openvibers/openvibe.bot Commits
Bot: the Build docs live inside the OpenVibe Frame with a docs layout (sidebar, readable widths, theme colours) (#53)
Changes
nnew file mode 100644@@@ -0,0 +1,48 @@++/* OpenVibe.Bot's "Build" pages (/docs, /docs/drivers, /docs/profiles): a docs layout inside the OpenVibe Frame.++ Colours come from the Frame's theme variables, so the pages follow the person's theme. */++.docs-wrap { max-width: 1120px; margin: 0 auto; padding: 32px 16px 64px; display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 40px; align-items: start; }++.docs-side { position: sticky; top: 76px; display: flex; flex-direction: column; gap: 2px; }++.docs-side .docs-side-title { font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--text-secondary, #96a7c2); margin: 0 0 8px 10px; }++.docs-side a { display: block; padding: 8px 10px; border-radius: 8px; color: var(--text-secondary, #96a7c2); text-decoration: none; font-size: 14px; font-weight: 600; }++.docs-side a:hover { color: var(--text-primary, #e6edf7); background: var(--bg-secondary, #101828); }++.docs-side a[aria-current="page"] { color: var(--text-primary, #e6edf7); background: var(--accent-glow, rgba(59,130,246,.14)); box-shadow: inset 2px 0 0 var(--accent, #3b82f6); }++.docs-side .docs-side-rule { height: 1px; background: var(--border, #1f2d47); margin: 10px 0; }++++.docs-main { min-width: 0; color: var(--text-primary, #e6edf7); font-size: 15.5px; line-height: 1.65; }++.docs-main h1 { font-size: clamp(28px, 4vw, 38px); line-height: 1.15; letter-spacing: -.02em; margin: 0 0 12px; }++.docs-main > p.muted:first-of-type { font-size: 17px; margin: 0 0 28px; }++.docs-main h2 { font-size: 20px; letter-spacing: -.01em; margin: 0 0 10px; }++.docs-main h3 { font-size: 16px; margin: 18px 0 6px; }++.docs-main p { margin: 0 0 12px; }++.docs-main ul, .docs-main ol { margin: 0 0 12px; padding-left: 22px; }++.docs-main li { margin: 4px 0; }++.docs-main a { color: var(--accent-light, #60a5fa); text-underline-offset: 2px; }++.docs-main .muted { color: var(--text-secondary, #96a7c2); }++.docs-main code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .9em; background: var(--bg-secondary, #101828); border: 1px solid var(--border, #1f2d47); border-radius: 5px; padding: 1px 5px; }++.docs-main pre { margin: 10px 0 14px; padding: 14px 16px; overflow-x: auto; background: var(--bg-primary, #0a0f1c); border: 1px solid var(--border, #1f2d47); border-radius: 10px; line-height: 1.5; }++.docs-main pre code { background: none; border: 0; padding: 0; font-size: 13px; white-space: pre; }++++.docs-main .card { background: var(--bg-secondary, #101828); border: 1px solid var(--border, #1f2d47); border-radius: 14px; padding: 20px 22px; margin: 0 0 16px; }++.docs-main .card > :last-child { margin-bottom: 0; }++.docs-main .card h2 { font-size: 13px; font-weight: 750; letter-spacing: .07em; text-transform: uppercase; color: var(--text-secondary, #96a7c2); margin-bottom: 12px; }++.docs-main .card .card { background: var(--bg-primary, #0a0f1c); padding: 14px 16px; margin: 10px 0; }++++.docs-main form { display: grid; gap: 12px; margin: 6px 0 10px; }++.docs-main .field { display: grid; gap: 6px; }++.docs-main .field-label { font-size: 13px; font-weight: 700; color: var(--text-secondary, #96a7c2); }++.docs-main textarea { width: 100%; box-sizing: border-box; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; line-height: 1.5; color: var(--text-primary, #e6edf7); background: var(--bg-primary, #0a0f1c); border: 1px solid var(--border, #1f2d47); border-radius: 10px; padding: 12px 14px; resize: vertical; }++.docs-main textarea:focus { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }++.docs-main button.primary { justify-self: start; font: inherit; font-weight: 700; font-size: 14px; color: #fff; background: var(--accent, #3b82f6); border: 0; border-radius: 10px; padding: 10px 18px; cursor: pointer; }++.docs-main button.primary:hover { filter: brightness(1.08); }++.docs-main .error { border: 1px solid rgba(239, 68, 68, .45); background: rgba(239, 68, 68, .08); border-radius: 10px; padding: 12px 14px; }++.docs-main [role="status"] strong { color: #22c55e; }++.docs-foot { margin-top: 28px; padding-top: 16px; border-top: 1px solid var(--border, #1f2d47); font-size: 13.5px; }++++@media (max-width: 860px) {++ .docs-wrap { grid-template-columns: 1fr; gap: 18px; padding-top: 18px; }++ .docs-side { position: static; flex-direction: row; flex-wrap: wrap; gap: 6px; }++ .docs-side .docs-side-title, .docs-side .docs-side-rule { display: none; }++ .docs-side a { padding: 7px 12px; border: 1px solid var(--border, #1f2d47); border-radius: 999px; font-size: 13px; }++ .docs-side a[aria-current="page"] { box-shadow: none; border-color: var(--accent, #3b82f6); }++ .docs-main .card { padding: 16px; }++}@@@ -13,7 +13,8 @@* renderProfilesPage({ config, source, result }) GET /docs/profiles, POST /docs/profiles/validate* formatProblems(problems) / validationResult(result)*/--const { page, esc } = require('./render');++const { esc } = require('./render');++const { framePage } = require('./home');const simRover = require('../profiles/sim.rover.json');// The driver protocol and the bundled drivers live in OpenVibe.Node (the Node core and its plugins/ tree).@@@ -28,19 +29,20 @@ const out = (href, text) => `<a href="${esc(href)}" rel="noopener">${esc(text)}</** A link on this site. */const inLink = (href, text) => `<a href="${esc(href)}">${esc(text)}</a>`;--/** The shared header of the three pages, marking the current one. Plain markup: no script anywhere. */--function docsNav(current) {-- const items = [['/docs', 'Build'], ['/docs/drivers', 'Write a driver'], ['/docs/profiles', 'Robot profiles'], ['/robots', 'Robots']];-- const links = items.map(([href, label]) => (href === current-- ? `<span class="pill">${esc(label)}</span>`-- : `<a href="${esc(href)}">${esc(label)}</a>`)).join(' ');-- return `<nav class="topbar"><a class="brand" href="/"><span>OpenVibe<b>.Bot</b></span></a><span class="crumbs">${links}</span></nav>`;--}++const PAGES = [['/docs', 'Overview'], ['/docs/drivers', 'Write a driver'], ['/docs/profiles', 'Robot profiles']];--/** The shared footer of the three pages. */--const docsFoot = () => `<footer class="page"><p class="muted">The Node and its bundled drivers are in `-- + `${out(`${TREE}/plugins`, 'OpenVibers/OpenVibe.Node')}; the full protocol is `-- + `${out(`${BLOB}/docs/protocol.md`, 'protocol.md')} and the plugin contract is ${out(`${BLOB}/docs/plugins.md`, 'plugins.md')}.</p></footer>`;++/** The Build pages inside the OpenVibe Frame: a sidebar of the three pages beside the content, plain markup. */++function docsPage({ config, path, title, description, content }) {++ const side = PAGES.map(([href, label]) => `<a href="${esc(href)}"${href === path ? ' aria-current="page"' : ''}>${esc(label)}</a>`).join('');++ const body = `<div class="docs-wrap">++<nav class="docs-side" aria-label="Build for OpenVibe.Bot"><p class="docs-side-title">Build</p>${side}<div class="docs-side-rule"></div><a href="/robots">Your robots</a></nav>++<main id="main" class="docs-main">++${content}++<p class="docs-foot muted">The Node and its bundled drivers are in ${out(`${TREE}/plugins`, 'OpenVibers/OpenVibe.Node')}; the full protocol is ${out(`${BLOB}/docs/protocol.md`, 'protocol.md')} and the plugin contract is ${out(`${BLOB}/docs/plugins.md`, 'plugins.md')}.</p>++</main>++</div>`;++ return framePage({ config, title: title.includes('OpenVibe.Bot') ? title : `${title} · OpenVibe.Bot`, description, canonicalPath: path, body, head: '<link rel="stylesheet" href="/docs/docs.css">' });++}/** A message type: its name, a sentence, and a short JSON example. */const message = (name, text, sample) => `<div class="card"><p><code>${esc(name)}</code> — ${esc(text)}</p>${code(sample)}</div>`;@@@ -48,8 +50,7 @@ const message = (name, text, sample) => `<div class="card"><p><code>${esc(name)}// ── GET /docs ─────────────────────────────────────────────────────────────────────────────────────function renderDocsIndex({ config }) {-- const body = `${docsNav('/docs')}--<main class="page">++ const content = `<h1>Build for OpenVibe.Bot</h1><p class="muted">OpenVibe.Bot drives a robot from its profile. Two things are open to build against: the <b>driver</b> — a separate program the OpenVibe Node runs — and the <b>profile</b>, the JSON that turns a driver's capabilities into a panel.</p><section class="card"><h2>Write a driver</h2>@@@ -68,16 +69,14 @@ function renderDocsIndex({ config }) {<li>Bundled drivers: ${out(`${TREE}/plugins/dryrun`, 'dryrun')}, ${out(`${TREE}/plugins/adeept_adr036`, 'adeept_adr036')}, ${out(`${TREE}/plugins/cozmo`, 'cozmo')} and ${out(`${TREE}/plugins/relay`, 'relay')}.</li><li>Profiles ship in this repository (${inLink('/api/v1/profiles', 'GET /api/v1/profiles')}) and are validated by the same loader the pages use.</li></ul>--</main>--${docsFoot()}`;-- return page('Build for OpenVibe.Bot', body);++`;++ return docsPage({ config, path: '/docs', title: 'Build for OpenVibe.Bot', description: 'Build drivers and robot profiles for OpenVibe.Bot, the open robot control panel: the protocol, the profile format and a validator.', content });}// ── GET /docs/drivers ──────────────────────────────────────────────────────────────────────────────function renderDriversPage({ config }) {-- const body = `${docsNav('/docs/drivers')}--<main class="page">++ const content = `<h1>Write a driver</h1><p class="muted">A driver is how OpenVibe.Bot moves a specific piece of hardware. This page is the spec: build against it instead of reverse-engineering Bot. The full contract is ${out(`${BLOB}/docs/plugins.md`, 'plugins.md')} in OpenVibe.Node.</p>@@@ -219,9 +218,8 @@ ${code('{ "plugins": [ { "name": "dryrun", "config": { "record": "/tmp/dryrun.js<section class="card"><h2>Contribute it</h2><p>Open a pull request to ${out(NODE, 'OpenVibers/OpenVibe.Node')} adding your driver under ${out(`${TREE}/plugins`, 'plugins/')}, with the robot profile it pairs with. The profile is validated by Bot's loader (<code>bot.robot-profile@1</code> plus the capability, driver, widget and command registries), and the driver is reviewed before it runs on anyone's Node. A genuinely new capability, driver or widget name is one line in Bot's registries — never a schema change.</p></section>--</main>--${docsFoot()}`;-- return page('Write a driver', body);++`;++ return docsPage({ config, path: '/docs/drivers', title: 'Write a driver', description: 'How to write a driver for the OpenVibe Node: JSON lines over stdio, the lifecycle, every message, the safety rules, a Python example and how to test it.', content });}// ── GET /docs/profiles · POST /docs/profiles/validate ─────────────────────────────────────────────@@@ -244,8 +242,7 @@ function validationResult(result) {function renderProfilesPage({ config, source = '', result = null } = {}) {const example = JSON.stringify(simRover, null, 2);-- const body = `${docsNav('/docs/profiles')}--<main class="page">++ const content = `<h1>Robot profiles</h1><p class="muted">A robot profile (<code>bot.robot-profile@1</code>) is the contract between a robot and its panel. The panel is a function of the profile: a new robot needs a profile, not code. Profiles ship as <code>server/profiles/*.json</code>, are validated by the loader, and are served at ${inLink('/api/v1/profiles', 'GET /api/v1/profiles')}.</p>@@@ -279,9 +276,8 @@ ${code(example)}</section><p class="muted">Tools can POST the profile as JSON to <code>/api/v1/profiles/validate</code> and read <code>{ valid, problems }</code> back.</p></section>${validationResult(result)}--</main>--${docsFoot()}`;-- return page('Robot profiles', body);++`;++ return docsPage({ config, path: '/docs/profiles', title: 'Robot profiles', description: 'The bot.robot-profile@1 format that turns a driver\'s capabilities into a panel, a full example, and a validator for your own profile.', content });}module.exports = { renderDocsIndex, renderDriversPage, renderProfilesPage, validationResult };@@@ -80,6 +80,28 @@ function sections({ signedIn }) {<p class="sc-actions"><a class="sc-btn sc-primary" href="/docs">Build for OpenVibe.Bot</a> <a class="sc-btn" href="/docs/drivers">Write a driver</a> <a class="sc-btn" href="/docs/profiles">Robot profiles</a></p></section>`;}++/** The OpenVibe Frame around one of Bot's public pages (the front page, the Build docs): navbar, footer, theme. */++function framePage({ config, title, description, canonicalPath = '/', body, head = '' }) {++ const nav = {++ service: 'bot',++ apiBase: NETWORK_URL,++ links: [{ label: 'Robots', href: '/robots' }, { label: 'Build', href: '/docs' }],++ history: { type: 'page', title },++ sessionUrl: '/auth/me',++ loginUrl: '/auth/login?next={path}',++ logoutUrl: '/auth/logout?next={path}',++ notificationsRealtime: true,++ };++ return shell.page({++ name: SITE_NAME, service: 'bot', lang: 'en', title, siteName: SITE_NAME, description, summary: description,++ canonical: `${config.baseUrl}${canonicalPath}`, robots: 'index, follow',++ navbar: nav, footer: { service: 'bot', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME }, home: '/',++ navLinks: [{ label: 'Robots', href: '/robots' }, { label: 'Build', href: '/docs' }],++ head: [appIcon.headTags({ site: 'bot' }), '<link rel="manifest" href="/manifest.webmanifest">', head].filter(Boolean).join('\n'),++ body: `<div id="navbar-mount"></div>\n${body}`,++ });++}++function renderHome({ config, signedIn = false }) {const nav = {service: 'bot',@@@ -113,4 +135,4 @@ ${sections({ signedIn })}});}--module.exports = { renderHome, HOME_CSP, sections };++module.exports = { renderHome, framePage, HOME_CSP, sections };@@@ -126,10 +126,13 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi// profile validator is anonymous, so it is rate-limited per address the way the app's other public limits// are, and a cross-site form post is refused as every other form post refuses one.const validateLimit = limits ? [limits('bot.docs.validate', { minute: 30, hour: 300 })] : [];-- r.get('/docs', (req, res) => res.type('html').send(renderDocsIndex({ config })));-- r.get('/docs/drivers', (req, res) => res.type('html').send(renderDriversPage({ config })));-- r.get('/docs/profiles', (req, res) => res.type('html').send(renderProfilesPage({ config })));-- r.post('/docs/profiles/validate', ...validateLimit, express.urlencoded({ extended: false, limit: '32kb' }), (req, res, next) => {++ // The Build pages run inside the OpenVibe Frame (navbar, footer, theme), so they take the front page's CSP.++ const frameCsp = (req, res, next) => { res.setHeader('Content-Security-Policy', HOME_CSP); next(); };++ r.get('/docs/docs.css', asset('docs.css', 'text/css'));++ r.get('/docs', frameCsp, (req, res) => res.type('html').send(renderDocsIndex({ config })));++ r.get('/docs/drivers', frameCsp, (req, res) => res.type('html').send(renderDriversPage({ config })));++ r.get('/docs/profiles', frameCsp, (req, res) => res.type('html').send(renderProfilesPage({ config })));++ r.post('/docs/profiles/validate', frameCsp, ...validateLimit, express.urlencoded({ extended: false, limit: '32kb' }), (req, res, next) => {try {if (!sameOrigin(req)) return res.status(403).type('text/plain').send('cross-site form posts are refused\n');res.setHeader('Cache-Control', 'no-store');@@@ -1,7 +1,7 @@'use strict';// The public build pages (plan T15): GET /docs, /docs/drivers and /docs/profiles, the profile validator they// carry (POST /docs/profiles/validate, POST /api/v1/profiles/validate), and the sitemap that lists them. The--// pages carry no script, so the site-wide CSP stands; the validator runs the same checks a shipped profile does.++// pages run inside the OpenVibe Frame with the front page's CSP; the validator runs the same checks a shipped profile does.const assert = require('assert');const fs = require('fs');const path = require('path');@@@ -9,8 +9,8 @@ const { boot, check, done } = require('./helpers/app');const ROOT = path.join(__dirname, '..');const sim = JSON.parse(fs.readFileSync(path.join(ROOT, 'server', 'profiles', 'sim.rover.json'), 'utf8'));--// The app's own CSP (server/app.js), which the docs pages do not loosen.--const SITE_CSP = "default-src 'self'; frame-ancestors 'self'; object-src 'none'; base-uri 'self'";++// The docs pages run inside the OpenVibe Frame (navbar, footer, theme), so they carry the front page's CSP.++const { HOME_CSP: SITE_CSP } = require('../server/web/home');(async () => {const t = await boot({ openre: false });@@@ -21,7 +21,7 @@ const SITE_CSP = "default-src 'self'; frame-ancestors 'self'; object-src 'none';body: new URLSearchParams({ profile }).toString(),});-- await check('GET /docs: the build index, linking both guides and the robots page, under the site CSP', async () => {++ await check('GET /docs: the build index, linking both guides and the robots page, inside the OpenVibe Frame', async () => {const r = await get('/docs');assert.strictEqual(r.status, 200);assert.match(r.headers.get('content-type'), /^text\/html/);@@@ -31,7 +31,8 @@ const SITE_CSP = "default-src 'self'; frame-ancestors 'self'; object-src 'none';assert.match(html, /href="\/docs\/drivers"/);assert.match(html, /href="\/docs\/profiles"/);assert.match(html, /href="\/robots"/);-- assert.ok(!/<script\b/i.test(html), 'no script on a docs page');++ assert.ok(html.includes('id="navbar-mount"') && html.includes('/docs/docs.css'), 'inside the OpenVibe Frame, with the docs stylesheet');++ assert.ok(html.includes('aria-current="page"'), 'the sidebar marks the current page');});await check('GET /docs/drivers: the guide, connection kinds, every message, safety rules and testing', async () => {@@@ -72,7 +73,7 @@ const SITE_CSP = "default-src 'self'; frame-ancestors 'self'; object-src 'none';assert.match(html, /sim\.rover/);assert.match(html, /action="\/docs\/profiles\/validate"/);assert.match(html, /<textarea name="profile"/);-- assert.ok(!/<script\b/i.test(html), 'no script on the profiles page');++ assert.ok(html.includes('id="navbar-mount"'), 'inside the OpenVibe Frame');});await check('POST /docs/profiles/validate: the shipped sim.rover profile is valid', async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
+/* OpenVibe.Bot's "Build" pages (/docs, /docs/drivers, /docs/profiles): a docs layout inside the OpenVibe Frame.
+ Colours come from the Frame's theme variables, so the pages follow the person's theme. */
+.docs-wrap { max-width: 1120px; margin: 0 auto; padding: 32px 16px 64px; display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 40px; align-items: start; }
+.docs-side { position: sticky; top: 76px; display: flex; flex-direction: column; gap: 2px; }
+.docs-side .docs-side-title { font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--text-secondary, #96a7c2); margin: 0 0 8px 10px; }
+.docs-side a { display: block; padding: 8px 10px; border-radius: 8px; color: var(--text-secondary, #96a7c2); text-decoration: none; font-size: 14px; font-weight: 600; }
+.docs-side a:hover { color: var(--text-primary, #e6edf7); background: var(--bg-secondary, #101828); }
+.docs-side a[aria-current="page"] { color: var(--text-primary, #e6edf7); background: var(--accent-glow, rgba(59,130,246,.14)); box-shadow: inset 2px 0 0 var(--accent, #3b82f6); }
+.docs-side .docs-side-rule { height: 1px; background: var(--border, #1f2d47); margin: 10px 0; }
+
+.docs-main { min-width: 0; color: var(--text-primary, #e6edf7); font-size: 15.5px; line-height: 1.65; }
+.docs-main h1 { font-size: clamp(28px, 4vw, 38px); line-height: 1.15; letter-spacing: -.02em; margin: 0 0 12px; }
+.docs-main > p.muted:first-of-type { font-size: 17px; margin: 0 0 28px; }
+.docs-main h2 { font-size: 20px; letter-spacing: -.01em; margin: 0 0 10px; }
+.docs-main h3 { font-size: 16px; margin: 18px 0 6px; }
+.docs-main p { margin: 0 0 12px; }
+.docs-main ul, .docs-main ol { margin: 0 0 12px; padding-left: 22px; }
+.docs-main li { margin: 4px 0; }
+.docs-main a { color: var(--accent-light, #60a5fa); text-underline-offset: 2px; }
+.docs-main .muted { color: var(--text-secondary, #96a7c2); }
+.docs-main code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .9em; background: var(--bg-secondary, #101828); border: 1px solid var(--border, #1f2d47); border-radius: 5px; padding: 1px 5px; }
+.docs-main pre { margin: 10px 0 14px; padding: 14px 16px; overflow-x: auto; background: var(--bg-primary, #0a0f1c); border: 1px solid var(--border, #1f2d47); border-radius: 10px; line-height: 1.5; }
+.docs-main pre code { background: none; border: 0; padding: 0; font-size: 13px; white-space: pre; }
+
+.docs-main .card { background: var(--bg-secondary, #101828); border: 1px solid var(--border, #1f2d47); border-radius: 14px; padding: 20px 22px; margin: 0 0 16px; }
+.docs-main .card > :last-child { margin-bottom: 0; }
+.docs-main .card h2 { font-size: 13px; font-weight: 750; letter-spacing: .07em; text-transform: uppercase; color: var(--text-secondary, #96a7c2); margin-bottom: 12px; }
+.docs-main .card .card { background: var(--bg-primary, #0a0f1c); padding: 14px 16px; margin: 10px 0; }
+
+.docs-main form { display: grid; gap: 12px; margin: 6px 0 10px; }
+.docs-main .field { display: grid; gap: 6px; }
+.docs-main .field-label { font-size: 13px; font-weight: 700; color: var(--text-secondary, #96a7c2); }
+.docs-main textarea { width: 100%; box-sizing: border-box; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; line-height: 1.5; color: var(--text-primary, #e6edf7); background: var(--bg-primary, #0a0f1c); border: 1px solid var(--border, #1f2d47); border-radius: 10px; padding: 12px 14px; resize: vertical; }
+.docs-main textarea:focus { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }
+.docs-main button.primary { justify-self: start; font: inherit; font-weight: 700; font-size: 14px; color: #fff; background: var(--accent, #3b82f6); border: 0; border-radius: 10px; padding: 10px 18px; cursor: pointer; }
+.docs-main button.primary:hover { filter: brightness(1.08); }
+.docs-main .error { border: 1px solid rgba(239, 68, 68, .45); background: rgba(239, 68, 68, .08); border-radius: 10px; padding: 12px 14px; }
+.docs-main [role="status"] strong { color: #22c55e; }
+.docs-foot { margin-top: 28px; padding-top: 16px; border-top: 1px solid var(--border, #1f2d47); font-size: 13.5px; }
+
+@media (max-width: 860px) {
+ .docs-wrap { grid-template-columns: 1fr; gap: 18px; padding-top: 18px; }
+ .docs-side { position: static; flex-direction: row; flex-wrap: wrap; gap: 6px; }
+ .docs-side .docs-side-title, .docs-side .docs-side-rule { display: none; }
+ .docs-side a { padding: 7px 12px; border: 1px solid var(--border, #1f2d47); border-radius: 999px; font-size: 13px; }
+ .docs-side a[aria-current="page"] { box-shadow: none; border-color: var(--accent, #3b82f6); }
+ .docs-main .card { padding: 16px; }
+}
@@ -13,7 +13,8 @@
@@ -13,7 +13,8 @@
* renderProfilesPage({ config, source, result }) GET /docs/profiles, POST /docs/profiles/validate
* renderProfilesPage({ config, source, result }) GET /docs/profiles, POST /docs/profiles/validate
* formatProblems(problems) / validationResult(result)
* formatProblems(problems) / validationResult(result)
*/
*/
-const { page, esc } = require('./render');
+const { esc } = require('./render');
+const { framePage } = require('./home');
const simRover = require('../profiles/sim.rover.json');
const simRover = require('../profiles/sim.rover.json');
// The driver protocol and the bundled drivers live in OpenVibe.Node (the Node core and its plugins/ tree).
// The driver protocol and the bundled drivers live in OpenVibe.Node (the Node core and its plugins/ tree).
@@ -28,19 +29,20 @@ const out = (href, text) => `<a href="${esc(href)}" rel="noopener">${esc(text)}<
@@ -28,19 +29,20 @@ const out = (href, text) => `<a href="${esc(href)}" rel="noopener">${esc(text)}<
/** A link on this site. */
/** A link on this site. */
const inLink = (href, text) => `<a href="${esc(href)}">${esc(text)}</a>`;
const inLink = (href, text) => `<a href="${esc(href)}">${esc(text)}</a>`;
-/** The shared header of the three pages, marking the current one. Plain markup: no script anywhere. */
-function docsNav(current) {
- const items = [['/docs', 'Build'], ['/docs/drivers', 'Write a driver'], ['/docs/profiles', 'Robot profiles'], ['/robots', 'Robots']];
- const links = items.map(([href, label]) => (href === current
- ? `<span class="pill">${esc(label)}</span>`
- : `<a href="${esc(href)}">${esc(label)}</a>`)).join(' ');
- return `<nav class="topbar"><a class="brand" href="/"><span>OpenVibe<b>.Bot</b></span></a><span class="crumbs">${links}</span></nav>`;
-}
+const PAGES = [['/docs', 'Overview'], ['/docs/drivers', 'Write a driver'], ['/docs/profiles', 'Robot profiles']];
-/** The shared footer of the three pages. */
-const docsFoot = () => `<footer class="page"><p class="muted">The Node and its bundled drivers are in `
- + `${out(`${TREE}/plugins`, 'OpenVibers/OpenVibe.Node')}; the full protocol is `
- + `${out(`${BLOB}/docs/protocol.md`, 'protocol.md')} and the plugin contract is ${out(`${BLOB}/docs/plugins.md`, 'plugins.md')}.</p></footer>`;
+/** The Build pages inside the OpenVibe Frame: a sidebar of the three pages beside the content, plain markup. */
+function docsPage({ config, path, title, description, content }) {
+ const side = PAGES.map(([href, label]) => `<a href="${esc(href)}"${href === path ? ' aria-current="page"' : ''}>${esc(label)}</a>`).join('');
+ const body = `<div class="docs-wrap">
+<nav class="docs-side" aria-label="Build for OpenVibe.Bot"><p class="docs-side-title">Build</p>${side}<div class="docs-side-rule"></div><a href="/robots">Your robots</a></nav>
+<main id="main" class="docs-main">
+${content}
+<p class="docs-foot muted">The Node and its bundled drivers are in ${out(`${TREE}/plugins`, 'OpenVibers/OpenVibe.Node')}; the full protocol is ${out(`${BLOB}/docs/protocol.md`, 'protocol.md')} and the plugin contract is ${out(`${BLOB}/docs/plugins.md`, 'plugins.md')}.</p>
+</main>
+</div>`;
+ return framePage({ config, title: title.includes('OpenVibe.Bot') ? title : `${title} · OpenVibe.Bot`, description, canonicalPath: path, body, head: '<link rel="stylesheet" href="/docs/docs.css">' });
+}
/** A message type: its name, a sentence, and a short JSON example. */
/** A message type: its name, a sentence, and a short JSON example. */
const message = (name, text, sample) => `<div class="card"><p><code>${esc(name)}</code> — ${esc(text)}</p>${code(sample)}</div>`;
const message = (name, text, sample) => `<div class="card"><p><code>${esc(name)}</code> — ${esc(text)}</p>${code(sample)}</div>`;
@@ -48,8 +50,7 @@ const message = (name, text, sample) => `<div class="card"><p><code>${esc(name)}
@@ -48,8 +50,7 @@ const message = (name, text, sample) => `<div class="card"><p><code>${esc(name)}
// ── GET /docs ─────────────────────────────────────────────────────────────────────────────────────
// ── GET /docs ─────────────────────────────────────────────────────────────────────────────────────
function renderDocsIndex({ config }) {
function renderDocsIndex({ config }) {
- const body = `${docsNav('/docs')}
-<main class="page">
+ const content = `
<h1>Build for OpenVibe.Bot</h1>
<h1>Build for OpenVibe.Bot</h1>
<p class="muted">OpenVibe.Bot drives a robot from its profile. Two things are open to build against: the <b>driver</b> — a separate program the OpenVibe Node runs — and the <b>profile</b>, the JSON that turns a driver's capabilities into a panel.</p>
<p class="muted">OpenVibe.Bot drives a robot from its profile. Two things are open to build against: the <b>driver</b> — a separate program the OpenVibe Node runs — and the <b>profile</b>, the JSON that turns a driver's capabilities into a panel.</p>
<section class="card"><h2>Write a driver</h2>
<section class="card"><h2>Write a driver</h2>
@@ -68,16 +69,14 @@ function renderDocsIndex({ config }) {
@@ -68,16 +69,14 @@ function renderDocsIndex({ config }) {
<li>Bundled drivers: ${out(`${TREE}/plugins/dryrun`, 'dryrun')}, ${out(`${TREE}/plugins/adeept_adr036`, 'adeept_adr036')}, ${out(`${TREE}/plugins/cozmo`, 'cozmo')} and ${out(`${TREE}/plugins/relay`, 'relay')}.</li>
<li>Bundled drivers: ${out(`${TREE}/plugins/dryrun`, 'dryrun')}, ${out(`${TREE}/plugins/adeept_adr036`, 'adeept_adr036')}, ${out(`${TREE}/plugins/cozmo`, 'cozmo')} and ${out(`${TREE}/plugins/relay`, 'relay')}.</li>
<li>Profiles ship in this repository (${inLink('/api/v1/profiles', 'GET /api/v1/profiles')}) and are validated by the same loader the pages use.</li>
<li>Profiles ship in this repository (${inLink('/api/v1/profiles', 'GET /api/v1/profiles')}) and are validated by the same loader the pages use.</li>
</ul>
</ul>
-</main>
-${docsFoot()}`;
- return page('Build for OpenVibe.Bot', body);
+`;
+ return docsPage({ config, path: '/docs', title: 'Build for OpenVibe.Bot', description: 'Build drivers and robot profiles for OpenVibe.Bot, the open robot control panel: the protocol, the profile format and a validator.', content });
}
}
// ── GET /docs/drivers ──────────────────────────────────────────────────────────────────────────────
// ── GET /docs/drivers ──────────────────────────────────────────────────────────────────────────────
function renderDriversPage({ config }) {
function renderDriversPage({ config }) {
- const body = `${docsNav('/docs/drivers')}
-<main class="page">
+ const content = `
<h1>Write a driver</h1>
<h1>Write a driver</h1>
<p class="muted">A driver is how OpenVibe.Bot moves a specific piece of hardware. This page is the spec: build against it instead of reverse-engineering Bot. The full contract is ${out(`${BLOB}/docs/plugins.md`, 'plugins.md')} in OpenVibe.Node.</p>
<p class="muted">A driver is how OpenVibe.Bot moves a specific piece of hardware. This page is the spec: build against it instead of reverse-engineering Bot. The full contract is ${out(`${BLOB}/docs/plugins.md`, 'plugins.md')} in OpenVibe.Node.</p>
@@ -219,9 +218,8 @@ ${code('{ "plugins": [ { "name": "dryrun", "config": { "record": "/tmp/dryrun.js
@@ -219,9 +218,8 @@ ${code('{ "plugins": [ { "name": "dryrun", "config": { "record": "/tmp/dryrun.js
<section class="card"><h2>Contribute it</h2>
<section class="card"><h2>Contribute it</h2>
<p>Open a pull request to ${out(NODE, 'OpenVibers/OpenVibe.Node')} adding your driver under ${out(`${TREE}/plugins`, 'plugins/')}, with the robot profile it pairs with. The profile is validated by Bot's loader (<code>bot.robot-profile@1</code> plus the capability, driver, widget and command registries), and the driver is reviewed before it runs on anyone's Node. A genuinely new capability, driver or widget name is one line in Bot's registries — never a schema change.</p></section>
<p>Open a pull request to ${out(NODE, 'OpenVibers/OpenVibe.Node')} adding your driver under ${out(`${TREE}/plugins`, 'plugins/')}, with the robot profile it pairs with. The profile is validated by Bot's loader (<code>bot.robot-profile@1</code> plus the capability, driver, widget and command registries), and the driver is reviewed before it runs on anyone's Node. A genuinely new capability, driver or widget name is one line in Bot's registries — never a schema change.</p></section>
-</main>
-${docsFoot()}`;
- return page('Write a driver', body);
+`;
+ return docsPage({ config, path: '/docs/drivers', title: 'Write a driver', description: 'How to write a driver for the OpenVibe Node: JSON lines over stdio, the lifecycle, every message, the safety rules, a Python example and how to test it.', content });
}
}
// ── GET /docs/profiles · POST /docs/profiles/validate ─────────────────────────────────────────────
// ── GET /docs/profiles · POST /docs/profiles/validate ─────────────────────────────────────────────
@@ -244,8 +242,7 @@ function validationResult(result) {
@@ -244,8 +242,7 @@ function validationResult(result) {
function renderProfilesPage({ config, source = '', result = null } = {}) {
function renderProfilesPage({ config, source = '', result = null } = {}) {
const example = JSON.stringify(simRover, null, 2);
const example = JSON.stringify(simRover, null, 2);
- const body = `${docsNav('/docs/profiles')}
-<main class="page">
+ const content = `
<h1>Robot profiles</h1>
<h1>Robot profiles</h1>
<p class="muted">A robot profile (<code>bot.robot-profile@1</code>) is the contract between a robot and its panel. The panel is a function of the profile: a new robot needs a profile, not code. Profiles ship as <code>server/profiles/*.json</code>, are validated by the loader, and are served at ${inLink('/api/v1/profiles', 'GET /api/v1/profiles')}.</p>
<p class="muted">A robot profile (<code>bot.robot-profile@1</code>) is the contract between a robot and its panel. The panel is a function of the profile: a new robot needs a profile, not code. Profiles ship as <code>server/profiles/*.json</code>, are validated by the loader, and are served at ${inLink('/api/v1/profiles', 'GET /api/v1/profiles')}.</p>
@@ -279,9 +276,8 @@ ${code(example)}</section>
@@ -279,9 +276,8 @@ ${code(example)}</section>
<p class="muted">Tools can POST the profile as JSON to <code>/api/v1/profiles/validate</code> and read <code>{ valid, problems }</code> back.</p></section>
<p class="muted">Tools can POST the profile as JSON to <code>/api/v1/profiles/validate</code> and read <code>{ valid, problems }</code> back.</p></section>
${validationResult(result)}
${validationResult(result)}
-</main>
-${docsFoot()}`;
- return page('Robot profiles', body);
+`;
+ return docsPage({ config, path: '/docs/profiles', title: 'Robot profiles', description: 'The bot.robot-profile@1 format that turns a driver\'s capabilities into a panel, a full example, and a validator for your own profile.', content });
}
}
module.exports = { renderDocsIndex, renderDriversPage, renderProfilesPage, validationResult };
module.exports = { renderDocsIndex, renderDriversPage, renderProfilesPage, validationResult };
@@ -80,6 +80,28 @@ function sections({ signedIn }) {
@@ -80,6 +80,28 @@ function sections({ signedIn }) {
<p class="sc-actions"><a class="sc-btn sc-primary" href="/docs">Build for OpenVibe.Bot</a> <a class="sc-btn" href="/docs/drivers">Write a driver</a> <a class="sc-btn" href="/docs/profiles">Robot profiles</a></p></section>`;
<p class="sc-actions"><a class="sc-btn sc-primary" href="/docs">Build for OpenVibe.Bot</a> <a class="sc-btn" href="/docs/drivers">Write a driver</a> <a class="sc-btn" href="/docs/profiles">Robot profiles</a></p></section>`;
}
}
+/** The OpenVibe Frame around one of Bot's public pages (the front page, the Build docs): navbar, footer, theme. */
+function framePage({ config, title, description, canonicalPath = '/', body, head = '' }) {
+ const nav = {
+ service: 'bot',
+ apiBase: NETWORK_URL,
+ links: [{ label: 'Robots', href: '/robots' }, { label: 'Build', href: '/docs' }],
+ history: { type: 'page', title },
+ sessionUrl: '/auth/me',
+ loginUrl: '/auth/login?next={path}',
+ logoutUrl: '/auth/logout?next={path}',
+ notificationsRealtime: true,
+ };
+ return shell.page({
+ name: SITE_NAME, service: 'bot', lang: 'en', title, siteName: SITE_NAME, description, summary: description,
+ canonical: `${config.baseUrl}${canonicalPath}`, robots: 'index, follow',
+ navbar: nav, footer: { service: 'bot', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME }, home: '/',
+ navLinks: [{ label: 'Robots', href: '/robots' }, { label: 'Build', href: '/docs' }],
+ head: [appIcon.headTags({ site: 'bot' }), '<link rel="manifest" href="/manifest.webmanifest">', head].filter(Boolean).join('\n'),
+ body: `<div id="navbar-mount"></div>\n${body}`,
+ });
+}
+
function renderHome({ config, signedIn = false }) {
function renderHome({ config, signedIn = false }) {
const nav = {
const nav = {
service: 'bot',
service: 'bot',
@@ -113,4 +135,4 @@ ${sections({ signedIn })}
@@ -113,4 +135,4 @@ ${sections({ signedIn })}
});
});
}
}
-module.exports = { renderHome, HOME_CSP, sections };
+module.exports = { renderHome, framePage, HOME_CSP, sections };
@@ -126,10 +126,13 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -126,10 +126,13 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
// profile validator is anonymous, so it is rate-limited per address the way the app's other public limits
// profile validator is anonymous, so it is rate-limited per address the way the app's other public limits
// are, and a cross-site form post is refused as every other form post refuses one.
// are, and a cross-site form post is refused as every other form post refuses one.
const validateLimit = limits ? [limits('bot.docs.validate', { minute: 30, hour: 300 })] : [];
const validateLimit = limits ? [limits('bot.docs.validate', { minute: 30, hour: 300 })] : [];
- r.get('/docs', (req, res) => res.type('html').send(renderDocsIndex({ config })));
- r.get('/docs/drivers', (req, res) => res.type('html').send(renderDriversPage({ config })));
- r.get('/docs/profiles', (req, res) => res.type('html').send(renderProfilesPage({ config })));
- r.post('/docs/profiles/validate', ...validateLimit, express.urlencoded({ extended: false, limit: '32kb' }), (req, res, next) => {
+ // The Build pages run inside the OpenVibe Frame (navbar, footer, theme), so they take the front page's CSP.
+ const frameCsp = (req, res, next) => { res.setHeader('Content-Security-Policy', HOME_CSP); next(); };
+ r.get('/docs/docs.css', asset('docs.css', 'text/css'));
+ r.get('/docs', frameCsp, (req, res) => res.type('html').send(renderDocsIndex({ config })));
+ r.get('/docs/drivers', frameCsp, (req, res) => res.type('html').send(renderDriversPage({ config })));
+ r.get('/docs/profiles', frameCsp, (req, res) => res.type('html').send(renderProfilesPage({ config })));
+ r.post('/docs/profiles/validate', frameCsp, ...validateLimit, express.urlencoded({ extended: false, limit: '32kb' }), (req, res, next) => {
try {
try {
if (!sameOrigin(req)) return res.status(403).type('text/plain').send('cross-site form posts are refused\n');
if (!sameOrigin(req)) return res.status(403).type('text/plain').send('cross-site form posts are refused\n');
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Cache-Control', 'no-store');
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
// The public build pages (plan T15): GET /docs, /docs/drivers and /docs/profiles, the profile validator they
// The public build pages (plan T15): GET /docs, /docs/drivers and /docs/profiles, the profile validator they
// carry (POST /docs/profiles/validate, POST /api/v1/profiles/validate), and the sitemap that lists them. The
// carry (POST /docs/profiles/validate, POST /api/v1/profiles/validate), and the sitemap that lists them. The
-// pages carry no script, so the site-wide CSP stands; the validator runs the same checks a shipped profile does.
+// pages run inside the OpenVibe Frame with the front page's CSP; the validator runs the same checks a shipped profile does.
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
@@ -9,8 +9,8 @@ const { boot, check, done } = require('./helpers/app');
@@ -9,8 +9,8 @@ const { boot, check, done } = require('./helpers/app');
const ROOT = path.join(__dirname, '..');
const ROOT = path.join(__dirname, '..');
const sim = JSON.parse(fs.readFileSync(path.join(ROOT, 'server', 'profiles', 'sim.rover.json'), 'utf8'));
const sim = JSON.parse(fs.readFileSync(path.join(ROOT, 'server', 'profiles', 'sim.rover.json'), 'utf8'));
-// The app's own CSP (server/app.js), which the docs pages do not loosen.
-const SITE_CSP = "default-src 'self'; frame-ancestors 'self'; object-src 'none'; base-uri 'self'";
+// The docs pages run inside the OpenVibe Frame (navbar, footer, theme), so they carry the front page's CSP.
+const { HOME_CSP: SITE_CSP } = require('../server/web/home');
(async () => {
(async () => {
const t = await boot({ openre: false });
const t = await boot({ openre: false });
@@ -21,7 +21,7 @@ const SITE_CSP = "default-src 'self'; frame-ancestors 'self'; object-src 'none';
@@ -21,7 +21,7 @@ const SITE_CSP = "default-src 'self'; frame-ancestors 'self'; object-src 'none';
body: new URLSearchParams({ profile }).toString(),
body: new URLSearchParams({ profile }).toString(),
});
});
- await check('GET /docs: the build index, linking both guides and the robots page, under the site CSP', async () => {
+ await check('GET /docs: the build index, linking both guides and the robots page, inside the OpenVibe Frame', async () => {
const r = await get('/docs');
const r = await get('/docs');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.match(r.headers.get('content-type'), /^text\/html/);
assert.match(r.headers.get('content-type'), /^text\/html/);
@@ -31,7 +31,8 @@ const SITE_CSP = "default-src 'self'; frame-ancestors 'self'; object-src 'none';
@@ -31,7 +31,8 @@ const SITE_CSP = "default-src 'self'; frame-ancestors 'self'; object-src 'none';
assert.match(html, /href="\/docs\/drivers"/);
assert.match(html, /href="\/docs\/drivers"/);
assert.match(html, /href="\/docs\/profiles"/);
assert.match(html, /href="\/docs\/profiles"/);
assert.match(html, /href="\/robots"/);
assert.match(html, /href="\/robots"/);
- assert.ok(!/<script\b/i.test(html), 'no script on a docs page');
+ assert.ok(html.includes('id="navbar-mount"') && html.includes('/docs/docs.css'), 'inside the OpenVibe Frame, with the docs stylesheet');
+ assert.ok(html.includes('aria-current="page"'), 'the sidebar marks the current page');
});
});
await check('GET /docs/drivers: the guide, connection kinds, every message, safety rules and testing', async () => {
await check('GET /docs/drivers: the guide, connection kinds, every message, safety rules and testing', async () => {
@@ -72,7 +73,7 @@ const SITE_CSP = "default-src 'self'; frame-ancestors 'self'; object-src 'none';
@@ -72,7 +73,7 @@ const SITE_CSP = "default-src 'self'; frame-ancestors 'self'; object-src 'none';
assert.match(html, /sim\.rover/);
assert.match(html, /sim\.rover/);
assert.match(html, /action="\/docs\/profiles\/validate"/);
assert.match(html, /action="\/docs\/profiles\/validate"/);
assert.match(html, /<textarea name="profile"/);
assert.match(html, /<textarea name="profile"/);
- assert.ok(!/<script\b/i.test(html), 'no script on the profiles page');
+ assert.ok(html.includes('id="navbar-mount"'), 'inside the OpenVibe Frame');
});
});
await check('POST /docs/profiles/validate: the shipped sim.rover profile is valid', async () => {
await check('POST /docs/profiles/validate: the shipped sim.rover profile is valid', async () => {