# 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. ## 1.7.1 — 2026-11-10 - `blocks.css`: a callout with no title starts at its padding (its first paragraph's top margin pushed the words down, so the card looked taller above its text than below). ## 1.7.0 — 2026-11-09 - **`openvibe-publishing/blocks.css`: one stylesheet for every block the library renders** (additive). The look of an infobox, a timeline, a stat, a quote, a callout, a gallery, a compare, a hero, a section with its reactions, patch notes, a clip, a video, a link out and a missing picture, on the shared theme tokens only (each with a fallback) and with no site's classes. A product serves this file and links it wherever it renders a body; it places blocks (its text measure, an infobox beside a lead) and does not restyle them. Before this the rules lived in Blog alone, so a wiki article showed its blocks as bare browser markup. - `test/blocks-css.test.js` fails when a renderer emits a class the sheet has no rule for, when a callout tone has no rule, when a token has no fallback, or when a site class appears in the sheet. ## 1.6.1 — 2026-11-09 - `blocks`: a timeline and an infobox have one row per line (1.6.0 made a whole body one row) with list markers dropped, and a row splits at the first ": " or " — ", so a time such as 10:45 stays whole. A timeline's `datetime` is set only for a date or a time. An infobox row without a key is a `
`, valid inside its `
`. ## 1.6.0 — 2026-11-09 **Additive: `openvibe-publishing/blocks`.** No existing export, table layout, reason code or document shape changes; a product on v1.5.x upgrades by pinning v1.6.0. ### Added — `openvibe-publishing/blocks` The rich block model Blog and Wiki share: a post or an article body is Markdown plus blocks, and the Markdown stays the source of truth (revisions diff it, and an editor that does not know a block still shows its text). - `blocks.parse(markdown)` → a plain-JSON tree: `{ type: 'root', children: [...] }`, where a run of prose is `{ type: 'text', value }` and a block is `{ type, attrs, children }`. A block is a fenced directive, `:::name{attr="value"}` … `:::`; a one-line block closes itself. Blocks nest (a section holds prose and other blocks). A `:::` inside a fenced code block is prose, never a directive. - `blocks.validate(tree)` → `{ ok, errors: [{ path, message }] }`: a missing required attribute, a Media object id that is not one, a section id that is not a slug or that two sections share, a callout tone outside info/warn/tip, an embed url that is not http(s). - `blocks.render(tree, options)` → server-rendered HTML, complete without JavaScript. Every block has a plain-HTML form: a section's reactions are a `
` that posts (signed out, the reader is asked to sign in), the patch notes are a `
` grouped by site with every commit linked, the images are `` with width, height and alt, and `loading="lazy"` after the first one. The caller resolves the Media ids (`media`, `urlFor`); the renderer never guesses a URL and a broken object renders as an explicit "no longer available" placeholder. - `blocks.toText(tree)` → the plain text of a body: what a feed, a search document and an AI reader get. - `seo.structuredData.article({ ..., hasPart })` → the sections of the article as WebPageElements, so a reader (and a crawler) sees the parts of a long post. - `blocks.serialize(tree)` → the Markdown that parses back into the same tree: a caller who has blocks (an editor, an agent) hands Blog the Markdown it will keep, and the Markdown stays the source of truth. - `blocks.creditLine(credit)` → the credit of an image: an AI image says "Made with ", an Openverse or Commons image keeps its licence and its author (both linked), and nothing known means no credit line — an empty credit is never a fake one. - The blocks: hero, section, figure, compare, gallery, clip, pic, video, embed, callout, stat, patchnotes, infobox, timeline, quote. - `render(tree, { renderText })`: a caller whose prose is not plain Markdown renders the text parts itself. - `render()` is safe on its own: an embed whose URL is not http(s) is shown as text, never a link (a caller that skipped `validate()` could otherwise publish a `javascript:` link). ## 1.5.0 — 2026-11-02 **Additive: `openvibe-publishing/search-relay`.** No existing export, table layout, reason code or document shape changes; a product on v1.4.x upgrades by pinning v1.5.0. ### Added — `openvibe-publishing/search-relay` `createSearchRelay({ db, table, owner, searchUrl, auth, … })` delivers the Search documents a product queued in its own outbox table (`event_id, topic, payload, created_at, published_at, attempts, last_error, next_attempt_at, claimed_at, claimed_by`) through OpenVibe.Search's owner API: PUT `/api/v1/documents/:owner/:type/:id` for a document, DELETE with `?revision=` for a tombstone, with the product's service token (`search.document.write` on `openvibe.search`; `auth` is `{ authHeaders(), invalidate() }`, e.g. openvibe-contracts `serviceAuth.createTokenClient`). - Rows go oldest first, at most `maxPerTick` (100) a tick: the owner API allows a service 120 writes a minute. Within a tick only a document's newest row is sent; older ones are marked `superseded`. - 2xx sets `published_at`. 409 `search.stale_revision` is done (Search holds a newer revision). 409 `search.revision_conflict` is sent once more as the stored revision + 1. - 5xx, 408, 401 (which drops the token) and network errors retry with backoff, 15 s doubling to an hour. 429 stops the tick until Retry-After and releases the rest unclaimed, without counting an attempt. - Any other refusal (a bad document, not the owner) and a payload naming another owner are set aside for good: `published_at` is set, `last_error` starts `rejected`, and the reason is logged. - A claim is a five-minute lease (`FOR UPDATE SKIP LOCKED`), so two processes never send one row at once. - `start()` (a tick five seconds after boot, then every minute), `kick()` (a tick in two seconds), `drain()`, `stop()`, `status()` (`pending`, totals, last run, last error, pause). Off without `searchUrl` or `auth`. Why: Meme, Exchange and Fun queue a document per change into `_outbox` and left the delivery for later; 118 meme entries and 104 Exchange listings had never reached Search (2026-11-02). ## 1.4.1 — 2026-10-22 **Fix: `seo.canonicalUrl` keeps a path with several leading slashes on the site.** A request path such as `//feed/` or `//wp-includes/wlwmanifest.xml` (scanners send them) was read as a protocol-relative URL naming another host, so `canonicalUrl` threw "path must stay on the origin" and News, Deals and Blog answered 500. A run of leading slashes (or backslashes) now counts as one: `canonicalUrl('https://openvibe.news', '//feed/')` is `https://openvibe.news/feed`. An absolute URL on another origin still throws. No export, table layout, reason code or document shape changes. ## 1.4.0 — 2026-10-10 **Additive: `openvibe-publishing/search-feed`.** No existing export, table layout, reason code or document shape changes; a product on v1.3.0 upgrades by pinning v1.4.0. ### Added — `openvibe-publishing/search-feed` - `createSearchFeed({ owner, db, outbox, baseUrl, types, now?, indexnow?, log? })` puts a product's record pages (listings, job ads, help articles, catalog items, quests) in OpenVibe.Search through the publication glue: one `search.index-document@1` per row on the caller's transaction handle, revisions from the index sequencer (`_index_revisions`), and the product's outbox. Each type gives `page(row)`, `document(row)` (title, summary, body, facets, dates, authorship, provenance, plus `listed` and `noindex`) and `rows(afterId, limit)`; `exists(ids)` is optional. - `sync(t, type, row)` and `remove(t, type, id)` run inside the write's transaction. An unchanged row sends nothing, `listed: false` is a tombstone, and an id Search never had sends nothing. - `sweep()` brings Search level with the tables: every row in id order, re-sending only what changed, and a tombstone for each id whose row is gone (with `exists`). One transaction per row; a failing row is counted and logged and the pass goes on. Concurrent calls share one pass. - Indexability follows the page's own robots (`noindex`) and expiry (`expiresAt`), never the publication gate's word count: a record is not an article. ## 1.3.0 — 2026-10-04 **Additive: `layout.renderDocument` now forwards the AI summary to the shell.** No existing export, table layout, reason code or document shape changes; a product on v1.2.0 upgrades by pinning v1.3.0. Every option defaults to what the product already renders, so a product that passes none of them is byte-identical. ### Changed — `openvibe-publishing/layout` - `renderDocument(o)` passes four options through to `shell.page`, which builds them with `seo.pageSummary`: **`summary`** (one-line AI summary), **`facts`** (`[[key, value], string]` or `[string]` rows), **`updated`** (ISO date) and **`url`** (absolute; defaults to the document's canonical). With a summary the document carries `` and a `WebPage` JSON-LD tag in ``, and with facts the `