openvibers/openvibe.meme Commits
Agent layer: /.well-known/agent.json and Markdown twins of the public pages (#2)
Changes
nnew file mode 100644@@@ -0,0 +1,17 @@++'use strict';++// What openvibe.meme tells an agent about itself (openvibe-shared/agent-layer): /.well-known/agent.json.++// Every field is something this site really serves: the JSON API under /api/v1, the developers page that++// documents it, and the app's own per-address limits. No OpenAPI document or MCP endpoint is published.++module.exports = {++ id: 'meme',++ name: 'OpenVibe.Meme',++ purpose: 'The open meme database, rebuilt with AI: every entry cites the moment it happened, follows its later uses, and people vote for meme of the week.',++ home: 'https://openvibe.meme',++ site: 'openvibe.meme',++ api: '/api/v1',++ docs: '/developers',++ policy: {++ auth: ['none', 'Network OIDC'],++ rateLimit: '300 page requests a minute per address; 240 API requests a minute; sign-in limited to 60 attempts per 15 minutes',++ },++};nnew file mode 100644@@@ -0,0 +1,104 @@++'use strict';++// The public pages the agent layer serves as Markdown twins (openvibe-shared/agent-layer), rendered through the++// same views and layout as the HTML routes and always signed out: the entry report form, the week vote and a++// caption's owner controls never appear in a twin, and a hidden entry or caption has no twin at all.++const seo = require('openvibe-shared/seo');++const views = require('./views');++const { raw } = require('./render/html');++const { renderPage } = require('./render/layout');++const { homeJsonLd } = require('./http/discovery');++const { currentWeek } = require('./week');++++function createAgentPages(ctx) {++ const { config, s, entries } = ctx;++ const base = () => String(config.baseUrl).replace(/\/+$/, '');++ const page = (path, o) => ({ html: renderPage({ viewer: { kind: 'anonymous' }, config, path, agentLayer: true, ...o }), title: o.title });++++ async function counts() {++ const rows = await s.db.many("SELECT COUNT(*)::int AS entries, (SELECT COUNT(*)::int FROM meme_citations c JOIN meme_entries e ON e.id = c.entry_id WHERE e.visibility = 'public') AS citations FROM meme_entries WHERE visibility = 'public'");++ return rows[0] || { entries: 0, citations: 0 };++ }++++ async function home() {++ const { week } = currentWeek(s.now());++ const [trending, weekOut, c] = await Promise.all([entries.trending({ limit: 8 }), entries.week({ week }), counts()]);++ return page('/', {++ index: true, title: null,++ description: 'The open meme database, rebuilt with AI: every entry cites the moment it happened, follows its later uses, and people vote for meme of the week.',++ jsonLd: homeJsonLd(config),++ body: raw(views.home({ trending, week: weekOut, counts: c })),++ });++ }++++ async function make(query) {++ const templates = await entries.templates();++ const values = { template_id: query.get('template_id') || '', visibility: query.get('visibility') || 'public' };++ for (const i of [1, 2, 3, 4]) values[`line${i}`] = String(query.get(`line${i}`) || '').slice(0, 120);++ const chosen = templates.find((t) => t.id === values.template_id) || templates[0] || null;++ const previewLines = [1, 2, 3, 4].map((i) => String(values[`line${i}`] || '').replace(/\s+/g, ' ').trim()).filter(Boolean);++ const preview = chosen && previewLines.length ? { template: chosen, lines: previewLines } : null;++ return page('/make', {++ index: true, title: 'Make a caption',++ description: 'Pick a template, type up to four lines and preview them over the image before saving.',++ body: raw(views.make({ templates, values: { ...values, template_id: chosen ? chosen.id : '' }, preview, signedIn: false, problem: null })),++ });++ }++++ async function renderPublicPage(rawPath, { query } = {}) {++ const p = String(rawPath || '/').replace(/\/+$/, '') || '/';++ const q = query instanceof URLSearchParams ? query : new URLSearchParams();++ if (p === '/') return home();++ if (p === '/week') {++ const { week } = currentWeek(s.now());++ const out = await entries.week({ week });++ return page(p, {++ index: true, title: 'Meme of the week',++ description: 'This ISO week’s candidates for meme of the week and the people’s vote.',++ body: raw(views.week({ out, signedIn: false, voted: false, error: false })),++ });++ }++ if (p === '/make') return make(q);++ if (p === '/developers') {++ return page(p, {++ index: true, title: 'The OpenVibe.Meme API',++ description: 'The JSON API, the Search document and the public sources the importer reads.',++ jsonLd: [seo.jsonLd.webPage({ name: 'The OpenVibe.Meme API', url: `${base()}/developers`, description: 'The JSON API, the Search document and the public sources the importer reads.', siteUrl: base() })],++ body: raw(views.developers()),++ });++ }++ const entryMatch = p.match(/^\/m\/([^/]+)$/);++ if (entryMatch) {++ let slug;++ try { slug = decodeURIComponent(entryMatch[1]); } catch { return null; }++ const row = await entries.bySlug(slug);++ if (!row || row.visibility !== 'public') return null;++ const citations = await entries.citations(row.id);++ const e = entries.wireEntry(row);++ return page(p, {++ index: true, title: e.title,++ description: (e.summary || `${e.title} on OpenVibe.Meme`).slice(0, 160),++ jsonLd: [seo.jsonLd.webPage({ name: e.title, url: `${base()}/m/${e.slug}`, description: (e.summary || `${e.title} on OpenVibe.Meme`).slice(0, 160), siteUrl: base() })],++ body: raw(views.entry({ e, citations, signedIn: false, staff: false, reported: false, problem: null })),++ });++ }++ const captionMatch = p.match(/^\/c\/([^/]+)$/);++ if (captionMatch) {++ let id;++ try { id = decodeURIComponent(captionMatch[1]); } catch { return null; }++ const c = await entries.captionById(id);++ if (!c || c.visibility !== 'public') return null;++ const template = await entries.templateById(c.template_id);++ return page(p, {++ index: true,++ title: template ? `A caption on ${template.name}` : 'A saved caption',++ description: c.lines.join(' / ').slice(0, 160),++ body: raw(views.caption({ caption: c, template, mine: false })),++ });++ }++ return null;++ }++++ return { renderPublicPage };++}++++module.exports = { createAgentPages };@@@ -196,6 +196,22 @@ async function createApp(opts = {}) {// ── Pages ───────────────────────────────────────────────app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.MEME_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));++ // ── Agent layer (openvibe-shared/agent-layer) ───────────++ // The manifest and the Markdown twins of the public pages. Gated to twin requests only, so a browser and a++ // page drawn for a signed-in person always reach the site's own routes; the twin renderer is signed out.++ {++ const agentLayer = require('openvibe-shared/agent-layer');++ const agent = express.Router();++ agent.use((req, _res, next) => {++ const twinPath = req.path.endsWith('.md');++ const twinFormat = req.query.format === 'markdown';++ const twinAccept = !!(req.headers.accept && req.accepts(['html', 'text/markdown']) === 'text/markdown');++ if (req.path === '/.well-known/agent.json' || twinPath || twinFormat || twinAccept) return next();++ return next('router');++ });++ agentLayer.mount(agent, { descriptor: require('./agent-descriptor'), render: require('./agent-pages').createAgentPages(ctx).renderPublicPage });++ app.use(agent);++ }app.use(createPageRoutes(ctx));app.use((req, res) => send(res, 404, {viewer: req.viewer, config, path: req.originalUrl, title: 'Not found',@@@ -52,7 +52,8 @@ function createDiscoveryRoutes(ctx) {r.get('/robots.txt', (_req, res) => {res.type('text/plain').set('Cache-Control', TEXT).send('# openvibe.meme: the public entries are for search and AI crawlers; sign-in, the API and the internal route are not.\n'-- + seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));++ + seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW })++ + `# Agent manifest: ${abs('/.well-known/agent.json')}\n`);});r.get('/llms.txt', (_req, res) => {@@@ -47,7 +47,7 @@ function createPageRoutes(ctx) {const { week } = currentWeek(s.now());const [trending, weekOut, c] = await Promise.all([entries.trending({ limit: 8 }), entries.week({ week }), counts()]);return page(req, res, {-- index: true, cache: PUBLIC_CACHE,++ index: true, cache: PUBLIC_CACHE, agentLayer: true,title: null,description: 'The open meme database, rebuilt with AI: every entry cites the moment it happened, follows its later uses, and people vote for meme of the week.',jsonLd: homeJsonLd(config),@@@ -85,6 +85,7 @@ function createPageRoutes(ctx) {});return page(req, res, {index: e.visibility === 'public',++ agentLayer: e.visibility === 'public',cache: e.visibility === 'public' ? PUBLIC_CACHE : null,title: e.title,description,@@@ -116,7 +117,7 @@ function createPageRoutes(ctx) {const subject = signedIn(req) ? req.viewer.subject : null;const out = await entries.week({ week, subject });return page(req, res, {-- index: true, cache: PUBLIC_CACHE,++ index: true, cache: PUBLIC_CACHE, agentLayer: true,title: 'Meme of the week',description: 'This ISO week’s candidates for meme of the week and the people’s vote.',body: raw(views.week({ out, signedIn: Boolean(subject), voted: req.query.voted === '1', error: req.query.error === '1' })),@@@ -146,7 +147,7 @@ function createPageRoutes(ctx) {const previewLines = LINES.map((i) => String(values[`line${i}`] || '').replace(/\s+/g, ' ').trim()).filter(Boolean);const preview = chosen && previewLines.length ? { template: chosen, lines: previewLines } : null;return page(req, res, {-- index: true, cache: PUBLIC_CACHE,++ index: true, cache: PUBLIC_CACHE, agentLayer: true,title: 'Make a caption',description: 'Pick a template, type up to four lines and preview them over the image before saving.',body: raw(views.make({ templates, values: { ...values, template_id: chosen ? chosen.id : '' }, preview, signedIn: Boolean(signedIn(req)), problem })),@@@ -172,6 +173,7 @@ function createPageRoutes(ctx) {const template = await entries.templateById(c.template_id);return page(req, res, {index: c.visibility === 'public',++ agentLayer: c.visibility === 'public',cache: c.visibility === 'public' ? PUBLIC_CACHE : null,title: template ? `A caption on ${template.name}` : 'A saved caption',description: c.lines.join(' / ').slice(0, 160),@@@ -181,7 +183,7 @@ function createPageRoutes(ctx) {// ── Developers ──────────────────────────────────────────r.get('/developers', (req, res) => page(req, res, {-- index: true, cache: PUBLIC_CACHE,++ index: true, cache: PUBLIC_CACHE, agentLayer: true,title: 'The OpenVibe.Meme API',description: 'The JSON API, the Search document and the public sources the importer reads.',jsonLd: [seo.jsonLd.webPage({ name: 'The OpenVibe.Meme API', url: `${base()}/developers`, description: 'The JSON API, the Search document and the public sources the importer reads.', siteUrl: base() })],@@@ -74,6 +74,7 @@ function renderPage(o) {siteName: SITE_NAME,description: o.description || `${SITE_NAME} — ${TAGLINE}`,canonical,++ agentLayer: Boolean(o.agentLayer),robots: o.index ? 'index, follow' : 'noindex, nofollow',jsonLd: (o.jsonLd || []).filter(Boolean),home: '/',@@@ -112,6 +113,10 @@ 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');++ if (o.agentLayer) {++ const at = `${o.config.baseUrl}${String(o.path || '/').split('?')[0]}`;++ res.set('Link', require('openvibe-shared/agent-layer').linkHeaderTags(at).header);++ }res.status(status).type('html').send(renderPage(o));}nnew file mode 100644@@@ -0,0 +1,55 @@++'use strict';++/** The agent layer: the manifest and the Markdown twins of the public pages, rendered through the same views. */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ try {++ await check('manifest is JSON with only the endpoints Meme really serves', async () => {++ const r = await t.get('/.well-known/agent.json');++ assert.strictEqual(r.status, 200);++ assert.match(r.headers.get('content-type'), /application\/json/);++ const m = r.json();++ assert.strictEqual(m.id, 'meme');++ assert.strictEqual(m.home, 'https://openvibe.meme');++ assert.strictEqual(m.api, 'https://openvibe.meme/api/v1');++ assert.strictEqual(m.docs, 'https://openvibe.meme/developers');++ assert.deepStrictEqual(m.policy.auth, ['none', 'Network OIDC']);++ assert.strictEqual(m.openapi, undefined);++ assert.strictEqual(m.mcp, undefined);++ });++++ await check('the home names its twin in the head and the Link header', async () => {++ const r = await t.get('/');++ assert.strictEqual(r.status, 200);++ assert.ok(r.text.includes('<link rel="alternate" type="text/markdown" href="https://openvibe.meme/index.md">'));++ assert.match(r.headers.get('link'), /https:\/\/openvibe\.meme\/index\.md>; rel="alternate"; type="text\/markdown"/);++ });++++ await check('/index.md and content twins are Markdown of the same pages; unknown twins are 404', async () => {++ const home = await t.get('/index.md');++ assert.strictEqual(home.status, 200);++ assert.match(home.headers.get('content-type'), /text\/markdown/);++ assert.ok(home.text.startsWith('---\ntitle:'));++ for (const [path, heading] of [['/developers.md', '# The OpenVibe.Meme API'], ['/week.md', '# Meme of the week'], ['/make.md', '# Make a caption']]) {++ const r = await t.get(path);++ assert.strictEqual(r.status, 200, path);++ assert.ok(r.text.includes(heading), `${path} missing ${heading}`);++ }++ assert.strictEqual((await t.get('/no-such-page.md')).status, 404);++ });++++ await check('/.well-known/security.txt is not swallowed by the layer, and robots names the manifest', async () => {++ const sec = await t.get('/.well-known/security.txt');++ assert.strictEqual(sec.status, 404);++ assert.doesNotMatch(sec.headers.get('content-type') || '', /application\/json/);++ const robots = await t.get('/robots.txt');++ assert.strictEqual(robots.status, 200);++ assert.ok(robots.text.includes('# Agent manifest: https://openvibe.meme/.well-known/agent.json'));++ });++ } finally {++ await t.close();++ done();++ }++})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
+'use strict';
+// What openvibe.meme tells an agent about itself (openvibe-shared/agent-layer): /.well-known/agent.json.
+// Every field is something this site really serves: the JSON API under /api/v1, the developers page that
+// documents it, and the app's own per-address limits. No OpenAPI document or MCP endpoint is published.
+module.exports = {
+ id: 'meme',
+ name: 'OpenVibe.Meme',
+ purpose: 'The open meme database, rebuilt with AI: every entry cites the moment it happened, follows its later uses, and people vote for meme of the week.',
+ home: 'https://openvibe.meme',
+ site: 'openvibe.meme',
+ api: '/api/v1',
+ docs: '/developers',
+ policy: {
+ auth: ['none', 'Network OIDC'],
+ rateLimit: '300 page requests a minute per address; 240 API requests a minute; sign-in limited to 60 attempts per 15 minutes',
+ },
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,104 @@
@@ -0,0 +1,104 @@
+'use strict';
+// The public pages the agent layer serves as Markdown twins (openvibe-shared/agent-layer), rendered through the
+// same views and layout as the HTML routes and always signed out: the entry report form, the week vote and a
+// caption's owner controls never appear in a twin, and a hidden entry or caption has no twin at all.
+const seo = require('openvibe-shared/seo');
+const views = require('./views');
+const { raw } = require('./render/html');
+const { renderPage } = require('./render/layout');
+const { homeJsonLd } = require('./http/discovery');
+const { currentWeek } = require('./week');
+
+function createAgentPages(ctx) {
+ const { config, s, entries } = ctx;
+ const base = () => String(config.baseUrl).replace(/\/+$/, '');
+ const page = (path, o) => ({ html: renderPage({ viewer: { kind: 'anonymous' }, config, path, agentLayer: true, ...o }), title: o.title });
+
+ async function counts() {
+ const rows = await s.db.many("SELECT COUNT(*)::int AS entries, (SELECT COUNT(*)::int FROM meme_citations c JOIN meme_entries e ON e.id = c.entry_id WHERE e.visibility = 'public') AS citations FROM meme_entries WHERE visibility = 'public'");
+ return rows[0] || { entries: 0, citations: 0 };
+ }
+
+ async function home() {
+ const { week } = currentWeek(s.now());
+ const [trending, weekOut, c] = await Promise.all([entries.trending({ limit: 8 }), entries.week({ week }), counts()]);
+ return page('/', {
+ index: true, title: null,
+ description: 'The open meme database, rebuilt with AI: every entry cites the moment it happened, follows its later uses, and people vote for meme of the week.',
+ jsonLd: homeJsonLd(config),
+ body: raw(views.home({ trending, week: weekOut, counts: c })),
+ });
+ }
+
+ async function make(query) {
+ const templates = await entries.templates();
+ const values = { template_id: query.get('template_id') || '', visibility: query.get('visibility') || 'public' };
+ for (const i of [1, 2, 3, 4]) values[`line${i}`] = String(query.get(`line${i}`) || '').slice(0, 120);
+ const chosen = templates.find((t) => t.id === values.template_id) || templates[0] || null;
+ const previewLines = [1, 2, 3, 4].map((i) => String(values[`line${i}`] || '').replace(/\s+/g, ' ').trim()).filter(Boolean);
+ const preview = chosen && previewLines.length ? { template: chosen, lines: previewLines } : null;
+ return page('/make', {
+ index: true, title: 'Make a caption',
+ description: 'Pick a template, type up to four lines and preview them over the image before saving.',
+ body: raw(views.make({ templates, values: { ...values, template_id: chosen ? chosen.id : '' }, preview, signedIn: false, problem: null })),
+ });
+ }
+
+ async function renderPublicPage(rawPath, { query } = {}) {
+ const p = String(rawPath || '/').replace(/\/+$/, '') || '/';
+ const q = query instanceof URLSearchParams ? query : new URLSearchParams();
+ if (p === '/') return home();
+ if (p === '/week') {
+ const { week } = currentWeek(s.now());
+ const out = await entries.week({ week });
+ return page(p, {
+ index: true, title: 'Meme of the week',
+ description: 'This ISO week’s candidates for meme of the week and the people’s vote.',
+ body: raw(views.week({ out, signedIn: false, voted: false, error: false })),
+ });
+ }
+ if (p === '/make') return make(q);
+ if (p === '/developers') {
+ return page(p, {
+ index: true, title: 'The OpenVibe.Meme API',
+ description: 'The JSON API, the Search document and the public sources the importer reads.',
+ jsonLd: [seo.jsonLd.webPage({ name: 'The OpenVibe.Meme API', url: `${base()}/developers`, description: 'The JSON API, the Search document and the public sources the importer reads.', siteUrl: base() })],
+ body: raw(views.developers()),
+ });
+ }
+ const entryMatch = p.match(/^\/m\/([^/]+)$/);
+ if (entryMatch) {
+ let slug;
+ try { slug = decodeURIComponent(entryMatch[1]); } catch { return null; }
+ const row = await entries.bySlug(slug);
+ if (!row || row.visibility !== 'public') return null;
+ const citations = await entries.citations(row.id);
+ const e = entries.wireEntry(row);
+ return page(p, {
+ index: true, title: e.title,
+ description: (e.summary || `${e.title} on OpenVibe.Meme`).slice(0, 160),
+ jsonLd: [seo.jsonLd.webPage({ name: e.title, url: `${base()}/m/${e.slug}`, description: (e.summary || `${e.title} on OpenVibe.Meme`).slice(0, 160), siteUrl: base() })],
+ body: raw(views.entry({ e, citations, signedIn: false, staff: false, reported: false, problem: null })),
+ });
+ }
+ const captionMatch = p.match(/^\/c\/([^/]+)$/);
+ if (captionMatch) {
+ let id;
+ try { id = decodeURIComponent(captionMatch[1]); } catch { return null; }
+ const c = await entries.captionById(id);
+ if (!c || c.visibility !== 'public') return null;
+ const template = await entries.templateById(c.template_id);
+ return page(p, {
+ index: true,
+ title: template ? `A caption on ${template.name}` : 'A saved caption',
+ description: c.lines.join(' / ').slice(0, 160),
+ body: raw(views.caption({ caption: c, template, mine: false })),
+ });
+ }
+ return null;
+ }
+
+ return { renderPublicPage };
+}
+
+module.exports = { createAgentPages };
@@ -196,6 +196,22 @@ async function createApp(opts = {}) {
@@ -196,6 +196,22 @@ async function createApp(opts = {}) {
// ── Pages ───────────────────────────────────────────────
// ── Pages ───────────────────────────────────────────────
app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.MEME_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.MEME_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
+ // ── Agent layer (openvibe-shared/agent-layer) ───────────
+ // The manifest and the Markdown twins of the public pages. Gated to twin requests only, so a browser and a
+ // page drawn for a signed-in person always reach the site's own routes; the twin renderer is signed out.
+ {
+ const agentLayer = require('openvibe-shared/agent-layer');
+ const agent = express.Router();
+ agent.use((req, _res, next) => {
+ const twinPath = req.path.endsWith('.md');
+ const twinFormat = req.query.format === 'markdown';
+ const twinAccept = !!(req.headers.accept && req.accepts(['html', 'text/markdown']) === 'text/markdown');
+ if (req.path === '/.well-known/agent.json' || twinPath || twinFormat || twinAccept) return next();
+ return next('router');
+ });
+ agentLayer.mount(agent, { descriptor: require('./agent-descriptor'), render: require('./agent-pages').createAgentPages(ctx).renderPublicPage });
+ app.use(agent);
+ }
app.use(createPageRoutes(ctx));
app.use(createPageRoutes(ctx));
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',
@@ -52,7 +52,8 @@ function createDiscoveryRoutes(ctx) {
@@ -52,7 +52,8 @@ 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.meme: the public entries are for search and AI crawlers; sign-in, the API and the internal route are not.\n'
'# openvibe.meme: the public entries are for search and AI crawlers; sign-in, the API and the internal route are not.\n'
- + seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));
+ + seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW })
+ + `# Agent manifest: ${abs('/.well-known/agent.json')}\n`);
});
});
r.get('/llms.txt', (_req, res) => {
r.get('/llms.txt', (_req, res) => {
@@ -47,7 +47,7 @@ function createPageRoutes(ctx) {
@@ -47,7 +47,7 @@ function createPageRoutes(ctx) {
const { week } = currentWeek(s.now());
const { week } = currentWeek(s.now());
const [trending, weekOut, c] = await Promise.all([entries.trending({ limit: 8 }), entries.week({ week }), counts()]);
const [trending, weekOut, c] = await Promise.all([entries.trending({ limit: 8 }), entries.week({ week }), counts()]);
return page(req, res, {
return page(req, res, {
- index: true, cache: PUBLIC_CACHE,
+ index: true, cache: PUBLIC_CACHE, agentLayer: true,
title: null,
title: null,
description: 'The open meme database, rebuilt with AI: every entry cites the moment it happened, follows its later uses, and people vote for meme of the week.',
description: 'The open meme database, rebuilt with AI: every entry cites the moment it happened, follows its later uses, and people vote for meme of the week.',
jsonLd: homeJsonLd(config),
jsonLd: homeJsonLd(config),
@@ -85,6 +85,7 @@ function createPageRoutes(ctx) {
@@ -85,6 +85,7 @@ function createPageRoutes(ctx) {
});
});
return page(req, res, {
return page(req, res, {
index: e.visibility === 'public',
index: e.visibility === 'public',
+ agentLayer: e.visibility === 'public',
cache: e.visibility === 'public' ? PUBLIC_CACHE : null,
cache: e.visibility === 'public' ? PUBLIC_CACHE : null,
title: e.title,
title: e.title,
description,
description,
@@ -116,7 +117,7 @@ function createPageRoutes(ctx) {
@@ -116,7 +117,7 @@ function createPageRoutes(ctx) {
const subject = signedIn(req) ? req.viewer.subject : null;
const subject = signedIn(req) ? req.viewer.subject : null;
const out = await entries.week({ week, subject });
const out = await entries.week({ week, subject });
return page(req, res, {
return page(req, res, {
- index: true, cache: PUBLIC_CACHE,
+ index: true, cache: PUBLIC_CACHE, agentLayer: true,
title: 'Meme of the week',
title: 'Meme of the week',
description: 'This ISO week’s candidates for meme of the week and the people’s vote.',
description: 'This ISO week’s candidates for meme of the week and the people’s vote.',
body: raw(views.week({ out, signedIn: Boolean(subject), voted: req.query.voted === '1', error: req.query.error === '1' })),
body: raw(views.week({ out, signedIn: Boolean(subject), voted: req.query.voted === '1', error: req.query.error === '1' })),
@@ -146,7 +147,7 @@ function createPageRoutes(ctx) {
@@ -146,7 +147,7 @@ function createPageRoutes(ctx) {
const previewLines = LINES.map((i) => String(values[`line${i}`] || '').replace(/\s+/g, ' ').trim()).filter(Boolean);
const previewLines = LINES.map((i) => String(values[`line${i}`] || '').replace(/\s+/g, ' ').trim()).filter(Boolean);
const preview = chosen && previewLines.length ? { template: chosen, lines: previewLines } : null;
const preview = chosen && previewLines.length ? { template: chosen, lines: previewLines } : null;
return page(req, res, {
return page(req, res, {
- index: true, cache: PUBLIC_CACHE,
+ index: true, cache: PUBLIC_CACHE, agentLayer: true,
title: 'Make a caption',
title: 'Make a caption',
description: 'Pick a template, type up to four lines and preview them over the image before saving.',
description: 'Pick a template, type up to four lines and preview them over the image before saving.',
body: raw(views.make({ templates, values: { ...values, template_id: chosen ? chosen.id : '' }, preview, signedIn: Boolean(signedIn(req)), problem })),
body: raw(views.make({ templates, values: { ...values, template_id: chosen ? chosen.id : '' }, preview, signedIn: Boolean(signedIn(req)), problem })),
@@ -172,6 +173,7 @@ function createPageRoutes(ctx) {
@@ -172,6 +173,7 @@ function createPageRoutes(ctx) {
const template = await entries.templateById(c.template_id);
const template = await entries.templateById(c.template_id);
return page(req, res, {
return page(req, res, {
index: c.visibility === 'public',
index: c.visibility === 'public',
+ agentLayer: c.visibility === 'public',
cache: c.visibility === 'public' ? PUBLIC_CACHE : null,
cache: c.visibility === 'public' ? PUBLIC_CACHE : null,
title: template ? `A caption on ${template.name}` : 'A saved caption',
title: template ? `A caption on ${template.name}` : 'A saved caption',
description: c.lines.join(' / ').slice(0, 160),
description: c.lines.join(' / ').slice(0, 160),
@@ -181,7 +183,7 @@ function createPageRoutes(ctx) {
@@ -181,7 +183,7 @@ function createPageRoutes(ctx) {
// ── Developers ──────────────────────────────────────────
// ── Developers ──────────────────────────────────────────
r.get('/developers', (req, res) => page(req, res, {
r.get('/developers', (req, res) => page(req, res, {
- index: true, cache: PUBLIC_CACHE,
+ index: true, cache: PUBLIC_CACHE, agentLayer: true,
title: 'The OpenVibe.Meme API',
title: 'The OpenVibe.Meme API',
description: 'The JSON API, the Search document and the public sources the importer reads.',
description: 'The JSON API, the Search document and the public sources the importer reads.',
jsonLd: [seo.jsonLd.webPage({ name: 'The OpenVibe.Meme API', url: `${base()}/developers`, description: 'The JSON API, the Search document and the public sources the importer reads.', siteUrl: base() })],
jsonLd: [seo.jsonLd.webPage({ name: 'The OpenVibe.Meme API', url: `${base()}/developers`, description: 'The JSON API, the Search document and the public sources the importer reads.', siteUrl: base() })],
@@ -74,6 +74,7 @@ function renderPage(o) {
@@ -74,6 +74,7 @@ function renderPage(o) {
siteName: SITE_NAME,
siteName: SITE_NAME,
description: o.description || `${SITE_NAME} — ${TAGLINE}`,
description: o.description || `${SITE_NAME} — ${TAGLINE}`,
canonical,
canonical,
+ agentLayer: Boolean(o.agentLayer),
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: '/',
@@ -112,6 +113,10 @@ function send(res, status, o) {
@@ -112,6 +113,10 @@ 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');
+ if (o.agentLayer) {
+ const at = `${o.config.baseUrl}${String(o.path || '/').split('?')[0]}`;
+ res.set('Link', require('openvibe-shared/agent-layer').linkHeaderTags(at).header);
+ }
res.status(status).type('html').send(renderPage(o));
res.status(status).type('html').send(renderPage(o));
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,55 @@
@@ -0,0 +1,55 @@
+'use strict';
+/** The agent layer: the manifest and the Markdown twins of the public pages, rendered through the same views. */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ try {
+ await check('manifest is JSON with only the endpoints Meme really serves', async () => {
+ const r = await t.get('/.well-known/agent.json');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers.get('content-type'), /application\/json/);
+ const m = r.json();
+ assert.strictEqual(m.id, 'meme');
+ assert.strictEqual(m.home, 'https://openvibe.meme');
+ assert.strictEqual(m.api, 'https://openvibe.meme/api/v1');
+ assert.strictEqual(m.docs, 'https://openvibe.meme/developers');
+ assert.deepStrictEqual(m.policy.auth, ['none', 'Network OIDC']);
+ assert.strictEqual(m.openapi, undefined);
+ assert.strictEqual(m.mcp, undefined);
+ });
+
+ await check('the home names its twin in the head and the Link header', async () => {
+ const r = await t.get('/');
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.text.includes('<link rel="alternate" type="text/markdown" href="https://openvibe.meme/index.md">'));
+ assert.match(r.headers.get('link'), /https:\/\/openvibe\.meme\/index\.md>; rel="alternate"; type="text\/markdown"/);
+ });
+
+ await check('/index.md and content twins are Markdown of the same pages; unknown twins are 404', async () => {
+ const home = await t.get('/index.md');
+ assert.strictEqual(home.status, 200);
+ assert.match(home.headers.get('content-type'), /text\/markdown/);
+ assert.ok(home.text.startsWith('---\ntitle:'));
+ for (const [path, heading] of [['/developers.md', '# The OpenVibe.Meme API'], ['/week.md', '# Meme of the week'], ['/make.md', '# Make a caption']]) {
+ const r = await t.get(path);
+ assert.strictEqual(r.status, 200, path);
+ assert.ok(r.text.includes(heading), `${path} missing ${heading}`);
+ }
+ assert.strictEqual((await t.get('/no-such-page.md')).status, 404);
+ });
+
+ await check('/.well-known/security.txt is not swallowed by the layer, and robots names the manifest', async () => {
+ const sec = await t.get('/.well-known/security.txt');
+ assert.strictEqual(sec.status, 404);
+ assert.doesNotMatch(sec.headers.get('content-type') || '', /application\/json/);
+ const robots = await t.get('/robots.txt');
+ assert.strictEqual(robots.status, 200);
+ assert.ok(robots.text.includes('# Agent manifest: https://openvibe.meme/.well-known/agent.json'));
+ });
+ } finally {
+ await t.close();
+ done();
+ }
+})();