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)
Changes
@@@ -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; }++}@@@ -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"},@@@ -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'),nnew file mode 100644@@@ -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 };@@@ -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) => {@@@ -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.@@@ -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 || '/'}`,@@@ -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,};@@@ -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 = [];@@@ -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_idWHERE 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_idWHERE 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) {nnew file mode 100644@@@ -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, ''')}</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 & 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 & 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); });@@@ -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');@@@ -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.3jsFiles: 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.6externalFiles: 1, // 0};@@@ -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)',};@@@ -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);@@@ -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');
@@ -99,3 +99,47 @@ main .sc-ic .ov-icon { --ovi-size: 28px !important; }
@@ -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 { padding: 12px 14px; }
main .sc-sec[id^="inside-"] .sc-card small { -webkit-line-clamp: 2; line-clamp: 2; }
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; }
+}
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
"space": {
"space": {
"slug": "openvibe",
"slug": "openvibe",
"name": "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",
"kind": "official",
"visibility": "public"
"visibility": "public"
},
},
@@ -38,6 +38,10 @@ function load(env = process.env) {
@@ -38,6 +38,10 @@ function load(env = process.env) {
networkUrl,
networkUrl,
networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
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,
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.
// A browser's user JWT is accepted when its aud contains one of these.
userAudiences: list(env.WIKI_USER_AUDIENCES, 'openvibe.wiki,openvibe.network'),
userAudiences: list(env.WIKI_USER_AUDIENCES, 'openvibe.wiki,openvibe.network'),
new file mode 100644
new file mode 100644
@@ -0,0 +1,51 @@
@@ -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 };
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
* ones enter feeds. Members/private/deleted content never appears.
* ones enter feeds. Members/private/deleted content never appears.
*
*
* /robots.txt crawl rules + sitemap location + explicit automated-consumer policy
* /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)
* /feed.atom recent changes (Atom), /feed.json (JSON Feed 1.1)
* /llms.txt orientation for language models
* /llms.txt orientation for language models
* /llms-full.txt the same orientation plus an excerpt of every indexable page, bounded
* /llms-full.txt the same orientation plus an excerpt of every indexable page, bounded
@@ -51,12 +51,15 @@ function createMachine({ svc, config }) {
@@ -51,12 +51,15 @@ function createMachine({ svc, config }) {
if (!page.revision_published_at) continue;
if (!page.revision_published_at) continue;
newestPage.set(space.id, Math.max(newestPage.get(space.id) || 0, page.revision_published_at));
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));
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) => {
router.get('/sitemap.xml', wrap(async (_req, res) => {
@@ -38,8 +38,12 @@ const showcase = require('openvibe-shared/showcase');
@@ -38,8 +38,12 @@ const showcase = require('openvibe-shared/showcase');
const content = require('../wiki/content');
const content = require('../wiki/content');
const { actorMiddleware, resolveCitations, resolveBundleCitations, citationsFromForm } = require('./common');
const { actorMiddleware, resolveCitations, resolveBundleCitations, citationsFromForm } = require('./common');
const importer = require('../wiki/import');
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 router = express.Router();
const form = express.urlencoded({ extended: false, limit: '600kb' });
const form = express.urlencoded({ extended: false, limit: '600kb' });
router.use(actorMiddleware(viewers, { services: false }));
router.use(actorMiddleware(viewers, { services: false }));
@@ -122,15 +126,16 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
@@ -122,15 +126,16 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
// ── Home, recent, search ─────────────────────────────────
// ── Home, recent, search ─────────────────────────────────
router.get('/', wrap(async (req, res) => {
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
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).
// 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)
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
// 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`).
// 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],
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.
// What shipped on OpenVibe.Wiki: the shared update log every OpenVibe site has.
@@ -84,7 +84,7 @@ function renderPage(o) {
@@ -84,7 +84,7 @@ function renderPage(o) {
siteName: SITE_NAME,
siteName: SITE_NAME,
lang: 'en',
lang: 'en',
// The front page's title says what the site is (search results show it); every other page is "<page> · OpenVibe.Wiki".
// 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,
description: o.description || DEFAULT_DESCRIPTION,
summary: o.summary,
summary: o.summary,
canonical: `${config.baseUrl}${o.path || '/'}`,
canonical: `${config.baseUrl}${o.path || '/'}`,
@@ -44,7 +44,27 @@ function shelfIcon(slug) {
@@ -44,7 +44,27 @@ function shelfIcon(slug) {
return `ov:${m && RING.has(m[1]) ? m[1] : 'wiki'}`;
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 official = spaces.filter((s) => s.kind === 'official');
const user = spaces.filter((s) => s.kind === 'user');
const user = spaces.filter((s) => s.kind === 'user');
const signedIn = !!(actor && actor.subject);
const signedIn = !!(actor && actor.subject);
@@ -53,31 +73,79 @@ function home({ spaces, recent, shelves = new Map(), actor }) {
@@ -53,31 +73,79 @@ function home({ spaces, recent, shelves = new Map(), actor }) {
const shelf = shelves.get(s.id);
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>`;
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 shelved = official.filter((s) => shelves.get(s.id) && shelves.get(s.id).pages.length);
const unshelved = official.filter((s) => !shelved.includes(s));
const unshelved = official.filter((s) => !shelved.includes(s));
const inside = shelved.map((s) => {
const inside = shelved.map((s) => {
const shelf = shelves.get(s.id);
const shelf = shelves.get(s.id);
const day = shelf.updated ? new Date(Number(shelf.updated)).toISOString().slice(0, 10) : null;
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({
return html`${raw(showcase.hero({
eyebrow: 'OpenVibe.Wiki',
eyebrow: 'OpenVibe.Wiki',
title: 'Knowledge,', accent: 'with sources',
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>
<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({
${raw(showcase.features({
title: 'What a wiki gets',
title: 'What a wiki gets',
items: [
items: [
@@ -89,8 +157,26 @@ ${raw(showcase.features({
@@ -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.' },
{ 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) {
function treeHtml(nodes, space) {
@@ -434,7 +520,7 @@ function signInPage({ next, message }) {
@@ -434,7 +520,7 @@ function signInPage({ next, message }) {
module.exports = {
module.exports = {
vipGateBody,
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,
spaceSettingsPage, importPage, pageSettingsPage, revertPage, confirmPublishPage, proposalsPage, searchPage, recentPage,
signInPage, errorBody, wpath, VIS_LABEL,
signInPage, errorBody, wpath, VIS_LABEL,
};
};
@@ -96,6 +96,10 @@ async function seed(svc, data, { publish = true, revise = false, log = console }
@@ -96,6 +96,10 @@ async function seed(svc, data, { publish = true, revise = false, log = console }
if (!space) {
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);
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}`);
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 bySlug = new Map();
const created = [];
const created = [];
@@ -19,6 +19,12 @@ const hooks = require('openvibe-publishing/index-hooks');
@@ -19,6 +19,12 @@ const hooks = require('openvibe-publishing/index-hooks');
const authorship = require('openvibe-publishing/authorship');
const authorship = require('openvibe-publishing/authorship');
const ssr = require('openvibe-publishing/ssr');
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. */
/** 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) {
function firstSentence(text) {
const t = String(text || '').replace(/\s+/g, ' ').trim();
const t = String(text || '').replace(/\s+/g, ' ').trim();
@@ -138,6 +144,26 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -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
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'
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 ?`),
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
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'
WHERE p.state = 'published' AND s.deleted_at IS NULL AND s.visibility = 'public' AND p.visibility = 'public'
ORDER BY p.id LIMIT 50000`),
ORDER BY p.id LIMIT 50000`),
@@ -681,8 +707,12 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -681,8 +707,12 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
const level = roots.length === 1 ? roots[0].children : roots;
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 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 }))) : [];
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 {
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,
total, updated: updated || null,
};
};
},
},
@@ -1140,6 +1170,21 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -1140,6 +1170,21 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
// Discovery (public, indexable only) -----------------------------------------------
// Discovery (public, indexable only) -----------------------------------------------
async publishedPublic() { return await expandPublished(await q.allPublishedPublic.all()); },
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)))); },
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. */
/** The same, for one public space (its feed): null when the slug names no public, live space. */
async recentChangesIn(slug, limit = 50) {
async recentChangesIn(slug, limit = 50) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,102 @@
@@ -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, ''')}</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 & 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 & 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); });
@@ -52,7 +52,7 @@ const robotsOf = (head) => /<meta name="robots" content="([^"]*)">/.exec(head)[1
@@ -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.
// The home page: indexable by an explicit robots string, plus the showcase kit's stylesheet.
r = await H.req(h, 'GET', '/');
r = await H.req(h, 'GET', '/');
let { head } = frameOf(r.text, '/');
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.ok(/<link rel="canonical" href="https?:\/\/[^"\/]+\/">/.test(head), 'the home canonical');
assert.strictEqual(robotsOf(head), 'index, follow');
assert.strictEqual(robotsOf(head), 'index, follow');
assert.ok(/<link rel="stylesheet" href="\/shared\/[^"]*showcase[^"]*">/.test(head), 'the extra openvibe-shared stylesheet');
assert.ok(/<link rel="stylesheet" href="\/shared\/[^"]*showcase[^"]*">/.test(head), 'the extra openvibe-shared stylesheet');
@@ -12,14 +12,14 @@ const { spawn } = require('child_process');
@@ -12,14 +12,14 @@ const { spawn } = require('child_process');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const { measure, check, format } = require('openvibe-shared/perf-budget');
const BUDGETS = {
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)
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)
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)
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
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
externalFiles: 1, // 0
};
};
@@ -61,6 +61,7 @@ const INTERNAL = [
@@ -61,6 +61,7 @@ const INTERNAL = [
const REVIEWED = {
const REVIEWED = {
'server/auth/keys.js': 'Network JWKS (configured)',
'server/auth/keys.js': 'Network JWKS (configured)',
'server/auth/session.js': 'Network OAuth token and revoke (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/platform.js': 'Community, Sources, Media, VIP (configured)',
'server/integrations/projects-module.js': 'Network user modules (configured)',
'server/integrations/projects-module.js': 'Network user modules (configured)',
};
};
@@ -90,6 +90,14 @@ const clone = (x) => JSON.parse(JSON.stringify(x));
@@ -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(beta.id), 2, 'still exactly one new revision');
assert.strictEqual(await h.stores.revisions.headNumber(alpha.id), 1);
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).
// Without --revise the changed page is left alone (unchanged semantics).
const plain = await seed(h.svc, changed, { log: H.quiet });
const plain = await seed(h.svc, changed, { log: H.quiet });
assert.strictEqual(plain.created.length, 0);
assert.strictEqual(plain.created.length, 0);
@@ -65,16 +65,18 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'ope
@@ -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.
// 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', '/');
const homePage = await H.req(h, 'GET', '/');
assert.strictEqual(homePage.status, 200);
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');
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 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.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(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(!/<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('>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.
// 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);
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');
const atomFeed = await H.req(h, 'GET', '/feed.atom');