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)
Changes
@@@ -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; }++}@@@ -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 seeconst [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,});@@@ -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) {@@@ -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));@@@ -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.6externalFiles: 1, // 0};@@@ -61,6 +61,20 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'opeassert.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');
@@ -65,6 +65,19 @@ table { border-collapse: collapse; }
@@ -65,6 +65,19 @@ table { border-collapse: collapse; }
.wk-citations blockquote { margin: 4px 0 0 12px; color: var(--wk-muted); font-style: italic; }
.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-history tr.wk-live { background: var(--wk-panel); }
.wk-tree { padding-left: 1.2em; }
.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 { list-style: none; padding: 0; }
.wk-comments li { border-top: 1px solid var(--wk-line); padding: 8px 0; }
.wk-comments li { border-top: 1px solid var(--wk-line); padding: 8px 0; }
@media (max-width: 640px) {
@media (max-width: 640px) {
@@ -79,4 +92,10 @@ main .sc-hero { padding-top: 16px; }
@@ -79,4 +92,10 @@ main .sc-hero { padding-top: 16px; }
main .sc-hero h1 { font-size: clamp(30px, 4.6vw, 46px); }
main .sc-hero h1 { font-size: clamp(30px, 4.6vw, 46px); }
main .sc-sec { margin-top: 36px; }
main .sc-sec { margin-top: 36px; }
main .sc-ic .ov-icon { --ovi-size: 28px !important; }
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; }
+}
@@ -123,9 +123,12 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
@@ -123,9 +123,12 @@ function createPages({ svc, viewers, platform, config, log = console, limits })
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] = 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
// 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, 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],
robots: 'index, follow', cache: 'public', active: 'home', path: '/', styles: [showcase.STYLESHEET],
summary: AI_SUMMARY,
summary: AI_SUMMARY,
});
});
@@ -32,18 +32,53 @@ function vipGateBody({ title, spaceName, joinUrl, signedIn, signInUrl }) {
@@ -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>`;
<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 official = spaces.filter((s) => s.kind === 'official');
const user = spaces.filter((s) => s.kind === 'user');
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({
return html`${raw(showcase.hero({
eyebrow: 'OpenVibe.Wiki',
eyebrow: 'OpenVibe.Wiki',
title: 'Knowledge,', accent: 'with sources',
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.',
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' }],
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',
title: 'What a space gets',
items: [
items: [
{ icon: 'ov:wiki', title: 'Page trees', text: 'Pages nest under pages, so a space reads like a book.' },
{ 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 }) {
@@ -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.' },
{ 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) {
function treeHtml(nodes, space) {
@@ -18,6 +18,14 @@ const seo = require('openvibe-publishing/seo');
@@ -18,6 +18,14 @@ const seo = require('openvibe-publishing/seo');
const hooks = require('openvibe-publishing/index-hooks');
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');
+
+/** 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 { isMediaId } = require('openvibe-publishing/media');
const { ulid } = require('openvibe-contracts').ids;
const { ulid } = require('openvibe-contracts').ids;
const content = require('./content');
const content = require('./content');
@@ -656,6 +664,26 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -656,6 +664,26 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
},
},
// Pages ----------------------------------------------------------------------------
// 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) {
async tree(space, actor) {
await roles(actor);
await roles(actor);
const pages = (await q.pagesOfSpace.all(space.id)).filter((p) => access.canReadPage(space, p, actor));
const pages = (await q.pagesOfSpace.all(space.id)).filter((p) => access.canReadPage(space, p, actor));
@@ -18,8 +18,8 @@ const BUDGETS = {
@@ -18,8 +18,8 @@ const BUDGETS = {
jsRawKB: 245, // 239.1 (197.7 before openvibe-shared/shell's web-runtime.js)
jsRawKB: 245, // 239.1 (197.7 before openvibe-shared/shell's web-runtime.js)
jsBrotliKB: 59, // 56.3 (45.6 before web-runtime.js)
jsBrotliKB: 59, // 56.3 (45.6 before web-runtime.js)
cssFiles: 2, // 1
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
externalFiles: 1, // 0
};
};
@@ -61,6 +61,20 @@ const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'seeds', 'ope
@@ -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('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`);
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.
// 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');