Fix card image priority: own > mined from article > inherited

The resolved (inheriting) Node.image was checked before mining the
article, so a front-page banner image shadowed the articles' own
extracted figures everywhere: card covers, og:image and the editor's
card previews.
This commit is contained in:
2026-09-23 06:10:01 +00:00
parent 6c0de19ae8
commit 4d6735f609
7 changed files with 52 additions and 25 deletions
+2 -2
View File
@@ -34,11 +34,11 @@ markdown-it-py renderer (html passthrough + attrs, footnote, deflist, tasklists,
The shared page layout as an html5tagger `Template` with placeholders (`Title`, `Brand`, `Banner`, `Nav`, `Sidebar`, `Main`), nav rendering straight from the `Data.menu` tree (siblings sorted by `Node.order`; nav links to content-less labels point at their first child via `first_leaf`, the first published descendant with content), and page/404 rendering. The shared page layout as an html5tagger `Template` with placeholders (`Title`, `Brand`, `Banner`, `Nav`, `Sidebar`, `Main`), nav rendering straight from the `Data.menu` tree (siblings sorted by `Node.order`; nav links to content-less labels point at their first child via `first_leaf`, the first published descendant with content), 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 card image is the node's own `Node.image` when one resolves (nearest ancestor, front page last — see docs/content-model.md), otherwise mined from the article, preferring 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 site origin (`SITE_URL``https://<hostname>` from the CLI hostname argument; on localhost the request's own base URL is the fallback); `article:published/modified_time` come from `Node.created`/`modified`. Additionally `twitter:image` pins extension-less `/_f/{hash}` card images to the `.webp` variant — X only honors WebP via twitter:image (not og:image) and its scraper cannot be trusted to negotiate via Accept. `twitter:card` is `summary_large_image` when the image's probed store dimensions suit a large card (>= 600px wide, aspect between 1.4 and 2.5; dimensions are read from the `<hash>.webp` derivative via pyvips, cached per hash) and `summary` for small or portrait images — external or unprobeable images keep the presence-based default (large when an image exists). The node's `Node.large` setting overrides that pick per article (False = small, True = large, the default None = automatic; NOT inherited like `Node.image`). The page title is injected as `# {title}` when the markdown has no h1 of its own, so it never appears twice (it always supplies `<title>` and nav labels). 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 card image is the node's own `Node.image` when set, otherwise mined from the article (preferring a `{.hero}`-classed image, then the first raster `<img>`, then the first SVG), and only when the article yields none the inherited one (nearest ancestor, front page last — see docs/content-model.md); the first `<video>` yields `og:video`; URLs are made absolute with the site origin (`SITE_URL``https://<hostname>` from the CLI hostname argument; on localhost the request's own base URL is the fallback); `article:published/modified_time` come from `Node.created`/`modified`. Additionally `twitter:image` pins extension-less `/_f/{hash}` card images to the `.webp` variant — X only honors WebP via twitter:image (not og:image) and its scraper cannot be trusted to negotiate via Accept. `twitter:card` is `summary_large_image` when the image's probed store dimensions suit a large card (>= 600px wide, aspect between 1.4 and 2.5; dimensions are read from the `<hash>.webp` derivative via pyvips, cached per hash) and `summary` for small or portrait images — external or unprobeable images keep the presence-based default (large when an image exists). The node's `Node.large` setting overrides that pick per article (False = small, True = large, the default None = automatic; NOT inherited like `Node.image`). The page title is injected as `# {title}` when the markdown has no h1 of its own, so it never appears twice (it always supplies `<title>` and nav labels).
The navbar holds top-level items only; the current section's subitems go to a left `#sidebar` as a nested list (the section's direct children plain, deeper levels indented with article-list-style markers), rendered only from the second level down — main-level pages list their children as cards after the content instead. Below that, the sidebar renders when the section offers at least two published items, or exactly one while viewing anything other than that only page — the section index, a 404, a grandchild (so those pages can reach the child), and also on that only page itself when it has published children of its own; no aside element at all on the front page, main-level pages, leaf pages and the sole childless page of a one-page section. Also, category labels are nodes without content — None *or* empty markdown — and their nav links point at their first child page. Dynamic regions have stable ids (`#page-banner`, `#nav`, `#sidebar`, `#main`) for fetch-navigation swaps (`#sidebar` may be absent on either side of a swap). The navbar holds top-level items only; the current section's subitems go to a left `#sidebar` as a nested list (the section's direct children plain, deeper levels indented with article-list-style markers), rendered only from the second level down — main-level pages list their children as cards after the content instead. Below that, the sidebar renders when the section offers at least two published items, or exactly one while viewing anything other than that only page — the section index, a 404, a grandchild (so those pages can reach the child), and also on that only page itself when it has published children of its own; no aside element at all on the front page, main-level pages, leaf pages and the sole childless page of a one-page section. Also, category labels are nodes without content — None *or* empty markdown — and their nav links point at their first child page. Dynamic regions have stable ids (`#page-banner`, `#nav`, `#sidebar`, `#main`) for fetch-navigation swaps (`#sidebar` may be absent on either side of a swap).
Any page with published children — a category page — lists them as a card grid (`nav.cards`) after the markdown content, as does the content-less category 404. Each card links to the child page (a content-less child to its first leaf) and shows the child's card image (its resolved `Node.image` when set, else the same hero → first raster → first SVG heuristics as `og:image`). The layout follows the same selection as `twitter:card` (_card_large — the child's per-article `Node.large` override, else the image's probed dimensions): large cards show the image as a full-card cover with the title overlaid, small cards (`.card.compact`) split horizontally at the golden ratio (two sub-grids, top φ : bottom 1): the square image fills the top part with the title beside it at its bottom, the article description (which only the small format carries) tops the bottom part — title and description carry the translucent band (the same band color as the large cards' title) as their own background; imageless cards keep the image space as a blank gradient. Each card carries the target article's language as its `lang` (the page language when the target is translated into it, else the target's primary language — matching the per-card text fallback) so the clamped title/description hyphenate correctly (`hyphens: auto`). Any page with published children — a category page — lists them as a card grid (`nav.cards`) after the markdown content, as does the content-less category 404. Each card links to the child page (a content-less child to its first leaf) and shows the child's card image (its own `Node.image` when set, else the same hero → first raster → first SVG heuristics as `og:image`, else the inherited image). The layout follows the same selection as `twitter:card` (_card_large — the child's per-article `Node.large` override, else the image's probed dimensions): large cards show the image as a full-card cover with the title overlaid, small cards (`.card.compact`) split horizontally at the golden ratio (two sub-grids, top φ : bottom 1): the square image fills the top part with the title beside it at its bottom, the article description (which only the small format carries) tops the bottom part — title and description carry the translucent band (the same band color as the large cards' title) as their own background; imageless cards keep the image space as a blank gradient. Each card carries the target article's language as its `lang` (the page language when the target is translated into it, else the target's primary language — matching the per-card text fallback) so the clamped title/description hyphenate correctly (`hyphens: auto`).
## `seed.py` ## `seed.py`
+1 -1
View File
@@ -24,7 +24,7 @@ Files are content-addressed (blake3[:12] + extension) and stored **on disk** und
## Card images ## Card images
`Node.image` names a content-addressed store file (12-hex hash, served at `/_f/{name}`) used as the page's card image: `og:image`/`twitter:image` meta and the card cover in listings. Empty inherits the nearest ancestor's image, the front page last; unset everywhere, the meta tags fall back to mining the rendered article (hero → first raster → first SVG). Set in the editor's banner panel (upload → `PUT /_api/files/{name}`, then a `save` with `image` over the editor WebSocket), stored at `IMAGE_MAXSIZE` like other uploads. `twitter:card` picks `summary_large_image` vs `summary` from the image's probed dimensions (views.py `_image_dims`). `Node.image` names a content-addressed store file (12-hex hash, served at `/_f/{name}`) used as the page's card image: `og:image`/`twitter:image` meta and the card cover in listings. The effective image follows the priority: the node's own `image`, then one mined from the rendered article (hero → first raster → first SVG), then the inherited image (the nearest ancestor's, the front page last). Set in the editor's banner panel (upload → `PUT /_api/files/{name}`, then a `save` with `image` over the editor WebSocket), stored at `IMAGE_MAXSIZE` like other uploads. `twitter:card` picks `summary_large_image` vs `summary` from the image's probed dimensions (views.py `_image_dims`).
`Node.large: bool | None` overrides the automatic card-mode pick per article: None = automatic, False forces a small card, True a large one. Unlike `image`, it is NOT inherited down the tree. Set from the banner panel's card previews (a `save` with `large` over the editor WebSocket). `Node.large: bool | None` overrides the automatic card-mode pick per article: None = automatic, False forces a small card, True a large one. Unlike `image`, it is NOT inherited down the tree. Set from the banner panel's card previews (a `save` with `large` over the editor WebSocket).
+1 -1
View File
@@ -27,7 +27,7 @@ Pagerite is a single-user CMS/blog. This document records the initial high-level
- All pages share one static layout, defined once as an **html5tagger Template** with capitalized placeholders (`Title`, `Banner`, `Nav`, `Sidebar`, `Main`) filled per request. The dynamic regions carry stable ids (`#page-banner`, `#nav`, `#sidebar`, `#main`). - All pages share one static layout, defined once as an **html5tagger Template** with capitalized placeholders (`Title`, `Banner`, `Nav`, `Sidebar`, `Main`) filled per request. The dynamic regions carry stable ids (`#page-banner`, `#nav`, `#sidebar`, `#main`).
- The page top is a **full-width banner header** with the site name and the navigation bar overlaid on it — no separate chrome header. The banner combines two layers, stacked in `#page-banner` (a grid, so they overlay): first the **banner design** — a named design living in a theme folder (`pagerite/themes/{name}/banner.css` plus artwork as `banner.html` — arbitrary markup like canvas + style + script — or `banner.svg`), chosen per page via `Node.banner_design` (a design name, "" for none, None to inherit from the nearest ancestor, then the front page, then the active theme's own design). The artwork is inlined into a `div[data-design]` wrapper: SVG artwork can be recolored from the theme stylesheet (corporate's single SVG serves both light and dark mode via `var()`-driven stops). Second, **per-page author code**: `Node.banner` holds an arbitrary trusted HTML snippet (an image, a styled div, canvas + script — anything), resolved by walking up the node's ancestors to the front page and rendered **after** the design artwork, so author styles always win over the design's own. The base stylesheet falls back to a plain gradient. There is deliberately no scrim fading the banner into the page background — any such fade would ruin user-supplied designs; themes that want one bake it into their SVG (purple does). - The page top is a **full-width banner header** with the site name and the navigation bar overlaid on it — no separate chrome header. The banner combines two layers, stacked in `#page-banner` (a grid, so they overlay): first the **banner design** — a named design living in a theme folder (`pagerite/themes/{name}/banner.css` plus artwork as `banner.html` — arbitrary markup like canvas + style + script — or `banner.svg`), chosen per page via `Node.banner_design` (a design name, "" for none, None to inherit from the nearest ancestor, then the front page, then the active theme's own design). The artwork is inlined into a `div[data-design]` wrapper: SVG artwork can be recolored from the theme stylesheet (corporate's single SVG serves both light and dark mode via `var()`-driven stops). Second, **per-page author code**: `Node.banner` holds an arbitrary trusted HTML snippet (an image, a styled div, canvas + script — anything), resolved by walking up the node's ancestors to the front page and rendered **after** the design artwork, so author styles always win over the design's own. The base stylesheet falls back to a plain gradient. There is deliberately no scrim fading the banner into the page background — any such fade would ruin user-supplied designs; themes that want one bake it into their SVG (purple does).
- **Fetch-navigation.** Links are plain `<a href>`; a small script (`frontend/src/pagerite.js`) intercepts same-origin clicks, fetches the page, and swaps the `#page-banner`, `#nav`, `#sidebar` and `#main` regions, the document title, and the site-wide custom CSS (`<style id="pagerite-user">` in `<head>`), keeping the rest of `<head>` and the layout chrome. Without JS everything works as normal page loads. Scripts inside fetched banner and content regions are re-created so they execute. Swaps run inside `document.startViewTransition` for the page transition selected in the site settings (`Data.transition`; the `cube` design — CSS adapted from termotohtori.fi, fragile, do not tweak — rotates, mirrored on browser back; `crossfade` fades; both skipped under `prefers-reduced-motion`). With `cube`, navigation within the same top-level section crossfades instead of rotating. - **Fetch-navigation.** Links are plain `<a href>`; a small script (`frontend/src/pagerite.js`) intercepts same-origin clicks, fetches the page, and swaps the `#page-banner`, `#nav`, `#sidebar` and `#main` regions, the document title, and the site-wide custom CSS (`<style id="pagerite-user">` in `<head>`), keeping the rest of `<head>` and the layout chrome. Without JS everything works as normal page loads. Scripts inside fetched banner and content regions are re-created so they execute. Swaps run inside `document.startViewTransition` for the page transition selected in the site settings (`Data.transition`; the `cube` design — CSS adapted from termotohtori.fi, fragile, do not tweak — rotates, mirrored on browser back; `crossfade` fades; both skipped under `prefers-reduced-motion`). With `cube`, navigation within the same top-level section crossfades instead of rotating.
- **The site structure is a tree of labels.** `Data.menu` holds the top-level items by slug, each with `children` keyed by slug — the URL path is the slug chain. The front page is a top-level node with slug "" (an item *parallel* to the other main level pages, not their parent) and cannot have children. The header navbar holds only the top level; a top-level item is highlighted when viewing any of its subpages. A page with published children lists them as **cards** after its content (the child page's card image as the cover — its resolved `Node.image` when set, else mined like the og tags — laid out by the child's card-mode selection: full-card cover with the title overlaid, or a golden-ratio split with a square image and the title in the top part, the description below it on a translucent band); a **left sidebar** (`#sidebar`) with the section's sub-navigation appears only from the second level down, when there is something to navigate — main-level pages, sections with fewer than two published items, leaf pages and the front page render no aside element at all. Other sections' subitems are never shown without navigating into them first. - **The site structure is a tree of labels.** `Data.menu` holds the top-level items by slug, each with `children` keyed by slug — the URL path is the slug chain. The front page is a top-level node with slug "" (an item *parallel* to the other main level pages, not their parent) and cannot have children. The header navbar holds only the top level; a top-level item is highlighted when viewing any of its subpages. A page with published children lists them as **cards** after its content (the child page's card image as the cover — its own `Node.image` when set, else mined like the og tags, else the inherited one — laid out by the child's card-mode selection: full-card cover with the title overlaid, or a golden-ratio split with a square image and the title in the top part, the description below it on a translucent band); a **left sidebar** (`#sidebar`) with the section's sub-navigation appears only from the second level down, when there is something to navigate — main-level pages, sections with fewer than two published items, leaf pages and the front page render no aside element at all. Other sections' subitems are never shown without navigating into them first.
- **Landing pages are optional.** Every label can either have content (`Node.content`, a Markdown page) or none — a content-less label renders a 404 page listing its children as cards (with a pen to create the landing page) instead of redirecting, while nav links to it point straight at its first child, so categories need no filler content and normal navigation never sees the 404. Title and slug of every label are editable; renaming a slug moves the whole subtree. The sidebar never lists the section itself, avoiding title duplication with the navbar. - **Landing pages are optional.** Every label can either have content (`Node.content`, a Markdown page) or none — a content-less label renders a 404 page listing its children as cards (with a pen to create the landing page) instead of redirecting, while nav links to it point straight at its first child, so categories need no filler content and normal navigation never sees the 404. Title and slug of every label are editable; renaming a slug moves the whole subtree. The sidebar never lists the section itself, avoiding title duplication with the navbar.
- **Menu order is manual.** Each node has a fractional `order` key among its siblings; reordering/moving writes only the moved node (it takes a fresh value halfway between its new siblings; all other items keep theirs). New pages append at the end of their menu. Structure edits (reorder, move/rename with the whole subtree, retitle) go through `POST /_api/structure` and the editor's structure panel. - **Menu order is manual.** Each node has a fractional `order` key among its siblings; reordering/moving writes only the moved node (it takes a fresh value halfway between its new siblings; all other items keep theirs). New pages append at the end of their menu. Structure edits (reorder, move/rename with the whole subtree, retitle) go through `POST /_api/structure` and the editor's structure panel.
- Unpublished pages are hidden from both nav and URL access (404). - Unpublished pages are hidden from both nav and URL access (404).
+1 -1
View File
@@ -7,7 +7,7 @@ The Vue editor is a single tabbed `EditorShell.vue` mounted in a host div create
The shell hosts five kept-alive tabs (ordered site-wide first — site, structure, localization — then, after a visual break, the per-page tabs — article, banner): The shell hosts five kept-alive tabs (ordered site-wide first — site, structure, localization — then, after a visual break, the per-page tabs — article, banner):
- `PageEditor.vue` — CodeMirror + server-rendered preview over WebSocket `/_api/ws/editor`, previewing into the visible article; editor and article scrolls are linked piecewise-linearly, keyed on the section anchors' `data-line` (markdown source line the backend stamps on top-level anchored h1/h2s): the page follows the cursor (fractional, wrap-aware, scrolling only when the cursor's page position leaves the viewport, with an edge margin), the editor follows page scroll with a progress-based viewport anchor, applied instantly (the window keeps scrolling normally while any editor is open — the panel is fixed to the viewport's left edge, its top tracking the banner's bottom edge until the banner scrolls away — and the panel scrolls internally); anchored h2s carry their own edit pens that open the editor scrolled to that section; a format bar offers Markdown helpers — bold/italic/code/link/table/image upload (always block-level on a fresh blank-separated line of its own — a cursor on a non-empty line, e.g. inside an existing image tag, inserts after that line, never into it; always with an empty `""` caption, cursor inside the quotes), toggling fences (` ``` ` code blocks and `::: aside` containers share the same machinery: clicked inside one they remove it and select the content, otherwise they wrap the selection or the cursor's line, keeping it selected), and `.left`/`.right`/`.wide`/`.margin` placement toggles plus `.small`/`.large`/`.huge` text-size toggles (brace attributes on the block at the cursor, mutually exclusive within each group; on `:::` containers a placement class replaces the container name instead — `::: aside``::: margin`), with Ctrl/Cmd-B/I/S bindings — for the hard-to-remember syntax. Edits content and title only, never the path. - `PageEditor.vue` — CodeMirror + server-rendered preview over WebSocket `/_api/ws/editor`, previewing into the visible article; editor and article scrolls are linked piecewise-linearly, keyed on the section anchors' `data-line` (markdown source line the backend stamps on top-level anchored h1/h2s): the page follows the cursor (fractional, wrap-aware, scrolling only when the cursor's page position leaves the viewport, with an edge margin), the editor follows page scroll with a progress-based viewport anchor, applied instantly (the window keeps scrolling normally while any editor is open — the panel is fixed to the viewport's left edge, its top tracking the banner's bottom edge until the banner scrolls away — and the panel scrolls internally); anchored h2s carry their own edit pens that open the editor scrolled to that section; a format bar offers Markdown helpers — bold/italic/code/link/table/image upload (always block-level on a fresh blank-separated line of its own — a cursor on a non-empty line, e.g. inside an existing image tag, inserts after that line, never into it; always with an empty `""` caption, cursor inside the quotes), toggling fences (` ``` ` code blocks and `::: aside` containers share the same machinery: clicked inside one they remove it and select the content, otherwise they wrap the selection or the cursor's line, keeping it selected), and `.left`/`.right`/`.wide`/`.margin` placement toggles plus `.small`/`.large`/`.huge` text-size toggles (brace attributes on the block at the cursor, mutually exclusive within each group; on `:::` containers a placement class replaces the container name instead — `::: aside``::: margin`), with Ctrl/Cmd-B/I/S bindings — for the hard-to-remember syntax. Edits content and title only, never the path.
- `BannerEditor.vue` — per-page banner HTML + banner design selector, previewed into `#page-banner`, plus the page's card image (`Node.image`, inherited by the subtree): just an upload button and a ✕ clearing the node's own (back to inherit) — the label states which image is in use (none / inherited from … / set for this article, “used in /<path>/*” when it has children / mined from the article) and the card previews below show it. Below it, the site's own cards preview in both modes (small and large) with the real `.card` markup and styles from pagerite.css — theme variables included, they are the site's look — scaled down via font-size (the card internals are all em, so the layout proportions match real cards exactly); both render the effective card image (the resolved node image, else the image the server mines from the article), and the description appears only in the small format, like the backend's `_card`. The previews double as the card-mode selector for the per-article `Node.large` override: clicking one forces that mode (thin solid outline), clicking the selected one returns to automatic; under automatic the mode auto currently resolves to gets a dashed marker (both outlines — selection never shifts the layout), approximated from image presence only (the server's dimension probe is not available in the panel). - `BannerEditor.vue` — per-page banner HTML + banner design selector, previewed into `#page-banner`, plus the page's card image (`Node.image`, inherited by the subtree): just an upload button and a ✕ clearing the node's own (back to inherit) — the label states which image is in use (none / set for this article, “used in /<path>/*” when it has children / mined from the article / inherited from …) and the card previews below show it. Below it, the site's own cards preview in both modes (small and large) with the real `.card` markup and styles from pagerite.css — theme variables included, they are the site's look — scaled down via font-size (the card internals are all em, so the layout proportions match real cards exactly); both render the effective card image (the node's own, else the image the server mines from the article, else the inherited one), and the description appears only in the small format, like the backend's `_card`. The previews double as the card-mode selector for the per-article `Node.large` override: clicking one forces that mode (thin solid outline), clicking the selected one returns to automatic; under automatic the mode auto currently resolves to gets a dashed marker (both outlines — selection never shifts the layout), approximated from image presence only (the server's dimension probe is not available in the panel).
- `SiteEditor.vue` — site brand + optional custom brand HTML with image/video upload + theme selector + page-transition selector + font picker + favicon upload — clicking the preview tile picks a new one — + site-wide custom CSS, CSS injected into `<head id="pagerite-user">`. - `SiteEditor.vue` — site brand + optional custom brand HTML with image/video upload + theme selector + page-transition selector + font picker + favicon upload — clicking the preview tile picks a new one — + site-wide custom CSS, CSS injected into `<head id="pagerite-user">`.
- `StructureEditor.vue` — the vue-draggable structure tree with always-editable title/slug inputs per row and a per-row flag dropdown setting the page's primary language (`Node.language`, inherited by the subtree). - `StructureEditor.vue` — the vue-draggable structure tree with always-editable title/slug inputs per row and a per-row flag dropdown setting the page's primary language (`Node.language`, inherited by the subtree).
- `LocalizationEditor.vue` — the site-wide translation settings: target languages as a flag grid (toggles, grouped in geographic rows; see docs/localization.md), the refresh-all-translations button, and the translator service WebSocket URL(s) to connect `scripts/translator.py` to. - `LocalizationEditor.vue` — the site-wide translation settings: target languages as a flag grid (toggles, grouped in geographic rows; see docs/localization.md), the refresh-all-translations button, and the translator service WebSocket URL(s) to connect `scripts/translator.py` to.
+10 -6
View File
@@ -74,7 +74,8 @@ const image = ref('')
const imageResolved = ref('') const imageResolved = ref('')
const imageSource = ref('') const imageSource = ref('')
// The image the server would mine from the article itself — the card // The image the server would mine from the article itself — the card
// previews fall back to it when no node image resolves (mirrors og:image). // previews fall back to it when the node has no image of its own, and it
// beats an inherited one (mirrors og:image).
const imageMined = ref('') const imageMined = ref('')
// Whether the page has children (from the doc message): an own share // Whether the page has children (from the doc message): an own share
// image is inherited by the whole section. // image is inherited by the whole section.
@@ -86,22 +87,25 @@ const imageLabel = computed(() => {
? `card image: set for this article — used in /${path.value}/*` ? `card image: set for this article — used in /${path.value}/*`
: 'card image: set for this article' : 'card image: set for this article'
} }
if (imageMined.value) return 'card image: from the article'
if (imageResolved.value) { if (imageResolved.value) {
const where = imageSource.value === '' ? 'the front page' : `/${imageSource.value}` const where = imageSource.value === '' ? 'the front page' : `/${imageSource.value}`
return `card image: inherited from ${where}` return `card image: inherited from ${where}`
} }
if (imageMined.value) return 'card image: from the article'
return 'card image: none' return 'card image: none'
}) })
// The page title and description (from the doc message) feed the mock card // The page title and description (from the doc message) feed the mock card
// previews; empty shows placeholder bars / text instead. // previews; empty shows placeholder bars / text instead.
const pageTitle = ref('') const pageTitle = ref('')
const pageDesc = ref('') const pageDesc = ref('')
// The image the Twitter cards preview with: the resolved node card image, // The image the Twitter cards preview with: the node's own card image,
// else the mined article image (what og:image would use). image_resolved is // else the mined article image, else the inherited node image (what
// a bare store hash; image_mined is already a src path. // og:image would use). image/image_resolved are bare store hashes;
// image_mined is already a src path.
const cardImage = computed(() => const cardImage = computed(() =>
imageResolved.value ? `/_f/${imageResolved.value}` : imageMined.value, image.value
? `/_f/${image.value}`
: imageMined.value || (imageResolved.value ? `/_f/${imageResolved.value}` : ''),
) )
// Card-mode override (Node.large, per-article, NOT inherited): // Card-mode override (Node.large, per-article, NOT inherited):
// null = automatic, false = small, true = large. // null = automatic, false = small, true = large.
+2 -1
View File
@@ -505,7 +505,8 @@ async def editor_ws(ws: WebSocket) -> None:
"image_resolved": img, "image_resolved": img,
# The image the og:/twitter: heuristics would mine # The image the og:/twitter: heuristics would mine
# from the article itself ("" = none): the previews # from the article itself ("" = none): the previews
# show it when no node image resolves. # show it when the node has no image of its own
# (it beats an inherited one).
"image_mined": img_mined, "image_mined": img_mined,
"image_source": ( "image_source": (
"" if node is None or node.image else img_source "" if node is None or node.image else img_source
+35 -13
View File
@@ -1071,9 +1071,10 @@ def _card(
the small format). Imageless cards keep the image space blank (a the small format). Imageless cards keep the image space blank (a
gradient cover). gradient cover).
The cover is the page's resolved card image (Node.image, inheriting The cover is the node's own ``image`` when set, else mined from the
down the tree) when set, else mined from the rendered article like rendered article like og:image, else the inherited image (the nearest
og:image; the mode follows the same selection as twitter:card ancestor's or the front page's); the mode follows the same selection
as twitter:card
(_card_large: the node's override, else the image's dimensions). The (_card_large: the node's override, else the image's dimensions). The
card text localizes per target article where that page is available in card text localizes per target article where that page is available in
the language: the title comes from the translation's title map and the the language: the title comes from the translation's title map and the
@@ -1081,8 +1082,8 @@ def _card(
with per-card fallback to the original otherwise. with per-card fallback to the original otherwise.
""" """
image = html = "" image = html = ""
if name := card_image(menu, path)[0]: if node.image:
image = f"/_f/{name}" image = f"/_f/{node.image}"
if node.chunks and not image: if node.chunks and not image:
md = node_markdown(data, node) or "" md = node_markdown(data, node) or ""
if lang and lang in node.langs: if lang and lang in node.langs:
@@ -1097,6 +1098,12 @@ def _card(
directives={"cards": lambda _args, _env: ""}, directives={"cards": lambda _args, _env: ""},
).html ).html
image, _ = _media(html) image, _ = _media(html)
if not image:
# The node's own setting was empty: an inherited image applies
# only when the article itself yielded none (card_image with no
# own image set resolves to the nearest ancestor's/front page's).
if name := card_image(menu, path)[0]:
image = f"/_f/{name}"
large = _card_large(node, image) large = _card_large(node, image)
description = "" description = ""
if not large and node.chunks and not html: if not large and node.chunks and not html:
@@ -1227,16 +1234,18 @@ def _social_meta(
brand: str, brand: str,
base_url: str, base_url: str,
card: str = "", card: str = "",
card_fallback: str = "",
) -> dict[str, str]: ) -> dict[str, str]:
"""Open Graph/Twitter/SEO meta tags for a content page. """Open Graph/Twitter/SEO meta tags for a content page.
The card image is the node's own ``image`` setting when one resolves The card image is the node's own ``image`` setting (``card``) when
(``card``, see card_image — the nearest ancestor's or the front set; with none set, heuristics over the rendered article pick the
page's otherwise); with none set, heuristics over the rendered article first representative <img> (a {.hero} first, then raster, then SVG);
pick the first representative <img> (a {.hero} first, then raster, only when the article yields none does the inherited image
then SVG). The description is the first paragraph's text; the first (``card_fallback`` — the nearest ancestor's or the front page's, see
<video> yields og:video. Absolute URLs are built from the request's card_image) apply. The description is the first paragraph's text; the
base (social scrapers cannot use relative ones). first <video> yields og:video. Absolute URLs are built from the
request's base (social scrapers cannot use relative ones).
``twitter:image`` pins extension-less store links to the ``.webp`` ``twitter:image`` pins extension-less store links to the ``.webp``
variant: X only honors WebP via twitter:image (not og:image) and its variant: X only honors WebP via twitter:image (not og:image) and its
@@ -1252,6 +1261,8 @@ def _social_meta(
_, video = _card_media(html, base_url) _, video = _card_media(html, base_url)
else: else:
image, video = _card_media(html, base_url) image, video = _card_media(html, base_url)
if not image and card_fallback and base_url:
image = f"{base_url}/_f/{card_fallback}"
twitter_image = re.sub(r"(/_f/[0-9a-f]{12})$", r"\1.webp", image) if image else "" twitter_image = re.sub(r"(/_f/[0-9a-f]{12})$", r"\1.webp", image) if image else ""
large = _card_large(node, image) large = _card_large(node, image)
return { return {
@@ -1334,8 +1345,19 @@ def render_page(
lang = original lang = original
title = _title(path.rpartition("/")[2], node, translation, path) title = _title(path.rpartition("/")[2], node, translation, path)
main = page_content(menu, data, path, translation, link_lang, lang) main = page_content(menu, data, path, translation, link_lang, lang)
# The card image priority: the node's own setting, then mined from the
# article (inside _social_meta), then the inherited one. card_image
# resolves both: its source equals the path iff the node itself set it.
img, img_from = card_image(menu, path)
social = _social_meta( social = _social_meta(
node, path, title, str(main), brand, base_url, card_image(menu, path)[0] node,
path,
title,
str(main),
brand,
base_url,
card=img if img_from == path else "",
card_fallback="" if img_from == path else img,
) )
canonical, alternates = _language_urls(data, path, node, lang, original, base_url) canonical, alternates = _language_urls(data, path, node, lang, original, base_url)
return str( return str(