feat: add structured data schemas, og tags, share button, and configurable site url
This commit is contained in:
@@ -11,3 +11,4 @@ DATABASE_URL = environ.get("DEVPLACE_DATABASE_URL", f"sqlite:///{BASE_DIR / 'dev
|
||||
SECRET_KEY = environ.get("SECRET_KEY", "devplace-secret-key-change-in-production")
|
||||
SESSION_MAX_AGE = 86400 * 7
|
||||
PORT = 10500
|
||||
SITE_URL = environ.get("DEVPLACE_SITE_URL", "").rstrip("/")
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ logging.basicConfig(
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_rate_limit_store = defaultdict(list)
|
||||
RATE_LIMIT = 60
|
||||
RATE_LIMIT = int(os.environ.get("DEVPLACE_RATE_LIMIT", "60"))
|
||||
RATE_WINDOW = 60
|
||||
|
||||
class UploadStaticFiles(StaticFiles):
|
||||
|
||||
@@ -6,7 +6,7 @@ from devplacepy.database import get_table, load_comments, get_vote_counts, resol
|
||||
from devplacepy.attachments import get_attachments, delete_target_attachments
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.utils import generate_uid, get_current_user, require_user, time_ago, make_combined_slug, create_mention_notifications
|
||||
from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, combine
|
||||
from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, combine, software_source_code_schema
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@@ -103,12 +103,13 @@ async def gist_detail(request: Request, gist_slug: str):
|
||||
request,
|
||||
title=gist.get("title", "Gist"),
|
||||
description=gist.get("description", "")[:160],
|
||||
og_type="article",
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Gists", "url": "/gists"},
|
||||
{"name": gist.get("title", "Gist"), "url": f"/gists/{gist['slug'] or gist['uid']}"},
|
||||
],
|
||||
schemas=[website_schema(base)],
|
||||
schemas=[website_schema(base), software_source_code_schema(gist, base)],
|
||||
)
|
||||
return templates.TemplateResponse(request, "gist_detail.html", {
|
||||
**seo_ctx,
|
||||
|
||||
@@ -5,7 +5,7 @@ from fastapi.responses import HTMLResponse
|
||||
from devplacepy.database import get_table, db, load_comments, resolve_by_slug
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.utils import get_current_user, time_ago
|
||||
from devplacepy.seo import base_seo_context, website_schema, site_url, discussion_forum_posting, combine
|
||||
from devplacepy.seo import base_seo_context, website_schema, site_url, discussion_forum_posting, combine, news_article_schema
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@@ -84,8 +84,10 @@ async def news_detail_page(request: Request, news_slug: str):
|
||||
request,
|
||||
title=article.get("title", "News Article"),
|
||||
description=(article.get("description", "") or "")[:200],
|
||||
og_type="article",
|
||||
og_image=image_url or None,
|
||||
breadcrumbs=[{"name": "Home", "url": "/feed"}, {"name": "News", "url": "/news"}, {"name": article.get("title", "")[:60], "url": page_url}],
|
||||
schemas=[website_schema(base)],
|
||||
schemas=[website_schema(base), news_article_schema(article, base, image_url)],
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(request, "news_detail.html", {
|
||||
|
||||
@@ -68,6 +68,7 @@ async def profile_page(request: Request, username: str, tab: str = "posts"):
|
||||
title=f"{profile_user['username']} (@{profile_user['username']})",
|
||||
description=desc,
|
||||
robots=robots,
|
||||
og_type="profile",
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": profile_user['username'], "url": f"/profile/{profile_user['username']}"},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse, PlainTextResponse
|
||||
from fastapi.responses import PlainTextResponse, Response
|
||||
from devplacepy.seo import make_sitemap, site_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -17,16 +17,18 @@ Disallow: /notifications/
|
||||
Disallow: /votes/
|
||||
Disallow: /avatar/
|
||||
Disallow: /follow/
|
||||
Disallow: /admin/
|
||||
Disallow: /uploads/
|
||||
Disallow: /*?tab=
|
||||
Disallow: /*?sort=
|
||||
Allow: /static/
|
||||
|
||||
Sitemap: {base}/sitemap.xml
|
||||
""")
|
||||
""", headers={"Cache-Control": "public, max-age=3600"})
|
||||
|
||||
|
||||
@router.get("/sitemap.xml", response_class=HTMLResponse)
|
||||
@router.get("/sitemap.xml")
|
||||
async def sitemap_xml(request: Request):
|
||||
base = site_url(request)
|
||||
xml = make_sitemap(base)
|
||||
return HTMLResponse(content=xml, media_type="application/xml")
|
||||
return Response(content=xml, media_type="application/xml", headers={"Cache-Control": "public, max-age=3600"})
|
||||
|
||||
+72
-12
@@ -3,6 +3,8 @@ import logging
|
||||
from datetime import datetime
|
||||
from xml.etree.ElementTree import Element, tostring
|
||||
from xml.dom import minidom
|
||||
from devplacepy.config import SITE_URL
|
||||
from devplacepy.utils import strip_html
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -13,14 +15,15 @@ SITE_NAME = "DevPlace"
|
||||
def truncate(text, max_len=160):
|
||||
if not text:
|
||||
return ""
|
||||
text = " ".join(text.split())[:max_len]
|
||||
if len(text) >= max_len:
|
||||
text = text.rsplit(" ", 1)[0] + "..."
|
||||
return text
|
||||
text = " ".join(text.split())
|
||||
if len(text) <= max_len:
|
||||
return text
|
||||
text = text[:max_len - 3].rsplit(" ", 1)[0]
|
||||
return text + "..."
|
||||
|
||||
|
||||
def site_url(request):
|
||||
return str(request.base_url).rstrip("/")
|
||||
return SITE_URL or str(request.base_url).rstrip("/")
|
||||
|
||||
|
||||
def website_schema(base_url):
|
||||
@@ -73,8 +76,6 @@ def discussion_forum_posting(post, author, comment_count, star_count, base_url):
|
||||
{"@type": "InteractionCounter", "interactionType": "https://schema.org/CommentAction", "userInteractionCount": comment_count}
|
||||
]
|
||||
}
|
||||
if post.get("title"):
|
||||
schema["headline"] = post["title"]
|
||||
return schema
|
||||
|
||||
|
||||
@@ -98,7 +99,7 @@ def software_application_schema(project, base_url):
|
||||
"@type": "SoftwareApplication",
|
||||
"name": project.get("title", "Untitled"),
|
||||
"description": truncate(project.get("description", ""), 300),
|
||||
"url": f"{base_url}/projects",
|
||||
"url": f"{base_url}/projects/{project.get('slug') or project['uid']}",
|
||||
"applicationCategory": "DeveloperApplication",
|
||||
"operatingSystem": project.get("platforms", "Cross-platform"),
|
||||
"author": {
|
||||
@@ -114,6 +115,43 @@ def software_application_schema(project, base_url):
|
||||
}
|
||||
|
||||
|
||||
def organization_schema(base_url):
|
||||
return {
|
||||
"@type": "Organization",
|
||||
"name": SITE_NAME,
|
||||
"url": base_url,
|
||||
"logo": f"{base_url}{DEFAULT_OG_IMAGE}",
|
||||
}
|
||||
|
||||
|
||||
def news_article_schema(article, base_url, image_url=""):
|
||||
url = f"{base_url}/news/{article.get('slug') or article['uid']}"
|
||||
schema = {
|
||||
"@type": "NewsArticle",
|
||||
"headline": (article.get("title") or "Untitled")[:110],
|
||||
"description": truncate(strip_html(article.get("description", "") or ""), 200),
|
||||
"url": url,
|
||||
"datePublished": article.get("synced_at", "") or article.get("created_at", ""),
|
||||
"mainEntityOfPage": {"@type": "WebPage", "@id": url},
|
||||
"author": {"@type": "Organization", "name": article.get("source_name") or SITE_NAME},
|
||||
"publisher": organization_schema(base_url),
|
||||
}
|
||||
if image_url:
|
||||
schema["image"] = image_url
|
||||
return schema
|
||||
|
||||
|
||||
def software_source_code_schema(gist, base_url):
|
||||
return {
|
||||
"@type": "SoftwareSourceCode",
|
||||
"name": gist.get("title") or "Gist",
|
||||
"description": truncate(strip_html(gist.get("description", "") or ""), 200),
|
||||
"url": f"{base_url}/gists/{gist.get('slug') or gist['uid']}",
|
||||
"programmingLanguage": gist.get("language", "") or "text",
|
||||
"dateCreated": gist.get("created_at", ""),
|
||||
}
|
||||
|
||||
|
||||
def combine(schemas):
|
||||
if not schemas:
|
||||
return None
|
||||
@@ -129,21 +167,25 @@ def combine(schemas):
|
||||
return json.dumps({"@context": "https://schema.org", "@graph": cleaned}, ensure_ascii=False)
|
||||
|
||||
|
||||
DEFAULT_OG_IMAGE = "/static/og-default.svg"
|
||||
DEFAULT_OG_IMAGE = "/static/og-default.png"
|
||||
|
||||
|
||||
def base_seo_context(request, title="", description="", robots="index,follow", og_type="website", og_image=None, breadcrumbs=None, schemas=None):
|
||||
base = site_url(request)
|
||||
page_title = f"{title} - {SITE_NAME}" if title else SITE_NAME
|
||||
canonical = str(request.url).split("?")[0]
|
||||
canonical = f"{base}{request.url.path}"
|
||||
page = request.query_params.get("page")
|
||||
if page and page not in ("", "1"):
|
||||
canonical = f"{canonical}?page={page}"
|
||||
clean_description = truncate(strip_html(description), 160)
|
||||
og_img = og_image or f"{base}{DEFAULT_OG_IMAGE}"
|
||||
return {
|
||||
"page_title": page_title,
|
||||
"meta_description": description,
|
||||
"meta_description": clean_description,
|
||||
"meta_robots": robots,
|
||||
"canonical_url": canonical,
|
||||
"og_title": title or SITE_NAME,
|
||||
"og_description": description,
|
||||
"og_description": clean_description,
|
||||
"og_image": og_img,
|
||||
"og_type": og_type,
|
||||
"breadcrumbs": breadcrumbs or [],
|
||||
@@ -180,6 +222,7 @@ def make_sitemap(base_url):
|
||||
urlset.append(url_element(f"{base_url}/feed", changefreq="hourly", priority="0.9"))
|
||||
urlset.append(url_element(f"{base_url}/news", changefreq="hourly", priority="0.9"))
|
||||
urlset.append(url_element(f"{base_url}/projects", changefreq="daily", priority="0.8"))
|
||||
urlset.append(url_element(f"{base_url}/gists", changefreq="daily", priority="0.8"))
|
||||
|
||||
if "posts" in db.tables:
|
||||
posts = list(get_table("posts").find(order_by=["-created_at"], _limit=500))
|
||||
@@ -211,11 +254,28 @@ def make_sitemap(base_url):
|
||||
priority="0.6"
|
||||
))
|
||||
|
||||
if "news" in db.tables:
|
||||
articles = list(get_table("news").find(status="published", order_by=["-synced_at"], _limit=500))
|
||||
for a in articles:
|
||||
urlset.append(url_element(
|
||||
f"{base_url}/news/{a.get('slug') or a['uid']}",
|
||||
lastmod=a.get("synced_at", "") or a.get("created_at", ""),
|
||||
changefreq="weekly",
|
||||
priority="0.7"
|
||||
))
|
||||
|
||||
if "users" in db.tables:
|
||||
post_counts = {}
|
||||
if "posts" in db.tables:
|
||||
for row in db.query("SELECT user_uid, COUNT(*) AS c FROM posts GROUP BY user_uid"):
|
||||
post_counts[row["user_uid"]] = row["c"]
|
||||
users = list(get_table("users").find(order_by=["-created_at"], _limit=200))
|
||||
for u in users:
|
||||
if post_counts.get(u["uid"], 0) < 2:
|
||||
continue
|
||||
urlset.append(url_element(
|
||||
f"{base_url}/profile/{u['username']}",
|
||||
lastmod=u.get("created_at", ""),
|
||||
changefreq="weekly",
|
||||
priority="0.4"
|
||||
))
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
@@ -259,6 +259,11 @@
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.news-detail-back:hover {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export class DomUtils {
|
||||
constructor() {
|
||||
this.initClipboardCopy();
|
||||
this.initShareButtons();
|
||||
this.initTogglers();
|
||||
this.initStopPropagation();
|
||||
this.initCardLinks();
|
||||
@@ -23,6 +24,24 @@ export class DomUtils {
|
||||
});
|
||||
}
|
||||
|
||||
initShareButtons() {
|
||||
document.querySelectorAll("[data-share]").forEach((btn) => {
|
||||
btn.addEventListener("click", async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const url = new URL(btn.dataset.share || window.location.href, window.location.href).href;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
const original = btn.textContent;
|
||||
btn.textContent = "Copied!";
|
||||
setTimeout(() => { btn.textContent = original; }, 1000);
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
initTogglers() {
|
||||
document.querySelectorAll("[data-toggle]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="theme-color" content="#0f0a1a">
|
||||
<title>{% if page_title %}{{ page_title }}{% else %}DevPlace - The Developer Social Network{% endif %}</title>
|
||||
<meta name="description" content="{% if meta_description %}{{ meta_description }}{% else %}Track industry shifts. Discover bold releases. Share what you're building in an open, uncensored environment.{% endif %}">
|
||||
<link rel="canonical" href="{{ canonical_url or request.url }}">
|
||||
@@ -12,16 +13,17 @@
|
||||
<meta property="og:description" content="{{ og_description or meta_description or 'The Developer Social Network' }}">
|
||||
<meta property="og:type" content="{{ og_type or 'website' }}">
|
||||
<meta property="og:url" content="{{ canonical_url or request.url }}">
|
||||
<meta property="og:image" content="{{ og_image }}">
|
||||
<meta property="og:image" content="{{ og_image or '/static/og-default.png' }}">
|
||||
<meta property="og:site_name" content="DevPlace">
|
||||
<meta property="og:locale" content="en_US">
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="{{ og_title or page_title or 'DevPlace' }}">
|
||||
<meta name="twitter:description" content="{{ og_description or meta_description or 'The Developer Social Network' }}">
|
||||
<meta name="twitter:image" content="{{ og_image }}">
|
||||
<meta name="twitter:image" content="{{ og_image or '/static/og-default.png' }}">
|
||||
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>💻</text></svg>">
|
||||
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
|
||||
|
||||
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net">
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net">
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
<a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-action-btn share">
|
||||
↗︎ Open
|
||||
</a>
|
||||
<button class="post-action-btn">🔗 Share</button>
|
||||
<button type="button" class="post-action-btn" data-share="/posts/{{ item.post['slug'] or item.post['uid'] }}">🔗 Share</button>
|
||||
</div>
|
||||
|
||||
{% if user %}
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
</div>
|
||||
|
||||
<div class="gist-detail-actions">
|
||||
<button type="button" class="gist-star-btn" data-share="/gists/{{ gist['slug'] or gist['uid'] }}">🔗 Share</button>
|
||||
{% if user %}
|
||||
<form method="POST" action="/votes/gist/{{ gist['uid'] }}" style="display:inline;">
|
||||
<input type="hidden" name="value" value="1">
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
<a href="{{ article['url'] }}" target="_blank" rel="noopener" class="news-detail-read-link">
|
||||
Read on {{ article['source_name'] }} ↗
|
||||
</a>
|
||||
<button type="button" class="news-detail-back" data-share="/news/{{ article['slug'] or article['uid'] }}">🔗 Share</button>
|
||||
<a href="/news" class="news-detail-back">← Back to News</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
<button type="submit" class="post-action-btn vote-down">−</button>
|
||||
</form>
|
||||
</div>
|
||||
<button class="post-action-btn">🔗 Share</button>
|
||||
<button type="button" class="post-action-btn" data-share="/posts/{{ post['slug'] or post['uid'] }}">🔗 Share</button>
|
||||
{% if user and post['user_uid'] == user['uid'] %}
|
||||
<button class="post-action-btn" data-modal="edit-post-modal"><span class="icon">✏️</span>Edit</button>
|
||||
<form method="POST" action="/posts/delete/{{ post['slug'] or post['uid'] }}" class="inline-form">
|
||||
|
||||
@@ -132,6 +132,7 @@
|
||||
{% endif %}
|
||||
|
||||
<div class="project-detail-actions">
|
||||
<button type="button" class="project-star-btn" data-share="/projects/{{ project['slug'] or project['uid'] }}">🔗 Share</button>
|
||||
{% if user %}
|
||||
<form method="POST" action="/votes/project/{{ project['uid'] }}" style="display:inline;">
|
||||
<input type="hidden" name="value" value="1">
|
||||
|
||||
Reference in New Issue
Block a user