openvibers/openvibe.search Commits

The Search front page opens with the showcase kit and browse-by-source cards (#12)

OP OpenVibers committed 5 Oct 2026 · a50eba2b0a · 5 files, +63 −13

* The Search front page opens with the showcase kit and browse-by-source cards (plan T11)

The plain front page (no query, status 200) now starts with
openvibe-shared/showcase: a hero ("Search the OpenVibe network"), the
search box right under it, then "Browse by source": one card per source
the index accepts (SEARCH_EVENT_OWNERS), each linking its filtered search
(/?owner=<id>). Result pages are unchanged: no kit, no kit CSS, noindex.

- The hero is the page's one h1; result pages keep the OpenVibe.Search h1.
- showcase.css is same-origin (/shared/showcase.css), so style-src gains
  'self'; it and the column rules load on the front page only.
- openvibe-shared v2.6.0 -> v2.9.0 (package.json + lockfile).
- test/page.test.js: the hero, the sources section, the stylesheet, the
  CSP, one h1, the form after the hero, and no showcase on a result page.

* Search's Trade card says what Trade is: watchlists and market context, no listings (review)

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 360c2f4..3f2f3ee 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,7 +15,7 @@
"express": "^4.21.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.96.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.9.0",
"pg": "^8.23.0"
},
"devDependencies": {
@@@ -795,9 +795,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "2.6.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0",
-- "integrity": "sha512-IKKvWTM/9UonQ4msrzM4yir0QcTUNbhyqeN1r5ywWavU4VTsL1sI5QdHQlxge7BAsMrQ4OvMEPlHjuAfUJM3rg==",
++ "version": "2.9.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.9.0",
++ "integrity": "sha512-ESXg23tqj73C14KvoT3kC/6VfZEkjpN1Q9fdipQ3bqIGfxiiSQIz0osGzthHHm3okruCq6aDQld1kFP9tx8+kA==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex d802ea8..3584f21 100644
---- a/package.json
++++ b/package.json
@@@ -27,7 +27,7 @@
"express": "^4.21.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.96.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.6.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.9.0",
"pg": "^8.23.0"
},
"engines": {
ddiff --git a/server/app.js b/server/app.js
iindex 6297bdb..0aada15 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -73,7 +73,7 @@ function createApp({ config, db, store, engine, auth, outbox, relay, purges, pur
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 }));
++ app.use(pageRouter({ searcher, auth, baseUrl: config.baseUrl, newestPublic: () => store.newestPublic(), releasedAt: release.full().released_at, owners: config.events.owners }));
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/web/page.js b/server/web/page.js
iindex 602fa02..9c4a0a9 100644
---- a/server/web/page.js
++++ b/server/web/page.js
@@@ -37,6 +37,8 @@ const CF_BEACON = 'https://static.cloudflareinsights.com', CF_REPORT = 'https://
// The Events realtime stream: release notifications (release-watch's EventSource, openvibe-shared 1.17).
const EVENTS = 'https://events.openvibe.network';
const shell = require('openvibe-shared/shell');
++const showcase = require('openvibe-shared/showcase');
++const { DEFAULT_EVENT_OWNERS } = require('../config');
const frame = require('openvibe-shared/frame');
const LINKS = [{ label: 'Search', href: '/' }, { label: 'Updates', href: '/updates' }];
// The shell options every page shares: the navbar it boots and the noscript nav and footer it renders.
@@@ -51,7 +53,7 @@ const FOOTER_INIT = `window.addEventListener('DOMContentLoaded', function () { t
// them by hash and still has no 'unsafe-inline'.
const sha256 = (js) => `'sha256-${require('crypto').createHash('sha256').update(js, 'utf8').digest('base64')}'`;
const INLINE = [...shell.scripts(FRAME).matchAll(/<script>([\s\S]*?)<\/script>/g)].map((m) => m[1]).concat(FOOTER_INIT);
--const CSP = `default-src 'none'; script-src 'self' ${NETWORK} ${CF_BEACON} ${INLINE.map(sha256).join(' ')}; connect-src 'self' ${NETWORK} ${CF_REPORT} ${EVENTS}; style-src 'unsafe-inline' ${NETWORK}; img-src 'self' data: https:; frame-src ${NETWORK}; form-action 'self' ${NETWORK}; base-uri 'none'; frame-ancestors 'none'`;
++const CSP = `default-src 'none'; script-src 'self' ${NETWORK} ${CF_BEACON} ${INLINE.map(sha256).join(' ')}; connect-src 'self' ${NETWORK} ${CF_REPORT} ${EVENTS}; style-src 'self' 'unsafe-inline' ${NETWORK}; img-src 'self' data: https:; frame-src ${NETWORK}; form-action 'self' ${NETWORK}; base-uri 'none'; frame-ancestors 'none'`;
const ICON_LINKS = require('openvibe-shared/app-icon').headTags({ site: 'network', iconBase: `${NETWORK}/assets` }).split('\n').filter((l) => l.startsWith('<link') && !/rel="manifest"/.test(l)).join('\n');
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
@@@ -180,7 +182,39 @@ function sitemapXml(origin, { indexLastmod = null, releasedAt = null } = {}) {
return seo.sitemapXml(entries);
}
--function layout({ title, q, owner, type, body, noindex, canonical, jsonLd }) {
++// The front page's "browse by source" cards: each source the index accepts, with what it sends, linking to its filtered
++// search (?owner=, which lists that source's public documents). Names and lines only; nothing here is counted or claimed.
++const SOURCES = {
++ wiki: ['OpenVibe.Wiki', 'Wiki pages with their sources and history.'],
++ blog: ['OpenVibe.Blog', 'Posts from every blog on the network.'],
++ news: ['OpenVibe.News', 'Source-backed stories.'],
++ reviews: ['OpenVibe.Reviews', 'Reviews and the evidence behind them.'],
++ deals: ['OpenVibe.Deals', 'Deals people found.'],
++ coupons: ['OpenVibe.Coupons', 'Coupon codes with their restrictions and reports.'],
++ trade: ['OpenVibe.Trade', 'Watchlists and sourced market context, with no orders or custody.'],
++ community: ['OpenVibe.Community', 'Public pastes and posts.'],
++ live: ['OpenVibe.Live', 'Channels, streams and clips.'],
++ media: ['OpenVibe.Media', 'Public videos, clips and files.'],
++ codes: ['OpenVibe.Codes', 'The developer docs and API reference.'],
++ games: ['OpenVibe.Games', 'Browser games.'],
++ tools: ['OpenVibe.Tools', 'Every online tool, by what it does.'],
++ sources: ['OpenVibe.Sources', 'The registry of news and data sources.'],
++};
++const ICON_OF = { sources: 'docs' };
++// 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; }
++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; }
++</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 layout({ title, q, owner, type, body, noindex, canonical, jsonLd, front = false }) {
const url = noindex ? undefined : canonical;
return shell.page({
...FRAME,
@@@ -194,6 +228,8 @@ function layout({ title, q, owner, type, body, noindex, canonical, jsonLd }) {
`<meta name="ov-boost" content="search@${esc(RELEASE)}">`,
`<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
`<script>${FOOTER_INIT}</script>`,
++ // The kit's sheet on the front page only (its hero and source cards); result pages stay as light as before.
++ ...(front ? [`<link rel="stylesheet" href="${ovServe.url('showcase.css')}">`, FRONT_CSS] : []),
`<style>
/* The network theme (the Frame's theme loader sets these tokens on <html>) wins; the values here are the
defaults when it does not load. Search's own names follow them, so the page and the shared widgets
@@@ -224,8 +260,12 @@ mark { background: var(--mark); color: inherit; }
].join('\n'),
body: `<div id="navbar-mount"></div>
<main id="main">
--<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>
++${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.",
++ }) : `<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>
@@@ -255,7 +295,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 }) {
++function pageRouter({ searcher, auth, baseUrl = 'https://search.openvibe.network', newestPublic = null, releasedAt = null, owners = DEFAULT_EVENT_OWNERS }) {
const origin = String(baseUrl).replace(/\/+$/, '');
const router = express.Router();
@@@ -313,7 +353,8 @@ function pageRouter({ searcher, auth, baseUrl = 'https://search.openvibe.network
if (!(err instanceof AuthError)) return next(err);
viewer = null; // a browser page never 401s: an unverifiable credential searches as anonymous
}
-- let body = '<p class="note">Type some words to search. Filters: <code>?owner=wiki</code>, <code>?type=page</code>.</p>' + frame.shipped({ service: 'search', title: 'Recently shipped on OpenVibe.Search' });
++ // 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' });
let status = 200;
if (searching) {
try {
@@@ -337,7 +378,7 @@ 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 }));
++ 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 }));
});
return router;
ddiff --git a/test/page.test.js b/test/page.test.js
iindex 0fe40ce..c8b7738 100644
---- a/test/page.test.js
++++ b/test/page.test.js
@@@ -58,6 +58,14 @@ t('the front page is a search form, indexable, no-store, with a strict CSP', asy
assert.ok(allowed.includes(crypto.createHash('sha256').update(body, 'utf8').digest('base64')), `inline script allowed by its hash: ${body.slice(0, 60)}`);
}
assert.strictEqual(inline, 2, 'the shell boot and the footer init');
++ // The front page opens with the showcase kit (openvibe-shared/showcase): its hero is the one h1, its sheet is
++ // same-origin (style-src 'self'), and the sources to browse link their filtered searches.
++ assert.ok(r.text.includes('class="sc-hero') && r.text.includes('<section class="sc-sec" id="sources"'), 'hero and sources');
++ assert.match(r.text, /<link rel="stylesheet" href="\/shared\/showcase\.css\?v=[^"]+">/);
++ assert.match(csp, /style-src 'self' 'unsafe-inline' https:\/\/openvibe\.network;/);
++ assert.strictEqual((r.text.match(/<h1[\s>]/g) || []).length, 1, 'one h1');
++ assert.ok(r.text.includes('href="/?owner=wiki"'), 'browse the wiki');
++ assert.ok(r.text.indexOf('class="sc-hero') < r.text.indexOf('role="search"'), 'the search form follows the hero');
assert.match(r.text, /OpenVibeNavbar\.init\(\{"service":"search"/, 'the shell boots the navbar');
assert.match(r.text, /OpenVibeFooter\.init\(\{"service":"search"[^)]*"mount":"#ov-footer"/, 'the footer is upgraded in place');
// The rendered head (openvibe-shared/shell): one title, the canonical, robots, the AI summary and JSON-LD.
@@@ -98,6 +106,7 @@ t('/updates is the shared log; the old Frame init script is gone', async () => {
t('results show public documents only, escaped, linked to their canonical URL, noindex', async () => {
const r = await html('/?q=kestrel');
++ assert.ok(!r.text.includes('sc-hero') && !r.text.includes('showcase.css'), 'a result page has no showcase');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.headers.get('x-robots-tag'), 'noindex, nofollow');
assert.match(r.text, /<meta name="robots" content="noindex, nofollow">/);