openvibers/openvibe.publishing Commits
Add layout.renderDocument over shell.page and release 1.2.0
Changes
@@@ -4,6 +4,30 @@ 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.2.0 — 2026-10-04++++**Additive: one new entry point, `openvibe-publishing/layout`.** No existing export, table layout, reason++code or document shape changes; a product on v1.1.0 upgrades by pinning v1.2.0.++`require('openvibe-publishing')` exposes `.layout` lazily.++++### Added — `openvibe-publishing/layout`++++- `renderDocument(o)` — the page document the seven publication sites (Wiki, Blog, News, Reviews, Deals,++ Coupons, Trade) each hand-wrote, composed by `openvibe-shared/shell` `page()` (T11 shell adoption).++ The gate's decision stays the robots source: `robots` is `decision.robots` and the canonical is++ `decision.canonical || o.canonical`; without a decision (or an explicit `robots`) it throws a++ `TypeError`, so no page is ever indexable by default. It adds the publication extras to the shell's++ head (article published/modified times, prev/next, feed links, the app icon, the site stylesheet,++ shared stylesheets, the site's scripts, the boost marker, referrer, extra head markup), the body frame++ (skip link, `#navbar-mount`, header, the no-JavaScript account bar, `<main>`) and the footer init the++ shell never emits (`window.__OV_PAGE` + `OpenVibeFooter.init`). Every value reaching markup is escaped;++ the boot JSON escapes `<`. Styling is unchanged. Needs `openvibe-shared` ≥ 2.6.0 (`shell`); the other++ entry points keep the `>=1.5.0` peer range.++++### Changed++++- Tests pin `openvibe-shared` v2.6.0 (was v2.5.0).++## 1.1.0 — 2026-09-29**Additive: two new entry points, `openvibe-publishing/ingest` and `openvibe-publishing/publication`.**@@@ -3,12 +3,13 @@> Shared publishing packages for the OpenVibe publication products: Wiki, Blog, News, Reviews,> Deals, Coupons and Trade.--**Status:** alpha, **1.1.0**: the stores run on PostgreSQL through the `openvibe-sdk/db` async data++**Status:** alpha, **1.2.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 ingestchassis (`openvibe-publishing/ingest`) and publication glue (`openvibe-publishing/publication`) —--additive, no existing export changed. The exit proof (`examples/two-products`) runs in `npm test`.++additive, no existing export changed. v1.2.0 adds `openvibe-publishing/layout`, the page document the++seven sites render through `openvibe-shared/shell` (additive). The exit proof (`examples/two-products`) runs in `npm test`.Releases v0.1.0 to v1.0.0 are tagged (see [CHANGELOG.md](CHANGELOG.md)). All seven products (Wiki andBlog, public at openvibe.wiki and openvibe.blog; News, Reviews, Deals, Coupons and Trade, deployedloopback-only, not launched) run v1.0.0 on PostgreSQL today; the five content products move to v1.1.0 as@@@ -65,16 +66,17 @@ Every module is its own entry point and can be used alone.| `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/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/layout` | `renderDocument(o)`: the whole page through `openvibe-shared/shell` `page()` (≥ 2.6.0), with robots and canonical from the gate's decision (throws without one), article times, prev/next, feeds, the app icon, stylesheets, the boost marker, the body frame and the footer init | — |`require('openvibe-publishing')` exposes all of them lazily (`.revisions`, `.seo`, `.indexHooks`,--`.ingest`, `.publication`, …), plus `schema({ … })`, the DDL of several stores at once (below).++`.ingest`, `.publication`, `.layout`, …), plus `schema({ … })`, the DDL of several stores at once (below).## InstallPin the release tarball, like every OpenVibe package (never a `file:` link or a vendored copy):```json--"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.1.0",++"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.2.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.15.0","pg": "^8.23.0"```@@@ -5,7 +5,7 @@"wave": "W15","kind": "packages","package": "openvibe-publishing",-- "version": "1.1.0",++ "version": "1.2.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","runtime": null,"port": null,@@@ -24,7 +24,8 @@"ai","ssr","ingest",-- "publication"++ "publication",++ "layout"],"consumers": ["OpenVibe.Wiki (v1.1.0 on PostgreSQL; public at https://openvibe.wiki)",@@@ -40,10 +41,10 @@"contracts": "openvibe-contracts v0.76.0 (devDependency): emits search.index-document@1 and events.event-envelope@1 (validated in tests)","dependsOn": ["openvibe-sdk >=0.15.0 (peer; tests pin v0.15.0)",-- "openvibe-shared >=1.5.0 (optional peer; tests pin v1.25.0)",++ "openvibe-shared >=1.5.0 (optional peer; tests pin v2.6.0; layout needs >=2.6.0)","PostgreSQL 18 through PgBouncer (the product's database)"],-- "tests": "npm test (Node 22): 18 files, 135 cases; test/postgres.test.js runs on PostgreSQL 18 + PgBouncer when OV_TEST_PG_URL is set (CI starts the containers), else it is listed as skipped",++ "tests": "npm test (Node 22): 19 files, 140 cases; test/postgres.test.js runs on PostgreSQL 18 + PgBouncer when OV_TEST_PG_URL is set (CI starts the containers), else it is listed as skipped","released": true,"deployed": false,"deployment": "none: a package released as git tags; products pin the tag and deploy it with their own releases",@@@ -61,7 +62,8 @@"Search index documents, tombstones and publication events","auto-escaping SSR and a safe Markdown subset","the ingest chassis: the OpenVibe.Sources client, a change cursor + pull loop with per-item savepoint isolation and an {applied|hold|removed} apply contract, a signed event consumer with an exactly-once inbox, the normalisers and PSL/registrable-host and freshness helpers the five content products share",-- "the publication glue: gate → document → sequencer.stamp → events → outbox.enqueue on the caller's transaction handle, tombstones on unpublish/merge, and the IndexNow ping"++ "the publication glue: gate → document → sequencer.stamp → events → outbox.enqueue on the caller's transaction handle, tombstones on unpublish/merge, and the IndexNow ping",++ "the page document through openvibe-shared/shell page(): robots and canonical from the gate's decision, article times, prev/next, feeds, app icon, stylesheets, boost marker and the footer init"],-- "updated": "2026-09-29"++ "updated": "2026-10-04"}@@@ -2,7 +2,8 @@/*** 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, …/ai, …/ingest, …/publication,++ * …/layout* This root export loads each one lazily, on first access.** schema({ revisions: 'wiki_page', citations: 'wiki', … }) is the DDL of every store a product uses,@@@ -22,6 +23,7 @@ const MODULES = {ai: './lib/ai',ingest: './lib/ingest',publication: './lib/publication',++ layout: './lib/layout',};/** Which function gives each storing module's DDL. */nnew file mode 100644@@@ -0,0 +1,119 @@++'use strict';++/**++ * openvibe-publishing/layout — the page document every publication site (Wiki, Blog, News, Reviews,++ * Deals, Coupons, Trade) renders, composed by openvibe-shared/shell page().++ *++ * const layout = require('openvibe-publishing/layout');++ * res.type('html').send(layout.renderDocument({++ * site: 'news', siteName: 'OpenVibe.News', title: `${o.title} · OpenVibe.News`,++ * decision, canonical, jsonLd, feeds, navbar, footer, navLinks, css, release, body,++ * }));++ *++ * The gate's decision is the robots source: there is no default that makes a page indexable, so a++ * document needs a decision (or, for a page the gate never sees, an explicit robots string). The++ * canonical is the decision's (the original, for a duplicate) when it has one.++ *++ * The shell writes the SEO head, the theme-loader, the deferred web runtime, navbar and footer++ * scripts, the noscript navigation and the server-rendered footer, and boots the navbar. This adds++ * the publication extras (article times, prev/next, feeds, the app icon, stylesheets, the boost++ * marker) and the body frame, and initialises the footer, which the shell never does.++ *++ * Every value that reaches markup is escaped; css, scripts, head, header, account, body and shipped++ * are trusted markup the caller already built.++ */++const shell = require('openvibe-shared/shell');++const serve = require('openvibe-shared/serve');++const appIcon = require('openvibe-shared/app-icon');++const sharedSeo = require('openvibe-shared/seo');++const { feedLinks } = require('./seo');++const { escapeHtml: esc } = require('./ssr');++++/** JSON that is safe inside a <script>: `<` is escaped so nothing can close the element. */++function json(value) {++ return JSON.stringify(value).replace(/</g, '\\u003c');++}++++/**++ * o: site (service id, e.g. 'news'), service (defaults to site), siteName ('OpenVibe.News'), lang,++ * title (composed by the caller), description, canonical (absolute), type, image, imageAlt,++ * author, keywords, locale, twitterSite, jsonLd [], prev, next, published, modified,++ * decision (or robots), feeds [{ type, href, title }], navbar, footer, navLinks, home,++ * css (stylesheet href), styles [openvibe-shared/serve names], scripts [<script> HTML],++ * referrer, release, head (extra head HTML), iconSite, skipLabel, account (noscript account-bar++ * HTML), header (HTML after #navbar-mount), body (main HTML), mainId ('main'), mainClass ('page'),++ * bodyClass, shipped (frame.shipped HTML)++ */++function renderDocument(o = {}) {++ if (!o.decision && !o.robots) throw new TypeError('renderDocument needs the gate decision (or explicit robots)');++ const robots = o.decision ? o.decision.robots : o.robots;++ const canonical = o.decision ? (o.decision.canonical || o.canonical) : o.canonical;++ const site = o.site;++ const service = o.service || site;++ const navbar = { service, ...(o.navbar || {}) };++ const footer = o.footer || { service, variant: 'compact' };++ const mainId = o.mainId || 'main';++ const mainClass = o.mainClass == null ? 'page' : o.mainClass;++++ const head = [++ o.published ? `<meta property="article:published_time" content="${esc(o.published)}">` : '',++ o.modified ? `<meta property="article:modified_time" content="${esc(o.modified)}">` : '',++ o.prev ? `<link rel="prev" href="${esc(sharedSeo.absolute(o.prev, canonical))}">` : '',++ o.next ? `<link rel="next" href="${esc(sharedSeo.absolute(o.next, canonical))}">` : '',++ feedLinks(o.feeds || []),++ appIcon.headTags({ site: o.iconSite || site }),++ o.css ? `<link rel="stylesheet" href="${esc(o.css)}">` : '',++ ...(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(serve.url(name))}">`),++ ...(o.scripts || []),++ o.release ? `<meta name="ov-boost" content="${esc(`${site}@${o.release}`)}">` : '',++ o.release ? `<script src="${esc(serve.url('boost.js'))}" data-main="#${esc(mainId)}" defer></script>` : '',++ o.referrer ? `<meta name="referrer" content="${esc(o.referrer)}">` : '',++ o.head || '',++ ].filter(Boolean).join('\n');++++ const body = [++ `<a class="skip" href="#${esc(mainId)}">${esc(o.skipLabel || 'Skip to content')}</a>`,++ '<div id="navbar-mount"></div>',++ o.header || '',++ o.account ? `<noscript><div class="account-bar" role="navigation" aria-label="Account">${o.account}</div></noscript>` : '',++ `<main id="${esc(mainId)}"${mainClass ? ` class="${esc(mainClass)}"` : ''}>`,++ o.body || '',++ o.shipped || '',++ '</main>',++ `<script>++window.__OV_PAGE = ${json({ navbar, footer })};++document.addEventListener('DOMContentLoaded', function () {++ try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* the Frame is optional */ }++});++</script>`,++ ].filter(Boolean).join('\n');++++ return shell.page({++ // shell's `name` is the noscript-nav brand (and drops this site from the network links), so it++ // gets the display name; the service id reaches the navbar and the footer through their configs.++ name: o.siteName || site,++ service,++ lang: o.lang || 'en',++ title: o.title || o.siteName || site,++ siteName: o.siteName || site,++ description: o.description,++ canonical,++ robots,++ type: o.type || 'website',++ image: o.image || undefined,++ imageAlt: o.imageAlt || undefined,++ author: o.author || undefined,++ keywords: o.keywords || undefined,++ locale: o.locale || undefined,++ twitterSite: o.twitterSite || undefined,++ jsonLd: (o.jsonLd || []).filter(Boolean),++ navLinks: o.navLinks || [],++ home: o.home || '/',++ navbar,++ footer,++ body,++ bodyClass: o.bodyClass || undefined,++ head,++ });++}++++module.exports = { renderDocument };@@@ -1,12 +1,12 @@{"name": "openvibe-publishing",-- "version": "1.1.0",++ "version": "1.2.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-publishing",-- "version": "1.1.0",++ "version": "1.2.0","license": "MIT","devDependencies": {"@electric-sql/pglite": "^0.5.8",@@@ -14,7 +14,7 @@"ajv-formats": "^3.0.1","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.15.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0","pg": "^8.23.0"},"engines": {@@@ -270,9 +270,9 @@}},"node_modules/openvibe-shared": {-- "version": "2.5.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",-- "integrity": "sha512-fHuUS6SXM7ezatfAyqLm1V8B6puV9YXSnz6aG6XQbWx28qghRCCHeUF3lr4nxiCGdn9SyikYtIyLGL5r1ycp9Q==",++ "version": "2.6.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0",++ "integrity": "sha512-IKKvWTM/9UonQ4msrzM4yir0QcTUNbhyqeN1r5ywWavU4VTsL1sI5QdHQlxge7BAsMrQ4OvMEPlHjuAfUJM3rg==","dev": true,"license": "MIT","dependencies": {@@@ -1,6 +1,6 @@{"name": "openvibe-publishing",-- "version": "1.1.0",++ "version": "1.2.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": {@@@ -18,7 +18,8 @@"./ssr": "./lib/ssr.js","./ai": "./lib/ai.js","./ingest": "./lib/ingest.js",-- "./publication": "./lib/publication.js"++ "./publication": "./lib/publication.js",++ "./layout": "./lib/layout.js"},"files": ["index.js",@@@ -52,7 +53,7 @@"ajv-formats": "^3.0.1","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.15.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0","pg": "^8.23.0"}}nnew file mode 100644@@@ -0,0 +1,88 @@++'use strict';++const assert = require('assert');++const { renderDocument } = require('../lib/layout');++const { suite } = require('./helpers/db');++++const { test, run } = suite();++++const decision = { indexable: false, robots: 'noindex, follow', canonical: 'https://openvibe.news/a/original', codes: ['duplicate'] };++const doc = (extra = {}) => ({++ site: 'news', siteName: 'OpenVibe.News', title: 'A story · OpenVibe.News', description: 'What happened.',++ canonical: 'https://openvibe.news/a/story', type: 'article', decision,++ published: '2026-10-01T08:00:00Z', modified: '2026-10-02T09:00:00Z', prev: '/a/older',++ jsonLd: [{ '@context': 'https://schema.org', '@type': 'NewsArticle', headline: 'A story' }, null],++ feeds: [{ type: 'rss', href: '/feed.xml', title: 'OpenVibe.News' }],++ navbar: { service: 'news', links: [{ label: 'News', href: '/' }] },++ footer: { service: 'news', variant: 'full', mount: '#ov-footer', brandName: 'OpenVibe.News' },++ navLinks: [{ label: 'News', href: '/' }, { label: 'Topics', href: '/topics' }],++ css: '/css/news.css?v=abc', styles: ['showcase.css'], release: 'r42', referrer: 'strict-origin-when-cross-origin',++ account: '<a href="/auth/login">Sign in with OpenVibe</a>', body: '<h1>A story</h1>',++ ...extra,++});++const count = (s, re) => (s.match(re) || []).length;++++test('renderDocument composes one whole document through openvibe-shared/shell', () => {++ const out = renderDocument(doc());++ assert.match(out, /^<!doctype html>\n<html lang="en">/);++ assert.strictEqual(count(out, /<title>/g), 1);++ assert.match(out, /<title>A story · OpenVibe\.News<\/title>/);++ assert.strictEqual(count(out, /<script type="application\/ld\+json">/g), 1);++ assert.match(out, /<link rel="alternate" type="application\/rss\+xml" href="\/feed\.xml" title="OpenVibe\.News">/);++ assert.match(out, /<meta property="article:published_time" content="2026-10-01T08:00:00Z">/);++ assert.match(out, /<meta property="article:modified_time" content="2026-10-02T09:00:00Z">/);++ assert.match(out, /<link rel="prev" href="https:\/\/openvibe\.news\/a\/older">/);++ assert.ok(out.includes(require('openvibe-shared/app-icon').headTags({ site: 'news' })), 'app-icon head tags');++ assert.match(out, /<link rel="stylesheet" href="\/css\/news\.css\?v=abc">/);++ assert.match(out, /<link rel="stylesheet" href="\/shared\/showcase\.css\?v=/);++ for (const f of ['theme-loader.js', 'web-runtime.js', 'navbar.js', 'footer.js', 'boost.js']) {++ assert.match(out, new RegExp(`<script src="/shared/${f.replace('.', '\\.')}\\?v=`), f);++ }++ assert.match(out, /<meta name="ov-boost" content="news@r42">/);++ assert.match(out, /<meta name="referrer" content="strict-origin-when-cross-origin">/);++ assert.match(out, /OpenVibeNavbar\.init\(/);++ assert.match(out, /OpenVibeFooter\.init\(window\.__OV_PAGE\.footer\)/);++ assert.match(out, /window\.__OV_PAGE = \{"navbar":\{"service":"news"/);++ assert.match(out, /<a class="skip" href="#main">Skip to content<\/a>\n<div id="navbar-mount"><\/div>/);++ assert.match(out, /<noscript><div class="account-bar"[^>]*><a href="\/auth\/login">Sign in with OpenVibe<\/a><\/div><\/noscript>/);++ assert.match(out, /<main id="main" class="page">\n<h1>A story<\/h1>\n<\/main>/);++ assert.match(out, /<noscript><nav aria-label="Site"[^>]*><a href="\/"[^>]*>OpenVibe\.News<\/a><a href="\/">News<\/a><a href="\/topics">Topics<\/a>/);++});++++test('robots always comes from the decision, and a decision canonical wins over o.canonical', () => {++ const out = renderDocument(doc());++ assert.match(out, /<meta name="robots" content="noindex, follow">/);++ assert.match(out, /<link rel="canonical" href="https:\/\/openvibe\.news\/a\/original">/);++ assert.doesNotMatch(out, /a\/story"/);++ const own = renderDocument(doc({ decision: { robots: 'index, follow' } }));++ assert.match(own, /<link rel="canonical" href="https:\/\/openvibe\.news\/a\/story">/);++ assert.match(own, /<meta name="robots" content="index, follow">/);++ const explicit = renderDocument(doc({ decision: undefined, robots: 'noindex, nofollow' }));++ assert.match(explicit, /<meta name="robots" content="noindex, nofollow">/);++ assert.match(explicit, /<link rel="canonical" href="https:\/\/openvibe\.news\/a\/story">/);++});++++test('renderDocument needs the gate decision (or explicit robots)', () => {++ assert.throws(() => renderDocument({}), (e) => e instanceof TypeError && /gate decision/.test(e.message));++ assert.throws(() => renderDocument(), TypeError);++});++++test('values reaching markup are escaped, and the JSON boot cannot close its <script>', () => {++ const out = renderDocument(doc({++ published: '"><script>x</script>', css: '/x.css"onload="y', release: '<r>',++ navbar: { service: 'news', history: { title: '</script><script>alert(1)</script>' } },++ }));++ assert.doesNotMatch(out, /<script>x<\/script>/);++ assert.match(out, /href="\/x\.css"onload="y"/);++ assert.match(out, /content="news@<r>"/);++ assert.doesNotMatch(out, /<\/script><script>alert/);++ assert.match(out, /\\u003c\/script>\\u003cscript>alert/);++});++++test('no release, referrer, css or account: those extras are left out', () => {++ const out = renderDocument({ site: 'blog', siteName: 'OpenVibe.Blog', robots: 'index, follow', canonical: 'https://openvibe.blog/', body: '<p>hi</p>' });++ assert.doesNotMatch(out, /ov-boost|boost\.js|name="referrer"|account-bar|article:published_time|rel="prev"/);++ assert.match(out, /<title>OpenVibe\.Blog<\/title>/);++ assert.match(out, /window\.__OV_PAGE = \{"navbar":\{"service":"blog"\},"footer":\{"service":"blog","variant":"compact"\}\}/);++});++++run();@@@ -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', 'media', 'publication', 'revisions', 'schedule', 'seo', 'ssr', 'taxonomy']);++ ['ai', 'authorship', 'citations', 'discussion', 'index-hooks', 'ingest', 'layout', 'media', 'publication', 'revisions', 'schedule', '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', 'media', 'publication', 'revisions', 'schedule', 'schema', 'seo', 'ssr', 'taxonomy', 'version']);++ assert.deepStrictEqual(out.names.sort(), ['PublishingError', 'ai', 'authorship', 'citations', 'discussion', 'indexHooks', 'ingest', 'layout', 'media', 'publication', 'revisions', 'schedule', 'schema', '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,30 @@ All notable changes to `openvibe-publishing`. Versions follow [semver](https://s
@@ -4,6 +4,30 @@ 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.2.0 — 2026-10-04
+
+**Additive: one new entry point, `openvibe-publishing/layout`.** No existing export, table layout, reason
+code or document shape changes; a product on v1.1.0 upgrades by pinning v1.2.0.
+`require('openvibe-publishing')` exposes `.layout` lazily.
+
+### Added — `openvibe-publishing/layout`
+
+- `renderDocument(o)` — the page document the seven publication sites (Wiki, Blog, News, Reviews, Deals,
+ Coupons, Trade) each hand-wrote, composed by `openvibe-shared/shell` `page()` (T11 shell adoption).
+ The gate's decision stays the robots source: `robots` is `decision.robots` and the canonical is
+ `decision.canonical || o.canonical`; without a decision (or an explicit `robots`) it throws a
+ `TypeError`, so no page is ever indexable by default. It adds the publication extras to the shell's
+ head (article published/modified times, prev/next, feed links, the app icon, the site stylesheet,
+ shared stylesheets, the site's scripts, the boost marker, referrer, extra head markup), the body frame
+ (skip link, `#navbar-mount`, header, the no-JavaScript account bar, `<main>`) and the footer init the
+ shell never emits (`window.__OV_PAGE` + `OpenVibeFooter.init`). Every value reaching markup is escaped;
+ the boot JSON escapes `<`. Styling is unchanged. Needs `openvibe-shared` ≥ 2.6.0 (`shell`); the other
+ entry points keep the `>=1.5.0` peer range.
+
+### Changed
+
+- Tests pin `openvibe-shared` v2.6.0 (was v2.5.0).
+
## 1.1.0 — 2026-09-29
## 1.1.0 — 2026-09-29
**Additive: two new entry points, `openvibe-publishing/ingest` and `openvibe-publishing/publication`.**
**Additive: two new entry points, `openvibe-publishing/ingest` and `openvibe-publishing/publication`.**
@@ -3,12 +3,13 @@
@@ -3,12 +3,13 @@
> 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.1.0**: the stores run on PostgreSQL through the `openvibe-sdk/db` async data
+**Status:** alpha, **1.2.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
chassis (`openvibe-publishing/ingest`) and publication glue (`openvibe-publishing/publication`) —
chassis (`openvibe-publishing/ingest`) and publication glue (`openvibe-publishing/publication`) —
-additive, no existing export changed. The exit proof (`examples/two-products`) runs in `npm test`.
+additive, no existing export changed. v1.2.0 adds `openvibe-publishing/layout`, the page document the
+seven sites render through `openvibe-shared/shell` (additive). The exit proof (`examples/two-products`) runs in `npm test`.
Releases v0.1.0 to v1.0.0 are tagged (see [CHANGELOG.md](CHANGELOG.md)). All seven products (Wiki and
Releases v0.1.0 to v1.0.0 are tagged (see [CHANGELOG.md](CHANGELOG.md)). All seven products (Wiki and
Blog, public at openvibe.wiki and openvibe.blog; News, Reviews, Deals, Coupons and Trade, deployed
Blog, public at openvibe.wiki and openvibe.blog; News, Reviews, Deals, Coupons and Trade, deployed
loopback-only, not launched) run v1.0.0 on PostgreSQL today; the five content products move to v1.1.0 as
loopback-only, not launched) run v1.0.0 on PostgreSQL today; the five content products move to v1.1.0 as
@@ -65,16 +66,17 @@ Every module is its own entry point and can be used alone.
@@ -65,16 +66,17 @@ Every module is its own entry point and can be used alone.
| `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/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/layout` | `renderDocument(o)`: the whole page through `openvibe-shared/shell` `page()` (≥ 2.6.0), with robots and canonical from the gate's decision (throws without one), article times, prev/next, feeds, the app icon, stylesheets, the boost marker, the body frame and the footer init | — |
`require('openvibe-publishing')` exposes all of them lazily (`.revisions`, `.seo`, `.indexHooks`,
`require('openvibe-publishing')` exposes all of them lazily (`.revisions`, `.seo`, `.indexHooks`,
-`.ingest`, `.publication`, …), plus `schema({ … })`, the DDL of several stores at once (below).
+`.ingest`, `.publication`, `.layout`, …), plus `schema({ … })`, the DDL of several stores at once (below).
## Install
## Install
Pin the release tarball, like every OpenVibe package (never a `file:` link or a vendored copy):
Pin the release tarball, like every OpenVibe package (never a `file:` link or a vendored copy):
```json
```json
-"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.1.0",
+"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.2.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.15.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.15.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
```
```
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
"wave": "W15",
"wave": "W15",
"kind": "packages",
"kind": "packages",
"package": "openvibe-publishing",
"package": "openvibe-publishing",
- "version": "1.1.0",
+ "version": "1.2.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,
@@ -24,7 +24,8 @@
@@ -24,7 +24,8 @@
"ai",
"ai",
"ssr",
"ssr",
"ingest",
"ingest",
- "publication"
+ "publication",
+ "layout"
],
],
"consumers": [
"consumers": [
"OpenVibe.Wiki (v1.1.0 on PostgreSQL; public at https://openvibe.wiki)",
"OpenVibe.Wiki (v1.1.0 on PostgreSQL; public at https://openvibe.wiki)",
@@ -40,10 +41,10 @@
@@ -40,10 +41,10 @@
"contracts": "openvibe-contracts v0.76.0 (devDependency): emits search.index-document@1 and events.event-envelope@1 (validated in tests)",
"contracts": "openvibe-contracts v0.76.0 (devDependency): emits search.index-document@1 and events.event-envelope@1 (validated in tests)",
"dependsOn": [
"dependsOn": [
"openvibe-sdk >=0.15.0 (peer; tests pin v0.15.0)",
"openvibe-sdk >=0.15.0 (peer; tests pin v0.15.0)",
- "openvibe-shared >=1.5.0 (optional peer; tests pin v1.25.0)",
+ "openvibe-shared >=1.5.0 (optional peer; tests pin v2.6.0; layout needs >=2.6.0)",
"PostgreSQL 18 through PgBouncer (the product's database)"
"PostgreSQL 18 through PgBouncer (the product's database)"
],
],
- "tests": "npm test (Node 22): 18 files, 135 cases; test/postgres.test.js runs on PostgreSQL 18 + PgBouncer when OV_TEST_PG_URL is set (CI starts the containers), else it is listed as skipped",
+ "tests": "npm test (Node 22): 19 files, 140 cases; test/postgres.test.js runs on PostgreSQL 18 + PgBouncer when OV_TEST_PG_URL is set (CI starts the containers), else it is listed as skipped",
"released": true,
"released": true,
"deployed": false,
"deployed": false,
"deployment": "none: a package released as git tags; products pin the tag and deploy it with their own releases",
"deployment": "none: a package released as git tags; products pin the tag and deploy it with their own releases",
@@ -61,7 +62,8 @@
@@ -61,7 +62,8 @@
"Search index documents, tombstones and publication events",
"Search index documents, tombstones and publication events",
"auto-escaping SSR and a safe Markdown subset",
"auto-escaping SSR and a safe Markdown subset",
"the ingest chassis: the OpenVibe.Sources client, a change cursor + pull loop with per-item savepoint isolation and an {applied|hold|removed} apply contract, a signed event consumer with an exactly-once inbox, the normalisers and PSL/registrable-host and freshness helpers the five content products share",
"the ingest chassis: the OpenVibe.Sources client, a change cursor + pull loop with per-item savepoint isolation and an {applied|hold|removed} apply contract, a signed event consumer with an exactly-once inbox, the normalisers and PSL/registrable-host and freshness helpers the five content products share",
- "the publication glue: gate → document → sequencer.stamp → events → outbox.enqueue on the caller's transaction handle, tombstones on unpublish/merge, and the IndexNow ping"
+ "the publication glue: gate → document → sequencer.stamp → events → outbox.enqueue on the caller's transaction handle, tombstones on unpublish/merge, and the IndexNow ping",
+ "the page document through openvibe-shared/shell page(): robots and canonical from the gate's decision, article times, prev/next, feeds, app icon, stylesheets, boost marker and the footer init"
],
],
- "updated": "2026-09-29"
+ "updated": "2026-10-04"
}
}
@@ -2,7 +2,8 @@
@@ -2,7 +2,8 @@
/**
/**
* 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, …/ai, …/ingest, …/publication,
+ * …/layout
* This root export loads each one lazily, on first access.
* This root export loads each one lazily, on first access.
*
*
* schema({ revisions: 'wiki_page', citations: 'wiki', … }) is the DDL of every store a product uses,
* schema({ revisions: 'wiki_page', citations: 'wiki', … }) is the DDL of every store a product uses,
@@ -22,6 +23,7 @@ const MODULES = {
@@ -22,6 +23,7 @@ const MODULES = {
ai: './lib/ai',
ai: './lib/ai',
ingest: './lib/ingest',
ingest: './lib/ingest',
publication: './lib/publication',
publication: './lib/publication',
+ layout: './lib/layout',
};
};
/** Which function gives each storing module's DDL. */
/** Which function gives each storing module's DDL. */
new file mode 100644
new file mode 100644
@@ -0,0 +1,119 @@
@@ -0,0 +1,119 @@
+'use strict';
+/**
+ * openvibe-publishing/layout — the page document every publication site (Wiki, Blog, News, Reviews,
+ * Deals, Coupons, Trade) renders, composed by openvibe-shared/shell page().
+ *
+ * const layout = require('openvibe-publishing/layout');
+ * res.type('html').send(layout.renderDocument({
+ * site: 'news', siteName: 'OpenVibe.News', title: `${o.title} · OpenVibe.News`,
+ * decision, canonical, jsonLd, feeds, navbar, footer, navLinks, css, release, body,
+ * }));
+ *
+ * The gate's decision is the robots source: there is no default that makes a page indexable, so a
+ * document needs a decision (or, for a page the gate never sees, an explicit robots string). The
+ * canonical is the decision's (the original, for a duplicate) when it has one.
+ *
+ * The shell writes the SEO head, the theme-loader, the deferred web runtime, navbar and footer
+ * scripts, the noscript navigation and the server-rendered footer, and boots the navbar. This adds
+ * the publication extras (article times, prev/next, feeds, the app icon, stylesheets, the boost
+ * marker) and the body frame, and initialises the footer, which the shell never does.
+ *
+ * Every value that reaches markup is escaped; css, scripts, head, header, account, body and shipped
+ * are trusted markup the caller already built.
+ */
+const shell = require('openvibe-shared/shell');
+const serve = require('openvibe-shared/serve');
+const appIcon = require('openvibe-shared/app-icon');
+const sharedSeo = require('openvibe-shared/seo');
+const { feedLinks } = require('./seo');
+const { escapeHtml: esc } = require('./ssr');
+
+/** JSON that is safe inside a <script>: `<` is escaped so nothing can close the element. */
+function json(value) {
+ return JSON.stringify(value).replace(/</g, '\\u003c');
+}
+
+/**
+ * o: site (service id, e.g. 'news'), service (defaults to site), siteName ('OpenVibe.News'), lang,
+ * title (composed by the caller), description, canonical (absolute), type, image, imageAlt,
+ * author, keywords, locale, twitterSite, jsonLd [], prev, next, published, modified,
+ * decision (or robots), feeds [{ type, href, title }], navbar, footer, navLinks, home,
+ * css (stylesheet href), styles [openvibe-shared/serve names], scripts [<script> HTML],
+ * referrer, release, head (extra head HTML), iconSite, skipLabel, account (noscript account-bar
+ * HTML), header (HTML after #navbar-mount), body (main HTML), mainId ('main'), mainClass ('page'),
+ * bodyClass, shipped (frame.shipped HTML)
+ */
+function renderDocument(o = {}) {
+ if (!o.decision && !o.robots) throw new TypeError('renderDocument needs the gate decision (or explicit robots)');
+ const robots = o.decision ? o.decision.robots : o.robots;
+ const canonical = o.decision ? (o.decision.canonical || o.canonical) : o.canonical;
+ const site = o.site;
+ const service = o.service || site;
+ const navbar = { service, ...(o.navbar || {}) };
+ const footer = o.footer || { service, variant: 'compact' };
+ const mainId = o.mainId || 'main';
+ const mainClass = o.mainClass == null ? 'page' : o.mainClass;
+
+ const head = [
+ o.published ? `<meta property="article:published_time" content="${esc(o.published)}">` : '',
+ o.modified ? `<meta property="article:modified_time" content="${esc(o.modified)}">` : '',
+ o.prev ? `<link rel="prev" href="${esc(sharedSeo.absolute(o.prev, canonical))}">` : '',
+ o.next ? `<link rel="next" href="${esc(sharedSeo.absolute(o.next, canonical))}">` : '',
+ feedLinks(o.feeds || []),
+ appIcon.headTags({ site: o.iconSite || site }),
+ o.css ? `<link rel="stylesheet" href="${esc(o.css)}">` : '',
+ ...(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(serve.url(name))}">`),
+ ...(o.scripts || []),
+ o.release ? `<meta name="ov-boost" content="${esc(`${site}@${o.release}`)}">` : '',
+ o.release ? `<script src="${esc(serve.url('boost.js'))}" data-main="#${esc(mainId)}" defer></script>` : '',
+ o.referrer ? `<meta name="referrer" content="${esc(o.referrer)}">` : '',
+ o.head || '',
+ ].filter(Boolean).join('\n');
+
+ const body = [
+ `<a class="skip" href="#${esc(mainId)}">${esc(o.skipLabel || 'Skip to content')}</a>`,
+ '<div id="navbar-mount"></div>',
+ o.header || '',
+ o.account ? `<noscript><div class="account-bar" role="navigation" aria-label="Account">${o.account}</div></noscript>` : '',
+ `<main id="${esc(mainId)}"${mainClass ? ` class="${esc(mainClass)}"` : ''}>`,
+ o.body || '',
+ o.shipped || '',
+ '</main>',
+ `<script>
+window.__OV_PAGE = ${json({ navbar, footer })};
+document.addEventListener('DOMContentLoaded', function () {
+ try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* the Frame is optional */ }
+});
+</script>`,
+ ].filter(Boolean).join('\n');
+
+ return shell.page({
+ // shell's `name` is the noscript-nav brand (and drops this site from the network links), so it
+ // gets the display name; the service id reaches the navbar and the footer through their configs.
+ name: o.siteName || site,
+ service,
+ lang: o.lang || 'en',
+ title: o.title || o.siteName || site,
+ siteName: o.siteName || site,
+ description: o.description,
+ canonical,
+ robots,
+ type: o.type || 'website',
+ image: o.image || undefined,
+ imageAlt: o.imageAlt || undefined,
+ author: o.author || undefined,
+ keywords: o.keywords || undefined,
+ locale: o.locale || undefined,
+ twitterSite: o.twitterSite || undefined,
+ jsonLd: (o.jsonLd || []).filter(Boolean),
+ navLinks: o.navLinks || [],
+ home: o.home || '/',
+ navbar,
+ footer,
+ body,
+ bodyClass: o.bodyClass || undefined,
+ head,
+ });
+}
+
+module.exports = { renderDocument };
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-publishing",
"name": "openvibe-publishing",
- "version": "1.1.0",
+ "version": "1.2.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-publishing",
"name": "openvibe-publishing",
- "version": "1.1.0",
+ "version": "1.2.0",
"license": "MIT",
"license": "MIT",
"devDependencies": {
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"@electric-sql/pglite": "^0.5.8",
@@ -14,7 +14,7 @@
@@ -14,7 +14,7 @@
"ajv-formats": "^3.0.1",
"ajv-formats": "^3.0.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.15.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.15.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"engines": {
"engines": {
@@ -270,9 +270,9 @@
@@ -270,9 +270,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "2.5.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",
- "integrity": "sha512-fHuUS6SXM7ezatfAyqLm1V8B6puV9YXSnz6aG6XQbWx28qghRCCHeUF3lr4nxiCGdn9SyikYtIyLGL5r1ycp9Q==",
+ "version": "2.6.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0",
+ "integrity": "sha512-IKKvWTM/9UonQ4msrzM4yir0QcTUNbhyqeN1r5ywWavU4VTsL1sI5QdHQlxge7BAsMrQ4OvMEPlHjuAfUJM3rg==",
"dev": true,
"dev": true,
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-publishing",
"name": "openvibe-publishing",
- "version": "1.1.0",
+ "version": "1.2.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": {
@@ -18,7 +18,8 @@
@@ -18,7 +18,8 @@
"./ssr": "./lib/ssr.js",
"./ssr": "./lib/ssr.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",
+ "./layout": "./lib/layout.js"
},
},
"files": [
"files": [
"index.js",
"index.js",
@@ -52,7 +53,7 @@
@@ -52,7 +53,7 @@
"ajv-formats": "^3.0.1",
"ajv-formats": "^3.0.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.15.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.15.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
}
}
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,88 @@
@@ -0,0 +1,88 @@
+'use strict';
+const assert = require('assert');
+const { renderDocument } = require('../lib/layout');
+const { suite } = require('./helpers/db');
+
+const { test, run } = suite();
+
+const decision = { indexable: false, robots: 'noindex, follow', canonical: 'https://openvibe.news/a/original', codes: ['duplicate'] };
+const doc = (extra = {}) => ({
+ site: 'news', siteName: 'OpenVibe.News', title: 'A story · OpenVibe.News', description: 'What happened.',
+ canonical: 'https://openvibe.news/a/story', type: 'article', decision,
+ published: '2026-10-01T08:00:00Z', modified: '2026-10-02T09:00:00Z', prev: '/a/older',
+ jsonLd: [{ '@context': 'https://schema.org', '@type': 'NewsArticle', headline: 'A story' }, null],
+ feeds: [{ type: 'rss', href: '/feed.xml', title: 'OpenVibe.News' }],
+ navbar: { service: 'news', links: [{ label: 'News', href: '/' }] },
+ footer: { service: 'news', variant: 'full', mount: '#ov-footer', brandName: 'OpenVibe.News' },
+ navLinks: [{ label: 'News', href: '/' }, { label: 'Topics', href: '/topics' }],
+ css: '/css/news.css?v=abc', styles: ['showcase.css'], release: 'r42', referrer: 'strict-origin-when-cross-origin',
+ account: '<a href="/auth/login">Sign in with OpenVibe</a>', body: '<h1>A story</h1>',
+ ...extra,
+});
+const count = (s, re) => (s.match(re) || []).length;
+
+test('renderDocument composes one whole document through openvibe-shared/shell', () => {
+ const out = renderDocument(doc());
+ assert.match(out, /^<!doctype html>\n<html lang="en">/);
+ assert.strictEqual(count(out, /<title>/g), 1);
+ assert.match(out, /<title>A story · OpenVibe\.News<\/title>/);
+ assert.strictEqual(count(out, /<script type="application\/ld\+json">/g), 1);
+ assert.match(out, /<link rel="alternate" type="application\/rss\+xml" href="\/feed\.xml" title="OpenVibe\.News">/);
+ assert.match(out, /<meta property="article:published_time" content="2026-10-01T08:00:00Z">/);
+ assert.match(out, /<meta property="article:modified_time" content="2026-10-02T09:00:00Z">/);
+ assert.match(out, /<link rel="prev" href="https:\/\/openvibe\.news\/a\/older">/);
+ assert.ok(out.includes(require('openvibe-shared/app-icon').headTags({ site: 'news' })), 'app-icon head tags');
+ assert.match(out, /<link rel="stylesheet" href="\/css\/news\.css\?v=abc">/);
+ assert.match(out, /<link rel="stylesheet" href="\/shared\/showcase\.css\?v=/);
+ for (const f of ['theme-loader.js', 'web-runtime.js', 'navbar.js', 'footer.js', 'boost.js']) {
+ assert.match(out, new RegExp(`<script src="/shared/${f.replace('.', '\\.')}\\?v=`), f);
+ }
+ assert.match(out, /<meta name="ov-boost" content="news@r42">/);
+ assert.match(out, /<meta name="referrer" content="strict-origin-when-cross-origin">/);
+ assert.match(out, /OpenVibeNavbar\.init\(/);
+ assert.match(out, /OpenVibeFooter\.init\(window\.__OV_PAGE\.footer\)/);
+ assert.match(out, /window\.__OV_PAGE = \{"navbar":\{"service":"news"/);
+ assert.match(out, /<a class="skip" href="#main">Skip to content<\/a>\n<div id="navbar-mount"><\/div>/);
+ assert.match(out, /<noscript><div class="account-bar"[^>]*><a href="\/auth\/login">Sign in with OpenVibe<\/a><\/div><\/noscript>/);
+ assert.match(out, /<main id="main" class="page">\n<h1>A story<\/h1>\n<\/main>/);
+ assert.match(out, /<noscript><nav aria-label="Site"[^>]*><a href="\/"[^>]*>OpenVibe\.News<\/a><a href="\/">News<\/a><a href="\/topics">Topics<\/a>/);
+});
+
+test('robots always comes from the decision, and a decision canonical wins over o.canonical', () => {
+ const out = renderDocument(doc());
+ assert.match(out, /<meta name="robots" content="noindex, follow">/);
+ assert.match(out, /<link rel="canonical" href="https:\/\/openvibe\.news\/a\/original">/);
+ assert.doesNotMatch(out, /a\/story"/);
+ const own = renderDocument(doc({ decision: { robots: 'index, follow' } }));
+ assert.match(own, /<link rel="canonical" href="https:\/\/openvibe\.news\/a\/story">/);
+ assert.match(own, /<meta name="robots" content="index, follow">/);
+ const explicit = renderDocument(doc({ decision: undefined, robots: 'noindex, nofollow' }));
+ assert.match(explicit, /<meta name="robots" content="noindex, nofollow">/);
+ assert.match(explicit, /<link rel="canonical" href="https:\/\/openvibe\.news\/a\/story">/);
+});
+
+test('renderDocument needs the gate decision (or explicit robots)', () => {
+ assert.throws(() => renderDocument({}), (e) => e instanceof TypeError && /gate decision/.test(e.message));
+ assert.throws(() => renderDocument(), TypeError);
+});
+
+test('values reaching markup are escaped, and the JSON boot cannot close its <script>', () => {
+ const out = renderDocument(doc({
+ published: '"><script>x</script>', css: '/x.css"onload="y', release: '<r>',
+ navbar: { service: 'news', history: { title: '</script><script>alert(1)</script>' } },
+ }));
+ assert.doesNotMatch(out, /<script>x<\/script>/);
+ assert.match(out, /href="\/x\.css"onload="y"/);
+ assert.match(out, /content="news@<r>"/);
+ assert.doesNotMatch(out, /<\/script><script>alert/);
+ assert.match(out, /\\u003c\/script>\\u003cscript>alert/);
+});
+
+test('no release, referrer, css or account: those extras are left out', () => {
+ const out = renderDocument({ site: 'blog', siteName: 'OpenVibe.Blog', robots: 'index, follow', canonical: 'https://openvibe.blog/', body: '<p>hi</p>' });
+ assert.doesNotMatch(out, /ov-boost|boost\.js|name="referrer"|account-bar|article:published_time|rel="prev"/);
+ assert.match(out, /<title>OpenVibe\.Blog<\/title>/);
+ assert.match(out, /window\.__OV_PAGE = \{"navbar":\{"service":"blog"\},"footer":\{"service":"blog","variant":"compact"\}\}/);
+});
+
+run();
@@ -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', 'media', 'publication', 'revisions', 'schedule', 'seo', 'ssr', 'taxonomy']);
+ ['ai', 'authorship', 'citations', 'discussion', 'index-hooks', 'ingest', 'layout', 'media', 'publication', 'revisions', 'schedule', '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', 'media', 'publication', 'revisions', 'schedule', 'schema', 'seo', 'ssr', 'taxonomy', 'version']);
+ assert.deepStrictEqual(out.names.sort(), ['PublishingError', 'ai', 'authorship', 'citations', 'discussion', 'indexHooks', 'ingest', 'layout', 'media', 'publication', 'revisions', 'schedule', 'schema', '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/', () => {