openvibers/openvibe.media Commits
Media: JSON-LD on the media index (WebSite, ItemList) (#92)
Changes
@@@ -189,6 +189,17 @@ async function tabCounts() {// ── Rendering ────────────────────────────────────────────────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) {++ 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');++}++function renderCard(c) {const preview = c.thumb? `<img src="${esc(c.thumb)}" alt="" loading="lazy">`@@@ -229,6 +240,7 @@ function renderPage(tab, page, data, counts) {<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>@@@ -101,6 +101,14 @@ const http = require('http');assert.strictEqual(r.status, 404, 'a <video> element never gets HTML');r = await get('/p/slug1', nav);assert.deepStrictEqual([r.status, r.headers.location], [301, 'https://openvibe.community/p/slug1'], 'the paste page is a redirect, no row needed');++ r = await get('/', nav);++ assert.strictEqual(r.status, 200);++ const ld = [...r.body.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));++ 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');++ r = await get('/?page=2', nav);++ assert.ok(!r.body.includes('application/ld+json'), 'a later (noindex) page carries none');r = await get('/og-image.png', {});assert.strictEqual(r.status, 200);assert.strictEqual(r.headers['content-type'], 'image/png');
@@ -189,6 +189,17 @@ async function tabCounts() {
@@ -189,6 +189,17 @@ async function tabCounts() {
// ── Rendering ────────────────────────────────────────────────
// ── Rendering ────────────────────────────────────────────────
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. */
+function browseJsonLd(config, 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');
+}
+
function renderCard(c) {
function renderCard(c) {
const preview = c.thumb
const preview = c.thumb
? `<img src="${esc(c.thumb)}" alt="" loading="lazy">`
? `<img src="${esc(c.thumb)}" alt="" loading="lazy">`
@@ -229,6 +240,7 @@ function renderPage(tab, page, data, counts) {
@@ -229,6 +240,7 @@ function renderPage(tab, page, data, counts) {
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="OpenVibe.Media — Media Index">
<meta name="twitter:title" content="OpenVibe.Media — Media Index">
<meta name="twitter:image" content="${config.publicUrl}/og-image.png">
<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. -->
<!-- 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>
<script src="${require('openvibe-shared/serve').url('theme-loader.js')}" defer></script>
<style>
<style>
@@ -101,6 +101,14 @@ const http = require('http');
@@ -101,6 +101,14 @@ const http = require('http');
assert.strictEqual(r.status, 404, 'a <video> element never gets HTML');
assert.strictEqual(r.status, 404, 'a <video> element never gets HTML');
r = await get('/p/slug1', nav);
r = await get('/p/slug1', nav);
assert.deepStrictEqual([r.status, r.headers.location], [301, 'https://openvibe.community/p/slug1'], 'the paste page is a redirect, no row needed');
assert.deepStrictEqual([r.status, r.headers.location], [301, 'https://openvibe.community/p/slug1'], 'the paste page is a redirect, no row needed');
+ r = await get('/', nav);
+ assert.strictEqual(r.status, 200);
+ const ld = [...r.body.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
+ 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');
+ r = await get('/?page=2', nav);
+ assert.ok(!r.body.includes('application/ld+json'), 'a later (noindex) page carries none');
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');