openvibers/openvibe.wiki Commits

Wiki: the front page shows what is inside the official space, and the empty community section says how to start one (#51)

OP OpenVibers committed 29 days ago · 94fc7d3d74 · 6 files, +110 −14

**The Wiki's front page shows what is in the wiki.**

**Before:** the official space was one bulleted link, the community section said "None yet.", and the recent pages were a plain list. 36 product pages were one click away, and nothing on the home showed them.

**Now:**
- **A shelf for each public official space** ("The OpenVibe space"):
  - its pages one level under the root page, as cards (showcase kit) with each product's ring icon and the first sentence of its summary;
  - the lede is the space's own description plus "39 pages, updated 2026-10-15";
  - a link opens the full tree.
  - Summaries are clamped to three lines, or two on phones, where the cards are also tighter.
  - A space that isn't public, or has no pages, is still a card under "Official spaces".
- **Community spaces:** cards when there are any. Otherwise a short empty state says who can start one and what kinds there are, with the start button.
- **Recently published:** title and space on the left, date on the right, rows divided by lines.
- The spaces sections use the showcase's heading style, so the home reads as one family.
- An old 8 px height rule on `#spaces` is removed. It was sized for an empty anchor and would have collapsed the new section.

**Service:** `svc.shelf(space, actor, limit)` reads the tree the viewer can see, the pages under its single root, and their published revisions in one batch. It returns the pages with a first sentence each, the readable page count and the last update. The home builds shelves only for public official spaces, because the home is publicly cached.

**Checked** locally with the production seed at 1280 and 390 px: no horizontal overflow, 38 cards.

**Tests:**
- `seed.test.js`: the shelf names the space, gives the page count and date, has one card per page under the root, links the pages, keeps cards short, doesn't list the shelved space again, and shows the community empty state.
- `perf-budget`: CSS raised to 20 KB raw / 5 KB brotli (measured 19.1 / 4.6) as a decision. HTML stays within its budget.

Changes

ddiff --git a/public/css/wiki.css b/public/css/wiki.css
iindex b68c384..391e924 100644
---- a/public/css/wiki.css
++++ b/public/css/wiki.css
@@@ -65,6 +65,19 @@ table { border-collapse: collapse; }
.wk-citations blockquote { margin: 4px 0 0 12px; color: var(--wk-muted); font-style: italic; }
.wk-history tr.wk-live { background: var(--wk-panel); }
.wk-tree { padding-left: 1.2em; }
++/* The home: space cards, the empty community state, the recent list. */
++.wk-spaces { list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 1fr)); gap: 12px; }
++.wk-space { display: flex; flex-direction: column; gap: 4px; height: 100%; padding: 14px 16px; border: 1px solid var(--wk-line); border-radius: 12px; background: var(--wk-panel); color: var(--wk-text); text-decoration: none; }
++.wk-space:hover { border-color: var(--wk-accent); }
++.wk-space b { font-size: 1.05rem; }
++.wk-space-text { color: var(--wk-muted); font-size: 15px; }
++.wk-space-meta { color: var(--wk-muted); font-size: 13px; margin-top: auto; padding-top: 4px; }
++.wk-empty { padding: 16px; border: 1px dashed var(--wk-line); border-radius: 12px; }
++.wk-empty p { margin: 0 0 10px; }
++.wk-empty p:last-child { margin: 0; }
++.wk-more { margin: 10px 0 0; }
++.wk-recent { list-style: none; padding: 0; margin: 0; }
++.wk-recent li { display: flex; justify-content: space-between; align-items: baseline; gap: 4px 16px; flex-wrap: wrap; padding: 6px 0; border-bottom: 1px solid var(--wk-line); }
.wk-comments { list-style: none; padding: 0; }
.wk-comments li { border-top: 1px solid var(--wk-line); padding: 8px 0; }
@media (max-width: 640px) {
@@@ -79,4 +92,10 @@ main .sc-hero { padding-top: 16px; }
main .sc-hero h1 { font-size: clamp(30px, 4.6vw, 46px); }
main .sc-sec { margin-top: 36px; }
main .sc-ic .ov-icon { --ovi-size: 28px !important; }
--#spaces { display: block; height: 8px; }
++/* The shelf: what is inside a space, a summary of three lines at most per page. */
++.wk-main .sc-sec[id^="inside-"] .sc-card small, main .sc-sec[id^="inside-"] .sc-card small { display: -webkit-box; -webkit-line-clamp: 3; line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
++@media (max-width: 640px) {
++ main .sc-sec[id^="inside-"] .sc-grid { gap: 8px; }
++ 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; }
++}
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 814f95e..78ea2a5 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -123,9 +123,12 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
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)]);
++ // The official spaces' 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)])));
// 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, actor: req.actor }) + frame.shipped({ service: 'wiki', title: 'Recently shipped on OpenVibe.Wiki' }), {
++ send(req, res, 200, views.home({ spaces, recent, shelves, 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,
});
ddiff --git a/server/render/views.js b/server/render/views.js
iindex a0ecdf9..aadded1 100644
---- a/server/render/views.js
++++ b/server/render/views.js
@@@ -32,18 +32,53 @@ function vipGateBody({ title, spaceName, joinUrl, signedIn, signInUrl }) {
<p>${signedIn ? '' : html`<a class="wk-btn" href="${signInUrl}">Sign in</a> `}${joinUrl ? html`<a class="wk-btn" href="${joinUrl}" rel="noopener">See their VIP plans</a>` : ''}</p><p><a href="/">Back to the wiki</a></p></section>`;
}
--function home({ spaces, recent, actor }) {
++// A product page's ring icon on the shelf: its slug names the product (openvibe-live → live); the rest are the network's
++// libraries and runtimes. Anything else gets the wiki's own.
++const SHELF_ICON = { openrestream: 'stream', 'openvibe-mediahub': 'video', 'openvibe-node': 'plug', 'openvibe-sources': 'feeds', 'openvibe-play': 'games',
++ 'openvibe-publishing': 'docs', 'openvibe-contracts': 'code', 'openvibe-sdk': 'code', 'openvibe-shared': 'code', 'openvibe-extensions': 'plug', 'openvibe-examples': 'code' };
++const RING = new Set(['ov', 'network', 'live', 'tools', 'media', 'games', 'community', 'chat', 'codes', 'bot', 'blog', 'wiki', 'news', 'reviews', 'tips', 'vip', 'trade', 'host', 'deals', 'coupons',
++ 'video', 'search', 'food', 'work', 'rent', 'help', 'quest', 'inventory', 'services', 'space', 'watch', 'actor', 'events', 'ai', 'run', 'billing', 'pics', 'zone', 'download']);
++function shelfIcon(slug) {
++ if (SHELF_ICON[slug]) return `ov:${SHELF_ICON[slug]}`;
++ const m = /^openvibe-([a-z]+)$/.exec(slug || '');
++ return `ov:${m && RING.has(m[1]) ? m[1] : 'wiki'}`;
++}
++
++function home({ spaces, recent, shelves = new Map(), actor }) {
const official = spaces.filter((s) => s.kind === 'official');
const user = spaces.filter((s) => s.kind === 'user');
-- const list = (items) => items.length ? html`<ul class="wk-spaces">${items.map((s) => html`<li><a href="/s/${e(s.slug)}"><strong>${s.name}</strong></a>${s.visibility !== 'public' ? html` <span class="wk-tag">${s.visibility}</span>` : ''}${s.description ? html`<br><span class="wk-muted">${s.description}</span>` : ''}</li>`)}</ul>` : html`<p class="wk-muted">None yet.</p>`;
-- const start = actor && actor.subject ? { label: 'Start a space', href: '/new-space', primary: true } : { label: 'Sign in to start a space', href: '/auth/login?next=%2Fnew-space', primary: true };
-- // What OpenVibe.Wiki is for (openvibe-shared/showcase), then the spaces and what was published.
++ const signedIn = !!(actor && actor.subject);
++ const start = signedIn ? { label: 'Start a space', href: '/new-space', primary: true } : { label: 'Sign in to start a space', href: '/auth/login?next=%2Fnew-space', primary: true };
++ const card = (s) => {
++ 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 space 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 spaces".
++ 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.
return html`${raw(showcase.hero({
eyebrow: 'OpenVibe.Wiki',
title: 'Knowledge,', accent: 'with sources',
lede: 'Wiki spaces with page trees, revision history, citations and discussion. Official spaces are edited by OpenVibe staff; anyone signed in with an OpenVibe account can start a space of their own.',
actions: [start, { label: 'Browse the spaces', href: '#spaces' }],
-- }) + showcase.features({
++ }))}
++<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 spaces</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 spaces</h2>${user.length ? html`<ul class="wk-spaces">${user.map(card)}</ul>`
++ : html`<div class="wk-empty"><p><b>No community spaces 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>
++${raw(showcase.features({
title: 'What a space gets',
items: [
{ icon: 'ov:wiki', title: 'Page trees', text: 'Pages nest under pages, so a space reads like a book.' },
@@@ -54,11 +89,8 @@ function home({ spaces, recent, actor }) {
{ icon: 'ov:search', title: 'Search and feeds', text: 'Full-text search across the spaces you can see, and Atom and JSON feeds.' },
],
}))}
--<div id="spaces"></div>
--<section><h2>Official spaces</h2>${list(official)}</section>
--<section><h2>Community spaces</h2>${list(user)}</section>
--<section><h2>Recently published</h2>${recent.length ? html`<ul class="wk-recent">${recent.map((r) => html`<li><a href="${wpath(r.space, r.page)}">${r.page.title}</a> <span class="wk-muted">in ${r.space.name} · ${t(r.page.revision_published_at)}</span></li>`)}</ul>` : html`<p class="wk-muted">Nothing published yet.</p>`}
--<p><a href="/recent">All recent changes</a> · <a href="/feed.atom">Atom</a> · <a href="/feed.json">JSON Feed</a></p></section>`;
++<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>`;
}
function treeHtml(nodes, space) {
ddiff --git a/server/wiki/service.js b/server/wiki/service.js
iindex 51921d4..e004513 100644
---- a/server/wiki/service.js
++++ b/server/wiki/service.js
@@@ -18,6 +18,14 @@ const seo = require('openvibe-publishing/seo');
const hooks = require('openvibe-publishing/index-hooks');
const authorship = require('openvibe-publishing/authorship');
const ssr = require('openvibe-publishing/ssr');
++
++/** 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();
++ const m = /^(.{40,200}?[.!?])(?:\s|$)/.exec(t);
++ if (m) return m[1];
++ return t.length <= 160 ? t : `${t.slice(0, 160).replace(/\s+\S*$/, '')}…`;
++}
const { isMediaId } = require('openvibe-publishing/media');
const { ulid } = require('openvibe-contracts').ids;
const content = require('./content');
@@@ -656,6 +664,26 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
},
// Pages ----------------------------------------------------------------------------
++ /**
++ * A space's front shelf (the home page): its published pages one level under the top (the children of its
++ * root page when it has exactly one), each with its summary, at most `limit`, and how many pages the viewer
++ * can read in the space. → { pages: [{ page, summary }], total, updated }
++ */
++ async shelf(space, actor, limit = 48) {
++ const roots = await svc.tree(space, actor);
++ let total = 0;
++ let updated = 0;
++ const walk = (nodes) => { for (const n of nodes) { if (n.page.state === 'published') { total++; updated = Math.max(updated, Number(n.page.revision_published_at) || 0); } walk(n.children); } };
++ walk(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 revs = pages.length ? await revisions.getMany(pages.map((p) => ({ entityId: p.id, revision: p.published_revision }))) : [];
++ return {
++ pages: pages.map((page, i) => ({ page, summary: revs[i] ? firstSentence(revs[i].fields.summary || ssr.markdownToText(revs[i].content, 240)) : '' })),
++ total, updated: updated || null,
++ };
++ },
++
async tree(space, actor) {
await roles(actor);
const pages = (await q.pagesOfSpace.all(space.id)).filter((p) => access.canReadPage(space, p, actor));
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex cd46bdb..fca1290 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -18,8 +18,8 @@ const BUDGETS = {
jsRawKB: 245, // 239.1 (197.7 before openvibe-shared/shell's web-runtime.js)
jsBrotliKB: 59, // 56.3 (45.6 before web-runtime.js)
cssFiles: 2, // 1
-- cssRawKB: 18, // 16.3 (wiki.css + the cached /shared/showcase.css)
-- cssBrotliKB: 4.5, // 4.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
externalFiles: 1, // 0
};
ddiff --git a/test/seed.test.js b/test/seed.test.js
iindex 6246fc0..37f72d4 100644
---- a/test/seed.test.js
++++ b/test/seed.test.js
@@@ -61,6 +61,20 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'ope
assert.ok(r.text.includes('Not yet reviewed by a person.'), `${p.title}: review state disclosed`);
assert.ok(r.text.includes('<meta name="robots" content="noindex, nofollow">'), `${p.title}: noindex`);
}
++ // The home's shelf: the official space's pages under its root page, each a card with its first sentence, and the
++ // 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');
++ 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;
++ 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 spaces<'), 'a shelved space is not listed again');
++ assert.ok(homePage.text.includes('No community spaces yet.'), 'the community section says how to start one');
// 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');