SEO/social meta for content pages; full-height site editor
- views.py: description, canonical, Open Graph and twitter:card tags
from heuristics over the rendered article — first paragraph as
description, share image prefers a {.hero} image, then first raster,
then first SVG; first <video> becomes og:video; published/modified
times from the node. Absolute URLs from the request base.
- SiteEditor: panel fills the full window height; the brand-HTML and
custom-CSS CodeMirror windows grow to share leftover space equally
instead of fixed max-heights.
This commit is contained in:
@@ -108,7 +108,14 @@ not for the public pages. See `docs/design-principles.md` for the design.
|
|||||||
rendering straight from the `Data.menu` tree (siblings sorted by
|
rendering straight from the `Data.menu` tree (siblings sorted by
|
||||||
`Node.order`; nav links to content-less labels point at their first
|
`Node.order`; nav links to content-less labels point at their first
|
||||||
child via `first_leaf`, the first published descendant with content),
|
child via `first_leaf`, the first published descendant with content),
|
||||||
and page/404 rendering. If the markdown contains its own h1, the page title
|
and page/404 rendering. Content pages get SEO/social meta (description,
|
||||||
|
canonical link, Open Graph + twitter card) from heuristics over the
|
||||||
|
rendered article: the description is the first paragraph's text, the
|
||||||
|
share image prefers a `{.hero}`-classed image, then the first raster
|
||||||
|
`<img>`, then the first SVG; the first `<video>` yields `og:video`;
|
||||||
|
URLs are made absolute with the request base URL;
|
||||||
|
`article:published/modified_time` come from `Node.created`/`modified`.
|
||||||
|
If the markdown contains its own h1, the page title
|
||||||
is NOT rendered as an additional h1 (it still supplies <title> and nav
|
is NOT rendered as an additional h1 (it still supplies <title> and nav
|
||||||
labels). The navbar holds
|
labels). The navbar holds
|
||||||
top-level items only; the current section's subitems go to a left
|
top-level items only; the current section's subitems go to a left
|
||||||
|
|||||||
+1
-1
@@ -700,7 +700,7 @@ async def show_page(request: Request, path: str) -> HTMLResponse | Response:
|
|||||||
if request.headers.get("if-none-match") == etag:
|
if request.headers.get("if-none-match") == etag:
|
||||||
return Response(status_code=304)
|
return Response(status_code=304)
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
views.render_page(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html),
|
views.render_page(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html, str(request.base_url).rstrip("/")),
|
||||||
headers={
|
headers={
|
||||||
"etag": etag,
|
"etag": etag,
|
||||||
"last-modified": _http_date(node.modified),
|
"last-modified": _http_date(node.modified),
|
||||||
|
|||||||
+103
-2
@@ -15,8 +15,10 @@ own URL renders a placeholder page (render_category).
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from html import unescape
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
from html5tagger import HTML, Document, E, Template
|
from html5tagger import HTML, Document, E, Template
|
||||||
|
|
||||||
@@ -114,6 +116,7 @@ def _layout(
|
|||||||
theme: str = "",
|
theme: str = "",
|
||||||
banner_design: str = "",
|
banner_design: str = "",
|
||||||
favicon: str = "",
|
favicon: str = "",
|
||||||
|
social: dict[str, str] | None = None,
|
||||||
) -> Template:
|
) -> Template:
|
||||||
"""Page layout template with standard asset URLs and ES-module scripts.
|
"""Page layout template with standard asset URLs and ES-module scripts.
|
||||||
|
|
||||||
@@ -126,11 +129,22 @@ def _layout(
|
|||||||
In dev, pagerite.js re-appends the backend-rendered theme/design links
|
In dev, pagerite.js re-appends the backend-rendered theme/design links
|
||||||
(and the custom CSS) after the Vite-injected base styles, keeping this
|
(and the custom CSS) after the Vite-injected base styles, keeping this
|
||||||
order intact.
|
order intact.
|
||||||
|
|
||||||
|
``social`` maps meta keys to contents: ``og:*``/``article:*`` go out as
|
||||||
|
property attributes, everything else (description, twitter:*) as name.
|
||||||
"""
|
"""
|
||||||
doc = Document(E.Title, lang="en")
|
doc = Document(E.Title, lang="en")
|
||||||
# Responsive layout (see the 48rem breakpoint in pagerite.css) needs
|
# Responsive layout (see the 48rem breakpoint in pagerite.css) needs
|
||||||
# the real device width, not the default 980px layout viewport.
|
# the real device width, not the default 980px layout viewport.
|
||||||
doc.meta(name="viewport", content="width=device-width, initial-scale=1")
|
doc.meta(name="viewport", content="width=device-width, initial-scale=1")
|
||||||
|
for key, value in (social or {}).items():
|
||||||
|
if value:
|
||||||
|
if key.startswith(("og:", "article:")):
|
||||||
|
doc.meta(property=key, content=value)
|
||||||
|
elif key == "canonical":
|
||||||
|
doc.link(rel="canonical", href=value)
|
||||||
|
else:
|
||||||
|
doc.meta(name=key, content=value)
|
||||||
# A custom favicon (from the site editor) is linked explicitly; without
|
# A custom favicon (from the site editor) is linked explicitly; without
|
||||||
# one, browsers fall back to the build's /favicon.ico by convention.
|
# one, browsers fall back to the build's /favicon.ico by convention.
|
||||||
if favicon:
|
if favicon:
|
||||||
@@ -426,6 +440,87 @@ def page_content(menu: dict[str, Node], path: str) -> HTML:
|
|||||||
return HTML(str(doc))
|
return HTML(str(doc))
|
||||||
|
|
||||||
|
|
||||||
|
_FIRST_P = re.compile(r"<p[^>]*>(.*?)</p>", re.S)
|
||||||
|
_TAG = re.compile(r"<[^>]+>")
|
||||||
|
_IMG_TAG = re.compile(r"<img\b[^>]*>")
|
||||||
|
_VIDEO_TAG = re.compile(r"<video\b[^>]*>")
|
||||||
|
_ATTR_SRC = re.compile(r'src="([^"]+)"')
|
||||||
|
_ATTR_CLASS = re.compile(r'class="([^"]*)"')
|
||||||
|
|
||||||
|
|
||||||
|
def _share_media(html: str, base_url: str) -> tuple[str, str]:
|
||||||
|
"""(image, video) share URLs from the rendered article.
|
||||||
|
|
||||||
|
Image preference: an image with class "hero" (author override, may
|
||||||
|
appear anywhere in the article), then the first raster image (SVGs
|
||||||
|
rasterize poorly or not at all on many social scrapers), then the
|
||||||
|
first SVG. Video: the first <video> — og:video is in the OGP spec and
|
||||||
|
honored mainly by Facebook; X/Twitter ignores it. Absolute URLs are
|
||||||
|
built from the request base, scrapers cannot use relative ones.
|
||||||
|
"""
|
||||||
|
if not base_url:
|
||||||
|
return "", ""
|
||||||
|
|
||||||
|
def absolute(src: str) -> str:
|
||||||
|
src = unescape(src)
|
||||||
|
return src if src.startswith(("http://", "https://")) else f"{base_url}{src}"
|
||||||
|
|
||||||
|
hero = raster = svg = video = ""
|
||||||
|
for tag in _IMG_TAG.findall(html):
|
||||||
|
if not (src := _ATTR_SRC.search(tag)):
|
||||||
|
continue
|
||||||
|
src = src.group(1)
|
||||||
|
cls = _ATTR_CLASS.search(tag)
|
||||||
|
if cls and "hero" in cls.group(1).split():
|
||||||
|
hero = src
|
||||||
|
break
|
||||||
|
if src.lower().split("?")[0].endswith(".svg"):
|
||||||
|
svg = svg or src
|
||||||
|
else:
|
||||||
|
raster = raster or src
|
||||||
|
# Keep scanning: a later hero still wins.
|
||||||
|
for tag in _VIDEO_TAG.findall(html):
|
||||||
|
if m := _ATTR_SRC.search(tag):
|
||||||
|
video = m.group(1)
|
||||||
|
break
|
||||||
|
image = hero or raster or svg
|
||||||
|
return (absolute(image) if image else "", absolute(video) if video else "")
|
||||||
|
|
||||||
|
|
||||||
|
def _social_meta(
|
||||||
|
node: Node, path: str, title: str, html: str, brand: str, base_url: str,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Open Graph/Twitter/SEO meta tags for a content page.
|
||||||
|
|
||||||
|
Heuristics over the rendered article: the description is the first
|
||||||
|
paragraph's text (truncated at ~200 chars on a word boundary), the
|
||||||
|
share image the article's first <img> — authors lead with their most
|
||||||
|
representative figure. Absolute URLs are built from the request's base
|
||||||
|
(social scrapers cannot use relative ones).
|
||||||
|
"""
|
||||||
|
url = f"{base_url}/{path}" if base_url else ""
|
||||||
|
m = _FIRST_P.search(html)
|
||||||
|
text = unescape(_TAG.sub("", m.group(1) if m else ""))
|
||||||
|
text = " ".join(text.split())
|
||||||
|
if len(text) > 200:
|
||||||
|
text = text[:200].rsplit(" ", 1)[0] + "…"
|
||||||
|
image, video = _share_media(html, base_url)
|
||||||
|
return {
|
||||||
|
"description": text,
|
||||||
|
"canonical": url,
|
||||||
|
"og:type": "article",
|
||||||
|
"og:title": title,
|
||||||
|
"og:description": text,
|
||||||
|
"og:url": url,
|
||||||
|
"og:site_name": brand,
|
||||||
|
"og:image": image,
|
||||||
|
"og:video": video,
|
||||||
|
"article:published_time": node.created.isoformat(),
|
||||||
|
"article:modified_time": node.modified.isoformat(),
|
||||||
|
"twitter:card": "summary_large_image" if image else "summary",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def render_page(
|
def render_page(
|
||||||
menu: dict[str, Node],
|
menu: dict[str, Node],
|
||||||
path: str,
|
path: str,
|
||||||
@@ -434,18 +529,24 @@ def render_page(
|
|||||||
theme: str = "",
|
theme: str = "",
|
||||||
favicon: str = "",
|
favicon: str = "",
|
||||||
brand_html: str = "",
|
brand_html: str = "",
|
||||||
|
base_url: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Render a full HTML page for the slug path."""
|
"""Render a full HTML page for the slug path."""
|
||||||
node = resolve(menu, path)[-1]
|
node = resolve(menu, path)[-1]
|
||||||
title = _title(path.rpartition("/")[2], node)
|
title = _title(path.rpartition("/")[2], node)
|
||||||
|
main = page_content(menu, path)
|
||||||
|
social = _social_meta(node, path, title, str(main), brand, base_url)
|
||||||
return str(
|
return str(
|
||||||
_layout(_page_assets(), custom_css, theme, banner_design(menu, path, theme), favicon)(
|
_layout(
|
||||||
|
_page_assets(), custom_css, theme, banner_design(menu, path, theme),
|
||||||
|
favicon, social,
|
||||||
|
)(
|
||||||
Title=f"{title} – {brand}" if brand else title,
|
Title=f"{title} – {brand}" if brand else title,
|
||||||
Brand=_brand_link(brand, brand_html),
|
Brand=_brand_link(brand, brand_html),
|
||||||
Nav=nav_html(menu, path),
|
Nav=nav_html(menu, path),
|
||||||
Sidebar=sidebar_html(menu, path),
|
Sidebar=sidebar_html(menu, path),
|
||||||
Banner=banner_html(menu, path, theme),
|
Banner=banner_html(menu, path, theme),
|
||||||
Main=page_content(menu, path),
|
Main=main,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user