Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ff5c6b526 | ||
|
|
2aed176c9e | ||
|
|
9ff22016d1 | ||
|
|
97bde49296 | ||
|
|
4f97a6592c | ||
|
|
7e86efeb9f | ||
|
|
580ebac06c | ||
|
|
4d6735f609 |
@@ -16,6 +16,7 @@ Pagerite is a CMS. See `docs` for the full design and implementation details. Ke
|
|||||||
- `api.py` — editor REST + WS: `/_api/pages`, `/_api/structure`, `/_api/settings`, `/_api/toggle-task`, `/_api/translations`, `/_api/ws/editor`, `/_translate/{key}`.
|
- `api.py` — editor REST + WS: `/_api/pages`, `/_api/structure`, `/_api/settings`, `/_api/toggle-task`, `/_api/translations`, `/_api/ws/editor`, `/_translate/{key}`.
|
||||||
- `tracking.py` — visit analytics: GeoIP, client enrichment, favicon fetch, `/_ws`, `/_api/ws/analytics`, the `/_a` page (docs/analytics.md).
|
- `tracking.py` — visit analytics: GeoIP, client enrichment, favicon fetch, `/_ws`, `/_api/ws/analytics`, the `/_a` page (docs/analytics.md).
|
||||||
- `pages.py` — public content pages: `/`, `/sitemap.xml`, `/robots.txt`, the `/{path:path}` catch-all.
|
- `pages.py` — public content pages: `/`, `/sitemap.xml`, `/robots.txt`, the `/{path:path}` catch-all.
|
||||||
|
- `feeds.py` — machine-readable exports: `/llms.txt`, `/feed.json` (JSON Feed 1.1), `/feed.xml` (RSS 2.0 + atom:link); all published articles, full content, linked from every page `<head>` and the sitemap, recorded in analytics.
|
||||||
- `data.py` — msgspec Structs for the kanta database.
|
- `data.py` — msgspec Structs for the kanta database.
|
||||||
- `chunks.py` — block-level Markdown chunking and content-hash keys for the chunk stores (docs/migrate.md).
|
- `chunks.py` — block-level Markdown chunking and content-hash keys for the chunk stores (docs/migrate.md).
|
||||||
- `i18n.py` — language selection, translation assembly (chunks + overrides) and translated-edit recording (per-chunk user overrides in `Data.overrides`, per-language title overrides, refresh).
|
- `i18n.py` — language selection, translation assembly (chunks + overrides) and translated-edit recording (per-chunk user overrides in `Data.overrides`, per-language title overrides, refresh).
|
||||||
|
|||||||
+12
-4
@@ -9,7 +9,9 @@ from the kanta content database, path from `PAGERITE_ANALYTICS` (default:
|
|||||||
`Favicon`), the `Store` (raw log + atomic JSON persistence) and
|
`Favicon`), the `Store` (raw log + atomic JSON persistence) and
|
||||||
`Store.display()`, where **all** classification happens.
|
`Store.display()`, where **all** classification happens.
|
||||||
- `pagerite/pages.py` — records every served document as one raw GET line
|
- `pagerite/pages.py` — records every served document as one raw GET line
|
||||||
(`_record_get`, in `pagerite/tracking.py`) with its true HTTP status.
|
(`_record_get`, in `pagerite/tracking.py`) with its true HTTP status, plus
|
||||||
|
the `/robots.txt` and `/sitemap.xml` machinery GETs (never followed by an
|
||||||
|
activity message, they surface as crawler hits).
|
||||||
- `pagerite/tracking.py` — the `/_ws` activity WebSocket, and
|
- `pagerite/tracking.py` — the `/_ws` activity WebSocket, and
|
||||||
`WebSocket /_api/ws/analytics` (admin-gated like every `/_api` endpoint).
|
`WebSocket /_api/ws/analytics` (admin-gated like every `/_api` endpoint).
|
||||||
- `frontend/src/pagerite.js` — the client activity channel and the 📊 pen.
|
- `frontend/src/pagerite.js` — the client activity channel and the 📊 pen.
|
||||||
@@ -211,7 +213,10 @@ for misses.
|
|||||||
are caught by the abuse rules regardless. In the viewer, crawler hits are
|
are caught by the abuse rules regardless. In the viewer, crawler hits are
|
||||||
grouped by client hash and shown as a trail of pages, preceded by the
|
grouped by client hash and shown as a trail of pages, preceded by the
|
||||||
referer when there is one (rendered with its favicon like visit
|
referer when there is one (rendered with its favicon like visit
|
||||||
referers). The crawler table lists the most recent crawler first, with
|
referers). Non-article machinery GETs (`/robots.txt`, `/sitemap.xml`,
|
||||||
|
`/llms.txt`, the feeds) appear as
|
||||||
|
emoji-marked steps (🤖 / 🗺️ / 🧠 / 📡) so they stand out from article steps.
|
||||||
|
The crawler table lists the most recent crawler first, with
|
||||||
the most active as a tie-breaker.
|
the most active as a tie-breaker.
|
||||||
- **Abuse (scanner) hits**: a 404 on a telltale path — an empty URL segment
|
- **Abuse (scanner) hits**: a 404 on a telltale path — an empty URL segment
|
||||||
(`//foo` — no real client generates those), any segment starting with a
|
(`//foo` — no real client generates those), any segment starting with a
|
||||||
@@ -438,8 +443,11 @@ map: each visit is attributed to `utm_campaign`, then `utm_source`, then the
|
|||||||
referer origin, then any other `utm_*` tag, so UTM-tagged visits are grouped
|
referer origin, then any other `utm_*` tag, so UTM-tagged visits are grouped
|
||||||
under their campaign/source value rather than the referer domain. A UTM
|
under their campaign/source value rather than the referer domain. A UTM
|
||||||
source node only links to its referer when every visit carrying that tag
|
source node only links to its referer when every visit carrying that tag
|
||||||
came from the same origin. External exits are full-size nodes in a matching
|
came from the same origin. Within the source and exit rows the pills are
|
||||||
row centered below the map, so the site itself stays in the middle), per-page view
|
not sorted by count; each slides sideways toward the pages it connects to,
|
||||||
|
minimizing the weighted horizontal connection distance while keeping a
|
||||||
|
minimum pill spacing. External exits are full-size nodes in a matching
|
||||||
|
row below the map, so the site itself stays in the middle), per-page view
|
||||||
counts, the top transitions and the 50 most recent visit trails. Data is
|
counts, the top transitions and the 50 most recent visit trails. Data is
|
||||||
streamed live over `WebSocket /_api/ws/analytics`, which pushes the latest
|
streamed live over `WebSocket /_api/ws/analytics`, which pushes the latest
|
||||||
JSON snapshot on connect and again whenever the analytics file is updated
|
JSON snapshot on connect and again whenever the analytics file is updated
|
||||||
|
|||||||
+3
-2
@@ -10,6 +10,7 @@ Thin FastAPI assembly: lifespan (open the kanta database, load the file store, t
|
|||||||
- `files.py` — the `FileStore` and image derivative helpers, and the file routes: `/_api/files`, `/_f/`, `/_themes/`, `/_fonts/`, the favicon settings endpoints.
|
- `files.py` — the `FileStore` and image derivative helpers, and the file routes: `/_api/files`, `/_f/`, `/_themes/`, `/_fonts/`, the favicon settings endpoints.
|
||||||
- `api.py` — the editor REST API and WebSockets: `/_api/pages`, `/_api/structure`, `/_api/settings`, `/_api/toggle-task`, `/_api/translations`, `/_api/ws/editor`, and the translator channel `/_translate/{clientkey}`.
|
- `api.py` — the editor REST API and WebSockets: `/_api/pages`, `/_api/structure`, `/_api/settings`, `/_api/toggle-task`, `/_api/translations`, `/_api/ws/editor`, and the translator channel `/_translate/{clientkey}`.
|
||||||
- `tracking.py` — visit analytics: GeoIP, client enrichment, favicon fetching, debounced broadcasts, the `/_ws` activity socket, the admin stream `/_api/ws/analytics`, and the `/_a` viewer page.
|
- `tracking.py` — visit analytics: GeoIP, client enrichment, favicon fetching, debounced broadcasts, the `/_ws` activity socket, the admin stream `/_api/ws/analytics`, and the `/_a` viewer page.
|
||||||
|
- `feeds.py` — machine-readable site exports: `/llms.txt` (Markdown site map for LLM agents), `/feed.json` (JSON Feed 1.1) and `/feed.xml` (RSS 2.0 + atom:link), all carrying every published article with full content (relative URLs absolutized), linked from every page's `<head>` and from the sitemap, and recorded in analytics like page GETs. Bodies are RAM-cached (keyed by base URL, cleared by `_invalidate_pages` like the page render cache) and served with a content-hash ETag (304 revalidation).
|
||||||
- `pages.py` — the public content pages: `/`, `/sitemap.xml`, `/robots.txt` and the `/{path:path}` catch-all.
|
- `pages.py` — the public content pages: `/`, `/sitemap.xml`, `/robots.txt` and the `/{path:path}` catch-all.
|
||||||
|
|
||||||
Route ordering is load-bearing and lives in `app.py`: the api/tracking/files routers are included BEFORE `frontend.route(app, "/")` is called — fastapi-vue inserts its file routes at the position where `route()` was called (during `load()` in the lifespan), so anything registered earlier wins. The content catch-all `/{path:path}` is included AFTER `frontend.route()` so that built frontend assets still take priority over content slugs. The `Frontend` is constructed with `spa=False` explicitly: it only serves the built files without a catch-all.
|
Route ordering is load-bearing and lives in `app.py`: the api/tracking/files routers are included BEFORE `frontend.route(app, "/")` is called — fastapi-vue inserts its file routes at the position where `route()` was called (during `load()` in the lifespan), so anything registered earlier wins. The content catch-all `/{path:path}` is included AFTER `frontend.route()` so that built frontend assets still take priority over content slugs. The `Frontend` is constructed with `spa=False` explicitly: it only serves the built files without a catch-all.
|
||||||
@@ -34,11 +35,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 (wider than 600px, taller than 400px and clearly wider than tall — aspect > 1.05, so square and portrait images keep the compact layout; dimensions are read from the `<hash>.webp` derivative via pyvips, cached per hash) and `summary` otherwise — 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`
|
||||||
|
|
||||||
|
|||||||
@@ -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). The special value `@favicon` resolves to the site icon (`Data.favicon`) at render time — it follows favicon changes rather than copying the current icon. Set in the editor's banner panel (upload → `PUT /_api/files/{name}` or paste from the pasteboard — an image uploads, an image URL is sent as the `image` setting itself and fetched/stored server-side by the save handler, since cross-origin URLs are CORS-blocked for the browser — then a `save` with `image` over the editor WebSocket; the site-icon button saves `@favicon`), 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).
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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): upload and paste (pasteboard: an image uploads, an image URL is fetched and stored server-side via the `save` socket message) buttons, a site-icon button (saves `@favicon`, which resolves to the current site icon at render time, following favicon changes) 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 / the site icon / 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.
|
||||||
|
|||||||
@@ -67,42 +67,57 @@ const bannerDesignInherited = ref('')
|
|||||||
let refreshOnSave = null
|
let refreshOnSave = null
|
||||||
|
|
||||||
// --- Card image (Node.image, '' = inherit, like the banner design) ------
|
// --- Card image (Node.image, '' = inherit, like the banner design) ------
|
||||||
// The node's own setting, the effective image after inheritance ("" =
|
// The node's own setting ('@favicon' = the site icon, resolved live
|
||||||
// none) and which node supplied an inherited one ("" = the front page,
|
// against the favicon ref), the effective image after inheritance ("" =
|
||||||
// "" also when own/none — mirrors bannerFrom).
|
// none, already resolved server-side) and which node supplied an
|
||||||
|
// inherited one ("" = the front page, "" also when own/none — mirrors
|
||||||
|
// bannerFrom).
|
||||||
const image = ref('')
|
const image = ref('')
|
||||||
const imageResolved = ref('')
|
const imageResolved = ref('')
|
||||||
const imageSource = ref('')
|
const imageSource = ref('')
|
||||||
|
// The site icon (bare store hash, "" = none): the "@favicon" own setting
|
||||||
|
// resolves against it, live, in the previews.
|
||||||
|
const favicon = 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.
|
||||||
const hasChildren = ref(false)
|
const hasChildren = ref(false)
|
||||||
// The block label states which image is currently in use.
|
// The block label states which image is currently in use.
|
||||||
const imageLabel = computed(() => {
|
const imageLabel = computed(() => {
|
||||||
|
if (image.value === '@favicon') {
|
||||||
|
return 'card image: the site icon (follows favicon changes)'
|
||||||
|
}
|
||||||
if (image.value) {
|
if (image.value) {
|
||||||
return hasChildren.value
|
return hasChildren.value
|
||||||
? `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
|
// ("@favicon" resolves to the site icon), else the mined article image,
|
||||||
// a bare store hash; image_mined is already a src path.
|
// else the inherited node image (what og:image would use).
|
||||||
const cardImage = computed(() =>
|
// image/image_resolved are bare store hashes; image_mined is already a
|
||||||
imageResolved.value ? `/_f/${imageResolved.value}` : imageMined.value,
|
// src path.
|
||||||
)
|
const cardImage = computed(() => {
|
||||||
|
// "@favicon" with no site icon configured falls through like no own
|
||||||
|
// image at all (mirrors the backend's resolution).
|
||||||
|
const own = image.value === '@favicon' ? favicon.value : image.value
|
||||||
|
if (own) return `/_f/${own}`
|
||||||
|
return 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.
|
||||||
const large = ref(null)
|
const large = ref(null)
|
||||||
@@ -144,13 +159,39 @@ async function uploadCardImage(ev) {
|
|||||||
const file = ev.target.files[0]
|
const file = ev.target.files[0]
|
||||||
ev.target.value = '' // allow re-picking the same file
|
ev.target.value = '' // allow re-picking the same file
|
||||||
if (!file || !file.type.startsWith('image/')) return
|
if (!file || !file.type.startsWith('image/')) return
|
||||||
const name = file.name.replace(/[^\w.-]/g, '-')
|
storeCardImage(file, file.name)
|
||||||
const res = await apiFetch(`/_api/files/${encodeURIComponent(name)}`, { method: 'PUT', body: file })
|
}
|
||||||
|
|
||||||
|
async function storeCardImage(blob, filename) {
|
||||||
|
const name = filename.replace(/[^\w.-]/g, '-')
|
||||||
|
const res = await apiFetch(`/_api/files/${encodeURIComponent(name)}`, { method: 'PUT', body: blob })
|
||||||
if (!res.ok) return
|
if (!res.ok) return
|
||||||
const { path: stored } = await res.json() // "/_f/<hash>[.ext]"
|
const { path: stored } = await res.json() // "/_f/<hash>[.ext]"
|
||||||
saveImage(stored.split('/').pop().split('.')[0])
|
saveImage(stored.split('/').pop().split('.')[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function pasteCardImage() {
|
||||||
|
// The pasteboard button (unlike pasting into a text editor, where the
|
||||||
|
// paste event carries files) must read the clipboard explicitly.
|
||||||
|
try {
|
||||||
|
for (const item of await navigator.clipboard.read()) {
|
||||||
|
const type = item.types.find((t) => t.startsWith('image/'))
|
||||||
|
if (type) {
|
||||||
|
const blob = await item.getType(type)
|
||||||
|
await storeCardImage(blob, `paste.${type.split('/')[1].replace('+xml', '')}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No image on the pasteboard: a pasted URL goes as the setting
|
||||||
|
// itself — the server fetches and stores it (cross-origin URLs are
|
||||||
|
// CORS-blocked for the browser).
|
||||||
|
const text = (await navigator.clipboard.readText()).trim()
|
||||||
|
if (/^https?:\/\/\S+$/.test(text)) saveImage(text)
|
||||||
|
} catch {
|
||||||
|
// Clipboard read denied or empty: nothing to do.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 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) {
|
||||||
@@ -326,6 +367,7 @@ function onMessage(ev) {
|
|||||||
imageResolved.value = msg.image_resolved ?? ''
|
imageResolved.value = msg.image_resolved ?? ''
|
||||||
imageMined.value = msg.image_mined ?? ''
|
imageMined.value = msg.image_mined ?? ''
|
||||||
imageSource.value = msg.image_source ?? ''
|
imageSource.value = msg.image_source ?? ''
|
||||||
|
favicon.value = msg.favicon ?? ''
|
||||||
hasChildren.value = msg.has_children ?? false
|
hasChildren.value = msg.has_children ?? false
|
||||||
large.value = msg.large ?? null
|
large.value = msg.large ?? null
|
||||||
pageTitle.value = msg.title ?? ''
|
pageTitle.value = msg.title ?? ''
|
||||||
@@ -451,6 +493,19 @@ onUnmounted(() => {
|
|||||||
title="upload card image (og:image / card covers) — the subtree inherits it"
|
title="upload card image (og:image / card covers) — the subtree inherits it"
|
||||||
@click="imageInput.click()"
|
@click="imageInput.click()"
|
||||||
>🖼︎</button>
|
>🖼︎</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="icon-btn"
|
||||||
|
title="paste a card image from the pasteboard (image or image URL)"
|
||||||
|
@click="pasteCardImage"
|
||||||
|
>📋</button>
|
||||||
|
<button
|
||||||
|
v-if="favicon"
|
||||||
|
type="button"
|
||||||
|
class="icon-btn favicon-btn"
|
||||||
|
title="use the site icon as the card image — follows favicon changes"
|
||||||
|
@click="saveImage('@favicon')"
|
||||||
|
><img :src="`/_f/${favicon}`" alt="site icon" /></button>
|
||||||
<input
|
<input
|
||||||
ref="imageInput"
|
ref="imageInput"
|
||||||
type="file"
|
type="file"
|
||||||
@@ -611,6 +666,14 @@ onUnmounted(() => {
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The "use site icon" button shows the icon itself. */
|
||||||
|
.favicon-btn img {
|
||||||
|
display: block;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
/* The first icon button pushes itself (and any siblings after it, like
|
/* The first icon button pushes itself (and any siblings after it, like
|
||||||
the card-image clear button) to the end of the row. */
|
the card-image clear button) to the end of the row. */
|
||||||
.block-head .icon-btn:first-of-type {
|
.block-head .icon-btn:first-of-type {
|
||||||
|
|||||||
@@ -160,9 +160,32 @@ function externalOrigin(url) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Non-article machinery paths shown in trails with an emoji marker:
|
||||||
|
// recorded like page GETs but fetched by crawlers/feed readers, so they
|
||||||
|
// surface in the crawler rows. Feed paths are pre-registered for the
|
||||||
|
// future RSS/Atom routes.
|
||||||
|
const MACHINE_STEPS = {
|
||||||
|
'/robots.txt': ['🤖', 'robots.txt'],
|
||||||
|
'/sitemap.xml': ['🗺️', 'sitemap.xml'],
|
||||||
|
'/llms.txt': ['🧠', 'llms.txt'],
|
||||||
|
'/feed.json': ['📡', 'feed.json'],
|
||||||
|
'/feed.xml': ['📡', 'feed.xml'],
|
||||||
|
'/rss.xml': ['📡', 'rss.xml'],
|
||||||
|
'/atom.xml': ['📡', 'atom.xml'],
|
||||||
|
'/feed': ['📡', 'feed'],
|
||||||
|
}
|
||||||
|
|
||||||
/** Format one trail step: an internal page or an external https origin. */
|
/** Format one trail step: an internal page or an external https origin. */
|
||||||
function stepOf(path, titles) {
|
function stepOf(path, titles) {
|
||||||
if (path?.startsWith('/')) {
|
if (path?.startsWith('/')) {
|
||||||
|
// Known non-article machinery GETs (fetched by crawlers and feed
|
||||||
|
// readers, recorded like page GETs): emoji-marked so they stand out
|
||||||
|
// from article steps in the trails.
|
||||||
|
const machine = MACHINE_STEPS[path]
|
||||||
|
if (machine) {
|
||||||
|
const [emoji, name] = machine
|
||||||
|
return { path, slug: `${emoji} ${name}`, title: name, external: false, machine: true }
|
||||||
|
}
|
||||||
return { path, slug: slugOf(path), title: titles.get(path) || '', external: false, home: path === '/' }
|
return { path, slug: slugOf(path), title: titles.get(path) || '', external: false, home: path === '/' }
|
||||||
}
|
}
|
||||||
if (path?.startsWith('https://')) {
|
if (path?.startsWith('https://')) {
|
||||||
|
|||||||
@@ -18,7 +18,9 @@
|
|||||||
* connections. Animated beads flow along every edge in each direction,
|
* connections. Animated beads flow along every edge in each direction,
|
||||||
* emitted at time intervals inversely proportional (linear) to the
|
* emitted at time intervals inversely proportional (linear) to the
|
||||||
* directional count.
|
* directional count.
|
||||||
* External sources appear as nodes in a row above the map. Sources are
|
* External sources appear as nodes in a row above the map, each pill
|
||||||
|
* slid sideways toward the pages it connects to (weighted by count,
|
||||||
|
* minimum spacing kept). Sources are
|
||||||
* identified from visit records in this order: utm_campaign, utm_source,
|
* identified from visit records in this order: utm_campaign, utm_source,
|
||||||
* referer, then other utm_* tags. Visits with a UTM tag are grouped under
|
* referer, then other utm_* tags. Visits with a UTM tag are grouped under
|
||||||
* that tag's value, not under the referer domain. A UTM source node only
|
* that tag's value, not under the referer domain. A UTM source node only
|
||||||
@@ -700,6 +702,51 @@ function buildInternalEdges(pairs, byPath, dayScale = 1) {
|
|||||||
return { edges, flows }
|
return { edges, flows }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Weighted median of {x, w} targets: the x minimizing Σ w|x − t| —
|
||||||
|
* the spot where a pill's total horizontal connection pull balances.
|
||||||
|
*/
|
||||||
|
function weightedMedian(targets) {
|
||||||
|
const ts = [...targets].sort((a, b) => a.x - b.x)
|
||||||
|
let total = 0
|
||||||
|
for (const t of ts) total += t.w
|
||||||
|
let acc = 0
|
||||||
|
for (const t of ts) {
|
||||||
|
acc += t.w
|
||||||
|
if (acc >= total / 2) return t.x
|
||||||
|
}
|
||||||
|
return ts[ts.length - 1].x
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slide the pills of an external row sideways so each sits as close as
|
||||||
|
* possible to the pages it connects to: minimize Σ w|pill.x − target.x|
|
||||||
|
* over all drawn connections (weight = count), subject to a minimum
|
||||||
|
* center `spacing` (no overlap). Pills are ordered by their weighted
|
||||||
|
* median target (swapping any inverted adjacent pair can only add
|
||||||
|
* crossing distance), then positioned by coordinate descent on this
|
||||||
|
* convex objective: each pass snaps a pill to its weighted median,
|
||||||
|
* clamped to the spacing window between its current neighbors.
|
||||||
|
*/
|
||||||
|
function placeRow(items, spacing) {
|
||||||
|
items.sort((a, b) => a.anchor - b.anchor)
|
||||||
|
const n = items.length
|
||||||
|
for (const it of items) it.x = it.anchor
|
||||||
|
for (let pass = 0; pass < 40; pass++) {
|
||||||
|
let moved = 0
|
||||||
|
const sweep = (i) => {
|
||||||
|
const lo = i > 0 ? items[i - 1].x + spacing : -Infinity
|
||||||
|
const hi = i < n - 1 ? items[i + 1].x - spacing : Infinity
|
||||||
|
const x = Math.min(Math.max(items[i].anchor, lo), hi)
|
||||||
|
moved = Math.max(moved, Math.abs(x - items[i].x))
|
||||||
|
items[i].x = x
|
||||||
|
}
|
||||||
|
for (let i = 0; i < n; i++) sweep(i)
|
||||||
|
for (let i = n - 1; i >= 0; i--) sweep(i)
|
||||||
|
if (moved < 0.01) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const UTM_PRIORITY = ['utm_campaign', 'utm_source']
|
const UTM_PRIORITY = ['utm_campaign', 'utm_source']
|
||||||
const UTM_FALLBACK = ['utm_medium', 'utm_content', 'utm_term', 'utm_id']
|
const UTM_FALLBACK = ['utm_medium', 'utm_content', 'utm_term', 'utm_id']
|
||||||
|
|
||||||
@@ -764,10 +811,12 @@ function collectSourcePairs(visits) {
|
|||||||
* Place external source and exit nodes and build their edges and bead
|
* Place external source and exit nodes and build their edges and bead
|
||||||
* flows.
|
* flows.
|
||||||
* Sources (incoming links) are derived from visit UTM/referer data and form
|
* Sources (incoming links) are derived from visit UTM/referer data and form
|
||||||
* a row centered above the map, hottest first; exits come from the
|
* a row above the map; exits come from the
|
||||||
* transition matrix and form a matching row centered below the map, so
|
* transition matrix and form a matching row below the map, so
|
||||||
* the site itself stays in the middle. Both rows sit EXT_GAP beyond the
|
* the site itself stays in the middle. Both rows sit EXT_GAP beyond the
|
||||||
* map's bounds.
|
* map's bounds. Within a row the pills slide sideways toward the pages
|
||||||
|
* they connect to (placeRow), minimizing the weighted horizontal
|
||||||
|
* connection distance while keeping a minimum center spacing.
|
||||||
* Widths and pruning use the same log scale and traffic-share rule as
|
* Widths and pruning use the same log scale and traffic-share rule as
|
||||||
* internal connections.
|
* internal connections.
|
||||||
*/
|
*/
|
||||||
@@ -785,9 +834,10 @@ function buildExternal({ sources, exits }, byPath, innerBounds, dayScale = 1) {
|
|||||||
|
|
||||||
const width = (count) => scaledWidth(count * dayScale)
|
const width = (count) => scaledWidth(count * dayScale)
|
||||||
|
|
||||||
// Incoming: one source node per identified source, in a row centered
|
// Incoming: one source node per identified source, in a row above the
|
||||||
// above the map, with an edge to each page that source led to. A source
|
// map, with an edge to each page that source led to. A source whose
|
||||||
// whose connectors are all culled (below MIN_WMID) is dropped itself.
|
// connectors are all culled (below MIN_WMID) is dropped itself. Pills
|
||||||
|
// slide sideways toward their connection targets (see placeRow).
|
||||||
const bySource = new Map() // source -> pairs, sorted by total incoming count
|
const bySource = new Map() // source -> pairs, sorted by total incoming count
|
||||||
for (const p of liveSources.filter((p) => p.in >= minCount)) {
|
for (const p of liveSources.filter((p) => p.in >= minCount)) {
|
||||||
const g = bySource.get(p.source) || []
|
const g = bySource.get(p.source) || []
|
||||||
@@ -804,20 +854,25 @@ function buildExternal({ sources, exits }, byPath, innerBounds, dayScale = 1) {
|
|||||||
}))
|
}))
|
||||||
.sort((a, b) => b.total - a.total)
|
.sort((a, b) => b.total - a.total)
|
||||||
.slice(0, MAX_EXT_IN)
|
.slice(0, MAX_EXT_IN)
|
||||||
.filter(({ ps }) =>
|
.map((o) => ({
|
||||||
ps.some((p) => !byPath.get(p.page).hidden && width(p.in) >= MIN_WMID))
|
...o,
|
||||||
|
// Anchoring targets: the drawn (non-culled) connections only.
|
||||||
|
targets: o.ps
|
||||||
|
.filter((p) => !byPath.get(p.page).hidden && width(p.in) >= MIN_WMID)
|
||||||
|
.map((p) => ({ x: byPath.get(p.page).x, w: p.in })),
|
||||||
|
}))
|
||||||
|
.filter((o) => o.targets.length)
|
||||||
if (origins.length) {
|
if (origins.length) {
|
||||||
const cx = (innerBounds.x0 + innerBounds.x1) / 2
|
|
||||||
const y = innerBounds.y0 - TNODE_BOUND - EXT_GAP
|
const y = innerBounds.y0 - TNODE_BOUND - EXT_GAP
|
||||||
const spacing = TNODE_W + 44
|
for (const o of origins) o.anchor = weightedMedian(o.targets)
|
||||||
const x0 = cx - ((origins.length - 1) * spacing) / 2
|
placeRow(origins, TNODE_W + 44)
|
||||||
origins.forEach(({ source, ps, total, href, isUtm }, i) => {
|
origins.forEach(({ source, ps, total, href, isUtm, x }) => {
|
||||||
const label = isUtm ? source : extLabel(source)
|
const label = isUtm ? source : extLabel(source)
|
||||||
const xn = {
|
const xn = {
|
||||||
path: source,
|
path: source,
|
||||||
href,
|
href,
|
||||||
label, // clipped at the pill border on render
|
label, // clipped at the pill border on render
|
||||||
x: x0 + i * spacing,
|
x,
|
||||||
y,
|
y,
|
||||||
count: total,
|
count: total,
|
||||||
kind: 'source',
|
kind: 'source',
|
||||||
@@ -836,10 +891,11 @@ function buildExternal({ sources, exits }, byPath, innerBounds, dayScale = 1) {
|
|||||||
|
|
||||||
// Outgoing: one exit node per distinct full URL (so several links to
|
// Outgoing: one exit node per distinct full URL (so several links to
|
||||||
// the same domain stay distinct), showing the total count across all
|
// the same domain stay distinct), showing the total count across all
|
||||||
// pages linking to it, in a row centered below the map (hottest
|
// pages linking to it, in a row below the map mirroring the source row
|
||||||
// first), mirroring the source row above. Each (URL, page) pair
|
// above. Each (URL, page) pair contributes an edge from that page. An
|
||||||
// contributes an edge from that page. An exit whose connectors are all
|
// exit whose connectors are all culled (below MIN_WMID) is dropped
|
||||||
// culled (below MIN_WMID) is dropped itself.
|
// itself. Pills slide sideways toward their connection targets (see
|
||||||
|
// placeRow).
|
||||||
const byExt = new Map() // full URL -> { ext, out, pairs }
|
const byExt = new Map() // full URL -> { ext, out, pairs }
|
||||||
for (const p of liveExits.filter((p) => p.out >= minCount)) {
|
for (const p of liveExits.filter((p) => p.out >= minCount)) {
|
||||||
const g = byExt.get(p.ext) || { ext: p.ext, out: 0, pairs: [] }
|
const g = byExt.get(p.ext) || { ext: p.ext, out: 0, pairs: [] }
|
||||||
@@ -850,19 +906,23 @@ function buildExternal({ sources, exits }, byPath, innerBounds, dayScale = 1) {
|
|||||||
const targets = [...byExt.values()]
|
const targets = [...byExt.values()]
|
||||||
.sort((a, b) => b.out - a.out)
|
.sort((a, b) => b.out - a.out)
|
||||||
.slice(0, MAX_EXT_OUT)
|
.slice(0, MAX_EXT_OUT)
|
||||||
.filter(({ pairs }) =>
|
.map((t) => ({
|
||||||
pairs.some((p) => !byPath.get(p.page).hidden && width(p.out) >= MIN_WMID))
|
...t,
|
||||||
|
targets: t.pairs
|
||||||
|
.filter((p) => !byPath.get(p.page).hidden && width(p.out) >= MIN_WMID)
|
||||||
|
.map((p) => ({ x: byPath.get(p.page).x, w: p.out })),
|
||||||
|
}))
|
||||||
|
.filter((t) => t.targets.length)
|
||||||
if (targets.length) {
|
if (targets.length) {
|
||||||
const cx = (innerBounds.x0 + innerBounds.x1) / 2
|
|
||||||
const y = innerBounds.y1 + TNODE_BOUND + EXT_GAP
|
const y = innerBounds.y1 + TNODE_BOUND + EXT_GAP
|
||||||
const spacing = TNODE_W + 44
|
for (const t of targets) t.anchor = weightedMedian(t.targets)
|
||||||
const x0 = cx - ((targets.length - 1) * spacing) / 2
|
placeRow(targets, TNODE_W + 44)
|
||||||
targets.forEach(({ ext, out, pairs }, i) => {
|
targets.forEach(({ ext, out, pairs, x }) => {
|
||||||
const xn = {
|
const xn = {
|
||||||
path: ext,
|
path: ext,
|
||||||
href: ext,
|
href: ext,
|
||||||
label: extLabel(ext),
|
label: extLabel(ext),
|
||||||
x: x0 + i * spacing,
|
x,
|
||||||
y,
|
y,
|
||||||
count: out,
|
count: out,
|
||||||
kind: 'exit',
|
kind: 'exit',
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ to ``Analytics.gets`` as a raw access-log line (path with query string, true
|
|||||||
HTTP status, external referer origin, preload flag, rendered content
|
HTTP status, external referer origin, preload flag, rendered content
|
||||||
language) and every pagerite.js activity message from the /_ws WebSocket is
|
language) and every pagerite.js activity message from the /_ws WebSocket is
|
||||||
appended to ``Analytics.msgs``
|
appended to ``Analytics.msgs``
|
||||||
(navigations ``fr`` -> ``to`` and active reading-time updates). Nothing is
|
(navigations ``fr`` -> ``to`` and active reading-time updates). The
|
||||||
|
non-document machinery GETs ``/robots.txt`` and ``/sitemap.xml`` are
|
||||||
|
recorded the same way: never followed by an activity message, they surface
|
||||||
|
as crawler hits at display time. Nothing is
|
||||||
classified when it is recorded: whether a client turns out to be a reader,
|
classified when it is recorded: whether a client turns out to be a reader,
|
||||||
a crawler or a scanner is decided by ``Store.display()`` from the raw
|
a crawler or a scanner is decided by ``Store.display()`` from the raw
|
||||||
events, so the stored data survives any future change to the classification
|
events, so the stored data survives any future change to the classification
|
||||||
|
|||||||
+34
-8
@@ -407,8 +407,8 @@ async def editor_ws(ws: WebSocket) -> None:
|
|||||||
-> {"type": "doc", "path", "exists", "title", "markdown", "published",
|
-> {"type": "doc", "path", "exists", "title", "markdown", "published",
|
||||||
"banner", "banner_design", "banner_from", "banner_design_from",
|
"banner", "banner_design", "banner_from", "banner_design_from",
|
||||||
"banner_design_inherited", "description", "image", "image_resolved",
|
"banner_design_inherited", "description", "image", "image_resolved",
|
||||||
"image_mined", "image_source", "has_children", "large", "lang",
|
"image_mined", "image_source", "favicon", "has_children", "large",
|
||||||
"primary_lang", "langs",
|
"lang", "primary_lang", "langs",
|
||||||
"translate_langs"}
|
"translate_langs"}
|
||||||
<- {"type": "render", "path", "markdown"}
|
<- {"type": "render", "path", "markdown"}
|
||||||
-> {"type": "html", "path", "html"}
|
-> {"type": "html", "path", "html"}
|
||||||
@@ -416,7 +416,8 @@ async def editor_ws(ws: WebSocket) -> None:
|
|||||||
"banner"?, "banner_design"?, "image"?, "large"?, "move_from"?,
|
"banner"?, "banner_design"?, "image"?, "large"?, "move_from"?,
|
||||||
"lang"?, "base"?}
|
"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; image: a store hash, "@favicon", "" to
|
||||||
|
inherit, or an http(s) URL the server fetches and stores)
|
||||||
-> {"type": "saved", "path"} | {"type": "error", "detail"}
|
-> {"type": "saved", "path"} | {"type": "error", "detail"}
|
||||||
|
|
||||||
With "lang" (a translation, not the primary language), open returns the
|
With "lang" (a translation, not the primary language), open returns the
|
||||||
@@ -458,11 +459,13 @@ 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 node's card image: its own setting ("" = inherit,
|
||||||
# the effective one after inheritance ("" = none) and
|
# "@favicon" = the site icon), the effective one after
|
||||||
|
# inheritance ("" = none, resolved to a store name) and
|
||||||
# which node supplied an inherited one ("" = front page;
|
# which node supplied an inherited one ("" = front page;
|
||||||
# "" also when own/none — mirrors banner_from).
|
# "" also when own/none — mirrors banner_from).
|
||||||
img, img_source = views.card_image(data.menu, path)
|
img, img_source = views.card_image(data.menu, path)
|
||||||
|
img = views._resolve_image_name(data, img)
|
||||||
# The card preview's description and mined image,
|
# The card preview's description and mined image,
|
||||||
# from the same rendered-article heuristics as the
|
# from the same rendered-article heuristics as the
|
||||||
# og:/twitter: meta (_description, _media).
|
# og:/twitter: meta (_description, _media).
|
||||||
@@ -503,9 +506,13 @@ async def editor_ws(ws: WebSocket) -> None:
|
|||||||
# /<path>/*"): the subtree inherits it.
|
# /<path>/*"): the subtree inherits it.
|
||||||
"has_children": bool(node.children) if node else False,
|
"has_children": bool(node.children) if node else False,
|
||||||
"image_resolved": img,
|
"image_resolved": img,
|
||||||
|
# The site icon ("" = none): the "@favicon" own
|
||||||
|
# setting previews/resolves against it.
|
||||||
|
"favicon": data.favicon,
|
||||||
# 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
|
||||||
@@ -655,9 +662,28 @@ async def editor_ws(ws: WebSocket) -> None:
|
|||||||
image = msg.get("image")
|
image = msg.get("image")
|
||||||
if image is not None:
|
if image is not None:
|
||||||
# Card-image setting (inherited by the subtree): a
|
# Card-image setting (inherited by the subtree): a
|
||||||
# 12-hex content-addressed store name, "" = inherit.
|
# 12-hex content-addressed store name, "@favicon" =
|
||||||
|
# the site icon, "" = inherit, or an http(s) URL —
|
||||||
|
# pasted image links are fetched and stored
|
||||||
|
# server-side (cross-origin is CORS-blocked for the
|
||||||
|
# browser), the stored name becomes the setting.
|
||||||
image = str(image).strip()
|
image = str(image).strip()
|
||||||
if image and not re.fullmatch(r"[0-9a-f]{12}", image):
|
if image.startswith(("http://", "https://")):
|
||||||
|
from pagerite.files import fetch_image
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Bare hash name, like an upload's setting.
|
||||||
|
image = (await fetch_image(image)).split(".")[0]
|
||||||
|
except HTTPException as e:
|
||||||
|
await ws.send_json(
|
||||||
|
{"type": "error", "detail": str(e.detail)}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if (
|
||||||
|
image
|
||||||
|
and image != "@favicon"
|
||||||
|
and not re.fullmatch(r"[0-9a-f]{12}", image)
|
||||||
|
):
|
||||||
await ws.send_json(
|
await ws.send_json(
|
||||||
{
|
{
|
||||||
"type": "error",
|
"type": "error",
|
||||||
|
|||||||
+4
-1
@@ -11,6 +11,8 @@ The routes live in specialized modules, included below as APIRouters:
|
|||||||
(``/_api/*``, ``/_translate/{clientkey}``).
|
(``/_api/*``, ``/_translate/{clientkey}``).
|
||||||
- ``pagerite.tracking`` — visit analytics (``/_ws``, ``/_api/ws/analytics``,
|
- ``pagerite.tracking`` — visit analytics (``/_ws``, ``/_api/ws/analytics``,
|
||||||
the ``/_a`` viewer page).
|
the ``/_a`` viewer page).
|
||||||
|
- ``pagerite.feeds`` — machine-readable site exports: ``/llms.txt``,
|
||||||
|
``/feed.json`` (JSON Feed) and ``/feed.xml`` (RSS).
|
||||||
- ``pagerite.pages`` — the public content pages: ``/``, ``/sitemap.xml``,
|
- ``pagerite.pages`` — the public content pages: ``/``, ``/sitemap.xml``,
|
||||||
``/robots.txt`` and the ``/{path:path}`` catch-all.
|
``/robots.txt`` and the ``/{path:path}`` catch-all.
|
||||||
|
|
||||||
@@ -39,7 +41,7 @@ from fastapi.responses import Response
|
|||||||
from fastapi_vue import Frontend, env
|
from fastapi_vue import Frontend, env
|
||||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||||
|
|
||||||
from pagerite import api, files, pages, tracking
|
from pagerite import api, feeds, files, pages, tracking
|
||||||
from pagerite.files import file_store
|
from pagerite.files import file_store
|
||||||
from pagerite.state import analytics_store, config, kanta
|
from pagerite.state import analytics_store, config, kanta
|
||||||
|
|
||||||
@@ -128,4 +130,5 @@ frontend.route(app, "/")
|
|||||||
|
|
||||||
# The content catch-all goes last: built assets win over content slugs,
|
# The content catch-all goes last: built assets win over content slugs,
|
||||||
# anything unmatched falls through to content (and 404).
|
# anything unmatched falls through to content (and 404).
|
||||||
|
app.include_router(feeds.router)
|
||||||
app.include_router(pages.router)
|
app.include_router(pages.router)
|
||||||
|
|||||||
+3
-1
@@ -93,7 +93,9 @@ class Node(msgspec.Struct, omit_defaults=True):
|
|||||||
#: Content-addressed card image name (served at "/_f/{name}") for
|
#: Content-addressed card image name (served at "/_f/{name}") for
|
||||||
#: og:image/twitter:image and card covers. "" inherits the nearest
|
#: og:image/twitter:image and card covers. "" inherits the nearest
|
||||||
#: ancestor's image, the front page last; unset everywhere falls back
|
#: ancestor's image, the front page last; unset everywhere falls back
|
||||||
#: to mining the rendered article.
|
#: to mining the rendered article (which beats an inherited image).
|
||||||
|
#: "@favicon" resolves to the site icon (Data.favicon) at render time,
|
||||||
|
#: following favicon changes.
|
||||||
image: str = ""
|
image: str = ""
|
||||||
#: Card-mode override (site cards + twitter:card): None = pick
|
#: Card-mode override (site cards + twitter:card): None = pick
|
||||||
#: automatically from the card image's dimensions, False forces a
|
#: automatically from the card image's dimensions, False forces a
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
"""Machine-readable site exports: /llms.txt, /feed.json and /feed.xml.
|
||||||
|
|
||||||
|
- ``/llms.txt`` (llmstxt.org convention): a Markdown map of the site for
|
||||||
|
LLM agents — the brand as title, then every published article as a link
|
||||||
|
with a short excerpt.
|
||||||
|
- ``/feed.json``: JSON Feed 1.1 of all published articles, full content.
|
||||||
|
- ``/feed.xml``: the same as RSS 2.0 (with an atom:link self reference)
|
||||||
|
for older feed readers.
|
||||||
|
|
||||||
|
All three are linked from every page's <head> (see views._layout) and from
|
||||||
|
the sitemap, recorded in analytics like page GETs (they surface as crawler
|
||||||
|
hits — no activity message ever follows them), and rendered in the site's
|
||||||
|
primary language only (feeds have no per-language negotiation here).
|
||||||
|
|
||||||
|
Rendering every article is expensive, so bodies are cached in RAM keyed by
|
||||||
|
the public base URL and cleared by ``state._invalidate_pages`` on any
|
||||||
|
content/settings write — the same hook that drops the page render cache.
|
||||||
|
Each response also carries a content-hash ETag and answers 304, so polling
|
||||||
|
feed readers revalidate cheaply.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import blake3
|
||||||
|
import json
|
||||||
|
from datetime import UTC
|
||||||
|
from email.utils import format_datetime
|
||||||
|
from functools import lru_cache
|
||||||
|
from xml.sax.saxutils import escape as xml_escape
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import Response
|
||||||
|
|
||||||
|
from pagerite.data import Node, node_markdown, sorted_nodes
|
||||||
|
from pagerite.markdown import make_md
|
||||||
|
from pagerite.state import SITE_URL, data
|
||||||
|
from pagerite.tracking import _record_get
|
||||||
|
from pagerite.views import _description
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
_md = make_md()
|
||||||
|
|
||||||
|
|
||||||
|
def _articles() -> list[tuple[str, Node]]:
|
||||||
|
"""All published content pages in menu order: (path, node)."""
|
||||||
|
out: list[tuple[str, Node]] = []
|
||||||
|
|
||||||
|
def walk(nodes: dict[str, Node], prefix: str) -> None:
|
||||||
|
for slug, node in sorted_nodes(nodes):
|
||||||
|
path = f"{prefix}/{slug}" if prefix else slug
|
||||||
|
if node.published and node.chunks is not None:
|
||||||
|
out.append((path, node))
|
||||||
|
if node.children:
|
||||||
|
walk(node.children, path)
|
||||||
|
|
||||||
|
walk(data.menu, "")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _body_html(node: Node, base: str) -> str:
|
||||||
|
"""Full article HTML for feed content, with relative URLs absolutized.
|
||||||
|
|
||||||
|
Rendered without the layout segmentation of page rendering (colseg
|
||||||
|
wrappers are meaningless in a feed reader); the item title carries the
|
||||||
|
page title, so no implicit h1 is injected either.
|
||||||
|
"""
|
||||||
|
html = _md.render(node_markdown(data, node) or "")
|
||||||
|
return html.replace('src="/', f'src="{base}/').replace('href="/', f'href="{base}/')
|
||||||
|
|
||||||
|
|
||||||
|
def _rfc822(node: Node) -> str:
|
||||||
|
return format_datetime(node.modified.astimezone(UTC), usegmt=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(node: Node) -> str:
|
||||||
|
return node.modified.astimezone(UTC).replace(microsecond=0).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _render_llms(base: str) -> str:
|
||||||
|
lines = [f"# {data.brand}", "", "## Pages", ""]
|
||||||
|
for path, node in _articles():
|
||||||
|
url = f"{base}/{path}" if path else base
|
||||||
|
excerpt = _description(_body_html(node, base), 120)
|
||||||
|
suffix = f": {excerpt}" if excerpt else ""
|
||||||
|
lines.append(f"- [{node.title or path}]({url}){suffix}")
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _render_feed_json(base: str) -> str:
|
||||||
|
items = [
|
||||||
|
{
|
||||||
|
"id": (url := f"{base}/{path}" if path else base),
|
||||||
|
"url": url,
|
||||||
|
"title": node.title or path,
|
||||||
|
"content_html": _body_html(node, base),
|
||||||
|
"date_published": node.created.astimezone(UTC)
|
||||||
|
.replace(microsecond=0)
|
||||||
|
.isoformat(),
|
||||||
|
"date_modified": _iso(node),
|
||||||
|
}
|
||||||
|
for path, node in _articles()
|
||||||
|
]
|
||||||
|
feed = {
|
||||||
|
"version": "https://jsonfeed.org/version/1.1",
|
||||||
|
"title": data.brand,
|
||||||
|
"home_page_url": base,
|
||||||
|
"feed_url": f"{base}/feed.json",
|
||||||
|
"items": items,
|
||||||
|
}
|
||||||
|
return json.dumps(feed, ensure_ascii=False, indent=1)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_feed_xml(base: str) -> str:
|
||||||
|
items = []
|
||||||
|
for path, node in _articles():
|
||||||
|
url = f"{base}/{path}" if path else base
|
||||||
|
items.append(
|
||||||
|
f"<item><title>{xml_escape(node.title or path)}</title>"
|
||||||
|
f"<link>{xml_escape(url)}</link>"
|
||||||
|
f'<guid isPermaLink="true">{xml_escape(url)}</guid>'
|
||||||
|
f"<pubDate>{_rfc822(node)}</pubDate>"
|
||||||
|
f"<description><![CDATA[{_body_html(node, base)}]]></description>"
|
||||||
|
f"</item>"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||||
|
'<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">'
|
||||||
|
f"<channel><title>{xml_escape(data.brand)}</title>"
|
||||||
|
f"<link>{xml_escape(base)}</link>"
|
||||||
|
f"<description>{xml_escape(data.brand)}</description>"
|
||||||
|
f'<atom:link href="{xml_escape(base)}/feed.xml" rel="self" type="application/rss+xml" />'
|
||||||
|
+ "".join(items)
|
||||||
|
+ "</channel></rss>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
#: Export body builders by route path.
|
||||||
|
_RENDERERS = {
|
||||||
|
"/llms.txt": (_render_llms, "text/plain"),
|
||||||
|
"/feed.json": (_render_feed_json, "application/feed+json"),
|
||||||
|
"/feed.xml": (_render_feed_xml, "application/rss+xml"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=12)
|
||||||
|
def _cached_feed(path: str, base_url: str) -> str:
|
||||||
|
"""Rendered export body; cleared by ``state._invalidate_pages`` on any
|
||||||
|
content/settings write. base_url is part of the key because the bodies
|
||||||
|
bake absolute URLs into every link, image and guid."""
|
||||||
|
return _RENDERERS[path][0](base_url)
|
||||||
|
|
||||||
|
|
||||||
|
def _feed_response(request: Request, path: str) -> Response:
|
||||||
|
"""Cached export response with a content-hash ETag (304 on match), so
|
||||||
|
polling feed readers revalidate without a rerender or a download."""
|
||||||
|
base = SITE_URL or str(request.base_url).rstrip("/")
|
||||||
|
body = _cached_feed(path, base)
|
||||||
|
headers = {"cache-control": "no-cache"}
|
||||||
|
tag = f'"{blake3.blake3(body.encode()).hexdigest()[:32]}"'
|
||||||
|
headers["etag"] = tag
|
||||||
|
if request.headers.get("if-none-match") == tag:
|
||||||
|
return Response(status_code=304, headers=headers)
|
||||||
|
_record_get(request)
|
||||||
|
return Response(body, media_type=_RENDERERS[path][1], headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/llms.txt")
|
||||||
|
async def llms_txt(request: Request) -> Response:
|
||||||
|
"""Markdown map of the site for LLM agents (llmstxt.org)."""
|
||||||
|
return _feed_response(request, "/llms.txt")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/feed.json")
|
||||||
|
async def feed_json(request: Request) -> Response:
|
||||||
|
"""JSON Feed 1.1 of all published articles, full content."""
|
||||||
|
return _feed_response(request, "/feed.json")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/feed.xml")
|
||||||
|
async def feed_xml(request: Request) -> Response:
|
||||||
|
"""RSS 2.0 of all published articles (full content in CDATA), with an
|
||||||
|
atom:link self reference."""
|
||||||
|
return _feed_response(request, "/feed.xml")
|
||||||
@@ -17,8 +17,10 @@ import mimetypes
|
|||||||
import tempfile
|
import tempfile
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
import blake3
|
import blake3
|
||||||
|
import httpx
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from fastapi.responses import RedirectResponse, Response
|
from fastapi.responses import RedirectResponse, Response
|
||||||
from mediapreview import dispatch
|
from mediapreview import dispatch
|
||||||
@@ -213,6 +215,34 @@ def store_image(
|
|||||||
return digest
|
return digest
|
||||||
|
|
||||||
|
|
||||||
|
#: Pasted-URL image fetches (fetch_image) refuse bodies over this size.
|
||||||
|
FETCH_MAXSIZE = 20 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_image(url: str) -> str:
|
||||||
|
"""Fetch an image URL and store it like an upload (store_image's
|
||||||
|
derivative pipeline), returning the stored file name.
|
||||||
|
|
||||||
|
Server-side because arbitrary cross-origin URLs are CORS-blocked for
|
||||||
|
the browser; used by the editor socket when a card-image setting
|
||||||
|
arrives as a URL (paste). 415 for non-images, 413 over FETCH_MAXSIZE,
|
||||||
|
502 when the fetch itself fails.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(follow_redirects=True, timeout=15) as client:
|
||||||
|
r = await client.get(url)
|
||||||
|
r.raise_for_status()
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise HTTPException(502, f"image fetch failed: {e}") from e
|
||||||
|
ctype = r.headers.get("content-type", "").split(";")[0].strip().lower()
|
||||||
|
if not ctype.startswith("image/"):
|
||||||
|
raise HTTPException(415, "the URL is not an image")
|
||||||
|
if len(r.content) > FETCH_MAXSIZE:
|
||||||
|
raise HTTPException(413, "image too large")
|
||||||
|
ext = _ext(Path(urlsplit(url).path).name) or mimetypes.guess_extension(ctype) or ""
|
||||||
|
return await asyncio.to_thread(store_image, r.content, ext, derive=ext != ".gif")
|
||||||
|
|
||||||
|
|
||||||
@router.put("/_api/files/{name}")
|
@router.put("/_api/files/{name}")
|
||||||
async def upload_file(name: str, request: Request) -> dict[str, str]:
|
async def upload_file(name: str, request: Request) -> dict[str, str]:
|
||||||
"""Store an upload (image, video...) in the content-addressed store.
|
"""Store an upload (image, video...) in the content-addressed store.
|
||||||
|
|||||||
+3
-1
@@ -102,7 +102,9 @@ def select_language(
|
|||||||
return original
|
return original
|
||||||
|
|
||||||
|
|
||||||
def hybrid_items(data: Data, node: Node, path: str, lang: str) -> list[tuple[bytes | None, str]]:
|
def hybrid_items(
|
||||||
|
data: Data, node: Node, path: str, lang: str
|
||||||
|
) -> list[tuple[bytes | None, str]]:
|
||||||
"""The served hybrid as (anchor, block text) pairs: the anchor is the
|
"""The served hybrid as (anchor, block text) pairs: the anchor is the
|
||||||
ORIGINAL chunk hash behind the block (None for translation-only
|
ORIGINAL chunk hash behind the block (None for translation-only
|
||||||
addition blocks), in article order.
|
addition blocks), in article order.
|
||||||
|
|||||||
+26
-2
@@ -5,7 +5,9 @@ the page (or a category placeholder, or 404); it must be registered AFTER
|
|||||||
the fastapi-vue asset routes so built frontend files win over content slugs
|
the fastapi-vue asset routes so built frontend files win over content slugs
|
||||||
(see app.py). Every served document is recorded raw in analytics (one
|
(see app.py). Every served document is recorded raw in analytics (one
|
||||||
access-log line with its true HTTP status; classification happens at
|
access-log line with its true HTTP status; classification happens at
|
||||||
display time — see pagerite/analytics.py).
|
display time — see pagerite/analytics.py), as are robots.txt and sitemap.xml
|
||||||
|
fetches (they surface as crawler hits, since no activity message ever
|
||||||
|
follows them).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -53,7 +55,8 @@ async def front_page(request: Request) -> Response:
|
|||||||
|
|
||||||
@router.get("/sitemap.xml")
|
@router.get("/sitemap.xml")
|
||||||
async def sitemap(request: Request) -> Response:
|
async def sitemap(request: Request) -> Response:
|
||||||
"""Dynamically generate a sitemap of all published article pages."""
|
"""Dynamically generate a sitemap of all published article pages, plus
|
||||||
|
the machine-readable exports (feeds, llms.txt)."""
|
||||||
base = SITE_URL or str(request.base_url).rstrip("/")
|
base = SITE_URL or str(request.base_url).rstrip("/")
|
||||||
entries: list[tuple[str, datetime, int]] = []
|
entries: list[tuple[str, datetime, int]] = []
|
||||||
|
|
||||||
@@ -110,6 +113,26 @@ async def sitemap(request: Request) -> Response:
|
|||||||
)
|
)
|
||||||
lines.append("</urlset>")
|
lines.append("</urlset>")
|
||||||
|
|
||||||
|
# The machine-readable exports (feeds, llms.txt) are linked too, with
|
||||||
|
# the latest article's modification time as their lastmod.
|
||||||
|
if entries:
|
||||||
|
latest = max(m for _, m, _ in entries)
|
||||||
|
lastmod = (
|
||||||
|
latest.astimezone(UTC)
|
||||||
|
.replace(microsecond=0)
|
||||||
|
.isoformat()
|
||||||
|
.replace("+00:00", "Z")
|
||||||
|
)
|
||||||
|
for special in ("llms.txt", "feed.json", "feed.xml"):
|
||||||
|
lines.insert(
|
||||||
|
-1,
|
||||||
|
f" <url><loc>{xml_escape(f'{base}/{special}')}</loc>"
|
||||||
|
f"<lastmod>{lastmod}</lastmod></url>",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Recorded like a page GET: never followed by an activity message, so
|
||||||
|
# it lands in the crawler list at display time (docs/analytics.md).
|
||||||
|
_record_get(request)
|
||||||
return Response(
|
return Response(
|
||||||
"\n".join(lines),
|
"\n".join(lines),
|
||||||
media_type="application/xml",
|
media_type="application/xml",
|
||||||
@@ -124,6 +147,7 @@ async def robots_txt(request: Request) -> Response:
|
|||||||
the sitemap."""
|
the sitemap."""
|
||||||
base = SITE_URL or str(request.base_url).rstrip("/")
|
base = SITE_URL or str(request.base_url).rstrip("/")
|
||||||
body = f"User-agent: *\nAllow: /\nDisallow: /auth/\nDisallow: /_api\nSitemap: {base}/sitemap.xml\n"
|
body = f"User-agent: *\nAllow: /\nDisallow: /auth/\nDisallow: /_api\nSitemap: {base}/sitemap.xml\n"
|
||||||
|
_record_get(request)
|
||||||
return Response(
|
return Response(
|
||||||
body,
|
body,
|
||||||
media_type="text/plain",
|
media_type="text/plain",
|
||||||
|
|||||||
+7
-2
@@ -171,11 +171,16 @@ _render_gen = 0
|
|||||||
|
|
||||||
|
|
||||||
def _invalidate_pages() -> None:
|
def _invalidate_pages() -> None:
|
||||||
"""Drop cached page bodies and bump the render generation (ETags);
|
"""Drop cached page bodies (and the feed/llms.txt export bodies) and
|
||||||
any content change also re-runs translation dispatch."""
|
bump the render generation (ETags); any content change also re-runs
|
||||||
|
translation dispatch."""
|
||||||
global _render_gen
|
global _render_gen
|
||||||
_render_gen += 1
|
_render_gen += 1
|
||||||
_cached_body.cache_clear()
|
_cached_body.cache_clear()
|
||||||
|
# Local import: pagerite.feeds imports this module.
|
||||||
|
from pagerite import feeds
|
||||||
|
|
||||||
|
feeds._cached_feed.cache_clear()
|
||||||
dispatcher.schedule()
|
dispatcher.schedule()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+70
-23
@@ -379,6 +379,18 @@ def _layout(
|
|||||||
doc.link(rel="canonical", href=canonical)
|
doc.link(rel="canonical", href=canonical)
|
||||||
for hreflang, href in alternates:
|
for hreflang, href in alternates:
|
||||||
doc.link(rel="alternate", hreflang=hreflang, href=href)
|
doc.link(rel="alternate", hreflang=hreflang, href=href)
|
||||||
|
# Feed/LLM discovery links (see pagerite/feeds.py): identical on every
|
||||||
|
# page, so the positional <head> sync (swapdoc.js) is unaffected.
|
||||||
|
doc.link(
|
||||||
|
rel="alternate",
|
||||||
|
type="application/feed+json",
|
||||||
|
title="JSON Feed",
|
||||||
|
href="/feed.json",
|
||||||
|
)
|
||||||
|
doc.link(
|
||||||
|
rel="alternate", type="application/rss+xml", title="RSS feed", href="/feed.xml"
|
||||||
|
)
|
||||||
|
doc.link(rel="llms-txt", type="text/markdown", title="llms.txt", href="/llms.txt")
|
||||||
for key, value in (social or {}).items():
|
for key, value in (social or {}).items():
|
||||||
if value:
|
if value:
|
||||||
if key.startswith(("og:", "article:")):
|
if key.startswith(("og:", "article:")):
|
||||||
@@ -786,14 +798,23 @@ def banner_source(menu: dict[str, Node], path: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_image_name(data: Data, name: str) -> str:
|
||||||
|
"""The store name a card-image setting resolves to: the "@favicon"
|
||||||
|
sentinel follows the site icon (set in the site editor — "" when
|
||||||
|
unset); anything else is already a store name."""
|
||||||
|
return data.favicon if name == "@favicon" else name
|
||||||
|
|
||||||
|
|
||||||
def card_image(menu: dict[str, Node], path: str) -> tuple[str, str]:
|
def card_image(menu: dict[str, Node], path: str) -> tuple[str, str]:
|
||||||
"""The effective card image at ``path`` and which node supplied it.
|
"""The effective card image at ``path`` and which node supplied it.
|
||||||
|
|
||||||
Nearest ancestor with ``image`` set wins (the node itself first), the
|
Nearest ancestor with ``image`` set wins (the node itself first), the
|
||||||
front page — a top-level sibling of the chain — last. ("", "") when no
|
front page — a top-level sibling of the chain — last. ("", "") when no
|
||||||
node sets one: rendering falls back to mining the article HTML. The
|
node sets one: rendering falls back to mining the article HTML (which
|
||||||
source path ("" = front page) feeds the editor banner panel's inherit
|
beats an inherited image — see _card/_social_meta). The returned name
|
||||||
label.
|
is the raw setting (may be "@favicon"; resolve with
|
||||||
|
_resolve_image_name). The source path ("" = front page) feeds the
|
||||||
|
editor banner panel's inherit label.
|
||||||
"""
|
"""
|
||||||
chain = resolve(menu, path) or []
|
chain = resolve(menu, path) or []
|
||||||
segs = path.split("/")
|
segs = path.split("/")
|
||||||
@@ -1036,20 +1057,22 @@ def _walk(node: Node, path: str):
|
|||||||
def _card_large(node: Node, image: str) -> bool:
|
def _card_large(node: Node, image: str) -> bool:
|
||||||
"""Whether the card renders large (True) or small (False).
|
"""Whether the card renders large (True) or small (False).
|
||||||
|
|
||||||
Automatic: large when the image's probed store dimensions suit a large
|
Automatic: large when the image is big and wide enough for the
|
||||||
card (>= 600px wide, landscape-ish aspect 1.4–2.5), small for
|
full-card cover — wider than 600px, taller than 400px and clearly
|
||||||
small/portrait images — and, when dimensions are unknown or the image
|
wider than tall (aspect > 1.05, so square and portrait images keep
|
||||||
is external, for any present image. The node's ``large`` setting
|
the compact layout whose box they fit). When dimensions are unknown
|
||||||
(per-article, not inherited) overrides the automatic pick; None
|
or the image is external, any present image defaults to large. The
|
||||||
means automatic. Shared by twitter:card (_social_meta, which maps it
|
node's ``large`` setting (per-article, not inherited) overrides the
|
||||||
to "summary_large_image"/"summary") and the site's own cards (_card).
|
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)
|
large = bool(image)
|
||||||
if (m := re.search(r"/_f/([0-9a-f]{12})$", image)) and (
|
if (m := re.search(r"/_f/([0-9a-f]{12})$", image)) and (
|
||||||
dims := _image_dims(m.group(1))
|
dims := _image_dims(m.group(1))
|
||||||
):
|
):
|
||||||
w, h = dims
|
w, h = dims
|
||||||
large = w >= 600 and h > 0 and 1.4 <= w / h <= 2.5
|
large = w > 600 and h > 400 and w / h > 1.05
|
||||||
if node.large is not None:
|
if node.large is not None:
|
||||||
large = node.large
|
large = node.large
|
||||||
return large
|
return large
|
||||||
@@ -1071,9 +1094,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,7 +1105,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:
|
||||||
|
if name := _resolve_image_name(data, node.image):
|
||||||
image = f"/_f/{name}"
|
image = f"/_f/{name}"
|
||||||
if node.chunks and not image:
|
if node.chunks and not image:
|
||||||
md = node_markdown(data, node) or ""
|
md = node_markdown(data, node) or ""
|
||||||
@@ -1097,6 +1122,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 := _resolve_image_name(data, 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 +1258,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 +1285,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 +1369,20 @@ 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)
|
||||||
|
img = _resolve_image_name(data, img)
|
||||||
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(
|
||||||
|
|||||||
Reference in New Issue
Block a user