2026-06-12 01:58:46 +02:00
|
|
|
|
# retoor <retoor@molodetz.nl>
|
|
|
|
|
|
|
2026-05-11 05:30:51 +02:00
|
|
|
|
import json
|
|
|
|
|
|
import logging
|
2026-06-05 21:51:36 +02:00
|
|
|
|
import os
|
feat: add canonical redirect, og_image, next_page_url, and sitemap caching across content routers
Implement canonical_redirect helper in content.py to enforce slug-based 301 redirects for gist, news, post, and project detail pages. Add first_image_url helper to extract primary image from item or attachments for Open Graph meta tags. Inject next_page_url into feed, gists, news, and projects list page SEO contexts for pagination link rel. Extend seo.py with SITEMAP_URL_LIMIT, SITEMAP_TTL constants and _sitemap cache variable for future sitemap generation optimization. Update profile page to raise 404 via not_found instead of 302 redirect, and include og_image from avatar_url.
2026-06-05 20:05:07 +02:00
|
|
|
|
import time
|
|
|
|
|
|
from urllib.parse import urlencode
|
2026-05-11 05:30:51 +02:00
|
|
|
|
from xml.etree.ElementTree import Element, tostring
|
|
|
|
|
|
from xml.dom import minidom
|
2026-05-23 03:21:55 +02:00
|
|
|
|
from devplacepy.config import SITE_URL
|
|
|
|
|
|
from devplacepy.utils import strip_html
|
2026-05-11 05:30:51 +02:00
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
SITE_NAME = "DevPlace"
|
feat: add canonical redirect, og_image, next_page_url, and sitemap caching across content routers
Implement canonical_redirect helper in content.py to enforce slug-based 301 redirects for gist, news, post, and project detail pages. Add first_image_url helper to extract primary image from item or attachments for Open Graph meta tags. Inject next_page_url into feed, gists, news, and projects list page SEO contexts for pagination link rel. Extend seo.py with SITEMAP_URL_LIMIT, SITEMAP_TTL constants and _sitemap cache variable for future sitemap generation optimization. Update profile page to raise 404 via not_found instead of 302 redirect, and include og_image from avatar_url.
2026-06-05 20:05:07 +02:00
|
|
|
|
SITEMAP_URL_LIMIT = 5000
|
2026-06-05 21:51:36 +02:00
|
|
|
|
SITEMAP_TTL = int(os.environ.get("DEVPLACE_SITEMAP_TTL", "3600"))
|
feat: add canonical redirect, og_image, next_page_url, and sitemap caching across content routers
Implement canonical_redirect helper in content.py to enforce slug-based 301 redirects for gist, news, post, and project detail pages. Add first_image_url helper to extract primary image from item or attachments for Open Graph meta tags. Inject next_page_url into feed, gists, news, and projects list page SEO contexts for pagination link rel. Extend seo.py with SITEMAP_URL_LIMIT, SITEMAP_TTL constants and _sitemap cache variable for future sitemap generation optimization. Update profile page to raise 404 via not_found instead of 302 redirect, and include og_image from avatar_url.
2026-06-05 20:05:07 +02:00
|
|
|
|
_sitemap_cache = {}
|
2026-05-11 05:30:51 +02:00
|
|
|
|
|
|
|
|
|
|
|
2026-06-19 22:15:22 +02:00
|
|
|
|
def plain_markdown(text: str) -> str:
|
|
|
|
|
|
if not text:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
from devplacepy.seo_meta_text import plain_text_from_markdown
|
|
|
|
|
|
|
|
|
|
|
|
return plain_text_from_markdown(text)
|
|
|
|
|
|
|
|
|
|
|
|
|
feat: add router table entries for uploads, media, zips, forks, tools, proxy, push, docs, openai, devii, xmlrpc, devrant, dbapi, pubsub and add type annotations to cache, config, responses, schemas, seo, and stealth modules
2026-06-16 14:05:44 +02:00
|
|
|
|
def truncate(text: str, max_len: int = 160) -> str:
|
2026-05-11 05:30:51 +02:00
|
|
|
|
if not text:
|
|
|
|
|
|
return ""
|
2026-05-23 03:21:55 +02:00
|
|
|
|
text = " ".join(text.split())
|
|
|
|
|
|
if len(text) <= max_len:
|
|
|
|
|
|
return text
|
2026-06-09 18:48:08 +02:00
|
|
|
|
text = text[: max_len - 3].rsplit(" ", 1)[0]
|
2026-05-23 03:21:55 +02:00
|
|
|
|
return text + "..."
|
2026-05-11 05:30:51 +02:00
|
|
|
|
|
|
|
|
|
|
|
2026-06-09 06:41:27 +02:00
|
|
|
|
def public_base_url() -> str:
|
|
|
|
|
|
from devplacepy.database import get_setting
|
2026-06-09 18:48:08 +02:00
|
|
|
|
|
2026-06-09 06:41:27 +02:00
|
|
|
|
return (get_setting("site_url", "").strip() or SITE_URL or "").rstrip("/")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-11 05:30:51 +02:00
|
|
|
|
def site_url(request):
|
2026-06-09 06:41:27 +02:00
|
|
|
|
return public_base_url() or str(request.base_url).rstrip("/")
|
2026-05-11 05:30:51 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def website_schema(base_url):
|
|
|
|
|
|
return {
|
|
|
|
|
|
"@type": "WebSite",
|
|
|
|
|
|
"name": SITE_NAME,
|
|
|
|
|
|
"url": base_url,
|
|
|
|
|
|
"potentialAction": {
|
|
|
|
|
|
"@type": "SearchAction",
|
|
|
|
|
|
"target": {
|
|
|
|
|
|
"@type": "EntryPoint",
|
2026-06-09 18:48:08 +02:00
|
|
|
|
"urlTemplate": f"{base_url}/feed?search={{query}}",
|
2026-05-11 05:30:51 +02:00
|
|
|
|
},
|
2026-06-09 18:48:08 +02:00
|
|
|
|
"query-input": "required name=query",
|
|
|
|
|
|
},
|
2026-05-11 05:30:51 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def breadcrumb_schema(items, base_url):
|
|
|
|
|
|
return {
|
|
|
|
|
|
"@type": "BreadcrumbList",
|
|
|
|
|
|
"itemListElement": [
|
|
|
|
|
|
{
|
|
|
|
|
|
"@type": "ListItem",
|
|
|
|
|
|
"position": i + 1,
|
|
|
|
|
|
"item": {
|
2026-06-09 18:48:08 +02:00
|
|
|
|
"@id": item["url"]
|
|
|
|
|
|
if item["url"].startswith("http")
|
|
|
|
|
|
else f"{base_url}{item['url']}",
|
|
|
|
|
|
"name": item["name"],
|
|
|
|
|
|
},
|
2026-05-11 05:30:51 +02:00
|
|
|
|
}
|
|
|
|
|
|
for i, item in enumerate(items)
|
2026-06-09 18:48:08 +02:00
|
|
|
|
],
|
2026-05-11 05:30:51 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def discussion_forum_posting(post, author, comment_count, star_count, base_url):
|
|
|
|
|
|
schema = {
|
|
|
|
|
|
"@type": "DiscussionForumPosting",
|
|
|
|
|
|
"headline": post.get("title") or "Untitled",
|
2026-06-19 22:15:22 +02:00
|
|
|
|
"text": truncate(plain_markdown(post.get("content", "")), 500),
|
2026-05-11 20:49:45 +02:00
|
|
|
|
"url": f"{base_url}/posts/{post.get('slug') or post['uid']}",
|
2026-05-11 05:30:51 +02:00
|
|
|
|
"author": {
|
|
|
|
|
|
"@type": "Person",
|
|
|
|
|
|
"name": author["username"] if author else "Unknown",
|
2026-06-09 18:48:08 +02:00
|
|
|
|
"url": f"{base_url}/profile/{author['username']}" if author else "",
|
2026-05-11 05:30:51 +02:00
|
|
|
|
},
|
|
|
|
|
|
"datePublished": post.get("created_at", ""),
|
feat: add canonical redirect, og_image, next_page_url, and sitemap caching across content routers
Implement canonical_redirect helper in content.py to enforce slug-based 301 redirects for gist, news, post, and project detail pages. Add first_image_url helper to extract primary image from item or attachments for Open Graph meta tags. Inject next_page_url into feed, gists, news, and projects list page SEO contexts for pagination link rel. Extend seo.py with SITEMAP_URL_LIMIT, SITEMAP_TTL constants and _sitemap cache variable for future sitemap generation optimization. Update profile page to raise 404 via not_found instead of 302 redirect, and include og_image from avatar_url.
2026-06-05 20:05:07 +02:00
|
|
|
|
"dateModified": post.get("updated_at") or post.get("created_at", ""),
|
2026-05-11 05:30:51 +02:00
|
|
|
|
"interactionStatistic": [
|
2026-06-09 18:48:08 +02:00
|
|
|
|
{
|
|
|
|
|
|
"@type": "InteractionCounter",
|
|
|
|
|
|
"interactionType": "https://schema.org/LikeAction",
|
|
|
|
|
|
"userInteractionCount": star_count,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"@type": "InteractionCounter",
|
|
|
|
|
|
"interactionType": "https://schema.org/CommentAction",
|
|
|
|
|
|
"userInteractionCount": comment_count,
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
2026-05-11 05:30:51 +02:00
|
|
|
|
}
|
|
|
|
|
|
return schema
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def profile_page_schema(profile_user, post_count, base_url):
|
|
|
|
|
|
return {
|
|
|
|
|
|
"@type": "ProfilePage",
|
|
|
|
|
|
"mainEntity": {
|
|
|
|
|
|
"@type": "Person",
|
|
|
|
|
|
"name": profile_user.get("username", ""),
|
|
|
|
|
|
"alternateName": profile_user.get("username", ""),
|
|
|
|
|
|
"description": profile_user.get("bio", "") or f"Developer on {SITE_NAME}",
|
|
|
|
|
|
"interactionStatistic": [
|
2026-06-09 18:48:08 +02:00
|
|
|
|
{
|
|
|
|
|
|
"@type": "InteractionCounter",
|
|
|
|
|
|
"interactionType": "https://schema.org/WriteAction",
|
|
|
|
|
|
"userInteractionCount": post_count,
|
|
|
|
|
|
}
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
2026-05-11 05:30:51 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def software_application_schema(project, base_url):
|
|
|
|
|
|
return {
|
|
|
|
|
|
"@type": "SoftwareApplication",
|
|
|
|
|
|
"name": project.get("title", "Untitled"),
|
2026-06-19 22:15:22 +02:00
|
|
|
|
"description": truncate(plain_markdown(project.get("description", "")), 300),
|
2026-05-23 03:21:55 +02:00
|
|
|
|
"url": f"{base_url}/projects/{project.get('slug') or project['uid']}",
|
2026-05-11 05:30:51 +02:00
|
|
|
|
"applicationCategory": "DeveloperApplication",
|
|
|
|
|
|
"operatingSystem": project.get("platforms", "Cross-platform"),
|
2026-06-09 18:48:08 +02:00
|
|
|
|
"author": {"@type": "Person", "name": project.get("author_name", "Unknown")},
|
2026-05-11 05:30:51 +02:00
|
|
|
|
"datePublished": project.get("created_at", ""),
|
feat: add canonical redirect, og_image, next_page_url, and sitemap caching across content routers
Implement canonical_redirect helper in content.py to enforce slug-based 301 redirects for gist, news, post, and project detail pages. Add first_image_url helper to extract primary image from item or attachments for Open Graph meta tags. Inject next_page_url into feed, gists, news, and projects list page SEO contexts for pagination link rel. Extend seo.py with SITEMAP_URL_LIMIT, SITEMAP_TTL constants and _sitemap cache variable for future sitemap generation optimization. Update profile page to raise 404 via not_found instead of 302 redirect, and include og_image from avatar_url.
2026-06-05 20:05:07 +02:00
|
|
|
|
"dateModified": project.get("updated_at") or project.get("created_at", ""),
|
2026-06-09 18:48:08 +02:00
|
|
|
|
"offers": {"@type": "Offer", "price": "0", "priceCurrency": "USD"},
|
2026-05-11 05:30:51 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 02:36:10 +02:00
|
|
|
|
def web_application_schema(name, description, path, base_url, category="DeveloperApplication"):
|
|
|
|
|
|
return {
|
|
|
|
|
|
"@type": "WebApplication",
|
|
|
|
|
|
"name": name,
|
|
|
|
|
|
"description": truncate(description, 300),
|
|
|
|
|
|
"url": f"{base_url}{path}",
|
|
|
|
|
|
"applicationCategory": category,
|
|
|
|
|
|
"operatingSystem": "All",
|
|
|
|
|
|
"browserRequirements": "Requires JavaScript",
|
|
|
|
|
|
"offers": {"@type": "Offer", "price": "0", "priceCurrency": "USD"},
|
|
|
|
|
|
"provider": {"@type": "Organization", "name": SITE_NAME, "url": base_url},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-23 03:21:55 +02:00
|
|
|
|
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],
|
2026-06-19 22:15:22 +02:00
|
|
|
|
"description": truncate(plain_markdown(article.get("description", "") or ""), 200),
|
2026-05-23 03:21:55 +02:00
|
|
|
|
"url": url,
|
|
|
|
|
|
"datePublished": article.get("synced_at", "") or article.get("created_at", ""),
|
feat: add canonical redirect, og_image, next_page_url, and sitemap caching across content routers
Implement canonical_redirect helper in content.py to enforce slug-based 301 redirects for gist, news, post, and project detail pages. Add first_image_url helper to extract primary image from item or attachments for Open Graph meta tags. Inject next_page_url into feed, gists, news, and projects list page SEO contexts for pagination link rel. Extend seo.py with SITEMAP_URL_LIMIT, SITEMAP_TTL constants and _sitemap cache variable for future sitemap generation optimization. Update profile page to raise 404 via not_found instead of 302 redirect, and include og_image from avatar_url.
2026-06-05 20:05:07 +02:00
|
|
|
|
"dateModified": article.get("synced_at", "") or article.get("created_at", ""),
|
2026-05-23 03:21:55 +02:00
|
|
|
|
"mainEntityOfPage": {"@type": "WebPage", "@id": url},
|
2026-06-09 18:48:08 +02:00
|
|
|
|
"author": {
|
|
|
|
|
|
"@type": "Organization",
|
|
|
|
|
|
"name": article.get("source_name") or SITE_NAME,
|
|
|
|
|
|
},
|
2026-05-23 03:21:55 +02:00
|
|
|
|
"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",
|
2026-06-19 22:15:22 +02:00
|
|
|
|
"description": truncate(plain_markdown(gist.get("description", "") or ""), 200),
|
2026-05-23 03:21:55 +02:00
|
|
|
|
"url": f"{base_url}/gists/{gist.get('slug') or gist['uid']}",
|
|
|
|
|
|
"programmingLanguage": gist.get("language", "") or "text",
|
|
|
|
|
|
"dateCreated": gist.get("created_at", ""),
|
feat: add canonical redirect, og_image, next_page_url, and sitemap caching across content routers
Implement canonical_redirect helper in content.py to enforce slug-based 301 redirects for gist, news, post, and project detail pages. Add first_image_url helper to extract primary image from item or attachments for Open Graph meta tags. Inject next_page_url into feed, gists, news, and projects list page SEO contexts for pagination link rel. Extend seo.py with SITEMAP_URL_LIMIT, SITEMAP_TTL constants and _sitemap cache variable for future sitemap generation optimization. Update profile page to raise 404 via not_found instead of 302 redirect, and include og_image from avatar_url.
2026-06-05 20:05:07 +02:00
|
|
|
|
"dateModified": gist.get("updated_at") or gist.get("created_at", ""),
|
2026-05-23 03:21:55 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-26 14:57:18 +02:00
|
|
|
|
def quiz_schema(quiz, author, base_url):
|
|
|
|
|
|
return {
|
|
|
|
|
|
"@type": "Quiz",
|
|
|
|
|
|
"name": quiz.get("title") or "Quiz",
|
|
|
|
|
|
"description": truncate(plain_markdown(quiz.get("description", "") or ""), 200),
|
|
|
|
|
|
"url": f"{base_url}/quizzes/{quiz.get('slug') or quiz['uid']}",
|
|
|
|
|
|
"about": quiz.get("title") or "Quiz",
|
|
|
|
|
|
"educationalLevel": "beginner",
|
|
|
|
|
|
"numberOfQuestions": int(quiz.get("question_count") or 0),
|
|
|
|
|
|
"author": {
|
|
|
|
|
|
"@type": "Person",
|
|
|
|
|
|
"name": (author or {}).get("username", "") or "DevPlace member",
|
|
|
|
|
|
"url": f"{base_url}/profile/{(author or {}).get('username', '')}",
|
|
|
|
|
|
},
|
|
|
|
|
|
"dateCreated": quiz.get("created_at", ""),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-23 09:10:31 +02:00
|
|
|
|
def _json_ld_dumps(payload):
|
|
|
|
|
|
raw = json.dumps(payload, ensure_ascii=False)
|
|
|
|
|
|
return (
|
|
|
|
|
|
raw.replace("<", "\\u003c")
|
2026-06-09 18:48:08 +02:00
|
|
|
|
.replace(">", "\\u003e")
|
|
|
|
|
|
.replace("&", "\\u0026")
|
|
|
|
|
|
.replace("
", "\\u2028")
|
|
|
|
|
|
.replace("
", "\\u2029")
|
2026-05-23 09:10:31 +02:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-11 05:30:51 +02:00
|
|
|
|
def combine(schemas):
|
|
|
|
|
|
if not schemas:
|
|
|
|
|
|
return None
|
|
|
|
|
|
cleaned = []
|
|
|
|
|
|
for s in schemas:
|
|
|
|
|
|
if s is not None:
|
|
|
|
|
|
s.pop("@context", None)
|
|
|
|
|
|
cleaned.append(s)
|
|
|
|
|
|
if not cleaned:
|
|
|
|
|
|
return None
|
2026-05-23 09:10:31 +02:00
|
|
|
|
payload = (
|
|
|
|
|
|
{"@context": "https://schema.org", **cleaned[0]}
|
|
|
|
|
|
if len(cleaned) == 1
|
|
|
|
|
|
else {"@context": "https://schema.org", "@graph": cleaned}
|
|
|
|
|
|
)
|
|
|
|
|
|
return _json_ld_dumps(payload)
|
2026-05-11 05:30:51 +02:00
|
|
|
|
|
|
|
|
|
|
|
2026-05-23 03:21:55 +02:00
|
|
|
|
DEFAULT_OG_IMAGE = "/static/og-default.png"
|
2026-05-11 05:30:51 +02:00
|
|
|
|
|
|
|
|
|
|
|
feat: add canonical redirect, og_image, next_page_url, and sitemap caching across content routers
Implement canonical_redirect helper in content.py to enforce slug-based 301 redirects for gist, news, post, and project detail pages. Add first_image_url helper to extract primary image from item or attachments for Open Graph meta tags. Inject next_page_url into feed, gists, news, and projects list page SEO contexts for pagination link rel. Extend seo.py with SITEMAP_URL_LIMIT, SITEMAP_TTL constants and _sitemap cache variable for future sitemap generation optimization. Update profile page to raise 404 via not_found instead of 302 redirect, and include og_image from avatar_url.
2026-06-05 20:05:07 +02:00
|
|
|
|
def absolute_url(base, path):
|
|
|
|
|
|
if not path:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
return path if path.startswith("http") else f"{base}{path}"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-01 15:15:33 +02:00
|
|
|
|
def _clean_breadcrumbs(breadcrumbs):
|
|
|
|
|
|
if not breadcrumbs:
|
|
|
|
|
|
return breadcrumbs
|
|
|
|
|
|
from devplacepy.seo_meta_text import plain_text_from_markdown
|
|
|
|
|
|
|
|
|
|
|
|
cleaned = []
|
|
|
|
|
|
for crumb in breadcrumbs:
|
|
|
|
|
|
name = crumb.get("name", "")
|
|
|
|
|
|
plain = plain_text_from_markdown(name) or name
|
|
|
|
|
|
cleaned.append({**crumb, "name": plain})
|
|
|
|
|
|
return cleaned
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-09 18:48:08 +02:00
|
|
|
|
def base_seo_context(
|
|
|
|
|
|
request,
|
|
|
|
|
|
title="",
|
|
|
|
|
|
description="",
|
|
|
|
|
|
robots="index,follow",
|
|
|
|
|
|
og_type="website",
|
|
|
|
|
|
og_image=None,
|
|
|
|
|
|
breadcrumbs=None,
|
|
|
|
|
|
schemas=None,
|
|
|
|
|
|
prev_url=None,
|
|
|
|
|
|
next_url=None,
|
2026-06-19 22:15:22 +02:00
|
|
|
|
keywords="",
|
|
|
|
|
|
seo_target=None,
|
2026-06-09 18:48:08 +02:00
|
|
|
|
):
|
2026-06-19 22:15:22 +02:00
|
|
|
|
from devplacepy.seo_meta_text import plain_seo_defaults, plain_text_from_markdown
|
|
|
|
|
|
|
2026-05-11 05:30:51 +02:00
|
|
|
|
base = site_url(request)
|
2026-06-19 22:15:22 +02:00
|
|
|
|
seo_title = title
|
|
|
|
|
|
clean_description = truncate(plain_text_from_markdown(description), 160)
|
|
|
|
|
|
meta_keywords = (keywords or "").strip()
|
|
|
|
|
|
|
|
|
|
|
|
ready = _ready_seo_metadata(seo_target)
|
|
|
|
|
|
if ready:
|
|
|
|
|
|
seo_title = ready.get("seo_title") or seo_title
|
|
|
|
|
|
clean_description = ready.get("seo_description") or clean_description
|
|
|
|
|
|
meta_keywords = ready.get("seo_keywords") or meta_keywords
|
|
|
|
|
|
elif seo_target:
|
|
|
|
|
|
defaults = plain_seo_defaults(title, description)
|
|
|
|
|
|
seo_title = seo_title or defaults["title"]
|
|
|
|
|
|
clean_description = clean_description or defaults["description"]
|
|
|
|
|
|
meta_keywords = meta_keywords or defaults["keywords"]
|
|
|
|
|
|
|
|
|
|
|
|
page_title = f"{seo_title} - {SITE_NAME}" if seo_title else SITE_NAME
|
2026-05-23 03:21:55 +02:00
|
|
|
|
canonical = f"{base}{request.url.path}"
|
|
|
|
|
|
page = request.query_params.get("page")
|
|
|
|
|
|
if page and page not in ("", "1"):
|
|
|
|
|
|
canonical = f"{canonical}?page={page}"
|
feat: add canonical redirect, og_image, next_page_url, and sitemap caching across content routers
Implement canonical_redirect helper in content.py to enforce slug-based 301 redirects for gist, news, post, and project detail pages. Add first_image_url helper to extract primary image from item or attachments for Open Graph meta tags. Inject next_page_url into feed, gists, news, and projects list page SEO contexts for pagination link rel. Extend seo.py with SITEMAP_URL_LIMIT, SITEMAP_TTL constants and _sitemap cache variable for future sitemap generation optimization. Update profile page to raise 404 via not_found instead of 302 redirect, and include og_image from avatar_url.
2026-06-05 20:05:07 +02:00
|
|
|
|
og_img = absolute_url(base, og_image) or f"{base}{DEFAULT_OG_IMAGE}"
|
2026-07-01 15:15:33 +02:00
|
|
|
|
breadcrumbs = _clean_breadcrumbs(breadcrumbs)
|
feat: add canonical redirect, og_image, next_page_url, and sitemap caching across content routers
Implement canonical_redirect helper in content.py to enforce slug-based 301 redirects for gist, news, post, and project detail pages. Add first_image_url helper to extract primary image from item or attachments for Open Graph meta tags. Inject next_page_url into feed, gists, news, and projects list page SEO contexts for pagination link rel. Extend seo.py with SITEMAP_URL_LIMIT, SITEMAP_TTL constants and _sitemap cache variable for future sitemap generation optimization. Update profile page to raise 404 via not_found instead of 302 redirect, and include og_image from avatar_url.
2026-06-05 20:05:07 +02:00
|
|
|
|
page_schemas = list(schemas or [])
|
|
|
|
|
|
if breadcrumbs:
|
|
|
|
|
|
page_schemas.append(breadcrumb_schema(breadcrumbs, base))
|
2026-05-11 05:30:51 +02:00
|
|
|
|
return {
|
|
|
|
|
|
"page_title": page_title,
|
2026-05-23 03:21:55 +02:00
|
|
|
|
"meta_description": clean_description,
|
2026-06-19 22:15:22 +02:00
|
|
|
|
"meta_keywords": meta_keywords,
|
2026-05-11 05:30:51 +02:00
|
|
|
|
"meta_robots": robots,
|
|
|
|
|
|
"canonical_url": canonical,
|
2026-06-19 22:15:22 +02:00
|
|
|
|
"og_title": seo_title or SITE_NAME,
|
2026-05-23 03:21:55 +02:00
|
|
|
|
"og_description": clean_description,
|
2026-05-11 05:30:51 +02:00
|
|
|
|
"og_image": og_img,
|
|
|
|
|
|
"og_type": og_type,
|
|
|
|
|
|
"breadcrumbs": breadcrumbs or [],
|
feat: add canonical redirect, og_image, next_page_url, and sitemap caching across content routers
Implement canonical_redirect helper in content.py to enforce slug-based 301 redirects for gist, news, post, and project detail pages. Add first_image_url helper to extract primary image from item or attachments for Open Graph meta tags. Inject next_page_url into feed, gists, news, and projects list page SEO contexts for pagination link rel. Extend seo.py with SITEMAP_URL_LIMIT, SITEMAP_TTL constants and _sitemap cache variable for future sitemap generation optimization. Update profile page to raise 404 via not_found instead of 302 redirect, and include og_image from avatar_url.
2026-06-05 20:05:07 +02:00
|
|
|
|
"page_schema": combine(page_schemas),
|
|
|
|
|
|
"prev_url": absolute_url(base, prev_url),
|
|
|
|
|
|
"next_url": absolute_url(base, next_url),
|
2026-05-11 05:30:51 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-19 22:15:22 +02:00
|
|
|
|
def _ready_seo_metadata(seo_target):
|
|
|
|
|
|
if not seo_target:
|
|
|
|
|
|
return None
|
|
|
|
|
|
target_type, target_uid = seo_target
|
|
|
|
|
|
if not target_type or not target_uid:
|
|
|
|
|
|
return None
|
|
|
|
|
|
try:
|
|
|
|
|
|
from devplacepy.database import get_seo_metadata
|
|
|
|
|
|
|
|
|
|
|
|
return get_seo_metadata(target_type, str(target_uid))
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
feat: add canonical redirect, og_image, next_page_url, and sitemap caching across content routers
Implement canonical_redirect helper in content.py to enforce slug-based 301 redirects for gist, news, post, and project detail pages. Add first_image_url helper to extract primary image from item or attachments for Open Graph meta tags. Inject next_page_url into feed, gists, news, and projects list page SEO contexts for pagination link rel. Extend seo.py with SITEMAP_URL_LIMIT, SITEMAP_TTL constants and _sitemap cache variable for future sitemap generation optimization. Update profile page to raise 404 via not_found instead of 302 redirect, and include og_image from avatar_url.
2026-06-05 20:05:07 +02:00
|
|
|
|
def next_page_url(request, next_cursor):
|
|
|
|
|
|
if not next_cursor:
|
|
|
|
|
|
return None
|
|
|
|
|
|
params = dict(request.query_params)
|
|
|
|
|
|
params["before"] = next_cursor
|
|
|
|
|
|
return f"{request.url.path}?{urlencode(params)}"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-09 18:48:08 +02:00
|
|
|
|
def list_page_seo(
|
|
|
|
|
|
request, title="", description="", breadcrumbs=None, prev_url=None, next_url=None
|
|
|
|
|
|
):
|
feat: add leaderboard router, gamification backfill, and shared content creation helpers
Introduce a new `/leaderboard` endpoint ranking top 50 members by total stars, wire it into the app router and documentation. Implement `_backfill_gamification()` in `database.py` to compute XP/levels for existing accounts from prior posts, comments, votes, and follows. Extract `is_owner()`, `create_content_item()`, and `detail_context()` into `content.py` to centralize content creation, reward awarding, mention notifications, and attachment linking. Update comment creation/deletion in `comments.py` to use `award_rewards()` with `XP_COMMENT` and the new `is_owner()` helper. Add shared template partials documentation for vote bars, star buttons, post headers, and topic selectors to `AGENTS.md`.
2026-05-30 20:16:39 +02:00
|
|
|
|
base = site_url(request)
|
|
|
|
|
|
return base_seo_context(
|
|
|
|
|
|
request,
|
|
|
|
|
|
title=title,
|
|
|
|
|
|
description=description,
|
|
|
|
|
|
breadcrumbs=breadcrumbs,
|
|
|
|
|
|
schemas=[website_schema(base)],
|
feat: add canonical redirect, og_image, next_page_url, and sitemap caching across content routers
Implement canonical_redirect helper in content.py to enforce slug-based 301 redirects for gist, news, post, and project detail pages. Add first_image_url helper to extract primary image from item or attachments for Open Graph meta tags. Inject next_page_url into feed, gists, news, and projects list page SEO contexts for pagination link rel. Extend seo.py with SITEMAP_URL_LIMIT, SITEMAP_TTL constants and _sitemap cache variable for future sitemap generation optimization. Update profile page to raise 404 via not_found instead of 302 redirect, and include og_image from avatar_url.
2026-06-05 20:05:07 +02:00
|
|
|
|
prev_url=prev_url,
|
|
|
|
|
|
next_url=next_url,
|
feat: add leaderboard router, gamification backfill, and shared content creation helpers
Introduce a new `/leaderboard` endpoint ranking top 50 members by total stars, wire it into the app router and documentation. Implement `_backfill_gamification()` in `database.py` to compute XP/levels for existing accounts from prior posts, comments, votes, and follows. Extract `is_owner()`, `create_content_item()`, and `detail_context()` into `content.py` to centralize content creation, reward awarding, mention notifications, and attachment linking. Update comment creation/deletion in `comments.py` to use `award_rewards()` with `XP_COMMENT` and the new `is_owner()` helper. Add shared template partials documentation for vote bars, star buttons, post headers, and topic selectors to `AGENTS.md`.
2026-05-30 20:16:39 +02:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-11 05:30:51 +02:00
|
|
|
|
def make_sitemap(base_url):
|
feat: add canonical redirect, og_image, next_page_url, and sitemap caching across content routers
Implement canonical_redirect helper in content.py to enforce slug-based 301 redirects for gist, news, post, and project detail pages. Add first_image_url helper to extract primary image from item or attachments for Open Graph meta tags. Inject next_page_url into feed, gists, news, and projects list page SEO contexts for pagination link rel. Extend seo.py with SITEMAP_URL_LIMIT, SITEMAP_TTL constants and _sitemap cache variable for future sitemap generation optimization. Update profile page to raise 404 via not_found instead of 302 redirect, and include og_image from avatar_url.
2026-06-05 20:05:07 +02:00
|
|
|
|
cached = _sitemap_cache.get(base_url)
|
|
|
|
|
|
if cached and time.time() - cached[0] < SITEMAP_TTL:
|
|
|
|
|
|
return cached[1]
|
|
|
|
|
|
xml = _build_sitemap(base_url)
|
|
|
|
|
|
_sitemap_cache[base_url] = (time.time(), xml)
|
|
|
|
|
|
return xml
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _collect(table, limit, label, **query):
|
2026-06-11 22:36:47 +02:00
|
|
|
|
if table.has_column("deleted_at") and "deleted_at" not in query:
|
|
|
|
|
|
query["deleted_at"] = None
|
feat: add canonical redirect, og_image, next_page_url, and sitemap caching across content routers
Implement canonical_redirect helper in content.py to enforce slug-based 301 redirects for gist, news, post, and project detail pages. Add first_image_url helper to extract primary image from item or attachments for Open Graph meta tags. Inject next_page_url into feed, gists, news, and projects list page SEO contexts for pagination link rel. Extend seo.py with SITEMAP_URL_LIMIT, SITEMAP_TTL constants and _sitemap cache variable for future sitemap generation optimization. Update profile page to raise 404 via not_found instead of 302 redirect, and include og_image from avatar_url.
2026-06-05 20:05:07 +02:00
|
|
|
|
rows = list(table.find(_limit=limit, **query))
|
|
|
|
|
|
if len(rows) >= limit:
|
|
|
|
|
|
logger.warning("sitemap: %s truncated at %d entries", label, limit)
|
|
|
|
|
|
return rows
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_sitemap(base_url):
|
2026-05-11 05:30:51 +02:00
|
|
|
|
from devplacepy.database import get_table, db
|
|
|
|
|
|
|
|
|
|
|
|
def url_element(loc, lastmod=None, changefreq=None, priority=None):
|
|
|
|
|
|
u = Element("url")
|
|
|
|
|
|
loc_el = Element("loc")
|
|
|
|
|
|
loc_el.text = loc
|
|
|
|
|
|
u.append(loc_el)
|
|
|
|
|
|
if lastmod:
|
|
|
|
|
|
lm = Element("lastmod")
|
|
|
|
|
|
lm.text = lastmod
|
|
|
|
|
|
u.append(lm)
|
|
|
|
|
|
if changefreq:
|
|
|
|
|
|
cf = Element("changefreq")
|
|
|
|
|
|
cf.text = changefreq
|
|
|
|
|
|
u.append(cf)
|
|
|
|
|
|
if priority is not None:
|
|
|
|
|
|
pr = Element("priority")
|
|
|
|
|
|
pr.text = str(priority)
|
|
|
|
|
|
u.append(pr)
|
|
|
|
|
|
return u
|
|
|
|
|
|
|
|
|
|
|
|
urlset = Element("urlset")
|
|
|
|
|
|
urlset.set("xmlns", "http://www.sitemaps.org/schemas/sitemap/0.9")
|
|
|
|
|
|
|
|
|
|
|
|
urlset.append(url_element(f"{base_url}/", changefreq="daily", priority="1.0"))
|
|
|
|
|
|
urlset.append(url_element(f"{base_url}/feed", changefreq="hourly", priority="0.9"))
|
2026-05-11 22:12:43 +02:00
|
|
|
|
urlset.append(url_element(f"{base_url}/news", changefreq="hourly", priority="0.9"))
|
2026-06-09 18:48:08 +02:00
|
|
|
|
urlset.append(
|
|
|
|
|
|
url_element(f"{base_url}/projects", changefreq="daily", priority="0.8")
|
|
|
|
|
|
)
|
2026-05-23 03:21:55 +02:00
|
|
|
|
urlset.append(url_element(f"{base_url}/gists", changefreq="daily", priority="0.8"))
|
2026-07-26 14:57:18 +02:00
|
|
|
|
urlset.append(url_element(f"{base_url}/quizzes", changefreq="daily", priority="0.8"))
|
2026-06-09 18:48:08 +02:00
|
|
|
|
urlset.append(
|
|
|
|
|
|
url_element(f"{base_url}/leaderboard", changefreq="daily", priority="0.7")
|
|
|
|
|
|
)
|
2026-06-14 09:48:10 +02:00
|
|
|
|
urlset.append(url_element(f"{base_url}/issues", changefreq="daily", priority="0.6"))
|
2026-06-14 02:16:22 +02:00
|
|
|
|
urlset.append(url_element(f"{base_url}/tools", changefreq="monthly", priority="0.5"))
|
|
|
|
|
|
urlset.append(url_element(f"{base_url}/tools/seo", changefreq="monthly", priority="0.5"))
|
2026-06-14 03:34:21 +02:00
|
|
|
|
urlset.append(url_element(f"{base_url}/tools/deepsearch", changefreq="monthly", priority="0.5"))
|
2026-05-11 05:30:51 +02:00
|
|
|
|
|
|
|
|
|
|
if "posts" in db.tables:
|
2026-06-09 18:48:08 +02:00
|
|
|
|
posts = _collect(
|
|
|
|
|
|
get_table("posts"), SITEMAP_URL_LIMIT, "posts", order_by=["-created_at"]
|
|
|
|
|
|
)
|
2026-05-12 12:45:52 +02:00
|
|
|
|
for p in posts:
|
2026-06-09 18:48:08 +02:00
|
|
|
|
urlset.append(
|
|
|
|
|
|
url_element(
|
|
|
|
|
|
f"{base_url}/posts/{p.get('slug') or p['uid']}",
|
|
|
|
|
|
lastmod=p.get("created_at", ""),
|
|
|
|
|
|
changefreq="weekly",
|
|
|
|
|
|
priority="0.7",
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2026-05-11 05:30:51 +02:00
|
|
|
|
|
|
|
|
|
|
if "projects" in db.tables:
|
2026-06-09 18:48:08 +02:00
|
|
|
|
projects = _collect(
|
|
|
|
|
|
get_table("projects"),
|
|
|
|
|
|
SITEMAP_URL_LIMIT,
|
|
|
|
|
|
"projects",
|
|
|
|
|
|
order_by=["-created_at"],
|
|
|
|
|
|
)
|
2026-05-11 05:30:51 +02:00
|
|
|
|
for p in projects:
|
2026-06-09 06:41:27 +02:00
|
|
|
|
if p.get("is_private"):
|
|
|
|
|
|
continue
|
2026-06-09 18:48:08 +02:00
|
|
|
|
urlset.append(
|
|
|
|
|
|
url_element(
|
|
|
|
|
|
f"{base_url}/projects/{p.get('slug') or p['uid']}",
|
|
|
|
|
|
lastmod=p.get("created_at", ""),
|
|
|
|
|
|
changefreq="weekly",
|
|
|
|
|
|
priority="0.6",
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2026-05-11 05:30:51 +02:00
|
|
|
|
|
2026-05-12 15:07:34 +02:00
|
|
|
|
if "gists" in db.tables:
|
2026-06-09 18:48:08 +02:00
|
|
|
|
gists = _collect(
|
|
|
|
|
|
get_table("gists"), SITEMAP_URL_LIMIT, "gists", order_by=["-created_at"]
|
|
|
|
|
|
)
|
2026-05-12 15:07:34 +02:00
|
|
|
|
for g in gists:
|
2026-06-09 18:48:08 +02:00
|
|
|
|
urlset.append(
|
|
|
|
|
|
url_element(
|
|
|
|
|
|
f"{base_url}/gists/{g.get('slug') or g['uid']}",
|
|
|
|
|
|
lastmod=g.get("created_at", ""),
|
|
|
|
|
|
changefreq="weekly",
|
2026-07-26 14:57:18 +02:00
|
|
|
|
priority="0.6",
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if "quizzes" in db.tables:
|
|
|
|
|
|
quizzes = _collect(
|
|
|
|
|
|
get_table("quizzes"),
|
|
|
|
|
|
SITEMAP_URL_LIMIT,
|
|
|
|
|
|
"quizzes",
|
|
|
|
|
|
status="published",
|
|
|
|
|
|
order_by=["-created_at"],
|
|
|
|
|
|
)
|
|
|
|
|
|
for quiz in quizzes:
|
|
|
|
|
|
urlset.append(
|
|
|
|
|
|
url_element(
|
|
|
|
|
|
f"{base_url}/quizzes/{quiz.get('slug') or quiz['uid']}",
|
|
|
|
|
|
lastmod=quiz.get("published_at", "") or quiz.get("created_at", ""),
|
|
|
|
|
|
changefreq="weekly",
|
2026-06-09 18:48:08 +02:00
|
|
|
|
priority="0.6",
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2026-05-12 15:07:34 +02:00
|
|
|
|
|
2026-05-23 03:21:55 +02:00
|
|
|
|
if "news" in db.tables:
|
2026-06-09 18:48:08 +02:00
|
|
|
|
articles = _collect(
|
|
|
|
|
|
get_table("news"),
|
|
|
|
|
|
SITEMAP_URL_LIMIT,
|
|
|
|
|
|
"news",
|
|
|
|
|
|
status="published",
|
|
|
|
|
|
order_by=["-synced_at"],
|
|
|
|
|
|
)
|
2026-05-23 03:21:55 +02:00
|
|
|
|
for a in articles:
|
2026-06-09 18:48:08 +02:00
|
|
|
|
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",
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2026-05-23 03:21:55 +02:00
|
|
|
|
|
2026-05-11 05:30:51 +02:00
|
|
|
|
if "users" in db.tables:
|
2026-05-23 03:21:55 +02:00
|
|
|
|
post_counts = {}
|
|
|
|
|
|
if "posts" in db.tables:
|
2026-06-09 18:48:08 +02:00
|
|
|
|
for row in db.query(
|
2026-06-11 22:36:47 +02:00
|
|
|
|
"SELECT user_uid, COUNT(*) AS c FROM posts WHERE deleted_at IS NULL GROUP BY user_uid"
|
2026-06-09 18:48:08 +02:00
|
|
|
|
):
|
2026-05-23 03:21:55 +02:00
|
|
|
|
post_counts[row["user_uid"]] = row["c"]
|
2026-06-09 18:48:08 +02:00
|
|
|
|
users = _collect(
|
|
|
|
|
|
get_table("users"), SITEMAP_URL_LIMIT, "users", order_by=["-created_at"]
|
|
|
|
|
|
)
|
2026-05-11 05:30:51 +02:00
|
|
|
|
for u in users:
|
2026-05-23 03:21:55 +02:00
|
|
|
|
if post_counts.get(u["uid"], 0) < 2:
|
|
|
|
|
|
continue
|
2026-06-09 18:48:08 +02:00
|
|
|
|
urlset.append(
|
|
|
|
|
|
url_element(
|
|
|
|
|
|
f"{base_url}/profile/{u['username']}",
|
|
|
|
|
|
lastmod=u.get("created_at", ""),
|
|
|
|
|
|
changefreq="weekly",
|
|
|
|
|
|
priority="0.4",
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2026-05-11 05:30:51 +02:00
|
|
|
|
|
2026-06-12 05:37:12 +02:00
|
|
|
|
try:
|
2026-06-16 07:08:58 +02:00
|
|
|
|
from devplacepy.routers.docs.pages import DOCS_PAGES
|
2026-06-12 05:37:12 +02:00
|
|
|
|
|
2026-06-16 07:08:58 +02:00
|
|
|
|
for page in DOCS_PAGES:
|
|
|
|
|
|
if page.get("admin") or page.get("kind") == "live":
|
2026-06-12 05:37:12 +02:00
|
|
|
|
continue
|
|
|
|
|
|
urlset.append(
|
|
|
|
|
|
url_element(
|
2026-06-16 07:08:58 +02:00
|
|
|
|
f"{base_url}/docs/{page['slug']}.html",
|
2026-06-12 05:37:12 +02:00
|
|
|
|
changefreq="weekly",
|
|
|
|
|
|
priority="0.5",
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception:
|
2026-06-16 07:08:58 +02:00
|
|
|
|
logger.warning("sitemap: could not add docs pages")
|
2026-06-12 05:37:12 +02:00
|
|
|
|
|
2026-05-11 05:30:51 +02:00
|
|
|
|
rough = tostring(urlset, encoding="unicode")
|
|
|
|
|
|
dom = minidom.parseString(rough)
|
|
|
|
|
|
return dom.toprettyxml(indent=" ")
|