openvibers/openvibe.blog Commits
Devblogs: rich blocks, the post page, the index and the editor's inserter (#243)
Changes
@@@ -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 -->@@@ -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"nnew file mode 100644BBinary files /dev/null and b/docs/screens-b1/devblog-1280-dark.png differnnew file mode 100644BBinary files /dev/null and b/docs/screens-b1/devblog-1280-light.png differnnew file mode 100644BBinary files /dev/null and b/docs/screens-b1/devblog-390-dark.png differnnew file mode 100644BBinary files /dev/null and b/docs/screens-b1/devblog-390-light.png differnnew file mode 100644BBinary files /dev/null and b/docs/screens-b1/editor-1280-dark.png differnnew file mode 100644BBinary files /dev/null and b/docs/screens-b1/editor-1280-light.png differnnew file mode 100644BBinary files /dev/null and b/docs/screens-b1/editor-390-dark.png differnnew file mode 100644BBinary files /dev/null and b/docs/screens-b1/editor-390-light.png differnnew file mode 100644BBinary files /dev/null and b/docs/screens-b1/index-1280-dark.png differnnew file mode 100644BBinary files /dev/null and b/docs/screens-b1/index-1280-light.png differnnew file mode 100644BBinary files /dev/null and b/docs/screens-b1/index-390-dark.png differnnew file mode 100644BBinary files /dev/null and b/docs/screens-b1/index-390-light.png differnnew file mode 100644BBinary files /dev/null and b/docs/screens-b1/post-1280-dark.png differnnew file mode 100644BBinary files /dev/null and b/docs/screens-b1/post-1280-light.png differnnew file mode 100644BBinary files /dev/null and b/docs/screens-b1/post-390-dark.png differnnew file mode 100644BBinary files /dev/null and b/docs/screens-b1/post-390-light.png differ@@@ -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"@@@ -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"nnew file mode 100644@@@ -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; }++}@@@ -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; }++}nnew file mode 100644@@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');++ };++++ // ── 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();++ }++ });++})();nnew file mode 100644@@@ -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);++})();@@@ -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'"],@@@ -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.@@@ -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 };@@@ -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;@@@ -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, medif (!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, medurl: 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, meddateModified: 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}` })),});}@@@ -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 },};@@@ -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) };}));@@@ -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);@@@ -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(),@@@ -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) => {nnew file mode 100644@@@ -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 };@@@ -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(', ')}">@@@ -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 };@@@ -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>` : '')}@@@ -51,6 +51,12 @@ const WORKFLOW = { id: 'blog.draft_post', version: 1, runId: 'run_01J8Z3K4M5N6P7const 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');nnew file mode 100644@@@ -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, /<script>/, '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 & 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();++})();nnew file mode 100644@@@ -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 & 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 & 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 & 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();++})();nnew file mode 100644@@@ -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();++})();@@@ -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();@@@ -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);});@@@ -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 <x><\/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 <x><\/a>/.test(html), 'the phone chips are real links, not escaped markup');++ assert.ok(!html.includes('<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 <b></span></a>') && html.includes('<a rel="next" href="/p37">'));@@@ -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\)/);});
@@ -330,7 +330,7 @@ Called elsewhere, as the service principal `blog`: `identity.subject.resolve` an
@@ -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
- **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).
`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`
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),
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).
`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 [
@@ -544,5 +544,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
- openvibe-contracts: v0.338.0
- openvibe-contracts: v0.338.0
- openvibe-sdk: v0.123.0
- openvibe-sdk: v0.123.0
- openvibe-shared: v4.173.0
- openvibe-shared: v4.173.0
-- openvibe-publishing: v1.4.1
+- openvibe-publishing: v1.6.1
<!-- versions:end -->
<!-- versions:end -->
@@ -11,7 +11,7 @@
@@ -11,7 +11,7 @@
"sdk": "openvibe-sdk v0.123.0",
"sdk": "openvibe-sdk v0.123.0",
"shared": "openvibe-shared v4.173.0",
"shared": "openvibe-shared v4.173.0",
"packages": [
"packages": [
- "openvibe-publishing v1.4.1",
+ "openvibe-publishing v1.6.1",
"openvibe-contracts v0.338.0",
"openvibe-contracts v0.338.0",
"openvibe-shared v4.173.0",
"openvibe-shared v4.173.0",
"openvibe-sdk v0.123.0"
"openvibe-sdk v0.123.0"
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screens-b1/devblog-1280-dark.png differ
Binary files /dev/null and b/docs/screens-b1/devblog-1280-dark.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screens-b1/devblog-1280-light.png differ
Binary files /dev/null and b/docs/screens-b1/devblog-1280-light.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screens-b1/devblog-390-dark.png differ
Binary files /dev/null and b/docs/screens-b1/devblog-390-dark.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screens-b1/devblog-390-light.png differ
Binary files /dev/null and b/docs/screens-b1/devblog-390-light.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screens-b1/editor-1280-dark.png differ
Binary files /dev/null and b/docs/screens-b1/editor-1280-dark.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screens-b1/editor-1280-light.png differ
Binary files /dev/null and b/docs/screens-b1/editor-1280-light.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screens-b1/editor-390-dark.png differ
Binary files /dev/null and b/docs/screens-b1/editor-390-dark.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screens-b1/editor-390-light.png differ
Binary files /dev/null and b/docs/screens-b1/editor-390-light.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screens-b1/index-1280-dark.png differ
Binary files /dev/null and b/docs/screens-b1/index-1280-dark.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screens-b1/index-1280-light.png differ
Binary files /dev/null and b/docs/screens-b1/index-1280-light.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screens-b1/index-390-dark.png differ
Binary files /dev/null and b/docs/screens-b1/index-390-dark.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screens-b1/index-390-light.png differ
Binary files /dev/null and b/docs/screens-b1/index-390-light.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screens-b1/post-1280-dark.png differ
Binary files /dev/null and b/docs/screens-b1/post-1280-dark.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screens-b1/post-1280-light.png differ
Binary files /dev/null and b/docs/screens-b1/post-1280-light.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screens-b1/post-390-dark.png differ
Binary files /dev/null and b/docs/screens-b1/post-390-dark.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screens-b1/post-390-light.png differ
Binary files /dev/null and b/docs/screens-b1/post-390-light.png differ
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.173.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -1164,9 +1164,9 @@
@@ -1164,9 +1164,9 @@
}
}
},
},
"node_modules/openvibe-publishing": {
"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",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -28,7 +28,7 @@
@@ -28,7 +28,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.173.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
new file mode 100644
new file mode 100644
@@ -0,0 +1,198 @@
@@ -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; }
+}
@@ -22,12 +22,6 @@ h2 { font-size: 1.35rem; margin: 1.6rem 0 .5rem; }
@@ -22,12 +22,6 @@ h2 { font-size: 1.35rem; margin: 1.6rem 0 .5rem; }
.post-item-title a:hover { text-decoration: underline; }
.post-item-title a:hover { text-decoration: underline; }
.summary { margin: 6px 0; }
.summary { margin: 6px 0; }
.tags a, .categories a { margin-right: 6px; }
.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 { 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); }
.disclosure strong { color: var(--text-primary); }
.sources ol { padding-left: 1.2rem; }
.sources ol { padding-left: 1.2rem; }
@@ -190,52 +184,6 @@ body.blog-home .page { max-width: 1120px; }
@@ -190,52 +184,6 @@ body.blog-home .page { max-width: 1120px; }
.pick-bar { display: none; }
.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,
/* 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. */
its content sits inside the card. A wider screen shows the section as before. */
@media (max-width: 640px) {
@media (max-width: 640px) {
@@ -251,3 +199,49 @@ body.post-page .page { max-width: 1160px; }
@@ -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 > summary + * { margin-top: 0 !important; padding-top: 4px !important; }
.bl-fold-block[open] { padding-bottom: 12px; }
.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; }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,423 @@
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
+ };
+
+ // ── 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();
+ }
+ });
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,35 @@
@@ -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);
+})();
@@ -163,7 +163,9 @@ async function createApp(opts = {}) {
@@ -163,7 +163,9 @@ async function createApp(opts = {}) {
mediaSrc: ["'self'", 'https:'],
mediaSrc: ["'self'", 'https:'],
// openvibe.events: release notifications (release-watch's EventSource, openvibe-shared 1.17).
// openvibe.events: release notifications (release-watch's EventSource, openvibe-shared 1.17).
// The navbar's Messages count asks Chat (openvibe-shared 4.130.0).
// 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'],
frameSrc: ["'self'", 'https://openvibe.network'],
frameAncestors: ["'self'"],
frameAncestors: ["'self'"],
objectSrc: ["'none'"],
objectSrc: ["'none'"],
@@ -75,6 +75,38 @@ function createCommunity({ store, config, fetchImpl = globalThis.fetch }) {
@@ -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
* 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.
* again when it is public again. Needs community.comment.moderate.
@@ -97,7 +97,31 @@ function createAiDrafts({ config, store, posts, access, fetchImpl = globalThis.f
@@ -97,7 +97,31 @@ function createAiDrafts({ config, store, posts, access, fetchImpl = globalThis.f
}, { traceparent });
}, { 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 };
module.exports = { createAiDrafts };
@@ -21,6 +21,7 @@ const { slugify } = require('openvibe-publishing/taxonomy');
@@ -21,6 +21,7 @@ const { slugify } = require('openvibe-publishing/taxonomy');
const seo = require('openvibe-publishing/seo');
const seo = require('openvibe-publishing/seo');
const authorship = require('openvibe-publishing/authorship');
const authorship = require('openvibe-publishing/authorship');
const { isMediaId } = require('openvibe-publishing/media');
const { isMediaId } = require('openvibe-publishing/media');
+const blocks = require('openvibe-publishing/blocks');
const { ApiError } = require('../http/errors');
const { ApiError } = require('../http/errors');
const { actorRef, canPublish } = require('./publication');
const { actorRef, canPublish } = require('./publication');
const reviewKind = require('./review-kind');
const reviewKind = require('./review-kind');
@@ -85,6 +86,23 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,
@@ -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`);
if (b.length > MAX_BODY) throw new ApiError(413, 'post.too_long', `A post is at most ${MAX_BODY} characters`);
return b;
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) {
function summary(v) {
if (v == null) return null;
if (v == null) return null;
const s = String(v).replace(/\s+/g, ' ').trim().slice(0, 500);
const s = String(v).replace(/\s+/g, ' ').trim().slice(0, 500);
@@ -261,7 +279,7 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,
@@ -261,7 +279,7 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,
const subject = actingSubject(viewer);
const subject = actingSubject(viewer);
if (!await access.canWrite(store, viewer, blog, 'create')) throw new ApiError(403, 'blog.forbidden', 'You cannot write on this blog');
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 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 vis = visibilityOf(blog, input.visibility, input.entitlementKey ?? input.entitlement_key);
const rec = authorshipFor(viewer, input, null);
const rec = authorshipFor(viewer, input, null);
return await store.tx(async () => {
return await store.tx(async () => {
@@ -284,10 +302,19 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,
@@ -284,10 +302,19 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,
if (sharing) await sharing.register(viewer, id, 'private');
if (sharing) await sharing.register(viewer, id, 'private');
const post = await q.byId.get(id);
const post = await q.byId.get(id);
await setTerms(blog, post, input);
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({
await outbox.emit({
event_type: 'blog.post.created', actor: actorRef(viewer), visibility: 'internal', priority: 'low',
event_type: 'blog.post.created', actor: actorRef(viewer), visibility: 'internal', priority: 'low',
subject: { type: 'post', id, revision: revision.number },
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 });
}, { traceparent });
return { post: await q.byId.get(id), revision };
return { post: await q.byId.get(id), revision };
});
});
@@ -308,7 +335,7 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,
@@ -308,7 +335,7 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,
const head = await store.revisions.head(post.id);
const head = await store.revisions.head(post.id);
let revision = head;
let revision = head;
let created = false;
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) {
if (contentChange) {
const expected = input.expectedRevision ?? input.expected_revision;
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');
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,
@@ -318,7 +345,7 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,
};
};
const rec = authorshipFor(viewer, input, publication.authorshipOf(head));
const rec = authorshipFor(viewer, input, publication.authorshipOf(head));
const out = await store.revisions.create({
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,
fields, meta: { authorship: rec }, author: authorLabel(viewer), message: input.message || null,
});
});
revision = out.revision;
revision = out.revision;
@@ -7,6 +7,7 @@
@@ -7,6 +7,7 @@
*/
*/
const seo = require('openvibe-publishing/seo');
const seo = require('openvibe-publishing/seo');
const ssr = require('openvibe-publishing/ssr');
const ssr = require('openvibe-publishing/ssr');
+const blocks = require('openvibe-publishing/blocks');
const reviewKind = require('./review-kind');
const reviewKind = require('./review-kind');
const { renderBody } = require('../render/pages');
const { renderBody } = require('../render/pages');
@@ -56,12 +57,17 @@ function createReading({ store, blogs, posts: postsApi, publication, people, med
@@ -56,12 +57,17 @@ function createReading({ store, blogs, posts: postsApi, publication, people, med
if (!rev) return null;
if (!rev) return null;
const blog = blogById.get(post.blog_id);
const blog = blogById.get(post.blog_id);
const person = who.get(post.author_subject);
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 {
return {
post, rev, blog,
post, rev, blog,
url: publication.postPath(blog, post),
url: publication.postPath(blog, post),
author: person ? { name: person.name, url: urls.author(person, blog) } : null,
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,
badge: { members: 'Members only', private: 'Private' }[post.visibility] || null,
};
};
}));
}));
@@ -90,7 +96,7 @@ function createReading({ store, blogs, posts: postsApi, publication, people, med
@@ -90,7 +96,7 @@ function createReading({ store, blogs, posts: postsApi, publication, people, med
url: publication.postUrl(blog, post),
url: publication.postUrl(blog, post),
title: rev.fields.title,
title: rev.fields.title,
summary: rev.fields.summary || ssr.markdownToText(rev.content, 300),
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,
published: post.first_published_at,
updated: rev.createdAt,
updated: rev.createdAt,
decision,
decision,
@@ -141,9 +147,10 @@ function createReading({ store, blogs, posts: postsApi, publication, people, med
@@ -141,9 +147,10 @@ function createReading({ store, blogs, posts: postsApi, publication, people, med
}
}
/** BlogPosting JSON-LD from real fields only (missing → omitted). */
/** 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 url = publication.postUrl(blog, post);
const cover = attachments.find((a) => a.role === 'cover' && !a.broken);
const cover = attachments.find((a) => a.role === 'cover' && !a.broken);
+ const art = heroArt || (cover ? cover.mediaId : null);
return seo.structuredData.article({
return seo.structuredData.article({
type: 'BlogPosting',
type: 'BlogPosting',
headline: rev.fields.title,
headline: rev.fields.title,
@@ -153,11 +160,12 @@ function createReading({ store, blogs, posts: postsApi, publication, people, med
@@ -153,11 +160,12 @@ function createReading({ store, blogs, posts: postsApi, publication, people, med
dateModified: post.published_revision ? rev.createdAt : null,
dateModified: post.published_revision ? rev.createdAt : null,
authors: person && person.known ? [{ name: person.name, url: publication.abs(urls.author(person, blog)) }] : [],
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,
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),
keywords: tags.map((t) => t.name),
inLanguage: blog.language,
inLanguage: blog.language,
citations: citations.filter((c) => c.url).map((c) => ({ url: c.url, title: c.title })),
citations: citations.filter((c) => c.url).map((c) => ({ url: c.url, title: c.title })),
wordCount: ssr.wordCount(ssr.markdownToText(rev.content)),
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}` })),
});
});
}
}
@@ -78,6 +78,8 @@ const BUDGETS = {
@@ -78,6 +78,8 @@ const BUDGETS = {
'blog.post.media': { minute: 20, hour: 200 },
'blog.post.media': { minute: 20, hour: 200 },
// A word diff of two long revisions costs CPU: a writer's pace, not a crawler's.
// A word diff of two long revisions costs CPU: a writer's pace, not a crawler's.
'blog.post.diff': { minute: 30, hour: 600 },
'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).
// A comment goes to OpenVibe.Community in the person's name (Community allows 20 a minute).
'blog.comment.create': { minute: 20, hour: 300 },
'blog.comment.create': { minute: 20, hour: 300 },
};
};
@@ -36,6 +36,7 @@ const cache = require('openvibe-shared/cache-policy');
@@ -36,6 +36,7 @@ const cache = require('openvibe-shared/cache-policy');
const { run, jsonBody, ApiError, privateNoStore } = require('./errors');
const { run, jsonBody, ApiError, privateNoStore } = require('./errors');
const { guard } = require('../auth/viewer');
const { guard } = require('../auth/viewer');
const { checkCapability, CAPABILITIES } = require('../auth/capabilities');
const { checkCapability, CAPABILITIES } = require('../auth/capabilities');
+const blockRender = require('../render/blocks');
function cors(origins) {
function cors(origins) {
const allowed = new Set(origins);
const allowed = new Set(origins);
@@ -113,6 +114,9 @@ function createApi(ctx) {
@@ -113,6 +114,9 @@ function createApi(ctx) {
const rec = publication.authorshipOf(rev);
const rec = publication.authorshipOf(rev);
Object.assign(out, {
Object.assign(out, {
entitlement_key: post.entitlement_key, body: rev ? rev.content : null,
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,
authorship: rec, review: rev ? await publication.reviewOf(post, rev) : null,
scheduled: await posts.pendingJobs(post), attachments: await posts.attachments(post),
scheduled: await posts.pendingJobs(post), attachments: await posts.attachments(post),
citations: rev ? await posts.citations(post, rev.number) : [],
citations: rev ? await posts.citations(post, rev.number) : [],
@@ -245,11 +249,21 @@ function createApi(ctx) {
@@ -245,11 +249,21 @@ function createApi(ctx) {
router.get('/posts/:id', guard('blog.post.read'), run(async (req) => {
router.get('/posts/:id', guard('blog.post.read'), run(async (req) => {
const { post, blog } = await mustPost(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);
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.
// 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 && 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 (!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) };
return { post: await postDto(post) };
}));
}));
@@ -21,6 +21,7 @@ const authorship = require('openvibe-publishing/authorship');
@@ -21,6 +21,7 @@ const authorship = require('openvibe-publishing/authorship');
const { escapeHtml: esc } = require('openvibe-publishing/ssr');
const { escapeHtml: esc } = require('openvibe-publishing/ssr');
const { renderPage } = require('../render/layout');
const { renderPage } = require('../render/layout');
const editor = require('../render/editor');
const editor = require('../render/editor');
+const blockRender = require('../render/blocks');
const { csrfToken, checkCsrf } = require('../auth/forms');
const { csrfToken, checkCsrf } = require('../auth/forms');
const { asApiError } = require('./errors');
const { asApiError } = require('./errors');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
@@ -278,6 +279,41 @@ function createEditorRoutes(ctx) {
@@ -278,6 +279,41 @@ function createEditorRoutes(ctx) {
res.redirect(303, `/write/posts/${found.post.id}?saved=${encodeURIComponent('Media removed.')}`);
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.
// Settings → How I write: the voice card the AI prompt uses.
router.get('/voice', wrap(async (req, res) => {
router.get('/voice', wrap(async (req, res) => {
const mine = await ctx.patchNotes.voice.get(req.viewer.subject);
const mine = await ctx.patchNotes.voice.get(req.viewer.subject);
@@ -137,7 +137,9 @@ route('post', '/blogs/{handle}/posts', {
@@ -137,7 +137,9 @@ route('post', '/blogs/{handle}/posts', {
operationId: 'createPost', summary: 'Create a post (a draft)', tags: ['posts'], security: PERSON, parameters: [HANDLE],
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).',
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: {
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,
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' }),
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 }),
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', {
@@ -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]) },
responses: { 200: { description: 'The feed (application/feed+json)', content: { 'application/feed+json': { schema: { type: 'object' } } } }, ...errors([404, 429]) },
});
});
route('get', '/posts/{id}', {
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}', {
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],
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).',
description: 'blog.post.update. Any content change needs expected_revision (428 without it; 412 when it is stale).',
requestBody: body({ type: 'object', properties: {
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(),
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'),
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(),
allow_comments: { type: 'boolean' }, noindex: { type: 'boolean' }, citations: arrayOf(citation), message: str(),
@@ -25,8 +25,8 @@ const ssr = require('openvibe-publishing/ssr');
@@ -25,8 +25,8 @@ const ssr = require('openvibe-publishing/ssr');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const { renderPage, SITE_SUMMARY } = require('../render/layout');
const { renderPage, SITE_SUMMARY } = require('../render/layout');
const pages = require('../render/pages');
const pages = require('../render/pages');
+const blockRender = require('../render/blocks');
const homePage = require('../render/home');
const homePage = require('../render/home');
-const deskCatalog = require('../desks/catalog');
// The short home (the owner's rule, 2026-10-30): pick blocks and folds.
// The short home (the owner's rule, 2026-10-30): pick blocks and folds.
const { foldBlock } = require('../render/short');
const { foldBlock } = require('../render/short');
const { createHome } = require('../domain/home');
const { createHome } = require('../domain/home');
@@ -40,6 +40,7 @@ const PER_PAGE = 15;
@@ -40,6 +40,7 @@ const PER_PAGE = 15;
function createPublicRoutes(ctx) {
function createPublicRoutes(ctx) {
const { config, store, blogs, posts, publication, reading, people, community, media, access, entitlements, viewers, limits, desks, sharing } = 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 homeData = ctx.home || createHome({ store });
const router = express.Router();
const router = express.Router();
router.use(viewers.middleware({ services: false }));
router.use(viewers.middleware({ services: false }));
@@ -97,8 +98,85 @@ function createPublicRoutes(ctx) {
@@ -97,8 +98,85 @@ function createPublicRoutes(ctx) {
// ── Blog front pages ────────────────────────────────────
// ── 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 } = {}) {
async function blogFront(req, res, blog, { home = false } = {}) {
const page = pageNumber(req);
const page = pageNumber(req);
+ if (home && page === 1) return await homeIndex(req, res, blog);
const scope = await access.listingScope(store, req.viewer, blog);
const scope = await access.listingScope(store, req.viewer, blog);
const path = publication.blogPath(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 });
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) {
@@ -123,28 +201,6 @@ function createPublicRoutes(ctx) {
const blogLd = blog.kind === 'official'
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, 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 };
: { '@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, {
send(req, res, 200, {
title: blog.title,
title: blog.title,
description: blog.description,
description: blog.description,
@@ -168,6 +224,26 @@ function createPublicRoutes(ctx) {
@@ -168,6 +224,26 @@ function createPublicRoutes(ctx) {
router.get('/', wrap(async (req, res) => await blogFront(req, res, await blogs.official(), { home: true })));
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
// 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.
// network changelog this service keeps), like every OpenVibe site's /updates.
router.get('/updates', wrap(async (req, res) => send(req, res, 200, {
router.get('/updates', wrap(async (req, res) => send(req, res, 200, {
@@ -338,6 +414,27 @@ function createPublicRoutes(ctx) {
@@ -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). */
/** 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 }) {
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);
const decision = preview ? await publication.decide(blog, post, rev, { state: 'draft' }) : await publication.decide(blog, post, rev);
@@ -352,6 +449,24 @@ function createPublicRoutes(ctx) {
@@ -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 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 comments = preview ? null : await commentsFor(req, blog, post, rev);
const cacheable = !preview && post.state === 'published' && post.visibility === 'public';
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) : '';
const csrf = req.viewer.kind === 'user' ? csrfToken(config, req.viewer) : '';
send(req, res, status, {
send(req, res, status, {
title: rev.fields.title,
title: rev.fields.title,
@@ -361,13 +476,13 @@ function createPublicRoutes(ctx) {
@@ -361,13 +476,13 @@ function createPublicRoutes(ctx) {
canonical: publication.postUrl(blog, post),
canonical: publication.postUrl(blog, post),
type: 'article',
type: 'article',
author: person.known ? person.name : undefined,
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,
published: post.first_published_at ? new Date(post.first_published_at).toISOString() : null,
modified: post.published_revision ? rev.createdAt : null,
modified: post.published_revision ? rev.createdAt : null,
feeds: await reading.feedsOf(blog),
feeds: await reading.feedsOf(blog),
theme: { slug: blog.theme, vars: blogs.themeVars(blog) },
theme: { slug: blog.theme, vars: blogs.themeVars(blog) },
lang: blog.language,
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({
body: pages.postPage({
blog, blogUrl: publication.blogPath(blog), post, rev,
blog, blogUrl: publication.blogPath(blog), post, rev,
author: { name: person.name, url: reading.urls.author(person, blog) },
author: { name: person.name, url: reading.urls.author(person, blog) },
@@ -378,6 +493,19 @@ function createPublicRoutes(ctx) {
@@ -378,6 +493,19 @@ function createPublicRoutes(ctx) {
urlFor: media.urlFor, shareUrl: publication.postUrl(blog, post), breadcrumbs: crumbs, jsonUrl: `${path}.json`,
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),
postPath: path, csrf, signedIn: req.viewer.kind === 'user' && Boolean(req.viewer.subject),
loginUrl: `/auth/login?next=${encodeURIComponent(path)}`,
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'],
styles: ['kit.css'],
}, { cacheable });
}, { cacheable });
@@ -466,6 +594,26 @@ function createPublicRoutes(ctx) {
@@ -466,6 +594,26 @@ function createPublicRoutes(ctx) {
return await renderPost(req, res, { blog, post, rev });
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
// Per-actor limit (http/actor-limits.js) before the form is read: the comment goes to Community in
// the person's name.
// the person's name.
router.post('/@:handle/:slug/comments', limits.budget('blog.comment.create'), express.urlencoded({ extended: false, limit: '32kb' }), wrap(async (req, res) => {
router.post('/@:handle/:slug/comments', limits.budget('blog.comment.create'), express.urlencoded({ extended: false, limit: '32kb' }), wrap(async (req, res) => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,203 @@
@@ -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 };
@@ -7,6 +7,7 @@
@@ -7,6 +7,7 @@
const ssr = require('openvibe-publishing/ssr');
const ssr = require('openvibe-publishing/ssr');
const { time } = require('./pages');
const { time } = require('./pages');
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
+const { asset } = require('./layout');
const { html: h, raw } = ssr;
const { html: h, raw } = ssr;
@@ -64,13 +65,24 @@ function aiDraftForm({ blog, csrf }) {
@@ -64,13 +65,24 @@ function aiDraftForm({ blog, csrf }) {
function postForm({ blog, post, head, terms, series, csrf, message, action }) {
function postForm({ blog, post, head, terms, series, csrf, message, action }) {
const f = head ? head.fields : {};
const f = head ? head.fields : {};
const vis = post ? post.visibility : 'public';
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)}
<form method="post" action="${action}" class="post-form">${csrfField(csrf)}
${head ? h`<input type="hidden" name="expectedRevision" value="${head.number}">` : ''}
${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="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="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>
<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>` : ''}
${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="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(', ')}">
<label for="categories">Categories <span class="meta">(comma-separated; "Parent > Child" nests)</span></label><input id="categories" name="categories" value="${(terms.categories || []).join(', ')}">
@@ -42,6 +42,71 @@ function statsPanel(stats) {
@@ -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>`);
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) {
function postCard(item) {
const { post, rev, url, author, tags, badge } = item;
const { post, rev, url, author, tags, badge } = item;
const summary = rev.fields.summary || ssr.markdownToText(rev.content, 220);
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
@@ -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
* (Map desk → post), tags ([{ slug, name, posts }]), series ([{ title, url, posts }]), memberItems, signedIn
*/
*/
function homeBody(o) {
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 start = { label: signedIn ? 'Start writing' : 'Sign in and start writing', href: '/write', primary: true };
const top = week.length ? week[0].changes : 0;
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);
const desks = catalog.ALL.map((d) => ({ desk: d, post: latestDesks.get(d.key) })).filter((x) => x.post);
@@ -70,7 +135,8 @@ function homeBody(o) {
@@ -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>
<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>` : ''}
${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>` : ''}
${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({
${week.length ? raw(foldBlock({
id: 'week',
id: 'week',
head: '<h2 id="bh-week-h">This week across OpenVibe</h2>',
head: '<h2 id="bh-week-h">This week across OpenVibe</h2>',
@@ -132,4 +198,4 @@ function homeJsonLd({ base, items }) {
@@ -132,4 +198,4 @@ function homeJsonLd({ base, items }) {
return out;
return out;
}
}
-module.exports = { homeBody, homeJsonLd, siteName, FAQ };
+module.exports = { homeBody, homeJsonLd, indexList, indexTabs, nameFilter, INDEX_TABS, siteName, FAQ };
@@ -9,6 +9,8 @@
@@ -9,6 +9,8 @@
*/
*/
const ssr = require('openvibe-publishing/ssr');
const ssr = require('openvibe-publishing/ssr');
const { figureHtml } = require('openvibe-publishing/media');
const { figureHtml } = require('openvibe-publishing/media');
+const blockRender = require('./blocks');
+const { asset } = require('./layout');
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
@@ -75,13 +77,21 @@ function sectionsOf(bodyHtml) {
@@ -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). */
/** The contents list: a fold under the title on phones, a rail beside the text on wide screens (CSS picks one). */
function contentsHtml(sections) {
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>`);
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) {
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>`);
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. */
/** The series: one line under the title; the previous and next parts, and the parts around this one, after the text. */
function seriesLine(series) {
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');
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()
@@ -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
* 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.
* 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) {
function postListItem(item) {
@@ -192,32 +190,60 @@ ${m.replies && m.replies.length ? h`<ol class="replies">${m.replies.map(one)}</o
@@ -192,32 +190,60 @@ ${m.replies && m.replies.length ? h`<ol class="replies">${m.replies.map(one)}</o
${list}${more}${form}</section>`);
${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. */
/** One post. */
function postPage(o) {
function postPage(o) {
const { blog, blogUrl, post, rev, author, tags, categories, series, attachments, citations, disclosure, decisionNote, comments, canEdit, urlFor, shareUrl } = 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 rel = blog.kind === 'official' ? 'noopener' : 'nofollow ugc noopener';
const cover = attachments.filter((a) => a.role === 'cover');
const cover = attachments.filter((a) => a.role === 'cover');
const gallery = attachments.filter((a) => a.role === 'gallery');
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 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 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 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 toc = body.sections.length >= 3 ? body.sections : null;
const minutes = readMinutes(rev.content);
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' : ''}">
<div class="post-wrap${toc ? ' has-toc' : ''}">
${raw(toc ? contentsHtml(toc) : '')}
${raw(toc ? contentsHtml(toc) : '')}
+${raw(toc ? contentsChips(toc) : '')}
<div class="post-main">
<div class="post-main">
<article class="post" data-post-id="${post.id}" data-revision="${rev.number}">
<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)) : ''}
${series ? raw(seriesLine(series)) : ''}
${share ? raw(String(share)) : ''}
${share ? raw(String(share)) : ''}
${disclosure ? h`<p class="disclosure" role="note"><strong>${disclosure.short}.</strong> ${disclosure.long}</p>` : ''}
${disclosure ? h`<p class="disclosure" role="note"><strong>${disclosure.short}.</strong> ${disclosure.long}</p>` : ''}
${raw(toc ? contentsFold(toc) : '')}
${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>
</header>
<div class="post-body">${raw(body.html)}</div>
<div class="post-body">${raw(body.html)}</div>
${raw(gallery.length ? `<div class="gallery">${gallery.map((a) => figureHtml(a, { urlFor })).join('')}</div>` : '')}
${raw(gallery.length ? `<div class="gallery">${gallery.map((a) => figureHtml(a, { urlFor })).join('')}</div>` : '')}
@@ -51,6 +51,12 @@ const WORKFLOW = { id: 'blog.draft_post', version: 1, runId: 'run_01J8Z3K4M5N6P7
@@ -51,6 +51,12 @@ const WORKFLOW = { id: 'blog.draft_post', version: 1, runId: 'run_01J8Z3K4M5N6P7
const created = (await t.events('blog.post.created'))[0];
const created = (await t.events('blog.post.created'))[0];
assert.deepStrictEqual(created.actor, { type: 'service', id: 'ai' });
assert.deepStrictEqual(created.actor, { type: 'service', id: 'ai' });
assert.strictEqual(created.payload.authorship, '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 } });
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.status, 400);
assert.strictEqual(noWorkflow.json().code, 'authorship.workflow_required');
assert.strictEqual(noWorkflow.json().code, 'authorship.workflow_required');
new file mode 100644
new file mode 100644
@@ -0,0 +1,163 @@
@@ -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, /<script>/, '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 & 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();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,116 @@
@@ -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 & 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 & 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 & 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();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,76 @@
@@ -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();
+})();
@@ -119,7 +119,7 @@ async function startCommunity({ network }) {
@@ -119,7 +119,7 @@ async function startCommunity({ network }) {
const key = `${ref.service}:${ref.type}:${ref.id}`;
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 });
if (byRef.has(key)) return json(200, { thread: { id: byRef.get(key), visibility: threads.get(byRef.get(key)).visibility }, created: false });
const id = ++seq;
const id = ++seq;
- threads.set(id, { ref, visibility: 'public', comments: [] });
+ threads.set(id, { ref, visibility: 'public', comments: [], reactions: {} });
byRef.set(key, id);
byRef.set(key, id);
return json(201, { thread: { id, visibility: 'public' }, created: true });
return json(201, { thread: { id, visibility: 'public' }, created: true });
}
}
@@ -140,6 +140,37 @@ async function startCommunity({ network }) {
@@ -140,6 +140,37 @@ async function startCommunity({ network }) {
t.comments.push(comment);
t.comments.push(comment);
return json(201, { 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$/);
m = req.url.match(/^\/api\/v1\/comments\/threads\/(\d+)\/visibility$/);
if (m && req.method === 'PUT') {
if (m && req.method === 'PUT') {
const c = auth();
const c = auth();
@@ -72,7 +72,7 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');
@@ -72,7 +72,7 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');
const page = await t.get('/@alice/first-light');
const page = await t.get('/@alice/first-light');
assert.strictEqual(page.status, 200);
assert.strictEqual(page.status, 200);
const beforeScripts = page.text.split('<script src=')[0];
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(page.text, /Hello <strong>stars<\/strong>/);
assert.match(beforeScripts, /<link rel="canonical" href="https:\/\/openvibe\.blog\/@alice\/first-light">/);
assert.match(beforeScripts, /<link rel="canonical" href="https:\/\/openvibe\.blog\/@alice\/first-light">/);
assert.match(page.text, /<meta name="robots" content="index, follow">/);
assert.match(page.text, /<meta name="robots" content="index, follow">/);
@@ -116,10 +116,10 @@ const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');
@@ -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 () => {
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)' } });
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.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);
assert.strictEqual((await t.events('blog.post.updated')).length, 0);
await t.get(`/api/v1/posts/${post.id}/publish`, { as: alice, json: { revision: 3 } });
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.post.updated')).length, 1);
assert.strictEqual((await t.events('blog.index_document.upserted')).length, 2);
assert.strictEqual((await t.events('blog.index_document.upserted')).length, 2);
});
});
@@ -42,6 +42,8 @@ const base = {
@@ -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('<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(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 <x><\/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(/<a href="#three-x">Three <x><\/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 <x><\/a>/.test(html), 'the phone chips are real links, not escaped markup');
+ assert.ok(!html.includes('<a class="toc-chip"'), 'no escaped chip markup');
assert.ok(html.includes('≈ 3 min read'));
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('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 <b></span></a>') && html.includes('<a rel="next" href="/p37">'));
assert.ok(html.includes('<a rel="prev" href="/p35"><small>Previous</small><span>Part 35 <b></span></a>') && html.includes('<a rel="next" href="/p37">'));
@@ -104,6 +104,12 @@ const MED_B = 'med_01J8Z3K4M5N6P7Q8R9S0T1V2W4';
@@ -104,6 +104,12 @@ const MED_B = 'med_01J8Z3K4M5N6P7Q8R9S0T1V2W4';
assert.strictEqual(m.broken_reason, 'deleted');
assert.strictEqual(m.broken_reason, 'deleted');
assert.strictEqual(m.url, null);
assert.strictEqual(m.url, null);
assert.match((await t.get('/@dan/feed.json')).text, /no longer available/);
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 });
const editor = await t.get(`/write/posts/${post.id}`, { as: dan });
assert.match(editor.text, /unavailable \(deleted\)/);
assert.match(editor.text, /unavailable \(deleted\)/);
});
});