openvibers/openvibe.publishing Commits
1.6.1: a timeline and an infobox have one row per line (#191)
Changes
@@@ -4,6 +4,12 @@ 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.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@@@ -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 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@@@ -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 tablethrough 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 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@@@ -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,@@@ -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>';@@@ -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",@@@ -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": {@@@ -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);++}++
@@ -4,6 +4,12 @@ All notable changes to `openvibe-publishing`. Versions follow [semver](https://s
@@ -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
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.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
## 1.6.0 — 2026-11-09
**Additive: `openvibe-publishing/blocks`.** No existing export, table layout, reason code or document shape
**Additive: `openvibe-publishing/blocks`.** 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.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
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
@@ -16,7 +16,7 @@ several slashes (`//feed/`) on the site in `seo.canonicalUrl`, where it threw (a
@@ -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
`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`.
v1.6.0 adds `openvibe-publishing/blocks`, the rich block model Blog and Wiki share: a body is Markdown plus
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,
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
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
"wave": "W15",
"wave": "W15",
"kind": "packages",
"kind": "packages",
"package": "openvibe-publishing",
"package": "openvibe-publishing",
- "version": "1.6.0",
+ "version": "1.6.1",
"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,
@@ -114,6 +114,23 @@ function unhide(value) {
@@ -114,6 +114,23 @@ function unhide(value) {
return String(value == null ? '' : value).split(HIDDEN).join(':::');
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;
const DIRECTIVE_RE = /:::+\s*([a-z][a-z0-9_]*)?\s*(\{[^{}]*\})?/g;
/**
/**
@@ -498,23 +515,22 @@ function render(tree, opts = {}) {
@@ -498,23 +515,22 @@ function render(tree, opts = {}) {
case 'patchnotes':
case 'patchnotes':
return patchNotesHtml(node);
return patchNotesHtml(node);
case 'infobox': {
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('');
}).join('');
const title = typeof a.title === 'string' ? '<h2 class="ov-infobox-title">' + esc(a.title) + '</h2>' : '';
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>';
return '<aside class="ov-infobox">' + title + '<dl>' + items + '</dl></aside>';
}
}
case 'timeline': {
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('');
}).join('');
const title = typeof a.title === 'string' ? '<h2 class="ov-timeline-title">' + esc(a.title) + '</h2>' : '';
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>';
return '<div class="ov-timeline">' + title + '<ol>' + items + '</ol></div>';
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-publishing",
"name": "openvibe-publishing",
- "version": "1.5.0",
+ "version": "1.6.1",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-publishing",
"name": "openvibe-publishing",
- "version": "1.5.0",
+ "version": "1.6.1",
"license": "MIT",
"license": "MIT",
"devDependencies": {
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"@electric-sql/pglite": "^0.5.8",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-publishing",
"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.",
"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": {
@@ -244,3 +244,17 @@ run();
@@ -244,3 +244,17 @@ run();
const ok = b.render(b.parse(':::embed{url="https://example.com/x"}\n:::\n'));
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);
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);
+}
+