openvibers/openvibe.community Commits

Pastes browse: Show pastes, images or all (?type=), carried through paging (lang=image still means images); the home page's 'What is coming' roadmap block is replaced by what is live (spaces, threads, Pulse); /llms.txt says what Community is and how AI pastes are labelled

OP OpenVibers committed 24 Sept 2026 · 985da86d6c · 5 files, +47 −17


Changes

ddiff --git a/server/app.js b/server/app.js
iindex a74f793..9115ae8 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -192,6 +192,7 @@ function createApp(opts = {}) {
// ── Machine endpoints ────────────────────────────────────
app.get('/robots.txt', (_req, res) => res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(seo.robotsTxt()));
++ app.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(seo.llmsTxt()));
app.get('/sitemap.xml', seo.sitemapHandler);
app.get('/feed.xml', wrap(seo.feedHandler));
ddiff --git a/server/pastes/catalog.js b/server/pastes/catalog.js
iindex f8547f8..e7b440a 100644
---- a/server/pastes/catalog.js
++++ b/server/pastes/catalog.js
@@@ -66,8 +66,11 @@ function normalizeBrowseQuery(query = {}) {
const sort = query.sort === 'views' ? 'views' : 'new';
const lang = String(query.lang || '').trim().toLowerCase().replace(/[^a-z0-9+#.-]/g, '').slice(0, 32);
const page = Math.max(1, Math.min(parseInt(query.page, 10) || 1, 500));
-- return { q, sort, lang, page };
++ // What to list: text pastes (default), images (screenshots) or all of them.
++ const type = ['all', 'pastes', 'images'].includes(query.type) ? query.type : (lang === 'image' ? 'images' : 'pastes');
++ return { q, sort, lang: type === 'images' && lang === 'image' ? '' : lang, page, type };
}
++const _ofType = (type) => (p) => (type === 'all' ? true : type === 'images' ? p.type === 'screenshot' : p.type !== 'screenshot');
/**
* Browse: { q, sort: 'new'|'views', lang, page } → { pastes, total, page, pages, perPage, ... }.
@@@ -75,11 +78,11 @@ function normalizeBrowseQuery(query = {}) {
* sort and the language filter are answered from the recent window.
*/
async function browse(query = {}, ctx = {}) {
-- const { q, sort, lang, page } = normalizeBrowseQuery(query);
++ const { q, sort, lang, page, type } = normalizeBrowseQuery(query);
const offset = (page - 1) * PER_PAGE;
let pastes, total, windowed = false;
-- if (sort === 'new' && !lang) {
++ if (sort === 'new' && !lang && type === 'pastes') {
const out = await source.listPastes({ limit: PER_PAGE, offset, search: q || undefined, type: 'paste' }, ctx);
pastes = _textOnlyPublic(out && out.pastes);
total = Number(out && out.total) || pastes.length;
@@@ -91,14 +94,15 @@ async function browse(query = {}, ctx = {}) {
} else {
rows = await recent();
}
-- if (lang) rows = rows.filter((p) => (lang === 'image' ? p.type === 'screenshot' : (p.language || 'text') === lang));
++ rows = rows.filter(_ofType(type));
++ if (lang) rows = rows.filter((p) => (p.language || 'text') === lang);
rows = rows.slice().sort(sort === 'views' ? byViews : byNewest);
total = rows.length;
pastes = rows.slice(offset, offset + PER_PAGE);
windowed = true;
}
const pages = Math.max(1, Math.ceil(total / PER_PAGE));
-- return { pastes, total, page, pages, perPage: PER_PAGE, q, sort, lang, windowed, windowSize: WINDOW };
++ return { pastes, total, page, pages, perPage: PER_PAGE, q, sort, lang, type, windowed, windowSize: WINDOW };
}
/** For tests and a hot reload after a write. */
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex 192a83a..2d65b67 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -60,6 +60,7 @@ function pagerHref(state, page) {
if (state.q) q.set('q', state.q);
if (state.sort && state.sort !== 'new') q.set('sort', state.sort);
if (state.lang) q.set('lang', state.lang);
++ if (state.type && state.type !== 'pastes') q.set('type', state.type);
if (page > 1) q.set('page', String(page));
const s = q.toString();
return `/pastes${s ? `?${s}` : ''}`;
@@@ -127,14 +128,13 @@ function homePage({ latest, trending, languages, user }) {
</div>
</section>
--<section class="section coming" id="coming">
-- <h2>What is coming</h2>
++<section class="section coming" id="spaces">
++ <h2>Spaces and threads</h2>
<div class="coming-grid">
-- <div class="coming-item"><i class="fa-solid fa-layer-group" aria-hidden="true"></i><h3><a href="/s">Spaces</a></h3><p>Open now: General, Feedback and Showcase. Places for the communities around each streamer, game and project come next — run by the people in them.</p></div>
-- <div class="coming-item"><i class="fa-solid fa-comments" aria-hidden="true"></i><h3><a href="/s/general">Threads</a></h3><p>Long-form discussion that outlives a chat scrollback, readable without JavaScript. Forums, the way they should have stayed.</p></div>
-- <div class="coming-item"><i class="fa-solid fa-inbox" aria-hidden="true"></i><h3>Submissions</h3><p>Send in clips, art, ideas and reports; the community reviews and features the best of it.</p></div>
++ <div class="coming-item"><i class="fa-solid fa-layer-group" aria-hidden="true"></i><h3><a href="/s">Spaces</a></h3><p>General, Feedback and Showcase: places for the community to talk, share and get help.</p></div>
++ <div class="coming-item"><i class="fa-solid fa-comments" aria-hidden="true"></i><h3><a href="/s/general">Threads</a></h3><p>Long-form discussion that outlives a chat scrollback: start one in any open space.</p></div>
++ <div class="coming-item"><i class="fa-solid fa-wave-square" aria-hidden="true"></i><h3><a href="/pulse">Pulse</a></h3><p>What is happening across the OpenVibe network, in one feed.</p></div>
</div>
-- <p class="muted">Pastes, spaces, threads and the <a href="/pulse">Pulse</a> of the network are live today. The rest is being built in the open — follow along on <a href="https://github.com/OpenVibers" rel="noopener">GitHub</a> and <a href="https://discord.gg/M6MuRUaeJj" rel="noopener">Discord</a>.</p>
</section>`;
return renderPage({
title: null,
@@@ -148,21 +148,21 @@ function homePage({ latest, trending, languages, user }) {
// ── Browse ───────────────────────────────────────────────────
function browsePage(result, languages) {
-- const { pastes, total, page, pages, q, sort, lang, windowed, windowSize } = result;
++ const { pastes, total, page, pages, q, sort, lang, type = 'pastes', windowed, windowSize } = result;
const titleBits = [];
if (q) titleBits.push(`“${q}”`);
if (lang) titleBits.push(languageLabel(lang));
titleBits.push(sort === 'views' ? 'most viewed' : 'newest');
const title = `Pastes — ${titleBits.join(', ')}${page > 1 ? ` (page ${page})` : ''}`;
-- const canonical = pagerHref({ q, sort, lang }, page);
++ const canonical = pagerHref({ q, sort, lang, type }, page);
const opt = (id, label) => `<option value="${esc(id)}"${id === (lang || '') ? ' selected' : ''}>${esc(label)}</option>`;
const langOptions = [opt('', 'All languages')].concat((languages || []).map((l) => opt(l.language, `${languageLabel(l.language)} (${l.count})`)));
if (lang && !(languages || []).some((l) => l.language === lang)) langOptions.push(opt(lang, languageLabel(lang)));
const pager = pages > 1 ? `<nav class="pager" aria-label="Pages">
-- ${page > 1 ? `<a rel="prev" href="${esc(pagerHref({ q, sort, lang }, page - 1))}"><i class="fa-solid fa-chevron-left" aria-hidden="true"></i> Newer</a>` : '<span></span>'}
++ ${page > 1 ? `<a rel="prev" href="${esc(pagerHref({ q, sort, lang, type }, page - 1))}"><i class="fa-solid fa-chevron-left" aria-hidden="true"></i> Newer</a>` : '<span></span>'}
<span class="pager-info">Page ${page} of ${pages}</span>
-- ${page < pages ? `<a rel="next" href="${esc(pagerHref({ q, sort, lang }, page + 1))}">Older <i class="fa-solid fa-chevron-right" aria-hidden="true"></i></a>` : '<span></span>'}
++ ${page < pages ? `<a rel="next" href="${esc(pagerHref({ q, sort, lang, type }, page + 1))}">Older <i class="fa-solid fa-chevron-right" aria-hidden="true"></i></a>` : '<span></span>'}
</nav>` : '';
const body = `
@@@ -172,6 +172,7 @@ function browsePage(result, languages) {
</header>
<form class="filters" method="get" action="/pastes" role="search" data-browse>
<label class="search"><i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i><input type="search" name="q" value="${esc(q)}" placeholder="Search titles and content" aria-label="Search pastes"></label>
++ <label>Show <select name="type"><option value="pastes"${type === 'pastes' ? ' selected' : ''}>Pastes</option><option value="images"${type === 'images' ? ' selected' : ''}>Images</option><option value="all"${type === 'all' ? ' selected' : ''}>All</option></select></label>
<label>Sort <select name="sort"><option value="new"${sort === 'new' ? ' selected' : ''}>Newest</option><option value="views"${sort === 'views' ? ' selected' : ''}>Most viewed</option></select></label>
<label>Language <select name="lang">${langOptions.join('')}</select></label>
<button class="btn" type="submit">Apply</button>
ddiff --git a/server/seo.js b/server/seo.js
iindex adaf116..b9a6901 100644
---- a/server/seo.js
++++ b/server/seo.js
@@@ -118,6 +118,30 @@ function threadLd({ space, thread, posts, opening, description }) {
};
}
++// ── llms.txt: what this site is, for language-model crawlers (llmstxt.org) ────────────
++function llmsTxt() {
++ const u = config.baseUrl;
++ return [
++ '# OpenVibe.Community',
++ '',
++ '> The discussion side of the OpenVibe network: pastes (text and screenshots), spaces with threads, and the Pulse feed of what is happening across the network.',
++ '',
++ '## Browse',
++ `- [Pastes](${u}/pastes): public pastes, newest first (?type=pastes|images|all, ?sort=views, ?lang=<language>)`,
++ `- [Spaces](${u}/s): open discussion spaces and their threads`,
++ `- [Pulse](${u}/pulse): network activity`,
++ '',
++ '## Machine-readable',
++ `- [Sitemap](${u}/sitemap.xml)`,
++ `- [Latest pastes (RSS)](${u}/feed.xml)`,
++ `- [Latest threads (RSS)](${u}/s/feed.xml)`,
++ '',
++ '## Content labels',
++ '- Pastes made by the OpenVibe AI (moments caught from live streams) carry origin "ai" and are labelled as AI-generated; everything else was written by people.',
++ '',
++ ].join('\n');
++}
++
// ── robots.txt ───────────────────────────────────────────────
function robotsTxt() {
return [
@@@ -226,4 +250,4 @@ ${items.join('\n')}
function resetCaches() { _sitemap = null; _sitemapAt = 0; }
--module.exports = { isoDate, clean, websiteLd, breadcrumbLd, pasteLd, threadLd, discussionAuthorLd, robotsTxt, buildSitemap, sitemapHandler, feedHandler, threadFeed, useForum, resetCaches };
++module.exports = { llmsTxt, isoDate, clean, websiteLd, breadcrumbLd, pasteLd, threadLd, discussionAuthorLd, robotsTxt, buildSitemap, sitemapHandler, feedHandler, threadFeed, useForum, resetCaches };
ddiff --git a/test/ssr.test.js b/test/ssr.test.js
iindex e3b6061..bfad78f 100644
---- a/test/ssr.test.js
++++ b/test/ssr.test.js
@@@ -22,7 +22,7 @@ const { boot, check, done } = require('./helpers/app');
has(r.text, '<h1>Share it with a link. Talk about it here.</h1>');
has(r.text, '<h2>Latest pastes</h2>');
has(r.text, '<h2>Most viewed</h2>');
-- has(r.text, 'id="coming"');
++ has(r.text, 'id="spaces"');
has(r.text, 'href="/p/amber-fox-42"');
// Most-viewed puts the 900-view nginx paste first.
const trendingAt = r.text.indexOf('id="trending"');