openvibers/openvibe.design Commits

Changes: fold older Shared releases and add release pages (#264)

OP OpenVibers committed 2 days ago · 81680f1608 · 6 files, +97 −18

Changes

ddiff --git a/server/http/api.js b/server/http/api.js
iindex 36cd2a6..cf369f4 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -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) => {
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 756d83c..e69026c 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -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));
});
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 4daf109..40dc6e6 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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) => {
ddiff --git a/server/views/changes.js b/server/views/changes.js
iindex 7d43821..0c2ff60 100644
---- a/server/views/changes.js
++++ b/server/views/changes.js
@@@ -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 };
ddiff --git a/test/changes-chrome.test.js b/test/changes-chrome.test.js
iindex 3d3c806..e24f02d 100644
---- a/test/changes-chrome.test.js
++++ b/test/changes-chrome.test.js
@@@ -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,
ddiff --git a/test/changes.test.js b/test/changes.test.js
iindex 6b3dba6..5b28fcc 100644
---- a/test/changes.test.js
++++ b/test/changes.test.js
@@@ -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',