openvibers/openvibe.publishing Commits

openvibe-publishing 0.1.0: shared publishing packages (revisions, schedule, taxonomy, citations, media, discussion, seo gate, authorship, index hooks, ssr) with the two-products exit proof

OP OpenVibers committed 23 Sept 2026 · 6b4f426bad · 39 files, +5778 −0


Changes

ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
nnew file mode 100644
iindex 0000000..280c92d
---- /dev/null
++++ b/.github/workflows/ci.yml
@@@ -0,0 +1,32 @@
++name: ci
++on:
++ push:
++ branches: [main]
++ tags: ['v*']
++ pull_request:
++jobs:
++ test:
++ runs-on: ubuntu-latest
++ steps:
++ - uses: actions/checkout@v4
++ - uses: actions/setup-node@v4
++ with:
++ node-version: 22.22.1 # the consuming services run Node 22
++ cache: npm
++ - run: npm ci --no-audit --no-fund
++ - run: npm test
++ # The release artifact is GitHub's tarball of the tag (repository root = package root):
++ # install one built the same way into an empty project and load every entry point by name.
++ - name: install from a tag-style tarball
++ run: |
++ git archive --format=tar.gz --prefix=OpenVibe.Publishing-ci/ -o "$RUNNER_TEMP/openvibe-publishing.tgz" HEAD
++ mkdir "$RUNNER_TEMP/consumer" && cd "$RUNNER_TEMP/consumer" && npm init -y >/dev/null
++ npm install --no-audit --no-fund "$RUNNER_TEMP/openvibe-publishing.tgz" better-sqlite3@11
++ node -e "
++ const p = require('openvibe-publishing');
++ for (const m of ['revisions','schedule','taxonomy','citations','media','discussion','seo','authorship','index-hooks','ssr']) require('openvibe-publishing/' + m);
++ const Database = require('better-sqlite3');
++ const db = new Database(':memory:');
++ p.revisions.createRevisionStore(db, { prefix: 'ci' }).create({ entityId: 'e', expectedRevision: 0, content: 'x' });
++ console.log('ok', require('openvibe-publishing/package.json').version);
++ "
ddiff --git a/.gitignore b/.gitignore
nnew file mode 100644
iindex 0000000..70e63ce
---- /dev/null
++++ b/.gitignore
@@@ -0,0 +1,5 @@
++node_modules/
++*.db
++*.db-shm
++*.db-wal
++.tmp/
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
nnew file mode 100644
iindex 0000000..bb95a93
---- /dev/null
++++ b/CHANGELOG.md
@@@ -0,0 +1,34 @@
++# Changelog
++
++All notable changes to `openvibe-publishing`. Versions follow [semver](https://semver.org/): a
++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.
++
++## 0.1.0 — 2026-09-22
++
++First version (roadmap Wave 15). Packages only: no runtime, no domain, no database.
++
++### Added
++- `revisions`: drafts, immutable revisions with parent pointers (UPDATE/DELETE blocked by SQLite
++ triggers), optimistic concurrency with a 412 `revision.conflict`, line and word diffs (Myers, with
++ a bounded fallback), revert as a new revision, audited `purgeEntity`.
++- `schedule`: idempotent publish/unpublish jobs with leases; re-run safe after a worker crash;
++ retry with backoff; abandoned after `maxAttempts`; injectable clock.
++- `taxonomy`: slugs, get-or-create terms per vocabulary, hierarchical categories, entity links.
++- `citations`: append-only source references per revision (Sources item id / URL, retrieved_at,
++ quote span, license note), `carryForward`, lookups by source item.
++- `media`: attachments by Media object id with `unverified | available | broken` states and an
++ explicit broken placeholder in `figureHtml`.
++- `discussion`: Community thread resolution (`POST /api/v1/comments/threads/resolve`) with a
++ service token; stores thread ids only.
++- `seo`: the deterministic indexability gate (19 stable reason codes, `hidden`/`noindex` effects),
++ canonical URL builder, history-aware redirects, meta/robots tags, sitemaps, RSS 2.0, Atom 1.0,
++ JSON Feed 1.1, JSON-LD builders (Article/BlogPosting/NewsArticle, Review, Product/Offer,
++ AggregateRating, BreadcrumbList, WebPage) that omit anything not provided.
++- `authorship`: human / ai / hybrid / imported records with AI workflow + run references,
++ disclosure labels, append-only human review log; AI content starts as draft + noindex.
++- `index-hooks`: Search index documents and tombstones, `actionFor` transitions, and
++ `<product>.<type>.<action>` event envelopes valid against `events.event-envelope@1`.
++- `ssr`: escaping templates, safe Markdown subset, pagination, breadcrumbs, diff markup, `<time>`.
++- `examples/two-products`: a mini wiki and a mini blog with separate databases (the exit proof).
++- Proposed contract `docs/contracts-proposal/search.index-document.v1.json`.
ddiff --git a/LICENSE b/LICENSE
nnew file mode 100644
iindex 0000000..ac75a34
---- /dev/null
++++ b/LICENSE
@@@ -0,0 +1,21 @@
++MIT License
++
++Copyright (c) 2026 OpenVibers
++
++Permission is hereby granted, free of charge, to any person obtaining a copy
++of this software and associated documentation files (the "Software"), to deal
++in the Software without restriction, including without limitation the rights
++to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
++copies of the Software, and to permit persons to whom the Software is
++furnished to do so, subject to the following conditions:
++
++The above copyright notice and this permission notice shall be included in all
++copies or substantial portions of the Software.
++
++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
++IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
++FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
++AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
++LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
++OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
++SOFTWARE.
ddiff --git a/README.md b/README.md
nnew file mode 100644
iindex 0000000..5d51c12
---- /dev/null
++++ b/README.md
@@@ -0,0 +1,138 @@
++# OpenVibe.Publishing
++
++> Shared publishing packages for the OpenVibe publication products: Wiki, Blog, News, Reviews,
++> Deals, Coupons and Trade.
++
++**Status:** alpha, 0.1.0 (roadmap Wave 15). The modules are tested and the exit proof
++(`examples/two-products`) runs in `npm test`; not tagged or released yet. No product uses them in production yet: Wiki and Blog (Wave 16) are the
++first planned consumers.
++**Package:** `openvibe-publishing` (CommonJS, Node ≥ 20, production runs Node 22).
++**License:** MIT, like OpenVibe.Shared.
++
++## The rule: packages, not an authority
++
++This repository is **code, never state**. It has no runtime, no port, no domain, no database and
++no API of its own, and it must never get one. Each product (Wiki, Blog, News, …) owns its
++publication state in its own database; these modules take the product's `better-sqlite3` handle
++and a table prefix, and create `<prefix>_…` tables *inside that product's database*. Two products
++share the code, not the data: `wiki.db` holds `wiki_*` tables, `blog.db` holds `blog_*` tables,
++and nothing is shared between them (proved by [test/two-products.test.js](test/two-products.test.js)).
++
++Following the plan (§12.4, §31.1, §31.5): share *how* something is done (revisions, the gate,
++feeds, indexing); never share *what is true* (which revision is live, who may read it, editorial
++rules). A generic `OpenVibe.Content` authority is explicitly not what this is.
++
++## Module map
++
++Every module is its own entry point and can be used alone.
++
++| Entry point | What it does | Tables (in the consumer's DB) |
++|---|---|---|
++| `openvibe-publishing/revisions` | Drafts, immutable revisions with parent pointers, optimistic concurrency (`expectedRevision` → 412 `revision.conflict`), line/word diff, revert as a new revision, audited purge | `<prefix>_revisions`, `<prefix>_drafts`, `<prefix>_revision_purges` |
++| `openvibe-publishing/schedule` | Scheduled publish/unpublish jobs: idempotent `schedule()`, lease-based `claim()`, re-run safe across worker restarts, retries with backoff, injectable clock | `<prefix>_schedule_jobs` |
++| `openvibe-publishing/taxonomy` | Tags and terms with slugs, hierarchical categories (ancestors for breadcrumbs, descendants, trees, cycle checks) | `<prefix>_terms`, `<prefix>_term_links` |
++| `openvibe-publishing/citations` | Source references on one revision: Sources item id and/or URL, `retrieved_at`, quote span, license note; append-only, never dropped by later revisions; `carryForward` to reuse | `<prefix>_citations`, `<prefix>_citation_purges` |
++| `openvibe-publishing/media` | Attachments by Media object id (`med_…` / `legacy:…`), explicit `broken` state when the object is gone, `verify()` that never flips state on an outage, `<figure>` rendering with an honest placeholder | `<prefix>_attachments` |
++| `openvibe-publishing/discussion` | Community comment-thread references: resolves via `POST /api/v1/comments/threads/resolve`, stores the thread id only (no comment content) | `<prefix>_discussion_refs` |
++| `openvibe-publishing/seo` | The deterministic **indexability gate**; canonical URLs; history-aware redirects (old slug → 301, gone → 410); meta/robots tags; sitemaps; RSS, Atom and JSON Feed; JSON-LD built only from provided fields | `<prefix>_redirects` |
++| `openvibe-publishing/authorship` | human / ai / hybrid / imported records with the OpenVibe.AI workflow + run id, disclosure labels, AI content held as draft + noindex until a person's review | `<prefix>_reviews` |
++| `openvibe-publishing/index-hooks` | Search index documents (and tombstones) from a published revision; `<product>.<type>.published\|updated\|unpublished\|deleted` event envelopes for the product's outbox | — |
++| `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>` | — |
++
++`require('openvibe-publishing')` exposes all of them lazily (`.revisions`, `.seo`, `.indexHooks`, …).
++
++## 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/v0.1.0"
++```
++
++`better-sqlite3` (≥ 11) is a peer dependency: the product brings its own handle. The only runtime
++dependency is `openvibe-shared` (v1.0.0), whose `seo` module is the network's single implementation
++of head tags, sitemap XML, robots.txt and JSON-LD escaping; this package adds the publication rules on top.
++
++## The indexability gate
++
++```js
++const seo = require('openvibe-publishing/seo');
++const decision = seo.evaluate(
++ { state: 'published', visibility: 'public', canonicalUrl, text, citationCount: 2, authorship: { mode: 'ai', reviewed: false } },
++ { policy: { minWords: 150, requireSources: true }, now: Date.now() },
++);
++// → { indexable: false, listable: false, robots: 'noindex, nofollow', codes: ['ai_generated_unreviewed'], reasons: [...], canonical, gate }
++```
++
++A pure function of (facts, policy, now): the same input always gives the same decision. Every "no"
++carries stable reason codes, in a fixed order (`seo.REASONS`):
++
++| Effect | Codes |
++|---|---|
++| **hidden** — never indexed, never in sitemaps, feeds or public search | `deleted`, `takedown`, `private`, `gated`, `unlisted`, `draft` (draft or scheduled), `unpublished`, `ai_generated_unreviewed`, `stub_provider`, `unreviewed_sensitive` |
++| **noindex** — may be served and listed in feeds, never indexed or put in a sitemap | `retracted`, `expired`, `stale_price`, `duplicate_of` (canonical points at the original), `thin`, `unsourced`, `unsupported_claims`, `missing_canonical`, `noindex_requested` |
++
++There is no default that makes content indexable: unknown fact names throw, a rule that needs a fact
++the caller did not give throws, and time rules need an explicit `now` (no hidden clock). Each product
++sets its own policy (`minWords`, `requireSources`, `minSources`, `priceMaxAgeMs`, `sensitiveCategories`).
++`metaTags`, `robotsMeta`, `sitemap`, the three feed builders and `buildIndexDocument` (for a
++published document) all require the decision, so an object without one cannot reach a crawler by omission.
++
++## Honesty guarantees (tested)
++
++- **Structured data** is generated only from provided fields. A missing rating, price, currency,
++ date or author is omitted, never defaulted (no `bestRating: 5`, no assumed `InStock`, no
++ "now" as a date, no placeholder author). An `AggregateRating` needs a real count; an `Offer` needs a
++ price and a currency. [test/seo.test.js](test/seo.test.js) checks every output leaf against the input.
++- **Feeds and sitemaps** never invent `pubDate`, `lastmod` or authors; an Atom entry without any date
++ is left out rather than dated "now".
++- **Citations** never get a retrieval time they did not have, and cannot be edited or deleted
++ (SQLite triggers) except by an audited purge.
++- **Media** outages are `check_failed`, not "gone" and not "fine".
++- **Discussion** failures are errors, never a fabricated thread.
++- **AI content** starts as draft + noindex: `authorship.canPublish()` refuses it and the gate hides it
++ (`ai_generated_unreviewed`) until a person (a `usr_` subject, never a service) records an approving
++ review. Products must call these; the package cannot stop a product that bypasses its own checks.
++
++## Events and Search
++
++`index-hooks.buildIndexDocument()` produces the Search document ([REF] §12.3 fields plus the gate
++decision) or a tombstone; the proposed schema is
++[docs/contracts-proposal/search.index-document.v1.json](docs/contracts-proposal/search.index-document.v1.json).
++`publicationEvent()` wraps it in an `events.event-envelope@1` envelope with `event_type`
++`<product>.<type>.<action>` and no `event_id` (the OpenVibe.Events outbox assigns one); events about
++private, gated or unlisted content are `internal`. Enqueue it with the Events outbox in the same
++transaction as the product's state change.
++
++## Exit proof: two products
++
++[examples/two-products](examples/two-products) holds a mini wiki and a mini blog, each with its own
++SQLite file and its own publication tables, both using revisions, citations, the gate and feeds with
++different editorial policies. `node examples/two-products/wiki/app.js` (port 4801) and
++`node examples/two-products/blog/app.js` (port 4811) serve them on temp databases.
++[test/two-products.test.js](test/two-products.test.js) shows they load the same modules, that each
++database holds only its own product's tables, that writes never cross, and that pages are useful
++without JavaScript, old slugs 301, private/VIP/deleted content leaves feeds and sitemaps, and
++scheduled publication survives a worker restart.
++
++## Development
++
++```bash
++fnm exec --using=22.22.1 npm install
++fnm exec --using=22.22.1 npm test # every test/*.test.js, temp databases, no network
++```
++
++## What it does not do
++
++- It does not own publication state, run workers, listen on a port, or talk to a database it was not handed.
++- It does not decide editorial rules: each product passes its own gate policy and its own permission checks.
++- It does not store or render comment content (Community owns it), media bytes (Media owns them) or
++ source items (Sources owns them); it stores references.
++- It is not a notification, search or event runtime: it builds the documents and envelopes those services consume.
++
++## Depends on
++
++- `openvibe-shared` v1.0.0 (`seo`), at runtime.
++- `better-sqlite3` ≥ 11, supplied by the consumer.
++- Contracts it produces for: `common.entity-ref@1`, `media.media-ref@1`, `events.event-envelope@1`
++ (validated in tests against `openvibe-contracts` v0.7.0), and the proposed `search.index-document@1`.
ddiff --git a/STATUS.json b/STATUS.json
nnew file mode 100644
iindex 0000000..cd27d25
---- /dev/null
++++ b/STATUS.json
@@@ -0,0 +1,21 @@
++{
++ "repository": "OpenVibers/OpenVibe.Publishing",
++ "stage": "alpha",
++ "code": true,
++ "wave": "W15",
++ "kind": "packages",
++ "package": "openvibe-publishing",
++ "version": "0.1.0",
++ "plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
++ "runtime": null,
++ "port": null,
++ "domain": null,
++ "database": null,
++ "modules": ["revisions", "schedule", "taxonomy", "citations", "media", "discussion", "seo", "authorship", "index-hooks", "ssr"],
++ "consumers": [],
++ "plannedConsumers": ["OpenVibe.Wiki", "OpenVibe.Blog", "OpenVibe.News", "OpenVibe.Reviews", "OpenVibe.Deals", "OpenVibe.Coupons", "OpenVibe.Trade"],
++ "exitProof": "examples/two-products + test/two-products.test.js",
++ "contracts": "openvibe-contracts v0.7.0 (tests); proposes search.index-document@1",
++ "released": false,
++ "updated": "2026-09-22"
++}
ddiff --git a/docs/contracts-proposal/search.index-document.v1.json b/docs/contracts-proposal/search.index-document.v1.json
nnew file mode 100644
iindex 0000000..c6b9456
---- /dev/null
++++ b/docs/contracts-proposal/search.index-document.v1.json
@@@ -0,0 +1,113 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/search/index-document.v1.json",
++ "title": "SearchIndexDocument",
++ "description": "PROPOSAL (openvibe-publishing 0.1.0, for OpenVibe.Contracts and OpenVibe.Search). The document a publication product sends to Search for one revision of one resource, or the tombstone that removes it. Owner, resource, revision, visibility + ACL, canonical URL, title/summary/body, facets, provenance, publication state, deletion marker ([REF] §12.3), plus the publishing SEO gate's indexability decision. Search must never return a document to a query that fails its ACL, and must not list a document whose indexability.listable is false on public surfaces.",
++ "oneOf": [
++ {
++ "title": "IndexDocument",
++ "type": "object",
++ "required": ["schema", "owner_service", "resource_type", "resource_id", "revision", "visibility", "acl", "canonical_url", "title", "summary", "body", "facets", "provenance", "publication_state", "deleted", "indexability", "published_at", "updated_at"],
++ "additionalProperties": false,
++ "properties": {
++ "schema": { "const": "search.index-document@1" },
++ "owner_service": { "$ref": "#/$defs/service" },
++ "resource_type": { "$ref": "#/$defs/segment" },
++ "resource_id": { "type": "string", "minLength": 1, "maxLength": 200 },
++ "revision": { "type": "integer", "minimum": 0 },
++ "visibility": { "enum": ["public", "private", "gated"] },
++ "acl": {
++ "oneOf": [
++ { "type": "object", "required": ["public"], "additionalProperties": false, "properties": { "public": { "const": true } } },
++ {
++ "type": "object", "required": ["public"], "additionalProperties": false,
++ "properties": {
++ "public": { "const": false },
++ "subjects": { "type": "array", "minItems": 1, "items": { "type": "string", "pattern": "^(usr|gst|app|mod)_[0-9A-HJKMNP-TV-Z]{26}$" } },
++ "entitlements": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } }
++ },
++ "anyOf": [{ "required": ["subjects"] }, { "required": ["entitlements"] }]
++ }
++ ]
++ },
++ "canonical_url": { "type": "string", "pattern": "^https?://" },
++ "title": { "type": "string", "minLength": 1 },
++ "summary": { "type": ["string", "null"] },
++ "body": { "type": "string", "maxLength": 100000 },
++ "body_truncated": { "const": true },
++ "locale": { "type": "string" },
++ "facets": {
++ "type": "object",
++ "propertyNames": { "pattern": "^[a-z][a-z0-9_]{0,39}$" },
++ "additionalProperties": {
++ "anyOf": [
++ { "type": ["string", "number", "boolean"] },
++ { "type": "array", "items": { "type": ["string", "number", "boolean"] } }
++ ]
++ }
++ },
++ "provenance": {
++ "type": "object",
++ "additionalProperties": false,
++ "properties": {
++ "authorship": {
++ "type": "object", "required": ["mode"], "additionalProperties": false,
++ "properties": {
++ "mode": { "enum": ["human", "ai", "hybrid", "imported"] },
++ "reviewed": { "type": "boolean" },
++ "workflow": { "type": "object", "required": ["id", "run_id"], "additionalProperties": false, "properties": { "id": { "type": "string" }, "run_id": { "type": "string" } } }
++ }
++ },
++ "citations": {
++ "type": "array",
++ "items": {
++ "type": "object", "additionalProperties": false,
++ "properties": {
++ "source_item_id": { "type": "string" },
++ "url": { "type": "string" },
++ "retrieved_at": { "type": "string", "format": "date-time" },
++ "license_note": { "type": "string" }
++ },
++ "anyOf": [{ "required": ["source_item_id"] }, { "required": ["url"] }]
++ }
++ },
++ "derived_from": { "type": "object" }
++ }
++ },
++ "publication_state": { "const": "published" },
++ "deleted": { "const": false },
++ "indexability": {
++ "type": "object", "required": ["indexable", "listable", "reasons", "gate"], "additionalProperties": false,
++ "properties": {
++ "indexable": { "type": "boolean" },
++ "listable": { "type": "boolean" },
++ "reasons": { "type": "array", "items": { "type": "string", "pattern": "^[a-z][a-z_]*$" } },
++ "gate": { "type": ["string", "null"] }
++ }
++ },
++ "published_at": { "type": ["string", "null"], "format": "date-time" },
++ "updated_at": { "type": ["string", "null"], "format": "date-time" }
++ }
++ },
++ {
++ "title": "IndexTombstone",
++ "type": "object",
++ "required": ["schema", "owner_service", "resource_type", "resource_id", "revision", "publication_state", "deleted", "updated_at"],
++ "additionalProperties": false,
++ "properties": {
++ "schema": { "const": "search.index-document@1" },
++ "owner_service": { "$ref": "#/$defs/service" },
++ "resource_type": { "$ref": "#/$defs/segment" },
++ "resource_id": { "type": "string", "minLength": 1, "maxLength": 200 },
++ "revision": { "type": "integer", "minimum": 0 },
++ "publication_state": { "enum": ["draft", "scheduled", "published", "unpublished", "deleted"] },
++ "deleted": { "const": true },
++ "updated_at": { "type": ["string", "null"], "format": "date-time" }
++ }
++ }
++ ],
++ "$defs": {
++ "service": { "type": "string", "pattern": "^[a-z][a-z0-9-]{1,39}$" },
++ "segment": { "type": "string", "pattern": "^[a-z][a-z0-9_]{0,39}$" }
++ }
++}
ddiff --git a/examples/two-products/README.md b/examples/two-products/README.md
nnew file mode 100644
iindex 0000000..e9dbe3f
---- /dev/null
++++ b/examples/two-products/README.md
@@@ -0,0 +1,22 @@
++# Two products, one package, two databases
++
++The Wave 15 exit proof. `wiki/app.js` and `blog/app.js` are deliberately small products that
++import `openvibe-publishing/*` exactly as a real product would, each opening its **own** SQLite file:
++
++| | Mini wiki | Mini blog |
++|---|---|---|
++| Database | `wiki.db` — only `wiki_*` tables | `blog.db` — only `blog_*` tables |
++| Publication state | `wiki_pages` (owned by the app) | `blog_posts` (owned by the app) |
++| Revisions | `wiki_page_revisions` | `blog_post_revisions` |
++| Citations | `wiki_citations` (sources required) | `blog_post_citations` (optional) |
++| Gate policy | `minWords 30`, `requireSources` | `minWords 20` |
++| Feeds | Atom + JSON Feed | RSS + JSON Feed |
++| Also | redirects, AI authorship + review, index events | taxonomy, scheduling, media attachments |
++
++```bash
++node examples/two-products/wiki/app.js # http://127.0.0.1:4801/p/rye
++node examples/two-products/blog/app.js # http://127.0.0.1:4811/posts/first-loaf
++```
++
++Both use temp databases and made-up sample text. The assertions live in
++[../../test/two-products.test.js](../../test/two-products.test.js).
ddiff --git a/examples/two-products/blog/app.js b/examples/two-products/blog/app.js
nnew file mode 100644
iindex 0000000..14e70fc
---- /dev/null
++++ b/examples/two-products/blog/app.js
@@@ -0,0 +1,155 @@
++'use strict';
++/**
++ * Mini blog — the second example consumer of openvibe-publishing, with its own SQLite file and its
++ * own publication state (blog_posts). Different editorial rules from the wiki (sources optional,
++ * shorter minimum), same shared mechanics.
++ *
++ * node examples/two-products/blog/app.js # http://127.0.0.1:4811 with a temp database
++ *
++ * Uses: revisions (blog_post_*), citations (blog_post_citations), taxonomy (blog_terms), schedule
++ * (blog_schedule_jobs), media attachments (blog_post_attachments), seo gate + sitemap + RSS/JSON
++ * Feed, ssr.
++ */
++const http = require('http');
++const path = require('path');
++const os = require('os');
++const fs = require('fs');
++const Database = require('better-sqlite3');
++const { createRevisionStore } = require('openvibe-publishing/revisions');
++const { createCitationStore } = require('openvibe-publishing/citations');
++const { createTaxonomy, slugify } = require('openvibe-publishing/taxonomy');
++const { createScheduler } = require('openvibe-publishing/schedule');
++const { createAttachmentStore, figureHtml } = require('openvibe-publishing/media');
++const seo = require('openvibe-publishing/seo');
++const ssr = require('openvibe-publishing/ssr');
++
++const POLICY = { minWords: 20, requireSources: false };
++
++function createBlog({ dbPath, origin = 'https://openvibe.blog', mediaOrigin = 'https://openvibe.media', now = () => Date.now() } = {}) {
++ const db = new Database(dbPath);
++ db.pragma('journal_mode = WAL');
++ db.exec(`
++ CREATE TABLE IF NOT EXISTS blog_posts (
++ id TEXT PRIMARY KEY,
++ slug TEXT NOT NULL UNIQUE,
++ author_name TEXT,
++ visibility TEXT NOT NULL DEFAULT 'public',
++ state TEXT NOT NULL DEFAULT 'draft',
++ published_revision INTEGER,
++ published_at INTEGER,
++ updated_at INTEGER NOT NULL
++ );
++ `);
++ const revisions = createRevisionStore(db, { prefix: 'blog_post', now });
++ const citations = createCitationStore(db, { prefix: 'blog_post', now, revisions });
++ const taxonomy = createTaxonomy(db, { prefix: 'blog', now });
++ const scheduler = createScheduler(db, { prefix: 'blog', now, leaseMs: 60000 });
++ const media = createAttachmentStore(db, { prefix: 'blog_post', now });
++
++ const urlOf = (slug) => seo.canonicalUrl(origin, ['posts', slug]);
++ const getPost = (id) => db.prepare('SELECT * FROM blog_posts WHERE id = ?').get(id);
++
++ function decide(post, rev) {
++ return seo.evaluate({
++ state: post.state, visibility: post.visibility, canonicalUrl: urlOf(post.slug),
++ text: ssr.markdownToText(rev.content), citationCount: citations.forRevision(post.id, rev.number).length,
++ }, { policy: POLICY, now: now() });
++ }
++
++ /** The idempotent effect a scheduled job applies: "revision N is the published one". */
++ function applyPublish(id, revision) {
++ const info = db.prepare("UPDATE blog_posts SET state = 'published', published_revision = ?, published_at = COALESCE(published_at, ?), updated_at = ? WHERE id = ? AND (state IS NOT 'published' OR published_revision IS NOT ?)")
++ .run(revision, now(), now(), id, revision);
++ return { changed: info.changes };
++ }
++
++ const api = {
++ db, revisions, citations, taxonomy, scheduler, media, policy: POLICY,
++
++ draft({ title, body, authorName = null, tags = [], sources = [] }) {
++ return db.transaction(() => {
++ const slug = slugify(title);
++ const id = `post_${slug}`;
++ db.prepare('INSERT INTO blog_posts (id, slug, author_name, updated_at) VALUES (?, ?, ?, ?)').run(id, slug, authorName, now());
++ const { revision } = revisions.create({ entityId: id, expectedRevision: 0, content: body, fields: { title } });
++ citations.attachMany(id, revision.number, sources);
++ taxonomy.setTerms(id, 'tag', tags);
++ return { id, revision: revision.number };
++ })();
++ },
++
++ edit(id, { expectedRevision, body, title }) {
++ const head = revisions.head(id);
++ return revisions.create({ entityId: id, expectedRevision, content: body, fields: { title: title || head.fields.title } }).revision.number;
++ },
++
++ publishNow(id, revision) { return applyPublish(id, revision); },
++ schedulePublish(id, { revision, at }) { return scheduler.schedule({ entityId: id, action: 'publish', runAt: at, revision }); },
++ runScheduled(worker) {
++ return scheduler.runDue({ worker, handler: (job) => (job.action === 'publish' ? applyPublish(job.entityId, job.revision) : null) });
++ },
++ setVisibility(id, visibility) { db.prepare('UPDATE blog_posts SET visibility = ?, updated_at = ? WHERE id = ?').run(visibility, now(), id); },
++
++ published() {
++ return db.prepare("SELECT * FROM blog_posts WHERE state = 'published' ORDER BY published_at DESC, id").all().map((post) => {
++ const rev = revisions.get(post.id, post.published_revision);
++ return { post, rev, decision: decide(post, rev) };
++ });
++ },
++
++ feedItems() {
++ return api.published().map(({ post, rev, decision }) => ({
++ id: `tag:openvibe.blog,2026:post/${post.id}`, url: urlOf(post.slug), title: rev.fields.title,
++ summary: ssr.markdownToText(rev.content, 200), contentHtml: ssr.renderMarkdown(rev.content),
++ published: post.published_at, updated: rev.createdAt, decision,
++ authors: post.author_name ? [{ name: post.author_name }] : [],
++ tags: taxonomy.termsFor(post.id, 'tag').map((t) => t.name),
++ }));
++ },
++ rss() { return seo.rssFeed({ title: 'Mini blog', link: `${origin}/`, description: 'Posts from the mini blog example', feedUrl: `${origin}/feed.xml` }, api.feedItems()); },
++ jsonFeedDoc() { return seo.jsonFeed({ title: 'Mini blog', link: `${origin}/`, feedUrl: `${origin}/feed.json` }, api.feedItems()); },
++ sitemapXml() { return seo.sitemap(api.published().map(({ post, rev, decision }) => ({ loc: urlOf(post.slug), lastmod: rev.createdAt, decision }))).files[0]; },
++
++ renderPost(post) {
++ const rev = revisions.get(post.id, post.published_revision);
++ const decision = decide(post, rev);
++ const figures = media.list(post.id).map((a) => ssr.raw(figureHtml(a, { urlFor: (id) => `${mediaOrigin}/o/${id}` })));
++ const tags = taxonomy.termsFor(post.id, 'tag');
++ const head = seo.metaTags({ title: rev.fields.title, description: ssr.markdownToText(rev.content, 160), decision, type: 'article',
++ jsonLd: seo.structuredData.article({ type: 'BlogPosting', headline: rev.fields.title, url: urlOf(post.slug),
++ datePublished: post.published_at ? new Date(post.published_at).toISOString() : null, dateModified: rev.createdAt,
++ authors: post.author_name ? [{ name: post.author_name }] : [], keywords: tags.map((t) => t.name) }) });
++ return String(ssr.html`<!doctype html><html lang="en"><head><meta charset="utf-8">${ssr.raw(head)}${ssr.raw(seo.feedLinks([{ type: 'rss', href: '/feed.xml', title: 'RSS' }]))}</head><body>
++<article><h1>${rev.fields.title}</h1><p>${post.author_name ? ssr.html`By ${post.author_name} · ` : ''}${ssr.raw(ssr.timeTag(post.published_at))}</p>
++${figures}
++${ssr.raw(ssr.renderMarkdown(rev.content))}
++<p>${tags.map((t) => ssr.html`<a href="/tags/${t.slug}">#${t.name}</a> `)}</p></article></body></html>`);
++ },
++
++ handler(req, res) {
++ const url = new URL(req.url, origin);
++ const send = (status, type, body) => { res.writeHead(status, { 'Content-Type': type }); res.end(body); };
++ if (url.pathname === '/feed.xml') return send(200, 'application/rss+xml', api.rss());
++ if (url.pathname === '/feed.json') return send(200, 'application/feed+json', JSON.stringify(api.jsonFeedDoc()));
++ if (url.pathname === '/sitemap.xml') return send(200, 'application/xml', api.sitemapXml());
++ const m = url.pathname.match(/^\/posts\/([^/]+)$/);
++ const post = m && db.prepare('SELECT * FROM blog_posts WHERE slug = ?').get(decodeURIComponent(m[1]));
++ if (!post || post.state !== 'published' || post.visibility === 'private' || post.visibility === 'gated') return send(404, 'text/plain', 'Not found');
++ return send(200, 'text/html; charset=utf-8', api.renderPost(post));
++ },
++
++ close() { db.close(); },
++ };
++ return api;
++}
++
++module.exports = { createBlog };
++
++if (require.main === module) {
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mini-blog-'));
++ const blog = createBlog({ dbPath: path.join(dir, 'blog.db'), origin: 'http://127.0.0.1:4811' });
++ const { id, revision } = blog.draft({ title: 'First loaf', authorName: 'Alex', tags: ['bread', 'rye'],
++ body: 'My first rye loaf came out dense but tasty. Next time I will give the starter a longer rise and bake it a little hotter.' });
++ blog.publishNow(id, revision);
++ http.createServer(blog.handler).listen(4811, '127.0.0.1', () => console.log(`mini blog on http://127.0.0.1:4811/posts/first-loaf (db ${dir}/blog.db)`));
++}
ddiff --git a/examples/two-products/wiki/app.js b/examples/two-products/wiki/app.js
nnew file mode 100644
iindex 0000000..5277bcc
---- /dev/null
++++ b/examples/two-products/wiki/app.js
@@@ -0,0 +1,249 @@
++'use strict';
++/**
++ * Mini wiki — an example consumer of openvibe-publishing. It owns its own SQLite file and its own
++ * publication state (wiki_pages); the packages only supply the mechanics.
++ *
++ * node examples/two-products/wiki/app.js # http://127.0.0.1:4801 with a temp database
++ *
++ * Uses: revisions (wiki_page_*), citations (wiki_citations), seo gate + redirects + sitemap +
++ * Atom/JSON feeds, authorship (AI pages start as drafts), index-hooks (events into wiki_outbox), ssr.
++ */
++const http = require('http');
++const path = require('path');
++const os = require('os');
++const fs = require('fs');
++const Database = require('better-sqlite3');
++// The package's public entry points, exactly as a product would import them.
++const { createRevisionStore } = require('openvibe-publishing/revisions');
++const { createCitationStore } = require('openvibe-publishing/citations');
++const seo = require('openvibe-publishing/seo');
++const authorship = require('openvibe-publishing/authorship');
++const hooks = require('openvibe-publishing/index-hooks');
++const ssr = require('openvibe-publishing/ssr');
++const { slugify } = require('openvibe-publishing/taxonomy');
++
++const POLICY = { minWords: 30, requireSources: true, minSources: 1 };
++
++function createWiki({ dbPath, origin = 'https://openvibe.wiki', now = () => Date.now() } = {}) {
++ const db = new Database(dbPath);
++ db.pragma('journal_mode = WAL');
++ db.exec(`
++ CREATE TABLE IF NOT EXISTS wiki_pages (
++ id TEXT PRIMARY KEY,
++ slug TEXT NOT NULL UNIQUE,
++ owner TEXT NOT NULL,
++ visibility TEXT NOT NULL DEFAULT 'public',
++ state TEXT NOT NULL DEFAULT 'draft',
++ published_revision INTEGER,
++ published_at INTEGER,
++ updated_at INTEGER NOT NULL
++ );
++ CREATE TABLE IF NOT EXISTS wiki_outbox (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ envelope TEXT NOT NULL
++ );
++ `);
++ const revisions = createRevisionStore(db, { prefix: 'wiki_page', now });
++ const citations = createCitationStore(db, { prefix: 'wiki', now, revisions });
++ const redirects = seo.createRedirectStore(db, { prefix: 'wiki_page', now });
++ const reviews = authorship.createReviewLog(db, { prefix: 'wiki_page', now });
++
++ const pathOf = (slug) => `/p/${slug}`;
++ const urlOf = (slug) => seo.canonicalUrl(origin, pathOf(slug));
++ const getPage = (id) => db.prepare('SELECT * FROM wiki_pages WHERE id = ?').get(id);
++ const bySlug = (slug) => db.prepare('SELECT * FROM wiki_pages WHERE slug = ?').get(slug);
++
++ function decide(page, rev) {
++ const rec = rev.meta.authorship;
++ return seo.evaluate({
++ state: page.state, visibility: page.visibility, canonicalUrl: urlOf(page.slug),
++ text: ssr.markdownToText(rev.content), citationCount: citations.forRevision(page.id, rev.number).length,
++ ...(rec ? authorship.gateFacts(rec, reviews.latest(page.id, rev.number)) : {}),
++ }, { policy: POLICY, now: now() });
++ }
++
++ function document(page, rev, decision) {
++ return hooks.buildIndexDocument({
++ service: 'wiki', type: 'page', id: page.id, revision: rev ? rev.number : 0, state: page.state, visibility: page.visibility,
++ canonicalUrl: urlOf(page.slug), title: rev ? rev.fields.title : page.slug, summary: rev ? ssr.markdownToText(rev.content, 160) : null,
++ body: rev ? ssr.markdownToText(rev.content) : '', decision, acl: { subjects: [page.owner] },
++ provenance: { authorship: rev && rev.meta.authorship, citations: rev ? citations.forRevision(page.id, rev.number) : [] },
++ publishedAt: page.published_at, updatedAt: page.updated_at,
++ });
++ }
++
++ function emit(before, page, actor) {
++ const rev = page.published_revision ? revisions.get(page.id, page.published_revision) : null;
++ const action = hooks.actionFor(before && { state: before.state, visibility: before.visibility, revision: before.published_revision },
++ { state: page.state, visibility: page.visibility, revision: page.published_revision });
++ if (!action) return null;
++ const decision = rev ? decide(page, rev) : null;
++ const env = hooks.publicationEvent({ product: 'wiki', type: 'page', action, id: page.id, revision: page.published_revision || 0, actor, document: document(page, rev, decision), decision, now: now() });
++ db.prepare('INSERT INTO wiki_outbox (envelope) VALUES (?)').run(JSON.stringify(env));
++ return env;
++ }
++
++ const api = {
++ db, revisions, citations, redirects, reviews, policy: POLICY,
++
++ createPage({ title, body, author, sources = [], authorship: rec = authorship.record({ mode: 'human', authors: [author] }) }) {
++ return db.transaction(() => {
++ const id = `pg_${slugify(title)}`;
++ db.prepare('INSERT INTO wiki_pages (id, slug, owner, updated_at) VALUES (?, ?, ?, ?)').run(id, slugify(title), author, now());
++ const { revision } = revisions.create({ entityId: id, expectedRevision: 0, content: body, fields: { title }, meta: { authorship: rec }, author });
++ citations.attachMany(id, revision.number, sources);
++ return { id, revision: revision.number, initial: authorship.initialState(rec) };
++ })();
++ },
++
++ /** Edit: new revision; `keepSources` carries earlier citations forward, `sources` adds new ones. */
++ edit(id, { expectedRevision, body, title, author, sources = [], keepSources = [] }) {
++ return db.transaction(() => {
++ const head = revisions.head(id);
++ const { revision } = revisions.create({
++ entityId: id, expectedRevision, content: body, fields: { title: title || head.fields.title },
++ meta: { authorship: authorship.record({ mode: 'human', authors: [author] }) }, author,
++ });
++ if (keepSources.length) citations.carryForward({ entityId: id, fromRevision: head.number, toRevision: revision.number, ids: keepSources });
++ citations.attachMany(id, revision.number, sources);
++ return revision.number;
++ })();
++ },
++
++ revert(id, { toRevision, expectedRevision, author }) {
++ return db.transaction(() => {
++ const { revision } = revisions.revert({ entityId: id, toRevision, expectedRevision, author });
++ citations.carryForward({ entityId: id, fromRevision: toRevision, toRevision: revision.number });
++ return revision.number;
++ })();
++ },
++
++ publish(id, { revision, actor }) {
++ return db.transaction(() => {
++ const before = getPage(id);
++ const rev = revisions.get(id, revision);
++ const rec = rev.meta.authorship;
++ const ok = authorship.canPublish(rec, reviews.latest(id, revision));
++ if (!ok.ok) throw Object.assign(new Error(`cannot publish: ${ok.reason}`), { status: 409, code: ok.reason });
++ db.prepare("UPDATE wiki_pages SET state = 'published', published_revision = ?, published_at = COALESCE(published_at, ?), updated_at = ? WHERE id = ?").run(revision, now(), now(), id);
++ return emit(before, getPage(id), actor);
++ })();
++ },
++
++ setVisibility(id, visibility, { actor }) {
++ return db.transaction(() => {
++ const before = getPage(id);
++ db.prepare('UPDATE wiki_pages SET visibility = ?, updated_at = ? WHERE id = ?').run(visibility, now(), id);
++ return emit(before, getPage(id), actor);
++ })();
++ },
++
++ rename(id, newTitle) {
++ return db.transaction(() => {
++ const page = getPage(id);
++ const slug = slugify(newTitle);
++ redirects.recordMove(id, pathOf(page.slug), pathOf(slug));
++ db.prepare('UPDATE wiki_pages SET slug = ?, updated_at = ? WHERE id = ?').run(slug, now(), id);
++ return slug;
++ })();
++ },
++
++ remove(id, { actor }) {
++ return db.transaction(() => {
++ const before = getPage(id);
++ db.prepare("UPDATE wiki_pages SET state = 'deleted', updated_at = ? WHERE id = ?").run(now(), id);
++ return emit(before, getPage(id), actor);
++ })();
++ },
++
++ outbox() { return db.prepare('SELECT envelope FROM wiki_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope)); },
++
++ /** Every page with its live revision and gate decision. */
++ published() {
++ return db.prepare("SELECT * FROM wiki_pages WHERE state = 'published' ORDER BY published_at DESC, id").all().map((page) => {
++ const rev = revisions.get(page.id, page.published_revision);
++ return { page, rev, decision: decide(page, rev) };
++ });
++ },
++
++ sitemapXml() {
++ return seo.sitemap(api.published().map(({ page, rev, decision }) => ({ loc: urlOf(page.slug), lastmod: rev.createdAt, decision }))).files[0];
++ },
++
++ feedItems() {
++ return api.published().map(({ page, rev, decision }) => ({
++ id: `tag:openvibe.wiki,2026:page/${page.id}`, url: urlOf(page.slug), title: rev.fields.title,
++ summary: ssr.markdownToText(rev.content, 200), contentHtml: ssr.renderMarkdown(rev.content, { rel: 'noopener' }),
++ published: page.published_at, updated: rev.createdAt, decision,
++ }));
++ },
++
++ atom() {
++ // Atom needs a feed-level <updated>: the last real change to any page (never "now").
++ const last = db.prepare('SELECT MAX(updated_at) AS t FROM wiki_pages').get().t;
++ return seo.atomFeed({ title: 'Mini wiki: recent pages', link: origin + '/', feedUrl: `${origin}/feed.atom`, updated: last }, api.feedItems());
++ },
++ jsonFeedDoc() { return seo.jsonFeed({ title: 'Mini wiki: recent pages', link: origin + '/', feedUrl: `${origin}/feed.json` }, api.feedItems()); },
++
++ renderPage(page) {
++ const rev = revisions.get(page.id, page.published_revision);
++ const decision = decide(page, rev);
++ const cites = citations.forRevision(page.id, rev.number);
++ const disclosure = authorship.disclosure(rev.meta.authorship, reviews.latest(page.id, rev.number));
++ const crumbs = [{ name: 'Wiki', url: `${origin}/` }, { name: rev.fields.title, url: urlOf(page.slug) }];
++ const head = seo.metaTags({ title: rev.fields.title, description: ssr.markdownToText(rev.content, 160), decision, type: 'article',
++ jsonLd: [seo.structuredData.article({ headline: rev.fields.title, url: urlOf(page.slug), dateModified: rev.createdAt,
++ datePublished: page.published_at ? new Date(page.published_at).toISOString() : null, citations: cites }), seo.structuredData.breadcrumbs(crumbs)].filter(Boolean) });
++ return String(ssr.html`<!doctype html><html lang="en"><head><meta charset="utf-8">${ssr.raw(head)}</head><body>
++${ssr.raw(ssr.breadcrumbsHtml(crumbs))}
++<article><h1>${rev.fields.title}</h1>${disclosure ? ssr.html`<p class="disclosure">${disclosure.long}</p>` : ''}
++${ssr.raw(ssr.renderMarkdown(rev.content, { rel: 'noopener' }))}
++<p>Revision ${rev.number}, ${ssr.raw(ssr.timeTag(rev.createdAt))} · <a href="${pathOf(page.slug)}/history">history</a></p>
++<section><h2>Sources</h2><ol>${cites.map((c) => ssr.html`<li>${c.url ? ssr.html`<a href="${c.url}" rel="noopener">${c.title || c.url}</a>` : c.sourceItemId}${c.retrievedAt ? ssr.html`, retrieved ${ssr.raw(ssr.timeTag(c.retrievedAt))}` : ''}${c.licenseNote ? ` (${c.licenseNote})` : ''}</li>`)}</ol></section>
++</article></body></html>`);
++ },
++
++ /** Node http handler: pages, 301/410 history, sitemap, feeds. No JavaScript anywhere. */
++ handler(req, res) {
++ const url = new URL(req.url, origin);
++ const send = (status, type, body, headers = {}) => { res.writeHead(status, { 'Content-Type': type, ...headers }); res.end(body); };
++ if (url.pathname === '/sitemap.xml') return send(200, 'application/xml', api.sitemapXml());
++ if (url.pathname === '/feed.atom') return send(200, 'application/atom+xml', api.atom());
++ if (url.pathname === '/feed.json') return send(200, 'application/feed+json', JSON.stringify(api.jsonFeedDoc()));
++ if (url.pathname === '/robots.txt') return send(200, 'text/plain', seo.robotsTxt({ sitemaps: [`${origin}/sitemap.xml`] }));
++ const m = url.pathname.match(/^\/p\/([^/]+)(\/history)?$/);
++ if (!m) return send(404, 'text/plain', 'Not found');
++ const page = bySlug(decodeURIComponent(m[1]));
++ if (!page) {
++ const r = redirects.resolve(url.pathname.replace(/\/history$/, ''), { currentPath: (id) => { const p = getPage(id); return p && p.state !== 'deleted' ? pathOf(p.slug) : null; } });
++ if (r && r.status === 301) return send(301, 'text/plain', 'Moved', { Location: r.location + (m[2] || '') });
++ if (r && r.status === 410) return send(410, 'text/plain', 'Gone');
++ return send(404, 'text/plain', 'Not found');
++ }
++ if (page.state === 'deleted') return send(410, 'text/plain', 'Gone');
++ if (page.state !== 'published' || page.visibility === 'private' || page.visibility === 'gated') return send(404, 'text/plain', 'Not found');
++ if (m[2]) {
++ const list = revisions.list(page.id).map((r) => ssr.html`<li>r${r.number} ${r.kind} ${ssr.raw(ssr.timeTag(r.createdAt))}</li>`);
++ return send(200, 'text/html; charset=utf-8', String(ssr.html`<!doctype html><title>History</title><meta name="robots" content="noindex"><ol>${list}</ol>`));
++ }
++ return send(200, 'text/html; charset=utf-8', api.renderPage(page));
++ },
++
++ close() { db.close(); },
++ };
++ return api;
++}
++
++module.exports = { createWiki };
++
++if (require.main === module) {
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mini-wiki-'));
++ const wiki = createWiki({ dbPath: path.join(dir, 'wiki.db'), origin: 'http://127.0.0.1:4801' });
++ const { id } = wiki.createPage({
++ title: 'Rye', author: 'usr_01J8Z6Q3KX0000000000000000',
++ body: 'Rye is a grass grown extensively as a grain, a cover crop and a forage crop. It is closely related to wheat and barley, and its grain is used for flour, bread, beer, whiskey and animal fodder.',
++ sources: [{ url: 'https://en.wikipedia.org/wiki/Rye', title: 'Rye — Wikipedia', licenseNote: 'CC BY-SA 4.0' }],
++ });
++ wiki.publish(id, { revision: 1, actor: 'usr_01J8Z6Q3KX0000000000000000' });
++ http.createServer(wiki.handler).listen(4801, '127.0.0.1', () => console.log(`mini wiki on http://127.0.0.1:4801/p/rye (db ${dir}/wiki.db)`));
++}
ddiff --git a/index.js b/index.js
nnew file mode 100644
iindex 0000000..c500a70
---- /dev/null
++++ b/index.js
@@@ -0,0 +1,28 @@
++'use strict';
++/**
++ * 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
++ * This root export loads each one lazily, on first access.
++ */
++const MODULES = {
++ revisions: './lib/revisions',
++ schedule: './lib/schedule',
++ taxonomy: './lib/taxonomy',
++ citations: './lib/citations',
++ media: './lib/media',
++ discussion: './lib/discussion',
++ seo: './lib/seo',
++ authorship: './lib/authorship',
++ indexHooks: './lib/index-hooks',
++ ssr: './lib/ssr',
++};
++
++const api = {};
++for (const [name, path] of Object.entries(MODULES)) {
++ Object.defineProperty(api, name, { enumerable: true, get: () => require(path) });
++}
++Object.defineProperty(api, 'PublishingError', { enumerable: true, get: () => require('./lib/internal').PublishingError });
++Object.defineProperty(api, 'version', { enumerable: true, value: require('./package.json').version });
++
++module.exports = api;
ddiff --git a/lib/authorship.js b/lib/authorship.js
nnew file mode 100644
iindex 0000000..3363811
---- /dev/null
++++ b/lib/authorship.js
@@@ -0,0 +1,150 @@
++'use strict';
++/**
++ * openvibe-publishing/authorship — who made a revision (human | ai | hybrid | imported), which
++ * OpenVibe.AI workflow run produced it, the disclosure label, and the human review that lets
++ * AI-generated content leave draft/noindex.
++ *
++ * const authorship = require('openvibe-publishing/authorship');
++ * const rec = authorship.record({ mode: 'ai', workflow: { id: 'wiki.generate_page', version: 3, runId: 'run_…' }, source: { label: 'Sources item src_…' } });
++ * authorship.initialState(rec) // → { state: 'draft', noindex: true, reason: 'ai_generated_unreviewed' }
++ * authorship.disclosure(rec).short // → 'AI-generated'
++ * const reviews = authorship.createReviewLog(db, { prefix: 'wiki_page' }); // wiki_page_reviews
++ * reviews.record({ entityId, revision: 4, reviewer: 'usr_…', decision: 'approved' });
++ * authorship.gateFacts(rec, reviews.latest(entityId, 4)) // → { authorship: { mode: 'ai', reviewed: true } }
++ *
++ * Modes (§15.12 names in brackets): human; ai [AI-generated]; hybrid [AI-assisted: a person wrote or
++ * cut it from an AI suggestion]; imported [legacy/imported, keeps the original author].
++ * Rules:
++ * - ai and hybrid records must name the AI workflow and run (workflow.id + workflow.runId).
++ * - human records must not carry a workflow (that would hide AI involvement the other way round).
++ * - AI-generated content starts as draft + noindex; only a review by a person (a usr_ subject,
++ * never a service or the AI itself) marks it reviewed.
++ * - The record is metadata the product stores with the revision (revisions `meta.authorship`);
++ * reviews are append-only rows, so the revision itself stays immutable.
++ * Authorship is data, not a sentence: surfaces render the disclosure from this record.
++ */
++const { PublishingError, assertDb, assertPrefix, clockOf, assertEntityId, assertRevisionNumber } = require('./internal');
++
++const MODES = ['human', 'ai', 'hybrid', 'imported'];
++const USER_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const SUBJECT_RE = /^(usr|gst)_[0-9A-HJKMNP-TV-Z]{26}$/;
++const WORKFLOW_RE = /^[a-z][a-z0-9_]*(\.[a-z0-9_]+)+$/;
++
++function record({ mode, authors = [], workflow = null, stubProvider = false, source = null, importedFrom = null } = {}) {
++ if (!MODES.includes(mode)) throw new TypeError(`mode must be one of ${MODES.join(', ')}`);
++ for (const a of authors) if (!SUBJECT_RE.test(String(a))) throw new TypeError('authors must be usr_/gst_ subject ids');
++ let wf = null;
++ if (workflow != null) {
++ if (!workflow.id || !WORKFLOW_RE.test(workflow.id)) throw new TypeError('workflow.id must be an OpenVibe.AI workflow key like "wiki.generate_page"');
++ if (!workflow.runId || typeof workflow.runId !== 'string') throw new TypeError('workflow.runId is required');
++ wf = { id: workflow.id, runId: workflow.runId };
++ if (workflow.version != null) wf.version = workflow.version;
++ if (workflow.model != null) wf.model = String(workflow.model);
++ }
++ if ((mode === 'ai' || mode === 'hybrid') && !wf) throw new PublishingError(400, 'authorship.workflow_required', `${mode} authorship must reference the AI workflow and run`);
++ if (mode === 'human' && wf) throw new PublishingError(400, 'authorship.workflow_on_human', 'human authorship cannot carry an AI workflow; use hybrid');
++ if ((mode === 'human' || mode === 'hybrid') && !authors.length) throw new PublishingError(400, 'authorship.author_required', `${mode} authorship names the person accountable`);
++ if (mode === 'imported' && !importedFrom) throw new PublishingError(400, 'authorship.import_source_required', 'imported authorship names where it came from');
++ const out = { mode, authors: [...authors] };
++ if (wf) out.workflow = wf;
++ if (stubProvider) out.stubProvider = true;
++ if (source) out.source = { label: source.label == null ? null : String(source.label), ref: source.ref || null };
++ if (importedFrom) out.importedFrom = { label: String(importedFrom.label || ''), originalAuthor: importedFrom.originalAuthor == null ? null : String(importedFrom.originalAuthor) };
++ return out;
++}
++
++/** Only AI-generated content needs a review before it may be published and indexed. */
++function needsReview(rec) { return rec.mode === 'ai' || Boolean(rec.stubProvider); }
++
++function isReviewed(rec, review) {
++ return Boolean(review && review.decision === 'approved' && USER_RE.test(String(review.reviewer)));
++}
++
++/** Where a new revision starts. AI-generated → draft + noindex until reviewed. */
++function initialState(rec, review = null) {
++ if (needsReview(rec) && !isReviewed(rec, review)) return { state: 'draft', noindex: true, reason: rec.mode === 'ai' ? 'ai_generated_unreviewed' : 'stub_provider' };
++ return { state: 'draft', noindex: false, reason: null };
++}
++
++/** { ok, reason } — whether this revision may be published now. */
++function canPublish(rec, review = null) {
++ if (needsReview(rec) && !isReviewed(rec, review)) return { ok: false, reason: rec.mode === 'ai' ? 'ai_generated_unreviewed' : 'stub_provider' };
++ return { ok: true, reason: null };
++}
++
++/** Facts for seo.evaluate: { authorship: { mode, reviewed }, stubProvider }. */
++function gateFacts(rec, review = null) {
++ const reviewed = isReviewed(rec, review);
++ const out = { authorship: { mode: rec.mode, reviewed } };
++ if (rec.stubProvider) out.stubProvider = true;
++ return out;
++}
++
++/**
++ * Disclosure text, rendered at the item (not in a footer). Returns null for plain human content.
++ * { short, long, mode }
++ */
++function disclosure(rec, review = null, { sourceLabel } = {}) {
++ const src = sourceLabel || (rec.source && rec.source.label) || null;
++ const wf = rec.workflow ? `${rec.workflow.id}${rec.workflow.version != null ? ` v${rec.workflow.version}` : ''}` : null;
++ const reviewed = isReviewed(rec, review);
++ switch (rec.mode) {
++ case 'human': return null;
++ case 'ai': return {
++ mode: 'ai',
++ short: 'AI-generated',
++ long: `AI-generated${src ? ` from ${src}` : ''} by workflow ${wf}${reviewed ? ', reviewed by a person' : ', not yet reviewed by a person'}.`,
++ };
++ case 'hybrid': return { mode: 'hybrid', short: 'Written with AI assistance', long: `Written by a person with AI assistance (workflow ${wf})${src ? `, from ${src}` : ''}.` };
++ case 'imported': return {
++ mode: 'imported',
++ short: 'Imported',
++ long: `Imported from ${rec.importedFrom.label}${rec.importedFrom.originalAuthor ? `; originally by ${rec.importedFrom.originalAuthor}` : ''}.`,
++ };
++ default: return null;
++ }
++}
++
++/** Append-only review log: <prefix>_reviews. */
++function createReviewLog(db, { prefix, now } = {}) {
++ assertDb(db);
++ assertPrefix(prefix);
++ const clock = clockOf(now);
++ const R = `${prefix}_reviews`;
++ db.exec(`
++ CREATE TABLE IF NOT EXISTS ${R} (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ entity_id TEXT NOT NULL,
++ revision INTEGER NOT NULL,
++ reviewer TEXT NOT NULL,
++ decision TEXT NOT NULL CHECK (decision IN ('approved','rejected')),
++ note TEXT,
++ reviewed_at INTEGER NOT NULL
++ );
++ CREATE INDEX IF NOT EXISTS ${R}_rev ON ${R} (entity_id, revision, id);
++ CREATE TRIGGER IF NOT EXISTS ${R}_no_update BEFORE UPDATE ON ${R} BEGIN SELECT RAISE(ABORT, '${R} rows are immutable'); END;
++ `);
++ const shape = (r) => (r ? { id: r.id, entityId: r.entity_id, revision: r.revision, reviewer: r.reviewer, decision: r.decision, note: r.note, reviewedAt: new Date(r.reviewed_at).toISOString() } : null);
++ const q = {
++ insert: db.prepare(`INSERT INTO ${R} (entity_id, revision, reviewer, decision, note, reviewed_at) VALUES (?, ?, ?, ?, ?, ?)`),
++ latest: db.prepare(`SELECT * FROM ${R} WHERE entity_id = ? AND revision = ? ORDER BY id DESC LIMIT 1`),
++ all: db.prepare(`SELECT * FROM ${R} WHERE entity_id = ? ORDER BY id`),
++ byId: db.prepare(`SELECT * FROM ${R} WHERE id = ?`),
++ };
++ return {
++ table: R,
++ /** A person's review of one revision. The reviewer must be a usr_ subject. */
++ record({ entityId, revision, reviewer, decision, note = null } = {}) {
++ assertEntityId(entityId);
++ assertRevisionNumber(revision, 'revision');
++ if (!USER_RE.test(String(reviewer))) throw new PublishingError(400, 'review.reviewer_not_person', 'Only a person (usr_ subject) can review');
++ if (decision !== 'approved' && decision !== 'rejected') throw new TypeError('decision must be approved or rejected');
++ const info = q.insert.run(entityId, revision, reviewer, decision, note == null ? null : String(note).slice(0, 2000), clock());
++ return shape(q.byId.get(info.lastInsertRowid));
++ },
++ latest(entityId, revision) { return shape(q.latest.get(assertEntityId(entityId), revision)); },
++ history(entityId) { return q.all.all(assertEntityId(entityId)).map(shape); },
++ };
++}
++
++module.exports = { MODES, record, needsReview, isReviewed, initialState, canPublish, gateFacts, disclosure, createReviewLog };
ddiff --git a/lib/citations.js b/lib/citations.js
nnew file mode 100644
iindex 0000000..77cb46d
---- /dev/null
++++ b/lib/citations.js
@@@ -0,0 +1,205 @@
++'use strict';
++/**
++ * openvibe-publishing/citations — source references attached to one specific revision.
++ *
++ * const { createCitationStore } = require('openvibe-publishing/citations');
++ * const cites = createCitationStore(db, { prefix: 'wiki', revisions }); // wiki_citations
++ * cites.attach({ entityId: 'pg_1', revision: 2, url: 'https://example.org/a', retrievedAt: '2026-09-20T10:00:00Z',
++ * quote: { text: '…', start: 120, end: 180 }, licenseNote: 'CC BY 4.0', sourceItemId: 'src_…' });
++ * cites.carryForward({ entityId: 'pg_1', fromRevision: 2, toRevision: 3 }); // reuse in r3
++ * cites.forRevision('pg_1', 2); // still there after r3 dropped them
++ *
++ * Rules:
++ * - A citation belongs to (entity, revision). It is never dropped: the table is append-only
++ * (UPDATE and DELETE abort in SQLite triggers), so a later revision that does not reuse a
++ * source leaves the earlier revision's citations intact. Only purgeEntity (audited) removes rows.
++ * - Every citation names a source: a Sources item id, an http(s) URL, or both.
++ * - Nothing is filled in: a missing retrieved_at, title or license note stays null. This module
++ * never stamps "now" as a retrieval time.
++ * - If a revisions store is passed, attach() refuses revisions that do not exist.
++ */
++const { PublishingError, assertDb, assertPrefix, clockOf, assertEntityId, assertRevisionNumber, isoOrNull, immutableTriggers } = require('./internal');
++
++function shape(row) {
++ if (!row) return null;
++ const quote = row.quote_text == null && row.quote_start == null ? null
++ : { text: row.quote_text, start: row.quote_start, end: row.quote_end };
++ return {
++ id: row.id,
++ entityId: row.entity_id,
++ revision: row.revision,
++ anchor: row.anchor,
++ sourceItemId: row.source_item_id,
++ url: row.url,
++ title: row.title,
++ retrievedAt: row.retrieved_at,
++ quote,
++ licenseNote: row.license_note,
++ carriedFrom: row.carried_from,
++ attachedBy: row.attached_by,
++ attachedAt: new Date(row.attached_at).toISOString(),
++ };
++}
++
++function checkUrl(url) {
++ if (url == null || url === '') return null;
++ let u;
++ try { u = new URL(String(url)); } catch { throw new TypeError('url must be an absolute URL'); }
++ if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new TypeError('url must be http(s)');
++ return u.toString();
++}
++
++function checkQuote(quote) {
++ if (quote == null) return { text: null, start: null, end: null };
++ if (typeof quote !== 'object') throw new TypeError('quote must be { text, start, end }');
++ const text = quote.text == null ? null : String(quote.text).slice(0, 5000);
++ const start = quote.start == null ? null : Number(quote.start);
++ const end = quote.end == null ? null : Number(quote.end);
++ if ((start == null) !== (end == null)) throw new TypeError('quote.start and quote.end go together');
++ if (start != null && (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start)) throw new TypeError('quote span must be 0 <= start <= end');
++ if (text == null && start == null) return { text: null, start: null, end: null };
++ return { text, start, end };
++}
++
++function createCitationStore(db, { prefix, now, revisions = null } = {}) {
++ assertDb(db);
++ assertPrefix(prefix);
++ const clock = clockOf(now);
++ const C = `${prefix}_citations`;
++ const P = `${prefix}_citation_purges`;
++
++ db.exec(`
++ CREATE TABLE IF NOT EXISTS ${C} (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ entity_id TEXT NOT NULL,
++ revision INTEGER NOT NULL CHECK (revision >= 1),
++ anchor TEXT,
++ source_item_id TEXT,
++ url TEXT,
++ title TEXT,
++ retrieved_at TEXT,
++ quote_text TEXT,
++ quote_start INTEGER,
++ quote_end INTEGER,
++ license_note TEXT,
++ carried_from INTEGER REFERENCES ${C}(id),
++ attached_by TEXT,
++ attached_at INTEGER NOT NULL,
++ CHECK (source_item_id IS NOT NULL OR url IS NOT NULL)
++ );
++ CREATE INDEX IF NOT EXISTS ${C}_rev ON ${C} (entity_id, revision);
++ CREATE INDEX IF NOT EXISTS ${C}_source ON ${C} (source_item_id);
++ CREATE TABLE IF NOT EXISTS ${P} (
++ entity_id TEXT PRIMARY KEY,
++ reason TEXT NOT NULL,
++ purged_by TEXT,
++ purged_at INTEGER NOT NULL
++ );
++ ${immutableTriggers(C, P)}
++ `);
++
++ const q = {
++ insert: db.prepare(`INSERT INTO ${C} (entity_id, revision, anchor, source_item_id, url, title, retrieved_at, quote_text, quote_start, quote_end, license_note, carried_from, attached_by, attached_at)
++ VALUES (@entity_id, @revision, @anchor, @source_item_id, @url, @title, @retrieved_at, @quote_text, @quote_start, @quote_end, @license_note, @carried_from, @attached_by, @attached_at)`),
++ byId: db.prepare(`SELECT * FROM ${C} WHERE id = ?`),
++ forRevision: db.prepare(`SELECT * FROM ${C} WHERE entity_id = ? AND revision = ? ORDER BY id`),
++ history: db.prepare(`SELECT * FROM ${C} WHERE entity_id = ? ORDER BY revision, id`),
++ bySource: db.prepare(`SELECT * FROM ${C} WHERE source_item_id = ? ORDER BY entity_id, revision, id`),
++ purged: db.prepare(`SELECT 1 FROM ${P} WHERE entity_id = ?`),
++ };
++
++ function checkRevision(entityId, revision) {
++ assertRevisionNumber(revision, 'revision');
++ if (revision < 1) throw new TypeError('revision must be >= 1');
++ if (revisions && !revisions.get(entityId, revision)) throw new PublishingError(404, 'revision.not_found', `No revision ${revision} of ${entityId}`);
++ }
++
++ function insert(entityId, revision, c, carriedFrom = null) {
++ const sourceItemId = c.sourceItemId == null || c.sourceItemId === '' ? null : String(c.sourceItemId).slice(0, 128);
++ const url = checkUrl(c.url);
++ if (!sourceItemId && !url) throw new PublishingError(400, 'citation.no_source', 'A citation needs a Sources item id or an http(s) URL');
++ const quote = checkQuote(c.quote);
++ const row = {
++ entity_id: entityId,
++ revision,
++ anchor: c.anchor == null ? null : String(c.anchor).slice(0, 100),
++ source_item_id: sourceItemId,
++ url,
++ title: c.title == null ? null : String(c.title).slice(0, 500),
++ retrieved_at: isoOrNull(c.retrievedAt, 'retrievedAt'),
++ quote_text: quote.text,
++ quote_start: quote.start,
++ quote_end: quote.end,
++ license_note: c.licenseNote == null ? null : String(c.licenseNote).slice(0, 1000),
++ carried_from: carriedFrom,
++ attached_by: c.attachedBy == null ? null : String(c.attachedBy),
++ attached_at: clock(),
++ };
++ return shape(q.byId.get(q.insert.run(row).lastInsertRowid));
++ }
++
++ const api = {
++ table: C,
++
++ /** Attach one citation to (entityId, revision). */
++ attach({ entityId, revision, ...citation } = {}) {
++ assertEntityId(entityId);
++ checkRevision(entityId, revision);
++ if (q.purged.get(entityId)) throw new PublishingError(410, 'entity.purged', `${entityId} was purged`);
++ return insert(entityId, revision, citation);
++ },
++
++ /** Attach several in one transaction. */
++ attachMany(entityId, revision, list = []) {
++ assertEntityId(entityId);
++ checkRevision(entityId, revision);
++ if (q.purged.get(entityId)) throw new PublishingError(410, 'entity.purged', `${entityId} was purged`);
++ return db.transaction(() => list.map((c) => insert(entityId, revision, c)))();
++ },
++
++ /**
++ * Reuse citations of `fromRevision` in `toRevision` (all, or only `ids`). New rows point back
++ * with carried_from; the originals are untouched.
++ */
++ carryForward({ entityId, fromRevision, toRevision, ids = null, attachedBy = null } = {}) {
++ assertEntityId(entityId);
++ checkRevision(entityId, toRevision);
++ return db.transaction(() => {
++ const wanted = ids ? new Set(ids.map(Number)) : null;
++ return q.forRevision.all(entityId, fromRevision)
++ .filter((r) => !wanted || wanted.has(r.id))
++ .map((r) => {
++ const c = shape(r);
++ return insert(entityId, toRevision, { ...c, attachedBy: attachedBy || c.attachedBy }, r.carried_from || r.id);
++ });
++ })();
++ },
++
++ get(id) { return shape(q.byId.get(Number(id))); },
++ forRevision(entityId, revision) { return q.forRevision.all(assertEntityId(entityId), revision).map(shape); },
++ /** Every citation any revision of the entity ever had, oldest revision first. */
++ history(entityId) { return q.history.all(assertEntityId(entityId)).map(shape); },
++ /** Where a Sources item is cited — for correction/removal propagation (a source changed → revise). */
++ bySourceItem(sourceItemId) { return q.bySource.all(String(sourceItemId)).map(shape); },
++
++ /** Audited erasure of every citation of one entity. */
++ purgeEntity(entityId, { reason, purgedBy = null } = {}) {
++ if (!reason) throw new TypeError('a purge needs a recorded reason');
++ return db.transaction(() => {
++ db.prepare(`INSERT OR IGNORE INTO ${P} (entity_id, reason, purged_by, purged_at) VALUES (?, ?, ?, ?)`).run(assertEntityId(entityId), String(reason), purgedBy, clock());
++ return { deleted: db.prepare(`DELETE FROM ${C} WHERE entity_id = ?`).run(entityId).changes };
++ })();
++ },
++ };
++ return api;
++}
++
++/** Plain facts for the SEO gate: how many citations, and how many name a retrieval time. */
++function gateFacts(citations = []) {
++ return {
++ citationCount: citations.length,
++ datedCitationCount: citations.filter((c) => c && c.retrievedAt).length,
++ };
++}
++
++module.exports = { createCitationStore, gateFacts };
ddiff --git a/lib/diff.js b/lib/diff.js
nnew file mode 100644
iindex 0000000..7f71d2e
---- /dev/null
++++ b/lib/diff.js
@@@ -0,0 +1,118 @@
++'use strict';
++/**
++ * Line and word diffs (Myers' O(ND) algorithm over tokens), used by revisions.diff and ssr.diffHtml.
++ *
++ * diffText(a, b, { mode: 'line' | 'word' }) ->
++ * { mode, ops: [{ op: 'equal'|'insert'|'delete', text }], added, removed, approximate }
++ *
++ * `added` / `removed` count tokens (lines or words). Common prefix and suffix are trimmed before
++ * the search; if the two texts differ by more than `maxEdits` tokens the middle is reported as one
++ * delete plus one insert and `approximate` is true, so a pathological input cannot exhaust memory.
++ */
++
++const WORD_RE = /\s+|[\p{L}\p{N}_]+|[^\s\p{L}\p{N}_]/gu;
++
++function tokenize(text, mode) {
++ const s = String(text == null ? '' : text);
++ if (!s) return [];
++ if (mode === 'word') return s.match(WORD_RE) || [];
++ return s.match(/[^\n]*\n|[^\n]+$/g) || [];
++}
++
++function myers(a, b, maxEdits) {
++ const n = a.length;
++ const m = b.length;
++ const max = n + m;
++ const offset = max + 1;
++ const v = new Int32Array(2 * max + 3);
++ const trace = [];
++ for (let d = 0; d <= max; d++) {
++ if (d > maxEdits) return null;
++ trace.push(v.slice(offset - d - 1, offset + d + 2));
++ for (let k = -d; k <= d; k += 2) {
++ let x = (k === -d || (k !== d && v[offset + k - 1] < v[offset + k + 1])) ? v[offset + k + 1] : v[offset + k - 1] + 1;
++ let y = x - k;
++ while (x < n && y < m && a[x] === b[y]) { x++; y++; }
++ v[offset + k] = x;
++ if (x >= n && y >= m) return backtrack(trace, a, b);
++ }
++ }
++ return backtrack(trace, a, b);
++}
++
++function backtrack(trace, a, b) {
++ const ops = [];
++ let x = a.length;
++ let y = b.length;
++ for (let d = trace.length - 1; d >= 0; d--) {
++ const row = trace[d];
++ const at = (k) => row[k + d + 1];
++ const k = x - y;
++ const prevK = (k === -d || (k !== d && at(k - 1) < at(k + 1))) ? k + 1 : k - 1;
++ const prevX = at(prevK);
++ const prevY = prevX - prevK;
++ while (x > prevX && y > prevY) { ops.push({ op: 'equal', text: a[x - 1] }); x--; y--; }
++ if (d > 0) {
++ if (x === prevX) { ops.push({ op: 'insert', text: b[y - 1] }); y--; }
++ else { ops.push({ op: 'delete', text: a[x - 1] }); x--; }
++ }
++ x = prevX;
++ y = prevY;
++ }
++ return ops.reverse();
++}
++
++function merge(ops) {
++ const out = [];
++ for (const o of ops) {
++ const last = out[out.length - 1];
++ if (last && last.op === o.op) last.text += o.text;
++ else out.push({ op: o.op, text: o.text });
++ }
++ return out;
++}
++
++function diffTokens(a, b, { maxEdits = 4000 } = {}) {
++ let start = 0;
++ while (start < a.length && start < b.length && a[start] === b[start]) start++;
++ let endA = a.length;
++ let endB = b.length;
++ while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) { endA--; endB--; }
++ const midA = a.slice(start, endA);
++ const midB = b.slice(start, endB);
++ let middle = myers(midA, midB, maxEdits);
++ let approximate = false;
++ if (!middle) {
++ approximate = true;
++ middle = [...midA.map((t) => ({ op: 'delete', text: t })), ...midB.map((t) => ({ op: 'insert', text: t }))];
++ }
++ const ops = [
++ ...a.slice(0, start).map((t) => ({ op: 'equal', text: t })),
++ ...middle,
++ ...a.slice(endA).map((t) => ({ op: 'equal', text: t })),
++ ];
++ let added = 0;
++ let removed = 0;
++ for (const o of ops) {
++ if (o.op === 'insert' && o.text.trim()) added++;
++ if (o.op === 'delete' && o.text.trim()) removed++;
++ }
++ return { ops: merge(ops), added, removed, approximate };
++}
++
++function diffText(a, b, { mode = 'line', maxEdits } = {}) {
++ if (mode !== 'line' && mode !== 'word') throw new TypeError('mode must be "line" or "word"');
++ return { mode, ...diffTokens(tokenize(a, mode), tokenize(b, mode), { maxEdits }) };
++}
++
++/** Unified-style text for a line diff: "+ added", "- removed", " kept". */
++function formatLines(result) {
++ const out = [];
++ for (const o of result.ops) {
++ const sign = o.op === 'insert' ? '+ ' : o.op === 'delete' ? '- ' : ' ';
++ for (const line of tokenize(o.text, 'line')) out.push(sign + line.replace(/\n$/, ''));
++ }
++ return out.join('\n');
++}
++
++module.exports = { diffText, formatLines, tokenize };
ddiff --git a/lib/discussion.js b/lib/discussion.js
nnew file mode 100644
iindex 0000000..357002e
---- /dev/null
++++ b/lib/discussion.js
@@@ -0,0 +1,132 @@
++'use strict';
++/**
++ * openvibe-publishing/discussion — Community comment-thread references. Reference, never copy.
++ *
++ * const { createDiscussionClient, createDiscussionRefs } = require('openvibe-publishing/discussion');
++ * const community = createDiscussionClient({ communityUrl: 'https://openvibe.community', tokenClient });
++ * const refs = createDiscussionRefs(db, { prefix: 'wiki' }); // wiki_discussion_refs
++ * const { threadId } = await refs.threadFor('pg_1', { service: 'wiki', type: 'page', id: 'pg_1', label: 'Sourdough' }, { client: community });
++ *
++ * The thread and its comments live in OpenVibe.Community. This module calls
++ * `POST /api/v1/comments/threads/resolve { ref: EntityRef }` (get-or-create; 201 created, 200
++ * existing) with the product's Network service token (`community.comment.write`, audience
++ * `openvibe.community`), and stores only the thread id next to the entity. There is no column for
++ * comment text, counts or authors, on purpose: products render the thread by embedding or by
++ * reading Community, so moderation and deletion there are always what readers see.
++ *
++ * tokenClient is anything with `authHeaders()` → { Authorization } (openvibe-contracts'
++ * serviceAuth.createTokenClient). Failures are errors (code 'discussion.unavailable' or the
++ * problem code Community returned) — never a fabricated thread.
++ */
++const { PublishingError, assertDb, assertPrefix, clockOf, assertEntityId, parseJson } = require('./internal');
++
++const SERVICE_RE = /^[a-z][a-z0-9-]{1,39}$/;
++const TYPE_RE = /^[a-z][a-z0-9_]{1,39}$/;
++const SUBJECT_RE = /^(usr|gst)_[0-9A-HJKMNP-TV-Z]{26}$/;
++const TRACEPARENT_RE = /^[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$/;
++
++/** Validates and normalises an EntityRef (common.entity-ref@1). */
++function entityRef({ service, type, id, label, revision } = {}) {
++ if (!SERVICE_RE.test(String(service || ''))) throw new TypeError('ref.service must be a service slug');
++ if (!TYPE_RE.test(String(type || ''))) throw new TypeError('ref.type must match ^[a-z][a-z0-9_]{1,39}$');
++ if (typeof id !== 'string' || !id || id.length > 128) throw new TypeError('ref.id must be a 1–128 character string');
++ const ref = { service, type, id };
++ if (Number.isInteger(revision) && revision >= 0) ref.revision = revision;
++ if (label != null && label !== '') ref.label = String(label).slice(0, 200);
++ return ref;
++}
++
++function createDiscussionClient({ communityUrl, tokenClient, fetchImpl = globalThis.fetch, timeoutMs = 5000 } = {}) {
++ if (!communityUrl) throw new TypeError('communityUrl is required');
++ if (!tokenClient || typeof tokenClient.authHeaders !== 'function') throw new TypeError('tokenClient with authHeaders() is required');
++ if (typeof fetchImpl !== 'function') throw new TypeError('fetch is required');
++ const base = String(communityUrl).replace(/\/+$/, '');
++
++ return {
++ base,
++ /**
++ * Get-or-create the Community thread for an entity.
++ * opts: subject (usr_/gst_ id the product acts for; omit to act as the service),
++ * traceparent, requestId.
++ * → { threadId, created, visibility }
++ */
++ async resolveThread(ref, { subject, traceparent, requestId } = {}) {
++ const body = { ref: entityRef(ref) };
++ const headers = { 'Content-Type': 'application/json', Accept: 'application/json', ...(await tokenClient.authHeaders()) };
++ if (subject) {
++ if (!SUBJECT_RE.test(subject)) throw new TypeError('subject must be a usr_… or gst_… id');
++ headers['X-OV-Subject'] = subject;
++ }
++ if (traceparent && TRACEPARENT_RE.test(traceparent)) headers.traceparent = traceparent;
++ if (requestId) headers['X-OpenVibe-Request-Id'] = String(requestId).slice(0, 128);
++ const ctrl = new AbortController();
++ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
++ let res;
++ try {
++ res = await fetchImpl(`${base}/api/v1/comments/threads/resolve`, { method: 'POST', headers, body: JSON.stringify(body), signal: ctrl.signal });
++ } catch (err) {
++ throw new PublishingError(503, 'discussion.unavailable', `Community did not answer: ${err && err.name === 'AbortError' ? 'timeout' : String(err && err.message || err)}`);
++ } finally {
++ clearTimeout(timer);
++ }
++ const text = await res.text().catch(() => '');
++ const json = parseJson(text, null);
++ if (res.status === 401 && typeof tokenClient.invalidate === 'function') tokenClient.invalidate();
++ if (!res.ok) {
++ const code = json && typeof json.code === 'string' ? json.code : 'discussion.unavailable';
++ throw new PublishingError(res.status >= 500 ? 503 : res.status, code, (json && (json.detail || json.title)) || `Community answered ${res.status}`, { upstreamStatus: res.status });
++ }
++ const thread = json && json.thread;
++ if (!thread || typeof thread.id !== 'string' && typeof thread.id !== 'number') {
++ throw new PublishingError(502, 'discussion.bad_response', 'Community returned no thread id');
++ }
++ return { threadId: String(thread.id), created: res.status === 201 || Boolean(json.created), visibility: thread.visibility || null };
++ },
++ };
++}
++
++/** Local table of entity → Community thread id. Stores the reference only. */
++function createDiscussionRefs(db, { prefix, now } = {}) {
++ assertDb(db);
++ assertPrefix(prefix);
++ const clock = clockOf(now);
++ const R = `${prefix}_discussion_refs`;
++ db.exec(`
++ CREATE TABLE IF NOT EXISTS ${R} (
++ entity_id TEXT PRIMARY KEY,
++ thread_id TEXT NOT NULL,
++ ref TEXT NOT NULL,
++ resolved_at INTEGER NOT NULL
++ );
++ `);
++ const q = {
++ get: db.prepare(`SELECT * FROM ${R} WHERE entity_id = ?`),
++ put: db.prepare(`INSERT INTO ${R} (entity_id, thread_id, ref, resolved_at) VALUES (?, ?, ?, ?)
++ ON CONFLICT (entity_id) DO UPDATE SET thread_id = excluded.thread_id, ref = excluded.ref, resolved_at = excluded.resolved_at`),
++ del: db.prepare(`DELETE FROM ${R} WHERE entity_id = ?`),
++ };
++ const shape = (row) => (row ? { entityId: row.entity_id, threadId: row.thread_id, ref: parseJson(row.ref, null), resolvedAt: new Date(row.resolved_at).toISOString() } : null);
++
++ const api = {
++ table: R,
++ get(entityId) { return shape(q.get.get(assertEntityId(entityId))); },
++ /** Record a thread id obtained elsewhere (e.g. from a Community event). */
++ set(entityId, threadId, ref) {
++ q.put.run(assertEntityId(entityId), String(threadId), JSON.stringify(entityRef(ref)), clock());
++ return api.get(entityId);
++ },
++ forget(entityId) { return q.del.run(assertEntityId(entityId)).changes > 0; },
++ /** The stored thread id, or resolve it through Community once and store it. */
++ async threadFor(entityId, ref, { client, ...opts } = {}) {
++ const known = api.get(entityId);
++ if (known) return { threadId: known.threadId, created: false, cached: true };
++ if (!client) throw new TypeError('client (createDiscussionClient) is required to resolve a new thread');
++ const out = await client.resolveThread(ref, opts);
++ api.set(entityId, out.threadId, ref);
++ return { ...out, cached: false };
++ },
++ };
++ return api;
++}
++
++module.exports = { createDiscussionClient, createDiscussionRefs, entityRef };
ddiff --git a/lib/index-hooks.js b/lib/index-hooks.js
nnew file mode 100644
iindex 0000000..b8226c1
---- /dev/null
++++ b/lib/index-hooks.js
@@@ -0,0 +1,197 @@
++'use strict';
++/**
++ * openvibe-publishing/index-hooks — Search index documents and publication event envelopes.
++ *
++ * const hooks = require('openvibe-publishing/index-hooks');
++ * const doc = hooks.buildIndexDocument({ service: 'wiki', type: 'page', id: 'pg_1', revision: 4, state: 'published',
++ * visibility: 'public', canonicalUrl, title, summary, body, facets: { space: 'food' }, provenance, decision });
++ * const action = hooks.actionFor(before, after); // 'published' | 'updated' | 'unpublished' | 'deleted' | null
++ * const env = hooks.publicationEvent({ product: 'wiki', type: 'page', action, id: 'pg_1', revision: 4,
++ * actor: 'usr_…', document: doc, decision, now: Date.now() });
++ * outbox.enqueue(env); // OpenVibe.Events createOutbox, in the same transaction as the product's change
++ *
++ * The index document is the Search contract of [REF] §12.3 (owner service, resource type/id,
++ * revision, visibility + ACL, canonical URL, title, summary, body, facets, provenance, publication
++ * state, deletion marker) plus the gate's indexability decision. Proposed schema:
++ * docs/contracts-proposal/search.index-document.v1.json.
++ *
++ * Safety rules built in:
++ * - Anything not published, deleted, or unlisted becomes a tombstone (deletion marker, no
++ * title/summary/body), so a visibility change or deletion removes content from the index.
++ * - private content must carry an ACL naming subjects; gated content must name entitlements.
++ * There is no way to build a private document that reads as world-visible.
++ * - Events about non-public or non-listable content are 'internal', never 'public'.
++ * - The envelope carries no event_id; the outbox assigns evt_<ULID> (or pass eventId).
++ */
++const SERVICE_RE = /^[a-z][a-z0-9-]{1,39}$/;
++const SEGMENT_RE = /^[a-z][a-z0-9_]{0,39}$/;
++const ULID = '[0-9A-HJKMNP-TV-Z]{26}';
++const SUBJECT_ID_RE = new RegExp(`^(usr|gst|app|mod)_${ULID}$`);
++const ACTIONS = ['published', 'updated', 'unpublished', 'deleted'];
++const SCHEMA = 'search.index-document@1';
++const MAX_BODY = 100000;
++
++function assertDecision(d) {
++ if (!d || typeof d.indexable !== 'boolean' || typeof d.listable !== 'boolean') throw new TypeError('decision (seo.evaluate) is required');
++ return { indexable: d.indexable, listable: d.listable, reasons: Array.isArray(d.codes) ? [...d.codes] : [], gate: d.gate || null };
++}
++
++function iso(v, name) {
++ if (v == null || v === '') return null;
++ const d = new Date(v);
++ if (Number.isNaN(d.getTime())) throw new TypeError(`${name} must be a valid date`);
++ return d.toISOString();
++}
++
++function checkFacets(facets) {
++ const out = {};
++ for (const [k, v] of Object.entries(facets || {})) {
++ if (!SEGMENT_RE.test(k)) throw new TypeError(`facet name "${k}" must match ${SEGMENT_RE}`);
++ if (v == null) continue;
++ const ok = (x) => typeof x === 'string' || typeof x === 'boolean' || (typeof x === 'number' && Number.isFinite(x));
++ if (Array.isArray(v) ? !v.every(ok) : !ok(v)) throw new TypeError(`facet "${k}" must be a string, number, boolean or an array of them`);
++ out[k] = Array.isArray(v) ? [...v] : v;
++ }
++ return out;
++}
++
++function checkProvenance(p = {}) {
++ const out = {};
++ if (p.authorship) {
++ out.authorship = { mode: p.authorship.mode };
++ if (p.authorship.workflow) out.authorship.workflow = { id: p.authorship.workflow.id, run_id: p.authorship.workflow.runId };
++ if (p.authorship.reviewed != null) out.authorship.reviewed = Boolean(p.authorship.reviewed);
++ }
++ if (Array.isArray(p.citations)) {
++ out.citations = p.citations.map((c) => {
++ const o = {};
++ if (c.sourceItemId) o.source_item_id = String(c.sourceItemId);
++ if (c.url) o.url = String(c.url);
++ if (c.retrievedAt) o.retrieved_at = iso(c.retrievedAt, 'citation.retrievedAt');
++ if (c.licenseNote) o.license_note = String(c.licenseNote);
++ return o;
++ }).filter((o) => o.source_item_id || o.url);
++ }
++ if (p.derivedFrom) out.derived_from = p.derivedFrom;
++ return out;
++}
++
++function checkIdentity({ service, type, id, revision }) {
++ if (!SERVICE_RE.test(String(service || ''))) throw new TypeError('service must be a service slug');
++ if (!SEGMENT_RE.test(String(type || ''))) throw new TypeError('type must match ^[a-z][a-z0-9_]{0,39}$');
++ if (typeof id !== 'string' || !id || id.length > 200) throw new TypeError('id must be a non-empty string');
++ if (!Number.isInteger(revision) || revision < 0) throw new TypeError('revision must be a non-negative integer');
++}
++
++/** A deletion marker: Search removes (resource, revision ≤ this) and caches purge it. */
++function tombstone({ service, type, id, revision, state = 'deleted', updatedAt = null }) {
++ checkIdentity({ service, type, id, revision });
++ return {
++ schema: SCHEMA, owner_service: service, resource_type: type, resource_id: id, revision,
++ publication_state: state, deleted: true, updated_at: iso(updatedAt, 'updatedAt'),
++ };
++}
++
++/**
++ * Build the Search document for a revision. Non-published, deleted and unlisted resources come
++ * back as tombstones.
++ */
++function buildIndexDocument({
++ service, type, id, revision, state, visibility, acl = {}, canonicalUrl, title, summary = null, body = '',
++ facets = {}, provenance = {}, decision, publishedAt = null, updatedAt = null, locale = null, deleted = false,
++} = {}) {
++ checkIdentity({ service, type, id, revision });
++ if (deleted || state !== 'published' || visibility === 'unlisted') {
++ return tombstone({ service, type, id, revision, state: deleted ? 'deleted' : state || 'deleted', updatedAt });
++ }
++ const indexability = assertDecision(decision);
++ let docAcl;
++ if (visibility === 'public') docAcl = { public: true };
++ else if (visibility === 'private') {
++ const subjects = (acl.subjects || []).map(String);
++ if (!subjects.length || !subjects.every((s) => SUBJECT_ID_RE.test(s))) throw new TypeError('private documents need acl.subjects (usr_/gst_/app_/mod_ ids)');
++ docAcl = { public: false, subjects };
++ } else if (visibility === 'gated') {
++ const entitlements = (acl.entitlements || []).map(String).filter(Boolean);
++ if (!entitlements.length) throw new TypeError('gated documents need acl.entitlements');
++ docAcl = { public: false, entitlements, ...(acl.subjects && acl.subjects.length ? { subjects: acl.subjects.map(String) } : {}) };
++ } else {
++ throw new TypeError('visibility must be public, private, gated or unlisted');
++ }
++ if (!canonicalUrl || !/^https?:\/\//.test(canonicalUrl)) throw new TypeError('canonicalUrl must be absolute');
++ if (!title) throw new TypeError('title is required');
++ const text = String(body == null ? '' : body);
++ const doc = {
++ schema: SCHEMA, owner_service: service, resource_type: type, resource_id: id, revision,
++ visibility, acl: docAcl, canonical_url: canonicalUrl, title: String(title),
++ summary: summary == null ? null : String(summary), body: text.slice(0, MAX_BODY),
++ facets: checkFacets(facets), provenance: checkProvenance(provenance),
++ publication_state: 'published', deleted: false, indexability,
++ published_at: iso(publishedAt, 'publishedAt'), updated_at: iso(updatedAt, 'updatedAt'),
++ };
++ if (text.length > MAX_BODY) doc.body_truncated = true;
++ if (locale) doc.locale = String(locale);
++ return doc;
++}
++
++/**
++ * Which publication event a transition produces. before/after: { state, visibility, revision }
++ * (before may be null for a new resource). → 'published' | 'updated' | 'unpublished' | 'deleted' | null
++ */
++function actionFor(before, after) {
++ const was = Boolean(before && before.state === 'published');
++ if (after.state === 'deleted') return before && before.state === 'deleted' ? null : (before ? 'deleted' : null);
++ const is = after.state === 'published';
++ if (!was && is) return 'published';
++ if (was && !is) return 'unpublished';
++ if (was && is && (before.revision !== after.revision || before.visibility !== after.visibility)) return 'updated';
++ return null;
++}
++
++/** 'usr_…' | 'svc:wiki' | 'service:wiki' | { type, id } → SubjectRef. */
++function subjectRef(actor) {
++ if (actor && typeof actor === 'object') return { type: actor.type, id: actor.id };
++ const s = String(actor || '');
++ if (/^svc:/.test(s)) return { type: 'service', id: s.slice(4) };
++ const m = s.match(/^(user|guest|service|system|app|mod):(.+)$/);
++ if (m) return { type: m[1], id: m[2] };
++ if (/^usr_/.test(s)) return { type: 'user', id: s };
++ if (/^gst_/.test(s)) return { type: 'guest', id: s };
++ throw new TypeError('actor must be a subject (usr_…, gst_…, svc:<service>) or a SubjectRef');
++}
++
++/**
++ * `<product>.<type>.<action>` envelope (contracts events/event-envelope@1) for the product's outbox.
++ * document: the index document (or tombstone) for this revision — it becomes the payload.
++ */
++function publicationEvent({ product, type, action, id, revision, actor, document, decision = null, now = Date.now(), traceId = null, eventId = null, extra = {} } = {}) {
++ if (!SERVICE_RE.test(String(product || '')) || !SEGMENT_RE.test(String(product))) throw new TypeError('product must be a lowercase service slug without dashes, e.g. "wiki"');
++ if (!SEGMENT_RE.test(String(type || ''))) throw new TypeError('type must match ^[a-z][a-z0-9_]{0,39}$');
++ if (!ACTIONS.includes(action)) throw new TypeError(`action must be one of ${ACTIONS.join(', ')}`);
++ if (!document || document.resource_id !== id) throw new TypeError('document (buildIndexDocument/tombstone) for this resource is required');
++ if ((action === 'deleted' || action === 'unpublished') && !document.deleted) throw new TypeError(`${action} events carry a tombstone`);
++ // A published-but-unlisted resource is a tombstone with publication_state 'published': it may be updated, not listed.
++ if ((action === 'published' || action === 'updated') && document.publication_state !== 'published') throw new TypeError(`${action} events carry a published document`);
++ const isPublic = !document.deleted && document.visibility === 'public' && document.indexability && document.indexability.listable;
++ const env = {
++ event_type: `${product}.${type}.${action}`,
++ version: 1,
++ source: product,
++ actor: subjectRef(actor),
++ timestamp: new Date(now).toISOString(),
++ visibility: isPublic ? 'public' : 'internal',
++ subject: { type, id, revision },
++ payload: {
++ canonical_url: document.canonical_url || null,
++ publication_state: document.publication_state,
++ indexability: decision ? assertDecision(decision) : document.indexability || null,
++ document,
++ ...extra,
++ },
++ };
++ if (eventId) env.event_id = eventId;
++ if (traceId && /^[0-9a-f]{32}$/.test(traceId)) env.trace_id = traceId;
++ return env;
++}
++
++module.exports = { SCHEMA, ACTIONS, buildIndexDocument, tombstone, actionFor, publicationEvent, subjectRef };
ddiff --git a/lib/internal.js b/lib/internal.js
nnew file mode 100644
iindex 0000000..1d549e2
---- /dev/null
++++ b/lib/internal.js
@@@ -0,0 +1,110 @@
++'use strict';
++/**
++ * Helpers shared by the modules in this package. Not a public entry point.
++ *
++ * Every storage module works on a better-sqlite3 handle the consumer owns and names its tables
++ * with the consumer's prefix, so a product's publication state stays in the product's database.
++ */
++const crypto = require('crypto');
++
++const PREFIX_RE = /^[a-z][a-z0-9_]{0,39}$/;
++const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
++
++/** An error with an HTTP status and a stable code, ready for contracts.http.sendProblem(res, status, code, …). */
++class PublishingError extends Error {
++ constructor(status, code, message, extra = {}) {
++ super(message);
++ this.name = 'PublishingError';
++ this.status = status;
++ this.code = code;
++ Object.assign(this, extra);
++ }
++}
++
++function assertDb(db) {
++ if (!db || typeof db.prepare !== 'function' || typeof db.transaction !== 'function') {
++ throw new TypeError('a better-sqlite3 database handle is required');
++ }
++}
++
++function assertPrefix(prefix) {
++ if (typeof prefix !== 'string' || !PREFIX_RE.test(prefix)) {
++ throw new TypeError(`prefix must match ${PREFIX_RE} (got ${JSON.stringify(prefix)})`);
++ }
++ return prefix;
++}
++
++function clockOf(now) {
++ if (now == null) return () => Date.now();
++ if (typeof now !== 'function') throw new TypeError('now must be a function returning epoch milliseconds');
++ return () => {
++ const t = Number(now());
++ if (!Number.isFinite(t)) throw new TypeError('now() must return epoch milliseconds');
++ return t;
++ };
++}
++
++/** Time-sortable id with a type prefix: rev_01J…, job_01J…. */
++function newId(prefix, nowMs = Date.now()) {
++ let time = '';
++ for (let t = Math.floor(nowMs), i = 0; i < 10; i++, t = Math.floor(t / 32)) time = ALPHABET[t % 32] + time;
++ const bytes = crypto.randomBytes(16);
++ let rand = '';
++ for (let i = 0; i < 16; i++) rand += ALPHABET[bytes[i] % 32];
++ return `${prefix}_${time}${rand}`;
++}
++
++function sha256(text) {
++ return crypto.createHash('sha256').update(String(text)).digest('hex');
++}
++
++/** Stable JSON: object keys sorted, so the same fields always hash the same. */
++function stableStringify(value) {
++ if (value === undefined) return 'null';
++ if (value === null || typeof value !== 'object') return JSON.stringify(value);
++ if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
++ const keys = Object.keys(value).filter((k) => value[k] !== undefined).sort();
++ return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`;
++}
++
++function parseJson(text, fallback = null) {
++ if (text == null) return fallback;
++ try { return JSON.parse(text); } catch { return fallback; }
++}
++
++function assertEntityId(id, name = 'entityId') {
++ if (typeof id !== 'string' || !id || id.length > 200) throw new TypeError(`${name} must be a non-empty string of at most 200 characters`);
++ return id;
++}
++
++function assertRevisionNumber(n, name = 'revision') {
++ if (!Number.isInteger(n) || n < 0) throw new TypeError(`${name} must be a non-negative integer`);
++ return n;
++}
++
++/** ISO-8601 instant or null. Never invents a time: missing stays null. */
++function isoOrNull(value, name) {
++ if (value == null || value === '') return null;
++ const d = value instanceof Date ? value : new Date(value);
++ if (Number.isNaN(d.getTime())) throw new TypeError(`${name} must be a valid date`);
++ return d.toISOString();
++}
++
++/**
++ * Append-only protection for a table: UPDATE always aborts, DELETE aborts unless the entity was
++ * first registered in `<purgeTable>` (an audited, explicit erasure path).
++ */
++function immutableTriggers(table, purgeTable, keyColumn = 'entity_id') {
++ return `
++ CREATE TRIGGER IF NOT EXISTS ${table}_no_update BEFORE UPDATE ON ${table}
++ BEGIN SELECT RAISE(ABORT, '${table} rows are immutable'); END;
++ CREATE TRIGGER IF NOT EXISTS ${table}_no_delete BEFORE DELETE ON ${table}
++ WHEN NOT EXISTS (SELECT 1 FROM ${purgeTable} WHERE entity_id = OLD.${keyColumn})
++ BEGIN SELECT RAISE(ABORT, '${table} rows are never deleted outside a recorded purge'); END;
++ `;
++}
++
++module.exports = {
++ PublishingError, assertDb, assertPrefix, clockOf, newId, sha256, stableStringify, parseJson,
++ assertEntityId, assertRevisionNumber, isoOrNull, immutableTriggers, PREFIX_RE,
++};
ddiff --git a/lib/media.js b/lib/media.js
nnew file mode 100644
iindex 0000000..f581f6f
---- /dev/null
++++ b/lib/media.js
@@@ -0,0 +1,192 @@
++'use strict';
++/**
++ * openvibe-publishing/media — attachments by OpenVibe.Media object id, with an explicit
++ * broken-asset state.
++ *
++ * const { createAttachmentStore } = require('openvibe-publishing/media');
++ * const media = createAttachmentStore(db, { prefix: 'blog_post' }); // blog_post_attachments
++ * media.attach({ entityId: 'post_1', mediaId: 'med_01J…', role: 'cover', alt: 'A loaf' });
++ * // Media says the object is gone (media.object.deleted event, or a 404 on fetch):
++ * media.markBroken('med_01J…', 'deleted');
++ * media.list('post_1') // → [{ …, state: 'broken', brokenReason: 'deleted' }]
++ *
++ * Bytes never live here: a row is a reference (MediaRef: med_<ULID> or the transitional
++ * legacy:<app>:<kind>:<id>). States:
++ * unverified attached, never checked
++ * available the last check found the object
++ * broken the object is gone (not_found | deleted | forbidden) — rendered as an explicit
++ * "media unavailable" placeholder, never silently dropped or replaced
++ * A check that fails for another reason (timeout, 5xx) changes nothing and is reported as
++ * check_failed: an outage is not evidence the object is gone, nor that it exists.
++ */
++const { PublishingError, assertDb, assertPrefix, clockOf, assertEntityId } = require('./internal');
++
++const MEDIA_ID_RE = /^(med_[0-9A-HJKMNP-TV-Z]{26}|legacy:[a-z][a-z0-9-]{1,39}:(vod|clip|file|paste|thumbnail|avatar):[A-Za-z0-9._/-]{1,200})$/;
++const ROLE_RE = /^[a-z][a-z0-9_]{1,39}$/;
++const VARIANT_RE = /^[a-z0-9][a-z0-9_-]{0,39}$/;
++const STATES = ['unverified', 'available', 'broken'];
++const BROKEN_REASONS = ['not_found', 'deleted', 'forbidden'];
++
++function isMediaId(id) { return typeof id === 'string' && MEDIA_ID_RE.test(id); }
++
++function shape(row) {
++ if (!row) return null;
++ return {
++ id: row.id,
++ entityId: row.entity_id,
++ revision: row.revision,
++ mediaId: row.media_id,
++ role: row.role,
++ variant: row.variant,
++ alt: row.alt,
++ caption: row.caption,
++ position: row.position,
++ state: row.state,
++ broken: row.state === 'broken',
++ brokenReason: row.broken_reason,
++ checkedAt: row.checked_at == null ? null : new Date(row.checked_at).toISOString(),
++ };
++}
++
++function createAttachmentStore(db, { prefix, now } = {}) {
++ assertDb(db);
++ assertPrefix(prefix);
++ const clock = clockOf(now);
++ const A = `${prefix}_attachments`;
++
++ db.exec(`
++ CREATE TABLE IF NOT EXISTS ${A} (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ entity_id TEXT NOT NULL,
++ revision INTEGER,
++ media_id TEXT NOT NULL,
++ role TEXT NOT NULL DEFAULT 'inline',
++ variant TEXT,
++ alt TEXT,
++ caption TEXT,
++ position INTEGER NOT NULL DEFAULT 0,
++ state TEXT NOT NULL DEFAULT 'unverified' CHECK (state IN ('unverified','available','broken')),
++ broken_reason TEXT CHECK (broken_reason IS NULL OR broken_reason IN ('not_found','deleted','forbidden')),
++ checked_at INTEGER,
++ created_at INTEGER NOT NULL,
++ CHECK ((state = 'broken') = (broken_reason IS NOT NULL))
++ );
++ CREATE INDEX IF NOT EXISTS ${A}_entity ON ${A} (entity_id, position);
++ CREATE INDEX IF NOT EXISTS ${A}_media ON ${A} (media_id);
++ `);
++
++ const q = {
++ insert: db.prepare(`INSERT INTO ${A} (entity_id, revision, media_id, role, variant, alt, caption, position, created_at)
++ VALUES (@entity_id, @revision, @media_id, @role, @variant, @alt, @caption, @position, @now)`),
++ byId: db.prepare(`SELECT * FROM ${A} WHERE id = ?`),
++ list: db.prepare(`SELECT * FROM ${A} WHERE entity_id = ? ORDER BY position, id`),
++ listRev: db.prepare(`SELECT * FROM ${A} WHERE entity_id = ? AND (revision IS NULL OR revision = ?) ORDER BY position, id`),
++ detach: db.prepare(`DELETE FROM ${A} WHERE id = ? AND entity_id = ?`),
++ broken: db.prepare(`UPDATE ${A} SET state = 'broken', broken_reason = ?, checked_at = ? WHERE media_id = ?`),
++ available: db.prepare(`UPDATE ${A} SET state = 'available', broken_reason = NULL, checked_at = ? WHERE media_id = ?`),
++ brokenFor: db.prepare(`SELECT * FROM ${A} WHERE entity_id = ? AND state = 'broken' ORDER BY position, id`),
++ entitiesWith: db.prepare(`SELECT DISTINCT entity_id FROM ${A} WHERE media_id = ? ORDER BY entity_id`),
++ };
++
++ const api = {
++ table: A,
++
++ attach({ entityId, mediaId, revision = null, role = 'inline', variant = null, alt = null, caption = null, position = 0 } = {}) {
++ assertEntityId(entityId);
++ if (!isMediaId(mediaId)) throw new PublishingError(400, 'media.invalid_id', 'mediaId must be a Media object id (med_<ULID> or legacy:<app>:<kind>:<id>)');
++ if (!ROLE_RE.test(role)) throw new TypeError(`role must match ${ROLE_RE}`);
++ if (variant != null && !VARIANT_RE.test(variant)) throw new TypeError(`variant must match ${VARIANT_RE}`);
++ if (revision != null && (!Number.isInteger(revision) || revision < 1)) throw new TypeError('revision must be a positive integer');
++ const info = q.insert.run({
++ entity_id: entityId, revision, media_id: mediaId, role, variant,
++ alt: alt == null ? null : String(alt).slice(0, 1000),
++ caption: caption == null ? null : String(caption).slice(0, 2000),
++ position: Number.isInteger(position) ? position : 0, now: clock(),
++ });
++ return shape(q.byId.get(info.lastInsertRowid));
++ },
++
++ detach(entityId, attachmentId) { return q.detach.run(Number(attachmentId), assertEntityId(entityId)).changes > 0; },
++
++ get(id) { return shape(q.byId.get(Number(id))); },
++
++ /** All attachments of an entity; with `revision`, entity-wide ones plus that revision's. */
++ list(entityId, { revision } = {}) {
++ assertEntityId(entityId);
++ return (revision == null ? q.list.all(entityId) : q.listRev.all(entityId, revision)).map(shape);
++ },
++
++ broken(entityId) { return q.brokenFor.all(assertEntityId(entityId)).map(shape); },
++
++ /** Entities that reference a Media object — what to re-render/re-index when it changes. */
++ entitiesUsing(mediaId) { return q.entitiesWith.all(String(mediaId)).map((r) => r.entity_id); },
++
++ /** Mark every attachment of this object broken. Returns the affected entity ids. */
++ markBroken(mediaId, reason = 'not_found') {
++ if (!BROKEN_REASONS.includes(reason)) throw new TypeError(`reason must be one of ${BROKEN_REASONS.join(', ')}`);
++ return db.transaction(() => {
++ const ids = api.entitiesUsing(mediaId);
++ q.broken.run(reason, clock(), String(mediaId));
++ return ids;
++ })();
++ },
++
++ markAvailable(mediaId) {
++ return db.transaction(() => {
++ const ids = api.entitiesUsing(mediaId);
++ q.available.run(clock(), String(mediaId));
++ return ids;
++ })();
++ },
++
++ /**
++ * Check an entity's attachments against Media. resolve(mediaId) must return
++ * { exists: true } | { exists: false, reason: 'not_found'|'deleted'|'forbidden' } or throw.
++ * Returns [{ mediaId, outcome: 'available'|'broken'|'check_failed', reason? }].
++ */
++ async verify(entityId, { resolve } = {}) {
++ if (typeof resolve !== 'function') throw new TypeError('resolve(mediaId) is required');
++ const ids = [...new Set(api.list(entityId).map((a) => a.mediaId))];
++ const out = [];
++ for (const mediaId of ids) {
++ let r;
++ try { r = await resolve(mediaId); } catch (err) {
++ out.push({ mediaId, outcome: 'check_failed', error: String(err && err.message || err) });
++ continue;
++ }
++ if (r && r.exists === true) { api.markAvailable(mediaId); out.push({ mediaId, outcome: 'available' }); }
++ else if (r && r.exists === false && BROKEN_REASONS.includes(r.reason || 'not_found')) {
++ api.markBroken(mediaId, r.reason || 'not_found');
++ out.push({ mediaId, outcome: 'broken', reason: r.reason || 'not_found' });
++ } else out.push({ mediaId, outcome: 'check_failed', error: 'resolver returned no verdict' });
++ }
++ return out;
++ },
++ };
++ return api;
++}
++
++/** The Contracts MediaRef ({ media_id, role?, variant? }) for an attachment. */
++function mediaRef(att) {
++ const ref = { media_id: att.mediaId };
++ if (att.role) ref.role = att.role;
++ if (att.variant) ref.variant = att.variant;
++ return ref;
++}
++
++/**
++ * Server-rendered <figure> for an attachment. urlFor(mediaId, att) returns the delivery URL.
++ * A broken attachment renders an explicit placeholder — no <img>, no guessed URL.
++ */
++function figureHtml(att, { urlFor, unavailableText = 'This media is no longer available.' } = {}) {
++ const { escapeHtml: esc } = require('./ssr');
++ const caption = att.caption ? `<figcaption>${esc(att.caption)}</figcaption>` : '';
++ if (att.state === 'broken') {
++ return `<figure class="ov-media ov-media-broken" data-media-id="${esc(att.mediaId)}" data-state="broken"><div class="ov-media-missing" role="img" aria-label="${esc(unavailableText)}">${esc(unavailableText)}</div>${caption}</figure>`;
++ }
++ if (typeof urlFor !== 'function') throw new TypeError('urlFor(mediaId) is required to render an available attachment');
++ const src = urlFor(att.mediaId, att);
++ return `<figure class="ov-media" data-media-id="${esc(att.mediaId)}" data-state="${esc(att.state)}"><img src="${esc(src)}" alt="${esc(att.alt || '')}" loading="lazy" decoding="async">${caption}</figure>`;
++}
++
++module.exports = { createAttachmentStore, mediaRef, figureHtml, isMediaId, STATES, BROKEN_REASONS, MEDIA_ID_RE };
ddiff --git a/lib/revisions.js b/lib/revisions.js
nnew file mode 100644
iindex 0000000..b854d0d
---- /dev/null
++++ b/lib/revisions.js
@@@ -0,0 +1,302 @@
++'use strict';
++/**
++ * openvibe-publishing/revisions — drafts and immutable revisions in the consumer's own database.
++ *
++ * const { createRevisionStore } = require('openvibe-publishing/revisions');
++ * const revs = createRevisionStore(db, { prefix: 'wiki_page' }); // wiki_page_revisions, …
++ * const r1 = revs.create({ entityId: 'pg_1', expectedRevision: 0, content: '# Hi', fields: { title: 'Hi' }, author: 'user:usr_…' });
++ * revs.create({ entityId: 'pg_1', expectedRevision: 1, content: '# Hello', author: … }); // r2, parent r1
++ * revs.diff('pg_1', 1, 2, { mode: 'word' });
++ * revs.revert({ entityId: 'pg_1', toRevision: 1, expectedRevision: 2, author: … }); // r3 = copy of r1
++ *
++ * Tables (created idempotently):
++ * <prefix>_revisions immutable: UPDATE aborts; DELETE aborts unless the entity is purged
++ * <prefix>_drafts one mutable working copy per (entity, owner), based on a revision
++ * <prefix>_revision_purges audit rows for explicit erasure (purgeEntity)
++ *
++ * Optimistic concurrency: every write names the revision number it was based on
++ * (`expectedRevision`, 0 for a new entity). If the head moved, the write throws a PublishingError
++ * with status 412 and code 'revision.conflict' carrying `expected` and `current`. A UNIQUE
++ * (entity_id, number) index is the last line of defence against two processes racing.
++ *
++ * This module stores content, not publication state: which revision is live is the product's
++ * decision, kept in the product's own tables.
++ */
++const {
++ PublishingError, assertDb, assertPrefix, clockOf, newId, sha256, stableStringify, parseJson,
++ assertEntityId, assertRevisionNumber, immutableTriggers,
++} = require('./internal');
++const { diffText } = require('./diff');
++
++const KINDS = ['edit', 'revert', 'import'];
++
++function conflict(entityId, expected, current) {
++ return new PublishingError(412, 'revision.conflict',
++ `Revision conflict on ${entityId}: expected ${expected}, current is ${current}`,
++ { entityId, expected, current });
++}
++
++function shape(row) {
++ if (!row) return null;
++ return {
++ id: row.id,
++ entityId: row.entity_id,
++ number: row.number,
++ parentId: row.parent_id,
++ parentNumber: row.parent_number,
++ kind: row.kind,
++ revertedTo: row.reverted_to,
++ content: row.content,
++ fields: parseJson(row.fields, {}),
++ meta: parseJson(row.meta, {}),
++ contentHash: row.content_hash,
++ author: row.author,
++ message: row.message,
++ createdAt: new Date(row.created_at).toISOString(),
++ };
++}
++
++function createRevisionStore(db, { prefix, now } = {}) {
++ assertDb(db);
++ assertPrefix(prefix);
++ const clock = clockOf(now);
++ const T = `${prefix}_revisions`;
++ const D = `${prefix}_drafts`;
++ const P = `${prefix}_revision_purges`;
++
++ db.exec(`
++ CREATE TABLE IF NOT EXISTS ${T} (
++ id TEXT PRIMARY KEY,
++ entity_id TEXT NOT NULL,
++ number INTEGER NOT NULL CHECK (number >= 1),
++ parent_id TEXT,
++ parent_number INTEGER,
++ kind TEXT NOT NULL CHECK (kind IN ('edit','revert','import')),
++ reverted_to INTEGER,
++ content TEXT NOT NULL,
++ fields TEXT NOT NULL DEFAULT '{}',
++ meta TEXT NOT NULL DEFAULT '{}',
++ content_hash TEXT NOT NULL,
++ author TEXT,
++ message TEXT,
++ created_at INTEGER NOT NULL
++ );
++ CREATE UNIQUE INDEX IF NOT EXISTS ${T}_entity_number ON ${T} (entity_id, number);
++ CREATE TABLE IF NOT EXISTS ${D} (
++ entity_id TEXT NOT NULL,
++ owner TEXT NOT NULL,
++ base_revision INTEGER NOT NULL,
++ content TEXT NOT NULL,
++ fields TEXT NOT NULL DEFAULT '{}',
++ meta TEXT NOT NULL DEFAULT '{}',
++ created_at INTEGER NOT NULL,
++ updated_at INTEGER NOT NULL,
++ PRIMARY KEY (entity_id, owner)
++ );
++ CREATE TABLE IF NOT EXISTS ${P} (
++ entity_id TEXT PRIMARY KEY,
++ reason TEXT NOT NULL,
++ purged_by TEXT,
++ purged_at INTEGER NOT NULL
++ );
++ ${immutableTriggers(T, P)}
++ `);
++
++ const q = {
++ head: db.prepare(`SELECT * FROM ${T} WHERE entity_id = ? ORDER BY number DESC LIMIT 1`),
++ byNumber: db.prepare(`SELECT * FROM ${T} WHERE entity_id = ? AND number = ?`),
++ byId: db.prepare(`SELECT * FROM ${T} WHERE id = ?`),
++ list: db.prepare(`SELECT * FROM ${T} WHERE entity_id = ? AND number < ? ORDER BY number DESC LIMIT ?`),
++ insert: db.prepare(`INSERT INTO ${T} (id, entity_id, number, parent_id, parent_number, kind, reverted_to, content, fields, meta, content_hash, author, message, created_at)
++ VALUES (@id, @entity_id, @number, @parent_id, @parent_number, @kind, @reverted_to, @content, @fields, @meta, @content_hash, @author, @message, @created_at)`),
++ draft: db.prepare(`SELECT * FROM ${D} WHERE entity_id = ? AND owner = ?`),
++ draftsFor: db.prepare(`SELECT * FROM ${D} WHERE entity_id = ? ORDER BY updated_at DESC`),
++ upsertDraft: db.prepare(`INSERT INTO ${D} (entity_id, owner, base_revision, content, fields, meta, created_at, updated_at)
++ VALUES (@entity_id, @owner, @base_revision, @content, @fields, @meta, @now, @now)
++ ON CONFLICT (entity_id, owner) DO UPDATE SET base_revision = excluded.base_revision, content = excluded.content,
++ fields = excluded.fields, meta = excluded.meta, updated_at = excluded.updated_at`),
++ deleteDraft: db.prepare(`DELETE FROM ${D} WHERE entity_id = ? AND owner = ?`),
++ purged: db.prepare(`SELECT 1 FROM ${P} WHERE entity_id = ?`),
++ };
++
++ function headNumber(entityId) {
++ const h = q.head.get(entityId);
++ return h ? h.number : 0;
++ }
++
++ function contentHash(content, fields) {
++ return sha256(`${content}\u0000${stableStringify(fields || {})}`);
++ }
++
++ function insertRevision({ entityId, expectedRevision, content, fields, meta, author, message, kind, revertedTo, allowUnchanged }) {
++ assertEntityId(entityId);
++ assertRevisionNumber(expectedRevision, 'expectedRevision');
++ if (typeof content !== 'string') throw new TypeError('content must be a string');
++ if (fields != null && (typeof fields !== 'object' || Array.isArray(fields))) throw new TypeError('fields must be an object');
++ if (meta != null && (typeof meta !== 'object' || Array.isArray(meta))) throw new TypeError('meta must be an object');
++ if (!KINDS.includes(kind)) throw new TypeError(`kind must be one of ${KINDS.join(', ')}`);
++ if (q.purged.get(entityId)) throw new PublishingError(410, 'entity.purged', `${entityId} was purged; its id cannot be reused`);
++ const head = q.head.get(entityId);
++ const current = head ? head.number : 0;
++ if (current !== expectedRevision) throw conflict(entityId, expectedRevision, current);
++ const hash = contentHash(content, fields);
++ if (head && head.content_hash === hash && !allowUnchanged) return { revision: shape(head), created: false };
++ const t = clock();
++ const row = {
++ id: newId('rev', t),
++ entity_id: entityId,
++ number: current + 1,
++ parent_id: head ? head.id : null,
++ parent_number: head ? head.number : null,
++ kind,
++ reverted_to: revertedTo == null ? null : revertedTo,
++ content,
++ fields: JSON.stringify(fields || {}),
++ meta: JSON.stringify(meta || {}),
++ content_hash: hash,
++ author: author == null ? null : String(author),
++ message: message == null ? null : String(message).slice(0, 2000),
++ created_at: t,
++ };
++ try {
++ q.insert.run(row);
++ } catch (err) {
++ if (/UNIQUE constraint failed/.test(String(err && err.message))) throw conflict(entityId, expectedRevision, headNumber(entityId));
++ throw err;
++ }
++ return { revision: shape(q.byId.get(row.id)), created: true };
++ }
++
++ const tx = (fn) => db.transaction(fn);
++
++ const store = {
++ prefix,
++ tables: { revisions: T, drafts: D, purges: P },
++
++ /** Latest revision of an entity, or null. */
++ head(entityId) { return shape(q.head.get(assertEntityId(entityId))); },
++
++ /** Current head number (0 when the entity has no revision yet). */
++ headNumber(entityId) { return headNumber(assertEntityId(entityId)); },
++
++ get(entityId, number) { return shape(q.byNumber.get(assertEntityId(entityId), number)); },
++
++ getById(id) { return shape(q.byId.get(String(id))); },
++
++ /** Newest first. `before` is an exclusive revision number cursor. */
++ list(entityId, { limit = 50, before } = {}) {
++ const lim = Math.max(1, Math.min(500, Number(limit) || 50));
++ const cursor = before == null ? Number.MAX_SAFE_INTEGER : Number(before);
++ return q.list.all(assertEntityId(entityId), cursor, lim).map(shape);
++ },
++
++ /** Walks parent pointers from `number` (default: head) back to revision 1. */
++ lineage(entityId, number) {
++ let row = number == null ? q.head.get(assertEntityId(entityId)) : q.byNumber.get(assertEntityId(entityId), number);
++ const out = [];
++ while (row) {
++ out.push(shape(row));
++ row = row.parent_id ? q.byId.get(row.parent_id) : null;
++ }
++ return out;
++ },
++
++ /**
++ * New immutable revision. Returns { revision, created }. An edit identical to the head
++ * (same content and fields) returns the head with created: false unless allowUnchanged.
++ */
++ create({ entityId, expectedRevision, content, fields = {}, meta = {}, author = null, message = null, kind = 'edit', allowUnchanged = false } = {}) {
++ if (kind === 'revert') throw new TypeError('use revert() to create a revert revision');
++ return tx(() => insertRevision({ entityId, expectedRevision, content, fields, meta, author, message, kind, allowUnchanged }))();
++ },
++
++ /** Revert as a new revision: copies revision `toRevision`'s content and fields; history is never rewritten. */
++ revert({ entityId, toRevision, expectedRevision, author = null, message = null, meta = {} } = {}) {
++ return tx(() => {
++ const target = q.byNumber.get(assertEntityId(entityId), toRevision);
++ if (!target) throw new PublishingError(404, 'revision.not_found', `No revision ${toRevision} of ${entityId}`);
++ return insertRevision({
++ entityId, expectedRevision, content: target.content, fields: parseJson(target.fields, {}),
++ meta: { ...meta, revertedFrom: headNumber(entityId) }, author,
++ message: message == null ? `Revert to revision ${toRevision}` : message,
++ kind: 'revert', revertedTo: toRevision, allowUnchanged: true,
++ });
++ })();
++ },
++
++ /** Line or word diff of the content plus a field-by-field comparison. */
++ diff(entityId, fromNumber, toNumber, { mode = 'line' } = {}) {
++ const a = q.byNumber.get(assertEntityId(entityId), fromNumber);
++ const b = q.byNumber.get(entityId, toNumber);
++ if (!a || !b) throw new PublishingError(404, 'revision.not_found', `No revision ${!a ? fromNumber : toNumber} of ${entityId}`);
++ const fa = parseJson(a.fields, {});
++ const fb = parseJson(b.fields, {});
++ const fields = [];
++ for (const key of [...new Set([...Object.keys(fa), ...Object.keys(fb)])].sort()) {
++ if (stableStringify(fa[key]) !== stableStringify(fb[key])) fields.push({ field: key, from: fa[key] === undefined ? null : fa[key], to: fb[key] === undefined ? null : fb[key] });
++ }
++ return { entityId, from: fromNumber, to: toNumber, content: diffText(a.content, b.content, { mode }), fields };
++ },
++
++ /** Save (create or replace) the owner's working copy. baseRevision defaults to the current head. */
++ saveDraft({ entityId, owner, content, fields = {}, meta = {}, baseRevision } = {}) {
++ assertEntityId(entityId);
++ if (typeof owner !== 'string' || !owner) throw new TypeError('owner must be a subject string');
++ if (typeof content !== 'string') throw new TypeError('content must be a string');
++ const base = baseRevision == null ? headNumber(entityId) : assertRevisionNumber(baseRevision, 'baseRevision');
++ q.upsertDraft.run({ entity_id: entityId, owner, base_revision: base, content, fields: JSON.stringify(fields || {}), meta: JSON.stringify(meta || {}), now: clock() });
++ return store.getDraft(entityId, owner);
++ },
++
++ getDraft(entityId, owner) {
++ const row = q.draft.get(assertEntityId(entityId), String(owner));
++ if (!row) return null;
++ return {
++ entityId: row.entity_id, owner: row.owner, baseRevision: row.base_revision, content: row.content,
++ fields: parseJson(row.fields, {}), meta: parseJson(row.meta, {}),
++ createdAt: new Date(row.created_at).toISOString(), updatedAt: new Date(row.updated_at).toISOString(),
++ stale: row.base_revision !== headNumber(row.entity_id),
++ };
++ },
++
++ drafts(entityId) {
++ return q.draftsFor.all(assertEntityId(entityId)).map((row) => store.getDraft(row.entity_id, row.owner));
++ },
++
++ discardDraft(entityId, owner) { return q.deleteDraft.run(assertEntityId(entityId), String(owner)).changes > 0; },
++
++ /**
++ * Turn a draft into a revision, based on the revision the draft started from. If someone
++ * published in between, this is a 412 conflict and the draft is kept.
++ */
++ commitDraft({ entityId, owner, message = null, author } = {}) {
++ return tx(() => {
++ const d = q.draft.get(assertEntityId(entityId), String(owner));
++ if (!d) throw new PublishingError(404, 'draft.not_found', `No draft of ${entityId} for ${owner}`);
++ const out = insertRevision({
++ entityId, expectedRevision: d.base_revision, content: d.content, fields: parseJson(d.fields, {}),
++ meta: parseJson(d.meta, {}), author: author || owner, message, kind: 'edit',
++ });
++ q.deleteDraft.run(entityId, owner);
++ return out;
++ })();
++ },
++
++ /**
++ * Explicit, audited erasure of every revision and draft of one entity (legal removal).
++ * The purge row stays as the record that it happened.
++ */
++ purgeEntity(entityId, { reason, purgedBy = null } = {}) {
++ if (!reason) throw new TypeError('a purge needs a recorded reason');
++ return tx(() => {
++ db.prepare(`INSERT OR IGNORE INTO ${P} (entity_id, reason, purged_by, purged_at) VALUES (?, ?, ?, ?)`).run(assertEntityId(entityId), String(reason), purgedBy, clock());
++ const n = db.prepare(`DELETE FROM ${T} WHERE entity_id = ?`).run(entityId).changes;
++ db.prepare(`DELETE FROM ${D} WHERE entity_id = ?`).run(entityId);
++ return { deleted: n };
++ })();
++ },
++ };
++ return store;
++}
++
++module.exports = { createRevisionStore, diffText, PublishingError, KINDS };
ddiff --git a/lib/schedule.js b/lib/schedule.js
nnew file mode 100644
iindex 0000000..f5e4b50
---- /dev/null
++++ b/lib/schedule.js
@@@ -0,0 +1,205 @@
++'use strict';
++/**
++ * openvibe-publishing/schedule — scheduled publish/unpublish jobs that survive worker restarts.
++ *
++ * const { createScheduler } = require('openvibe-publishing/schedule');
++ * const sched = createScheduler(db, { prefix: 'blog', leaseMs: 60000, now: () => Date.now() });
++ * sched.schedule({ entityId: 'post_1', action: 'publish', runAt: Date.parse('2026-10-01T09:00:00Z'), revision: 3 });
++ * // in the product's worker loop:
++ * await sched.runDue({ worker: 'blog-1', handler: async (job) => { publishPost(job.entityId, job.revision); } });
++ *
++ * Table <prefix>_schedule_jobs. Guarantees:
++ * - schedule() is idempotent: the same (entity, action, runAt, revision) — or the same explicit
++ * `key` — returns the existing job instead of adding a second one.
++ * - claim() takes a lease (lease_owner, lease_until). A worker that dies mid-job leaves the lease
++ * to expire, and the next claim re-runs the job: at-least-once. The handler must therefore be
++ * re-run safe ("make revision N the published one" is; "append a publish" is not). Each job
++ * carries a stable `key` the handler can use as its own idempotency key.
++ * - complete()/fail() only count when the caller still holds the lease, so a worker whose lease
++ * was taken over cannot overwrite the new owner's result.
++ * - failures retry with backoff up to maxAttempts, then the job is 'failed' (the product emits
++ * its `<product>.schedule.failed` event from runDue's summary).
++ * The clock is injectable (`now`), so tests and replays are deterministic.
++ */
++const { PublishingError, assertDb, assertPrefix, clockOf, newId, parseJson, assertEntityId } = require('./internal');
++
++const ACTIONS = ['publish', 'unpublish'];
++const STATUSES = ['pending', 'running', 'done', 'failed', 'cancelled'];
++
++function shape(row) {
++ if (!row) return null;
++ return {
++ id: row.id,
++ key: row.idem_key,
++ entityId: row.entity_id,
++ action: row.action,
++ revision: row.revision,
++ runAt: new Date(row.run_at).toISOString(),
++ status: row.status,
++ attempts: row.attempts,
++ leaseOwner: row.lease_owner,
++ leaseUntil: row.lease_until == null ? null : new Date(row.lease_until).toISOString(),
++ lastError: row.last_error,
++ result: parseJson(row.result, null),
++ createdAt: new Date(row.created_at).toISOString(),
++ updatedAt: new Date(row.updated_at).toISOString(),
++ };
++}
++
++function toMs(v, name) {
++ const t = v instanceof Date ? v.getTime() : typeof v === 'number' ? v : Date.parse(v);
++ if (!Number.isFinite(t)) throw new TypeError(`${name} must be a date, ISO string or epoch milliseconds`);
++ return t;
++}
++
++function createScheduler(db, { prefix, now, leaseMs = 60000, maxAttempts = 5, backoffMs = [5000, 30000, 120000, 600000] } = {}) {
++ assertDb(db);
++ assertPrefix(prefix);
++ const clock = clockOf(now);
++ if (!(leaseMs > 0)) throw new TypeError('leaseMs must be positive');
++ const J = `${prefix}_schedule_jobs`;
++
++ db.exec(`
++ CREATE TABLE IF NOT EXISTS ${J} (
++ id TEXT PRIMARY KEY,
++ idem_key TEXT NOT NULL UNIQUE,
++ entity_id TEXT NOT NULL,
++ action TEXT NOT NULL CHECK (action IN ('publish','unpublish')),
++ revision INTEGER,
++ run_at INTEGER NOT NULL,
++ status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','running','done','failed','cancelled')),
++ attempts INTEGER NOT NULL DEFAULT 0,
++ lease_owner TEXT,
++ lease_until INTEGER,
++ last_error TEXT,
++ result TEXT,
++ created_at INTEGER NOT NULL,
++ updated_at INTEGER NOT NULL
++ );
++ CREATE INDEX IF NOT EXISTS ${J}_due ON ${J} (status, run_at);
++ CREATE INDEX IF NOT EXISTS ${J}_entity ON ${J} (entity_id, run_at);
++ `);
++
++ const q = {
++ byId: db.prepare(`SELECT * FROM ${J} WHERE id = ?`),
++ byKey: db.prepare(`SELECT * FROM ${J} WHERE idem_key = ?`),
++ insert: db.prepare(`INSERT OR IGNORE INTO ${J} (id, idem_key, entity_id, action, revision, run_at, created_at, updated_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`),
++ due: db.prepare(`SELECT * FROM ${J} WHERE (status = 'pending' AND run_at <= @now) OR (status = 'running' AND lease_until <= @now)
++ ORDER BY run_at, id LIMIT @limit`),
++ take: db.prepare(`UPDATE ${J} SET status = 'running', lease_owner = @worker, lease_until = @until, attempts = attempts + 1, updated_at = @now
++ WHERE id = @id AND ((status = 'pending' AND run_at <= @now) OR (status = 'running' AND lease_until <= @now))`),
++ complete: db.prepare(`UPDATE ${J} SET status = 'done', result = @result, lease_owner = NULL, lease_until = NULL, last_error = NULL, updated_at = @now
++ WHERE id = @id AND status = 'running' AND lease_owner = @worker`),
++ retry: db.prepare(`UPDATE ${J} SET status = 'pending', run_at = @runAt, last_error = @error, lease_owner = NULL, lease_until = NULL, updated_at = @now
++ WHERE id = @id AND status = 'running' AND lease_owner = @worker`),
++ dead: db.prepare(`UPDATE ${J} SET status = 'failed', last_error = @error, lease_owner = NULL, lease_until = NULL, updated_at = @now
++ WHERE id = @id AND status = 'running' AND lease_owner = @worker`),
++ abandon: db.prepare(`UPDATE ${J} SET status = 'failed', last_error = 'lease expired on every attempt', lease_owner = NULL, lease_until = NULL, updated_at = @now
++ WHERE id = @id AND status = 'running' AND lease_until <= @now`),
++ cancel: db.prepare(`UPDATE ${J} SET status = 'cancelled', updated_at = ? WHERE id = ? AND status = 'pending'`),
++ cancelEntity: db.prepare(`UPDATE ${J} SET status = 'cancelled', updated_at = ? WHERE entity_id = ? AND status = 'pending' AND (? IS NULL OR action = ?)`),
++ forEntity: db.prepare(`SELECT * FROM ${J} WHERE entity_id = ? ORDER BY run_at, id`),
++ };
++
++ function backoff(attempts) {
++ return backoffMs[Math.min(attempts - 1, backoffMs.length - 1)] || 0;
++ }
++
++ function claimDue(worker, limit) {
++ if (typeof worker !== 'string' || !worker) throw new TypeError('worker must be a non-empty string');
++ return db.transaction(() => {
++ const t = clock();
++ const rows = q.due.all({ now: t, limit: Math.max(1, Math.min(100, limit)) });
++ const jobs = [];
++ const abandoned = [];
++ for (const r of rows) {
++ // A job whose workers keep dying stops after maxAttempts instead of looping forever.
++ if (r.status === 'running' && r.attempts >= maxAttempts) {
++ if (q.abandon.run({ id: r.id, now: t }).changes === 1) abandoned.push(shape(q.byId.get(r.id)));
++ continue;
++ }
++ if (q.take.run({ id: r.id, worker, until: t + leaseMs, now: t }).changes === 1) jobs.push(shape(q.byId.get(r.id)));
++ }
++ return { jobs, abandoned };
++ }).immediate();
++ }
++
++ const api = {
++ table: J,
++ ACTIONS,
++ STATUSES,
++
++ /** Idempotent. Returns { job, created }. */
++ schedule({ entityId, action, runAt, revision = null, key } = {}) {
++ assertEntityId(entityId);
++ if (!ACTIONS.includes(action)) throw new TypeError(`action must be one of ${ACTIONS.join(', ')}`);
++ const at = toMs(runAt, 'runAt');
++ if (revision != null && (!Number.isInteger(revision) || revision < 1)) throw new TypeError('revision must be a positive integer');
++ const idem = key ? String(key) : `${entityId}:${action}:${at}:${revision == null ? '-' : revision}`;
++ const t = clock();
++ const info = q.insert.run(newId('job', t), idem, entityId, action, revision, at, t, t);
++ return { job: shape(q.byKey.get(idem)), created: info.changes === 1 };
++ },
++
++ get(id) { return shape(q.byId.get(String(id))); },
++
++ jobs(entityId) { return q.forEntity.all(assertEntityId(entityId)).map(shape); },
++
++ /** Cancel one pending job (a running job finishes or its lease expires; it cannot be cancelled mid-flight). */
++ cancel(id) { return q.cancel.run(clock(), String(id)).changes > 0; },
++
++ /** Cancel every pending job of an entity, optionally only one action. Returns the count. */
++ cancelPending(entityId, action = null) {
++ return q.cancelEntity.run(clock(), assertEntityId(entityId), action, action).changes;
++ },
++
++ /** Lease up to `limit` due jobs (pending and due, or running with an expired lease). */
++ claim({ worker, limit = 10 } = {}) { return claimDue(worker, limit).jobs; },
++
++ /** true when recorded; false when this worker no longer holds the lease. */
++ complete(id, worker, result = null) {
++ return q.complete.run({ id: String(id), worker, result: result == null ? null : JSON.stringify(result), now: clock() }).changes === 1;
++ },
++
++ /** Returns { status: 'retry' | 'failed' | 'lost' }. */
++ fail(id, worker, error) {
++ const row = q.byId.get(String(id));
++ if (!row || row.status !== 'running' || row.lease_owner !== worker) return { status: 'lost' };
++ const t = clock();
++ const msg = String(error && error.message ? error.message : error || 'failed').slice(0, 1000);
++ if (row.attempts >= maxAttempts) {
++ q.dead.run({ id: row.id, worker, error: msg, now: t });
++ return { status: 'failed', job: shape(q.byId.get(row.id)) };
++ }
++ q.retry.run({ id: row.id, worker, error: msg, runAt: t + backoff(row.attempts), now: t });
++ return { status: 'retry', job: shape(q.byId.get(row.id)) };
++ },
++
++ /**
++ * Claim and run due jobs one by one. handler(job) may be async; a throw counts as a failure.
++ * Returns { done: [job], retried: [job], failed: [job], lost: [job] }.
++ */
++ async runDue({ worker, handler, limit = 10 } = {}) {
++ if (typeof handler !== 'function') throw new TypeError('handler must be a function');
++ const { jobs, abandoned } = claimDue(worker, limit);
++ const summary = { done: [], retried: [], failed: [...abandoned], lost: [] };
++ for (const job of jobs) {
++ try {
++ const result = await handler(job);
++ if (api.complete(job.id, worker, result === undefined ? null : result)) summary.done.push(api.get(job.id));
++ else summary.lost.push(api.get(job.id));
++ } catch (err) {
++ const f = api.fail(job.id, worker, err);
++ if (f.status === 'failed') summary.failed.push(f.job);
++ else if (f.status === 'retry') summary.retried.push(f.job);
++ else summary.lost.push(api.get(job.id));
++ }
++ }
++ return summary;
++ },
++ };
++ return api;
++}
++
++module.exports = { createScheduler, ACTIONS, STATUSES, PublishingError };
ddiff --git a/lib/seo.js b/lib/seo.js
nnew file mode 100644
iindex 0000000..1288a0e
---- /dev/null
++++ b/lib/seo.js
@@@ -0,0 +1,570 @@
++'use strict';
++/**
++ * openvibe-publishing/seo — the deterministic indexability gate and the discovery artifacts that
++ * obey it: canonical URLs, history-aware redirects, meta/robots tags, sitemaps, RSS/Atom/JSON
++ * Feed, and JSON-LD built only from fields the product actually has.
++ *
++ * const seo = require('openvibe-publishing/seo');
++ * const decision = seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl, text, citationCount: 3 },
++ * { policy: { minWords: 150, requireSources: true } });
++ * // → { indexable: true, listable: true, robots: 'index, follow', reasons: [], codes: [], canonical, gate }
++ * seo.metaTags({ title, description, decision }) // robots come from the decision, never a default
++ * seo.sitemap([{ loc, lastmod, decision }]) // only indexable entries
++ * seo.atomFeed(channel, [{ id, url, title, published, decision, … }]) // only listable items
++ * seo.structuredData.article({ headline, url, datePublished, authors }) // missing → omitted
++ *
++ * Generic primitives (head tag layout, sitemap XML, robots.txt, JSON-LD script escaping) are the
++ * network's single implementation in openvibe-shared/seo; this module adds the publication rules.
++ *
++ * THE GATE. evaluate(facts, { policy, now }) is a pure function: the same facts, policy and `now`
++ * always give the same decision, and every "no" names a stable reason code (REASONS, in evaluation
++ * order). Each reason has an effect:
++ * hidden — not indexable and not listable: never in search, sitemaps or feeds
++ * noindex — may be served and listed in feeds, but never indexed or put in a sitemap
++ * Unknown fact names throw (a typo must not silently pass the gate), and so do missing facts a
++ * rule needs — there is no default that makes content indexable.
++ */
++const sharedSeo = require('openvibe-shared/seo');
++const { assertDb, assertPrefix, clockOf, assertEntityId } = require('./internal');
++const { wordCount } = require('./ssr');
++
++const GATE = 'openvibe-publishing/seo@1';
++
++const REASONS = Object.freeze([
++ { code: 'deleted', effect: 'hidden', description: 'The resource is deleted.' },
++ { code: 'takedown', effect: 'hidden', description: 'Removed by a moderation, legal or owner takedown.' },
++ { code: 'private', effect: 'hidden', description: 'Visibility is private.' },
++ { code: 'gated', effect: 'hidden', description: 'Only entitled members (e.g. VIP) may read it.' },
++ { code: 'unlisted', effect: 'hidden', description: 'Reachable by link only; never listed or indexed.' },
++ { code: 'draft', effect: 'hidden', description: 'Not published yet (draft or scheduled).' },
++ { code: 'unpublished', effect: 'hidden', description: 'Was published, has been unpublished.' },
++ { code: 'ai_generated_unreviewed', effect: 'hidden', description: 'AI-generated and no human review is recorded.' },
++ { code: 'stub_provider', effect: 'hidden', description: 'Produced while only the AI stub provider was available, and not reviewed.' },
++ { code: 'unreviewed_sensitive', effect: 'hidden', description: 'Sensitive or regulated category without a recorded review.' },
++ { code: 'retracted', effect: 'noindex', description: 'Retracted; kept visible with its correction, not indexed.' },
++ { code: 'expired', effect: 'noindex', description: 'Past its expiry time.' },
++ { code: 'stale_price', effect: 'noindex', description: 'A price is shown without a recent enough observation time.' },
++ { code: 'duplicate_of', effect: 'noindex', description: 'Duplicates another canonical page.' },
++ { code: 'thin', effect: 'noindex', description: 'Below the product\'s minimum amount of text.' },
++ { code: 'unsourced', effect: 'noindex', description: 'Fewer citations than the product requires.' },
++ { code: 'unsupported_claims', effect: 'noindex', description: 'Contains claims flagged as lacking a source.' },
++ { code: 'missing_canonical', effect: 'noindex', description: 'No canonical URL was provided.' },
++ { code: 'noindex_requested', effect: 'noindex', description: 'The author or an editor asked for noindex.' },
++]);
++const REASON_BY_CODE = Object.freeze(Object.fromEntries(REASONS.map((r) => [r.code, r])));
++
++const STATES = ['draft', 'scheduled', 'published', 'unpublished', 'deleted'];
++const VISIBILITIES = ['public', 'unlisted', 'private', 'gated'];
++const FACT_KEYS = new Set([
++ 'state', 'visibility', 'takedown', 'canonicalUrl', 'duplicateOf', 'text', 'wordCount', 'citationCount',
++ 'unsupportedClaims', 'authorship', 'stubProvider', 'sensitive', 'category', 'sensitiveReviewed',
++ 'expiresAt', 'price', 'retracted', 'noindex',
++]);
++const DEFAULT_POLICY = Object.freeze({
++ minWords: 150,
++ requireSources: false,
++ minSources: 1,
++ priceMaxAgeMs: 24 * 60 * 60 * 1000,
++ sensitiveCategories: [],
++});
++
++function timeOf(value, name) {
++ const t = value instanceof Date ? value.getTime() : typeof value === 'number' ? value : Date.parse(value);
++ if (!Number.isFinite(t)) throw new TypeError(`${name} must be a valid date`);
++ return t;
++}
++
++/**
++ * The indexability decision. facts (see FACT_KEYS):
++ * state (required) draft | scheduled | published | unpublished | deleted
++ * visibility (required) public | unlisted | private | gated
++ * canonicalUrl, duplicateOf (URL of the original this duplicates)
++ * text or wordCount (required when policy.minWords > 0)
++ * citationCount (required when policy.requireSources)
++ * unsupportedClaims number of claims flagged unsourced
++ * authorship { mode: human|ai|hybrid|imported, reviewed: bool } (authorship.gateFacts)
++ * stubProvider, sensitive, category, sensitiveReviewed, takedown, retracted, noindex (booleans/strings)
++ * expiresAt instant; price { observedAt } — both need options.now
++ */
++function evaluate(facts, { policy = {}, now } = {}) {
++ if (!facts || typeof facts !== 'object') throw new TypeError('facts must be an object');
++ for (const k of Object.keys(facts)) if (!FACT_KEYS.has(k)) throw new TypeError(`unknown fact "${k}"`);
++ const p = { ...DEFAULT_POLICY, ...policy };
++ if (!STATES.includes(facts.state)) throw new TypeError(`facts.state must be one of ${STATES.join(', ')}`);
++ if (!VISIBILITIES.includes(facts.visibility)) throw new TypeError(`facts.visibility must be one of ${VISIBILITIES.join(', ')}`);
++ const nowMs = now == null ? null : timeOf(now, 'now');
++ const needNow = (what) => { if (nowMs == null) throw new TypeError(`${what} needs options.now (the gate has no hidden clock)`); return nowMs; };
++
++ const found = new Map();
++ const hit = (code, detail = null) => { if (!found.has(code)) found.set(code, detail); };
++
++ if (facts.state === 'deleted') hit('deleted');
++ if (facts.takedown) hit('takedown');
++ if (facts.visibility === 'private') hit('private');
++ if (facts.visibility === 'gated') hit('gated');
++ if (facts.visibility === 'unlisted') hit('unlisted');
++ if (facts.state === 'draft' || facts.state === 'scheduled') hit('draft', facts.state);
++ if (facts.state === 'unpublished') hit('unpublished');
++
++ const a = facts.authorship;
++ if (a != null) {
++ if (typeof a !== 'object' || !a.mode) throw new TypeError('facts.authorship must be { mode, reviewed }');
++ if (a.mode === 'ai' && !a.reviewed) hit('ai_generated_unreviewed');
++ }
++ const reviewed = Boolean(a && a.reviewed);
++ if (facts.stubProvider && !reviewed) hit('stub_provider');
++
++ const sensitive = Boolean(facts.sensitive) || (facts.category != null && p.sensitiveCategories.includes(facts.category));
++ if (sensitive && !facts.sensitiveReviewed) hit('unreviewed_sensitive', facts.category != null ? String(facts.category) : null);
++
++ if (facts.retracted) hit('retracted');
++ if (facts.expiresAt != null && timeOf(facts.expiresAt, 'expiresAt') <= needNow('expiresAt')) hit('expired', new Date(timeOf(facts.expiresAt, 'expiresAt')).toISOString());
++ if (facts.price != null) {
++ const observed = facts.price && facts.price.observedAt;
++ if (observed == null || observed === '') hit('stale_price', 'price has no observation time');
++ else if (needNow('price.observedAt') - timeOf(observed, 'price.observedAt') > p.priceMaxAgeMs) hit('stale_price', `observed ${new Date(timeOf(observed, 'price.observedAt')).toISOString()}`);
++ }
++ if (facts.duplicateOf && facts.duplicateOf !== facts.canonicalUrl) hit('duplicate_of', String(facts.duplicateOf));
++
++ if (p.minWords > 0) {
++ let words;
++ if (Number.isInteger(facts.wordCount)) words = facts.wordCount;
++ else if (typeof facts.text === 'string') words = wordCount(facts.text);
++ else throw new TypeError('facts.text or facts.wordCount is required when policy.minWords > 0');
++ if (words < p.minWords) hit('thin', `${words} of ${p.minWords} words`);
++ }
++ if (p.requireSources) {
++ if (!Number.isInteger(facts.citationCount)) throw new TypeError('facts.citationCount is required when policy.requireSources');
++ if (facts.citationCount < p.minSources) hit('unsourced', `${facts.citationCount} of ${p.minSources} sources`);
++ }
++ if (Number(facts.unsupportedClaims) > 0) hit('unsupported_claims', `${Number(facts.unsupportedClaims)} claims`);
++ if (!facts.canonicalUrl) hit('missing_canonical');
++ if (facts.noindex) hit('noindex_requested');
++
++ const reasons = REASONS.filter((r) => found.has(r.code)).map((r) => ({ code: r.code, effect: r.effect, detail: found.get(r.code) }));
++ const hidden = reasons.some((r) => r.effect === 'hidden');
++ const indexable = reasons.length === 0;
++ return {
++ gate: GATE,
++ indexable,
++ listable: !hidden,
++ robots: indexable ? 'index, follow' : hidden ? 'noindex, nofollow' : 'noindex, follow',
++ canonical: found.has('duplicate_of') ? String(facts.duplicateOf) : (facts.canonicalUrl || null),
++ reasons,
++ codes: reasons.map((r) => r.code),
++ };
++}
++
++function requireDecision(decision, where) {
++ if (!decision || typeof decision.indexable !== 'boolean' || typeof decision.robots !== 'string') {
++ throw new TypeError(`${where} needs the gate's decision (seo.evaluate): publication state, not rendering, decides indexability`);
++ }
++ return decision;
++}
++
++// ---- Canonical URLs and redirects --------------------------------------------------------------
++
++/**
++ * Absolute canonical URL: https/http origin + path segments (each encoded), no fragment, no
++ * trailing slash (except the root), and only whitelisted query parameters, sorted.
++ * canonicalUrl('https://openvibe.wiki', ['spaces', 'food', 'pages', 'Crème brûlée'])
++ * canonicalUrl('https://openvibe.blog', '/alex/posts/bread?page=2&utm_source=x', { query: ['page'] })
++ */
++function canonicalUrl(origin, path = '/', { query = [] } = {}) {
++ let base;
++ try { base = new URL(String(origin)); } catch { throw new TypeError('origin must be an absolute URL'); }
++ if (base.protocol !== 'https:' && base.protocol !== 'http:') throw new TypeError('origin must be http(s)');
++ let pathname;
++ let search = '';
++ if (Array.isArray(path)) {
++ pathname = '/' + path.filter((s) => s != null && s !== '').map((s) => encodeURIComponent(String(s))).join('/');
++ } else {
++ const u = new URL(String(path || '/'), base.origin);
++ if (u.origin !== base.origin) throw new TypeError('path must stay on the origin');
++ pathname = u.pathname;
++ const keep = [...u.searchParams.entries()].filter(([k]) => query.includes(k)).sort(([x], [y]) => (x < y ? -1 : x > y ? 1 : 0));
++ if (keep.length) search = '?' + new URLSearchParams(keep).toString();
++ }
++ pathname = pathname.replace(/\/{2,}/g, '/');
++ if (pathname.length > 1) pathname = pathname.replace(/\/+$/, '');
++ return `${base.protocol}//${base.host.toLowerCase()}${pathname}${search}`;
++}
++
++function normalizePath(p) {
++ const s = String(p == null ? '' : p).split('#')[0].split('?')[0];
++ if (!s.startsWith('/') || s.startsWith('//')) throw new TypeError('paths must be site-relative and start with one "/"');
++ return s.length > 1 ? s.replace(/\/+$/, '') : s;
++}
++
++/**
++ * History-aware redirects in <prefix>_redirects: every path an entity ever had points at the
++ * entity, and resolve() answers with the entity's *current* path, so chains collapse into one 301.
++ */
++function createRedirectStore(db, { prefix, now } = {}) {
++ assertDb(db);
++ assertPrefix(prefix);
++ const clock = clockOf(now);
++ const R = `${prefix}_redirects`;
++ db.exec(`
++ CREATE TABLE IF NOT EXISTS ${R} (
++ from_path TEXT PRIMARY KEY,
++ entity_id TEXT NOT NULL,
++ reason TEXT,
++ created_at INTEGER NOT NULL
++ );
++ CREATE INDEX IF NOT EXISTS ${R}_entity ON ${R} (entity_id);
++ `);
++ const q = {
++ put: db.prepare(`INSERT INTO ${R} (from_path, entity_id, reason, created_at) VALUES (?, ?, ?, ?)
++ ON CONFLICT (from_path) DO UPDATE SET entity_id = excluded.entity_id, reason = excluded.reason, created_at = excluded.created_at`),
++ del: db.prepare(`DELETE FROM ${R} WHERE from_path = ?`),
++ get: db.prepare(`SELECT * FROM ${R} WHERE from_path = ?`),
++ history: db.prepare(`SELECT * FROM ${R} WHERE entity_id = ? ORDER BY created_at, from_path`),
++ };
++ return {
++ table: R,
++ /** An entity moved from oldPath to newPath (slug rename, space move). */
++ recordMove(entityId, oldPath, newPath, { reason = 'renamed' } = {}) {
++ assertEntityId(entityId);
++ const from = normalizePath(oldPath);
++ const to = normalizePath(newPath);
++ db.transaction(() => {
++ q.del.run(to); // the new path is live: it must not redirect anywhere
++ if (from !== to) q.put.run(from, entityId, reason, clock());
++ })();
++ },
++ /** Claim a path for a (new) entity: removes any redirect that used it. */
++ release(path) { return q.del.run(normalizePath(path)).changes > 0; },
++ /**
++ * currentPath(entityId) → the entity's live path, or null when it is gone.
++ * → null (not a historical path) | { status: 301, location } | { status: 410 }
++ */
++ resolve(path, { currentPath } = {}) {
++ if (typeof currentPath !== 'function') throw new TypeError('currentPath(entityId) is required');
++ let from;
++ try { from = normalizePath(path); } catch { return null; }
++ const row = q.get.get(from);
++ if (!row) return null;
++ const target = currentPath(row.entity_id);
++ if (target == null) return { status: 410, entityId: row.entity_id };
++ const location = normalizePath(target);
++ if (location === from) return null;
++ return { status: 301, location, entityId: row.entity_id };
++ },
++ history(entityId) { return q.history.all(assertEntityId(entityId)).map((r) => ({ path: r.from_path, reason: r.reason, at: new Date(r.created_at).toISOString() })); },
++ };
++}
++
++// ---- Meta tags ---------------------------------------------------------------------------------
++
++/** <meta name="robots"> from the decision. */
++function robotsMeta(decision) {
++ return `<meta name="robots" content="${sharedSeo.esc(requireDecision(decision, 'robotsMeta').robots)}">`;
++}
++
++/** X-Robots-Tag header value for non-HTML representations (JSON, feeds of private collections). */
++function xRobotsTag(decision) { return requireDecision(decision, 'xRobotsTag').robots; }
++
++/**
++ * The page's <head> SEO block (openvibe-shared/seo headTags) with robots and canonical taken from
++ * the decision. Options are headTags' (title, description, image, type, siteName, jsonLd, …);
++ * the canonical defaults to decision.canonical (the original, for a duplicate).
++ */
++function metaTags({ decision, canonical, ...rest } = {}) {
++ requireDecision(decision, 'metaTags');
++ return sharedSeo.headTags({ ...rest, canonical: decision.canonical || canonical, robots: decision.robots });
++}
++
++// ---- Sitemaps ----------------------------------------------------------------------------------
++
++/**
++ * entries: [{ loc (absolute), lastmod? (a real revision time), decision, images? }]
++ * → { files: [xml], count, skipped: [{ loc, codes }] }. Only indexable entries are written;
++ * a missing lastmod is left out, never filled with "now". Split every maxUrls (50,000).
++ */
++function sitemap(entries = [], { maxUrls = 50000 } = {}) {
++ const keep = [];
++ const skipped = [];
++ for (const e of entries) {
++ if (!e || !e.loc) throw new TypeError('every sitemap entry needs loc');
++ const d = requireDecision(e.decision, 'sitemap entries');
++ if (!d.indexable || !/^https?:\/\//i.test(e.loc)) { skipped.push({ loc: e.loc, codes: d.codes || [] }); continue; }
++ const u = { loc: d.canonical || e.loc };
++ if (e.lastmod != null && e.lastmod !== '') u.lastmod = new Date(timeOf(e.lastmod, 'lastmod')).toISOString();
++ if (Array.isArray(e.images) && e.images.length) u.images = e.images;
++ keep.push(u);
++ }
++ const size = Math.max(1, Math.min(50000, Math.floor(maxUrls) || 50000));
++ const files = [];
++ for (let i = 0; i < keep.length; i += size) files.push(sharedSeo.sitemapXml(keep.slice(i, i + size)));
++ if (!files.length) files.push(sharedSeo.sitemapXml([]));
++ return { files, count: keep.length, skipped };
++}
++
++/** Sitemap index over per-section sitemaps: [{ loc, lastmod? }]. */
++function sitemapIndex(maps = []) { return sharedSeo.sitemapIndexXml(maps); }
++
++/** robots.txt (openvibe-shared/seo), with the sitemap locations declared. */
++function robotsTxt(opts = {}) { return sharedSeo.robotsTxt(opts); }
++
++// ---- Feeds -------------------------------------------------------------------------------------
++
++const xml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' }[c]))
++ // Characters XML 1.0 forbids.
++ .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F￾￿]/g, '');
++const isoOr = (v, name) => (v == null || v === '' ? null : new Date(timeOf(v, name)).toISOString());
++
++function feedItems(items) {
++ const out = [];
++ const skipped = [];
++ for (const it of items) {
++ if (!it || it.id == null || !it.url) throw new TypeError('every feed item needs a stable id and a canonical url');
++ const d = requireDecision(it.decision, 'feed items');
++ if (!d.listable) { skipped.push({ id: String(it.id), codes: d.codes }); continue; }
++ out.push({
++ id: String(it.id), url: it.url, title: it.title == null ? null : String(it.title),
++ summary: it.summary == null ? null : String(it.summary), contentHtml: it.contentHtml == null ? null : String(it.contentHtml),
++ published: isoOr(it.published, 'published'), updated: isoOr(it.updated, 'updated'),
++ authors: (it.authors || []).filter((a) => a && a.name).map((a) => ({ name: String(a.name), url: a.url || null })),
++ tags: (it.tags || []).filter((t) => t != null && t !== '').map(String),
++ image: it.image || null,
++ });
++ }
++ return { items: out, skipped };
++}
++
++function requireChannel(c, keys) {
++ for (const k of keys) if (!c || !c[k]) throw new TypeError(`feed channel needs ${k}`);
++}
++
++/** RSS 2.0. channel: { title, link, description, feedUrl?, language?, updated? }. */
++function rssFeed(channel, items = []) {
++ requireChannel(channel, ['title', 'link', 'description']);
++ const { items: list } = feedItems(items);
++ const out = ['<?xml version="1.0" encoding="UTF-8"?>',
++ '<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">',
++ '<channel>', `<title>${xml(channel.title)}</title>`, `<link>${xml(channel.link)}</link>`, `<description>${xml(channel.description)}</description>`];
++ if (channel.feedUrl) out.push(`<atom:link href="${xml(channel.feedUrl)}" rel="self" type="application/rss+xml"/>`);
++ if (channel.language) out.push(`<language>${xml(channel.language)}</language>`);
++ if (channel.updated) out.push(`<lastBuildDate>${new Date(timeOf(channel.updated, 'channel.updated')).toUTCString()}</lastBuildDate>`);
++ for (const it of list) {
++ const parts = [];
++ if (it.title) parts.push(`<title>${xml(it.title)}</title>`);
++ parts.push(`<link>${xml(it.url)}</link>`, `<guid isPermaLink="false">${xml(it.id)}</guid>`);
++ if (it.summary) parts.push(`<description>${xml(it.summary)}</description>`);
++ if (it.contentHtml) parts.push(`<content:encoded>${xml(it.contentHtml)}</content:encoded>`);
++ if (it.published) parts.push(`<pubDate>${new Date(it.published).toUTCString()}</pubDate>`);
++ for (const a of it.authors) parts.push(`<dc:creator>${xml(a.name)}</dc:creator>`);
++ for (const t of it.tags) parts.push(`<category>${xml(t)}</category>`);
++ out.push(`<item>${parts.join('')}</item>`);
++ }
++ out.push('</channel>', '</rss>', '');
++ return out.join('\n');
++}
++
++const atomId = (id) => (/^[a-z][a-z0-9+.-]*:/i.test(id) ? id : `urn:openvibe:${id}`);
++
++/**
++ * Atom 1.0. channel: { title, link, feedUrl, id?, updated?, author? { name, url }, subtitle? }.
++ * Entries with neither `updated` nor `published` are left out (Atom requires a date and this
++ * module will not invent one). The feed's <updated> is channel.updated or the newest entry.
++ */
++function atomFeed(channel, items = []) {
++ requireChannel(channel, ['title', 'link', 'feedUrl']);
++ const { items: list } = feedItems(items);
++ const dated = list.filter((it) => it.updated || it.published);
++ const newest = dated.map((it) => it.updated || it.published).sort().pop();
++ const updated = channel.updated ? new Date(timeOf(channel.updated, 'channel.updated')).toISOString() : newest;
++ if (!updated) throw new TypeError('an Atom feed needs channel.updated or at least one dated entry');
++ const out = ['<?xml version="1.0" encoding="UTF-8"?>', '<feed xmlns="http://www.w3.org/2005/Atom">',
++ `<id>${xml(atomId(channel.id || channel.feedUrl))}</id>`, `<title>${xml(channel.title)}</title>`,
++ `<link rel="alternate" type="text/html" href="${xml(channel.link)}"/>`, `<link rel="self" type="application/atom+xml" href="${xml(channel.feedUrl)}"/>`,
++ `<updated>${updated}</updated>`];
++ if (channel.subtitle) out.push(`<subtitle>${xml(channel.subtitle)}</subtitle>`);
++ if (channel.author && channel.author.name) out.push(`<author><name>${xml(channel.author.name)}</name>${channel.author.url ? `<uri>${xml(channel.author.url)}</uri>` : ''}</author>`);
++ for (const it of dated) {
++ const parts = [`<id>${xml(atomId(it.id))}</id>`, `<title>${xml(it.title || '')}</title>`, `<link rel="alternate" type="text/html" href="${xml(it.url)}"/>`,
++ `<updated>${it.updated || it.published}</updated>`];
++ if (it.published) parts.push(`<published>${it.published}</published>`);
++ for (const a of it.authors) parts.push(`<author><name>${xml(a.name)}</name>${a.url ? `<uri>${xml(a.url)}</uri>` : ''}</author>`);
++ for (const t of it.tags) parts.push(`<category term="${xml(t)}"/>`);
++ if (it.summary) parts.push(`<summary>${xml(it.summary)}</summary>`);
++ if (it.contentHtml) parts.push(`<content type="html">${xml(it.contentHtml)}</content>`);
++ out.push(`<entry>${parts.join('')}</entry>`);
++ }
++ out.push('</feed>', '');
++ return out.join('\n');
++}
++
++/** JSON Feed 1.1 (as an object; JSON.stringify it). channel: { title, link, feedUrl, description?, language?, authors? }. */
++function jsonFeed(channel, items = []) {
++ requireChannel(channel, ['title', 'link', 'feedUrl']);
++ const { items: list } = feedItems(items);
++ const feed = { version: 'https://jsonfeed.org/version/1.1', title: String(channel.title), home_page_url: channel.link, feed_url: channel.feedUrl };
++ if (channel.description) feed.description = String(channel.description);
++ if (channel.language) feed.language = String(channel.language);
++ const chAuthors = (channel.authors || []).filter((a) => a && a.name).map((a) => compact({ name: a.name, url: a.url }));
++ if (chAuthors.length) feed.authors = chAuthors;
++ feed.items = list.map((it) => compact({
++ id: it.id, url: it.url, title: it.title,
++ content_html: it.contentHtml || undefined,
++ content_text: it.contentHtml ? undefined : (it.summary || it.title || ''),
++ summary: it.summary, image: it.image,
++ date_published: it.published, date_modified: it.updated,
++ authors: it.authors.length ? it.authors.map((a) => compact({ name: a.name, url: a.url })) : undefined,
++ tags: it.tags.length ? it.tags : undefined,
++ }, { keepEmptyString: ['content_text'] }));
++ return feed;
++}
++
++/** <link rel="alternate"> tags that advertise feeds: [{ type: 'rss'|'atom'|'json', href, title }]. */
++function feedLinks(feeds = []) {
++ const types = { rss: 'application/rss+xml', atom: 'application/atom+xml', json: 'application/feed+json' };
++ return feeds.filter((f) => f && f.href && types[f.type])
++ .map((f) => `<link rel="alternate" type="${types[f.type]}" href="${sharedSeo.esc(f.href)}"${f.title ? ` title="${sharedSeo.esc(f.title)}"` : ''}>`).join('\n');
++}
++
++// ---- Structured data ---------------------------------------------------------------------------
++
++/**
++ * Recursively drops null / undefined / '' / NaN / empty arrays / empty objects, and objects whose
++ * only keys are '@type' / '@context' / '@id'. Nothing is ever added.
++ */
++function compact(value, { keepEmptyString = [] } = {}) {
++ const walk = (v, key) => {
++ if (v == null) return undefined;
++ if (typeof v === 'number') return Number.isFinite(v) ? v : undefined;
++ if (typeof v === 'string') return v === '' && !keepEmptyString.includes(key) ? undefined : v;
++ if (Array.isArray(v)) { const a = v.map((x) => walk(x, key)).filter((x) => x !== undefined); return a.length ? a : undefined; }
++ if (typeof v === 'object') {
++ const o = {};
++ for (const [k, x] of Object.entries(v)) { const w = walk(x, k); if (w !== undefined) o[k] = w; }
++ const meaningful = Object.keys(o).filter((k) => k !== '@type' && k !== '@context' && k !== '@id');
++ return meaningful.length ? o : undefined;
++ }
++ return v;
++ };
++ return walk(value, null);
++}
++
++const iso = (v, name) => (v == null || v === '' ? undefined : new Date(timeOf(v, name)).toISOString());
++const num = (v) => (v == null || v === '' ? undefined : (Number.isFinite(Number(v)) ? Number(v) : undefined));
++const CTX = 'https://schema.org';
++
++function person(a) {
++ if (!a) return undefined;
++ if (typeof a === 'string') return { '@type': 'Person', name: a };
++ return { '@type': a.type === 'Organization' ? 'Organization' : 'Person', name: a.name, url: a.url };
++}
++
++function org(o) {
++ if (!o) return undefined;
++ return { '@type': 'Organization', name: o.name, url: o.url, logo: o.logo ? { '@type': 'ImageObject', url: o.logo } : undefined };
++}
++
++/** schema.org Rating, or undefined unless a numeric value is provided. No default bestRating. */
++function rating(r) {
++ if (!r || num(r.value) === undefined) return undefined;
++ return { '@type': 'Rating', ratingValue: num(r.value), bestRating: num(r.best), worstRating: num(r.worst) };
++}
++
++/** AggregateRating only with a numeric value AND a positive count of ratings or reviews. */
++function aggregateRating(r) {
++ if (!r || num(r.value) === undefined) return undefined;
++ const ratingCount = Number.isInteger(r.count) && r.count > 0 ? r.count : undefined;
++ const reviewCount = Number.isInteger(r.reviewCount) && r.reviewCount > 0 ? r.reviewCount : undefined;
++ if (ratingCount === undefined && reviewCount === undefined) return undefined;
++ return { '@type': 'AggregateRating', ratingValue: num(r.value), ratingCount, reviewCount, bestRating: num(r.best), worstRating: num(r.worst) };
++}
++
++const AVAILABILITY = {
++ in_stock: 'https://schema.org/InStock', out_of_stock: 'https://schema.org/OutOfStock', preorder: 'https://schema.org/PreOrder',
++ discontinued: 'https://schema.org/Discontinued', limited: 'https://schema.org/LimitedAvailability', sold_out: 'https://schema.org/SoldOut',
++ online_only: 'https://schema.org/OnlineOnly', in_store_only: 'https://schema.org/InStoreOnly',
++};
++const CONDITION = { new: 'https://schema.org/NewCondition', used: 'https://schema.org/UsedCondition', refurbished: 'https://schema.org/RefurbishedCondition', damaged: 'https://schema.org/DamagedCondition' };
++
++/** Offer only with a numeric price and a 3-letter currency. Availability only from a known value. */
++function offer(o) {
++ if (!o || num(o.price) === undefined || !/^[A-Z]{3}$/.test(String(o.priceCurrency || ''))) return undefined;
++ return {
++ '@type': 'Offer', price: num(o.price), priceCurrency: o.priceCurrency, url: o.url,
++ availability: AVAILABILITY[o.availability], itemCondition: CONDITION[o.condition],
++ priceValidUntil: o.priceValidUntil ? iso(o.priceValidUntil, 'priceValidUntil').slice(0, 10) : undefined,
++ validFrom: iso(o.validFrom, 'validFrom'), validThrough: iso(o.validThrough, 'validThrough'),
++ seller: o.seller ? org(o.seller) : undefined,
++ };
++}
++
++function citationList(list) {
++ return (list || []).map((c) => (c && c.url ? { '@type': 'CreativeWork', url: c.url, name: c.title || undefined } : undefined));
++}
++
++const structuredData = {
++ /**
++ * Article / BlogPosting / NewsArticle from what the page shows. Missing headline or url → null.
++ * { type, headline, url, description, datePublished, dateModified, authors: [ {name,url,type} | 'Name' ],
++ * publisher: { name, url, logo }, image, section, keywords[], inLanguage, citations: [{ url, title }], wordCount }
++ */
++ article(a = {}) {
++ if (!a.headline || !a.url) return null;
++ const type = ['Article', 'BlogPosting', 'NewsArticle'].includes(a.type) ? a.type : 'Article';
++ return compact({
++ '@context': CTX, '@type': type, '@id': `${a.url}#article`, headline: sharedSeo.clip(a.headline, 110), url: a.url,
++ mainEntityOfPage: a.url, description: a.description, datePublished: iso(a.datePublished, 'datePublished'),
++ dateModified: iso(a.dateModified, 'dateModified'), author: (a.authors || []).map(person), publisher: org(a.publisher),
++ image: a.image, articleSection: a.section, keywords: a.keywords && a.keywords.length ? a.keywords.join(', ') : undefined,
++ inLanguage: a.inLanguage, citation: citationList(a.citations), wordCount: Number.isInteger(a.wordCount) ? a.wordCount : undefined,
++ });
++ },
++
++ /** A single review. Needs the reviewed item's name; the rating appears only if provided. */
++ review(r = {}) {
++ if (!r.itemReviewed || !r.itemReviewed.name) return null;
++ return compact({
++ '@context': CTX, '@type': 'Review', url: r.url,
++ itemReviewed: { '@type': r.itemReviewed.type || 'Thing', name: r.itemReviewed.name, url: r.itemReviewed.url },
++ author: person(r.author), datePublished: iso(r.datePublished, 'datePublished'), reviewBody: r.body,
++ reviewRating: rating(r.rating), publisher: org(r.publisher),
++ });
++ },
++
++ /** Product with offers / aggregateRating only where the data exists. Missing name → null. */
++ product(p = {}) {
++ if (!p.name) return null;
++ return compact({
++ '@context': CTX, '@type': 'Product', name: p.name, url: p.url, description: p.description, image: p.image,
++ brand: p.brand ? { '@type': 'Brand', name: p.brand } : undefined, sku: p.sku, gtin: p.gtin, mpn: p.mpn,
++ offers: (p.offers || []).map(offer), aggregateRating: aggregateRating(p.aggregateRating),
++ });
++ },
++
++ /** An entity with an aggregate rating (Reviews). Missing name → null; missing counts → no rating. */
++ ratedThing(t = {}) {
++ if (!t.name) return null;
++ return compact({ '@context': CTX, '@type': t.type || 'Thing', name: t.name, url: t.url, description: t.description, aggregateRating: aggregateRating(t.aggregateRating) });
++ },
++
++ breadcrumbs(items = []) {
++ const list = items.filter((it) => it && it.name);
++ if (!list.length) return null;
++ return compact({ '@context': CTX, '@type': 'BreadcrumbList', itemListElement: list.map((it, i) => ({ '@type': 'ListItem', position: i + 1, name: it.name, item: it.url })) });
++ },
++
++ webPage(w = {}) {
++ if (!w.url) return null;
++ return compact({ '@context': CTX, '@type': w.type || 'WebPage', '@id': `${w.url}#page`, url: w.url, name: w.name, description: w.description, dateModified: iso(w.dateModified, 'dateModified'), inLanguage: w.inLanguage });
++ },
++};
++
++/** <script type="application/ld+json"> (safe against </script> break-out); '' for null. */
++function jsonLdScript(obj) { return obj ? sharedSeo.jsonLdTag(obj) : ''; }
++
++module.exports = {
++ GATE, REASONS, REASON_BY_CODE, STATES, VISIBILITIES, DEFAULT_POLICY,
++ evaluate, canonicalUrl, createRedirectStore, normalizePath,
++ metaTags, robotsMeta, xRobotsTag,
++ sitemap, sitemapIndex, robotsTxt,
++ rssFeed, atomFeed, jsonFeed, feedLinks,
++ structuredData, jsonLdScript, compact,
++};
ddiff --git a/lib/ssr.js b/lib/ssr.js
nnew file mode 100644
iindex 0000000..1c98446
---- /dev/null
++++ b/lib/ssr.js
@@@ -0,0 +1,253 @@
++'use strict';
++/**
++ * openvibe-publishing/ssr — small helpers for server-rendered pages that are useful without JS.
++ *
++ * const ssr = require('openvibe-publishing/ssr');
++ * ssr.html`<h1>${title}</h1>${ssr.raw(ssr.renderMarkdown(body))}` // values escaped unless raw()
++ * ssr.paginate({ page: 2, perPage: 20, total: 95, href: (p) => `/tags/bread?page=${p}` })
++ * ssr.paginationHtml(pager) ssr.breadcrumbsHtml([{ name, url }, …])
++ * ssr.diffHtml(revs.diff(id, 1, 2, { mode: 'word' }).content)
++ * ssr.timeTag('2026-09-20T10:00:00Z') // '' when the date is unknown — never a guessed date
++ *
++ * Markdown safety model: the source is never trusted as HTML. Text is escaped before any tag is
++ * written, and the only tags produced are this file's own (p, br, h2–h6, blockquote, ul/ol/li, hr,
++ * pre/code, strong, em, del, a). Links keep http(s), mailto, same-site paths and #fragments only.
++ * No images (media goes through openvibe-publishing/media by object id), tables or raw HTML.
++ * The Markdown renderer follows the approach of OpenVibe.Community's server/render/markdown.js (MIT),
++ * without the syntax highlighter, with configurable link rel and heading level.
++ */
++
++const ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
++function escapeHtml(value) {
++ return String(value == null ? '' : value).replace(/[&<>"']/g, (c) => ESC[c]);
++}
++const escapeAttr = escapeHtml;
++
++class Raw {
++ constructor(s) { this.html = String(s == null ? '' : s); }
++ toString() { return this.html; }
++}
++/** Mark trusted HTML (output of this module, or your own template) so html`` does not escape it. */
++function raw(s) { return s instanceof Raw ? s : new Raw(s); }
++
++function interp(v) {
++ if (v == null || v === false) return '';
++ if (v instanceof Raw) return v.html;
++ if (Array.isArray(v)) return v.map(interp).join('');
++ return escapeHtml(v);
++}
++
++/** Tagged template: every interpolated value is escaped unless wrapped in raw(). Returns a Raw. */
++function html(strings, ...values) {
++ let out = strings[0];
++ for (let i = 0; i < values.length; i++) out += interp(values[i]) + strings[i + 1];
++ return new Raw(out);
++}
++
++// ---- Markdown ----------------------------------------------------------------------------------
++
++const MAX_SOURCE = 200000;
++const FENCE = /^ {0,3}(`{3,}|~{3,})\s*([\w+#.-]{0,32})\s*$/;
++const HEADING = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/;
++const QUOTE = /^ {0,3}>\s?(.*)$/;
++const HR = /^ {0,3}([-*_])(\s*\1){2,}\s*$/;
++const UL = /^ {0,3}[-*+]\s+(.*)$/;
++const OL = /^ {0,3}(\d{1,9})[.)]\s+(.*)$/;
++const SAFE_URL = /^(https?:\/\/|mailto:|\/(?![/\\])|#)/i;
++
++function decodeBasic(s) {
++ return s.replace(/&(amp|lt|gt|quot|#39);/g, (_m, e) => ({ amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'" }[e]));
++}
++
++function emphasis(s) {
++ return s
++ .replace(/\*\*(?=\S)([\s\S]*?\S)\*\*/g, '<strong>$1</strong>')
++ .replace(/(^|[^\w])__(?=\S)([\s\S]*?\S)__(?!\w)/g, '$1<strong>$2</strong>')
++ .replace(/(^|[^*\w])\*(?=[^\s*])([^*\n]*?[^\s*])\*(?![*\w])/g, '$1<em>$2</em>')
++ .replace(/(^|[^\w])_(?=[^\s_])([^_\n]*?[^\s_])_(?!\w)/g, '$1<em>$2</em>')
++ .replace(/~~(?=\S)([\s\S]*?\S)~~/g, '<del>$1</del>');
++}
++
++function inline(src, opts) {
++ const held = [];
++ const hold = (h) => `\u0000${held.push(h) - 1}\u0000`;
++ const relAttr = opts.rel ? ` rel="${escapeHtml(opts.rel)}"` : '';
++ let s = String(src).replace(/(`+)([^`\n]|[^`\n][\s\S]*?[^`\n])\1(?!`)/g, (_m, _t, code) => hold(`<code>${escapeHtml(code)}</code>`));
++ s = escapeHtml(s);
++ s = s.replace(/\[([^\]\n]{1,300})\]\(([^\s()]{1,2000})\)/g, (m, text, url) => {
++ const href = decodeBasic(url);
++ if (!SAFE_URL.test(href)) return m;
++ return hold(`<a href="${escapeHtml(href)}"${relAttr}>${emphasis(text)}</a>`);
++ });
++ s = s.replace(/\bhttps?:\/\/[^\s<>\u0000]+/gi, (m) => {
++ let url = m;
++ let tail = '';
++ for (;;) {
++ const ent = url.match(/&(quot|#39|gt|lt);$/);
++ if (ent) { tail = ent[0] + tail; url = url.slice(0, -ent[0].length); continue; }
++ if (/[.,;:!?)\]'"]$/.test(url)) { tail = url.slice(-1) + tail; url = url.slice(0, -1); continue; }
++ break;
++ }
++ if (url.length <= 8) return m;
++ const href = decodeBasic(url);
++ return hold(`<a href="${escapeHtml(href)}"${relAttr}>${escapeHtml(href)}</a>`) + tail;
++ });
++ s = emphasis(s).replace(/\n/g, '<br>\n');
++ for (let i = 0; i < 3 && s.includes('\u0000'); i++) s = s.replace(/\u0000(\d+)\u0000/g, (_m, n) => held[Number(n)] || '');
++ return s;
++}
++
++function startsBlock(line) {
++ return FENCE.test(line) || HEADING.test(line) || QUOTE.test(line) || HR.test(line) || UL.test(line) || OL.test(line);
++}
++
++function blocks(lines, depth, opts) {
++ const out = [];
++ let i = 0;
++ while (i < lines.length) {
++ const line = lines[i];
++ if (!line.trim()) { i++; continue; }
++ let m = line.match(FENCE);
++ if (m) {
++ const close = new RegExp(`^ {0,3}\\${m[1][0]}{${m[1].length},}\\s*$`);
++ const body = [];
++ i++;
++ while (i < lines.length && !close.test(lines[i])) body.push(lines[i++]);
++ i++;
++ const lang = m[2] ? ` class="language-${escapeHtml(m[2].toLowerCase())}"` : '';
++ out.push(`<pre><code${lang}>${escapeHtml(body.join('\n'))}</code></pre>`);
++ continue;
++ }
++ if (HR.test(line)) { out.push('<hr>'); i++; continue; }
++ m = line.match(HEADING);
++ if (m) {
++ const level = Math.min(Math.max(m[1].length + opts.headingShift, 2), 6);
++ out.push(`<h${level}>${inline(m[2], opts)}</h${level}>`);
++ i++;
++ continue;
++ }
++ if (QUOTE.test(line)) {
++ const inner = [];
++ while (i < lines.length && lines[i].trim() && QUOTE.test(lines[i])) inner.push(lines[i++].match(QUOTE)[1]);
++ out.push(depth < 3 ? `<blockquote>${blocks(inner, depth + 1, opts)}</blockquote>` : `<blockquote><p>${inline(inner.join('\n'), opts)}</p></blockquote>`);
++ continue;
++ }
++ if (UL.test(line) || OL.test(line)) {
++ const ordered = !UL.test(line);
++ const re = ordered ? OL : UL;
++ const start = ordered ? parseInt(line.match(OL)[1], 10) : 1;
++ const items = [];
++ while (i < lines.length && lines[i].trim()) {
++ const mm = lines[i].match(re);
++ if (mm) items.push(ordered ? mm[2] : mm[1]);
++ else if (/^\s+\S/.test(lines[i]) && items.length) items[items.length - 1] += `\n${lines[i].trim()}`;
++ else break;
++ i++;
++ }
++ const tag = ordered ? 'ol' : 'ul';
++ out.push(`<${tag}${ordered && start !== 1 ? ` start="${start}"` : ''}>${items.map((it) => `<li>${inline(it, opts)}</li>`).join('')}</${tag}>`);
++ continue;
++ }
++ const para = [];
++ while (i < lines.length && lines[i].trim() && (!para.length || !startsBlock(lines[i]))) para.push(lines[i++]);
++ out.push(`<p>${inline(para.join('\n'), opts)}</p>`);
++ }
++ return out.join('\n');
++}
++
++/**
++ * Markdown → safe HTML. Options: rel (link rel, default 'nofollow ugc noopener' — pass 'noopener'
++ * for editorial text you vouch for), headingShift (default 1: '#' becomes <h2>, the page owns <h1>).
++ */
++function renderMarkdown(source, { rel = 'nofollow ugc noopener', headingShift = 1 } = {}) {
++ const text = String(source == null ? '' : source).slice(0, MAX_SOURCE).replace(/\u0000/g, '').replace(/\r\n?/g, '\n');
++ return blocks(text.split('\n'), 0, { rel, headingShift });
++}
++
++/** Markdown → plain text for descriptions, feeds and index documents (the caller escapes it). */
++function markdownToText(source, max = Infinity) {
++ const t = String(source == null ? '' : source).replace(/\u0000/g, '')
++ .replace(/^ {0,3}(`{3,}|~{3,}).*$/gm, '')
++ .replace(/\[([^\]\n]*)\]\([^)\s]*\)/g, '$1')
++ .replace(/^ {0,3}(#{1,6}|>|[-*+]|\d{1,9}[.)])\s+/gm, '')
++ .replace(/^ {0,3}([-*_])(\s*\1){2,}\s*$/gm, '')
++ .replace(/(\*\*|__|~~|`)/g, '')
++ .replace(/\s+/g, ' ')
++ .trim();
++ return t.length > max ? `${t.slice(0, max - 1).replace(/\s+\S*$/, '')}…` : t;
++}
++
++/** Words in a text (Unicode letters/numbers runs) — the SEO gate's thin-content measure. */
++function wordCount(text) {
++ const m = String(text == null ? '' : text).match(/[\p{L}\p{N}][\p{L}\p{N}'’_-]*/gu);
++ return m ? m.length : 0;
++}
++
++// ---- Pagination, breadcrumbs, diff, time -------------------------------------------------------
++
++/**
++ * Server pagination. href(page) builds each page's URL. Out-of-range pages are clamped and
++ * reported (`outOfRange: true`) so the caller can answer 404 instead of an empty page.
++ */
++function paginate({ page = 1, perPage = 20, total = 0, href } = {}) {
++ if (typeof href !== 'function') throw new TypeError('href(page) is required');
++ const per = Math.max(1, Math.min(500, Math.floor(Number(perPage)) || 20));
++ const count = Math.max(0, Math.floor(Number(total)) || 0);
++ const pages = Math.max(1, Math.ceil(count / per));
++ const asked = Math.floor(Number(page)) || 1;
++ const current = Math.min(Math.max(1, asked), pages);
++ const window = [];
++ for (let p = Math.max(1, current - 2); p <= Math.min(pages, current + 2); p++) window.push({ page: p, href: href(p), current: p === current });
++ return {
++ page: current, perPage: per, total: count, pages, offset: (current - 1) * per, limit: per,
++ outOfRange: asked !== current,
++ prev: current > 1 ? { page: current - 1, href: href(current - 1) } : null,
++ next: current < pages ? { page: current + 1, href: href(current + 1) } : null,
++ first: { page: 1, href: href(1) }, last: { page: pages, href: href(pages) },
++ window,
++ };
++}
++
++/** Plain links (rel=prev/next) that work without JavaScript. */
++function paginationHtml(p, { label = 'Pagination' } = {}) {
++ if (!p || p.pages <= 1) return '';
++ const parts = [];
++ if (p.prev) parts.push(`<a href="${escapeHtml(p.prev.href)}" rel="prev">&larr; Newer</a>`);
++ if (p.window[0] && p.window[0].page > 1) parts.push(`<a href="${escapeHtml(p.first.href)}">1</a>`, p.window[0].page > 2 ? '<span aria-hidden="true">…</span>' : '');
++ for (const w of p.window) parts.push(w.current ? `<span aria-current="page">${w.page}</span>` : `<a href="${escapeHtml(w.href)}">${w.page}</a>`);
++ const lastShown = p.window[p.window.length - 1];
++ if (lastShown && lastShown.page < p.pages) parts.push(lastShown.page < p.pages - 1 ? '<span aria-hidden="true">…</span>' : '', `<a href="${escapeHtml(p.last.href)}">${p.pages}</a>`);
++ if (p.next) parts.push(`<a href="${escapeHtml(p.next.href)}" rel="next">Older &rarr;</a>`);
++ return `<nav class="ov-pagination" aria-label="${escapeHtml(label)}">${parts.filter(Boolean).join(' ')}</nav>`;
++}
++
++/** Breadcrumb trail: items [{ name, url }], the last one is the current page. */
++function breadcrumbsHtml(items = [], { label = 'Breadcrumb' } = {}) {
++ const list = items.filter((it) => it && it.name);
++ if (!list.length) return '';
++ const li = list.map((it, i) => (i === list.length - 1 || !it.url
++ ? `<li${i === list.length - 1 ? ' aria-current="page"' : ''}>${escapeHtml(it.name)}</li>`
++ : `<li><a href="${escapeHtml(it.url)}">${escapeHtml(it.name)}</a></li>`));
++ return `<nav class="ov-breadcrumbs" aria-label="${escapeHtml(label)}"><ol>${li.join('')}</ol></nav>`;
++}
++
++/** A diff (from revisions.diff(...).content or diffText) as <ins>/<del> markup inside <pre>. */
++function diffHtml(result) {
++ const ops = result && result.ops ? result.ops : [];
++ const body = ops.map((o) => (o.op === 'insert' ? `<ins>${escapeHtml(o.text)}</ins>` : o.op === 'delete' ? `<del>${escapeHtml(o.text)}</del>` : escapeHtml(o.text))).join('');
++ return `<pre class="ov-diff ov-diff-${result && result.mode === 'word' ? 'word' : 'line'}">${body}</pre>`;
++}
++
++/** <time> for a known instant; '' for an unknown one (the page must not guess a date). */
++function timeTag(value, { label } = {}) {
++ if (value == null || value === '') return '';
++ const d = new Date(value);
++ if (Number.isNaN(d.getTime())) return '';
++ const iso = d.toISOString();
++ return `<time datetime="${iso}">${escapeHtml(label || iso.slice(0, 10))}</time>`;
++}
++
++module.exports = {
++ escapeHtml, escapeAttr, html, raw, renderMarkdown, markdownToText, wordCount,
++ paginate, paginationHtml, breadcrumbsHtml, diffHtml, timeTag,
++};
ddiff --git a/lib/taxonomy.js b/lib/taxonomy.js
nnew file mode 100644
iindex 0000000..970d667
---- /dev/null
++++ b/lib/taxonomy.js
@@@ -0,0 +1,213 @@
++'use strict';
++/**
++ * openvibe-publishing/taxonomy — tags, terms with slugs and hierarchical categories.
++ *
++ * const { createTaxonomy, slugify } = require('openvibe-publishing/taxonomy');
++ * const tax = createTaxonomy(db, { prefix: 'blog' }); // blog_terms, blog_term_links
++ * const cooking = tax.ensureTerm({ vocabulary: 'category', name: 'Cooking' });
++ * const bread = tax.ensureTerm({ vocabulary: 'category', name: 'Bread', parentId: cooking.id });
++ * tax.setTerms('post_1', 'tag', ['sourdough', 'Rye Bread']); // names or term ids
++ * tax.ancestors(bread.id); // [Cooking, Bread] — breadcrumbs
++ * tax.entitiesFor(cooking.id, { includeDescendants: true });
++ *
++ * Vocabularies are free-form names ('tag', 'category', 'series', …) chosen by the product. Slugs
++ * are unique per vocabulary. Hierarchy is a parent pointer; cycles are refused.
++ */
++const { PublishingError, assertDb, assertPrefix, clockOf, assertEntityId } = require('./internal');
++
++const VOCAB_RE = /^[a-z][a-z0-9_]{0,39}$/;
++
++/** "Crème Brûlée & Co." → "creme-brulee-co". Throws when nothing sluggable remains. */
++function slugify(text, { max = 80 } = {}) {
++ const s = String(text == null ? '' : text)
++ .normalize('NFKD')
++ .replace(/[̀-ͯ]/g, '')
++ .toLowerCase()
++ .replace(/['’]/g, '')
++ .replace(/[^\p{L}\p{N}]+/gu, '-')
++ .replace(/^-+|-+$/g, '')
++ .slice(0, max)
++ .replace(/-+$/g, '');
++ if (!s) throw new TypeError(`cannot make a slug from ${JSON.stringify(text)}`);
++ return s;
++}
++
++function shape(row) {
++ if (!row) return null;
++ return { id: row.id, vocabulary: row.vocabulary, slug: row.slug, name: row.name, parentId: row.parent_id, description: row.description };
++}
++
++function createTaxonomy(db, { prefix, now } = {}) {
++ assertDb(db);
++ assertPrefix(prefix);
++ const clock = clockOf(now);
++ const T = `${prefix}_terms`;
++ const L = `${prefix}_term_links`;
++
++ db.exec(`
++ CREATE TABLE IF NOT EXISTS ${T} (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ vocabulary TEXT NOT NULL,
++ slug TEXT NOT NULL,
++ name TEXT NOT NULL,
++ parent_id INTEGER REFERENCES ${T}(id),
++ description TEXT,
++ created_at INTEGER NOT NULL,
++ UNIQUE (vocabulary, slug)
++ );
++ CREATE INDEX IF NOT EXISTS ${T}_parent ON ${T} (parent_id);
++ CREATE TABLE IF NOT EXISTS ${L} (
++ entity_id TEXT NOT NULL,
++ term_id INTEGER NOT NULL REFERENCES ${T}(id),
++ position INTEGER NOT NULL DEFAULT 0,
++ created_at INTEGER NOT NULL,
++ PRIMARY KEY (entity_id, term_id)
++ );
++ CREATE INDEX IF NOT EXISTS ${L}_term ON ${L} (term_id);
++ `);
++
++ const q = {
++ byId: db.prepare(`SELECT * FROM ${T} WHERE id = ?`),
++ bySlug: db.prepare(`SELECT * FROM ${T} WHERE vocabulary = ? AND slug = ?`),
++ insert: db.prepare(`INSERT INTO ${T} (vocabulary, slug, name, parent_id, description, created_at) VALUES (?, ?, ?, ?, ?, ?)`),
++ setParent: db.prepare(`UPDATE ${T} SET parent_id = ? WHERE id = ?`),
++ rename: db.prepare(`UPDATE ${T} SET name = ? WHERE id = ?`),
++ children: db.prepare(`SELECT * FROM ${T} WHERE parent_id = ? ORDER BY name`),
++ vocab: db.prepare(`SELECT * FROM ${T} WHERE vocabulary = ? ORDER BY name`),
++ ancestors: db.prepare(`WITH RECURSIVE up(id, parent_id, depth) AS (
++ SELECT id, parent_id, 0 FROM ${T} WHERE id = ?
++ UNION ALL SELECT t.id, t.parent_id, up.depth + 1 FROM ${T} t JOIN up ON t.id = up.parent_id WHERE up.depth < 64)
++ SELECT t.* FROM up JOIN ${T} t ON t.id = up.id ORDER BY up.depth DESC`),
++ descendants: db.prepare(`WITH RECURSIVE down(id, depth) AS (
++ SELECT id, 0 FROM ${T} WHERE id = ?
++ UNION ALL SELECT t.id, down.depth + 1 FROM ${T} t JOIN down ON t.parent_id = down.id WHERE down.depth < 64)
++ SELECT t.* FROM down JOIN ${T} t ON t.id = down.id WHERE down.depth > 0 ORDER BY t.name`),
++ linksFor: db.prepare(`SELECT t.*, l.position FROM ${L} l JOIN ${T} t ON t.id = l.term_id WHERE l.entity_id = ? ORDER BY t.vocabulary, l.position, t.name`),
++ unlinkVocab: db.prepare(`DELETE FROM ${L} WHERE entity_id = ? AND term_id IN (SELECT id FROM ${T} WHERE vocabulary = ?)`),
++ link: db.prepare(`INSERT OR IGNORE INTO ${L} (entity_id, term_id, position, created_at) VALUES (?, ?, ?, ?)`),
++ unlinkTerm: db.prepare(`DELETE FROM ${L} WHERE term_id = ?`),
++ reparentChildren: db.prepare(`UPDATE ${T} SET parent_id = ? WHERE parent_id = ?`),
++ deleteTerm: db.prepare(`DELETE FROM ${T} WHERE id = ?`),
++ };
++
++ function vocabOf(v) {
++ if (typeof v !== 'string' || !VOCAB_RE.test(v)) throw new TypeError(`vocabulary must match ${VOCAB_RE}`);
++ return v;
++ }
++
++ function mustGet(id) {
++ const row = q.byId.get(Number(id));
++ if (!row) throw new PublishingError(404, 'term.not_found', `No term ${id}`);
++ return row;
++ }
++
++ function checkParent(termId, parentId, vocabulary) {
++ if (parentId == null) return null;
++ const parent = mustGet(parentId);
++ if (parent.vocabulary !== vocabulary) throw new PublishingError(400, 'term.parent_vocabulary', 'A parent must be in the same vocabulary');
++ if (termId != null) {
++ const chain = q.ancestors.all(parent.id).map((r) => r.id);
++ if (chain.includes(Number(termId))) throw new PublishingError(400, 'term.cycle', 'That parent would create a cycle');
++ }
++ return parent.id;
++ }
++
++ const api = {
++ tables: { terms: T, links: L },
++
++ get(id) { return shape(q.byId.get(Number(id))); },
++ bySlug(vocabulary, slug) { return shape(q.bySlug.get(vocabOf(vocabulary), String(slug))); },
++
++ /** Get-or-create by slug (derived from name unless given). The existing term is returned unchanged. */
++ ensureTerm({ vocabulary, name, slug, parentId = null, description = null } = {}) {
++ vocabOf(vocabulary);
++ const label = String(name == null ? '' : name).replace(/\s+/g, ' ').trim().slice(0, 200);
++ if (!label) throw new TypeError('name is required');
++ const s = slug ? slugify(slug) : slugify(label);
++ return db.transaction(() => {
++ const existing = q.bySlug.get(vocabulary, s);
++ if (existing) return shape(existing);
++ const pid = checkParent(null, parentId, vocabulary);
++ const info = q.insert.run(vocabulary, s, label, pid, description, clock());
++ return shape(q.byId.get(info.lastInsertRowid));
++ })();
++ },
++
++ rename(id, name) {
++ mustGet(id);
++ const label = String(name || '').replace(/\s+/g, ' ').trim().slice(0, 200);
++ if (!label) throw new TypeError('name is required');
++ q.rename.run(label, Number(id));
++ return api.get(id);
++ },
++
++ setParent(id, parentId) {
++ const term = mustGet(id);
++ if (parentId != null && Number(parentId) === term.id) throw new PublishingError(400, 'term.cycle', 'A term cannot be its own parent');
++ q.setParent.run(checkParent(term.id, parentId, term.vocabulary), term.id);
++ return api.get(id);
++ },
++
++ /** Root first, the term itself last: a breadcrumb trail. */
++ ancestors(id) { return q.ancestors.all(Number(id)).map(shape); },
++ descendants(id) { return q.descendants.all(Number(id)).map(shape); },
++ children(id) { return q.children.all(Number(id)).map(shape); },
++
++ /** Nested tree of a vocabulary: [{ ...term, children: [...] }]. */
++ tree(vocabulary) {
++ const rows = q.vocab.all(vocabOf(vocabulary)).map((r) => ({ ...shape(r), children: [] }));
++ const byId = new Map(rows.map((r) => [r.id, r]));
++ const roots = [];
++ for (const r of rows) (r.parentId != null && byId.has(r.parentId) ? byId.get(r.parentId).children : roots).push(r);
++ return roots;
++ },
++
++ terms(vocabulary) { return q.vocab.all(vocabOf(vocabulary)).map(shape); },
++
++ /** Replace the entity's terms in one vocabulary. Items are term ids or names (created on demand). */
++ setTerms(entityId, vocabulary, items = []) {
++ assertEntityId(entityId);
++ vocabOf(vocabulary);
++ return db.transaction(() => {
++ q.unlinkVocab.run(entityId, vocabulary);
++ const t = clock();
++ const seen = new Set();
++ items.forEach((item, i) => {
++ const term = typeof item === 'number' ? mustGet(item) : q.bySlug.get(vocabulary, slugify(item)) || q.byId.get(api.ensureTerm({ vocabulary, name: item }).id);
++ if (term.vocabulary !== vocabulary) throw new PublishingError(400, 'term.vocabulary_mismatch', `Term ${term.id} is not a ${vocabulary}`);
++ if (seen.has(term.id)) return;
++ seen.add(term.id);
++ q.link.run(entityId, term.id, i, t);
++ });
++ return api.termsFor(entityId, vocabulary);
++ })();
++ },
++
++ termsFor(entityId, vocabulary) {
++ const rows = q.linksFor.all(assertEntityId(entityId));
++ return rows.filter((r) => !vocabulary || r.vocabulary === vocabulary).map(shape);
++ },
++
++ /** Entity ids tagged with the term (and, optionally, any of its descendants). */
++ entitiesFor(termId, { includeDescendants = false, limit = 1000 } = {}) {
++ const ids = [Number(termId), ...(includeDescendants ? q.descendants.all(Number(termId)).map((r) => r.id) : [])];
++ const marks = ids.map(() => '?').join(',');
++ return db.prepare(`SELECT DISTINCT entity_id FROM ${L} WHERE term_id IN (${marks}) ORDER BY entity_id LIMIT ?`)
++ .all(...ids, Math.max(1, Math.min(10000, limit))).map((r) => r.entity_id);
++ },
++
++ /** Remove a term: its children move up to its parent, its links are dropped. */
++ remove(id) {
++ const term = mustGet(id);
++ db.transaction(() => {
++ q.reparentChildren.run(term.parent_id, term.id);
++ q.unlinkTerm.run(term.id);
++ q.deleteTerm.run(term.id);
++ })();
++ return true;
++ },
++ };
++ return api;
++}
++
++module.exports = { createTaxonomy, slugify };
ddiff --git a/package-lock.json b/package-lock.json
nnew file mode 100644
iindex 0000000..9525a3d
---- /dev/null
++++ b/package-lock.json
@@@ -0,0 +1,726 @@
++{
++ "name": "openvibe-publishing",
++ "version": "0.1.0",
++ "lockfileVersion": 3,
++ "requires": true,
++ "packages": {
++ "": {
++ "name": "openvibe-publishing",
++ "version": "0.1.0",
++ "license": "MIT",
++ "dependencies": {
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0"
++ },
++ "devDependencies": {
++ "ajv": "^8.17.1",
++ "ajv-formats": "^3.0.1",
++ "better-sqlite3": "^11.10.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.7.0"
++ },
++ "engines": {
++ "node": ">=20"
++ },
++ "peerDependencies": {
++ "better-sqlite3": ">=11"
++ },
++ "peerDependenciesMeta": {
++ "better-sqlite3": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/ajv": {
++ "version": "8.20.0",
++ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
++ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "fast-deep-equal": "^3.1.3",
++ "fast-uri": "^3.0.1",
++ "json-schema-traverse": "^1.0.0",
++ "require-from-string": "^2.0.2"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/epoberezkin"
++ }
++ },
++ "node_modules/ajv-formats": {
++ "version": "3.0.1",
++ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
++ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "ajv": "^8.0.0"
++ },
++ "peerDependencies": {
++ "ajv": "^8.0.0"
++ },
++ "peerDependenciesMeta": {
++ "ajv": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/base64-js": {
++ "version": "1.5.1",
++ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
++ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
++ "dev": true,
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/better-sqlite3": {
++ "version": "11.10.0",
++ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
++ "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
++ "dev": true,
++ "hasInstallScript": true,
++ "license": "MIT",
++ "dependencies": {
++ "bindings": "^1.5.0",
++ "prebuild-install": "^7.1.1"
++ }
++ },
++ "node_modules/bindings": {
++ "version": "1.5.0",
++ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
++ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "file-uri-to-path": "1.0.0"
++ }
++ },
++ "node_modules/bl": {
++ "version": "4.1.0",
++ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
++ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "buffer": "^5.5.0",
++ "inherits": "^2.0.4",
++ "readable-stream": "^3.4.0"
++ }
++ },
++ "node_modules/buffer": {
++ "version": "5.7.1",
++ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
++ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
++ "dev": true,
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT",
++ "dependencies": {
++ "base64-js": "^1.3.1",
++ "ieee754": "^1.1.13"
++ }
++ },
++ "node_modules/buffer-equal-constant-time": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
++ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/chownr": {
++ "version": "1.1.4",
++ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
++ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
++ "dev": true,
++ "license": "ISC"
++ },
++ "node_modules/decompress-response": {
++ "version": "6.0.0",
++ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
++ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "mimic-response": "^3.1.0"
++ },
++ "engines": {
++ "node": ">=10"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/deep-extend": {
++ "version": "0.6.0",
++ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
++ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">=4.0.0"
++ }
++ },
++ "node_modules/detect-libc": {
++ "version": "2.1.2",
++ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
++ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
++ "dev": true,
++ "license": "Apache-2.0",
++ "engines": {
++ "node": ">=8"
++ }
++ },
++ "node_modules/ecdsa-sig-formatter": {
++ "version": "1.0.11",
++ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
++ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "safe-buffer": "^5.0.1"
++ }
++ },
++ "node_modules/end-of-stream": {
++ "version": "1.4.5",
++ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
++ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "once": "^1.4.0"
++ }
++ },
++ "node_modules/expand-template": {
++ "version": "2.0.3",
++ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
++ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
++ "dev": true,
++ "license": "(MIT OR WTFPL)",
++ "engines": {
++ "node": ">=6"
++ }
++ },
++ "node_modules/fast-deep-equal": {
++ "version": "3.1.3",
++ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
++ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
++ "dev": true,
++ "license": "MIT"
++ },
++ "node_modules/fast-uri": {
++ "version": "3.1.8",
++ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
++ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
++ "dev": true,
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fastify"
++ },
++ {
++ "type": "opencollective",
++ "url": "https://opencollective.com/fastify"
++ }
++ ],
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/file-uri-to-path": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
++ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
++ "dev": true,
++ "license": "MIT"
++ },
++ "node_modules/fs-constants": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
++ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
++ "dev": true,
++ "license": "MIT"
++ },
++ "node_modules/github-from-package": {
++ "version": "0.0.0",
++ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
++ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
++ "dev": true,
++ "license": "MIT"
++ },
++ "node_modules/ieee754": {
++ "version": "1.2.1",
++ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
++ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
++ "dev": true,
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/inherits": {
++ "version": "2.0.4",
++ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
++ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
++ "dev": true,
++ "license": "ISC"
++ },
++ "node_modules/ini": {
++ "version": "1.3.8",
++ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
++ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
++ "dev": true,
++ "license": "ISC"
++ },
++ "node_modules/json-schema-traverse": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
++ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
++ "dev": true,
++ "license": "MIT"
++ },
++ "node_modules/jsonwebtoken": {
++ "version": "9.0.3",
++ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
++ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
++ "license": "MIT",
++ "dependencies": {
++ "jws": "^4.0.1",
++ "lodash.includes": "^4.3.0",
++ "lodash.isboolean": "^3.0.3",
++ "lodash.isinteger": "^4.0.4",
++ "lodash.isnumber": "^3.0.3",
++ "lodash.isplainobject": "^4.0.6",
++ "lodash.isstring": "^4.0.1",
++ "lodash.once": "^4.0.0",
++ "ms": "^2.1.1",
++ "semver": "^7.5.4"
++ },
++ "engines": {
++ "node": ">=12",
++ "npm": ">=6"
++ }
++ },
++ "node_modules/jwa": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
++ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
++ "license": "MIT",
++ "dependencies": {
++ "buffer-equal-constant-time": "^1.0.1",
++ "ecdsa-sig-formatter": "1.0.11",
++ "safe-buffer": "^5.0.1"
++ }
++ },
++ "node_modules/jws": {
++ "version": "4.0.1",
++ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
++ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
++ "license": "MIT",
++ "dependencies": {
++ "jwa": "^2.0.1",
++ "safe-buffer": "^5.0.1"
++ }
++ },
++ "node_modules/lodash.includes": {
++ "version": "4.3.0",
++ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
++ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isboolean": {
++ "version": "3.0.3",
++ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
++ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isinteger": {
++ "version": "4.0.4",
++ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
++ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isnumber": {
++ "version": "3.0.3",
++ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
++ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isplainobject": {
++ "version": "4.0.6",
++ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
++ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isstring": {
++ "version": "4.0.1",
++ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
++ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.once": {
++ "version": "4.1.1",
++ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
++ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
++ "license": "MIT"
++ },
++ "node_modules/mimic-response": {
++ "version": "3.1.0",
++ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
++ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">=10"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/minimist": {
++ "version": "1.2.8",
++ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
++ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
++ "dev": true,
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/mkdirp-classic": {
++ "version": "0.5.3",
++ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
++ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
++ "dev": true,
++ "license": "MIT"
++ },
++ "node_modules/ms": {
++ "version": "2.1.3",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
++ "license": "MIT"
++ },
++ "node_modules/napi-build-utils": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
++ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
++ "dev": true,
++ "license": "MIT"
++ },
++ "node_modules/node-abi": {
++ "version": "3.96.0",
++ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",
++ "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "semver": "^7.3.5"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
++ "node_modules/once": {
++ "version": "1.4.0",
++ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
++ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
++ "dev": true,
++ "license": "ISC",
++ "dependencies": {
++ "wrappy": "1"
++ }
++ },
++ "node_modules/openvibe-contracts": {
++ "version": "0.7.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.7.0",
++ "integrity": "sha512-gUrjxbTy/HX0QMQGoDPphmFdwm7iitmNMMchs9tDSDGR47FRFHL/RgBgIKYR1S7M6ICu261YycFV8+YVglafXA==",
++ "dev": true,
++ "license": "AGPL-3.0-only",
++ "dependencies": {
++ "ajv": "^8.17.1",
++ "ajv-formats": "^3.0.1"
++ },
++ "bin": {
++ "openvibe-contracts-check": "bin/check-service.js"
++ },
++ "engines": {
++ "node": ">=20"
++ }
++ },
++ "node_modules/openvibe-shared": {
++ "version": "1.0.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0",
++ "integrity": "sha512-JOSH6LbIVzE9oiVWZmUnjZ+Ab8dRQ/SPk9aqBjbJGXp6kBoJA+tj7x1mp6vCusaxGHp17b2r9gmJQXk1TRL/JA==",
++ "license": "MIT",
++ "dependencies": {
++ "jsonwebtoken": "^9.0.0"
++ }
++ },
++ "node_modules/prebuild-install": {
++ "version": "7.1.3",
++ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
++ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
++ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "detect-libc": "^2.0.0",
++ "expand-template": "^2.0.3",
++ "github-from-package": "0.0.0",
++ "minimist": "^1.2.3",
++ "mkdirp-classic": "^0.5.3",
++ "napi-build-utils": "^2.0.0",
++ "node-abi": "^3.3.0",
++ "pump": "^3.0.0",
++ "rc": "^1.2.7",
++ "simple-get": "^4.0.0",
++ "tar-fs": "^2.0.0",
++ "tunnel-agent": "^0.6.0"
++ },
++ "bin": {
++ "prebuild-install": "bin.js"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
++ "node_modules/pump": {
++ "version": "3.0.4",
++ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
++ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "end-of-stream": "^1.1.0",
++ "once": "^1.3.1"
++ }
++ },
++ "node_modules/rc": {
++ "version": "1.2.8",
++ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
++ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
++ "dev": true,
++ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
++ "dependencies": {
++ "deep-extend": "^0.6.0",
++ "ini": "~1.3.0",
++ "minimist": "^1.2.0",
++ "strip-json-comments": "~2.0.1"
++ },
++ "bin": {
++ "rc": "cli.js"
++ }
++ },
++ "node_modules/readable-stream": {
++ "version": "3.6.2",
++ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
++ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "inherits": "^2.0.3",
++ "string_decoder": "^1.1.1",
++ "util-deprecate": "^1.0.1"
++ },
++ "engines": {
++ "node": ">= 6"
++ }
++ },
++ "node_modules/require-from-string": {
++ "version": "2.0.2",
++ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
++ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/safe-buffer": {
++ "version": "5.2.1",
++ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
++ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/semver": {
++ "version": "7.8.5",
++ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
++ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
++ "license": "ISC",
++ "bin": {
++ "semver": "bin/semver.js"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
++ "node_modules/simple-concat": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
++ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
++ "dev": true,
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/simple-get": {
++ "version": "4.0.1",
++ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
++ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
++ "dev": true,
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT",
++ "dependencies": {
++ "decompress-response": "^6.0.0",
++ "once": "^1.3.1",
++ "simple-concat": "^1.0.0"
++ }
++ },
++ "node_modules/string_decoder": {
++ "version": "1.3.0",
++ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
++ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "safe-buffer": "~5.2.0"
++ }
++ },
++ "node_modules/strip-json-comments": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
++ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
++ "dev": true,
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/tar-fs": {
++ "version": "2.1.5",
++ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
++ "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "chownr": "^1.1.1",
++ "mkdirp-classic": "^0.5.2",
++ "pump": "^3.0.0",
++ "tar-stream": "^2.1.4"
++ }
++ },
++ "node_modules/tar-stream": {
++ "version": "2.2.0",
++ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
++ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
++ "dev": true,
++ "license": "MIT",
++ "dependencies": {
++ "bl": "^4.0.3",
++ "end-of-stream": "^1.4.1",
++ "fs-constants": "^1.0.0",
++ "inherits": "^2.0.3",
++ "readable-stream": "^3.1.1"
++ },
++ "engines": {
++ "node": ">=6"
++ }
++ },
++ "node_modules/tunnel-agent": {
++ "version": "0.6.0",
++ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
++ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
++ "dev": true,
++ "license": "Apache-2.0",
++ "dependencies": {
++ "safe-buffer": "^5.0.1"
++ },
++ "engines": {
++ "node": "*"
++ }
++ },
++ "node_modules/util-deprecate": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
++ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
++ "dev": true,
++ "license": "MIT"
++ },
++ "node_modules/wrappy": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
++ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
++ "dev": true,
++ "license": "ISC"
++ }
++ }
++}
ddiff --git a/package.json b/package.json
nnew file mode 100644
iindex 0000000..8ba81f3
---- /dev/null
++++ b/package.json
@@@ -0,0 +1,55 @@
++{
++ "name": "openvibe-publishing",
++ "version": "0.1.0",
++ "description": "Shared publishing packages for the OpenVibe publication products: revisions, scheduling, taxonomy, citations, media attachments, discussion references, the SEO indexability gate, authorship, index hooks and SSR helpers. Packages, not an authority.",
++ "main": "index.js",
++ "exports": {
++ ".": "./index.js",
++ "./package.json": "./package.json",
++ "./revisions": "./lib/revisions.js",
++ "./schedule": "./lib/schedule.js",
++ "./taxonomy": "./lib/taxonomy.js",
++ "./citations": "./lib/citations.js",
++ "./media": "./lib/media.js",
++ "./discussion": "./lib/discussion.js",
++ "./seo": "./lib/seo.js",
++ "./authorship": "./lib/authorship.js",
++ "./index-hooks": "./lib/index-hooks.js",
++ "./ssr": "./lib/ssr.js"
++ },
++ "files": [
++ "index.js",
++ "lib/",
++ "docs/",
++ "CHANGELOG.md"
++ ],
++ "scripts": {
++ "test": "node test/run.js"
++ },
++ "engines": {
++ "node": ">=20"
++ },
++ "repository": {
++ "type": "git",
++ "url": "git+https://github.com/OpenVibers/OpenVibe.Publishing.git"
++ },
++ "homepage": "https://github.com/OpenVibers/OpenVibe.Publishing#readme",
++ "license": "MIT",
++ "dependencies": {
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0"
++ },
++ "peerDependencies": {
++ "better-sqlite3": ">=11"
++ },
++ "peerDependenciesMeta": {
++ "better-sqlite3": {
++ "optional": true
++ }
++ },
++ "devDependencies": {
++ "ajv": "^8.17.1",
++ "ajv-formats": "^3.0.1",
++ "better-sqlite3": "^11.10.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.7.0"
++ }
++}
ddiff --git a/test/authorship.test.js b/test/authorship.test.js
nnew file mode 100644
iindex 0000000..834c993
---- /dev/null
++++ b/test/authorship.test.js
@@@ -0,0 +1,71 @@
++'use strict';
++const assert = require('assert');
++const { openDb, fakeClock, suite } = require('./helpers/db');
++const authorship = require('../lib/authorship');
++const seo = require('../lib/seo');
++
++const { test, run } = suite();
++const USER = 'usr_01J8Z6Q3KX0000000000000000';
++const WF = { id: 'wiki.generate_page', version: 3, runId: 'run_01J8Z6Q3KX0000000000000000', model: 'stub' };
++const facts = (extra) => ({ state: 'published', visibility: 'public', canonicalUrl: 'https://openvibe.wiki/p/x', wordCount: 500, ...extra });
++
++test('records: ai/hybrid need a workflow run, human cannot carry one', () => {
++ assert.deepStrictEqual(authorship.record({ mode: 'human', authors: [USER] }), { mode: 'human', authors: [USER] });
++ const ai = authorship.record({ mode: 'ai', workflow: WF, source: { label: 'goosely\'s stream' } });
++ assert.deepStrictEqual(ai.workflow, WF);
++ assert.throws(() => authorship.record({ mode: 'ai' }), (e) => e.code === 'authorship.workflow_required');
++ assert.throws(() => authorship.record({ mode: 'hybrid', authors: [USER] }), (e) => e.code === 'authorship.workflow_required');
++ assert.throws(() => authorship.record({ mode: 'human', authors: [USER], workflow: WF }), (e) => e.code === 'authorship.workflow_on_human');
++ assert.throws(() => authorship.record({ mode: 'human' }), (e) => e.code === 'authorship.author_required');
++ assert.throws(() => authorship.record({ mode: 'imported' }), (e) => e.code === 'authorship.import_source_required');
++ assert.throws(() => authorship.record({ mode: 'robot' }), /mode/);
++ assert.throws(() => authorship.record({ mode: 'ai', workflow: { id: 'Bad Id', runId: 'r' } }), /workflow.id/);
++ assert.throws(() => authorship.record({ mode: 'human', authors: ['42'] }), /subject/);
++});
++
++test('AI-generated defaults to draft + noindex until a person reviews it', () => {
++ const db = openDb('auth');
++ const reviews = authorship.createReviewLog(db, { prefix: 'wiki_page', now: fakeClock() });
++ const rec = authorship.record({ mode: 'ai', workflow: WF });
++ assert.deepStrictEqual(authorship.initialState(rec), { state: 'draft', noindex: true, reason: 'ai_generated_unreviewed' });
++ assert.deepStrictEqual(authorship.canPublish(rec), { ok: false, reason: 'ai_generated_unreviewed' });
++ const before = seo.evaluate(facts(authorship.gateFacts(rec, reviews.latest('pg', 1))));
++ assert.deepStrictEqual(before.codes, ['ai_generated_unreviewed']);
++
++ assert.throws(() => reviews.record({ entityId: 'pg', revision: 1, reviewer: 'svc:wiki', decision: 'approved' }), (e) => e.code === 'review.reviewer_not_person');
++ reviews.record({ entityId: 'pg', revision: 1, reviewer: USER, decision: 'rejected', note: 'wrong facts' });
++ assert.strictEqual(authorship.canPublish(rec, reviews.latest('pg', 1)).ok, false);
++ reviews.record({ entityId: 'pg', revision: 1, reviewer: USER, decision: 'approved' });
++ const review = reviews.latest('pg', 1);
++ assert.strictEqual(authorship.canPublish(rec, review).ok, true);
++ assert.strictEqual(seo.evaluate(facts(authorship.gateFacts(rec, review))).indexable, true);
++ assert.strictEqual(reviews.latest('pg', 2), null, 'a review covers one revision only');
++ assert.strictEqual(reviews.history('pg').length, 2);
++ assert.throws(() => db.prepare("UPDATE wiki_page_reviews SET decision = 'approved'").run(), /immutable/);
++});
++
++test('stub-provider output is held like AI output', () => {
++ const rec = authorship.record({ mode: 'hybrid', authors: [USER], workflow: WF, stubProvider: true });
++ assert.deepStrictEqual(authorship.canPublish(rec), { ok: false, reason: 'stub_provider' });
++ assert.deepStrictEqual(seo.evaluate(facts(authorship.gateFacts(rec))).codes, ['stub_provider']);
++});
++
++test('human and hybrid content publishes without an AI review', () => {
++ assert.strictEqual(authorship.canPublish(authorship.record({ mode: 'human', authors: [USER] })).ok, true);
++ assert.strictEqual(authorship.canPublish(authorship.record({ mode: 'hybrid', authors: [USER], workflow: WF })).ok, true);
++});
++
++test('disclosure labels name the workflow and the source; human content has none', () => {
++ assert.strictEqual(authorship.disclosure(authorship.record({ mode: 'human', authors: [USER] })), null);
++ const ai = authorship.record({ mode: 'ai', workflow: WF, source: { label: 'goosely\'s stream' } });
++ assert.deepStrictEqual(authorship.disclosure(ai), {
++ mode: 'ai', short: 'AI-generated',
++ long: 'AI-generated from goosely\'s stream by workflow wiki.generate_page v3, not yet reviewed by a person.',
++ });
++ assert.match(authorship.disclosure(ai, { decision: 'approved', reviewer: USER }).long, /reviewed by a person\.$/);
++ assert.strictEqual(authorship.disclosure(authorship.record({ mode: 'hybrid', authors: [USER], workflow: WF })).short, 'Written with AI assistance');
++ const imp = authorship.record({ mode: 'imported', importedFrom: { label: 'the legacy Live pastes', originalAuthor: 'goosely' } });
++ assert.strictEqual(authorship.disclosure(imp).long, 'Imported from the legacy Live pastes; originally by goosely.');
++});
++
++run();
ddiff --git a/test/citations.test.js b/test/citations.test.js
nnew file mode 100644
iindex 0000000..ac185df
---- /dev/null
++++ b/test/citations.test.js
@@@ -0,0 +1,84 @@
++'use strict';
++const assert = require('assert');
++const { openDb, fakeClock, suite } = require('./helpers/db');
++const { createRevisionStore } = require('../lib/revisions');
++const { createCitationStore, gateFacts } = require('../lib/citations');
++
++const { test, run } = suite();
++
++function setup() {
++ const db = openDb('cite');
++ const now = fakeClock();
++ const revisions = createRevisionStore(db, { prefix: 'wiki_page', now });
++ const cites = createCitationStore(db, { prefix: 'wiki', now, revisions });
++ revisions.create({ entityId: 'pg', expectedRevision: 0, content: 'Rye is a grass.' });
++ revisions.create({ entityId: 'pg', expectedRevision: 1, content: 'Rye is a grass grown as a grain.' });
++ revisions.create({ entityId: 'pg', expectedRevision: 2, content: 'Rye is a cereal.' });
++ return { db, now, revisions, cites };
++}
++
++test('a citation records source, retrieval time, quote span and license on one revision', () => {
++ const { cites } = setup();
++ const c = cites.attach({
++ entityId: 'pg', revision: 1, sourceItemId: 'src_01J8Z6Q3KX0000000000000001', url: 'https://example.org/rye',
++ title: 'Rye', retrievedAt: '2026-09-20T10:00:00Z', quote: { text: 'Rye is a grass', start: 0, end: 14 },
++ licenseNote: 'CC BY-SA 4.0', anchor: '1', attachedBy: 'usr_01J8Z6Q3KX0000000000000000',
++ });
++ assert.strictEqual(c.revision, 1);
++ assert.strictEqual(c.retrievedAt, '2026-09-20T10:00:00.000Z');
++ assert.deepStrictEqual(c.quote, { text: 'Rye is a grass', start: 0, end: 14 });
++ assert.strictEqual(c.licenseNote, 'CC BY-SA 4.0');
++ assert.strictEqual(cites.table, 'wiki_citations');
++});
++
++test('nothing is filled in: unknown retrieval time and title stay null', () => {
++ const { cites } = setup();
++ const c = cites.attach({ entityId: 'pg', revision: 1, url: 'https://example.org/x' });
++ assert.strictEqual(c.retrievedAt, null);
++ assert.strictEqual(c.title, null);
++ assert.strictEqual(c.licenseNote, null);
++ assert.strictEqual(c.quote, null);
++ assert.deepStrictEqual(gateFacts([c]), { citationCount: 1, datedCitationCount: 0 });
++});
++
++test('citations stay attached to the revision that used them after later revisions drop them', () => {
++ const { cites } = setup();
++ const a = cites.attach({ entityId: 'pg', revision: 1, url: 'https://example.org/a' });
++ const b = cites.attach({ entityId: 'pg', revision: 1, url: 'https://example.org/b' });
++ const carried = cites.carryForward({ entityId: 'pg', fromRevision: 1, toRevision: 2, ids: [a.id] });
++ assert.strictEqual(carried.length, 1);
++ assert.strictEqual(carried[0].carriedFrom, a.id);
++ // revision 3 reuses nothing
++ assert.deepStrictEqual(cites.forRevision('pg', 3), []);
++ assert.deepStrictEqual(cites.forRevision('pg', 1).map((c) => c.url), ['https://example.org/a', 'https://example.org/b']);
++ assert.deepStrictEqual(cites.forRevision('pg', 2).map((c) => c.url), ['https://example.org/a']);
++ assert.strictEqual(cites.history('pg').length, 3);
++ assert.ok(cites.get(b.id));
++});
++
++test('citation rows cannot be updated or deleted outside an audited purge', () => {
++ const { db, cites } = setup();
++ cites.attach({ entityId: 'pg', revision: 1, url: 'https://example.org/a' });
++ assert.throws(() => db.prepare("UPDATE wiki_citations SET url = 'https://evil.test'").run(), /immutable/);
++ assert.throws(() => db.prepare('DELETE FROM wiki_citations').run(), /never deleted/);
++ assert.deepStrictEqual(cites.purgeEntity('pg', { reason: 'legal' }), { deleted: 1 });
++ assert.throws(() => cites.attach({ entityId: 'pg', revision: 1, url: 'https://example.org/a' }), (e) => e.status === 410);
++});
++
++test('validation: a source is required, URLs are http(s), revisions must exist, spans are ordered', () => {
++ const { cites } = setup();
++ assert.throws(() => cites.attach({ entityId: 'pg', revision: 1 }), (e) => e.code === 'citation.no_source');
++ assert.throws(() => cites.attach({ entityId: 'pg', revision: 1, url: 'javascript:alert(1)' }), /http/);
++ assert.throws(() => cites.attach({ entityId: 'pg', revision: 9, url: 'https://example.org' }), (e) => e.code === 'revision.not_found');
++ assert.throws(() => cites.attach({ entityId: 'pg', revision: 1, url: 'https://example.org', quote: { start: 5, end: 2 } }), /span/);
++ assert.throws(() => cites.attach({ entityId: 'pg', revision: 1, url: 'https://example.org', retrievedAt: 'yesterday' }), /retrievedAt/);
++});
++
++test('bySourceItem finds every revision citing a source (correction propagation)', () => {
++ const { cites } = setup();
++ cites.attach({ entityId: 'pg', revision: 1, sourceItemId: 'src_A' });
++ cites.attach({ entityId: 'pg', revision: 3, sourceItemId: 'src_A' });
++ assert.deepStrictEqual(cites.bySourceItem('src_A').map((c) => c.revision), [1, 3]);
++});
++
++run();
ddiff --git a/test/discussion.test.js b/test/discussion.test.js
nnew file mode 100644
iindex 0000000..540fd63
---- /dev/null
++++ b/test/discussion.test.js
@@@ -0,0 +1,100 @@
++'use strict';
++const assert = require('assert');
++const http = require('http');
++const contracts = require('openvibe-contracts');
++const { openDb, fakeClock, suite } = require('./helpers/db');
++const { createDiscussionClient, createDiscussionRefs, entityRef } = require('../lib/discussion');
++
++const { test, run } = suite();
++const USER = 'usr_01J8Z6Q3KX0000000000000000';
++
++/** A stand-in for OpenVibe.Community's POST /api/v1/comments/threads/resolve (get-or-create). */
++function fakeCommunity() {
++ const threads = new Map();
++ const requests = [];
++ let mode = 'ok';
++ const server = http.createServer((req, res) => {
++ let body = '';
++ req.on('data', (c) => { body += c; });
++ req.on('end', () => {
++ requests.push({ method: req.method, url: req.url, headers: req.headers, body });
++ const send = (status, obj, type = 'application/json') => { res.writeHead(status, { 'Content-Type': type }); res.end(JSON.stringify(obj)); };
++ if (mode === 'down') return send(503, { type: 'x', title: 'Service Unavailable', status: 503, code: 'service.unavailable', error: 'down' }, 'application/problem+json');
++ if (req.method !== 'POST' || req.url !== '/api/v1/comments/threads/resolve') return send(404, { code: 'route.not_found' });
++ if (req.headers.authorization !== 'Bearer svc-token') return send(401, { code: 'auth.invalid_token', error: 'no' });
++ const { ref } = JSON.parse(body);
++ if (!contracts.validate('common.entity-ref@1', ref).valid) return send(400, { code: 'ref.invalid' });
++ const key = `${ref.service}/${ref.type}/${ref.id}`;
++ if (threads.has(key)) return send(200, { thread: threads.get(key), created: false });
++ const thread = { id: `thr_${threads.size + 1}`, ref, visibility: 'public', comment_count: 0 };
++ threads.set(key, thread);
++ return send(201, { thread, created: true });
++ });
++ });
++ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({
++ url: `http://127.0.0.1:${server.address().port}`, requests, setMode: (m) => { mode = m; }, close: () => server.close(),
++ })));
++}
++
++const tokenClient = { authHeaders: async () => ({ Authorization: 'Bearer svc-token' }), invalidated: 0, invalidate() { this.invalidated++; } };
++
++test('resolveThread: get-or-create over the Community contract, with service auth and trace headers', async () => {
++ const community = await fakeCommunity();
++ try {
++ const client = createDiscussionClient({ communityUrl: community.url + '/', tokenClient });
++ const ref = { service: 'wiki', type: 'page', id: 'pg_1', label: 'Rye' };
++ const first = await client.resolveThread(ref, { subject: USER, traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', requestId: 'req-1' });
++ const second = await client.resolveThread(ref);
++ assert.deepStrictEqual(first, { threadId: 'thr_1', created: true, visibility: 'public' });
++ assert.strictEqual(second.threadId, 'thr_1');
++ assert.strictEqual(second.created, false);
++ const h = community.requests[0].headers;
++ assert.strictEqual(h['x-ov-subject'], USER);
++ assert.strictEqual(h.traceparent, '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01');
++ assert.strictEqual(h['x-openvibe-request-id'], 'req-1');
++ assert.strictEqual(community.requests[1].headers['x-ov-subject'], undefined, 'acts as the service when no subject');
++ } finally { community.close(); }
++});
++
++test('failures are errors, never a fabricated thread', async () => {
++ const community = await fakeCommunity();
++ try {
++ const client = createDiscussionClient({ communityUrl: community.url, tokenClient });
++ community.setMode('down');
++ await assert.rejects(client.resolveThread({ service: 'blog', type: 'post', id: 'p' }), (e) => e.status === 503 && e.code === 'service.unavailable');
++ const bad = createDiscussionClient({ communityUrl: community.url, tokenClient: { authHeaders: async () => ({ Authorization: 'Bearer wrong' }), invalidate() { this.hit = true; } } });
++ community.setMode('ok');
++ await assert.rejects(bad.resolveThread({ service: 'blog', type: 'post', id: 'p' }), (e) => e.status === 401 && e.code === 'auth.invalid_token');
++ const nowhere = createDiscussionClient({ communityUrl: 'http://127.0.0.1:1', tokenClient, timeoutMs: 500 });
++ await assert.rejects(nowhere.resolveThread({ service: 'blog', type: 'post', id: 'p' }), (e) => e.code === 'discussion.unavailable');
++ await assert.rejects(client.resolveThread({ service: 'Blog!', type: 'post', id: 'p' }), /service/);
++ await assert.rejects(client.resolveThread({ service: 'blog', type: 'post', id: 'p' }, { subject: '42' }), /subject/);
++ } finally { community.close(); }
++});
++
++test('the local table stores the reference only — no comment content columns — and resolves once', async () => {
++ const community = await fakeCommunity();
++ try {
++ const db = openDb('disc');
++ const refs = createDiscussionRefs(db, { prefix: 'wiki', now: fakeClock() });
++ const client = createDiscussionClient({ communityUrl: community.url, tokenClient });
++ const ref = { service: 'wiki', type: 'page', id: 'pg_9' };
++ const a = await refs.threadFor('pg_9', ref, { client });
++ const b = await refs.threadFor('pg_9', ref, { client });
++ assert.strictEqual(a.cached, false);
++ assert.strictEqual(b.cached, true);
++ assert.strictEqual(a.threadId, b.threadId);
++ assert.strictEqual(community.requests.length, 1);
++ const cols = db.prepare('PRAGMA table_info(wiki_discussion_refs)').all().map((c) => c.name).sort();
++ assert.deepStrictEqual(cols, ['entity_id', 'ref', 'resolved_at', 'thread_id']);
++ assert.ok(contracts.validate('common.entity-ref@1', refs.get('pg_9').ref).valid);
++ } finally { community.close(); }
++});
++
++test('entityRef validation matches common.entity-ref@1', () => {
++ const r = entityRef({ service: 'news', type: 'story', id: 'st_1', label: 'x'.repeat(500), revision: 3 });
++ assert.strictEqual(r.label.length, 200);
++ assert.ok(contracts.validate('common.entity-ref@1', r).valid);
++});
++
++run();
ddiff --git a/test/helpers/db.js b/test/helpers/db.js
nnew file mode 100644
iindex 0000000..c5d9918
---- /dev/null
++++ b/test/helpers/db.js
@@@ -0,0 +1,49 @@
++'use strict';
++/** A fresh on-disk SQLite database in a temp directory, removed at exit. */
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const Database = require('better-sqlite3');
++
++const dirs = [];
++process.on('exit', () => { for (const d of dirs) fs.rmSync(d, { recursive: true, force: true }); });
++
++function tempDir(tag = 'pub') {
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), `ovpub-${tag}-`));
++ dirs.push(dir);
++ return dir;
++}
++
++function openDb(tag) {
++ const db = new Database(path.join(tempDir(tag), 'test.db'));
++ db.pragma('journal_mode = WAL');
++ db.pragma('foreign_keys = ON');
++ return db;
++}
++
++/** A controllable clock. */
++function fakeClock(start = Date.parse('2026-09-22T12:00:00Z')) {
++ let t = start;
++ const now = () => t;
++ now.advance = (ms) => { t += ms; return t; };
++ now.set = (ms) => { t = ms; return t; };
++ return now;
++}
++
++/** Tiny async-aware test runner: test(name, fn) then run(). */
++function suite() {
++ const tests = [];
++ return {
++ test(name, fn) { tests.push({ name, fn }); },
++ async run() {
++ let failed = 0;
++ for (const t of tests) {
++ try { await t.fn(); console.log(` ok ${t.name}`); } catch (err) { failed++; console.log(` FAIL ${t.name}\n${err && err.stack || err}`); }
++ }
++ console.log(`${tests.length - failed}/${tests.length} passed`);
++ if (failed) process.exit(1);
++ },
++ };
++}
++
++module.exports = { openDb, tempDir, fakeClock, suite };
ddiff --git a/test/index-hooks.test.js b/test/index-hooks.test.js
nnew file mode 100644
iindex 0000000..4b9052c
---- /dev/null
++++ b/test/index-hooks.test.js
@@@ -0,0 +1,111 @@
++'use strict';
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const Ajv = require('ajv/dist/2020');
++const addFormats = require('ajv-formats');
++const contracts = require('openvibe-contracts');
++const hooks = require('../lib/index-hooks');
++const seo = require('../lib/seo');
++const { suite } = require('./helpers/db');
++
++const { test, run } = suite();
++const USER = 'usr_01J8Z6Q3KX0000000000000000';
++const ajv = new Ajv({ allErrors: true, strict: false });
++addFormats(ajv);
++const validateDoc = ajv.compile(JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'docs', 'contracts-proposal', 'search.index-document.v1.json'), 'utf8')));
++
++const facts = { state: 'published', visibility: 'public', canonicalUrl: 'https://openvibe.wiki/p/rye', wordCount: 400, citationCount: 2 };
++const decision = seo.evaluate(facts, { policy: { requireSources: true } });
++const input = (extra = {}) => ({
++ service: 'wiki', type: 'page', id: 'pg_1', revision: 4, state: 'published', visibility: 'public',
++ canonicalUrl: 'https://openvibe.wiki/p/rye', title: 'Rye', summary: 'A cereal grass.', body: 'Rye is a grass grown as a grain.',
++ facets: { space: 'food', tags: ['grain', 'bread'] },
++ provenance: { authorship: { mode: 'human' }, citations: [{ sourceItemId: 'src_1', url: 'https://example.org/rye', retrievedAt: '2026-09-20T10:00:00Z' }] },
++ decision, publishedAt: '2026-09-20T12:00:00Z', updatedAt: '2026-09-21T12:00:00Z', ...extra,
++});
++
++function withEventId(env) { return { ...env, event_id: contracts.ids.newId('event') }; }
++
++test('a published revision becomes a full index document with every §12.3 field', () => {
++ const doc = hooks.buildIndexDocument(input());
++ for (const k of ['owner_service', 'resource_type', 'resource_id', 'revision', 'visibility', 'acl', 'canonical_url', 'title', 'summary', 'body', 'facets', 'provenance', 'publication_state', 'deleted', 'indexability']) {
++ assert.ok(k in doc, `missing ${k}`);
++ }
++ assert.deepStrictEqual(doc.acl, { public: true });
++ assert.deepStrictEqual(doc.indexability, { indexable: true, listable: true, reasons: [], gate: 'openvibe-publishing/seo@1' });
++ assert.deepStrictEqual(doc.provenance.citations, [{ source_item_id: 'src_1', url: 'https://example.org/rye', retrieved_at: '2026-09-20T10:00:00.000Z' }]);
++ assert.ok(validateDoc(doc), JSON.stringify(validateDoc.errors));
++});
++
++test('deleted, unpublished, draft and unlisted resources become tombstones with no content', () => {
++ for (const extra of [{ deleted: true }, { state: 'unpublished' }, { state: 'draft' }, { visibility: 'unlisted' }]) {
++ const doc = hooks.buildIndexDocument(input(extra));
++ assert.strictEqual(doc.deleted, true, JSON.stringify(extra));
++ for (const k of ['title', 'summary', 'body', 'canonical_url', 'acl']) assert.ok(!(k in doc), `${k} leaked in a tombstone`);
++ assert.ok(validateDoc(doc), JSON.stringify(validateDoc.errors));
++ }
++});
++
++test('private content needs an ACL of subjects; gated content needs entitlements', () => {
++ const priv = seo.evaluate({ ...facts, visibility: 'private' }, { policy: { requireSources: true } });
++ assert.throws(() => hooks.buildIndexDocument(input({ visibility: 'private', decision: priv })), /acl.subjects/);
++ assert.throws(() => hooks.buildIndexDocument(input({ visibility: 'private', decision: priv, acl: { subjects: ['everyone'] } })), /acl.subjects/);
++ const doc = hooks.buildIndexDocument(input({ visibility: 'private', decision: priv, acl: { subjects: [USER] } }));
++ assert.deepStrictEqual(doc.acl, { public: false, subjects: [USER] });
++ assert.strictEqual(doc.indexability.listable, false);
++ assert.throws(() => hooks.buildIndexDocument(input({ visibility: 'gated', decision: priv })), /entitlements/);
++ const vip = hooks.buildIndexDocument(input({ visibility: 'gated', decision: priv, acl: { entitlements: ['vip.tier.gold'] } }));
++ assert.deepStrictEqual(vip.acl, { public: false, entitlements: ['vip.tier.gold'] });
++ assert.ok(validateDoc(vip));
++ assert.throws(() => hooks.buildIndexDocument(input({ decision: undefined })), /decision/);
++});
++
++test('actionFor maps state transitions to event actions', () => {
++ const pub = { state: 'published', visibility: 'public', revision: 1 };
++ assert.strictEqual(hooks.actionFor(null, pub), 'published');
++ assert.strictEqual(hooks.actionFor({ state: 'draft', revision: 1 }, pub), 'published');
++ assert.strictEqual(hooks.actionFor(pub, { ...pub, revision: 2 }), 'updated');
++ assert.strictEqual(hooks.actionFor(pub, { ...pub, visibility: 'private' }), 'updated');
++ assert.strictEqual(hooks.actionFor(pub, pub), null);
++ assert.strictEqual(hooks.actionFor(pub, { ...pub, state: 'unpublished' }), 'unpublished');
++ assert.strictEqual(hooks.actionFor(pub, { ...pub, state: 'deleted' }), 'deleted');
++ assert.strictEqual(hooks.actionFor({ state: 'deleted' }, { state: 'deleted' }), null);
++ assert.strictEqual(hooks.actionFor({ state: 'draft' }, { state: 'draft' }), null);
++});
++
++test('publication events are valid contracts envelopes: <product>.<type>.<action>', () => {
++ const doc = hooks.buildIndexDocument(input());
++ const env = hooks.publicationEvent({ product: 'wiki', type: 'page', action: 'published', id: 'pg_1', revision: 4, actor: USER, document: doc, decision, now: Date.parse('2026-09-22T12:00:00Z') });
++ assert.strictEqual(env.event_type, 'wiki.page.published');
++ assert.strictEqual(env.visibility, 'public');
++ assert.deepStrictEqual(env.actor, { type: 'user', id: USER });
++ assert.deepStrictEqual(env.subject, { type: 'page', id: 'pg_1', revision: 4 });
++ assert.strictEqual(env.payload.document, doc);
++ assert.ok(!('event_id' in env), 'the outbox assigns event_id');
++ const check = contracts.validate('events.event-envelope@1', withEventId(env));
++ assert.ok(check.valid, JSON.stringify(check.errors));
++
++ const tomb = hooks.buildIndexDocument(input({ deleted: true }));
++ const del = hooks.publicationEvent({ product: 'blog', type: 'post', action: 'deleted', id: 'pg_1', revision: 4, actor: 'svc:blog', document: tomb });
++ assert.strictEqual(del.event_type, 'blog.post.deleted');
++ assert.strictEqual(del.visibility, 'internal');
++ assert.deepStrictEqual(del.actor, { type: 'service', id: 'blog' });
++ assert.ok(contracts.validate('events.event-envelope@1', withEventId(del)).valid);
++ assert.throws(() => hooks.publicationEvent({ product: 'blog', type: 'post', action: 'deleted', id: 'pg_1', revision: 4, actor: USER, document: doc }), /tombstone/);
++ assert.throws(() => hooks.publicationEvent({ product: 'blog', type: 'post', action: 'published', id: 'pg_1', revision: 4, actor: USER, document: tomb }), /published document/);
++ assert.throws(() => hooks.publicationEvent({ product: 'blog', type: 'post', action: 'moved', id: 'pg_1', revision: 4, actor: USER, document: doc }), /action/);
++});
++
++test('events about private or non-listable content are internal', () => {
++ const priv = seo.evaluate({ ...facts, visibility: 'private' }, { policy: { requireSources: true } });
++ const doc = hooks.buildIndexDocument(input({ visibility: 'private', decision: priv, acl: { subjects: [USER] } }));
++ const env = hooks.publicationEvent({ product: 'wiki', type: 'page', action: 'updated', id: 'pg_1', revision: 4, actor: USER, document: doc });
++ assert.strictEqual(env.visibility, 'internal');
++ const unlisted = hooks.buildIndexDocument(input({ visibility: 'unlisted' }));
++ const env2 = hooks.publicationEvent({ product: 'wiki', type: 'page', action: 'updated', id: 'pg_1', revision: 4, actor: USER, document: unlisted });
++ assert.strictEqual(env2.visibility, 'internal');
++ assert.ok(contracts.validate('events.event-envelope@1', withEventId(env2)).valid);
++});
++
++run();
ddiff --git a/test/media.test.js b/test/media.test.js
nnew file mode 100644
iindex 0000000..372a068
---- /dev/null
++++ b/test/media.test.js
@@@ -0,0 +1,61 @@
++'use strict';
++const assert = require('assert');
++const { openDb, fakeClock, suite } = require('./helpers/db');
++const { createAttachmentStore, mediaRef, figureHtml, isMediaId } = require('../lib/media');
++const contracts = require('openvibe-contracts');
++
++const { test, run } = suite();
++const MED = 'med_01J8Z6Q3KX0000000000000000';
++const MED2 = 'med_01J8Z6Q3KX0000000000000001';
++
++test('attachments reference Media ids and validate as Contracts MediaRef', () => {
++ const media = createAttachmentStore(openDb('media'), { prefix: 'blog_post', now: fakeClock() });
++ const a = media.attach({ entityId: 'post_1', mediaId: MED, role: 'cover', alt: 'A loaf' });
++ assert.strictEqual(a.state, 'unverified');
++ assert.strictEqual(a.broken, false);
++ assert.ok(contracts.validate('media.media-ref@1', mediaRef(a)).valid);
++ assert.ok(isMediaId('legacy:live:vod:123'));
++ assert.throws(() => media.attach({ entityId: 'post_1', mediaId: 'https://cdn.example/x.png' }), (e) => e.code === 'media.invalid_id');
++ assert.strictEqual(media.table, 'blog_post_attachments');
++});
++
++test('a deleted Media object surfaces an explicit broken-asset state', () => {
++ const media = createAttachmentStore(openDb('media'), { prefix: 'blog_post', now: fakeClock() });
++ media.attach({ entityId: 'post_1', mediaId: MED, role: 'cover', caption: 'Crumb' });
++ media.attach({ entityId: 'post_2', mediaId: MED });
++ media.attach({ entityId: 'post_2', mediaId: MED2 });
++ assert.deepStrictEqual(media.markBroken(MED, 'deleted'), ['post_1', 'post_2']);
++ const [att] = media.list('post_1');
++ assert.strictEqual(att.state, 'broken');
++ assert.strictEqual(att.brokenReason, 'deleted');
++ assert.ok(att.checkedAt);
++ assert.strictEqual(media.broken('post_2').length, 1);
++ const html = figureHtml(att, { urlFor: () => 'https://openvibe.media/o/x' });
++ assert.match(html, /data-state="broken"/);
++ assert.match(html, /no longer available/);
++ assert.doesNotMatch(html, /<img/, 'no image and no guessed URL for a broken object');
++ assert.match(html, /<figcaption>Crumb<\/figcaption>/);
++});
++
++test('verify(): 404 → broken, found → available, outage → unchanged (check_failed)', async () => {
++ const media = createAttachmentStore(openDb('media'), { prefix: 'wiki_page', now: fakeClock() });
++ media.attach({ entityId: 'pg', mediaId: MED });
++ media.attach({ entityId: 'pg', mediaId: MED2 });
++ const out = await media.verify('pg', { resolve: async (id) => (id === MED ? { exists: true } : { exists: false, reason: 'not_found' }) });
++ assert.deepStrictEqual(out.map((o) => o.outcome), ['available', 'broken']);
++ const outage = await media.verify('pg', { resolve: async () => { throw new Error('ECONNREFUSED'); } });
++ assert.deepStrictEqual(outage.map((o) => o.outcome), ['check_failed', 'check_failed']);
++ assert.deepStrictEqual(media.list('pg').map((a) => a.state), ['available', 'broken'], 'an outage changes nothing');
++ media.markAvailable(MED2);
++ assert.strictEqual(media.list('pg')[1].state, 'available');
++});
++
++test('figureHtml escapes alt/caption and needs a URL builder for available media', () => {
++ const att = { mediaId: MED, state: 'available', alt: '"><script>x</script>', caption: '<b>c</b>' };
++ const html = figureHtml(att, { urlFor: (id) => `https://openvibe.media/o/${id}` });
++ assert.doesNotMatch(html, /<script>/);
++ assert.match(html, /&lt;b&gt;c&lt;\/b&gt;/);
++ assert.throws(() => figureHtml(att), /urlFor/);
++});
++
++run();
ddiff --git a/test/package.test.js b/test/package.test.js
nnew file mode 100644
iindex 0000000..7494b36
---- /dev/null
++++ b/test/package.test.js
@@@ -0,0 +1,50 @@
++'use strict';
++/** Package shape: every subpath export loads alone, the root is lazy, no server code needs a browser. */
++const assert = require('assert');
++const { spawnSync } = require('child_process');
++const path = require('path');
++const pkg = require('../package.json');
++const { suite } = require('./helpers/db');
++
++const { test, run } = suite();
++const ROOT = path.join(__dirname, '..');
++const SUBPATHS = Object.keys(pkg.exports).filter((k) => k !== '.' && k !== './package.json');
++
++test('every subpath export resolves to an existing module', () => {
++ assert.deepStrictEqual(SUBPATHS.map((s) => s.slice(2)).sort(),
++ ['authorship', 'citations', 'discussion', 'index-hooks', 'media', 'revisions', 'schedule', 'seo', 'ssr', 'taxonomy']);
++ for (const s of SUBPATHS) assert.ok(require(`openvibe-publishing/${s.slice(2)}`), s);
++});
++
++test('each module loads alone in a fresh process, without better-sqlite3 being required', () => {
++ for (const s of SUBPATHS) {
++ const code = `const Module = require('module'); const orig = Module._load;
++ Module._load = function (req, ...rest) { if (req === 'better-sqlite3') throw new Error('loaded better-sqlite3'); return orig.call(this, req, ...rest); };
++ require('openvibe-publishing/${s.slice(2)}'); console.log('ok');`;
++ const r = spawnSync(process.execPath, ['-e', code], { cwd: ROOT, encoding: 'utf8' });
++ assert.strictEqual(r.status, 0, `${s}: ${r.stderr}`);
++ }
++});
++
++test('the root export is lazy and exposes every module', () => {
++ const r = spawnSync(process.execPath, ['-e', `const p = require('openvibe-publishing');
++ const before = Object.keys(require.cache).filter((f) => f.includes('/lib/')).length;
++ const names = Object.keys(p); p.seo.evaluate; const after = Object.keys(require.cache).filter((f) => f.includes('/lib/')).length;
++ console.log(JSON.stringify({ names, before, after, v: p.version }));`], { cwd: ROOT, encoding: 'utf8' });
++ assert.strictEqual(r.status, 0, r.stderr);
++ const out = JSON.parse(r.stdout);
++ assert.strictEqual(out.before, 0);
++ assert.ok(out.after > 0);
++ assert.strictEqual(out.v, pkg.version);
++ assert.deepStrictEqual(out.names.sort(), ['PublishingError', 'authorship', 'citations', 'discussion', 'indexHooks', 'media', 'revisions', 'schedule', 'seo', 'ssr', 'taxonomy', 'version']);
++});
++
++test('the package owns no runtime: no listen(), no database file, no env config in lib/', () => {
++ const fs = require('fs');
++ for (const f of fs.readdirSync(path.join(ROOT, 'lib'))) {
++ const src = fs.readFileSync(path.join(ROOT, 'lib', f), 'utf8');
++ assert.doesNotMatch(src, /\.listen\(|new Database\(|process\.env|require\('better-sqlite3'\)/, `${f} must take the consumer's handle, not open its own`);
++ }
++});
++
++run();
ddiff --git a/test/revisions.test.js b/test/revisions.test.js
nnew file mode 100644
iindex 0000000..f61d1e7
---- /dev/null
++++ b/test/revisions.test.js
@@@ -0,0 +1,161 @@
++'use strict';
++const assert = require('assert');
++const { openDb, fakeClock, suite } = require('./helpers/db');
++const { createRevisionStore, diffText } = require('../lib/revisions');
++const { formatLines } = require('../lib/diff');
++
++const { test, run } = suite();
++const USER = 'user:usr_01J8Z6Q3KX0000000000000000';
++
++test('create → edit → revert keeps an immutable lineage with parent pointers', () => {
++ const db = openDb('rev');
++ const revs = createRevisionStore(db, { prefix: 'wiki_page', now: fakeClock() });
++ const r1 = revs.create({ entityId: 'pg_1', expectedRevision: 0, content: 'Bread is baked.\n', fields: { title: 'Bread' }, author: USER }).revision;
++ const r2 = revs.create({ entityId: 'pg_1', expectedRevision: 1, content: 'Bread is baked dough.\nIt is old.\n', fields: { title: 'Bread' }, author: USER }).revision;
++ assert.strictEqual(r1.number, 1);
++ assert.strictEqual(r1.parentId, null);
++ assert.strictEqual(r2.parentId, r1.id);
++ assert.strictEqual(r2.parentNumber, 1);
++ const r3 = revs.revert({ entityId: 'pg_1', toRevision: 1, expectedRevision: 2, author: USER }).revision;
++ assert.strictEqual(r3.number, 3);
++ assert.strictEqual(r3.kind, 'revert');
++ assert.strictEqual(r3.revertedTo, 1);
++ assert.strictEqual(r3.parentId, r2.id);
++ assert.strictEqual(r3.content, r1.content);
++ assert.deepStrictEqual(revs.lineage('pg_1').map((r) => r.number), [3, 2, 1]);
++ assert.strictEqual(revs.get('pg_1', 2).content, 'Bread is baked dough.\nIt is old.\n', 'revert did not rewrite history');
++ assert.deepStrictEqual(Object.keys(revs.tables), ['revisions', 'drafts', 'purges']);
++ assert.strictEqual(revs.tables.revisions, 'wiki_page_revisions');
++});
++
++test('rows are immutable in SQLite itself (UPDATE and DELETE abort)', () => {
++ const db = openDb('rev');
++ const revs = createRevisionStore(db, { prefix: 'blog_post' });
++ revs.create({ entityId: 'p', expectedRevision: 0, content: 'x' });
++ assert.throws(() => db.prepare('UPDATE blog_post_revisions SET content = ?').run('y'), /immutable/);
++ assert.throws(() => db.prepare('DELETE FROM blog_post_revisions').run(), /never deleted/);
++ assert.strictEqual(revs.head('p').content, 'x');
++});
++
++test('optimistic concurrency: a stale expectedRevision is a 412 conflict', () => {
++ const db = openDb('rev');
++ const revs = createRevisionStore(db, { prefix: 'wiki_page' });
++ revs.create({ entityId: 'pg', expectedRevision: 0, content: 'a' });
++ revs.create({ entityId: 'pg', expectedRevision: 1, content: 'b' });
++ let err;
++ try { revs.create({ entityId: 'pg', expectedRevision: 1, content: 'c' }); } catch (e) { err = e; }
++ assert.ok(err, 'conflict thrown');
++ assert.strictEqual(err.status, 412);
++ assert.strictEqual(err.code, 'revision.conflict');
++ assert.strictEqual(err.expected, 1);
++ assert.strictEqual(err.current, 2);
++ assert.strictEqual(revs.headNumber('pg'), 2);
++ assert.throws(() => revs.create({ entityId: 'new', content: 'x' }), /expectedRevision/, 'expectedRevision is required');
++});
++
++test('a second connection on the same file with a stale base revision gets 412', () => {
++ const db = openDb('race');
++ const other = new (require('better-sqlite3'))(db.name);
++ const a = createRevisionStore(db, { prefix: 'wiki_page' });
++ const b = createRevisionStore(other, { prefix: 'wiki_page' });
++ a.create({ entityId: 'pg', expectedRevision: 0, content: 'base' });
++ a.create({ entityId: 'pg', expectedRevision: 1, content: 'from a' });
++ assert.throws(() => b.create({ entityId: 'pg', expectedRevision: 1, content: 'from b' }), (e) => e.status === 412);
++ other.close();
++});
++
++test('unchanged edits do not create empty revisions', () => {
++ const revs = createRevisionStore(openDb('rev'), { prefix: 'w' });
++ revs.create({ entityId: 'e', expectedRevision: 0, content: 'same', fields: { a: 1, b: 2 } });
++ const again = revs.create({ entityId: 'e', expectedRevision: 1, content: 'same', fields: { b: 2, a: 1 } });
++ assert.strictEqual(again.created, false);
++ assert.strictEqual(revs.headNumber('e'), 1);
++});
++
++test('line and word diffs', () => {
++ const revs = createRevisionStore(openDb('rev'), { prefix: 'w' });
++ revs.create({ entityId: 'e', expectedRevision: 0, content: 'one\ntwo\nthree\n', fields: { title: 'A' } });
++ revs.create({ entityId: 'e', expectedRevision: 1, content: 'one\n2\nthree\nfour\n', fields: { title: 'B', tag: 'x' } });
++ const d = revs.diff('e', 1, 2);
++ assert.strictEqual(d.content.mode, 'line');
++ assert.deepStrictEqual(d.content.ops, [
++ { op: 'equal', text: 'one\n' }, { op: 'delete', text: 'two\n' }, { op: 'insert', text: '2\n' },
++ { op: 'equal', text: 'three\n' }, { op: 'insert', text: 'four\n' },
++ ]);
++ assert.strictEqual(d.content.added, 2);
++ assert.strictEqual(d.content.removed, 1);
++ assert.deepStrictEqual(d.fields, [{ field: 'tag', from: null, to: 'x' }, { field: 'title', from: 'A', to: 'B' }]);
++ assert.strictEqual(formatLines(d.content), ' one\n- two\n+ 2\n three\n+ four');
++
++ const w = diffText('The quick brown fox', 'The slow brown fox jumps', { mode: 'word' });
++ const rebuiltOld = w.ops.filter((o) => o.op !== 'insert').map((o) => o.text).join('');
++ const rebuiltNew = w.ops.filter((o) => o.op !== 'delete').map((o) => o.text).join('');
++ assert.strictEqual(rebuiltOld, 'The quick brown fox');
++ assert.strictEqual(rebuiltNew, 'The slow brown fox jumps');
++ assert.ok(w.ops.some((o) => o.op === 'delete' && o.text === 'quick'));
++ assert.ok(w.ops.some((o) => o.op === 'insert' && o.text === 'slow'));
++});
++
++test('diff reconstructs both sides on random inputs', () => {
++ let seed = 7;
++ const rnd = () => { seed = (seed * 1103515245 + 12345) % 2147483648; return seed / 2147483648; };
++ const words = ['a', 'b', 'c', 'd', 'e'];
++ for (let i = 0; i < 200; i++) {
++ const mk = () => Array.from({ length: Math.floor(rnd() * 12) }, () => words[Math.floor(rnd() * words.length)]).join('\n');
++ const a = mk();
++ const b = mk();
++ const r = diffText(a, b, { mode: 'line' });
++ assert.strictEqual(r.ops.filter((o) => o.op !== 'insert').map((o) => o.text).join(''), a);
++ assert.strictEqual(r.ops.filter((o) => o.op !== 'delete').map((o) => o.text).join(''), b);
++ }
++});
++
++test('a huge rewrite falls back to an approximate diff instead of exhausting memory', () => {
++ const a = Array.from({ length: 3000 }, (_, i) => `a${i}`).join('\n');
++ const b = Array.from({ length: 3000 }, (_, i) => `b${i}`).join('\n');
++ const r = diffText(a, b, { maxEdits: 100 });
++ assert.strictEqual(r.approximate, true);
++ assert.strictEqual(r.ops.filter((o) => o.op !== 'insert').map((o) => o.text).join(''), a);
++});
++
++test('drafts: save, commit on the base revision, conflict keeps the draft', () => {
++ const revs = createRevisionStore(openDb('rev'), { prefix: 'blog_post', now: fakeClock() });
++ revs.create({ entityId: 'p1', expectedRevision: 0, content: 'v1' });
++ const d = revs.saveDraft({ entityId: 'p1', owner: USER, content: 'my edit' });
++ assert.strictEqual(d.baseRevision, 1);
++ assert.strictEqual(d.stale, false);
++ revs.create({ entityId: 'p1', expectedRevision: 1, content: 'someone else' });
++ assert.strictEqual(revs.getDraft('p1', USER).stale, true);
++ assert.throws(() => revs.commitDraft({ entityId: 'p1', owner: USER }), (e) => e.status === 412);
++ assert.ok(revs.getDraft('p1', USER), 'draft survives a conflict');
++ revs.saveDraft({ entityId: 'p1', owner: USER, content: 'rebased edit', baseRevision: 2 });
++ const out = revs.commitDraft({ entityId: 'p1', owner: USER, message: 'rebased' });
++ assert.strictEqual(out.revision.number, 3);
++ assert.strictEqual(out.revision.author, USER);
++ assert.strictEqual(revs.getDraft('p1', USER), null);
++});
++
++test('purge is explicit, audited and final', () => {
++ const db = openDb('rev');
++ const revs = createRevisionStore(db, { prefix: 'w' });
++ revs.create({ entityId: 'gone', expectedRevision: 0, content: 'x' });
++ revs.create({ entityId: 'kept', expectedRevision: 0, content: 'y' });
++ assert.throws(() => revs.purgeEntity('gone'), /reason/);
++ assert.deepStrictEqual(revs.purgeEntity('gone', { reason: 'legal request #12', purgedBy: USER }), { deleted: 1 });
++ assert.strictEqual(revs.head('gone'), null);
++ assert.ok(revs.head('kept'));
++ assert.strictEqual(db.prepare('SELECT reason FROM w_revision_purges WHERE entity_id = ?').get('gone').reason, 'legal request #12');
++ assert.throws(() => revs.create({ entityId: 'gone', expectedRevision: 0, content: 'again' }), (e) => e.status === 410);
++ assert.throws(() => db.prepare("DELETE FROM w_revisions WHERE entity_id = 'kept'").run(), /never deleted/);
++});
++
++test('prefix and handle are validated; schema creation is idempotent', () => {
++ const db = openDb('rev');
++ assert.throws(() => createRevisionStore(db, { prefix: 'Bad-Prefix' }), /prefix/);
++ assert.throws(() => createRevisionStore(db, { prefix: 'x; DROP TABLE y' }), /prefix/);
++ assert.throws(() => createRevisionStore({}, { prefix: 'ok' }), /better-sqlite3/);
++ createRevisionStore(db, { prefix: 'twice' });
++ createRevisionStore(db, { prefix: 'twice' });
++});
++
++run();
ddiff --git a/test/run.js b/test/run.js
nnew file mode 100644
iindex 0000000..0ed7891
---- /dev/null
++++ b/test/run.js
@@@ -0,0 +1,62 @@
++#!/usr/bin/env node
++/**
++ * Runs every test in test/ — the files named *.test.js — each in its own process, a few at a
++ * time, and fails if any of them fails.
++ *
++ * npm test # everything
++ * npm test -- seo revisions # only files whose name contains one of the words
++ *
++ * Plain Node: temp SQLite files, in-process HTTP, no network.
++ */
++'use strict';
++const { spawn } = require('child_process');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++
++const filters = process.argv.slice(2).filter((a) => !a.startsWith('-'));
++const files = fs.readdirSync(__dirname)
++ .filter((f) => f.endsWith('.test.js'))
++ .filter((f) => !filters.length || filters.some((w) => f.includes(w)))
++ .sort();
++const PARALLEL = Math.max(1, Math.min(4, os.cpus().length - 1));
++const TIMEOUT_MS = 60000;
++
++function runOne(file) {
++ return new Promise((resolve) => {
++ const started = Date.now();
++ const child = spawn(process.execPath, [path.join(__dirname, file)], {
++ cwd: path.join(__dirname, '..'),
++ env: { ...process.env, NODE_ENV: 'test' },
++ stdio: ['ignore', 'pipe', 'pipe'],
++ });
++ let output = '';
++ child.stdout.on('data', (c) => { output += c; });
++ child.stderr.on('data', (c) => { output += c; });
++ const timer = setTimeout(() => { output += `\n[run] timed out after ${TIMEOUT_MS}ms`; child.kill('SIGKILL'); }, TIMEOUT_MS);
++ child.on('close', (code, signal) => {
++ clearTimeout(timer);
++ resolve({ file, ok: code === 0, code: code ?? signal, ms: Date.now() - started, output });
++ });
++ });
++}
++
++(async () => {
++ if (!files.length) { console.error('no test files matched'); process.exit(1); }
++ const queue = [...files];
++ const results = [];
++ await Promise.all(Array.from({ length: PARALLEL }, async () => {
++ while (queue.length) {
++ const r = await runOne(queue.shift());
++ results.push(r);
++ console.log(`${r.ok ? '✓' : '✗'} ${r.file.padEnd(32)} ${String(r.ms).padStart(6)}ms`);
++ }
++ }));
++ const failed = results.filter((r) => !r.ok);
++ for (const r of failed) {
++ console.log(`\n── ${r.file} (exit ${r.code}) ──`);
++ console.log(r.output.split('\n').slice(-40).join('\n'));
++ }
++ console.log(`\n${results.length - failed.length}/${results.length} test files passed`);
++ process.exit(failed.length ? 1 : 0);
++})();
ddiff --git a/test/schedule.test.js b/test/schedule.test.js
nnew file mode 100644
iindex 0000000..724258f
---- /dev/null
++++ b/test/schedule.test.js
@@@ -0,0 +1,112 @@
++'use strict';
++const assert = require('assert');
++const Database = require('better-sqlite3');
++const { openDb, fakeClock, suite } = require('./helpers/db');
++const { createScheduler } = require('../lib/schedule');
++
++const { test, run } = suite();
++const T0 = Date.parse('2026-10-01T09:00:00Z');
++
++test('schedule() is idempotent for the same job', () => {
++ const s = createScheduler(openDb('sch'), { prefix: 'blog', now: fakeClock(T0 - 60000) });
++ const a = s.schedule({ entityId: 'post_1', action: 'publish', runAt: T0, revision: 3 });
++ const b = s.schedule({ entityId: 'post_1', action: 'publish', runAt: new Date(T0).toISOString(), revision: 3 });
++ assert.strictEqual(a.created, true);
++ assert.strictEqual(b.created, false);
++ assert.strictEqual(a.job.id, b.job.id);
++ assert.strictEqual(s.jobs('post_1').length, 1);
++ assert.strictEqual(s.table, 'blog_schedule_jobs');
++});
++
++test('jobs are not due before runAt, and run once when due', async () => {
++ const clock = fakeClock(T0 - 60000);
++ const s = createScheduler(openDb('sch'), { prefix: 'blog', now: clock });
++ s.schedule({ entityId: 'post_1', action: 'publish', runAt: T0, revision: 2 });
++ let calls = 0;
++ const handler = () => { calls++; return { published: 2 }; };
++ assert.deepStrictEqual((await s.runDue({ worker: 'w1', handler })).done, []);
++ clock.set(T0);
++ const out = await s.runDue({ worker: 'w1', handler });
++ assert.strictEqual(out.done.length, 1);
++ assert.deepStrictEqual(out.done[0].result, { published: 2 });
++ await s.runDue({ worker: 'w1', handler });
++ assert.strictEqual(calls, 1);
++});
++
++test('a worker that dies mid-job: the lease expires and a restarted worker re-runs it; the effect happens once', async () => {
++ const clock = fakeClock(T0);
++ const db = openDb('sch');
++ const s = createScheduler(db, { prefix: 'blog', now: clock, leaseMs: 30000 });
++ // the product's own state, updated idempotently by the handler
++ db.exec('CREATE TABLE posts (id TEXT PRIMARY KEY, published_revision INTEGER, publish_count INTEGER NOT NULL DEFAULT 0)');
++ db.prepare("INSERT INTO posts (id) VALUES ('post_1')").run();
++ const handler = (job) => {
++ const info = db.prepare('UPDATE posts SET published_revision = ?, publish_count = publish_count + 1 WHERE id = ? AND published_revision IS NOT ?').run(job.revision, job.entityId, job.revision);
++ return { changed: info.changes };
++ };
++ s.schedule({ entityId: 'post_1', action: 'publish', runAt: T0, revision: 4 });
++
++ // worker A claims, applies the effect, then "crashes" before complete()
++ const [claimed] = s.claim({ worker: 'A' });
++ handler(claimed);
++ assert.strictEqual(s.get(claimed.id).status, 'running');
++
++ // restart: a new scheduler instance on a new connection (like a new process)
++ const s2 = createScheduler(new Database(db.name), { prefix: 'blog', now: clock, leaseMs: 30000 });
++ assert.strictEqual(s2.claim({ worker: 'B' }).length, 0, 'lease still held');
++ clock.advance(30001);
++ const out = await s2.runDue({ worker: 'B', handler });
++ assert.strictEqual(out.done.length, 1);
++ assert.strictEqual(out.done[0].attempts, 2);
++ assert.deepStrictEqual(out.done[0].result, { changed: 0 });
++ const post = db.prepare("SELECT * FROM posts WHERE id = 'post_1'").get();
++ assert.strictEqual(post.published_revision, 4);
++ assert.strictEqual(post.publish_count, 1, 'published exactly once');
++ // the crashed worker coming back cannot overwrite the result
++ assert.strictEqual(s.complete(claimed.id, 'A', { late: true }), false);
++ assert.deepStrictEqual(s.get(claimed.id).result, { changed: 0 });
++});
++
++test('failures retry with backoff, then fail for good', async () => {
++ const clock = fakeClock(T0);
++ const s = createScheduler(openDb('sch'), { prefix: 'blog', now: clock, maxAttempts: 2, backoffMs: [1000] });
++ s.schedule({ entityId: 'p', action: 'unpublish', runAt: T0 });
++ const boom = () => { throw new Error('media service down'); };
++ let out = await s.runDue({ worker: 'w', handler: boom });
++ assert.strictEqual(out.retried.length, 1);
++ assert.strictEqual(out.retried[0].lastError, 'media service down');
++ assert.strictEqual((await s.runDue({ worker: 'w', handler: boom })).retried.length, 0, 'waits for backoff');
++ clock.advance(1000);
++ out = await s.runDue({ worker: 'w', handler: boom });
++ assert.strictEqual(out.failed.length, 1);
++ assert.strictEqual(out.failed[0].status, 'failed');
++});
++
++test('a job whose workers keep crashing is abandoned after maxAttempts and reported as failed', async () => {
++ const clock = fakeClock(T0);
++ const s = createScheduler(openDb('sch'), { prefix: 'blog', now: clock, maxAttempts: 2, leaseMs: 1000 });
++ s.schedule({ entityId: 'p', action: 'publish', runAt: T0, revision: 1 });
++ s.claim({ worker: 'a' }); clock.advance(1001);
++ s.claim({ worker: 'b' }); clock.advance(1001);
++ const out = await s.runDue({ worker: 'c', handler: () => assert.fail('must not run a third time') });
++ assert.strictEqual(out.failed.length, 1);
++ assert.match(out.failed[0].lastError, /lease expired/);
++});
++
++test('cancel and cancelPending', () => {
++ const s = createScheduler(openDb('sch'), { prefix: 'blog', now: fakeClock(T0) });
++ const { job } = s.schedule({ entityId: 'p', action: 'publish', runAt: T0 + 1000, revision: 1 });
++ s.schedule({ entityId: 'p', action: 'unpublish', runAt: T0 + 5000 });
++ assert.strictEqual(s.cancel(job.id), true);
++ assert.strictEqual(s.cancelPending('p', 'unpublish'), 1);
++ assert.deepStrictEqual(s.jobs('p').map((j) => j.status), ['cancelled', 'cancelled']);
++});
++
++test('validation', () => {
++ const s = createScheduler(openDb('sch'), { prefix: 'blog' });
++ assert.throws(() => s.schedule({ entityId: 'p', action: 'delete', runAt: T0 }), /action/);
++ assert.throws(() => s.schedule({ entityId: 'p', action: 'publish', runAt: 'soon' }), /runAt/);
++ assert.throws(() => s.claim({}), /worker/);
++});
++
++run();
ddiff --git a/test/seo.test.js b/test/seo.test.js
nnew file mode 100644
iindex 0000000..f756f9c
---- /dev/null
++++ b/test/seo.test.js
@@@ -0,0 +1,294 @@
++'use strict';
++const assert = require('assert');
++const { openDb, fakeClock, suite } = require('./helpers/db');
++const seo = require('../lib/seo');
++
++const { test, run } = suite();
++const NOW = Date.parse('2026-09-22T12:00:00Z');
++const LONG = 'word '.repeat(200);
++const base = (extra = {}) => ({ state: 'published', visibility: 'public', canonicalUrl: 'https://openvibe.wiki/p/rye', text: LONG, ...extra });
++
++// ---- the gate ----------------------------------------------------------------------------------
++
++test('published, sourced, non-duplicate content is indexable', () => {
++ const d = seo.evaluate(base({ citationCount: 2 }), { policy: { requireSources: true } });
++ assert.deepStrictEqual(d, {
++ gate: 'openvibe-publishing/seo@1', indexable: true, listable: true, robots: 'index, follow',
++ canonical: 'https://openvibe.wiki/p/rye', reasons: [], codes: [],
++ });
++});
++
++test('every reason code fires on its own fact, with a stable effect', () => {
++ const policy = { requireSources: true, sensitiveCategories: ['health'] };
++ const cases = [
++ ['deleted', { state: 'deleted' }, 'hidden'],
++ ['takedown', { takedown: true }, 'hidden'],
++ ['private', { visibility: 'private' }, 'hidden'],
++ ['gated', { visibility: 'gated' }, 'hidden'],
++ ['unlisted', { visibility: 'unlisted' }, 'hidden'],
++ ['draft', { state: 'draft' }, 'hidden'],
++ ['draft', { state: 'scheduled' }, 'hidden'],
++ ['unpublished', { state: 'unpublished' }, 'hidden'],
++ ['ai_generated_unreviewed', { authorship: { mode: 'ai', reviewed: false } }, 'hidden'],
++ ['stub_provider', { stubProvider: true }, 'hidden'],
++ ['unreviewed_sensitive', { category: 'health' }, 'hidden'],
++ ['unreviewed_sensitive', { sensitive: true }, 'hidden'],
++ ['retracted', { retracted: true }, 'noindex'],
++ ['expired', { expiresAt: '2026-09-01T00:00:00Z' }, 'noindex'],
++ ['stale_price', { price: { observedAt: '2026-09-01T00:00:00Z' } }, 'noindex'],
++ ['stale_price', { price: {} }, 'noindex'],
++ ['duplicate_of', { duplicateOf: 'https://openvibe.wiki/p/rye-grain' }, 'noindex'],
++ ['thin', { text: 'too short' }, 'noindex'],
++ ['unsourced', { citationCount: 0 }, 'noindex'],
++ ['unsupported_claims', { unsupportedClaims: 2 }, 'noindex'],
++ ['missing_canonical', { canonicalUrl: undefined }, 'noindex'],
++ ['noindex_requested', { noindex: true }, 'noindex'],
++ ];
++ for (const [code, facts, effect] of cases) {
++ const f = base({ citationCount: 1, ...facts });
++ if (f.canonicalUrl === undefined) delete f.canonicalUrl;
++ const d = seo.evaluate(f, { policy, now: NOW });
++ assert.deepStrictEqual(d.codes, [code], `${JSON.stringify(facts)} → ${d.codes}`);
++ assert.strictEqual(d.reasons[0].effect, effect);
++ assert.strictEqual(d.indexable, false);
++ assert.strictEqual(d.listable, effect !== 'hidden');
++ assert.strictEqual(d.robots, effect === 'hidden' ? 'noindex, nofollow' : 'noindex, follow');
++ assert.ok(seo.REASON_BY_CODE[code].description);
++ }
++});
++
++test('the gate is deterministic: same input → same output, reasons in REASONS order', () => {
++ const facts = base({ state: 'draft', visibility: 'private', text: 'short', noindex: true, citationCount: 0, authorship: { mode: 'ai', reviewed: false }, duplicateOf: 'https://x.test/o' });
++ const opts = { policy: { requireSources: true }, now: NOW };
++ const a = seo.evaluate(facts, opts);
++ const b = seo.evaluate(JSON.parse(JSON.stringify(facts)), opts);
++ assert.deepStrictEqual(a, b);
++ assert.deepStrictEqual(a.codes, ['private', 'draft', 'ai_generated_unreviewed', 'duplicate_of', 'thin', 'unsourced', 'noindex_requested']);
++ const order = seo.REASONS.map((r) => r.code);
++ assert.deepStrictEqual([...a.codes].sort((x, y) => order.indexOf(x) - order.indexOf(y)), a.codes);
++ assert.strictEqual(a.canonical, 'https://x.test/o', 'a duplicate canonicalises to its original');
++ assert.strictEqual(a.reasons.find((r) => r.code === 'thin').detail, '1 of 150 words');
++});
++
++test('reviews lift the AI/stub/sensitive holds; fresh prices and future expiry pass', () => {
++ const d = seo.evaluate(base({
++ authorship: { mode: 'ai', reviewed: true }, stubProvider: true, sensitive: true, sensitiveReviewed: true,
++ expiresAt: '2026-12-01T00:00:00Z', price: { observedAt: NOW - 3600e3 },
++ }), { now: NOW });
++ assert.strictEqual(d.indexable, true);
++ assert.strictEqual(seo.evaluate(base({ authorship: { mode: 'hybrid', reviewed: false } })).indexable, true);
++});
++
++test('no silent pass: unknown facts, missing facts and time facts without `now` throw', () => {
++ assert.throws(() => seo.evaluate(base({ visiblity: 'public' })), /unknown fact "visiblity"/);
++ assert.throws(() => seo.evaluate({ visibility: 'public', text: LONG }), /state/);
++ assert.throws(() => seo.evaluate({ state: 'published', text: LONG }), /visibility/);
++ const { text, ...noText } = base();
++ assert.throws(() => seo.evaluate(noText), /text or facts.wordCount/);
++ assert.throws(() => seo.evaluate(base(), { policy: { requireSources: true } }), /citationCount/);
++ assert.throws(() => seo.evaluate(base({ expiresAt: '2026-01-01' })), /needs options.now/);
++ assert.throws(() => seo.evaluate(base({ price: { observedAt: NOW } })), /needs options.now/);
++ assert.strictEqual(seo.evaluate(noText, { policy: { minWords: 0 } }).indexable, true);
++ assert.strictEqual(seo.evaluate(base({ text: undefined, wordCount: 151 })).indexable, true);
++});
++
++// ---- canonical URLs and redirects --------------------------------------------------------------
++
++test('canonicalUrl', () => {
++ assert.strictEqual(seo.canonicalUrl('https://OpenVibe.Wiki', ['spaces', 'food', 'Crème brûlée']), 'https://openvibe.wiki/spaces/food/Cr%C3%A8me%20br%C3%BBl%C3%A9e');
++ assert.strictEqual(seo.canonicalUrl('https://openvibe.blog/', '/alex/posts/bread/?utm_source=x&page=2#top', { query: ['page'] }), 'https://openvibe.blog/alex/posts/bread?page=2');
++ assert.strictEqual(seo.canonicalUrl('https://openvibe.blog', '/'), 'https://openvibe.blog/');
++ assert.throws(() => seo.canonicalUrl('https://openvibe.blog', 'https://evil.test/x'), /origin/);
++ assert.throws(() => seo.canonicalUrl('ftp://x'), /http/);
++});
++
++test('history-aware redirects: every old slug 301s straight to the current path; gone → 410', () => {
++ const redirects = seo.createRedirectStore(openDb('seo'), { prefix: 'wiki_page', now: fakeClock() });
++ const current = { pg_1: '/p/rye' };
++ const currentPath = (id) => current[id] || null;
++ redirects.recordMove('pg_1', '/p/ryegrass', '/p/rye-grain');
++ redirects.recordMove('pg_1', '/p/rye-grain', '/p/rye');
++ assert.deepStrictEqual(redirects.resolve('/p/ryegrass', { currentPath }), { status: 301, location: '/p/rye', entityId: 'pg_1' });
++ assert.deepStrictEqual(redirects.resolve('/p/rye-grain/?x=1', { currentPath }), { status: 301, location: '/p/rye', entityId: 'pg_1' });
++ assert.strictEqual(redirects.resolve('/p/rye', { currentPath }), null);
++ assert.strictEqual(redirects.resolve('/p/unknown', { currentPath }), null);
++ // renamed back to an old slug: that slug is live again and must not redirect
++ redirects.recordMove('pg_1', '/p/rye', '/p/ryegrass');
++ current.pg_1 = '/p/ryegrass';
++ assert.strictEqual(redirects.resolve('/p/ryegrass', { currentPath }), null);
++ assert.deepStrictEqual(redirects.resolve('/p/rye', { currentPath }).location, '/p/ryegrass');
++ assert.deepStrictEqual(redirects.history('pg_1').map((h) => h.path).sort(), ['/p/rye', '/p/rye-grain']);
++ delete current.pg_1;
++ assert.deepStrictEqual(redirects.resolve('/p/rye', { currentPath }), { status: 410, entityId: 'pg_1' });
++ assert.strictEqual(redirects.table, 'wiki_page_redirects');
++ assert.throws(() => redirects.recordMove('pg_1', 'no-slash', '/x'), /start with/);
++});
++
++// ---- meta tags ---------------------------------------------------------------------------------
++
++test('meta tags take robots and canonical from the decision, and refuse to render without one', () => {
++ const ok = seo.evaluate(base());
++ const html = seo.metaTags({ title: 'Rye', description: 'A grass.', decision: ok });
++ assert.match(html, /<meta name="robots" content="index, follow">/);
++ assert.match(html, /<link rel="canonical" href="https:\/\/openvibe.wiki\/p\/rye">/);
++ const dup = seo.evaluate(base({ duplicateOf: 'https://openvibe.wiki/p/original' }));
++ assert.match(seo.metaTags({ title: 'Rye', decision: dup }), /rel="canonical" href="https:\/\/openvibe.wiki\/p\/original"/);
++ assert.match(seo.metaTags({ title: 'x', decision: seo.evaluate(base({ visibility: 'private' })) }), /content="noindex, nofollow"/);
++ assert.throws(() => seo.metaTags({ title: 'Rye' }), /decision/);
++ assert.strictEqual(seo.robotsMeta(dup), '<meta name="robots" content="noindex, follow">');
++ assert.strictEqual(seo.xRobotsTag(ok), 'index, follow');
++});
++
++// ---- sitemaps ----------------------------------------------------------------------------------
++
++test('sitemaps contain only indexable URLs and never invent lastmod', () => {
++ const entries = [
++ { loc: 'https://openvibe.wiki/p/a', lastmod: '2026-09-20T08:00:00Z', decision: seo.evaluate(base({ canonicalUrl: 'https://openvibe.wiki/p/a' })) },
++ { loc: 'https://openvibe.wiki/p/b', decision: seo.evaluate(base({ canonicalUrl: 'https://openvibe.wiki/p/b' })) },
++ { loc: 'https://openvibe.wiki/p/draft', lastmod: '2026-09-21', decision: seo.evaluate(base({ state: 'draft' })) },
++ { loc: 'https://openvibe.wiki/p/private', decision: seo.evaluate(base({ visibility: 'private' })) },
++ { loc: 'https://openvibe.wiki/p/thin', decision: seo.evaluate(base({ text: 'short' })) },
++ ];
++ const out = seo.sitemap(entries);
++ assert.strictEqual(out.count, 2);
++ assert.strictEqual(out.files.length, 1);
++ assert.match(out.files[0], /<loc>https:\/\/openvibe.wiki\/p\/a<\/loc><lastmod>2026-09-20<\/lastmod>/);
++ assert.match(out.files[0], /<loc>https:\/\/openvibe.wiki\/p\/b<\/loc><\/url>/, 'no lastmod for b');
++ assert.doesNotMatch(out.files[0], /draft|private|thin/);
++ assert.deepStrictEqual(out.skipped.map((s) => s.codes), [['draft'], ['private'], ['thin']]);
++ assert.throws(() => seo.sitemap([{ loc: 'https://x.test/' }]), /decision/);
++ const many = Array.from({ length: 5 }, (_, i) => ({ loc: `https://x.test/${i}`, decision: seo.evaluate(base({ canonicalUrl: `https://x.test/${i}` })) }));
++ assert.strictEqual(seo.sitemap(many, { maxUrls: 2 }).files.length, 3);
++ assert.match(seo.sitemapIndex([{ loc: 'https://x.test/sitemap-1.xml' }]), /<sitemapindex/);
++ assert.match(seo.robotsTxt({ sitemaps: ['https://x.test/sitemap.xml'] }), /Sitemap: https:\/\/x.test\/sitemap.xml/);
++});
++
++// ---- feeds -------------------------------------------------------------------------------------
++
++function feedFixture() {
++ const ok = seo.evaluate(base());
++ const thin = seo.evaluate(base({ text: 'short' }));
++ return [
++ { id: 'tag:openvibe.blog,2026:post/1', url: 'https://openvibe.blog/a/1', title: 'Rye <bread> & co', summary: 'About rye', contentHtml: '<p>Rye</p>', published: '2026-09-20T08:00:00Z', updated: '2026-09-21T08:00:00Z', authors: [{ name: 'Alex', url: 'https://openvibe.blog/a' }], tags: ['bread'], decision: ok },
++ { id: 'post-2', url: 'https://openvibe.blog/a/2', title: 'Undated and authorless', decision: thin },
++ { id: 'post-3', url: 'https://openvibe.blog/a/3', title: 'Private', published: '2026-09-19T08:00:00Z', decision: seo.evaluate(base({ visibility: 'private' })) },
++ { id: 'post-4', url: 'https://openvibe.blog/a/4', title: 'VIP', published: '2026-09-19T08:00:00Z', decision: seo.evaluate(base({ visibility: 'gated' })) },
++ ];
++}
++const channel = { title: 'Alex bakes', link: 'https://openvibe.blog/a', feedUrl: 'https://openvibe.blog/a/feed.xml', description: 'Bread notes' };
++
++test('RSS: only listable items; no invented pubDate or author; XML escaped', () => {
++ const xml = seo.rssFeed(channel, feedFixture());
++ assert.match(xml, /<title>Rye &lt;bread&gt; &amp; co<\/title>/);
++ assert.match(xml, /<pubDate>Sun, 20 Sep 2026 08:00:00 GMT<\/pubDate>/);
++ assert.match(xml, /<dc:creator>Alex<\/dc:creator>/);
++ assert.strictEqual((xml.match(/<item>/g) || []).length, 2, 'the thin post is listable, private and VIP are not');
++ assert.doesNotMatch(xml, /Private|VIP/);
++ const item2 = xml.split('<item>')[2];
++ assert.doesNotMatch(item2, /pubDate|dc:creator/);
++ assert.doesNotMatch(xml, /lastBuildDate/, 'no channel date unless provided');
++ assert.throws(() => seo.rssFeed({ title: 'x', link: 'https://x.test' }, []), /description/);
++});
++
++test('Atom: undated entries are left out instead of dated "now"; feed updated = newest entry', () => {
++ const xml = seo.atomFeed(channel, feedFixture());
++ assert.strictEqual((xml.match(/<entry>/g) || []).length, 1);
++ assert.match(xml, /<updated>2026-09-21T08:00:00.000Z<\/updated>/);
++ assert.match(xml, /<id>tag:openvibe.blog,2026:post\/1<\/id>/);
++ assert.doesNotMatch(xml, /Undated/);
++ assert.throws(() => seo.atomFeed(channel, [feedFixture()[1]]), /needs channel.updated/);
++ assert.match(seo.atomFeed({ ...channel, updated: '2026-09-22T00:00:00Z' }, [feedFixture()[1]]), /<feed/);
++});
++
++test('JSON Feed 1.1: only provided fields', () => {
++ const f = seo.jsonFeed(channel, feedFixture());
++ assert.strictEqual(f.version, 'https://jsonfeed.org/version/1.1');
++ assert.strictEqual(f.items.length, 2);
++ assert.deepStrictEqual(Object.keys(f.items[1]).sort(), ['content_text', 'id', 'title', 'url']);
++ assert.strictEqual(f.items[0].date_published, '2026-09-20T08:00:00.000Z');
++ assert.deepStrictEqual(f.items[0].authors, [{ name: 'Alex', url: 'https://openvibe.blog/a' }]);
++ assert.throws(() => seo.jsonFeed(channel, [{ id: 'x', url: 'https://x.test' }]), /decision/);
++ assert.match(seo.feedLinks([{ type: 'atom', href: '/feed.atom', title: 'Atom' }]), /application\/atom\+xml/);
++});
++
++// ---- structured data ---------------------------------------------------------------------------
++
++/** Every leaf in the output must come from the input (or be a schema.org constant). */
++function leaves(obj, out = [], key = null) {
++ if (obj == null) return out;
++ if (Array.isArray(obj)) { for (const x of obj) leaves(x, out, key); return out; }
++ if (typeof obj === 'object') { for (const [k, v] of Object.entries(obj)) leaves(v, out, k); return out; }
++ out.push({ key, value: obj });
++ return out;
++}
++function assertOnlyFrom(output, input, allowed = []) {
++ const inputs = new Set(leaves(input).map((l) => String(l.value)));
++ for (const l of leaves(output)) {
++ if (['@context', '@type', '@id'].includes(l.key)) continue;
++ if (allowed.includes(String(l.value))) continue;
++ assert.ok(inputs.has(String(l.value)) || inputs.has(new Date(l.value).toISOString()) || [...inputs].some((v) => !Number.isNaN(Date.parse(v)) && Date.parse(v) === Date.parse(l.value)),
++ `invented value ${l.key}=${JSON.stringify(l.value)}`);
++ }
++}
++function hasKey(obj, name) { return JSON.stringify(obj).includes(`"${name}"`); }
++
++test('article: a missing author or date is omitted, never defaulted', () => {
++ const input = { type: 'BlogPosting', headline: 'Rye', url: 'https://openvibe.blog/a/1' };
++ const ld = seo.structuredData.article(input);
++ assert.deepStrictEqual(ld, { '@context': 'https://schema.org', '@type': 'BlogPosting', '@id': 'https://openvibe.blog/a/1#article', headline: 'Rye', url: 'https://openvibe.blog/a/1', mainEntityOfPage: 'https://openvibe.blog/a/1' });
++ for (const k of ['author', 'datePublished', 'dateModified', 'publisher', 'image', 'citation']) assert.ok(!hasKey(ld, k), `${k} must be absent`);
++ const full = { type: 'NewsArticle', headline: 'Rye', url: 'https://openvibe.news/s/1', datePublished: '2026-09-20T08:00:00Z', authors: [{ name: 'Sam' }, { name: '' }],
++ publisher: { name: 'OpenVibe News' }, citations: [{ url: 'https://example.org/src', title: 'Source' }, { title: 'no url' }] };
++ const ld2 = seo.structuredData.article(full);
++ assert.deepStrictEqual(ld2.author, [{ '@type': 'Person', name: 'Sam' }], 'nameless author dropped');
++ assert.deepStrictEqual(ld2.citation, [{ '@type': 'CreativeWork', url: 'https://example.org/src', name: 'Source' }]);
++ assertOnlyFrom(ld2, full);
++ assert.strictEqual(seo.structuredData.article({ url: 'https://x.test' }), null, 'no headline → no Article');
++});
++
++test('review: no rating, no reviewRating; no bestRating default', () => {
++ const input = { itemReviewed: { type: 'Product', name: 'Bread knife' }, author: { name: 'Sam' }, body: 'Sharp.' };
++ const ld = seo.structuredData.review(input);
++ assert.ok(!hasKey(ld, 'reviewRating'));
++ assert.ok(!hasKey(ld, 'ratingValue'));
++ assert.ok(!hasKey(ld, 'datePublished'));
++ const rated = seo.structuredData.review({ ...input, rating: { value: 4 } });
++ assert.deepStrictEqual(rated.reviewRating, { '@type': 'Rating', ratingValue: 4 }, 'bestRating not assumed to be 5');
++ assertOnlyFrom(rated, { ...input, rating: { value: 4 } });
++ assert.strictEqual(seo.structuredData.review({ body: 'x' }), null);
++});
++
++test('aggregate ratings need a value AND a real count; otherwise nothing', () => {
++ assert.strictEqual(seo.structuredData.ratedThing({ name: 'Cafe', aggregateRating: { value: 4.5 } }).aggregateRating, undefined);
++ assert.strictEqual(seo.structuredData.ratedThing({ name: 'Cafe', aggregateRating: { value: 4.5, count: 0 } }).aggregateRating, undefined);
++ assert.strictEqual(seo.structuredData.ratedThing({ name: 'Cafe', aggregateRating: { count: 12 } }).aggregateRating, undefined);
++ const ok = seo.structuredData.ratedThing({ type: 'LocalBusiness', name: 'Cafe', aggregateRating: { value: 4.5, count: 12, best: 5 } });
++ assert.deepStrictEqual(ok.aggregateRating, { '@type': 'AggregateRating', ratingValue: 4.5, ratingCount: 12, bestRating: 5 });
++});
++
++test('product: no price or currency → no Offer; no availability → none assumed', () => {
++ const noPrice = seo.structuredData.product({ name: 'Rye flour', offers: [{ priceCurrency: 'EUR', availability: 'in_stock' }, { price: 3.2 }] });
++ assert.ok(!hasKey(noPrice, 'offers'));
++ assert.ok(!hasKey(noPrice, 'price'));
++ assert.ok(!hasKey(noPrice, 'aggregateRating'));
++ const input = { name: 'Rye flour', url: 'https://openvibe.deals/o/1', offers: [{ price: '3.20', priceCurrency: 'EUR', url: 'https://shop.example/rye' }] };
++ const priced = seo.structuredData.product(input);
++ assert.deepStrictEqual(priced.offers, [{ '@type': 'Offer', price: 3.2, priceCurrency: 'EUR', url: 'https://shop.example/rye' }]);
++ assert.ok(!hasKey(priced, 'availability'), 'availability never assumed');
++ assertOnlyFrom(priced, input, ['3.2']);
++ const avail = seo.structuredData.product({ name: 'x', offers: [{ price: 1, priceCurrency: 'USD', availability: 'out_of_stock' }] });
++ assert.strictEqual(avail.offers[0].availability, 'https://schema.org/OutOfStock');
++ assert.ok(!hasKey(seo.structuredData.product({ name: 'x', offers: [{ price: 1, priceCurrency: 'USD', availability: 'probably' }] }), 'availability'));
++});
++
++test('breadcrumbs, webPage and the JSON-LD script tag', () => {
++ const bc = seo.structuredData.breadcrumbs([{ name: 'Food', url: 'https://w.test/food' }, { name: 'Rye' }]);
++ assert.deepStrictEqual(bc.itemListElement[1], { '@type': 'ListItem', position: 2, name: 'Rye' });
++ assert.strictEqual(seo.structuredData.webPage({ url: 'https://w.test/' })['@type'], 'WebPage');
++ const tag = seo.jsonLdScript(seo.structuredData.article({ headline: '</script><script>alert(1)</script>', url: 'https://w.test/' }));
++ assert.doesNotMatch(tag, /<\/script><script>/);
++ assert.strictEqual(seo.jsonLdScript(null), '');
++ assert.deepStrictEqual(seo.compact({ a: '', b: [null, { '@type': 'X' }], c: NaN, d: 0, e: false }), { d: 0, e: false });
++});
++
++run();
ddiff --git a/test/ssr.test.js b/test/ssr.test.js
nnew file mode 100644
iindex 0000000..f67b694
---- /dev/null
++++ b/test/ssr.test.js
@@@ -0,0 +1,73 @@
++'use strict';
++const assert = require('assert');
++const ssr = require('../lib/ssr');
++const { diffText } = require('../lib/diff');
++const { suite } = require('./helpers/db');
++
++const { test, run } = suite();
++
++test('html`` escapes interpolations unless raw()', () => {
++ const name = '<img src=x onerror=alert(1)>';
++ const out = String(ssr.html`<h1>${name}</h1>${ssr.raw('<em>ok</em>')}${null}${['a', '<b>']}`);
++ assert.strictEqual(out, '<h1>&lt;img src=x onerror=alert(1)&gt;</h1><em>ok</em>a&lt;b&gt;');
++ assert.strictEqual(ssr.escapeHtml(`"'&`), '&quot;&#39;&amp;');
++});
++
++test('markdown: the supported subset renders; raw HTML and unsafe links do not', () => {
++ const src = [
++ '# Title', '', 'Some **bold**, *em*, ~~gone~~ and `code <x>`.', '',
++ '- one', '- two', '', '1. first', '2. second', '', '> quoted', '', '---', '',
++ '```js', 'const a = "<b>";', '```', '',
++ '[site](https://openvibe.wiki/p) [bad](javascript:alert(1)) <script>alert(1)</script>',
++ 'Bare https://example.org/x?a=1&b=2.',
++ ].join('\n');
++ const html = ssr.renderMarkdown(src);
++ assert.match(html, /<h2>Title<\/h2>/, '# is demoted: the page owns <h1>');
++ assert.match(html, /<strong>bold<\/strong>, <em>em<\/em>, <del>gone<\/del> and <code>code &lt;x&gt;<\/code>/);
++ assert.match(html, /<ul><li>one<\/li><li>two<\/li><\/ul>/);
++ assert.match(html, /<ol><li>first<\/li><li>second<\/li><\/ol>/);
++ assert.match(html, /<blockquote><p>quoted<\/p><\/blockquote>/);
++ assert.match(html, /<pre><code class="language-js">const a = &quot;&lt;b&gt;&quot;;<\/code><\/pre>/);
++ assert.match(html, /<a href="https:\/\/openvibe.wiki\/p" rel="nofollow ugc noopener">site<\/a>/);
++ assert.doesNotMatch(html, /href="javascript/);
++ assert.doesNotMatch(html, /<script>/);
++ assert.match(html, /<a href="https:\/\/example.org\/x\?a=1&amp;b=2" rel="nofollow ugc noopener">https:\/\/example.org\/x\?a=1&amp;b=2<\/a>\./);
++ assert.match(ssr.renderMarkdown('[a](/local)', { rel: 'noopener' }), /<a href="\/local" rel="noopener">a<\/a>/);
++ assert.doesNotMatch(ssr.renderMarkdown('[a](//evil.test)'), /<a /);
++ assert.doesNotMatch(ssr.renderMarkdown('[x](https://a.test/"onmouseover="alert(1))'), /" onmouseover|"onmouseover="alert/);
++});
++
++test('markdownToText and wordCount', () => {
++ assert.strictEqual(ssr.markdownToText('# Hi\n\nSome **bold** [link](https://x.test).\n\n```\ncode\n```'), 'Hi Some bold link. code');
++ assert.strictEqual(ssr.markdownToText('one two three four', 10), 'one two…');
++ assert.strictEqual(ssr.wordCount("It's a rye-bread loaf, 2 kg."), 6);
++ assert.strictEqual(ssr.wordCount(''), 0);
++});
++
++test('pagination is plain links and clamps out-of-range pages', () => {
++ const p = ssr.paginate({ page: 3, perPage: 10, total: 95, href: (n) => `/tags/rye?page=${n}` });
++ assert.strictEqual(p.pages, 10);
++ assert.strictEqual(p.offset, 20);
++ assert.strictEqual(p.prev.href, '/tags/rye?page=2');
++ const html = ssr.paginationHtml(p);
++ assert.match(html, /<a href="\/tags\/rye\?page=2" rel="prev">/);
++ assert.match(html, /<span aria-current="page">3<\/span>/);
++ assert.match(html, /<a href="\/tags\/rye\?page=10">10<\/a>/);
++ const far = ssr.paginate({ page: 99, perPage: 10, total: 95, href: (n) => `?p=${n}` });
++ assert.strictEqual(far.page, 10);
++ assert.strictEqual(far.outOfRange, true);
++ assert.strictEqual(ssr.paginationHtml(ssr.paginate({ total: 3, href: String })), '');
++});
++
++test('breadcrumbs, diff markup and honest <time>', () => {
++ const bc = ssr.breadcrumbsHtml([{ name: 'Food', url: '/food' }, { name: '<Rye>', url: '/food/rye' }]);
++ assert.strictEqual(bc, '<nav class="ov-breadcrumbs" aria-label="Breadcrumb"><ol><li><a href="/food">Food</a></li><li aria-current="page">&lt;Rye&gt;</li></ol></nav>');
++ const d = ssr.diffHtml(diffText('a <b>', 'a <i>', { mode: 'word' }));
++ assert.match(d, /<del>b<\/del><ins>i<\/ins>/);
++ assert.doesNotMatch(d, /<b>/);
++ assert.strictEqual(ssr.timeTag(null), '');
++ assert.strictEqual(ssr.timeTag('not a date'), '');
++ assert.strictEqual(ssr.timeTag('2026-09-20T10:00:00Z'), '<time datetime="2026-09-20T10:00:00.000Z">2026-09-20</time>');
++});
++
++run();
ddiff --git a/test/taxonomy.test.js b/test/taxonomy.test.js
nnew file mode 100644
iindex 0000000..f30dda5
---- /dev/null
++++ b/test/taxonomy.test.js
@@@ -0,0 +1,71 @@
++'use strict';
++const assert = require('assert');
++const { openDb, suite } = require('./helpers/db');
++const { createTaxonomy, slugify } = require('../lib/taxonomy');
++
++const { test, run } = suite();
++
++test('slugify', () => {
++ assert.strictEqual(slugify('Crème Brûlée & Co.'), 'creme-brulee-co');
++ assert.strictEqual(slugify("Baker's Guide"), 'bakers-guide');
++ assert.strictEqual(slugify('日本 料理'), '日本-料理');
++ assert.throws(() => slugify('!!!'), /slug/);
++ assert.ok(slugify('x'.repeat(200)).length <= 80);
++});
++
++test('terms are unique per vocabulary slug; ensureTerm is get-or-create', () => {
++ const tax = createTaxonomy(openDb('tax'), { prefix: 'blog' });
++ const a = tax.ensureTerm({ vocabulary: 'tag', name: 'Sourdough' });
++ const b = tax.ensureTerm({ vocabulary: 'tag', name: 'sourdough' });
++ const c = tax.ensureTerm({ vocabulary: 'category', name: 'Sourdough' });
++ assert.strictEqual(a.id, b.id);
++ assert.notStrictEqual(a.id, c.id);
++ assert.strictEqual(tax.bySlug('tag', 'sourdough').name, 'Sourdough');
++ assert.deepStrictEqual(tax.tables, { terms: 'blog_terms', links: 'blog_term_links' });
++});
++
++test('hierarchical categories: ancestors (breadcrumbs), descendants, tree, cycles refused', () => {
++ const tax = createTaxonomy(openDb('tax'), { prefix: 'wiki' });
++ const food = tax.ensureTerm({ vocabulary: 'category', name: 'Food' });
++ const baking = tax.ensureTerm({ vocabulary: 'category', name: 'Baking', parentId: food.id });
++ const bread = tax.ensureTerm({ vocabulary: 'category', name: 'Bread', parentId: baking.id });
++ assert.deepStrictEqual(tax.ancestors(bread.id).map((t) => t.name), ['Food', 'Baking', 'Bread']);
++ assert.deepStrictEqual(tax.descendants(food.id).map((t) => t.name).sort(), ['Baking', 'Bread']);
++ const tree = tax.tree('category');
++ assert.strictEqual(tree.length, 1);
++ assert.strictEqual(tree[0].children[0].children[0].name, 'Bread');
++ assert.throws(() => tax.setParent(food.id, bread.id), (e) => e.code === 'term.cycle');
++ assert.throws(() => tax.setParent(food.id, food.id), (e) => e.code === 'term.cycle');
++ const tag = tax.ensureTerm({ vocabulary: 'tag', name: 'x' });
++ assert.throws(() => tax.setParent(tag.id, food.id), (e) => e.code === 'term.parent_vocabulary');
++});
++
++test('setTerms replaces one vocabulary; entitiesFor includes descendants on request', () => {
++ const tax = createTaxonomy(openDb('tax'), { prefix: 'blog' });
++ const food = tax.ensureTerm({ vocabulary: 'category', name: 'Food' });
++ const bread = tax.ensureTerm({ vocabulary: 'category', name: 'Bread', parentId: food.id });
++ tax.setTerms('post_1', 'tag', ['Rye', 'rye', 'Starter']);
++ tax.setTerms('post_1', 'category', [bread.id]);
++ tax.setTerms('post_2', 'category', [food.id]);
++ assert.deepStrictEqual(tax.termsFor('post_1', 'tag').map((t) => t.slug), ['rye', 'starter']);
++ tax.setTerms('post_1', 'tag', ['Starter']);
++ assert.deepStrictEqual(tax.termsFor('post_1', 'tag').map((t) => t.slug), ['starter']);
++ assert.strictEqual(tax.termsFor('post_1', 'category').length, 1, 'other vocabularies untouched');
++ assert.deepStrictEqual(tax.entitiesFor(food.id), ['post_2']);
++ assert.deepStrictEqual(tax.entitiesFor(food.id, { includeDescendants: true }), ['post_1', 'post_2']);
++ assert.throws(() => tax.setTerms('post_1', 'tag', [bread.id]), (e) => e.code === 'term.vocabulary_mismatch');
++});
++
++test('remove re-parents children and drops links', () => {
++ const tax = createTaxonomy(openDb('tax'), { prefix: 'blog' });
++ const a = tax.ensureTerm({ vocabulary: 'category', name: 'A' });
++ const b = tax.ensureTerm({ vocabulary: 'category', name: 'B', parentId: a.id });
++ const c = tax.ensureTerm({ vocabulary: 'category', name: 'C', parentId: b.id });
++ tax.setTerms('e', 'category', [b.id]);
++ tax.remove(b.id);
++ assert.strictEqual(tax.get(c.id).parentId, a.id);
++ assert.deepStrictEqual(tax.termsFor('e'), []);
++ assert.strictEqual(tax.rename(a.id, ' All ').name, 'All');
++});
++
++run();
ddiff --git a/test/two-products.test.js b/test/two-products.test.js
nnew file mode 100644
iindex 0000000..74b736e
---- /dev/null
++++ b/test/two-products.test.js
@@@ -0,0 +1,233 @@
++'use strict';
++/**
++ * Wave 15 exit proof: two different products share revision, citation, SEO-gate and feed code
++ * without sharing a database or an authority.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const http = require('http');
++const path = require('path');
++const { tempDir, fakeClock, suite } = require('./helpers/db');
++const { createWiki } = require('../examples/two-products/wiki/app');
++const { createBlog } = require('../examples/two-products/blog/app');
++
++const { test, run } = suite();
++const ALEX = 'usr_01J8Z6Q3KX0000000000000000';
++const EDITOR = 'usr_01J8Z6Q3KX0000000000000001';
++const LONG = 'Rye is a grass grown extensively as a grain, a cover crop and a forage crop. It is closely related to wheat and barley, and its grain is used for flour, bread, beer, whiskey and animal fodder.';
++const MED = 'med_01J8Z6Q3KX0000000000000000';
++
++function tablesOf(db) {
++ return db.prepare("SELECT name FROM sqlite_master WHERE type IN ('table','index','trigger') AND name NOT LIKE 'sqlite_%'").all().map((r) => r.name).sort();
++}
++
++function listen(handler) {
++ return new Promise((resolve) => {
++ const server = http.createServer(handler).listen(0, '127.0.0.1', () => {
++ const base = `http://127.0.0.1:${server.address().port}`;
++ resolve({ base, close: () => server.close(), get: (p) => fetch(base + p, { redirect: 'manual' }) });
++ });
++ });
++}
++
++function setup() {
++ const clock = fakeClock(Date.parse('2026-09-22T12:00:00Z'));
++ const wikiDir = tempDir('wiki');
++ const blogDir = tempDir('blog');
++ const wiki = createWiki({ dbPath: path.join(wikiDir, 'wiki.db'), now: clock });
++ const blog = createBlog({ dbPath: path.join(blogDir, 'blog.db'), now: clock });
++ return { clock, wiki, blog, wikiFile: path.join(wikiDir, 'wiki.db'), blogFile: path.join(blogDir, 'blog.db') };
++}
++
++test('same code: both products run the very same package modules', () => {
++ const { wiki, blog } = setup();
++ const revisionsFile = require.resolve('openvibe-publishing/revisions');
++ assert.strictEqual(revisionsFile, path.join(__dirname, '..', 'lib', 'revisions.js'));
++ for (const mod of ['revisions', 'citations', 'seo', 'ssr']) {
++ const file = require.resolve(`openvibe-publishing/${mod}`);
++ const users = Object.values(require.cache).filter((m) => m.children.some((c) => c.filename === file)).map((m) => path.basename(path.dirname(m.filename)));
++ assert.ok(users.includes('wiki') && users.includes('blog'), `${mod} is loaded by both apps (${users})`);
++ }
++ wiki.close(); blog.close();
++});
++
++test('no shared database: two files, each holding only its own product\'s tables', () => {
++ const { wiki, blog, wikiFile, blogFile } = setup();
++ assert.notStrictEqual(fs.realpathSync(wikiFile), fs.realpathSync(blogFile));
++ const wt = tablesOf(wiki.db);
++ const bt = tablesOf(blog.db);
++ assert.ok(wt.length > 5 && bt.length > 5);
++ assert.ok(wt.every((n) => n.startsWith('wiki_')), `wiki db: ${wt.filter((n) => !n.startsWith('wiki_'))}`);
++ assert.ok(bt.every((n) => n.startsWith('blog_')), `blog db: ${bt.filter((n) => !n.startsWith('blog_'))}`);
++ for (const t of ['wiki_page_revisions', 'wiki_citations', 'wiki_page_redirects', 'wiki_pages']) assert.ok(wt.includes(t), t);
++ for (const t of ['blog_post_revisions', 'blog_post_citations', 'blog_schedule_jobs', 'blog_terms', 'blog_post_attachments', 'blog_posts']) assert.ok(bt.includes(t), t);
++ const attached = wiki.db.prepare('PRAGMA database_list').all();
++ assert.deepStrictEqual(attached.map((d) => d.name), ['main'], 'nothing attached across products');
++ wiki.close(); blog.close();
++});
++
++test('each product owns its state: writes in one never appear in the other', () => {
++ const { wiki, blog } = setup();
++ const { id: pageId } = wiki.createPage({ title: 'Rye', body: LONG, author: ALEX, sources: [{ url: 'https://example.org/rye', retrievedAt: '2026-09-20T10:00:00Z' }] });
++ wiki.publish(pageId, { revision: 1, actor: ALEX });
++ const { id: postId } = blog.draft({ title: 'First loaf', body: LONG, authorName: 'Alex', tags: ['rye'] });
++ assert.strictEqual(wiki.db.prepare('SELECT COUNT(*) AS n FROM wiki_page_revisions').get().n, 1);
++ assert.strictEqual(blog.db.prepare('SELECT COUNT(*) AS n FROM blog_post_revisions').get().n, 1);
++ assert.strictEqual(wiki.revisions.head(postId), null);
++ assert.strictEqual(blog.revisions.head(pageId), null);
++ assert.strictEqual(blog.citations.history(postId).length, 0);
++ assert.strictEqual(wiki.citations.history(pageId).length, 1);
++ wiki.close(); blog.close();
++});
++
++test('revisions and citations: edit, diff, revert; citations stay on the revision that used them', () => {
++ const { wiki, clock } = setup();
++ const { id } = wiki.createPage({ title: 'Rye', body: LONG, author: ALEX, sources: [{ url: 'https://example.org/a' }, { url: 'https://example.org/b' }] });
++ const [a] = wiki.citations.forRevision(id, 1);
++ clock.advance(1000);
++ const r2 = wiki.edit(id, { expectedRevision: 1, body: LONG.replace('grass', 'cereal grass'), author: ALEX, keepSources: [a.id] });
++ assert.throws(() => wiki.edit(id, { expectedRevision: 1, body: 'lost update', author: ALEX }), (e) => e.status === 412);
++ const diff = wiki.revisions.diff(id, 1, r2, { mode: 'word' });
++ assert.ok(diff.content.ops.some((o) => o.op === 'insert' && o.text.includes('cereal')));
++ assert.deepStrictEqual(wiki.citations.forRevision(id, 1).map((c) => c.url), ['https://example.org/a', 'https://example.org/b']);
++ assert.deepStrictEqual(wiki.citations.forRevision(id, r2).map((c) => c.url), ['https://example.org/a']);
++ const r3 = wiki.revert(id, { toRevision: 1, expectedRevision: r2, author: ALEX });
++ assert.strictEqual(wiki.revisions.get(id, r3).content, LONG);
++ assert.deepStrictEqual(wiki.citations.forRevision(id, r3).map((c) => c.url), ['https://example.org/a', 'https://example.org/b']);
++ assert.deepStrictEqual(wiki.revisions.lineage(id).map((r) => r.number), [3, 2, 1]);
++ wiki.close();
++});
++
++test('one gate, two editorial policies: the wiki needs sources, the blog does not', () => {
++ const { wiki, blog } = setup();
++ const { id: unsourced } = wiki.createPage({ title: 'Barley', body: LONG, author: ALEX });
++ wiki.publish(unsourced, { revision: 1, actor: ALEX });
++ const { id: post, revision } = blog.draft({ title: 'Barley notes', body: LONG });
++ blog.publishNow(post, revision);
++ const [w] = wiki.published();
++ const [b] = blog.published();
++ assert.deepStrictEqual(w.decision.codes, ['unsourced']);
++ assert.strictEqual(b.decision.indexable, true);
++ assert.doesNotMatch(wiki.sitemapXml(), /barley/, 'unsourced page stays out of the sitemap');
++ assert.match(wiki.atom(), /Barley/, 'but is listable in the feed (noindex, not hidden)');
++ assert.match(blog.sitemapXml(), /posts\/barley-notes/);
++ wiki.close(); blog.close();
++});
++
++test('AI-generated wiki page: draft + noindex until a person reviews it', () => {
++ const { wiki } = setup();
++ const authorship = require('openvibe-publishing/authorship');
++ const rec = authorship.record({ mode: 'ai', workflow: { id: 'wiki.generate_page', version: 1, runId: 'run_1' } });
++ const { id, initial } = wiki.createPage({ title: 'Spelt', body: LONG, author: EDITOR, authorship: rec, sources: [{ sourceItemId: 'src_9' }] });
++ assert.deepStrictEqual(initial, { state: 'draft', noindex: true, reason: 'ai_generated_unreviewed' });
++ assert.throws(() => wiki.publish(id, { revision: 1, actor: EDITOR }), /ai_generated_unreviewed/);
++ wiki.reviews.record({ entityId: id, revision: 1, reviewer: EDITOR, decision: 'approved' });
++ wiki.publish(id, { revision: 1, actor: EDITOR });
++ const [p] = wiki.published();
++ assert.strictEqual(p.decision.indexable, true);
++ assert.match(wiki.renderPage(p.page), /AI-generated by workflow wiki.generate_page v1, reviewed by a person/);
++ wiki.close();
++});
++
++test('scheduled blog publication is idempotent across a worker restart', async () => {
++ const { blog, clock, blogFile } = setup();
++ const { id, revision } = blog.draft({ title: 'Scheduled', body: LONG });
++ const at = clock() + 3600e3;
++ blog.schedulePublish(id, { revision, at });
++ blog.schedulePublish(id, { revision, at });
++ assert.strictEqual(blog.scheduler.jobs(id).length, 1);
++ clock.set(at);
++ const [job] = blog.scheduler.claim({ worker: 'w1' }); // worker 1 dies before finishing
++ assert.ok(job);
++ blog.close();
++ const blog2 = createBlog({ dbPath: blogFile, now: clock }); // "restart"
++ clock.advance(60001);
++ const out = await blog2.runScheduled('w2');
++ assert.strictEqual(out.done.length, 1);
++ assert.strictEqual(blog2.published().length, 1);
++ await blog2.runScheduled('w3');
++ assert.strictEqual(blog2.scheduler.jobs(id)[0].attempts, 2);
++ blog2.close();
++});
++
++test('HTTP: pages useful without JavaScript, 301 for old slugs, private/deleted leave feeds and sitemaps', async () => {
++ const { wiki, blog, clock } = setup();
++ const { id } = wiki.createPage({ title: 'Rye', body: `${LONG}\n\nSee [wheat](/p/wheat).`, author: ALEX, sources: [{ url: 'https://example.org/rye', title: 'Rye facts', retrievedAt: '2026-09-20T10:00:00Z', licenseNote: 'CC BY 4.0' }] });
++ wiki.publish(id, { revision: 1, actor: ALEX });
++ wiki.rename(id, 'Rye (grain)');
++ const { id: post, revision } = blog.draft({ title: 'Crumb shots', body: LONG, authorName: 'Alex', tags: ['bread'] });
++ blog.media.attach({ entityId: post, mediaId: MED, role: 'inline', alt: 'crumb' });
++ blog.publishNow(post, revision);
++ const w = await listen(wiki.handler);
++ const b = await listen(blog.handler);
++ try {
++ let res = await w.get('/p/rye-grain');
++ let html = await res.text();
++ assert.strictEqual(res.status, 200);
++ assert.match(html, /<h1>Rye<\/h1>/, 'renaming the slug does not touch the title field');
++ assert.match(html, /grown extensively as a grain/, 'the content is in the HTML');
++ assert.match(html, /<a href="https:\/\/example.org\/rye" rel="noopener">Rye facts<\/a>, retrieved <time datetime="2026-09-20T10:00:00.000Z">/);
++ assert.match(html, /<meta name="robots" content="index, follow">/);
++ assert.match(html, /<link rel="canonical" href="https:\/\/openvibe.wiki\/p\/rye-grain">/);
++ assert.match(html, /application\/ld\+json/);
++ assert.doesNotMatch(html.replace(/<script type="application\/ld\+json">[\s\S]*?<\/script>/g, ''), /<script/i, 'no JavaScript needed');
++
++ res = await w.get('/p/rye');
++ assert.strictEqual(res.status, 301);
++ assert.strictEqual(res.headers.get('location'), '/p/rye-grain');
++
++ res = await b.get('/posts/crumb-shots');
++ html = await res.text();
++ assert.strictEqual(res.status, 200);
++ assert.match(html, /<img src="https:\/\/openvibe.media\/o\/med_/);
++ blog.media.markBroken(MED, 'deleted');
++ html = await (await b.get('/posts/crumb-shots')).text();
++ assert.match(html, /data-state="broken"/);
++ assert.doesNotMatch(html, /<img/);
++
++ // private: gone from page, feeds and sitemap; the event is internal
++ wiki.setVisibility(id, 'private', { actor: ALEX });
++ assert.strictEqual((await w.get('/p/rye-grain')).status, 404);
++ assert.doesNotMatch(await (await w.get('/sitemap.xml')).text(), /rye/);
++ assert.doesNotMatch(await (await w.get('/feed.atom')).text(), /<entry>/);
++ assert.strictEqual(JSON.parse(await (await w.get('/feed.json')).text()).items.length, 0);
++ const events = wiki.outbox();
++ assert.deepStrictEqual(events.map((e) => e.event_type), ['wiki.page.published', 'wiki.page.updated']);
++ assert.strictEqual(events[1].visibility, 'internal');
++ assert.deepStrictEqual(events[1].payload.document.acl, { public: false, subjects: [ALEX] });
++
++ blog.setVisibility(post, 'gated');
++ assert.strictEqual((await b.get('/posts/crumb-shots')).status, 404);
++ assert.doesNotMatch(await (await b.get('/feed.xml')).text(), /Crumb/);
++ assert.doesNotMatch(await (await b.get('/sitemap.xml')).text(), /crumb/);
++
++ // deleted: 410 on the page and on its old slug; tombstone event
++ clock.advance(1000);
++ wiki.remove(id, { actor: ALEX });
++ assert.strictEqual((await w.get('/p/rye-grain')).status, 410);
++ assert.strictEqual((await w.get('/p/rye')).status, 410);
++ const last = wiki.outbox().pop();
++ assert.strictEqual(last.event_type, 'wiki.page.deleted');
++ assert.strictEqual(last.payload.document.deleted, true);
++ assert.ok(!('body' in last.payload.document));
++ } finally { w.close(); b.close(); wiki.close(); blog.close(); }
++});
++
++test('feeds from both products carry only provided dates and authors', () => {
++ const { wiki, blog } = setup();
++ const { id } = wiki.createPage({ title: 'Oats', body: LONG, author: ALEX, sources: [{ url: 'https://example.org/oats' }] });
++ wiki.publish(id, { revision: 1, actor: ALEX });
++ const { id: post, revision } = blog.draft({ title: 'No byline', body: LONG });
++ blog.publishNow(post, revision);
++ const rss = blog.rss();
++ assert.doesNotMatch(rss, /dc:creator/, 'no author invented for a post without a byline');
++ assert.match(rss, /<pubDate>Tue, 22 Sep 2026 12:00:00 GMT<\/pubDate>/);
++ const jf = blog.jsonFeedDoc();
++ assert.ok(!('authors' in jf.items[0]));
++ const atom = wiki.atom();
++ assert.doesNotMatch(atom, /<author>/);
++ assert.match(atom, /<published>2026-09-22T12:00:00.000Z<\/published>/);
++ wiki.close(); blog.close();
++});
++
++run();