openvibers/openvibe.blog Commits

Add /llms-full.txt and the home AI summary, bump openvibe-publishing…

OP OpenVibers committed 4 Oct 2026 · de148d0bed · 8 files, +64 −11

Asked: T11 step 4b — OpenVibe.Blog: adopt openvibe-publishing v1.3.0 and add /llms-full.txt. ONE pull request from current origin/main (git fetch first; the local checkout is stale). 1) Bump openvibe-publishing v1.2.0 to v1.3.0 (tagged at 2eedb98; renderDocument now forwards summary, facts, updated and ur…
Harness job blog-t11-step-4b-openvibe-blog-221215.

Changes

ddiff --git a/STATUS.json b/STATUS.json
iindex 5a85ca5..c88569c 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -9,7 +9,7 @@
"domain": "openvibe.blog",
"contracts": "openvibe-contracts v0.79.0",
"packages": [
-- "openvibe-publishing v1.2.0",
++ "openvibe-publishing v1.3.0",
"openvibe-contracts v0.79.0",
"openvibe-shared v2.6.0",
"openvibe-sdk v0.26.0"
ddiff --git a/package-lock.json b/package-lock.json
iindex 3cecd0b..9119f57 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -16,7 +16,7 @@
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
-- "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.2.0",
++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0",
"pg": "^8.23.0"
@@@ -889,9 +889,9 @@
}
},
"node_modules/openvibe-publishing": {
-- "version": "1.2.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.2.0",
-- "integrity": "sha512-n/20yxXo5jRs0wi7KsR+R9jDkGsoBak3gqElHV6XyU+i1t3XCzaoz+sDb10G2cbc4uEaNB/F09VtQKrQzLp6YQ==",
++ "version": "1.3.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",
++ "integrity": "sha512-dIXLK5JpFqUEztywlWgSupUa9NEkovVczR2qeIvpst5evOyKZlwGzQktArvWUQn1ly6CoLL5mn/FnKiqoPvZFA==",
"license": "MIT",
"engines": {
"node": ">=20"
ddiff --git a/package.json b/package.json
iindex be5a6d2..1e76870 100644
---- a/package.json
++++ b/package.json
@@@ -28,7 +28,7 @@
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
-- "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.2.0",
++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0",
"pg": "^8.23.0"
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex a12b172..b393d01 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -5,6 +5,7 @@
*
* GET /robots.txt sitemap location + explicit automated-consumer policy
* GET /llms.txt orientation for language models (what the site is, entry points)
++ * GET /llms-full.txt the same header plus an excerpt of every indexable post (never the body)
* GET /sitemap.xml sitemap index over the two sections below
* GET /sitemaps/posts.xml published, public, INDEXABLE posts only (the gate decides), lastmod
* = the published revision's real time
@@@ -15,8 +16,10 @@
*/
const express = require('express');
const seo = require('openvibe-publishing/seo');
++const ssr = require('openvibe-publishing/ssr');
const sharedSeo = require('openvibe-shared/seo');
const cache = require('openvibe-shared/cache-policy');
++const { SITE_SUMMARY } = require('../render/layout');
function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {
const router = express.Router();
@@@ -57,7 +60,7 @@ function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {
const official = await blogs.official();
res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(sharedSeo.llmsTxt({
name: 'OpenVibe.Blog',
-- summary: 'The official OpenVibe blog and a blog for every OpenVibe member: server-rendered posts with feeds, sitemaps and a JSON representation of every post.',
++ summary: SITE_SUMMARY,
details: 'Only public, published posts are listed, fed or mapped. Every post page has a machine-readable twin at <post URL>.json with the same content, its revision, authorship (human, AI-assisted or AI-generated, and whether a person reviewed it), sources and indexability reasons. AI-generated drafts are never published or indexed before a person reviews them.',
sections: [
{ title: 'Start here', links: [
@@@ -73,6 +76,23 @@ function createDiscoveryRoutes({ config, store, blogs, publication, reading }) {
}));
});
++ router.get('/llms-full.txt', async (_req, res) => {
++ // Only public, published, indexable posts (the sitemap's gate), each as its summary — or a
++ // clipped excerpt when the author wrote no summary. Full post bodies are never included.
++ const posts = (await postEntries()).filter((e) => e.decision.indexable).map((e) => ({
++ title: e.post.title,
++ url: publication.postUrl(e.blog, e.post),
++ text: e.rev.fields.summary || ssr.markdownToText(e.rev.content || '', 160),
++ }));
++ res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(sharedSeo.llmsFull({
++ site: 'OpenVibe.Blog',
++ summary: SITE_SUMMARY,
++ base: config.baseUrl,
++ maxBytes: 512 * 1024,
++ sections: [{ title: 'Posts', pages: posts }],
++ }));
++ });
++
router.get('/sitemap.xml', async (_req, res) => {
const entries = (await postEntries()).filter((e) => e.decision.indexable);
const newest = entries.length ? entries.map((e) => e.rev.createdAt).sort().pop() : null;
ddiff --git a/server/http/public.js b/server/http/public.js
iindex 193d66e..e85b85c 100644
---- a/server/http/public.js
++++ b/server/http/public.js
@@@ -24,7 +24,7 @@ const seo = require('openvibe-publishing/seo');
const ssr = require('openvibe-publishing/ssr');
const authorship = require('openvibe-publishing/authorship');
const cache = require('openvibe-shared/cache-policy');
--const { renderPage } = require('../render/layout');
++const { renderPage, SITE_SUMMARY } = require('../render/layout');
const pages = require('../render/pages');
const { csrfToken, checkCsrf } = require('../auth/forms');
@@@ -127,6 +127,8 @@ function createPublicRoutes(ctx) {
send(req, res, 200, {
title: blog.title,
description: blog.description,
++ // The network blog's home carries the site summary an AI can read (ai-summary meta + WebPage JSON-LD).
++ ...(home ? { summary: SITE_SUMMARY } : {}),
decision: pageDecision(canonical, { empty: total === 0, query: ['page'] }),
canonical,
feeds,
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 1f96c96..f1fd00d 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -19,6 +19,8 @@ const { escapeHtml: esc } = require('openvibe-publishing/ssr');
const NETWORK_URL = 'https://openvibe.network';
const SITE_NAME = 'OpenVibe.Blog';
++// One site summary, shared by /llms.txt, /llms-full.txt and the home page's ai-summary.
++const SITE_SUMMARY = 'The official OpenVibe blog and a blog for every OpenVibe member: server-rendered posts with feeds, sitemaps and a JSON representation of every post.';
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const hashes = new Map();
@@@ -80,6 +82,10 @@ function renderPage(o) {
description: o.description || 'The official OpenVibe blog and a blog for every member.',
canonical: o.canonical,
decision: o.decision,
++ summary: o.summary,
++ facts: o.facts,
++ updated: o.updated,
++ url: o.url,
type: o.type || 'website',
image: o.image,
author: o.author,
@@@ -106,4 +112,4 @@ function renderPage(o) {
return style ? html.replace('<main id="main" class="page blog-surface">', () => `<main id="main" class="page blog-surface"${style}>`) : html;
}
--module.exports = { renderPage, asset, assetVersion, themeStyle, setRelease, SITE_NAME, NETWORK_URL };
++module.exports = { renderPage, asset, assetVersion, themeStyle, setRelease, SITE_NAME, SITE_SUMMARY, NETWORK_URL };
ddiff --git a/test/layout.test.js b/test/layout.test.js
iindex 3dcdb6e..383ea2d 100644
---- a/test/layout.test.js
++++ b/test/layout.test.js
@@@ -56,14 +56,16 @@ const split = (html) => ({ head: html.slice(0, html.indexOf('</head>')), body: h
assert.strictEqual(count(r.text, /<main\b/g), 1, 'one <main>');
});
-- await check('the editor stays noindex; the home page keeps its feeds and one JSON-LD', async () => {
++ await check('the editor stays noindex; the home page keeps its feeds, the Blog JSON-LD and the AI summary', async () => {
const { head } = split((await t.get('/write', { as: eve })).text);
assert.strictEqual(count(head, /<title>/g), 1);
assert.ok(/<meta name="robots" content="noindex[^"]*">/.test(head), 'the editor is noindex');
const home = split((await t.get('/')).text).head;
assert.strictEqual(count(home, /<title>/g), 1);
assert.ok(home.includes('<link rel="canonical" href="https://openvibe.blog/">'));
-- assert.strictEqual(count(home, /<script type="application\/ld\+json">/g), 1, 'one JSON-LD script on the home page');
++ // The home carries the Blog JSON-LD and, since llms-full.txt, the site summary's WebPage JSON-LD.
++ assert.deepStrictEqual([...home.matchAll(/<script type="application\/ld\+json">(.*?)<\/script>/g)].map((m) => JSON.parse(m[1])['@type']), ['Blog', 'WebPage']);
++ assert.match(home, /<meta name="ai-summary" content="[^"]+">/, 'the AI summary head');
assert.ok(home.includes('<link rel="alternate" type="application/atom+xml" href="/atom.xml" title="The OpenVibe blog (Atom)">'));
assert.ok(home.includes('<link rel="alternate" type="application/feed+json" href="/feed.json" title="The OpenVibe blog (JSON Feed)">'));
assert.ok(/<link rel="stylesheet" href="\/shared\/showcase\.css\?v=[0-9a-f]+">/.test(home), 'the showcase kit stylesheet on the home');
ddiff --git a/test/seed-ops.test.js b/test/seed-ops.test.js
iindex a9d5cb7..175fd8d 100644
---- a/test/seed-ops.test.js
++++ b/test/seed-ops.test.js
@@@ -82,6 +82,29 @@ const { seedOfficialPost, loadFacts, SLUG } = require('../server/seed');
assert.match(idx, /sitemaps\/posts\.xml/);
});
++ await check('llms-full.txt lists only indexable posts as summary text, within maxBytes, never the body', async () => {
++ const full = await t.get('/llms-full.txt');
++ assert.strictEqual(full.status, 200);
++ assert.match(full.headers.get('content-type'), /text\/plain/);
++ assert.ok(Buffer.byteLength(full.text) <= 512 * 1024, 'stays within maxBytes (512 KiB)');
++ assert.match(full.text, /^# OpenVibe\.Blog/);
++ const urls = [...full.text.matchAll(/^URL: (\S+)$/gm)].map((m) => m[1]);
++ assert.ok(urls.length >= 1, 'lists the published post');
++ // The same gate as the sitemap: every listed URL is one the sitemap also lists as indexable.
++ const locs = new Set([...(await t.get('/sitemaps/posts.xml')).text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]));
++ for (const u of urls) assert.ok(locs.has(u), `only indexable posts: ${u}`);
++ // The author's summary, not the full body (the verbatim quotes live only in the post).
++ assert.match(full.text, /Commit messages and release notes quoted from the OpenVibers repositories/);
++ assert.ok(!full.text.includes(facts.items[0].quote), 'the body is never included');
++ });
++
++ await check('/ carries the AI summary head (ai-summary meta and WebPage JSON-LD)', async () => {
++ const home = await t.get('/');
++ assert.match(home.text, /<meta name="ai-summary" content="[^"]+">/);
++ const ld = [...home.text.matchAll(/<script type="application\/ld\+json">(.*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
++ assert.ok(ld.some((x) => x['@type'] === 'WebPage' && x.description && x.url === 'https://openvibe.blog/'), 'WebPage JSON-LD names the home URL and summary');
++ });
++
await check('legal pages and 404s', async () => {
assert.strictEqual((await t.get('/terms')).status, 200);
const nf = await t.get('/@nobody-here');