Card images: hierarchical setting, compact-card image, explicit card-mode override
This commit is contained in:
@@ -28,7 +28,7 @@ The worst case scenario when a hacker gains access to your admin accounts (say i
|
|||||||
|
|
||||||
**Theme just every part to your liking.** Themes, banner designs and page transitions are included — pick one from the site editor or copy a folder and make it yours. Several high quality fonts are included among with other assets: your site never phones a third party or us for anything. And if after all you need to customize, additional site and banner code may be provided by the admin panel.
|
**Theme just every part to your liking.** Themes, banner designs and page transitions are included — pick one from the site editor or copy a folder and make it yours. Several high quality fonts are included among with other assets: your site never phones a third party or us for anything. And if after all you need to customize, additional site and banner code may be provided by the admin panel.
|
||||||
|
|
||||||
**Search engines and social cards come free.** Every page gets a proper description, canonical link and Open Graph/Twitter card metadata derived from the article — including a share image picked from your own figures — without a single "SEO plugin". Category index pages, if you wish to have those, also get their sub pages shown automatically in card format.
|
**Search engines and social cards come free.** Every page gets a proper description, canonical link and Open Graph/Twitter card metadata derived from the article — including a card image picked from your own figures — without a single "SEO plugin". Category index pages, if you wish to have those, also get their sub pages shown automatically in card format.
|
||||||
|
|
||||||

|

|
||||||
_You can see your readers. Built-in analytics need no cookies and no third-party tracker: visits, referers, reading time and a live map of how people move between your pages, plus separate ledgers for crawlers and the abusers probing for wordpress PHP files — who are, of course, wasting their time here._
|
_You can see your readers. Built-in analytics need no cookies and no third-party tracker: visits, referers, reading time and a live map of how people move between your pages, plus separate ledgers for crawlers and the abusers probing for wordpress PHP files — who are, of course, wasting their time here._
|
||||||
|
|||||||
+2
-2
@@ -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 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 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}` share 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. 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 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).
|
||||||
|
|
||||||
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 share image (the same hero → first raster → first SVG heuristics as `og:image`) as a full-card cover with the title overlaid.
|
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`).
|
||||||
|
|
||||||
## `seed.py`
|
## `seed.py`
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ Files are content-addressed (blake3[:12] + extension) and stored **on disk** und
|
|||||||
|
|
||||||
`Node.banner_design` picks a banner design: a theme folder name whose `banner.css` styles it and whose `banner.html` (arbitrary markup: canvas + style + script) or `banner.svg` supplies the inline artwork (wrapped in `div[data-design]`); "" = explicitly no design, None = inherit (nearest ancestor, front page last, then the active theme's own design if it ships banner.css/banner.svg/banner.html). The design's banner.css lives in `<head>` (id `pagerite-banner`) between the theme and the custom CSS — a `<link>` in dev, an inline `<style>` in production.
|
`Node.banner_design` picks a banner design: a theme folder name whose `banner.css` styles it and whose `banner.html` (arbitrary markup: canvas + style + script) or `banner.svg` supplies the inline artwork (wrapped in `div[data-design]`); "" = explicitly no design, None = inherit (nearest ancestor, front page last, then the active theme's own design if it ships banner.css/banner.svg/banner.html). The design's banner.css lives in `<head>` (id `pagerite-banner`) between the theme and the custom CSS — a `<link>` in dev, an inline `<style>` in production.
|
||||||
|
|
||||||
|
## 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.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).
|
||||||
|
|
||||||
## Site settings
|
## Site settings
|
||||||
|
|
||||||
`Data.brand` is the site name (header link + `<title>` suffix), editable in the site editor via `/_api/settings`; empty = no header link and no `<title>` suffix.
|
`Data.brand` is the site name (header link + `<title>` suffix), editable in the site editor via `/_api/settings`; empty = no header link and no `<title>` suffix.
|
||||||
|
|||||||
@@ -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 share image as the cover, like the og tags, with the title overlaid); 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 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.
|
||||||
- **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).
|
||||||
|
|||||||
+2
-2
@@ -7,9 +7,9 @@ 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`.
|
- `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).
|
||||||
- `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, plus 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.
|
||||||
|
|
||||||
Media uploads everywhere use the image icon buttons (pasting into the editor works too). The article, banner and site-settings pens are shorthands that open the shell on the matching tab; once open, clicking a pen switches tabs (and retargets the editors to the current page) instead of closing/remounting. The close button in the tab bar closes the shell (deliberately NOT Escape — it fired too easily by accident); tabs have no close buttons of their own. Closing only HIDES the shell — the Vue app stays mounted, so page-editor state (unsaved text included) survives until a real page reload; the editor always follows the URL, so fetch-navigating with the shell open (or before re-opening it) retargets it to the new page — unsaved text is stashed per path for the session and restored when returning, cleared on save. Saving there is explicit (Ctrl+S) and refreshes the page regions in place. Admin panels never reload the page.
|
Media uploads everywhere use the image icon buttons (pasting into the editor works too). The article, banner and site-settings pens are shorthands that open the shell on the matching tab; once open, clicking a pen switches tabs (and retargets the editors to the current page) instead of closing/remounting. The close button in the tab bar closes the shell (deliberately NOT Escape — it fired too easily by accident); tabs have no close buttons of their own. Closing only HIDES the shell — the Vue app stays mounted, so page-editor state (unsaved text included) survives until a real page reload; the editor always follows the URL, so fetch-navigating with the shell open (or before re-opening it) retargets it to the new page — unsaved text is stashed per path for the session and restored when returning, cleared on save. Saving there is explicit (Ctrl+S) and refreshes the page regions in place. Admin panels never reload the page.
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
// Banner editor tab: per-page banner HTML and banner design, previewed into
|
// Banner editor tab: per-page banner HTML and banner design, previewed into
|
||||||
// the real #page-banner region. Close and tab switching live in EditorShell.
|
// the real #page-banner region, plus the page's card image (Node.image,
|
||||||
|
// inherited by the subtree — the effective one previews, dimmed when
|
||||||
|
// inherited). Close and tab switching live in EditorShell.
|
||||||
import { computed, onActivated, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, onActivated, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { EditorView, basicSetup } from 'codemirror'
|
import { EditorView, basicSetup } from 'codemirror'
|
||||||
import { Compartment, EditorState } from '@codemirror/state'
|
import { Compartment, EditorState } from '@codemirror/state'
|
||||||
@@ -23,6 +25,7 @@ const path = ref('')
|
|||||||
const banner = ref('')
|
const banner = ref('')
|
||||||
const saveError = ref('')
|
const saveError = ref('')
|
||||||
const fileInput = ref(null)
|
const fileInput = ref(null)
|
||||||
|
const imageInput = ref(null)
|
||||||
const bannerEl = ref(null)
|
const bannerEl = ref(null)
|
||||||
|
|
||||||
let ws = null
|
let ws = null
|
||||||
@@ -63,6 +66,91 @@ const bannerDesignInherited = ref('')
|
|||||||
// whose re-render must not race the save it triggers).
|
// whose re-render must not race the save it triggers).
|
||||||
let refreshOnSave = null
|
let refreshOnSave = null
|
||||||
|
|
||||||
|
// --- Card image (Node.image, '' = inherit, like the banner design) ------
|
||||||
|
// The node's own setting, the effective image after inheritance ("" =
|
||||||
|
// none) and which node supplied an inherited one ("" = the front page,
|
||||||
|
// "" also when own/none — mirrors bannerFrom).
|
||||||
|
const image = ref('')
|
||||||
|
const imageResolved = ref('')
|
||||||
|
const imageSource = ref('')
|
||||||
|
// 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).
|
||||||
|
const imageMined = ref('')
|
||||||
|
// Whether the page has children (from the doc message): an own share
|
||||||
|
// image is inherited by the whole section.
|
||||||
|
const hasChildren = ref(false)
|
||||||
|
// The block label states which image is currently in use.
|
||||||
|
const imageLabel = computed(() => {
|
||||||
|
if (image.value) {
|
||||||
|
return hasChildren.value
|
||||||
|
? `card image: set for this article — used in /${path.value}/*`
|
||||||
|
: 'card image: set for this article'
|
||||||
|
}
|
||||||
|
if (imageResolved.value) {
|
||||||
|
const where = imageSource.value === '' ? 'the front page' : `/${imageSource.value}`
|
||||||
|
return `card image: inherited from ${where}`
|
||||||
|
}
|
||||||
|
if (imageMined.value) return 'card image: from the article'
|
||||||
|
return 'card image: none'
|
||||||
|
})
|
||||||
|
// The page title and description (from the doc message) feed the mock card
|
||||||
|
// previews; empty shows placeholder bars / text instead.
|
||||||
|
const pageTitle = ref('')
|
||||||
|
const pageDesc = ref('')
|
||||||
|
// The image the Twitter cards preview with: the resolved node card image,
|
||||||
|
// else the mined article image (what og:image would use). image_resolved is
|
||||||
|
// a bare store hash; image_mined is already a src path.
|
||||||
|
const cardImage = computed(() =>
|
||||||
|
imageResolved.value ? `/_f/${imageResolved.value}` : imageMined.value,
|
||||||
|
)
|
||||||
|
// Card-mode override (Node.large, per-article, NOT inherited):
|
||||||
|
// null = automatic, false = small, true = large.
|
||||||
|
const large = ref(null)
|
||||||
|
// Approximation of the server's automatic pick for the "automatic"
|
||||||
|
// marker: the real check probes image dimensions (>= 600px wide,
|
||||||
|
// landscape-ish AR) server-side, unavailable here — presence of an
|
||||||
|
// effective image stands in for "large".
|
||||||
|
const autoLarge = computed(() => !!cardImage.value)
|
||||||
|
const effectiveLarge = computed(() => large.value ?? autoLarge.value)
|
||||||
|
|
||||||
|
function toggleCard(forced) {
|
||||||
|
// Clicking the already-selected card deselects back to automatic.
|
||||||
|
const msg = {
|
||||||
|
type: 'save',
|
||||||
|
path: normPath(path.value),
|
||||||
|
large: large.value === forced ? null : forced,
|
||||||
|
}
|
||||||
|
large.value = msg.large
|
||||||
|
pendingSave = msg
|
||||||
|
send(msg)
|
||||||
|
// twitter:card is part of the page head: re-render on ack.
|
||||||
|
refreshOnSave = rerender
|
||||||
|
}
|
||||||
|
function saveImage(hash) {
|
||||||
|
const msg = { type: 'save', path: normPath(path.value), image: hash }
|
||||||
|
pendingSave = msg
|
||||||
|
send(msg)
|
||||||
|
// The card image feeds the card previews, card covers and social meta:
|
||||||
|
// on ack re-open the doc (fresh image/image_resolved/image_source — the
|
||||||
|
// banner itself saves in real time, so nothing is lost) and re-render.
|
||||||
|
refreshOnSave = () => {
|
||||||
|
openPath(normPath(path.value))
|
||||||
|
rerender()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadCardImage(ev) {
|
||||||
|
// Card images go to the shared content store, like banner media.
|
||||||
|
const file = ev.target.files[0]
|
||||||
|
ev.target.value = '' // allow re-picking the same file
|
||||||
|
if (!file || !file.type.startsWith('image/')) return
|
||||||
|
const name = file.name.replace(/[^\w.-]/g, '-')
|
||||||
|
const res = await apiFetch(`/_api/files/${encodeURIComponent(name)}`, { method: 'PUT', body: file })
|
||||||
|
if (!res.ok) return
|
||||||
|
const { path: stored } = await res.json() // "/_f/<hash>[.ext]"
|
||||||
|
saveImage(stored.split('/').pop().split('.')[0])
|
||||||
|
}
|
||||||
|
|
||||||
// The inherit option names the design actually in effect and its source.
|
// The inherit option names the design actually in effect and its source.
|
||||||
const inheritLabel = computed(() => {
|
const inheritLabel = computed(() => {
|
||||||
if (bannerDesignFrom.value === null) {
|
if (bannerDesignFrom.value === null) {
|
||||||
@@ -234,6 +322,14 @@ function onMessage(ev) {
|
|||||||
bannerDesignFrom.value = msg.banner_design_from ?? null
|
bannerDesignFrom.value = msg.banner_design_from ?? null
|
||||||
bannerDesignInherited.value = msg.banner_design_inherited ?? ''
|
bannerDesignInherited.value = msg.banner_design_inherited ?? ''
|
||||||
bannerFrom.value = msg.banner_from ?? null
|
bannerFrom.value = msg.banner_from ?? null
|
||||||
|
image.value = msg.image ?? ''
|
||||||
|
imageResolved.value = msg.image_resolved ?? ''
|
||||||
|
imageMined.value = msg.image_mined ?? ''
|
||||||
|
imageSource.value = msg.image_source ?? ''
|
||||||
|
hasChildren.value = msg.has_children ?? false
|
||||||
|
large.value = msg.large ?? null
|
||||||
|
pageTitle.value = msg.title ?? ''
|
||||||
|
pageDesc.value = msg.description ?? ''
|
||||||
if (banner.value.trim()) previewBanner()
|
if (banner.value.trim()) previewBanner()
|
||||||
} else if (msg.type === 'saved') {
|
} else if (msg.type === 'saved') {
|
||||||
saveError.value = ''
|
saveError.value = ''
|
||||||
@@ -339,7 +435,66 @@ onUnmounted(() => {
|
|||||||
<div v-if="saveError">{{ saveError }}</div>
|
<div v-if="saveError">{{ saveError }}</div>
|
||||||
<ConnNote :text="connNote" />
|
<ConnNote :text="connNote" />
|
||||||
|
|
||||||
<section class="block" @paste="onBannerPaste">
|
<section class="block card-image">
|
||||||
|
<div class="block-head">
|
||||||
|
<span class="block-label">{{ imageLabel }}</span>
|
||||||
|
<button
|
||||||
|
v-if="image"
|
||||||
|
type="button"
|
||||||
|
class="icon-btn del"
|
||||||
|
title="clear the card image (back to inherit)"
|
||||||
|
@click="saveImage('')"
|
||||||
|
>❌</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="icon-btn"
|
||||||
|
title="upload card image (og:image / card covers) — the subtree inherits it"
|
||||||
|
@click="imageInput.click()"
|
||||||
|
>🖼︎</button>
|
||||||
|
<input
|
||||||
|
ref="imageInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
hidden
|
||||||
|
@change="uploadCardImage"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- The site's own cards double as the card-mode selector: rendered
|
||||||
|
with the real .card styles from pagerite.css (theme variables
|
||||||
|
and all — they ARE the site's look). Clicking one forces that
|
||||||
|
mode (Node.large), clicking the selected one returns to
|
||||||
|
automatic. The description only exists in the small format,
|
||||||
|
like the backend's _card. -->
|
||||||
|
<div class="site-previews">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="card compact preview"
|
||||||
|
:class="{ selected: large === false, auto: large === null && !effectiveLarge }"
|
||||||
|
title="small card — click to force it, click again for automatic"
|
||||||
|
@click="toggleCard(false)"
|
||||||
|
>
|
||||||
|
<span class="top">
|
||||||
|
<img v-if="cardImage" class="cover" :src="cardImage" alt="" />
|
||||||
|
<span class="title">{{ pageTitle || 'page title' }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="bottom">
|
||||||
|
<span v-if="pageDesc" class="desc">{{ pageDesc }}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="card preview"
|
||||||
|
:class="{ selected: large === true, auto: large === null && effectiveLarge }"
|
||||||
|
title="large card — click to force it, click again for automatic"
|
||||||
|
@click="toggleCard(true)"
|
||||||
|
>
|
||||||
|
<span class="cover" :style="cardImage ? `background-image: url('${cardImage}')` : null" />
|
||||||
|
<span class="title">{{ pageTitle || 'page title' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="block banner-block" @paste="onBannerPaste">
|
||||||
<div class="block-head">
|
<div class="block-head">
|
||||||
<select
|
<select
|
||||||
v-model="bannerDesign"
|
v-model="bannerDesign"
|
||||||
@@ -356,7 +511,7 @@ onUnmounted(() => {
|
|||||||
class="icon-btn"
|
class="icon-btn"
|
||||||
title="upload banner image/video (replaces existing media) — pasting works too"
|
title="upload banner image/video (replaces existing media) — pasting works too"
|
||||||
@click="fileInput.click()"
|
@click="fileInput.click()"
|
||||||
>🖼️</button>
|
>🖼︎</button>
|
||||||
<input
|
<input
|
||||||
ref="fileInput"
|
ref="fileInput"
|
||||||
type="file"
|
type="file"
|
||||||
@@ -385,10 +540,61 @@ onUnmounted(() => {
|
|||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-block {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The card-image section: a real preview of the effective image (the
|
||||||
|
node's own or the inherited one, dimmed then), with upload/clear in the
|
||||||
|
head row like the banner media button. */
|
||||||
|
.card-image {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The site's own card previews: real .card markup/styles from pagerite.css,
|
||||||
|
scaled down via font-size (the card internals are all em, so the layout
|
||||||
|
proportions match the real cards exactly). They double as the card-mode
|
||||||
|
selector: thin outlines only (no border changes, so selecting never
|
||||||
|
shifts the layout) — solid accent for a forced mode, dashed muted for
|
||||||
|
the mode "automatic" currently resolves to (approximated from image
|
||||||
|
presence). */
|
||||||
|
.site-previews {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.8rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-previews .card.preview {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.67rem;
|
||||||
|
width: 24em;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
text-align: start;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-previews .card.preview.selected {
|
||||||
|
outline: 1px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-previews .card.preview.auto:not(.selected) {
|
||||||
|
outline: 1px dashed var(--muted);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.block-head {
|
.block-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -401,11 +607,16 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.block-head .icon-btn {
|
.block-head .icon-btn {
|
||||||
margin-left: auto;
|
|
||||||
padding: 0 0.2rem;
|
padding: 0 0.2rem;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The first icon button pushes itself (and any siblings after it, like
|
||||||
|
the card-image clear button) to the end of the row. */
|
||||||
|
.block-head .icon-btn:first-of-type {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
/* The banner design selector stays compact; the upload button is pushed
|
/* The banner design selector stays compact; the upload button is pushed
|
||||||
right by its auto margin. */
|
right by its auto margin. */
|
||||||
.design-select {
|
.design-select {
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
// Recursive site-structure tree with drag-and-drop ordering (vue-draggable).
|
// Recursive site-structure tree with drag-and-drop ordering (vue-draggable).
|
||||||
// Nodes come from the server (GET /_api/pages via StructureEditor.vue) as
|
// Nodes come from the server (GET /_api/pages via StructureEditor.vue) as
|
||||||
// {slug, path, title, translated, order, published, has_content, language,
|
// {slug, path, title, translated, order, published, has_content, language,
|
||||||
// primary, children}. The row's flag (LangSelect) sets the node's primary
|
// primary, children}. The row's flag
|
||||||
// language (language; '' = inherit — dimmed, showing the resolved flag);
|
// (LangSelect) sets the node's primary language (language; '' = inherit —
|
||||||
// the setting covers the whole subtree.
|
// dimmed, showing the resolved flag); the setting covers the whole subtree.
|
||||||
// With a `lang` prop (StructureEditor's language strip) the titles shown
|
// With a `lang` prop (StructureEditor's language strip) the titles shown
|
||||||
// are that language's; `translated` marks rows with an actual translation
|
// are that language's; `translated` marks rows with an actual translation
|
||||||
// (untranslated rows show the original title, dimmed).
|
// (untranslated rows show the original title, dimmed).
|
||||||
|
|||||||
@@ -287,7 +287,7 @@ body {
|
|||||||
|
|
||||||
#nav span {
|
#nav span {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.95rem;
|
font-size: 0.95em;
|
||||||
}
|
}
|
||||||
|
|
||||||
#nav .current {
|
#nav .current {
|
||||||
@@ -485,16 +485,25 @@ main {
|
|||||||
404) lists its published children after the markdown content — one
|
404) lists its published children after the markdown content — one
|
||||||
card per child (a child without a page of its own is represented by
|
card per child (a child without a page of its own is represented by
|
||||||
its first leaf page; see _cards in views.py). The row bleeds to full
|
its first leaf page; see _cards in views.py). The row bleeds to full
|
||||||
page width (div.wide): the cards first grow to fill it, then shrink
|
page width (div.wide); every card has the same fixed 16/10 shape,
|
||||||
rather than wrap. Every card has the same fixed 16/10 shape, covered
|
scaling only with the available width. Cards come in two modes
|
||||||
entirely by the
|
following the child's twitter:card selection (_card_large — the
|
||||||
page's share image (og:image heuristics, as a background — a gradient
|
per-article override, else the card image's dimensions): large cards
|
||||||
placeholder when it has none) with the title overlaid on a translucent
|
are covered entirely by the page's card image (og:image heuristics, as
|
||||||
band at the bottom. The card is one <a> holding only phrasing-level
|
a background — a gradient placeholder when it has none) with the title
|
||||||
spans; the spans lay out as blocks. */
|
overlaid on a translucent band at the bottom; small cards (.card.compact)
|
||||||
|
split at the golden ratio: a square cover in the top part with the
|
||||||
|
title beside it, the article description below. The card is one <a>
|
||||||
|
holding only phrasing-level spans; the
|
||||||
|
spans lay out as blocks. */
|
||||||
|
/* Equal-width columns (grid, not flex: no differential shrink, no
|
||||||
|
cross-axis stretch — every card keeps the fixed 16/10 shape, scaling
|
||||||
|
only with the available width). Cards stop growing at their cap; the
|
||||||
|
row centers in the bleed then. */
|
||||||
.cards {
|
.cards {
|
||||||
display: flex;
|
display: grid;
|
||||||
/* Cards stop growing at their cap; center the row in the bleed then. */
|
grid-auto-flow: column;
|
||||||
|
grid-auto-columns: minmax(0, 24em);
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
margin-top: 2.5rem;
|
margin-top: 2.5rem;
|
||||||
@@ -503,39 +512,36 @@ main {
|
|||||||
padding-inline: 1.25rem;
|
padding-inline: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Phones: the cards stack vertically instead of shrinking to slivers.
|
/* Phones: the cards stack vertically instead of shrinking to slivers. */
|
||||||
Text-only cards (gradient cover + description) then fit their content —
|
|
||||||
the fixed 16/10 shape only makes sense for image covers. */
|
|
||||||
@media (max-width: 48rem) {
|
@media (max-width: 48rem) {
|
||||||
.cards {
|
.cards {
|
||||||
flex-direction: column;
|
grid-auto-flow: row;
|
||||||
}
|
grid-auto-columns: unset;
|
||||||
|
grid-template-columns: minmax(0, 24em);
|
||||||
.card:has(.desc) {
|
|
||||||
aspect-ratio: auto;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
/* Grow to fill the row (up to the cap — full-width rows would make huge
|
/* Fill the (equal-width) grid column; the height comes only from the
|
||||||
cards), shrink (not wrap) when there are too many. */
|
fixed aspect ratio (align-self: start — the row must not stretch it).
|
||||||
flex: 1 1 0;
|
Overflowing content is clipped. */
|
||||||
max-width: 24rem;
|
width: 100%;
|
||||||
|
align-self: start;
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
aspect-ratio: 16 / 10;
|
aspect-ratio: 16 / 10;
|
||||||
/* Allow shrinking below the text's min-content width: without this the
|
|
||||||
text cards hold their row wider than the image-only cards. */
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5em;
|
||||||
background: var(--surface);
|
/* Lifted from the page background (a touch of the text color) so cards
|
||||||
|
read as distinct surfaces. */
|
||||||
|
background: color-mix(var(--surface) 88%, var(--text));
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: transform 0.2s, box-shadow 0.2s;
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
box-shadow: 0 0 0.1rem black;
|
box-shadow: 0 0 0.1em black;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card:hover {
|
.card:hover {
|
||||||
@@ -555,16 +561,22 @@ main {
|
|||||||
|
|
||||||
.card .title {
|
.card .title {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: block;
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
overflow: hidden;
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
padding: 0.75rem 1.25rem 0.9rem;
|
padding: 0.75em 1.25em 0.9em;
|
||||||
background: color-mix(var(--bg) 40%, transparent);
|
background: color-mix(var(--bg) 65%, transparent);
|
||||||
/* Pinned: the article a:hover accent must not leak through the card. */
|
/* Pinned: the article a:hover accent must not leak through the card. */
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: var(--font-heading);
|
font-family: var(--font-heading);
|
||||||
font-size: 1.25rem;
|
font-size: 1.25em;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
|
/* Hyphenation needs the card's lang (the target article's language). */
|
||||||
|
hyphens: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Image-less cards carry the description under the title, extending the
|
/* Image-less cards carry the description under the title, extending the
|
||||||
@@ -575,19 +587,112 @@ main {
|
|||||||
|
|
||||||
.card .desc {
|
.card .desc {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: block;
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 4;
|
||||||
|
line-clamp: 4;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
/* Four lines exactly: the text itself is truncated server-side (_cards),
|
/* Four lines exactly, with an ellipsis on overflow: the text itself is
|
||||||
this is just the safety net. max-height spans the lines plus the
|
truncated server-side (_cards), this is just the safety net. */
|
||||||
vertical padding (border-box), so nothing bleeds past the clip. */
|
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
max-height: calc(4 * 1.4em + 1.3rem);
|
padding: 0.4em 1.25em 0.9em;
|
||||||
padding: 0.4rem 1.25rem 0.9rem;
|
background: color-mix(var(--bg) 55%, transparent);
|
||||||
background: color-mix(var(--bg) 30%, transparent);
|
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.95rem;
|
font-size: 0.95em;
|
||||||
text-align: start;
|
text-align: start;
|
||||||
hyphens: none;
|
hyphens: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Small cards (_card_large false; the twitter "summary" style): externally
|
||||||
|
the same 16/10 shape as the large card, split horizontally in two
|
||||||
|
sub-grids at the golden ratio — the top part (the image, its full
|
||||||
|
height, with the title beside it at the bottom) takes φ, the bottom
|
||||||
|
part (the description — which only the small format carries — above
|
||||||
|
spare space) takes 1, so the image's bottom edge sits at ~62% of the
|
||||||
|
card instead of centering wherever the differing title/description
|
||||||
|
sizes land it. The title and description carry the translucent band
|
||||||
|
(the same band color as the large cards' title) as their own
|
||||||
|
backgrounds. Imageless cards are just the gradient with the title
|
||||||
|
spanning the full width. */
|
||||||
|
.card.compact {
|
||||||
|
display: grid;
|
||||||
|
/* minmax(0, …): the parts never grow past the card — overlong text is
|
||||||
|
line-clamped (title/description) instead of pushing the layout. */
|
||||||
|
grid-template-rows: minmax(0, 1.618fr) minmax(0, 1fr);
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
color-mix(var(--surface) 88%, var(--text)),
|
||||||
|
var(--bg)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top part: spare space above the title row. The image column is as wide
|
||||||
|
as the top part is tall — the card is 16/10 and the top is φ/(φ+1) of
|
||||||
|
its height, i.e. ~38.6% of its width — so a square image at that width
|
||||||
|
fills the whole top part. */
|
||||||
|
.card.compact .top {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 38.6% 1fr;
|
||||||
|
grid-template-rows: minmax(0, 1fr) auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The image fills its whole grid area (column 1, both rows); cover crops
|
||||||
|
rather than letterboxes. */
|
||||||
|
.card.compact img.cover {
|
||||||
|
position: static;
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1 / 3;
|
||||||
|
z-index: 2;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
object-fit: cover;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.compact .title {
|
||||||
|
/* Full width: the translucent band runs behind the image to the left
|
||||||
|
edge (the image paints over it, z-index 2), the text indented past
|
||||||
|
the image column. */
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-row: 2;
|
||||||
|
z-index: 1;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.4em 0.6em 0 calc(38.6% + 0.6em);
|
||||||
|
background: color-mix(var(--bg) 65%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* No image: no indentation, the text starts at the left edge. */
|
||||||
|
.card.compact .top:not(:has(img.cover)) .title {
|
||||||
|
padding-left: 0.6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* No description: the title supplies the band's bottom padding. */
|
||||||
|
.card.compact:not(:has(.desc)) .title {
|
||||||
|
padding-bottom: 0.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bottom part: the description above spare space. */
|
||||||
|
.card.compact .bottom {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.compact .desc {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-row: 1;
|
||||||
|
z-index: 1;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.4em 0.6em;
|
||||||
|
background: color-mix(var(--bg) 65%, transparent);
|
||||||
|
/* Three lines fit the bottom part (⅜ of the card) even at the card cap;
|
||||||
|
the line clamp adds the ellipsis past that. */
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
line-clamp: 3;
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
article h1,
|
article h1,
|
||||||
@@ -786,7 +891,7 @@ article {
|
|||||||
/* pagerite.js tucks the pen at the end of the article's first h1. */
|
/* pagerite.js tucks the pen at the end of the article's first h1. */
|
||||||
article h1 .edit-link {
|
article h1 .edit-link {
|
||||||
position: static;
|
position: static;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1em;
|
||||||
vertical-align: 0.3em;
|
vertical-align: 0.3em;
|
||||||
margin-inline-start: 0.4rem;
|
margin-inline-start: 0.4rem;
|
||||||
}
|
}
|
||||||
@@ -1431,7 +1536,7 @@ article figure img {
|
|||||||
max-width: 65ch;
|
max-width: 65ch;
|
||||||
padding: 0.8rem 1rem;
|
padding: 0.8rem 1rem;
|
||||||
color: var(--lightbox-text);
|
color: var(--lightbox-text);
|
||||||
font-size: 0.95rem;
|
font-size: 0.95em;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|||||||
+66
-2
@@ -10,6 +10,7 @@ server-generated key in the path is the access control).
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from fastapi import (
|
from fastapi import (
|
||||||
@@ -403,12 +404,16 @@ async def editor_ws(ws: WebSocket) -> None:
|
|||||||
Stateless protocol (each message carries the path):
|
Stateless protocol (each message carries the path):
|
||||||
<- {"type": "open", "path", "lang"?}
|
<- {"type": "open", "path", "lang"?}
|
||||||
-> {"type": "doc", "path", "exists", "title", "markdown", "published",
|
-> {"type": "doc", "path", "exists", "title", "markdown", "published",
|
||||||
"banner", "banner_design", "lang", "primary_lang", "langs",
|
"banner", "banner_design", "banner_from", "banner_design_from",
|
||||||
|
"banner_design_inherited", "description", "image", "image_resolved",
|
||||||
|
"image_mined", "image_source", "has_children", "large", "lang",
|
||||||
|
"primary_lang", "langs",
|
||||||
"translate_langs"}
|
"translate_langs"}
|
||||||
<- {"type": "render", "path", "markdown"}
|
<- {"type": "render", "path", "markdown"}
|
||||||
-> {"type": "html", "path", "html"}
|
-> {"type": "html", "path", "html"}
|
||||||
<- {"type": "save", "path", "title"?, "markdown"?, "published"?,
|
<- {"type": "save", "path", "title"?, "markdown"?, "published"?,
|
||||||
"banner"?, "banner_design"?, "move_from"?, "lang"?, "base"?}
|
"banner"?, "banner_design"?, "image"?, "large"?, "move_from"?,
|
||||||
|
"lang"?, "base"?}
|
||||||
(absent fields keep their old values; move_from: rename/move a
|
(absent fields keep their old values; move_from: rename/move a
|
||||||
page, subtree included)
|
page, subtree included)
|
||||||
-> {"type": "saved", "path"} | {"type": "error", "detail"}
|
-> {"type": "saved", "path"} | {"type": "error", "detail"}
|
||||||
@@ -452,6 +457,16 @@ async def editor_ws(ws: WebSocket) -> None:
|
|||||||
# original (docs/localization.md editor flow).
|
# original (docs/localization.md editor flow).
|
||||||
markdown = i18n.hybrid_markdown(data, node, path, lang)
|
markdown = i18n.hybrid_markdown(data, node, path, lang)
|
||||||
title = i18n.title_map(data, lang).get(path) or title
|
title = i18n.title_map(data, lang).get(path) or title
|
||||||
|
# The node's card image: its own setting ("" = inherit),
|
||||||
|
# the effective one after inheritance ("" = none) and
|
||||||
|
# which node supplied an inherited one ("" = front page;
|
||||||
|
# "" also when own/none — mirrors banner_from).
|
||||||
|
img, img_source = views.card_image(data.menu, path)
|
||||||
|
# The card preview's description and mined image,
|
||||||
|
# from the same rendered-article heuristics as the
|
||||||
|
# og:/twitter: meta (_description, _media).
|
||||||
|
html = render(markdown, path).html if markdown else ""
|
||||||
|
img_mined = views._media(html)[0] if html else ""
|
||||||
await ws.send_json(
|
await ws.send_json(
|
||||||
{
|
{
|
||||||
"type": "doc",
|
"type": "doc",
|
||||||
@@ -481,6 +496,23 @@ async def editor_ws(ws: WebSocket) -> None:
|
|||||||
if src is not None
|
if src is not None
|
||||||
else views.theme_banner_design(data.theme)
|
else views.theme_banner_design(data.theme)
|
||||||
),
|
),
|
||||||
|
"description": views._description(html) if html else "",
|
||||||
|
"image": node.image if node else "",
|
||||||
|
# For the banner panel's image label ("…used in
|
||||||
|
# /<path>/*"): the subtree inherits it.
|
||||||
|
"has_children": bool(node.children) if node else False,
|
||||||
|
"image_resolved": img,
|
||||||
|
# The image the og:/twitter: heuristics would mine
|
||||||
|
# from the article itself ("" = none): the previews
|
||||||
|
# show it when no node image resolves.
|
||||||
|
"image_mined": img_mined,
|
||||||
|
"image_source": (
|
||||||
|
"" if node is None or node.image else img_source
|
||||||
|
),
|
||||||
|
# Card-mode override (per-article, not
|
||||||
|
# inherited): null = automatic, otherwise
|
||||||
|
# false = small, true = large.
|
||||||
|
"large": node.large if node else None,
|
||||||
# Language context for the editor's picker: the
|
# Language context for the editor's picker: the
|
||||||
# language this Markdown represents ("" = primary),
|
# language this Markdown represents ("" = primary),
|
||||||
# the page's own primary language, the translations
|
# the page's own primary language, the translations
|
||||||
@@ -609,6 +641,30 @@ async def editor_ws(ws: WebSocket) -> None:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
image = msg.get("image")
|
||||||
|
if image is not None:
|
||||||
|
# Card-image setting (inherited by the subtree): a
|
||||||
|
# 12-hex content-addressed store name, "" = inherit.
|
||||||
|
image = str(image).strip()
|
||||||
|
if image and not re.fullmatch(r"[0-9a-f]{12}", image):
|
||||||
|
await ws.send_json(
|
||||||
|
{
|
||||||
|
"type": "error",
|
||||||
|
"detail": "image must be a store file name",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
large = msg.get("large")
|
||||||
|
if "large" in msg and not (large is None or isinstance(large, bool)):
|
||||||
|
# Card-mode override: null = automatic, true =
|
||||||
|
# large, false = small.
|
||||||
|
await ws.send_json(
|
||||||
|
{
|
||||||
|
"type": "error",
|
||||||
|
"detail": "large must be null or a boolean",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
with kanta.transaction(
|
with kanta.transaction(
|
||||||
f"page:{lang}" if translated else "page",
|
f"page:{lang}" if translated else "page",
|
||||||
user=ws.headers.get("remote-user"),
|
user=ws.headers.get("remote-user"),
|
||||||
@@ -671,6 +727,14 @@ async def editor_ws(ws: WebSocket) -> None:
|
|||||||
node.banner = msg["banner"]
|
node.banner = msg["banner"]
|
||||||
if "banner_design" in msg:
|
if "banner_design" in msg:
|
||||||
node.banner_design = msg["banner_design"]
|
node.banner_design = msg["banner_design"]
|
||||||
|
if image is not None:
|
||||||
|
# Part of every render's social meta and card
|
||||||
|
# covers: a change invalidates everywhere.
|
||||||
|
node.image = image
|
||||||
|
if "large" in msg:
|
||||||
|
# Per-article card-mode override (not
|
||||||
|
# inherited); None = automatic.
|
||||||
|
node.large = large
|
||||||
node.modified = datetime.now(UTC)
|
node.modified = datetime.now(UTC)
|
||||||
_invalidate_pages()
|
_invalidate_pages()
|
||||||
await ws.send_json({"type": "saved", "path": path})
|
await ws.send_json({"type": "saved", "path": path})
|
||||||
|
|||||||
@@ -63,6 +63,16 @@ class Node(msgspec.Struct, omit_defaults=True):
|
|||||||
#: "" = explicitly no design, None = inherit (nearest ancestor, front
|
#: "" = explicitly no design, None = inherit (nearest ancestor, front
|
||||||
#: page last, then the active theme's own design).
|
#: page last, then the active theme's own design).
|
||||||
banner_design: str | None = None
|
banner_design: str | None = None
|
||||||
|
#: Content-addressed card image name (served at "/_f/{name}") for
|
||||||
|
#: og:image/twitter:image and card covers. "" inherits the nearest
|
||||||
|
#: ancestor's image, the front page last; unset everywhere falls back
|
||||||
|
#: to mining the rendered article.
|
||||||
|
image: str = ""
|
||||||
|
#: Card-mode override (site cards + twitter:card): None = pick
|
||||||
|
#: automatically from the card image's dimensions, False forces a
|
||||||
|
#: small card, True a large one. Per-article only — NOT inherited
|
||||||
|
#: down the tree (unlike image).
|
||||||
|
large: bool | None = None
|
||||||
published: bool = True
|
published: bool = True
|
||||||
children: dict[str, "Node"] = {}
|
children: dict[str, "Node"] = {}
|
||||||
created: datetime = msgspec.field(
|
created: datetime = msgspec.field(
|
||||||
|
|||||||
+156
-33
@@ -780,6 +780,57 @@ def banner_source(menu: dict[str, Node], path: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def card_image(menu: dict[str, Node], path: str) -> tuple[str, str]:
|
||||||
|
"""The effective card image at ``path`` and which node supplied it.
|
||||||
|
|
||||||
|
Nearest ancestor with ``image`` set wins (the node itself first), the
|
||||||
|
front page — a top-level sibling of the chain — last. ("", "") when no
|
||||||
|
node sets one: rendering falls back to mining the article HTML. The
|
||||||
|
source path ("" = front page) feeds the editor banner panel's inherit
|
||||||
|
label.
|
||||||
|
"""
|
||||||
|
chain = resolve(menu, path) or []
|
||||||
|
segs = path.split("/")
|
||||||
|
for i in range(len(chain) - 1, -1, -1):
|
||||||
|
if chain[i].image:
|
||||||
|
return chain[i].image, "/".join(segs[: i + 1])
|
||||||
|
front = menu.get("")
|
||||||
|
if front and front.image:
|
||||||
|
return front.image, ""
|
||||||
|
return "", ""
|
||||||
|
|
||||||
|
|
||||||
|
_image_dims_cache: dict[str, tuple[int, int] | None] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _image_dims(name: str) -> tuple[int, int] | None:
|
||||||
|
"""(width, height) of a stored card image, None when unknown.
|
||||||
|
|
||||||
|
Probed from the ``<hash>.webp`` derivative via pyvips, cached per hash
|
||||||
|
(store contents are immutable). Failures (missing file, undecodable)
|
||||||
|
cache None — callers fall back to presence-based heuristics.
|
||||||
|
"""
|
||||||
|
if name in _image_dims_cache:
|
||||||
|
return _image_dims_cache[name]
|
||||||
|
dims = _probe_dims(name)
|
||||||
|
_image_dims_cache[name] = dims
|
||||||
|
return dims
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_dims(name: str) -> tuple[int, int] | None:
|
||||||
|
try:
|
||||||
|
from pagerite.files import file_store
|
||||||
|
|
||||||
|
if not (entry := file_store.get(f"{name}.webp")):
|
||||||
|
return None
|
||||||
|
import pyvips
|
||||||
|
|
||||||
|
img = pyvips.Image.new_from_buffer(entry[0], "")
|
||||||
|
return img.width, img.height
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def page_content(
|
def page_content(
|
||||||
menu: dict[str, Node],
|
menu: dict[str, Node],
|
||||||
data: Data,
|
data: Data,
|
||||||
@@ -877,7 +928,7 @@ def _cards(
|
|||||||
breakout): the cards grow to fill the page and shrink rather than
|
breakout): the cards grow to fill the page and shrink rather than
|
||||||
wrap. A child without a page of its own is represented by its first
|
wrap. A child without a page of its own is represented by its first
|
||||||
leaf page (_represent, the nav-link logic). Each card is one <a>
|
leaf page (_represent, the nav-link logic). Each card is one <a>
|
||||||
showing the page's share
|
showing the page's card
|
||||||
image (the same heuristics as og:image) as the cover and its title;
|
image (the same heuristics as og:image) as the cover and its title;
|
||||||
image-less cards get a gradient cover and also show the description.
|
image-less cards get a gradient cover and also show the description.
|
||||||
Only phrasing-level elements (spans) go inside the <a>: as a formatting
|
Only phrasing-level elements (spans) go inside the <a>: as a formatting
|
||||||
@@ -895,7 +946,7 @@ def _cards(
|
|||||||
return
|
return
|
||||||
with doc.div(class_="cards wide"):
|
with doc.div(class_="cards wide"):
|
||||||
for cpath, cnode in items:
|
for cpath, cnode in items:
|
||||||
_card(doc, data, cnode, cpath, translation, link_lang, lang)
|
_card(doc, menu, data, cnode, cpath, translation, link_lang, lang)
|
||||||
|
|
||||||
|
|
||||||
#: A lone {cards} or {cards: ...} line in the markdown: card rows placed
|
#: A lone {cards} or {cards: ...} line in the markdown: card rows placed
|
||||||
@@ -964,7 +1015,7 @@ def _cards_tag(
|
|||||||
doc = E.div(class_="cards wide")
|
doc = E.div(class_="cards wide")
|
||||||
with doc:
|
with doc:
|
||||||
for cpath, cnode in items:
|
for cpath, cnode in items:
|
||||||
_card(doc, data, cnode, cpath, translation, link_lang, lang)
|
_card(doc, menu, data, cnode, cpath, translation, link_lang, lang)
|
||||||
return str(doc)
|
return str(doc)
|
||||||
|
|
||||||
|
|
||||||
@@ -979,8 +1030,31 @@ def _walk(node: Node, path: str):
|
|||||||
yield from _walk(child, f"{path}/{slug}")
|
yield from _walk(child, f"{path}/{slug}")
|
||||||
|
|
||||||
|
|
||||||
|
def _card_large(node: Node, image: str) -> bool:
|
||||||
|
"""Whether the card renders large (True) or small (False).
|
||||||
|
|
||||||
|
Automatic: large when the image's probed store dimensions suit a large
|
||||||
|
card (>= 600px wide, landscape-ish aspect 1.4–2.5), small for
|
||||||
|
small/portrait images — and, when dimensions are unknown or the image
|
||||||
|
is external, for any present image. The node's ``large`` setting
|
||||||
|
(per-article, not inherited) overrides the automatic pick; None
|
||||||
|
means automatic. Shared by twitter:card (_social_meta, which maps it
|
||||||
|
to "summary_large_image"/"summary") and the site's own cards (_card).
|
||||||
|
"""
|
||||||
|
large = bool(image)
|
||||||
|
if (m := re.search(r"/_f/([0-9a-f]{12})$", image)) and (
|
||||||
|
dims := _image_dims(m.group(1))
|
||||||
|
):
|
||||||
|
w, h = dims
|
||||||
|
large = w >= 600 and h > 0 and 1.4 <= w / h <= 2.5
|
||||||
|
if node.large is not None:
|
||||||
|
large = node.large
|
||||||
|
return large
|
||||||
|
|
||||||
|
|
||||||
def _card(
|
def _card(
|
||||||
doc,
|
doc,
|
||||||
|
menu: dict[str, Node],
|
||||||
data: Data,
|
data: Data,
|
||||||
node: Node,
|
node: Node,
|
||||||
path: str,
|
path: str,
|
||||||
@@ -988,16 +1062,25 @@ def _card(
|
|||||||
link_lang: str = "",
|
link_lang: str = "",
|
||||||
lang: str = "",
|
lang: str = "",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""One card: cover + title, plus the description when the
|
"""One card: large mode is a full-card cover with the title overlaid;
|
||||||
page has no image (its card shows a gradient cover instead).
|
small mode a square cover in the golden-ratio top part with the title
|
||||||
|
beside it and the description below (the description only exists in
|
||||||
|
the small format). Imageless cards keep the image space blank (a
|
||||||
|
gradient cover).
|
||||||
|
|
||||||
The card text localizes per target article where that page is
|
The cover is the page's resolved card image (Node.image, inheriting
|
||||||
available in the language: the title comes from the translation's
|
down the tree) when set, else mined from the rendered article like
|
||||||
title map and the cover/description heuristics run on the target's
|
og:image; the mode follows the same selection as twitter:card
|
||||||
hybrid Markdown — with per-card fallback to the original otherwise.
|
(_card_large: the node's override, else the image's dimensions). The
|
||||||
|
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
|
||||||
|
cover/description heuristics run on the target's hybrid Markdown —
|
||||||
|
with per-card fallback to the original otherwise.
|
||||||
"""
|
"""
|
||||||
image = description = ""
|
image = html = ""
|
||||||
if node.chunks:
|
if name := card_image(menu, path)[0]:
|
||||||
|
image = f"/_f/{name}"
|
||||||
|
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:
|
||||||
md = i18n.hybrid_markdown(data, node, path, lang)
|
md = i18n.hybrid_markdown(data, node, path, lang)
|
||||||
@@ -1011,18 +1094,47 @@ def _card(
|
|||||||
directives={"cards": lambda _args, _env: ""},
|
directives={"cards": lambda _args, _env: ""},
|
||||||
).html
|
).html
|
||||||
image, _ = _media(html)
|
image, _ = _media(html)
|
||||||
if not image:
|
large = _card_large(node, image)
|
||||||
description = _description(html, 150)
|
description = ""
|
||||||
with doc.a(href=_href(path, link_lang), class_="card"):
|
if not large and node.chunks and not html:
|
||||||
|
md = node_markdown(data, node) or ""
|
||||||
|
if lang and lang in node.langs:
|
||||||
|
md = i18n.hybrid_markdown(data, node, path, lang)
|
||||||
|
html = render(
|
||||||
|
md,
|
||||||
|
path,
|
||||||
|
node.created,
|
||||||
|
node.modified,
|
||||||
|
directives={"cards": lambda _args, _env: ""},
|
||||||
|
).html
|
||||||
|
if not large and html:
|
||||||
|
description = _description(html, 150)
|
||||||
|
title = _title(path.rpartition("/")[2], node, translation, path)
|
||||||
|
# The card text's language: the page language when the target article
|
||||||
|
# is translated into it, else the target's own primary language (the
|
||||||
|
# per-card fallback). Set on the link so hyphenation works.
|
||||||
|
card_lang = lang if lang and lang in node.langs else i18n.primary_lang(menu, path)
|
||||||
|
if not large:
|
||||||
|
with doc.a(href=_href(path, link_lang), class_="card compact", lang=card_lang):
|
||||||
|
# Two sub-grids split at the golden ratio (.top : .bottom =
|
||||||
|
# φ : 1): the square image fills the top part with the title
|
||||||
|
# beside it at the bottom, the description sits at the top of
|
||||||
|
# the bottom part. The title/description carry the translucent
|
||||||
|
# band as their own background.
|
||||||
|
with doc.span(class_="top"):
|
||||||
|
if image:
|
||||||
|
doc.img(src=image, alt="", class_="cover")
|
||||||
|
doc.span(title, class_="title")
|
||||||
|
with doc.span(class_="bottom"):
|
||||||
|
if description:
|
||||||
|
doc.span(description, class_="desc")
|
||||||
|
else:
|
||||||
|
cover = {"class_": "cover"}
|
||||||
if image:
|
if image:
|
||||||
doc.span(class_="cover", style=f'background-image: url("{image}")')
|
cover["style"] = f'background-image: url("{image}")'
|
||||||
else:
|
with doc.a(href=_href(path, link_lang), class_="card", lang=card_lang):
|
||||||
doc.span(class_="cover")
|
doc.span(**cover)
|
||||||
doc.span(
|
doc.span(title, class_="title")
|
||||||
_title(path.rpartition("/")[2], node, translation, path), class_="title"
|
|
||||||
)
|
|
||||||
if description:
|
|
||||||
doc.span(description, class_="desc")
|
|
||||||
|
|
||||||
|
|
||||||
_FIRST_P = re.compile(r"<p[^>]*>(.*?)</p>", re.S)
|
_FIRST_P = re.compile(r"<p[^>]*>(.*?)</p>", re.S)
|
||||||
@@ -1084,8 +1196,8 @@ def _media(html: str) -> tuple[str, str]:
|
|||||||
return hero or raster or svg, video
|
return hero or raster or svg, video
|
||||||
|
|
||||||
|
|
||||||
def _share_media(html: str, base_url: str) -> tuple[str, str]:
|
def _card_media(html: str, base_url: str) -> tuple[str, str]:
|
||||||
"""(image, video) share URLs from the rendered article.
|
"""(image, video) card URLs from the rendered article.
|
||||||
|
|
||||||
The _media picks as absolute URLs built from the request base —
|
The _media picks as absolute URLs built from the request base —
|
||||||
social scrapers cannot use relative ones. Extension-less store links
|
social scrapers cannot use relative ones. Extension-less store links
|
||||||
@@ -1111,23 +1223,34 @@ def _social_meta(
|
|||||||
html: str,
|
html: str,
|
||||||
brand: str,
|
brand: str,
|
||||||
base_url: str,
|
base_url: str,
|
||||||
|
card: 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.
|
||||||
|
|
||||||
Heuristics over the rendered article: the description is the first
|
The card image is the node's own ``image`` setting when one resolves
|
||||||
paragraph's text (truncated at ~200 chars on a word boundary), the
|
(``card``, see card_image — the nearest ancestor's or the front
|
||||||
share image the article's first <img> — authors lead with their most
|
page's otherwise); with none set, heuristics over the rendered article
|
||||||
representative figure. Absolute URLs are built from the request's base
|
pick the first representative <img> (a {.hero} first, then raster,
|
||||||
(social scrapers cannot use relative ones).
|
then SVG). The description is the first paragraph's text; the 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
|
||||||
scraper cannot be trusted to negotiate via Accept.
|
scraper cannot be trusted to negotiate via Accept. ``twitter:card``
|
||||||
|
comes from _card_large (the node's per-article ``large`` override,
|
||||||
|
else the image's probed dimensions), mapped to
|
||||||
|
"summary_large_image"/"summary" only here.
|
||||||
"""
|
"""
|
||||||
url = f"{base_url}/{path}" if base_url else ""
|
url = f"{base_url}/{path}" if base_url else ""
|
||||||
text = _description(html)
|
text = _description(html)
|
||||||
image, video = _share_media(html, base_url)
|
if card and base_url:
|
||||||
|
image = f"{base_url}/_f/{card}"
|
||||||
|
_, video = _card_media(html, base_url)
|
||||||
|
else:
|
||||||
|
image, video = _card_media(html, base_url)
|
||||||
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)
|
||||||
return {
|
return {
|
||||||
"description": text,
|
"description": text,
|
||||||
"og:type": "article",
|
"og:type": "article",
|
||||||
@@ -1139,7 +1262,7 @@ def _social_meta(
|
|||||||
"og:video": video,
|
"og:video": video,
|
||||||
"article:published_time": node.created.isoformat(),
|
"article:published_time": node.created.isoformat(),
|
||||||
"article:modified_time": node.modified.isoformat(),
|
"article:modified_time": node.modified.isoformat(),
|
||||||
"twitter:card": "summary_large_image" if image else "summary",
|
"twitter:card": "summary_large_image" if large else "summary",
|
||||||
"twitter:image": twitter_image,
|
"twitter:image": twitter_image,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1208,7 +1331,7 @@ 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)
|
||||||
social = _social_meta(node, path, title, str(main), brand, base_url)
|
social = _social_meta(node, path, title, str(main), brand, base_url, card_image(menu, path)[0])
|
||||||
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(
|
||||||
_layout(
|
_layout(
|
||||||
|
|||||||
Reference in New Issue
Block a user