feat: add structured data schemas, og tags, share button, and configurable site url

This commit is contained in:
2026-05-23 01:21:55 +00:00
parent 8eea7cc0c9
commit 77ad93a4bb
35 changed files with 437 additions and 62 deletions
+1
View File
@@ -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
View File
@@ -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):
+3 -2
View File
@@ -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,
+4 -2
View File
@@ -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", {
+1
View File
@@ -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']}"},
+6 -4
View File
@@ -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
View File
@@ -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

+5
View File
@@ -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 {
+19
View File
@@ -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

+4 -2
View File
@@ -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>&#x1f4bb;</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">
+1 -1
View File
@@ -100,7 +100,7 @@
<a href="/posts/{{ item.post['slug'] or item.post['uid'] }}" class="post-action-btn share">
&#x2197;&#xFE0E; Open
</a>
<button class="post-action-btn">&#x1F517; Share</button>
<button type="button" class="post-action-btn" data-share="/posts/{{ item.post['slug'] or item.post['uid'] }}">&#x1F517; Share</button>
</div>
{% if user %}
+1
View File
@@ -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'] }}">&#x1F517; Share</button>
{% if user %}
<form method="POST" action="/votes/gist/{{ gist['uid'] }}" style="display:inline;">
<input type="hidden" name="value" value="1">
+1
View File
@@ -39,6 +39,7 @@
<a href="{{ article['url'] }}" target="_blank" rel="noopener" class="news-detail-read-link">
Read on {{ article['source_name'] }} &#x2197;
</a>
<button type="button" class="news-detail-back" data-share="/news/{{ article['slug'] or article['uid'] }}">&#x1F517; Share</button>
<a href="/news" class="news-detail-back">&larr; Back to News</a>
</div>
</div>
+1 -1
View File
@@ -47,7 +47,7 @@
<button type="submit" class="post-action-btn vote-down">−</button>
</form>
</div>
<button class="post-action-btn">&#x1F517; Share</button>
<button type="button" class="post-action-btn" data-share="/posts/{{ post['slug'] or post['uid'] }}">&#x1F517; Share</button>
{% if user and post['user_uid'] == user['uid'] %}
<button class="post-action-btn" data-modal="edit-post-modal"><span class="icon">&#x270F;&#xFE0F;</span>Edit</button>
<form method="POST" action="/posts/delete/{{ post['slug'] or post['uid'] }}" class="inline-form">
+1
View File
@@ -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'] }}">&#x1F517; Share</button>
{% if user %}
<form method="POST" action="/votes/project/{{ project['uid'] }}" style="display:inline;">
<input type="hidden" name="value" value="1">