openvibers/openvibe.publishing Commits
1.6.0: the rich block model (openvibe-publishing/blocks) (#186)
Changes
@@@ -4,6 +4,42 @@ All notable changes to `openvibe-publishing`. Versions follow [semver](https://sbreaking change to any exported function, table layout, reason code or document shape is a newmajor (a minor while 0.x). A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball.++## 1.6.0 — 2026-11-09++++**Additive: `openvibe-publishing/blocks`.** No existing export, table layout, reason code or document shape++changes; a product on v1.5.x upgrades by pinning v1.6.0.++++### Added — `openvibe-publishing/blocks`++++The rich block model Blog and Wiki share: a post or an article body is Markdown plus blocks, and the++Markdown stays the source of truth (revisions diff it, and an editor that does not know a block still++shows its text).++++- `blocks.parse(markdown)` → a plain-JSON tree: `{ type: 'root', children: [...] }`, where a run of prose is++ `{ type: 'text', value }` and a block is `{ type, attrs, children }`. A block is a fenced directive,++ `:::name{attr="value"}` … `:::`; a one-line block closes itself. Blocks nest (a section holds prose and++ other blocks). A `:::` inside a fenced code block is prose, never a directive.++- `blocks.validate(tree)` → `{ ok, errors: [{ path, message }] }`: a missing required attribute, a Media++ object id that is not one, a section id that is not a slug or that two sections share, a callout tone++ outside info/warn/tip, an embed url that is not http(s).++- `blocks.render(tree, options)` → server-rendered HTML, complete without JavaScript. Every block has a++ plain-HTML form: a section's reactions are a `<form>` that posts (signed out, the reader is asked to++ sign in), the patch notes are a `<details>` grouped by site with every commit linked, the images are++ `<img>` with width, height and alt, and `loading="lazy"` after the first one. The caller resolves the++ Media ids (`media`, `urlFor`); the renderer never guesses a URL and a broken object renders as an++ explicit "no longer available" placeholder.++- `blocks.toText(tree)` → the plain text of a body: what a feed, a search document and an AI reader get.++- `seo.structuredData.article({ ..., hasPart })` → the sections of the article as WebPageElements, so a reader (and a crawler) sees the parts of a long post.++- `blocks.serialize(tree)` → the Markdown that parses back into the same tree: a caller who has blocks (an editor, an agent) hands Blog the Markdown it will keep, and the Markdown stays the source of truth.++- `blocks.creditLine(credit)` → the credit of an image: an AI image says "Made with <model>", an Openverse++ or Commons image keeps its licence and its author (both linked), and nothing known means no credit line++ — an empty credit is never a fake one.++- The blocks: hero, section, figure, compare, gallery, clip, pic, video, embed, callout, stat, patchnotes,++ infobox, timeline, quote.++- `render(tree, { renderText })`: a caller whose prose is not plain Markdown renders the text parts itself.++- `render()` is safe on its own: an embed whose URL is not http(s) is shown as text, never a link (a caller that skipped++ `validate()` could otherwise publish a `javascript:` link).++## 1.5.0 — 2026-11-02**Additive: `openvibe-publishing/search-relay`.** No existing export, table layout, reason code or document shape@@@ -3,7 +3,7 @@> Shared publishing packages for the OpenVibe publication products: Wiki, Blog, News, Reviews,> Deals, Coupons and Trade.--**Status:** alpha, **1.5.0**: the stores run on PostgreSQL through the `openvibe-sdk/db` async data++**Status:** alpha, **1.6.0**: the stores run on PostgreSQL through the `openvibe-sdk/db` async datalayer (ADR-035: PostgreSQL 18 behind PgBouncer in production, PGlite in tests). Every store method isasync, takes the product's `openvibe-sdk/db` handle, and accepts the caller's transaction handle; eachstore gives its DDL as `schema(prefix)` for the product's migrations. v1.1.0 adds the shared ingest@@@ -15,7 +15,8 @@ which puts a record product's pages in OpenVibe.Search (additive). v1.4.1 keepsseveral slashes (`//feed/`) on the site in `seo.canonicalUrl`, where it threw (a 500 on News, Deals and Blog). v1.5.0 adds`openvibe-publishing/search-relay`, which delivers the Search documents a product queued in its own outbox tablethrough Search's owner API (Meme, Exchange and Fun queue them; additive). The exit proof (`examples/two-products`) runs in `npm test`.--Releases v0.1.0 to v1.5.0 are tagged (see [CHANGELOG.md](CHANGELOG.md)). Fifteen products pin a release. The seven++v1.6.0 adds `openvibe-publishing/blocks`, the rich block model Blog and Wiki share: a body is Markdown plus++blocks, parsed, validated, rendered server-side and flattened to text (additive). Releases v0.1.0 to v1.6.0 are tagged (see [CHANGELOG.md](CHANGELOG.md)). Fifteen products pin a release. The sevenpublication products run on PostgreSQL and are public: openvibe.wiki, openvibe.blog, openvibe.news, openvibe.reviews,openvibe.deals, openvibe.coupons and openvibe.trade (on 2026-10-22 Blog, News and Deals pinned v1.4.1, the other fourv1.3.0). Seven more use `search-feed` to put their pages in OpenVibe.Search: Work, Rent, Help, Inventory, Quest, Host and@@@ -71,6 +72,7 @@ Every module is its own entry point and can be used alone.| `openvibe-publishing/index-hooks` | `search.index-document@1` documents and tombstones, the `<owner>.index_document.upserted\|deleted` events OpenVibe.Search consumes, a monotonic index-revision sequencer, and the product's own `<product>.<type>.published\|updated\|unpublished\|deleted` events | `<prefix>_index_revisions` || `openvibe-publishing/ai` | Ask OpenVibe.AI for a draft: run a registered workflow with the product's service token (polls a slow run), read its citations, get the `{ id, version, runId, model }` an AI authorship record needs; coded `AiRunError`s, never a partial draft | — || `openvibe-publishing/ssr` | auto-escaping `html` tagged templates with `raw()`, a safe Markdown subset, plain-text extraction, word count, server pagination, breadcrumbs, diff markup, honest `<time>` | — |++| `openvibe-publishing/blocks` | The rich block model Blog and Wiki share: a body is Markdown plus blocks (`:::name{attr="value"}` … `:::`); `parse`, `validate`, `render` (server-side HTML, every block a plain-HTML form, images as `<img>` with width/height/alt and lazy loading after the first, the credit line of the image) and `toText` for feeds, search and AI readers | — || `openvibe-publishing/ingest` | The shared ingest chassis: the OpenVibe.Sources client, a named change cursor and the `pullChanges` loop (per-item savepoint isolation, `{applied\|hold\|removed}`), the signed event consumer with an exactly-once inbox, the shared normalisers, and the generic PSL/registrable-host and freshness helpers | `<prefix>_ingest_cursor` || `openvibe-publishing/publication` | The shared publication glue: gate → document → `sequencer.stamp` → events → `outbox.enqueue` on the caller's transaction handle, tombstones on unpublish/merge, and the IndexNow ping; it emits exactly `search.index-document@1` and the product's events | — || `openvibe-publishing/search-feed` | Record pages (listings, job ads, help articles, catalog items) in OpenVibe.Search: `sync`/`remove` on the write's transaction, and a `sweep()` that re-sends only what changed and tombstones vanished rows | — |@@@ -5,7 +5,7 @@"wave": "W15","kind": "packages","package": "openvibe-publishing",-- "version": "1.5.0",++ "version": "1.6.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","runtime": null,"port": null,@@@ -23,6 +23,7 @@"index-hooks","ai","ssr",++ "blocks","ingest","publication","layout",@@@ -2,7 +2,7 @@/*** openvibe-publishing — every module is also its own entry point and can be used alone:* require('openvibe-publishing/revisions'), …/schedule, …/taxonomy, …/citations, …/media,-- * …/discussion, …/seo, …/authorship, …/index-hooks, …/ssr, …/ai, …/ingest, …/publication,++ * …/discussion, …/seo, …/authorship, …/index-hooks, …/ssr, …/blocks, …/ai, …/ingest, …/publication,* …/search-feed, …/search-relay, …/layout* This root export loads each one lazily, on first access.*@@@ -20,6 +20,7 @@ const MODULES = {authorship: './lib/authorship',indexHooks: './lib/index-hooks',ssr: './lib/ssr',++ blocks: './lib/blocks',ai: './lib/ai',ingest: './lib/ingest',publication: './lib/publication',nnew file mode 100644@@@ -0,0 +1,577 @@++'use strict';++/**++ * openvibe-publishing/blocks — the rich block model shared by OpenVibe.Blog and OpenVibe.Wiki.++ *++ * const blocks = require('openvibe-publishing/blocks');++ * const tree = blocks.parse(markdown); // → { type: 'root', children: [...] }++ * const check = blocks.validate(tree); // → { ok, errors: [{ path, message }] }++ * const html = blocks.render(tree, { ... }); // → server-rendered HTML, complete without script++ * const text = blocks.toText(tree); // → plain text for feeds, search and AI readers++ *++ * A BODY IS MARKDOWN PLUS BLOCKS. The Markdown stays the source of truth: revisions diff it, and an++ * editor that does not know a block still shows its text (toText). A block is a fenced directive:++ *++ * :::hero{art="med_..." kicker="Devblog" date="2026-11-09"}++ * The teaser goes here.++ * :::++ *++ * :::section{id="blueprint-rework" by="usr_..." react}++ * ## Blueprint rework++ * The prose, and the blocks inside it.++ * :::figure{src="med_..." alt="The channel header"}++ * :::++ * :::++ *++ * Rules:++ * - A block opens with :::name{attr="value"} and closes with ::: . A block with no body may be++ * written on one line: :::figure{src="med_..."}: the second ::: closes it.++ * - ":::" is reserved. Write a backslash before it in prose for a literal one.++ * - Attributes: key="value", key='value', key=bare (true), key=bare-word (the word).++ * - A block name is [a-z][a-z0-9_]*.++ * - Blocks nest: a section holds prose and other blocks.++ *++ * The renderer is server-side and complete without JavaScript. Every block has a plain-HTML form: a++ * section's reactions are a <form> that posts, the patch notes are a <details> a crawler reads, and++ * images are <img> with width, height, alt and loading="lazy" after the first one.++ *++ * Images are Media object ids (med_...). The renderer never guesses a URL: the caller resolves the++ * id and the credit line comes from what Media knows — an AI image says "Made with <model>", an++ * Openverse or Commons image keeps its licence and its author.++ */++++const { escapeHtml: esc, renderMarkdown, markdownToText } = require('./ssr');++++/** The blocks this model knows. A leaf block takes no body. */++const TYPES = Object.freeze({++ hero: { leaf: false, requires: [] },++ section: { leaf: false, requires: [] },++ figure: { leaf: true, requires: ['src'] },++ compare: { leaf: true, requires: ['before', 'after'] },++ gallery: { leaf: true, requires: ['items'] },++ clip: { leaf: true, requires: ['id'] },++ pic: { leaf: true, requires: ['id'] },++ video: { leaf: true, requires: ['id'] },++ embed: { leaf: true, requires: ['url'] },++ callout: { leaf: false, requires: ['tone'] },++ stat: { leaf: true, requires: ['value', 'label'] },++ patchnotes: { leaf: true, requires: [] },++ infobox: { leaf: false, requires: [] },++ timeline: { leaf: false, requires: [] },++ quote: { leaf: false, requires: [] },++});++++const TYPE_NAMES = Object.freeze(Object.keys(TYPES));++const CALLOUT_TONES = Object.freeze(['info', 'warn', 'tip']);++const MEDIA_ID_RE = /^med_[0-9A-HJKMNP-TV-Z]{26}$/;++const CLIP_ID_RE = /^(clp|vod)_[0-9A-HJKMNP-TV-Z]{26}$/;++const SUBJECT_RE = /^(usr|gst)_[0-9A-HJKMNP-TV-Z]{26}$/;++const SAFE_URL_RE = /^https?:\/\//i;++const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;++const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;++++/** { key: "value" }, { key: 'value' }, { key: bare } (true), { key=bare } (the bare word). */++function parseAttrs(source) {++ const attrs = {};++ const text = String(source == null ? '' : source);++ const re = /([a-z][a-z0-9_-]*)(?:=(?:"([^"]*)"|'([^']*)'|([^\s}]*)))?/g;++ let m;++ while ((m = re.exec(text))) {++ const value = m[2] !== undefined ? m[2] : (m[3] !== undefined ? m[3] : (m[4] !== undefined ? m[4] : true));++ attrs[m[1]] = value;++ }++ return attrs;++}++++/** The attribute text of a directive: the inside of its braces. */++function attrsOf(directive) {++ const i = directive.indexOf('{');++ const j = directive.lastIndexOf('}');++ return (i === -1 || j <= i) ? '' : directive.slice(i + 1, j);++}++++/**++ * The lines of a body, with the ::: inside a fenced code block hidden from the directive scanner.++ * The hidden marker is put back when the text is read, so a code sample still reads as written.++ */++const HIDDEN = '\u0000';++function maskedLines(source) {++ const lines = String(source == null ? '' : source).split('\n');++ const fence = /^( {0,3})(\x60{3,}|~{3,})/;++ let open = null;++ return lines.map((line) => {++ const m = line.match(fence);++ if (open) {++ if (m && m[2][0] === open[0] && m[2].length >= open.length) { open = null; return line.replace(/:::/g, HIDDEN); }++ return line.replace(/:::/g, HIDDEN); // inside a code block: never a directive++ }++ if (m) { open = m[2]; return line.replace(/:::/g, HIDDEN); }++ return line;++ });++}++++/** The hidden markers back in the text. */++function unhide(value) {++ return String(value == null ? '' : value).split(HIDDEN).join(':::');++}++++const DIRECTIVE_RE = /:::+\s*([a-z][a-z0-9_]*)?\s*(\{[^{}]*\})?/g;++++/**++ * Markdown plus blocks → a tree. The tree is plain JSON, so the API hands it out as it is:++ * { type: 'root', children: [ { type: 'section', attrs: {}, children: [ ... ] } ] }++ * A run of prose between blocks is { type: 'text', value: 'markdown' }.++ */++function parse(source) {++ const lines = maskedLines(source);++ const root = { type: 'root', children: [] };++ const stack = [root];++ let text = [];++++ const flush = () => {++ if (!text.length) return;++ const value = text.join('\n');++ text = [];++ if (value.trim()) stack[stack.length - 1].children.push({ type: 'text', value: unhide(value) });++ };++++ for (const line of lines) {++ if (line === null) { text.push(''); continue; }++ let cursor = 0;++ let matched = false;++ DIRECTIVE_RE.lastIndex = 0;++ let m;++ while ((m = DIRECTIVE_RE.exec(line))) {++ matched = true;++ const before = line.slice(cursor, m.index);++ if (before.trim()) { flush(); text.push(before); flush(); }++ cursor = m.index + m[0].length;++ const name = m[1];++ if (!name) {++ flush();++ if (stack.length > 1) stack.pop(); // a closing ::: ends the open block++ continue;++ }++ flush();++ const node = { type: name, attrs: parseAttrs(attrsOf(m[2] || '')), children: [] };++ stack[stack.length - 1].children.push(node);++ stack.push(node);++ }++ const tail = line.slice(cursor);++ if (!matched || tail.trim() || tail.length) text.push(tail);++ }++ flush();++ return root;++}++++/** Every node of the tree, depth first. */++function walk(node, visit) {++ visit(node);++ for (const child of node.children || []) walk(child, visit);++}++++/** A comma or space separated list of ids. */++function listOf(value) {++ if (Array.isArray(value)) return value.map((v) => String(v).trim()).filter(Boolean);++ if (value == null || value === true) return [];++ return String(value).split(/[\s,]+/).map((v) => v.trim()).filter(Boolean);++}++++/** The problems in a tree: { ok, errors: [{ path, message }] }. */++function validate(tree) {++ const errors = [];++ const seenIds = new Set();++ const at = (path, message) => errors.push({ path, message });++++ walk(tree, (node) => {++ if (node.type === 'root' || node.type === 'text') return;++ const def = TYPES[node.type];++ const path = node.type;++ if (!def) { at(path, 'unknown block type "' + node.type + '"'); return; }++ const a = node.attrs || {};++ for (const key of def.requires) {++ const v = a[key];++ if (v === undefined || v === null || v === '' || v === true) at(path, '"' + node.type + '" needs ' + key);++ }++ if (node.type === 'figure' && typeof a.src === 'string' && !MEDIA_ID_RE.test(a.src)) at(path, 'figure src must be a Media object id (med_...)');++ if (node.type === 'pic' && typeof a.id === 'string' && !MEDIA_ID_RE.test(a.id)) at(path, 'pic id must be a Media object id (med_...)');++ if ((node.type === 'clip' || node.type === 'video') && typeof a.id === 'string' && !CLIP_ID_RE.test(a.id)) at(path, node.type + ' id must be a clip or video id');++ if (node.type === 'embed' && typeof a.url === 'string' && !SAFE_URL_RE.test(a.url)) at(path, 'embed url must be http(s)');++ if (node.type === 'callout' && typeof a.tone === 'string' && !CALLOUT_TONES.includes(a.tone)) at(path, 'callout tone must be one of ' + CALLOUT_TONES.join(', '));++ if (node.type === 'hero' && typeof a.art === 'string' && !MEDIA_ID_RE.test(a.art)) at(path, 'hero art must be a Media object id (med_...)');++ if (node.type === 'section') {++ if (a.id !== undefined) {++ if (typeof a.id !== 'string' || !SLUG_RE.test(a.id)) at(path, 'section id must be a lowercase slug');++ else if (seenIds.has(a.id)) at(path, 'two sections share the id "' + a.id + '"');++ else seenIds.add(a.id);++ }++ if (a.by !== undefined && typeof a.by === 'string' && !SUBJECT_RE.test(a.by)) at(path, 'section by must be a subject id (usr_...)');++ }++ if (node.type === 'compare') {++ for (const key of ['before', 'after']) {++ if (typeof a[key] === 'string' && !MEDIA_ID_RE.test(a[key])) at(path, 'compare ' + key + ' must be a Media object id (med_...)');++ }++ }++ if (node.type === 'gallery') {++ const items = listOf(a.items);++ if (!items.length) at(path, 'gallery needs at least one Media object id');++ for (const id of items) if (!MEDIA_ID_RE.test(id)) at(path, 'gallery item "' + id + '" is not a Media object id (med_...)');++ }++ if (node.type === 'stat' && typeof a.value === 'string' && a.value.length > 40) at(path, 'stat value is too long');++ if (node.type === 'patchnotes') {++ for (const key of ['from', 'to']) {++ if (a[key] !== undefined && !DATE_RE.test(String(a[key]))) at(path, 'patchnotes ' + key + ' must be a date (YYYY-MM-DD)');++ }++ }++ });++ return { ok: errors.length === 0, errors };++}++++/** The plain text of a tree: what a feed, a search document and an AI reader get. */++function toText(tree) {++ const parts = [];++ walk(tree, (node) => {++ if (node.type === 'text') { parts.push(markdownToText(unhide(node.value))); return; }++ const a = node.attrs || {};++ if (node.type === 'hero' && a.kicker) parts.push(String(a.kicker));++ else if (node.type === 'figure' && a.alt) parts.push(String(a.alt));++ else if ((node.type === 'compare' || node.type === 'gallery') && a.caption) parts.push(String(a.caption));++ else if (node.type === 'stat') parts.push(String(a.value) + ' ' + String(a.label));++ else if (node.type === 'callout' && a.title) parts.push(String(a.title));++ else if (node.type === 'patchnotes') parts.push('Patch notes');++ });++ return parts.filter((p) => p && p.trim()).join('\n');++}++++module.exports = {++ TYPES, TYPE_NAMES, CALLOUT_TONES, MEDIA_ID_RE,++ parse, parseAttrs, validate, toText, walk, listOf,++};++++++// ---- render -----------------------------------------------------------------------------------++++/**++ * The credit line of an image.++ * { kind: 'ai', model: 'FLUX.1' } → "Made with FLUX.1"++ * { kind: 'source', author: 'Jane', licence: 'CC BY 4.0' } → "Jane · CC BY 4.0" (the author links)++ * { kind: 'text', text: 'Screenshot, openvibe.live' } → the text as it is++ * Nothing known → no credit line: an empty credit is not a fake one.++ */++function creditLine(credit, { rel = 'noopener' } = {}) {++ if (!credit || typeof credit !== 'object') return '';++ if (credit.kind === 'ai') return credit.model ? 'Made with ' + String(credit.model) : 'Made with AI';++ if (credit.kind === 'source') {++ const parts = [];++ if (credit.author) {++ parts.push(credit.authorUrl++ ? '<a href="' + esc(credit.authorUrl) + '" rel="' + esc(rel) + '">' + esc(credit.author) + '</a>'++ : esc(credit.author));++ }++ if (credit.licence) {++ parts.push(credit.licenceUrl++ ? '<a href="' + esc(credit.licenceUrl) + '" rel="' + esc(rel) + '">' + esc(credit.licence) + '</a>'++ : esc(credit.licence));++ }++ return parts.join(' · ');++ }++ if (credit.kind === 'text' && credit.text) return esc(credit.text);++ return '';++}++++/** The credit of one image: the block's own credit attribute wins, else what Media knows. */++function creditOf(node, media, opts) {++ const a = node.attrs || {};++ if (typeof a.credit === 'string' && a.credit) return { kind: 'text', text: a.credit };++ if (media && media.credit) return media.credit;++ return null;++}++++/** The dimensions of an image: the block's own width/height, else what Media knows. */++function dimsOf(node, media) {++ const a = node.attrs || {};++ const w = Number.isInteger(a.width) ? a.width : (media && Number.isInteger(media.width) ? media.width : null);++ const h = Number.isInteger(a.height) ? a.height : (media && Number.isInteger(media.height) ? media.height : null);++ return { width: w, height: h };++}++++function num(value) {++ const n = Number(value);++ return Number.isFinite(n) && n > 0 ? Math.round(n) : null;++}++++/**++ * The HTML of a tree.++ *++ * render(tree, {++ * media(mediaId) → { width, height, alt, credit } | null (what Media knows)++ * urlFor(mediaId) → the delivery URL (required for any image)++ * person(subject) → { name, url } | null (a section's byline)++ * patchNotes(from, to) → [{ site, commits: [{ sha, url, title }] }] (the folded list)++ * reactions → { sectionId: { up, down } } (the counts)++ * reactionAction → where the form posts (a section's reactions)++ * csrf → the form's CSRF token++ * signedIn → a person may react++ * loginUrl → where a signed-out reader signs in++ * rel → the link rel of editorial text++ * headingShift → how far the Markdown headings move (default 1)++ * renderText → (value, opts) → HTML, for a caller whose prose is not plain Markdown++ * })++ */++function render(tree, opts = {}) {++ const options = opts || {};++ const rel = options.rel || 'nofollow ugc noopener';++ const shift = Number.isInteger(options.headingShift) ? options.headingShift : 1;++ let images = 0;++++ const img = (node, mediaId, extraClass) => {++ const media = typeof options.media === 'function' ? options.media(mediaId) : null;++ if (!media || media.broken) {++ return '<figure class="ov-media ov-media-broken" data-media-id="' + esc(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 = typeof options.urlFor === 'function' ? options.urlFor(mediaId, media) : null;++ if (!url) {++ return '<figure class="ov-media ov-media-broken" data-media-id="' + esc(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 a = node.attrs || {};++ const dims = dimsOf(node, media);++ const alt = typeof a.alt === 'string' && a.alt ? a.alt : (media.alt || '');++ const lazy = images > 0 ? ' loading="lazy"' : '';++ images++;++ const width = num(a.width) || dims.width;++ const height = num(a.height) || dims.height;++ return '<figure class="ov-media ' + esc(extraClass || '') + '" data-media-id="' + esc(mediaId) + '" data-state="available">'++ + '<img src="' + esc(url) + '" alt="' + esc(alt) + '"'++ + (width ? ' width="' + width + '"' : '') + (height ? ' height="' + height + '"' : '')++ + lazy + ' decoding="async">'++ + '</figure>';++ };++++ const captionOf = (node, mediaId) => {++ const a = node.attrs || {};++ const media = typeof options.media === 'function' ? options.media(mediaId) : null;++ const caption = typeof a.caption === 'string' ? a.caption : (media && media.caption ? media.caption : null);++ const credit = creditOf(node, media, options);++ const creditHtml = creditLine(credit, { rel });++ if (!caption && !creditHtml) return '';++ return '<figcaption>' + (caption ? esc(caption) : '')++ + (caption && creditHtml ? ' ' : '')++ + (creditHtml ? '<span class="ov-media-credit">' + creditHtml + '</span>' : '')++ + '</figcaption>';++ };++++ const figureOf = (node, mediaId, extraClass) => {++ const body = img(node, mediaId, extraClass);++ const caption = captionOf(node, mediaId);++ if (!caption) return body;++ return body.replace('</figure>', caption + '</figure>');++ };++++ const reactionsForm = (sectionId) => {++ if (!sectionId) return '';++ const counts = (options.reactions && options.reactions[sectionId]) || { up: 0, down: 0 };++ const up = Number.isInteger(counts.up) ? counts.up : 0;++ const down = Number.isInteger(counts.down) ? counts.down : 0;++ const action = options.reactionAction || '';++ const signedIn = Boolean(options.signedIn);++ const csrf = options.csrf ? '<input type="hidden" name="_csrf" value="' + esc(options.csrf) + '">' : '';++ const buttons = '<button type="submit" name="reaction" value="up" class="ov-react ov-react-up"'++ + ' aria-label="This section is good">👍 <span class="ov-react-count">' + up + '</span></button>'++ + '<button type="submit" name="reaction" value="down" class="ov-react ov-react-down"'++ + ' aria-label="This section is not good">👎 <span class="ov-react-count">' + down + '</span></button>';++ if (!signedIn) {++ const login = options.loginUrl ? '/auth/login?next=' + encodeURIComponent(options.path || '') : '#';++ return '<div class="ov-reactions" data-section="' + esc(sectionId) + '">'++ + '<span class="ov-react-count">👍 ' + up + '</span>'++ + '<span class="ov-react-count">👎 ' + down + '</span>'++ + '<a class="ov-react-signin" href="' + esc(login) + '">Sign in to react</a></div>';++ }++ return '<form class="ov-reactions" method="post" action="' + esc(action) + '" data-section="' + esc(sectionId) + '">'++ + csrf++ + '<input type="hidden" name="section" value="' + esc(sectionId) + '">'++ + buttons++ + '</form>';++ };++++ const bylineOf = (node) => {++ const a = node.attrs || {};++ if (!a.by) return '';++ const person = typeof options.person === 'function' ? options.person(a.by) : null;++ const name = person && person.name ? person.name : 'The OpenVibe team';++ if (person && person.url) return '<p class="ov-section-by">by <a href="' + esc(person.url) + '" rel="author">' + esc(name) + '</a></p>';++ return '<p class="ov-section-by">by ' + esc(name) + '</p>';++ };++++ const patchNotesHtml = (node) => {++ const a = node.attrs || {};++ let groups = [];++ if (typeof options.patchNotes === 'function') {++ const got = options.patchNotes(a.from || null, a.to || null);++ if (Array.isArray(got)) groups = got;++ }++ const total = groups.reduce((n, g) => n + ((g.commits && g.commits.length) || 0), 0);++ const sites = groups.length;++ const label = total++ ? total + ' change' + (total === 1 ? '' : 's') + ' across ' + sites + ' site' + (sites === 1 ? '' : 's')++ : 'No changes in this window';++ const body = groups.map((g) => {++ const commits = (g.commits || []).map((c) => '<li><a href="' + esc(c.url || '#') + '" rel="noopener"><code>' + esc(c.sha || '') + '</code></a> '++ + esc(c.title || '') + '</li>').join('');++ return '<section class="ov-patchnotes-site"><h3>' + esc(g.site || '') + '</h3><ul>' + commits + '</ul></section>';++ }).join('');++ return '<details class="ov-patchnotes"><summary>' + esc(label) + '</summary>' + body + '</details>';++ };++++ // Inside a hero or a section the Markdown headings are the page's own: the post page owns the++ // h1 (the post's name), so a section's "## Heading" is its h2 and its body headings move with it.++ const inner = (node, headingShift) => (node.children || []).map((c) => one(c, headingShift)).join('');++++ const one = (node, headingShift = shift) => {++ const a = node.attrs || {};++ switch (node.type) {++ case 'text':++ return typeof options.renderText === 'function'++ ? options.renderText(unhide(node.value), { rel, headingShift })++ : renderMarkdown(unhide(node.value), { rel, headingShift });++ case 'hero': {++ const art = typeof a.art === 'string' ? a.art : null;++ const body = inner(node, Math.max(0, headingShift - 1));++ return '<header class="ov-hero"'++ + (a.kicker ? ' data-kicker="' + esc(a.kicker) + '"' : '') + '>'++ + (art ? figureOf(node, art, 'ov-hero-art') : '')++ + (a.kicker ? '<p class="ov-kicker">' + esc(a.kicker) + '</p>' : '')++ + (a.date ? '<p class="ov-hero-date"><time datetime="' + esc(a.date) + '">' + esc(a.date) + '</time></p>' : '')++ + '<div class="ov-hero-body">' + body + '</div>'++ + '</header>';++ }++ case 'section': {++ const id = typeof a.id === 'string' ? a.id : null;++ const body = inner(node, Math.max(0, headingShift - 1));++ const react = a.react !== undefined && a.react !== false;++ return '<section class="ov-section' + (id ? '' : ' ov-section-noid') + '"'++ + (id ? ' id="' + esc(id) + '" data-section-id="' + esc(id) + '"' : '') + '>'++ + bylineOf(node)++ + body++ + (react && id ? reactionsForm(id) : '')++ + '</section>';++ }++ case 'figure':++ return figureOf(node, a.src, 'ov-figure');++ case 'compare': {++ const before = img(node, a.before, 'ov-compare-before');++ const after = img(node, a.after, 'ov-compare-after');++ const caption = typeof a.caption === 'string' && a.caption++ ? '<figcaption>' + esc(a.caption) + '</figcaption>' : '';++ return '<figure class="ov-compare">'++ + '<div class="ov-compare-pair">' + before + after + '</div>'++ + caption + '</figure>';++ }++ case 'gallery': {++ const items = listOf(a.items);++ const figures = items.map((id) => figureOf({ type: 'figure', attrs: {}, children: [] }, id, 'ov-gallery-item')).join('');++ const caption = typeof a.caption === 'string' && a.caption++ ? '<figcaption>' + esc(a.caption) + '</figcaption>' : '';++ return '<figure class="ov-gallery"><div class="ov-gallery-grid">' + figures + '</div>' + caption + '</figure>';++ }++ case 'clip':++ return '<figure class="ov-clip" data-clip-id="' + esc(a.id || '') + '"><a href="/clips/' + esc(a.id || '') + '" rel="noopener">Clip</a></figure>';++ case 'pic':++ return figureOf(node, a.id, 'ov-pic');++ case 'video':++ return '<figure class="ov-video" data-video-id="' + esc(a.id || '') + '"><a href="/videos/' + esc(a.id || '') + '" rel="noopener">Video</a></figure>';++ case 'embed': {++ const url = typeof a.url === 'string' ? a.url.trim() : '';++ // A link only for http(s): render() is safe on its own, even for a caller that skipped validate()++ // (":::embed{url=\"javascript:…\"}" would otherwise be a clickable script).++ if (!SAFE_URL_RE.test(url)) return '<figure class="ov-embed"><span>' + esc(url) + '</span></figure>';++ return '<figure class="ov-embed"><a href="' + esc(url) + '" rel="noopener nofollow">' + esc(url) + '</a></figure>';++ }++ case 'callout': {++ const tone = CALLOUT_TONES.includes(a.tone) ? a.tone : 'info';++ const title = typeof a.title === 'string' ? '<p class="ov-callout-title">' + esc(a.title) + '</p>' : '';++ return '<aside class="ov-callout ov-callout-' + esc(tone) + '">' + title + inner(node, headingShift) + '</aside>';++ }++ case 'stat':++ return '<div class="ov-stat"><span class="ov-stat-value">' + esc(a.value) + '</span>'++ + '<span class="ov-stat-label">' + esc(a.label) + '</span></div>';++ case 'patchnotes':++ return patchNotesHtml(node);++ case 'infobox': {++ const rows = (node.children || []).filter((c) => c.type === 'text').map((c) => unhide(c.value).trim()).filter(Boolean);++ const items = rows.map((row) => {++ const at = row.indexOf(':');++ if (at === -1) return '<div class="ov-infobox-row"><span class="ov-infobox-value">' + esc(row) + '</span></div>';++ return '<div class="ov-infobox-row"><dt class="ov-infobox-key">' + esc(row.slice(0, at).trim()) + '</dt>'++ + '<dd class="ov-infobox-value">' + esc(row.slice(at + 1).trim()) + '</dd></div>';++ }).join('');++ const title = typeof a.title === 'string' ? '<h2 class="ov-infobox-title">' + esc(a.title) + '</h2>' : '';++ return '<aside class="ov-infobox">' + title + '<dl>' + items + '</dl></aside>';++ }++ case 'timeline': {++ const rows = (node.children || []).filter((c) => c.type === 'text').map((c) => unhide(c.value).trim()).filter(Boolean);++ const items = rows.map((row) => {++ const at = row.indexOf(':');++ if (at === -1) return '<li class="ov-timeline-item"><span class="ov-timeline-text">' + esc(row) + '</span></li>';++ return '<li class="ov-timeline-item"><time class="ov-timeline-when" datetime="' + esc(row.slice(0, at).trim()) + '">' + esc(row.slice(0, at).trim()) + '</time>'++ + '<span class="ov-timeline-text">' + esc(row.slice(at + 1).trim()) + '</span></li>';++ }).join('');++ const title = typeof a.title === 'string' ? '<h2 class="ov-timeline-title">' + esc(a.title) + '</h2>' : '';++ return '<div class="ov-timeline">' + title + '<ol>' + items + '</ol></div>';++ }++ case 'quote': {++ const body = inner(node, headingShift);++ const by = typeof a.by === 'string' && a.by ? '<footer class="ov-quote-by">— ' + esc(a.by) + '</footer>' : '';++ return '<blockquote class="ov-quote">' + body + by + '</blockquote>';++ }++ default:++ return '';++ }++ };++++ const root = tree && tree.type === 'root' ? tree : { type: 'root', children: [tree].filter(Boolean) };++ return (root.children || []).map((c) => one(c, shift)).join('');++}++++module.exports.render = render;++module.exports.creditLine = creditLine;++++++// ---- serialize ---------------------------------------------------------------------------------++++/** An attribute value as a directive writes it: a bare word, a quoted string, or a bare flag. */++function attrValue(value) {++ if (value === true) return null; // a bare flag: { react }++ const s = String(value);++ return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';++}++++/** The directive of a block: :::name{attr="value"}. */++function directive(node) {++ const parts = [];++ for (const [key, value] of Object.entries(node.attrs || {})) {++ const v = attrValue(value);++ parts.push(v === null ? key : key + '=' + v);++ }++ return ':::' + node.type + (parts.length ? '{' + parts.join(' ') + '}' : '');++}++++/**++ * A tree → the Markdown that parses back into it. The Markdown stays the source of truth, so a++ * caller who has blocks (an editor, an agent) can hand Blog the Markdown it will keep.++ * serialize(parse(markdown)) === markdown, for the Markdown the parser writes.++ */++function serialize(tree) {++ const root = tree && tree.type === 'root' ? tree : { type: 'root', children: [tree].filter(Boolean) };++ const out = [];++ for (const node of root.children || []) {++ if (node.type === 'text') { out.push(node.value); continue; }++ const body = serialize({ type: 'root', children: node.children || [] });++ out.push(body.trim() ? directive(node) + '\n' + body.replace(/\n+$/, '') + '\n:::' : directive(node) + ':::');++ }++ return out.join('\n').replace(/\n{3,}/g, '\n\n').trim() + '\n';++}++++module.exports.serialize = serialize;++module.exports.directive = directive;++@@@ -512,6 +512,20 @@ function citationList(list) {return (list || []).map((c) => (c && c.url ? { '@type': 'CreativeWork', url: c.url, name: c.title || undefined } : undefined));}++++/** The parts of an article: its sections. { url, name } each, as a WebPageElement list. */++function hasParts(parts) {++ if (!Array.isArray(parts) || !parts.length) return undefined;++ const out = parts++ .filter((p) => p && p.name)++ .map((p) => compact({++ '@type': 'WebPageElement',++ name: sharedSeo.clip(String(p.name), 110),++ url: p.url || undefined,++ }));++ return out.length ? out : undefined;++}++const structuredData = {/*** Article / BlogPosting / NewsArticle from what the page shows. Missing headline or url → null.@@@ -527,6 +541,7 @@ const structuredData = {dateModified: iso(a.dateModified, 'dateModified'), author: (a.authors || []).map(person), publisher: org(a.publisher),image: a.image, articleSection: a.section, keywords: a.keywords && a.keywords.length ? a.keywords.join(', ') : undefined,inLanguage: a.inLanguage, citation: citationList(a.citations), wordCount: Number.isInteger(a.wordCount) ? a.wordCount : undefined,++ hasPart: hasParts(a.hasPart),});},@@@ -1,6 +1,6 @@{"name": "openvibe-publishing",-- "version": "1.5.0",++ "version": "1.6.0","description": "Shared publishing packages for the OpenVibe publication products, on PostgreSQL through openvibe-sdk/db: revisions, scheduling, taxonomy, citations, media attachments, discussion references, the SEO indexability gate, authorship, index hooks, SSR helpers, the ingest chassis (Sources client, change cursor, signed event consumer, normalisers, PSL) and the publication glue. Packages, not an authority.","main": "index.js","exports": {@@@ -16,6 +16,7 @@"./authorship": "./lib/authorship.js","./index-hooks": "./lib/index-hooks.js","./ssr": "./lib/ssr.js",++ "./blocks": "./lib/blocks.js","./ai": "./lib/ai.js","./ingest": "./lib/ingest.js","./publication": "./lib/publication.js",nnew file mode 100644@@@ -0,0 +1,246 @@++'use strict';++const assert = require('assert');++const blocks = require('../lib/blocks');++const { suite } = require('./helpers/db');++++const { test, run } = suite();++++const MED = 'med_01J8ZQ4X3M000000000000000A';++const MED2 = 'med_01J8ZQ4X3M000000000000000B';++const clip = 'clp_01J8ZQ4X3M000000000000000C';++++test('a block directive parses into a tree; prose around it stays prose', () => {++ const tree = blocks.parse([++ 'Some intro.',++ '',++ ':::hero{art="' + MED + '" kicker="Devblog" date="2026-11-09"}',++ 'The teaser.',++ ':::',++ '',++ ':::section{id="blueprint-rework" by="usr_01J8ZQ4X3M000000000000000D" react}',++ '## Blueprint rework',++ 'The prose.',++ ':::figure{src="' + MED + '" alt="The header"}',++ ':::',++ ':::',++ ].join('\n'));++ assert.strictEqual(tree.type, 'root');++ const kinds = tree.children.map((c) => c.type);++ assert.deepStrictEqual(kinds, ['text', 'hero', 'section']);++ const hero = tree.children[1];++ assert.strictEqual(hero.attrs.art, MED);++ assert.strictEqual(hero.attrs.kicker, 'Devblog');++ assert.strictEqual(hero.attrs.date, '2026-11-09');++ assert.deepStrictEqual(hero.children.map((c) => c.type), ['text']);++ const section = tree.children[2];++ assert.strictEqual(section.attrs.id, 'blueprint-rework');++ assert.strictEqual(section.attrs.react, true);++ assert.deepStrictEqual(section.children.map((c) => c.type), ['text', 'figure']);++ assert.strictEqual(section.children[1].attrs.src, MED);++});++++test('a one-line block closes itself; attributes parse in every quoting', () => {++ const tree = blocks.parse([++ ':::figure{src="' + MED + '" alt="A"}',++ ':::',++ ':::stat{value=43 label="sites updated"}',++ ':::',++ ].join('\n'));++ assert.deepStrictEqual(tree.children.map((c) => c.type), ['figure', 'stat']);++ assert.strictEqual(tree.children[0].children.length, 0);++ assert.strictEqual(tree.children[1].attrs.value, '43');++ assert.strictEqual(tree.children[1].attrs.label, 'sites updated');++});++++test('a ::: inside a code fence is prose, not a directive', () => {++ const tree = blocks.parse(['```', ':::figure{src="x"}', ':::', '```', '', 'after.'].join('\n'));++ assert.deepStrictEqual(tree.children.map((c) => c.type), ['text']);++ assert.match(tree.children[0].value, /:::figure/);++});++++test('validate names what is missing and what is malformed', () => {++ const good = blocks.parse(':::figure{src="' + MED + '" alt="A"}\n:::');++ assert.deepStrictEqual(blocks.validate(good), { ok: true, errors: [] });++++ const bad = blocks.parse([++ ':::figure{src="not-a-media-id"}\n:::',++ ':::stat{value="1"}\n:::',++ ':::section{id="A B"}\n:::',++ ':::callout{tone="loud"}\n:::',++ ':::nope{}\n:::',++ ].join('\n'));++ const out = blocks.validate(bad);++ assert.strictEqual(out.ok, false);++ const messages = out.errors.map((e) => e.message);++ assert.ok(messages.some((m) => /figure src must be a Media object id/.test(m)), messages.join(' | '));++ assert.ok(messages.some((m) => /"stat" needs label/.test(m)), messages.join(' | '));++ assert.ok(messages.some((m) => /section id must be a lowercase slug/.test(m)), messages.join(' | '));++ assert.ok(messages.some((m) => /callout tone must be one of/.test(m)), messages.join(' | '));++ assert.ok(messages.some((m) => /unknown block type/.test(m)), messages.join(' | '));++});++++test('two sections may not share an id', () => {++ const tree = blocks.parse(':::section{id="a"}\nx\n:::\n:::section{id="a"}\ny\n:::');++ const out = blocks.validate(tree);++ assert.strictEqual(out.ok, false);++ assert.ok(out.errors.some((e) => /share the id/.test(e.message)));++});++++test('toText gives a feed and an AI reader the words, without the markup', () => {++ const tree = blocks.parse([++ 'The intro.',++ ':::figure{src="' + MED + '" alt="A lit stage"}\n:::',++ ':::stat{value="43" label="sites updated"}\n:::',++ ':::section{id="s"}\nSome **bold** words.\n:::',++ ].join('\n'));++ const text = blocks.toText(tree);++ assert.match(text, /The intro\./);++ assert.match(text, /A lit stage/);++ assert.match(text, /43 sites updated/);++ assert.match(text, /Some bold words\./);++ assert.doesNotMatch(text, /\*\*/);++});++++test('the renderer writes plain HTML: an image, a credit, a details, a form', () => {++ const tree = blocks.parse([++ ':::figure{src="' + MED + '" alt="The stage" credit="Screenshot, openvibe.live"}',++ ':::',++ ':::patchnotes{from="2026-11-02" to="2026-11-09"}',++ ':::',++ ':::section{id="blueprint-rework" react}',++ '## Blueprint rework',++ 'Prose.',++ ':::',++ ].join('\n'));++ const html = blocks.render(tree, {++ media: (id) => (id === MED ? { width: 1200, height: 630, alt: 'The stage' } : null),++ urlFor: (id) => 'https://media.openvibe.live/o/' + id,++ patchNotes: () => [{ site: 'openvibe.blog', commits: [{ sha: 'abc1234', url: 'https://git/commit/abc1234', title: 'Blueprint rework' }] }],++ reactions: { 'blueprint-rework': { up: 12, down: 1 } },++ reactionAction: '/p/post-1/react',++ csrf: 'tok',++ signedIn: true,++ });++ assert.match(html, /<img src="https:\/\/media\.openvibe\.live\/o\//);++ assert.match(html, /alt="The stage"/);++ assert.match(html, /width="1200"/);++ assert.match(html, /height="630"/);++ assert.match(html, /Screenshot, openvibe\.live/);++ assert.match(html, /<details class="ov-patchnotes"><summary>1 change across 1 site<\/summary>/);++ assert.match(html, /<code>abc1234<\/code>/);++ assert.match(html, /<form class="ov-reactions" method="post" action="\/p\/post-1\/react"/);++ assert.match(html, /name="section" value="blueprint-rework"/);++ assert.match(html, /👍 <span class="ov-react-count">12<\/span>/);++ assert.match(html, /👎 <span class="ov-react-count">1<\/span>/);++ assert.match(html, /<h2>Blueprint rework<\/h2>/);++ assert.doesNotMatch(html, /<script/);++});++++test('only the first image is eager; the rest are lazy', () => {++ const tree = blocks.parse([++ ':::figure{src="' + MED + '" alt="one"}\n:::',++ ':::figure{src="' + MED2 + '" alt="two"}\n:::',++ ].join('\n'));++ const html = blocks.render(tree, {++ media: () => ({ width: 800, height: 600 }),++ urlFor: (id) => 'https://media.openvibe.live/o/' + id,++ });++ assert.strictEqual((html.match(/loading="lazy"/g) || []).length, 1);++ assert.ok(html.indexOf('loading="lazy"') > html.indexOf('data-media-id="' + MED + '"'));++});++++test('a signed-out reader is asked to sign in; a broken media says so', () => {++ const tree = blocks.parse(':::figure{src="' + MED + '" alt="x"}\n:::\n:::section{id="s" react}\nx\n:::');++ const html = blocks.render(tree, {++ media: () => null,++ urlFor: () => 'https://media.openvibe.live/o/x',++ signedIn: false,++ loginUrl: '/auth/login?next=%2Fpost',++ });++ assert.match(html, /This media is no longer available\./);++ assert.match(html, /Sign in to react/);++ assert.doesNotMatch(html, /<form class="ov-reactions"/);++});++++test('an AI image says "Made with <model>"; a source image keeps its licence and author', () => {++ const ai = blocks.creditLine({ kind: 'ai', model: 'FLUX.1' });++ assert.strictEqual(ai, 'Made with FLUX.1');++ const src = blocks.creditLine({ kind: 'source', author: 'Jane Roe', licence: 'CC BY 4.0', authorUrl: 'https://commons.example/jane', licenceUrl: 'https://creativecommons.org/licenses/by/4.0/' });++ assert.match(src, /Jane Roe/);++ assert.match(src, /CC BY 4\.0/);++ assert.match(src, /<a href="https:\/\/commons\.example\/jane"/);++ assert.strictEqual(blocks.creditLine(null), '');++ assert.strictEqual(blocks.creditLine({ kind: 'text', text: 'Screenshot' }), 'Screenshot');++});++++test('an unknown block renders as nothing, and never as raw HTML', () => {++ const tree = blocks.parse(':::mystery{}\n:::');++ assert.strictEqual(blocks.render(tree, {}), '');++ const xss = blocks.parse(':::callout{tone="info"}\n<script>alert(1)</script>\n:::');++ const html = blocks.render(xss, {});++ assert.doesNotMatch(html, /<script>/);++ assert.match(html, /<script>/);++});++++test('the compare, gallery, callout, stat, quote, infobox and timeline blocks render', () => {++ const tree = blocks.parse([++ ':::compare{before="' + MED + '" after="' + MED2 + '" caption="Before and after"}\n:::',++ ':::gallery{items="' + MED + ',' + MED2 + '"}\n:::',++ ':::callout{tone="warn" title="Careful"}\nWatch out.\n:::',++ ':::stat{value="43" label="sites updated"}\n:::',++ ':::quote{by="Ada"}\nIt works.\n:::',++ ':::infobox{title="The facts"}\nReleased: 2026-11-09\n:::',++ ':::timeline{title="When"}\n2026-11-02: We started.\n:::',++ ].join('\n'));++ const html = blocks.render(tree, { media: () => ({ width: 100, height: 100 }), urlFor: (id) => '/o/' + id });++ assert.match(html, /ov-compare/);++ assert.match(html, /Before and after/);++ assert.match(html, /ov-gallery-grid/);++ assert.match(html, /ov-callout-warn/);++ assert.match(html, /Watch out\./);++ assert.match(html, /<span class="ov-stat-value">43<\/span>/);++ assert.match(html, /<blockquote class="ov-quote"/);++ assert.match(html, /— Ada/);++ assert.match(html, /ov-infobox-key">Released/);++ assert.match(html, /ov-timeline-when/);++});++++test('serialize writes the Markdown that parses back into the same tree', () => {++ const md = [++ 'Some intro.',++ '',++ ':::hero{art="' + MED + '" kicker="Devblog" date="2026-11-09"}',++ 'The teaser.',++ ':::',++ '',++ ':::section{id="blueprint-rework" by="usr_01J8ZQ4X3M000000000000000D" react}',++ '## Blueprint rework',++ ':::figure{src="' + MED + '" alt="A"}',++ ':::',++ ':::',++ ].join('\n');++ const tree = blocks.parse(md);++ const back = blocks.parse(blocks.serialize(tree));++ assert.deepStrictEqual(JSON.parse(JSON.stringify(back)), JSON.parse(JSON.stringify(tree)));++ assert.match(blocks.serialize(tree), /:::hero\{art="med_/);++ assert.match(blocks.serialize(tree), /:::section\{id="blueprint-rework"/);++ assert.match(blocks.serialize(tree), /react/);++});++++test('serialize quotes what needs quoting and writes a bare flag', () => {++ const tree = blocks.parse(':::section{id="a-b" by="usr_01J8ZQ4X3M000000000000000D" react}\nText with "quotes"\n:::');++ const md = blocks.serialize(tree);++ assert.match(md, /:::section\{id="a-b" by="usr_01J8ZQ4X3M000000000000000D" react\}/);++ assert.match(md, /Text with "quotes"/);++});++++run();++++// render() is safe on its own: an embed that is not http(s) is never a link (a caller may skip validate()).++{++ const b = require('../lib/blocks');++ const html = b.render(b.parse(':::embed{url="javascript:alert(1)"}\n:::\n'));++ require('assert').ok(!/href="javascript:/i.test(html), html);++ const ok = b.render(b.parse(':::embed{url="https://example.com/x"}\n:::\n'));++ require('assert').ok(/href="https:\/\/example.com\/x"/.test(ok), ok);++}@@@ -12,7 +12,7 @@ const SUBPATHS = Object.keys(pkg.exports).filter((k) => k !== '.' && k !== './patest('every subpath export resolves to an existing module', () => {assert.deepStrictEqual(SUBPATHS.map((s) => s.slice(2)).sort(),-- ['ai', 'authorship', 'citations', 'discussion', 'index-hooks', 'ingest', 'layout', 'media', 'publication', 'revisions', 'schedule', 'search-feed', 'search-relay', 'seo', 'ssr', 'taxonomy']);++ ['ai', 'authorship', 'blocks', 'citations', 'discussion', 'index-hooks', 'ingest', 'layout', 'media', 'publication', 'revisions', 'schedule', 'search-feed', 'search-relay', 'seo', 'ssr', 'taxonomy']);for (const s of SUBPATHS) assert.ok(require(`openvibe-publishing/${s.slice(2)}`), s);});@@@ -37,7 +37,7 @@ test('the root export is lazy and exposes every module', () => {assert.strictEqual(out.before, 0);assert.ok(out.after > 0);assert.strictEqual(out.v, pkg.version);-- assert.deepStrictEqual(out.names.sort(), ['PublishingError', 'ai', 'authorship', 'citations', 'discussion', 'indexHooks', 'ingest', 'layout', 'media', 'publication', 'revisions', 'schedule', 'schema', 'searchFeed', 'searchRelay', 'seo', 'ssr', 'taxonomy', 'version']);++ assert.deepStrictEqual(out.names.sort(), ['PublishingError', 'ai', 'authorship', 'blocks', 'citations', 'discussion', 'indexHooks', 'ingest', 'layout', 'media', 'publication', 'revisions', 'schedule', 'schema', 'searchFeed', 'searchRelay', 'seo', 'ssr', 'taxonomy', 'version']);});test('the package owns no runtime: no listen(), no database of its own, no env config in lib/', () => {
@@ -4,6 +4,42 @@ All notable changes to `openvibe-publishing`. Versions follow [semver](https://s
@@ -4,6 +4,42 @@ All notable changes to `openvibe-publishing`. Versions follow [semver](https://s
breaking change to any exported function, table layout, reason code or document shape is a new
breaking change to any exported function, table layout, reason code or document shape is a new
major (a minor while 0.x). A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball.
major (a minor while 0.x). A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball.
+## 1.6.0 — 2026-11-09
+
+**Additive: `openvibe-publishing/blocks`.** No existing export, table layout, reason code or document shape
+changes; a product on v1.5.x upgrades by pinning v1.6.0.
+
+### Added — `openvibe-publishing/blocks`
+
+The rich block model Blog and Wiki share: a post or an article body is Markdown plus blocks, and the
+Markdown stays the source of truth (revisions diff it, and an editor that does not know a block still
+shows its text).
+
+- `blocks.parse(markdown)` → a plain-JSON tree: `{ type: 'root', children: [...] }`, where a run of prose is
+ `{ type: 'text', value }` and a block is `{ type, attrs, children }`. A block is a fenced directive,
+ `:::name{attr="value"}` … `:::`; a one-line block closes itself. Blocks nest (a section holds prose and
+ other blocks). A `:::` inside a fenced code block is prose, never a directive.
+- `blocks.validate(tree)` → `{ ok, errors: [{ path, message }] }`: a missing required attribute, a Media
+ object id that is not one, a section id that is not a slug or that two sections share, a callout tone
+ outside info/warn/tip, an embed url that is not http(s).
+- `blocks.render(tree, options)` → server-rendered HTML, complete without JavaScript. Every block has a
+ plain-HTML form: a section's reactions are a `<form>` that posts (signed out, the reader is asked to
+ sign in), the patch notes are a `<details>` grouped by site with every commit linked, the images are
+ `<img>` with width, height and alt, and `loading="lazy"` after the first one. The caller resolves the
+ Media ids (`media`, `urlFor`); the renderer never guesses a URL and a broken object renders as an
+ explicit "no longer available" placeholder.
+- `blocks.toText(tree)` → the plain text of a body: what a feed, a search document and an AI reader get.
+- `seo.structuredData.article({ ..., hasPart })` → the sections of the article as WebPageElements, so a reader (and a crawler) sees the parts of a long post.
+- `blocks.serialize(tree)` → the Markdown that parses back into the same tree: a caller who has blocks (an editor, an agent) hands Blog the Markdown it will keep, and the Markdown stays the source of truth.
+- `blocks.creditLine(credit)` → the credit of an image: an AI image says "Made with <model>", an Openverse
+ or Commons image keeps its licence and its author (both linked), and nothing known means no credit line
+ — an empty credit is never a fake one.
+- The blocks: hero, section, figure, compare, gallery, clip, pic, video, embed, callout, stat, patchnotes,
+ infobox, timeline, quote.
+- `render(tree, { renderText })`: a caller whose prose is not plain Markdown renders the text parts itself.
+- `render()` is safe on its own: an embed whose URL is not http(s) is shown as text, never a link (a caller that skipped
+ `validate()` could otherwise publish a `javascript:` link).
+
## 1.5.0 — 2026-11-02
## 1.5.0 — 2026-11-02
**Additive: `openvibe-publishing/search-relay`.** No existing export, table layout, reason code or document shape
**Additive: `openvibe-publishing/search-relay`.** No existing export, table layout, reason code or document shape
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
> Shared publishing packages for the OpenVibe publication products: Wiki, Blog, News, Reviews,
> Shared publishing packages for the OpenVibe publication products: Wiki, Blog, News, Reviews,
> Deals, Coupons and Trade.
> Deals, Coupons and Trade.
-**Status:** alpha, **1.5.0**: the stores run on PostgreSQL through the `openvibe-sdk/db` async data
+**Status:** alpha, **1.6.0**: the stores run on PostgreSQL through the `openvibe-sdk/db` async data
layer (ADR-035: PostgreSQL 18 behind PgBouncer in production, PGlite in tests). Every store method is
layer (ADR-035: PostgreSQL 18 behind PgBouncer in production, PGlite in tests). Every store method is
async, takes the product's `openvibe-sdk/db` handle, and accepts the caller's transaction handle; each
async, takes the product's `openvibe-sdk/db` handle, and accepts the caller's transaction handle; each
store gives its DDL as `schema(prefix)` for the product's migrations. v1.1.0 adds the shared ingest
store gives its DDL as `schema(prefix)` for the product's migrations. v1.1.0 adds the shared ingest
@@ -15,7 +15,8 @@ which puts a record product's pages in OpenVibe.Search (additive). v1.4.1 keeps
@@ -15,7 +15,8 @@ which puts a record product's pages in OpenVibe.Search (additive). v1.4.1 keeps
several slashes (`//feed/`) on the site in `seo.canonicalUrl`, where it threw (a 500 on News, Deals and Blog). v1.5.0 adds
several slashes (`//feed/`) on the site in `seo.canonicalUrl`, where it threw (a 500 on News, Deals and Blog). v1.5.0 adds
`openvibe-publishing/search-relay`, which delivers the Search documents a product queued in its own outbox table
`openvibe-publishing/search-relay`, which delivers the Search documents a product queued in its own outbox table
through Search's owner API (Meme, Exchange and Fun queue them; additive). The exit proof (`examples/two-products`) runs in `npm test`.
through Search's owner API (Meme, Exchange and Fun queue them; additive). The exit proof (`examples/two-products`) runs in `npm test`.
-Releases v0.1.0 to v1.5.0 are tagged (see [CHANGELOG.md](CHANGELOG.md)). Fifteen products pin a release. The seven
+v1.6.0 adds `openvibe-publishing/blocks`, the rich block model Blog and Wiki share: a body is Markdown plus
+blocks, parsed, validated, rendered server-side and flattened to text (additive). Releases v0.1.0 to v1.6.0 are tagged (see [CHANGELOG.md](CHANGELOG.md)). Fifteen products pin a release. The seven
publication products run on PostgreSQL and are public: openvibe.wiki, openvibe.blog, openvibe.news, openvibe.reviews,
publication products run on PostgreSQL and are public: openvibe.wiki, openvibe.blog, openvibe.news, openvibe.reviews,
openvibe.deals, openvibe.coupons and openvibe.trade (on 2026-10-22 Blog, News and Deals pinned v1.4.1, the other four
openvibe.deals, openvibe.coupons and openvibe.trade (on 2026-10-22 Blog, News and Deals pinned v1.4.1, the other four
v1.3.0). Seven more use `search-feed` to put their pages in OpenVibe.Search: Work, Rent, Help, Inventory, Quest, Host and
v1.3.0). Seven more use `search-feed` to put their pages in OpenVibe.Search: Work, Rent, Help, Inventory, Quest, Host and
@@ -71,6 +72,7 @@ Every module is its own entry point and can be used alone.
@@ -71,6 +72,7 @@ Every module is its own entry point and can be used alone.
| `openvibe-publishing/index-hooks` | `search.index-document@1` documents and tombstones, the `<owner>.index_document.upserted\|deleted` events OpenVibe.Search consumes, a monotonic index-revision sequencer, and the product's own `<product>.<type>.published\|updated\|unpublished\|deleted` events | `<prefix>_index_revisions` |
| `openvibe-publishing/index-hooks` | `search.index-document@1` documents and tombstones, the `<owner>.index_document.upserted\|deleted` events OpenVibe.Search consumes, a monotonic index-revision sequencer, and the product's own `<product>.<type>.published\|updated\|unpublished\|deleted` events | `<prefix>_index_revisions` |
| `openvibe-publishing/ai` | Ask OpenVibe.AI for a draft: run a registered workflow with the product's service token (polls a slow run), read its citations, get the `{ id, version, runId, model }` an AI authorship record needs; coded `AiRunError`s, never a partial draft | — |
| `openvibe-publishing/ai` | Ask OpenVibe.AI for a draft: run a registered workflow with the product's service token (polls a slow run), read its citations, get the `{ id, version, runId, model }` an AI authorship record needs; coded `AiRunError`s, never a partial draft | — |
| `openvibe-publishing/ssr` | auto-escaping `html` tagged templates with `raw()`, a safe Markdown subset, plain-text extraction, word count, server pagination, breadcrumbs, diff markup, honest `<time>` | — |
| `openvibe-publishing/ssr` | auto-escaping `html` tagged templates with `raw()`, a safe Markdown subset, plain-text extraction, word count, server pagination, breadcrumbs, diff markup, honest `<time>` | — |
+| `openvibe-publishing/blocks` | The rich block model Blog and Wiki share: a body is Markdown plus blocks (`:::name{attr="value"}` … `:::`); `parse`, `validate`, `render` (server-side HTML, every block a plain-HTML form, images as `<img>` with width/height/alt and lazy loading after the first, the credit line of the image) and `toText` for feeds, search and AI readers | — |
| `openvibe-publishing/ingest` | The shared ingest chassis: the OpenVibe.Sources client, a named change cursor and the `pullChanges` loop (per-item savepoint isolation, `{applied\|hold\|removed}`), the signed event consumer with an exactly-once inbox, the shared normalisers, and the generic PSL/registrable-host and freshness helpers | `<prefix>_ingest_cursor` |
| `openvibe-publishing/ingest` | The shared ingest chassis: the OpenVibe.Sources client, a named change cursor and the `pullChanges` loop (per-item savepoint isolation, `{applied\|hold\|removed}`), the signed event consumer with an exactly-once inbox, the shared normalisers, and the generic PSL/registrable-host and freshness helpers | `<prefix>_ingest_cursor` |
| `openvibe-publishing/publication` | The shared publication glue: gate → document → `sequencer.stamp` → events → `outbox.enqueue` on the caller's transaction handle, tombstones on unpublish/merge, and the IndexNow ping; it emits exactly `search.index-document@1` and the product's events | — |
| `openvibe-publishing/publication` | The shared publication glue: gate → document → `sequencer.stamp` → events → `outbox.enqueue` on the caller's transaction handle, tombstones on unpublish/merge, and the IndexNow ping; it emits exactly `search.index-document@1` and the product's events | — |
| `openvibe-publishing/search-feed` | Record pages (listings, job ads, help articles, catalog items) in OpenVibe.Search: `sync`/`remove` on the write's transaction, and a `sweep()` that re-sends only what changed and tombstones vanished rows | — |
| `openvibe-publishing/search-feed` | Record pages (listings, job ads, help articles, catalog items) in OpenVibe.Search: `sync`/`remove` on the write's transaction, and a `sweep()` that re-sends only what changed and tombstones vanished rows | — |
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
"wave": "W15",
"wave": "W15",
"kind": "packages",
"kind": "packages",
"package": "openvibe-publishing",
"package": "openvibe-publishing",
- "version": "1.5.0",
+ "version": "1.6.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"runtime": null,
"runtime": null,
"port": null,
"port": null,
@@ -23,6 +23,7 @@
@@ -23,6 +23,7 @@
"index-hooks",
"index-hooks",
"ai",
"ai",
"ssr",
"ssr",
+ "blocks",
"ingest",
"ingest",
"publication",
"publication",
"layout",
"layout",
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
/**
/**
* openvibe-publishing — every module is also its own entry point and can be used alone:
* openvibe-publishing — every module is also its own entry point and can be used alone:
* require('openvibe-publishing/revisions'), …/schedule, …/taxonomy, …/citations, …/media,
* require('openvibe-publishing/revisions'), …/schedule, …/taxonomy, …/citations, …/media,
- * …/discussion, …/seo, …/authorship, …/index-hooks, …/ssr, …/ai, …/ingest, …/publication,
+ * …/discussion, …/seo, …/authorship, …/index-hooks, …/ssr, …/blocks, …/ai, …/ingest, …/publication,
* …/search-feed, …/search-relay, …/layout
* …/search-feed, …/search-relay, …/layout
* This root export loads each one lazily, on first access.
* This root export loads each one lazily, on first access.
*
*
@@ -20,6 +20,7 @@ const MODULES = {
@@ -20,6 +20,7 @@ const MODULES = {
authorship: './lib/authorship',
authorship: './lib/authorship',
indexHooks: './lib/index-hooks',
indexHooks: './lib/index-hooks',
ssr: './lib/ssr',
ssr: './lib/ssr',
+ blocks: './lib/blocks',
ai: './lib/ai',
ai: './lib/ai',
ingest: './lib/ingest',
ingest: './lib/ingest',
publication: './lib/publication',
publication: './lib/publication',
new file mode 100644
new file mode 100644
@@ -0,0 +1,577 @@
@@ -0,0 +1,577 @@
+'use strict';
+/**
+ * openvibe-publishing/blocks — the rich block model shared by OpenVibe.Blog and OpenVibe.Wiki.
+ *
+ * const blocks = require('openvibe-publishing/blocks');
+ * const tree = blocks.parse(markdown); // → { type: 'root', children: [...] }
+ * const check = blocks.validate(tree); // → { ok, errors: [{ path, message }] }
+ * const html = blocks.render(tree, { ... }); // → server-rendered HTML, complete without script
+ * const text = blocks.toText(tree); // → plain text for feeds, search and AI readers
+ *
+ * A BODY IS MARKDOWN PLUS BLOCKS. The Markdown stays the source of truth: revisions diff it, and an
+ * editor that does not know a block still shows its text (toText). A block is a fenced directive:
+ *
+ * :::hero{art="med_..." kicker="Devblog" date="2026-11-09"}
+ * The teaser goes here.
+ * :::
+ *
+ * :::section{id="blueprint-rework" by="usr_..." react}
+ * ## Blueprint rework
+ * The prose, and the blocks inside it.
+ * :::figure{src="med_..." alt="The channel header"}
+ * :::
+ * :::
+ *
+ * Rules:
+ * - A block opens with :::name{attr="value"} and closes with ::: . A block with no body may be
+ * written on one line: :::figure{src="med_..."}: the second ::: closes it.
+ * - ":::" is reserved. Write a backslash before it in prose for a literal one.
+ * - Attributes: key="value", key='value', key=bare (true), key=bare-word (the word).
+ * - A block name is [a-z][a-z0-9_]*.
+ * - Blocks nest: a section holds prose and other blocks.
+ *
+ * The renderer is server-side and complete without JavaScript. Every block has a plain-HTML form: a
+ * section's reactions are a <form> that posts, the patch notes are a <details> a crawler reads, and
+ * images are <img> with width, height, alt and loading="lazy" after the first one.
+ *
+ * Images are Media object ids (med_...). The renderer never guesses a URL: the caller resolves the
+ * id and the credit line comes from what Media knows — an AI image says "Made with <model>", an
+ * Openverse or Commons image keeps its licence and its author.
+ */
+
+const { escapeHtml: esc, renderMarkdown, markdownToText } = require('./ssr');
+
+/** The blocks this model knows. A leaf block takes no body. */
+const TYPES = Object.freeze({
+ hero: { leaf: false, requires: [] },
+ section: { leaf: false, requires: [] },
+ figure: { leaf: true, requires: ['src'] },
+ compare: { leaf: true, requires: ['before', 'after'] },
+ gallery: { leaf: true, requires: ['items'] },
+ clip: { leaf: true, requires: ['id'] },
+ pic: { leaf: true, requires: ['id'] },
+ video: { leaf: true, requires: ['id'] },
+ embed: { leaf: true, requires: ['url'] },
+ callout: { leaf: false, requires: ['tone'] },
+ stat: { leaf: true, requires: ['value', 'label'] },
+ patchnotes: { leaf: true, requires: [] },
+ infobox: { leaf: false, requires: [] },
+ timeline: { leaf: false, requires: [] },
+ quote: { leaf: false, requires: [] },
+});
+
+const TYPE_NAMES = Object.freeze(Object.keys(TYPES));
+const CALLOUT_TONES = Object.freeze(['info', 'warn', 'tip']);
+const MEDIA_ID_RE = /^med_[0-9A-HJKMNP-TV-Z]{26}$/;
+const CLIP_ID_RE = /^(clp|vod)_[0-9A-HJKMNP-TV-Z]{26}$/;
+const SUBJECT_RE = /^(usr|gst)_[0-9A-HJKMNP-TV-Z]{26}$/;
+const SAFE_URL_RE = /^https?:\/\//i;
+const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
+const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
+
+/** { key: "value" }, { key: 'value' }, { key: bare } (true), { key=bare } (the bare word). */
+function parseAttrs(source) {
+ const attrs = {};
+ const text = String(source == null ? '' : source);
+ const re = /([a-z][a-z0-9_-]*)(?:=(?:"([^"]*)"|'([^']*)'|([^\s}]*)))?/g;
+ let m;
+ while ((m = re.exec(text))) {
+ const value = m[2] !== undefined ? m[2] : (m[3] !== undefined ? m[3] : (m[4] !== undefined ? m[4] : true));
+ attrs[m[1]] = value;
+ }
+ return attrs;
+}
+
+/** The attribute text of a directive: the inside of its braces. */
+function attrsOf(directive) {
+ const i = directive.indexOf('{');
+ const j = directive.lastIndexOf('}');
+ return (i === -1 || j <= i) ? '' : directive.slice(i + 1, j);
+}
+
+/**
+ * The lines of a body, with the ::: inside a fenced code block hidden from the directive scanner.
+ * The hidden marker is put back when the text is read, so a code sample still reads as written.
+ */
+const HIDDEN = '\u0000';
+function maskedLines(source) {
+ const lines = String(source == null ? '' : source).split('\n');
+ const fence = /^( {0,3})(\x60{3,}|~{3,})/;
+ let open = null;
+ return lines.map((line) => {
+ const m = line.match(fence);
+ if (open) {
+ if (m && m[2][0] === open[0] && m[2].length >= open.length) { open = null; return line.replace(/:::/g, HIDDEN); }
+ return line.replace(/:::/g, HIDDEN); // inside a code block: never a directive
+ }
+ if (m) { open = m[2]; return line.replace(/:::/g, HIDDEN); }
+ return line;
+ });
+}
+
+/** The hidden markers back in the text. */
+function unhide(value) {
+ return String(value == null ? '' : value).split(HIDDEN).join(':::');
+}
+
+const DIRECTIVE_RE = /:::+\s*([a-z][a-z0-9_]*)?\s*(\{[^{}]*\})?/g;
+
+/**
+ * Markdown plus blocks → a tree. The tree is plain JSON, so the API hands it out as it is:
+ * { type: 'root', children: [ { type: 'section', attrs: {}, children: [ ... ] } ] }
+ * A run of prose between blocks is { type: 'text', value: 'markdown' }.
+ */
+function parse(source) {
+ const lines = maskedLines(source);
+ const root = { type: 'root', children: [] };
+ const stack = [root];
+ let text = [];
+
+ const flush = () => {
+ if (!text.length) return;
+ const value = text.join('\n');
+ text = [];
+ if (value.trim()) stack[stack.length - 1].children.push({ type: 'text', value: unhide(value) });
+ };
+
+ for (const line of lines) {
+ if (line === null) { text.push(''); continue; }
+ let cursor = 0;
+ let matched = false;
+ DIRECTIVE_RE.lastIndex = 0;
+ let m;
+ while ((m = DIRECTIVE_RE.exec(line))) {
+ matched = true;
+ const before = line.slice(cursor, m.index);
+ if (before.trim()) { flush(); text.push(before); flush(); }
+ cursor = m.index + m[0].length;
+ const name = m[1];
+ if (!name) {
+ flush();
+ if (stack.length > 1) stack.pop(); // a closing ::: ends the open block
+ continue;
+ }
+ flush();
+ const node = { type: name, attrs: parseAttrs(attrsOf(m[2] || '')), children: [] };
+ stack[stack.length - 1].children.push(node);
+ stack.push(node);
+ }
+ const tail = line.slice(cursor);
+ if (!matched || tail.trim() || tail.length) text.push(tail);
+ }
+ flush();
+ return root;
+}
+
+/** Every node of the tree, depth first. */
+function walk(node, visit) {
+ visit(node);
+ for (const child of node.children || []) walk(child, visit);
+}
+
+/** A comma or space separated list of ids. */
+function listOf(value) {
+ if (Array.isArray(value)) return value.map((v) => String(v).trim()).filter(Boolean);
+ if (value == null || value === true) return [];
+ return String(value).split(/[\s,]+/).map((v) => v.trim()).filter(Boolean);
+}
+
+/** The problems in a tree: { ok, errors: [{ path, message }] }. */
+function validate(tree) {
+ const errors = [];
+ const seenIds = new Set();
+ const at = (path, message) => errors.push({ path, message });
+
+ walk(tree, (node) => {
+ if (node.type === 'root' || node.type === 'text') return;
+ const def = TYPES[node.type];
+ const path = node.type;
+ if (!def) { at(path, 'unknown block type "' + node.type + '"'); return; }
+ const a = node.attrs || {};
+ for (const key of def.requires) {
+ const v = a[key];
+ if (v === undefined || v === null || v === '' || v === true) at(path, '"' + node.type + '" needs ' + key);
+ }
+ if (node.type === 'figure' && typeof a.src === 'string' && !MEDIA_ID_RE.test(a.src)) at(path, 'figure src must be a Media object id (med_...)');
+ if (node.type === 'pic' && typeof a.id === 'string' && !MEDIA_ID_RE.test(a.id)) at(path, 'pic id must be a Media object id (med_...)');
+ if ((node.type === 'clip' || node.type === 'video') && typeof a.id === 'string' && !CLIP_ID_RE.test(a.id)) at(path, node.type + ' id must be a clip or video id');
+ if (node.type === 'embed' && typeof a.url === 'string' && !SAFE_URL_RE.test(a.url)) at(path, 'embed url must be http(s)');
+ if (node.type === 'callout' && typeof a.tone === 'string' && !CALLOUT_TONES.includes(a.tone)) at(path, 'callout tone must be one of ' + CALLOUT_TONES.join(', '));
+ if (node.type === 'hero' && typeof a.art === 'string' && !MEDIA_ID_RE.test(a.art)) at(path, 'hero art must be a Media object id (med_...)');
+ if (node.type === 'section') {
+ if (a.id !== undefined) {
+ if (typeof a.id !== 'string' || !SLUG_RE.test(a.id)) at(path, 'section id must be a lowercase slug');
+ else if (seenIds.has(a.id)) at(path, 'two sections share the id "' + a.id + '"');
+ else seenIds.add(a.id);
+ }
+ if (a.by !== undefined && typeof a.by === 'string' && !SUBJECT_RE.test(a.by)) at(path, 'section by must be a subject id (usr_...)');
+ }
+ if (node.type === 'compare') {
+ for (const key of ['before', 'after']) {
+ if (typeof a[key] === 'string' && !MEDIA_ID_RE.test(a[key])) at(path, 'compare ' + key + ' must be a Media object id (med_...)');
+ }
+ }
+ if (node.type === 'gallery') {
+ const items = listOf(a.items);
+ if (!items.length) at(path, 'gallery needs at least one Media object id');
+ for (const id of items) if (!MEDIA_ID_RE.test(id)) at(path, 'gallery item "' + id + '" is not a Media object id (med_...)');
+ }
+ if (node.type === 'stat' && typeof a.value === 'string' && a.value.length > 40) at(path, 'stat value is too long');
+ if (node.type === 'patchnotes') {
+ for (const key of ['from', 'to']) {
+ if (a[key] !== undefined && !DATE_RE.test(String(a[key]))) at(path, 'patchnotes ' + key + ' must be a date (YYYY-MM-DD)');
+ }
+ }
+ });
+ return { ok: errors.length === 0, errors };
+}
+
+/** The plain text of a tree: what a feed, a search document and an AI reader get. */
+function toText(tree) {
+ const parts = [];
+ walk(tree, (node) => {
+ if (node.type === 'text') { parts.push(markdownToText(unhide(node.value))); return; }
+ const a = node.attrs || {};
+ if (node.type === 'hero' && a.kicker) parts.push(String(a.kicker));
+ else if (node.type === 'figure' && a.alt) parts.push(String(a.alt));
+ else if ((node.type === 'compare' || node.type === 'gallery') && a.caption) parts.push(String(a.caption));
+ else if (node.type === 'stat') parts.push(String(a.value) + ' ' + String(a.label));
+ else if (node.type === 'callout' && a.title) parts.push(String(a.title));
+ else if (node.type === 'patchnotes') parts.push('Patch notes');
+ });
+ return parts.filter((p) => p && p.trim()).join('\n');
+}
+
+module.exports = {
+ TYPES, TYPE_NAMES, CALLOUT_TONES, MEDIA_ID_RE,
+ parse, parseAttrs, validate, toText, walk, listOf,
+};
+
+
+// ---- render -----------------------------------------------------------------------------------
+
+/**
+ * The credit line of an image.
+ * { kind: 'ai', model: 'FLUX.1' } → "Made with FLUX.1"
+ * { kind: 'source', author: 'Jane', licence: 'CC BY 4.0' } → "Jane · CC BY 4.0" (the author links)
+ * { kind: 'text', text: 'Screenshot, openvibe.live' } → the text as it is
+ * Nothing known → no credit line: an empty credit is not a fake one.
+ */
+function creditLine(credit, { rel = 'noopener' } = {}) {
+ if (!credit || typeof credit !== 'object') return '';
+ if (credit.kind === 'ai') return credit.model ? 'Made with ' + String(credit.model) : 'Made with AI';
+ if (credit.kind === 'source') {
+ const parts = [];
+ if (credit.author) {
+ parts.push(credit.authorUrl
+ ? '<a href="' + esc(credit.authorUrl) + '" rel="' + esc(rel) + '">' + esc(credit.author) + '</a>'
+ : esc(credit.author));
+ }
+ if (credit.licence) {
+ parts.push(credit.licenceUrl
+ ? '<a href="' + esc(credit.licenceUrl) + '" rel="' + esc(rel) + '">' + esc(credit.licence) + '</a>'
+ : esc(credit.licence));
+ }
+ return parts.join(' · ');
+ }
+ if (credit.kind === 'text' && credit.text) return esc(credit.text);
+ return '';
+}
+
+/** The credit of one image: the block's own credit attribute wins, else what Media knows. */
+function creditOf(node, media, opts) {
+ const a = node.attrs || {};
+ if (typeof a.credit === 'string' && a.credit) return { kind: 'text', text: a.credit };
+ if (media && media.credit) return media.credit;
+ return null;
+}
+
+/** The dimensions of an image: the block's own width/height, else what Media knows. */
+function dimsOf(node, media) {
+ const a = node.attrs || {};
+ const w = Number.isInteger(a.width) ? a.width : (media && Number.isInteger(media.width) ? media.width : null);
+ const h = Number.isInteger(a.height) ? a.height : (media && Number.isInteger(media.height) ? media.height : null);
+ return { width: w, height: h };
+}
+
+function num(value) {
+ const n = Number(value);
+ return Number.isFinite(n) && n > 0 ? Math.round(n) : null;
+}
+
+/**
+ * The HTML of a tree.
+ *
+ * render(tree, {
+ * media(mediaId) → { width, height, alt, credit } | null (what Media knows)
+ * urlFor(mediaId) → the delivery URL (required for any image)
+ * person(subject) → { name, url } | null (a section's byline)
+ * patchNotes(from, to) → [{ site, commits: [{ sha, url, title }] }] (the folded list)
+ * reactions → { sectionId: { up, down } } (the counts)
+ * reactionAction → where the form posts (a section's reactions)
+ * csrf → the form's CSRF token
+ * signedIn → a person may react
+ * loginUrl → where a signed-out reader signs in
+ * rel → the link rel of editorial text
+ * headingShift → how far the Markdown headings move (default 1)
+ * renderText → (value, opts) → HTML, for a caller whose prose is not plain Markdown
+ * })
+ */
+function render(tree, opts = {}) {
+ const options = opts || {};
+ const rel = options.rel || 'nofollow ugc noopener';
+ const shift = Number.isInteger(options.headingShift) ? options.headingShift : 1;
+ let images = 0;
+
+ const img = (node, mediaId, extraClass) => {
+ const media = typeof options.media === 'function' ? options.media(mediaId) : null;
+ if (!media || media.broken) {
+ return '<figure class="ov-media ov-media-broken" data-media-id="' + esc(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 = typeof options.urlFor === 'function' ? options.urlFor(mediaId, media) : null;
+ if (!url) {
+ return '<figure class="ov-media ov-media-broken" data-media-id="' + esc(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 a = node.attrs || {};
+ const dims = dimsOf(node, media);
+ const alt = typeof a.alt === 'string' && a.alt ? a.alt : (media.alt || '');
+ const lazy = images > 0 ? ' loading="lazy"' : '';
+ images++;
+ const width = num(a.width) || dims.width;
+ const height = num(a.height) || dims.height;
+ return '<figure class="ov-media ' + esc(extraClass || '') + '" data-media-id="' + esc(mediaId) + '" data-state="available">'
+ + '<img src="' + esc(url) + '" alt="' + esc(alt) + '"'
+ + (width ? ' width="' + width + '"' : '') + (height ? ' height="' + height + '"' : '')
+ + lazy + ' decoding="async">'
+ + '</figure>';
+ };
+
+ const captionOf = (node, mediaId) => {
+ const a = node.attrs || {};
+ const media = typeof options.media === 'function' ? options.media(mediaId) : null;
+ const caption = typeof a.caption === 'string' ? a.caption : (media && media.caption ? media.caption : null);
+ const credit = creditOf(node, media, options);
+ const creditHtml = creditLine(credit, { rel });
+ if (!caption && !creditHtml) return '';
+ return '<figcaption>' + (caption ? esc(caption) : '')
+ + (caption && creditHtml ? ' ' : '')
+ + (creditHtml ? '<span class="ov-media-credit">' + creditHtml + '</span>' : '')
+ + '</figcaption>';
+ };
+
+ const figureOf = (node, mediaId, extraClass) => {
+ const body = img(node, mediaId, extraClass);
+ const caption = captionOf(node, mediaId);
+ if (!caption) return body;
+ return body.replace('</figure>', caption + '</figure>');
+ };
+
+ const reactionsForm = (sectionId) => {
+ if (!sectionId) return '';
+ const counts = (options.reactions && options.reactions[sectionId]) || { up: 0, down: 0 };
+ const up = Number.isInteger(counts.up) ? counts.up : 0;
+ const down = Number.isInteger(counts.down) ? counts.down : 0;
+ const action = options.reactionAction || '';
+ const signedIn = Boolean(options.signedIn);
+ const csrf = options.csrf ? '<input type="hidden" name="_csrf" value="' + esc(options.csrf) + '">' : '';
+ const buttons = '<button type="submit" name="reaction" value="up" class="ov-react ov-react-up"'
+ + ' aria-label="This section is good">👍 <span class="ov-react-count">' + up + '</span></button>'
+ + '<button type="submit" name="reaction" value="down" class="ov-react ov-react-down"'
+ + ' aria-label="This section is not good">👎 <span class="ov-react-count">' + down + '</span></button>';
+ if (!signedIn) {
+ const login = options.loginUrl ? '/auth/login?next=' + encodeURIComponent(options.path || '') : '#';
+ return '<div class="ov-reactions" data-section="' + esc(sectionId) + '">'
+ + '<span class="ov-react-count">👍 ' + up + '</span>'
+ + '<span class="ov-react-count">👎 ' + down + '</span>'
+ + '<a class="ov-react-signin" href="' + esc(login) + '">Sign in to react</a></div>';
+ }
+ return '<form class="ov-reactions" method="post" action="' + esc(action) + '" data-section="' + esc(sectionId) + '">'
+ + csrf
+ + '<input type="hidden" name="section" value="' + esc(sectionId) + '">'
+ + buttons
+ + '</form>';
+ };
+
+ const bylineOf = (node) => {
+ const a = node.attrs || {};
+ if (!a.by) return '';
+ const person = typeof options.person === 'function' ? options.person(a.by) : null;
+ const name = person && person.name ? person.name : 'The OpenVibe team';
+ if (person && person.url) return '<p class="ov-section-by">by <a href="' + esc(person.url) + '" rel="author">' + esc(name) + '</a></p>';
+ return '<p class="ov-section-by">by ' + esc(name) + '</p>';
+ };
+
+ const patchNotesHtml = (node) => {
+ const a = node.attrs || {};
+ let groups = [];
+ if (typeof options.patchNotes === 'function') {
+ const got = options.patchNotes(a.from || null, a.to || null);
+ if (Array.isArray(got)) groups = got;
+ }
+ const total = groups.reduce((n, g) => n + ((g.commits && g.commits.length) || 0), 0);
+ const sites = groups.length;
+ const label = total
+ ? total + ' change' + (total === 1 ? '' : 's') + ' across ' + sites + ' site' + (sites === 1 ? '' : 's')
+ : 'No changes in this window';
+ const body = groups.map((g) => {
+ const commits = (g.commits || []).map((c) => '<li><a href="' + esc(c.url || '#') + '" rel="noopener"><code>' + esc(c.sha || '') + '</code></a> '
+ + esc(c.title || '') + '</li>').join('');
+ return '<section class="ov-patchnotes-site"><h3>' + esc(g.site || '') + '</h3><ul>' + commits + '</ul></section>';
+ }).join('');
+ return '<details class="ov-patchnotes"><summary>' + esc(label) + '</summary>' + body + '</details>';
+ };
+
+ // Inside a hero or a section the Markdown headings are the page's own: the post page owns the
+ // h1 (the post's name), so a section's "## Heading" is its h2 and its body headings move with it.
+ const inner = (node, headingShift) => (node.children || []).map((c) => one(c, headingShift)).join('');
+
+ const one = (node, headingShift = shift) => {
+ const a = node.attrs || {};
+ switch (node.type) {
+ case 'text':
+ return typeof options.renderText === 'function'
+ ? options.renderText(unhide(node.value), { rel, headingShift })
+ : renderMarkdown(unhide(node.value), { rel, headingShift });
+ case 'hero': {
+ const art = typeof a.art === 'string' ? a.art : null;
+ const body = inner(node, Math.max(0, headingShift - 1));
+ return '<header class="ov-hero"'
+ + (a.kicker ? ' data-kicker="' + esc(a.kicker) + '"' : '') + '>'
+ + (art ? figureOf(node, art, 'ov-hero-art') : '')
+ + (a.kicker ? '<p class="ov-kicker">' + esc(a.kicker) + '</p>' : '')
+ + (a.date ? '<p class="ov-hero-date"><time datetime="' + esc(a.date) + '">' + esc(a.date) + '</time></p>' : '')
+ + '<div class="ov-hero-body">' + body + '</div>'
+ + '</header>';
+ }
+ case 'section': {
+ const id = typeof a.id === 'string' ? a.id : null;
+ const body = inner(node, Math.max(0, headingShift - 1));
+ const react = a.react !== undefined && a.react !== false;
+ return '<section class="ov-section' + (id ? '' : ' ov-section-noid') + '"'
+ + (id ? ' id="' + esc(id) + '" data-section-id="' + esc(id) + '"' : '') + '>'
+ + bylineOf(node)
+ + body
+ + (react && id ? reactionsForm(id) : '')
+ + '</section>';
+ }
+ case 'figure':
+ return figureOf(node, a.src, 'ov-figure');
+ case 'compare': {
+ const before = img(node, a.before, 'ov-compare-before');
+ const after = img(node, a.after, 'ov-compare-after');
+ const caption = typeof a.caption === 'string' && a.caption
+ ? '<figcaption>' + esc(a.caption) + '</figcaption>' : '';
+ return '<figure class="ov-compare">'
+ + '<div class="ov-compare-pair">' + before + after + '</div>'
+ + caption + '</figure>';
+ }
+ case 'gallery': {
+ const items = listOf(a.items);
+ const figures = items.map((id) => figureOf({ type: 'figure', attrs: {}, children: [] }, id, 'ov-gallery-item')).join('');
+ const caption = typeof a.caption === 'string' && a.caption
+ ? '<figcaption>' + esc(a.caption) + '</figcaption>' : '';
+ return '<figure class="ov-gallery"><div class="ov-gallery-grid">' + figures + '</div>' + caption + '</figure>';
+ }
+ case 'clip':
+ return '<figure class="ov-clip" data-clip-id="' + esc(a.id || '') + '"><a href="/clips/' + esc(a.id || '') + '" rel="noopener">Clip</a></figure>';
+ case 'pic':
+ return figureOf(node, a.id, 'ov-pic');
+ case 'video':
+ return '<figure class="ov-video" data-video-id="' + esc(a.id || '') + '"><a href="/videos/' + esc(a.id || '') + '" rel="noopener">Video</a></figure>';
+ case 'embed': {
+ const url = typeof a.url === 'string' ? a.url.trim() : '';
+ // A link only for http(s): render() is safe on its own, even for a caller that skipped validate()
+ // (":::embed{url=\"javascript:…\"}" would otherwise be a clickable script).
+ if (!SAFE_URL_RE.test(url)) return '<figure class="ov-embed"><span>' + esc(url) + '</span></figure>';
+ return '<figure class="ov-embed"><a href="' + esc(url) + '" rel="noopener nofollow">' + esc(url) + '</a></figure>';
+ }
+ case 'callout': {
+ const tone = CALLOUT_TONES.includes(a.tone) ? a.tone : 'info';
+ const title = typeof a.title === 'string' ? '<p class="ov-callout-title">' + esc(a.title) + '</p>' : '';
+ return '<aside class="ov-callout ov-callout-' + esc(tone) + '">' + title + inner(node, headingShift) + '</aside>';
+ }
+ case 'stat':
+ return '<div class="ov-stat"><span class="ov-stat-value">' + esc(a.value) + '</span>'
+ + '<span class="ov-stat-label">' + esc(a.label) + '</span></div>';
+ case 'patchnotes':
+ return patchNotesHtml(node);
+ case 'infobox': {
+ const rows = (node.children || []).filter((c) => c.type === 'text').map((c) => unhide(c.value).trim()).filter(Boolean);
+ const items = rows.map((row) => {
+ const at = row.indexOf(':');
+ if (at === -1) return '<div class="ov-infobox-row"><span class="ov-infobox-value">' + esc(row) + '</span></div>';
+ return '<div class="ov-infobox-row"><dt class="ov-infobox-key">' + esc(row.slice(0, at).trim()) + '</dt>'
+ + '<dd class="ov-infobox-value">' + esc(row.slice(at + 1).trim()) + '</dd></div>';
+ }).join('');
+ const title = typeof a.title === 'string' ? '<h2 class="ov-infobox-title">' + esc(a.title) + '</h2>' : '';
+ return '<aside class="ov-infobox">' + title + '<dl>' + items + '</dl></aside>';
+ }
+ case 'timeline': {
+ const rows = (node.children || []).filter((c) => c.type === 'text').map((c) => unhide(c.value).trim()).filter(Boolean);
+ const items = rows.map((row) => {
+ const at = row.indexOf(':');
+ if (at === -1) return '<li class="ov-timeline-item"><span class="ov-timeline-text">' + esc(row) + '</span></li>';
+ return '<li class="ov-timeline-item"><time class="ov-timeline-when" datetime="' + esc(row.slice(0, at).trim()) + '">' + esc(row.slice(0, at).trim()) + '</time>'
+ + '<span class="ov-timeline-text">' + esc(row.slice(at + 1).trim()) + '</span></li>';
+ }).join('');
+ const title = typeof a.title === 'string' ? '<h2 class="ov-timeline-title">' + esc(a.title) + '</h2>' : '';
+ return '<div class="ov-timeline">' + title + '<ol>' + items + '</ol></div>';
+ }
+ case 'quote': {
+ const body = inner(node, headingShift);
+ const by = typeof a.by === 'string' && a.by ? '<footer class="ov-quote-by">— ' + esc(a.by) + '</footer>' : '';
+ return '<blockquote class="ov-quote">' + body + by + '</blockquote>';
+ }
+ default:
+ return '';
+ }
+ };
+
+ const root = tree && tree.type === 'root' ? tree : { type: 'root', children: [tree].filter(Boolean) };
+ return (root.children || []).map((c) => one(c, shift)).join('');
+}
+
+module.exports.render = render;
+module.exports.creditLine = creditLine;
+
+
+// ---- serialize ---------------------------------------------------------------------------------
+
+/** An attribute value as a directive writes it: a bare word, a quoted string, or a bare flag. */
+function attrValue(value) {
+ if (value === true) return null; // a bare flag: { react }
+ const s = String(value);
+ return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
+}
+
+/** The directive of a block: :::name{attr="value"}. */
+function directive(node) {
+ const parts = [];
+ for (const [key, value] of Object.entries(node.attrs || {})) {
+ const v = attrValue(value);
+ parts.push(v === null ? key : key + '=' + v);
+ }
+ return ':::' + node.type + (parts.length ? '{' + parts.join(' ') + '}' : '');
+}
+
+/**
+ * A tree → the Markdown that parses back into it. The Markdown stays the source of truth, so a
+ * caller who has blocks (an editor, an agent) can hand Blog the Markdown it will keep.
+ * serialize(parse(markdown)) === markdown, for the Markdown the parser writes.
+ */
+function serialize(tree) {
+ const root = tree && tree.type === 'root' ? tree : { type: 'root', children: [tree].filter(Boolean) };
+ const out = [];
+ for (const node of root.children || []) {
+ if (node.type === 'text') { out.push(node.value); continue; }
+ const body = serialize({ type: 'root', children: node.children || [] });
+ out.push(body.trim() ? directive(node) + '\n' + body.replace(/\n+$/, '') + '\n:::' : directive(node) + ':::');
+ }
+ return out.join('\n').replace(/\n{3,}/g, '\n\n').trim() + '\n';
+}
+
+module.exports.serialize = serialize;
+module.exports.directive = directive;
+
@@ -512,6 +512,20 @@ function citationList(list) {
@@ -512,6 +512,20 @@ function citationList(list) {
return (list || []).map((c) => (c && c.url ? { '@type': 'CreativeWork', url: c.url, name: c.title || undefined } : undefined));
return (list || []).map((c) => (c && c.url ? { '@type': 'CreativeWork', url: c.url, name: c.title || undefined } : undefined));
}
}
+
+/** The parts of an article: its sections. { url, name } each, as a WebPageElement list. */
+function hasParts(parts) {
+ if (!Array.isArray(parts) || !parts.length) return undefined;
+ const out = parts
+ .filter((p) => p && p.name)
+ .map((p) => compact({
+ '@type': 'WebPageElement',
+ name: sharedSeo.clip(String(p.name), 110),
+ url: p.url || undefined,
+ }));
+ return out.length ? out : undefined;
+}
+
const structuredData = {
const structuredData = {
/**
/**
* Article / BlogPosting / NewsArticle from what the page shows. Missing headline or url → null.
* Article / BlogPosting / NewsArticle from what the page shows. Missing headline or url → null.
@@ -527,6 +541,7 @@ const structuredData = {
@@ -527,6 +541,7 @@ const structuredData = {
dateModified: iso(a.dateModified, 'dateModified'), author: (a.authors || []).map(person), publisher: org(a.publisher),
dateModified: iso(a.dateModified, 'dateModified'), author: (a.authors || []).map(person), publisher: org(a.publisher),
image: a.image, articleSection: a.section, keywords: a.keywords && a.keywords.length ? a.keywords.join(', ') : undefined,
image: a.image, articleSection: a.section, keywords: a.keywords && a.keywords.length ? a.keywords.join(', ') : undefined,
inLanguage: a.inLanguage, citation: citationList(a.citations), wordCount: Number.isInteger(a.wordCount) ? a.wordCount : undefined,
inLanguage: a.inLanguage, citation: citationList(a.citations), wordCount: Number.isInteger(a.wordCount) ? a.wordCount : undefined,
+ hasPart: hasParts(a.hasPart),
});
});
},
},
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-publishing",
"name": "openvibe-publishing",
- "version": "1.5.0",
+ "version": "1.6.0",
"description": "Shared publishing packages for the OpenVibe publication products, on PostgreSQL through openvibe-sdk/db: revisions, scheduling, taxonomy, citations, media attachments, discussion references, the SEO indexability gate, authorship, index hooks, SSR helpers, the ingest chassis (Sources client, change cursor, signed event consumer, normalisers, PSL) and the publication glue. Packages, not an authority.",
"description": "Shared publishing packages for the OpenVibe publication products, on PostgreSQL through openvibe-sdk/db: revisions, scheduling, taxonomy, citations, media attachments, discussion references, the SEO indexability gate, authorship, index hooks, SSR helpers, the ingest chassis (Sources client, change cursor, signed event consumer, normalisers, PSL) and the publication glue. Packages, not an authority.",
"main": "index.js",
"main": "index.js",
"exports": {
"exports": {
@@ -16,6 +16,7 @@
@@ -16,6 +16,7 @@
"./authorship": "./lib/authorship.js",
"./authorship": "./lib/authorship.js",
"./index-hooks": "./lib/index-hooks.js",
"./index-hooks": "./lib/index-hooks.js",
"./ssr": "./lib/ssr.js",
"./ssr": "./lib/ssr.js",
+ "./blocks": "./lib/blocks.js",
"./ai": "./lib/ai.js",
"./ai": "./lib/ai.js",
"./ingest": "./lib/ingest.js",
"./ingest": "./lib/ingest.js",
"./publication": "./lib/publication.js",
"./publication": "./lib/publication.js",
new file mode 100644
new file mode 100644
@@ -0,0 +1,246 @@
@@ -0,0 +1,246 @@
+'use strict';
+const assert = require('assert');
+const blocks = require('../lib/blocks');
+const { suite } = require('./helpers/db');
+
+const { test, run } = suite();
+
+const MED = 'med_01J8ZQ4X3M000000000000000A';
+const MED2 = 'med_01J8ZQ4X3M000000000000000B';
+const clip = 'clp_01J8ZQ4X3M000000000000000C';
+
+test('a block directive parses into a tree; prose around it stays prose', () => {
+ const tree = blocks.parse([
+ 'Some intro.',
+ '',
+ ':::hero{art="' + MED + '" kicker="Devblog" date="2026-11-09"}',
+ 'The teaser.',
+ ':::',
+ '',
+ ':::section{id="blueprint-rework" by="usr_01J8ZQ4X3M000000000000000D" react}',
+ '## Blueprint rework',
+ 'The prose.',
+ ':::figure{src="' + MED + '" alt="The header"}',
+ ':::',
+ ':::',
+ ].join('\n'));
+ assert.strictEqual(tree.type, 'root');
+ const kinds = tree.children.map((c) => c.type);
+ assert.deepStrictEqual(kinds, ['text', 'hero', 'section']);
+ const hero = tree.children[1];
+ assert.strictEqual(hero.attrs.art, MED);
+ assert.strictEqual(hero.attrs.kicker, 'Devblog');
+ assert.strictEqual(hero.attrs.date, '2026-11-09');
+ assert.deepStrictEqual(hero.children.map((c) => c.type), ['text']);
+ const section = tree.children[2];
+ assert.strictEqual(section.attrs.id, 'blueprint-rework');
+ assert.strictEqual(section.attrs.react, true);
+ assert.deepStrictEqual(section.children.map((c) => c.type), ['text', 'figure']);
+ assert.strictEqual(section.children[1].attrs.src, MED);
+});
+
+test('a one-line block closes itself; attributes parse in every quoting', () => {
+ const tree = blocks.parse([
+ ':::figure{src="' + MED + '" alt="A"}',
+ ':::',
+ ':::stat{value=43 label="sites updated"}',
+ ':::',
+ ].join('\n'));
+ assert.deepStrictEqual(tree.children.map((c) => c.type), ['figure', 'stat']);
+ assert.strictEqual(tree.children[0].children.length, 0);
+ assert.strictEqual(tree.children[1].attrs.value, '43');
+ assert.strictEqual(tree.children[1].attrs.label, 'sites updated');
+});
+
+test('a ::: inside a code fence is prose, not a directive', () => {
+ const tree = blocks.parse(['```', ':::figure{src="x"}', ':::', '```', '', 'after.'].join('\n'));
+ assert.deepStrictEqual(tree.children.map((c) => c.type), ['text']);
+ assert.match(tree.children[0].value, /:::figure/);
+});
+
+test('validate names what is missing and what is malformed', () => {
+ const good = blocks.parse(':::figure{src="' + MED + '" alt="A"}\n:::');
+ assert.deepStrictEqual(blocks.validate(good), { ok: true, errors: [] });
+
+ const bad = blocks.parse([
+ ':::figure{src="not-a-media-id"}\n:::',
+ ':::stat{value="1"}\n:::',
+ ':::section{id="A B"}\n:::',
+ ':::callout{tone="loud"}\n:::',
+ ':::nope{}\n:::',
+ ].join('\n'));
+ const out = blocks.validate(bad);
+ assert.strictEqual(out.ok, false);
+ const messages = out.errors.map((e) => e.message);
+ assert.ok(messages.some((m) => /figure src must be a Media object id/.test(m)), messages.join(' | '));
+ assert.ok(messages.some((m) => /"stat" needs label/.test(m)), messages.join(' | '));
+ assert.ok(messages.some((m) => /section id must be a lowercase slug/.test(m)), messages.join(' | '));
+ assert.ok(messages.some((m) => /callout tone must be one of/.test(m)), messages.join(' | '));
+ assert.ok(messages.some((m) => /unknown block type/.test(m)), messages.join(' | '));
+});
+
+test('two sections may not share an id', () => {
+ const tree = blocks.parse(':::section{id="a"}\nx\n:::\n:::section{id="a"}\ny\n:::');
+ const out = blocks.validate(tree);
+ assert.strictEqual(out.ok, false);
+ assert.ok(out.errors.some((e) => /share the id/.test(e.message)));
+});
+
+test('toText gives a feed and an AI reader the words, without the markup', () => {
+ const tree = blocks.parse([
+ 'The intro.',
+ ':::figure{src="' + MED + '" alt="A lit stage"}\n:::',
+ ':::stat{value="43" label="sites updated"}\n:::',
+ ':::section{id="s"}\nSome **bold** words.\n:::',
+ ].join('\n'));
+ const text = blocks.toText(tree);
+ assert.match(text, /The intro\./);
+ assert.match(text, /A lit stage/);
+ assert.match(text, /43 sites updated/);
+ assert.match(text, /Some bold words\./);
+ assert.doesNotMatch(text, /\*\*/);
+});
+
+test('the renderer writes plain HTML: an image, a credit, a details, a form', () => {
+ const tree = blocks.parse([
+ ':::figure{src="' + MED + '" alt="The stage" credit="Screenshot, openvibe.live"}',
+ ':::',
+ ':::patchnotes{from="2026-11-02" to="2026-11-09"}',
+ ':::',
+ ':::section{id="blueprint-rework" react}',
+ '## Blueprint rework',
+ 'Prose.',
+ ':::',
+ ].join('\n'));
+ const html = blocks.render(tree, {
+ media: (id) => (id === MED ? { width: 1200, height: 630, alt: 'The stage' } : null),
+ urlFor: (id) => 'https://media.openvibe.live/o/' + id,
+ patchNotes: () => [{ site: 'openvibe.blog', commits: [{ sha: 'abc1234', url: 'https://git/commit/abc1234', title: 'Blueprint rework' }] }],
+ reactions: { 'blueprint-rework': { up: 12, down: 1 } },
+ reactionAction: '/p/post-1/react',
+ csrf: 'tok',
+ signedIn: true,
+ });
+ assert.match(html, /<img src="https:\/\/media\.openvibe\.live\/o\//);
+ assert.match(html, /alt="The stage"/);
+ assert.match(html, /width="1200"/);
+ assert.match(html, /height="630"/);
+ assert.match(html, /Screenshot, openvibe\.live/);
+ assert.match(html, /<details class="ov-patchnotes"><summary>1 change across 1 site<\/summary>/);
+ assert.match(html, /<code>abc1234<\/code>/);
+ assert.match(html, /<form class="ov-reactions" method="post" action="\/p\/post-1\/react"/);
+ assert.match(html, /name="section" value="blueprint-rework"/);
+ assert.match(html, /👍 <span class="ov-react-count">12<\/span>/);
+ assert.match(html, /👎 <span class="ov-react-count">1<\/span>/);
+ assert.match(html, /<h2>Blueprint rework<\/h2>/);
+ assert.doesNotMatch(html, /<script/);
+});
+
+test('only the first image is eager; the rest are lazy', () => {
+ const tree = blocks.parse([
+ ':::figure{src="' + MED + '" alt="one"}\n:::',
+ ':::figure{src="' + MED2 + '" alt="two"}\n:::',
+ ].join('\n'));
+ const html = blocks.render(tree, {
+ media: () => ({ width: 800, height: 600 }),
+ urlFor: (id) => 'https://media.openvibe.live/o/' + id,
+ });
+ assert.strictEqual((html.match(/loading="lazy"/g) || []).length, 1);
+ assert.ok(html.indexOf('loading="lazy"') > html.indexOf('data-media-id="' + MED + '"'));
+});
+
+test('a signed-out reader is asked to sign in; a broken media says so', () => {
+ const tree = blocks.parse(':::figure{src="' + MED + '" alt="x"}\n:::\n:::section{id="s" react}\nx\n:::');
+ const html = blocks.render(tree, {
+ media: () => null,
+ urlFor: () => 'https://media.openvibe.live/o/x',
+ signedIn: false,
+ loginUrl: '/auth/login?next=%2Fpost',
+ });
+ assert.match(html, /This media is no longer available\./);
+ assert.match(html, /Sign in to react/);
+ assert.doesNotMatch(html, /<form class="ov-reactions"/);
+});
+
+test('an AI image says "Made with <model>"; a source image keeps its licence and author', () => {
+ const ai = blocks.creditLine({ kind: 'ai', model: 'FLUX.1' });
+ assert.strictEqual(ai, 'Made with FLUX.1');
+ const src = blocks.creditLine({ kind: 'source', author: 'Jane Roe', licence: 'CC BY 4.0', authorUrl: 'https://commons.example/jane', licenceUrl: 'https://creativecommons.org/licenses/by/4.0/' });
+ assert.match(src, /Jane Roe/);
+ assert.match(src, /CC BY 4\.0/);
+ assert.match(src, /<a href="https:\/\/commons\.example\/jane"/);
+ assert.strictEqual(blocks.creditLine(null), '');
+ assert.strictEqual(blocks.creditLine({ kind: 'text', text: 'Screenshot' }), 'Screenshot');
+});
+
+test('an unknown block renders as nothing, and never as raw HTML', () => {
+ const tree = blocks.parse(':::mystery{}\n:::');
+ assert.strictEqual(blocks.render(tree, {}), '');
+ const xss = blocks.parse(':::callout{tone="info"}\n<script>alert(1)</script>\n:::');
+ const html = blocks.render(xss, {});
+ assert.doesNotMatch(html, /<script>/);
+ assert.match(html, /<script>/);
+});
+
+test('the compare, gallery, callout, stat, quote, infobox and timeline blocks render', () => {
+ const tree = blocks.parse([
+ ':::compare{before="' + MED + '" after="' + MED2 + '" caption="Before and after"}\n:::',
+ ':::gallery{items="' + MED + ',' + MED2 + '"}\n:::',
+ ':::callout{tone="warn" title="Careful"}\nWatch out.\n:::',
+ ':::stat{value="43" label="sites updated"}\n:::',
+ ':::quote{by="Ada"}\nIt works.\n:::',
+ ':::infobox{title="The facts"}\nReleased: 2026-11-09\n:::',
+ ':::timeline{title="When"}\n2026-11-02: We started.\n:::',
+ ].join('\n'));
+ const html = blocks.render(tree, { media: () => ({ width: 100, height: 100 }), urlFor: (id) => '/o/' + id });
+ assert.match(html, /ov-compare/);
+ assert.match(html, /Before and after/);
+ assert.match(html, /ov-gallery-grid/);
+ assert.match(html, /ov-callout-warn/);
+ assert.match(html, /Watch out\./);
+ assert.match(html, /<span class="ov-stat-value">43<\/span>/);
+ assert.match(html, /<blockquote class="ov-quote"/);
+ assert.match(html, /— Ada/);
+ assert.match(html, /ov-infobox-key">Released/);
+ assert.match(html, /ov-timeline-when/);
+});
+
+test('serialize writes the Markdown that parses back into the same tree', () => {
+ const md = [
+ 'Some intro.',
+ '',
+ ':::hero{art="' + MED + '" kicker="Devblog" date="2026-11-09"}',
+ 'The teaser.',
+ ':::',
+ '',
+ ':::section{id="blueprint-rework" by="usr_01J8ZQ4X3M000000000000000D" react}',
+ '## Blueprint rework',
+ ':::figure{src="' + MED + '" alt="A"}',
+ ':::',
+ ':::',
+ ].join('\n');
+ const tree = blocks.parse(md);
+ const back = blocks.parse(blocks.serialize(tree));
+ assert.deepStrictEqual(JSON.parse(JSON.stringify(back)), JSON.parse(JSON.stringify(tree)));
+ assert.match(blocks.serialize(tree), /:::hero\{art="med_/);
+ assert.match(blocks.serialize(tree), /:::section\{id="blueprint-rework"/);
+ assert.match(blocks.serialize(tree), /react/);
+});
+
+test('serialize quotes what needs quoting and writes a bare flag', () => {
+ const tree = blocks.parse(':::section{id="a-b" by="usr_01J8ZQ4X3M000000000000000D" react}\nText with "quotes"\n:::');
+ const md = blocks.serialize(tree);
+ assert.match(md, /:::section\{id="a-b" by="usr_01J8ZQ4X3M000000000000000D" react\}/);
+ assert.match(md, /Text with "quotes"/);
+});
+
+run();
+
+// render() is safe on its own: an embed that is not http(s) is never a link (a caller may skip validate()).
+{
+ const b = require('../lib/blocks');
+ const html = b.render(b.parse(':::embed{url="javascript:alert(1)"}\n:::\n'));
+ require('assert').ok(!/href="javascript:/i.test(html), html);
+ const ok = b.render(b.parse(':::embed{url="https://example.com/x"}\n:::\n'));
+ require('assert').ok(/href="https:\/\/example.com\/x"/.test(ok), ok);
+}
@@ -12,7 +12,7 @@ const SUBPATHS = Object.keys(pkg.exports).filter((k) => k !== '.' && k !== './pa
@@ -12,7 +12,7 @@ const SUBPATHS = Object.keys(pkg.exports).filter((k) => k !== '.' && k !== './pa
test('every subpath export resolves to an existing module', () => {
test('every subpath export resolves to an existing module', () => {
assert.deepStrictEqual(SUBPATHS.map((s) => s.slice(2)).sort(),
assert.deepStrictEqual(SUBPATHS.map((s) => s.slice(2)).sort(),
- ['ai', 'authorship', 'citations', 'discussion', 'index-hooks', 'ingest', 'layout', 'media', 'publication', 'revisions', 'schedule', 'search-feed', 'search-relay', 'seo', 'ssr', 'taxonomy']);
+ ['ai', 'authorship', 'blocks', 'citations', 'discussion', 'index-hooks', 'ingest', 'layout', 'media', 'publication', 'revisions', 'schedule', 'search-feed', 'search-relay', 'seo', 'ssr', 'taxonomy']);
for (const s of SUBPATHS) assert.ok(require(`openvibe-publishing/${s.slice(2)}`), s);
for (const s of SUBPATHS) assert.ok(require(`openvibe-publishing/${s.slice(2)}`), s);
});
});
@@ -37,7 +37,7 @@ test('the root export is lazy and exposes every module', () => {
@@ -37,7 +37,7 @@ test('the root export is lazy and exposes every module', () => {
assert.strictEqual(out.before, 0);
assert.strictEqual(out.before, 0);
assert.ok(out.after > 0);
assert.ok(out.after > 0);
assert.strictEqual(out.v, pkg.version);
assert.strictEqual(out.v, pkg.version);
- assert.deepStrictEqual(out.names.sort(), ['PublishingError', 'ai', 'authorship', 'citations', 'discussion', 'indexHooks', 'ingest', 'layout', 'media', 'publication', 'revisions', 'schedule', 'schema', 'searchFeed', 'searchRelay', 'seo', 'ssr', 'taxonomy', 'version']);
+ assert.deepStrictEqual(out.names.sort(), ['PublishingError', 'ai', 'authorship', 'blocks', 'citations', 'discussion', 'indexHooks', 'ingest', 'layout', 'media', 'publication', 'revisions', 'schedule', 'schema', 'searchFeed', 'searchRelay', 'seo', 'ssr', 'taxonomy', 'version']);
});
});
test('the package owns no runtime: no listen(), no database of its own, no env config in lib/', () => {
test('the package owns no runtime: no listen(), no database of its own, no env config in lib/', () => {