openvibers/openvibe.services Commits
The docs pages have the Markdown twins they name (#209)
Changes
@@@ -3,12 +3,39 @@// view builders the routes use (http/pages.js and http/docs.js + render/layout.js's renderPage), so a twin// cannot drift from its page. Anonymous only: /projects, /staff and the API are never rendered here.const { createPageViews } = require('./http/pages');--const { createDocsViews } = require('./http/docs');++const { createDocsViews, createDocsRoutes } = require('./http/docs');++++// The docs pages the docs router builds inline (contracts, capabilities, events, services, tools, SDK, …) are twinned by++// running that router signed out: each named a twin that answered 404 (11-03, tools/twin-named-check.js).++const DOCS_ROUTER_RE = /^\/docs\/(?:contracts|capabilities|events|services|tools|billing|limits|export|sdk|adr|updates)(?:\/[^/]+)?$/;++++/** One GET through `router`, as a signed-out visitor; the HTML of a 200 answer, or null. */++function renderThrough(router, mount, path, query) {++ return new Promise((resolve) => {++ const search = query ? query.toString() : '';++ const sub = path.slice(mount.length) || '/';++ const req = {++ method: 'GET', url: sub + (search ? `?${search}` : ''), originalUrl: path + (search ? `?${search}` : ''), baseUrl: mount, path: sub,++ params: {}, query: Object.fromEntries(new URLSearchParams(search)), headers: { accept: 'text/html', host: 'localhost' },++ viewer: { kind: 'anonymous' }, get(name) { return this.headers[String(name).toLowerCase()]; },++ };++ const headers = {};++ const res = {++ statusCode: 200,++ get: (k) => headers[String(k).toLowerCase()], set(k, v) { headers[String(k).toLowerCase()] = v; return this; },++ status(code) { this.statusCode = code; return this; }, type(t) { headers['content-type'] = t; return this; },++ send(body) { resolve(this.statusCode === 200 && /html/.test(String(headers['content-type'] || '')) && typeof body === 'string' ? body : null); return this; },++ json() { resolve(null); return this; }, end() { resolve(null); return this; }, redirect() { resolve(null); return this; },++ };++ try { router.handle(req, res, () => resolve(null)); } catch { resolve(null); }++ });++}const { renderPage } = require('./render/layout');function createAgentPages(ctx) {const pages = createPageViews(ctx);const docs = createDocsViews(ctx);++ const docsRouter = createDocsRoutes(ctx);const twin = (o, path, query) => {if (!o) return null;@@@ -32,6 +59,10 @@ function createAgentPages(ctx) {if (path === '/policy/transparency') return twin(pages.policyTransparency(), path);if (path === '/docs') return twin(docs.index(), path);if (path === '/docs/api') return twin(docs.apiExplorer(), path);++ if (DOCS_ROUTER_RE.test(path)) {++ const htmlOut = await renderThrough(docsRouter, '/docs', path, query);++ return htmlOut ? { html: htmlOut } : null;++ }const api = /^\/docs\/api\/([^/]+)$/.exec(path);if (api) {const id = decodeURIComponent(api[1]);@@@ -224,12 +224,12 @@ function createDiscoveryRoutes(ctx) {] },{ title: 'Reference', links: [{ title: 'API explorer', url: abs('/docs/api'), note: 'each service\'s OpenAPI document, per page as .json' },-- { title: 'Contracts', url: abs('/docs/contracts'), html: true, note: 'schemas, field tables and fixtures, per page as .json' },-- { title: 'Capabilities', url: abs('/docs/capabilities'), html: true },-- { title: 'Events', url: abs('/docs/events'), html: true },-- { title: 'Services', url: abs('/docs/services'), html: true },-- { title: 'Tools', url: abs('/docs/tools'), html: true },-- { title: 'SDK', url: abs('/docs/sdk'), html: true },++ { title: 'Contracts', url: abs('/docs/contracts'), note: 'schemas, field tables and fixtures, per page as .json' },++ { title: 'Capabilities', url: abs('/docs/capabilities') },++ { title: 'Events', url: abs('/docs/events') },++ { title: 'Services', url: abs('/docs/services') },++ { title: 'Tools', url: abs('/docs/tools') },++ { title: 'SDK', url: abs('/docs/sdk') },] },{ title: 'Extend OpenVibe', links: [{ title: 'Mod the network', url: abs('/mod'), note: 'every way to extend OpenVibe today, and where packaged mods stand' },@@@ -61,7 +61,7 @@ const PRIVATE = ['/projects', '/staff', '/auth/', '/oauth/test-callback'];assert.ok(/\n## Optional\n/.test(r.text), 'no Optional section');assert.strictEqual(r.text.lastIndexOf('\n## '), r.text.indexOf('\n## Optional'), 'Optional is the last section');// A page whose twin answers 404 keeps its URL and says (HTML); a file keeps its URL.-- assert.ok(r.text.includes('(https://openvibe.services/docs/contracts) (HTML)'), 'the twin-less contracts page is marked (HTML)');++ assert.ok(r.text.includes('(https://openvibe.services/docs/contracts.md)') && r.text.includes('(https://openvibe.services/oauth) (HTML)'), 'the docs pages link their twins; a page with none is marked (HTML)');assert.ok(!/\]\(https:\/\/openvibe\.services\/mcp\)/.test(r.text), '/mcp must not be a page link');assert.ok(/MCP server: POST https:\/\/openvibe\.services\/mcp/.test(r.text), 'the MCP endpoint is named in the details');const own = [...r.text.matchAll(/\]\((https:\/\/openvibe\.services[^)\s]*)\)( \(HTML\))?/g)];@@@ -28,6 +28,18 @@ const sdkPkg = require('openvibe-sdk/package.json');assert.ok(contracts.resolve('services.app-manifest@1'));});++ await check('the docs pages have the Markdown twins they name (11-03: /docs/contracts and the rest answered 404)', async () => {++ for (const p of ['/docs/contracts', '/docs/capabilities', '/docs/events', '/docs/sdk', '/docs/contracts/errors.problem', '/docs/sdk/core']) {++ const page = await t.get(p);++ assert.match(page.headers.get('link') || page.text, /text\/markdown/, `${p} names a twin`);++ const twin = await t.get(`${p}.md`);++ assert.strictEqual(twin.status, 200, `${p}.md`);++ assert.match(twin.headers.get('content-type') || '', /^text\/markdown/, `${p}.md is Markdown`);++ assert.ok(!/<\/?(div|table|td|tr|section)\b/i.test(twin.text), `${p}.md is Markdown, not HTML`);++ }++ assert.strictEqual((await t.get('/docs/contracts/no-such-contract.md')).status, 404, 'no page, no twin');++ });++await check('every docs page states the versions it was generated from', async () => {for (const p of ['/docs', '/docs/contracts', '/docs/capabilities', '/docs/events', '/docs/sdk', '/docs/contracts/errors.problem', '/docs/sdk/core']) {const r = await t.get(p);
@@ -3,12 +3,39 @@
@@ -3,12 +3,39 @@
// view builders the routes use (http/pages.js and http/docs.js + render/layout.js's renderPage), so a twin
// view builders the routes use (http/pages.js and http/docs.js + render/layout.js's renderPage), so a twin
// cannot drift from its page. Anonymous only: /projects, /staff and the API are never rendered here.
// cannot drift from its page. Anonymous only: /projects, /staff and the API are never rendered here.
const { createPageViews } = require('./http/pages');
const { createPageViews } = require('./http/pages');
-const { createDocsViews } = require('./http/docs');
+const { createDocsViews, createDocsRoutes } = require('./http/docs');
+
+// The docs pages the docs router builds inline (contracts, capabilities, events, services, tools, SDK, …) are twinned by
+// running that router signed out: each named a twin that answered 404 (11-03, tools/twin-named-check.js).
+const DOCS_ROUTER_RE = /^\/docs\/(?:contracts|capabilities|events|services|tools|billing|limits|export|sdk|adr|updates)(?:\/[^/]+)?$/;
+
+/** One GET through `router`, as a signed-out visitor; the HTML of a 200 answer, or null. */
+function renderThrough(router, mount, path, query) {
+ return new Promise((resolve) => {
+ const search = query ? query.toString() : '';
+ const sub = path.slice(mount.length) || '/';
+ const req = {
+ method: 'GET', url: sub + (search ? `?${search}` : ''), originalUrl: path + (search ? `?${search}` : ''), baseUrl: mount, path: sub,
+ params: {}, query: Object.fromEntries(new URLSearchParams(search)), headers: { accept: 'text/html', host: 'localhost' },
+ viewer: { kind: 'anonymous' }, get(name) { return this.headers[String(name).toLowerCase()]; },
+ };
+ const headers = {};
+ const res = {
+ statusCode: 200,
+ get: (k) => headers[String(k).toLowerCase()], set(k, v) { headers[String(k).toLowerCase()] = v; return this; },
+ status(code) { this.statusCode = code; return this; }, type(t) { headers['content-type'] = t; return this; },
+ send(body) { resolve(this.statusCode === 200 && /html/.test(String(headers['content-type'] || '')) && typeof body === 'string' ? body : null); return this; },
+ json() { resolve(null); return this; }, end() { resolve(null); return this; }, redirect() { resolve(null); return this; },
+ };
+ try { router.handle(req, res, () => resolve(null)); } catch { resolve(null); }
+ });
+}
const { renderPage } = require('./render/layout');
const { renderPage } = require('./render/layout');
function createAgentPages(ctx) {
function createAgentPages(ctx) {
const pages = createPageViews(ctx);
const pages = createPageViews(ctx);
const docs = createDocsViews(ctx);
const docs = createDocsViews(ctx);
+ const docsRouter = createDocsRoutes(ctx);
const twin = (o, path, query) => {
const twin = (o, path, query) => {
if (!o) return null;
if (!o) return null;
@@ -32,6 +59,10 @@ function createAgentPages(ctx) {
@@ -32,6 +59,10 @@ function createAgentPages(ctx) {
if (path === '/policy/transparency') return twin(pages.policyTransparency(), path);
if (path === '/policy/transparency') return twin(pages.policyTransparency(), path);
if (path === '/docs') return twin(docs.index(), path);
if (path === '/docs') return twin(docs.index(), path);
if (path === '/docs/api') return twin(docs.apiExplorer(), path);
if (path === '/docs/api') return twin(docs.apiExplorer(), path);
+ if (DOCS_ROUTER_RE.test(path)) {
+ const htmlOut = await renderThrough(docsRouter, '/docs', path, query);
+ return htmlOut ? { html: htmlOut } : null;
+ }
const api = /^\/docs\/api\/([^/]+)$/.exec(path);
const api = /^\/docs\/api\/([^/]+)$/.exec(path);
if (api) {
if (api) {
const id = decodeURIComponent(api[1]);
const id = decodeURIComponent(api[1]);
@@ -224,12 +224,12 @@ function createDiscoveryRoutes(ctx) {
@@ -224,12 +224,12 @@ function createDiscoveryRoutes(ctx) {
] },
] },
{ title: 'Reference', links: [
{ title: 'Reference', links: [
{ title: 'API explorer', url: abs('/docs/api'), note: 'each service\'s OpenAPI document, per page as .json' },
{ title: 'API explorer', url: abs('/docs/api'), note: 'each service\'s OpenAPI document, per page as .json' },
- { title: 'Contracts', url: abs('/docs/contracts'), html: true, note: 'schemas, field tables and fixtures, per page as .json' },
- { title: 'Capabilities', url: abs('/docs/capabilities'), html: true },
- { title: 'Events', url: abs('/docs/events'), html: true },
- { title: 'Services', url: abs('/docs/services'), html: true },
- { title: 'Tools', url: abs('/docs/tools'), html: true },
- { title: 'SDK', url: abs('/docs/sdk'), html: true },
+ { title: 'Contracts', url: abs('/docs/contracts'), note: 'schemas, field tables and fixtures, per page as .json' },
+ { title: 'Capabilities', url: abs('/docs/capabilities') },
+ { title: 'Events', url: abs('/docs/events') },
+ { title: 'Services', url: abs('/docs/services') },
+ { title: 'Tools', url: abs('/docs/tools') },
+ { title: 'SDK', url: abs('/docs/sdk') },
] },
] },
{ title: 'Extend OpenVibe', links: [
{ title: 'Extend OpenVibe', links: [
{ title: 'Mod the network', url: abs('/mod'), note: 'every way to extend OpenVibe today, and where packaged mods stand' },
{ title: 'Mod the network', url: abs('/mod'), note: 'every way to extend OpenVibe today, and where packaged mods stand' },
@@ -61,7 +61,7 @@ const PRIVATE = ['/projects', '/staff', '/auth/', '/oauth/test-callback'];
@@ -61,7 +61,7 @@ const PRIVATE = ['/projects', '/staff', '/auth/', '/oauth/test-callback'];
assert.ok(/\n## Optional\n/.test(r.text), 'no Optional section');
assert.ok(/\n## Optional\n/.test(r.text), 'no Optional section');
assert.strictEqual(r.text.lastIndexOf('\n## '), r.text.indexOf('\n## Optional'), 'Optional is the last section');
assert.strictEqual(r.text.lastIndexOf('\n## '), r.text.indexOf('\n## Optional'), 'Optional is the last section');
// A page whose twin answers 404 keeps its URL and says (HTML); a file keeps its URL.
// A page whose twin answers 404 keeps its URL and says (HTML); a file keeps its URL.
- assert.ok(r.text.includes('(https://openvibe.services/docs/contracts) (HTML)'), 'the twin-less contracts page is marked (HTML)');
+ assert.ok(r.text.includes('(https://openvibe.services/docs/contracts.md)') && r.text.includes('(https://openvibe.services/oauth) (HTML)'), 'the docs pages link their twins; a page with none is marked (HTML)');
assert.ok(!/\]\(https:\/\/openvibe\.services\/mcp\)/.test(r.text), '/mcp must not be a page link');
assert.ok(!/\]\(https:\/\/openvibe\.services\/mcp\)/.test(r.text), '/mcp must not be a page link');
assert.ok(/MCP server: POST https:\/\/openvibe\.services\/mcp/.test(r.text), 'the MCP endpoint is named in the details');
assert.ok(/MCP server: POST https:\/\/openvibe\.services\/mcp/.test(r.text), 'the MCP endpoint is named in the details');
const own = [...r.text.matchAll(/\]\((https:\/\/openvibe\.services[^)\s]*)\)( \(HTML\))?/g)];
const own = [...r.text.matchAll(/\]\((https:\/\/openvibe\.services[^)\s]*)\)( \(HTML\))?/g)];
@@ -28,6 +28,18 @@ const sdkPkg = require('openvibe-sdk/package.json');
@@ -28,6 +28,18 @@ const sdkPkg = require('openvibe-sdk/package.json');
assert.ok(contracts.resolve('services.app-manifest@1'));
assert.ok(contracts.resolve('services.app-manifest@1'));
});
});
+ await check('the docs pages have the Markdown twins they name (11-03: /docs/contracts and the rest answered 404)', async () => {
+ for (const p of ['/docs/contracts', '/docs/capabilities', '/docs/events', '/docs/sdk', '/docs/contracts/errors.problem', '/docs/sdk/core']) {
+ const page = await t.get(p);
+ assert.match(page.headers.get('link') || page.text, /text\/markdown/, `${p} names a twin`);
+ const twin = await t.get(`${p}.md`);
+ assert.strictEqual(twin.status, 200, `${p}.md`);
+ assert.match(twin.headers.get('content-type') || '', /^text\/markdown/, `${p}.md is Markdown`);
+ assert.ok(!/<\/?(div|table|td|tr|section)\b/i.test(twin.text), `${p}.md is Markdown, not HTML`);
+ }
+ assert.strictEqual((await t.get('/docs/contracts/no-such-contract.md')).status, 404, 'no page, no twin');
+ });
+
await check('every docs page states the versions it was generated from', async () => {
await check('every docs page states the versions it was generated from', async () => {
for (const p of ['/docs', '/docs/contracts', '/docs/capabilities', '/docs/events', '/docs/sdk', '/docs/contracts/errors.problem', '/docs/sdk/core']) {
for (const p of ['/docs', '/docs/contracts', '/docs/capabilities', '/docs/events', '/docs/sdk', '/docs/contracts/errors.problem', '/docs/sdk/core']) {
const r = await t.get(p);
const r = await t.get(p);