Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
399aa95d44 | ||
|
|
f793d21c5e | ||
|
|
fb3e6d1a04 |
@@ -12,7 +12,7 @@ Pagerite is a CMS. See `docs` for the full design and implementation details. Ke
|
|||||||
- `pagerite/` — Python backend package (hatchling build target).
|
- `pagerite/` — Python backend package (hatchling build target).
|
||||||
- `app.py` — FastAPI app and route registration.
|
- `app.py` — FastAPI app and route registration.
|
||||||
- `data.py` — msgspec Structs for the kanta database.
|
- `data.py` — msgspec Structs for the kanta database.
|
||||||
- `migrations.py` — kanta schema migrations (`migrate_vN`), e.g. v1 moves legacy in-db file blobs to the on-disk store.
|
- `migrations.py` — kanta migrations (`migrate_vN`); ALL schema/storage upgrades live here (raw state dict before struct decoding), never in the app lifespan: v1 moves legacy in-db file blobs to the on-disk store and rebuilds the legacy flat `pages` as the menu tree, v2 rewrites `/_f/{hash}.ext` image links to the extension-less form, backfills AVIF/WebP/JPEG derivatives on disk and drops the obsolete `version` field.
|
||||||
- `markdown.py` — markdown-it-py renderer.
|
- `markdown.py` — markdown-it-py renderer.
|
||||||
- `views.py` — shared page layout and rendering; theme/user-font resolution across `THEME_DIRS` / `FONT_DIRS` (cwd, site, platform data roots, then built-in `pagerite/themes/`, see `docs/themes-and-assets.md`).
|
- `views.py` — shared page layout and rendering; theme/user-font resolution across `THEME_DIRS` / `FONT_DIRS` (cwd, site, platform data roots, then built-in `pagerite/themes/`, see `docs/themes-and-assets.md`).
|
||||||
- `seed.py` — demo content, written only on first database creation.
|
- `seed.py` — demo content, written only on first database creation.
|
||||||
|
|||||||
+3
-3
@@ -8,9 +8,9 @@ The FastAPI app. FastAPI's built-in API docs are disabled (`docs_url`/`redoc_url
|
|||||||
|
|
||||||
The build mirrors the URL space — hashed immutable assets under `/_assets/`, `favicon.ico` at the site root — and an `index.html` in the build would become a `/` route, so leave it out of the build to keep `/` ours.
|
The build mirrors the URL space — hashed immutable assets under `/_assets/`, `favicon.ico` at the site root — and an `index.html` in the build would become a `/` route, so leave it out of the build to keep `/` ours.
|
||||||
|
|
||||||
Generated HTML pages (content pages, category/404 placeholders, `/_a`) go through `_html_response`: zstd-compressed per request at level 9 when the client sends `accept-encoding: zstd` (no gzip fallback; static assets are pre-compressed by the `Frontend`), with `vary: accept-encoding` set and the ETag kept identical across encodings so `if-none-match` revalidation still works. In production the rendered bodies are cached in an LRU keyed by everything the output depends on — page kind, path, the site origin (social meta), encoding, and `data.version`, which bumps on every content/settings change and so transparently invalidates the whole cache. The cache is bypassed in dev, where theme/design CSS is re-read from disk per request. Content pages carry an ETag built from the node's modified timestamp and `data.version`; `/_a` instead gets a blake3 hash of the rendered body (it has no Node), with matching `if-none-match` revalidations answered by a 304.
|
Generated HTML pages (content pages, category/404 placeholders, `/_a`) go through `_html_response`: zstd-compressed per request at level 9 when the client sends `accept-encoding: zstd` (no gzip fallback; static assets are pre-compressed by the `Frontend`), with `vary: accept-encoding` set and the ETag kept identical across encodings so `if-none-match` revalidation still works. In production the rendered bodies are cached in an LRU keyed by everything the output depends on — page kind, path, the site origin (social meta), encoding — and cleared wholesale by `_invalidate_pages()` on every content/settings change, which also bumps the in-memory render generation. The cache is bypassed in dev, where theme/design CSS is re-read from disk per request. Content pages carry an ETag built from the node's modified timestamp and the render generation; `/_a` instead gets a blake3 hash of the rendered body (it has no Node), with matching `if-none-match` revalidations answered by a 304.
|
||||||
|
|
||||||
Uploaded files, seed assets and fetched external-site favicons live in the `FileStore`: content-addressed files on disk under `<hostname>/files/` (`PAGERITE_FILES`), fully cached in RAM at startup — both the raw body and a zstd-compressed copy (kept only when smaller). `GET /_f/{name}` serves from the RAM cache with immutable caching, answering the zstd variant when the client accepts it; the name is the ETag. Legacy databases that still carry blobs in a `files` kanta field are migrated to disk by `pagerite/migrations.py::migrate_v1` (kanta's `migrate_vN` mechanism, wired via `Kanta(..., migrations="pagerite.migrations")`), which pops the field from the raw state before struct decoding.
|
Uploaded files, seed assets and fetched external-site favicons live in the `FileStore`: content-addressed files on disk under `<hostname>/files/` (`PAGERITE_FILES`), fully cached in RAM at startup — both the raw body and a zstd-compressed copy (kept only when smaller). `GET /_f/{name}` serves from the RAM cache with immutable caching, answering the zstd variant when the client accepts it; the name is the ETag. Uploaded raster images (and rasterized SVGs) are stored as `<hash>.orig<ext>` (internal only, never served) plus AVIF, WebP and JPEG derivatives, and pages link the extension-less `/_f/{hash}`: the server serves a format only when the Accept header lists it explicitly (`image/avif` → AVIF, `image/webp` → WebP, otherwise — including `*/*` — JPEG), with `vary: accept`; an explicit extension pins the format. `migrate_v2` rewrites old `/_f/{hash}.avif` article links to the bare form, backfills missing derivatives on disk, and drops the obsolete `version` field. Legacy databases that still carry blobs in a `files` kanta field or a flat `pages` store are migrated by `pagerite/migrations.py::migrate_v1` (kanta's `migrate_vN` mechanism, wired via `Kanta(..., migrations="pagerite.migrations")`), which rewrites the raw state before struct decoding — all schema/storage upgrades live in that module, none in the app lifespan.
|
||||||
|
|
||||||
## `data.py`
|
## `data.py`
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ markdown-it-py renderer (html passthrough + attrs, footnote, deflist, tasklists,
|
|||||||
|
|
||||||
The shared page layout as an html5tagger `Template` with placeholders (`Title`, `Brand`, `Banner`, `Nav`, `Sidebar`, `Main`), nav rendering straight from the `Data.menu` tree (siblings sorted by `Node.order`; nav links to content-less labels point at their first child via `first_leaf`, the first published descendant with content), and page/404 rendering.
|
The shared page layout as an html5tagger `Template` with placeholders (`Title`, `Brand`, `Banner`, `Nav`, `Sidebar`, `Main`), nav rendering straight from the `Data.menu` tree (siblings sorted by `Node.order`; nav links to content-less labels point at their first child via `first_leaf`, the first published descendant with content), and page/404 rendering.
|
||||||
|
|
||||||
Content pages get SEO/social meta (description, canonical link, Open Graph + twitter card) from heuristics over the rendered article: the description is the first paragraph's text, the share image prefers a `{.hero}`-classed image, then the first raster `<img>`, then the first SVG; the first `<video>` yields `og:video`; URLs are made absolute with the site origin (`SITE_URL` — `https://<hostname>` from the CLI hostname argument; on localhost the request's own base URL is the fallback); `article:published/modified_time` come from `Node.created`/`modified`. 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 share image prefers a `{.hero}`-classed image, then the first raster `<img>`, then the first SVG; the first `<video>` yields `og:video`; URLs are made absolute with the site origin (`SITE_URL` — `https://<hostname>` from the CLI hostname argument; on localhost the request's own base URL is the fallback); `article:published/modified_time` come from `Node.created`/`modified`. Additionally `twitter:image` pins extension-less `/_f/{hash}` share images to the `.webp` variant — X only honors WebP via twitter:image (not og:image) and its scraper cannot be trusted to negotiate via Accept. The page title is injected as `# {title}` when the markdown has no h1 of its own, so it never appears twice (it always supplies `<title>` and nav labels).
|
||||||
|
|
||||||
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).
|
||||||
|
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ The site structure is stored in the kanta database managed by `pagerite/data.py`
|
|||||||
|
|
||||||
`Node.content` is the Markdown page, or None for a pure category label whose URL renders a 404 listing its children as cards (while nav links to it point at its first child); every label's title and slug are editable. A page with published children — a category page — lists them as cards after its markdown content; the sidebar sub-navigation renders only from the second level down, never on main-level pages.
|
`Node.content` is the Markdown page, or None for a pure category label whose URL renders a 404 listing its children as cards (while nav links to it point at its first child); every label's title and slug are editable. A page with published children — a category page — lists them as cards after its markdown content; the sidebar sub-navigation renders only from the second level down, never on main-level pages.
|
||||||
|
|
||||||
Siblings order by the fractional `Node.order` key: a moved item gets a fresh key relative to its new siblings, all others keep theirs. `resolve`/`find_slot` walk the tree by path; moves are slot detach/attach carrying the whole subtree. Legacy flat `Data.pages` (pre-tree databases) migrates into `menu` on startup. The app owns the `Data` object; reads are plain attribute access, writes in `kanta.transaction(...)`.
|
Siblings order by the fractional `Node.order` key: a moved item gets a fresh key relative to its new siblings, all others keep theirs. `resolve`/`find_slot` walk the tree by path; moves are slot detach/attach carrying the whole subtree. Legacy flat `pages` (pre-tree databases) migrates into `menu` via `migrate_v1`. The app owns the `Data` object; reads are plain attribute access, writes in `kanta.transaction(...)`.
|
||||||
|
|
||||||
`Data.version` is bumped on every write and embedded in page ETags so nav-affecting changes invalidate caches.
|
Every content/settings write calls `_invalidate_pages()` in app.py, which clears the rendered-body LRU and bumps an in-memory render generation embedded in page ETags, so nav-affecting changes invalidate caches. (This used to be a persisted `Data.version` counter — cache invalidation is not database state, so the field was dropped; old databases lose the key on re-serialization.)
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
Files are content-addressed (blake3[:12] + extension) and stored **on disk** under `<hostname>/files/` (path from `PAGERITE_FILES`), served at `/_f/{name}` with immutable caching. Uploaded raster images (except SVG/GIF) are stored as a pair: the untouched original under `<hash>.orig<ext>` and a mediapreview-recompressed AVIF derivative (`<hash>.avif`, thumbnailed to `IMAGE_MAXSIZE` at `IMAGE_QUALITY`) which is the externally linked file; deleting either name removes the pair. the `FileStore` in app.py caches every file in RAM, both uncompressed and zstd-compressed (the compressed copy only when smaller), so `/_f` answers both encodings without disk reads. Pages reference files by absolute `/_f/` URLs so hierarchy moves never break them. Pre-refactor databases kept the blobs in a `Data.files` kanta field; the kanta migration `pagerite/migrations.py::migrate_v1` writes them to disk on open and drops the field (removed from `Data`). Fetched favicons of external analytics sites live in the same store (see `docs/analytics.md`).
|
Files are content-addressed (blake3[:12] + extension) and stored **on disk** under `<hostname>/files/` (path from `PAGERITE_FILES`), served at `/_f/{name}` with immutable caching. Uploaded raster images (except GIF) and SVGs (rasterized) get a set of derivatives: the untouched original under `<hash>.orig<ext>` (internal only — it may carry EXIF data and is never served; SVG originals stay servable as `<hash>.svg`), a mediapreview-recompressed AVIF (`<hash>.avif`, thumbnailed to `IMAGE_MAXSIZE` at `IMAGE_QUALITY`), and WebP/JPEG fallbacks re-encoded from the AVIF at lower quality (`IMAGE_WEBP_QUALITY`/`IMAGE_JPG_QUALITY`, chosen for similar-or-smaller file size). Pages link the bare `/_f/<hash>` and the server negotiates by Accept header: a format is served only when listed explicitly (`image/avif` → AVIF, `image/webp` → WebP, anything else including `image/*` and `*/*` → JPEG); an explicit extension in the URL pins the format. Responses carry `vary: accept`. Favicons uploaded in settings go through the same pipeline at `FAVICON_MAXSIZE` (192px). Existing databases are updated by `migrate_v2` (link rewrite plus on-disk derivative backfill). Deleting any name of a hash removes the whole group. The `FileStore` in app.py caches every file in RAM, both uncompressed and zstd-compressed (the compressed copy only when smaller), so `/_f` answers both encodings without disk reads. Pages reference files by absolute `/_f/` URLs so hierarchy moves never break them. Pre-refactor databases kept the blobs in a `Data.files` kanta field; the kanta migration `pagerite/migrations.py::migrate_v1` writes them to disk on open and drops the field (removed from `Data`). Fetched favicons of external analytics sites live in the same store (see `docs/analytics.md`).
|
||||||
|
|
||||||
## Banners
|
## Banners
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Pagerite is a single-user CMS/blog. This document records the initial high-level
|
|||||||
- Content is written in **Markdown** with powerful extensions (tables, footnotes, code highlighting, etc.).
|
- Content is written in **Markdown** with powerful extensions (tables, footnotes, code highlighting, etc.).
|
||||||
- **Embedded HTML is passed through unfiltered**, including inline scripts and other dynamic content the author wants to post. This is safe by the single-trusted-author assumption above.
|
- **Embedded HTML is passed through unfiltered**, including inline scripts and other dynamic content the author wants to post. This is safe by the single-trusted-author assumption above.
|
||||||
- Renderer: **markdown-it-py** with mdit-py-plugins (footnotes, definition lists, task lists, brace-attributes, admonitions and `::: name` containers — generic `<div class="name">` wrappers (the name may be followed by brace attributes: `::: aside {.right}`), of which `::: aside` floats as a muted side box and `{.margin}` / `::: margin` marks any block a margin note — on all but phone widths they float in the side zone at the article's left (the region the nav sidebar overlays, or the sidebar's own track when the layout reserves one) and the text never moves — and `::: nocols` opts its section out of column layout; tables and strikethrough from the default preset), GitHub-style alerts (`> [!NOTE]` / TIP / IMPORTANT / WARNING / CAUTION, rendered in the admonition callout styling), with `html=True` for raw passthrough, `typographer=True` for SmartyPants-style replacements in body text (curly quotes, `--` / `---` → en / em dashes, `...` → ellipsis, `(c)` → ©, etc.), and `breaks=True` so single line breaks inside paragraphs become `<br>` — including inside blockquotes, where every newline is kept and a blank `>` line starts a new paragraph. Code spans/blocks and raw HTML are left untouched. Fenced code blocks are highlighted server-side with **Pygments** (`nowrap` spans styled by `/_assets/pygments-*.css`, which maps every token class onto the `--code-*` variables; the base stylesheet defines light and dark palette sets resolved via `light-dark()`, so each theme gets the set matching its `color-scheme` and may only retint `--code-bg` to keep the well in the page's color family); a JS copy button appears on hover. Should this prove limiting, we implement our own renderer on top of html5tagger, which we already use for all HTML generation.
|
- Renderer: **markdown-it-py** with mdit-py-plugins (footnotes, definition lists, task lists, brace-attributes, admonitions and `::: name` containers — generic `<div class="name">` wrappers (the name may be followed by brace attributes: `::: aside {.right}`), of which `::: aside` floats as a muted side box and `{.margin}` / `::: margin` marks any block a margin note — on all but phone widths they float in the side zone at the article's left (the region the nav sidebar overlays, or the sidebar's own track when the layout reserves one) and the text never moves — and `::: nocols` opts its section out of column layout; tables and strikethrough from the default preset), GitHub-style alerts (`> [!NOTE]` / TIP / IMPORTANT / WARNING / CAUTION, rendered in the admonition callout styling), with `html=True` for raw passthrough, `typographer=True` for SmartyPants-style replacements in body text (curly quotes, `--` / `---` → en / em dashes, `...` → ellipsis, `(c)` → ©, etc.), and `breaks=True` so single line breaks inside paragraphs become `<br>` — including inside blockquotes, where every newline is kept and a blank `>` line starts a new paragraph. Code spans/blocks and raw HTML are left untouched. Fenced code blocks are highlighted server-side with **Pygments** (`nowrap` spans styled by `/_assets/pygments-*.css`, which maps every token class onto the `--code-*` variables; the base stylesheet defines light and dark palette sets resolved via `light-dark()`, so each theme gets the set matching its `color-scheme` and may only retint `--code-bg` to keep the well in the page's color family); a JS copy button appears on hover. Should this prove limiting, we implement our own renderer on top of html5tagger, which we already use for all HTML generation.
|
||||||
- **Files are content-addressed.** Uploads (`PUT /_api/files/{filename}`) are stored on disk (`<hostname>/files/`, RAM-cached uncompressed + zstd) by content hash — blake3, first 6 bytes hex + original extension — and served immutable from `/_f/{hash}.ext`. Raster images (not SVG/GIF) are recompressed via mediapreview: the original is kept as `/_f/{hash}.orig{ext}` while pages link the thumbnailed AVIF derivative `/_f/{hash}.avif`. Absolute URLs that survive page renames and dedupe identical content; pages no longer own files. An image standing alone in its paragraph becomes a block `<figure>` — with `<figcaption>` when it has a title; images inline with text and raw `<img>` HTML stay plain inline images. Positioning is by attribute classes: `{.right}` — `{.right}`, `{.left}` float at 30% of the text column (the caption wraps within it; an explicit `width=300` makes the figure shrink-wrap the image instead), `{.margin}` makes it a margin note, floating in the side zone left of the text on all but phone widths, `{.wide}` goes full bleed (viewport edge to edge, or up to the docked editor; the sidebar stacks on top of it); plain attributes like `width=300` work too. The same brace syntax on a block's last line (no blank line between) applies to the whole block: a paragraph ending with `{.wide}` becomes a full-width element that breaks out of the column layout, and space-separated at the end of a text line (`some text {.small}`) the braces likewise belong to the block — a space is what keeps them off an image or link ending the line, which keep their own directly-attached attrs; text size classes `{.small}` / `{.large}` / `{.huge}` (em-based) work on any block; written on the line after a block it applies to that preceding block — this is how headings, `::: containers` and code fences take classes (a wide code fence goes full bleed like a wide figure). Headings (h1/h2) clear floats, so images never overflow into the next section.
|
- **Files are content-addressed.** Uploads (`PUT /_api/files/{filename}`) are stored on disk (`<hostname>/files/`, RAM-cached uncompressed + zstd) by content hash — blake3, first 6 bytes hex + original extension — and served immutable from `/_f/…`. Raster images (not GIF) and SVGs (rasterized) are recompressed via mediapreview: the original is kept as `{hash}.orig{ext}` (internal only, never served — it may carry EXIF data; SVG originals stay servable as `{hash}.svg`) while pages link the extension-less `/_f/{hash}` and the server picks from the derivatives (`{hash}.avif` / `{hash}.webp` / `{hash}.jpg`) by Accept header — a format only when listed explicitly (`image/avif` → AVIF, `image/webp` → WebP, otherwise JPEG), with `vary: accept`; an explicit extension in the URL pins the format. Absolute URLs that survive page renames and dedupe identical content; pages no longer own files. An image standing alone in its paragraph becomes a block `<figure>` — with `<figcaption>` when it has a title; images inline with text and raw `<img>` HTML stay plain inline images. Positioning is by attribute classes: `{.right}` — `{.right}`, `{.left}` float at 30% of the text column (the caption wraps within it; an explicit `width=300` makes the figure shrink-wrap the image instead), `{.margin}` makes it a margin note, floating in the side zone left of the text on all but phone widths, `{.wide}` goes full bleed (viewport edge to edge, or up to the docked editor; the sidebar stacks on top of it); plain attributes like `width=300` work too. The same brace syntax on a block's last line (no blank line between) applies to the whole block: a paragraph ending with `{.wide}` becomes a full-width element that breaks out of the column layout, and space-separated at the end of a text line (`some text {.small}`) the braces likewise belong to the block — a space is what keeps them off an image or link ending the line, which keep their own directly-attached attrs; text size classes `{.small}` / `{.large}` / `{.huge}` (em-based) work on any block; written on the line after a block it applies to that preceding block — this is how headings, `::: containers` and code fences take classes (a wide code fence goes full bleed like a wide figure). Headings (h1/h2) clear floats, so images never overflow into the next section.
|
||||||
|
|
||||||
## Page structure and navigation
|
## Page structure and navigation
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ Pagerite is a single-user CMS/blog. This document records the initial high-level
|
|||||||
- **Page mode** — the 🖊️ next to a page's heading (including 404s, which is how new pages start) opens a CodeMirror Markdown editor docked to the left of the article: the panel is fixed to the viewport's left edge (its top tracks the banner's bottom until the banner scrolls away), the content shifts right and the sidebar hides while editing. Preview renders server-side per keystroke (no debouncing) and swaps the whole visible article content in one go (the edit pen and category cards survive the swap).
|
- **Page mode** — the 🖊️ next to a page's heading (including 404s, which is how new pages start) opens a CodeMirror Markdown editor docked to the left of the article: the panel is fixed to the viewport's left edge (its top tracks the banner's bottom until the banner scrolls away), the content shifts right and the sidebar hides while editing. Preview renders server-side per keystroke (no debouncing) and swaps the whole visible article content in one go (the edit pen and category cards survive the swap).
|
||||||
- **Site mode** — the ⚙️ at the top right (after the 📊 analytics link, before login) opens a panel with the site **brand** (applied to the header live), a **theme** selector (swapping the theme stylesheet in place), a **page transition** selector (`cube`/`crossfade`, swapping `#pagerite-transition` in place), **font** picks (heading/body/brand — stored as plain `:root` rows inside the custom CSS, referencing the base stylesheet's per-family font variables), a **site-wide custom CSS** field (injected into `<style id="pagerite-user">` in the live page head and swapped during fetch-navigation), the page's **banner design** selector (inherit / none / any design found on disk, inherited by children), the page's **banner HTML** field (supplementing the design, previewed into the real banner region, so you see exactly which banner you're editing) and the **structure tree**. Everything saves immediately as you edit — no save button, no edit mode.
|
- **Site mode** — the ⚙️ at the top right (after the 📊 analytics link, before login) opens a panel with the site **brand** (applied to the header live), a **theme** selector (swapping the theme stylesheet in place), a **page transition** selector (`cube`/`crossfade`, swapping `#pagerite-transition` in place), **font** picks (heading/body/brand — stored as plain `:root` rows inside the custom CSS, referencing the base stylesheet's per-family font variables), a **site-wide custom CSS** field (injected into `<style id="pagerite-user">` in the live page head and swapped during fetch-navigation), the page's **banner design** selector (inherit / none / any design found on disk, inherited by children), the page's **banner HTML** field (supplementing the design, previewed into the real banner region, so you see exactly which banner you're editing) and the **structure tree**. Everything saves immediately as you edit — no save button, no edit mode.
|
||||||
- Clicking a pen again closes the editor (without saving; a dirty preview reloads the page). The pens are `<button>`s wired up by `pagerite.js` — editing is an action, not a navigation. The editor's WebSocket **reconnects automatically** with local text and pending saves preserved. (All users are trusted authors for now; access control later with SSO.)
|
- Clicking a pen again closes the editor (without saving; a dirty preview reloads the page). The pens are `<button>`s wired up by `pagerite.js` — editing is an action, not a navigation. The editor's WebSocket **reconnects automatically** with local text and pending saves preserved. (All users are trusted authors for now; access control later with SSO.)
|
||||||
- **CodeMirror 6** for Markdown editing (no WYSIWYG), title/published controls. Images can be pasted straight into the editor or chosen via a file input: they upload to the content store (`PUT /_api/files/...`) and insert `` at the cursor.
|
- **CodeMirror 6** for Markdown editing (no WYSIWYG), title/published controls. Images can be pasted straight into the editor or chosen via a file input: they upload to the content store (`PUT /_api/files/...`) and insert `` at the cursor.
|
||||||
- The **structure panel** (vue-draggable tree of the whole site, in site mode) covers page management: reorder any menu level, drag across sections, add, delete (two clicks: the button arms, then deletes — no dialogs). Every node is a real label — content-less category rows offer a ➕ to give them a landing page. Deleting a category removes only its landing page (the label and its subpages stay). Every non-empty list ends with a ➕ row that starts a new page as a local-only tree row at that level; the row can be dragged into place before its title and slug are filled in and is persisted only on commit. While dragging, these ➕ rows double as "end of this list" drop targets; dropping ON the lower part of a row makes the page that row's first child (even a leaf's, creating a sublist), while a row's exposed top edge inserts a sibling before it. A dragged row's indentation previews the target list's depth. Rows are always editable: titles save while typing, slug edits commit on blur/Enter since they rename the path (moving the whole subtree). The front page is the root row with an empty slug — renaming it away leaves no front page ("/" redirects to the first nav item), and giving another top-level row the empty slug makes it the front page.
|
- The **structure panel** (vue-draggable tree of the whole site, in site mode) covers page management: reorder any menu level, drag across sections, add, delete (two clicks: the button arms, then deletes — no dialogs). Every node is a real label — content-less category rows offer a ➕ to give them a landing page. Deleting a category removes only its landing page (the label and its subpages stay). Every non-empty list ends with a ➕ row that starts a new page as a local-only tree row at that level; the row can be dragged into place before its title and slug are filled in and is persisted only on commit. While dragging, these ➕ rows double as "end of this list" drop targets; dropping ON the lower part of a row makes the page that row's first child (even a leaf's, creating a sublist), while a row's exposed top edge inserts a sibling before it. A dragged row's indentation previews the target list's depth. Rows are always editable: titles save while typing, slug edits commit on blur/Enter since they rename the path (moving the whole subtree). The front page is the root row with an empty slug — renaming it away leaves no front page ("/" redirects to the first nav item), and giving another top-level row the empty slug makes it the front page.
|
||||||
- Preview and saving go over a **WebSocket** (`/_api/ws/editor`) with a stateless JSON protocol (`open`/`render`/`save`; on save all fields are optional and absent ones keep their old values, `move_from` renames), avoiding REST polling and races. Rendering always stays server-side.
|
- Preview and saving go over a **WebSocket** (`/_api/ws/editor`) with a stateless JSON protocol (`open`/`render`/`save`; on save all fields are optional and absent ones keep their old values, `move_from` renames), avoiding REST polling and races. Rendering always stays server-side.
|
||||||
- A REST API also exists for scripting, all under `/_api/`: `GET pages` (the full tree), `PUT/DELETE pages/{path}`, `GET/PUT settings` (site brand, theme and custom CSS), `POST structure` (reorder/move/retitle), file upload/removal via `PUT/DELETE files/{name}`.
|
- A REST API also exists for scripting, all under `/_api/`: `GET pages` (the full tree), `PUT/DELETE pages/{path}`, `GET/PUT settings` (site brand, theme and custom CSS), `POST structure` (reorder/move/retitle), file upload/removal via `PUT/DELETE files/{name}`.
|
||||||
|
|||||||
@@ -26,7 +26,16 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
appType: 'mpa', // no SPA fallback; every HTML page is served by FastAPI
|
appType: 'mpa', // no SPA fallback; every HTML page is served by FastAPI
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
// All components are precompiled SFCs — drop the runtime template
|
||||||
|
// compiler (~60 kB min) from the bundle.
|
||||||
|
vue: 'vue/dist/vue.runtime.esm-bundler.js',
|
||||||
|
},
|
||||||
|
},
|
||||||
build: {
|
build: {
|
||||||
|
// The main editor bundle (CodeMirror + Vue) is intentionally one chunk.
|
||||||
|
chunkSizeWarningLimit: 1200,
|
||||||
// Mirror the URL space in the build output: hashed files land under
|
// Mirror the URL space in the build output: hashed files land under
|
||||||
// frontend-build/_assets/ and the Frontend serves the build directory
|
// frontend-build/_assets/ and the Frontend serves the build directory
|
||||||
// at the site root (frontend/public/favicon.ico -> /favicon.ico).
|
// at the site root (frontend/public/favicon.ico -> /favicon.ico).
|
||||||
|
|||||||
+177
-69
@@ -80,10 +80,18 @@ analytics_store = analytics.Store(ANALYTICS_PATH)
|
|||||||
# files on disk under hash-prefixed names, cached in RAM, served at /_f/.
|
# files on disk under hash-prefixed names, cached in RAM, served at /_f/.
|
||||||
FILES_DIR = Path(os.getenv("PAGERITE_FILES", str(SITE_DIR / "files")))
|
FILES_DIR = Path(os.getenv("PAGERITE_FILES", str(SITE_DIR / "files")))
|
||||||
|
|
||||||
# Uploaded raster images are thumbnailed to this size and recompressed to
|
# Uploaded images are thumbnailed to this size and recompressed to AVIF
|
||||||
# AVIF; the untouched original is kept alongside as ``<hash>.orig<ext>``.
|
# (primary), with WebP and JPEG fallbacks re-encoded from the AVIF at
|
||||||
|
# somewhat lower quality (similar or smaller file size); the untouched
|
||||||
|
# original is kept alongside as ``<hash>.orig<ext>`` (never served).
|
||||||
IMAGE_MAXSIZE = 1920
|
IMAGE_MAXSIZE = 1920
|
||||||
IMAGE_QUALITY = 60
|
IMAGE_QUALITY = 60
|
||||||
|
IMAGE_WEBP_QUALITY = 50
|
||||||
|
IMAGE_JPG_QUALITY = 55
|
||||||
|
|
||||||
|
# Favicons get the same derivatives but thumbnailed much smaller — 192px
|
||||||
|
# is plenty (browsers scale down for the 16x16 tab icon themselves).
|
||||||
|
FAVICON_MAXSIZE = 192
|
||||||
|
|
||||||
# Live WebSocket clients for the analytics stream.
|
# Live WebSocket clients for the analytics stream.
|
||||||
_analytics_ws_clients: set[WebSocket] = set()
|
_analytics_ws_clients: set[WebSocket] = set()
|
||||||
@@ -199,9 +207,21 @@ def _hash_name(body: bytes, orig: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _store_seed_file(markdown: str, banner: str, orig: str, body: bytes) -> tuple[str, str]:
|
def _store_seed_file(markdown: str, banner: str, orig: str, body: bytes) -> tuple[str, str]:
|
||||||
"""Store a seed file content-addressed and point references at /_f/."""
|
"""Store a seed file content-addressed and point references at /_f/.
|
||||||
name = _hash_name(body, orig)
|
|
||||||
file_store.put(name, body)
|
Images get the same AVIF/WebP/JPEG derivatives as uploads and are
|
||||||
|
linked extension-less; other content is stored as-is with its
|
||||||
|
extension."""
|
||||||
|
digest = blake3.blake3(body).hexdigest()[:12]
|
||||||
|
derivatives = None if _ext(orig) == ".gif" else _image_derivatives(body, _ext(orig))
|
||||||
|
if derivatives is None:
|
||||||
|
file_store.put(digest + _ext(orig), body)
|
||||||
|
name = digest + _ext(orig)
|
||||||
|
else:
|
||||||
|
file_store.put(f"{digest}.svg" if _ext(orig) == ".svg" else f"{digest}.orig{_ext(orig)}", body)
|
||||||
|
for fmt, variant in derivatives.items():
|
||||||
|
file_store.put(f"{digest}.{fmt}", variant)
|
||||||
|
name = digest
|
||||||
markdown = markdown.replace(f"]({orig}", f"](/_f/{name}")
|
markdown = markdown.replace(f"]({orig}", f"](/_f/{name}")
|
||||||
banner = banner.replace(f'src="/{orig}"', f'src="/_f/{name}"')
|
banner = banner.replace(f'src="/{orig}"', f'src="/_f/{name}"')
|
||||||
banner = banner.replace(f'src="{orig}"', f'src="/_f/{name}"')
|
banner = banner.replace(f'src="{orig}"', f'src="/_f/{name}"')
|
||||||
@@ -241,24 +261,6 @@ def _remove_page_content(menu: dict[str, Node], path: str) -> None:
|
|||||||
del slot[0][slot[1]]
|
del slot[0][slot[1]]
|
||||||
|
|
||||||
|
|
||||||
def _migrate_legacy() -> None:
|
|
||||||
"""Rebuild the legacy flat page store as a tree (one-time migration)."""
|
|
||||||
if not data.pages:
|
|
||||||
return
|
|
||||||
with kanta.transaction("migrate pages to tree"):
|
|
||||||
for path, page in data.pages.items():
|
|
||||||
node = _ensure(data.menu, path)
|
|
||||||
node.title = page.title
|
|
||||||
node.content = page.markdown
|
|
||||||
node.banner = page.banner
|
|
||||||
node.published = page.published
|
|
||||||
node.order = page.order
|
|
||||||
node.created = page.created
|
|
||||||
node.modified = page.modified
|
|
||||||
data.pages.clear()
|
|
||||||
data.version += 1
|
|
||||||
|
|
||||||
|
|
||||||
@kanta.bootstrap
|
@kanta.bootstrap
|
||||||
def _seed(data: Data) -> None:
|
def _seed(data: Data) -> None:
|
||||||
"""Write the demo pages on database creation (never on existing dbs)."""
|
"""Write the demo pages on database creation (never on existing dbs)."""
|
||||||
@@ -280,10 +282,9 @@ def _seed(data: Data) -> None:
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
||||||
"""Open the database, migrate legacy content, load assets, load GeoIP."""
|
"""Open the database (migrations run inside kanta.open), load assets, load GeoIP."""
|
||||||
await kanta.open()
|
await kanta.open()
|
||||||
await asyncio.to_thread(file_store.load)
|
await asyncio.to_thread(file_store.load)
|
||||||
_migrate_legacy()
|
|
||||||
await frontend.load()
|
await frontend.load()
|
||||||
# Decompress/open the DB-IP MMDB once at startup. Lookups are then
|
# Decompress/open the DB-IP MMDB once at startup. Lookups are then
|
||||||
# read-only and safe to run in background ``to_thread`` workers.
|
# read-only and safe to run in background ``to_thread`` workers.
|
||||||
@@ -361,11 +362,11 @@ class FileStore:
|
|||||||
self._cache[name] = self._entry(body)
|
self._cache[name] = self._entry(body)
|
||||||
|
|
||||||
def delete(self, name: str) -> None:
|
def delete(self, name: str) -> None:
|
||||||
"""Delete a file plus its derivative/original counterpart, if any.
|
"""Delete a file plus its derivatives/original counterparts, if any.
|
||||||
|
|
||||||
An image upload is stored as a pair sharing the hash prefix
|
An image upload is stored as a group sharing the hash prefix
|
||||||
(``<hash>.orig.<ext>`` + ``<hash>.avif``); deleting either removes
|
(``<hash>.orig.<ext>`` + ``<hash>.avif/.webp/.jpg``); deleting any
|
||||||
both.
|
of the names removes them all.
|
||||||
"""
|
"""
|
||||||
stem = name.partition(".")[0]
|
stem = name.partition(".")[0]
|
||||||
for key in [k for k in self._cache if k.partition(".")[0] == stem]:
|
for key in [k for k in self._cache if k.partition(".")[0] == stem]:
|
||||||
@@ -391,12 +392,25 @@ def _render_html(kind: str, path: str, base_url: str) -> str:
|
|||||||
return views.render_analytics(data.menu, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html, transition=data.transition)
|
return views.render_analytics(data.menu, data.brand, data.custom_css, data.theme, data.favicon, data.brand_html, transition=data.transition)
|
||||||
|
|
||||||
|
|
||||||
|
# Render generation: bumped (and the body cache cleared) by every
|
||||||
|
# content/settings write, so page ETags and cached copies invalidate when
|
||||||
|
# navigation-affecting changes happen. In-memory only — not database state.
|
||||||
|
_render_gen = 0
|
||||||
|
|
||||||
|
|
||||||
|
def _invalidate_pages() -> None:
|
||||||
|
"""Drop cached page bodies and bump the render generation (ETags)."""
|
||||||
|
global _render_gen
|
||||||
|
_render_gen += 1
|
||||||
|
_cached_body.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=128)
|
@lru_cache(maxsize=128)
|
||||||
def _cached_body(kind: str, path: str, base_url: str, version: int, zstd: bool) -> bytes:
|
def _cached_body(kind: str, path: str, base_url: str, zstd: bool) -> bytes:
|
||||||
"""Rendered page body. Every input the output depends on is in the key:
|
"""Rendered page body; cleared by _invalidate_pages on any
|
||||||
data.version bumps on any content/settings change, base_url feeds the
|
content/settings change. base_url feeds the social meta URLs and zstd
|
||||||
social meta URLs, and zstd selects the stored encoding (both variants
|
selects the stored encoding (both variants are cached rather than
|
||||||
are cached rather than re-compressed).
|
re-compressed).
|
||||||
"""
|
"""
|
||||||
body = _render_html(kind, path, base_url).encode()
|
body = _render_html(kind, path, base_url).encode()
|
||||||
return _zstd.compress(body) if zstd else body
|
return _zstd.compress(body) if zstd else body
|
||||||
@@ -433,8 +447,8 @@ def _html_response(
|
|||||||
identity = _render_html(kind, path, base_url).encode()
|
identity = _render_html(kind, path, base_url).encode()
|
||||||
body = _zstd.compress(identity) if zstd else identity
|
body = _zstd.compress(identity) if zstd else identity
|
||||||
else:
|
else:
|
||||||
identity = _cached_body(kind, path, base_url, data.version, False)
|
identity = _cached_body(kind, path, base_url, False)
|
||||||
body = _cached_body(kind, path, base_url, data.version, True) if zstd else identity
|
body = _cached_body(kind, path, base_url, True) if zstd else identity
|
||||||
h = dict(headers or {})
|
h = dict(headers or {})
|
||||||
if zstd:
|
if zstd:
|
||||||
h["vary"] = "accept-encoding"
|
h["vary"] = "accept-encoding"
|
||||||
@@ -502,7 +516,7 @@ async def save_page(path: str, page: PageIn) -> None:
|
|||||||
if page.banner is not None:
|
if page.banner is not None:
|
||||||
node.banner = page.banner
|
node.banner = page.banner
|
||||||
node.modified = datetime.now(UTC)
|
node.modified = datetime.now(UTC)
|
||||||
data.version += 1
|
_invalidate_pages()
|
||||||
|
|
||||||
|
|
||||||
class StructureOp(BaseModel):
|
class StructureOp(BaseModel):
|
||||||
@@ -563,7 +577,7 @@ async def update_structure(op: StructureOp) -> None:
|
|||||||
elif op.order is not None:
|
elif op.order is not None:
|
||||||
node.order = op.order
|
node.order = op.order
|
||||||
node.modified = datetime.now(UTC)
|
node.modified = datetime.now(UTC)
|
||||||
data.version += 1
|
_invalidate_pages()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/_api/settings")
|
@app.get("/_api/settings")
|
||||||
@@ -597,32 +611,44 @@ class SettingsIn(BaseModel):
|
|||||||
|
|
||||||
@app.put("/_api/settings", status_code=204)
|
@app.put("/_api/settings", status_code=204)
|
||||||
async def put_settings(settings: SettingsIn) -> None:
|
async def put_settings(settings: SettingsIn) -> None:
|
||||||
"""Update site-wide settings; bumps the version so ETags invalidate."""
|
"""Update site-wide settings; invalidates cached pages and ETags."""
|
||||||
with kanta.transaction("update settings"):
|
with kanta.transaction("update settings"):
|
||||||
data.brand = settings.brand
|
data.brand = settings.brand
|
||||||
data.brand_html = settings.brand_html
|
data.brand_html = settings.brand_html
|
||||||
data.theme = settings.theme
|
data.theme = settings.theme
|
||||||
data.custom_css = settings.custom_css
|
data.custom_css = settings.custom_css
|
||||||
data.transition = settings.transition
|
data.transition = settings.transition
|
||||||
data.version += 1
|
_invalidate_pages()
|
||||||
|
|
||||||
|
|
||||||
@app.put("/_api/settings/favicon")
|
@app.put("/_api/settings/favicon")
|
||||||
async def put_favicon(request: Request) -> dict[str, str]:
|
async def put_favicon(request: Request) -> dict[str, str]:
|
||||||
"""Upload a favicon into the content-addressed store and activate it.
|
"""Upload a favicon into the content-addressed store and activate it.
|
||||||
|
|
||||||
Raw image body (ico/png/svg...); the stored name is a blake3 hash
|
Raw image body (ico/png/svg...). Decodable images are thumbnailed to
|
||||||
prefix + extension, and pages link it as <link rel="icon">. Returns
|
FAVICON_MAXSIZE (192px — browsers scale down from there themselves)
|
||||||
|
and stored as AVIF/WebP/JPEG derivatives linked extension-less; SVG
|
||||||
|
originals also stay servable under their ``.svg`` name. Undecodable
|
||||||
|
bodies are stored as-is. Pages link it as <link rel="icon">. Returns
|
||||||
{"path": "/_f/..."}.
|
{"path": "/_f/..."}.
|
||||||
"""
|
"""
|
||||||
body = await request.body()
|
body = await request.body()
|
||||||
if not body:
|
if not body:
|
||||||
raise HTTPException(400, "empty file")
|
raise HTTPException(400, "empty file")
|
||||||
stored = _hash_name(body, request.headers.get("x-filename", "favicon.ico"))
|
ext = _ext(request.headers.get("x-filename", "favicon.ico"))
|
||||||
file_store.put(stored, body)
|
digest = blake3.blake3(body).hexdigest()[:12]
|
||||||
|
derivatives = await asyncio.to_thread(_image_derivatives, body, ext, FAVICON_MAXSIZE)
|
||||||
|
if derivatives is None: # undecodable (e.g. some .ico): store as-is
|
||||||
|
stored = digest + ext
|
||||||
|
file_store.put(stored, body)
|
||||||
|
else:
|
||||||
|
stored = digest
|
||||||
|
file_store.put(f"{digest}.svg" if ext == ".svg" else f"{digest}.orig{ext}", body)
|
||||||
|
for fmt, variant in derivatives.items():
|
||||||
|
file_store.put(f"{digest}.{fmt}", variant)
|
||||||
with kanta.transaction("upload favicon"):
|
with kanta.transaction("upload favicon"):
|
||||||
data.favicon = stored
|
data.favicon = stored
|
||||||
data.version += 1
|
_invalidate_pages()
|
||||||
return {"path": f"/_f/{stored}"}
|
return {"path": f"/_f/{stored}"}
|
||||||
|
|
||||||
|
|
||||||
@@ -634,7 +660,7 @@ async def delete_favicon() -> None:
|
|||||||
"""
|
"""
|
||||||
with kanta.transaction("clear favicon"):
|
with kanta.transaction("clear favicon"):
|
||||||
data.favicon = ""
|
data.favicon = ""
|
||||||
data.version += 1
|
_invalidate_pages()
|
||||||
|
|
||||||
|
|
||||||
class ToggleTaskIn(BaseModel):
|
class ToggleTaskIn(BaseModel):
|
||||||
@@ -671,11 +697,11 @@ async def toggle_task_endpoint(body: ToggleTaskIn) -> dict[str, str]:
|
|||||||
with kanta.transaction("toggle task", extra=path):
|
with kanta.transaction("toggle task", extra=path):
|
||||||
node.content = new_markdown
|
node.content = new_markdown
|
||||||
node.modified = datetime.now(UTC)
|
node.modified = datetime.now(UTC)
|
||||||
data.version += 1
|
_invalidate_pages()
|
||||||
return {"markdown": new_markdown}
|
return {"markdown": new_markdown}
|
||||||
|
|
||||||
|
|
||||||
def _to_avif(body: bytes, ext: str) -> bytes | None:
|
def _to_avif(body: bytes, ext: str, maxsize: int = IMAGE_MAXSIZE) -> bytes | None:
|
||||||
"""Recompress an image body to a thumbnailed AVIF via mediapreview's
|
"""Recompress an image body to a thumbnailed AVIF via mediapreview's
|
||||||
dispatch (pyvips for common formats, ffmpeg for HEIC/HEIF/AVIF), or
|
dispatch (pyvips for common formats, ffmpeg for HEIC/HEIF/AVIF), or
|
||||||
None if the body is not a decodable image (stored as-is by the caller).
|
None if the body is not a decodable image (stored as-is by the caller).
|
||||||
@@ -689,7 +715,7 @@ def _to_avif(body: bytes, ext: str) -> bytes | None:
|
|||||||
avif, _resp = dispatch(
|
avif, _resp = dispatch(
|
||||||
Path(tmp.name),
|
Path(tmp.name),
|
||||||
quality=IMAGE_QUALITY,
|
quality=IMAGE_QUALITY,
|
||||||
maxsize=IMAGE_MAXSIZE,
|
maxsize=maxsize,
|
||||||
maxzoom=1,
|
maxzoom=1,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -697,6 +723,56 @@ def _to_avif(body: bytes, ext: str) -> bytes | None:
|
|||||||
return avif
|
return avif
|
||||||
|
|
||||||
|
|
||||||
|
def _svg_to_png(body: bytes, maxsize: int) -> bytes | None:
|
||||||
|
"""Rasterize an SVG to PNG via pyvips, scaled so the long side is
|
||||||
|
``maxsize`` — SVGs often carry no meaningful intrinsic resolution, so
|
||||||
|
we rasterize at full image size rather than the tiny nominal one."""
|
||||||
|
import pyvips
|
||||||
|
|
||||||
|
try:
|
||||||
|
img = pyvips.Image.new_from_buffer(body, "")
|
||||||
|
scale = maxsize / max(img.width, img.height) if img.width and img.height else maxsize
|
||||||
|
if scale != 1:
|
||||||
|
img = pyvips.Image.new_from_buffer(body, "", scale=scale)
|
||||||
|
return img.write_to_buffer(".png")
|
||||||
|
except pyvips.Error:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _avif_to_format(avif: bytes, suffix: str, quality: int) -> bytes:
|
||||||
|
"""Re-encode the AVIF derivative into a fallback format (WebP/JPEG)
|
||||||
|
via pyvips. JPEG has no alpha, so it is flattened onto white;
|
||||||
|
``strip`` keeps metadata (EXIF) out of the fallbacks."""
|
||||||
|
import pyvips
|
||||||
|
|
||||||
|
img = pyvips.Image.new_from_buffer(avif, "")
|
||||||
|
if suffix == ".jpg" and img.hasalpha():
|
||||||
|
img = img.flatten(background=[255, 255, 255])
|
||||||
|
return img.write_to_buffer(suffix, Q=quality, strip=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _image_derivatives(body: bytes, ext: str, maxsize: int = IMAGE_MAXSIZE) -> dict[str, bytes] | None:
|
||||||
|
"""The served variants of an uploaded image: ``avif`` (primary,
|
||||||
|
thumbnailed to ``maxsize``) plus ``webp`` and ``jpg`` fallbacks
|
||||||
|
re-encoded from it. SVGs are rasterized first (they are vector, so
|
||||||
|
the raster replaces nothing — the .svg itself stays servable).
|
||||||
|
Returns None for non-decodable content (stored as-is by the caller).
|
||||||
|
"""
|
||||||
|
if ext == ".svg":
|
||||||
|
png = _svg_to_png(body, maxsize)
|
||||||
|
if png is None:
|
||||||
|
return None
|
||||||
|
body, ext = png, ".png"
|
||||||
|
avif = _to_avif(body, ext, maxsize)
|
||||||
|
if avif is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"avif": avif,
|
||||||
|
"webp": _avif_to_format(avif, ".webp", IMAGE_WEBP_QUALITY),
|
||||||
|
"jpg": _avif_to_format(avif, ".jpg", IMAGE_JPG_QUALITY),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.put("/_api/files/{name}")
|
@app.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.
|
||||||
@@ -704,11 +780,14 @@ async def upload_file(name: str, request: Request) -> dict[str, str]:
|
|||||||
The stored name is a blake3 hash prefix + the original extension,
|
The stored name is a blake3 hash prefix + the original extension,
|
||||||
served immutable at "/_f/{name}"; returns {"path": "/_f/..."}.
|
served immutable at "/_f/{name}"; returns {"path": "/_f/..."}.
|
||||||
|
|
||||||
Raster images are additionally recompressed with mediapreview: the
|
Raster images and SVGs are recompressed (SVGs rasterized) into AVIF
|
||||||
original goes to ``<hash>.orig<ext>`` (kept for reprocessing) while
|
(primary) plus WebP and JPEG fallbacks: the original goes to
|
||||||
pages link the thumbnailed AVIF derivative ``<hash>.avif``. SVGs and
|
``<hash>.orig<ext>`` (kept for reprocessing, never served — it may
|
||||||
GIFs are stored as-is (vector/animation would be lost). Other content
|
carry EXIF data; SVG originals stay servable as ``<hash>.svg`` since
|
||||||
and failed conversions fall back to plain storage.
|
vector carries no EXIF) and pages link the bare ``/_f/<hash>``, the
|
||||||
|
server picking the format from the request's Accept header. GIFs are
|
||||||
|
stored as-is (animation would be lost), as is other non-decodable
|
||||||
|
content.
|
||||||
"""
|
"""
|
||||||
if "/" in name or name in {".", ".."}:
|
if "/" in name or name in {".", ".."}:
|
||||||
raise HTTPException(400, "bad file name")
|
raise HTTPException(400, "bad file name")
|
||||||
@@ -717,18 +796,19 @@ async def upload_file(name: str, request: Request) -> dict[str, str]:
|
|||||||
raise HTTPException(400, "empty file")
|
raise HTTPException(400, "empty file")
|
||||||
ext = _ext(name)
|
ext = _ext(name)
|
||||||
digest = blake3.blake3(body).hexdigest()[:12]
|
digest = blake3.blake3(body).hexdigest()[:12]
|
||||||
avif = (
|
derivatives = (
|
||||||
None
|
None
|
||||||
if ext in {".svg", ".gif"}
|
if ext == ".gif"
|
||||||
else await asyncio.to_thread(_to_avif, body, ext)
|
else await asyncio.to_thread(_image_derivatives, body, ext)
|
||||||
)
|
)
|
||||||
if avif is None: # not a decodable image: store the body as-is
|
if derivatives is None: # not a decodable image: store the body as-is
|
||||||
stored = digest + ext
|
stored = digest + ext
|
||||||
file_store.put(stored, body)
|
file_store.put(stored, body)
|
||||||
return {"path": f"/_f/{stored}"}
|
return {"path": f"/_f/{stored}"}
|
||||||
file_store.put(f"{digest}.orig{ext}", body)
|
file_store.put(f"{digest}.svg" if ext == ".svg" else f"{digest}.orig{ext}", body)
|
||||||
file_store.put(f"{digest}.avif", avif)
|
for fmt, variant in derivatives.items():
|
||||||
return {"path": f"/_f/{digest}.avif"}
|
file_store.put(f"{digest}.{fmt}", variant)
|
||||||
|
return {"path": f"/_f/{digest}"}
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/_api/files/{name}", status_code=204)
|
@app.delete("/_api/files/{name}", status_code=204)
|
||||||
@@ -786,19 +866,47 @@ async def stored_file(name: str, request: Request) -> Response:
|
|||||||
"""Serve a file from the content-addressed store (immutable: the name
|
"""Serve a file from the content-addressed store (immutable: the name
|
||||||
is its own hash, so cache forever). Bodies are served from the RAM
|
is its own hash, so cache forever). Bodies are served from the RAM
|
||||||
cache, zstd-compressed when the client accepts it and compression
|
cache, zstd-compressed when the client accepts it and compression
|
||||||
actually shrank the file."""
|
actually shrank the file.
|
||||||
|
|
||||||
|
A bare ``/_f/{hash}`` (no extension, how pages link uploaded images)
|
||||||
|
content-negotiates between the stored derivatives: a format is served
|
||||||
|
only when the Accept header lists it explicitly — ``image/avif`` →
|
||||||
|
AVIF, ``image/webp`` → WebP, anything else (including ``image/*`` and
|
||||||
|
``*/*``) → JPEG. An explicit extension pins the format. ``.orig.``
|
||||||
|
originals are internal (they may carry EXIF data) and never served."""
|
||||||
|
if ".orig." in name:
|
||||||
|
raise HTTPException(404)
|
||||||
|
etag = name
|
||||||
|
vary = ""
|
||||||
entry = file_store.get(name)
|
entry = file_store.get(name)
|
||||||
|
if entry is None and "." not in name:
|
||||||
|
# Extension-less image link: negotiate avif/webp/jpg by Accept.
|
||||||
|
vary = "accept"
|
||||||
|
accept = request.headers.get("accept", "")
|
||||||
|
if "image/avif" in accept:
|
||||||
|
order = ("avif", "webp", "jpg")
|
||||||
|
elif "image/webp" in accept:
|
||||||
|
order = ("webp", "jpg", "avif")
|
||||||
|
else:
|
||||||
|
order = ("jpg", "webp", "avif")
|
||||||
|
for ext in order:
|
||||||
|
etag = f"{name}.{ext}"
|
||||||
|
entry = file_store.get(etag)
|
||||||
|
if entry is not None:
|
||||||
|
break
|
||||||
if entry is None:
|
if entry is None:
|
||||||
raise HTTPException(404)
|
raise HTTPException(404)
|
||||||
if request.headers.get("if-none-match") == name:
|
if request.headers.get("if-none-match") == etag:
|
||||||
return Response(status_code=304)
|
return Response(status_code=304)
|
||||||
body, compressed = entry
|
body, compressed = entry
|
||||||
headers = {"etag": name, "cache-control": "public, max-age=31536000, immutable"}
|
headers = {"etag": etag, "cache-control": "public, max-age=31536000, immutable"}
|
||||||
if compressed is not None and "zstd" in request.headers.get("accept-encoding", ""):
|
if compressed is not None and "zstd" in request.headers.get("accept-encoding", ""):
|
||||||
headers["content-encoding"] = "zstd"
|
headers["content-encoding"] = "zstd"
|
||||||
headers["vary"] = "accept-encoding"
|
vary = f"{vary}, accept-encoding".lstrip(", ")
|
||||||
body = compressed
|
body = compressed
|
||||||
mime = mimetypes.guess_type(name)[0] or "application/octet-stream"
|
if vary:
|
||||||
|
headers["vary"] = vary
|
||||||
|
mime = mimetypes.guess_type(etag)[0] or "application/octet-stream"
|
||||||
return Response(body, media_type=mime, headers=headers)
|
return Response(body, media_type=mime, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
@@ -821,7 +929,7 @@ async def delete_page(path: str) -> None:
|
|||||||
node.modified = datetime.now(UTC)
|
node.modified = datetime.now(UTC)
|
||||||
else:
|
else:
|
||||||
del slot[0][slot[1]]
|
del slot[0][slot[1]]
|
||||||
data.version += 1
|
_invalidate_pages()
|
||||||
|
|
||||||
|
|
||||||
_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
|
_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
|
||||||
@@ -1274,7 +1382,7 @@ async def editor_ws(ws: WebSocket) -> None:
|
|||||||
if "banner_design" in msg:
|
if "banner_design" in msg:
|
||||||
node.banner_design = msg["banner_design"]
|
node.banner_design = msg["banner_design"]
|
||||||
node.modified = datetime.now(UTC)
|
node.modified = datetime.now(UTC)
|
||||||
data.version += 1
|
_invalidate_pages()
|
||||||
await ws.send_json({"type": "saved", "path": path})
|
await ws.send_json({"type": "saved", "path": path})
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
pass
|
pass
|
||||||
@@ -1398,7 +1506,7 @@ async def show_page(request: Request, path: str) -> Response:
|
|||||||
# from pagerite.js's in-memory page cache (preload everything, never
|
# from pagerite.js's in-memory page cache (preload everything, never
|
||||||
# fetch on navigation); the ETag just makes those one-time preload
|
# fetch on navigation); the ETag just makes those one-time preload
|
||||||
# fetches and any revalidation cheap.
|
# fetches and any revalidation cheap.
|
||||||
etag = f'"{path}@{node.modified.timestamp()}v{data.version}"'
|
etag = f'"{path}@{node.modified.timestamp()}g{_render_gen}"'
|
||||||
if request.headers.get("if-none-match") == etag:
|
if request.headers.get("if-none-match") == etag:
|
||||||
return Response(status_code=304)
|
return Response(status_code=304)
|
||||||
if _is_trackable_path(path):
|
if _is_trackable_path(path):
|
||||||
|
|||||||
@@ -50,34 +50,11 @@ class Node(msgspec.Struct, omit_defaults=True):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class Page(msgspec.Struct, omit_defaults=True):
|
|
||||||
"""Legacy flat page record, from before the tree model.
|
|
||||||
|
|
||||||
Kept only so old databases still decode; app.py migrates any entries
|
|
||||||
into ``Data.menu`` on startup and clears this.
|
|
||||||
"""
|
|
||||||
|
|
||||||
title: str
|
|
||||||
markdown: str
|
|
||||||
published: bool = True
|
|
||||||
order: float = 0
|
|
||||||
banner: str = ""
|
|
||||||
created: datetime = msgspec.field(
|
|
||||||
default_factory=lambda: datetime.now(UTC),
|
|
||||||
)
|
|
||||||
modified: datetime = msgspec.field(
|
|
||||||
default_factory=lambda: datetime.now(UTC),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Data(msgspec.Struct):
|
class Data(msgspec.Struct):
|
||||||
"""Root object of the kanta database. Owned and edited in place by us."""
|
"""Root object of the kanta database. Owned and edited in place by us."""
|
||||||
|
|
||||||
#: Top-level menu items by slug; "" is the front page.
|
#: Top-level menu items by slug; "" is the front page.
|
||||||
menu: dict[str, Node] = {}
|
menu: dict[str, Node] = {}
|
||||||
#: Bumped on every structure/content write, so page ETags (which embed
|
|
||||||
#: it) invalidate cached copies when navigation-affecting changes happen.
|
|
||||||
version: int = 0
|
|
||||||
#: Site name shown in the header and <title> suffix; editable in the
|
#: Site name shown in the header and <title> suffix; editable in the
|
||||||
#: site editor. Empty = no brand link in the header, no title suffix.
|
#: site editor. Empty = no brand link in the header, no title suffix.
|
||||||
brand: str = "Pagerite"
|
brand: str = "Pagerite"
|
||||||
@@ -101,9 +78,6 @@ class Data(msgspec.Struct):
|
|||||||
#: linked as <link rel="icon"> on every page. Empty = the build's
|
#: linked as <link rel="icon"> on every page. Empty = the build's
|
||||||
#: /favicon.ico.
|
#: /favicon.ico.
|
||||||
favicon: str = ""
|
favicon: str = ""
|
||||||
#: Legacy flat page store (pre-tree databases); migrated into `menu`
|
|
||||||
#: on startup, then cleared. Never written otherwise.
|
|
||||||
pages: dict[str, Page] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def prettify(slug: str) -> str:
|
def prettify(slug: str) -> str:
|
||||||
|
|||||||
+121
-11
@@ -1,23 +1,133 @@
|
|||||||
"""Kanta schema migrations, discovered by name (``migrate_vN``).
|
"""Kanta schema migrations, discovered by name (``migrate_vN``).
|
||||||
|
|
||||||
Each function receives the raw state dict (JSON-level: bytes are base64
|
Each function receives the raw state dict (JSON-level: bytes are base64
|
||||||
strings) before it is decoded into ``Data`` structs, and runs exactly once
|
strings, datetimes RFC 3339 strings, struct fields with default values
|
||||||
|
omitted) before it is decoded into ``Data`` structs, and runs exactly once
|
||||||
per database based on its recorded version.
|
per database based on its recorded version.
|
||||||
|
|
||||||
|
All storage/schema upgrades live here — including on-disk file work, which
|
||||||
|
runs through app.py's file store (imported lazily: app.py owns the store
|
||||||
|
and passes this module to Kanta; at migration time, during lifespan
|
||||||
|
``kanta.open()``, the app module is fully loaded).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pagerite.data import prettify
|
||||||
|
|
||||||
|
|
||||||
|
def _append_order(nodes: dict) -> float:
|
||||||
|
"""Raw-dict equivalent of data.append_order (order keys may be absent)."""
|
||||||
|
return max((n.get("order", 0) for n in nodes.values()), default=0) + 1
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure(menu: dict, path: str) -> dict:
|
||||||
|
"""Raw-dict equivalent of app._ensure: the node dict at ``path``,
|
||||||
|
creating it and any missing ancestors (content-less category labels)
|
||||||
|
appended at the end of their level."""
|
||||||
|
nodes = menu
|
||||||
|
node = None
|
||||||
|
for seg in path.split("/"):
|
||||||
|
node = nodes.get(seg)
|
||||||
|
if node is None:
|
||||||
|
node = {"title": prettify(seg), "order": _append_order(nodes)}
|
||||||
|
nodes[seg] = node
|
||||||
|
nodes = node.setdefault("children", {})
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
def migrate_v1(d: dict) -> None:
|
def migrate_v1(d: dict) -> None:
|
||||||
"""Move in-database file blobs to the on-disk content-addressed store."""
|
"""Move in-database file blobs to the on-disk content-addressed store,
|
||||||
|
and rebuild the legacy flat page store (``pages``) as the menu tree."""
|
||||||
files = d.pop("files", None)
|
files = d.pop("files", None)
|
||||||
if not files:
|
if files:
|
||||||
return
|
from pagerite.app import file_store
|
||||||
# Deferred import: app.py owns the file store and passes this module to
|
|
||||||
# Kanta; at migration time (lifespan open) the module is fully loaded.
|
|
||||||
from pagerite.app import file_store
|
|
||||||
|
|
||||||
for name, body in files.items():
|
for name, body in files.items():
|
||||||
if isinstance(body, str): # JSON-level bytes are base64 strings
|
if isinstance(body, str): # JSON-level bytes are base64 strings
|
||||||
body = base64.b64decode(body)
|
body = base64.b64decode(body)
|
||||||
file_store.put(name, body)
|
file_store.put(name, body)
|
||||||
|
pages = d.pop("pages", None)
|
||||||
|
if not pages:
|
||||||
|
return
|
||||||
|
menu = d.setdefault("menu", {})
|
||||||
|
for path, page in pages.items():
|
||||||
|
node = _ensure(menu, path)
|
||||||
|
node["title"] = page["title"]
|
||||||
|
node["content"] = page["markdown"]
|
||||||
|
for key in ("banner", "published", "order", "created", "modified"):
|
||||||
|
if key in page:
|
||||||
|
node[key] = page[key]
|
||||||
|
|
||||||
|
|
||||||
|
#: Extension-less file links: uploaded images are linked as /_f/<hash>
|
||||||
|
#: and the server negotiates avif/webp/jpg from the Accept header.
|
||||||
|
_DERIVATIVE_LINK = re.compile(r"(/_f/[0-9a-f]{12})\.(?:avif|webp)\b")
|
||||||
|
|
||||||
|
|
||||||
|
def _backfill_derivatives() -> None:
|
||||||
|
"""Create missing AVIF/WebP/JPEG derivatives for files stored before
|
||||||
|
they were introduced (older uploads may have only the original plus
|
||||||
|
AVIF, and SVGs no raster variants at all). WebP/JPEG are re-encoded
|
||||||
|
from an existing AVIF when available, everything else from the
|
||||||
|
original (SVGs rasterized first)."""
|
||||||
|
from pagerite import app
|
||||||
|
|
||||||
|
file_store = app.file_store
|
||||||
|
try:
|
||||||
|
paths = [f for f in file_store.path.iterdir() if f.is_file()]
|
||||||
|
except FileNotFoundError:
|
||||||
|
return
|
||||||
|
groups: dict[str, list[Path]] = {}
|
||||||
|
for p in paths:
|
||||||
|
groups.setdefault(p.name.partition(".")[0], []).append(p)
|
||||||
|
for digest, files in groups.items():
|
||||||
|
names = {p.name for p in files}
|
||||||
|
source = next(
|
||||||
|
(p for p in files if ".orig." in p.name or p.suffix == ".svg"), None
|
||||||
|
)
|
||||||
|
if source is None:
|
||||||
|
continue # plain as-is file, no derivatives to make
|
||||||
|
avif = file_store.get(f"{digest}.avif")
|
||||||
|
if avif is None:
|
||||||
|
ext = source.suffix
|
||||||
|
body = source.read_bytes()
|
||||||
|
if ext == ".svg":
|
||||||
|
png = app._svg_to_png(body, app.IMAGE_MAXSIZE)
|
||||||
|
if png is None:
|
||||||
|
continue
|
||||||
|
body, ext = png, ".png"
|
||||||
|
converted = app._to_avif(body, ext)
|
||||||
|
if converted is None:
|
||||||
|
continue
|
||||||
|
file_store.put(f"{digest}.avif", converted)
|
||||||
|
avif = file_store.get(f"{digest}.avif")
|
||||||
|
for fmt, quality in (
|
||||||
|
("webp", app.IMAGE_WEBP_QUALITY),
|
||||||
|
("jpg", app.IMAGE_JPG_QUALITY),
|
||||||
|
):
|
||||||
|
if f"{digest}.{fmt}" not in names:
|
||||||
|
file_store.put(
|
||||||
|
f"{digest}.{fmt}", app._avif_to_format(avif[0], f".{fmt}", quality)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_v2(d: dict) -> None:
|
||||||
|
"""Extension-less image links: strip .avif/.webp extensions from /_f/
|
||||||
|
links in page content and banners (the server now negotiates the format
|
||||||
|
by Accept header), backfill missing AVIF/WebP/JPEG derivatives on disk,
|
||||||
|
and drop the obsolete render-counter field ``version`` (invalidation is
|
||||||
|
an in-memory concern now, not database state)."""
|
||||||
|
|
||||||
|
def walk(nodes: dict) -> None:
|
||||||
|
for node in nodes.values():
|
||||||
|
for field in ("content", "banner"):
|
||||||
|
if isinstance(node.get(field), str):
|
||||||
|
node[field] = _DERIVATIVE_LINK.sub(r"\1", node[field])
|
||||||
|
walk(node.get("children") or {})
|
||||||
|
|
||||||
|
walk(d.get("menu") or {})
|
||||||
|
d.pop("version", None)
|
||||||
|
_backfill_derivatives()
|
||||||
|
|||||||
+1
-1
@@ -51,7 +51,7 @@ The 🖊️ pens open a tabbed editor over the page you are viewing:
|
|||||||
|
|
||||||
The URL is the structure: a page at `docs/markdown` lives under `docs`, and the menus are derived from that. Slugs are lowercase ASCII (`a-z 0-9 - _`). A node without content is a category label — it renders a placeholder and its menu link points at its first child page. This site's own `docs` label demonstrates that, and the sidebar on this page shows the two submenu levels below it.
|
The URL is the structure: a page at `docs/markdown` lives under `docs`, and the menus are derived from that. Slugs are lowercase ASCII (`a-z 0-9 - _`). A node without content is a category label — it renders a placeholder and its menu link points at its first child page. This site's own `docs` label demonstrates that, and the sidebar on this page shows the two submenu levels below it.
|
||||||
|
|
||||||
Images and files uploaded anywhere land in a content-addressed store served from `/_f/{hash}.ext`, so links survive page moves. The article editor's format bar and copy-paste both upload images for you.
|
Images and files uploaded anywhere land in a content-addressed store served from `/_f/{hash}`, so links survive page moves. The server picks AVIF, WebP or JPEG from your browser's Accept header. The article editor's format bar and copy-paste both upload images for you.
|
||||||
|
|
||||||
{dates}
|
{dates}
|
||||||
"""
|
"""
|
||||||
|
|||||||
+12
-1
@@ -815,7 +815,10 @@ def _share_media(html: str, base_url: str) -> tuple[str, str]:
|
|||||||
"""(image, video) share URLs from the rendered article.
|
"""(image, video) share URLs from the rendered article.
|
||||||
|
|
||||||
The _media picks as absolute URLs built from the request base —
|
The _media picks as absolute URLs built from the request base —
|
||||||
social scrapers cannot use relative ones.
|
social scrapers cannot use relative ones. Extension-less store links
|
||||||
|
(``/_f/<hash>``) are used as-is: the server negotiates the format
|
||||||
|
from the scraper's Accept header (no explicit image/avif|webp → JPEG,
|
||||||
|
which every scraper supports).
|
||||||
"""
|
"""
|
||||||
if not base_url:
|
if not base_url:
|
||||||
return "", ""
|
return "", ""
|
||||||
@@ -838,10 +841,17 @@ def _social_meta(
|
|||||||
share image the article's first <img> — authors lead with their most
|
share image the article's first <img> — authors lead with their most
|
||||||
representative figure. Absolute URLs are built from the request's base
|
representative figure. Absolute URLs are built from the request's base
|
||||||
(social scrapers cannot use relative ones).
|
(social scrapers cannot use relative ones).
|
||||||
|
|
||||||
|
``twitter:image`` pins extension-less store links to the ``.webp``
|
||||||
|
variant: X only honors WebP via twitter:image (not og:image) and its
|
||||||
|
scraper cannot be trusted to negotiate via Accept.
|
||||||
"""
|
"""
|
||||||
url = f"{base_url}/{path}" if base_url else ""
|
url = f"{base_url}/{path}" if base_url else ""
|
||||||
text = _description(html)
|
text = _description(html)
|
||||||
image, video = _share_media(html, base_url)
|
image, video = _share_media(html, base_url)
|
||||||
|
twitter_image = (
|
||||||
|
re.sub(r"(/_f/[0-9a-f]{12})$", r"\1.webp", image) if image else ""
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"description": text,
|
"description": text,
|
||||||
"canonical": url,
|
"canonical": url,
|
||||||
@@ -855,6 +865,7 @@ def _social_meta(
|
|||||||
"article:published_time": node.created.isoformat(),
|
"article:published_time": node.created.isoformat(),
|
||||||
"article:modified_time": node.modified.isoformat(),
|
"article:modified_time": node.modified.isoformat(),
|
||||||
"twitter:card": "summary_large_image" if image else "summary",
|
"twitter:card": "summary_large_image" if image else "summary",
|
||||||
|
"twitter:image": twitter_image,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user