openvibers/openvibe.shared agent-layer.js
agent-layer.js
| 1 | ; |
| 2 | // Server-only discovery and representations of the site's own public render output. |
| 3 | const { parseHTML } = require('linkedom'); |
| 4 | const esc = s => String(s).replace(/[&"<>]/g, c => ({ '&': '&', '"': '"', '<': '<', '>': '>' }[c])); |
| 5 | const md = s => String(s || '').replace(/\\/g, '\\\\').replace(/([\[\]*_`])/g, '\\$1'); |
| 6 | |
| 7 | function twinUrl(value) { |
| 8 | const url = new URL(value, 'https://agent.invalid'); |
| 9 | url.searchParams.delete('format'); |
| 10 | if (!url.pathname.endsWith('.md')) url.pathname = url.pathname === '/' ? '/index.md' : url.pathname.replace(/\/$/, '') + '.md'; |
| 11 | return /^[a-z]+:/i.test(value) ? url.href : url.pathname + url.search + url.hash; |
| 12 | } |
| 13 | |
| 14 | function agentManifest(descriptor = {}, overrides = {}) { |
| 15 | const d = { ...descriptor, ...overrides }; |
| 16 | const home = d.home || d.canonical; |
| 17 | const abs = value => value && home ? new URL(value, home).href : value; |
| 18 | return { |
| 19 | network: 'OpenVibe', site: d.site || (home ? new URL(home).hostname : undefined), |
| 20 | id: d.id, name: d.name, purpose: d.purpose, home, |
| 21 | api: abs(d.api), openapi: abs(d.openapi), |
| 22 | mcp: d.mcp ? { transport: 'streamable-http', ...d.mcp, url: abs(d.mcp.url) } : undefined, |
| 23 | docs: abs(d.docs), llms: abs(d.llms || (home && '/llms.txt')), |
| 24 | llmsFull: abs(d.llmsFull || (home && '/llms-full.txt')), sitemap: abs(d.sitemap || (home && '/sitemap.xml')), |
| 25 | policy: { robots: 'https://openvibe.network/bot.json', ...d.policy }, |
| 26 | }; |
| 27 | } |
| 28 | |
| 29 | function linkHeaderTags(canonical, llms = '/llms.txt') { |
| 30 | const twin = twinUrl(canonical); |
| 31 | // URL encoding keeps arbitrary caller input out of HTTP header syntax. |
| 32 | const headerUrl = s => String(s).replace(/[<>"\r\n]/g, c => encodeURIComponent(c)); |
| 33 | return { |
| 34 | head: `<link rel="alternate" type="text/markdown" href="${esc(twin)}">\n<link rel="describedby" href="${esc(llms)}">`, |
| 35 | header: `<${headerUrl(twin)}>; rel="alternate"; type="text/markdown", <${headerUrl(llms)}>; rel="describedby"`, |
| 36 | }; |
| 37 | } |
| 38 | |
| 39 | function markdownTwin({ html, title, canonical, updated = null }) { |
| 40 | const { document } = parseHTML(html); |
| 41 | title = title || document.querySelector('title')?.textContent || ''; |
| 42 | const walk = node => { |
| 43 | if (node.nodeType === 3) return md(node.textContent.replace(/\s+/g, ' ')); |
| 44 | if (node.nodeType !== 1) return ''; |
| 45 | const tag = node.localName; |
| 46 | // Controls are not content: buttons, inputs, icons, players, and a card's ⋯ menu (Save, Share) say nothing to a reader. |
| 47 | if (['script', 'style', 'template', 'nav', 'footer', 'button', 'svg', 'select', 'input', 'textarea', 'video', 'audio', 'dialog'].includes(tag) |
| 48 | || node.hasAttribute('hidden') || node.getAttribute('aria-hidden') === 'true' || (tag === 'details' && /\bovk-more\b/.test(node.getAttribute('class') || ''))) return ''; |
| 49 | // Pieces the page sets side by side with no space between them (a card's who · count · when, a tile's name and |
| 50 | // line) keep a space between them, so "Maticus12 viewsTue" reads "Maticus 12 views Tue". |
| 51 | const inside = () => Array.from(node.childNodes).reduce((out, child) => { |
| 52 | const piece = walk(child); |
| 53 | return out && piece && child.nodeType === 1 && !['sup', 'sub', 'mark', 'wbr', 'abbr'].includes(child.localName) && !/\s$/.test(out) && !/^\s/.test(piece) && !/[([]$/.test(out) ? `${out} ${piece}` : out + piece; |
| 54 | }, ''); |
| 55 | if (/^h[1-6]$/.test(tag)) return `\n\n${'#'.repeat(Number(tag[1]))} ${inside().trim()}\n\n`; |
| 56 | if (tag === 'a') return node.hasAttribute('href') ? `[${inside()}](${destination(node.getAttribute('href'))})` : inside(); |
| 57 | if (tag === 'img') return ` || '')})`; |
| 58 | if (tag === 'br') return '\n'; |
| 59 | if (tag === 'strong' || tag === 'b') return `**${inside()}**`; |
| 60 | if (tag === 'em' || tag === 'i') return `*${inside()}*`; |
| 61 | if (tag === 'pre') return `\n\n~~~~\n${node.textContent}\n~~~~\n\n`; |
| 62 | if (tag === 'code') return '`' + node.textContent.replace(/`/g, '\\`') + '`'; |
| 63 | if (tag === 'ul' || tag === 'ol') { |
| 64 | return '\n\n' + Array.from(node.children).filter(n => n.localName === 'li').map((n, i) => { |
| 65 | const text = Array.from(n.childNodes).map(walk).join('').trim(); |
| 66 | const prefix = tag === 'ol' ? `${Number(node.getAttribute('start') || 1) + i}. ` : '- '; |
| 67 | return prefix + text.replace(/\n/g, '\n' + ' '.repeat(prefix.length)); |
| 68 | }).join('\n') + '\n\n'; |
| 69 | } |
| 70 | if (tag === 'table') { |
| 71 | const rows = Array.from(node.querySelectorAll('tr')).map(row => Array.from(row.children).filter(n => /^(th|td)$/.test(n.localName)).map(n => walk(n).trim().replace(/\|/g, '\\|').replace(/\n+/g, '<br>'))); |
| 72 | if (!rows.length) return ''; |
| 73 | const width = Math.max(...rows.map(r => r.length)); |
| 74 | const line = row => '| ' + Array.from({ length: width }, (_, i) => row[i] || '').join(' | ') + ' |'; |
| 75 | return '\n\n' + [line(rows[0]), line(Array(width).fill('---')), ...rows.slice(1).map(line)].join('\n') + '\n\n'; |
| 76 | } |
| 77 | if (tag === 'dl') { |
| 78 | // A strip of numbers (kit.stats) or a definition list: one "- label: value" line each. |
| 79 | const pairs = []; |
| 80 | for (const n of node.querySelectorAll('dt')) { |
| 81 | let dd = n.nextElementSibling; |
| 82 | while (dd && dd.localName !== 'dd') dd = dd.nextElementSibling; |
| 83 | if (!dd) dd = n.previousElementSibling && n.previousElementSibling.localName === 'dd' ? n.previousElementSibling : null; |
| 84 | const k = walk(n).trim(), v = dd ? walk(dd).trim() : ''; |
| 85 | if (k || v) pairs.push(`- ${k}${v ? `: ${v}` : ''}`); |
| 86 | } |
| 87 | return pairs.length ? `\n\n${pairs.join('\n')}\n\n` : ''; |
| 88 | } |
| 89 | const content = inside(); |
| 90 | return /^(p|div|section|article|main|blockquote|dt|dd)$/.test(tag) ? `\n\n${content}\n\n` : content; |
| 91 | }; |
| 92 | const destination = value => String(value).replace(/[\s()<>]/g, c => encodeURIComponent(c).replace(/\(/g, '%28').replace(/\)/g, '%29')); |
| 93 | const content = walk(document.querySelector('main') || document.body).replace(/\n[ \t]+\n/g, '\n\n').replace(/\n{3,}/g, '\n\n').trim(); |
| 94 | return `---\ntitle: ${JSON.stringify(title)}\ncanonical: ${JSON.stringify(canonical)}\nupdated: ${JSON.stringify(updated)}\n---\n\n# ${md(title)}\n\nHTML: [${md(title || 'Original page')}](${destination(canonical)})\n\n${content}\n`; |
| 95 | } |
| 96 | |
| 97 | /** Mount before public HTML routes. render(path, { query }) returns { html, title?, updated? } or null. |
| 98 | * It must use the same public snapshot renderer as HTML and must never return personalised content. |
| 99 | */ |
| 100 | function mount(app, { descriptor = {}, render }) { |
| 101 | const manifest = agentManifest(descriptor); |
| 102 | app.use((req, res, next) => { |
| 103 | if (!['GET', 'HEAD'].includes(req.method)) return next(); |
| 104 | const url = new URL(req.url, 'https://agent.invalid'); |
| 105 | if (url.pathname === '/.well-known/agent.json') { |
| 106 | res.setHeader('Content-Type', 'application/json; charset=utf-8'); |
| 107 | res.setHeader('Cache-Control', 'public, max-age=3600'); |
| 108 | return res.end(req.method === 'HEAD' ? undefined : JSON.stringify(manifest)); |
| 109 | } |
| 110 | const isTwin = url.pathname.endsWith('.md') || url.searchParams.get('format') === 'markdown' || (req.headers.accept && req.accepts(['html', 'text/markdown']) === 'text/markdown'); |
| 111 | let path = url.pathname; |
| 112 | if (path === '/index.md') path = '/'; |
| 113 | else if (path.endsWith('.md')) path = path.slice(0, -3); |
| 114 | url.searchParams.delete('format'); |
| 115 | if (!render) return next(); |
| 116 | Promise.resolve().then(() => render(path, { query: url.searchParams })).then(page => { |
| 117 | if (!page) { |
| 118 | if (!isTwin) return next(); |
| 119 | res.statusCode = 404; |
| 120 | res.setHeader('Content-Type', 'text/markdown; charset=utf-8'); |
| 121 | return res.end(req.method === 'HEAD' ? undefined : '# Not found\n'); |
| 122 | } |
| 123 | const canonical = new URL(path + url.search, manifest.home).href; |
| 124 | const links = linkHeaderTags(path + url.search, manifest.llms || '/llms.txt'); |
| 125 | res.setHeader('Link', [res.getHeader('Link'), links.header].filter(Boolean).join(', ')); |
| 126 | res.setHeader('Vary', [res.getHeader('Vary'), 'Accept'].filter(Boolean).join(', ')); |
| 127 | res.setHeader('Cache-Control', 'public, max-age=300'); |
| 128 | res.setHeader('Content-Type', isTwin ? 'text/markdown; charset=utf-8' : 'text/html; charset=utf-8'); |
| 129 | const body = isTwin ? markdownTwin({ ...page, canonical }) : page.html.replace(/<link\b(?=[^>]*\brel=["'](?:alternate|describedby)["'])(?=[^>]*(?:text\/markdown|rel=["']describedby["']))[^>]*>/gi, '').replace(/<\/head>/i, links.head + '\n</head>'); |
| 130 | res.end(req.method === 'HEAD' ? undefined : body); |
| 131 | }).catch(next); |
| 132 | }); |
| 133 | } |
| 134 | |
| 135 | module.exports = { mount, agentManifest, markdownTwin, linkHeaderTags, twinUrl }; |
| 136 |