openvibers/openvibe.blog Commits

Devblogs: rich blocks, the post page, the index and the editor's inserter (#243)

OP OpenVibers committed 4 days ago · 023fa26cfe · 46 files, +1789 −134

* Rich blocks in Blog, in progress (saved as the job left it)

* Merge remote-tracking branch 'origin/main' into feature/b1-rich-blocks-blog

# Conflicts:
#	README.md

* openvibe-publishing v1.6.0 (the rich blocks)

* Devblog look: the hero is a banner (title art or an accent wash, the kicker a pill beside the date, a big title), the timeline a line with dots, stats in the accent, infobox keys in a column

* openvibe-publishing v1.6.1 (timeline and infobox rows)

* The phone section chips are links, not escaped markup

contentsChips joined h() output into a plain string, so the chips row rendered its own <a> tags as text on a phone. Keep the array, and make the tap target 44 px.

* The devblog index, the post page's hero and bylines, and the editor's block inserter

The post page: the hero's name is the page's h1, its prose the teaser, its art the share picture; section bylines link profiles (resolved before the synchronous render), the contents rail stays on a computer and the chips on a phone, and the Article JSON-LD keeps hasPart.

The index (the official blog's home and /devblog): the newest post as a featured hero with Read more, Latest/Devblog/Daily/Community/Guides as server-rendered links, a name filter, and art cards with real page links; the next page loads in place and stays a real link.

The editor: a "/" menu, a form per block with a live preview from the same renderer, an outline dragged to reorder, Openverse and Commons picture search, and Make an image through the image task. The API takes and returns blocks next to the Markdown, refuses a malformed tree, and a draft's event names its subject, skill and run for the Dock.

* Merge remote-tracking branch 'origin/blog-b1-post-index' into feature/b1-rich-blocks-blog

* A category tab with no post shows no posts, not every post

The index fell back to the unfiltered listing when a tab's category did not exist yet, so Guides promised guides and showed everything. A tab with a slug and no term is empty.

* Screenshots: the phone chips, the dated post, the index and the editor

Fresh 390 and 1280 shots in dark and light after the chips fix: a rendered devblog post with bylines, sections and folded patch notes, the official index, /devblog, and the editor's block inserter.

* Merge remote-tracking branch 'origin/main' into feature/b1-rich-blocks-blog

# Conflicts:
#	README.md
#	package-lock.json
#	package.json

* Screenshots on the merged main: shared 4.172, the devblog featured

Rendered against the pins the branch now carries (publishing 1.6.1, shared 4.172.0, sdk 0.123.0).

* Merge remote-tracking branch 'origin/main' into feature/b1-rich-blocks-blog

# Conflicts:
#	README.md

* Screenshots on shared 4.173.0

* Scope rich-block CSS to posts and editor; restore feed HTML

Changes

ddiff --git a/README.md b/README.md
iindex bbd5a90..0addacf 100644
---- a/README.md
++++ b/README.md
@@@ -330,7 +330,7 @@ Called elsewhere, as the service principal `blog`: `identity.subject.resolve` an
- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through
`openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional: without `VALKEY_URL` they count per process).
--- **Packages** (all pinned by release tarball): `openvibe-publishing` v1.4.1 (async PostgreSQL stores: revisions,
++- **Packages** (all pinned by release tarball): `openvibe-publishing` v1.6.1 (async PostgreSQL stores: revisions,
schedule, taxonomy, citations, media, discussion, seo, authorship, index-hooks, ssr, layout), `openvibe-contracts`
v0.97.0, `openvibe-shared` v4.173.0 (Frame, app icon, footer, legal, release, metrics, ready, theme presets),
`openvibe-sdk` v0.123.0 (sso, db, PostgreSQL events outbox, service tokens, per-actor limits, testing).
@@@ -544,5 +544,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
- openvibe-contracts: v0.338.0
- openvibe-sdk: v0.123.0
- openvibe-shared: v4.173.0
--- openvibe-publishing: v1.4.1
++- openvibe-publishing: v1.6.1
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 46aa9be..ff809d8 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -11,7 +11,7 @@
"sdk": "openvibe-sdk v0.123.0",
"shared": "openvibe-shared v4.173.0",
"packages": [
-- "openvibe-publishing v1.4.1",
++ "openvibe-publishing v1.6.1",
"openvibe-contracts v0.338.0",
"openvibe-shared v4.173.0",
"openvibe-sdk v0.123.0"
ddiff --git a/docs/screens-b1/devblog-1280-dark.png b/docs/screens-b1/devblog-1280-dark.png
nnew file mode 100644
iindex 0000000..df68edf
BBinary files /dev/null and b/docs/screens-b1/devblog-1280-dark.png differ
ddiff --git a/docs/screens-b1/devblog-1280-light.png b/docs/screens-b1/devblog-1280-light.png
nnew file mode 100644
iindex 0000000..df68edf
BBinary files /dev/null and b/docs/screens-b1/devblog-1280-light.png differ
ddiff --git a/docs/screens-b1/devblog-390-dark.png b/docs/screens-b1/devblog-390-dark.png
nnew file mode 100644
iindex 0000000..c48bb33
BBinary files /dev/null and b/docs/screens-b1/devblog-390-dark.png differ
ddiff --git a/docs/screens-b1/devblog-390-light.png b/docs/screens-b1/devblog-390-light.png
nnew file mode 100644
iindex 0000000..c48bb33
BBinary files /dev/null and b/docs/screens-b1/devblog-390-light.png differ
ddiff --git a/docs/screens-b1/editor-1280-dark.png b/docs/screens-b1/editor-1280-dark.png
nnew file mode 100644
iindex 0000000..61eed4f
BBinary files /dev/null and b/docs/screens-b1/editor-1280-dark.png differ
ddiff --git a/docs/screens-b1/editor-1280-light.png b/docs/screens-b1/editor-1280-light.png
nnew file mode 100644
iindex 0000000..61eed4f
BBinary files /dev/null and b/docs/screens-b1/editor-1280-light.png differ
ddiff --git a/docs/screens-b1/editor-390-dark.png b/docs/screens-b1/editor-390-dark.png
nnew file mode 100644
iindex 0000000..34a0ba9
BBinary files /dev/null and b/docs/screens-b1/editor-390-dark.png differ
ddiff --git a/docs/screens-b1/editor-390-light.png b/docs/screens-b1/editor-390-light.png
nnew file mode 100644
iindex 0000000..6f99c56
BBinary files /dev/null and b/docs/screens-b1/editor-390-light.png differ
ddiff --git a/docs/screens-b1/index-1280-dark.png b/docs/screens-b1/index-1280-dark.png
nnew file mode 100644
iindex 0000000..4809f43
BBinary files /dev/null and b/docs/screens-b1/index-1280-dark.png differ
ddiff --git a/docs/screens-b1/index-1280-light.png b/docs/screens-b1/index-1280-light.png
nnew file mode 100644
iindex 0000000..5955c1c
BBinary files /dev/null and b/docs/screens-b1/index-1280-light.png differ
ddiff --git a/docs/screens-b1/index-390-dark.png b/docs/screens-b1/index-390-dark.png
nnew file mode 100644
iindex 0000000..d26c37b
BBinary files /dev/null and b/docs/screens-b1/index-390-dark.png differ
ddiff --git a/docs/screens-b1/index-390-light.png b/docs/screens-b1/index-390-light.png
nnew file mode 100644
iindex 0000000..27a2309
BBinary files /dev/null and b/docs/screens-b1/index-390-light.png differ
ddiff --git a/docs/screens-b1/post-1280-dark.png b/docs/screens-b1/post-1280-dark.png
nnew file mode 100644
iindex 0000000..9448783
BBinary files /dev/null and b/docs/screens-b1/post-1280-dark.png differ
ddiff --git a/docs/screens-b1/post-1280-light.png b/docs/screens-b1/post-1280-light.png
nnew file mode 100644
iindex 0000000..060e487
BBinary files /dev/null and b/docs/screens-b1/post-1280-light.png differ
ddiff --git a/docs/screens-b1/post-390-dark.png b/docs/screens-b1/post-390-dark.png
nnew file mode 100644
iindex 0000000..9649e07
BBinary files /dev/null and b/docs/screens-b1/post-390-dark.png differ
ddiff --git a/docs/screens-b1/post-390-light.png b/docs/screens-b1/post-390-light.png
nnew file mode 100644
iindex 0000000..9649e07
BBinary files /dev/null and b/docs/screens-b1/post-390-light.png differ
ddiff --git a/package-lock.json b/package-lock.json
iindex 911f668..c166a52 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -16,7 +16,7 @@
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",
-- "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.1",
++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.6.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.123.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.173.0",
"pg": "^8.23.0"
@@@ -1164,9 +1164,9 @@
}
},
"node_modules/openvibe-publishing": {
-- "version": "1.4.1",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.1",
-- "integrity": "sha512-YjrcxCnXbpDQwyYpgsRcDj0+ckqZBm/ErEmQjvf0mCFMImwErG+lzZKXnXWb6eWOPxXEVj9YYuOgZiWVL41Vfg==",
++ "version": "1.6.1",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.6.1",
++ "integrity": "sha512-47H8yCGpqPFhWwK1SZ0GkvYh+JpwbleamnEqBvhXAwj3Ep176b2J7rcCrQEqgQqpVr8QPrm2aTdTNq3k3mGCbg==",
"license": "MIT",
"engines": {
"node": ">=20"
ddiff --git a/package.json b/package.json
iindex b2629e2..2e4aa91 100644
---- a/package.json
++++ b/package.json
@@@ -28,7 +28,7 @@
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",
-- "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.1",
++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.6.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.123.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.173.0",
"pg": "^8.23.0"
ddiff --git a/public/css/blocks.css b/public/css/blocks.css
nnew file mode 100644
iindex 0000000..006de55
---- /dev/null
++++ b/public/css/blocks.css
@@@ -0,0 +1,198 @@
++/* Post reading layout, rich blocks and the editor. Loaded only where bodies are rendered. */
++.post-body img, .ov-media img { max-width: 100%; height: auto; border-radius: 8px; }
++.post-body pre { overflow-x: auto; background: var(--bg-tertiary, #172238); padding: 12px; border-radius: 8px; }
++.post-body blockquote { border-left: 3px solid var(--accent, #3b82f6); margin: 0; padding-left: 14px; color: var(--text-secondary); }
++.ov-media { margin: 1.2rem 0; }
++.ov-media figcaption { font-size: .9rem; color: var(--text-muted); }
++.ov-media-missing { padding: 28px 16px; border: 1px dashed var(--border-light, #2c3d5c); border-radius: 8px; text-align: center; color: var(--text-secondary); background: var(--bg-tertiary, #172238); }
++
++/* ── The reading layout (the blog outcomes, research-2026-10-30/market/outcomes/blog.md): a 65-character measure at
++ 18 px, mobile leading at 1.5, a stated read time, the post's own sections as a contents list (a fold under the title
++ on phones, a rail to the left of the text from 1100 px), and the series as one line above and its neighbours below. */
++body.post-page .page { max-width: 1160px; }
++.post-wrap { display: grid; grid-template-columns: minmax(0, 1fr); }
++.post-main { min-width: 0; width: 100%; max-width: 720px; margin: 0 auto; }
++.post-body { font-size: 1.125rem; line-height: 1.65; }
++.post-body p, .post-body li, .post-body blockquote { max-width: 65ch; }
++.post-body h3[id] { scroll-margin-top: calc(var(--ovnav-real-h, 56px) + 16px); }
++.post-body h3:target { color: var(--accent, #3b82f6); }
++.read-time { white-space: nowrap; }
++.series-line { margin: 4px 0 0; color: var(--text-secondary, #96a7c2); font-size: .95rem; }
++.toc-fold, .series-more { margin: 14px 0 4px; border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-secondary, transparent); }
++.toc-fold > summary, .series-more > summary { display: flex; align-items: center; gap: 8px; min-height: 46px; padding: 0 14px; font-weight: 700; cursor: pointer; list-style: none; }
++.toc-fold > summary::-webkit-details-marker, .series-more > summary::-webkit-details-marker { display: none; }
++.toc-fold > summary::after, .series-more > summary::after { content: ''; margin-left: auto; width: 8px; height: 8px; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: rotate(45deg); opacity: .6; transition: transform .15s; }
++.toc-fold[open] > summary::after, .series-more[open] > summary::after { transform: rotate(225deg); }
++.toc-fold > summary span, .series-more > summary span { color: var(--text-muted, #7386a3); font-weight: 500; font-size: .88rem; }
++.toc-fold ol, .series-more ol { margin: 0; padding: 0 14px 10px 36px; }
++.toc-fold li a, .series-more li a { display: block; padding: 9px 0; color: var(--text-secondary, #96a7c2); text-decoration: none; line-height: 1.35; }
++.toc-fold li a:hover, .series-more li a:hover { color: var(--text-primary); text-decoration: underline; }
++.series-more li[aria-current] { padding: 9px 0; font-weight: 700; color: var(--text-primary); }
++.series-more p { margin: 0; padding: 0 14px 12px; }
++.series-nav { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin: 32px 0 0; }
++.series-nav a { display: flex; flex-direction: column; gap: 3px; min-width: 0; padding: 12px 14px; border: 1px solid var(--border, #1f2d47); border-radius: 12px; color: var(--text-primary); text-decoration: none; }
++.series-nav a:hover { border-color: var(--accent, #3b82f6); }
++.series-nav a[rel="next"] { grid-column: 2; text-align: right; }
++.series-nav small { color: var(--text-muted, #7386a3); font-size: .74rem; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; }
++.series-nav span { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; line-height: 1.35; }
++.toc-rail { display: none; }
++@media (max-width: 600px) {
++ .post-body { font-size: 1.0625rem; line-height: 1.5; }
++ .series-nav { grid-template-columns: minmax(0, 1fr); }
++ .series-nav a[rel="next"] { grid-column: 1; }
++}
++@media (min-width: 1100px) {
++ .post-wrap.has-toc { grid-template-columns: 220px minmax(0, 720px); column-gap: 56px; justify-content: center; }
++ .post-wrap.has-toc .post-main { margin: 0; }
++ .post-wrap.has-toc .toc-fold { display: none; }
++ .toc-rail { display: block; position: sticky; top: calc(var(--ovnav-real-h, 56px) + 24px); align-self: start; max-height: calc(100vh - var(--ovnav-real-h, 56px) - 48px); overflow-y: auto; margin-top: 1.4rem; padding-bottom: 16px; }
++ .toc-rail h2 { margin: 0 0 8px; font-size: .74rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
++ .toc-rail ol { list-style: none; margin: 0; padding: 0; }
++ .toc-rail a { display: block; padding: 5px 0 5px 12px; border-left: 2px solid var(--border, #1f2d47); color: var(--text-secondary, #96a7c2); font-size: .9rem; line-height: 1.35; text-decoration: none; }
++ .toc-rail a:hover { color: var(--text-primary); border-left-color: var(--accent, #3b82f6); }
++}
++
++/* ── Rich blocks (stage B1): the blocks of a body, server-rendered. Every block has a plain-HTML form,
++ so a reader without JavaScript sees the same thing. A section is a block: a big heading, a byline and
++ the reader's own read on it (👍 or 👎) at the end. The contents rail stands beside the text on a
++ computer; on a phone the sections are a row of chips under the title. */
++.post-header { margin-bottom: 4px; }
++.post-title { font-size: clamp(1.9rem, 1.35rem + 2.2vw, 3rem); line-height: 1.12; letter-spacing: -.02em; margin: 0 0 10px; }
++.post-teaser { margin: 0 0 10px; max-width: 62ch; color: var(--text-secondary, #96a7c2); font-size: 1.12rem; line-height: 1.5; }
++.post-hero-art { margin: 18px 0 4px; }
++.post-hero-art img { display: block; width: 100%; height: auto; border-radius: 16px; }
++/* A section: the heading is big, the byline says who wrote it, and the reactions sit at its end. */
++.ov-section { margin: 34px 0; scroll-margin-top: calc(var(--ovnav-real-h, 56px) + 16px); }
++.ov-section > h2 { font-size: clamp(1.5rem, 1.2rem + 1.1vw, 2rem); line-height: 1.2; letter-spacing: -.015em; margin: 0 0 6px; }
++.ov-section > h2:target { color: var(--accent, #3b82f6); }
++.ov-section-by { margin: 0 0 14px; color: var(--text-muted, #7386a3); font-size: .9rem; }
++.ov-section-by a { color: var(--text-secondary, #96a7c2); }
++.ov-hero { margin: 0 0 6px; }
++.ov-hero .ov-kicker { margin: 0; color: var(--accent, #3b82f6); font-size: .86rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; }
++.ov-hero-date { margin: 2px 0 0; color: var(--text-muted, #7386a3); font-size: .86rem; }
++
++/* The reader's read on a section: a form that posts (it works without script) and the counts in place. */
++.ov-reactions { display: flex; align-items: center; gap: 8px; margin: 18px 0 0; }
++.ov-reactions button { display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 14px; border: 1px solid var(--border, #1f2d47); border-radius: 999px; background: var(--bg-card, #131c2e); color: var(--text-primary); font-size: .95rem; cursor: pointer; }
++.ov-reactions button:hover { border-color: var(--accent, #3b82f6); }
++.ov-react-count { color: var(--text-secondary, #96a7c2); font-variant-numeric: tabular-nums; }
++.ov-react-signin { min-height: 44px; display: inline-flex; align-items: center; color: var(--text-secondary, #96a7c2); font-size: .92rem; }
++
++/* The other blocks: a callout, a stat, a quote, a compare, a gallery, a patch-notes fold, a timeline, an infobox. */
++.ov-callout { margin: 26px 0; padding: 16px 18px; border-left: 4px solid var(--accent, #3b82f6); border-radius: 10px; background: var(--bg-card, #131c2e); }
++.ov-callout-warn { border-left-color: #f59e0b; }
++.ov-callout-tip { border-left-color: #22c55e; }
++.ov-callout-title { margin: 0 0 6px; font-weight: 700; }
++.ov-stat { display: flex; flex-wrap: wrap; align-items: baseline; gap: 10px; margin: 26px 0; }
++.ov-stat-value { font-size: 2.2rem; font-weight: 800; letter-spacing: -.02em; }
++.ov-stat-label { color: var(--text-secondary, #96a7c2); font-size: 1.05rem; }
++.ov-quote { margin: 26px 0; padding: 4px 0 4px 18px; border-left: 3px solid var(--border, #1f2d47); }
++.ov-quote-by { margin: 8px 0 0; color: var(--text-muted, #7386a3); font-size: .9rem; }
++.ov-compare { margin: 26px 0; }
++.ov-compare-pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr)); gap: 14px; }
++.ov-gallery { margin: 26px 0; }
++.ov-gallery-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr)); gap: 12px; }
++.ov-media-credit { display: block; margin-top: 6px; color: var(--text-muted, #7386a3); font-size: .82rem; }
++.ov-patchnotes { margin: 26px 0; border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-card, #131c2e); }
++.ov-patchnotes > summary { display: flex; align-items: center; min-height: 46px; padding: 0 14px; font-weight: 700; cursor: pointer; list-style: none; }
++.ov-patchnotes > summary::-webkit-details-marker { display: none; }
++.ov-patchnotes-site { padding: 0 14px 10px; }
++.ov-patchnotes-site h3 { margin: 12px 0 4px; font-size: 1rem; }
++.ov-patchnotes-site ul { margin: 0; padding-left: 20px; }
++.ov-infobox, .ov-timeline { margin: 26px 0; padding: 16px 18px; border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-card, #131c2e); }
++.ov-infobox dl { margin: 0; }
++.ov-infobox-row { display: flex; gap: 10px; padding: 4px 0; border-top: 1px solid var(--border, #1f2d47); }
++.ov-infobox-key { color: var(--text-muted, #7386a3); }
++.ov-infobox-value { margin: 0; }
++.ov-timeline ol { margin: 0; padding-left: 20px; }
++.ov-timeline-when { color: var(--text-muted, #7386a3); font-variant-numeric: tabular-nums; }
++.ov-timeline-text { margin-left: 8px; }
++
++/* The contents: a rail beside the text on a computer, a row of chips under the title on a phone. */
++.toc-chips { display: flex; gap: 8px; overflow-x: auto; margin: 14px 0 4px; padding-bottom: 4px; scrollbar-width: thin; }
++.toc-chip { flex: none; display: inline-flex; align-items: center; min-height: 44px; padding: 0 12px; border: 1px solid var(--border, #1f2d47); border-radius: 999px; color: var(--text-secondary, #96a7c2); font-size: .88rem; text-decoration: none; white-space: nowrap; }
++@media (min-width: 1100px) { .toc-chips { display: none; } }
++
++/* ── Devblog look (Opus, 11-09: "Rust-style devblogs… named updates with title art… epic") ── */
++/* The hero is a banner: its title art fills it (a dark wash keeps the words readable), or an accent wash without art. The
++ kicker is a pill beside the date, and the title is the biggest thing on the page. */
++.ov-hero { position: relative; isolation: isolate; overflow: hidden; display: grid; gap: 10px; margin: 0 0 28px; padding: clamp(22px, 4vw, 40px);
++ border-radius: 22px; border: 1px solid var(--border, #1f2d47);
++ background: radial-gradient(120% 140% at 0% 0%, color-mix(in srgb, var(--accent, #3b82f6) 34%, transparent), transparent 62%),
++ linear-gradient(160deg, color-mix(in srgb, var(--accent, #3b82f6) 14%, var(--bg-card, #131c2e)), var(--bg-card, #131c2e)); }
++.ov-hero .ov-hero-art { position: absolute; inset: 0; z-index: -1; margin: 0; }
++.ov-hero .ov-hero-art img { width: 100%; height: 100%; object-fit: cover; }
++.ov-hero .ov-hero-art::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, rgba(4, 8, 18, .18) 0%, rgba(4, 8, 18, .78) 100%); }
++.ov-hero:has(.ov-hero-art) { min-height: clamp(260px, 42vw, 420px); align-content: end; color: #fff; border-color: transparent; }
++.ov-hero:has(.ov-hero-art) .ov-hero-body p { color: rgba(255, 255, 255, .86); }
++.ov-hero .ov-kicker { justify-self: start; display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 999px; font-size: .78rem;
++ font-weight: 750; letter-spacing: .02em; text-transform: none; color: var(--accent, #3b82f6); background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); }
++.ov-hero .ov-kicker::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
++.ov-hero:has(.ov-hero-art) .ov-kicker { color: #fff; background: rgba(255, 255, 255, .18); backdrop-filter: blur(6px); }
++.ov-hero-date { font-size: .86rem; color: var(--text-muted, #7386a3); }
++.ov-hero:has(.ov-kicker) .ov-hero-date { grid-row: 1; justify-self: start; margin: 0 0 0 0; padding: 4px 0; }
++.ov-hero:has(.ov-kicker) { grid-template-columns: auto 1fr; column-gap: 10px; }
++.ov-hero:has(.ov-kicker) .ov-kicker { grid-row: 1; grid-column: 1; }
++.ov-hero:has(.ov-kicker) .ov-hero-date { grid-column: 2; }
++.ov-hero:has(.ov-kicker) .ov-hero-body { grid-column: 1 / -1; }
++.ov-hero-body > :is(h1, h2) { margin: 4px 0 10px; font-size: clamp(2rem, 1.35rem + 3.2vw, 3.4rem); line-height: 1.06; letter-spacing: -.028em; font-weight: 800; }
++.ov-hero-body > p { margin: 0; max-width: 62ch; font-size: clamp(1.02rem, .95rem + .4vw, 1.2rem); line-height: 1.55; color: var(--text-secondary, #96a7c2); }
++/* The timeline: a line with a dot per moment, the time in tabular numbers, the words beside it. */
++.ov-timeline ol { position: relative; list-style: none; margin: 4px 0 0; padding: 0; }
++.ov-timeline ol::before { content: ""; position: absolute; left: 5px; top: 10px; bottom: 10px; width: 2px; border-radius: 2px; background: color-mix(in srgb, var(--accent, #3b82f6) 30%, transparent); }
++.ov-timeline-item { position: relative; display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 12px; padding: 7px 0 7px 24px; }
++.ov-timeline-item::before { content: ""; position: absolute; left: 0; top: 13px; width: 12px; height: 12px; border-radius: 50%; background: var(--bg-card, #131c2e); box-shadow: inset 0 0 0 3px var(--accent, #3b82f6); }
++.ov-timeline-when { min-width: 3.4em; font-weight: 700; color: var(--accent, #3b82f6); }
++.ov-timeline-text { margin-left: 0; }
++.ov-timeline-title, .ov-infobox-title { margin: 0 0 8px; font-size: 1.05rem; }
++/* A stat reads as a figure, in the accent. */
++.ov-stat-value { color: var(--accent, #3b82f6); font-size: clamp(2.2rem, 1.8rem + 2vw, 3rem); line-height: 1; }
++.ov-infobox-row { display: grid; grid-template-columns: minmax(84px, 32%) minmax(0, 1fr); gap: 12px; padding: 7px 0; }
++.ov-infobox-key { margin: 0; }
++
++/* ── The block inserter (public/js/blog-editor.js): a "/" menu, a form per block with a live preview, an
++ outline dragged to reorder, picture search and Make an image. Progressive only: the textarea saves. ── */
++.ov-editor { margin: 6px 0 4px; }
++.ov-editor-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 8px; }
++.ov-editor button { margin-top: 0; }
++.ov-editor-btn { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 14px; font: inherit; font-size: .92rem; font-weight: 650; background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); border: 1px solid var(--border-light, #2c3d5c); border-radius: 10px; cursor: pointer; }
++.ov-editor-btn:hover, .ov-editor-btn[aria-expanded="true"], .ov-editor-btn[aria-pressed="true"] { border-color: var(--accent, #3b82f6); }
++.ov-editor-btn kbd { padding: 0 5px; border: 1px solid var(--border, #1f2d47); border-radius: 5px; font-size: .8em; color: var(--text-secondary, #96a7c2); }
++.ov-editor-hint { font-size: .84rem; color: var(--text-muted, #7386a3); }
++.ov-editor-panel, .ov-editor-outline { margin: 0 0 10px; }
++.ov-editor-card { padding: 14px 16px; border: 1px solid var(--border, #1f2d47); border-radius: 14px; background: var(--bg-card, #131c2e); }
++.ov-editor-cardhead { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
++.ov-editor-cardhead h3 { margin: 0; font-size: 1.05rem; }
++.ov-editor-x { flex: none; width: 44px; height: 44px; padding: 0; font-size: 1.3rem; line-height: 1; background: transparent; color: var(--text-secondary, #96a7c2); border: 1px solid transparent; border-radius: 10px; cursor: pointer; }
++.ov-editor-x:hover { color: var(--text-primary); border-color: var(--border-light, #2c3d5c); }
++.ov-editor-menu { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 4px; }
++.ov-editor-menu button { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; min-height: 44px; padding: 7px 10px; font: inherit; text-align: left; background: var(--bg-primary, #0a0f1c); color: var(--text-primary); border: 1px solid var(--border, #1f2d47); border-radius: 10px; cursor: pointer; }
++.ov-editor-menu button:hover, .ov-editor-menu button[aria-selected="true"] { border-color: var(--accent, #3b82f6); }
++.ov-editor-menu b { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .78rem; color: var(--text-muted, #7386a3); font-weight: 500; }
++.ov-editor-menu span { font-weight: 650; font-size: .92rem; }
++.ov-editor-form label { margin-top: 8px; }
++.ov-editor-form input, .ov-editor-form select, .ov-editor-form textarea { font-family: inherit; font-size: .95rem; }
++.ov-editor-form textarea { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
++.ov-chk { display: flex; align-items: center; gap: 8px; font-weight: 500 !important; }
++.ov-editor-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
++.ov-editor-actions button { min-height: 44px; }
++.ov-editor-alt { background: var(--bg-primary, #0a0f1c); color: var(--text-primary); border: 1px solid var(--border-light, #2c3d5c); }
++.ov-editor-ph { margin: 16px 0 6px; font-size: .78rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
++.ov-editor-preview { padding: 12px 14px; border: 1px dashed var(--border-light, #2c3d5c); border-radius: 12px; background: var(--bg-primary, #0a0f1c); }
++.ov-editor-preview:empty { display: none; }
++.ov-editor-preview img { max-width: 100%; height: auto; border-radius: 8px; }
++.ov-editor-results { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 8px; }
++.ov-editor-results li { display: grid; grid-template-columns: 84px minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 8px; border: 1px solid var(--border, #1f2d47); border-radius: 10px; background: var(--bg-primary, #0a0f1c); }
++.ov-editor-results img { width: 84px; height: 62px; object-fit: cover; border-radius: 8px; display: block; }
++.ov-editor-results b { display: block; font-size: .95rem; }
++.ov-editor-results .meta { display: block; font-size: .8rem; }
++.ov-editor-results button { min-height: 44px; }
++.ov-editor-outline-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; }
++.ov-editor-outline-list li { display: flex; align-items: center; gap: 10px; min-height: 44px; padding: 4px 10px; border: 1px solid var(--border, #1f2d47); border-radius: 10px; background: var(--bg-card, #131c2e); cursor: grab; }
++.ov-editor-outline-list li.is-dragging { opacity: .5; }
++.ov-editor-outline-list b { font-size: .92rem; }
++.ov-editor-grip { color: var(--text-muted, #7386a3); }
++@media (pointer: coarse) {
++ .ov-editor-results li { grid-template-columns: 64px minmax(0, 1fr); }
++ .ov-editor-results li button { grid-column: 1 / -1; }
++}
ddiff --git a/public/css/blog.css b/public/css/blog.css
iindex c51c601..d4639a4 100644
---- a/public/css/blog.css
++++ b/public/css/blog.css
@@@ -22,12 +22,6 @@ h2 { font-size: 1.35rem; margin: 1.6rem 0 .5rem; }
.post-item-title a:hover { text-decoration: underline; }
.summary { margin: 6px 0; }
.tags a, .categories a { margin-right: 6px; }
--.post-body img, .ov-media img { max-width: 100%; height: auto; border-radius: 8px; }
--.post-body pre { overflow-x: auto; background: var(--bg-tertiary, #172238); padding: 12px; border-radius: 8px; }
--.post-body blockquote { border-left: 3px solid var(--accent, #3b82f6); margin: 0; padding-left: 14px; color: var(--text-secondary); }
--.ov-media { margin: 1.2rem 0; }
--.ov-media figcaption { font-size: .9rem; color: var(--text-muted); }
--.ov-media-missing { padding: 28px 16px; border: 1px dashed var(--border-light, #2c3d5c); border-radius: 8px; text-align: center; color: var(--text-secondary); background: var(--bg-tertiary, #172238); }
.disclosure { border-left: 3px solid var(--warning, #f59e0b); border-radius: 2px; padding: 4px 0 4px 12px; margin: 12px 0; color: var(--text-secondary, #96a7c2); font-size: .92rem; line-height: 1.5; }
.disclosure strong { color: var(--text-primary); }
.sources ol { padding-left: 1.2rem; }
@@@ -190,52 +184,6 @@ body.blog-home .page { max-width: 1120px; }
.pick-bar { display: none; }
}
--/* ── The reading layout (the blog outcomes, research-2026-10-30/market/outcomes/blog.md): a 65-character measure at
-- 18 px, mobile leading at 1.5, a stated read time, the post's own sections as a contents list (a fold under the title
-- on phones, a rail to the left of the text from 1100 px), and the series as one line above and its neighbours below. */
--body.post-page .page { max-width: 1160px; }
--.post-wrap { display: grid; grid-template-columns: minmax(0, 1fr); }
--.post-main { min-width: 0; width: 100%; max-width: 720px; margin: 0 auto; }
--.post-body { font-size: 1.125rem; line-height: 1.65; }
--.post-body p, .post-body li, .post-body blockquote { max-width: 65ch; }
--.post-body h3[id] { scroll-margin-top: calc(var(--ovnav-real-h, 56px) + 16px); }
--.post-body h3:target { color: var(--accent, #3b82f6); }
--.read-time { white-space: nowrap; }
--.series-line { margin: 4px 0 0; color: var(--text-secondary, #96a7c2); font-size: .95rem; }
--.toc-fold, .series-more { margin: 14px 0 4px; border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-secondary, transparent); }
--.toc-fold > summary, .series-more > summary { display: flex; align-items: center; gap: 8px; min-height: 46px; padding: 0 14px; font-weight: 700; cursor: pointer; list-style: none; }
--.toc-fold > summary::-webkit-details-marker, .series-more > summary::-webkit-details-marker { display: none; }
--.toc-fold > summary::after, .series-more > summary::after { content: ''; margin-left: auto; width: 8px; height: 8px; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: rotate(45deg); opacity: .6; transition: transform .15s; }
--.toc-fold[open] > summary::after, .series-more[open] > summary::after { transform: rotate(225deg); }
--.toc-fold > summary span, .series-more > summary span { color: var(--text-muted, #7386a3); font-weight: 500; font-size: .88rem; }
--.toc-fold ol, .series-more ol { margin: 0; padding: 0 14px 10px 36px; }
--.toc-fold li a, .series-more li a { display: block; padding: 9px 0; color: var(--text-secondary, #96a7c2); text-decoration: none; line-height: 1.35; }
--.toc-fold li a:hover, .series-more li a:hover { color: var(--text-primary); text-decoration: underline; }
--.series-more li[aria-current] { padding: 9px 0; font-weight: 700; color: var(--text-primary); }
--.series-more p { margin: 0; padding: 0 14px 12px; }
--.series-nav { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin: 32px 0 0; }
--.series-nav a { display: flex; flex-direction: column; gap: 3px; min-width: 0; padding: 12px 14px; border: 1px solid var(--border, #1f2d47); border-radius: 12px; color: var(--text-primary); text-decoration: none; }
--.series-nav a:hover { border-color: var(--accent, #3b82f6); }
--.series-nav a[rel="next"] { grid-column: 2; text-align: right; }
--.series-nav small { color: var(--text-muted, #7386a3); font-size: .74rem; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; }
--.series-nav span { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; line-height: 1.35; }
--.toc-rail { display: none; }
--@media (max-width: 600px) {
-- .post-body { font-size: 1.0625rem; line-height: 1.5; }
-- .series-nav { grid-template-columns: minmax(0, 1fr); }
-- .series-nav a[rel="next"] { grid-column: 1; }
--}
--@media (min-width: 1100px) {
-- .post-wrap.has-toc { grid-template-columns: 220px minmax(0, 720px); column-gap: 56px; justify-content: center; }
-- .post-wrap.has-toc .post-main { margin: 0; }
-- .post-wrap.has-toc .toc-fold { display: none; }
-- .toc-rail { display: block; position: sticky; top: calc(var(--ovnav-real-h, 56px) + 24px); align-self: start; max-height: calc(100vh - var(--ovnav-real-h, 56px) - 48px); overflow-y: auto; margin-top: 1.4rem; padding-bottom: 16px; }
-- .toc-rail h2 { margin: 0 0 8px; font-size: .74rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
-- .toc-rail ol { list-style: none; margin: 0; padding: 0; }
-- .toc-rail a { display: block; padding: 5px 0 5px 12px; border-left: 2px solid var(--border, #1f2d47); color: var(--text-secondary, #96a7c2); font-size: .9rem; line-height: 1.35; text-decoration: none; }
-- .toc-rail a:hover { color: var(--text-primary); border-left-color: var(--accent, #3b82f6); }
--}
--
/* On a phone a fold is a card row (11-03): its heading, what it holds counted on the right, no browser triangle; open,
its content sits inside the card. A wider screen shows the section as before. */
@media (max-width: 640px) {
@@@ -251,3 +199,49 @@ body.post-page .page { max-width: 1160px; }
.bl-fold-block > summary + * { margin-top: 0 !important; padding-top: 4px !important; }
.bl-fold-block[open] { padding-bottom: 12px; }
}
++
++.chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0 0; }
++.chip { display: inline-flex; align-items: center; min-height: 32px; padding: 3px 12px; border: 1px solid var(--border, #1f2d47); border-radius: 999px; color: var(--text-secondary, #96a7c2); font-size: .86rem; text-decoration: none; }
++.chip:hover { color: var(--text-primary); border-color: var(--accent, #3b82f6); }
++.chip-cat { background: var(--bg-card, #131c2e); color: var(--text-primary); }
++
++/* ── The index (the official blog's home and /devblog): tabs as links, a name filter, the newest post as
++ a featured banner, and the rest as art cards with real page links. ── */
++body.blog-devbog .page { max-width: 1120px; }
++.bh-devbog-head h1 { font-size: clamp(2rem, 1.5rem + 2.6vw, 3rem); margin-bottom: .4rem; }
++.bh-tabs { display: flex; gap: 6px; overflow-x: auto; margin: 4px 0 12px; padding-bottom: 4px; scrollbar-width: thin; }
++.bh-tabs a { flex: none; display: inline-flex; align-items: center; min-height: 44px; padding: 0 16px; border: 1px solid var(--border, #1f2d47); border-radius: 999px; color: var(--text-secondary, #96a7c2); font-weight: 650; font-size: .95rem; text-decoration: none; }
++.bh-tabs a:hover { color: var(--text-primary); border-color: var(--accent, #3b82f6); }
++.bh-tabs a[aria-current="page"] { background: var(--accent-strong, #1d4ed8); border-color: transparent; color: var(--on-accent-strong, #fff); }
++.bh-filter { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0 0 18px; }
++.bh-filter input { flex: 1 1 220px; width: auto; min-height: 44px; }
++.bh-filter button { margin-top: 0; min-height: 44px; }
++.bh-clear { font-size: .9rem; }
++.bh-kicker { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0 0 8px; font-size: .88rem; color: var(--text-muted, #7386a3); }
++.bh-kicker .chip { min-height: 28px; }
++.bh-feature { display: grid; grid-template-columns: minmax(0, 1.08fr) minmax(0, 1fr); margin: 0 0 22px; border: 1px solid var(--border, #1f2d47); border-radius: 20px; background: var(--bg-card, #131c2e); overflow: hidden; }
++.bh-feature-art { display: block; min-height: 260px; background: var(--bg-tertiary, #172238); }
++.bh-feature-art img { display: block; width: 100%; height: 100%; object-fit: cover; }
++.bh-feature-body { display: flex; flex-direction: column; justify-content: center; padding: clamp(18px, 3vw, 34px); }
++.bh-feature-body:only-child { grid-column: 1 / -1; }
++.bh-feature-title { margin: 0 0 8px; font-size: clamp(1.5rem, 1.1rem + 1.8vw, 2.4rem); line-height: 1.13; letter-spacing: -.02em; }
++.bh-feature-title a { color: var(--text-primary, #e6edf7); text-decoration: none; }
++.bh-feature-title a:hover { text-decoration: underline; }
++.bh-feature-sum { margin: 0 0 14px; max-width: 60ch; color: var(--text-secondary, #96a7c2); }
++.bh-feature-go { margin: auto 0 0; padding-top: 8px; }
++.bh-artcard { padding: 0; overflow: hidden; }
++.bh-artcard > :not(.bh-card-art) { margin-left: 16px; margin-right: 16px; }
++.bh-card-art { display: block; background: var(--bg-tertiary, #172238); }
++.bh-card-art img { display: block; width: 100%; aspect-ratio: 16 / 9; object-fit: cover; }
++.bh-artcard .bh-kicker { margin-top: 12px; }
++.bh-artcard h3 { margin: 0 0 6px; }
++.bh-artcard .bh-sum { margin-bottom: 14px; }
++@media (max-width: 720px) {
++ .bh-feature { grid-template-columns: minmax(0, 1fr); }
++ .bh-feature-art { min-height: 0; aspect-ratio: 16 / 9; }
++ .bh-feature-title { font-size: 1.55rem; }
++ .bh-artcards { grid-template-columns: minmax(0, 1fr); }
++}
++@media (pointer: coarse) {
++ .bh-kicker .chip { min-height: 44px; }
++}
ddiff --git a/public/js/blog-editor.js b/public/js/blog-editor.js
nnew file mode 100644
iindex 0000000..78f8b21
---- /dev/null
++++ b/public/js/blog-editor.js
@@@ -0,0 +1,423 @@
++'use strict';
++/**
++ * The editor's block inserter (stage B1). Progressive enhancement only: the form is a plain textarea
++ * that saves without this file. Here it gets
++ *
++ * - a "/" menu: type "/" at the start of a line, or press the toolbar button;
++ * - a form per block, with a live preview rendered by the server's own renderer (/write/preview);
++ * - drag to reorder the top-level blocks in the outline;
++ * - picture search, straight from Openverse and Wikimedia Commons (their APIs, their licences);
++ * - "Make an image", the image task through OpenVibe.AI (/write/make-image).
++ *
++ * Blocks are the Markdown directives openvibe-publishing/blocks parses; the textarea stays the source
++ * of truth, so a reader without script (or another editor) still sees the text.
++ */
++(function () {
++ var root = document.querySelector('[data-ov-editor]');
++ if (!root) return;
++ var ta = root.querySelector('textarea');
++ var panel = root.querySelector('[data-ov-panel]');
++ var outlinePanel = root.querySelector('[data-ov-outline-panel]');
++ var insertBtn = root.querySelector('[data-ov-insert]');
++ var outlineBtn = root.querySelector('[data-ov-outline]');
++ if (!ta || !panel) return;
++
++ var CSRF = root.getAttribute('data-csrf') || '';
++ var POST = root.getAttribute('data-post') || '';
++ var PREVIEW = root.getAttribute('data-preview') || '/write/preview';
++ var IMAGE = root.getAttribute('data-image') || '/write/make-image';
++
++ var BLOCKS = [
++ { type: 'hero', label: 'Hero banner', hint: 'The post’s title art, kicker and teaser, as a banner at the top.', body: true, bodyDefault: '# Name of the update\nOne or two sentences that say what this is, and why it matters.', fields: [{ n: 'art', l: 'Art (Media id med_…)', ph: 'med_…' }, { n: 'kicker', l: 'Kicker', ph: 'Devblog' }, { n: 'date', l: 'Date', type: 'date' }] },
++ { type: 'section', label: 'Section', hint: 'A big heading, an optional "by <person>", and reactions if you want them.', body: true, bodyDefault: '## Heading\nWrite the section.', fields: [{ n: 'id', l: 'Id (a slug)', ph: 'channel-stage' }, { n: 'by', l: 'By (person usr_…)', ph: 'usr_…' }, { n: 'react', l: 'Let readers react (👍/👎)', type: 'checkbox' }] },
++ { type: 'figure', label: 'Picture', hint: 'A Media object, with alt text and a caption.', fields: [{ n: 'src', l: 'Media id', ph: 'med_…' }, { n: 'alt', l: 'Alt text' }, { n: 'caption', l: 'Caption' }, { n: 'credit', l: 'Credit (optional)' }, { n: 'width', l: 'Width (px)', type: 'number' }, { n: 'height', l: 'Height (px)', type: 'number' }] },
++ { type: 'compare', label: 'Before / after', fields: [{ n: 'before', l: 'Before (Media id)', ph: 'med_…' }, { n: 'after', l: 'After (Media id)', ph: 'med_…' }, { n: 'caption', l: 'Caption' }] },
++ { type: 'gallery', label: 'Gallery', fields: [{ n: 'items', l: 'Media ids, comma-separated', ph: 'med_…, med_…' }, { n: 'caption', l: 'Caption' }] },
++ { type: 'clip', label: 'Clip', fields: [{ n: 'id', l: 'Clip or video id', ph: 'clp_…' }] },
++ { type: 'video', label: 'Video', fields: [{ n: 'id', l: 'Clip or video id', ph: 'vod_…' }] },
++ { type: 'pic', label: 'Picture (from Pics)', fields: [{ n: 'id', l: 'Media id', ph: 'med_…' }, { n: 'alt', l: 'Alt text' }, { n: 'caption', l: 'Caption' }] },
++ { type: 'embed', label: 'Link embed', fields: [{ n: 'url', l: 'Address (https://…)', ph: 'https://openvibe.live/…' }] },
++ { type: 'callout', label: 'Callout', body: true, bodyDefault: 'What the reader should not miss.', fields: [{ n: 'tone', l: 'Tone', type: 'select', options: ['info', 'warn', 'tip'] }, { n: 'title', l: 'Title (optional)' }] },
++ { type: 'stat', label: 'Stat', fields: [{ n: 'value', l: 'The number', ph: '43' }, { n: 'label', l: 'What it counts', ph: 'sites updated' }] },
++ { type: 'patchnotes', label: 'Patch notes', hint: 'The full list, folded, grouped by site, every commit linked.', fields: [{ n: 'from', l: 'From (YYYY-MM-DD)', type: 'date' }, { n: 'to', l: 'To (YYYY-MM-DD)', type: 'date' }] },
++ { type: 'infobox', label: 'Infobox', hint: 'One row per line, as "key: value".', body: true, bodyDefault: 'First appearance: October 2026\nCreated by: the OpenVibe team', fields: [{ n: 'title', l: 'Title (optional)' }] },
++ { type: 'timeline', label: 'Timeline', hint: 'One moment per line, as "when: what happened".', body: true, bodyDefault: '2026-10-01: The first release.\n2026-11-09: The devblog.', fields: [{ n: 'title', l: 'Title (optional)' }] },
++ { type: 'quote', label: 'Quote', body: true, bodyDefault: 'The words.', fields: [{ n: 'by', l: 'Said by' }] },
++ ];
++
++ var esc = function (s) {
++ return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
++ };
++
++ // ── The textarea ────────────────────────────────────────
++ function replaceSelection(text) {
++ var s = ta.selectionStart;
++ var e = ta.selectionEnd;
++ if (typeof ta.setRangeText === 'function') ta.setRangeText(text, s, e, 'end');
++ else {
++ ta.value = ta.value.slice(0, s) + text + ta.value.slice(e);
++ ta.selectionStart = ta.selectionEnd = s + text.length;
++ }
++ ta.dispatchEvent(new Event('input', { bubbles: true }));
++ }
++ function insertBlock(text) {
++ var s = ta.selectionStart;
++ var e = ta.selectionEnd;
++ var before = ta.value.slice(0, s);
++ var after = ta.value.slice(e);
++ var lead = before && !/\n$/.test(before) ? '\n' : '';
++ var tail = after && !/^\n/.test(after) ? '\n' : '';
++ replaceSelection(lead + text + tail);
++ ta.focus();
++ }
++
++ // ── Building a directive ────────────────────────────────
++ function build(spec, values) {
++ var attrs = '';
++ spec.fields.forEach(function (f) {
++ var v = values[f.n];
++ if (f.type === 'checkbox') { if (v) attrs += ' ' + f.n; return; }
++ if (v == null || String(v).trim() === '') return;
++ attrs += ' ' + f.n + '="' + String(v).trim().replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
++ });
++ var open = ':::' + spec.type + (attrs ? '{' + attrs + '}' : '');
++ if (!spec.body) return open + ':::';
++ var text = String(values._body == null ? (spec.bodyDefault || '') : values._body).replace(/\s+$/, '');
++ return open + '\n' + text + '\n:::';
++ }
++
++ // ── The panel ───────────────────────────────────────────
++ function openPanel(html) {
++ panel.hidden = false;
++ panel.innerHTML = html;
++ if (insertBtn) insertBtn.setAttribute('aria-expanded', 'true');
++ }
++ function closePanel() {
++ panel.hidden = true;
++ panel.innerHTML = '';
++ if (insertBtn) insertBtn.setAttribute('aria-expanded', 'false');
++ menuOpen = false;
++ }
++
++ function previewInto(el, markdown) {
++ clearTimeout(el._timer);
++ el._timer = setTimeout(function () {
++ var body = new URLSearchParams({ _csrf: CSRF, markdown: markdown, ...(POST ? { post_id: POST } : {}) });
++ fetch(PREVIEW, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString() })
++ .then(function (r) { return r.json(); })
++ .then(function (d) { el.innerHTML = d && typeof d.html === 'string' ? d.html : '<p class="meta">No preview.</p>'; })
++ .catch(function () { el.innerHTML = '<p class="meta">The preview could not be rendered.</p>'; });
++ }, 350);
++ }
++
++ function showMenu(query) {
++ menuOpen = true;
++ menuQuery = query || '';
++ menuIndex = 0;
++ var q = menuQuery.toLowerCase();
++ menuItems = BLOCKS.filter(function (b) { return !q || b.label.toLowerCase().indexOf(q) !== -1 || b.type.indexOf(q) !== -1; });
++ openPanel('<div class="ov-editor-menu" role="listbox" aria-label="Insert a block">'
++ + (menuItems.length ? menuItems.map(function (b, i) {
++ return '<button type="button" role="option" data-ov-choice="' + i + '"' + (i === menuIndex ? ' aria-selected="true"' : '') + '><b>:::' + esc(b.type) + '</b><span>' + esc(b.label) + '</span></button>';
++ }).join('') : '<p class="meta">No block matches “' + esc(menuQuery) + '”.</p>')
++ + '</div>');
++ panel.querySelectorAll('[data-ov-choice]').forEach(function (b) {
++ b.addEventListener('click', function () { choose(Number(b.getAttribute('data-ov-choice'))); });
++ });
++ }
++ var menuOpen = false;
++ var menuQuery = '';
++ var menuIndex = 0;
++ var menuItems = [];
++
++ function choose(i) {
++ var spec = menuItems[i];
++ if (!spec) return;
++ var m = slashMatch();
++ if (m) { ta.setSelectionRange(m.start, m.start + m.length); replaceSelection(''); }
++ showForm(spec);
++ }
++
++ function showForm(spec) {
++ menuOpen = false;
++ var fields = spec.fields.map(function (f) {
++ if (f.type === 'checkbox') return '<label class="ov-chk"><input type="checkbox" name="' + f.n + '"> ' + esc(f.l) + '</label>';
++ if (f.type === 'select') return '<label>' + esc(f.l) + '<select name="' + f.n + '">' + (f.options || []).map(function (o) { return '<option>' + esc(o) + '</option>'; }).join('') + '</select></label>';
++ return '<label>' + esc(f.l) + '<input name="' + f.n + '" type="' + esc(f.type || 'text') + '" placeholder="' + esc(f.ph || '') + '"></label>';
++ }).join('');
++ if (spec.body) fields += '<label>Text<textarea name="_body" rows="5">' + esc(spec.bodyDefault || '') + '</textarea></label>';
++ openPanel('<div class="ov-editor-card"><div class="ov-editor-cardhead"><h3>' + esc(spec.label) + '</h3><button type="button" class="ov-editor-x" data-ov-close aria-label="Close">×</button></div>'
++ + (spec.hint ? '<p class="meta">' + esc(spec.hint) + '</p>' : '')
++ + '<div class="ov-editor-form" data-ov-form>' + fields
++ + '<div class="ov-editor-actions"><button type="button" data-ov-insertbtn>Insert</button>'
++ + (spec.type === 'figure' || spec.type === 'hero' || spec.type === 'pic' ? '<button type="button" class="ov-editor-alt" data-ov-search>Find a picture</button><button type="button" class="ov-editor-alt" data-ov-make>Make an image</button>' : '')
++ + '</div></div><h4 class="ov-editor-ph">Preview</h4><div class="ov-editor-preview post-body" data-ov-preview aria-live="polite"></div></div>');
++ panel.querySelector('[data-ov-close]').addEventListener('click', closePanel);
++ // The editor page is itself one big form, so the inserter's form is a div: a nested <form>
++ // would be dropped by the parser and its fields would never be read.
++ var form = panel.querySelector('[data-ov-form]');
++ var preview = panel.querySelector('[data-ov-preview]');
++ var read = function () {
++ var values = {};
++ form.querySelectorAll('[name]').forEach(function (el) {
++ values[el.name] = el.type === 'checkbox' ? el.checked : el.value;
++ });
++ return values;
++ };
++ var redraw = function () { previewInto(preview, build(spec, read())); };
++ form.addEventListener('input', redraw);
++ form.addEventListener('change', redraw);
++ form.addEventListener('keydown', function (e) {
++ if (e.key === 'Enter' && e.target.tagName !== 'TEXTAREA') { e.preventDefault(); insertBlock(build(spec, read())); closePanel(); }
++ });
++ panel.querySelector('[data-ov-insertbtn]').addEventListener('click', function () {
++ insertBlock(build(spec, read()));
++ closePanel();
++ });
++ var find = panel.querySelector('[data-ov-search]');
++ if (find) find.addEventListener('click', showSearch);
++ var make = panel.querySelector('[data-ov-make]');
++ if (make) make.addEventListener('click', showImage);
++ redraw();
++ }
++
++ // ── Picture search: Openverse and Commons, straight from the browser ──
++ function showSearch() {
++ openPanel('<div class="ov-editor-card"><div class="ov-editor-cardhead"><h3>Find a picture</h3><button type="button" class="ov-editor-x" data-ov-close aria-label="Close">×</button></div>'
++ + '<p class="meta">Openverse and Wikimedia Commons, in their own licences. A picture found here is inserted as a linked image with its credit; a block figure needs a Media object, attached in the Media panel above.</p>'
++ + '<div class="ov-editor-form ov-editor-search" data-ov-searchform><label>Search<input type="search" name="q" placeholder="a channel, a workbench, a town…"></label><div class="ov-editor-actions"><button type="button" data-ov-searchgo>Search</button></div></div>'
++ + '<p class="meta" data-ov-status aria-live="polite"></p><ul class="ov-editor-results" data-ov-results></ul></div>');
++ panel.querySelector('[data-ov-close]').addEventListener('click', closePanel);
++ var form = panel.querySelector('[data-ov-searchform]');
++ var status = panel.querySelector('[data-ov-status]');
++ var results = panel.querySelector('[data-ov-results]');
++ var search = function () {
++ var q = form.querySelector('input[name=q]').value;
++ if (!q) return;
++ status.textContent = 'Searching Openverse and Commons…';
++ results.innerHTML = '';
++ Promise.allSettled([openverse(q), commons(q)]).then(function (out) {
++ var items = [];
++ out.forEach(function (r) { if (r.status === 'fulfilled') items = items.concat(r.value); else if (!items.length) status.textContent = 'One source could not be reached: ' + (r.reason && r.reason.message || 'no answer') + '.'; });
++ status.textContent = items.length ? items.length + ' pictures' : 'No pictures found.';
++ results.innerHTML = items.map(function (p, i) {
++ return '<li><img src="' + esc(p.thumb) + '" alt="" loading="lazy" decoding="async"><div><b>' + esc(p.title || 'Untitled') + '</b><span class="meta">' + esc([p.author, p.licence, p.source].filter(Boolean).join(' · ')) + '</span></div><button type="button" data-ov-pick="' + i + '">Insert</button></li>';
++ }).join('');
++ results.querySelectorAll('[data-ov-pick]').forEach(function (b) {
++ b.addEventListener('click', function () {
++ var p = items[Number(b.getAttribute('data-ov-pick'))];
++ var alt = (p.title || 'Picture').replace(/\s+/g, ' ').slice(0, 200);
++ insertBlock('![' + alt.replace(/[[\]]/g, '') + '](' + p.url + ')\n*Picture: ' + [p.author, p.licence, p.source].filter(Boolean).join(' · ') + '*');
++ closePanel();
++ });
++ });
++ });
++ };
++ panel.querySelector('[data-ov-searchgo]').addEventListener('click', search);
++ form.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); search(); } });
++ }
++
++ function openverse(q) {
++ return fetch('https://api.openverse.org/v1/images/?q=' + encodeURIComponent(q) + '&page_size=12', { headers: { Accept: 'application/json' } })
++ .then(function (r) { if (!r.ok) throw new Error('Openverse answered ' + r.status); return r.json(); })
++ .then(function (d) {
++ return (d.results || []).map(function (x) {
++ return {
++ url: x.url, thumb: x.thumbnail || x.url, title: x.title,
++ author: x.creator, licence: [x.license, x.license_version].filter(Boolean).join(' ').toUpperCase(),
++ source: 'Openverse',
++ };
++ });
++ });
++ }
++
++ function commons(q) {
++ var api = 'https://commons.wikimedia.org/w/api.php?action=query&format=json&origin=*&generator=search&gsrnamespace=6&gsrlimit=12'
++ + '&gsrsearch=' + encodeURIComponent(q) + '&prop=imageinfo&iiprop=url%7Cextmetadata&iiurlwidth=480';
++ return fetch(api, { headers: { Accept: 'application/json' } })
++ .then(function (r) { if (!r.ok) throw new Error('Commons answered ' + r.status); return r.json(); })
++ .then(function (d) {
++ var pages = (d.query && d.query.pages) || {};
++ return Object.keys(pages).map(function (k) {
++ var p = pages[k];
++ var info = (p.imageinfo && p.imageinfo[0]) || {};
++ var meta = info.extmetadata || {};
++ var clean = function (v) { return v && v.value ? String(v.value).replace(/<[^>]*>/g, '').trim() : null; };
++ return {
++ url: info.url, thumb: info.thumburl || info.url, title: (p.title || '').replace(/^File:/, ''),
++ author: clean(meta.Artist), licence: clean(meta.LicenseShortName), source: 'Wikimedia Commons',
++ };
++ });
++ });
++ }
++
++ // ── Make an image ───────────────────────────────────────
++ function showImage() {
++ openPanel('<div class="ov-editor-card"><div class="ov-editor-cardhead"><h3>Make an image</h3><button type="button" class="ov-editor-x" data-ov-close aria-label="Close">×</button></div>'
++ + '<p class="meta">The image task through OpenVibe.AI, with the model the network chooses. The picture is stored in OpenVibe.Media; its credit says which model made it.</p>'
++ + '<div class="ov-editor-form" data-ov-imageform><label>What should it show?<textarea name="prompt" rows="3" maxlength="1000"></textarea></label><label>Alt text<input name="alt" maxlength="300"></label>'
++ + '<div class="ov-editor-actions"><button type="button" data-ov-imagego>Make the image</button></div></div>'
++ + '<p class="meta" data-ov-status aria-live="polite"></p><div class="ov-editor-preview" data-ov-made></div></div>');
++ panel.querySelector('[data-ov-close]').addEventListener('click', closePanel);
++ var form = panel.querySelector('[data-ov-imageform]');
++ var status = panel.querySelector('[data-ov-status]');
++ var made = panel.querySelector('[data-ov-made]');
++ panel.querySelector('[data-ov-imagego]').addEventListener('click', function () {
++ var prompt = form.querySelector('[name=prompt]').value;
++ var alt = form.querySelector('[name=alt]').value;
++ if (!prompt.trim()) { status.textContent = 'Say what the image should show.'; return; }
++ status.textContent = 'Making the image…';
++ var body = new URLSearchParams({ _csrf: CSRF, prompt: prompt, alt: alt });
++ fetch(IMAGE, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString() })
++ .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
++ .then(function (x) {
++ if (!x.ok) { status.textContent = x.d && (x.d.detail || x.d.message) || 'The image task is not available here.'; return; }
++ var alt = (x.d.alt || 'Illustration').replace(/\s+/g, ' ').slice(0, 200);
++ if (x.d.mediaId) {
++ insertBlock(':::figure{src="' + x.d.mediaId + '" alt="' + alt.replace(/"/g, '\\"') + '" credit="Made with ' + String(x.d.model || 'AI').replace(/"/g, '') + '"}:::');
++ status.textContent = 'Inserted as a figure. Attach ' + x.d.mediaId + ' in the Media panel so it renders.';
++ closePanel();
++ } else if (x.d.url) {
++ made.innerHTML = '<img src="' + esc(x.d.url) + '" alt="">';
++ status.textContent = 'Inserting the picture as a linked image…';
++ insertBlock('![' + alt.replace(/[[\]]/g, '') + '](' + x.d.url + ')\n*Made with ' + String(x.d.model || 'AI') + '*');
++ closePanel();
++ } else {
++ status.textContent = 'The run answered without a picture.';
++ }
++ })
++ .catch(function () { status.textContent = 'The image task could not be reached.'; });
++ });
++ }
++
++ // ── The outline: the top-level blocks, dragged into another order ──
++ var dragFrom = null;
++ function chunksOf(text) {
++ var lines = String(text).split('\n');
++ var chunks = [];
++ var depth = 0;
++ var start = 0;
++ var current = null;
++ var pushProse = function (end) {
++ if (end <= start) return;
++ var body = lines.slice(start, end).join('\n');
++ if (body.trim()) chunks.push({ type: 'text', label: 'Prose', start: start, end: end, body: body });
++ };
++ for (var i = 0; i < lines.length; i++) {
++ var line = lines[i];
++ var leaf = /^:::([a-z][a-z0-9_]*)(\{.*\})?:::\s*$/.exec(line);
++ var open = /^:::([a-z][a-z0-9_]*)/.exec(line);
++ if (leaf) {
++ if (depth === 0) {
++ pushProse(i);
++ chunks.push({ type: leaf[1], label: labelOf(leaf[1], lines.slice(i + 1, i + 2)), start: i, end: i + 1, body: line });
++ start = i + 1;
++ }
++ continue;
++ }
++ if (open) {
++ if (depth === 0) { pushProse(i); current = { type: open[1], start: i }; }
++ depth++;
++ continue;
++ }
++ if (/^:::\s*$/.test(line)) {
++ depth = Math.max(0, depth - 1);
++ if (depth === 0 && current) {
++ current.end = i + 1;
++ current.label = labelOf(current.type, lines.slice(current.start + 1, i).slice(0, 6));
++ current.body = lines.slice(current.start, i + 1).join('\n');
++ chunks.push(current);
++ current = null;
++ start = i + 1;
++ }
++ }
++ }
++ pushProse(lines.length);
++ return chunks;
++ }
++ var LABELS = { hero: 'Hero', section: 'Section', text: 'Prose', figure: 'Picture', gallery: 'Gallery', compare: 'Before / after', callout: 'Callout', stat: 'Stat', quote: 'Quote', infobox: 'Infobox', timeline: 'Timeline', patchnotes: 'Patch notes' };
++ function labelOf(type, lines) {
++ var name = LABELS[type] || type;
++ for (var i = 0; i < (lines || []).length; i++) {
++ var m = /^#{1,3}\s+(.+)$/.exec(lines[i] || '');
++ if (m) return name + ': ' + m[1].trim().slice(0, 70);
++ }
++ return name;
++ }
++ function renderOutline() {
++ var chunks = chunksOf(ta.value);
++ if (!chunks.length) { outlinePanel.innerHTML = '<p class="meta">Nothing to outline yet.</p>'; return; }
++ outlinePanel.innerHTML = '<p class="meta">Drag a block to move it. Prose moves with its own lines.</p><ol class="ov-editor-outline-list">'
++ + chunks.map(function (c, i) {
++ return '<li draggable="true" data-ov-chunk="' + i + '"><span class="ov-editor-grip" aria-hidden="true">⠿</span><b>' + esc(c.label) + '</b><span class="meta">' + esc(':::' + (c.type === 'text' ? 'prose' : c.type)) + '</span></li>';
++ }).join('') + '</ol>';
++ outlinePanel._chunks = chunks;
++ outlinePanel.querySelectorAll('[data-ov-chunk]').forEach(function (li) {
++ li.addEventListener('dragstart', function (e) {
++ dragFrom = Number(li.getAttribute('data-ov-chunk'));
++ if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', String(dragFrom)); }
++ li.classList.add('is-dragging');
++ });
++ li.addEventListener('dragend', function () { li.classList.remove('is-dragging'); dragFrom = null; });
++ li.addEventListener('dragover', function (e) { e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; });
++ li.addEventListener('drop', function (e) {
++ e.preventDefault();
++ var to = Number(li.getAttribute('data-ov-chunk'));
++ if (dragFrom == null || dragFrom === to) return;
++ var list = outlinePanel._chunks.slice();
++ var moved = list.splice(dragFrom, 1)[0];
++ list.splice(to, 0, moved);
++ ta.value = list.map(function (c) { return c.body; }).join('\n');
++ ta.dispatchEvent(new Event('input', { bubbles: true }));
++ renderOutline();
++ });
++ });
++ }
++ if (outlineBtn) {
++ outlineBtn.addEventListener('click', function () {
++ var open = outlinePanel.hidden;
++ outlinePanel.hidden = !open;
++ outlineBtn.setAttribute('aria-pressed', open ? 'true' : 'false');
++ if (open) renderOutline();
++ });
++ }
++ ta.addEventListener('input', function () {
++ maybeMenu();
++ if (outlinePanel && !outlinePanel.hidden) renderOutline();
++ });
++
++ // ── The "/" menu ────────────────────────────────────────
++ function slashMatch() {
++ var s = ta.selectionStart;
++ var lineStart = ta.value.lastIndexOf('\n', s - 1) + 1;
++ var before = ta.value.slice(lineStart, s);
++ var m = /^\/([a-z0-9 _-]*)$/i.exec(before);
++ return m ? { start: lineStart, length: before.length, q: m[1] } : null;
++ }
++ var menuTimer = null;
++ function maybeMenu() {
++ clearTimeout(menuTimer);
++ menuTimer = setTimeout(function () {
++ var m = slashMatch();
++ if (m) { if (!menuOpen || m.q !== menuQuery) showMenu(m.q); }
++ else if (menuOpen) closePanel();
++ }, 120);
++ }
++ if (insertBtn) insertBtn.addEventListener('click', function () { if (menuOpen) closePanel(); else showMenu(''); });
++ ta.addEventListener('keydown', function (e) {
++ if (!menuOpen) return;
++ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
++ e.preventDefault();
++ if (!menuItems.length) return;
++ menuIndex = (menuIndex + (e.key === 'ArrowDown' ? 1 : -1) + menuItems.length) % menuItems.length;
++ panel.querySelectorAll('[data-ov-choice]').forEach(function (b, i) { b.setAttribute('aria-selected', i === menuIndex ? 'true' : 'false'); });
++ } else if (e.key === 'Enter' || e.key === 'Tab') {
++ if (!menuItems.length) return;
++ e.preventDefault();
++ choose(menuIndex);
++ } else if (e.key === 'Escape') {
++ e.preventDefault();
++ closePanel();
++ }
++ });
++})();
ddiff --git a/public/js/blog-index.js b/public/js/blog-index.js
nnew file mode 100644
iindex 0000000..bd2a406
---- /dev/null
++++ b/public/js/blog-index.js
@@@ -0,0 +1,35 @@
++'use strict';
++/**
++ * The index's next page (the official blog's home and /devblog): as a reader nears the bottom, the
++ * next page's art cards are added in place. The "Next page" link stays a real link — a crawler, a
++ * reader without script or a failed fetch still moves to the same page.
++ */
++(function () {
++ var next = document.querySelector('.bh-next');
++ var grid = document.querySelector('.bh-artcards');
++ if (!next || !grid || !('IntersectionObserver' in window) || !window.fetch) return;
++ var busy = false;
++ function load() {
++ if (busy || !next) return;
++ var href = next.getAttribute('href');
++ if (!href) return;
++ busy = true;
++ fetch(href, { credentials: 'same-origin' })
++ .then(function (r) { return r.ok ? r.text() : null; })
++ .then(function (html) {
++ if (!html) return;
++ var doc = new DOMParser().parseFromString(html, 'text/html');
++ var cards = doc.querySelectorAll('.bh-artcards > li');
++ for (var i = 0; i < cards.length; i++) grid.appendChild(document.importNode(cards[i], true));
++ var following = doc.querySelector('.bh-next');
++ if (following) next.setAttribute('href', following.getAttribute('href'));
++ else { next.parentNode.removeChild(next); io.disconnect(); }
++ })
++ .catch(function () { /* the link is still there */ })
++ .then(function () { busy = false; });
++ }
++ var io = new IntersectionObserver(function (entries) {
++ for (var i = 0; i < entries.length; i++) if (entries[i].isIntersecting) load();
++ }, { rootMargin: '600px 0px' });
++ io.observe(next);
++})();
ddiff --git a/server/app.js b/server/app.js
iindex 19fea83..e0d922e 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -163,7 +163,9 @@ async function createApp(opts = {}) {
mediaSrc: ["'self'", 'https:'],
// openvibe.events: release notifications (release-watch's EventSource, openvibe-shared 1.17).
// The navbar's Messages count asks Chat (openvibe-shared 4.130.0).
-- connectSrc: ["'self'", 'https://openvibe.chat', 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],
++ // The editor's picture search asks Openverse and Wikimedia Commons directly (the plan's
++ // fallback while Exchange's image search is not here): their APIs, not ours, answer.
++ connectSrc: ["'self'", 'https://openvibe.chat', 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events', 'https://api.openverse.org', 'https://commons.wikimedia.org'],
frameSrc: ["'self'", 'https://openvibe.network'],
frameAncestors: ["'self'"],
objectSrc: ["'none'"],
ddiff --git a/server/clients/community.js b/server/clients/community.js
iindex 2e1502a..23e1eef 100644
---- a/server/clients/community.js
++++ b/server/clients/community.js
@@@ -75,6 +75,38 @@ function createCommunity({ store, config, fetchImpl = globalThis.fetch }) {
});
},
++ /**
++ * The section reactions of a post: the counts of every section of its thread, keyed by the
++ * section id. A post that has no thread yet, or a Community that cannot be reached, has no
++ * counts: the reader sees the sections without a reaction, never a fake one.
++ */
++ async sectionReactions(post, ctx) {
++ if (!post || post.visibility !== 'public') return {};
++ const known = await store.discussion.get(post.id);
++ if (!known) return {};
++ try {
++ const data = await call('GET', `/api/v1/comments/threads/${encodeURIComponent(known.threadId)}/section-reactions`, { ctx });
++ const out = {};
++ for (const [id, c] of Object.entries((data && data.sections) || {})) {
++ out[id] = { up: Number(c.up) || 0, down: Number(c.down) || 0 };
++ }
++ return out;
++ } catch (err) {
++ console.warn(`[Blog] section reactions for ${post.id} unavailable: ${err.message}`);
++ return {};
++ }
++ },
++
++ /** A reader's reaction to one section of a post (👍 or 👎). */
++ async reactToSection(post, threadId, subject, sectionId, value, ctx) {
++ if (!writeTokens) { const e = new Error('comments are not configured'); e.status = 503; throw e; }
++ const id = threadId || (await store.discussion.get(post.id) || {}).threadId;
++ if (!id) { const e = new Error('this post has no comment thread'); e.status = 409; throw e; }
++ return await call('POST', `/api/v1/comments/threads/${encodeURIComponent(id)}/section-reactions`, {
++ tokens: writeTokens, subject, body: { section_id: sectionId, value }, ctx,
++ });
++ },
++
/**
* Best effort: hide a post's thread when the post is deleted or stops being public, show it
* again when it is public again. Needs community.comment.moderate.
ddiff --git a/server/domain/ai-drafts.js b/server/domain/ai-drafts.js
iindex 391bd4f..f9a9bcb 100644
---- a/server/domain/ai-drafts.js
++++ b/server/domain/ai-drafts.js
@@@ -97,7 +97,31 @@ function createAiDrafts({ config, store, posts, access, fetchImpl = globalThis.f
}, { traceparent });
}
-- return { enabled: ai.enabled, draft, rawDraft };
++ /**
++ * "Make an image" in the editor: the image task (`image.generate`) through OpenVibe.AI, with the same
++ * target and on-behalf-of a draft uses. The answer's Media id is what a figure block names; a plain
++ * URL is returned as well so the editor can show what was made. Nothing is stored here.
++ */
++ async function image(viewer, blog, input = {}, { traceparent } = {}) {
++ if (!viewer || !viewer.subject) throw new ApiError(401, 'subject.required', 'Sign in to make an image');
++ const prompt = clean(input.prompt, 1000);
++ if (!prompt) throw new ApiError(422, 'ai_image.prompt_required', 'Say what the image should show');
++ try {
++ const r = await ai.run('image.generate', { prompt, ...(input.alt ? { alt: clean(input.alt, 300) } : {}) }, {
++ onBehalfOf: { type: 'user', id: viewer.subject }, target: { service: 'blog', type: 'blog', id: String(blog.id) }, traceparent,
++ });
++ const out = r.output || {};
++ return {
++ runId: r.runId, mediaId: out.media_id || out.mediaId || null, url: out.url || null,
++ alt: out.alt || clean(input.alt, 300) || null, model: (r.workflow && r.workflow.model) || null, synthetic: Boolean(r.synthetic),
++ };
++ } catch (err) {
++ if (err instanceof AiRunError) throw new ApiError(err.status || 502, err.code, err.message);
++ throw err;
++ }
++ }
++
++ return { enabled: ai.enabled, imageEnabled: ai.enabled, draft, rawDraft, image };
}
module.exports = { createAiDrafts };
ddiff --git a/server/domain/posts.js b/server/domain/posts.js
iindex ec7962b..92a53f0 100644
---- a/server/domain/posts.js
++++ b/server/domain/posts.js
@@@ -21,6 +21,7 @@ const { slugify } = require('openvibe-publishing/taxonomy');
const seo = require('openvibe-publishing/seo');
const authorship = require('openvibe-publishing/authorship');
const { isMediaId } = require('openvibe-publishing/media');
++const blocks = require('openvibe-publishing/blocks');
const { ApiError } = require('../http/errors');
const { actorRef, canPublish } = require('./publication');
const reviewKind = require('./review-kind');
@@@ -85,6 +86,23 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,
if (b.length > MAX_BODY) throw new ApiError(413, 'post.too_long', `A post is at most ${MAX_BODY} characters`);
return b;
}
++ /**
++ * The body of a post: Markdown, or the blocks of a body. A caller may send either (SDK:
++ * posts.create({ markdown | blocks })); the Markdown is what the post keeps, so blocks are
++ * written back as the Markdown they parse from. The Markdown stays the source of truth.
++ */
++ function bodyOf(input) {
++ if (input.blocks !== undefined && input.blocks !== null) {
++ // A malformed tree is refused here, not stored as a body its own renderer cannot read.
++ const tree = Array.isArray(input.blocks) ? { type: 'root', children: input.blocks } : input.blocks;
++ const check = blocks.validate(tree);
++ if (!check.ok) throw new ApiError(422, 'post.blocks_invalid', `The blocks are not valid: ${check.errors.slice(0, 5).map((e) => e.message).join('; ')}`);
++ const md = blocks.serialize(tree);
++ if (md.length > MAX_BODY) throw new ApiError(413, 'post.too_long', `A post is at most ${MAX_BODY} characters`);
++ return md;
++ }
++ return body(input.body !== undefined ? input.body : input.markdown);
++ }
function summary(v) {
if (v == null) return null;
const s = String(v).replace(/\s+/g, ' ').trim().slice(0, 500);
@@@ -261,7 +279,7 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,
const subject = actingSubject(viewer);
if (!await access.canWrite(store, viewer, blog, 'create')) throw new ApiError(403, 'blog.forbidden', 'You cannot write on this blog');
const t = title(input.title);
-- const content = body(input.body);
++ const content = bodyOf(input);
const vis = visibilityOf(blog, input.visibility, input.entitlementKey ?? input.entitlement_key);
const rec = authorshipFor(viewer, input, null);
return await store.tx(async () => {
@@@ -284,10 +302,19 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,
if (sharing) await sharing.register(viewer, id, 'private');
const post = await q.byId.get(id);
await setTerms(blog, post, input);
++ // The draft names its subject for the Dock: the blog and the post's own title, the run
++ // that wrote it (when an agent did) and the skill it ran. A person's draft carries no run.
await outbox.emit({
event_type: 'blog.post.created', actor: actorRef(viewer), visibility: 'internal', priority: 'low',
subject: { type: 'post', id, revision: revision.number },
-- payload: { blog: { id: blog.id, handle: blog.handle }, state: 'draft', visibility: post.visibility, authorship: rec.mode, ...(rec.workflow ? { workflow: rec.workflow } : {}) },
++ payload: {
++ blog: { id: blog.id, handle: blog.handle, title: blog.title, url: publication.blogPath(blog) },
++ post: { id, title: t, url: publication.postPath(blog, post) },
++ state: 'draft', visibility: post.visibility, authorship: rec.mode,
++ skill: (rec.workflow && rec.workflow.id) || 'blog.post.create',
++ run_id: (rec.workflow && rec.workflow.runId) || null,
++ ...(rec.workflow ? { workflow: rec.workflow } : {}),
++ },
}, { traceparent });
return { post: await q.byId.get(id), revision };
});
@@@ -308,7 +335,7 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,
const head = await store.revisions.head(post.id);
let revision = head;
let created = false;
-- const contentChange = input.title !== undefined || input.body !== undefined || input.summary !== undefined;
++ const contentChange = input.title !== undefined || input.body !== undefined || input.markdown !== undefined || input.blocks !== undefined || input.summary !== undefined;
if (contentChange) {
const expected = input.expectedRevision ?? input.expected_revision;
if (expected == null || expected === '') throw new ApiError(428, 'revision.expected_required', 'Send expectedRevision (the revision you edited) so concurrent edits are not lost');
@@@ -318,7 +345,7 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,
};
const rec = authorshipFor(viewer, input, publication.authorshipOf(head));
const out = await store.revisions.create({
-- entityId: post.id, expectedRevision: parseInt(expected, 10), content: input.body !== undefined ? body(input.body) : head.content,
++ entityId: post.id, expectedRevision: parseInt(expected, 10), content: (input.body !== undefined || input.markdown !== undefined || input.blocks !== undefined) ? bodyOf(input) : head.content,
fields, meta: { authorship: rec }, author: authorLabel(viewer), message: input.message || null,
});
revision = out.revision;
ddiff --git a/server/domain/reading.js b/server/domain/reading.js
iindex 370e9ff..2c45938 100644
---- a/server/domain/reading.js
++++ b/server/domain/reading.js
@@@ -7,6 +7,7 @@
*/
const seo = require('openvibe-publishing/seo');
const ssr = require('openvibe-publishing/ssr');
++const blocks = require('openvibe-publishing/blocks');
const reviewKind = require('./review-kind');
const { renderBody } = require('../render/pages');
@@@ -56,12 +57,17 @@ function createReading({ store, blogs, posts: postsApi, publication, people, med
if (!rev) return null;
const blog = blogById.get(post.blog_id);
const person = who.get(post.author_subject);
-- const { tags } = await termsView(post, blog, { networkTags: !perBlogLinks });
++ const { tags, categories } = await termsView(post, blog, { networkTags: !perBlogLinks });
++ // The hero art of a devblog is its card in a list: the block names a Media id, and the card
++ // shows the picture when Media can deliver it.
++ const hero = (blocks.parse(rev.content).children || []).find((c) => c.type === 'hero');
++ const artId = hero && hero.attrs && typeof hero.attrs.art === 'string' ? hero.attrs.art : null;
return {
post, rev, blog,
url: publication.postPath(blog, post),
author: person ? { name: person.name, url: urls.author(person, blog) } : null,
-- tags,
++ tags, categories,
++ art: artId && media ? { mediaId: artId, url: media.urlFor(artId) } : null,
badge: { members: 'Members only', private: 'Private' }[post.visibility] || null,
};
}));
@@@ -90,7 +96,7 @@ function createReading({ store, blogs, posts: postsApi, publication, people, med
url: publication.postUrl(blog, post),
title: rev.fields.title,
summary: rev.fields.summary || ssr.markdownToText(rev.content, 300),
-- contentHtml: fullContent ? renderBody(rev.content, atts, { urlFor: media.urlFor, rel }) : null,
++ contentHtml: fullContent ? renderBody(rev.content, atts, { urlFor: media.urlFor, rel }).html : null,
published: post.first_published_at,
updated: rev.createdAt,
decision,
@@@ -141,9 +147,10 @@ function createReading({ store, blogs, posts: postsApi, publication, people, med
}
/** BlogPosting JSON-LD from real fields only (missing → omitted). */
-- function postJsonLd({ blog, post, rev, person, tags, citations, attachments }) {
++ function postJsonLd({ blog, post, rev, person, tags, citations, attachments, sections = [], heroArt = null }) {
const url = publication.postUrl(blog, post);
const cover = attachments.find((a) => a.role === 'cover' && !a.broken);
++ const art = heroArt || (cover ? cover.mediaId : null);
return seo.structuredData.article({
type: 'BlogPosting',
headline: rev.fields.title,
@@@ -153,11 +160,12 @@ function createReading({ store, blogs, posts: postsApi, publication, people, med
dateModified: post.published_revision ? rev.createdAt : null,
authors: person && person.known ? [{ name: person.name, url: publication.abs(urls.author(person, blog)) }] : [],
publisher: blog.kind === 'official' ? { name: 'OpenVibe', url: 'https://openvibe.network' } : null,
-- image: cover ? media.urlFor(cover.mediaId) : null,
++ image: art ? media.urlFor(art) : null,
keywords: tags.map((t) => t.name),
inLanguage: blog.language,
citations: citations.filter((c) => c.url).map((c) => ({ url: c.url, title: c.title })),
wordCount: ssr.wordCount(ssr.markdownToText(rev.content)),
++ hasPart: sections.map((s) => ({ name: s.name || s.text || s.id, url: `${publication.postUrl(blog, post)}#${s.id}` })),
});
}
ddiff --git a/server/http/actor-limits.js b/server/http/actor-limits.js
iindex e7fb0eb..de5130d 100644
---- a/server/http/actor-limits.js
++++ b/server/http/actor-limits.js
@@@ -78,6 +78,8 @@ const BUDGETS = {
'blog.post.media': { minute: 20, hour: 200 },
// A word diff of two long revisions costs CPU: a writer's pace, not a crawler's.
'blog.post.diff': { minute: 30, hour: 600 },
++ // The editor's live preview: the same renderer the page uses, once per pause in the typing.
++ 'blog.post.preview': { minute: 120, hour: 1200 },
// A comment goes to OpenVibe.Community in the person's name (Community allows 20 a minute).
'blog.comment.create': { minute: 20, hour: 300 },
};
ddiff --git a/server/http/api.js b/server/http/api.js
iindex ff98265..ee23923 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -36,6 +36,7 @@ const cache = require('openvibe-shared/cache-policy');
const { run, jsonBody, ApiError, privateNoStore } = require('./errors');
const { guard } = require('../auth/viewer');
const { checkCapability, CAPABILITIES } = require('../auth/capabilities');
++const blockRender = require('../render/blocks');
function cors(origins) {
const allowed = new Set(origins);
@@@ -113,6 +114,9 @@ function createApi(ctx) {
const rec = publication.authorshipOf(rev);
Object.assign(out, {
entitlement_key: post.entitlement_key, body: rev ? rev.content : null,
++ // The blocks of the body, next to the Markdown: the Markdown stays the source of truth,
++ // and a caller that wants the blocks (an editor, an agent, another product) reads them here.
++ blocks: rev ? blockRender.bodyBlocks(rev.content) : null,
authorship: rec, review: rev ? await publication.reviewOf(post, rev) : null,
scheduled: await posts.pendingJobs(post), attachments: await posts.attachments(post),
citations: rev ? await posts.citations(post, rev.number) : [],
@@@ -245,11 +249,21 @@ function createApi(ctx) {
router.get('/posts/:id', guard('blog.post.read'), run(async (req) => {
const { post, blog } = await mustPost(req);
-- if (await access.canWrite(store, req.viewer, blog, 'edit', post) || access.isStaff(req.viewer)) return { post: await postDto(post, { full: true }) };
++ // ?format=blocks: the blocks of the body, parsed. The Markdown is the source of truth, so the
++ // blocks are what it says — never a second copy of the truth.
++ const asBlocks = String((req.query && req.query.format) || '').toLowerCase() === 'blocks';
++ if (await access.canWrite(store, req.viewer, blog, 'edit', post) || access.isStaff(req.viewer)) {
++ const dto = await postDto(post, { full: true });
++ return asBlocks ? { post: dto, blocks: dto.blocks } : { post: dto };
++ }
const d = await access.canReadPost(store, req.viewer, blog, post, entitlements, ctx.sharing);
// Members only: the teaser (title, the author's summary) and the join link, never the body.
if (!d.allowed && d.status === 403) throw new ApiError(403, 'post.members_only', 'Members only', { teaser: await reading.teaser(blog, post, { reason: d.vip || null }) });
if (!d.allowed) throw new ApiError(d.status, 'post.not_found', 'No such post');
++ if (asBlocks) {
++ const rev = await posts.revision(post, post.published_revision);
++ return { blocks: rev ? blockRender.bodyBlocks(rev.content) : null };
++ }
return { post: await postDto(post) };
}));
ddiff --git a/server/http/editor.js b/server/http/editor.js
iindex 81cc05a..b2a738a 100644
---- a/server/http/editor.js
++++ b/server/http/editor.js
@@@ -21,6 +21,7 @@ const authorship = require('openvibe-publishing/authorship');
const { escapeHtml: esc } = require('openvibe-publishing/ssr');
const { renderPage } = require('../render/layout');
const editor = require('../render/editor');
++const blockRender = require('../render/blocks');
const { csrfToken, checkCsrf } = require('../auth/forms');
const { asApiError } = require('./errors');
const cache = require('openvibe-shared/cache-policy');
@@@ -278,6 +279,41 @@ function createEditorRoutes(ctx) {
res.redirect(303, `/write/posts/${found.post.id}?saved=${encodeURIComponent('Media removed.')}`);
}));
++ // ── The block inserter ──────────────────────────────────
++ // The form's live preview: the same renderer the post page uses, never a second implementation.
++ router.post('/preview', B('blog.post.preview'), async (req, res, next) => {
++ try {
++ const b = formInput(req.body);
++ const markdown = String(b.markdown || '').slice(0, 600 * 1024);
++ // A post's own attachments resolve, so a figure in the preview looks as it will on the page.
++ let attachments = [];
++ let urlFor = () => null;
++ const postId = String(b.post_id || '');
++ if (postId) {
++ const post = await posts.get(postId);
++ if (post && post.state !== 'deleted') {
++ const blog = await posts.blogOf(post);
++ if (await access.canWrite(store, req.viewer, blog, 'edit', post) || access.isStaff(req.viewer)) {
++ attachments = await store.attachments.list(post.id);
++ urlFor = ctx.media.urlFor;
++ }
++ }
++ }
++ const rendered = blockRender.renderBody(markdown, attachments, { urlFor, rel: 'noopener', headingShift: 1 });
++ res.json({ ok: true, html: rendered.html, sections: rendered.sections });
++ } catch (err) { next(err); }
++ });
++
++ // "Make an image" in the inserter: the image task through OpenVibe.AI, the same service the AI draft uses.
++ router.post('/make-image', B('blog.post.ai_draft'), async (req, res, next) => {
++ try {
++ if (!ctx.aiDrafts || !ctx.aiDrafts.imageEnabled) return res.status(503).json({ code: 'ai.not_configured', detail: 'OpenVibe.AI has no image task on this server yet.' });
++ const found = await postFor(req);
++ const out = await ctx.aiDrafts.image(req.viewer, found ? found.blog : await blogs.official(), formInput(req.body), { traceparent: req.ov && req.ov.traceparent });
++ res.json(out);
++ } catch (err) { next(err); }
++ });
++
// Settings → How I write: the voice card the AI prompt uses.
router.get('/voice', wrap(async (req, res) => {
const mine = await ctx.patchNotes.voice.get(req.viewer.subject);
ddiff --git a/server/http/openapi.js b/server/http/openapi.js
iindex ae21df4..294bded 100644
---- a/server/http/openapi.js
++++ b/server/http/openapi.js
@@@ -137,7 +137,9 @@ route('post', '/blogs/{handle}/posts', {
operationId: 'createPost', summary: 'Create a post (a draft)', tags: ['posts'], security: PERSON, parameters: [HANDLE],
description: 'blog.post.create. X-OV-Origin: ai marks the revision AI-authored (it then needs a review before publishing).',
requestBody: body({ type: 'object', required: ['title'], properties: {
-- title: str({ maxLength: 200 }), body: str({ maxLength: 200000, description: 'Markdown' }), summary: str({ maxLength: 500 }),
++ title: str({ maxLength: 200 }), body: str({ maxLength: 200000, description: 'Markdown' }), markdown: str({ maxLength: 200000, description: 'Markdown (the same as body)' }),
++ blocks: { type: 'object', description: 'A block tree (see openvibe-publishing/blocks); the Markdown that parses back into it is what the post keeps', additionalProperties: true },
++ summary: str({ maxLength: 500 }),
slug: str({ pattern: '^[a-z0-9][a-z0-9-]{0,79}$' }), visibility,
entitlement_key: str({ pattern: '^[a-z][a-z0-9_.:-]{0,127}$', description: 'Only for visibility members' }),
tags: arrayOf(str({ maxLength: 50 })), categories: arrayOf(str()), series: nullable('string'), series_position: nullable('integer', { minimum: 1 }),
@@@ -151,15 +153,18 @@ route('get', '/blogs/{handle}/feed', {
responses: { 200: { description: 'The feed (application/feed+json)', content: { 'application/feed+json': { schema: { type: 'object' } } } }, ...errors([404, 429]) },
});
route('get', '/posts/{id}', {
-- operationId: 'getPost', summary: 'One post', tags: ['posts'], security: ANONYMOUS, parameters: [POST],
-- description: 'blog.post.read. A member sees the full post; a reader of a members-only post gets 403 with a teaser.',
-- responses: { 200: json('The post', { type: 'object', properties: { post: ref('Post') } }), 403: problem, ...errors([400, 404, 429]) },
++ operationId: 'getPost', summary: 'One post', tags: ['posts'], security: ANONYMOUS,
++ parameters: [POST, query('format', { type: 'string', enum: ['blocks'] }, 'blocks: the body parsed as blocks, without the Markdown')],
++ description: 'blog.post.read. A member sees the full post; a reader of a members-only post gets 403 with a teaser. For a writer the post carries the blocks next to the Markdown; ?format=blocks answers with the blocks alone.',
++ responses: { 200: json('The post', { type: 'object', properties: { post: ref('Post'), blocks: { type: 'object' } } }), 403: problem, ...errors([400, 404, 429]) },
});
route('patch', '/posts/{id}', {
operationId: 'updatePost', summary: 'Edit a post: a new revision, or the row (slug, visibility, terms, flags)', tags: ['posts'], security: PERSON, parameters: [POST],
description: 'blog.post.update. Any content change needs expected_revision (428 without it; 412 when it is stale).',
requestBody: body({ type: 'object', properties: {
-- expected_revision: { type: 'integer', minimum: 0 }, title: str({ maxLength: 200 }), body: str({ maxLength: 200000 }), summary: str({ maxLength: 500 }),
++ expected_revision: { type: 'integer', minimum: 0 }, title: str({ maxLength: 200 }), body: str({ maxLength: 200000 }), markdown: str({ maxLength: 200000 }),
++ blocks: { type: 'object', description: 'A block tree; the Markdown that parses back into it is what the revision keeps', additionalProperties: true },
++ summary: str({ maxLength: 500 }),
slug: str({ pattern: '^[a-z0-9][a-z0-9-]{0,79}$' }), visibility, entitlement_key: str(),
tags: arrayOf(str()), categories: arrayOf(str()), series: nullable('string'), series_position: nullable('integer'),
allow_comments: { type: 'boolean' }, noindex: { type: 'boolean' }, citations: arrayOf(citation), message: str(),
ddiff --git a/server/http/public.js b/server/http/public.js
iindex 107698d..5c629b6 100644
---- a/server/http/public.js
++++ b/server/http/public.js
@@@ -25,8 +25,8 @@ const ssr = require('openvibe-publishing/ssr');
const cache = require('openvibe-shared/cache-policy');
const { renderPage, SITE_SUMMARY } = require('../render/layout');
const pages = require('../render/pages');
++const blockRender = require('../render/blocks');
const homePage = require('../render/home');
--const deskCatalog = require('../desks/catalog');
// The short home (the owner's rule, 2026-10-30): pick blocks and folds.
const { foldBlock } = require('../render/short');
const { createHome } = require('../domain/home');
@@@ -40,6 +40,7 @@ const PER_PAGE = 15;
function createPublicRoutes(ctx) {
const { config, store, blogs, posts, publication, reading, people, community, media, access, entitlements, viewers, limits, desks, sharing } = ctx;
++ const changelog = ctx.changelog || null;
const homeData = ctx.home || createHome({ store });
const router = express.Router();
router.use(viewers.middleware({ services: false }));
@@@ -97,8 +98,85 @@ function createPublicRoutes(ctx) {
// ── Blog front pages ────────────────────────────────────
++ /**
++ * The index's rows: the official blog's posts as the tabs and the name filter narrow them. The tabs
++ * are categories (Devblog, Daily, Community, Guides); a tab whose category does not exist yet is
++ * empty, not hidden — every tab is a real, crawlable link.
++ */
++ async function indexData(req, blog, base) {
++ const page = pageNumber(req);
++ const scope = await access.listingScope(store, req.viewer, blog);
++ const wanted = String(req.query.tab || 'latest').toLowerCase();
++ const tab = homePage.INDEX_TABS.find((t) => t.id === wanted) || homePage.INDEX_TABS[0];
++ const q = String(req.query.q || '').replace(/\s+/g, ' ').trim().slice(0, 80);
++ const term = tab.slug ? await store.taxonomy.bySlug(blogs.categoryVocabulary(blog), tab.slug) : null;
++ // A tab names a category. Until that category has a post there is nothing to list: falling back to
++ // the unfiltered index would show every post under a tab that promises one kind.
++ const tabEmpty = Boolean(tab.slug && !term);
++ let rows;
++ let total;
++ if (tabEmpty) {
++ rows = [];
++ total = 0;
++ } else if (q) {
++ // A name filter is over the titles people see: a bounded read, filtered in place. The index is
++ // small by design (a blog's published posts), and the unfiltered listing keeps its SQL pager.
++ const all = await posts.listPublished({ blogId: blog.id, ...scope, ...(term ? { termIds: [term.id] } : {}), limit: 500 });
++ const revs = await store.revisions.getMany(all.posts.map((p) => ({ entityId: p.id, revision: p.published_revision })));
++ const needle = q.toLowerCase();
++ const matched = all.posts.filter((p, i) => revs[i] && String((revs[i].fields || {}).title || '').toLowerCase().includes(needle));
++ total = matched.length;
++ rows = matched.slice((page - 1) * PER_PAGE, page * PER_PAGE);
++ } else {
++ ({ total, posts: rows } = await posts.listPublished({ blogId: blog.id, ...scope, ...(term ? { termIds: [term.id] } : {}), limit: PER_PAGE, offset: (page - 1) * PER_PAGE }));
++ }
++ const pager = ssr.paginate({
++ page, perPage: PER_PAGE, total,
++ href: (p) => {
++ const params = new URLSearchParams();
++ if (p > 1) params.set('page', String(p));
++ if (tab.id !== 'latest') params.set('tab', tab.id);
++ if (q) params.set('q', q);
++ const s = params.toString();
++ return s ? `${base}?${s}` : base;
++ },
++ });
++ return { page, tab, q, term, items: await reading.listItems(rows), pager, feeds: await reading.feedsOf(blog), scope };
++ }
++
++ // The network's home (the official blog's first page and /devblog): the newest post as a featured
++ // hero, the tabs, the name filter and the archive as art cards, then everything else the home shows.
++ async function homeIndex(req, res, blog) {
++ const { tab, q, items, pager, feeds, scope } = await indexData(req, blog, '/');
++ if (pager.outOfRange && pager.total) return notFound(req, res);
++ const path = publication.blogPath(blog);
++ const blogLd = { '@context': 'https://schema.org', '@type': 'Blog', '@id': `${publication.abs(path)}#blog`, name: blog.title, url: publication.abs(path), description: blog.description || undefined, publisher: { '@type': 'Organization', name: 'OpenVibe', url: 'https://openvibe.network' } };
++ // Series are named only when they hold a post this viewer may see.
++ const shows = async (filter) => (await posts.listPublished({ ...filter, blogId: blog.id, ...scope, limit: 1 })).total > 0;
++ const seriesShown = [];
++ for (const sr of await blogs.series(blog)) if (await shows({ seriesId: sr.id })) seriesShown.push(sr);
++ const [stats, week, latestDesks, tags, memberRows, seriesCounts] = await Promise.all([
++ homeData.stats(), homeData.weekChanges(10), desks ? desks.latest() : new Map(), homeData.topTags(24), homeData.memberPosts(6),
++ Promise.all(seriesShown.map(async (sr) => ({ title: sr.title, url: `/@${blog.handle}/series/${encodeURIComponent(sr.slug)}`, posts: (await posts.listPublished({ blogId: blog.id, seriesId: sr.id, ...scope, limit: 1 })).total }))),
++ ]);
++ const memberItems = memberRows.length ? await reading.listItems(memberRows) : [];
++ const canonical = seo.canonicalUrl(config.baseUrl, '/', { query: ['page', 'tab', 'q'] });
++ return send(req, res, 200, {
++ title: blog.title, fullTitle: HOME_TITLE, description: HOME_DESCRIPTION, summary: SITE_SUMMARY,
++ decision: pageDecision(canonical, { empty: false, query: ['page', 'tab', 'q'] }),
++ canonical, feeds, theme: { slug: blog.theme, vars: blogs.themeVars(blog) }, lang: blog.language,
++ next: pager.next ? pager.next.href : null,
++ jsonLd: [blogLd, ...homePage.homeJsonLd({ base: config.baseUrl.replace(/\/+$/, ''), items })],
++ styles: [showcase.STYLESHEET], bodyClass: 'blog-home',
++ body: homePage.homeBody({ blog, items, pager, feeds, stats, week, latestDesks, tags, series: seriesCounts, memberItems, signedIn: req.viewer.kind === 'user', index: { tab: tab.id, q, base: '/' } })
++ // The shipped list is a part a reader rarely needs: a fold on a phone, the section it was where there is room.
++ + foldBlock({ id: 'shipped', head: '<h2 id="bh-shipped-h">Recently shipped on OpenVibe.Blog</h2>', html: frame.shipped({ service: 'blog', title: 'Recently shipped on OpenVibe.Blog' }) }),
++ }, { cacheable: !scope.restricted && !scope.restrictedAuthor });
++ }
++
async function blogFront(req, res, blog, { home = false } = {}) {
const page = pageNumber(req);
++ if (home && page === 1) return await homeIndex(req, res, blog);
const scope = await access.listingScope(store, req.viewer, blog);
const path = publication.blogPath(blog);
const { total, posts: rows } = await posts.listPublished({ blogId: blog.id, ...scope, limit: PER_PAGE, offset: (page - 1) * PER_PAGE });
@@@ -123,28 +201,6 @@ function createPublicRoutes(ctx) {
const blogLd = blog.kind === 'official'
? { '@context': 'https://schema.org', '@type': 'Blog', '@id': `${publication.abs(path)}#blog`, name: blog.title, url: publication.abs(path), description: blog.description || undefined, publisher: { '@type': 'Organization', name: 'OpenVibe', url: 'https://openvibe.network' } }
: { '@context': 'https://schema.org', '@type': 'Blog', '@id': `${publication.abs(path)}#blog`, name: blog.title, url: publication.abs(path), description: blog.description || undefined };
-- // The network's home (the official blog's first page): what OpenVibe.Blog is for, then everything it has to read.
-- if (home && pager.page === 1) {
-- const [stats, week, latestDesks, tags, memberRows, seriesCounts] = await Promise.all([
-- homeData.stats(), homeData.weekChanges(10), desks ? desks.latest() : new Map(), homeData.topTags(24), homeData.memberPosts(6),
-- Promise.all(seriesShown.map(async (sr) => ({ title: sr.title, url: `/@${blog.handle}/series/${encodeURIComponent(sr.slug)}`, posts: (await posts.listPublished({ blogId: blog.id, seriesId: sr.id, ...scope, limit: 1 })).total }))),
-- ]);
-- const memberItems = memberRows.length ? await reading.listItems(memberRows) : [];
-- // The desks with a newest post: how many chips the AI desks' pick list carries.
-- const deskCount = deskCatalog.ALL.filter((d) => latestDesks.get(d.key)).length;
-- return send(req, res, 200, {
-- title: blog.title, fullTitle: HOME_TITLE, description: HOME_DESCRIPTION, summary: SITE_SUMMARY,
-- decision: pageDecision(canonical, { empty: false, query: ['page'] }),
-- canonical, feeds, theme: { slug: blog.theme, vars: blogs.themeVars(blog) }, lang: blog.language,
-- next: pager.next ? pager.next.href : null,
-- jsonLd: [blogLd, ...homePage.homeJsonLd({ base: config.baseUrl.replace(/\/+$/, ''), items })],
-- styles: [showcase.STYLESHEET], bodyClass: 'blog-home',
-- // The rule that shows the picked item of each pick list: one per item, written for this page.
-- body: homePage.homeBody({ blog, items, pager, feeds, stats, week, latestDesks, tags, series: seriesCounts, memberItems, signedIn: req.viewer.kind === 'user' })
-- // The shipped list is a part a reader rarely needs: a fold on a phone, the section it was where there is room.
-- + foldBlock({ id: 'shipped', head: '<h2 id="bh-shipped-h">Recently shipped on OpenVibe.Blog</h2>', html: frame.shipped({ service: 'blog', title: 'Recently shipped on OpenVibe.Blog' }) }),
-- }, { cacheable: !scope.restricted && !scope.restrictedAuthor });
-- }
send(req, res, 200, {
title: blog.title,
description: blog.description,
@@@ -168,6 +224,26 @@ function createPublicRoutes(ctx) {
router.get('/', wrap(async (req, res) => await blogFront(req, res, await blogs.official(), { home: true })));
++ // The devblog index on its own address: the same newest-first featured post, tabs, filter and art
++ // cards the official blog's home shows, with nothing else. Every tab and every page is a real link.
++ router.get('/devblog', wrap(async (req, res) => {
++ const blog = await blogs.official();
++ const { tab, q, items, pager } = await indexData(req, blog, '/devblog');
++ if (pager.outOfRange && pager.total) return notFound(req, res);
++ const canonical = seo.canonicalUrl(config.baseUrl, '/devblog', { query: ['page', 'tab', 'q'] });
++ const blogLd = { '@context': 'https://schema.org', '@type': 'Blog', '@id': `${publication.abs('/devblog')}#blog`, name: 'OpenVibe Devblog', url: publication.abs('/devblog'), publisher: { '@type': 'Organization', name: 'OpenVibe', url: 'https://openvibe.network' } };
++ return send(req, res, 200, {
++ title: 'Devblog',
++ description: 'Named updates from the OpenVibe team: what changed, why we did it, and what it means for you.',
++ decision: pageDecision(canonical, { empty: items.length === 0, query: ['page', 'tab', 'q'] }),
++ canonical, feeds: await reading.feedsOf(blog), theme: { slug: blog.theme, vars: blogs.themeVars(blog) }, lang: blog.language,
++ prev: pager.prev ? pager.prev.href : null, next: pager.next ? pager.next.href : null,
++ jsonLd: [blogLd, ...homePage.homeJsonLd({ base: config.baseUrl.replace(/\/+$/, ''), items })],
++ styles: [showcase.STYLESHEET], bodyClass: 'blog-devbog',
++ body: `<div class="bh-devbog"><header class="bh-devbog-head"><h1>OpenVibe Devblog</h1><p class="lede">Named updates from the team: what changed, why we did it, and what it means for you.</p></header>${homePage.indexList({ base: '/devblog', tab: tab.id, q, items, pager })}</div>`,
++ }, { cacheable: true });
++ }));
++
// What shipped on OpenVibe.Blog: the shared update log (openvibe-shared shipped.js, fed by the
// network changelog this service keeps), like every OpenVibe site's /updates.
router.get('/updates', wrap(async (req, res) => send(req, res, 200, {
@@@ -338,6 +414,27 @@ function createPublicRoutes(ctx) {
};
}
++ /**
++ * The patch notes of a window: the commits that shipped between two dates, grouped by site, each
++ * commit linked. This is what a ":::patchnotes{from=… to=…}" block shows.
++ */
++ async function patchNotesFor(blog, post, from, to) {
++ if (!changelog || !changelog.entries) return [];
++ const rows = await changelog.entries({ limit: 200 }).catch(() => []);
++ const lo = from ? Date.parse(String(from) + 'T00:00:00Z') : null;
++ const hi = to ? Date.parse(String(to) + 'T23:59:59Z') : null;
++ const groups = new Map();
++ for (const e of rows) {
++ const at = Date.parse(e.deployed_at || e.committed_at || '');
++ if (!Number.isFinite(at)) continue;
++ if (lo !== null && at < lo) continue;
++ if (hi !== null && at > hi) continue;
++ if (!groups.has(e.service)) groups.set(e.service, []);
++ groups.get(e.service).push({ sha: e.short, url: e.url, title: e.subject });
++ }
++ return [...groups.entries()].map(([site, commits]) => ({ site, commits }));
++ }
++
/** Render one post (also used by the editor's preview with a chosen revision). */
async function renderPost(req, res, { blog, post, rev, preview = false, status = 200 }) {
const decision = preview ? await publication.decide(blog, post, rev, { state: 'draft' }) : await publication.decide(blog, post, rev);
@@@ -352,6 +449,24 @@ function createPublicRoutes(ctx) {
const crumbs = [blogCrumb(blog), ...(firstCategory ? (await store.taxonomy.ancestors(firstCategory.id)).map((t) => ({ name: t.name, url: reading.urls.category(blog, t) })) : []), { name: rev.fields.title }];
const comments = preview ? null : await commentsFor(req, blog, post, rev);
const cacheable = !preview && post.state === 'published' && post.visibility === 'public';
++ // The sections and the hero art of the body: the contents rail, the JSON-LD parts and the
++ // picture a reader sees when the post is shared.
++ const bodyTree = blockRender.bodyBlocks(rev.content);
++ const bodySections = blockRender.sectionsOf(bodyTree);
++ const bodyHero = blockRender.heroOf(bodyTree);
++ const bodyHeroArt = bodyHero && bodyHero.attrs && typeof bodyHero.attrs.art === 'string' ? bodyHero.attrs.art : null;
++ // Section bylines and folded patch notes resolve before the renderer runs (it renders
++ // synchronously): one person lookup per subject, one change lookup per window, never a
++ // promise left to be printed as "The OpenVibe team".
++ const byPeople = new Map();
++ for (const s of bodySections) {
++ if (s.by && !byPeople.has(s.by)) byPeople.set(s.by, await people.one(s.by));
++ }
++ const windows = new Map();
++ for (const w of blockRender.patchNoteWindows(bodyTree)) {
++ const key = `${w.from || ''}..${w.to || ''}`;
++ if (!windows.has(key)) windows.set(key, await patchNotesFor(blog, post, w.from, w.to));
++ }
const csrf = req.viewer.kind === 'user' ? csrfToken(config, req.viewer) : '';
send(req, res, status, {
title: rev.fields.title,
@@@ -361,13 +476,13 @@ function createPublicRoutes(ctx) {
canonical: publication.postUrl(blog, post),
type: 'article',
author: person.known ? person.name : undefined,
-- image: (attachments.find((a) => a.role === 'cover' && !a.broken) || null) && media.urlFor(attachments.find((a) => a.role === 'cover' && !a.broken).mediaId),
++ image: bodyHeroArt ? media.urlFor(bodyHeroArt) : ((attachments.find((a) => a.role === 'cover' && !a.broken) || null) && media.urlFor(attachments.find((a) => a.role === 'cover' && !a.broken).mediaId)),
published: post.first_published_at ? new Date(post.first_published_at).toISOString() : null,
modified: post.published_revision ? rev.createdAt : null,
feeds: await reading.feedsOf(blog),
theme: { slug: blog.theme, vars: blogs.themeVars(blog) },
lang: blog.language,
-- jsonLd: preview ? [] : [reading.postJsonLd({ blog, post, rev, person, tags, citations, attachments }), seo.structuredData.breadcrumbs(crumbs.map((c) => ({ name: c.name, url: c.url ? publication.abs(c.url) : publication.postUrl(blog, post) })))],
++ jsonLd: preview ? [] : [reading.postJsonLd({ blog, post, rev, person, tags, citations, attachments, sections: bodySections, heroArt: bodyHeroArt }), seo.structuredData.breadcrumbs(crumbs.map((c) => ({ name: c.name, url: c.url ? publication.abs(c.url) : publication.postUrl(blog, post) })))],
body: pages.postPage({
blog, blogUrl: publication.blogPath(blog), post, rev,
author: { name: person.name, url: reading.urls.author(person, blog) },
@@@ -378,6 +493,19 @@ function createPublicRoutes(ctx) {
urlFor: media.urlFor, shareUrl: publication.postUrl(blog, post), breadcrumbs: crumbs, jsonUrl: `${path}.json`,
postPath: path, csrf, signedIn: req.viewer.kind === 'user' && Boolean(req.viewer.subject),
loginUrl: `/auth/login?next=${encodeURIComponent(path)}`,
++ bodyOptions: {
++ urlFor: media.urlFor, rel: blog.kind === 'official' ? 'noopener' : 'nofollow ugc noopener',
++ reactions: await community.sectionReactions(post, req.ov),
++ reactionAction: path + '/react',
++ csrf, signedIn: req.viewer.kind === 'user' && Boolean(req.viewer.subject),
++ loginUrl: `/auth/login?next=${encodeURIComponent(path)}`,
++ path,
++ person: (subject) => {
++ const p = byPeople.get(subject);
++ return p && p.known ? { name: p.name, url: reading.urls.author(p, blog) } : null;
++ },
++ patchNotes: (from, to) => windows.get(`${from || ''}..${to || ''}`) || [],
++ },
}),
styles: ['kit.css'],
}, { cacheable });
@@@ -466,6 +594,26 @@ function createPublicRoutes(ctx) {
return await renderPost(req, res, { blog, post, rev });
}));
++ // A reader's reaction to one section of a post (👍 or 👎). The form posts without script: it
++ // lands here, the counts go to Community, and the reader is back on the page.
++ router.post('/@:handle/:slug/react', express.urlencoded({ extended: false, limit: '4kb' }), wrap(async (req, res) => {
++ const blog = await activeBlog(req);
++ if (!blog) return notFound(req, res);
++ const post = await posts.bySlug(blog, req.params.slug);
++ if (!post || post.state !== 'published' || post.visibility !== 'public') return notFound(req, res);
++ if (req.viewer.kind !== 'user' || !req.viewer.subject) return res.redirect(303, `/auth/login?next=${encodeURIComponent(publication.postPath(blog, post))}`);
++ if (!checkCsrf(config, req.viewer, req.body && req.body._csrf)) return messagePage(req, res, 403, 'Form expired', 'Reload the page and try again.');
++ const section = String((req.body && req.body.section) || '').trim();
++ const value = String((req.body && req.body.reaction) || '').trim().toLowerCase() === 'up' ? 1 : -1;
++ if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(section)) return messagePage(req, res, 400, 'Unknown section', 'That is not a section of this post.');
++ try {
++ await community.reactToSection(post, null, req.viewer.subject, section, value, req.ov);
++ } catch (err) {
++ return messagePage(req, res, err.status === 429 ? 429 : 502, 'Reaction not recorded', `OpenVibe.Community did not accept the reaction: ${err.message}`);
++ }
++ return res.redirect(303, `${publication.postPath(blog, post)}#${encodeURIComponent(section)}`);
++ }));
++
// Per-actor limit (http/actor-limits.js) before the form is read: the comment goes to Community in
// the person's name.
router.post('/@:handle/:slug/comments', limits.budget('blog.comment.create'), express.urlencoded({ extended: false, limit: '32kb' }), wrap(async (req, res) => {
ddiff --git a/server/render/blocks.js b/server/render/blocks.js
nnew file mode 100644
iindex 0000000..0181bb5
---- /dev/null
++++ b/server/render/blocks.js
@@@ -0,0 +1,203 @@
++'use strict';
++
++/**
++ * Blog's rendering of a body that is Markdown plus blocks (openvibe-publishing/blocks).
++ *
++ * The Markdown stays the source of truth: a revision keeps it, revisions diff it, and the editor
++ * writes it. This module parses it into a block tree, resolves the Media ids it names and renders
++ * the tree server-side — every block has a plain-HTML form, so a reader without JavaScript sees
++ * the same thing a reader with it does.
++ *
++ * A post's images are its attachments: the renderer never guesses a URL and a Media object that is
++ * gone renders as an explicit placeholder.
++ */
++const blocks = require('openvibe-publishing/blocks');
++const ssr = require('openvibe-publishing/ssr');
++const { escapeHtml: esc } = ssr;
++
++// A post may fold a block away: <details><summary>…</summary>…</details>. The package's renderer
++// escapes raw HTML on purpose, so Blog renders exactly this one shape itself — the summary as text,
++// the inside as Markdown — and leaves every other tag to be escaped. Patch notes keep the full commit
++// list there.
++const DETAILS = /<details>\s*<summary>([\s\S]*?)<\/summary>([\s\S]*?)<\/details>/gi;
++// A media line in the prose: [[media:med_…]] on its own line, the way a post names an attachment.
++const MEDIA_LINE = /^[ \t]*\[\[media:(med_[0-9A-HJKMNP-TV-Z]{26})\]\][ \t]*$/m;
++
++/** The prose of a body, with the folds Blog renders itself. */
++function renderProse(text, opts) {
++ const source = String(text || '');
++ let out = '';
++ let last = 0;
++ DETAILS.lastIndex = 0;
++ let m;
++ while ((m = DETAILS.exec(source))) {
++ out += ssr.renderMarkdown(source.slice(last, m.index), opts);
++ out += '<details class="ov-details"><summary>' + esc(m[1].replace(/<[^>]*>/g, '').trim()) + '</summary>' + ssr.renderMarkdown(m[2], opts) + '</details>';
++ last = m.index + m[0].length;
++ }
++ out += ssr.renderMarkdown(source.slice(last), opts);
++ return out;
++}
++
++/** The sections of a tree: the blocks that carry an id (a section), with their heading. */
++function sectionsOf(tree) {
++ const out = [];
++ const walk = (node) => {
++ if (node.type === 'section' && node.attrs && typeof node.attrs.id === 'string') {
++ const heading = firstHeading(node);
++ out.push({ id: node.attrs.id, name: heading || node.attrs.id, by: node.attrs.by || null });
++ }
++ for (const child of node.children || []) walk(child);
++ };
++ walk(tree);
++ return out;
++}
++
++/** The heading of a block: the first "# ", "## " or "### " line of its prose. */
++function firstHeading(node) {
++ for (const child of node.children || []) {
++ if (child.type !== 'text') continue;
++ const m = String(child.value || '').match(/^#{1,3}\s+(.+)$/m);
++ if (m) return m[1].trim();
++ }
++ return null;
++}
++
++/** The first line of a block's prose that is not a heading: the hero's teaser. */
++function firstProse(node) {
++ for (const child of node.children || []) {
++ if (child.type !== 'text') continue;
++ const line = String(child.value || '').split('\n').map((s) => s.trim()).find((s) => s && !/^#{1,3}\s/.test(s));
++ if (line) return line;
++ }
++ return null;
++}
++
++/** The patch-notes windows a body folds: every { from, to } a patchnotes block names. */
++function patchNoteWindows(tree) {
++ const out = [];
++ blocks.walk(tree, (node) => {
++ if (node.type === 'patchnotes') out.push({ from: node.attrs.from || null, to: node.attrs.to || null });
++ });
++ return out;
++}
++
++/** The hero of a tree: the first hero block, with its art. */
++function heroOf(tree) {
++ for (const child of tree.children || []) {
++ if (child.type === 'hero') return child;
++ }
++ return null;
++}
++
++/**
++ * Render a body.
++ *
++ * renderBody(content, attachments, {
++ * urlFor, (mediaId) → the delivery URL
++ * media, (mediaId) → { width, height, alt, credit } | null (what Media knows)
++ * rel, the link rel of editorial text
++ * reactions, { sectionId: { up, down } } the counts
++ * reactionAction, where the reaction form posts
++ * csrf, signedIn, loginUrl, path,
++ * person, (subject) → { name, url } (a section's byline)
++ * patchNotes, (from, to) → [ { site, commits } ] (the folded list)
++ * headingShift, how far the Markdown headings move
++ * })
++ */
++function renderBody(content, attachments = [], options = {}) {
++ const tree = blocks.parse(content);
++ const byId = new Map((attachments || []).map((a) => [a.mediaId, a]));
++ const used = new Set();
++ const urlFor = options.urlFor || (() => null);
++
++ const media = (mediaId) => {
++ const att = byId.get(mediaId);
++ if (!att) return null;
++ used.add(att.id);
++ if (att.broken || att.state === 'broken') return { broken: true };
++ return {
++ width: att.width || null,
++ height: att.height || null,
++ alt: att.alt || '',
++ caption: att.caption || null,
++ credit: att.credit || null,
++ };
++ };
++
++ // A media line in the prose is an attachment of the post: the same figure the Markdown path drew.
++ const renderText = (value, opts) => {
++ const parts = String(value).split(new RegExp(MEDIA_LINE.source, 'm'));
++ if (parts.length === 1) return renderProse(value, opts);
++ let out = '';
++ parts.forEach((part, i) => {
++ if (i % 2 === 0) { out += renderProse(part, opts); return; }
++ const att = byId.get(part);
++ if (att) { used.add(att.id); out += figureOf(att); return; }
++ out += '<figure class="ov-media ov-media-broken" data-media-id="' + esc(part) + '" data-state="not_attached">'
++ + '<div class="ov-media-missing" role="img" aria-label="Media not attached">This media is not attached to the post.</div></figure>';
++ });
++ return out;
++ };
++
++ const figureOf = (att) => {
++ if (att.broken || att.state === 'broken') {
++ return '<figure class="ov-media ov-media-broken" data-media-id="' + esc(att.mediaId) + '" data-state="broken">'
++ + '<div class="ov-media-missing" role="img" aria-label="This media is no longer available.">This media is no longer available.</div></figure>';
++ }
++ const url = urlFor(att.mediaId, att);
++ if (!url) {
++ return '<figure class="ov-media ov-media-broken" data-media-id="' + esc(att.mediaId) + '" data-state="unresolved">'
++ + '<div class="ov-media-missing" role="img" aria-label="This media is no longer available.">This media is no longer available.</div></figure>';
++ }
++ const caption = att.caption ? '<figcaption>' + esc(att.caption) + '</figcaption>' : '';
++ return '<figure class="ov-media" data-media-id="' + esc(att.mediaId) + '" data-state="' + esc(att.state || 'available') + '">'
++ + '<img src="' + esc(url) + '" alt="' + esc(att.alt || '') + '" loading="lazy" decoding="async">'
++ + caption + '</figure>';
++ };
++
++ const common = {
++ rel: options.rel,
++ renderText,
++ urlFor,
++ media,
++ person: options.person,
++ patchNotes: options.patchNotes,
++ reactions: options.reactions,
++ reactionAction: options.reactionAction,
++ csrf: options.csrf,
++ signedIn: options.signedIn,
++ loginUrl: options.loginUrl,
++ path: options.path,
++ };
++ // A post page owns the h1: the hero's "# Name" is that h1, so the page renders the hero itself
++ // (headingShift 0) and the body without it. Elsewhere (feeds, JSON, previews) the hero stays in place.
++ const hero = heroOf(tree);
++ const separate = Boolean(options.separateHero) && hero;
++ const body = separate ? { ...tree, children: (tree.children || []).filter((c) => c !== hero) } : tree;
++ const html = blocks.render(body, { ...common, headingShift: options.headingShift });
++ // The Markdown renderer never writes an h1 (a page owns it), so the hero's first heading is raised
++ // to the page's title here: "# Printed & Polished" IS this page's h1.
++ const heroHtml = separate
++ ? blocks.render({ type: 'root', children: [hero] }, { ...common, headingShift: 0 }).replace(/<h2>([\s\S]*?)<\/h2>/, '<h1>$1</h1>')
++ : null;
++
++ return {
++ tree, html, heroHtml,
++ sections: sectionsOf(tree), hero,
++ heroHeading: separate ? firstHeading(hero) : null,
++ heroProse: separate ? firstProse(hero) : null,
++ };
++}
++
++/** The plain text of a body: what a feed, a search document and an AI reader get. */
++function bodyText(content) {
++ return blocks.toText(blocks.parse(content));
++}
++
++/** The blocks of a body, as the tree the API hands out next to the Markdown. */
++function bodyBlocks(content) {
++ return blocks.parse(content);
++}
++
++module.exports = { renderBody, bodyText, bodyBlocks, sectionsOf, firstHeading, heroOf, patchNoteWindows, renderProse };
ddiff --git a/server/render/editor.js b/server/render/editor.js
iindex 3a39f51..4889597 100644
---- a/server/render/editor.js
++++ b/server/render/editor.js
@@@ -7,6 +7,7 @@
const ssr = require('openvibe-publishing/ssr');
const { time } = require('./pages');
const kit = require('openvibe-shared/kit');
++const { asset } = require('./layout');
const { html: h, raw } = ssr;
@@@ -64,13 +65,24 @@ function aiDraftForm({ blog, csrf }) {
function postForm({ blog, post, head, terms, series, csrf, message, action }) {
const f = head ? head.fields : {};
const vis = post ? post.visibility : 'public';
-- return String(h`${flash(message)}
++ return String(h`<link rel="stylesheet" href="${asset('css/blocks.css')}">
++${flash(message)}
<form method="post" action="${action}" class="post-form">${csrfField(csrf)}
${head ? h`<input type="hidden" name="expectedRevision" value="${head.number}">` : ''}
<label for="title">Title</label><input id="title" name="title" maxlength="200" required value="${f.title || ''}">
<label for="summary">Summary <span class="meta">(optional, shown in lists and feeds)</span></label><input id="summary" name="summary" maxlength="500" value="${f.summary || ''}">
--<label for="body">Text <span class="meta">(Markdown; a line <code>[[media:med_…]]</code> places an attached Media object)</span></label>
++<label for="body">Text <span class="meta">(Markdown and blocks; a line <code>[[media:med_…]]</code> places an attached Media object)</span></label>
++<div class="ov-editor" data-ov-editor data-post="${post ? post.id : ''}" data-csrf="${csrf}" data-preview="/write/preview" data-image="/write/make-image">
++<div class="ov-editor-bar">
++<button type="button" class="ov-editor-btn" data-ov-insert aria-expanded="false">Insert a block <kbd>/</kbd></button>
++<button type="button" class="ov-editor-btn" data-ov-outline aria-pressed="false">Outline</button>
++<span class="ov-editor-hint">Type “/” at the start of a line for the block menu; drag the outline to reorder.</span>
++</div>
++<div class="ov-editor-panel" data-ov-panel hidden></div>
++<div class="ov-editor-outline" data-ov-outline-panel hidden></div>
<textarea id="body" name="body" rows="22">${head ? head.content : ''}</textarea>
++</div>
++<script src="/js/blog-editor.js" defer></script>
${post ? h`<label for="slug">URL slug</label><input id="slug" name="slug" value="${post.slug}" pattern="[a-z0-9][a-z0-9-]{0,79}"><p class="meta">Changing it keeps the old address working (301).</p>` : ''}
<label for="tags">Tags <span class="meta">(comma-separated)</span></label><input id="tags" name="tags" value="${(terms.tags || []).join(', ')}">
<label for="categories">Categories <span class="meta">(comma-separated; "Parent > Child" nests)</span></label><input id="categories" name="categories" value="${(terms.categories || []).join(', ')}">
ddiff --git a/server/render/home.js b/server/render/home.js
iindex 6216e8f..555ec0f 100644
---- a/server/render/home.js
++++ b/server/render/home.js
@@@ -42,6 +42,71 @@ function statsPanel(stats) {
return String(h`<div class="bh-panel"><h2 class="bh-panel-h">On the blog</h2><dl class="bh-stats">${tiles.map(([k, v]) => h`<div><dt>${k}</dt><dd>${num(v)}</dd></div>`)}</dl></div>`);
}
++// The index (the official blog's home and /devblog, plan §4): the newest post as a featured hero, the
++// tabs as server-rendered links, a filter by name, and the archive as art cards with real page links.
++const INDEX_TABS = [
++ { id: 'latest', label: 'Latest', slug: null },
++ { id: 'devblog', label: 'Devblog', slug: 'devblog' },
++ { id: 'daily', label: 'Daily', slug: 'daily' },
++ { id: 'community', label: 'Community', slug: 'community' },
++ { id: 'guides', label: 'Guides', slug: 'guides' },
++];
++const indexHref = (base, tab, q) => {
++ const p = new URLSearchParams();
++ if (tab && tab !== 'latest') p.set('tab', tab);
++ if (q) p.set('q', q);
++ const s = p.toString();
++ return s ? `${base}?${s}` : base;
++};
++function indexTabs({ base, tab = 'latest', q = '' }) {
++ return h`<nav class="bh-tabs" aria-label="What to read">${INDEX_TABS.map((t) => h`<a href="${indexHref(base, t.id, q)}"${t.id === tab ? raw(' aria-current="page"') : ''}>${t.label}</a>`)}</nav>`;
++}
++function nameFilter({ base, tab = 'latest', q = '' }) {
++ return h`<form class="bh-filter" method="get" action="${base}" role="search">
++${tab !== 'latest' ? h`<input type="hidden" name="tab" value="${tab}">` : ''}
++<label class="skip" for="bh-q">Filter posts by name</label>
++<input id="bh-q" name="q" type="search" value="${q}" placeholder="Filter posts by name" maxlength="80">
++<button type="submit">Filter</button>${q ? h`<a class="bh-clear" href="${indexHref(base, tab, '')}">Clear</a>` : ''}</form>`;
++}
++const categoryOf = (item) => (item.categories && item.categories[0]) || null;
++function featureCard(item) {
++ const { post, rev, url, author, art } = item;
++ const cat = categoryOf(item);
++ const summary = rev.fields.summary || ssr.markdownToText(rev.content, 240);
++ return h`<article class="bh-feature">
++${art ? h`<a class="bh-feature-art" href="${url}" tabindex="-1" aria-hidden="true"><img src="${art.url}" alt="" loading="eager" decoding="async"></a>` : ''}
++<div class="bh-feature-body">
++<p class="bh-kicker">${cat ? h`<a class="chip chip-cat" href="${cat.url}">${cat.name}</a>` : h`<span class="badge">Newest</span>`} ${time(post.published_at)}${author ? h` · ${author.name}` : ''}</p>
++<h2 class="bh-feature-title"><a href="${url}">${rev.fields.title}</a></h2>
++${summary ? h`<p class="bh-feature-sum">${summary}</p>` : ''}
++<p class="bh-feature-go"><a class="sc-btn sc-primary" href="${url}">Read more<span aria-hidden="true"> →</span></a></p>
++</div></article>`;
++}
++function artCard(item) {
++ const { post, rev, url, author, art } = item;
++ const cat = categoryOf(item);
++ const summary = rev.fields.summary || ssr.markdownToText(rev.content, 160);
++ return h`<li class="bh-card bh-artcard">
++${art ? h`<a class="bh-card-art" href="${url}" tabindex="-1" aria-hidden="true"><img src="${art.url}" alt="" loading="lazy" decoding="async"></a>` : ''}
++<p class="bh-kicker">${cat ? h`<a class="chip chip-cat" href="${cat.url}">${cat.name}</a>` : ''}${time(post.published_at)}${author ? h` · ${author.name}` : ''}</p>
++<h3><a href="${url}">${rev.fields.title}</a></h3>
++${summary ? h`<p class="bh-sum">${summary}</p>` : ''}
++</li>`;
++}
++/** The index's newest post, tabs, filter and cards. The pager keeps real page links; a script adds the next page. */
++function indexList({ base = '/', tab = 'latest', q = '', items, pager, empty = 'Nothing published here yet.' }) {
++ // The first page's newest post is the featured banner; every other page is cards alone, so a
++ // reader who loads the next page in place never misses that page's first post.
++ const first = !pager || pager.page === 1;
++ const feature = first ? items[0] : null;
++ const rest = first ? items.slice(1) : items;
++ return String(h`${indexTabs({ base, tab, q })}${nameFilter({ base, tab, q })}
++${feature ? featureCard(feature) : (first ? h`<p class="empty">${q ? `No post matches “${q}”.` : empty}</p>` : '')}
++${rest.length ? h`<ol class="bh-cards bh-artcards">${rest.map(artCard)}</ol>` : ''}
++${raw(ssr.paginationHtml(pager))}
++${pager && pager.next ? h`<p class="bh-more"><a class="bh-next" href="${pager.next.href}">Next page</a></p><script src="/js/blog-index.js" defer></script>` : ''}`);
++}
++
function postCard(item) {
const { post, rev, url, author, tags, badge } = item;
const summary = rev.fields.summary || ssr.markdownToText(rev.content, 220);
@@@ -55,7 +120,7 @@ ${tags && tags.length ? h`<p class="tags">${tags.slice(0, 4).map((t) => h`<a hre
* (Map desk → post), tags ([{ slug, name, posts }]), series ([{ title, url, posts }]), memberItems, signedIn
*/
function homeBody(o) {
-- const { blog, items, pager, feeds, stats, week = [], latestDesks = new Map(), tags = [], series = [], memberItems = [], signedIn = false } = o;
++ const { blog, items, pager, feeds, stats, week = [], latestDesks = new Map(), tags = [], series = [], memberItems = [], signedIn = false, index = {} } = o;
const start = { label: signedIn ? 'Start writing' : 'Sign in and start writing', href: '/write', primary: true };
const top = week.length ? week[0].changes : 0;
const desks = catalog.ALL.map((d) => ({ desk: d, post: latestDesks.get(d.key) })).filter((x) => x.post);
@@@ -70,7 +135,8 @@ function homeBody(o) {
<section class="sc-sec bh-sec" id="posts" aria-labelledby="bh-posts-h"><h2 id="bh-posts-h">Latest from ${blog.title.replace(/^The /, 'the ')}</h2>
${blog.description ? h`<p class="sc-lede">${blog.description}</p>` : ''}
${feeds.length ? h`<p class="feeds">Follow: ${feeds.map((f) => h`<a href="${f.href}" type="${f.mime}">${f.label}</a> `)}</p>` : ''}
--${items.length ? h`<ol class="bh-cards">${items.map(postCard)}</ol>${raw(ssr.paginationHtml(pager))}<p class="bh-more bh-all"><a href="/@${blog.handle}">Every post, newest first</a></p>` : h`<p class="empty">Nothing published here yet.</p>`}</section>
++${raw(indexList({ base: index.base || '/', tab: index.tab || 'latest', q: index.q || '', items, pager }))}
++<p class="bh-more bh-all"><a href="/@${blog.handle}">Every post, newest first</a></p></section>
${week.length ? raw(foldBlock({
id: 'week',
head: '<h2 id="bh-week-h">This week across OpenVibe</h2>',
@@@ -132,4 +198,4 @@ function homeJsonLd({ base, items }) {
return out;
}
--module.exports = { homeBody, homeJsonLd, siteName, FAQ };
++module.exports = { homeBody, homeJsonLd, indexList, indexTabs, nameFilter, INDEX_TABS, siteName, FAQ };
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex 97a9054..2d66570 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -9,6 +9,8 @@
*/
const ssr = require('openvibe-publishing/ssr');
const { figureHtml } = require('openvibe-publishing/media');
++const blockRender = require('./blocks');
++const { asset } = require('./layout');
const kit = require('openvibe-shared/kit');
const ovServe = require('openvibe-shared/serve');
@@@ -75,13 +77,21 @@ function sectionsOf(bodyHtml) {
}
/** The contents list: a fold under the title on phones, a rail beside the text on wide screens (CSS picks one). */
function contentsHtml(sections) {
-- const list = h`<ol>${sections.map((x) => h`<li><a href="#${x.id}">${x.text}</a></li>`)}</ol>`;
++ const list = h`<ol>${sections.map((x) => h`<li><a href="#${x.id}">${x.text || x.name}</a></li>`)}</ol>`;
return String(h`<nav class="toc-rail" aria-labelledby="toc-rail-h"><h2 id="toc-rail-h">On this page</h2>${list}</nav>`);
}
function contentsFold(sections) {
-- const list = h`<ol>${sections.map((x) => h`<li><a href="#${x.id}">${x.text}</a></li>`)}</ol>`;
++ const list = h`<ol>${sections.map((x) => h`<li><a href="#${x.id}">${x.text || x.name}</a></li>`)}</ol>`;
return String(h`<details class="toc-fold"><summary>On this page <span>${sections.length} sections</span></summary>${list}</details>`);
}
++/**
++ * The sections as chips: on a phone there is no room for a rail, so the reader gets the sections as
++ * a row of links they can tap and jump by. On a computer the rail beside the text does the same job.
++ */
++function contentsChips(sections) {
++ const chips = sections.map((x) => h`<a class="toc-chip" href="#${x.id}">${x.text || x.name}</a>`);
++ return String(h`<nav class="toc-chips" aria-label="Sections of this post">${chips}</nav>`);
++}
/** The series: one line under the title; the previous and next parts, and the parts around this one, after the text. */
function seriesLine(series) {
const where = series.position && series.total ? `Part ${series.position} of ${series.total} in the series` : (series.position ? `Part ${series.position} of the series` : 'Part of the series');
@@@ -101,20 +111,8 @@ const time = (v) => raw(ssr.timeTag(v == null ? null : new Date(v).toISOString()
* Inline attachments that the body does not place follow the text; covers and galleries are
* rendered by the caller. A referenced id that is not attached renders an explicit notice.
*/
--function renderBody(content, attachments, { urlFor, rel }) {
-- const byId = new Map(attachments.map((a) => [a.mediaId, a]));
-- const used = new Set();
-- const parts = String(content || '').split(new RegExp(MEDIA_LINE.source, 'm'));
-- let out = '';
-- parts.forEach((part, i) => {
-- if (i % 2 === 0) { out += renderMarkdownWithDetails(part, { rel }); return; }
-- const att = byId.get(part);
-- if (att) { used.add(att.id); out += figureHtml(att, { urlFor }); } else {
-- out += `<figure class="ov-media ov-media-broken" data-media-id="${esc(part)}" data-state="not_attached"><div class="ov-media-missing" role="img" aria-label="Media not attached">This media is not attached to the post.</div></figure>`;
-- }
-- });
-- for (const a of attachments) if (a.role === 'inline' && !used.has(a.id)) out += figureHtml(a, { urlFor });
-- return out;
++function renderBody(content, attachments, options = {}) {
++ return blockRender.renderBody(content, attachments, options);
}
function postListItem(item) {
@@@ -192,32 +190,60 @@ ${m.replies && m.replies.length ? h`<ol class="replies">${m.replies.map(one)}</o
${list}${more}${form}</section>`);
}
++/** The sections of a body: the blocks that carry an id first, then the Markdown headings that are sections too. */
++function mergeSections(blockSections, markdownSections) {
++ const seen = new Set();
++ const out = [];
++ for (const x of [...(blockSections || []), ...(markdownSections || [])]) {
++ if (!x || !x.id || seen.has(x.id)) continue;
++ seen.add(x.id);
++ out.push(x);
++ }
++ return out;
++}
++
/** One post. */
function postPage(o) {
const { blog, blogUrl, post, rev, author, tags, categories, series, attachments, citations, disclosure, decisionNote, comments, canEdit, urlFor, shareUrl } = o;
const rel = blog.kind === 'official' ? 'noopener' : 'nofollow ugc noopener';
const cover = attachments.filter((a) => a.role === 'cover');
const gallery = attachments.filter((a) => a.role === 'gallery');
-- const shareImage = cover.length && cover[0].mediaId ? urlFor(cover[0].mediaId) : null;
++ const rendered = renderBody(rev.content, attachments, { ...(o.bodyOptions || { urlFor, rel }), separateHero: true });
++ const heroArt = rendered.hero && rendered.hero.attrs && typeof rendered.hero.attrs.art === 'string' ? rendered.hero.attrs.art : null;
++ const shareImage = heroArt ? urlFor(heroArt) : (cover.length && cover[0].mediaId ? urlFor(cover[0].mediaId) : null);
const share = shareUrl ? h`<p class="post-share">${raw(kit.share({ title: rev.fields.title, url: shareUrl, text: rev.fields.summary || '', image: shareImage || null, kind: 'post', id: post.id }))}</p>` : '';
const badge = { members: 'Members only', private: 'Private', unlisted: 'Unlisted' }[post.visibility];
const updated = rev.number > 1 && post.first_published_at && Date.parse(rev.createdAt) > post.first_published_at + 60000;
-- const body = sectionsOf(renderBody(rev.content, attachments, { urlFor, rel }));
++ // The contents: the sections of the body. A block's section keeps its own id and heading; a Markdown
++ // heading gets one, as it always did. The ids on the Markdown headings are added to the rendered
++ // HTML, so a link into a section lands on it.
++ const marked = sectionsOf(rendered.html);
++ const body = { html: marked.html, sections: mergeSections(rendered.sections, marked.sections) };
const toc = body.sections.length >= 3 ? body.sections : null;
const minutes = readMinutes(rev.content);
-- return String(h`${raw(ssr.breadcrumbsHtml(o.breadcrumbs))}
++ const teaser = rev.fields.summary || '';
++ // The hero is the page's banner: its "# Name" is the h1, its prose is the teaser, and the author's
++ // summary only shows when the hero has no prose of its own.
++ const heroHtml = rendered.heroHtml || null;
++ const heroTitle = rendered.heroHeading || null;
++ return String(h`<link rel="stylesheet" href="${asset('css/blocks.css')}">
++${raw(ssr.breadcrumbsHtml(o.breadcrumbs))}
<div class="post-wrap${toc ? ' has-toc' : ''}">
${raw(toc ? contentsHtml(toc) : '')}
++${raw(toc ? contentsChips(toc) : '')}
<div class="post-main">
<article class="post" data-post-id="${post.id}" data-revision="${rev.number}">
--<header>
--<h1>${rev.fields.title}</h1>
--<p class="byline">${author ? h`By <a href="${author.url}" rel="author">${author.name}</a> · ` : ''}${post.published_at ? h`${time(post.first_published_at || post.published_at)} · ` : ''}${updated ? h`updated ${time(Date.parse(rev.createdAt))} · ` : ''}<span class="read-time" title="At ${WORDS_PER_MINUTE} words a minute: a guide, not a promise">≈ ${minutes} min read</span>${badge ? h` <span class="badge">${badge}</span>` : ''}${post.state !== 'published' ? h` <span class="badge">${post.state}</span>` : ''}</p>
++<header class="post-header">
++${raw(heroHtml || '')}
++${heroHtml && heroTitle ? '' : h`<h1 class="post-title">${rev.fields.title}</h1>`}
++${teaser && !(heroHtml && rendered.heroProse) ? h`<p class="post-teaser">${teaser}</p>` : ''}
++<p class="byline">${author ? h`By <a href="${author.url}" rel="author">${author.name}</a> · ` : ''}${post.published_at ? h`<time datetime="${dateLabel(post.first_published_at || post.published_at)}">${time(post.first_published_at || post.published_at)}</time> · ` : ''}${updated ? h`updated ${time(Date.parse(rev.createdAt))} · ` : ''}<span class="read-time" title="At ${WORDS_PER_MINUTE} words a minute: a guide, not a promise">≈ ${minutes} min read</span>${badge ? h` <span class="badge">${badge}</span>` : ''}${post.state !== 'published' ? h` <span class="badge">${post.state}</span>` : ''}</p>
++${categories.length || tags.length ? h`<p class="chips">${categories.map((c) => h`<a class="chip chip-cat" href="${c.url}">${c.name}</a>`)}${tags.map((t) => h`<a class="chip chip-tag" href="${t.url}" rel="tag">#${t.name}</a>`)}</p>` : ''}
${series ? raw(seriesLine(series)) : ''}
${share ? raw(String(share)) : ''}
${disclosure ? h`<p class="disclosure" role="note"><strong>${disclosure.short}.</strong> ${disclosure.long}</p>` : ''}
${raw(toc ? contentsFold(toc) : '')}
--${raw(cover.map((a) => figureHtml(a, { urlFor })).join(''))}
++${raw(heroHtml ? '' : (heroArt ? `<figure class="post-hero-art" data-media-id="${esc(heroArt)}"><img src="${esc(urlFor(heroArt))}" alt="" loading="eager" decoding="async"></figure>` : cover.map((a) => figureHtml(a, { urlFor })).join('')))}
</header>
<div class="post-body">${raw(body.html)}</div>
${raw(gallery.length ? `<div class="gallery">${gallery.map((a) => figureHtml(a, { urlFor })).join('')}</div>` : '')}
ddiff --git a/test/api-ai.test.js b/test/api-ai.test.js
iindex 1af6ebd..7b8d75d 100644
---- a/test/api-ai.test.js
++++ b/test/api-ai.test.js
@@@ -51,6 +51,12 @@ const WORKFLOW = { id: 'blog.draft_post', version: 1, runId: 'run_01J8Z3K4M5N6P7
const created = (await t.events('blog.post.created'))[0];
assert.deepStrictEqual(created.actor, { type: 'service', id: 'ai' });
assert.strictEqual(created.payload.authorship, 'ai');
++ // The Dock's row names its subject and the run that wrote it.
++ assert.strictEqual(created.payload.skill, 'blog.draft_post');
++ assert.strictEqual(created.payload.run_id, WORKFLOW.runId);
++ assert.strictEqual(created.payload.blog.handle, 'fay');
++ assert.strictEqual(created.payload.post.title, 'An AI draft');
++ assert.strictEqual(created.payload.post.url, '/@fay/an-ai-draft');
const noWorkflow = await t.get('/api/v1/blogs/fay/posts', { as: ai, headers: { 'X-OV-Origin': 'ai', 'X-OV-Subject': fay.subject }, json: { title: 'No run', body: LONG } });
assert.strictEqual(noWorkflow.status, 400);
assert.strictEqual(noWorkflow.json().code, 'authorship.workflow_required');
ddiff --git a/test/blocks.test.js b/test/blocks.test.js
nnew file mode 100644
iindex 0000000..bcb1c2a
---- /dev/null
++++ b/test/blocks.test.js
@@@ -0,0 +1,163 @@
++'use strict';
++/**
++ * Rich blocks in a post: the body is Markdown plus blocks, the page renders them server-side (every
++ * block a plain-HTML form), the API hands the blocks out next to the Markdown and takes them in, and
++ * a reader's reaction to a section is a form that posts without script.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++const MED = 'med_01J8ZQ4X3M000000000000000A';
++const body = (by) => [
++ ':::hero{art="' + MED + '" kicker="Devblog" date="2026-11-09"}',
++ '# Printed & Polished',
++ 'This month: blueprints rework, the Uzi, our biggest optimisation pass yet.',
++ ':::',
++ '',
++ ':::section{id="blueprint-rework" by="' + by + '" react}',
++ '## Blueprint rework',
++ 'Workbenches no longer need fragments.',
++ ':::figure{src="' + MED + '" alt="The channel header"}',
++ ':::',
++ ':::',
++ '',
++ ':::patchnotes{from="2026-11-02" to="2026-11-09"}:::',
++].join('\n');
++
++(async () => {
++ const t = await boot();
++ const alice = t.network.addUser('alice', { display_name: 'Alice Writer' });
++ const BODY = body(alice.subject);
++ let post;
++
++ await check('a body with blocks is stored as the Markdown that parses back into them', async () => {
++ await t.get('/api/v1/blogs', { as: alice, json: {} });
++ const r = await t.get('/api/v1/blogs/alice/posts', { as: alice, json: { title: 'Blocks', body: BODY } });
++ assert.strictEqual(r.status, 201, r.text);
++ post = r.json().post;
++ assert.match(post.body, /:::section\{id="blueprint-rework"/);
++ // The hero art and the section picture are attached Media objects: the renderer shows what Media has.
++ t.media.objects.set(MED, 'ready');
++ const at = await t.get(`/api/v1/posts/${post.id}/attachments`, { as: alice, json: { media_id: MED, role: 'cover', alt: 'The channel header' } });
++ assert.strictEqual(at.status, 201, at.text);
++ });
++
++ await check('the API hands the blocks out next to the Markdown, and ?format=blocks asks for them alone', async () => {
++ const r = await t.get(`/api/v1/posts/${post.id}`, { as: alice });
++ assert.strictEqual(r.status, 200, r.text);
++ const dto = r.json().post;
++ assert.match(dto.body, /:::hero/);
++ assert.strictEqual(dto.blocks.type, 'root');
++ assert.deepStrictEqual(dto.blocks.children.map((c) => c.type), ['hero', 'section', 'patchnotes']);
++ const section = dto.blocks.children[1];
++ assert.strictEqual(section.attrs.id, 'blueprint-rework');
++ assert.strictEqual(section.attrs.react, true);
++
++ const only = await t.get(`/api/v1/posts/${post.id}?format=blocks`, { as: alice });
++ assert.strictEqual(only.status, 200);
++ assert.strictEqual(only.json().blocks.type, 'root');
++ });
++
++ await check('a reader gets the published blocks alone, without the Markdown', async () => {
++ await t.get(`/api/v1/posts/${post.id}/publish`, { as: alice, json: {} });
++ const r = await t.get(`/api/v1/posts/${post.id}?format=blocks`);
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.json().blocks.type, 'root');
++ assert.deepStrictEqual(r.json().blocks.children.map((c) => c.type), ['hero', 'section', 'patchnotes']);
++ assert.strictEqual(r.json().post, undefined, 'the blocks are the whole answer');
++ });
++
++ await check('a malformed block tree is refused, never stored', async () => {
++ const r = await t.get('/api/v1/blogs/alice/posts', { as: alice, json: { title: 'Broken', blocks: { type: 'root', children: [{ type: 'figure', attrs: { src: 'not-a-media-id' } }] } } });
++ assert.strictEqual(r.status, 422, r.text);
++ assert.match(r.json().detail, /figure src must be a Media object id/);
++ });
++
++ await check('a caller may post blocks instead of Markdown; the post keeps the Markdown', async () => {
++ const tree = {
++ type: 'root',
++ children: [{
++ type: 'section', attrs: { id: 'from-blocks' },
++ children: [{ type: 'text', value: '## From blocks' }],
++ }],
++ };
++ const r = await t.get('/api/v1/blogs/alice/posts', { as: alice, json: { title: 'Posted as blocks', blocks: tree } });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.match(r.json().post.body, /:::section\{id="from-blocks"/);
++ assert.match(r.json().post.body, /## From blocks/);
++ });
++
++ await check('the published page renders the blocks: the hero art, the section, the byline, the rail', async () => {
++ await t.get(`/api/v1/posts/${post.id}/publish`, { as: alice, json: {} });
++ const page = await t.get('/@alice/blocks');
++ assert.strictEqual(page.status, 200, page.text);
++ assert.match(page.text, /class="ov-media ov-hero-art"/);
++ assert.match(page.text, /id="blueprint-rework"/);
++ assert.match(page.text, /Blueprint rework/);
++ assert.match(page.text, /ov-reactions/);
++ assert.doesNotMatch(page.text, /&lt;script&gt;/, 'the blocks are not escaped into text');
++ // The hero's name is the page's h1: the post's own title does not stand beside it.
++ assert.strictEqual((page.text.match(/<h1\b/g) || []).length, 1, 'one h1');
++ assert.match(page.text, /<h1>Printed &amp; Polished<\/h1>/);
++ assert.doesNotMatch(page.text, /class="post-title"/);
++ // The section's byline names the person it names, with their profile link.
++ assert.match(page.text, /class="ov-section-by">by <a href="[^"]*authors\/[^"]*" rel="author">Alice Writer<\/a>/);
++ // The picture appears once as the hero art and once in the section, never as a second header art.
++ assert.strictEqual((page.text.match(new RegExp(`data-media-id="${MED}"`, 'g')) || []).length, 2, 'the hero art and the section figure, once each');
++ assert.ok(page.text.indexOf('id="blueprint-rework"') > page.text.indexOf('ov-hero-art'), 'the hero comes before the sections');
++ });
++
++ await check('the folded patch notes list the window’s commits, grouped by site and linked', async () => {
++ const db = t.ctx.store.db;
++ const ins = db.prepare(`INSERT INTO changelog_entries (service, repo, sha, subject, deployed_at, release) VALUES (?, ?, ?, ?, ?, 'r1')`);
++ await ins.run('live', 'OpenVibers/Live', 'abc1234def', 'Fix the channel header', '2026-11-05T12:00:00.000Z');
++ await ins.run('chat', 'OpenVibers/Chat', 'def5678abc', 'Faster messages', '2026-11-06T12:00:00.000Z');
++ const page = await t.get('/@alice/blocks');
++ assert.match(page.text, /<details class="ov-patchnotes"><summary>2 changes across 2 sites<\/summary>/);
++ assert.match(page.text, /<section class="ov-patchnotes-site"><h3[^>]*>live<\/h3><ul><li><a href="https:\/\/github.com\/OpenVibers\/Live\/commit\/abc1234def" rel="noopener"><code>abc1234<\/code><\/a> Fix the channel header<\/li>/);
++ assert.match(page.text, /Faster messages/);
++ });
++
++ await check('the reaction of a reader is a form that posts without script', async () => {
++ const page = await t.get('/@alice/blocks', { as: alice });
++ assert.match(page.text, /<form class="ov-reactions" method="post" action="\/@alice\/blocks\/react"/);
++ assert.match(page.text, /name="section" value="blueprint-rework"/);
++ assert.match(page.text, /name="reaction" value="up"/);
++ assert.match(page.text, /name="reaction" value="down"/);
++ });
++
++ await check('a signed-out reader is asked to sign in instead', async () => {
++ const page = await t.get('/@alice/blocks');
++ assert.match(page.text, /Sign in to react/);
++ assert.doesNotMatch(page.text, /<form class="ov-reactions"/);
++ });
++
++ await check('the JSON-LD names the sections as the parts of the article, and the image is the hero art', async () => {
++ const page = await t.get('/@alice/blocks');
++ const blocks = [...page.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
++ const posting = blocks.find((x) => x['@type'] === 'BlogPosting');
++ assert.ok(posting, 'no BlogPosting on the page');
++ assert.ok(posting.hasPart, 'the article has no parts');
++ assert.strictEqual(posting.hasPart[0].name, 'Blueprint rework');
++ assert.match(posting.hasPart[0].url, /#blueprint-rework$/);
++ assert.match(posting.image, /\/o\//);
++ });
++
++ await check('a reaction that posts lands in Community and the counts come back', async () => {
++ const bob = t.network.addUser('bob', { display_name: 'Bob Reader' });
++ const csrf = t.csrf(bob);
++ const r = await t.get('/@alice/blocks/react', { as: bob, form: { _csrf: csrf, section: 'blueprint-rework', reaction: 'up' } });
++ assert.strictEqual(r.status, 303, r.text);
++ assert.match(r.headers.get('location'), /#blueprint-rework$/);
++ const page = await t.get('/@alice/blocks', { as: bob });
++ assert.match(page.text, /👍 <span class="ov-react-count">1<\/span>/);
++ });
++
++ await check('a reaction without a valid CSRF token is refused', async () => {
++ const bob = t.network.addUser('bob2', { display_name: 'Bob Two' });
++ const r = await t.get('/@alice/blocks/react', { as: bob, form: { section: 'blueprint-rework', reaction: 'up' } });
++ assert.strictEqual(r.status, 403, r.text);
++ });
++
++ done();
++})();
ddiff --git a/test/devblog-index.test.js b/test/devblog-index.test.js
nnew file mode 100644
iindex 0000000..8eaa245
---- /dev/null
++++ b/test/devblog-index.test.js
@@@ -0,0 +1,116 @@
++'use strict';
++/**
++ * The index (the official blog's home and /devblog, stage B1): the newest post as a featured hero with
++ * Read more, the tabs as server-rendered links, a filter by name, and the archive as art cards with real
++ * page links. Every part of it is in the HTML, so a crawler and a reader without script get the same.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++const LONG = 'The quick brown fox jumps over the lazy dog while the network ships. '.repeat(12);
++const MED = 'med_01J8ZQ4X3M000000000000000A';
++
++(async () => {
++ const t = await boot();
++ const alice = t.network.addUser('alice', { display_name: 'Alice' });
++ t.media.objects.set(MED, 'ready');
++ const publish = async (input) => {
++ const r = await t.get('/api/v1/blogs/openvibe/posts', { as: t.official, json: input });
++ assert.strictEqual(r.status, 201, r.text);
++ const p = await t.get(`/api/v1/posts/${r.json().post.id}/publish`, { as: t.official, json: {} });
++ assert.strictEqual(p.status, 200, p.text);
++ t.clock.advance(60_000);
++ return r.json().post;
++ };
++
++ await check('setup: a guide, a community post, a daily update and the newest, a devblog with art', async () => {
++ await publish({ title: 'Meet the builders', categories: 'Community', body: `Who shipped what.\n\n${LONG}` });
++ await publish({ title: 'A quieter morning', categories: 'Daily', body: `The day's small changes.\n\n${LONG}` });
++ await publish({ title: 'Printed & Polished', categories: 'Devblog', summary: 'This month: blueprints, the Uzi, and our biggest optimisation pass.', body: `:::hero{art="${MED}" kicker="Devblog"}\n# Printed & Polished\nThe update.\n:::\n\n${LONG}` });
++ assert.ok(alice);
++ });
++
++ await check('the home: the newest post is the featured hero with its art and Read more', async () => {
++ const home = await t.get('/');
++ assert.strictEqual(home.status, 200, home.text);
++ const at = home.text.indexOf('class="bh-feature"');
++ assert.ok(at > 0, 'no featured hero');
++ const feature = home.text.slice(at, home.text.indexOf('</article>', at));
++ assert.match(feature, /class="bh-feature-art" href="\/@openvibe\/printed-polished"/);
++ assert.match(feature, /<h2 class="bh-feature-title"><a href="\/@openvibe\/printed-polished">Printed &amp; Polished<\/a><\/h2>/);
++ assert.match(feature, /Read more/);
++ const cards = home.text.slice(home.text.indexOf('class="bh-cards bh-artcards"'));
++ assert.ok(cards.indexOf('A quieter morning') < cards.indexOf('Meet the builders'), 'the cards are newest first');
++ });
++
++ await check('a tab whose category has no post is empty, not every post', async () => {
++ const empty = await t.get('/?tab=guides');
++ assert.strictEqual(empty.status, 200, empty.text);
++ assert.doesNotMatch(empty.text, /Meet the builders/);
++ assert.match(empty.text, /Nothing published here yet/);
++ // Now the category exists, and the same tab lists only its post.
++ await publish({ title: 'How to self-host', categories: 'Guides', body: `A guide.\n\n${LONG}` });
++ const filled = await t.get('/?tab=guides');
++ assert.match(filled.text, /How to self-host/);
++ assert.doesNotMatch(filled.text, /Meet the builders/);
++ });
++
++ await check('the tabs are server-rendered links, and each one lists only its category', async () => {
++ const home = await t.get('/');
++ for (const [label, href] of [['Latest', '/'], ['Devblog', '/?tab=devblog'], ['Daily', '/?tab=daily'], ['Community', '/?tab=community'], ['Guides', '/?tab=guides']]) {
++ assert.ok(home.text.includes(`<a href="${href}"`) && home.text.includes(`>${label}</a>`), `${label} tab`);
++ }
++ assert.match(home.text, /<a href="\/" aria-current="page">Latest<\/a>/);
++ const devblog = await t.get('/?tab=devblog');
++ assert.match(devblog.text, /<a href="\/\?tab=devblog" aria-current="page">Devblog<\/a>/);
++ assert.match(devblog.text, /Printed &amp; Polished/);
++ assert.doesNotMatch(devblog.text, /Meet the builders/);
++ });
++
++ await check('/devblog is the same index on its own address, with no marketing sections', async () => {
++ const page = await t.get('/devblog');
++ assert.strictEqual(page.status, 200, page.text);
++ assert.match(page.text, /<title>Devblog · OpenVibe\.Blog<\/title>/);
++ assert.ok(page.text.includes('class="bh-feature"') && page.text.includes('Printed &amp; Polished'));
++ assert.match(page.text, /<nav class="bh-tabs"/);
++ assert.doesNotMatch(page.text, /This week across OpenVibe/);
++ assert.match(page.text, /<link rel="canonical" href="https:\/\/openvibe\.blog\/devblog">/);
++ });
++
++ await check('a name filter narrows the list and says when nothing matches', async () => {
++ const only = await t.get('/?q=builder');
++ assert.match(only.text, /Meet the builders/);
++ assert.doesNotMatch(only.text, /How to self-host/);
++ assert.match(only.text, /id="bh-q" name="q" type="search" value="builder"/);
++ const none = await t.get('/?q=nothing-like-this');
++ assert.match(none.text, /No post matches/);
++ });
++
++ await check('the cards are art cards with real page links, and the next page is a real link', async () => {
++ const page = await t.get('/?tab=daily');
++ assert.match(page.text, /A quieter morning/, 'the tab’s only post is its feature');
++ assert.doesNotMatch(page.text, /class="bh-cards bh-artcards"/, 'nothing is left for a card grid');
++ for (let i = 0; i < 16; i++) await publish({ title: `Filler ${i}`, categories: 'Devblog', body: `Filler.\n\n${LONG}` });
++ const first = await t.get('/devblog');
++ assert.match(first.text, /<li class="bh-card bh-artcard">/);
++ assert.match(first.text, /<a class="bh-next" href="\/devblog\?page=2">Next page<\/a>/);
++ assert.match(first.text, /<script src="\/js\/blog-index\.js" defer><\/script>/);
++ const second = await t.get('/devblog?page=2');
++ assert.strictEqual(second.status, 200);
++ assert.match(second.text, /aria-current="page">2<\/span>/);
++ // Page 2 is cards alone: the first post of the page is not featured away, so loading the next
++ // page in place never misses a post.
++ assert.doesNotMatch(second.text, /class="bh-feature"/);
++ assert.strictEqual((second.text.match(/<li class="bh-card bh-artcard">/g) || []).length, 5);
++ });
++
++ await check('the index keeps WebSite and ItemList structured data', async () => {
++ const page = await t.get('/devblog');
++ const lds = [...page.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
++ assert.ok(lds.find((x) => x['@type'] === 'Blog'));
++ assert.ok(lds.find((x) => x['@type'] === 'ItemList'));
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/editor-inserter.test.js b/test/editor-inserter.test.js
nnew file mode 100644
iindex 0000000..960d72b
---- /dev/null
++++ b/test/editor-inserter.test.js
@@@ -0,0 +1,76 @@
++'use strict';
++/**
++ * The editor's block inserter (stage B1): the script is served and the page opts into it, the live preview
++ * is the server's own renderer (so a block looks in the editor exactly as it will in the post), and
++ * "Make an image" is honest about an OpenVibe.AI that is not configured. The form itself still posts
++ * without any of this.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const MED = 'med_01J8ZQ4X3M000000000000000A';
++
++(async () => {
++ const t = await boot();
++ const alice = t.network.addUser('alice', { display_name: 'Alice' });
++ t.media.objects.set(MED, 'ready');
++ await t.get('/api/v1/blogs', { as: alice, json: {} });
++ const created = await t.get('/api/v1/blogs/alice/posts', { as: alice, json: { title: 'In the editor', body: 'A first draft.' } });
++ const id = created.json().post.id;
++
++ await check('the edit page carries the inserter and its script; the form still works without it', async () => {
++ const page = await t.get(`/write/posts/${id}`, { as: alice });
++ assert.strictEqual(page.status, 200, page.text);
++ assert.match(page.text, /data-ov-editor data-post="pst_[^"]+" data-csrf="[^"]+" data-preview="\/write\/preview" data-image="\/write\/make-image"/);
++ assert.match(page.text, /<script src="\/js\/blog-editor\.js" defer><\/script>/);
++ assert.match(page.text, /<textarea id="body" name="body" rows="22">A first draft.<\/textarea>/, 'the textarea saves without script');
++ });
++
++ await check('the script is served, and the preview renders through the post renderer', async () => {
++ const js = await t.get('/js/blog-editor.js');
++ assert.strictEqual(js.status, 200);
++ assert.match(js.text, /data-ov-editor/);
++ const preview = await t.get('/write/preview', {
++ as: alice,
++ form: { _csrf: t.csrf(alice), markdown: `:::section{id="previewed"}\n## A previewed section\nHello.\n:::` },
++ });
++ assert.strictEqual(preview.status, 200, preview.text);
++ assert.strictEqual(preview.json().ok, true);
++ assert.match(preview.json().html, /<section class="ov-section" id="previewed"/);
++ assert.match(preview.json().html, /<h2>A previewed section<\/h2>/);
++ assert.deepStrictEqual(preview.json().sections.map((s) => s.id), ['previewed']);
++ });
++
++ await check('a figure in the preview resolves the post’s own attachments', async () => {
++ await t.get(`/api/v1/posts/${id}/attachments`, { as: alice, json: { media_id: MED, role: 'inline', alt: 'The workbench' } });
++ const r = await t.get('/write/preview', {
++ as: alice,
++ form: { _csrf: t.csrf(alice), post_id: id, markdown: `:::figure{src="${MED}" alt="The workbench"}:::` },
++ });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.match(r.json().html, new RegExp(`<img src="https://openvibe\\.media/o/${MED}" alt="The workbench"`));
++ const stranger = t.network.addUser('eve');
++ const other = await t.get('/write/preview', { as: stranger, form: { _csrf: t.csrf(stranger), post_id: id, markdown: `:::figure{src="${MED}"}:::` } });
++ assert.match(other.json().html, /ov-media-broken/, 'someone who may not edit the post sees no attachment');
++ });
++
++ await check('a preview without the form token is refused', async () => {
++ const r = await t.get('/write/preview', { as: alice, form: { markdown: '## No token' } });
++ assert.strictEqual(r.status, 403, r.text);
++ });
++
++ await check('the preview is a renderer of what was typed, not a read of anything stored', async () => {
++ const stranger = t.network.addUser('mallory');
++ const r = await t.get('/write/preview', { as: stranger, form: { _csrf: t.csrf(stranger), markdown: '## Mine' } });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.match(r.json().html, /Mine/);
++ });
++
++ await check('Make an image says OpenVibe.AI is not configured instead of faking one', async () => {
++ const r = await t.get('/write/make-image', { as: alice, form: { _csrf: t.csrf(alice), prompt: 'A channel header' } });
++ assert.strictEqual(r.status, 503, r.text);
++ assert.strictEqual(r.json().code, 'ai.not_configured');
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
iindex f838c8b..8850b22 100644
---- a/test/helpers/mocks.js
++++ b/test/helpers/mocks.js
@@@ -119,7 +119,7 @@ async function startCommunity({ network }) {
const key = `${ref.service}:${ref.type}:${ref.id}`;
if (byRef.has(key)) return json(200, { thread: { id: byRef.get(key), visibility: threads.get(byRef.get(key)).visibility }, created: false });
const id = ++seq;
-- threads.set(id, { ref, visibility: 'public', comments: [] });
++ threads.set(id, { ref, visibility: 'public', comments: [], reactions: {} });
byRef.set(key, id);
return json(201, { thread: { id, visibility: 'public' }, created: true });
}
@@@ -140,6 +140,37 @@ async function startCommunity({ network }) {
t.comments.push(comment);
return json(201, { comment });
}
++ m = req.url.match(/^\/api\/v1\/comments\/threads\/(\d+)\/section-reactions$/);
++ if (m && req.method === 'GET') {
++ const t = threads.get(Number(m[1]));
++ if (!t || t.visibility === 'hidden') return json(404, { code: 'thread.not_found' });
++ const sections = {};
++ for (const [key, r] of Object.entries(t.reactions)) {
++ const id = key.split(':')[0];
++ const s = sections[id] || (sections[id] = { up: 0, down: 0, mine: 0 });
++ if (r.value === 1) s.up++; else s.down++;
++ }
++ return json(200, { thread_id: Number(m[1]), sections });
++ }
++ if (m && req.method === 'POST') {
++ const c = auth();
++ if (!c || !(c.cap || []).includes('community.comment.write')) return json(403, { code: 'capability.denied' });
++ const t = threads.get(Number(m[1]));
++ if (!t) return json(404, { code: 'thread.not_found' });
++ const body = JSON.parse(raw);
++ const who = network.directory.get(req.headers['x-ov-subject']);
++ const key = `${body.section_id}:${who ? who.subject_id : 'anon'}`;
++ if (body.value === 0) delete t.reactions[key];
++ else t.reactions[key] = { value: body.value };
++ const sections = {};
++ for (const [key, r] of Object.entries(t.reactions)) {
++ const id = key.split(':')[0];
++ const s = sections[id] || (sections[id] = { up: 0, down: 0 });
++ if (r.value === 1) s.up++; else s.down++;
++ }
++ const out = sections[body.section_id] || { up: 0, down: 0 };
++ return json(200, { section_id: body.section_id, value: body.value, upvotes: out.up, downvotes: out.down });
++ }
m = req.url.match(/^\/api\/v1\/comments\/threads\/(\d+)\/visibility$/);
if (m && req.method === 'PUT') {
const c = auth();
ddiff --git a/test/lifecycle.test.js b/test/lifecycle.test.js
iindex f3a5153..9d0ded5 100644
---- a/test/lifecycle.test.js
++++ b/test/lifecycle.test.js
@@@ -72,7 +72,7 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');
const page = await t.get('/@alice/first-light');
assert.strictEqual(page.status, 200);
const beforeScripts = page.text.split('<script src=')[0];
-- assert.match(page.text, /<h1>First light<\/h1>/);
++ assert.match(page.text, /<h1 class="post-title">First light<\/h1>/);
assert.match(page.text, /Hello <strong>stars<\/strong>/);
assert.match(beforeScripts, /<link rel="canonical" href="https:\/\/openvibe\.blog\/@alice\/first-light">/);
assert.match(page.text, /<meta name="robots" content="index, follow">/);
@@@ -116,10 +116,10 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');
await check('a revision after publication does not change what readers see until it is published', async () => {
const r = await t.get(`/api/v1/posts/${post.id}`, { as: alice, method: 'PATCH', json: { expected_revision: 2, title: 'First light (revised)' } });
assert.strictEqual(r.json().revision, 3);
-- assert.match((await t.get('/@alice/first-light')).text, /<h1>First light<\/h1>/);
++ assert.match((await t.get('/@alice/first-light')).text, /<h1 class="post-title">First light<\/h1>/);
assert.strictEqual((await t.events('blog.post.updated')).length, 0);
await t.get(`/api/v1/posts/${post.id}/publish`, { as: alice, json: { revision: 3 } });
-- assert.match((await t.get('/@alice/first-light')).text, /<h1>First light \(revised\)<\/h1>/);
++ assert.match((await t.get('/@alice/first-light')).text, /<h1 class="post-title">First light \(revised\)<\/h1>/);
assert.strictEqual((await t.events('blog.post.updated')).length, 1);
assert.strictEqual((await t.events('blog.index_document.upserted')).length, 2);
});
ddiff --git a/test/reading-layout.test.js b/test/reading-layout.test.js
iindex 9d74a14..ac52f1e 100644
---- a/test/reading-layout.test.js
++++ b/test/reading-layout.test.js
@@@ -42,6 +42,8 @@ const base = {
assert.ok(html.includes('<div class="post-wrap has-toc">'), 'three sections: a contents list');
assert.ok(html.includes('<nav class="toc-rail" aria-labelledby="toc-rail-h">') && html.includes('<details class="toc-fold">'), 'the rail for wide screens, the fold for phones');
assert.ok(/<a href="#three-x">Three &lt;x&gt;<\/a>|<a href="#three">Three<\/a>/.test(html), `section text is escaped in the list: ${(html.match(/toc-fold[\s\S]*?<\/details>/) || [''])[0].slice(0, 400)}`);
++ assert.ok(html.includes('<nav class="toc-chips"') && /<a class="toc-chip" href="#three-x">Three &lt;x&gt;<\/a>/.test(html), 'the phone chips are real links, not escaped markup');
++ assert.ok(!html.includes('&lt;a class="toc-chip"'), 'no escaped chip markup');
assert.ok(html.includes('≈ 3 min read'));
assert.ok(html.includes('Part 36 of 118 in the series <a href="/@openvibe/series/patch-notes">Patch notes</a>'), 'the series is one line, not 50 links');
assert.ok(html.includes('<a rel="prev" href="/p35"><small>Previous</small><span>Part 35 &lt;b&gt;</span></a>') && html.includes('<a rel="next" href="/p37">'));
ddiff --git a/test/slug-media.test.js b/test/slug-media.test.js
iindex de05341..0e96e05 100644
---- a/test/slug-media.test.js
++++ b/test/slug-media.test.js
@@@ -104,6 +104,12 @@ const MED_B = 'med_01J8Z3K4M5N6P7Q8R9S0T1V2W4';
assert.strictEqual(m.broken_reason, 'deleted');
assert.strictEqual(m.url, null);
assert.match((await t.get('/@dan/feed.json')).text, /no longer available/);
++ const item = (await t.get('/@dan/feed.json')).json().items.find((entry) => entry.id === feedIdBefore);
++ assert.strictEqual(typeof item.content_html, 'string', 'feeds receive the rendered HTML, not the block result object');
++ assert.match(item.content_html, /data-state="broken"/);
++ for (const path of ['/@dan/feed.xml', '/@dan/atom.xml']) {
++ assert.match((await t.get(path)).text, /no longer available/);
++ }
const editor = await t.get(`/write/posts/${post.id}`, { as: dan });
assert.match(editor.text, /unavailable \(deleted\)/);
});