openvibers/openvibe.design Commits
Changes: fold older Shared releases and add release pages (#264)
Changes
@@@ -117,7 +117,7 @@ function createApi(ctx) {r.get('/v1/changes', (req, res) => {res.set('Cache-Control', 'public, max-age=3600');res.set('Access-Control-Allow-Origin', '*');-- return res.json(changesView.changesJson());++ return res.json(changesView.changesJson(req.query));});r.get('/v1/components', async (req, res) => {@@@ -175,7 +175,8 @@ function createDiscoveryRoutes(ctx) {r.get('/sitemap.xml', async (_req, res) => {const pages = publicPages(await themes());const lastmod = siteUpdated();-- const urls = pages.map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority, ...(e.images ? { images: e.images.map(abs) } : {}) }));++ const changePages = require('../changes').releases().map((release) => ({ path: `/changes/${release.version}`, changefreq: 'monthly', priority: 0.5 }));++ const urls = [...pages, ...changePages].map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority, ...(e.images ? { images: e.images.map(abs) } : {}) }));res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(urls));});@@@ -243,6 +243,10 @@ function createPageRoutes(ctx) {const rulesView = require('../views/rules');r.get('/rules', (req, res) => page(res, 200, rulesView.rulesPage(), req.viewer));// Every openvibe-shared release, read from the pinned package (server/changes.js).++ r.get('/changes/:version', (req, res) => {++ const html = changesView.releasePage(req.params.version);++ return html ? page(res, 200, html, req.viewer) : page(res, 404, views.notFound(), req.viewer);++ });r.get('/changes', (req, res) => page(res, 200, changesView.changesPage(), req.viewer));// The tokens of one built-in theme with !important: scripts/capture-release.js wears them for a light or dark plate.r.get('/scheme.css', (req, res) => {@@@ -8,7 +8,7 @@* changesJson() the same archive as data (/api/v1/changes)*/const kit = require('openvibe-shared/kit');--const { layout, esc } = require('../render/layout');++const { layout, esc, canonical } = require('../render/layout');const { releases, markdown, componentsOf, rulesOf, screenshots, pinned } = require('../changes');const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];@@@ -45,36 +45,73 @@ function releaseCard(r) {</article>`;}++function releaseRow(r) {++ const hasShots = screenshots(r.version).length > 0;++ return `<a href="/changes/${esc(r.version)}">${esc(r.version)} · ${esc(r.title)} · ${esc(r.date)}${hasShots ? ' ●' : ''}</a>`;++}++++function monthLabel(date) {++ const [year, month] = String(date || '').split('-').map(Number);++ return `${MONTHS[month - 1]} ${year}`;++}++++function releasePage(version) {++ const all = releases();++ const index = all.findIndex((r) => r.version === String(version || '').replace(/^v/, ''));++ if (index < 0) return null;++ const r = all[index];++ const pin = pinned();++ const canonicalPath = `/changes/${r.version}`;++ const navigation = `<nav class="chg-release-nav" aria-label="Release navigation">${index < all.length - 1 ? `<a rel="prev" href="/changes/${esc(all[index + 1].version)}">← ${esc(all[index + 1].version)}</a>` : '<span></span>'}${index > 0 ? `<a rel="next" href="/changes/${esc(all[index - 1].version)}">${esc(all[index - 1].version)} →</a>` : '<span></span>'}</nav>`;++ return layout({++ title: `OpenVibe.Shared ${r.version}: ${r.title}`,++ description: r.title,++ path: canonicalPath,++ active: '/components',++ styles: ['css/changes.css'],++ jsonLd: [{ '@context': 'https://schema.org', '@type': 'TechArticle', headline: `OpenVibe.Shared ${r.version}: ${r.title}`, datePublished: r.date, mainEntityOfPage: canonical(canonicalPath) }],++ body: `<article class="chg">${navigation}<header class="ds-page-h chg-hero"><p class="ds-kicker"><a href="/changes">OpenVibe.Shared releases</a></p><h1>Release ${esc(r.version)}</h1><p class="ds-lede">${esc(day(r.date))} · pinned package <code>openvibe-shared@${esc(pin.version)}</code></p></header><div class="chg-list">${releaseCard(r)}</div>${navigation}<p><a href="/changes">All releases</a></p></article>`,++ });++}++function changesPage() {const all = releases();const pin = pinned();const counted = all.filter((r) => screenshots(r.version).length).length;const hero = `<header class="ds-page-h chg-hero"><p class="ds-kicker">OpenVibe.Shared</p><h1>Every shared release, and where it shows</h1>--<p class="ds-lede">Each release of the shared package every OpenVibe site loads, newest first, in the words its own changelog uses. The text is read from <code>openvibe-shared@${esc(pin.version)}</code>, the version this site pins; no request is made to GitHub when a person opens this page. Each release links to the component pages it touches, numbers the <a href="/rules">rules</a> it carries out, and shows the screenshots the repo holds for it.</p>++<p class="ds-lede">Every release of the shared package, newest first, in its changelog's words, with the screens it changed.</p>${kit.stats([[all.length, 'releases'], [counted, 'with screenshots'], [pin.version, 'pinned']], { label: 'The archive in numbers' })}<p class="chg-links">${kit.button({ href: '/components/registry', label: 'The components', kind: 'quiet', icon: 'puzzle' })}${kit.button({ href: '/rules', label: 'The rules', kind: 'quiet', icon: 'rules' })}${kit.button({ href: '/api/v1/changes', label: 'As JSON', kind: 'quiet', icon: 'code' })}</p>--<p class="ds-fine">Older entries name fewer pages: the changelog is old, the component pages are new. The link is left out rather than guessed.</p></header>`;-- const latest = all.slice(0, 12).map((r) => `<a href="#v${esc(r.version)}"><b>${esc(r.version)}</b><span>${esc(r.title)}</span></a>`).join('');-- const index = all.length > 12 ? `<nav class="chg-index" aria-label="Newest releases"><p class="chg-label">Newest</p><div class="chg-index-row">${latest}</div><p class="ds-fine">${all.length} releases in all; every one follows, newest first.</p></nav>` : '';-- const list = all.map(releaseCard).join('');++ </header>`;++ const full = all.slice(0, 12).map(releaseCard).join('');++ const monthGroups = new Map();++ for (const r of all.slice(12)) {++ const key = r.date.slice(0, 7);++ if (!monthGroups.has(key)) monthGroups.set(key, { label: monthLabel(r.date), releases: [] });++ monthGroups.get(key).releases.push(r);++ }++ const folds = [...monthGroups.values()].map((group) => `<details class="chg-month"><summary>${esc(group.label)} · ${group.releases.length} ${group.releases.length === 1 ? 'release' : 'releases'}</summary>${group.releases.map(releaseRow).join('')}</details>`).join('');return layout({title: `Changes: ${all.length} OpenVibe.Shared releases`,description: `Every OpenVibe.Shared release in the pinned changelog, newest first: what changed, the component pages it touches, the rules it carries out, and its screenshots.`,path: '/changes',active: '/components',styles: ['css/changes.css'],-- body: `<article class="chg">${hero}${index}<div class="chg-list">${list}</div>++ body: `<article class="chg">${hero}<div class="chg-list">${full}</div>${folds}<footer class="chg-foot"><p>Read from <code>openvibe-shared@${esc(pin.version)}</code>. A release is listed in the order the package writes it; the links are the only additions.</p><p>${kit.button({ href: '/components/registry', label: 'Every component', kind: 'quiet' })}${kit.button({ href: '/rules', label: 'How OpenVibe decides', kind: 'quiet' })}</p></footer></article>`,});}--function changesJson() {++function changesJson(query = {}) {const all = releases();++ const limit = Number.isInteger(Number(query.limit)) && Number(query.limit) > 0 ? Math.min(Number(query.limit), 100) : all.length;++ const before = query.before ? all.findIndex((r) => r.version === String(query.before).replace(/^v/, '')) : -1;++ const eligible = before >= 0 ? all.slice(before + 1) : query.before ? [] : all;return {schema: 'openvibe.changes@1',pinned: pinned(),-- releases: all.map((r) => ({++ releases: eligible.slice(0, limit).map((r) => ({version: r.version,date: r.date,title: r.title,@@@ -85,4 +122,4 @@ function changesJson() {};}--module.exports = { changesPage, changesJson };++module.exports = { changesPage, releasePage, changesJson };@@@ -10,7 +10,7 @@ const harness = require('openvibe-shared/browser-harness');const t = await boot();try {await check('the component pages and /changes render with no console errors at 390 and 1280', async () => {-- const routes = ['/changes', '/components/registry', '/components/brand', '/components/menu', '/components/search-pill',++ const routes = ['/changes', '/changes/4.200.0', '/components/registry', '/components/brand', '/components/menu', '/components/search-pill','/components/quest-card', '/components/avatar', '/components/player', '/components/soft-pages', '/components/icon-ink', '/components/footer'];const report = await harness.run({base: t.base,@@@ -22,17 +22,42 @@ const { PUBLIC_DIR } = require('../server/assets');await check('/changes lists every release in the pinned CHANGELOG, newest first, in its own words', async () => {const r = await t.get('/changes');assert.strictEqual(r.status, 200);++ assert.ok(Buffer.byteLength(r.text) < 120 * 1024, `${Buffer.byteLength(r.text)} bytes`);assert.match(r.text, /Every shared release, and where it shows/);assert.match(r.text, new RegExp(`openvibe-shared@${changes.SHARED_VERSION.replace(/\./g, '\\.')}`));-- assert.strictEqual((r.text.match(/data-release="/g) || []).length, all.length, `${all.length} releases`);++ assert.strictEqual((r.text.match(/data-release="/g) || []).length, 12, 'exactly twelve full releases');++ const folded = [...r.text.matchAll(/<summary>([^<]+) · (\d+) releases?<\/summary>/g)];++ assert.strictEqual(folded.reduce((n, m) => n + Number(m[2]), 0), all.length - 12, 'fold counts cover every remaining release');++ assert.strictEqual((r.text.match(/<details class="chg-month">/g) || []).length, folded.length);let last = -1;-- for (const release of all) {-- const at = r.text.indexOf(`data-release="${release.version}"`);++ for (const [i, release] of all.entries()) {++ const marker = i < 12 ? `data-release="${release.version}"` : `href="/changes/${release.version}"`;++ const at = r.text.indexOf(marker);assert.ok(at > last, `${release.version} is in order`);last = at;}assert.ok(r.text.includes('The button becomes its tab; Vibe holds Chat and Agents'), '4.200.0 as written');-- assert.ok(r.text.includes('navbar.js no longer ships every glyph'), '1.0.0 as written');++ assert.ok(r.text.includes('/changes/1.0.0'), 'the oldest release has its own page');++ assert.ok(r.text.includes('navbar.js no longer ships every glyph'), 'older release title is in the compact archive');++ });++++ await check('each version page renders its full release, canonical, TechArticle date, neighbors, and plates', async () => {++ for (let i = 0; i < all.length; i++) {++ const release = all[i];++ const r = await t.get(`/changes/${release.version}`);++ assert.strictEqual(r.status, 200, release.version);++ assert.match(r.text, new RegExp(`canonical" href="https://openvibe\\.design/changes/${release.version.replace(/\./g, '\\.')}`));++ assert.match(r.text, new RegExp(`"datePublished":"${release.date}"`));++ assert.match(r.text, new RegExp(`data-release="${release.version.replace(/\./g, '\\.')}`));++ if (i + 1 < all.length) assert.ok(r.text.includes(`/changes/${all[i + 1].version}`), `${release.version} previous`);++ if (i > 0) assert.ok(r.text.includes(`/changes/${all[i - 1].version}`), `${release.version} next`);++ for (const shot of changes.screenshots(release.version).flatMap((g) => g.files)) {++ const image = await t.get(shot.href);++ assert.strictEqual(image.status, 200, shot.href);++ }++ }++ assert.strictEqual((await t.get('/changes/not-a-release')).status, 404);++ assert.strictEqual((await t.get('/changes/specimen-player.mp4')).status, 200);});await check('each release links the component pages it touches, and every link answers', async () => {@@@ -43,7 +68,6 @@ const { PUBLIC_DIR } = require('../server/assets');assert.match(r.text, /href="\/components\/menu"/);assert.match(r.text, /href="\/components\/brand"/);assert.match(r.text, /href="\/components\/player"/);-- assert.match(r.text, /href="\/components\/quest-card"/);});await check('the rule numbers are the register’s own, and each links to its rule', async () => {@@@ -84,6 +108,19 @@ const { PUBLIC_DIR } = require('../server/assets');assert.ok(body.releases[0].rules.some((x) => x.id === 'button-becomes-its-tab' && x.n > 0));});++ await check('/api/v1/changes supports limit and before without changing the default shape', async () => {++ const first = (await t.get('/api/v1/changes?limit=2')).json();++ assert.deepStrictEqual(first.releases.map((x) => x.version), all.slice(0, 2).map((x) => x.version));++ const next = (await t.get(`/api/v1/changes?limit=2&before=${all[1].version}`)).json();++ assert.deepStrictEqual(next.releases.map((x) => x.version), all.slice(2, 4).map((x) => x.version));++ assert.deepStrictEqual((await t.get('/api/v1/changes?before=missing')).json().releases, []);++ });++++ await check('the sitemap includes each release page', async () => {++ const map = (await t.get('/sitemap.xml')).text;++ for (const release of all) assert.ok(map.includes(`https://openvibe.design/changes/${release.version}`), release.version);++ });++await check('the home and every component page link the changes archive', async () => {assert.match((await t.get('/')).text, /href="\/changes"/, 'the home links it');const pages = ['/components/registry', '/components/brand', '/components/menu', '/components/navbar', '/components/search-pill',
@@ -117,7 +117,7 @@ function createApi(ctx) {
@@ -117,7 +117,7 @@ function createApi(ctx) {
r.get('/v1/changes', (req, res) => {
r.get('/v1/changes', (req, res) => {
res.set('Cache-Control', 'public, max-age=3600');
res.set('Cache-Control', 'public, max-age=3600');
res.set('Access-Control-Allow-Origin', '*');
res.set('Access-Control-Allow-Origin', '*');
- return res.json(changesView.changesJson());
+ return res.json(changesView.changesJson(req.query));
});
});
r.get('/v1/components', async (req, res) => {
r.get('/v1/components', async (req, res) => {
@@ -175,7 +175,8 @@ function createDiscoveryRoutes(ctx) {
@@ -175,7 +175,8 @@ function createDiscoveryRoutes(ctx) {
r.get('/sitemap.xml', async (_req, res) => {
r.get('/sitemap.xml', async (_req, res) => {
const pages = publicPages(await themes());
const pages = publicPages(await themes());
const lastmod = siteUpdated();
const lastmod = siteUpdated();
- const urls = pages.map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority, ...(e.images ? { images: e.images.map(abs) } : {}) }));
+ const changePages = require('../changes').releases().map((release) => ({ path: `/changes/${release.version}`, changefreq: 'monthly', priority: 0.5 }));
+ const urls = [...pages, ...changePages].map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority, ...(e.images ? { images: e.images.map(abs) } : {}) }));
res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(urls));
res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(urls));
});
});
@@ -243,6 +243,10 @@ function createPageRoutes(ctx) {
@@ -243,6 +243,10 @@ function createPageRoutes(ctx) {
const rulesView = require('../views/rules');
const rulesView = require('../views/rules');
r.get('/rules', (req, res) => page(res, 200, rulesView.rulesPage(), req.viewer));
r.get('/rules', (req, res) => page(res, 200, rulesView.rulesPage(), req.viewer));
// Every openvibe-shared release, read from the pinned package (server/changes.js).
// Every openvibe-shared release, read from the pinned package (server/changes.js).
+ r.get('/changes/:version', (req, res) => {
+ const html = changesView.releasePage(req.params.version);
+ return html ? page(res, 200, html, req.viewer) : page(res, 404, views.notFound(), req.viewer);
+ });
r.get('/changes', (req, res) => page(res, 200, changesView.changesPage(), req.viewer));
r.get('/changes', (req, res) => page(res, 200, changesView.changesPage(), req.viewer));
// The tokens of one built-in theme with !important: scripts/capture-release.js wears them for a light or dark plate.
// The tokens of one built-in theme with !important: scripts/capture-release.js wears them for a light or dark plate.
r.get('/scheme.css', (req, res) => {
r.get('/scheme.css', (req, res) => {
@@ -8,7 +8,7 @@
@@ -8,7 +8,7 @@
* changesJson() the same archive as data (/api/v1/changes)
* changesJson() the same archive as data (/api/v1/changes)
*/
*/
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
-const { layout, esc } = require('../render/layout');
+const { layout, esc, canonical } = require('../render/layout');
const { releases, markdown, componentsOf, rulesOf, screenshots, pinned } = require('../changes');
const { releases, markdown, componentsOf, rulesOf, screenshots, pinned } = require('../changes');
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
@@ -45,36 +45,73 @@ function releaseCard(r) {
@@ -45,36 +45,73 @@ function releaseCard(r) {
</article>`;
</article>`;
}
}
+function releaseRow(r) {
+ const hasShots = screenshots(r.version).length > 0;
+ return `<a href="/changes/${esc(r.version)}">${esc(r.version)} · ${esc(r.title)} · ${esc(r.date)}${hasShots ? ' ●' : ''}</a>`;
+}
+
+function monthLabel(date) {
+ const [year, month] = String(date || '').split('-').map(Number);
+ return `${MONTHS[month - 1]} ${year}`;
+}
+
+function releasePage(version) {
+ const all = releases();
+ const index = all.findIndex((r) => r.version === String(version || '').replace(/^v/, ''));
+ if (index < 0) return null;
+ const r = all[index];
+ const pin = pinned();
+ const canonicalPath = `/changes/${r.version}`;
+ const navigation = `<nav class="chg-release-nav" aria-label="Release navigation">${index < all.length - 1 ? `<a rel="prev" href="/changes/${esc(all[index + 1].version)}">← ${esc(all[index + 1].version)}</a>` : '<span></span>'}${index > 0 ? `<a rel="next" href="/changes/${esc(all[index - 1].version)}">${esc(all[index - 1].version)} →</a>` : '<span></span>'}</nav>`;
+ return layout({
+ title: `OpenVibe.Shared ${r.version}: ${r.title}`,
+ description: r.title,
+ path: canonicalPath,
+ active: '/components',
+ styles: ['css/changes.css'],
+ jsonLd: [{ '@context': 'https://schema.org', '@type': 'TechArticle', headline: `OpenVibe.Shared ${r.version}: ${r.title}`, datePublished: r.date, mainEntityOfPage: canonical(canonicalPath) }],
+ body: `<article class="chg">${navigation}<header class="ds-page-h chg-hero"><p class="ds-kicker"><a href="/changes">OpenVibe.Shared releases</a></p><h1>Release ${esc(r.version)}</h1><p class="ds-lede">${esc(day(r.date))} · pinned package <code>openvibe-shared@${esc(pin.version)}</code></p></header><div class="chg-list">${releaseCard(r)}</div>${navigation}<p><a href="/changes">All releases</a></p></article>`,
+ });
+}
+
function changesPage() {
function changesPage() {
const all = releases();
const all = releases();
const pin = pinned();
const pin = pinned();
const counted = all.filter((r) => screenshots(r.version).length).length;
const counted = all.filter((r) => screenshots(r.version).length).length;
const hero = `<header class="ds-page-h chg-hero"><p class="ds-kicker">OpenVibe.Shared</p><h1>Every shared release, and where it shows</h1>
const hero = `<header class="ds-page-h chg-hero"><p class="ds-kicker">OpenVibe.Shared</p><h1>Every shared release, and where it shows</h1>
-<p class="ds-lede">Each release of the shared package every OpenVibe site loads, newest first, in the words its own changelog uses. The text is read from <code>openvibe-shared@${esc(pin.version)}</code>, the version this site pins; no request is made to GitHub when a person opens this page. Each release links to the component pages it touches, numbers the <a href="/rules">rules</a> it carries out, and shows the screenshots the repo holds for it.</p>
+<p class="ds-lede">Every release of the shared package, newest first, in its changelog's words, with the screens it changed.</p>
${kit.stats([[all.length, 'releases'], [counted, 'with screenshots'], [pin.version, 'pinned']], { label: 'The archive in numbers' })}
${kit.stats([[all.length, 'releases'], [counted, 'with screenshots'], [pin.version, 'pinned']], { label: 'The archive in numbers' })}
<p class="chg-links">${kit.button({ href: '/components/registry', label: 'The components', kind: 'quiet', icon: 'puzzle' })}${kit.button({ href: '/rules', label: 'The rules', kind: 'quiet', icon: 'rules' })}${kit.button({ href: '/api/v1/changes', label: 'As JSON', kind: 'quiet', icon: 'code' })}</p>
<p class="chg-links">${kit.button({ href: '/components/registry', label: 'The components', kind: 'quiet', icon: 'puzzle' })}${kit.button({ href: '/rules', label: 'The rules', kind: 'quiet', icon: 'rules' })}${kit.button({ href: '/api/v1/changes', label: 'As JSON', kind: 'quiet', icon: 'code' })}</p>
-<p class="ds-fine">Older entries name fewer pages: the changelog is old, the component pages are new. The link is left out rather than guessed.</p></header>`;
- const latest = all.slice(0, 12).map((r) => `<a href="#v${esc(r.version)}"><b>${esc(r.version)}</b><span>${esc(r.title)}</span></a>`).join('');
- const index = all.length > 12 ? `<nav class="chg-index" aria-label="Newest releases"><p class="chg-label">Newest</p><div class="chg-index-row">${latest}</div><p class="ds-fine">${all.length} releases in all; every one follows, newest first.</p></nav>` : '';
- const list = all.map(releaseCard).join('');
+ </header>`;
+ const full = all.slice(0, 12).map(releaseCard).join('');
+ const monthGroups = new Map();
+ for (const r of all.slice(12)) {
+ const key = r.date.slice(0, 7);
+ if (!monthGroups.has(key)) monthGroups.set(key, { label: monthLabel(r.date), releases: [] });
+ monthGroups.get(key).releases.push(r);
+ }
+ const folds = [...monthGroups.values()].map((group) => `<details class="chg-month"><summary>${esc(group.label)} · ${group.releases.length} ${group.releases.length === 1 ? 'release' : 'releases'}</summary>${group.releases.map(releaseRow).join('')}</details>`).join('');
return layout({
return layout({
title: `Changes: ${all.length} OpenVibe.Shared releases`,
title: `Changes: ${all.length} OpenVibe.Shared releases`,
description: `Every OpenVibe.Shared release in the pinned changelog, newest first: what changed, the component pages it touches, the rules it carries out, and its screenshots.`,
description: `Every OpenVibe.Shared release in the pinned changelog, newest first: what changed, the component pages it touches, the rules it carries out, and its screenshots.`,
path: '/changes',
path: '/changes',
active: '/components',
active: '/components',
styles: ['css/changes.css'],
styles: ['css/changes.css'],
- body: `<article class="chg">${hero}${index}<div class="chg-list">${list}</div>
+ body: `<article class="chg">${hero}<div class="chg-list">${full}</div>${folds}
<footer class="chg-foot"><p>Read from <code>openvibe-shared@${esc(pin.version)}</code>. A release is listed in the order the package writes it; the links are the only additions.</p>
<footer class="chg-foot"><p>Read from <code>openvibe-shared@${esc(pin.version)}</code>. A release is listed in the order the package writes it; the links are the only additions.</p>
<p>${kit.button({ href: '/components/registry', label: 'Every component', kind: 'quiet' })}${kit.button({ href: '/rules', label: 'How OpenVibe decides', kind: 'quiet' })}</p></footer></article>`,
<p>${kit.button({ href: '/components/registry', label: 'Every component', kind: 'quiet' })}${kit.button({ href: '/rules', label: 'How OpenVibe decides', kind: 'quiet' })}</p></footer></article>`,
});
});
}
}
-function changesJson() {
+function changesJson(query = {}) {
const all = releases();
const all = releases();
+ const limit = Number.isInteger(Number(query.limit)) && Number(query.limit) > 0 ? Math.min(Number(query.limit), 100) : all.length;
+ const before = query.before ? all.findIndex((r) => r.version === String(query.before).replace(/^v/, '')) : -1;
+ const eligible = before >= 0 ? all.slice(before + 1) : query.before ? [] : all;
return {
return {
schema: 'openvibe.changes@1',
schema: 'openvibe.changes@1',
pinned: pinned(),
pinned: pinned(),
- releases: all.map((r) => ({
+ releases: eligible.slice(0, limit).map((r) => ({
version: r.version,
version: r.version,
date: r.date,
date: r.date,
title: r.title,
title: r.title,
@@ -85,4 +122,4 @@ function changesJson() {
@@ -85,4 +122,4 @@ function changesJson() {
};
};
}
}
-module.exports = { changesPage, changesJson };
+module.exports = { changesPage, releasePage, changesJson };
@@ -10,7 +10,7 @@ const harness = require('openvibe-shared/browser-harness');
@@ -10,7 +10,7 @@ const harness = require('openvibe-shared/browser-harness');
const t = await boot();
const t = await boot();
try {
try {
await check('the component pages and /changes render with no console errors at 390 and 1280', async () => {
await check('the component pages and /changes render with no console errors at 390 and 1280', async () => {
- const routes = ['/changes', '/components/registry', '/components/brand', '/components/menu', '/components/search-pill',
+ const routes = ['/changes', '/changes/4.200.0', '/components/registry', '/components/brand', '/components/menu', '/components/search-pill',
'/components/quest-card', '/components/avatar', '/components/player', '/components/soft-pages', '/components/icon-ink', '/components/footer'];
'/components/quest-card', '/components/avatar', '/components/player', '/components/soft-pages', '/components/icon-ink', '/components/footer'];
const report = await harness.run({
const report = await harness.run({
base: t.base,
base: t.base,
@@ -22,17 +22,42 @@ const { PUBLIC_DIR } = require('../server/assets');
@@ -22,17 +22,42 @@ const { PUBLIC_DIR } = require('../server/assets');
await check('/changes lists every release in the pinned CHANGELOG, newest first, in its own words', async () => {
await check('/changes lists every release in the pinned CHANGELOG, newest first, in its own words', async () => {
const r = await t.get('/changes');
const r = await t.get('/changes');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
+ assert.ok(Buffer.byteLength(r.text) < 120 * 1024, `${Buffer.byteLength(r.text)} bytes`);
assert.match(r.text, /Every shared release, and where it shows/);
assert.match(r.text, /Every shared release, and where it shows/);
assert.match(r.text, new RegExp(`openvibe-shared@${changes.SHARED_VERSION.replace(/\./g, '\\.')}`));
assert.match(r.text, new RegExp(`openvibe-shared@${changes.SHARED_VERSION.replace(/\./g, '\\.')}`));
- assert.strictEqual((r.text.match(/data-release="/g) || []).length, all.length, `${all.length} releases`);
+ assert.strictEqual((r.text.match(/data-release="/g) || []).length, 12, 'exactly twelve full releases');
+ const folded = [...r.text.matchAll(/<summary>([^<]+) · (\d+) releases?<\/summary>/g)];
+ assert.strictEqual(folded.reduce((n, m) => n + Number(m[2]), 0), all.length - 12, 'fold counts cover every remaining release');
+ assert.strictEqual((r.text.match(/<details class="chg-month">/g) || []).length, folded.length);
let last = -1;
let last = -1;
- for (const release of all) {
- const at = r.text.indexOf(`data-release="${release.version}"`);
+ for (const [i, release] of all.entries()) {
+ const marker = i < 12 ? `data-release="${release.version}"` : `href="/changes/${release.version}"`;
+ const at = r.text.indexOf(marker);
assert.ok(at > last, `${release.version} is in order`);
assert.ok(at > last, `${release.version} is in order`);
last = at;
last = at;
}
}
assert.ok(r.text.includes('The button becomes its tab; Vibe holds Chat and Agents'), '4.200.0 as written');
assert.ok(r.text.includes('The button becomes its tab; Vibe holds Chat and Agents'), '4.200.0 as written');
- assert.ok(r.text.includes('navbar.js no longer ships every glyph'), '1.0.0 as written');
+ assert.ok(r.text.includes('/changes/1.0.0'), 'the oldest release has its own page');
+ assert.ok(r.text.includes('navbar.js no longer ships every glyph'), 'older release title is in the compact archive');
+ });
+
+ await check('each version page renders its full release, canonical, TechArticle date, neighbors, and plates', async () => {
+ for (let i = 0; i < all.length; i++) {
+ const release = all[i];
+ const r = await t.get(`/changes/${release.version}`);
+ assert.strictEqual(r.status, 200, release.version);
+ assert.match(r.text, new RegExp(`canonical" href="https://openvibe\\.design/changes/${release.version.replace(/\./g, '\\.')}`));
+ assert.match(r.text, new RegExp(`"datePublished":"${release.date}"`));
+ assert.match(r.text, new RegExp(`data-release="${release.version.replace(/\./g, '\\.')}`));
+ if (i + 1 < all.length) assert.ok(r.text.includes(`/changes/${all[i + 1].version}`), `${release.version} previous`);
+ if (i > 0) assert.ok(r.text.includes(`/changes/${all[i - 1].version}`), `${release.version} next`);
+ for (const shot of changes.screenshots(release.version).flatMap((g) => g.files)) {
+ const image = await t.get(shot.href);
+ assert.strictEqual(image.status, 200, shot.href);
+ }
+ }
+ assert.strictEqual((await t.get('/changes/not-a-release')).status, 404);
+ assert.strictEqual((await t.get('/changes/specimen-player.mp4')).status, 200);
});
});
await check('each release links the component pages it touches, and every link answers', async () => {
await check('each release links the component pages it touches, and every link answers', async () => {
@@ -43,7 +68,6 @@ const { PUBLIC_DIR } = require('../server/assets');
@@ -43,7 +68,6 @@ const { PUBLIC_DIR } = require('../server/assets');
assert.match(r.text, /href="\/components\/menu"/);
assert.match(r.text, /href="\/components\/menu"/);
assert.match(r.text, /href="\/components\/brand"/);
assert.match(r.text, /href="\/components\/brand"/);
assert.match(r.text, /href="\/components\/player"/);
assert.match(r.text, /href="\/components\/player"/);
- assert.match(r.text, /href="\/components\/quest-card"/);
});
});
await check('the rule numbers are the register’s own, and each links to its rule', async () => {
await check('the rule numbers are the register’s own, and each links to its rule', async () => {
@@ -84,6 +108,19 @@ const { PUBLIC_DIR } = require('../server/assets');
@@ -84,6 +108,19 @@ const { PUBLIC_DIR } = require('../server/assets');
assert.ok(body.releases[0].rules.some((x) => x.id === 'button-becomes-its-tab' && x.n > 0));
assert.ok(body.releases[0].rules.some((x) => x.id === 'button-becomes-its-tab' && x.n > 0));
});
});
+ await check('/api/v1/changes supports limit and before without changing the default shape', async () => {
+ const first = (await t.get('/api/v1/changes?limit=2')).json();
+ assert.deepStrictEqual(first.releases.map((x) => x.version), all.slice(0, 2).map((x) => x.version));
+ const next = (await t.get(`/api/v1/changes?limit=2&before=${all[1].version}`)).json();
+ assert.deepStrictEqual(next.releases.map((x) => x.version), all.slice(2, 4).map((x) => x.version));
+ assert.deepStrictEqual((await t.get('/api/v1/changes?before=missing')).json().releases, []);
+ });
+
+ await check('the sitemap includes each release page', async () => {
+ const map = (await t.get('/sitemap.xml')).text;
+ for (const release of all) assert.ok(map.includes(`https://openvibe.design/changes/${release.version}`), release.version);
+ });
+
await check('the home and every component page link the changes archive', async () => {
await check('the home and every component page link the changes archive', async () => {
assert.match((await t.get('/')).text, /href="\/changes"/, 'the home links it');
assert.match((await t.get('/')).text, /href="\/changes"/, 'the home links it');
const pages = ['/components/registry', '/components/brand', '/components/menu', '/components/navbar', '/components/search-pill',
const pages = ['/components/registry', '/components/brand', '/components/menu', '/components/navbar', '/components/search-pill',