openvibers/openvibe.media Commits
Compose the media index on the shared shell with a hero and AI summary
Changes
@@@ -4,7 +4,8 @@* Server-rendered browse UI at GET / — a mass index of every public piece of* media this service owns, tabbed by kind (Videos / Clips / Thumbnails / Files).* Each card carries its description / AI overview and a link to its source page-- * in the owning app. Dependency-free: inline CSS, no client JS required.++ * in the owning app. Composed by page-frame (the shared shell) with the showcase hero on++ * the first page; no client JS required.*/'use strict';@@@ -17,9 +18,11 @@ const PAGE_SIZE = 48;const THUMB_DIR = path.resolve(config.thumbnails.path);// Public base URL per app for "source" links — shared with the watch pages.--const { appUrl, DEFAULT_THEME_CSS } = require('./page-frame');++const pageFrame = require('./page-frame');++const { appUrl, isoDate } = pageFrame;const cache = require('openvibe-shared/cache-policy');const frame = require('openvibe-shared/frame');++const showcase = require('openvibe-shared/showcase');const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');const abs = (u) => (!u ? null : (/^https?:\/\//i.test(u) ? u : `${config.publicUrl}${u.startsWith('/') ? '' : '/'}${u}`));@@@ -190,14 +193,14 @@ async function tabCounts() {const KIND_ICON = { video: '🎬', clip: '✂️', thumbnail: '🏞️', file: '📦' };/** JSON-LD for the index's first page: the site, and the cards it lists (ItemList). Later pages are noindex. */--function browseJsonLd(config, tab, cards) {++function browseJsonLd(tab, cards) {const seo = require('openvibe-shared/seo');const abs = (u) => { try { return new URL(u, `${config.publicUrl}/`).toString(); } catch { return null; } };const items = cards.map((c) => ({ name: c.title || 'Untitled', url: abs(c.view) })).filter((it) => it.url);return [-- tab === 'all' ? seo.jsonLdTag(seo.jsonLd.website({ name: 'OpenVibe.Media', url: `${config.publicUrl}/`, description: 'Every public file on the OpenVibe network: videos, clips, files and thumbnails.' })) : '',-- items.length ? seo.jsonLdTag(seo.jsonLd.itemList(tab === 'all' ? 'Latest public media' : `Latest public ${tab}`, items, { url: `${config.publicUrl}/${tab === 'all' ? '' : `?tab=${tab}`}` })) : '',-- ].filter(Boolean).join('\n');++ tab === 'all' ? seo.jsonLd.website({ name: 'OpenVibe.Media', url: `${config.publicUrl}/`, description: 'Every public file on the OpenVibe network: videos, clips, files and thumbnails.' }) : null,++ items.length ? seo.jsonLd.itemList(tab === 'all' ? 'Latest public media' : `Latest public ${tab}`, items, { url: `${config.publicUrl}/${tab === 'all' ? '' : `?tab=${tab}`}` }) : null,++ ].filter(Boolean);}function renderCard(c) {@@@ -221,68 +224,75 @@ function renderCard(c) {const TABS = [['all', 'All'], ['videos', 'Videos'], ['clips', 'Clips'], ['emotes', 'Emotes'], ['sounds', 'Sounds'], ['thumbnails', 'Thumbnails'], ['files', 'Files']];--function renderPage(tab, page, data, counts) {-- const pages = Math.max(1, Math.ceil(data.total / PAGE_SIZE));-- const nav = (p, label, dis) => dis ? `<span class="pg dis" aria-disabled="true">${label}</span>` : `<a class="pg" href="/?tab=${tab}&page=${p}">${label}</a>`;-- return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">--<meta name="viewport" content="width=device-width, initial-scale=1">--<title>OpenVibe.Media — Media Index</title>--<meta name="description" content="Index of all public media on the OpenVibe network — videos, clips, files and thumbnails.">--<link rel="canonical" href="${config.publicUrl}/${tab === 'all' && page === 1 ? '' : `?tab=${tab}${page > 1 ? `&page=${page}` : ''}`}">--<meta name="robots" content="${page > 1 ? 'noindex, follow' : 'index, follow'}">--<meta property="og:site_name" content="OpenVibe.Media">--<meta property="og:type" content="website">--<meta property="og:title" content="OpenVibe.Media — Media Index">--<meta property="og:description" content="Every public file on the OpenVibe network — videos, clips, files and thumbnails.">--<meta property="og:url" content="${config.publicUrl}/">--<meta property="og:image" content="${config.publicUrl}/og-image.png">--<meta property="og:image:width" content="1200"><meta property="og:image:height" content="630">--<meta name="twitter:card" content="summary_large_image">--<meta name="twitter:title" content="OpenVibe.Media — Media Index">--<meta name="twitter:image" content="${config.publicUrl}/og-image.png">--${page === 1 ? browseJsonLd(config, tab, data.cards) : ''}--<!-- The shared theme-loader applies the user's theme to <html> before anything paints. -->--<script src="${require('openvibe-shared/serve').url('theme-loader.js')}" defer></script>--<style>--${DEFAULT_THEME_CSS ? `:root{${DEFAULT_THEME_CSS}}` : ''}--:root{--bg:var(--bg-primary,#0a0f1c);--panel:var(--bg-card,#131c2e);--border:#1f2d47;--text:var(--text-primary,#e6edf7);--muted:var(--text-secondary,#96a7c2);--accent-page:var(--accent,#3b82f6)}--*{box-sizing:border-box;margin:0}body{background:var(--bg);color:var(--text);font:15px/1.5 system-ui,'Segoe UI',Arial,sans-serif;padding-bottom:3rem}--header.intro{display:flex;align-items:baseline;gap:.2rem .8rem;flex-wrap:wrap;padding:1rem 1.4rem .4rem}++// The index's own rules; page-frame's baseCss supplies the palette (--panel, --line, --muted, --acc …) and the reset.++// Card rules are scoped to .card: baseCss styles .meta and .desc for the watch pages.++const BROWSE_CSS = `++header.intro{display:flex;align-items:baseline;gap:.2rem .8rem;flex-wrap:wrap;padding:0 0 .4rem}header.intro h1{font-size:1.15rem;margin:0}header.intro .sub{color:var(--muted);font-size:.85rem}--nav.tabs{display:flex;gap:.25rem;padding:.2rem 1.4rem .7rem;flex-wrap:wrap}++nav.tabs{display:flex;gap:.25rem;padding:.2rem 0 .7rem;flex-wrap:wrap}nav.tabs a{color:var(--muted);text-decoration:none;padding:.45rem .9rem;border-radius:999px;font-size:.88rem;border:1px solid transparent}nav.tabs a:hover{color:var(--text)}nav.tabs a.on{color:var(--on-accent-strong,#fff);background:var(--accent-strong,#1d4ed8)}nav.tabs a .n{opacity:.75;font-size:.78rem;margin-left:.3rem}nav.tabs a.on .n{opacity:1}--.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:1rem;padding:0 1.4rem}--.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;overflow:hidden;display:flex;flex-direction:column}--.thumb{position:relative;display:block;aspect-ratio:16/9;background:#0b0f18}--.thumb img{width:100%;height:100%;object-fit:cover;display:block}--.thumb .ph{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:2.2rem;opacity:.5}--.thumb .dur{position:absolute;right:.5rem;bottom:.5rem;background:rgba(0,0,0,.75);padding:.1rem .45rem;border-radius:6px;font-size:.75rem}--.thumb .kind{position:absolute;left:.5rem;top:.5rem;background:rgba(var(--accent-rgb,59,130,246),.85);color:var(--on-accent,#fff);padding:.1rem .5rem;border-radius:6px;font-size:.7rem;text-transform:uppercase;letter-spacing:.05em}--.meta{padding:.7rem .8rem;display:flex;flex-direction:column;gap:.35rem;flex:1}--.title{font-weight:600;font-size:.92rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}--.desc{color:var(--muted);font-size:.8rem;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}--.foot{margin-top:auto;display:flex;justify-content:space-between;gap:.6rem;color:var(--muted);font-size:.74rem;flex-wrap:wrap}--.foot a{color:var(--accent-page);text-decoration:none}.foot a:hover{text-decoration:underline}++.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:1rem}++.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;overflow:hidden;display:flex;flex-direction:column}++.card .thumb{position:relative;display:block;aspect-ratio:16/9;background:#0b0f18}++.card .thumb img{width:100%;height:100%;object-fit:cover;display:block}++.card .thumb .ph{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:2.2rem;opacity:.5}++.card .thumb .dur{position:absolute;right:.5rem;bottom:.5rem;background:rgba(0,0,0,.75);color:#fff;padding:.1rem .45rem;border-radius:6px;font-size:.75rem}++.card .thumb .kind{position:absolute;left:.5rem;top:.5rem;background:rgba(var(--accent-rgb,59,130,246),.85);color:var(--on-accent,#fff);padding:.1rem .5rem;border-radius:6px;font-size:.7rem;text-transform:uppercase;letter-spacing:.05em}++.card .meta{margin:0;padding:.7rem .8rem;display:flex;flex-direction:column;flex-wrap:nowrap;align-items:stretch;gap:.35rem;flex:1;color:var(--text);font-size:inherit;line-height:1.5}++.card .title{font-weight:600;font-size:.92rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}++.card .desc{color:var(--muted);font-size:.8rem;white-space:normal;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}++.card .foot{margin-top:auto;display:flex;justify-content:space-between;gap:.6rem;color:var(--muted);font-size:.74rem;flex-wrap:wrap}++.card .foot a{color:var(--acc);text-decoration:none}.card .foot a:hover{text-decoration:underline}.pager{display:flex;gap:.5rem;justify-content:center;align-items:center;padding:1.4rem;color:var(--muted);font-size:.88rem}--.pg{color:var(--accent-page);text-decoration:none;padding:.35rem .8rem;border:1px solid var(--border);border-radius:8px}++.pg{color:var(--acc);text-decoration:none;padding:.35rem .8rem;border:1px solid var(--line);border-radius:8px}.pg.dis{color:var(--muted);opacity:.4}.empty{padding:3rem;text-align:center;color:var(--muted)}--</style>--<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">--${require('openvibe-shared/app-icon').headTags({ site: 'media', iconBase: '/assets' })}--</head><body>--<div id="navbar-mount"></div>${frame.noscriptNav({ name: 'OpenVibe.Media' })}--<header class="intro"><h1>Every public file on the network</h1><span class="sub">videos, clips, files & thumbnails from all OpenVibe sites</span></header>++`;++++const DESCRIPTION = 'Index of all public media on the OpenVibe network — videos, clips, files and thumbnails.';++++function renderPage(tab, page, data, counts) {++ const pages = Math.max(1, Math.ceil(data.total / PAGE_SIZE));++ const nav = (p, label, dis) => dis ? `<span class="pg dis" aria-disabled="true">${label}</span>` : `<a class="pg" href="/?tab=${tab}&page=${p}">${label}</a>`;++ const first = tab === 'all' && page === 1;++ const canonical = `${config.publicUrl}/${first ? '' : `?tab=${tab}${page > 1 ? `&page=${page}` : ''}`}`;++ // The one <h1> on the page: the showcase hero on the index's front page, the plain intro elsewhere.++ const heading = first ? showcase.hero({++ eyebrow: 'OpenVibe.Media',++ title: 'Every public file on the',++ accent: 'OpenVibe network',++ lede: 'Videos, clips, emotes, sounds, files and thumbnails from every OpenVibe site, in one index. Watch and download public media without an account.',++ actions: [++ { label: 'Browse videos', href: '/?tab=videos', primary: true },++ { label: 'Watch on OpenVibe.Live', href: appUrl('live') + '/' },++ ],++ }) : '<header class="intro"><h1>Every public file on the network</h1><span class="sub">videos, clips, files & thumbnails from all OpenVibe sites</span></header>';++ const body = `${heading}<nav class="tabs" aria-label="Media types">${TABS.map(([k, label]) => `<a class="${k === tab ? 'on' : ''}" href="/?tab=${k}">${label}<span class="n">${(counts[k] ?? 0).toLocaleString()}</span></a>`).join('')}</nav>${data.cards.length ? `<div class="grid">${data.cards.map(renderCard).join('')}</div>` : '<div class="empty">Nothing here yet.</div>'}<div class="pager">${nav(page - 1, '‹ Prev', page <= 1)}<span>Page ${page} / ${pages} · ${data.total.toLocaleString()} items</span>${nav(page + 1, 'Next ›', page >= pages)}</div>--${tab === 'all' && page === 1 ? `<div style="max-width:1200px;margin:0 auto;padding:0 1rem">${frame.shipped({ service: 'media', title: 'Recently shipped on OpenVibe.Media' })}</div>` : ''}--${frame.footer({ service: 'media', variant: 'full', updates: '/updates' })}--${require('./page-frame').frameScripts({ footer: { variant: 'full' } })}--</body></html>`;++${first ? frame.shipped({ service: 'media', title: 'Recently shipped on OpenVibe.Media' }) : ''}`;++ return pageFrame.page({++ seo: {++ title: 'OpenVibe.Media — Media Index', description: DESCRIPTION, canonical,++ robots: page > 1 ? 'noindex, follow' : 'index, follow', ogType: 'website',++ jsonLd: page === 1 ? browseJsonLd(tab, data.cards) : [],++ },++ css: BROWSE_CSS,++ body,++ // Later pages are noindex and carry no structured data, the summary's WebPage included.++ summary: page === 1 ? {++ summary: 'Index of all public media on the OpenVibe network: videos, clips, emotes, sounds, files and thumbnails, browsable and playable without an account.',++ url: canonical,++ facts: [['Items', data.total.toLocaleString('en-US')]],++ updated: isoDate(data.cards[0] && data.cards[0].date) || undefined,++ } : undefined,++ history: { type: 'page', title: 'OpenVibe.Media' },++ footer: { variant: 'full' },++ });}async function handle(req, res) {@@@ -82,6 +82,8 @@ function extraHead(seo, css) {].filter(Boolean).join('\n') : '';return [appIcon.headTags({ site: 'media', iconBase: '/assets' }),++ // openvibe-shared/showcase's stylesheet (the index's hero; the kit's sections on any page).++ `<link rel="stylesheet" href="${ovServe.url('showcase.css')}">`,`<style>${baseCss()}${css}</style>`,videoTags,// The default share card is 1200×630; a media thumbnail carries its own dimensions.@@@ -158,31 +160,6 @@ function footerBootScript(footer) {return `<script>window.addEventListener('DOMContentLoaded', function () { try { OpenVibeFooter.init(${jsonForScript(footerOptions(footer))}); } catch (e) { /* footer optional */ } });</script>`;}--/**-- * The scripts that mount the navbar + footer, after the page content so a-- * slow Network never blocks the body. Used by the hand-written browse index-- * (server/public/browse.js); the shell pages get the same navbar options through-- * shell.page() plus footerBootScript() (the shell does not initialise the footer),-- * and the footer is rendered by frame.footer.-- */--function frameScripts({ history, footer } = {}) {-- const navOpts = navOptions({ history });-- const footOpts = footerOptions(footer);-- return `<script src="${ovServe.url('navbar.js')}" defer></script>--<script src="${ovServe.url('footer.js')}" defer></script>--<script>--(function () {-- var tries = 0;-- function boot() {-- if (!window.OpenVibeNavbar || !window.OpenVibeFooter) { if (++tries < 60) setTimeout(boot, 100); return; }-- try { OpenVibeNavbar.init(${jsonForScript(navOpts)}); } catch (e) { /* navbar optional */ }-- try { OpenVibeFooter.init(${jsonForScript(footOpts)}); } catch (e) { /* footer optional */ }-- }-- if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();--})();--</script>`;--}--function defaultFooterLinks() {return [{@@@ -204,9 +181,11 @@ function defaultFooterLinks() {* `body` is the <main> contents. The shell emits the theme-loader/web-runtime/navbar/footer scripts,* the no-JavaScript nav and the shared footer; `history` and `footer` become the navbar init options* and the server-rendered footer. The site's app icon, palette and Open Graph video tags ride in-- * through the shell's `head`.++ * through the shell's `head`. `summary` is the page's AI summary (openvibe-shared/seo pageSummary): a++ * string, or { summary, url, facts: [[label, value]], updated }; the url defaults to the canonical.*/--function page({ seo, css = '', body, history, footer }) {++function page({ seo, css = '', body, history, footer, summary }) {++ const sum = summary == null ? null : typeof summary === 'object' ? summary : { summary };const html = shell.page({name: SITE_NAME,lang: 'en',@@@ -218,8 +197,9 @@ function page({ seo, css = '', body, history, footer }) {type: seo.ogType,robots: seo.robots || 'index, follow',jsonLd: seo.jsonLd,++ ...(sum ? { summary: sum.summary, facts: sum.facts, url: sum.url, updated: sum.updated } : {}),head: extraHead(seo, css) + footerBootScript(footer),-- body: `<div id="navbar-mount"></div>\n<main>\n${body}\n</main>`,++ body: `<div id="navbar-mount"></div>\n<main id="main">\n${body}\n</main>`,navLinks: [{ label: 'Videos', href: '/?tab=videos' }, { label: 'Clips', href: '/?tab=clips' }],navbar: navOptions({ history }),footer: footerOptions(footer),@@@ -236,5 +216,5 @@ function page({ seo, css = '', body, history, footer }) {module.exports = {NETWORK_URL, SITE_NAME, DEFAULT_OG_IMAGE, APP_PUBLIC_URLS, appUrl,esc, abs, snip, isoDate, isoDuration, fmtDuration, fmtDate, jsonForScript,-- extraHead, baseCss, frameScripts, navOptions, footerOptions, defaultFooterLinks, page, DEFAULT_THEME_CSS,++ extraHead, baseCss, navOptions, footerOptions, defaultFooterLinks, page, DEFAULT_THEME_CSS,};@@@ -173,16 +173,25 @@ function renderWatchPage(kind, record, readiness = null) {.player .state-detail { margin: 0; color: var(--muted); }.h2 { font-size: 1rem; margin: 1.2rem 0 .3rem; color: var(--text); }`;++ // Structured data and the AI summary describe public items only; unlisted and private pages carry none.++ const described = playable && (record.visibility || (record.is_public ? 'public' : 'private')) === 'public';return pageFrame.page({seo: {title: `${title} — ${SITE_NAME}`, description, canonical, robots,image: thumb || undefined, ogType: playable ? 'video.other' : 'website',// No playable bytes: no video URL for previews and no VideoObject that names them.video: playable ? { url: rawUrl, type: mime } : undefined,-- // Structured data describes public items only; unlisted and private pages carry none.-- jsonLd: playable && (record.visibility || (record.is_public ? 'public' : 'private')) === 'public' ? [videoObject] : [],++ jsonLd: described ? [videoObject] : [],},css, body,++ summary: described ? {++ summary: description, url: canonical,++ facts: [++ ['Duration', record.duration_seconds ? fmtDuration(record.duration_seconds) : null],++ ['Views', record.view_count != null ? Number(record.view_count).toLocaleString('en-US') : null],++ ].filter(([, v]) => v != null),++ updated: uploadDate || undefined,++ } : undefined,history: { type: isVod ? 'vod' : 'clip', title },footer: { variant: 'compact' },});@@@ -18,10 +18,12 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');jsFiles: 4, // 3// 2026-09-27: theme-loader (44.3 KB, 8.3 brotli) now comes from Media's own /shared (D42) instead of// openvibe.network, so it is counted here; the page itself did not grow.-- jsRawKB: 215, // 197.7-- jsBrotliKB: 50, // 45.6-- cssFiles: 2, // 1 (Font Awesome, cross-origin)-- cssRawKB: 10, // 0 same-origin++ // 2026-10-13 (T11): the index is composed by openvibe-shared/shell like the watch pages, which adds the++ // shared web-runtime.js; the showcase stylesheet (same-origin) replaces Font Awesome (cross-origin).++ jsRawKB: 240, // 230.9 (was 197.7)++ jsBrotliKB: 56, // 53.7 (was 45.6)++ cssFiles: 2, // 1 (showcase.css)++ cssRawKB: 13, // 11.6 same-origin (showcase.css; was 0)externalFiles: 3, // 2};@@@ -67,6 +67,11 @@ const http = require('http');assert.ok(other.includes('<meta name="robots" content="index, follow">'));const unlisted = pages.renderWatchPage('clip', { ...vod, id: 8, app_id: 'games', visibility: 'unlisted' });assert.ok(unlisted.includes('content="noindex, follow"'), 'unlisted never indexes');++ // The AI summary (openvibe-shared/seo pageSummary) on public watch pages: description, canonical, facts.++ assert.ok(html.includes('<meta name="ai-summary" content="We built a thing & it worked.">'), 'the watch page carries the AI summary');++ assert.ok(html.includes('<li>Duration: 1:02:05</li>') && html.includes('<li>Views: 1,234</li>'), 'with its facts');++ assert.ok(html.includes('"@type":"WebPage"') && html.includes('"url":"https://openvibe.live/vod/42"'), 'its WebPage names the canonical');++ assert.ok(!unlisted.includes('name="ai-summary"'), 'unlisted pages carry none');// Paste pages are Community's: there is no viewer here (public/routes.js only redirects).assert.ok(!/\bfree\b/i.test(html + pageFrame.baseCss()), 'no cost claims in served HTML');@@@ -90,6 +95,7 @@ const http = require('http');assert.strictEqual(r.status, 200);assert.ok(r.headers['content-type'].startsWith('text/html'));assert.ok(r.body.includes('Router VOD') && r.body.includes('id="navbar-mount"'));++ assert.ok(r.body.includes('<main id="main">'));// No Content-Security-Policy on a page response today: the shell composes inline scripts// (theme boot, navbar/runtime boot, footer init), so adding a policy later must allow them.assert.ok(!('content-security-policy' in r.headers), 'watch pages carry no CSP header');@@@ -107,8 +113,24 @@ const http = require('http');assert.ok(ld.some((x) => x['@type'] === 'WebSite'), 'the index names the site');const list = ld.find((x) => x['@type'] === 'ItemList');assert.ok(list && list.itemListElement.length >= 1 && list.itemListElement.every((it) => /^https?:\/\//.test(it.url)), 'the listed cards, with absolute URLs');++ const h1s = (html) => (html.match(/<h1[\s>]/g) || []).length;++ assert.ok(r.body.includes('class="sc-hero'), 'the index opens with the showcase hero');++ assert.ok(/\/shared\/showcase\.css\?v=[0-9a-f]{12}/.test(r.body), 'the kit stylesheet, through ovServe');++ assert.ok(r.body.includes('id="navbar-mount"') && r.body.includes('<main id="main">'));++ assert.ok(r.body.includes('<meta name="ai-summary"'), 'the index carries the AI summary');++ assert.ok(ld.some((x) => x['@type'] === 'WebPage'), 'the summary\'s WebPage');++ assert.strictEqual(h1s(r.body), 1, 'exactly one <h1>');++ assert.strictEqual((r.body.match(/<!doctype/gi) || []).length, 1, 'one document');++ for (const k of ['all', 'videos', 'clips', 'emotes', 'sounds', 'thumbnails', 'files']) assert.ok(r.body.includes(`href="/?tab=${k}"`), `tab ${k}`);++ assert.ok(r.body.includes('class="pager"') && r.body.includes('Page 1 /'), 'the pager');++ assert.ok(r.body.includes('<meta name="robots" content="index, follow">'));++ r = await get('/?tab=videos', nav);++ assert.ok(!r.body.includes('sc-hero') && h1s(r.body) === 1, 'the hero is the front page\'s only');r = await get('/?page=2', nav);assert.ok(!r.body.includes('application/ld+json'), 'a later (noindex) page carries none');++ assert.ok(!r.body.includes('sc-hero') && h1s(r.body) === 1, 'no hero after page 1');++ assert.ok(r.body.includes('<meta name="robots" content="noindex, follow">'));++ assert.ok(r.body.includes('Page 2 /') && r.body.includes('href="/?tab=all&page=1"'), 'the pager links back');r = await get('/og-image.png', {});assert.strictEqual(r.status, 200);assert.strictEqual(r.headers['content-type'], 'image/png');@@@ -13,8 +13,8 @@ const assert = require('assert');assert.ok(css.includes(`${k}:${DEFAULT_VARS[k]}`), `the page defines ${k} as the default theme does`);}assert.ok(!/url\(|;\s*}/.test(css.split('\n').find((l) => l.includes('--accent:')) || ''), 'only plain token values');-- // The index page (browse.js) writes its own <style>: it carries the same tokens.++ // The index page (browse.js) is composed by page-frame too, so it carries the same tokens.const src = require('fs').readFileSync(require('path').join(__dirname, '..', 'server', 'public', 'browse.js'), 'utf8');-- assert.ok(/<style>\n\$\{DEFAULT_THEME_CSS \?/.test(src), 'browse.js puts the default tokens first in its style');++ assert.ok(/pageFrame\.page\(/.test(src) && !/<!DOCTYPE/i.test(src), 'browse.js renders through page-frame, not its own document');console.log('theme default: all checks passed');})().catch((err) => { console.error(err); process.exit(1); });
@@ -4,7 +4,8 @@
@@ -4,7 +4,8 @@
* Server-rendered browse UI at GET / — a mass index of every public piece of
* Server-rendered browse UI at GET / — a mass index of every public piece of
* media this service owns, tabbed by kind (Videos / Clips / Thumbnails / Files).
* media this service owns, tabbed by kind (Videos / Clips / Thumbnails / Files).
* Each card carries its description / AI overview and a link to its source page
* Each card carries its description / AI overview and a link to its source page
- * in the owning app. Dependency-free: inline CSS, no client JS required.
+ * in the owning app. Composed by page-frame (the shared shell) with the showcase hero on
+ * the first page; no client JS required.
*/
*/
'use strict';
'use strict';
@@ -17,9 +18,11 @@ const PAGE_SIZE = 48;
@@ -17,9 +18,11 @@ const PAGE_SIZE = 48;
const THUMB_DIR = path.resolve(config.thumbnails.path);
const THUMB_DIR = path.resolve(config.thumbnails.path);
// Public base URL per app for "source" links — shared with the watch pages.
// Public base URL per app for "source" links — shared with the watch pages.
-const { appUrl, DEFAULT_THEME_CSS } = require('./page-frame');
+const pageFrame = require('./page-frame');
+const { appUrl, isoDate } = pageFrame;
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
+const showcase = require('openvibe-shared/showcase');
const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
const abs = (u) => (!u ? null : (/^https?:\/\//i.test(u) ? u : `${config.publicUrl}${u.startsWith('/') ? '' : '/'}${u}`));
const abs = (u) => (!u ? null : (/^https?:\/\//i.test(u) ? u : `${config.publicUrl}${u.startsWith('/') ? '' : '/'}${u}`));
@@ -190,14 +193,14 @@ async function tabCounts() {
@@ -190,14 +193,14 @@ async function tabCounts() {
const KIND_ICON = { video: '🎬', clip: '✂️', thumbnail: '🏞️', file: '📦' };
const KIND_ICON = { video: '🎬', clip: '✂️', thumbnail: '🏞️', file: '📦' };
/** JSON-LD for the index's first page: the site, and the cards it lists (ItemList). Later pages are noindex. */
/** JSON-LD for the index's first page: the site, and the cards it lists (ItemList). Later pages are noindex. */
-function browseJsonLd(config, tab, cards) {
+function browseJsonLd(tab, cards) {
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
const abs = (u) => { try { return new URL(u, `${config.publicUrl}/`).toString(); } catch { return null; } };
const abs = (u) => { try { return new URL(u, `${config.publicUrl}/`).toString(); } catch { return null; } };
const items = cards.map((c) => ({ name: c.title || 'Untitled', url: abs(c.view) })).filter((it) => it.url);
const items = cards.map((c) => ({ name: c.title || 'Untitled', url: abs(c.view) })).filter((it) => it.url);
return [
return [
- tab === 'all' ? seo.jsonLdTag(seo.jsonLd.website({ name: 'OpenVibe.Media', url: `${config.publicUrl}/`, description: 'Every public file on the OpenVibe network: videos, clips, files and thumbnails.' })) : '',
- items.length ? seo.jsonLdTag(seo.jsonLd.itemList(tab === 'all' ? 'Latest public media' : `Latest public ${tab}`, items, { url: `${config.publicUrl}/${tab === 'all' ? '' : `?tab=${tab}`}` })) : '',
- ].filter(Boolean).join('\n');
+ tab === 'all' ? seo.jsonLd.website({ name: 'OpenVibe.Media', url: `${config.publicUrl}/`, description: 'Every public file on the OpenVibe network: videos, clips, files and thumbnails.' }) : null,
+ items.length ? seo.jsonLd.itemList(tab === 'all' ? 'Latest public media' : `Latest public ${tab}`, items, { url: `${config.publicUrl}/${tab === 'all' ? '' : `?tab=${tab}`}` }) : null,
+ ].filter(Boolean);
}
}
function renderCard(c) {
function renderCard(c) {
@@ -221,68 +224,75 @@ function renderCard(c) {
@@ -221,68 +224,75 @@ function renderCard(c) {
const TABS = [['all', 'All'], ['videos', 'Videos'], ['clips', 'Clips'], ['emotes', 'Emotes'], ['sounds', 'Sounds'], ['thumbnails', 'Thumbnails'], ['files', 'Files']];
const TABS = [['all', 'All'], ['videos', 'Videos'], ['clips', 'Clips'], ['emotes', 'Emotes'], ['sounds', 'Sounds'], ['thumbnails', 'Thumbnails'], ['files', 'Files']];
-function renderPage(tab, page, data, counts) {
- const pages = Math.max(1, Math.ceil(data.total / PAGE_SIZE));
- const nav = (p, label, dis) => dis ? `<span class="pg dis" aria-disabled="true">${label}</span>` : `<a class="pg" href="/?tab=${tab}&page=${p}">${label}</a>`;
- return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
-<meta name="viewport" content="width=device-width, initial-scale=1">
-<title>OpenVibe.Media — Media Index</title>
-<meta name="description" content="Index of all public media on the OpenVibe network — videos, clips, files and thumbnails.">
-<link rel="canonical" href="${config.publicUrl}/${tab === 'all' && page === 1 ? '' : `?tab=${tab}${page > 1 ? `&page=${page}` : ''}`}">
-<meta name="robots" content="${page > 1 ? 'noindex, follow' : 'index, follow'}">
-<meta property="og:site_name" content="OpenVibe.Media">
-<meta property="og:type" content="website">
-<meta property="og:title" content="OpenVibe.Media — Media Index">
-<meta property="og:description" content="Every public file on the OpenVibe network — videos, clips, files and thumbnails.">
-<meta property="og:url" content="${config.publicUrl}/">
-<meta property="og:image" content="${config.publicUrl}/og-image.png">
-<meta property="og:image:width" content="1200"><meta property="og:image:height" content="630">
-<meta name="twitter:card" content="summary_large_image">
-<meta name="twitter:title" content="OpenVibe.Media — Media Index">
-<meta name="twitter:image" content="${config.publicUrl}/og-image.png">
-${page === 1 ? browseJsonLd(config, tab, data.cards) : ''}
-<!-- The shared theme-loader applies the user's theme to <html> before anything paints. -->
-<script src="${require('openvibe-shared/serve').url('theme-loader.js')}" defer></script>
-<style>
-${DEFAULT_THEME_CSS ? `:root{${DEFAULT_THEME_CSS}}` : ''}
-:root{--bg:var(--bg-primary,#0a0f1c);--panel:var(--bg-card,#131c2e);--border:#1f2d47;--text:var(--text-primary,#e6edf7);--muted:var(--text-secondary,#96a7c2);--accent-page:var(--accent,#3b82f6)}
-*{box-sizing:border-box;margin:0}body{background:var(--bg);color:var(--text);font:15px/1.5 system-ui,'Segoe UI',Arial,sans-serif;padding-bottom:3rem}
-header.intro{display:flex;align-items:baseline;gap:.2rem .8rem;flex-wrap:wrap;padding:1rem 1.4rem .4rem}
+// The index's own rules; page-frame's baseCss supplies the palette (--panel, --line, --muted, --acc …) and the reset.
+// Card rules are scoped to .card: baseCss styles .meta and .desc for the watch pages.
+const BROWSE_CSS = `
+header.intro{display:flex;align-items:baseline;gap:.2rem .8rem;flex-wrap:wrap;padding:0 0 .4rem}
header.intro h1{font-size:1.15rem;margin:0}
header.intro h1{font-size:1.15rem;margin:0}
header.intro .sub{color:var(--muted);font-size:.85rem}
header.intro .sub{color:var(--muted);font-size:.85rem}
-nav.tabs{display:flex;gap:.25rem;padding:.2rem 1.4rem .7rem;flex-wrap:wrap}
+nav.tabs{display:flex;gap:.25rem;padding:.2rem 0 .7rem;flex-wrap:wrap}
nav.tabs a{color:var(--muted);text-decoration:none;padding:.45rem .9rem;border-radius:999px;font-size:.88rem;border:1px solid transparent}
nav.tabs a{color:var(--muted);text-decoration:none;padding:.45rem .9rem;border-radius:999px;font-size:.88rem;border:1px solid transparent}
nav.tabs a:hover{color:var(--text)}nav.tabs a.on{color:var(--on-accent-strong,#fff);background:var(--accent-strong,#1d4ed8)}
nav.tabs a:hover{color:var(--text)}nav.tabs a.on{color:var(--on-accent-strong,#fff);background:var(--accent-strong,#1d4ed8)}
nav.tabs a .n{opacity:.75;font-size:.78rem;margin-left:.3rem}nav.tabs a.on .n{opacity:1}
nav.tabs a .n{opacity:.75;font-size:.78rem;margin-left:.3rem}nav.tabs a.on .n{opacity:1}
-.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:1rem;padding:0 1.4rem}
-.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;overflow:hidden;display:flex;flex-direction:column}
-.thumb{position:relative;display:block;aspect-ratio:16/9;background:#0b0f18}
-.thumb img{width:100%;height:100%;object-fit:cover;display:block}
-.thumb .ph{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:2.2rem;opacity:.5}
-.thumb .dur{position:absolute;right:.5rem;bottom:.5rem;background:rgba(0,0,0,.75);padding:.1rem .45rem;border-radius:6px;font-size:.75rem}
-.thumb .kind{position:absolute;left:.5rem;top:.5rem;background:rgba(var(--accent-rgb,59,130,246),.85);color:var(--on-accent,#fff);padding:.1rem .5rem;border-radius:6px;font-size:.7rem;text-transform:uppercase;letter-spacing:.05em}
-.meta{padding:.7rem .8rem;display:flex;flex-direction:column;gap:.35rem;flex:1}
-.title{font-weight:600;font-size:.92rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
-.desc{color:var(--muted);font-size:.8rem;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
-.foot{margin-top:auto;display:flex;justify-content:space-between;gap:.6rem;color:var(--muted);font-size:.74rem;flex-wrap:wrap}
-.foot a{color:var(--accent-page);text-decoration:none}.foot a:hover{text-decoration:underline}
+.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:1rem}
+.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;overflow:hidden;display:flex;flex-direction:column}
+.card .thumb{position:relative;display:block;aspect-ratio:16/9;background:#0b0f18}
+.card .thumb img{width:100%;height:100%;object-fit:cover;display:block}
+.card .thumb .ph{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:2.2rem;opacity:.5}
+.card .thumb .dur{position:absolute;right:.5rem;bottom:.5rem;background:rgba(0,0,0,.75);color:#fff;padding:.1rem .45rem;border-radius:6px;font-size:.75rem}
+.card .thumb .kind{position:absolute;left:.5rem;top:.5rem;background:rgba(var(--accent-rgb,59,130,246),.85);color:var(--on-accent,#fff);padding:.1rem .5rem;border-radius:6px;font-size:.7rem;text-transform:uppercase;letter-spacing:.05em}
+.card .meta{margin:0;padding:.7rem .8rem;display:flex;flex-direction:column;flex-wrap:nowrap;align-items:stretch;gap:.35rem;flex:1;color:var(--text);font-size:inherit;line-height:1.5}
+.card .title{font-weight:600;font-size:.92rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.card .desc{color:var(--muted);font-size:.8rem;white-space:normal;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
+.card .foot{margin-top:auto;display:flex;justify-content:space-between;gap:.6rem;color:var(--muted);font-size:.74rem;flex-wrap:wrap}
+.card .foot a{color:var(--acc);text-decoration:none}.card .foot a:hover{text-decoration:underline}
.pager{display:flex;gap:.5rem;justify-content:center;align-items:center;padding:1.4rem;color:var(--muted);font-size:.88rem}
.pager{display:flex;gap:.5rem;justify-content:center;align-items:center;padding:1.4rem;color:var(--muted);font-size:.88rem}
-.pg{color:var(--accent-page);text-decoration:none;padding:.35rem .8rem;border:1px solid var(--border);border-radius:8px}
+.pg{color:var(--acc);text-decoration:none;padding:.35rem .8rem;border:1px solid var(--line);border-radius:8px}
.pg.dis{color:var(--muted);opacity:.4}
.pg.dis{color:var(--muted);opacity:.4}
.empty{padding:3rem;text-align:center;color:var(--muted)}
.empty{padding:3rem;text-align:center;color:var(--muted)}
-</style>
-<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
-${require('openvibe-shared/app-icon').headTags({ site: 'media', iconBase: '/assets' })}
-</head><body>
-<div id="navbar-mount"></div>${frame.noscriptNav({ name: 'OpenVibe.Media' })}
-<header class="intro"><h1>Every public file on the network</h1><span class="sub">videos, clips, files & thumbnails from all OpenVibe sites</span></header>
+`;
+
+const DESCRIPTION = 'Index of all public media on the OpenVibe network — videos, clips, files and thumbnails.';
+
+function renderPage(tab, page, data, counts) {
+ const pages = Math.max(1, Math.ceil(data.total / PAGE_SIZE));
+ const nav = (p, label, dis) => dis ? `<span class="pg dis" aria-disabled="true">${label}</span>` : `<a class="pg" href="/?tab=${tab}&page=${p}">${label}</a>`;
+ const first = tab === 'all' && page === 1;
+ const canonical = `${config.publicUrl}/${first ? '' : `?tab=${tab}${page > 1 ? `&page=${page}` : ''}`}`;
+ // The one <h1> on the page: the showcase hero on the index's front page, the plain intro elsewhere.
+ const heading = first ? showcase.hero({
+ eyebrow: 'OpenVibe.Media',
+ title: 'Every public file on the',
+ accent: 'OpenVibe network',
+ lede: 'Videos, clips, emotes, sounds, files and thumbnails from every OpenVibe site, in one index. Watch and download public media without an account.',
+ actions: [
+ { label: 'Browse videos', href: '/?tab=videos', primary: true },
+ { label: 'Watch on OpenVibe.Live', href: appUrl('live') + '/' },
+ ],
+ }) : '<header class="intro"><h1>Every public file on the network</h1><span class="sub">videos, clips, files & thumbnails from all OpenVibe sites</span></header>';
+ const body = `${heading}
<nav class="tabs" aria-label="Media types">${TABS.map(([k, label]) => `<a class="${k === tab ? 'on' : ''}" href="/?tab=${k}">${label}<span class="n">${(counts[k] ?? 0).toLocaleString()}</span></a>`).join('')}</nav>
<nav class="tabs" aria-label="Media types">${TABS.map(([k, label]) => `<a class="${k === tab ? 'on' : ''}" href="/?tab=${k}">${label}<span class="n">${(counts[k] ?? 0).toLocaleString()}</span></a>`).join('')}</nav>
${data.cards.length ? `<div class="grid">${data.cards.map(renderCard).join('')}</div>` : '<div class="empty">Nothing here yet.</div>'}
${data.cards.length ? `<div class="grid">${data.cards.map(renderCard).join('')}</div>` : '<div class="empty">Nothing here yet.</div>'}
<div class="pager">${nav(page - 1, '‹ Prev', page <= 1)}<span>Page ${page} / ${pages} · ${data.total.toLocaleString()} items</span>${nav(page + 1, 'Next ›', page >= pages)}</div>
<div class="pager">${nav(page - 1, '‹ Prev', page <= 1)}<span>Page ${page} / ${pages} · ${data.total.toLocaleString()} items</span>${nav(page + 1, 'Next ›', page >= pages)}</div>
-${tab === 'all' && page === 1 ? `<div style="max-width:1200px;margin:0 auto;padding:0 1rem">${frame.shipped({ service: 'media', title: 'Recently shipped on OpenVibe.Media' })}</div>` : ''}
-${frame.footer({ service: 'media', variant: 'full', updates: '/updates' })}
-${require('./page-frame').frameScripts({ footer: { variant: 'full' } })}
-</body></html>`;
+${first ? frame.shipped({ service: 'media', title: 'Recently shipped on OpenVibe.Media' }) : ''}`;
+ return pageFrame.page({
+ seo: {
+ title: 'OpenVibe.Media — Media Index', description: DESCRIPTION, canonical,
+ robots: page > 1 ? 'noindex, follow' : 'index, follow', ogType: 'website',
+ jsonLd: page === 1 ? browseJsonLd(tab, data.cards) : [],
+ },
+ css: BROWSE_CSS,
+ body,
+ // Later pages are noindex and carry no structured data, the summary's WebPage included.
+ summary: page === 1 ? {
+ summary: 'Index of all public media on the OpenVibe network: videos, clips, emotes, sounds, files and thumbnails, browsable and playable without an account.',
+ url: canonical,
+ facts: [['Items', data.total.toLocaleString('en-US')]],
+ updated: isoDate(data.cards[0] && data.cards[0].date) || undefined,
+ } : undefined,
+ history: { type: 'page', title: 'OpenVibe.Media' },
+ footer: { variant: 'full' },
+ });
}
}
async function handle(req, res) {
async function handle(req, res) {
@@ -82,6 +82,8 @@ function extraHead(seo, css) {
@@ -82,6 +82,8 @@ function extraHead(seo, css) {
].filter(Boolean).join('\n') : '';
].filter(Boolean).join('\n') : '';
return [
return [
appIcon.headTags({ site: 'media', iconBase: '/assets' }),
appIcon.headTags({ site: 'media', iconBase: '/assets' }),
+ // openvibe-shared/showcase's stylesheet (the index's hero; the kit's sections on any page).
+ `<link rel="stylesheet" href="${ovServe.url('showcase.css')}">`,
`<style>${baseCss()}${css}</style>`,
`<style>${baseCss()}${css}</style>`,
videoTags,
videoTags,
// The default share card is 1200×630; a media thumbnail carries its own dimensions.
// The default share card is 1200×630; a media thumbnail carries its own dimensions.
@@ -158,31 +160,6 @@ function footerBootScript(footer) {
@@ -158,31 +160,6 @@ function footerBootScript(footer) {
return `<script>window.addEventListener('DOMContentLoaded', function () { try { OpenVibeFooter.init(${jsonForScript(footerOptions(footer))}); } catch (e) { /* footer optional */ } });</script>`;
return `<script>window.addEventListener('DOMContentLoaded', function () { try { OpenVibeFooter.init(${jsonForScript(footerOptions(footer))}); } catch (e) { /* footer optional */ } });</script>`;
}
}
-/**
- * The scripts that mount the navbar + footer, after the page content so a
- * slow Network never blocks the body. Used by the hand-written browse index
- * (server/public/browse.js); the shell pages get the same navbar options through
- * shell.page() plus footerBootScript() (the shell does not initialise the footer),
- * and the footer is rendered by frame.footer.
- */
-function frameScripts({ history, footer } = {}) {
- const navOpts = navOptions({ history });
- const footOpts = footerOptions(footer);
- return `<script src="${ovServe.url('navbar.js')}" defer></script>
-<script src="${ovServe.url('footer.js')}" defer></script>
-<script>
-(function () {
- var tries = 0;
- function boot() {
- if (!window.OpenVibeNavbar || !window.OpenVibeFooter) { if (++tries < 60) setTimeout(boot, 100); return; }
- try { OpenVibeNavbar.init(${jsonForScript(navOpts)}); } catch (e) { /* navbar optional */ }
- try { OpenVibeFooter.init(${jsonForScript(footOpts)}); } catch (e) { /* footer optional */ }
- }
- if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();
-})();
-</script>`;
-}
-
function defaultFooterLinks() {
function defaultFooterLinks() {
return [
return [
{
{
@@ -204,9 +181,11 @@ function defaultFooterLinks() {
@@ -204,9 +181,11 @@ function defaultFooterLinks() {
* `body` is the <main> contents. The shell emits the theme-loader/web-runtime/navbar/footer scripts,
* `body` is the <main> contents. The shell emits the theme-loader/web-runtime/navbar/footer scripts,
* the no-JavaScript nav and the shared footer; `history` and `footer` become the navbar init options
* the no-JavaScript nav and the shared footer; `history` and `footer` become the navbar init options
* and the server-rendered footer. The site's app icon, palette and Open Graph video tags ride in
* and the server-rendered footer. The site's app icon, palette and Open Graph video tags ride in
- * through the shell's `head`.
+ * through the shell's `head`. `summary` is the page's AI summary (openvibe-shared/seo pageSummary): a
+ * string, or { summary, url, facts: [[label, value]], updated }; the url defaults to the canonical.
*/
*/
-function page({ seo, css = '', body, history, footer }) {
+function page({ seo, css = '', body, history, footer, summary }) {
+ const sum = summary == null ? null : typeof summary === 'object' ? summary : { summary };
const html = shell.page({
const html = shell.page({
name: SITE_NAME,
name: SITE_NAME,
lang: 'en',
lang: 'en',
@@ -218,8 +197,9 @@ function page({ seo, css = '', body, history, footer }) {
@@ -218,8 +197,9 @@ function page({ seo, css = '', body, history, footer }) {
type: seo.ogType,
type: seo.ogType,
robots: seo.robots || 'index, follow',
robots: seo.robots || 'index, follow',
jsonLd: seo.jsonLd,
jsonLd: seo.jsonLd,
+ ...(sum ? { summary: sum.summary, facts: sum.facts, url: sum.url, updated: sum.updated } : {}),
head: extraHead(seo, css) + footerBootScript(footer),
head: extraHead(seo, css) + footerBootScript(footer),
- body: `<div id="navbar-mount"></div>\n<main>\n${body}\n</main>`,
+ body: `<div id="navbar-mount"></div>\n<main id="main">\n${body}\n</main>`,
navLinks: [{ label: 'Videos', href: '/?tab=videos' }, { label: 'Clips', href: '/?tab=clips' }],
navLinks: [{ label: 'Videos', href: '/?tab=videos' }, { label: 'Clips', href: '/?tab=clips' }],
navbar: navOptions({ history }),
navbar: navOptions({ history }),
footer: footerOptions(footer),
footer: footerOptions(footer),
@@ -236,5 +216,5 @@ function page({ seo, css = '', body, history, footer }) {
@@ -236,5 +216,5 @@ function page({ seo, css = '', body, history, footer }) {
module.exports = {
module.exports = {
NETWORK_URL, SITE_NAME, DEFAULT_OG_IMAGE, APP_PUBLIC_URLS, appUrl,
NETWORK_URL, SITE_NAME, DEFAULT_OG_IMAGE, APP_PUBLIC_URLS, appUrl,
esc, abs, snip, isoDate, isoDuration, fmtDuration, fmtDate, jsonForScript,
esc, abs, snip, isoDate, isoDuration, fmtDuration, fmtDate, jsonForScript,
- extraHead, baseCss, frameScripts, navOptions, footerOptions, defaultFooterLinks, page, DEFAULT_THEME_CSS,
+ extraHead, baseCss, navOptions, footerOptions, defaultFooterLinks, page, DEFAULT_THEME_CSS,
};
};
@@ -173,16 +173,25 @@ function renderWatchPage(kind, record, readiness = null) {
@@ -173,16 +173,25 @@ function renderWatchPage(kind, record, readiness = null) {
.player .state-detail { margin: 0; color: var(--muted); }
.player .state-detail { margin: 0; color: var(--muted); }
.h2 { font-size: 1rem; margin: 1.2rem 0 .3rem; color: var(--text); }`;
.h2 { font-size: 1rem; margin: 1.2rem 0 .3rem; color: var(--text); }`;
+ // Structured data and the AI summary describe public items only; unlisted and private pages carry none.
+ const described = playable && (record.visibility || (record.is_public ? 'public' : 'private')) === 'public';
return pageFrame.page({
return pageFrame.page({
seo: {
seo: {
title: `${title} — ${SITE_NAME}`, description, canonical, robots,
title: `${title} — ${SITE_NAME}`, description, canonical, robots,
image: thumb || undefined, ogType: playable ? 'video.other' : 'website',
image: thumb || undefined, ogType: playable ? 'video.other' : 'website',
// No playable bytes: no video URL for previews and no VideoObject that names them.
// No playable bytes: no video URL for previews and no VideoObject that names them.
video: playable ? { url: rawUrl, type: mime } : undefined,
video: playable ? { url: rawUrl, type: mime } : undefined,
- // Structured data describes public items only; unlisted and private pages carry none.
- jsonLd: playable && (record.visibility || (record.is_public ? 'public' : 'private')) === 'public' ? [videoObject] : [],
+ jsonLd: described ? [videoObject] : [],
},
},
css, body,
css, body,
+ summary: described ? {
+ summary: description, url: canonical,
+ facts: [
+ ['Duration', record.duration_seconds ? fmtDuration(record.duration_seconds) : null],
+ ['Views', record.view_count != null ? Number(record.view_count).toLocaleString('en-US') : null],
+ ].filter(([, v]) => v != null),
+ updated: uploadDate || undefined,
+ } : undefined,
history: { type: isVod ? 'vod' : 'clip', title },
history: { type: isVod ? 'vod' : 'clip', title },
footer: { variant: 'compact' },
footer: { variant: 'compact' },
});
});
@@ -18,10 +18,12 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
@@ -18,10 +18,12 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
jsFiles: 4, // 3
jsFiles: 4, // 3
// 2026-09-27: theme-loader (44.3 KB, 8.3 brotli) now comes from Media's own /shared (D42) instead of
// 2026-09-27: theme-loader (44.3 KB, 8.3 brotli) now comes from Media's own /shared (D42) instead of
// openvibe.network, so it is counted here; the page itself did not grow.
// openvibe.network, so it is counted here; the page itself did not grow.
- jsRawKB: 215, // 197.7
- jsBrotliKB: 50, // 45.6
- cssFiles: 2, // 1 (Font Awesome, cross-origin)
- cssRawKB: 10, // 0 same-origin
+ // 2026-10-13 (T11): the index is composed by openvibe-shared/shell like the watch pages, which adds the
+ // shared web-runtime.js; the showcase stylesheet (same-origin) replaces Font Awesome (cross-origin).
+ jsRawKB: 240, // 230.9 (was 197.7)
+ jsBrotliKB: 56, // 53.7 (was 45.6)
+ cssFiles: 2, // 1 (showcase.css)
+ cssRawKB: 13, // 11.6 same-origin (showcase.css; was 0)
externalFiles: 3, // 2
externalFiles: 3, // 2
};
};
@@ -67,6 +67,11 @@ const http = require('http');
@@ -67,6 +67,11 @@ const http = require('http');
assert.ok(other.includes('<meta name="robots" content="index, follow">'));
assert.ok(other.includes('<meta name="robots" content="index, follow">'));
const unlisted = pages.renderWatchPage('clip', { ...vod, id: 8, app_id: 'games', visibility: 'unlisted' });
const unlisted = pages.renderWatchPage('clip', { ...vod, id: 8, app_id: 'games', visibility: 'unlisted' });
assert.ok(unlisted.includes('content="noindex, follow"'), 'unlisted never indexes');
assert.ok(unlisted.includes('content="noindex, follow"'), 'unlisted never indexes');
+ // The AI summary (openvibe-shared/seo pageSummary) on public watch pages: description, canonical, facts.
+ assert.ok(html.includes('<meta name="ai-summary" content="We built a thing & it worked.">'), 'the watch page carries the AI summary');
+ assert.ok(html.includes('<li>Duration: 1:02:05</li>') && html.includes('<li>Views: 1,234</li>'), 'with its facts');
+ assert.ok(html.includes('"@type":"WebPage"') && html.includes('"url":"https://openvibe.live/vod/42"'), 'its WebPage names the canonical');
+ assert.ok(!unlisted.includes('name="ai-summary"'), 'unlisted pages carry none');
// Paste pages are Community's: there is no viewer here (public/routes.js only redirects).
// Paste pages are Community's: there is no viewer here (public/routes.js only redirects).
assert.ok(!/\bfree\b/i.test(html + pageFrame.baseCss()), 'no cost claims in served HTML');
assert.ok(!/\bfree\b/i.test(html + pageFrame.baseCss()), 'no cost claims in served HTML');
@@ -90,6 +95,7 @@ const http = require('http');
@@ -90,6 +95,7 @@ const http = require('http');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.ok(r.headers['content-type'].startsWith('text/html'));
assert.ok(r.headers['content-type'].startsWith('text/html'));
assert.ok(r.body.includes('Router VOD') && r.body.includes('id="navbar-mount"'));
assert.ok(r.body.includes('Router VOD') && r.body.includes('id="navbar-mount"'));
+ assert.ok(r.body.includes('<main id="main">'));
// No Content-Security-Policy on a page response today: the shell composes inline scripts
// No Content-Security-Policy on a page response today: the shell composes inline scripts
// (theme boot, navbar/runtime boot, footer init), so adding a policy later must allow them.
// (theme boot, navbar/runtime boot, footer init), so adding a policy later must allow them.
assert.ok(!('content-security-policy' in r.headers), 'watch pages carry no CSP header');
assert.ok(!('content-security-policy' in r.headers), 'watch pages carry no CSP header');
@@ -107,8 +113,24 @@ const http = require('http');
@@ -107,8 +113,24 @@ const http = require('http');
assert.ok(ld.some((x) => x['@type'] === 'WebSite'), 'the index names the site');
assert.ok(ld.some((x) => x['@type'] === 'WebSite'), 'the index names the site');
const list = ld.find((x) => x['@type'] === 'ItemList');
const list = ld.find((x) => x['@type'] === 'ItemList');
assert.ok(list && list.itemListElement.length >= 1 && list.itemListElement.every((it) => /^https?:\/\//.test(it.url)), 'the listed cards, with absolute URLs');
assert.ok(list && list.itemListElement.length >= 1 && list.itemListElement.every((it) => /^https?:\/\//.test(it.url)), 'the listed cards, with absolute URLs');
+ const h1s = (html) => (html.match(/<h1[\s>]/g) || []).length;
+ assert.ok(r.body.includes('class="sc-hero'), 'the index opens with the showcase hero');
+ assert.ok(/\/shared\/showcase\.css\?v=[0-9a-f]{12}/.test(r.body), 'the kit stylesheet, through ovServe');
+ assert.ok(r.body.includes('id="navbar-mount"') && r.body.includes('<main id="main">'));
+ assert.ok(r.body.includes('<meta name="ai-summary"'), 'the index carries the AI summary');
+ assert.ok(ld.some((x) => x['@type'] === 'WebPage'), 'the summary\'s WebPage');
+ assert.strictEqual(h1s(r.body), 1, 'exactly one <h1>');
+ assert.strictEqual((r.body.match(/<!doctype/gi) || []).length, 1, 'one document');
+ for (const k of ['all', 'videos', 'clips', 'emotes', 'sounds', 'thumbnails', 'files']) assert.ok(r.body.includes(`href="/?tab=${k}"`), `tab ${k}`);
+ assert.ok(r.body.includes('class="pager"') && r.body.includes('Page 1 /'), 'the pager');
+ assert.ok(r.body.includes('<meta name="robots" content="index, follow">'));
+ r = await get('/?tab=videos', nav);
+ assert.ok(!r.body.includes('sc-hero') && h1s(r.body) === 1, 'the hero is the front page\'s only');
r = await get('/?page=2', nav);
r = await get('/?page=2', nav);
assert.ok(!r.body.includes('application/ld+json'), 'a later (noindex) page carries none');
assert.ok(!r.body.includes('application/ld+json'), 'a later (noindex) page carries none');
+ assert.ok(!r.body.includes('sc-hero') && h1s(r.body) === 1, 'no hero after page 1');
+ assert.ok(r.body.includes('<meta name="robots" content="noindex, follow">'));
+ assert.ok(r.body.includes('Page 2 /') && r.body.includes('href="/?tab=all&page=1"'), 'the pager links back');
r = await get('/og-image.png', {});
r = await get('/og-image.png', {});
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.strictEqual(r.headers['content-type'], 'image/png');
assert.strictEqual(r.headers['content-type'], 'image/png');
@@ -13,8 +13,8 @@ const assert = require('assert');
@@ -13,8 +13,8 @@ const assert = require('assert');
assert.ok(css.includes(`${k}:${DEFAULT_VARS[k]}`), `the page defines ${k} as the default theme does`);
assert.ok(css.includes(`${k}:${DEFAULT_VARS[k]}`), `the page defines ${k} as the default theme does`);
}
}
assert.ok(!/url\(|;\s*}/.test(css.split('\n').find((l) => l.includes('--accent:')) || ''), 'only plain token values');
assert.ok(!/url\(|;\s*}/.test(css.split('\n').find((l) => l.includes('--accent:')) || ''), 'only plain token values');
- // The index page (browse.js) writes its own <style>: it carries the same tokens.
+ // The index page (browse.js) is composed by page-frame too, so it carries the same tokens.
const src = require('fs').readFileSync(require('path').join(__dirname, '..', 'server', 'public', 'browse.js'), 'utf8');
const src = require('fs').readFileSync(require('path').join(__dirname, '..', 'server', 'public', 'browse.js'), 'utf8');
- assert.ok(/<style>\n\$\{DEFAULT_THEME_CSS \?/.test(src), 'browse.js puts the default tokens first in its style');
+ assert.ok(/pageFrame\.page\(/.test(src) && !/<!DOCTYPE/i.test(src), 'browse.js renders through page-frame, not its own document');
console.log('theme default: all checks passed');
console.log('theme default: all checks passed');
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });