openvibers/openvibe.search Commits

search.openvibe.network's front page is a discovery page: example searches, the index in numbers, shelves of the newest streams, clips, posts, jobs, plans, coupons and more, every source with what it holds, the API and the questions people ask (#77)

OP OpenVibers committed 22 days ago · a20b8111a5 · 7 files, +309 −22

Changes

ddiff --git a/README.md b/README.md
iindex e12f0b0..1736c04 100644
---- a/README.md
++++ b/README.md
@@@ -86,6 +86,16 @@ The search page at `/` runs the same query as HTML for browsers (no JavaScript;
pages `noindex`; anonymous unless this host has an `ov_token` cookie). Other clients get a text
route index at `/`.
++The plain front page is the network's search front page (`server/web/page.js`, `server/web/front-data.js`):
++example searches, the index in numbers (public listed documents, sites sending, kinds, added or updated
++this week), shelves of the newest public documents of each kind (streams, clips, blog posts, jobs,
++hosting plans, coupons, forum threads, help articles, places, rental cities, quests, tools; read with
++the same searcher and anonymous viewer as `GET /api/v1/search?owner=&type=`, so nothing private or draft
++can appear; community pastes, whose titles an AI writes from a picture, and placeholder titles are not
++shelved), the sources with what each holds, the API samples and the questions (JSON-LD WebSite with its
++SearchAction, WebApplication, ItemList of the sources and FAQPage). It is read once per five minutes;
++an empty shelf is left out, and without the numbers the sources fall back to the static list.
++
### Ranking
Full-text results are ordered by full-text relevance (title ×10, summary ×4, body ×1) multiplied by a
ddiff --git a/server/app.js b/server/app.js
iindex 0f244f7..0b4eac9 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -8,6 +8,7 @@ const { createRelease } = require('openvibe-shared/release');
const { createSearchReadiness, registerSearchGauges } = require('./observability');
const { documentsRouter } = require('./api/documents');
const { queryRouter, createSearcher } = require('./api/query');
++const { createFrontData } = require('./web/front-data');
const { savedRouter } = require('./api/saved');
const { pageRouter } = require('./web/page');
const { webhookRouter } = require('./api/webhook');
@@@ -74,7 +75,9 @@ function createApp({ config, db, store, engine, auth, outbox, purges, purger, sa
app.use(savedRouter({ config, saved, searcher, auth, limits }));
// GET / (HTML search page for browsers, the text route index otherwise), /robots.txt, /llms.txt and
// /sitemap.xml (whose lastmods come from the index and the deployed release, never from the clock).
-- app.use(pageRouter({ searcher, auth, baseUrl: config.baseUrl, newestPublic: () => store.newestPublic(), releasedAt: release.full().released_at, owners: config.events.owners }));
++ // The front page's numbers and shelves (server/web/front-data.js): read as an anonymous visitor, kept five minutes.
++ const frontData = createFrontData({ store, searcher, log });
++ app.use(pageRouter({ searcher, auth, baseUrl: config.baseUrl, newestPublic: () => store.newestPublic(), releasedAt: release.full().released_at, owners: config.events.owners, frontData }));
app.use((req, res) => http.sendProblem(res, 404, 'search.not_found', { detail: `no route ${req.method} ${req.path}`, ctx: req.ov }));
ddiff --git a/server/store.js b/server/store.js
iindex 4da86d7..44695e6 100644
---- a/server/store.js
++++ b/server/store.js
@@@ -39,6 +39,8 @@ function createStore({ db, engine, outbox, purges = null, now = () => Date.now()
SUM(CASE WHEN exposure = 2 THEN 1 ELSE 0 END) AS public_unlisted,
SUM(CASE WHEN exposure = 1 THEN 1 ELSE 0 END) AS restricted FROM documents`),
newestPublic: db.prepare(`SELECT MAX(NULLIF(sort_at, '')) AS lastmod FROM documents WHERE deleted = 0 AND exposure = 3`),
++ coverage: db.prepare(`SELECT owner, type, COUNT(*) AS n FROM documents WHERE deleted = 0 AND exposure = 3 GROUP BY owner, type ORDER BY n DESC, owner, type`),
++ indexedSince: db.prepare('SELECT COUNT(*) AS n FROM documents WHERE deleted = 0 AND exposure = 3 AND indexed_at > ?'),
};
function rowValues(doc, hash, { via, eventId }) {
@@@ -210,7 +212,18 @@ function createStore({ db, engine, outbox, purges = null, now = () => Date.now()
return row && row.lastmod ? String(row.lastmod) : null;
}
-- return { apply, remove, get, byRid, ownerPage, counts, newestPublic, EXPOSURE_NAME };
++ /** The public, listed documents by owner and type, most first: what the front page says the index holds. */
++ async function publicCoverage() {
++ return (await st.coverage.all()).map((r) => ({ owner: String(r.owner), type: String(r.type), n: Number(r.n) }));
++ }
++
++ /** How many public, listed documents were indexed (added or revised) after `sinceMs`. */
++ async function publicIndexedSince(sinceMs) {
++ const row = await st.indexedSince.get(sinceMs);
++ return row ? Number(row.n) : 0;
++ }
++
++ return { apply, remove, get, byRid, ownerPage, counts, newestPublic, publicCoverage, publicIndexedSince, EXPOSURE_NAME };
}
module.exports = { createStore, EXPOSURE_NAME };
ddiff --git a/server/web/front-data.js b/server/web/front-data.js
nnew file mode 100644
iindex 0000000..4a56b60
---- /dev/null
++++ b/server/web/front-data.js
@@@ -0,0 +1,87 @@
++'use strict';
++/**
++ * What the front page shows of the index, read as an anonymous visitor would see it and kept a few minutes:
++ *
++ * createFrontData({ store, searcher, now, ttlMs, log }) → { read() }
++ * read() → { total, week, sources, kinds, coverage: [{ owner, type, n }], shelves: [{ owner, type, title, items }] } | null
++ *
++ * coverage is the public, listed documents by owner and type (store.publicCoverage). A shelf is one kind of document
++ * with its newest few, from the same searcher and anonymous viewer as GET /api/v1/search?owner=&type= (newest first), so
++ * the page never shows a document the API would not. A shelf with no documents is left out. read() never throws: a
++ * failure keeps the last answer (null before the first), and the page then shows only what does not need data.
++ */
++const { ANONYMOUS } = require('../auth');
++
++const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
++// The shelves, in the order the page shows them: a kind of document people browse, its heading and how many to show.
++// Community pastes are left off on purpose: their titles are written by an AI from a picture, and this page is not the
++// place to repeat them (they stay searchable).
++const SHELVES = [
++ ['live', 'vod', 'Recent streams'],
++ ['live', 'clip', 'Clips'],
++ ['blog', 'post', 'From the blogs'],
++ ['work', 'job', 'New jobs'],
++ ['host', 'plan', 'Hosting plans'],
++ ['coupons', 'coupon', 'Coupons'],
++ ['community', 'thread', 'Forum threads'],
++ ['help', 'article', 'Help articles'],
++ ['reviews', 'entity', 'Places and products'],
++ ['rent', 'city', 'Renting in a city'],
++ ['quest', 'quest', 'Quests'],
++ ['tools', 'tool', 'Tools'],
++];
++const PER_SHELF = 4;
++// Placeholder titles a site gives a document before anyone names it: real, but they tell a visitor nothing, so a shelf
++// skips them (they stay searchable).
++const PLACEHOLDER = /^(clip from stream|untitled|new paste|stream)$/i;
++
++function createFrontData({ store, searcher, now = () => Date.now(), ttlMs = 5 * 60 * 1000, waitMs = 2000, log = console } = {}) {
++ let cached = null;
++ let at = 0;
++ let pending = null;
++
++ async function shelf([owner, type, title]) {
++ const out = await searcher.run({ text: '', query: { owner, type }, viewer: ANONYMOUS, limit: PER_SHELF * 3 });
++ const items = (out.results || []).filter((r) => r.title && !PLACEHOLDER.test(String(r.title).trim())).slice(0, PER_SHELF);
++ return { owner, type, title, items: items.map((r) => ({ title: r.title, url: r.canonical_url, summary: r.summary || '', date: r.published_at || r.updated_at || null, facets: r.facets || {} })) };
++ }
++
++ async function load() {
++ const t = now();
++ const [coverage, week, shelves] = await Promise.all([
++ store.publicCoverage(),
++ store.publicIndexedSince(t - WEEK_MS),
++ Promise.all(SHELVES.map(shelf)),
++ ]);
++ return {
++ total: coverage.reduce((sum, r) => sum + r.n, 0),
++ week,
++ sources: new Set(coverage.map((r) => r.owner)).size,
++ kinds: coverage.length,
++ coverage,
++ shelves: shelves.filter((s) => s.items.length),
++ };
++ }
++
++ function refresh() {
++ if (!pending) {
++ pending = load()
++ .then((v) => { cached = v; at = now(); return v; })
++ .catch((e) => { log.warn(`[search] front page data: ${e.message}`); at = now() - ttlMs + 60 * 1000; return cached; })
++ .finally(() => { pending = null; });
++ }
++ return pending;
++ }
++
++ async function read() {
++ // Fresh, or a failure less than a minute ago (at is moved back so the next try waits a minute, not a request).
++ if (at && now() - at < ttlMs) return cached;
++ const p = refresh();
++ if (cached) return cached;
++ return Promise.race([p, new Promise((resolve) => setTimeout(() => resolve(null), waitMs).unref())]);
++ }
++
++ return { read };
++}
++
++module.exports = { createFrontData, SHELVES };
ddiff --git a/server/web/page.js b/server/web/page.js
iindex 5abf656..e85212b 100644
---- a/server/web/page.js
++++ b/server/web/page.js
@@@ -21,7 +21,8 @@ const { AuthError, ANONYMOUS } = require('../auth');
const { QueryError, one } = require('../api/query');
const SITE_NAME = 'OpenVibe.Search';
--const SITE_DESCRIPTION = "Search what the OpenVibe network's services have published.";
++const SITE_DESCRIPTION = 'Search jobs, streams, clips, blog posts, hosting plans, coupons, reviews, tools and help from every OpenVibe site in one place.';
++const FRONT_TITLE = 'OpenVibe.Search: search every OpenVibe site at once';
// The deployed release (app.js sets it from openvibe-shared/release): openvibe-shared/boost swaps a page in
// place only between pages of the same release, and does a normal load across a deploy.
@@@ -95,11 +96,14 @@ const TEXT_INDEX = [
* purpose) and OpenVibe.Search as the web application it is. Nothing private: both nodes describe
* the public front page only.
*/
--function homeJsonLd(origin) {
++function homeJsonLd(origin, data = null) {
return [
seo.jsonLd.website({ name: SITE_NAME, url: `${origin}/`, description: SITE_DESCRIPTION, searchUrl: `${origin}/?q={q}` }),
seo.jsonLd.softwareApp({ name: SITE_NAME, url: `${origin}/`, description: SITE_DESCRIPTION, category: 'SearchApplication' }),
-- ];
++ // What the index holds, as a list of the filtered searches the page links (only when the page shows them).
++ data && data.coverage.length ? seo.jsonLd.itemList('What OpenVibe.Search holds', sourcesOf(data).map((r) => ({ name: `${(SOURCES[r.owner] || [r.owner])[0]}: ${r.n} documents`, url: `${origin}/?owner=${encodeURIComponent(r.owner)}` })), { url: `${origin}/` }) : null,
++ seo.jsonLd.faq(FAQ.map(([q, a]) => ({ q, a }))),
++ ].filter(Boolean);
}
/** robots.txt: the same Disallows this site already had, plus the sitemap and the AI/search bots by name. */
@@@ -200,7 +204,7 @@ const SOURCES = {
tools: ['OpenVibe.Tools', 'Every online tool, by what it does.'],
sources: ['OpenVibe.Sources', 'The registry of news and data sources.'],
work: ['OpenVibe.Work', 'Job listings from open boards, each linked to the original.'],
-- rent: ['OpenVibe.Rent', 'Places and things people offer for rent.'],
++ rent: ['OpenVibe.Rent', 'City pages with what rent costs there and every rental site\'s search for it.'],
help: ['OpenVibe.Help', 'Answers about every OpenVibe site.'],
inventory: ['OpenVibe.Inventory', 'Items, badges and effects you can earn and wear.'],
quest: ['OpenVibe.Quest', 'Quests across the network and the badges they give.'],
@@@ -210,6 +214,40 @@ const SOURCES = {
bot: ['OpenVibe.Bot', 'The robots you can drive from a browser, and how to connect each.'],
};
const ICON_OF = { sources: 'docs', 'media-hub': 'video' };
++// What a count of each kind is called on the front page ("10,621 job listings"); a kind not named here is "documents".
++const KIND_WORDS = {
++ 'work/job': ['job listing', 'job listings'], 'community/paste': ['paste', 'pastes'], 'community/thread': ['forum thread', 'forum threads'],
++ 'reviews/entity': ['place or product', 'places and products'], 'rent/city': ['rental city page', 'rental city pages'], 'rent/homes_city': ['homes city page', 'homes city pages'],
++ 'live/vod': ['recorded stream', 'recorded streams'], 'live/clip': ['clip', 'clips'], 'live/channel': ['channel', 'channels'], 'host/tld': ['domain ending', 'domain endings'],
++ 'host/plan': ['hosting plan', 'hosting plans'], 'host/provider': ['hosting provider', 'hosting providers'], 'tools/tool': ['tool', 'tools'], 'coupons/coupon': ['coupon', 'coupons'],
++ 'coupons/merchant': ['store', 'stores'], 'help/article': ['help article', 'help articles'], 'blog/post': ['blog post', 'blog posts'], 'inventory/definition': ['item', 'items'],
++ 'quest/quest': ['quest', 'quests'], 'bot/model': ['robot model', 'robot models'], 'wiki/page': ['wiki page', 'wiki pages'], 'news/story': ['story', 'stories'],
++ 'deals/offer': ['deal', 'deals'], 'games/world': ['creator world', 'creator worlds'], 'media-hub/video': ['video', 'videos'], 'food/recipe': ['recipe', 'recipes'],
++};
++const fmt = (n) => Number(n).toLocaleString('en-US');
++const kindWords = (owner, type, n) => { const w = KIND_WORDS[`${owner}/${type}`]; return `${fmt(n)} ${w ? w[n === 1 ? 0 : 1] : n === 1 ? 'document' : 'documents'}`; };
++/** The sources with public documents, most first, each with its kinds (most first). */
++function sourcesOf(data) {
++ const by = new Map();
++ for (const r of data.coverage) {
++ if (!by.has(r.owner)) by.set(r.owner, { owner: r.owner, n: 0, kinds: [] });
++ const o = by.get(r.owner);
++ o.n += r.n;
++ o.kinds.push(r);
++ }
++ return [...by.values()].sort((a, b) => b.n - a.n || a.owner.localeCompare(b.owner));
++}
++// Searches that find something today, one click each (the result pages are noindex; these are links, not claims).
++const EXAMPLES = ['raspberry pi', 'remote', 'vps', 'dns', 'austin', 'patch notes', 'pizza', 'python'];
++/** Questions people ask first, answered from the README and the code. */
++const FAQ = [
++ ['What does OpenVibe.Search search?', 'What the OpenVibe sites publish: jobs, streams and clips, blog posts, forum threads, hosting plans, coupons, places and products, rental cities, tools, help articles, quests and more. Each site sends its documents as it publishes them.'],
++ ['Does it show anything private?', 'Never to anyone who should not see it. Signed out, you see public, published documents only. Private, draft and deleted documents are never listed, and a document that is removed or made private leaves the results at once.'],
++ ['How fresh are the results?', 'A document is indexed when its site publishes or changes it, through OpenVibe.Events, so new jobs, streams and posts show up within moments.'],
++ ['Can I be told about new results?', 'Yes. Save a search while signed in (POST /api/v1/saved-searches) and OpenVibe.Network sends you one notification when new matches appear.'],
++ ['Can my app use it?', 'Yes. GET /api/v1/search answers JSON with no account for public documents, and openvibe-sdk/search wraps it, with suggestions and filters by site, type and facet.'],
++ ['Why is a result missing?', 'The index holds what the sites have sent so far. A site adds its documents as it publishes them, so some older pages are not in it yet.'],
++];
// The front page's kit sections sit in this page's 760 px column, which already has its gutter.
const FRONT_CSS = `<style>
main .sc-hero, main .sc-sec { padding-left: 0; padding-right: 0; }
@@@ -217,18 +255,73 @@ main .sc-hero { padding-top: 16px; }
main .sc-hero h1 { font-size: clamp(30px, 6vw, 44px); }
main .sc-hero + form { margin-top: 20px; }
main .sc-sec { margin-top: 40px; }
++main.front { max-width: 1080px; }
++main.front > form { max-width: 760px; }
++.sc-eyebrow { color: color-mix(in srgb, var(--accent-light, #60a5fa) 45%, var(--text-primary)); }
++.fs-try { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: -6px 0 0; padding: 0; list-style: none; font-size: .9rem; color: var(--muted); }
++.fs-try li { padding: 0; border: 0; }
++.fs-try a { display: inline-block; padding: 4px 12px; border-radius: 999px; border: 1px solid var(--line); color: var(--fg); text-decoration: none; }
++.fs-try a:hover, .fs-try a:focus-visible { border-color: var(--accent); }
++.fs-stats { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(min(130px, 100%), 1fr)); }
++.fs-stats li { display: grid; gap: 2px; padding: 14px 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--bg-secondary); overflow-wrap: normal; }
++.fs-stats b { font-size: 28px; line-height: 1.1; letter-spacing: -.02em; }
++.fs-stats span { font-size: .85rem; color: var(--muted); }
++.fs-shelves { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr)); }
++.fs-shelf { min-width: 0; padding: 14px 16px 6px; border: 1px solid var(--line); border-radius: 16px; background: var(--bg-secondary); }
++.fs-shelf-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
++.fs-shelf-head h3 { margin: 0 0 6px; font-size: 1rem; }
++.fs-shelf-head a { font-size: .85rem; font-weight: 600; color: color-mix(in srgb, var(--accent) 65%, var(--fg)); }
++.fs-shelf ul { list-style: none; margin: 0; padding: 0; }
++.fs-shelf li { padding: 8px 0; border-top: 1px solid var(--line); }
++.fs-shelf li a { color: var(--fg); text-decoration: none; font-weight: 600; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
++.fs-shelf li a:hover, .fs-shelf li a:focus-visible { text-decoration: underline; }
++.fs-shelf li span { display: block; font-size: .8rem; color: var(--muted); }
++.fs-faq { display: grid; gap: 12px 28px; grid-template-columns: repeat(auto-fill, minmax(min(380px, 100%), 1fr)); }
++.fs-qa { padding: 4px 0 14px; border-bottom: 1px solid var(--line); }
++.fs-qa h3 { margin: 0 0 6px; font-size: 1rem; }
++.fs-qa p { margin: 0; color: var(--muted); line-height: 1.6; }
++main.front .sc-code { grid-template-columns: minmax(0, 1fr); }
++main.front .sc-steps { grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr)); }
++.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
++@media (max-width: 520px) { main.front .sc-code pre { white-space: pre-wrap; overflow-wrap: anywhere; } .fs-shelf li:nth-child(n+4) { display: none; } }
</style>`;
--function frontShowcase(owners = DEFAULT_EVENT_OWNERS) {
-- const items = owners.filter((o) => SOURCES[o]).map((o) => ({ icon: `ov:${ICON_OF[o] || o}`, title: SOURCES[o][0], text: SOURCES[o][1], href: `/?owner=${encodeURIComponent(o)}` }));
-- return showcase.features({ id: 'sources', title: 'Browse by source', lede: 'Each service sends what it publishes as it publishes it; open one to see its public documents.', items });
++function frontShowcase(owners = DEFAULT_EVENT_OWNERS, data = null) {
++ // With the index's numbers: the sources that hold public documents, most first, each with what it holds. Without
++ // them (the first read is late, or failed): every source the index accepts, with its line, as before.
++ const items = data && data.coverage.length
++ ? sourcesOf(data).filter((o) => SOURCES[o.owner]).map((o) => ({ icon: `ov:${ICON_OF[o.owner] || o.owner}`, title: SOURCES[o.owner][0], text: `${o.kinds.slice(0, 3).map((k) => kindWords(o.owner, k.type, k.n)).join(' · ')}. ${SOURCES[o.owner][1]}`, href: `/?owner=${encodeURIComponent(o.owner)}` }))
++ : owners.filter((o) => SOURCES[o]).map((o) => ({ icon: `ov:${ICON_OF[o] || o}`, title: SOURCES[o][0], text: SOURCES[o][1], href: `/?owner=${encodeURIComponent(o)}` }));
++ return showcase.features({ id: 'sources', title: 'Browse by source', lede: 'Each site sends what it publishes as it publishes it; open one to see its public documents.', items });
++}
++
++/** The front page's data sections: the numbers, the shelves of newest documents, the API and the questions. */
++function frontSections(data) {
++ const stats = data ? [[data.total, 'public documents'], [data.sources, 'sites sending'], [data.kinds, 'kinds of document'], [data.week, 'added or updated this week']].filter(([n]) => n > 0) : [];
++ const statRow = stats.length ? `<section class="sc-sec fs-stats-sec" aria-label="The index in numbers"><ul class="fs-stats">${stats.map(([n, label]) => `<li><b>${esc(fmt(n))}</b><span>${esc(label)}</span></li>`).join('')}</ul></section>` : '';
++ const shelves = data && data.shelves.length ? `<section class="sc-sec" aria-labelledby="h-new"><h2 id="h-new">New on the network</h2>
++<p class="sc-lede">The newest public documents of each kind, as they reached the index.</p>
++<div class="fs-shelves">${data.shelves.map((sh) => `<section class="fs-shelf" aria-labelledby="h-${esc(sh.owner)}-${esc(sh.type)}"><div class="fs-shelf-head"><h3 id="h-${esc(sh.owner)}-${esc(sh.type)}">${esc(sh.title)}</h3><a href="/?owner=${encodeURIComponent(sh.owner)}&amp;type=${encodeURIComponent(sh.type)}">More<span class="sr-only"> ${esc(sh.title.toLowerCase())}</span></a></div>
++<ul>${sh.items.map((it) => { const href = safeHref(it.url); const d = dateOf(it.date); return `<li>${href ? `<a href="${esc(href)}">${esc(it.title || 'Untitled')}</a>` : esc(it.title || 'Untitled')}${d ? `<span>${esc((SOURCES[sh.owner] || [sh.owner])[0])} · ${d}</span>` : `<span>${esc((SOURCES[sh.owner] || [sh.owner])[0])}</span>`}</li>`; }).join('')}</ul></section>`).join('')}</div></section>` : '';
++ const api = showcase.code({
++ id: 'api',
++ title: 'Search from your own code',
++ lede: 'The same index as JSON. Signed out you get public documents; with a person\'s token, also what that person may see.',
++ samples: [
++ { label: 'Search (curl, no token)', lang: 'sh', code: 'curl "https://search.openvibe.network/api/v1/search?q=raspberry+pi&limit=5"\n\n# One site and kind, newest first\ncurl "https://search.openvibe.network/api/v1/search?owner=work&type=job"' },
++ { label: 'JavaScript (openvibe-sdk)', lang: 'js', code: "import { createClient } from 'openvibe-sdk/core';\nimport { createSearchClient } from 'openvibe-sdk/search';\n\nconst search = createSearchClient(createClient());\nconst { results } = await search.query('raspberry pi', { limit: 5 });\nconst { suggestions } = await search.suggest('rasp');" },
++ ],
++ });
++ const faq = `<section class="sc-sec" id="faq" aria-labelledby="h-faq"><h2 id="h-faq">Questions</h2>
++<div class="fs-faq">${FAQ.map(([q, a]) => `<div class="fs-qa"><h3>${esc(q)}</h3><p>${esc(a)}</p></div>`).join('')}</div></section>`;
++ return { statRow, shelves, api, faq };
}
// ownHeading: the body brings its own <h1> (the update log), so the search page's heading and lede stay out.
--function layout({ title, q, owner, type, body, noindex, canonical, jsonLd, front = false, ownHeading = false }) {
++function layout({ title, q, owner, type, body, noindex, canonical, jsonLd, front = false, ownHeading = false, after = '', description = SITE_DESCRIPTION }) {
const url = noindex ? undefined : canonical;
return shell.page({
...FRAME,
-- title, description: SITE_DESCRIPTION, canonical: url, jsonLd,
++ title, description, canonical: url, jsonLd,
robots: noindex ? 'noindex, nofollow' : 'index, follow, max-image-preview:large, max-snippet:-1',
// The AI summary (and its WebPage JSON-LD) only where the page may be indexed, like the rest of the JSON-LD.
...(noindex ? {} : { summary: SITE_DESCRIPTION, url }),
@@@ -269,18 +362,20 @@ mark { background: var(--mark); color: inherit; }
</style>`,
].join('\n'),
body: `<div id="navbar-mount"></div>
--<main id="main">
++<main id="main"${front ? ' class="front"' : ''}>
${front ? showcase.hero({
eyebrow: 'OpenVibe.Search',
title: 'Search the', accent: 'OpenVibe network',
-- lede: "Wiki pages, posts, stories, pastes, tools and more from the network's services, in one index. Alpha: it holds only what they have sent so far.",
++ lede: 'Jobs, streams, clips, blog posts, hosting plans, coupons, places, tools and help from every OpenVibe site, in one index. Alpha: it holds what the sites have sent so far.',
}) : ownHeading ? '' : `<h1><a href="/" style="color:inherit;text-decoration:none">OpenVibe.Search</a></h1>
<p class="lede">Search what the OpenVibe network's services have published. Alpha: the index holds only what they have sent so far.</p>`}
<form method="get" action="/" role="search">
<input type="search" name="q" value="${esc(q)}" maxlength="500" placeholder="Search the network" aria-label="Search terms" autofocus>
${owner ? `<input type="hidden" name="owner" value="${esc(owner)}">` : ''}${type ? `<input type="hidden" name="type" value="${esc(type)}">` : ''}<button type="submit">Search</button>
</form>
++${front ? `<ul class="fs-try" aria-label="Try a search"><li>Try:</li>${EXAMPLES.map((e) => `<li><a href="/?q=${encodeURIComponent(e).replace(/%20/g, '+')}">${esc(e)}</a></li>`).join('')}</ul>` : ''}
${body}
++${after}
<section class="page-note">
<p>Private, draft and deleted documents are never shown. A document that is removed or made private leaves these results at once.
JSON API: <a href="/api/v1/search?q=${encodeURIComponent(q || '')}">/api/v1/search</a>.
@@@ -305,7 +400,7 @@ ${r.snippet_html ? `<p class="snip">${r.snippet_html}</p>` : r.summary ? `<p cla
/** baseUrl (config.baseUrl) names the canonical URL of the pages that may be indexed: the front page and /updates.
* newestPublic() reads the newest content time among the public indexable documents (the sitemap's real
* lastmod); releasedAt is the deployed release's date (/updates' lastmod). */
--function pageRouter({ searcher, auth, baseUrl = 'https://search.openvibe.network', newestPublic = null, releasedAt = null, owners = DEFAULT_EVENT_OWNERS }) {
++function pageRouter({ searcher, auth, baseUrl = 'https://search.openvibe.network', newestPublic = null, releasedAt = null, owners = DEFAULT_EVENT_OWNERS, frontData = null }) {
const origin = String(baseUrl).replace(/\/+$/, '');
const router = express.Router();
@@@ -364,7 +459,11 @@ function pageRouter({ searcher, auth, baseUrl = 'https://search.openvibe.network
viewer = null; // a browser page never 401s: an unverifiable credential searches as anonymous
}
// The plain front page opens with what Search is (openvibe-shared/showcase) and its sources to browse.
-- let body = frontShowcase(owners) + '<p class="note">Filters: <code>?owner=wiki</code>, <code>?type=page</code>.</p>' + frame.shipped({ service: 'search', title: 'Recently shipped on OpenVibe.Search' });
++ // With the index's numbers and shelves (server/web/front-data.js), read only for the plain front page.
++ const data = !searching && frontData ? await frontData.read() : null;
++ const front = frontSections(data);
++ let body = front.statRow + front.shelves + frontShowcase(owners, data) + '<p class="note">Filters: <code>?owner=wiki</code>, <code>?type=page</code>.</p>';
++ const after = front.api + front.faq + frame.shipped({ service: 'search', title: 'Recently shipped on OpenVibe.Search' });
let status = 200;
if (searching) {
try {
@@@ -387,8 +486,9 @@ function pageRouter({ searcher, auth, baseUrl = 'https://search.openvibe.network
}
}
// JSON-LD only where the page may be indexed: the plain front page, never a result page.
-- const jsonLd = searching || status !== 200 ? null : homeJsonLd(origin);
-- res.status(status).type('html').send(layout({ title: q ? `${q} · OpenVibe.Search` : 'OpenVibe.Search', q, owner, type, body, noindex: searching, canonical: `${origin}/`, jsonLd, front: !searching && status === 200 }));
++ const isFront = !searching && status === 200;
++ const jsonLd = isFront ? homeJsonLd(origin, data) : null;
++ res.status(status).type('html').send(layout({ title: q ? `${q} · OpenVibe.Search` : isFront ? FRONT_TITLE : 'OpenVibe.Search', q, owner, type, body, noindex: searching, canonical: `${origin}/`, jsonLd, front: isFront, after: isFront ? after : '' }));
});
return router;
ddiff --git a/test/front.test.js b/test/front.test.js
nnew file mode 100644
iindex 0000000..2be7563
---- /dev/null
++++ b/test/front.test.js
@@@ -0,0 +1,73 @@
++'use strict';
++/**
++ * The front page's data sections (server/web/front-data.js, server/web/page.js): the numbers and the sources come from
++ * the public, listed documents only; each shelf shows the newest documents of its kind as an anonymous search would,
++ * never a private or draft one, and is left out when empty; community pastes are not shelved; the example searches,
++ * the API samples and the questions are there, and the FAQPage holds exactly the questions shown.
++ */
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { boot, request, doc, suite } = require('./helpers');
++const { createFrontData } = require('../server/web/front-data');
++
++const t = suite('front');
++let svc;
++const html = (p) => request(svc.base, 'GET', p, { headers: { Accept: 'text/html' } });
++
++t('boot', async () => {
++ svc = await boot();
++ for (let i = 0; i < 6; i++) {
++ await svc.store.apply(doc({ owner: 'work', type: 'job', id: `job${i}`, title: `Robotics engineer ${i}`, canonical_url: `https://openvibe.work/jobs/job${i}`, updated_at: `2026-10-2${i}T10:00:00Z` }));
++ }
++ await svc.store.apply(doc({ owner: 'live', type: 'vod', id: 'v1', title: 'Building a rover live', canonical_url: 'https://openvibe.video/v/1', updated_at: '2026-10-21T10:00:00Z' }));
++ await svc.store.apply(doc({ owner: 'live', type: 'vod', id: 'v2', visibility: 'private', acl: { subjects: [ids.newId('user')] }, title: 'Secret stream', canonical_url: 'https://openvibe.video/v/2' }));
++ await svc.store.apply(doc({ owner: 'live', type: 'vod', id: 'v3', publication_state: 'draft', title: 'Draft stream', canonical_url: 'https://openvibe.video/v/3' }));
++ await svc.store.apply(doc({ owner: 'community', type: 'paste', id: 'p1', title: 'A picture of a desk', canonical_url: 'https://openvibe.community/p/1' }));
++});
++
++t('the numbers and the sources count public, listed documents only', async () => {
++ const data = await createFrontData({ store: svc.store, searcher: svc.searcher, log: { warn() {} } }).read();
++ assert.strictEqual(data.total, 8, '6 jobs, 1 public stream, 1 paste');
++ assert.strictEqual(data.sources, 3);
++ assert.deepStrictEqual(data.coverage[0], { owner: 'work', type: 'job', n: 6 });
++});
++
++t('shelves: newest first, four at most, nothing private or draft, no pastes', async () => {
++ const r = await html('/');
++ assert.strictEqual(r.status, 200);
++ const shelves = (/<section class="sc-sec" aria-labelledby="h-new">[\s\S]*?<\/div><\/section>/.exec(r.text) || [''])[0];
++ const jobs = (/<section class="fs-shelf" aria-labelledby="h-work-job">[\s\S]*?<\/section>/.exec(shelves) || [''])[0];
++ assert.deepStrictEqual([...jobs.matchAll(/>(Robotics engineer \d)</g)].map((m) => m[1]), ['Robotics engineer 5', 'Robotics engineer 4', 'Robotics engineer 3', 'Robotics engineer 2']);
++ assert.match(jobs, /href="\/\?owner=work&amp;type=job">More<span class="sr-only"> new jobs<\/span>/);
++ assert.match(shelves, /Building a rover live/);
++ assert.ok(!r.text.includes('Secret stream') && !r.text.includes('Draft stream'), 'nothing a signed-out visitor may not see');
++ assert.ok(!shelves.includes('A picture of a desk'), 'pastes are not shelved');
++ assert.ok(!shelves.includes('aria-labelledby="h-host-plan"'), 'an empty shelf is left out');
++});
++
++t('the stat row, the sources with their counts, the example searches, the API and the questions', async () => {
++ const r = await html('/');
++ assert.match(r.text, /<b>8<\/b><span>public documents<\/span>/);
++ assert.match(r.text, /<b>3<\/b><span>sites sending<\/span>/);
++ assert.match(r.text, /6 job listings\. Job listings from open boards/);
++ assert.ok(r.text.indexOf('href="/?owner=work"') < r.text.indexOf('href="/?owner=live"'), 'the source with most documents first');
++ assert.match(r.text, /<ul class="fs-try" aria-label="Try a search"><li>Try:<\/li><li><a href="\/\?q=raspberry\+pi">raspberry pi<\/a><\/li>/);
++ assert.match(r.text, /id="api"/);
++ const ld = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
++ const faq = (/<section class="sc-sec" id="faq"[\s\S]*?<\/section>/.exec(r.text) || [''])[0];
++ const shown = [...faq.matchAll(/<h3>([^<]+)<\/h3>/g)].map((m) => m[1].replace(/&#39;/g, "'"));
++ assert.ok(shown.length >= 5);
++ assert.deepStrictEqual(ld.find((x) => x['@type'] === 'FAQPage').mainEntity.map((q) => q.name), shown);
++ assert.strictEqual(ld.find((x) => x['@type'] === 'ItemList').numberOfItems, 3);
++ assert.strictEqual((r.text.match(/<h1[\s>]/g) || []).length, 1);
++});
++
++t('a result page keeps none of it', async () => {
++ const r = await html('/?q=robotics');
++ assert.ok(!r.text.includes('fs-stats') && !r.text.includes('id="faq"') && !r.text.includes('fs-try'));
++ assert.ok(r.text.includes('<main id="main">'));
++});
++
++t('done', async () => { await svc.stop(); });
++
++t.run();
ddiff --git a/test/page.test.js b/test/page.test.js
iindex 49802c4..28732e5 100644
---- a/test/page.test.js
++++ b/test/page.test.js
@@@ -71,18 +71,19 @@ t('the front page is a search form, indexable, no-store, with a strict CSP', asy
// The rendered head (openvibe-shared/shell): one title, the canonical, robots, the AI summary and JSON-LD.
const head = r.text.slice(0, r.text.indexOf('</head>'));
assert.strictEqual((head.match(/<title>/g) || []).length, 1);
-- assert.match(head, /<title>OpenVibe\.Search<\/title>/);
++ assert.match(head, /<title>OpenVibe\.Search: search every OpenVibe site at once<\/title>/);
assert.match(head, /<link rel="canonical" href="https?:\/\/[^"/]+\/">/);
assert.match(head, /<meta name="robots" content="index, follow[^"]*">/);
-- assert.match(head, /<meta name="description" content="Search what the OpenVibe network&#39;s services have published\.">/);
++ assert.match(head, /<meta name="description" content="Search jobs, streams, clips, blog posts, hosting plans, coupons, reviews, tools and help from every OpenVibe site in one place\.">/);
assert.match(head, /<meta name="ai-summary" content="[^"]+">/);
assert.match(head, /<script type="application\/ld\+json">/);
assert.ok(head.includes('data-ov-icon="network"') && head.includes('<link rel="apple-touch-icon" href="https://openvibe.network/assets/'), 'the network app icons');
assert.ok(head.includes('<meta name="color-scheme" content="light dark">') && head.includes('<style>'), 'the page\'s own head');
-- // JSON-LD on the front page only: the WebSite (with its SearchAction) and the search application.
++ // JSON-LD on the front page only: the WebSite (with its SearchAction), the search application, what the index holds
++ // (an ItemList of the sources the page links) and the questions it shows (FAQPage).
const ld = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
// (and the WebPage of the AI summary).
-- assert.deepStrictEqual(ld.map((o) => o['@type']), ['WebSite', 'WebApplication', 'WebPage']);
++ assert.deepStrictEqual(ld.map((o) => o['@type']), ['WebSite', 'WebApplication', 'ItemList', 'FAQPage', 'WebPage']);
assert.strictEqual(ld[0].potentialAction.target.urlTemplate.endsWith('/?q={search_term_string}'), true);
assert.ok(ld[0].url.endsWith('/') && ld[1].url.endsWith('/'));
assert.ok(r.text.includes('<div id="navbar-mount"></div>') && r.text.includes('id="ov-footer"'), 'the OpenVibe Frame');
@@@ -91,7 +92,7 @@ t('the front page is a search form, indexable, no-store, with a strict CSP', asy
// is the element that changes, so a same-release move happens in place (any doubt is a normal load).
assert.match(r.text, /<meta name="ov-boost" content="search@[^"]+">/);
assert.match(r.text, /<script src="\/shared\/boost\.js\?v=[^"]+" data-main="#main" defer><\/script>/);
-- assert.ok(r.text.includes('<main id="main">'), 'the changing part');
++ assert.ok(r.text.includes('<main id="main" class="front">'), 'the changing part');
});
t('/updates is the shared log; the old Frame init script is gone', async () => {