openvibers/openvibe.tips Commits
The discovery route changes are complete in the working tree on branch…
Changes
@@@ -26,7 +26,7 @@ const filterLib = require('./filter');const HANDLE_RE = /^[a-z0-9][a-z0-9_.-]{0,39}$/;// Paths the site itself uses; no creator page can take them.const RESERVED = new Set(['api', 'auth', 'overlay', 'internal', 'dashboard', 'receipts', 'assets', 'css', 'js', 'img', 'favicon.svg',-- 'robots.txt', 'sitemap.xml', 'release.json', 'manifest.webmanifest', 'terms', 'privacy', 'dmca', 'tos', 'about', 'help', 'goals', 'tips', 'admin', 'www',++ 'robots.txt', 'sitemap.xml', 'llms.txt', 'release.json', 'manifest.webmanifest', 'terms', 'privacy', 'dmca', 'tos', 'about', 'help', 'goals', 'tips', 'admin', 'www','moderate', 'supporters', 'metrics']);const VOICES = ['gary', 'brian', 'amy', 'emma', 'joey', 'justin', 'matthew', 'salli', 'kimberly', 'kendra', 'ivy', 'joanna'];@@@ -19,20 +19,24 @@* GET /overlay/:token the overlay page for OBS (token = scoped, revocable, never a cookie)* GET /overlay/:token/events SSE: alerts and goal updates (Last-Event-ID resumes/replays)* GET /overlay/:token/state JSON: active goals and recent alerts (read-only, for other overlay clients)-- * GET /robots.txt, /sitemap.xml++ * GET /robots.txt, /sitemap.xml, /llms.txt*/const crypto = require('crypto');const ovServe = require('openvibe-shared/serve');const frame = require('openvibe-shared/frame');++const seo = require('openvibe-shared/seo');const express = require('express');const { sql } = require('openvibe-sdk/db');const { TipsError } = require('../util');const { viewerMiddleware } = require('./session');const { asyncRouter } = require('./async-router');const pages = require('./pages');--const { esc, asset } = require('./layout');++const { asset } = require('./layout');const { PAGE_DEFAULTS } = require('../domain/profiles');++// The sitemap's lastmod: when this server booted — a real date, computed once, never per request.++const BOOT_AT = new Date().toISOString();++const FLASH = new Set(['Saved', 'Page settings saved', 'Goal added', 'Goal updated', 'Goal closed', 'Overlay link revoked', 'Alert settings saved', 'Test sent — check your overlay','Filter saved', 'Moderator removed', 'Invitation revoked']);const MOD_FLASH = new Set(['Hidden', 'Shown', 'You are now a moderator']);@@@ -73,13 +77,34 @@ function createWebRoutes({ domain, config, layout, userAuth }) {});// What shipped on OpenVibe.Tips: the shared update log every OpenVibe site has.r.get('/updates', withViewer, (req, res) => html(res, pageFor(req, { canonicalPath: '/updates', title: 'What shipped on OpenVibe.Tips', body: frame.updatesBody({ service: 'tips', siteName: 'OpenVibe.Tips' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>` })));-- r.get('/robots.txt', (req, res) => res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(-- `User-agent: *\nAllow: /\nDisallow: /dashboard\nDisallow: /receipts\nDisallow: /overlay/\nDisallow: /moderate/\nDisallow: /auth/\nDisallow: /api/\nDisallow: /internal/\nSitemap: ${config.baseUrl}/sitemap.xml\n`));++ // Crawl and machine-readability artifacts, built from openvibe-shared/seo — the same++ // toolkit the other OpenVibe sites use. Public data only, never the viewer.++ r.get('/robots.txt', (req, res) => res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(seo.robotsTxt({++ sitemaps: [`${config.baseUrl}/sitemap.xml`],++ disallow: ['/dashboard', '/receipts', '/overlay/', '/moderate/', '/auth/', '/api/', '/internal/'],++ allowAI: false,++ })));r.get('/sitemap.xml', async (req, res) => {const rows = await db.many(sql`SELECT handle, updated_at FROM creator_tip_profiles WHERE page_enabled ORDER BY handle`);-- const urls = [`<url><loc>${esc(config.baseUrl)}/</loc></url>`, ...rows.map((p) => `<url><loc>${esc(`${config.baseUrl}/${p.handle}`)}</loc><lastmod>${esc(p.updated_at.slice(0, 10))}</lastmod></url>`)];-- res.type('application/xml').set('Cache-Control', 'public, max-age=900').send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls.join('')}</urlset>\n`);++ const urls = [{ loc: `${config.baseUrl}/`, lastmod: BOOT_AT }, ...rows.map((p) => ({ loc: `${config.baseUrl}/${p.handle}`, lastmod: p.updated_at }))];++ res.type('application/xml').set('Cache-Control', 'public, max-age=900').send(seo.sitemapXml(urls));});++ // /llms.txt (llmstxt.org): a plain-markdown map of the site for language-model crawlers.++ r.get('/llms.txt', (req, res) => res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(seo.llmsTxt({++ name: 'OpenVibe.Tips',++ summary: 'OpenVibe.Tips: creator support — tips, goals, paid messages, TTS and media requests, and overlays.',++ details: 'A creator page lists the goals and a tip form that works without JavaScript; tips settle through Billing, and Tips owns the interaction, the goals and the overlay alerts. The home page lists creators whose pages are switched on. Reading any public page needs no account; tipping needs a signed-in supporter, and the dashboard, receipts, moderation, overlays, sign-in and the API are per-person and never listed here.',++ sections: [++ { title: 'Start here', links: [++ { title: 'Creators on OpenVibe.Tips', url: `${config.baseUrl}/`, note: 'every creator with their page switched on' },++ { title: 'What shipped on OpenVibe.Tips', url: `${config.baseUrl}/updates` },++ ] },++ { title: 'Machine-readable', links: [++ { title: 'Sitemap', url: `${config.baseUrl}/sitemap.xml`, note: 'the public pages, with lastmod' },++ { title: 'robots.txt', url: `${config.baseUrl}/robots.txt` },++ ] },++ ],++ })));// ── Receipts ─────────────────────────────────────────────/** The supporter's view of each receipt, with its creator's handle and name (one query for all creators). */@@@ -69,13 +69,27 @@ const { boot, check, done } = require('./helpers/app');assert.match(own.text, /Your page is switched off/);});-- await check('robots.txt and sitemap.xml list only switched-on pages', async () => {++ await check('robots.txt, sitemap.xml and llms.txt: the shared seo kit, listing only switched-on pages', async () => {const robots = await get('/robots.txt');-- assert.match(robots.text, /Disallow: \/dashboard/);-- assert.match(robots.text, /Disallow: \/overlay\//);++ assert.strictEqual(robots.status, 200);++ assert.match(robots.headers.get('content-type'), /^text\/plain/);++ assert.match(robots.text, /^Allow: \/$/m);++ for (const path of ['/dashboard', '/receipts', '/overlay/', '/moderate/', '/auth/', '/api/', '/internal/']) {++ assert.ok(robots.text.includes(`Disallow: ${path}\n`), `robots keeps ${path} private`);++ }++ assert.match(robots.text, /Sitemap: http:\/\/tips\.test\/sitemap\.xml\n/, 'robots names the sitemap');const sm = await get('/sitemap.xml');++ assert.strictEqual(sm.status, 200);++ assert.match(sm.headers.get('content-type'), /^application\/xml/);++ assert.match(sm.text, /<loc>http:\/\/tips\.test\/<\/loc>/, 'the sitemap lists the home URL');assert.match(sm.text, /<loc>http:\/\/tips\.test\/alex<\/loc>/);++ assert.match(sm.text, /<lastmod>\d{4}-\d{2}-\d{2}<\/lastmod>/, 'every url carries a real lastmod');assert.ok(!sm.text.includes('/bob<'));++ const llms = await get('/llms.txt');++ assert.strictEqual(llms.status, 200);++ assert.match(llms.headers.get('content-type'), /^text\/plain/);++ assert.match(llms.text, /^# /, 'llms.txt starts with a markdown heading');++ assert.match(llms.text, /\[Creators on OpenVibe\.Tips\]\(http:\/\/tips\.test\/\)/, 'llms.txt lists the main public pages');});await check('tipping without JavaScript: sign-in first, then a form post settles and lands on the receipt', async () => {
@@ -26,7 +26,7 @@ const filterLib = require('./filter');
@@ -26,7 +26,7 @@ const filterLib = require('./filter');
const HANDLE_RE = /^[a-z0-9][a-z0-9_.-]{0,39}$/;
const HANDLE_RE = /^[a-z0-9][a-z0-9_.-]{0,39}$/;
// Paths the site itself uses; no creator page can take them.
// Paths the site itself uses; no creator page can take them.
const RESERVED = new Set(['api', 'auth', 'overlay', 'internal', 'dashboard', 'receipts', 'assets', 'css', 'js', 'img', 'favicon.svg',
const RESERVED = new Set(['api', 'auth', 'overlay', 'internal', 'dashboard', 'receipts', 'assets', 'css', 'js', 'img', 'favicon.svg',
- 'robots.txt', 'sitemap.xml', 'release.json', 'manifest.webmanifest', 'terms', 'privacy', 'dmca', 'tos', 'about', 'help', 'goals', 'tips', 'admin', 'www',
+ 'robots.txt', 'sitemap.xml', 'llms.txt', 'release.json', 'manifest.webmanifest', 'terms', 'privacy', 'dmca', 'tos', 'about', 'help', 'goals', 'tips', 'admin', 'www',
'moderate', 'supporters', 'metrics']);
'moderate', 'supporters', 'metrics']);
const VOICES = ['gary', 'brian', 'amy', 'emma', 'joey', 'justin', 'matthew', 'salli', 'kimberly', 'kendra', 'ivy', 'joanna'];
const VOICES = ['gary', 'brian', 'amy', 'emma', 'joey', 'justin', 'matthew', 'salli', 'kimberly', 'kendra', 'ivy', 'joanna'];
@@ -19,20 +19,24 @@
@@ -19,20 +19,24 @@
* GET /overlay/:token the overlay page for OBS (token = scoped, revocable, never a cookie)
* GET /overlay/:token the overlay page for OBS (token = scoped, revocable, never a cookie)
* GET /overlay/:token/events SSE: alerts and goal updates (Last-Event-ID resumes/replays)
* GET /overlay/:token/events SSE: alerts and goal updates (Last-Event-ID resumes/replays)
* GET /overlay/:token/state JSON: active goals and recent alerts (read-only, for other overlay clients)
* GET /overlay/:token/state JSON: active goals and recent alerts (read-only, for other overlay clients)
- * GET /robots.txt, /sitemap.xml
+ * GET /robots.txt, /sitemap.xml, /llms.txt
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
+const seo = require('openvibe-shared/seo');
const express = require('express');
const express = require('express');
const { sql } = require('openvibe-sdk/db');
const { sql } = require('openvibe-sdk/db');
const { TipsError } = require('../util');
const { TipsError } = require('../util');
const { viewerMiddleware } = require('./session');
const { viewerMiddleware } = require('./session');
const { asyncRouter } = require('./async-router');
const { asyncRouter } = require('./async-router');
const pages = require('./pages');
const pages = require('./pages');
-const { esc, asset } = require('./layout');
+const { asset } = require('./layout');
const { PAGE_DEFAULTS } = require('../domain/profiles');
const { PAGE_DEFAULTS } = require('../domain/profiles');
+// The sitemap's lastmod: when this server booted — a real date, computed once, never per request.
+const BOOT_AT = new Date().toISOString();
+
const FLASH = new Set(['Saved', 'Page settings saved', 'Goal added', 'Goal updated', 'Goal closed', 'Overlay link revoked', 'Alert settings saved', 'Test sent — check your overlay',
const FLASH = new Set(['Saved', 'Page settings saved', 'Goal added', 'Goal updated', 'Goal closed', 'Overlay link revoked', 'Alert settings saved', 'Test sent — check your overlay',
'Filter saved', 'Moderator removed', 'Invitation revoked']);
'Filter saved', 'Moderator removed', 'Invitation revoked']);
const MOD_FLASH = new Set(['Hidden', 'Shown', 'You are now a moderator']);
const MOD_FLASH = new Set(['Hidden', 'Shown', 'You are now a moderator']);
@@ -73,13 +77,34 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
@@ -73,13 +77,34 @@ function createWebRoutes({ domain, config, layout, userAuth }) {
});
});
// What shipped on OpenVibe.Tips: the shared update log every OpenVibe site has.
// What shipped on OpenVibe.Tips: the shared update log every OpenVibe site has.
r.get('/updates', withViewer, (req, res) => html(res, pageFor(req, { canonicalPath: '/updates', title: 'What shipped on OpenVibe.Tips', body: frame.updatesBody({ service: 'tips', siteName: 'OpenVibe.Tips' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>` })));
r.get('/updates', withViewer, (req, res) => html(res, pageFor(req, { canonicalPath: '/updates', title: 'What shipped on OpenVibe.Tips', body: frame.updatesBody({ service: 'tips', siteName: 'OpenVibe.Tips' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>` })));
- r.get('/robots.txt', (req, res) => res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(
- `User-agent: *\nAllow: /\nDisallow: /dashboard\nDisallow: /receipts\nDisallow: /overlay/\nDisallow: /moderate/\nDisallow: /auth/\nDisallow: /api/\nDisallow: /internal/\nSitemap: ${config.baseUrl}/sitemap.xml\n`));
+ // Crawl and machine-readability artifacts, built from openvibe-shared/seo — the same
+ // toolkit the other OpenVibe sites use. Public data only, never the viewer.
+ r.get('/robots.txt', (req, res) => res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(seo.robotsTxt({
+ sitemaps: [`${config.baseUrl}/sitemap.xml`],
+ disallow: ['/dashboard', '/receipts', '/overlay/', '/moderate/', '/auth/', '/api/', '/internal/'],
+ allowAI: false,
+ })));
r.get('/sitemap.xml', async (req, res) => {
r.get('/sitemap.xml', async (req, res) => {
const rows = await db.many(sql`SELECT handle, updated_at FROM creator_tip_profiles WHERE page_enabled ORDER BY handle`);
const rows = await db.many(sql`SELECT handle, updated_at FROM creator_tip_profiles WHERE page_enabled ORDER BY handle`);
- const urls = [`<url><loc>${esc(config.baseUrl)}/</loc></url>`, ...rows.map((p) => `<url><loc>${esc(`${config.baseUrl}/${p.handle}`)}</loc><lastmod>${esc(p.updated_at.slice(0, 10))}</lastmod></url>`)];
- res.type('application/xml').set('Cache-Control', 'public, max-age=900').send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls.join('')}</urlset>\n`);
+ const urls = [{ loc: `${config.baseUrl}/`, lastmod: BOOT_AT }, ...rows.map((p) => ({ loc: `${config.baseUrl}/${p.handle}`, lastmod: p.updated_at }))];
+ res.type('application/xml').set('Cache-Control', 'public, max-age=900').send(seo.sitemapXml(urls));
});
});
+ // /llms.txt (llmstxt.org): a plain-markdown map of the site for language-model crawlers.
+ r.get('/llms.txt', (req, res) => res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(seo.llmsTxt({
+ name: 'OpenVibe.Tips',
+ summary: 'OpenVibe.Tips: creator support — tips, goals, paid messages, TTS and media requests, and overlays.',
+ details: 'A creator page lists the goals and a tip form that works without JavaScript; tips settle through Billing, and Tips owns the interaction, the goals and the overlay alerts. The home page lists creators whose pages are switched on. Reading any public page needs no account; tipping needs a signed-in supporter, and the dashboard, receipts, moderation, overlays, sign-in and the API are per-person and never listed here.',
+ sections: [
+ { title: 'Start here', links: [
+ { title: 'Creators on OpenVibe.Tips', url: `${config.baseUrl}/`, note: 'every creator with their page switched on' },
+ { title: 'What shipped on OpenVibe.Tips', url: `${config.baseUrl}/updates` },
+ ] },
+ { title: 'Machine-readable', links: [
+ { title: 'Sitemap', url: `${config.baseUrl}/sitemap.xml`, note: 'the public pages, with lastmod' },
+ { title: 'robots.txt', url: `${config.baseUrl}/robots.txt` },
+ ] },
+ ],
+ })));
// ── Receipts ─────────────────────────────────────────────
// ── Receipts ─────────────────────────────────────────────
/** The supporter's view of each receipt, with its creator's handle and name (one query for all creators). */
/** The supporter's view of each receipt, with its creator's handle and name (one query for all creators). */
@@ -69,13 +69,27 @@ const { boot, check, done } = require('./helpers/app');
@@ -69,13 +69,27 @@ const { boot, check, done } = require('./helpers/app');
assert.match(own.text, /Your page is switched off/);
assert.match(own.text, /Your page is switched off/);
});
});
- await check('robots.txt and sitemap.xml list only switched-on pages', async () => {
+ await check('robots.txt, sitemap.xml and llms.txt: the shared seo kit, listing only switched-on pages', async () => {
const robots = await get('/robots.txt');
const robots = await get('/robots.txt');
- assert.match(robots.text, /Disallow: \/dashboard/);
- assert.match(robots.text, /Disallow: \/overlay\//);
+ assert.strictEqual(robots.status, 200);
+ assert.match(robots.headers.get('content-type'), /^text\/plain/);
+ assert.match(robots.text, /^Allow: \/$/m);
+ for (const path of ['/dashboard', '/receipts', '/overlay/', '/moderate/', '/auth/', '/api/', '/internal/']) {
+ assert.ok(robots.text.includes(`Disallow: ${path}\n`), `robots keeps ${path} private`);
+ }
+ assert.match(robots.text, /Sitemap: http:\/\/tips\.test\/sitemap\.xml\n/, 'robots names the sitemap');
const sm = await get('/sitemap.xml');
const sm = await get('/sitemap.xml');
+ assert.strictEqual(sm.status, 200);
+ assert.match(sm.headers.get('content-type'), /^application\/xml/);
+ assert.match(sm.text, /<loc>http:\/\/tips\.test\/<\/loc>/, 'the sitemap lists the home URL');
assert.match(sm.text, /<loc>http:\/\/tips\.test\/alex<\/loc>/);
assert.match(sm.text, /<loc>http:\/\/tips\.test\/alex<\/loc>/);
+ assert.match(sm.text, /<lastmod>\d{4}-\d{2}-\d{2}<\/lastmod>/, 'every url carries a real lastmod');
assert.ok(!sm.text.includes('/bob<'));
assert.ok(!sm.text.includes('/bob<'));
+ const llms = await get('/llms.txt');
+ assert.strictEqual(llms.status, 200);
+ assert.match(llms.headers.get('content-type'), /^text\/plain/);
+ assert.match(llms.text, /^# /, 'llms.txt starts with a markdown heading');
+ assert.match(llms.text, /\[Creators on OpenVibe\.Tips\]\(http:\/\/tips\.test\/\)/, 'llms.txt lists the main public pages');
});
});
await check('tipping without JavaScript: sign-in first, then a form post settles and lands on the receipt', async () => {
await check('tipping without JavaScript: sign-in first, then a form post settles and lands on the receipt', async () => {