openvibers/openvibe.wiki Commits

The wiki's home shows what is in it: the numbers, the OpenVibe wiki shelved by site group, the most linked pages, recent revisions, how a page comes together, questions and links around the site (#93)

OP OpenVibers committed 22 days ago · 9facb7ec93 · 16 files, +396 −41

Changes

ddiff --git a/public/css/wiki.css b/public/css/wiki.css
iindex d6e58e3..fc25b0f 100644
---- a/public/css/wiki.css
++++ b/public/css/wiki.css
@@@ -99,3 +99,47 @@ main .sc-ic .ov-icon { --ovi-size: 28px !important; }
main .sc-sec[id^="inside-"] .sc-card { padding: 12px 14px; }
main .sc-sec[id^="inside-"] .sc-card small { -webkit-line-clamp: 2; line-clamp: 2; }
}
++
++/* The home (views.home): the numbers beside the hero, shelves by site group, ranked pages, questions, links around. */
++.wk-h-panel { padding: 18px; border: 1px solid var(--wk-line); border-radius: 16px; background: var(--wk-panel); }
++main .wk-h-panel-h { margin: 0 0 12px; padding: 0; border: 0; font-size: 1rem; }
++.wk-h-stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin: 0; }
++.wk-h-stats div { display: flex; flex-direction: column-reverse; padding: 10px 12px; border: 1px solid var(--wk-line); border-radius: 12px; background: var(--wk-bg); }
++.wk-h-stats dt { font-size: 13px; color: var(--wk-muted); }
++.wk-h-stats dd { margin: 0; font-size: 1.55rem; font-weight: 800; line-height: 1.2; font-variant-numeric: tabular-nums; }
++.wk-h-search { margin-top: 14px; }
++.wk-h-search label { display: block; font-size: 14px; font-weight: 600; margin-bottom: 6px; }
++.wk-h-search span { display: flex; gap: 6px; }
++.wk-h-search input { flex: 1; min-width: 0; }
++.wk-h-root a { display: block; padding: 14px 16px; border: 1px solid var(--wk-accent); border-radius: 14px; background: color-mix(in srgb, var(--wk-accent) 8%, var(--wk-panel)); color: var(--wk-text); text-decoration: none; }
++.wk-h-root b { display: block; }
++.wk-h-root span { display: block; color: var(--wk-muted); font-size: 15px; }
++.wk-h-group { margin-top: 22px; }
++main .wk-h-group h3 { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; margin: 0 0 10px; font-size: 1.05rem; }
++.wk-h-group h3 span { font-size: 13px; font-weight: 500; color: var(--wk-muted); }
++.wk-h-card small { display: -webkit-box !important; -webkit-line-clamp: 3; line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
++.wk-h-host { display: block; margin-top: 6px; font-size: 12.5px; color: var(--wk-muted); }
++.wk-h-rank { list-style: none; counter-reset: wk-rank; margin: 0; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 1fr)); }
++.wk-h-rank li { counter-increment: wk-rank; display: grid; grid-template-columns: 2em minmax(0, 1fr); align-content: start; gap: 2px 10px; padding: 12px 14px; border: 1px solid var(--wk-line); border-radius: 12px; background: var(--wk-panel); }
++.wk-h-rank li::before { content: counter(wk-rank); grid-row: span 3; font-size: 1.4rem; font-weight: 800; line-height: 1.2; color: var(--wk-accent); }
++.wk-h-rank a { font-weight: 700; }
++.wk-h-count { font-size: 13px; color: var(--wk-muted); }
++.wk-h-rank p { margin: 4px 0 0; font-size: 14px; color: var(--wk-muted); display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
++.wk-h-faq details { border-bottom: 1px solid var(--wk-line); padding: 10px 0; }
++.wk-h-faq summary { cursor: pointer; font-weight: 600; }
++.wk-h-faq details p { margin: 8px 0 0; color: var(--wk-muted); max-width: 760px; }
++.wk-h-links { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 230px), 1fr)); }
++.wk-h-links a { display: block; height: 100%; padding: 12px 14px; border: 1px solid var(--wk-line); border-radius: 12px; background: var(--wk-panel); color: var(--wk-text); text-decoration: none; }
++.wk-h-links a:hover, .wk-h-root a:hover { border-color: var(--wk-accent); }
++.wk-h-links b { display: block; }
++.wk-h-links small { display: block; color: var(--wk-muted); font-size: 13.5px; }
++@media (max-width: 560px) {
++ /* Phones: the shelves as compact two-up tiles (name and address); the summary is one tap away. */
++ .wk-h-group .sc-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
++ .wk-h-group .wk-h-card { padding: 10px 12px; }
++ .wk-h-group .wk-h-card .sc-ic, .wk-h-group .wk-h-card small { display: none !important; }
++ .wk-h-group .wk-h-card b { font-size: 14.5px; overflow-wrap: normal; }
++ .wk-h-group .wk-h-card span { min-width: 0; overflow-wrap: normal; }
++ .wk-h-group .wk-h-host { margin-top: 2px; font-size: 12px; }
++ .wk-h-stats dd { font-size: 1.3rem; }
++}
ddiff --git a/seeds/openvibe.json b/seeds/openvibe.json
iindex 91202bd..a0661ce 100644
---- a/seeds/openvibe.json
++++ b/seeds/openvibe.json
@@@ -2,7 +2,7 @@
"space": {
"slug": "openvibe",
"name": "OpenVibe",
-- "description": "Official space describing the OpenVibe network and its repositories, summarised from their README and STATUS files.",
++ "description": "The official wiki of the OpenVibe network: one page for every product and library, summarised from its README and STATUS files, with citations.",
"kind": "official",
"visibility": "public"
},
ddiff --git a/server/config.js b/server/config.js
iindex c3cf866..1de7d17 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -38,6 +38,10 @@ function load(env = process.env) {
networkUrl,
networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
++ // The Frame's site groups (the launcher's "Watch & stream", "Build & automate", …) shelve the official wiki's
++ // product pages on the home (server/http/frame-groups.js). Read from the Network in production; WIKI_FRAME_URL
++ // sets another address, and an empty value turns it off (one flat shelf).
++ frameUrl: env.WIKI_FRAME_URL != null ? trim(env.WIKI_FRAME_URL) : isProduction ? `${trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000')}/api/chrome` : '',
networkPublicKey: env.OV_NETWORK_PUBLIC_KEY ? String(env.OV_NETWORK_PUBLIC_KEY).replace(/\\n/g, '\n') : null,
// A browser's user JWT is accepted when its aud contains one of these.
userAudiences: list(env.WIKI_USER_AUDIENCES, 'openvibe.wiki,openvibe.network'),
ddiff --git a/server/http/frame-groups.js b/server/http/frame-groups.js
nnew file mode 100644
iindex 0000000..7dc5b04
---- /dev/null
++++ b/server/http/frame-groups.js
@@@ -0,0 +1,51 @@
++'use strict';
++/**
++ * The OpenVibe Frame's site groups ("Watch & stream", "Build & automate", …) as the Network's /api/chrome lists them for
++ * the launcher, so the home can shelve the official wiki's product pages the way the launcher groups the sites.
++ *
++ * const groups = createFrameGroups({ url: config.frameUrl });
++ * await groups.get() → { groups: [{ id, name }], hosts: Map(hostname → group id) } or null
++ *
++ * One read per 30 minutes. A stale copy is served while a refresh runs behind it; only the first view after boot waits,
++ * and never longer than the timeout. Off (no url), unreachable or malformed: null, and the home shows one flat shelf.
++ */
++function createFrameGroups({ url, fetchImpl = globalThis.fetch, ttlMs = 30 * 60 * 1000, retryMs = 60 * 1000, timeoutMs = 1500, now = Date.now } = {}) {
++ let cached = null;
++ let nextAt = 0;
++ let pending = null;
++
++ async function load() {
++ const r = await fetchImpl(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) });
++ if (!r.ok) throw new Error(`the frame answered ${r.status}`);
++ const d = await r.json();
++ const groups = (Array.isArray(d && d.groups) ? d.groups : [])
++ .filter((g) => g && typeof g.id === 'string' && typeof g.name === 'string' && g.id && g.name)
++ .slice(0, 12).map((g) => ({ id: g.id.slice(0, 20), name: g.name.slice(0, 40) }));
++ const hosts = new Map();
++ for (const n of Array.isArray(d && d.nav) ? d.nav : []) {
++ if (!n || typeof n.group !== 'string' || typeof n.url !== 'string') continue;
++ try { hosts.set(new URL(n.url).hostname, n.group.slice(0, 20)); } catch { /* not a URL */ }
++ }
++ return groups.length && hosts.size ? { groups, hosts } : null;
++ }
++
++ function refresh() {
++ if (!pending) {
++ pending = load().then((v) => { cached = v || cached; nextAt = now() + (v ? ttlMs : retryMs); return cached; },
++ () => { nextAt = now() + retryMs; return cached; })
++ .finally(() => { pending = null; });
++ }
++ return pending;
++ }
++
++ return {
++ async get() {
++ if (!url) return null;
++ if (now() < nextAt) return cached;
++ const p = refresh();
++ return cached || await p;
++ },
++ };
++}
++
++module.exports = { createFrameGroups };
ddiff --git a/server/http/machine.js b/server/http/machine.js
iindex 7ddf1c6..b62c045 100644
---- a/server/http/machine.js
++++ b/server/http/machine.js
@@@ -5,7 +5,7 @@
* ones enter feeds. Members/private/deleted content never appears.
*
* /robots.txt crawl rules + sitemap location + explicit automated-consumer policy
-- * /sitemap.xml sitemap index → /sitemaps/spaces.xml, /sitemaps/pages-<n>.xml
++ * /sitemap.xml sitemap index → /sitemaps/spaces.xml (the home and every public wiki), /sitemaps/pages-<n>.xml
* /feed.atom recent changes (Atom), /feed.json (JSON Feed 1.1)
* /llms.txt orientation for language models
* /llms-full.txt the same orientation plus an excerpt of every indexable page, bounded
@@@ -51,12 +51,15 @@ function createMachine({ svc, config }) {
if (!page.revision_published_at) continue;
newestPage.set(space.id, Math.max(newestPage.get(space.id) || 0, page.revision_published_at));
}
-- return (await svc.listSpaces({ kind: 'anonymous' })).filter((s) => s.visibility === 'public').map((s) => {
++ // A space index is navigation, not an article: no word or source minimum. The same for the home, listed first.
++ const navigation = (url) => seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: url, wordCount: 0 }, { policy: { minWords: 0, requireSources: false }, now: Date.now() });
++ const spaces = (await svc.listSpaces({ kind: 'anonymous' })).filter((s) => s.visibility === 'public').map((s) => {
const url = seo.canonicalUrl(origin, svc.spacePath(s));
-- // A space index is navigation, not an article: no word or source minimum.
-- const decision = seo.evaluate({ state: 'published', visibility: 'public', canonicalUrl: url, wordCount: 0 }, { policy: { minWords: 0, requireSources: false }, now: Date.now() });
-- return { loc: url, lastmod: Math.max(s.updated_at, newestPage.get(s.id) || 0), decision };
++ return { loc: url, lastmod: Math.max(s.updated_at, newestPage.get(s.id) || 0), decision: navigation(url) };
});
++ const home = seo.canonicalUrl(origin, '/');
++ const newest = Math.max(0, ...spaces.map((x) => x.lastmod || 0));
++ return [{ loc: home, lastmod: newest || undefined, decision: navigation(home) }, ...spaces];
}
router.get('/sitemap.xml', wrap(async (_req, res) => {
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 9de9f9c..ffb24a6 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -38,8 +38,12 @@ const showcase = require('openvibe-shared/showcase');
const content = require('../wiki/content');
const { actorMiddleware, resolveCitations, resolveBundleCitations, citationsFromForm } = require('./common');
const importer = require('../wiki/import');
++const { createFrameGroups } = require('./frame-groups');
--function createPages({ svc, viewers, platform, config, log = console, limits }) {
++// What the home says in search results and share cards (≤ 160 characters).
++const HOME_DESCRIPTION = 'Read the OpenVibe wiki or start your own: every page keeps its full revision history, lists the sources behind it and has a discussion thread.';
++
++function createPages({ svc, viewers, platform, config, log = console, limits, frameGroups = createFrameGroups({ url: config.frameUrl }) }) {
const router = express.Router();
const form = express.urlencoded({ extended: false, limit: '600kb' });
router.use(actorMiddleware(viewers, { services: false }));
@@@ -122,15 +126,16 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
// ── Home, recent, search ─────────────────────────────────
router.get('/', wrap(async (req, res) => {
if (req.actor.subject) await svc.access.prepareVip(req.actor, (await svc.vipSpaces()).map((space) => ({ space }))); // VIP spaces the viewer may see
-- const [spaces, recent] = await Promise.all([svc.listSpaces(req.actor), svc.recentChanges(10)]);
++ const [spaces, recent, stats, linked, groups] = await Promise.all([svc.listSpaces(req.actor), svc.recentChanges(8), svc.homeStats(), svc.mostLinked(6), frameGroups.get()]);
// The official wikis' shelves: what is inside them, on the front page (only public spaces: the home is cached).
const shelves = new Map(await Promise.all(spaces.filter((s) => s.kind === 'official' && s.visibility === 'public').slice(0, 3)
-- .map(async (s) => [s.id, await svc.shelf(s, req.actor, 48)])));
++ .map(async (s) => [s.id, await svc.shelf(s, req.actor, 60)])));
// The site's one-line AI summary: the shell turns it into the ai-summary meta and the home's
// WebPage JSON-LD (openvibe-publishing/layout v1.3.0 forwards `summary`).
-- send(req, res, 200, views.home({ spaces, recent, shelves, actor: req.actor }) + frame.shipped({ service: 'wiki', title: 'Recently shipped on OpenVibe.Wiki' }), {
++ send(req, res, 200, views.home({ spaces, recent, shelves, stats, linked, groups, actor: req.actor }) + frame.shipped({ service: 'wiki', title: 'Recently shipped on OpenVibe.Wiki' }), {
robots: 'index, follow', cache: 'public', active: 'home', path: '/', styles: [showcase.STYLESHEET],
-- summary: AI_SUMMARY,
++ summary: AI_SUMMARY, description: HOME_DESCRIPTION,
++ jsonLd: views.homeJsonLd({ shelves, spaces: spaces.filter((s) => s.kind === 'official' && s.visibility === 'public'), baseUrl: config.baseUrl }),
});
}));
// What shipped on OpenVibe.Wiki: the shared update log every OpenVibe site has.
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex b5f16c2..6523273 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -84,7 +84,7 @@ function renderPage(o) {
siteName: SITE_NAME,
lang: 'en',
// The front page's title says what the site is (search results show it); every other page is "<page> · OpenVibe.Wiki".
-- title: o.title ? `${o.title} · ${SITE_NAME}` : o.path === '/' ? `${SITE_NAME}: wikis with history and citations` : SITE_NAME,
++ title: o.title ? `${o.title} · ${SITE_NAME}` : o.path === '/' ? `${SITE_NAME}: wikis with full history and cited sources` : SITE_NAME,
description: o.description || DEFAULT_DESCRIPTION,
summary: o.summary,
canonical: `${config.baseUrl}${o.path || '/'}`,
ddiff --git a/server/render/views.js b/server/render/views.js
iindex 6f1b524..919cf01 100644
---- a/server/render/views.js
++++ b/server/render/views.js
@@@ -44,7 +44,27 @@ function shelfIcon(slug) {
return `ov:${m && RING.has(m[1]) ? m[1] : 'wiki'}`;
}
--function home({ spaces, recent, shelves = new Map(), actor }) {
++const ovIcons = require('openvibe-shared/ov-icons');
++
++/** A ring icon in a showcase card's tile (openvibe-shared/showcase's own markup, for the cards this file draws itself). */
++function ringTile(slug) {
++ const name = shelfIcon(slug).slice(3);
++ return raw(`<span class="sc-ic sc-ic-ov"><span class="ov-icon sc-ovi" data-icon="${ssr.escapeHtml(name)}" style="--ovi-size:40px" aria-hidden="true">${ovIcons.svg(name)}</span></span>`);
++}
++// A product name or host may break after its dots ("OpenVibe.<wbr>Coupons"), never inside a word, on narrow tiles.
++const dotBreaks = (text) => raw(ssr.escapeHtml(text).replace(/\./g, '.<wbr>'));
++const plural = (n, one, many = `${one}s`) => `${n.toLocaleString('en-US')} ${n === 1 ? one : many}`;
++
++// The questions the home answers, shown on the page and given to search engines as FAQPage (only what is on the page).
++const FAQ = [
++ { q: 'Who can start a wiki?', a: 'Anyone signed in with an OpenVibe account. A wiki can be public, for members (any signed-in account), for its owner\'s OpenVibe.VIP members, or private to the people given a role in it. Its owner picks the editors.' },
++ { q: 'Who writes the OpenVibe wiki?', a: 'OpenVibe staff. Each page summarises one product or library from its README and STATUS files at a pinned commit, and cites those files. Pages written with AI assistance say so at the top and stay out of search engines until a person reviews them.' },
++ { q: 'Can I see what changed on a page?', a: 'Yes. Every page keeps each published revision. Its history compares any two revisions word by word or line by line, and its sources view lists what each revision cited.' },
++ { q: 'Where do I talk about a page?', a: 'Each page has a discussion thread on OpenVibe.Community, shown under the page. Sign in with your OpenVibe account to post.' },
++ { q: 'How do I follow changes?', a: 'The whole site and each public wiki have Atom and JSON feeds, and signed-in readers can watch a page. Every public page is also served as JSON: add .json to its address.' },
++];
++
++function home({ spaces, recent, shelves = new Map(), stats = null, linked = [], groups = null, actor }) {
const official = spaces.filter((s) => s.kind === 'official');
const user = spaces.filter((s) => s.kind === 'user');
const signedIn = !!(actor && actor.subject);
@@@ -53,31 +73,79 @@ function home({ spaces, recent, shelves = new Map(), actor }) {
const shelf = shelves.get(s.id);
return html`<li><a class="wk-space" href="/s/${e(s.slug)}"><b>${s.name}</b>${s.visibility !== 'public' ? html` <span class="wk-tag">${s.visibility}</span>` : ''}${s.description ? html`<span class="wk-space-text">${s.description}</span>` : ''}${shelf && shelf.total ? html`<span class="wk-space-meta">${String(shelf.total)} page${shelf.total === 1 ? '' : 's'}${shelf.updated ? html` · updated ${t(shelf.updated)}` : ''}</span>` : ''}</a></li>`;
};
-- // Each official wiki with pages: what is inside it, its pages one level down as cards with their summaries. A space
-- // without a shelf (not public, or empty) is a card under "Official wikis".
++ const pageCard = (s, { page, summary, domain }) => html`<a class="sc-card wk-h-card" href="${wpath(s, page)}">${ringTile(page.slug)}<span><b>${dotBreaks(page.title)}</b>${summary ? html`<small>${summary}</small>` : ''}${domain ? html`<span class="wk-h-host">${dotBreaks(domain)}</span>` : ''}</span></a>`;
++ // Each official wiki with pages: its pages one level down as cards, shelved by the Frame's site groups when the
++ // Network has told us them (a page's domain → the launcher's group), else one grid.
const shelved = official.filter((s) => shelves.get(s.id) && shelves.get(s.id).pages.length);
const unshelved = official.filter((s) => !shelved.includes(s));
const inside = shelved.map((s) => {
const shelf = shelves.get(s.id);
const day = shelf.updated ? new Date(Number(shelf.updated)).toISOString().slice(0, 10) : null;
-- return showcase.features({
-- id: `inside-${s.slug}`, title: `The ${s.name} space`,
-- lede: `${s.description ? `${s.description.replace(/\.?\s*$/, '.')} ` : ''}${shelf.total} pages${day ? `, updated ${day}` : ''}.`,
-- items: shelf.pages.map(({ page, summary }) => ({ icon: shelfIcon(page.slug), title: page.title, text: summary, href: wpath(s, page) })),
-- }) + html`<p class="wk-more"><a href="/s/${e(s.slug)}">Every page of ${s.name}, as a tree</a></p>`;
-- }).join('');
-- // What OpenVibe.Wiki is for (openvibe-shared/showcase), then the spaces, what is inside them, and what was published.
++ let body;
++ if (groups) {
++ const byGroup = new Map(groups.groups.map((g) => [g.id, []]));
++ const rest = [];
++ for (const item of shelf.pages) {
++ let host = null;
++ try { host = item.domain ? new URL(`https://${item.domain.replace(/^https?:\/\//, '')}`).hostname : null; } catch { /* not a host */ }
++ const g = host && groups.hosts.get(host);
++ (g && byGroup.has(g) ? byGroup.get(g) : rest).push(item);
++ }
++ const blocks = groups.groups.filter((g) => byGroup.get(g.id).length).map((g) => ({ name: g.name, items: byGroup.get(g.id) }));
++ if (rest.length) blocks.push({ name: 'Platform and libraries', items: rest });
++ body = blocks.map((b) => html`<div class="wk-h-group"><h3>${b.name} <span>${plural(b.items.length, 'page')}</span></h3><div class="sc-grid">${b.items.map((item) => pageCard(s, item))}</div></div>`);
++ } else {
++ body = html`<div class="sc-grid">${shelf.pages.map((item) => pageCard(s, item))}</div>`;
++ }
++ const lede = `${s.description ? `${s.description.replace(/\.?\s*$/, '.')} ` : ''}${plural(shelf.total, 'page')}${day ? `, updated ${day}` : ''}.`;
++ return html`<section class="sc-sec wk-h-shelf" id="inside-${s.slug}" aria-labelledby="wk-in-${s.slug}"><h2 id="wk-in-${s.slug}">The ${s.name} wiki</h2><p class="sc-lede">${lede}</p>
++${shelf.root ? html`<p class="wk-h-root"><a href="${wpath(s, shelf.root.page)}"><b>Start with ${shelf.root.page.title}</b>${shelf.root.summary ? html`<span>${shelf.root.summary}</span>` : ''}</a></p>` : ''}
++${body}<p class="wk-more"><a href="/s/${e(s.slug)}">Every page of ${s.name}, as a tree</a></p></section>`;
++ });
++ // The numbers beside the hero: public wikis and pages only, and only the ones there are.
++ const tiles = stats ? [
++ stats.wikis > 1 ? ['Public wikis', stats.wikis] : null,
++ ['Published pages', stats.pages],
++ ['Revisions kept', stats.revisions],
++ stats.citations ? ['Citations', stats.citations] : null,
++ stats.wikis > 1 && stats.sources ? ['Sources cited', stats.sources] : null,
++ stats.links ? ['Links between pages', stats.links] : null,
++ ].filter(Boolean) : [];
++ const firstOfficial = shelved[0];
++ const aside = stats && stats.pages ? html`<div class="wk-h-panel"><h2 class="wk-h-panel-h">The wiki today</h2><dl class="wk-h-stats">${tiles.map(([k, v]) => html`<div><dt>${k}</dt><dd>${v.toLocaleString('en-US')}</dd></div>`)}</dl>
++<form class="wk-h-search" action="/search" method="get" role="search"><label for="wk-hq">Search every public page</label><span><input id="wk-hq" name="q" type="search" placeholder="Search pages"><button type="submit">Search</button></span></form></div>` : '';
++ const steps = showcase.steps({
++ title: 'How a page comes together',
++ items: [
++ { title: 'Start a wiki', text: 'Name it, choose who can read it and add the editors you trust.', href: start.href },
++ { title: 'Write pages', text: 'Markdown with [[links]] between pages, an infobox of facts, and pages nested under pages.' },
++ { title: 'Cite your sources', text: 'Attach the source behind a claim: its link, the quote and when it was read. The citation stays with that revision.' },
++ { title: 'Publish and keep going', text: 'Publish now or schedule it. Every revision stays in the history, ready to compare or restore.' },
++ ],
++ });
++ const links = [
++ { href: '/recent', title: 'Recent changes', text: 'Every public page, newest revision first.' },
++ { href: '/search', title: 'Search', text: 'Titles and text across the wikis you can read.' },
++ firstOfficial ? { href: `/s/${e(firstOfficial.slug)}`, title: `The ${firstOfficial.name} page tree`, text: 'Every page of the official wiki, nested.' } : null,
++ { href: start.href, title: 'Start a wiki', text: 'Public, members-only, VIP or private.' },
++ { href: '/feed.atom', title: 'Atom and JSON feeds', text: 'Follow new revisions in any reader.' },
++ { href: '/updates', title: 'What shipped', text: 'Every change to OpenVibe.Wiki itself.' },
++ ].filter(Boolean);
return html`${raw(showcase.hero({
eyebrow: 'OpenVibe.Wiki',
title: 'Knowledge,', accent: 'with sources',
-- lede: 'Wikis with page trees, revision history, citations and discussion. Official wikis are edited by OpenVibe staff; anyone signed in with an OpenVibe account can start a wiki of their own.',
-- actions: [start, { label: 'Browse the wikis', href: '#spaces' }],
++ lede: 'Read the OpenVibe wiki or start one of your own. Every page keeps its full revision history, lists the sources behind it and has a discussion thread.',
++ actions: [start, firstOfficial ? { label: `Read the ${firstOfficial.name} wiki`, href: `#inside-${firstOfficial.slug}` } : { label: 'Browse the wikis', href: '#spaces' }],
++ aside: aside ? { html: String(aside) } : undefined,
}))}
<div id="spaces"></div>
--${raw(inside)}
--${unshelved.length || !shelved.length ? html`<section class="sc-sec wk-home-sec" aria-labelledby="wk-official"><h2 id="wk-official">Official wikis</h2>${unshelved.length ? html`<ul class="wk-spaces">${unshelved.map(card)}</ul>` : html`<p class="wk-muted">None yet.</p>`}</section>` : ''}
--<section class="sc-sec wk-home-sec" aria-labelledby="wk-community"><h2 id="wk-community">Community wikis</h2>${user.length ? html`<ul class="wk-spaces">${user.map(card)}</ul>`
-- : html`<div class="wk-empty"><p><b>No community wikis yet.</b> Any OpenVibe account can start one: public, members-only, for its owner's VIP members, or private, with the editors it chooses.</p><p><a class="wk-button" href="${start.href}">${start.label}</a></p></div>`}</section>
++${inside}
++${linked.length ? html`<section class="sc-sec" aria-labelledby="wk-linked-h"><h2 id="wk-linked-h">Most linked pages</h2><p class="sc-lede">The pages other pages point to most often: a good place to start reading.</p>
++<ol class="wk-h-rank">${linked.map((x) => html`<li><a href="${wpath(x.space, x.page)}">${x.page.title}</a><span class="wk-h-count">Linked from ${plural(x.links, 'page')}</span>${x.rev && x.rev.fields && x.rev.fields.summary ? html`<p>${x.rev.fields.summary}</p>` : ''}</li>`)}</ol></section>` : ''}
++${recent.length ? html`<section class="sc-sec" aria-labelledby="wk-recent-h"><h2 id="wk-recent-h">Recently revised</h2><ul class="wk-recent">${recent.map((r) => html`<li><span><a href="${wpath(r.space, r.page)}">${r.page.title}</a> <span class="wk-muted">in ${r.space.name} · revision ${String(r.page.published_revision)}</span></span> <span class="wk-muted">${t(r.page.revision_published_at)}</span></li>`)}</ul>
++<p class="wk-more"><a href="/recent">All recent changes</a> · <a href="/feed.atom">Atom</a> · <a href="/feed.json">JSON Feed</a></p></section>` : ''}
++${unshelved.length ? html`<section class="sc-sec" aria-labelledby="wk-official"><h2 id="wk-official">Official wikis</h2><ul class="wk-spaces">${unshelved.map(card)}</ul></section>` : ''}
++${user.length ? html`<section class="sc-sec" aria-labelledby="wk-community"><h2 id="wk-community">Community wikis</h2><ul class="wk-spaces">${user.map(card)}</ul></section>` : ''}
${raw(showcase.features({
title: 'What a wiki gets',
items: [
@@@ -89,8 +157,26 @@ ${raw(showcase.features({
{ icon: 'ov:search', title: 'Search and feeds', text: 'Full-text search across the wikis you can see, and Atom and JSON feeds.' },
],
}))}
--<section class="sc-sec wk-home-sec" aria-labelledby="wk-recent-h"><h2 id="wk-recent-h">Recently published</h2>${recent.length ? html`<ul class="wk-recent">${recent.map((r) => html`<li><span><a href="${wpath(r.space, r.page)}">${r.page.title}</a> <span class="wk-muted">in ${r.space.name}</span></span> <span class="wk-muted">${t(r.page.revision_published_at)}</span></li>`)}</ul>` : html`<p class="wk-muted">Nothing published yet.</p>`}
--<p class="wk-more"><a href="/recent">All recent changes</a> · <a href="/feed.atom">Atom</a> · <a href="/feed.json">JSON Feed</a></p></section>`;
++${raw(steps)}
++<section class="sc-sec wk-h-faq" aria-labelledby="wk-faq-h"><h2 id="wk-faq-h">Questions</h2>${FAQ.map((f) => html`<details><summary>${f.q}</summary><p>${f.a}</p></details>`)}</section>
++<section class="sc-sec" aria-labelledby="wk-links-h"><h2 id="wk-links-h">Find your way around</h2><ul class="wk-h-links">${links.map((l) => html`<li><a href="${l.href}"><b>${l.title}</b><small>${l.text}</small></a></li>`)}</ul></section>
++${raw(showcase.cta({ title: 'Start a wiki of your own', text: 'A team handbook, a game guide, a project\'s docs: write it with history and sources from the first page.', actions: [start] }))}`;
++}
++
++/** The home's structured data: the site (with its search), the official pages it lists, and the questions it answers. */
++function homeJsonLd({ shelves = new Map(), spaces = [], baseUrl }) {
++ const SITE = String(baseUrl).replace(/\/+$/, '');
++ const out = [{ '@context': 'https://schema.org', '@type': 'WebSite', name: 'OpenVibe.Wiki', url: `${SITE}/`,
++ potentialAction: { '@type': 'SearchAction', target: { '@type': 'EntryPoint', urlTemplate: `${SITE}/search?q={search_term_string}` }, 'query-input': 'required name=search_term_string' } }];
++ const items = [];
++ for (const s of spaces) {
++ const shelf = shelves.get(s.id);
++ if (!shelf) continue;
++ for (const { page } of shelf.pages) items.push({ name: page.title, url: `${SITE}${wpath(s, page)}` });
++ }
++ if (items.length) out.push({ '@context': 'https://schema.org', '@type': 'ItemList', name: 'Pages of the OpenVibe wiki', numberOfItems: items.length, itemListElement: items.map((x, i) => ({ '@type': 'ListItem', position: i + 1, name: x.name, url: x.url })) });
++ out.push({ '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: FAQ.map((f) => ({ '@type': 'Question', name: f.q, acceptedAnswer: { '@type': 'Answer', text: f.a } })) });
++ return out;
}
function treeHtml(nodes, space) {
@@@ -434,7 +520,7 @@ function signInPage({ next, message }) {
module.exports = {
vipGateBody,
-- home, spacePage, articlePage, historyPage, sourcesPage, diffPage, editPage, formValuesFromRevision, newSpacePage,
++ home, homeJsonLd, spacePage, articlePage, historyPage, sourcesPage, diffPage, editPage, formValuesFromRevision, newSpacePage,
spaceSettingsPage, importPage, pageSettingsPage, revertPage, confirmPublishPage, proposalsPage, searchPage, recentPage,
signInPage, errorBody, wpath, VIS_LABEL,
};
ddiff --git a/server/wiki/seed.js b/server/wiki/seed.js
iindex 4efe4c2..9d7f745 100644
---- a/server/wiki/seed.js
++++ b/server/wiki/seed.js
@@@ -96,6 +96,10 @@ async function seed(svc, data, { publish = true, revise = false, log = console }
if (!space) {
space = await svc.createSpace({ slug: data.space.slug, name: data.space.name, description: data.space.description, kind: data.space.kind || 'official', visibility: data.space.visibility || 'public' }, SYSTEM);
log.log(`[seed] created space ${space.slug}`);
++ } else if (revise && ((data.space.description || null) !== (space.description || null) || (data.space.name && data.space.name !== space.name))) {
++ // --revise brings the wiki's own name and description in line with the seed too (they show on the home).
++ space = await svc.updateSpace(space.slug, { name: data.space.name || space.name, description: data.space.description || null }, SYSTEM);
++ log.log(`[seed] revised the description of space ${space.slug}`);
}
const bySlug = new Map();
const created = [];
ddiff --git a/server/wiki/service.js b/server/wiki/service.js
iindex e462fba..2872ded 100644
---- a/server/wiki/service.js
++++ b/server/wiki/service.js
@@@ -19,6 +19,12 @@ const hooks = require('openvibe-publishing/index-hooks');
const authorship = require('openvibe-publishing/authorship');
const ssr = require('openvibe-publishing/ssr');
++/** An infobox entry's text value by key (a revision's fields.infobox), or null. */
++function infoboxText(box, key) {
++ const x = Array.isArray(box) ? box.find((e) => e && e.key === key) : null;
++ return x && x.value != null && String(x.value).trim() ? String(x.value).trim() : null;
++}
++
/** The first sentence of a summary (a shelf card has room for one), or the summary cut at a word near 160 characters. */
function firstSentence(text) {
const t = String(text || '').replace(/\s+/g, ' ').trim();
@@@ -138,6 +144,26 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
recentInSpace: db.prepare(`SELECT p.* FROM wiki_pages p JOIN wiki_spaces s ON s.id = p.space_id
WHERE s.slug = ? AND p.state = 'published' AND s.deleted_at IS NULL AND s.visibility = 'public' AND p.visibility = 'public'
ORDER BY p.revision_published_at DESC, p.id LIMIT ?`),
++ // The home's numbers and rankings: public wikis and their published public pages only (the home is cached for
++ // everyone). A revision counts as a reader sees it in the history: up to the published one, never an AI
++ // proposal that was not approved; a link counts when both ends are such pages.
++ homeStats: db.prepare(`WITH pub AS (SELECT p.id, p.slug, p.published_revision, s.slug AS space_slug FROM wiki_pages p JOIN wiki_spaces s ON s.id = p.space_id
++ WHERE p.state = 'published' AND p.visibility = 'public' AND s.deleted_at IS NULL AND s.visibility = 'public')
++ SELECT (SELECT COUNT(*) FROM wiki_spaces WHERE deleted_at IS NULL AND visibility = 'public') AS wikis,
++ (SELECT COUNT(*) FROM pub) AS pages,
++ (SELECT COUNT(*) FROM wiki_page_revisions r JOIN pub ON pub.id = r.entity_id AND r.number <= pub.published_revision
++ WHERE NOT EXISTS (SELECT 1 FROM wiki_ai_proposals a WHERE a.page_id = r.entity_id AND a.revision = r.number AND a.status <> 'approved')) AS revisions,
++ (SELECT COUNT(*) FROM wiki_citations c JOIN pub ON pub.id = c.entity_id AND c.revision = pub.published_revision) AS citations,
++ (SELECT COUNT(DISTINCT COALESCE(c.url, c.source_item_id)) FROM wiki_citations c JOIN pub ON pub.id = c.entity_id AND c.revision = pub.published_revision) AS sources,
++ (SELECT COUNT(*) FROM wiki_page_links l JOIN pub f ON f.id = l.from_page_id AND l.from_revision = f.published_revision
++ JOIN pub t ON t.space_slug = l.target_space AND t.slug = l.target_slug WHERE t.id <> f.id) AS links`),
++ mostLinked: db.prepare(`WITH pub AS (SELECT p.id, p.slug, p.published_revision, s.slug AS space_slug FROM wiki_pages p JOIN wiki_spaces s ON s.id = p.space_id
++ WHERE p.state = 'published' AND p.visibility = 'public' AND s.deleted_at IS NULL AND s.visibility = 'public')
++ SELECT t.id, COUNT(DISTINCT f.id) AS n FROM wiki_page_links l
++ JOIN pub f ON f.id = l.from_page_id AND l.from_revision = f.published_revision
++ JOIN pub t ON t.space_slug = l.target_space AND t.slug = l.target_slug
++ WHERE t.id <> f.id GROUP BY t.id ORDER BY n DESC, t.id LIMIT ?`),
++ pagesByIds: db.prepare('SELECT * FROM wiki_pages WHERE id = ANY(?)'),
allPublishedPublic: db.prepare(`SELECT p.* FROM wiki_pages p JOIN wiki_spaces s ON s.id = p.space_id
WHERE p.state = 'published' AND s.deleted_at IS NULL AND s.visibility = 'public' AND p.visibility = 'public'
ORDER BY p.id LIMIT 50000`),
@@@ -681,8 +707,12 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
const level = roots.length === 1 ? roots[0].children : roots;
const pages = level.map((n) => n.page).filter((p) => p.state === 'published' && p.published_revision).slice(0, limit);
const revs = pages.length ? await revisions.getMany(pages.map((p) => ({ entityId: p.id, revision: p.published_revision }))) : [];
++ // A wiki with one root page (an overview over everything else): that page, for a "start here" link.
++ const top = roots.length === 1 && roots[0].page.state === 'published' && roots[0].page.published_revision ? roots[0].page : null;
++ const topRev = top ? (await revisions.getMany([{ entityId: top.id, revision: top.published_revision }]))[0] : null;
return {
-- pages: pages.map((page, i) => ({ page, summary: revs[i] ? firstSentence(revs[i].fields.summary || ssr.markdownToText(revs[i].content, 240)) : '' })),
++ root: top ? { page: top, summary: topRev ? firstSentence(topRev.fields.summary || ssr.markdownToText(topRev.content, 240)) : '' } : null,
++ pages: pages.map((page, i) => ({ page, summary: revs[i] ? firstSentence(revs[i].fields.summary || ssr.markdownToText(revs[i].content, 240)) : '', domain: revs[i] ? infoboxText(revs[i].fields.infobox, 'domain') : null })),
total, updated: updated || null,
};
},
@@@ -1140,6 +1170,21 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
// Discovery (public, indexable only) -----------------------------------------------
async publishedPublic() { return await expandPublished(await q.allPublishedPublic.all()); },
++ /** The home's numbers (public wikis and pages only): wikis, pages, revisions, citations, distinct sources, links between pages. */
++ async homeStats() {
++ const r = (await q.homeStats.get()) || {};
++ const n = (v) => Number(v) || 0;
++ return { wikis: n(r.wikis), pages: n(r.pages), revisions: n(r.revisions), citations: n(r.citations), sources: n(r.sources), links: n(r.links) };
++ },
++ /** Public published pages that the most other public pages link to: [{ space, page, rev, links }], most first. */
++ async mostLinked(limit = 6) {
++ const ranked = await q.mostLinked.all(Math.min(50, Math.max(1, limit)));
++ if (!ranked.length) return [];
++ const byId = new Map((await q.pagesByIds.all(ranked.map((r) => r.id))).map((p) => [p.id, p]));
++ const pages = ranked.map((r) => byId.get(r.id)).filter(Boolean);
++ const counts = new Map(ranked.map((r) => [r.id, Number(r.n) || 0]));
++ return (await expandPublished(pages)).map((x) => ({ ...x, links: counts.get(x.page.id) || 0 }));
++ },
async recentChanges(limit = 50) { return await expandPublished(await q.recent.all(Math.min(200, Math.max(1, limit)))); },
/** The same, for one public space (its feed): null when the slug names no public, live space. */
async recentChangesIn(slug, limit = 50) {
ddiff --git a/test/home.test.js b/test/home.test.js
nnew file mode 100644
iindex 0000000..dd67290
---- /dev/null
++++ b/test/home.test.js
@@@ -0,0 +1,102 @@
++'use strict';
++/**
++ * The home page: real numbers (public pages only), the most linked pages, recent revisions, the questions it answers
++ * (also as FAQPage), WebSite and ItemList structured data, the home in the sitemap, sections with nothing to show left
++ * out, and the official wiki's pages shelved by the Frame's site groups when the Network has told us them.
++ */
++const assert = require('assert');
++const H = require('./helpers');
++const { seed } = require('../server/wiki/seed');
++const views = require('../server/render/views');
++const { createFrameGroups } = require('../server/http/frame-groups');
++
++const cite = (quote) => ({ url: 'https://github.com/OpenVibers/OpenVibe.Wiki/blob/cc7a8fc09705bfc69ee24aeb43e29c2d4453ec6b/README.md', title: 'README', retrievedAt: '2026-10-11T12:00:00.000Z', quote });
++const page = (title, parent, links, domain) => ({
++ title, parent,
++ body: `**${title}** is a fixture page. ${links.map((l) => `It links to [[${l}]].`).join(' ')} ${H.LONG}\n`,
++ summary: `${title} in one line.`,
++ infobox: domain ? [{ key: 'domain', label: 'Domain', type: 'text', value: domain }] : [],
++ citations: [cite(`${title} is a fixture page.`)],
++});
++const data = {
++ space: { slug: 'openvibe', name: 'OpenVibe', description: 'The official fixture wiki.', kind: 'official', visibility: 'public' },
++ pages: [
++ page('OpenVibe', null, ['OpenVibe.Live', 'OpenVibe.Network', 'OpenVibe.Publishing']),
++ page('OpenVibe.Network', 'OpenVibe', [], 'openvibe.network'),
++ page('OpenVibe.Live', 'OpenVibe', ['OpenVibe.Network'], 'openvibe.live'),
++ page('OpenVibe.Publishing', 'OpenVibe', ['OpenVibe.Network', 'OpenVibe.Live'], null),
++ ],
++};
++const ld = (text) => [...text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
++
++(async () => {
++ const h = await H.boot();
++ try {
++ await seed(h.svc, data, { log: H.quiet });
++ const r = await H.req(h, 'GET', '/');
++ assert.strictEqual(r.status, 200);
++ const text = r.text;
++
++ // Search results: a title and a description that say what the site does.
++ assert.match(text, /<title>OpenVibe\.Wiki: wikis with full history and cited sources<\/title>/);
++ const desc = /<meta name="description" content="([^"]*)"/.exec(text)[1];
++ assert.ok(desc.length <= 160 && /revision history/.test(desc), desc);
++ assert.strictEqual((text.match(/<h1\b/g) || []).length, 1, 'one h1');
++
++ // The numbers: public published pages, their revisions, citations and links between them (all real).
++ const stats = await h.svc.homeStats();
++ assert.deepStrictEqual({ pages: stats.pages, revisions: stats.revisions, citations: stats.citations, links: stats.links }, { pages: 4, revisions: 4, citations: 4, links: 6 });
++ assert.match(text, /<dt>Published pages<\/dt><dd>4<\/dd>/);
++ assert.match(text, /<dt>Links between pages<\/dt><dd>6<\/dd>/);
++ assert.ok(!text.includes('<dt>Public wikis</dt>'), 'one wiki: no "1 wiki" tile');
++
++ // Most linked: Network (from three pages), then Live (two), then Publishing (one).
++ const linked = await h.svc.mostLinked(6);
++ assert.deepStrictEqual(linked.map((x) => [x.page.title, x.links]), [['OpenVibe.Network', 3], ['OpenVibe.Live', 2], ['OpenVibe.Publishing', 1]]);
++ assert.match(text, /OpenVibe\.Network<\/a><span class="wk-h-count">Linked from 3 pages<\/span>/);
++
++ // Recently revised, the questions, the links around the site, the start-here link.
++ assert.ok(text.includes('>Recently revised<') && text.includes('revision 1'));
++ assert.ok(text.includes('<summary>Who can start a wiki?</summary>'));
++ assert.ok(text.includes('href="/recent"') && text.includes('href="/updates"') && text.includes('href="/s/openvibe"'));
++ assert.ok(text.includes('Start with OpenVibe'));
++ assert.ok(!text.includes('>Community wikis<'), 'no public community wiki: no section');
++
++ // Structured data: WebSite with its search, the ItemList of the shelf, FAQPage of exactly the questions shown.
++ const blocks = ld(text);
++ const site = blocks.find((b) => b['@type'] === 'WebSite');
++ assert.ok(site && /\/search\?q=\{search_term_string\}$/.test(site.potentialAction.target.urlTemplate));
++ const list = blocks.find((b) => b['@type'] === 'ItemList');
++ assert.deepStrictEqual(list.itemListElement.map((x) => x.name).sort(), ['OpenVibe.Live', 'OpenVibe.Network', 'OpenVibe.Publishing']);
++ const faq = blocks.find((b) => b['@type'] === 'FAQPage');
++ for (const q of faq.mainEntity) assert.ok(text.includes(`<summary>${q.name.replace(/'/g, '&#39;')}</summary>`), q.name);
++
++ // The home is in the sitemap, first.
++ const map = (await H.req(h, 'GET', '/sitemaps/spaces.xml')).text;
++ assert.ok(map.indexOf('<loc>http://wiki.test/</loc>') > 0 && map.indexOf('<loc>http://wiki.test/</loc>') < map.indexOf('/s/openvibe'));
++
++ // Shelves by the Frame's groups: a page's domain picks its launcher group; the rest go under the platform.
++ const spaces = await h.svc.listSpaces({ kind: 'anonymous' });
++ const shelf = await h.svc.shelf(spaces[0], { kind: 'anonymous' }, 60);
++ const groups = { groups: [{ id: 'watch', name: 'Watch & stream' }, { id: 'you', name: 'Your OpenVibe' }, { id: 'talk', name: 'Talk & learn' }],
++ hosts: new Map([['openvibe.live', 'watch'], ['openvibe.network', 'you']]) };
++ const grouped = String(views.home({ spaces, recent: [], shelves: new Map([[spaces[0].id, shelf]]), groups, actor: { kind: 'anonymous' } }));
++ const order = ['Watch &amp; stream', 'OpenVibe.<wbr>Live', 'Your OpenVibe', 'OpenVibe.<wbr>Network', 'Platform and libraries', 'OpenVibe.<wbr>Publishing'].map((s) => grouped.indexOf(s));
++ assert.ok(order.every((i, n) => i > 0 && (n === 0 || i > order[n - 1])), `grouped in order: ${order}`);
++ assert.ok(!grouped.includes('Talk &amp; learn'), 'a group with no pages is left out');
++
++ // The Frame reader: groups and hosts from /api/chrome, cached; unreachable → null (one flat shelf).
++ let calls = 0;
++ const fg = createFrameGroups({ url: 'http://network.test/api/chrome', fetchImpl: async () => { calls++; return { ok: true, json: async () => ({ groups: [{ id: 'watch', name: 'Watch & stream' }], nav: [{ url: 'https://openvibe.live/', group: 'watch' }, { url: 'nope', group: 'x' }] }) }; } });
++ const got = await fg.get();
++ assert.deepStrictEqual([got.groups, [...got.hosts]], [[{ id: 'watch', name: 'Watch & stream' }], [['openvibe.live', 'watch']]]);
++ await fg.get();
++ assert.strictEqual(calls, 1, 'cached');
++ assert.strictEqual(await createFrameGroups({ url: 'http://network.test/api/chrome', fetchImpl: async () => { throw new Error('down'); } }).get(), null);
++ assert.strictEqual(await createFrameGroups({ url: '' }).get(), null);
++
++ console.log('home ok');
++ } finally {
++ await h.stop();
++ }
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/layout.test.js b/test/layout.test.js
iindex edd9d59..1fe69fe 100644
---- a/test/layout.test.js
++++ b/test/layout.test.js
@@@ -52,7 +52,7 @@ const robotsOf = (head) => /<meta name="robots" content="([^"]*)">/.exec(head)[1
// The home page: indexable by an explicit robots string, plus the showcase kit's stylesheet.
r = await H.req(h, 'GET', '/');
let { head } = frameOf(r.text, '/');
-- assert.ok(head.includes('<title>OpenVibe.Wiki: wikis with history and citations</title>'), `the home title says what the site is: ${(/<title>[^<]*<\/title>/.exec(head) || [''])[0]}`);
++ assert.ok(head.includes('<title>OpenVibe.Wiki: wikis with full history and cited sources</title>'), `the home title says what the site is: ${(/<title>[^<]*<\/title>/.exec(head) || [''])[0]}`);
assert.ok(/<link rel="canonical" href="https?:\/\/[^"\/]+\/">/.test(head), 'the home canonical');
assert.strictEqual(robotsOf(head), 'index, follow');
assert.ok(/<link rel="stylesheet" href="\/shared\/[^"]*showcase[^"]*">/.test(head), 'the extra openvibe-shared stylesheet');
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex 00055cc..8a860c8 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -12,14 +12,14 @@ const { spawn } = require('child_process');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const BUDGETS = {
-- htmlRawKB: 27.5, // measured 24.9 (fresh database; 2026-09-29 the showcase hero and features with six inline ring icons)
-- htmlBrotliKB: 7, // 6.3
++ htmlRawKB: 34, // 31.5 on 2026-10-22: the home's how-it-works steps, questions (and their FAQPage JSON-LD), links around the site and closing call; 24.9 (fresh database; 2026-09-29 the showcase hero and features with six inline ring icons)
++ htmlBrotliKB: 8.5, // 7.7 (2026-10-22 the richer home); 6.3
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell adds web-runtime, 2026-10-04)
jsRawKB: 250, // 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (197.7 before openvibe-shared/shell's web-runtime.js)
jsBrotliKB: 60, // 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (45.6 before web-runtime.js)
cssFiles: 2, // 1
-- cssRawKB: 20, // 19.1 (wiki.css + the cached /shared/showcase.css; 2026-10-15 the home's space cards, shelf and recent list)
-- cssBrotliKB: 5, // 4.6
++ cssRawKB: 25, // 23.5 on 2026-10-22 (the home's numbers, shelves by site group, ranked pages, questions and links); 19.1 (wiki.css + the cached /shared/showcase.css; 2026-10-15 the home's space cards, shelf and recent list)
++ cssBrotliKB: 6, // 5.3 (2026-10-22); 4.6
externalFiles: 1, // 0
};
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex d35e2cb..bec6a86 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -61,6 +61,7 @@ const INTERNAL = [
const REVIEWED = {
'server/auth/keys.js': 'Network JWKS (configured)',
'server/auth/session.js': 'Network OAuth token and revoke (configured)',
++ 'server/http/frame-groups.js': 'Network /api/chrome, the Frame\'s site groups (configured: WIKI_FRAME_URL or the internal Network)',
'server/integrations/platform.js': 'Community, Sources, Media, VIP (configured)',
'server/integrations/projects-module.js': 'Network user modules (configured)',
};
ddiff --git a/test/seed-revise.test.js b/test/seed-revise.test.js
iindex ba2e262..2f38344 100644
---- a/test/seed-revise.test.js
++++ b/test/seed-revise.test.js
@@@ -90,6 +90,14 @@ const clone = (x) => JSON.parse(JSON.stringify(x));
assert.strictEqual(await h.stores.revisions.headNumber(beta.id), 2, 'still exactly one new revision');
assert.strictEqual(await h.stores.revisions.headNumber(alpha.id), 1);
++ // --revise also brings the wiki's description in line with the seed; a plain run leaves it alone.
++ const described = clone(changed);
++ described.space.description = 'The revised fixture description';
++ await seed(h.svc, described, { log: H.quiet });
++ assert.strictEqual((await h.svc.findSpace('tests')).description, 'Seed revise fixture');
++ await seed(h.svc, described, { revise: true, log: H.quiet });
++ assert.strictEqual((await h.svc.findSpace('tests')).description, 'The revised fixture description');
++
// Without --revise the changed page is left alone (unchanged semantics).
const plain = await seed(h.svc, changed, { log: H.quiet });
assert.strictEqual(plain.created.length, 0);
ddiff --git a/test/seed.test.js b/test/seed.test.js
iindex ca57b49..00e874a 100644
---- a/test/seed.test.js
++++ b/test/seed.test.js
@@@ -65,16 +65,18 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'ope
// space's own description and page count as the section's lede. No "Official spaces" list repeats it.
const homePage = await H.req(h, 'GET', '/');
assert.strictEqual(homePage.status, 200);
-- const shelf = (/<section class="sc-sec" id="inside-openvibe"[\s\S]*?<\/section>/.exec(homePage.text) || [''])[0];
-- assert.ok(shelf.includes('The OpenVibe space'), 'the section names the space');
++ const shelf = (/<section class="sc-sec wk-h-shelf" id="inside-openvibe"[\s\S]*?<\/section>/.exec(homePage.text) || [''])[0];
++ assert.ok(shelf.includes('The OpenVibe wiki'), 'the section names the wiki');
assert.match(shelf, new RegExp(`${data.pages.length} pages, updated \\d{4}-\\d{2}-\\d{2}\\.`), 'its page count and date');
const roots = data.pages.filter((p) => !p.parent);
-- const shelfCards = (shelf.match(/class="sc-card"/g) || []).length;
++ const shelfCards = (shelf.match(/class="sc-card /g) || []).length;
assert.strictEqual(shelfCards, data.pages.length - roots.length, 'one card for each page under the root');
assert.ok(shelf.includes('href="/w/openvibe/openvibe-live"'), 'cards link the pages');
assert.ok(!/<small>[^<]{241,}<\/small>/.test(shelf), 'a card carries one short sentence');
assert.ok(!homePage.text.includes('>Official wikis<'), 'a shelved space is not listed again');
-- assert.ok(homePage.text.includes('No community wikis yet.'), 'the community section says how to start one');
++ assert.ok(!homePage.text.includes('>Community wikis<'), 'no community wikis: no empty section');
++ assert.ok(homePage.text.includes('Start a wiki of your own'), 'the home says how to start one');
++ assert.ok(shelf.includes('Start with OpenVibe'), 'the root page is the place to start');
// Absent from sitemaps, feeds and Search until reviewed.
assert.strictEqual(((await H.req(h, 'GET', '/sitemaps/pages-1.xml')).text.match(/<loc>/g) || []).length, 0);
const atomFeed = await H.req(h, 'GET', '/feed.atom');