openvibers/openvibe.publishing Commits

Add layout.renderDocument over shell.page and release 1.2.0

OP OpenVibers committed 4 Oct 2026 · c569ae6211 · 9 files, +260 −22

Asked: T11 step 2 second batch, s1 — OpenVibe.Publishing: add openvibe-publishing/layout.renderDocument over openvibe-shared/shell page() and release it as v1.2.0. This is the shared helper the seven publication sites (Blog, Coupons, Deals, News, Reviews, Trade, Wiki) will adopt next, one PR each, pinning…
Harness job publishing-t11-step-2-second-batch-115307 (plan T11).

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex e5af20e..68c098e 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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
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
**Additive: two new entry points, `openvibe-publishing/ingest` and `openvibe-publishing/publication`.**
ddiff --git a/README.md b/README.md
iindex 972cad7..0c5e5fd 100644
---- a/README.md
++++ b/README.md
@@@ -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 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
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
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
@@@ -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).
## Install
Pin 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"
```
ddiff --git a/STATUS.json b/STATUS.json
iindex babc95c..d6797a0 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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"
}
ddiff --git a/index.js b/index.js
iindex 032503c..e6dde35 100644
---- a/index.js
++++ b/index.js
@@@ -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. */
ddiff --git a/lib/layout.js b/lib/layout.js
nnew file mode 100644
iindex 0000000..7649b3c
---- /dev/null
++++ b/lib/layout.js
@@@ -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 };
ddiff --git a/package-lock.json b/package-lock.json
iindex 08d2634..9c28273 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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": {
ddiff --git a/package.json b/package.json
iindex a6741d6..6814173 100644
---- a/package.json
++++ b/package.json
@@@ -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"
}
}
ddiff --git a/test/layout.test.js b/test/layout.test.js
nnew file mode 100644
iindex 0000000..ff181d1
---- /dev/null
++++ b/test/layout.test.js
@@@ -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&quot;onload=&quot;y"/);
++ assert.match(out, /content="news@&lt;r&gt;"/);
++ 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();
ddiff --git a/test/package.test.js b/test/package.test.js
iindex f6f9151..df0d814 100644
---- a/test/package.test.js
++++ b/test/package.test.js
@@@ -12,7 +12,7 @@ const SUBPATHS = Object.keys(pkg.exports).filter((k) => k !== '.' && k !== './pa
test('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/', () => {