openvibers/openvibe.wiki Commits

Publish the New on the wiki card as a frame.menu-cards array (#233)

OP OpenVibers committed 6 days ago · f8f379eb02 · 8 files, +199 −10

Changes

ddiff --git a/README.md b/README.md
iindex f37e060..81adfaf 100644
---- a/README.md
++++ b/README.md
@@@ -444,7 +444,7 @@ implemented service. Still open: a person's review of the 42 seed pages (until t
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
--- openvibe-contracts: v0.312.0
++- openvibe-contracts: v0.317.0
- openvibe-sdk: v0.94.0
- openvibe-shared: v4.129.0
- openvibe-publishing: v1.3.0
ddiff --git a/STATUS.json b/STATUS.json
iindex dea4c57..bb35cb2 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -7,7 +7,7 @@
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4800,
"domain": "openvibe.wiki",
-- "contracts": "openvibe-contracts v0.312.0",
++ "contracts": "openvibe-contracts v0.317.0",
"sdk": "openvibe-sdk v0.94.0",
"shared": "openvibe-shared v4.129.0",
"packages": [
ddiff --git a/package-lock.json b/package-lock.json
iindex 7a78488..0b0e2ad 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -14,7 +14,7 @@
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.317.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.129.0",
@@@ -1134,9 +1134,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.312.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",
-- "integrity": "sha512-2GsD53zuIedL3eW2LBlYQ68ODTHzoZJHrAmVnZCpiL7ZWnAXGwPW9dpQagLI9Zdkh3LQuuU5ae1YJI0K+acXbA==",
++ "version": "0.317.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.317.0",
++ "integrity": "sha512-qy9hGj4cl5MSHHcYmOKcrL1BmMrbQ9uJCGYH5zxhp3gu6RwZqu7kHLVWBCcy5y7SY8odkxNuVU5Ts/RksiivxQ==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex f7e9e49..a0f8678 100644
---- a/package.json
++++ b/package.json
@@@ -25,7 +25,7 @@
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.317.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.129.0",
ddiff --git a/server/app.js b/server/app.js
iindex fa358e0..d131304 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -147,6 +147,12 @@ function createApp({ config, telemetry = null, svc, viewers: baseViewers, platfo
}));
// GET /<key>.txt — the IndexNow key file, only when a key is configured (server/index.js).
if (indexnow && indexnow.enabled) app.use(indexnow.keyFile);
++ // The menu's network card (frame.menu-cards@1): the same public recent changes the home page reads.
++ {
++ const { createMenuCard } = require('./menu-card');
++ const menuCard = createMenuCard({ load: async () => await svc.recentChanges(6) });
++ app.get('/.well-known/openvibe-menu.json', menuCard.handler);
++ }
app.use(createMachine({ svc, config }));
app.post(['/new-space', '/s/*', '/w/*', '/proposals/*'], limiter(10 * 60000, 120));
ddiff --git a/server/menu-card.js b/server/menu-card.js
nnew file mode 100644
iindex 0000000..2d64056
---- /dev/null
++++ b/server/menu-card.js
@@@ -0,0 +1,100 @@
++'use strict';
++// The menu's network card for OpenVibe.Wiki (frame.menu-card@1): GET /.well-known/openvibe-menu.json.
++//
++// Wiki publishes "New on the wiki": the six pages revised most recently, each linking to its article. The rows
++// are the same public recent changes the home page and /recent show (svc.recentChanges), which already keeps
++// drafts, members-only wikis and private pages out. The file is CORS *, cached for five minutes; with nothing
++// published it is served with no items and Network skips it.
++const BASE = 'https://openvibe.wiki';
++const CARD_ID = 'new-pages';
++const SITE = 'wiki';
++const CARD_TTL_S = 300;
++const CACHE_MS = CARD_TTL_S * 1000;
++const LIMIT = 6;
++
++/** Plain words for a card: no markup, no control characters, one line, within the schema's limit. */
++function plain(value, max) {
++ return String(value == null ? '' : value)
++ .replace(/<[^>]*>/g, ' ')
++ .replace(/[\u0000-\u001f\u007f]+/g, ' ')
++ .replace(/\s+/g, ' ')
++ .trim()
++ .slice(0, max);
++}
++
++/** "just now", "12m ago", "3h ago" or "2d ago"; empty when unknown. */
++function ago(value, now) {
++ const at = typeof value === 'number' ? value : Date.parse(String(value || ''));
++ if (!Number.isFinite(at)) return '';
++ const mins = Math.max(0, Math.floor((now - at) / 60_000));
++ if (mins < 1) return 'just now';
++ if (mins < 60) return `${mins}m ago`;
++ const hours = Math.floor(mins / 60);
++ if (hours < 24) return `${hours}h ago`;
++ return `${Math.floor(hours / 24)}d ago`;
++}
++
++/** The article's path, the same /w/<space>/<slug> the site renders. */
++const pagePath = (space, page) => `/w/${encodeURIComponent(String(space.slug))}/${encodeURIComponent(String(page.slug))}`;
++
++/** The card itself. `changes` are svc.recentChanges() rows: { space, page, rev, ... }. */
++function cardFrom(changes, at = Date.now()) {
++ const out = [];
++ for (const change of Array.isArray(changes) ? changes : []) {
++ const space = change && change.space;
++ const page = change && change.page;
++ if (!space || !page || page.state === 'deleted') continue;
++ if (page.visibility && page.visibility !== 'public') continue;
++ const title = plain(page.title, 80);
++ if (!title || !space.slug || !page.slug) continue;
++ const meta = [plain(space.name, 60), ago(page.revision_published_at || page.published_at, at)].filter(Boolean).join(' · ');
++ out.push({ title, ...(meta ? { meta: plain(meta, 120) } : {}), url: `${BASE}${pagePath(space, page)}` });
++ if (out.length >= LIMIT) break;
++ }
++ return {
++ id: CARD_ID,
++ site: SITE,
++ title: 'New on the wiki',
++ kind: 'rows',
++ items: out,
++ more: { label: 'All wikis', url: `${BASE}/` },
++ updated_at: new Date(at).toISOString(),
++ ttl_s: CARD_TTL_S,
++ };
++}
++
++/** The route. `load` returns svc.recentChanges() rows; `now` is injectable for the test. */
++function createMenuCard({ load, now = Date.now } = {}) {
++ let cached = null;
++ let cachedAt = 0;
++ let pending = null;
++
++ async function card() {
++ const at = now();
++ if (cached && at - cachedAt < CACHE_MS) return cached;
++ if (pending) return pending;
++ pending = (async () => {
++ try {
++ cached = cardFrom(await load(), at);
++ cachedAt = at;
++ } catch {
++ // Keep the last good card rather than publish an empty one on a database hiccup.
++ if (!cached) { cached = cardFrom([], at); cachedAt = at; }
++ }
++ return cached;
++ })().finally(() => { pending = null; });
++ return pending;
++ }
++
++ async function handler(_req, res) {
++ const body = await card();
++ res.set('Cache-Control', 'public, max-age=60')
++ .set('Access-Control-Allow-Origin', '*')
++ .set('Cross-Origin-Resource-Policy', 'cross-origin')
++ .json([body]);
++ }
++
++ return { card, handler, cardFrom, cacheMs: CACHE_MS };
++}
++
++module.exports = { createMenuCard, cardFrom, ago, pagePath, plain, BASE, CARD_ID, SITE, CARD_TTL_S, LIMIT };
ddiff --git a/test/event-contracts.test.js b/test/event-contracts.test.js
iindex b6baa73..674b8d7 100644
---- a/test/event-contracts.test.js
++++ b/test/event-contracts.test.js
@@@ -12,7 +12,6 @@ const { CAPS, PROPOSED } = require('../server/auth/capabilities');
(async () => {
// The capabilities the routes enforce are the released ones, so capabilities.check() decides them.
-- // One id waits for a Contracts release — wiki.review.ai_editor — and is decided locally until then.
for (const id of Object.values(CAPS)) {
const cap = contracts.capabilities.get(id);
if (!cap) {
@@@ -22,8 +21,6 @@ const { CAPS, PROPOSED } = require('../server/auth/capabilities');
assert.strictEqual(cap.owner, 'wiki', `${id} is owned by wiki`);
assert.notStrictEqual(cap.status, 'retired', `${id} is not retired`);
}
-- // The AI editor's id is the one proposal; when Contracts releases it, this line goes.
-- assert.strictEqual(contracts.capabilities.get('wiki.review.ai_editor'), undefined);
assert.deepStrictEqual(
contracts.capabilities.check({ cap: ['wiki.*'] }, CAPS.PAGE_READ),
{ allowed: true, code: null, reason: null },
ddiff --git a/test/menu-card.test.js b/test/menu-card.test.js
nnew file mode 100644
iindex 0000000..e18f7c1
---- /dev/null
++++ b/test/menu-card.test.js
@@@ -0,0 +1,86 @@
++'use strict';
++// The menu's network card for OpenVibe.Wiki (frame.menu-card@1): GET /.well-known/openvibe-menu.json serves
++// "New on the wiki" — the most recently revised public pages, each linking to its article. The array validates
++// against frame.menu-cards@1, carries no private or deleted page, is CORS * and cached for five minutes.
++const assert = require('assert');
++const contracts = require('openvibe-contracts');
++const { createMenuCard, cardFrom, pagePath, CARD_TTL_S } = require('../server/menu-card');
++
++const NOW = Date.parse('2026-11-07T12:00:00Z');
++const change = (over = {}) => ({
++ space: { slug: 'openvibe', name: 'OpenVibe', ...(over.space || {}) },
++ page: {
++ slug: 'how-it-works', title: 'How OpenVibe works', state: 'published', visibility: 'public',
++ revision_published_at: NOW - 2 * 60 * 60_000, ...(over.page || {}),
++ },
++});
++
++// ── The card's shape and words ───────────────────────────────────────────
++const card = cardFrom([
++ change(),
++ change({ space: { slug: 'cooking', name: 'Cooking' }, page: { slug: 'rye-bread', title: '<b>Rye</b>\nbread', revision_published_at: NOW - 26 * 60 * 60_000 } }),
++], NOW);
++const checked = contracts.validate('frame.menu-cards@1', [card]);
++assert.ok(checked.valid, JSON.stringify(checked.errors));
++assert.deepStrictEqual(Object.keys(card).sort(), ['id', 'items', 'kind', 'more', 'site', 'title', 'ttl_s', 'updated_at']);
++assert.strictEqual(card.id, 'new-pages');
++assert.strictEqual(card.site, 'wiki');
++assert.strictEqual(card.title, 'New on the wiki');
++assert.strictEqual(card.kind, 'rows');
++assert.strictEqual(card.ttl_s, CARD_TTL_S);
++assert.deepStrictEqual(card.items, [
++ { title: 'How OpenVibe works', meta: 'OpenVibe · 2h ago', url: 'https://openvibe.wiki/w/openvibe/how-it-works' },
++ { title: 'Rye bread', meta: 'Cooking · 1d ago', url: 'https://openvibe.wiki/w/cooking/rye-bread' },
++]);
++assert.deepStrictEqual(card.more, { label: 'All wikis', url: 'https://openvibe.wiki/' });
++assert.ok(card.title.length <= 60);
++for (const it of card.items) assert.ok(it.title.length <= 80 && (!it.meta || it.meta.length <= 120) && it.url.startsWith('https://openvibe.wiki/w/'));
++
++// Drafts, private wikis, deleted pages and broken rows never enter the card, and at most six pages are sent.
++assert.deepStrictEqual(cardFrom([change({ page: { visibility: 'private' } })], NOW).items, []);
++assert.deepStrictEqual(cardFrom([change({ page: { state: 'deleted' } })], NOW).items, []);
++assert.deepStrictEqual(cardFrom([change({ page: { title: ' ' } })], NOW).items, []);
++assert.deepStrictEqual(cardFrom([null, {}], NOW).items, []);
++assert.deepStrictEqual(cardFrom(null, NOW).items, []);
++const many = Array.from({ length: 9 }, (_, i) => change({ page: { slug: `page-${i}`, title: `Page ${i}` } }));
++assert.strictEqual(cardFrom(many, NOW).items.length, 6);
++
++// The path is the site's own, with both segments encoded.
++assert.strictEqual(pagePath({ slug: 'a b' }, { slug: 'c/d' }), '/w/a%20b/c%2Fd');
++
++// ── The five-minute cache and the route's headers ───────────────────────
++(async () => {
++ let t = NOW;
++ let loads = 0;
++ const menu = createMenuCard({ load: async () => { loads++; return [change()]; }, now: () => t });
++ const first = await menu.card();
++ assert.strictEqual(loads, 1);
++ assert.strictEqual(await menu.card(), first);
++ assert.strictEqual(loads, 1, 'a second request inside the ttl is served from the cache');
++ t += CARD_TTL_S * 1000 + 1;
++ await menu.card();
++ assert.strictEqual(loads, 2, 'after the ttl the card is rebuilt');
++
++ const headers = {};
++ let body = null;
++ const res = {
++ set(k, v) { headers[k] = v; return this; },
++ json(v) { body = v; return this; },
++ };
++ await menu.handler({}, res);
++ assert.strictEqual(headers['Access-Control-Allow-Origin'], '*');
++ assert.strictEqual(headers['Cross-Origin-Resource-Policy'], 'cross-origin');
++ assert.strictEqual(headers['Cache-Control'], 'public, max-age=60');
++ assert.ok(Array.isArray(body) && body.length === 1, 'the file is an array of cards');
++ assert.ok(contracts.validate('frame.menu-cards@1', body).valid);
++
++ // A failing database keeps the last good card rather than publishing an empty list.
++ let fail = false;
++ const flaky = createMenuCard({ load: async () => { if (fail) throw new Error('db down'); return [change()]; }, now: () => t });
++ const good = await flaky.card();
++ fail = true;
++ t += CARD_TTL_S * 1000 + 1;
++ assert.strictEqual(await flaky.card(), good);
++
++ console.log('menu card: shape, contract, public-only pages, plain text, 5 min cache, CORS and last-good card');
++})().catch((err) => { console.error(err); process.exit(1); });