- Add GET /profile route returning own profile page with tab support, backed by new my_profile_page handler in profile/index.py and documented in docs_api.py - Introduce _clean_breadcrumbs helper in seo.py that strips markdown from breadcrumb names before passing to schema generation - Skip data-confirm modal for elements with data-auto-submit attribute in ModalManager.js - Guard PushManager subscription against missing userUid and capture it from document body dataset - Switch profile bio template from render_title to render_content and use div instead of span for proper block rendering
515 lines
16 KiB
Python
515 lines
16 KiB
Python
# retoor <retoor@molodetz.nl>
|
||
|
||
import json
|
||
import logging
|
||
import os
|
||
import time
|
||
from urllib.parse import urlencode
|
||
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__)
|
||
|
||
|
||
SITE_NAME = "DevPlace"
|
||
SITEMAP_URL_LIMIT = 5000
|
||
SITEMAP_TTL = int(os.environ.get("DEVPLACE_SITEMAP_TTL", "3600"))
|
||
_sitemap_cache = {}
|
||
|
||
|
||
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)
|
||
|
||
|
||
def truncate(text: str, max_len: int = 160) -> str:
|
||
if not text:
|
||
return ""
|
||
text = " ".join(text.split())
|
||
if len(text) <= max_len:
|
||
return text
|
||
text = text[: max_len - 3].rsplit(" ", 1)[0]
|
||
return text + "..."
|
||
|
||
|
||
def public_base_url() -> str:
|
||
from devplacepy.database import get_setting
|
||
|
||
return (get_setting("site_url", "").strip() or SITE_URL or "").rstrip("/")
|
||
|
||
|
||
def site_url(request):
|
||
return public_base_url() or str(request.base_url).rstrip("/")
|
||
|
||
|
||
def website_schema(base_url):
|
||
return {
|
||
"@type": "WebSite",
|
||
"name": SITE_NAME,
|
||
"url": base_url,
|
||
"potentialAction": {
|
||
"@type": "SearchAction",
|
||
"target": {
|
||
"@type": "EntryPoint",
|
||
"urlTemplate": f"{base_url}/feed?search={{query}}",
|
||
},
|
||
"query-input": "required name=query",
|
||
},
|
||
}
|
||
|
||
|
||
def breadcrumb_schema(items, base_url):
|
||
return {
|
||
"@type": "BreadcrumbList",
|
||
"itemListElement": [
|
||
{
|
||
"@type": "ListItem",
|
||
"position": i + 1,
|
||
"item": {
|
||
"@id": item["url"]
|
||
if item["url"].startswith("http")
|
||
else f"{base_url}{item['url']}",
|
||
"name": item["name"],
|
||
},
|
||
}
|
||
for i, item in enumerate(items)
|
||
],
|
||
}
|
||
|
||
|
||
def discussion_forum_posting(post, author, comment_count, star_count, base_url):
|
||
schema = {
|
||
"@type": "DiscussionForumPosting",
|
||
"headline": post.get("title") or "Untitled",
|
||
"text": truncate(plain_markdown(post.get("content", "")), 500),
|
||
"url": f"{base_url}/posts/{post.get('slug') or post['uid']}",
|
||
"author": {
|
||
"@type": "Person",
|
||
"name": author["username"] if author else "Unknown",
|
||
"url": f"{base_url}/profile/{author['username']}" if author else "",
|
||
},
|
||
"datePublished": post.get("created_at", ""),
|
||
"dateModified": post.get("updated_at") or post.get("created_at", ""),
|
||
"interactionStatistic": [
|
||
{
|
||
"@type": "InteractionCounter",
|
||
"interactionType": "https://schema.org/LikeAction",
|
||
"userInteractionCount": star_count,
|
||
},
|
||
{
|
||
"@type": "InteractionCounter",
|
||
"interactionType": "https://schema.org/CommentAction",
|
||
"userInteractionCount": comment_count,
|
||
},
|
||
],
|
||
}
|
||
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": [
|
||
{
|
||
"@type": "InteractionCounter",
|
||
"interactionType": "https://schema.org/WriteAction",
|
||
"userInteractionCount": post_count,
|
||
}
|
||
],
|
||
},
|
||
}
|
||
|
||
|
||
def software_application_schema(project, base_url):
|
||
return {
|
||
"@type": "SoftwareApplication",
|
||
"name": project.get("title", "Untitled"),
|
||
"description": truncate(plain_markdown(project.get("description", "")), 300),
|
||
"url": f"{base_url}/projects/{project.get('slug') or project['uid']}",
|
||
"applicationCategory": "DeveloperApplication",
|
||
"operatingSystem": project.get("platforms", "Cross-platform"),
|
||
"author": {"@type": "Person", "name": project.get("author_name", "Unknown")},
|
||
"datePublished": project.get("created_at", ""),
|
||
"dateModified": project.get("updated_at") or project.get("created_at", ""),
|
||
"offers": {"@type": "Offer", "price": "0", "priceCurrency": "USD"},
|
||
}
|
||
|
||
|
||
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},
|
||
}
|
||
|
||
|
||
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(plain_markdown(article.get("description", "") or ""), 200),
|
||
"url": url,
|
||
"datePublished": article.get("synced_at", "") or article.get("created_at", ""),
|
||
"dateModified": 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(plain_markdown(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", ""),
|
||
"dateModified": gist.get("updated_at") or gist.get("created_at", ""),
|
||
}
|
||
|
||
|
||
def _json_ld_dumps(payload):
|
||
raw = json.dumps(payload, ensure_ascii=False)
|
||
return (
|
||
raw.replace("<", "\\u003c")
|
||
.replace(">", "\\u003e")
|
||
.replace("&", "\\u0026")
|
||
.replace("
", "\\u2028")
|
||
.replace("
", "\\u2029")
|
||
)
|
||
|
||
|
||
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
|
||
payload = (
|
||
{"@context": "https://schema.org", **cleaned[0]}
|
||
if len(cleaned) == 1
|
||
else {"@context": "https://schema.org", "@graph": cleaned}
|
||
)
|
||
return _json_ld_dumps(payload)
|
||
|
||
|
||
DEFAULT_OG_IMAGE = "/static/og-default.png"
|
||
|
||
|
||
def absolute_url(base, path):
|
||
if not path:
|
||
return ""
|
||
return path if path.startswith("http") else f"{base}{path}"
|
||
|
||
|
||
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
|
||
|
||
|
||
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,
|
||
keywords="",
|
||
seo_target=None,
|
||
):
|
||
from devplacepy.seo_meta_text import plain_seo_defaults, plain_text_from_markdown
|
||
|
||
base = site_url(request)
|
||
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
|
||
canonical = f"{base}{request.url.path}"
|
||
page = request.query_params.get("page")
|
||
if page and page not in ("", "1"):
|
||
canonical = f"{canonical}?page={page}"
|
||
og_img = absolute_url(base, og_image) or f"{base}{DEFAULT_OG_IMAGE}"
|
||
breadcrumbs = _clean_breadcrumbs(breadcrumbs)
|
||
page_schemas = list(schemas or [])
|
||
if breadcrumbs:
|
||
page_schemas.append(breadcrumb_schema(breadcrumbs, base))
|
||
return {
|
||
"page_title": page_title,
|
||
"meta_description": clean_description,
|
||
"meta_keywords": meta_keywords,
|
||
"meta_robots": robots,
|
||
"canonical_url": canonical,
|
||
"og_title": seo_title or SITE_NAME,
|
||
"og_description": clean_description,
|
||
"og_image": og_img,
|
||
"og_type": og_type,
|
||
"breadcrumbs": breadcrumbs or [],
|
||
"page_schema": combine(page_schemas),
|
||
"prev_url": absolute_url(base, prev_url),
|
||
"next_url": absolute_url(base, next_url),
|
||
}
|
||
|
||
|
||
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
|
||
|
||
|
||
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)}"
|
||
|
||
|
||
def list_page_seo(
|
||
request, title="", description="", breadcrumbs=None, prev_url=None, next_url=None
|
||
):
|
||
base = site_url(request)
|
||
return base_seo_context(
|
||
request,
|
||
title=title,
|
||
description=description,
|
||
breadcrumbs=breadcrumbs,
|
||
schemas=[website_schema(base)],
|
||
prev_url=prev_url,
|
||
next_url=next_url,
|
||
)
|
||
|
||
|
||
def make_sitemap(base_url):
|
||
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):
|
||
if table.has_column("deleted_at") and "deleted_at" not in query:
|
||
query["deleted_at"] = None
|
||
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):
|
||
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"))
|
||
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"))
|
||
urlset.append(
|
||
url_element(f"{base_url}/leaderboard", changefreq="daily", priority="0.7")
|
||
)
|
||
urlset.append(url_element(f"{base_url}/issues", changefreq="daily", priority="0.6"))
|
||
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"))
|
||
urlset.append(url_element(f"{base_url}/tools/deepsearch", changefreq="monthly", priority="0.5"))
|
||
|
||
if "posts" in db.tables:
|
||
posts = _collect(
|
||
get_table("posts"), SITEMAP_URL_LIMIT, "posts", order_by=["-created_at"]
|
||
)
|
||
for p in posts:
|
||
urlset.append(
|
||
url_element(
|
||
f"{base_url}/posts/{p.get('slug') or p['uid']}",
|
||
lastmod=p.get("created_at", ""),
|
||
changefreq="weekly",
|
||
priority="0.7",
|
||
)
|
||
)
|
||
|
||
if "projects" in db.tables:
|
||
projects = _collect(
|
||
get_table("projects"),
|
||
SITEMAP_URL_LIMIT,
|
||
"projects",
|
||
order_by=["-created_at"],
|
||
)
|
||
for p in projects:
|
||
if p.get("is_private"):
|
||
continue
|
||
urlset.append(
|
||
url_element(
|
||
f"{base_url}/projects/{p.get('slug') or p['uid']}",
|
||
lastmod=p.get("created_at", ""),
|
||
changefreq="weekly",
|
||
priority="0.6",
|
||
)
|
||
)
|
||
|
||
if "gists" in db.tables:
|
||
gists = _collect(
|
||
get_table("gists"), SITEMAP_URL_LIMIT, "gists", order_by=["-created_at"]
|
||
)
|
||
for g in gists:
|
||
urlset.append(
|
||
url_element(
|
||
f"{base_url}/gists/{g.get('slug') or g['uid']}",
|
||
lastmod=g.get("created_at", ""),
|
||
changefreq="weekly",
|
||
priority="0.6",
|
||
)
|
||
)
|
||
|
||
if "news" in db.tables:
|
||
articles = _collect(
|
||
get_table("news"),
|
||
SITEMAP_URL_LIMIT,
|
||
"news",
|
||
status="published",
|
||
order_by=["-synced_at"],
|
||
)
|
||
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 WHERE deleted_at IS NULL GROUP BY user_uid"
|
||
):
|
||
post_counts[row["user_uid"]] = row["c"]
|
||
users = _collect(
|
||
get_table("users"), SITEMAP_URL_LIMIT, "users", order_by=["-created_at"]
|
||
)
|
||
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",
|
||
)
|
||
)
|
||
|
||
try:
|
||
from devplacepy.routers.docs.pages import DOCS_PAGES
|
||
|
||
for page in DOCS_PAGES:
|
||
if page.get("admin") or page.get("kind") == "live":
|
||
continue
|
||
urlset.append(
|
||
url_element(
|
||
f"{base_url}/docs/{page['slug']}.html",
|
||
changefreq="weekly",
|
||
priority="0.5",
|
||
)
|
||
)
|
||
except Exception:
|
||
logger.warning("sitemap: could not add docs pages")
|
||
|
||
rough = tostring(urlset, encoding="unicode")
|
||
dom = minidom.parseString(rough)
|
||
return dom.toprettyxml(indent=" ")
|