openvibers/openvibe.search Commits
Search results say where each one comes from, and add something the title does not (#179)
Changes
@@@ -366,7 +366,7 @@ function frontSections(data) {}// 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, after = '', description = SITE_DESCRIPTION, heroAside = '' }) {++function layout({ title, q, owner, type, body, noindex, canonical, jsonLd, front = false, ownHeading = false, after = '', description = SITE_DESCRIPTION, heroAside = '', compact = false }) {const url = noindex ? undefined : canonical;return shell.page({...FRAME,@@@ -404,9 +404,30 @@ li a.t { color: var(--accent); font-size: 1.05rem; text-decoration: none; }li a.t:hover { text-decoration: underline; }.meta { color: var(--muted); font-size: .85rem; }.snip { margin: 4px 0 0; }++/* Result pages (2026-11-01): a compact heading, a row of sites to narrow to, and results that say where they come from. */++.h-compact { font-size: 1rem; margin: 0 0 12px; }++.h-compact a { color: var(--muted); text-decoration: none; }++.h-compact a:hover { color: var(--fg); }++main.results form { margin-bottom: 12px; }++.r-filter { display: flex; gap: 8px; overflow-x: auto; margin: 0 -16px 4px; padding: 2px 16px 8px; scrollbar-width: none; overscroll-behavior-x: contain; }++.r-filter::-webkit-scrollbar { display: none; }++.chip { flex: none; display: inline-flex; align-items: center; min-height: 36px; padding: 0 14px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); font-size: .875rem; font-weight: 600; text-decoration: none; white-space: nowrap; }++.chip:hover { color: var(--fg); border-color: var(--accent); }++.chip:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }++.chip.on { color: var(--fg); border-color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, transparent); }++ol.res > li { padding: 18px 0; }++.r-src { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 8px; font-size: .8125rem; line-height: 1.3; color: var(--muted); }++.r-ic { flex: none; display: grid; place-items: center; width: 24px; height: 24px; border-radius: 7px; font-size: .75rem; font-weight: 800; color: color-mix(in srgb, var(--accent) 75%, var(--fg)); background: color-mix(in srgb, var(--accent) 16%, transparent); }++.r-site { font-weight: 650; color: var(--fg); }++.r-kind::before, .r-vis::before { content: '·'; margin-right: 8px; }++.r-src time { margin-left: auto; font-variant-numeric: tabular-nums; }++.r-vis { text-transform: capitalize; }++ol.res a.t, ol.res span.t { display: block; margin-top: 8px; font-size: 1.0625rem; font-weight: 600; line-height: 1.35; color: color-mix(in srgb, var(--accent) 80%, var(--fg)); }++ol.res .snip { margin-top: 4px; font-size: .9375rem; line-height: 1.5; color: color-mix(in srgb, var(--fg) 80%, var(--bg)); }/* The match highlight follows the reader's theme, not the device's dark mode: --mark came from prefers-color-scheme, soDaylight on a dark-mode phone drew dark olive behind dark text (2026-11-01). A translucent yellow reads on both. */mark { background: color-mix(in srgb, #facc15 40%, transparent); color: inherit; border-radius: 2px; }++ol.res mark { background: color-mix(in srgb, var(--accent) 22%, transparent); color: var(--fg); font-weight: 600; padding: 0 1px; }.empty, .note { color: var(--muted); }.more { display: inline-block; margin-top: 16px; color: var(--accent); }.page-note { margin-top: 40px; color: var(--muted); font-size: .85rem; }@@@ -414,7 +435,7 @@ mark { background: color-mix(in srgb, #facc15 40%, transparent); color: inherit;</style>`,].join('\n'),body: `<div id="navbar-mount"></div>--<main id="main"${front ? ' class="front"' : ''}>++<main id="main"${front ? ' class="front"' : compact ? ' class="results"' : ''}>${front ? showcase.hero({eyebrow: 'OpenVibe.Search · part of OpenVibe Services',title: 'Search the', accent: 'OpenVibe network',@@@ -422,7 +443,7 @@ ${front ? showcase.hero({// The index's own numbers beside the heading (2026-11-01): the generic aura left a tall empty band between the// heading and the search box on a desktop....(heroAside ? { html: heroAside } : {}),-- }) : ownHeading ? '' : `<h1><a href="/" style="color:inherit;text-decoration:none">OpenVibe.Search</a></h1>++ }) : ownHeading ? '' : compact ? `<h1 class="h-compact"><a href="/">OpenVibe.Search</a></h1>` : `<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>@@@ -433,7 +454,7 @@ ${front ? `<ul class="fs-try" aria-label="Try a search"><li>Try:</li>${EXAMPLES.${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.++<p>${compact ? 'Alpha: the index holds only what the sites have sent so far. ' : ''}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>.Source: <a href="https://github.com/OpenVibers/OpenVibe.Search">OpenVibers/OpenVibe.Search</a>.</p></section>@@@ -442,17 +463,60 @@ Source: <a href="https://github.com/OpenVibers/OpenVibe.Search">OpenVibers/OpenV});}++// ── A result (2026-11-01 design) ──++// Each result says where it comes from first (the site, as a monogram tile in the theme's accent, its name, the kind of++// document and the day), then the title, then a line that adds something: the matched passage, or the document's own++// summary where the match is only the title (a snippet that repeats the title said nothing new).++const plainText = (h) => String(h || '').replace(/<[^>]+>/g, '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/\s+/g, ' ').trim();++const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];++/** "Oct 24", with the year when it is not this year ("Oct 24, 2025"); UTC, like the rest of the page. */++function shortDate(iso, now = Date.now()) {++ const t = Date.parse(iso || '');++ if (!Number.isFinite(t)) return '';++ const d = new Date(t);++ const y = d.getUTCFullYear();++ return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}${y === new Date(now).getUTCFullYear() ? '' : `, ${y}`}`;++}++const siteName = (owner) => (SOURCES[owner] ? SOURCES[owner][0] : owner);++const siteShort = (owner) => siteName(owner).replace(/^OpenVibe\./, '');++const kindOf = (owner, type) => {++ const w = KIND_WORDS[`${owner}/${type}`];++ const k = w ? w[0] : String(type || '').replace(/[_-]+/g, ' ');++ return k.charAt(0).toUpperCase() + k.slice(1);++};++const words = (s) => plainText(s).toLowerCase().split(/[^\p{L}\p{N}$.]+/u).filter((w) => w.length > 1);++/** The matched passage, unless it mostly repeats the title (then the document's own summary says more). */++function snippetOf(r) {++ const snip = words(r.snippet_html);++ const title = new Set(words(r.title));++ const inTitle = snip.filter((w) => title.has(w)).length;++ const repeatsTitle = !snip.length || inTitle / snip.length >= 0.5;++ if (!repeatsTitle) return r.snippet_html;++ return r.summary ? esc(r.summary) : '';++}function resultsHtml(results) {-- return `<ol>${results.map((r) => {++ return `<ol class="res">${results.map((r) => {const href = safeHref(r.canonical_url);const title = esc(r.title || `${r.owner}/${r.type}/${r.id}`);-- const date = dateOf(r.published_at || r.updated_at);-- return `<li>${href ? `<a class="t" href="${esc(href)}">${title}</a>` : `<span class="t">${title}</span>`}--<div class="meta">${esc(r.owner)} · ${esc(r.type)}${date ? ` · ${date}` : ''}${r.visibility !== 'public' ? ` · ${esc(r.visibility)}` : ''}</div>--${r.snippet_html ? `<p class="snip">${r.snippet_html}</p>` : r.summary ? `<p class="snip">${esc(r.summary)}</p>` : ''}</li>`;++ const iso = r.published_at || r.updated_at || '';++ const date = shortDate(iso);++ const snip = snippetOf(r);++ return `<li>++<div class="r-src"><span class="r-ic" aria-hidden="true">${esc(siteShort(r.owner).charAt(0).toUpperCase())}</span><span class="r-site">${esc(siteName(r.owner))}</span><span class="r-kind">${esc(kindOf(r.owner, r.type))}</span>${date ? `<time datetime="${esc(iso)}">${date}</time>` : ''}${r.visibility !== 'public' ? `<span class="r-vis">${esc(r.visibility)}</span>` : ''}</div>++${href ? `<a class="t" href="${esc(href)}">${title}</a>` : `<span class="t">${title}</span>`}++${snip ? `<p class="snip">${snip}</p>` : ''}</li>`;}).join('\n')}</ol>`;}++/** The sites in these results, as links that narrow the search to one (and one that widens it again). */++function filterRow({ q, owner, type, results }) {++ const owners = [...new Set([...(owner ? [owner] : []), ...results.map((r) => r.owner)])].filter(Boolean).slice(0, 10);++ if (owners.length < 2 && !owner) return '';++ const link = (o) => { const u = new URLSearchParams(); if (q) u.set('q', q); if (o) u.set('owner', o); if (type) u.set('type', type); return `/?${u.toString()}`; };++ const chip = (o, label) => `<a class="chip${(o || '') === (owner || '') ? ' on' : ''}" href="${esc(link(o))}"${(o || '') === (owner || '') ? ' aria-current="page"' : ''}>${esc(label)}</a>`;++ return `<nav class="r-filter" aria-label="Narrow to one site">${chip('', 'All sites')}${owners.map((o) => chip(o, siteShort(o))).join('')}</nav>`;++}++// The page a browser gets for a path nothing serves: the site's own Frame, a way back, and no clue// about what was asked for. An API client keeps the problem document (server/app.js).function renderNotFound({ origin }) {@@@ -570,7 +634,7 @@ function pageRouter({ searcher, auth, baseUrl = 'https://search.openvibe.networktry {const out = await searcher.run({ text: q, query: { ...(owner ? { owner } : {}), ...(type ? { type } : {}) }, viewer: viewer || ANONYMOUS, cursor });body = out.results.length-- ? resultsHtml(out.results)++ ? filterRow({ q, owner, type, results: out.results }) + resultsHtml(out.results): `<p class="empty">${cursor ? 'No more results.' : 'Nothing found. The index is young: services add documents as they publish them.'}</p>`;if (out.next_cursor) {const u = new URLSearchParams();@@@ -596,7 +660,7 @@ function pageRouter({ searcher, auth, baseUrl = 'https://search.openvibe.networkif (cursor) twinParams.set('cursor', cursor);const twinCanonical = `${origin}/${twinParams.toString() ? `?${twinParams.toString()}` : ''}`;res.setHeader('Link', seo.linkHeaderTags(twinCanonical, `${origin}/llms.txt`).header);-- 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 : '', heroAside: isFront ? front.statList : '' }));++ 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, compact: searching, after: isFront ? after : '', heroAside: isFront ? front.statList : '' }));});// A browser asking for a page nothing serves gets the site's own 404 page; an API path, another@@@ -65,7 +65,7 @@ t('the stat row, the sources with their counts, the example searches, the API ant('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">'));++ assert.ok(r.text.includes('<main id="main" class="results">'), 'a result page is a results page, not the front');});t('done', async () => { await svc.stop(); });@@@ -20,6 +20,7 @@ t('boot', async () => {await svc.store.apply(doc({ id: 'prv', visibility: 'private', acl: { subjects: [alice] }, title: 'Kestrel private diary', body: 'kestrel' }));await svc.store.apply(doc({ id: 'drf', visibility: 'draft', title: 'Kestrel draft', body: 'kestrel' }));for (let i = 0; i < 25; i++) await svc.store.apply(doc({ id: `many${i}`, title: `Heron sighting ${i}`, body: 'heron' }));++ await svc.store.apply(doc({ id: 'osp', title: 'Osprey notes', summary: 'Field notes', body: 'An osprey dives for fish at dawn near the kestrel boxes on the river', canonical_url: 'https://openvibe.wiki/p/osprey' }));});t('the front page is a search form, indexable, no-store, with a strict CSP', async () => {@@@ -122,6 +123,27 @@ t('results show public documents only, escaped, linked to their canonical URL, nassert.ok(!/private diary|Kestrel draft/.test(r.text));});++t('a result says where it comes from, and a passage that only repeats the title gives way to the summary', async () => {++ const r = await html('/?q=kestrel');++ // The source row: the site's monogram, its name, the kind of document and the day.++ assert.match(r.text, /<div class="r-src"><span class="r-ic" aria-hidden="true">W<\/span><span class="r-site">OpenVibe\.Wiki<\/span><span class="r-kind">Wiki page<\/span><time datetime="[^"]+">[A-Z][a-z]{2} \d{1,2}(, \d{4})?<\/time><\/div>/);++ // "kestrel kestrel" says nothing the title does not: the summary is shown instead, escaped.++ const pub = r.text.slice(r.text.indexOf('href="https://openvibe.wiki/p/kestrel'));++ assert.match(pub.slice(0, 400), /<p class="snip">Kestrel "boxes" & more<\/p>/);++ // A passage with words of its own keeps its highlight.++ const osp = r.text.slice(r.text.indexOf('href="https://openvibe.wiki/p/osprey"'));++ assert.match(osp.slice(0, 600), /<p class="snip">[^<]*<mark>kestrel<\/mark>/i);++ // A compact heading on a result page; the Alpha note moves to the foot.++ assert.match(r.text, /<h1 class="h-compact"><a href="\/">OpenVibe\.Search<\/a><\/h1>/);++ assert.ok(!r.text.includes('<p class="lede">'));++ assert.match(r.text, /Alpha: the index holds only what the sites have sent so far\./);++});++++t('a search narrowed to one site has a row to widen it again', async () => {++ const r = await html('/?q=heron&owner=wiki');++ assert.match(r.text, /<nav class="r-filter" aria-label="Narrow to one site"><a class="chip" href="\/\?q=heron">All sites<\/a><a class="chip on" href="\/\?q=heron&owner=wiki" aria-current="page">Wiki<\/a><\/nav>/);++});++t('a signed-in cookie adds that person\'s documents; a bad cookie searches as anonymous', async () => {const mine = await html('/?q=kestrel', { Cookie: `ov_token=${userToken({ subjectId: alice, aud: ['openvibe.search'] })}` });assert.match(mine.text, /Kestrel private diary/);
@@ -366,7 +366,7 @@ function frontSections(data) {
@@ -366,7 +366,7 @@ function frontSections(data) {
}
}
// ownHeading: the body brings its own <h1> (the update log), so the search page's heading and lede stay out.
// 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, after = '', description = SITE_DESCRIPTION, heroAside = '' }) {
+function layout({ title, q, owner, type, body, noindex, canonical, jsonLd, front = false, ownHeading = false, after = '', description = SITE_DESCRIPTION, heroAside = '', compact = false }) {
const url = noindex ? undefined : canonical;
const url = noindex ? undefined : canonical;
return shell.page({
return shell.page({
...FRAME,
...FRAME,
@@ -404,9 +404,30 @@ li a.t { color: var(--accent); font-size: 1.05rem; text-decoration: none; }
@@ -404,9 +404,30 @@ li a.t { color: var(--accent); font-size: 1.05rem; text-decoration: none; }
li a.t:hover { text-decoration: underline; }
li a.t:hover { text-decoration: underline; }
.meta { color: var(--muted); font-size: .85rem; }
.meta { color: var(--muted); font-size: .85rem; }
.snip { margin: 4px 0 0; }
.snip { margin: 4px 0 0; }
+/* Result pages (2026-11-01): a compact heading, a row of sites to narrow to, and results that say where they come from. */
+.h-compact { font-size: 1rem; margin: 0 0 12px; }
+.h-compact a { color: var(--muted); text-decoration: none; }
+.h-compact a:hover { color: var(--fg); }
+main.results form { margin-bottom: 12px; }
+.r-filter { display: flex; gap: 8px; overflow-x: auto; margin: 0 -16px 4px; padding: 2px 16px 8px; scrollbar-width: none; overscroll-behavior-x: contain; }
+.r-filter::-webkit-scrollbar { display: none; }
+.chip { flex: none; display: inline-flex; align-items: center; min-height: 36px; padding: 0 14px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); font-size: .875rem; font-weight: 600; text-decoration: none; white-space: nowrap; }
+.chip:hover { color: var(--fg); border-color: var(--accent); }
+.chip:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+.chip.on { color: var(--fg); border-color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, transparent); }
+ol.res > li { padding: 18px 0; }
+.r-src { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 8px; font-size: .8125rem; line-height: 1.3; color: var(--muted); }
+.r-ic { flex: none; display: grid; place-items: center; width: 24px; height: 24px; border-radius: 7px; font-size: .75rem; font-weight: 800; color: color-mix(in srgb, var(--accent) 75%, var(--fg)); background: color-mix(in srgb, var(--accent) 16%, transparent); }
+.r-site { font-weight: 650; color: var(--fg); }
+.r-kind::before, .r-vis::before { content: '·'; margin-right: 8px; }
+.r-src time { margin-left: auto; font-variant-numeric: tabular-nums; }
+.r-vis { text-transform: capitalize; }
+ol.res a.t, ol.res span.t { display: block; margin-top: 8px; font-size: 1.0625rem; font-weight: 600; line-height: 1.35; color: color-mix(in srgb, var(--accent) 80%, var(--fg)); }
+ol.res .snip { margin-top: 4px; font-size: .9375rem; line-height: 1.5; color: color-mix(in srgb, var(--fg) 80%, var(--bg)); }
/* The match highlight follows the reader's theme, not the device's dark mode: --mark came from prefers-color-scheme, so
/* The match highlight follows the reader's theme, not the device's dark mode: --mark came from prefers-color-scheme, so
Daylight on a dark-mode phone drew dark olive behind dark text (2026-11-01). A translucent yellow reads on both. */
Daylight on a dark-mode phone drew dark olive behind dark text (2026-11-01). A translucent yellow reads on both. */
mark { background: color-mix(in srgb, #facc15 40%, transparent); color: inherit; border-radius: 2px; }
mark { background: color-mix(in srgb, #facc15 40%, transparent); color: inherit; border-radius: 2px; }
+ol.res mark { background: color-mix(in srgb, var(--accent) 22%, transparent); color: var(--fg); font-weight: 600; padding: 0 1px; }
.empty, .note { color: var(--muted); }
.empty, .note { color: var(--muted); }
.more { display: inline-block; margin-top: 16px; color: var(--accent); }
.more { display: inline-block; margin-top: 16px; color: var(--accent); }
.page-note { margin-top: 40px; color: var(--muted); font-size: .85rem; }
.page-note { margin-top: 40px; color: var(--muted); font-size: .85rem; }
@@ -414,7 +435,7 @@ mark { background: color-mix(in srgb, #facc15 40%, transparent); color: inherit;
@@ -414,7 +435,7 @@ mark { background: color-mix(in srgb, #facc15 40%, transparent); color: inherit;
</style>`,
</style>`,
].join('\n'),
].join('\n'),
body: `<div id="navbar-mount"></div>
body: `<div id="navbar-mount"></div>
-<main id="main"${front ? ' class="front"' : ''}>
+<main id="main"${front ? ' class="front"' : compact ? ' class="results"' : ''}>
${front ? showcase.hero({
${front ? showcase.hero({
eyebrow: 'OpenVibe.Search · part of OpenVibe Services',
eyebrow: 'OpenVibe.Search · part of OpenVibe Services',
title: 'Search the', accent: 'OpenVibe network',
title: 'Search the', accent: 'OpenVibe network',
@@ -422,7 +443,7 @@ ${front ? showcase.hero({
@@ -422,7 +443,7 @@ ${front ? showcase.hero({
// The index's own numbers beside the heading (2026-11-01): the generic aura left a tall empty band between the
// The index's own numbers beside the heading (2026-11-01): the generic aura left a tall empty band between the
// heading and the search box on a desktop.
// heading and the search box on a desktop.
...(heroAside ? { html: heroAside } : {}),
...(heroAside ? { html: heroAside } : {}),
- }) : ownHeading ? '' : `<h1><a href="/" style="color:inherit;text-decoration:none">OpenVibe.Search</a></h1>
+ }) : ownHeading ? '' : compact ? `<h1 class="h-compact"><a href="/">OpenVibe.Search</a></h1>` : `<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>`}
<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">
<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>
<input type="search" name="q" value="${esc(q)}" maxlength="500" placeholder="Search the network" aria-label="Search terms" autofocus>
@@ -433,7 +454,7 @@ ${front ? `<ul class="fs-try" aria-label="Try a search"><li>Try:</li>${EXAMPLES.
@@ -433,7 +454,7 @@ ${front ? `<ul class="fs-try" aria-label="Try a search"><li>Try:</li>${EXAMPLES.
${body}
${body}
${after}
${after}
<section class="page-note">
<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.
+<p>${compact ? 'Alpha: the index holds only what the sites have sent so far. ' : ''}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>.
JSON API: <a href="/api/v1/search?q=${encodeURIComponent(q || '')}">/api/v1/search</a>.
Source: <a href="https://github.com/OpenVibers/OpenVibe.Search">OpenVibers/OpenVibe.Search</a>.</p>
Source: <a href="https://github.com/OpenVibers/OpenVibe.Search">OpenVibers/OpenVibe.Search</a>.</p>
</section>
</section>
@@ -442,17 +463,60 @@ Source: <a href="https://github.com/OpenVibers/OpenVibe.Search">OpenVibers/OpenV
@@ -442,17 +463,60 @@ Source: <a href="https://github.com/OpenVibers/OpenVibe.Search">OpenVibers/OpenV
});
});
}
}
+// ── A result (2026-11-01 design) ──
+// Each result says where it comes from first (the site, as a monogram tile in the theme's accent, its name, the kind of
+// document and the day), then the title, then a line that adds something: the matched passage, or the document's own
+// summary where the match is only the title (a snippet that repeats the title said nothing new).
+const plainText = (h) => String(h || '').replace(/<[^>]+>/g, '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/\s+/g, ' ').trim();
+const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
+/** "Oct 24", with the year when it is not this year ("Oct 24, 2025"); UTC, like the rest of the page. */
+function shortDate(iso, now = Date.now()) {
+ const t = Date.parse(iso || '');
+ if (!Number.isFinite(t)) return '';
+ const d = new Date(t);
+ const y = d.getUTCFullYear();
+ return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}${y === new Date(now).getUTCFullYear() ? '' : `, ${y}`}`;
+}
+const siteName = (owner) => (SOURCES[owner] ? SOURCES[owner][0] : owner);
+const siteShort = (owner) => siteName(owner).replace(/^OpenVibe\./, '');
+const kindOf = (owner, type) => {
+ const w = KIND_WORDS[`${owner}/${type}`];
+ const k = w ? w[0] : String(type || '').replace(/[_-]+/g, ' ');
+ return k.charAt(0).toUpperCase() + k.slice(1);
+};
+const words = (s) => plainText(s).toLowerCase().split(/[^\p{L}\p{N}$.]+/u).filter((w) => w.length > 1);
+/** The matched passage, unless it mostly repeats the title (then the document's own summary says more). */
+function snippetOf(r) {
+ const snip = words(r.snippet_html);
+ const title = new Set(words(r.title));
+ const inTitle = snip.filter((w) => title.has(w)).length;
+ const repeatsTitle = !snip.length || inTitle / snip.length >= 0.5;
+ if (!repeatsTitle) return r.snippet_html;
+ return r.summary ? esc(r.summary) : '';
+}
function resultsHtml(results) {
function resultsHtml(results) {
- return `<ol>${results.map((r) => {
+ return `<ol class="res">${results.map((r) => {
const href = safeHref(r.canonical_url);
const href = safeHref(r.canonical_url);
const title = esc(r.title || `${r.owner}/${r.type}/${r.id}`);
const title = esc(r.title || `${r.owner}/${r.type}/${r.id}`);
- const date = dateOf(r.published_at || r.updated_at);
- return `<li>${href ? `<a class="t" href="${esc(href)}">${title}</a>` : `<span class="t">${title}</span>`}
-<div class="meta">${esc(r.owner)} · ${esc(r.type)}${date ? ` · ${date}` : ''}${r.visibility !== 'public' ? ` · ${esc(r.visibility)}` : ''}</div>
-${r.snippet_html ? `<p class="snip">${r.snippet_html}</p>` : r.summary ? `<p class="snip">${esc(r.summary)}</p>` : ''}</li>`;
+ const iso = r.published_at || r.updated_at || '';
+ const date = shortDate(iso);
+ const snip = snippetOf(r);
+ return `<li>
+<div class="r-src"><span class="r-ic" aria-hidden="true">${esc(siteShort(r.owner).charAt(0).toUpperCase())}</span><span class="r-site">${esc(siteName(r.owner))}</span><span class="r-kind">${esc(kindOf(r.owner, r.type))}</span>${date ? `<time datetime="${esc(iso)}">${date}</time>` : ''}${r.visibility !== 'public' ? `<span class="r-vis">${esc(r.visibility)}</span>` : ''}</div>
+${href ? `<a class="t" href="${esc(href)}">${title}</a>` : `<span class="t">${title}</span>`}
+${snip ? `<p class="snip">${snip}</p>` : ''}</li>`;
}).join('\n')}</ol>`;
}).join('\n')}</ol>`;
}
}
+/** The sites in these results, as links that narrow the search to one (and one that widens it again). */
+function filterRow({ q, owner, type, results }) {
+ const owners = [...new Set([...(owner ? [owner] : []), ...results.map((r) => r.owner)])].filter(Boolean).slice(0, 10);
+ if (owners.length < 2 && !owner) return '';
+ const link = (o) => { const u = new URLSearchParams(); if (q) u.set('q', q); if (o) u.set('owner', o); if (type) u.set('type', type); return `/?${u.toString()}`; };
+ const chip = (o, label) => `<a class="chip${(o || '') === (owner || '') ? ' on' : ''}" href="${esc(link(o))}"${(o || '') === (owner || '') ? ' aria-current="page"' : ''}>${esc(label)}</a>`;
+ return `<nav class="r-filter" aria-label="Narrow to one site">${chip('', 'All sites')}${owners.map((o) => chip(o, siteShort(o))).join('')}</nav>`;
+}
+
// The page a browser gets for a path nothing serves: the site's own Frame, a way back, and no clue
// The page a browser gets for a path nothing serves: the site's own Frame, a way back, and no clue
// about what was asked for. An API client keeps the problem document (server/app.js).
// about what was asked for. An API client keeps the problem document (server/app.js).
function renderNotFound({ origin }) {
function renderNotFound({ origin }) {
@@ -570,7 +634,7 @@ function pageRouter({ searcher, auth, baseUrl = 'https://search.openvibe.network
@@ -570,7 +634,7 @@ function pageRouter({ searcher, auth, baseUrl = 'https://search.openvibe.network
try {
try {
const out = await searcher.run({ text: q, query: { ...(owner ? { owner } : {}), ...(type ? { type } : {}) }, viewer: viewer || ANONYMOUS, cursor });
const out = await searcher.run({ text: q, query: { ...(owner ? { owner } : {}), ...(type ? { type } : {}) }, viewer: viewer || ANONYMOUS, cursor });
body = out.results.length
body = out.results.length
- ? resultsHtml(out.results)
+ ? filterRow({ q, owner, type, results: out.results }) + resultsHtml(out.results)
: `<p class="empty">${cursor ? 'No more results.' : 'Nothing found. The index is young: services add documents as they publish them.'}</p>`;
: `<p class="empty">${cursor ? 'No more results.' : 'Nothing found. The index is young: services add documents as they publish them.'}</p>`;
if (out.next_cursor) {
if (out.next_cursor) {
const u = new URLSearchParams();
const u = new URLSearchParams();
@@ -596,7 +660,7 @@ function pageRouter({ searcher, auth, baseUrl = 'https://search.openvibe.network
@@ -596,7 +660,7 @@ function pageRouter({ searcher, auth, baseUrl = 'https://search.openvibe.network
if (cursor) twinParams.set('cursor', cursor);
if (cursor) twinParams.set('cursor', cursor);
const twinCanonical = `${origin}/${twinParams.toString() ? `?${twinParams.toString()}` : ''}`;
const twinCanonical = `${origin}/${twinParams.toString() ? `?${twinParams.toString()}` : ''}`;
res.setHeader('Link', seo.linkHeaderTags(twinCanonical, `${origin}/llms.txt`).header);
res.setHeader('Link', seo.linkHeaderTags(twinCanonical, `${origin}/llms.txt`).header);
- 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 : '', heroAside: isFront ? front.statList : '' }));
+ 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, compact: searching, after: isFront ? after : '', heroAside: isFront ? front.statList : '' }));
});
});
// A browser asking for a page nothing serves gets the site's own 404 page; an API path, another
// A browser asking for a page nothing serves gets the site's own 404 page; an API path, another
@@ -65,7 +65,7 @@ t('the stat row, the sources with their counts, the example searches, the API an
@@ -65,7 +65,7 @@ t('the stat row, the sources with their counts, the example searches, the API an
t('a result page keeps none of it', async () => {
t('a result page keeps none of it', async () => {
const r = await html('/?q=robotics');
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('fs-stats') && !r.text.includes('id="faq"') && !r.text.includes('fs-try'));
- assert.ok(r.text.includes('<main id="main">'));
+ assert.ok(r.text.includes('<main id="main" class="results">'), 'a result page is a results page, not the front');
});
});
t('done', async () => { await svc.stop(); });
t('done', async () => { await svc.stop(); });
@@ -20,6 +20,7 @@ t('boot', async () => {
@@ -20,6 +20,7 @@ t('boot', async () => {
await svc.store.apply(doc({ id: 'prv', visibility: 'private', acl: { subjects: [alice] }, title: 'Kestrel private diary', body: 'kestrel' }));
await svc.store.apply(doc({ id: 'prv', visibility: 'private', acl: { subjects: [alice] }, title: 'Kestrel private diary', body: 'kestrel' }));
await svc.store.apply(doc({ id: 'drf', visibility: 'draft', title: 'Kestrel draft', body: 'kestrel' }));
await svc.store.apply(doc({ id: 'drf', visibility: 'draft', title: 'Kestrel draft', body: 'kestrel' }));
for (let i = 0; i < 25; i++) await svc.store.apply(doc({ id: `many${i}`, title: `Heron sighting ${i}`, body: 'heron' }));
for (let i = 0; i < 25; i++) await svc.store.apply(doc({ id: `many${i}`, title: `Heron sighting ${i}`, body: 'heron' }));
+ await svc.store.apply(doc({ id: 'osp', title: 'Osprey notes', summary: 'Field notes', body: 'An osprey dives for fish at dawn near the kestrel boxes on the river', canonical_url: 'https://openvibe.wiki/p/osprey' }));
});
});
t('the front page is a search form, indexable, no-store, with a strict CSP', async () => {
t('the front page is a search form, indexable, no-store, with a strict CSP', async () => {
@@ -122,6 +123,27 @@ t('results show public documents only, escaped, linked to their canonical URL, n
@@ -122,6 +123,27 @@ t('results show public documents only, escaped, linked to their canonical URL, n
assert.ok(!/private diary|Kestrel draft/.test(r.text));
assert.ok(!/private diary|Kestrel draft/.test(r.text));
});
});
+t('a result says where it comes from, and a passage that only repeats the title gives way to the summary', async () => {
+ const r = await html('/?q=kestrel');
+ // The source row: the site's monogram, its name, the kind of document and the day.
+ assert.match(r.text, /<div class="r-src"><span class="r-ic" aria-hidden="true">W<\/span><span class="r-site">OpenVibe\.Wiki<\/span><span class="r-kind">Wiki page<\/span><time datetime="[^"]+">[A-Z][a-z]{2} \d{1,2}(, \d{4})?<\/time><\/div>/);
+ // "kestrel kestrel" says nothing the title does not: the summary is shown instead, escaped.
+ const pub = r.text.slice(r.text.indexOf('href="https://openvibe.wiki/p/kestrel'));
+ assert.match(pub.slice(0, 400), /<p class="snip">Kestrel "boxes" & more<\/p>/);
+ // A passage with words of its own keeps its highlight.
+ const osp = r.text.slice(r.text.indexOf('href="https://openvibe.wiki/p/osprey"'));
+ assert.match(osp.slice(0, 600), /<p class="snip">[^<]*<mark>kestrel<\/mark>/i);
+ // A compact heading on a result page; the Alpha note moves to the foot.
+ assert.match(r.text, /<h1 class="h-compact"><a href="\/">OpenVibe\.Search<\/a><\/h1>/);
+ assert.ok(!r.text.includes('<p class="lede">'));
+ assert.match(r.text, /Alpha: the index holds only what the sites have sent so far\./);
+});
+
+t('a search narrowed to one site has a row to widen it again', async () => {
+ const r = await html('/?q=heron&owner=wiki');
+ assert.match(r.text, /<nav class="r-filter" aria-label="Narrow to one site"><a class="chip" href="\/\?q=heron">All sites<\/a><a class="chip on" href="\/\?q=heron&owner=wiki" aria-current="page">Wiki<\/a><\/nav>/);
+});
+
t('a signed-in cookie adds that person\'s documents; a bad cookie searches as anonymous', async () => {
t('a signed-in cookie adds that person\'s documents; a bad cookie searches as anonymous', async () => {
const mine = await html('/?q=kestrel', { Cookie: `ov_token=${userToken({ subjectId: alice, aud: ['openvibe.search'] })}` });
const mine = await html('/?q=kestrel', { Cookie: `ov_token=${userToken({ subjectId: alice, aud: ['openvibe.search'] })}` });
assert.match(mine.text, /Kestrel private diary/);
assert.match(mine.text, /Kestrel private diary/);