openvibers/openvibe.community Commits
openvibe.community's home shows the forum, the roadmap and what is happening across OpenVibe, with real counts and questions; Pulse names a blog post's blog (it said "[object Object]") (#115)
Changes
nnew file mode 100644@@@ -0,0 +1,13 @@++-- phase: migrate++-- Pulse's blog items were titled "New post on the [object Object] blog": blog.post.published carries its blog as++-- { id, handle } and the consumer printed the object (fixed in server/pulse/consumer.js). Each such item gets the++-- handle from its own canonical URL (https://openvibe.blog/@<handle>/<post>), as the consumer now writes it.++UPDATE pulse_items++ SET title = 'New post on the ' || substring(url FROM '^https://[^/]+/@([a-z0-9][a-z0-9_-]{1,39})/') || ' blog'++ WHERE source_service = 'blog'++ AND title = 'New post on the [object Object] blog'++ AND substring(url FROM '^https://[^/]+/@([a-z0-9][a-z0-9_-]{1,39})/') IS NOT NULL;++UPDATE pulse_items++ SET title = 'New post on an OpenVibe blog'++ WHERE source_service = 'blog'++ AND title = 'New post on the [object Object] blog';@@@ -336,3 +336,22 @@ button.rx:hover { border-color: var(--accent); }/* A link inside running text is underlined: colour alone did not tell it apart (WCAG 1.4.1). */.lede a, .muted a, p a:not(.btn) { text-decoration: underline; text-underline-offset: 2px; }++++/* Home shelves (server/render/pages.js homePage): real counts, the forum's places, the roadmap, Pulse, questions */++.cm-stats { display: flex; flex-wrap: wrap; gap: 10px; margin: 4px 0 8px; padding: 0; }++.cm-stats > div { display: flex; flex-direction: column-reverse; min-width: 0; padding: 8px 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-card); }++.cm-stats dt { margin: 0; font-size: .8rem; color: var(--text-secondary); }++.cm-stats dd { margin: 0; font-size: 1.3rem; font-weight: 800; font-variant-numeric: tabular-nums; line-height: 1.2; }++.cm-shelf { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(min(240px, 100%), 1fr)); }++.cm-tile { display: grid; gap: 4px; height: 100%; padding: 14px 16px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-card); color: var(--text-primary); }++.cm-tile:hover, .cm-tile:focus-visible { border-color: var(--accent); text-decoration: none; }++.cm-tile span { font-size: .9rem; color: var(--text-secondary); }++.cm-tile small { font-size: .8rem; color: var(--text-secondary); }++.cm-list { list-style: none; margin: 0; padding: 0; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-card); }++.cm-list li { padding: 10px 14px; overflow-wrap: anywhere; }++.cm-list li + li { border-top: 1px solid var(--border); }++.cm-faq { display: grid; gap: 8px; max-width: 860px; }++.cm-faq details { border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-card); }++.cm-faq summary { cursor: pointer; padding: 12px 16px; font-weight: 700; }++.cm-faq details[open] summary { border-bottom: 1px solid var(--border); }++.cm-faq p { margin: 0; padding: 12px 16px 14px; color: var(--text-secondary); line-height: 1.6; }@@@ -294,9 +294,40 @@ async function createApp(opts = {}) {// Every page response below (redirects and error pages too) that sets no policy of its own.app.use(cache.applyHtml({ private: true }));++ // The home's other shelves: the forum's public places, the roadmap (in progress first), the network's latest++ // activity from Pulse (pastes are shown above it already) and real counts. Read at most once a minute; a shelf++ // that fails or is empty is left out of the page.++ let homeExtras = { at: 0, value: null };++ async function readHomeExtras() {++ if (homeExtras.value && Date.now() - homeExtras.at < 60_000) return homeExtras.value;++ const quiet = (p, fallback) => Promise.resolve().then(() => p).catch(() => fallback);++ const [spaces, roadmap, roadmapCounts, activity, counts] = await Promise.all([++ quiet(require('./forum/store').listSpaces(db, ['public']), []),++ quiet(db.prepare(`SELECT t.title, t.slug, t.status, t.last_activity_at FROM threads t JOIN spaces s ON s.id = t.space_id++ WHERE s.slug = 'roadmap' AND s.visibility = 'public' AND t.deleted_at IS NULL AND t.members_only_owner IS NULL++ ORDER BY (t.status = 'in_progress') DESC, t.last_activity_at DESC, t.id DESC LIMIT 8`).all(), []),++ quiet(db.prepare(`SELECT t.status, COUNT(*) AS n FROM threads t JOIN spaces s ON s.id = t.space_id++ WHERE s.slug = 'roadmap' AND t.deleted_at IS NULL GROUP BY t.status`).all(), []),++ quiet(pulse.list({ limit: 12 }).then((out) => (out.items || []).filter((it) => it.source && it.source.type !== 'paste').slice(0, 6)), []),++ quiet(db.prepare(`SELECT (SELECT COUNT(*) FROM pastes WHERE deleted_at IS NULL AND visibility = 'public') AS pastes,++ (SELECT COUNT(*) FROM pulse_items) AS pulse`).get(), {}),++ ]);++ const value = {++ spaces: spaces.filter((sp) => sp.slug !== 'roadmap' && !sp.members_only_owner),++ roadmap, activity,++ counts: {++ pastes: Number(counts.pastes) || 0, pulse: Number(counts.pulse) || 0,++ shipped: Number((roadmapCounts.find((r) => r.status === 'done') || {}).n) || 0,++ building: Number((roadmapCounts.find((r) => r.status === 'in_progress') || {}).n) || 0,++ },++ };++ homeExtras = { at: Date.now(), value };++ return value;++ }++app.get('/', withUser, wrap(async (req, res) => {-- const [latest, trending, languages] = await Promise.all([catalog.latest(12), catalog.trending(8), catalog.languages()]);-- html(res, pages.homePage({ latest, trending, languages, user: req.user }));++ const [latest, trending, languages, extras] = await Promise.all([catalog.latest(12), catalog.trending(8), catalog.languages(), readHomeExtras()]);++ html(res, pages.homePage({ latest, trending, languages, user: req.user, ...extras }));}));app.get('/updates', (req, res) => html(res, pages.updatesPage()));@@@ -68,7 +68,9 @@ function itemFor(event) {if (!publicIndexable(p)) return 'ignored:not_public';const url = httpsUrl(p.canonical_url);if (!url || !subjectId) return 'ignored:payload';-- const title = k[0] === 'blog' ? `New post on the ${clean(p.blog, 60) || 'OpenVibe'} blog`++ // blog.post.published names its blog as { id, handle } (contracts payload v1); a plain string is still read.++ const blogName = clean(p.blog && typeof p.blog === 'object' ? p.blog.handle : p.blog, 60);++ const title = k[0] === 'blog' ? (blogName ? `New post on the ${blogName} blog` : 'New post on an OpenVibe blog'): k[0] === 'wiki' ? `Wiki: ${clean(p.space, 40)} / ${clean(p.slug, 60)}`: `News: ${clean(p.topic, 60) || 'a new story'}`;const actor = event.actor && event.actor.type === 'user' ? subjectOf(event.actor) : null;@@@ -124,7 +124,65 @@ const TERMINAL = `$ curl -F 'content=<build.log' \\{ "slug": "amber-fox-42","url": "/p/amber-fox-42", … }`;--function homePage({ latest, trending, languages, user }) {++// Questions on the home page, each true of what runs today; the FAQPage JSON-LD repeats exactly these.++const HOME_FAQ = [++ { q: 'Do I need an account?', a: 'No. Anyone can read, and anyone can post a public paste. Sign in with your OpenVibe account to edit or delete your pastes, comment, start forum threads and keep your pastes in one list.' },++ { q: 'What can I paste?', a: 'Code, logs, configs and notes, with highlighting for every common language, a raw link and a download. You can also drop a screenshot: its location data is removed before it is stored.' },++ { q: 'Who can see my paste?', a: 'A public paste is listed and can be found in search. An unlisted one is reachable only by its link, and a paste can burn after it is read once.' },++ { q: 'Can I paste from a script?', a: 'Yes. Send the file to /api/pastes with curl or fetch, as the examples on this page show. No key is needed for a public paste; send your OpenVibe token to keep it under your account.' },++ { q: 'What is the roadmap?', a: 'OpenVibe\'s public plan, one forum thread per item, marked in progress or done. Reply to an item to ask about it or argue for it, and suggest something new in the Feedback forum.' },++ { q: 'What is Pulse?', a: 'One feed of what is happening across the OpenVibe network: who went live, new blog posts, new pastes and forum threads.' },++];++++const STATUS_WORDS = { in_progress: 'In progress', done: 'Done', planned: 'Planned', open: 'Open', paused: 'Paused', declined: 'Declined' };++const SERVICE_WORDS = { community: 'Community', live: 'Live', blog: 'Blog', wiki: 'Wiki', news: 'News', games: 'Games', media: 'Media' };++++/** The numbers under the heading: only real, non-zero counts. */++function homeStats(c = {}) {++ const items = [++ c.pastes ? [num(c.pastes), 'public pastes'] : null,++ c.shipped ? [num(c.shipped), 'roadmap items shipped'] : null,++ c.building ? [num(c.building), 'being built now'] : null,++ c.pulse ? [num(c.pulse), 'updates in Pulse'] : null,++ ].filter(Boolean);++ return items.length ? `<dl class="cm-stats">${items.map(([v, l]) => `<div><dt>${esc(l)}</dt><dd>${esc(v)}</dd></div>`).join('')}</dl>` : '';++}++++function forumShelf(spaces) {++ if (!spaces || !spaces.length) return '';++ return `<section class="section" id="forum" aria-labelledby="h-forum">++ <div class="section-head"><h2 id="h-forum">The forum</h2><a class="more" href="/s">All forums <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>++ <ul class="cm-shelf" role="list">${spaces.slice(0, 8).map((sp) => `<li><a class="cm-tile" href="/s/${esc(sp.slug)}"><strong>${esc(sp.name)}</strong>${sp.description ? `<span>${esc(ld.clean(sp.description, 140))}</span>` : ''}<small>${num(sp.thread_count)} ${Number(sp.thread_count) === 1 ? 'thread' : 'threads'}${sp.last_activity_at ? ` · active ${esc(timeAgo(sp.last_activity_at))}` : ''}</small></a></li>`).join('')}</ul>++</section>`;++}++++function roadmapShelf(items) {++ if (!items || !items.length) return '';++ return `<section class="section" id="roadmap" aria-labelledby="h-roadmap">++ <div class="section-head"><h2 id="h-roadmap">On the roadmap</h2><a class="more" href="/s/roadmap">The whole roadmap <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>++ <ul class="cm-list" role="list">${items.map((t) => `<li><a href="/s/roadmap/t/${esc(t.slug)}">${esc(t.title)}</a> <span class="badge badge-status status-${esc(t.status || 'open')}">${esc(STATUS_WORDS[t.status] || 'Open')}</span>${t.last_activity_at ? ` <span class="muted small">${timeTag(t.last_activity_at)}</span>` : ''}</li>`).join('')}</ul>++</section>`;++}++++function pulseShelf(items) {++ if (!items || !items.length) return '';++ return `<section class="section" id="pulse" aria-labelledby="h-pulse">++ <div class="section-head"><h2 id="h-pulse">Across OpenVibe</h2><a class="more" href="/pulse">All of Pulse <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>++ <ul class="cm-list" role="list">${items.map((it) => {++ const local = it.source.service === 'community';++ return `<li><span class="badge pulse-source">${esc(SERVICE_WORDS[it.source.service] || it.source.service)}</span> <a href="${esc(it.url)}"${local ? '' : ' rel="nofollow ugc noopener"'}>${esc(it.title)}</a>${it.origin === 'ai' ? ' <span class="badge badge-ai" title="Generated by AI">AI</span>' : ''} <span class="muted small">${timeTag(it.occurred_at)}</span></li>`;++ }).join('')}</ul>++</section>`;++}++++function homeFaq() {++ return `<section class="section" id="questions" aria-labelledby="h-faq">++ <div class="section-head"><h2 id="h-faq">Questions</h2></div>++ <div class="cm-faq">${HOME_FAQ.map((f) => `<details><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p></details>`).join('')}</div>++</section>`;++}++++function homePage({ latest, trending, languages, user, spaces = [], roadmap = [], activity = [], counts = {} }) {const body = showcase.hero({eyebrow: 'OpenVibe.Community',title: 'Share it with a link.', accent: 'Talk about it here.',@@@ -136,7 +194,7 @@ function homePage({ latest, trending, languages, user }) {],note: 'Run by the people using it and moderated in the open. Say it, own it.',aside: { html: `<pre class="cm-hero-code" aria-label="Paste a file from a terminal"><code>${esc(TERMINAL)}</code></pre>` },-- }) + `++ }) + homeStats(counts) + `<section class="section" id="latest"><div class="section-head"><h2>Latest pastes</h2><a class="more" href="/pastes">All pastes <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>${cardGrid(latest, 'No pastes yet — be the first.')}@@@ -147,6 +205,9 @@ function homePage({ latest, trending, languages, user }) {<div class="section-head"><h2>Most viewed</h2><a class="more" href="/pastes?sort=views">By views <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>${cardGrid(trending, 'Nothing trending yet.')}</section>++${forumShelf(spaces)}++${roadmapShelf(roadmap)}++${pulseShelf(activity)}` + showcase.features({id: 'spaces',title: 'What lives here',@@@ -172,18 +233,22 @@ const res = await fetch(api, {});const { url } = await res.json(); // "/p/<slug>"` },],-- }) + showcase.cta({++ }) + homeFaq() + showcase.cta({title: 'Start a paste',text: 'Paste it, get a link, share it anywhere. Sign in to keep your pastes together under your OpenVibe account, or post without one.',actions: [{ label: 'New paste', href: '/new', primary: true, icon: 'fa-plus' }, { label: 'Visit the Forum', href: '/s' }],}) + `<section class="section">${frame.shipped({ service: 'community', title: 'Recently shipped on OpenVibe.Community' })}</section>`;return renderPage({-- title: null,-- description: 'The people of OpenVibe. A community-run, open source home for pastes, forums and threads. Free speech within the rules.',++ title: 'OpenVibe.Community: pastes, forums and the OpenVibe roadmap',++ description: 'Paste code, logs or a screenshot and get a short link. Talk it over in the forum, follow the OpenVibe roadmap, and see what is happening in Pulse.',canonicalPath: '/',active: 'home',-- jsonLd: [ld.websiteLd()],++ jsonLd: [++ ld.websiteLd(),++ ...(latest && latest.length ? [{ '@context': 'https://schema.org', '@type': 'ItemList', name: 'Latest pastes', url: `${config.baseUrl}/pastes`, itemListElement: latest.slice(0, 12).map((p, i) => ({ '@type': 'ListItem', position: i + 1, name: p.title || 'Untitled', url: `${config.baseUrl}/p/${p.slug}` })) }] : []),++ { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: HOME_FAQ.map((f) => ({ '@type': 'Question', name: f.q, acceptedAnswer: { '@type': 'Answer', text: f.a } })) },++ ],styles: [showcase.STYLESHEET],body,});@@@ -10,13 +10,14 @@ const { spawn } = require('child_process');const { measure, check, format } = require('openvibe-shared/perf-budget');const BUDGETS = {-- htmlRawKB: 26, // measured 22.0 (fresh database; the showcase home)-- htmlBrotliKB: 6.5, // 5.7++ htmlRawKB: 35, // measured 31.7 on 10-22: the home gained the forum's places, the roadmap, Pulse, real counts and six++ // questions with their FAQPage JSON-LD; was 26 at 22.0++ htmlBrotliKB: 8.1, // 7.3 on 10-22 (the same shelves); was 6.5 at 5.7jsFiles: 7, // 6: openvibe-shared/shell adds web-runtime.js (raised with the shell, 2026-10-04)jsRawKB: 265, // 251.3 (was 208.8 before the shell's web-runtime.js)jsBrotliKB: 64, // 59.9 (was 48.9)cssFiles: 3, // 2-- cssRawKB: 40, // 36.5: community.css + the network's cached showcase.css (raised with the showcase home)++ cssRawKB: 43, // 40.6 on 10-22 (the home shelves' styles in community.css); was 40 at 36.5 with the showcase homecssBrotliKB: 8.5, // 7.7externalFiles: 2, // 1};@@@ -41,7 +41,8 @@ let SECRET, db, app, vipDrops, USR, seq, evt;r = await post(evt({ visibility: 'internal', subject: { type: 'stream', id: '9', revision: 1 } }));assert.strictEqual(r.json.outcome, 'ignored:not_public');r = await post(evt({ event_type: 'blog.post.published', source: 'blog', subject: { type: 'post', id: 'pst_1' }, actor: { type: 'service', id: 'blog' },-- payload: { blog: 'openvibe', canonical_url: 'https://openvibe.blog/@openvibe/hello', publication_state: 'published', visibility: 'public', indexability: { decision: 'index', reasons: [] } } }));++ // The contract's shape (blog.post.published v1): the blog is { id, handle }; the title names the handle.++ payload: { blog: { id: 'blg_01M00000000000000000000000', handle: 'openvibe' }, canonical_url: 'https://openvibe.blog/@openvibe/hello', publication_state: 'published', visibility: 'public', indexability: { decision: 'index', reasons: [] } } }));assert.strictEqual(r.json.outcome, 'pulse:created');assert.strictEqual((await items())[1].title, 'New post on the openvibe blog');assert.strictEqual((await items())[1].origin, 'system', 'no person when a service published it');@@@ -23,7 +23,7 @@ const { boot, check, done } = require('./helpers/app');const r = await t.get('/');assert.strictEqual(r.status, 200);has(r.text, '<html lang="en"');-- has(r.text, '<title>OpenVibe.Community — the people of OpenVibe</title>');++ has(r.text, '<title>OpenVibe.Community: pastes, forums and the OpenVibe roadmap</title>');has(r.text, '<link rel="canonical" href="https://openvibe.community/">');has(r.text, '<meta property="og:type" content="website">');has(r.text, '<meta name="twitter:card" content="summary">');@@@ -53,6 +53,14 @@ const { boot, check, done } = require('./helpers/app');assert.strictEqual(cfg.navbar.loginUrl, '/auth/login?next={path}', 'sign-in returns to the current page (boost moves between pages)');assert.strictEqual(cfg.footer.mount, '#ov-footer');assert.strictEqual(cfg.footer.variant, 'full');++ // The home's other shelves: the forum's places and the roadmap (both seeded at boot), and questions whose++ // FAQPage JSON-LD is exactly what the page shows.++ has(r.text, 'id="h-forum"');++ has(r.text, 'href="/s/general"');++ assert.ok(/<dt>public pastes<\/dt><dd>\d+<\/dd>/.test(r.text), 'the real count of public pastes');++ const faq = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].flatMap((m) => [].concat(JSON.parse(m[1]))).find((x) => x['@type'] === 'FAQPage');++ const shown = [...r.text.matchAll(/<summary>([^<]*)<\/summary>/g)].map((m) => m[1].replace(/'/g, "'"));++ assert.deepStrictEqual(faq.mainEntity.map((q) => q.name), shown, 'every question in the FAQPage is on the page');// Verbiage: never "free"/"no cost".assert.ok(!/\bfree\b(?! speech)/i.test(r.text.replace(/<script[\s\S]*?<\/script>/g, '')), 'no "free" claims outside "free speech"');});@@@ -62,7 +70,7 @@ const { boot, check, done } = require('./helpers/app');assert.ok(r.text.startsWith('<!doctype html>'));const head = r.text.split('</head>')[0];assert.strictEqual((head.match(/<title>/g) || []).length, 1, 'one title');-- has(head, '<title>OpenVibe.Community — the people of OpenVibe</title>');++ has(head, '<title>OpenVibe.Community: pastes, forums and the OpenVibe roadmap</title>');assert.strictEqual((head.match(/<link rel="canonical" href="https:\/\/openvibe\.community\/">/g) || []).length, 1, 'one canonical');has(head, '<meta name="robots" content="index,follow');has(head, '<script type="application/ld+json">');
new file mode 100644
new file mode 100644
@@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
+-- phase: migrate
+-- Pulse's blog items were titled "New post on the [object Object] blog": blog.post.published carries its blog as
+-- { id, handle } and the consumer printed the object (fixed in server/pulse/consumer.js). Each such item gets the
+-- handle from its own canonical URL (https://openvibe.blog/@<handle>/<post>), as the consumer now writes it.
+UPDATE pulse_items
+ SET title = 'New post on the ' || substring(url FROM '^https://[^/]+/@([a-z0-9][a-z0-9_-]{1,39})/') || ' blog'
+ WHERE source_service = 'blog'
+ AND title = 'New post on the [object Object] blog'
+ AND substring(url FROM '^https://[^/]+/@([a-z0-9][a-z0-9_-]{1,39})/') IS NOT NULL;
+UPDATE pulse_items
+ SET title = 'New post on an OpenVibe blog'
+ WHERE source_service = 'blog'
+ AND title = 'New post on the [object Object] blog';
@@ -336,3 +336,22 @@ button.rx:hover { border-color: var(--accent); }
@@ -336,3 +336,22 @@ button.rx:hover { border-color: var(--accent); }
/* A link inside running text is underlined: colour alone did not tell it apart (WCAG 1.4.1). */
/* A link inside running text is underlined: colour alone did not tell it apart (WCAG 1.4.1). */
.lede a, .muted a, p a:not(.btn) { text-decoration: underline; text-underline-offset: 2px; }
.lede a, .muted a, p a:not(.btn) { text-decoration: underline; text-underline-offset: 2px; }
+
+/* Home shelves (server/render/pages.js homePage): real counts, the forum's places, the roadmap, Pulse, questions */
+.cm-stats { display: flex; flex-wrap: wrap; gap: 10px; margin: 4px 0 8px; padding: 0; }
+.cm-stats > div { display: flex; flex-direction: column-reverse; min-width: 0; padding: 8px 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-card); }
+.cm-stats dt { margin: 0; font-size: .8rem; color: var(--text-secondary); }
+.cm-stats dd { margin: 0; font-size: 1.3rem; font-weight: 800; font-variant-numeric: tabular-nums; line-height: 1.2; }
+.cm-shelf { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(min(240px, 100%), 1fr)); }
+.cm-tile { display: grid; gap: 4px; height: 100%; padding: 14px 16px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-card); color: var(--text-primary); }
+.cm-tile:hover, .cm-tile:focus-visible { border-color: var(--accent); text-decoration: none; }
+.cm-tile span { font-size: .9rem; color: var(--text-secondary); }
+.cm-tile small { font-size: .8rem; color: var(--text-secondary); }
+.cm-list { list-style: none; margin: 0; padding: 0; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-card); }
+.cm-list li { padding: 10px 14px; overflow-wrap: anywhere; }
+.cm-list li + li { border-top: 1px solid var(--border); }
+.cm-faq { display: grid; gap: 8px; max-width: 860px; }
+.cm-faq details { border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-card); }
+.cm-faq summary { cursor: pointer; padding: 12px 16px; font-weight: 700; }
+.cm-faq details[open] summary { border-bottom: 1px solid var(--border); }
+.cm-faq p { margin: 0; padding: 12px 16px 14px; color: var(--text-secondary); line-height: 1.6; }
@@ -294,9 +294,40 @@ async function createApp(opts = {}) {
@@ -294,9 +294,40 @@ async function createApp(opts = {}) {
// Every page response below (redirects and error pages too) that sets no policy of its own.
// Every page response below (redirects and error pages too) that sets no policy of its own.
app.use(cache.applyHtml({ private: true }));
app.use(cache.applyHtml({ private: true }));
+ // The home's other shelves: the forum's public places, the roadmap (in progress first), the network's latest
+ // activity from Pulse (pastes are shown above it already) and real counts. Read at most once a minute; a shelf
+ // that fails or is empty is left out of the page.
+ let homeExtras = { at: 0, value: null };
+ async function readHomeExtras() {
+ if (homeExtras.value && Date.now() - homeExtras.at < 60_000) return homeExtras.value;
+ const quiet = (p, fallback) => Promise.resolve().then(() => p).catch(() => fallback);
+ const [spaces, roadmap, roadmapCounts, activity, counts] = await Promise.all([
+ quiet(require('./forum/store').listSpaces(db, ['public']), []),
+ quiet(db.prepare(`SELECT t.title, t.slug, t.status, t.last_activity_at FROM threads t JOIN spaces s ON s.id = t.space_id
+ WHERE s.slug = 'roadmap' AND s.visibility = 'public' AND t.deleted_at IS NULL AND t.members_only_owner IS NULL
+ ORDER BY (t.status = 'in_progress') DESC, t.last_activity_at DESC, t.id DESC LIMIT 8`).all(), []),
+ quiet(db.prepare(`SELECT t.status, COUNT(*) AS n FROM threads t JOIN spaces s ON s.id = t.space_id
+ WHERE s.slug = 'roadmap' AND t.deleted_at IS NULL GROUP BY t.status`).all(), []),
+ quiet(pulse.list({ limit: 12 }).then((out) => (out.items || []).filter((it) => it.source && it.source.type !== 'paste').slice(0, 6)), []),
+ quiet(db.prepare(`SELECT (SELECT COUNT(*) FROM pastes WHERE deleted_at IS NULL AND visibility = 'public') AS pastes,
+ (SELECT COUNT(*) FROM pulse_items) AS pulse`).get(), {}),
+ ]);
+ const value = {
+ spaces: spaces.filter((sp) => sp.slug !== 'roadmap' && !sp.members_only_owner),
+ roadmap, activity,
+ counts: {
+ pastes: Number(counts.pastes) || 0, pulse: Number(counts.pulse) || 0,
+ shipped: Number((roadmapCounts.find((r) => r.status === 'done') || {}).n) || 0,
+ building: Number((roadmapCounts.find((r) => r.status === 'in_progress') || {}).n) || 0,
+ },
+ };
+ homeExtras = { at: Date.now(), value };
+ return value;
+ }
+
app.get('/', withUser, wrap(async (req, res) => {
app.get('/', withUser, wrap(async (req, res) => {
- const [latest, trending, languages] = await Promise.all([catalog.latest(12), catalog.trending(8), catalog.languages()]);
- html(res, pages.homePage({ latest, trending, languages, user: req.user }));
+ const [latest, trending, languages, extras] = await Promise.all([catalog.latest(12), catalog.trending(8), catalog.languages(), readHomeExtras()]);
+ html(res, pages.homePage({ latest, trending, languages, user: req.user, ...extras }));
}));
}));
app.get('/updates', (req, res) => html(res, pages.updatesPage()));
app.get('/updates', (req, res) => html(res, pages.updatesPage()));
@@ -68,7 +68,9 @@ function itemFor(event) {
@@ -68,7 +68,9 @@ function itemFor(event) {
if (!publicIndexable(p)) return 'ignored:not_public';
if (!publicIndexable(p)) return 'ignored:not_public';
const url = httpsUrl(p.canonical_url);
const url = httpsUrl(p.canonical_url);
if (!url || !subjectId) return 'ignored:payload';
if (!url || !subjectId) return 'ignored:payload';
- const title = k[0] === 'blog' ? `New post on the ${clean(p.blog, 60) || 'OpenVibe'} blog`
+ // blog.post.published names its blog as { id, handle } (contracts payload v1); a plain string is still read.
+ const blogName = clean(p.blog && typeof p.blog === 'object' ? p.blog.handle : p.blog, 60);
+ const title = k[0] === 'blog' ? (blogName ? `New post on the ${blogName} blog` : 'New post on an OpenVibe blog')
: k[0] === 'wiki' ? `Wiki: ${clean(p.space, 40)} / ${clean(p.slug, 60)}`
: k[0] === 'wiki' ? `Wiki: ${clean(p.space, 40)} / ${clean(p.slug, 60)}`
: `News: ${clean(p.topic, 60) || 'a new story'}`;
: `News: ${clean(p.topic, 60) || 'a new story'}`;
const actor = event.actor && event.actor.type === 'user' ? subjectOf(event.actor) : null;
const actor = event.actor && event.actor.type === 'user' ? subjectOf(event.actor) : null;
@@ -124,7 +124,65 @@ const TERMINAL = `$ curl -F 'content=<build.log' \\
@@ -124,7 +124,65 @@ const TERMINAL = `$ curl -F 'content=<build.log' \\
{ "slug": "amber-fox-42",
{ "slug": "amber-fox-42",
"url": "/p/amber-fox-42", … }`;
"url": "/p/amber-fox-42", … }`;
-function homePage({ latest, trending, languages, user }) {
+// Questions on the home page, each true of what runs today; the FAQPage JSON-LD repeats exactly these.
+const HOME_FAQ = [
+ { q: 'Do I need an account?', a: 'No. Anyone can read, and anyone can post a public paste. Sign in with your OpenVibe account to edit or delete your pastes, comment, start forum threads and keep your pastes in one list.' },
+ { q: 'What can I paste?', a: 'Code, logs, configs and notes, with highlighting for every common language, a raw link and a download. You can also drop a screenshot: its location data is removed before it is stored.' },
+ { q: 'Who can see my paste?', a: 'A public paste is listed and can be found in search. An unlisted one is reachable only by its link, and a paste can burn after it is read once.' },
+ { q: 'Can I paste from a script?', a: 'Yes. Send the file to /api/pastes with curl or fetch, as the examples on this page show. No key is needed for a public paste; send your OpenVibe token to keep it under your account.' },
+ { q: 'What is the roadmap?', a: 'OpenVibe\'s public plan, one forum thread per item, marked in progress or done. Reply to an item to ask about it or argue for it, and suggest something new in the Feedback forum.' },
+ { q: 'What is Pulse?', a: 'One feed of what is happening across the OpenVibe network: who went live, new blog posts, new pastes and forum threads.' },
+];
+
+const STATUS_WORDS = { in_progress: 'In progress', done: 'Done', planned: 'Planned', open: 'Open', paused: 'Paused', declined: 'Declined' };
+const SERVICE_WORDS = { community: 'Community', live: 'Live', blog: 'Blog', wiki: 'Wiki', news: 'News', games: 'Games', media: 'Media' };
+
+/** The numbers under the heading: only real, non-zero counts. */
+function homeStats(c = {}) {
+ const items = [
+ c.pastes ? [num(c.pastes), 'public pastes'] : null,
+ c.shipped ? [num(c.shipped), 'roadmap items shipped'] : null,
+ c.building ? [num(c.building), 'being built now'] : null,
+ c.pulse ? [num(c.pulse), 'updates in Pulse'] : null,
+ ].filter(Boolean);
+ return items.length ? `<dl class="cm-stats">${items.map(([v, l]) => `<div><dt>${esc(l)}</dt><dd>${esc(v)}</dd></div>`).join('')}</dl>` : '';
+}
+
+function forumShelf(spaces) {
+ if (!spaces || !spaces.length) return '';
+ return `<section class="section" id="forum" aria-labelledby="h-forum">
+ <div class="section-head"><h2 id="h-forum">The forum</h2><a class="more" href="/s">All forums <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>
+ <ul class="cm-shelf" role="list">${spaces.slice(0, 8).map((sp) => `<li><a class="cm-tile" href="/s/${esc(sp.slug)}"><strong>${esc(sp.name)}</strong>${sp.description ? `<span>${esc(ld.clean(sp.description, 140))}</span>` : ''}<small>${num(sp.thread_count)} ${Number(sp.thread_count) === 1 ? 'thread' : 'threads'}${sp.last_activity_at ? ` · active ${esc(timeAgo(sp.last_activity_at))}` : ''}</small></a></li>`).join('')}</ul>
+</section>`;
+}
+
+function roadmapShelf(items) {
+ if (!items || !items.length) return '';
+ return `<section class="section" id="roadmap" aria-labelledby="h-roadmap">
+ <div class="section-head"><h2 id="h-roadmap">On the roadmap</h2><a class="more" href="/s/roadmap">The whole roadmap <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>
+ <ul class="cm-list" role="list">${items.map((t) => `<li><a href="/s/roadmap/t/${esc(t.slug)}">${esc(t.title)}</a> <span class="badge badge-status status-${esc(t.status || 'open')}">${esc(STATUS_WORDS[t.status] || 'Open')}</span>${t.last_activity_at ? ` <span class="muted small">${timeTag(t.last_activity_at)}</span>` : ''}</li>`).join('')}</ul>
+</section>`;
+}
+
+function pulseShelf(items) {
+ if (!items || !items.length) return '';
+ return `<section class="section" id="pulse" aria-labelledby="h-pulse">
+ <div class="section-head"><h2 id="h-pulse">Across OpenVibe</h2><a class="more" href="/pulse">All of Pulse <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>
+ <ul class="cm-list" role="list">${items.map((it) => {
+ const local = it.source.service === 'community';
+ return `<li><span class="badge pulse-source">${esc(SERVICE_WORDS[it.source.service] || it.source.service)}</span> <a href="${esc(it.url)}"${local ? '' : ' rel="nofollow ugc noopener"'}>${esc(it.title)}</a>${it.origin === 'ai' ? ' <span class="badge badge-ai" title="Generated by AI">AI</span>' : ''} <span class="muted small">${timeTag(it.occurred_at)}</span></li>`;
+ }).join('')}</ul>
+</section>`;
+}
+
+function homeFaq() {
+ return `<section class="section" id="questions" aria-labelledby="h-faq">
+ <div class="section-head"><h2 id="h-faq">Questions</h2></div>
+ <div class="cm-faq">${HOME_FAQ.map((f) => `<details><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p></details>`).join('')}</div>
+</section>`;
+}
+
+function homePage({ latest, trending, languages, user, spaces = [], roadmap = [], activity = [], counts = {} }) {
const body = showcase.hero({
const body = showcase.hero({
eyebrow: 'OpenVibe.Community',
eyebrow: 'OpenVibe.Community',
title: 'Share it with a link.', accent: 'Talk about it here.',
title: 'Share it with a link.', accent: 'Talk about it here.',
@@ -136,7 +194,7 @@ function homePage({ latest, trending, languages, user }) {
@@ -136,7 +194,7 @@ function homePage({ latest, trending, languages, user }) {
],
],
note: 'Run by the people using it and moderated in the open. Say it, own it.',
note: 'Run by the people using it and moderated in the open. Say it, own it.',
aside: { html: `<pre class="cm-hero-code" aria-label="Paste a file from a terminal"><code>${esc(TERMINAL)}</code></pre>` },
aside: { html: `<pre class="cm-hero-code" aria-label="Paste a file from a terminal"><code>${esc(TERMINAL)}</code></pre>` },
- }) + `
+ }) + homeStats(counts) + `
<section class="section" id="latest">
<section class="section" id="latest">
<div class="section-head"><h2>Latest pastes</h2><a class="more" href="/pastes">All pastes <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>
<div class="section-head"><h2>Latest pastes</h2><a class="more" href="/pastes">All pastes <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>
${cardGrid(latest, 'No pastes yet — be the first.')}
${cardGrid(latest, 'No pastes yet — be the first.')}
@@ -147,6 +205,9 @@ function homePage({ latest, trending, languages, user }) {
@@ -147,6 +205,9 @@ function homePage({ latest, trending, languages, user }) {
<div class="section-head"><h2>Most viewed</h2><a class="more" href="/pastes?sort=views">By views <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>
<div class="section-head"><h2>Most viewed</h2><a class="more" href="/pastes?sort=views">By views <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>
${cardGrid(trending, 'Nothing trending yet.')}
${cardGrid(trending, 'Nothing trending yet.')}
</section>
</section>
+${forumShelf(spaces)}
+${roadmapShelf(roadmap)}
+${pulseShelf(activity)}
` + showcase.features({
` + showcase.features({
id: 'spaces',
id: 'spaces',
title: 'What lives here',
title: 'What lives here',
@@ -172,18 +233,22 @@ const res = await fetch(api, {
@@ -172,18 +233,22 @@ const res = await fetch(api, {
});
});
const { url } = await res.json(); // "/p/<slug>"` },
const { url } = await res.json(); // "/p/<slug>"` },
],
],
- }) + showcase.cta({
+ }) + homeFaq() + showcase.cta({
title: 'Start a paste',
title: 'Start a paste',
text: 'Paste it, get a link, share it anywhere. Sign in to keep your pastes together under your OpenVibe account, or post without one.',
text: 'Paste it, get a link, share it anywhere. Sign in to keep your pastes together under your OpenVibe account, or post without one.',
actions: [{ label: 'New paste', href: '/new', primary: true, icon: 'fa-plus' }, { label: 'Visit the Forum', href: '/s' }],
actions: [{ label: 'New paste', href: '/new', primary: true, icon: 'fa-plus' }, { label: 'Visit the Forum', href: '/s' }],
}) + `
}) + `
<section class="section">${frame.shipped({ service: 'community', title: 'Recently shipped on OpenVibe.Community' })}</section>`;
<section class="section">${frame.shipped({ service: 'community', title: 'Recently shipped on OpenVibe.Community' })}</section>`;
return renderPage({
return renderPage({
- title: null,
- description: 'The people of OpenVibe. A community-run, open source home for pastes, forums and threads. Free speech within the rules.',
+ title: 'OpenVibe.Community: pastes, forums and the OpenVibe roadmap',
+ description: 'Paste code, logs or a screenshot and get a short link. Talk it over in the forum, follow the OpenVibe roadmap, and see what is happening in Pulse.',
canonicalPath: '/',
canonicalPath: '/',
active: 'home',
active: 'home',
- jsonLd: [ld.websiteLd()],
+ jsonLd: [
+ ld.websiteLd(),
+ ...(latest && latest.length ? [{ '@context': 'https://schema.org', '@type': 'ItemList', name: 'Latest pastes', url: `${config.baseUrl}/pastes`, itemListElement: latest.slice(0, 12).map((p, i) => ({ '@type': 'ListItem', position: i + 1, name: p.title || 'Untitled', url: `${config.baseUrl}/p/${p.slug}` })) }] : []),
+ { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: HOME_FAQ.map((f) => ({ '@type': 'Question', name: f.q, acceptedAnswer: { '@type': 'Answer', text: f.a } })) },
+ ],
styles: [showcase.STYLESHEET],
styles: [showcase.STYLESHEET],
body,
body,
});
});
@@ -10,13 +10,14 @@ const { spawn } = require('child_process');
@@ -10,13 +10,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: 26, // measured 22.0 (fresh database; the showcase home)
- htmlBrotliKB: 6.5, // 5.7
+ htmlRawKB: 35, // measured 31.7 on 10-22: the home gained the forum's places, the roadmap, Pulse, real counts and six
+ // questions with their FAQPage JSON-LD; was 26 at 22.0
+ htmlBrotliKB: 8.1, // 7.3 on 10-22 (the same shelves); was 6.5 at 5.7
jsFiles: 7, // 6: openvibe-shared/shell adds web-runtime.js (raised with the shell, 2026-10-04)
jsFiles: 7, // 6: openvibe-shared/shell adds web-runtime.js (raised with the shell, 2026-10-04)
jsRawKB: 265, // 251.3 (was 208.8 before the shell's web-runtime.js)
jsRawKB: 265, // 251.3 (was 208.8 before the shell's web-runtime.js)
jsBrotliKB: 64, // 59.9 (was 48.9)
jsBrotliKB: 64, // 59.9 (was 48.9)
cssFiles: 3, // 2
cssFiles: 3, // 2
- cssRawKB: 40, // 36.5: community.css + the network's cached showcase.css (raised with the showcase home)
+ cssRawKB: 43, // 40.6 on 10-22 (the home shelves' styles in community.css); was 40 at 36.5 with the showcase home
cssBrotliKB: 8.5, // 7.7
cssBrotliKB: 8.5, // 7.7
externalFiles: 2, // 1
externalFiles: 2, // 1
};
};
@@ -41,7 +41,8 @@ let SECRET, db, app, vipDrops, USR, seq, evt;
@@ -41,7 +41,8 @@ let SECRET, db, app, vipDrops, USR, seq, evt;
r = await post(evt({ visibility: 'internal', subject: { type: 'stream', id: '9', revision: 1 } }));
r = await post(evt({ visibility: 'internal', subject: { type: 'stream', id: '9', revision: 1 } }));
assert.strictEqual(r.json.outcome, 'ignored:not_public');
assert.strictEqual(r.json.outcome, 'ignored:not_public');
r = await post(evt({ event_type: 'blog.post.published', source: 'blog', subject: { type: 'post', id: 'pst_1' }, actor: { type: 'service', id: 'blog' },
r = await post(evt({ event_type: 'blog.post.published', source: 'blog', subject: { type: 'post', id: 'pst_1' }, actor: { type: 'service', id: 'blog' },
- payload: { blog: 'openvibe', canonical_url: 'https://openvibe.blog/@openvibe/hello', publication_state: 'published', visibility: 'public', indexability: { decision: 'index', reasons: [] } } }));
+ // The contract's shape (blog.post.published v1): the blog is { id, handle }; the title names the handle.
+ payload: { blog: { id: 'blg_01M00000000000000000000000', handle: 'openvibe' }, canonical_url: 'https://openvibe.blog/@openvibe/hello', publication_state: 'published', visibility: 'public', indexability: { decision: 'index', reasons: [] } } }));
assert.strictEqual(r.json.outcome, 'pulse:created');
assert.strictEqual(r.json.outcome, 'pulse:created');
assert.strictEqual((await items())[1].title, 'New post on the openvibe blog');
assert.strictEqual((await items())[1].title, 'New post on the openvibe blog');
assert.strictEqual((await items())[1].origin, 'system', 'no person when a service published it');
assert.strictEqual((await items())[1].origin, 'system', 'no person when a service published it');
@@ -23,7 +23,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -23,7 +23,7 @@ const { boot, check, done } = require('./helpers/app');
const r = await t.get('/');
const r = await t.get('/');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
has(r.text, '<html lang="en"');
has(r.text, '<html lang="en"');
- has(r.text, '<title>OpenVibe.Community — the people of OpenVibe</title>');
+ has(r.text, '<title>OpenVibe.Community: pastes, forums and the OpenVibe roadmap</title>');
has(r.text, '<link rel="canonical" href="https://openvibe.community/">');
has(r.text, '<link rel="canonical" href="https://openvibe.community/">');
has(r.text, '<meta property="og:type" content="website">');
has(r.text, '<meta property="og:type" content="website">');
has(r.text, '<meta name="twitter:card" content="summary">');
has(r.text, '<meta name="twitter:card" content="summary">');
@@ -53,6 +53,14 @@ const { boot, check, done } = require('./helpers/app');
@@ -53,6 +53,14 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(cfg.navbar.loginUrl, '/auth/login?next={path}', 'sign-in returns to the current page (boost moves between pages)');
assert.strictEqual(cfg.navbar.loginUrl, '/auth/login?next={path}', 'sign-in returns to the current page (boost moves between pages)');
assert.strictEqual(cfg.footer.mount, '#ov-footer');
assert.strictEqual(cfg.footer.mount, '#ov-footer');
assert.strictEqual(cfg.footer.variant, 'full');
assert.strictEqual(cfg.footer.variant, 'full');
+ // The home's other shelves: the forum's places and the roadmap (both seeded at boot), and questions whose
+ // FAQPage JSON-LD is exactly what the page shows.
+ has(r.text, 'id="h-forum"');
+ has(r.text, 'href="/s/general"');
+ assert.ok(/<dt>public pastes<\/dt><dd>\d+<\/dd>/.test(r.text), 'the real count of public pastes');
+ const faq = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].flatMap((m) => [].concat(JSON.parse(m[1]))).find((x) => x['@type'] === 'FAQPage');
+ const shown = [...r.text.matchAll(/<summary>([^<]*)<\/summary>/g)].map((m) => m[1].replace(/'/g, "'"));
+ assert.deepStrictEqual(faq.mainEntity.map((q) => q.name), shown, 'every question in the FAQPage is on the page');
// Verbiage: never "free"/"no cost".
// Verbiage: never "free"/"no cost".
assert.ok(!/\bfree\b(?! speech)/i.test(r.text.replace(/<script[\s\S]*?<\/script>/g, '')), 'no "free" claims outside "free speech"');
assert.ok(!/\bfree\b(?! speech)/i.test(r.text.replace(/<script[\s\S]*?<\/script>/g, '')), 'no "free" claims outside "free speech"');
});
});
@@ -62,7 +70,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -62,7 +70,7 @@ const { boot, check, done } = require('./helpers/app');
assert.ok(r.text.startsWith('<!doctype html>'));
assert.ok(r.text.startsWith('<!doctype html>'));
const head = r.text.split('</head>')[0];
const head = r.text.split('</head>')[0];
assert.strictEqual((head.match(/<title>/g) || []).length, 1, 'one title');
assert.strictEqual((head.match(/<title>/g) || []).length, 1, 'one title');
- has(head, '<title>OpenVibe.Community — the people of OpenVibe</title>');
+ has(head, '<title>OpenVibe.Community: pastes, forums and the OpenVibe roadmap</title>');
assert.strictEqual((head.match(/<link rel="canonical" href="https:\/\/openvibe\.community\/">/g) || []).length, 1, 'one canonical');
assert.strictEqual((head.match(/<link rel="canonical" href="https:\/\/openvibe\.community\/">/g) || []).length, 1, 'one canonical');
has(head, '<meta name="robots" content="index,follow');
has(head, '<meta name="robots" content="index,follow');
has(head, '<script type="application/ld+json">');
has(head, '<script type="application/ld+json">');