openvibers/openvibe.today Commits
Agent layer: /.well-known/agent.json and Markdown twins of the home, developers and event pages (#3)
Changes
@@@ -17,7 +17,7 @@"iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.233.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0","pg": "^8.23.0"},"devDependencies": {@@@ -27,6 +27,12 @@"node": ">=22.0.0"}},++ "node_modules/@acemir/cssom": {++ "version": "0.9.32",++ "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.32.tgz",++ "integrity": "sha512-OijrAUu7rrJhSIoHNg6LMPS5e344nwUHA+1EFYYjrh64D+GmytUsfBQjci45TjT+rd5Spsp8B5xsZDT+TgyXXQ==",++ "license": "MIT"++ },"node_modules/@electric-sql/pglite": {"version": "0.5.13","resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.13.tgz",@@@ -116,6 +122,19 @@"npm": "1.2.8000 || >= 1.4.16"}},++ "node_modules/boolbase": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz",++ "integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==",++ "license": "ISC",++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ },"node_modules/buffer-equal-constant-time": {"version": "1.0.1","resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",@@@ -218,6 +237,39 @@"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==","license": "MIT"},++ "node_modules/css-select": {++ "version": "7.0.0",++ "resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz",++ "integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "boolbase": "^2.0.0",++ "css-what": "^8.0.0",++ "domhandler": "^6.0.1",++ "domutils": "^4.0.2",++ "nth-check": "^3.0.1"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ },++ "node_modules/css-what": {++ "version": "8.0.0",++ "resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz",++ "integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ },"node_modules/debug": {"version": "2.6.9","resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",@@@ -255,6 +307,73 @@"npm": "1.2.8000 || >= 1.4.16"}},++ "node_modules/dom-serializer": {++ "version": "3.1.1",++ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",++ "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",++ "license": "MIT",++ "dependencies": {++ "domelementtype": "^3.0.0",++ "domhandler": "^6.0.0",++ "entities": "^8.0.0"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"++ }++ },++ "node_modules/domelementtype": {++ "version": "3.0.0",++ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",++ "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ ],++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=20.19.0"++ }++ },++ "node_modules/domhandler": {++ "version": "6.0.1",++ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",++ "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "domelementtype": "^3.0.0"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/fb55/domhandler?sponsor=1"++ }++ },++ "node_modules/domutils": {++ "version": "4.0.2",++ "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",++ "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "dom-serializer": "^3.0.0",++ "domelementtype": "^3.0.0",++ "domhandler": "^6.0.0"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/fb55/domutils?sponsor=1"++ }++ },"node_modules/dotenv": {"version": "16.6.1","resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",@@@ -305,6 +424,18 @@"node": ">= 0.8"}},++ "node_modules/entities": {++ "version": "8.1.0",++ "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",++ "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "url": "https://github.com/fb55/entities?sponsor=1"++ }++ },"node_modules/es-define-property": {"version": "1.0.1","resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",@@@ -563,6 +694,110 @@"url": "https://github.com/sponsors/EvanHahn"}},++ "node_modules/html-escaper": {++ "version": "3.0.3",++ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",++ "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",++ "license": "MIT"++ },++ "node_modules/htmlparser2": {++ "version": "10.1.0",++ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",++ "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",++ "funding": [++ "https://github.com/fb55/htmlparser2?sponsor=1",++ {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ ],++ "license": "MIT",++ "dependencies": {++ "domelementtype": "^2.3.0",++ "domhandler": "^5.0.3",++ "domutils": "^3.2.2",++ "entities": "^7.0.1"++ }++ },++ "node_modules/htmlparser2/node_modules/dom-serializer": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",++ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",++ "license": "MIT",++ "dependencies": {++ "domelementtype": "^2.3.0",++ "domhandler": "^5.0.2",++ "entities": "^4.2.0"++ },++ "funding": {++ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"++ }++ },++ "node_modules/htmlparser2/node_modules/dom-serializer/node_modules/entities": {++ "version": "4.5.0",++ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",++ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=0.12"++ },++ "funding": {++ "url": "https://github.com/fb55/entities?sponsor=1"++ }++ },++ "node_modules/htmlparser2/node_modules/domelementtype": {++ "version": "2.3.0",++ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",++ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ ],++ "license": "BSD-2-Clause"++ },++ "node_modules/htmlparser2/node_modules/domhandler": {++ "version": "5.0.3",++ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",++ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "domelementtype": "^2.3.0"++ },++ "engines": {++ "node": ">= 4"++ },++ "funding": {++ "url": "https://github.com/fb55/domhandler?sponsor=1"++ }++ },++ "node_modules/htmlparser2/node_modules/domutils": {++ "version": "3.2.2",++ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",++ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "dom-serializer": "^2.0.0",++ "domelementtype": "^2.3.0",++ "domhandler": "^5.0.3"++ },++ "funding": {++ "url": "https://github.com/fb55/domutils?sponsor=1"++ }++ },++ "node_modules/htmlparser2/node_modules/entities": {++ "version": "7.0.1",++ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",++ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=0.12"++ },++ "funding": {++ "url": "https://github.com/fb55/entities?sponsor=1"++ }++ },"node_modules/http-errors": {"version": "2.0.1","resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",@@@ -708,6 +943,30 @@"safe-buffer": "^5.0.1"}},++ "node_modules/linkedom": {++ "version": "0.18.14",++ "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.14.tgz",++ "integrity": "sha512-BRUnkOm4YR5vo+qI3j0EbungB0vZPSCue5AdnTQRaxAGSbsOZiBFRO0rh4zlo8qXUhli8pxvnpEXDklMnlq2Rg==",++ "license": "ISC",++ "dependencies": {++ "@acemir/cssom": "^0.9.32",++ "css-select": "^7.0.0",++ "html-escaper": "^3.0.3",++ "htmlparser2": "^10.1.0",++ "uhyphen": "^0.2.0"++ },++ "engines": {++ "node": ">=16"++ },++ "peerDependencies": {++ "canvas": ">= 2"++ },++ "peerDependenciesMeta": {++ "canvas": {++ "optional": true++ }++ }++ },"node_modules/lodash.defaults": {"version": "4.2.0","resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",@@@ -846,6 +1105,22 @@"node": ">= 0.6"}},++ "node_modules/nth-check": {++ "version": "3.0.1",++ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz",++ "integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "boolbase": "^2.0.0"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/fb55/nth-check?sponsor=1"++ }++ },"node_modules/object-inspect": {"version": "1.13.4","resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",@@@ -919,12 +1194,13 @@}},"node_modules/openvibe-shared": {-- "version": "4.33.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",-- "integrity": "sha512-t9XvhrQ+lQebOrFTjMCzI6/7/i4iQn9dmMr/8HVTNW1XoV1exyJa7IH/HfyyKuLxjA8xaowrDfD28LwKIibBNg==",++ "version": "4.34.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0",++ "integrity": "sha512-Rj5uRO3MbWkyIYAm+vK4Y94ugDNYEjEgGkYnPjSej4yMqhalpaPCkU96dzphMGiOUGP1PNWsiKhwaBj8tewgBg==","license": "MIT","dependencies": {-- "jsonwebtoken": "^9.0.0"++ "jsonwebtoken": "^9.0.0",++ "linkedom": "^0.18.13"},"bin": {"openvibe-build-assets": "scripts/build-assets.js"@@@ -1367,6 +1643,12 @@"node": ">= 0.6"}},++ "node_modules/uhyphen": {++ "version": "0.2.0",++ "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",++ "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",++ "license": "ISC"++ },"node_modules/unpipe": {"version": "1.0.0","resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",@@@ -24,7 +24,7 @@"iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.233.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0","pg": "^8.23.0"},"engines": {nnew file mode 100644@@@ -0,0 +1,16 @@++'use strict';++// What openvibe.today tells an agent about itself (openvibe-shared/agent-layer): /.well-known/agent.json.++// Every field is something this site really serves; no OpenAPI document is published for Today, so none is named.++module.exports = {++ id: 'today',++ name: 'OpenVibe.Today',++ purpose: 'The OpenVibe network\'s calendar: one public schedule of streams and events, reminders sent through OpenVibe.Network, a private ICS feed per person, and a daily brief.',++ home: 'https://openvibe.today',++ site: 'openvibe.today',++ api: '/api/v1',++ docs: '/developers',++ policy: {++ auth: ['none', 'Network OIDC'],++ rateLimit: '240 API requests a minute per address; 300 page requests a minute',++ },++};nnew file mode 100644@@@ -0,0 +1,27 @@++'use strict';++// The Markdown twins of openvibe.today's public pages (openvibe-shared/agent-layer), rendered by the same++// server renderer the routes use (createPublicPageViews in http/pages.js + render/layout.js's renderPage), so a++// twin cannot drift from its page. Anonymous only: an event shows no reminders and no owner controls, and the++// pages drawn for a signed-in person (/me, /brief, /schedule) have no twin at all.++const { createPublicPageViews } = require('./http/pages');++const { renderPage } = require('./render/layout');++++function createAgentPages(ctx) {++ const views = createPublicPageViews(ctx);++ const twin = (o, path) => (o ? { html: renderPage({ ...o, config: ctx.config, path }) } : null);++++ async function renderPublicPage(path) {++ if (path === '/') return twin(await views.home(), '/');++ if (path === '/developers') return twin(views.developers(), '/developers');++ const one = /^\/e\/([^/]+)$/.exec(path);++ if (one) {++ const id = decodeURIComponent(one[1]);++ return twin(await views.event(id), `/e/${encodeURIComponent(id)}`);++ }++ return null;++ }++++ return { renderPublicPage };++}++++module.exports = { createAgentPages };@@@ -161,6 +161,13 @@ async function createApp(opts = {}) {// ── Pages ───────────────────────────────────────────────app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.TODAY_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));app.use(createPageRoutes(ctx));++ // The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json, and each public page's Markdown twin.++ // It mounts after the page routes, because a page drawn for a signed-in person (/e/:id with its reminders, /me,++ // /brief) keeps its own route, and before the 404, so an unknown twin is still a 404.++ require('openvibe-shared/agent-layer').mount(app, {++ descriptor: require('./agent-descriptor'),++ render: require('./agent-pages').createAgentPages(ctx).renderPublicPage,++ });app.use((req, res) => send(res, 404, {viewer: req.viewer, config, path: req.originalUrl, title: 'Not found',body: raw(require('./views').notFound()),@@@ -49,6 +49,7 @@ function createDiscoveryRoutes(ctx) {r.get('/robots.txt', (_req, res) => {res.type('text/plain').set('Cache-Control', TEXT).send('# openvibe.today: the public pages are for search and AI crawlers; sign-in, the API, a person\'s agenda and brief are not.\n'++ + `# Agent manifest: ${abs('/.well-known/agent.json')}\n`+ seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));});@@@ -27,6 +27,81 @@ const views = require('../views');const PUBLIC_CACHE = 'public, max-age=60';const DAY = 24 * 60 * 60_000;++/** One event's end: its own, or an hour after it starts. */++const eventEnd = (e) => e.ends_at || new Date(new Date(e.starts_at).getTime() + 60 * 60_000).toISOString();++++/** schema.org Event for one of this site's events. */++function eventJsonLd(e, config) {++ const base = String(config.baseUrl).replace(/\/+$/, '');++ const end = eventEnd(e);++ const organizer = e.channel++ ? { '@type': 'Person', name: `@${e.channel}`, url: `https://openvibe.live/@${encodeURIComponent(e.channel)}` }++ : { '@type': 'Organization', name: SITE_NAME, url: base };++ return {++ '@context': 'https://schema.org',++ '@type': 'Event',++ name: e.title,++ startDate: e.starts_at,++ endDate: end,++ eventStatus: e.status === 'cancelled' ? 'https://schema.org/EventCancelled' : 'https://schema.org/EventScheduled',++ eventAttendanceMode: 'https://schema.org/OnlineEventAttendanceMode',++ location: { '@type': 'VirtualLocation', url: e.url || `${base}/e/${e.id}` },++ organizer,++ ...(e.description ? { description: e.description } : {}),++ url: `${base}/e/${e.id}`,++ };++}++++/**++ * The public pages' own options, so that the routes and the agent layer's Markdown twins++ * (openvibe-shared/agent-layer) render the very same page. `agent: true` marks a page that has a twin: the head and++ * the Link header advertise it. Nothing here is drawn for a signed-in person.++ */++function createPublicPageViews(ctx) {++ const { config, s, events } = ctx;++ const base = () => String(config.baseUrl).replace(/\/+$/, '');++++ async function home() {++ const now = s.now();++ const rows = await events.listPublic({ from: new Date(now - 2 * 60 * 60_000).toISOString(), to: new Date(now + 60 * DAY).toISOString(), limit: 20 });++ return {++ index: true, cache: PUBLIC_CACHE, agent: true,++ title: null,++ body: raw(views.home({ upcoming: rows, now })),++ description: 'Streams, events and reminders across OpenVibe on one calendar: schedule a stream, get reminded, and subscribe in your own calendar app.',++ jsonLd: homeJsonLd(config),++ };++ }++++ function developers() {++ const description = 'The JSON API, the ICS feeds and the Events topics OpenVibe.Today publishes and consumes.';++ return {++ index: true, cache: PUBLIC_CACHE, agent: true,++ title: 'The OpenVibe.Today API',++ description,++ jsonLd: [seo.jsonLd.webPage({ name: 'The OpenVibe.Today API', url: `${base()}/developers`, description, siteUrl: base() })],++ body: raw(views.developers()),++ };++ }++++ /** One public event as an anonymous visitor sees it (no reminders, no owner controls), or null. */++ async function event(id) {++ const row = await events.byIdForViewer(String(id || ''), null);++ if (!row) return null;++ const e = events.wire(row);++ if (e.visibility !== 'public' || e.status === 'cancelled') return null;++ return {++ index: true, cache: PUBLIC_CACHE, agent: true,++ title: e.title,++ description: (e.description || `${e.title} on OpenVibe.Today`).slice(0, 160),++ jsonLd: [eventJsonLd(e, config)],++ body: raw(views.event({ e, end: eventEnd(e), reminder: null, own: false, signedIn: false, path: `/e/${e.id}` })),++ };++ }++++ return { home, developers, event };++}++function createPageRoutes(ctx) {const { config, s, events, feeds, agenda, brief } = ctx;const r = asyncRouter();@@@ -38,17 +113,8 @@ function createPageRoutes(ctx) {const signInPrompt = (what, next) => html`<div class="notice">${what} <a href="/auth/login?next=${encodeURIComponent(next)}">Sign in with OpenVibe</a>.</div>`;// ── Home ─────────────────────────────────────────────────-- r.get('/', async (req, res) => {-- const now = s.now();-- const rows = await events.listPublic({ from: new Date(now - 2 * 60 * 60_000).toISOString(), to: new Date(now + 60 * DAY).toISOString(), limit: 20 });-- return page(req, res, {-- index: true, cache: PUBLIC_CACHE,-- title: null,-- body: raw(views.home({ upcoming: rows, now })),-- description: 'Streams, events and reminders across OpenVibe on one calendar: schedule a stream, get reminded, and subscribe in your own calendar app.',-- jsonLd: homeJsonLd(config),-- });-- });++ const publicViews = createPublicPageViews(ctx);++ r.get('/', async (req, res) => page(req, res, await publicViews.home()));// ── A channel's schedule ────────────────────────────────r.get('/@:username', async (req, res) => {@@@ -95,7 +161,9 @@ function createPageRoutes(ctx) {});// ── One event ───────────────────────────────────────────-- r.get('/e/:id', async (req, res) => {++ r.get('/e/:id', async (req, res, next) => {++ // /e/<id>.md is the agent layer's Markdown twin (server/agent-pages.js), not an event id.++ if (String(req.params.id || '').endsWith('.md')) return next();const subject = signedIn(req) ? req.viewer.subject : null;const row = await events.byIdForViewer(String(req.params.id || ''), subject);if (!row) return notFound(req, res, 'No such event');@@@ -103,29 +171,15 @@ function createPageRoutes(ctx) {const own = !!subject && row.owner_subject === subject;const reminder = subject ? ((await events.listReminders(subject)).find((x) => x.event_id === e.id) || null) : null;if (reminder && reminder.minutes_before == null) reminder.minutes_before = Math.round((new Date(e.starts_at).getTime() - new Date(reminder.remind_at).getTime()) / 60000);-- const end = e.ends_at || new Date(new Date(e.starts_at).getTime() + 60 * 60_000).toISOString();-- const organizer = e.channel-- ? { '@type': 'Person', name: `@${e.channel}`, url: `https://openvibe.live/@${encodeURIComponent(e.channel)}` }-- : { '@type': 'Organization', name: SITE_NAME, url: base() };-- const jsonLd = {-- '@context': 'https://schema.org',-- '@type': 'Event',-- name: e.title,-- startDate: e.starts_at,-- endDate: end,-- eventStatus: e.status === 'cancelled' ? 'https://schema.org/EventCancelled' : 'https://schema.org/EventScheduled',-- eventAttendanceMode: 'https://schema.org/OnlineEventAttendanceMode',-- location: { '@type': 'VirtualLocation', url: e.url || `${base()}/e/${e.id}` },-- organizer,-- ...(e.description ? { description: e.description } : {}),-- url: `${base()}/e/${e.id}`,-- };++ const end = eventEnd(e);++ const publicEvent = e.visibility === 'public' && e.status !== 'cancelled';return page(req, res, {-- index: e.visibility === 'public' && e.status !== 'cancelled',++ index: publicEvent,cache: e.visibility === 'public' ? PUBLIC_CACHE : null,++ agent: publicEvent,title: e.title,description: (e.description || `${e.title} on OpenVibe.Today`).slice(0, 160),-- jsonLd: [jsonLd],++ jsonLd: [eventJsonLd(e, config)],body: raw(views.event({ e, end, reminder, own, signedIn: !!subject, path: req.originalUrl })),});});@@@ -208,13 +262,7 @@ function createPageRoutes(ctx) {});// ── Developers ──────────────────────────────────────────-- r.get('/developers', (req, res) => page(req, res, {-- index: true, cache: PUBLIC_CACHE,-- title: 'The OpenVibe.Today API',-- description: 'The JSON API, the ICS feeds and the Events topics OpenVibe.Today publishes and consumes.',-- jsonLd: [seo.jsonLd.webPage({ name: 'The OpenVibe.Today API', url: `${base()}/developers`, description: 'The JSON API, the ICS feeds and the Events topics OpenVibe.Today publishes and consumes.', siteUrl: base() })],-- body: raw(views.developers()),-- }));++ r.get('/developers', (req, res) => page(req, res, publicViews.developers()));// ── ICS feeds ───────────────────────────────────────────r.get('/ics/:file', async (req, res) => {@@@ -255,4 +303,4 @@ function createPageRoutes(ctx) {return r;}--module.exports = { createPageRoutes };++module.exports = { createPageRoutes, createPublicPageViews };@@@ -66,6 +66,8 @@ function renderPage(o) {? html`<nav class="crumbs" aria-label="Breadcrumbs">${o.crumbs.map((c, i) => html`${i ? ' / ' : ''}${c.href ? html`<a href="${c.href}">${c.label}</a>` : c.label}`)}</nav>`: '';const scripts = (o.scripts || []).map((rel) => html`<script src="${asset(rel)}" defer></script>`);++ // A page with a Markdown twin (o.agent) advertises it in the head; the twin itself is server/agent-pages.js.++ const llms = `${String(o.config.baseUrl).replace(/\/+$/, '')}/llms.txt`;return shell.page({name: SITE_NAME,lang: 'en',@@@ -74,6 +76,8 @@ function renderPage(o) {siteName: SITE_NAME,description: o.description || `${SITE_NAME} — ${TAGLINE}`,canonical,++ agentLayer: o.agent === true,++ llms,robots: o.index ? 'index, follow' : 'noindex, nofollow',jsonLd: (o.jsonLd || []).filter(Boolean),home: '/',@@@ -113,6 +117,11 @@ function send(res, status, o) {const personal = o.viewer && o.viewer.kind === 'user';if (!res.get('Cache-Control')) res.set('Cache-Control', personal ? cache.htmlHeaders({ private: true }) : (o.cache || cache.htmlHeaders({ private: true })));res.set('Vary', 'Cookie');++ // A page with a Markdown twin names it, as the agent layer's own responses do (openvibe-shared/agent-layer).++ if (o.agent) {++ const site = String(o.config.baseUrl).replace(/\/+$/, '');++ res.set('Link', require('openvibe-shared/agent-layer').linkHeaderTags(`${site}${String(o.path || '/').split('?')[0]}`, `${site}/llms.txt`).header);++ }res.status(status).type('html').send(renderPage(o));}
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.233.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.233.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"devDependencies": {
"devDependencies": {
@@ -27,6 +27,12 @@
@@ -27,6 +27,12 @@
"node": ">=22.0.0"
"node": ">=22.0.0"
}
}
},
},
+ "node_modules/@acemir/cssom": {
+ "version": "0.9.32",
+ "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.32.tgz",
+ "integrity": "sha512-OijrAUu7rrJhSIoHNg6LMPS5e344nwUHA+1EFYYjrh64D+GmytUsfBQjci45TjT+rd5Spsp8B5xsZDT+TgyXXQ==",
+ "license": "MIT"
+ },
"node_modules/@electric-sql/pglite": {
"node_modules/@electric-sql/pglite": {
"version": "0.5.13",
"version": "0.5.13",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.13.tgz",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.13.tgz",
@@ -116,6 +122,19 @@
@@ -116,6 +122,19 @@
"npm": "1.2.8000 || >= 1.4.16"
"npm": "1.2.8000 || >= 1.4.16"
}
}
},
},
+ "node_modules/boolbase": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz",
+ "integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
"node_modules/buffer-equal-constant-time": {
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@@ -218,6 +237,39 @@
@@ -218,6 +237,39 @@
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/css-select": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz",
+ "integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^2.0.0",
+ "css-what": "^8.0.0",
+ "domhandler": "^6.0.1",
+ "domutils": "^4.0.2",
+ "nth-check": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/css-what": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz",
+ "integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
"node_modules/debug": {
"node_modules/debug": {
"version": "2.6.9",
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -255,6 +307,73 @@
@@ -255,6 +307,73 @@
"npm": "1.2.8000 || >= 1.4.16"
"npm": "1.2.8000 || >= 1.4.16"
}
}
},
},
+ "node_modules/dom-serializer": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
+ "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^3.0.0",
+ "domhandler": "^6.0.0",
+ "entities": "^8.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/domelementtype": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
+ "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/domhandler": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
+ "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/domutils": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
+ "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^3.0.0",
+ "domelementtype": "^3.0.0",
+ "domhandler": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
"node_modules/dotenv": {
"node_modules/dotenv": {
"version": "16.6.1",
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -305,6 +424,18 @@
@@ -305,6 +424,18 @@
"node": ">= 0.8"
"node": ">= 0.8"
}
}
},
},
+ "node_modules/entities": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
+ "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/es-define-property": {
"node_modules/es-define-property": {
"version": "1.0.1",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -563,6 +694,110 @@
@@ -563,6 +694,110 @@
"url": "https://github.com/sponsors/EvanHahn"
"url": "https://github.com/sponsors/EvanHahn"
}
}
},
},
+ "node_modules/html-escaper": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
+ "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",
+ "license": "MIT"
+ },
+ "node_modules/htmlparser2": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
+ "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.2.2",
+ "entities": "^7.0.1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/dom-serializer/node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/htmlparser2/node_modules/domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/domutils": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/http-errors": {
"node_modules/http-errors": {
"version": "2.0.1",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -708,6 +943,30 @@
@@ -708,6 +943,30 @@
"safe-buffer": "^5.0.1"
"safe-buffer": "^5.0.1"
}
}
},
},
+ "node_modules/linkedom": {
+ "version": "0.18.14",
+ "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.14.tgz",
+ "integrity": "sha512-BRUnkOm4YR5vo+qI3j0EbungB0vZPSCue5AdnTQRaxAGSbsOZiBFRO0rh4zlo8qXUhli8pxvnpEXDklMnlq2Rg==",
+ "license": "ISC",
+ "dependencies": {
+ "@acemir/cssom": "^0.9.32",
+ "css-select": "^7.0.0",
+ "html-escaper": "^3.0.3",
+ "htmlparser2": "^10.1.0",
+ "uhyphen": "^0.2.0"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "peerDependencies": {
+ "canvas": ">= 2"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
"node_modules/lodash.defaults": {
"node_modules/lodash.defaults": {
"version": "4.2.0",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
@@ -846,6 +1105,22 @@
@@ -846,6 +1105,22 @@
"node": ">= 0.6"
"node": ">= 0.6"
}
}
},
},
+ "node_modules/nth-check": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz",
+ "integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/fb55/nth-check?sponsor=1"
+ }
+ },
"node_modules/object-inspect": {
"node_modules/object-inspect": {
"version": "1.13.4",
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -919,12 +1194,13 @@
@@ -919,12 +1194,13 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.33.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
- "integrity": "sha512-t9XvhrQ+lQebOrFTjMCzI6/7/i4iQn9dmMr/8HVTNW1XoV1exyJa7IH/HfyyKuLxjA8xaowrDfD28LwKIibBNg==",
+ "version": "4.34.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0",
+ "integrity": "sha512-Rj5uRO3MbWkyIYAm+vK4Y94ugDNYEjEgGkYnPjSej4yMqhalpaPCkU96dzphMGiOUGP1PNWsiKhwaBj8tewgBg==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
- "jsonwebtoken": "^9.0.0"
+ "jsonwebtoken": "^9.0.0",
+ "linkedom": "^0.18.13"
},
},
"bin": {
"bin": {
"openvibe-build-assets": "scripts/build-assets.js"
"openvibe-build-assets": "scripts/build-assets.js"
@@ -1367,6 +1643,12 @@
@@ -1367,6 +1643,12 @@
"node": ">= 0.6"
"node": ">= 0.6"
}
}
},
},
+ "node_modules/uhyphen": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",
+ "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",
+ "license": "ISC"
+ },
"node_modules/unpipe": {
"node_modules/unpipe": {
"version": "1.0.0",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.233.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.233.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"engines": {
"engines": {
new file mode 100644
new file mode 100644
@@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
+'use strict';
+// What openvibe.today tells an agent about itself (openvibe-shared/agent-layer): /.well-known/agent.json.
+// Every field is something this site really serves; no OpenAPI document is published for Today, so none is named.
+module.exports = {
+ id: 'today',
+ name: 'OpenVibe.Today',
+ purpose: 'The OpenVibe network\'s calendar: one public schedule of streams and events, reminders sent through OpenVibe.Network, a private ICS feed per person, and a daily brief.',
+ home: 'https://openvibe.today',
+ site: 'openvibe.today',
+ api: '/api/v1',
+ docs: '/developers',
+ policy: {
+ auth: ['none', 'Network OIDC'],
+ rateLimit: '240 API requests a minute per address; 300 page requests a minute',
+ },
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
+'use strict';
+// The Markdown twins of openvibe.today's public pages (openvibe-shared/agent-layer), rendered by the same
+// server renderer the routes use (createPublicPageViews in http/pages.js + render/layout.js's renderPage), so a
+// twin cannot drift from its page. Anonymous only: an event shows no reminders and no owner controls, and the
+// pages drawn for a signed-in person (/me, /brief, /schedule) have no twin at all.
+const { createPublicPageViews } = require('./http/pages');
+const { renderPage } = require('./render/layout');
+
+function createAgentPages(ctx) {
+ const views = createPublicPageViews(ctx);
+ const twin = (o, path) => (o ? { html: renderPage({ ...o, config: ctx.config, path }) } : null);
+
+ async function renderPublicPage(path) {
+ if (path === '/') return twin(await views.home(), '/');
+ if (path === '/developers') return twin(views.developers(), '/developers');
+ const one = /^\/e\/([^/]+)$/.exec(path);
+ if (one) {
+ const id = decodeURIComponent(one[1]);
+ return twin(await views.event(id), `/e/${encodeURIComponent(id)}`);
+ }
+ return null;
+ }
+
+ return { renderPublicPage };
+}
+
+module.exports = { createAgentPages };
@@ -161,6 +161,13 @@ async function createApp(opts = {}) {
@@ -161,6 +161,13 @@ async function createApp(opts = {}) {
// ── Pages ───────────────────────────────────────────────
// ── Pages ───────────────────────────────────────────────
app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.TODAY_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.TODAY_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
app.use(createPageRoutes(ctx));
app.use(createPageRoutes(ctx));
+ // The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json, and each public page's Markdown twin.
+ // It mounts after the page routes, because a page drawn for a signed-in person (/e/:id with its reminders, /me,
+ // /brief) keeps its own route, and before the 404, so an unknown twin is still a 404.
+ require('openvibe-shared/agent-layer').mount(app, {
+ descriptor: require('./agent-descriptor'),
+ render: require('./agent-pages').createAgentPages(ctx).renderPublicPage,
+ });
app.use((req, res) => send(res, 404, {
app.use((req, res) => send(res, 404, {
viewer: req.viewer, config, path: req.originalUrl, title: 'Not found',
viewer: req.viewer, config, path: req.originalUrl, title: 'Not found',
body: raw(require('./views').notFound()),
body: raw(require('./views').notFound()),
@@ -49,6 +49,7 @@ function createDiscoveryRoutes(ctx) {
@@ -49,6 +49,7 @@ function createDiscoveryRoutes(ctx) {
r.get('/robots.txt', (_req, res) => {
r.get('/robots.txt', (_req, res) => {
res.type('text/plain').set('Cache-Control', TEXT).send(
res.type('text/plain').set('Cache-Control', TEXT).send(
'# openvibe.today: the public pages are for search and AI crawlers; sign-in, the API, a person\'s agenda and brief are not.\n'
'# openvibe.today: the public pages are for search and AI crawlers; sign-in, the API, a person\'s agenda and brief are not.\n'
+ + `# Agent manifest: ${abs('/.well-known/agent.json')}\n`
+ seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));
+ seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));
});
});
@@ -27,6 +27,81 @@ const views = require('../views');
@@ -27,6 +27,81 @@ const views = require('../views');
const PUBLIC_CACHE = 'public, max-age=60';
const PUBLIC_CACHE = 'public, max-age=60';
const DAY = 24 * 60 * 60_000;
const DAY = 24 * 60 * 60_000;
+/** One event's end: its own, or an hour after it starts. */
+const eventEnd = (e) => e.ends_at || new Date(new Date(e.starts_at).getTime() + 60 * 60_000).toISOString();
+
+/** schema.org Event for one of this site's events. */
+function eventJsonLd(e, config) {
+ const base = String(config.baseUrl).replace(/\/+$/, '');
+ const end = eventEnd(e);
+ const organizer = e.channel
+ ? { '@type': 'Person', name: `@${e.channel}`, url: `https://openvibe.live/@${encodeURIComponent(e.channel)}` }
+ : { '@type': 'Organization', name: SITE_NAME, url: base };
+ return {
+ '@context': 'https://schema.org',
+ '@type': 'Event',
+ name: e.title,
+ startDate: e.starts_at,
+ endDate: end,
+ eventStatus: e.status === 'cancelled' ? 'https://schema.org/EventCancelled' : 'https://schema.org/EventScheduled',
+ eventAttendanceMode: 'https://schema.org/OnlineEventAttendanceMode',
+ location: { '@type': 'VirtualLocation', url: e.url || `${base}/e/${e.id}` },
+ organizer,
+ ...(e.description ? { description: e.description } : {}),
+ url: `${base}/e/${e.id}`,
+ };
+}
+
+/**
+ * The public pages' own options, so that the routes and the agent layer's Markdown twins
+ * (openvibe-shared/agent-layer) render the very same page. `agent: true` marks a page that has a twin: the head and
+ * the Link header advertise it. Nothing here is drawn for a signed-in person.
+ */
+function createPublicPageViews(ctx) {
+ const { config, s, events } = ctx;
+ const base = () => String(config.baseUrl).replace(/\/+$/, '');
+
+ async function home() {
+ const now = s.now();
+ const rows = await events.listPublic({ from: new Date(now - 2 * 60 * 60_000).toISOString(), to: new Date(now + 60 * DAY).toISOString(), limit: 20 });
+ return {
+ index: true, cache: PUBLIC_CACHE, agent: true,
+ title: null,
+ body: raw(views.home({ upcoming: rows, now })),
+ description: 'Streams, events and reminders across OpenVibe on one calendar: schedule a stream, get reminded, and subscribe in your own calendar app.',
+ jsonLd: homeJsonLd(config),
+ };
+ }
+
+ function developers() {
+ const description = 'The JSON API, the ICS feeds and the Events topics OpenVibe.Today publishes and consumes.';
+ return {
+ index: true, cache: PUBLIC_CACHE, agent: true,
+ title: 'The OpenVibe.Today API',
+ description,
+ jsonLd: [seo.jsonLd.webPage({ name: 'The OpenVibe.Today API', url: `${base()}/developers`, description, siteUrl: base() })],
+ body: raw(views.developers()),
+ };
+ }
+
+ /** One public event as an anonymous visitor sees it (no reminders, no owner controls), or null. */
+ async function event(id) {
+ const row = await events.byIdForViewer(String(id || ''), null);
+ if (!row) return null;
+ const e = events.wire(row);
+ if (e.visibility !== 'public' || e.status === 'cancelled') return null;
+ return {
+ index: true, cache: PUBLIC_CACHE, agent: true,
+ title: e.title,
+ description: (e.description || `${e.title} on OpenVibe.Today`).slice(0, 160),
+ jsonLd: [eventJsonLd(e, config)],
+ body: raw(views.event({ e, end: eventEnd(e), reminder: null, own: false, signedIn: false, path: `/e/${e.id}` })),
+ };
+ }
+
+ return { home, developers, event };
+}
+
function createPageRoutes(ctx) {
function createPageRoutes(ctx) {
const { config, s, events, feeds, agenda, brief } = ctx;
const { config, s, events, feeds, agenda, brief } = ctx;
const r = asyncRouter();
const r = asyncRouter();
@@ -38,17 +113,8 @@ function createPageRoutes(ctx) {
@@ -38,17 +113,8 @@ function createPageRoutes(ctx) {
const signInPrompt = (what, next) => html`<div class="notice">${what} <a href="/auth/login?next=${encodeURIComponent(next)}">Sign in with OpenVibe</a>.</div>`;
const signInPrompt = (what, next) => html`<div class="notice">${what} <a href="/auth/login?next=${encodeURIComponent(next)}">Sign in with OpenVibe</a>.</div>`;
// ── Home ─────────────────────────────────────────────────
// ── Home ─────────────────────────────────────────────────
- r.get('/', async (req, res) => {
- const now = s.now();
- const rows = await events.listPublic({ from: new Date(now - 2 * 60 * 60_000).toISOString(), to: new Date(now + 60 * DAY).toISOString(), limit: 20 });
- return page(req, res, {
- index: true, cache: PUBLIC_CACHE,
- title: null,
- body: raw(views.home({ upcoming: rows, now })),
- description: 'Streams, events and reminders across OpenVibe on one calendar: schedule a stream, get reminded, and subscribe in your own calendar app.',
- jsonLd: homeJsonLd(config),
- });
- });
+ const publicViews = createPublicPageViews(ctx);
+ r.get('/', async (req, res) => page(req, res, await publicViews.home()));
// ── A channel's schedule ────────────────────────────────
// ── A channel's schedule ────────────────────────────────
r.get('/@:username', async (req, res) => {
r.get('/@:username', async (req, res) => {
@@ -95,7 +161,9 @@ function createPageRoutes(ctx) {
@@ -95,7 +161,9 @@ function createPageRoutes(ctx) {
});
});
// ── One event ───────────────────────────────────────────
// ── One event ───────────────────────────────────────────
- r.get('/e/:id', async (req, res) => {
+ r.get('/e/:id', async (req, res, next) => {
+ // /e/<id>.md is the agent layer's Markdown twin (server/agent-pages.js), not an event id.
+ if (String(req.params.id || '').endsWith('.md')) return next();
const subject = signedIn(req) ? req.viewer.subject : null;
const subject = signedIn(req) ? req.viewer.subject : null;
const row = await events.byIdForViewer(String(req.params.id || ''), subject);
const row = await events.byIdForViewer(String(req.params.id || ''), subject);
if (!row) return notFound(req, res, 'No such event');
if (!row) return notFound(req, res, 'No such event');
@@ -103,29 +171,15 @@ function createPageRoutes(ctx) {
@@ -103,29 +171,15 @@ function createPageRoutes(ctx) {
const own = !!subject && row.owner_subject === subject;
const own = !!subject && row.owner_subject === subject;
const reminder = subject ? ((await events.listReminders(subject)).find((x) => x.event_id === e.id) || null) : null;
const reminder = subject ? ((await events.listReminders(subject)).find((x) => x.event_id === e.id) || null) : null;
if (reminder && reminder.minutes_before == null) reminder.minutes_before = Math.round((new Date(e.starts_at).getTime() - new Date(reminder.remind_at).getTime()) / 60000);
if (reminder && reminder.minutes_before == null) reminder.minutes_before = Math.round((new Date(e.starts_at).getTime() - new Date(reminder.remind_at).getTime()) / 60000);
- const end = e.ends_at || new Date(new Date(e.starts_at).getTime() + 60 * 60_000).toISOString();
- const organizer = e.channel
- ? { '@type': 'Person', name: `@${e.channel}`, url: `https://openvibe.live/@${encodeURIComponent(e.channel)}` }
- : { '@type': 'Organization', name: SITE_NAME, url: base() };
- const jsonLd = {
- '@context': 'https://schema.org',
- '@type': 'Event',
- name: e.title,
- startDate: e.starts_at,
- endDate: end,
- eventStatus: e.status === 'cancelled' ? 'https://schema.org/EventCancelled' : 'https://schema.org/EventScheduled',
- eventAttendanceMode: 'https://schema.org/OnlineEventAttendanceMode',
- location: { '@type': 'VirtualLocation', url: e.url || `${base()}/e/${e.id}` },
- organizer,
- ...(e.description ? { description: e.description } : {}),
- url: `${base()}/e/${e.id}`,
- };
+ const end = eventEnd(e);
+ const publicEvent = e.visibility === 'public' && e.status !== 'cancelled';
return page(req, res, {
return page(req, res, {
- index: e.visibility === 'public' && e.status !== 'cancelled',
+ index: publicEvent,
cache: e.visibility === 'public' ? PUBLIC_CACHE : null,
cache: e.visibility === 'public' ? PUBLIC_CACHE : null,
+ agent: publicEvent,
title: e.title,
title: e.title,
description: (e.description || `${e.title} on OpenVibe.Today`).slice(0, 160),
description: (e.description || `${e.title} on OpenVibe.Today`).slice(0, 160),
- jsonLd: [jsonLd],
+ jsonLd: [eventJsonLd(e, config)],
body: raw(views.event({ e, end, reminder, own, signedIn: !!subject, path: req.originalUrl })),
body: raw(views.event({ e, end, reminder, own, signedIn: !!subject, path: req.originalUrl })),
});
});
});
});
@@ -208,13 +262,7 @@ function createPageRoutes(ctx) {
@@ -208,13 +262,7 @@ function createPageRoutes(ctx) {
});
});
// ── Developers ──────────────────────────────────────────
// ── Developers ──────────────────────────────────────────
- r.get('/developers', (req, res) => page(req, res, {
- index: true, cache: PUBLIC_CACHE,
- title: 'The OpenVibe.Today API',
- description: 'The JSON API, the ICS feeds and the Events topics OpenVibe.Today publishes and consumes.',
- jsonLd: [seo.jsonLd.webPage({ name: 'The OpenVibe.Today API', url: `${base()}/developers`, description: 'The JSON API, the ICS feeds and the Events topics OpenVibe.Today publishes and consumes.', siteUrl: base() })],
- body: raw(views.developers()),
- }));
+ r.get('/developers', (req, res) => page(req, res, publicViews.developers()));
// ── ICS feeds ───────────────────────────────────────────
// ── ICS feeds ───────────────────────────────────────────
r.get('/ics/:file', async (req, res) => {
r.get('/ics/:file', async (req, res) => {
@@ -255,4 +303,4 @@ function createPageRoutes(ctx) {
@@ -255,4 +303,4 @@ function createPageRoutes(ctx) {
return r;
return r;
}
}
-module.exports = { createPageRoutes };
+module.exports = { createPageRoutes, createPublicPageViews };
@@ -66,6 +66,8 @@ function renderPage(o) {
@@ -66,6 +66,8 @@ function renderPage(o) {
? html`<nav class="crumbs" aria-label="Breadcrumbs">${o.crumbs.map((c, i) => html`${i ? ' / ' : ''}${c.href ? html`<a href="${c.href}">${c.label}</a>` : c.label}`)}</nav>`
? html`<nav class="crumbs" aria-label="Breadcrumbs">${o.crumbs.map((c, i) => html`${i ? ' / ' : ''}${c.href ? html`<a href="${c.href}">${c.label}</a>` : c.label}`)}</nav>`
: '';
: '';
const scripts = (o.scripts || []).map((rel) => html`<script src="${asset(rel)}" defer></script>`);
const scripts = (o.scripts || []).map((rel) => html`<script src="${asset(rel)}" defer></script>`);
+ // A page with a Markdown twin (o.agent) advertises it in the head; the twin itself is server/agent-pages.js.
+ const llms = `${String(o.config.baseUrl).replace(/\/+$/, '')}/llms.txt`;
return shell.page({
return shell.page({
name: SITE_NAME,
name: SITE_NAME,
lang: 'en',
lang: 'en',
@@ -74,6 +76,8 @@ function renderPage(o) {
@@ -74,6 +76,8 @@ function renderPage(o) {
siteName: SITE_NAME,
siteName: SITE_NAME,
description: o.description || `${SITE_NAME} — ${TAGLINE}`,
description: o.description || `${SITE_NAME} — ${TAGLINE}`,
canonical,
canonical,
+ agentLayer: o.agent === true,
+ llms,
robots: o.index ? 'index, follow' : 'noindex, nofollow',
robots: o.index ? 'index, follow' : 'noindex, nofollow',
jsonLd: (o.jsonLd || []).filter(Boolean),
jsonLd: (o.jsonLd || []).filter(Boolean),
home: '/',
home: '/',
@@ -113,6 +117,11 @@ function send(res, status, o) {
@@ -113,6 +117,11 @@ function send(res, status, o) {
const personal = o.viewer && o.viewer.kind === 'user';
const personal = o.viewer && o.viewer.kind === 'user';
if (!res.get('Cache-Control')) res.set('Cache-Control', personal ? cache.htmlHeaders({ private: true }) : (o.cache || cache.htmlHeaders({ private: true })));
if (!res.get('Cache-Control')) res.set('Cache-Control', personal ? cache.htmlHeaders({ private: true }) : (o.cache || cache.htmlHeaders({ private: true })));
res.set('Vary', 'Cookie');
res.set('Vary', 'Cookie');
+ // A page with a Markdown twin names it, as the agent layer's own responses do (openvibe-shared/agent-layer).
+ if (o.agent) {
+ const site = String(o.config.baseUrl).replace(/\/+$/, '');
+ res.set('Link', require('openvibe-shared/agent-layer').linkHeaderTags(`${site}${String(o.path || '/').split('?')[0]}`, `${site}/llms.txt`).header);
+ }
res.status(status).type('html').send(renderPage(o));
res.status(status).type('html').send(renderPage(o));
}
}