openvibers/openvibe.publishing Commits

1.6.1: a timeline and an infobox have one row per line (#191)

OP OpenVibers committed 4 days ago · 495766c221 · 7 files, +54 −18

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 51590b8..1b429e9 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,12 @@ 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
major (a minor while 0.x). A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball.
++## 1.6.1 — 2026-11-09
++
++- `blocks`: a timeline and an infobox have one row per line (1.6.0 made a whole body one row) with list markers
++ dropped, and a row splits at the first ": " or " — ", so a time such as 10:45 stays whole. A timeline's `datetime`
++ is set only for a date or a time. An infobox row without a key is a `<dd>`, valid inside its `<dl>`.
++
## 1.6.0 — 2026-11-09
**Additive: `openvibe-publishing/blocks`.** No existing export, table layout, reason code or document shape
ddiff --git a/README.md b/README.md
iindex 6c64fb9..768541a 100644
---- a/README.md
++++ b/README.md
@@@ -3,7 +3,7 @@
> Shared publishing packages for the OpenVibe publication products: Wiki, Blog, News, Reviews,
> Deals, Coupons and Trade.
--**Status:** alpha, **1.6.0**: the stores run on PostgreSQL through the `openvibe-sdk/db` async data
++**Status:** alpha, **1.6.1**: 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
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
@@@ -16,7 +16,7 @@ several slashes (`//feed/`) on the site in `seo.canonicalUrl`, where it threw (a
`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`.
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
++blocks, parsed, validated, rendered server-side and flattened to text (additive). Releases v0.1.0 to v1.6.1 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,
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
ddiff --git a/STATUS.json b/STATUS.json
iindex df79a88..2f50799 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -5,7 +5,7 @@
"wave": "W15",
"kind": "packages",
"package": "openvibe-publishing",
-- "version": "1.6.0",
++ "version": "1.6.1",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"runtime": null,
"port": null,
ddiff --git a/lib/blocks.js b/lib/blocks.js
iindex 0d58d6f..0e3a246 100644
---- a/lib/blocks.js
++++ b/lib/blocks.js
@@@ -114,6 +114,23 @@ function unhide(value) {
return String(value == null ? '' : value).split(HIDDEN).join(':::');
}
++/**
++ * A timeline's or an infobox's rows: one per line (the body is one text node), list markers off. "when: text" splits at
++ * the first ": " or " — ", so a time such as 10:45 stays whole (1.6.1; 1.6.0 made a whole timeline one row, split at
++ * the first colon).
++ */
++function rowsOf(node) {
++ return (node.children || []).filter((c) => c.type === 'text')
++ .flatMap((c) => unhide(c.value).split('\n'))
++ .map((line) => line.replace(/^\s*(?:[-*+]|\d+[.)])\s+/, '').trim())
++ .filter(Boolean);
++}
++function splitRow(row) {
++ const m = /^(.+?)(?::\s+|\s+—\s+)(.+)$/.exec(row);
++ return m ? [m[1].trim(), m[2].trim()] : null;
++}
++const WHEN_RE = /^(\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?)?|\d{1,2}:\d{2})$/;
++
const DIRECTIVE_RE = /:::+\s*([a-z][a-z0-9_]*)?\s*(\{[^{}]*\})?/g;
/**
@@@ -498,23 +515,22 @@ function render(tree, opts = {}) {
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>';
++ const items = rowsOf(node).map((row) => {
++ const kv = splitRow(row);
++ if (!kv) return '<div class="ov-infobox-row"><dd class="ov-infobox-value">' + esc(row) + '</dd></div>';
++ return '<div class="ov-infobox-row"><dt class="ov-infobox-key">' + esc(kv[0]) + '</dt>'
++ + '<dd class="ov-infobox-value">' + esc(kv[1]) + '</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>';
++ const items = rowsOf(node).map((row) => {
++ const kv = splitRow(row);
++ if (!kv) return '<li class="ov-timeline-item"><span class="ov-timeline-text">' + esc(row) + '</span></li>';
++ const when = WHEN_RE.test(kv[0]) ? ' datetime="' + esc(kv[0]) + '"' : '';
++ return '<li class="ov-timeline-item"><time class="ov-timeline-when"' + when + '>' + esc(kv[0]) + '</time>'
++ + '<span class="ov-timeline-text">' + esc(kv[1]) + '</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>';
ddiff --git a/package-lock.json b/package-lock.json
iindex 8b93946..6b61491 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-publishing",
-- "version": "1.5.0",
++ "version": "1.6.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-publishing",
-- "version": "1.5.0",
++ "version": "1.6.1",
"license": "MIT",
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
ddiff --git a/package.json b/package.json
iindex e8d2e8c..5f85e73 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-publishing",
-- "version": "1.6.0",
++ "version": "1.6.1",
"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": {
ddiff --git a/test/blocks.test.js b/test/blocks.test.js
iindex 7640d88..f0af669 100644
---- a/test/blocks.test.js
++++ b/test/blocks.test.js
@@@ -244,3 +244,17 @@ run();
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);
}
++
++// 1.6.1: a timeline and an infobox have one row per line; "10:45: text" keeps the time whole; list markers go.
++{
++ const b = require('../lib/blocks');
++ const assert = require('assert');
++ const tl = b.render(b.parse(':::timeline\n- 10:45: Stage two is live\n- 2026-11-09: Shared 4.168\nA note without a time\n:::\n'));
++ assert.strictEqual((tl.match(/<li class="ov-timeline-item">/g) || []).length, 3, tl);
++ assert.ok(tl.includes('<time class="ov-timeline-when" datetime="10:45">10:45</time><span class="ov-timeline-text">Stage two is live</span>'), tl);
++ assert.ok(tl.includes('datetime="2026-11-09"'), tl);
++ const ib = b.render(b.parse(':::infobox{title="Release"}\nVersion: 4.168.0\nShipped — 9 November\n:::\n'));
++ assert.strictEqual((ib.match(/<div class="ov-infobox-row">/g) || []).length, 2, ib);
++ assert.ok(ib.includes('<dt class="ov-infobox-key">Shipped</dt><dd class="ov-infobox-value">9 November</dd>'), ib);
++}
++