openvibers/openvibe.search Commits
The Search front page opens with the showcase kit and browse-by-source cards (#12)
Changes
@@@ -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"@@@ -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": {@@@ -73,7 +73,7 @@ function createApp({ config, db, store, engine, auth, outbox, relay, purges, purapp.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 }));@@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 thedefaults 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.networkif (!(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;@@@ -58,6 +58,14 @@ t('the front page is a search form, indexable, no-store, with a strict CSP', asyassert.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">/);
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.96.0",
"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-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"
"pg": "^8.23.0"
},
},
"devDependencies": {
"devDependencies": {
@@ -795,9 +795,9 @@
@@ -795,9 +795,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"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",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -27,7 +27,7 @@
@@ -27,7 +27,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.96.0",
"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-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"
"pg": "^8.23.0"
},
},
"engines": {
"engines": {
@@ -73,7 +73,7 @@ function createApp({ config, db, store, engine, auth, outbox, relay, purges, pur
@@ -73,7 +73,7 @@ function createApp({ config, db, store, engine, auth, outbox, relay, purges, pur
app.use(savedRouter({ config, saved, searcher, auth, limits }));
app.use(savedRouter({ config, saved, searcher, auth, limits }));
// GET / (HTML search page for browsers, the text route index otherwise), /robots.txt, /llms.txt and
// 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).
// /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 }));
app.use((req, res) => http.sendProblem(res, 404, 'search.not_found', { detail: `no route ${req.method} ${req.path}`, ctx: req.ov }));
@@ -37,6 +37,8 @@ const CF_BEACON = 'https://static.cloudflareinsights.com', CF_REPORT = 'https://
@@ -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).
// The Events realtime stream: release notifications (release-watch's EventSource, openvibe-shared 1.17).
const EVENTS = 'https://events.openvibe.network';
const EVENTS = 'https://events.openvibe.network';
const shell = require('openvibe-shared/shell');
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 frame = require('openvibe-shared/frame');
const LINKS = [{ label: 'Search', href: '/' }, { label: 'Updates', href: '/updates' }];
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.
// 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
@@ -51,7 +53,7 @@ const FOOTER_INIT = `window.addEventListener('DOMContentLoaded', function () { t
// them by hash and still has no 'unsafe-inline'.
// them by hash and still has no 'unsafe-inline'.
const sha256 = (js) => `'sha256-${require('crypto').createHash('sha256').update(js, 'utf8').digest('base64')}'`;
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 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 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
@@ -180,7 +182,39 @@ function sitemapXml(origin, { indexLastmod = null, releasedAt = null } = {}) {
@@ -180,7 +182,39 @@ function sitemapXml(origin, { indexLastmod = null, releasedAt = null } = {}) {
return seo.sitemapXml(entries);
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;
const url = noindex ? undefined : canonical;
return shell.page({
return shell.page({
...FRAME,
...FRAME,
@@ -194,6 +228,8 @@ function layout({ title, q, owner, type, body, noindex, canonical, jsonLd }) {
@@ -194,6 +228,8 @@ function layout({ title, q, owner, type, body, noindex, canonical, jsonLd }) {
`<meta name="ov-boost" content="search@${esc(RELEASE)}">`,
`<meta name="ov-boost" content="search@${esc(RELEASE)}">`,
`<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
`<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
`<script>${FOOTER_INIT}</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>
`<style>
/* The network theme (the Frame's theme loader sets these tokens on <html>) wins; the values here are the
/* 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
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; }
@@ -224,8 +260,12 @@ mark { background: var(--mark); color: inherit; }
].join('\n'),
].join('\n'),
body: `<div id="navbar-mount"></div>
body: `<div id="navbar-mount"></div>
<main id="main">
<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">
<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>
${owner ? `<input type="hidden" name="owner" value="${esc(owner)}">` : ''}${type ? `<input type="hidden" name="type" value="${esc(type)}">` : ''}<button type="submit">Search</button>
${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
@@ -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.
/** 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
* 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). */
* 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 origin = String(baseUrl).replace(/\/+$/, '');
const router = express.Router();
const router = express.Router();
@@ -313,7 +353,8 @@ function pageRouter({ searcher, auth, baseUrl = 'https://search.openvibe.network
@@ -313,7 +353,8 @@ function pageRouter({ searcher, auth, baseUrl = 'https://search.openvibe.network
if (!(err instanceof AuthError)) return next(err);
if (!(err instanceof AuthError)) return next(err);
viewer = null; // a browser page never 401s: an unverifiable credential searches as anonymous
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;
let status = 200;
if (searching) {
if (searching) {
try {
try {
@@ -337,7 +378,7 @@ function pageRouter({ searcher, auth, baseUrl = 'https://search.openvibe.network
@@ -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.
// 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);
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;
return router;
@@ -58,6 +58,14 @@ t('the front page is a search form, indexable, no-store, with a strict CSP', asy
@@ -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.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');
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, /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');
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.
// 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 () => {
@@ -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 () => {
t('results show public documents only, escaped, linked to their canonical URL, noindex', async () => {
const r = await html('/?q=kestrel');
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.status, 200);
assert.strictEqual(r.headers.get('x-robots-tag'), 'noindex, nofollow');
assert.strictEqual(r.headers.get('x-robots-tag'), 'noindex, nofollow');
assert.match(r.text, /<meta name="robots" content="noindex, nofollow">/);
assert.match(r.text, /<meta name="robots" content="noindex, nofollow">/);