openvibers/openvibe.meme Commits

Rebuild Meme library and maker foundation (#123)

OP OpenVibers committed 4 days ago · a84a9ac4e5 · 23 files, +398 −237

Changes

ddiff --git a/README.md b/README.md
iindex 4e3548a..c9a43f2 100644
---- a/README.md
++++ b/README.md
@@@ -6,9 +6,11 @@
**Domain:** `openvibe.meme` · **Port:** 5120 · **Service id:** `meme` · **Env prefix:** `MEME`
**License:** AGPL-3.0-only (same as every OpenVibe service).
--OpenVibe.Meme turns OpenVibe's own running jokes into entries with a citation to the moment each one happened —
--a chat line, a VOD second, a Rundown segment — a timeline of later uses, a status (`rising`, `peaked`, `classic`,
--`dead`), a weekly vote and a caption generator on templates. Every public entry is also a Search Fabric document.
++OpenVibe.Meme is an open encyclopedia of internet memes and a maker attached to each entry. Pages explain the format,
++cite its origin and spread, connect its variations and related memes, and link to licensed templates. Members can
++make editable captions and submit examples for moderation. Known memes may show an OpenVibe.Live appearance when a
++transcript names that meme; OpenVibe-only catchphrase pages redirect to the homepage. Every public entry is also a
++Search Fabric document.
## Purpose
@@@ -16,9 +18,9 @@ a chat line, a VOD second, a Rundown segment — a timeline of later uses, a sta
the URL is canonical (OpenVibe.Video's `?t=` redirect is followed, e.g. `/v/6026?t=864` → `/v/6025?t=1541`),
and `quote_hash` is its sha256. A summary is stored as written (L24 has no looks filter); only an empty summary
falls back to the title. Verbatim quotes are never rewritten.
--- **Candidates stay hidden.** `server/importer.js` reads public Chat/Live APIs only, finds a 3–6 word phrase used at
-- least twice on two different days, attaches citations and inserts the entry `hidden`. It never publishes. A staff
-- person (Network role `global_mod` or above) publishes it with `POST /api/v1/entries/:slug/publish`.
++- **Live references stay attached to known memes.** `server/importer.js` reads public Chat/Live history and
++ transcripts, then attaches a dated use citation only when the text names an existing public internet-meme entry.
++ It never creates a meme from a stream catchphrase; the Chat global timeline is a paraphrase and is not used as a source.
- **A judge writes the entry.** `server/ai/judge.js` asks OpenVibe.AI to score each candidate from its own
citations — with the chat lines and transcript lines around each quote — and to write the entry from that material
only: a title in natural case, a short summary, where it started, how people use it and two or three example uses.
@@@ -33,8 +35,8 @@ a chat line, a VOD second, a Rundown segment — a timeline of later uses, a sta
- **The citation is checked again.** A daily job reads every public citation URL; a 404/410 marks it `gone`, a
401/403 marks it `hidden`, and an entry whose origin is gone is hidden (revision +1, Search tombstone).
- **Meme of the week.** People vote once per ISO week; a second vote changes the choice, never adds a ballot.
--- **The encyclopedia (M2).** An entry has a `scope` (`openvibe` for today's running jokes, `internet` for the
-- encyclopedia), an origin with a public citation, spread points (date, place, URL, count), named variations,
++- **The encyclopedia (M2).** An entry has a `scope` (`openvibe` for legacy OpenVibe-only phrases, which no longer
++ have public meme pages, `internet` for the encyclopedia), an origin with a public citation, spread points (date, place, URL, count), named variations,
Media examples, meaning and usage, L1 labels (`nudity`, `language`, `graphic`, `18+`, `flashing`, `spoilers`),
related memes and a moderated revision history. A signed-in person proposes an entry or an edit; it stays
pending, and staff approve or reject it. Every public entry is still one Search Fabric document, now with
@@@ -142,6 +144,8 @@ also gets per-actor minute/hour limits (`MEME_LIMITS_MINUTE`, `MEME_LIMITS_HOUR`
| Route | What it does |
|---|---|
| `GET /entries?scope=&q=&status=&label=&kind=&sort=&window=&channel=&limit=&cursor=` | public entries; `scope=openvibe\|internet`; `q` matches title/summary/meaning/usage/slug; `label` is one L1 label; `sort=top&window=day\|week\|month` ranks by live uses (a cursor only on `newest`); `channel=<stream_id>` is Live's per-channel Memes block; `Cache-Control: public, max-age=60` |
++| `GET /memes?scope=internet&q=&sort=top&window=week` | internet-only meme-library alias; defaults to `scope=internet` |
++| `GET /memes/:slug` | one public internet meme with origin, spread, variations, examples, related memes, and revisions |
| `GET /entries/:slug` | one entry with `origin` (a citation, or the web origin), `web_origin`, citations, `timeline`, `examples`, `spread`, `variations`, `related` and approved `revisions`, and the judge's `notes` (where it started, how people use it, the examples); a hidden proposal is 404 unless its author or staff |
| `GET /entries/:slug/revisions` | the approved revision history; staff also see pending and rejected ones |
| `GET /entries/:slug/series?days=30` | uses per UTC day, zero-filled, 1–365 days |
@@@ -158,6 +162,7 @@ also gets per-actor minute/hour limits (`MEME_LIMITS_MINUTE`, `MEME_LIMITS_HOUR`
| `GET /week` | this ISO week's candidates (public entries with a live use that week) and their vote counts |
| `POST /week/vote` | signed in, same-origin; `{ entry_id }`; upsert per (`week`, subject) — one vote a week |
| `GET /templates` | the caption templates with their caption counts and `layout` |
++| `POST /render` | signed in; `{ template_id, lines }` returns a PNG from the local renderer without an AI call |
| `POST /generate` | signed in; `{ template_slug \| topic, lines? }`; researches current uses, writes captions, renders the template's layout as a PNG, stores it in Media and saves the caption. 201 returns `media_id`, `media_url`, `captions`, `template`, `labels`, `caption`; 503 `meme.ai_unavailable`/`meme.media_unavailable`; 429 `meme.ai_budget` |
| `POST /captions` | signed in; `{ template_id, lines, visibility? }`, 1–4 lines × 120 chars, blocked words refused |
| `GET /captions/:id` | one caption; a public read never returns the author's subject |
@@@ -272,22 +277,17 @@ The saved caption carries the labels and the `med_…` id. Two calls per request
## The importer
`server/importer.js` runs every 30 minutes (`MEME_IMPORTER_INTERVAL_MS`), off under `MEME_DRILL`; tests call
--`runOnce()`. It reads:
++`runOnce()`. It reads public sources only:
1. **Chat global history** — verbatim lines with ids and timestamps, each a usable citation.
--2. **Chat global timeline** — a model paraphrase with no message id, so it is a discovery hint only; it never
-- becomes a citation.
--3. **Rundown topics and segments** — every quote is verbatim speech with `vod_id` and a VOD second, with `said_at`.
--4. **VOD transcripts** — a segment is used only when the public VOD row gives a date; source part ids are
++2. **Rundown topics and segments** — every quote is verbatim speech with `vod_id` and a VOD second, with `said_at`.
++3. **VOD transcripts** — a segment is used only when the public VOD row gives a date; source part ids are
canonicalised through OpenVibe.Video before a citation is stored.
--A 3–6 word phrase that starts and ends with a content word, appears in at least `MEME_IMPORTER_MIN_USES` uses on at
--least `MEME_IMPORTER_MIN_DAYS` different days, and is not a substring of a stronger phrase becomes one candidate
--(at most `MEME_IMPORTER_MAX_CANDIDATES` per run). Its earliest use is `origin`, the rest are `use`, and the entry
--is inserted `hidden` with a `title_key` (lowercase, quotes and punctuation stripped, whitespace collapsed). A
--re-run, or a second phrase the judge titles the same way, enriches that one entry for its kind; two public entries
--cannot share the key (the partial unique index), and if duplicates are merged the old slug and id answer 301 to the
--kept entry. The importer never publishes. Uses naming the deny list are dropped before anything is written.
++The linker matches a transcript against public internet-meme titles, attaches matched moments as `use` citations,
++and extends the entry's first/last-use range. It does not create entries or rewrite the researched origin. The
++source URL remains canonical, uses naming the deny list are dropped, and repeated reads are deduplicated by the
++citation store. `MEME_IMPORTER_MAX_KNOWN_MEMES` limits how many public entries are checked per run.
## Citations and re-verification
@@@ -321,6 +321,8 @@ signed-in person is `private, no-store`.
| Page | What it is |
|---|---|
| `/` | trending, meme of the week, the generator teaser |
++| `/browse` | searchable public encyclopedia with collection, type, and usage filters (`/search` alias) |
++| `/memes` | permanent redirect to the internet-meme collection |
| `/m/:slug` | one entry: origin with its citation link, the use timeline, examples, and a report form when signed in |
| `/week` | the ballot (`POST /week/vote`) |
| `/make` | the generator form: template, 1–4 lines, a server-rendered preview (`GET /make`), save (`POST /make` → 303 `/c/:id`) |
@@@ -328,8 +330,8 @@ signed-in person is `private, no-store`.
| `/developers` | the API, the Search document, the importer's sources |
| 404 | the not-found page |
--`public/css/meme.css` and `public/js/meme.js` are one-comment placeholder files: the pages are complete without
--them, and the main session designs them. The templates' images are local data-free SVG placeholders (or an
++The pages remain server-rendered and readable without JavaScript; `public/js/meme.js` makes the caption preview
++update as the user types. The templates' images are local data-free SVG placeholders (or an
`openvibe.media` object when staff add a real one). `robots.txt`, `sitemap.xml`, `llms.txt` and `llms-full.txt` list
the public pages and every public entry.
ddiff --git a/public/css/meme.css b/public/css/meme.css
iindex e14e47c..5ead294 100644
---- a/public/css/meme.css
++++ b/public/css/meme.css
@@@ -68,6 +68,23 @@ html.ov-motion .mm-sticker { animation: mm-fan .7s cubic-bezier(.2,.9,.3,1.3) bo
.mm-entry-title { font-size: 1.05rem; font-weight: 800; line-height: 1.3; overflow-wrap: anywhere; }
.mm-entry-meta, .mm-entry-when { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; color: var(--text-secondary, #96a7c2); font-size: .84rem; }
.mm-entry-when { color: var(--text-muted, #7386a3); font-size: .8rem; }
++.mm-entry-summary { color: var(--text-secondary, #96a7c2); font-size: .9rem; line-height: 1.45; }
++.mm-library-search { display: grid; gap: 10px; width: min(100%, 520px); padding: 18px; border: 1px solid var(--border, #1f2d47); border-radius: 18px; background: var(--bg-card, #0f1729); }
++.mm-library-search > label { font-weight: 800; }
++.mm-library-search > div { display: flex; gap: 8px; }
++.mm-library-search input { min-width: 0; flex: 1; min-height: 44px; padding: 9px 12px; border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-input, var(--bg-primary, #0a0f1c)); color: var(--text-primary, #e6edf7); font: inherit; }
++.mm-library-search p { margin: 0; color: var(--text-muted, #7386a3); font-size: .84rem; }
++.mm-history-strip { list-style: none; display: grid; gap: 8px; width: min(100%, 520px); margin: 0; padding: 0; }
++.mm-history-strip li { display: grid; grid-template-columns: 90px 1fr; gap: 12px; align-items: center; padding: 12px 14px; border: 1px solid var(--border, #1f2d47); border-radius: 14px; background: var(--bg-card, #0f1729); }
++.mm-history-strip li b { color: var(--room-strong); }
++.mm-history-strip li span { color: var(--text-secondary, #96a7c2); font-size: .9rem; }
++.mm-rise-card { display: flex; align-items: center; gap: 14px; width: min(100%, 460px); padding: 18px; border: 1px solid var(--border, #1f2d47); border-radius: 18px; background: var(--bg-card, #0f1729); }
++.mm-rise-mark { display: grid; place-items: center; flex: 0 0 48px; width: 48px; height: 48px; border-radius: 14px; color: var(--room-on); background: var(--room-fill); font-size: 1.8rem; font-weight: 900; }
++.mm-rise-card p { margin: 4px 0 0; color: var(--text-secondary, #96a7c2); }
++.mm-browse-filters { display: grid; grid-template-columns: minmax(200px, 2fr) repeat(3, minmax(140px, 1fr)) auto; gap: 12px; align-items: end; margin: 20px 0 28px; }
++.mm-browse-filters .mm-btn { min-height: 44px; }
++#mm-browse .mm-section { margin-top: 24px; }
++#mm-browse .mm-entries { grid-template-columns: repeat(auto-fill, minmax(min(100%, 250px), 1fr)); }
/* Citations: the quote is the thing */
.mm-cite { margin: 0; padding: 16px 18px; border-radius: 16px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); border-left: 4px solid var(--room-accent); }
@@@ -119,6 +136,7 @@ html.ov-motion .mm-sticker { animation: mm-fan .7s cubic-bezier(.2,.9,.3,1.3) bo
.mm-templates { margin: 0; padding: 0; border: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }
.mm-templates legend { margin-bottom: 8px; font-size: .82rem; font-weight: 700; color: var(--text-secondary, #96a7c2); }
.mm-template { position: relative; display: grid; gap: 6px; padding: 8px; border-radius: 14px; border: 2px solid var(--border, #1f2d47); cursor: pointer; background: var(--bg-primary, #0a0f1c); }
++.mm-template[hidden] { display: none !important; }
.mm-template input { position: absolute; opacity: 0; pointer-events: none; }
.mm-template img { width: 100%; height: auto; aspect-ratio: 11 / 7; object-fit: cover; border-radius: 9px; display: block; }
.mm-template span { display: grid; gap: 1px; font-size: .84rem; }
@@@ -155,8 +173,20 @@ html.ov-motion .mm-demo-bars i { animation: mm-bar .9s cubic-bezier(.2,.7,.2,1)
/* On a phone the room's picture is a faint corner mark behind the title: the branches only, no words to read through. */
/* Phones keep the tree's moments and stickers, smaller (11-07: hidden, the tree read as bare lines). */
--@media (max-width: 759px) { .sc-room-pic .mm-node { font-size: .68rem; padding: 5px 9px; } .sc-room-pic .mm-node-root { max-width: 78%; white-space: normal; text-align: center; font-size: .8rem; } .sc-room-pic .mm-sticker { font-size: .64rem; padding: 3px 7px; } }
++@media (max-width: 759px) { .sc-room-pic .mm-node { font-size: .68rem; padding: 5px 9px; } .sc-room-pic .mm-node-root { max-width: 78%; white-space: normal; text-align: center; font-size: .8rem; } .sc-room-pic .mm-sticker { font-size: .64rem; padding: 3px 7px; } .mm-history-strip li { grid-template-columns: 70px 1fr; gap: 8px; padding: 11px; } }
@media (max-width: 860px) { .mm-make-grid { grid-template-columns: minmax(0, 1fr); } .mm-preview { position: static; order: -1; } }
++@media (max-width: 760px) {
++ .mm-browse-filters { grid-template-columns: repeat(2, minmax(0, 1fr)); }
++ .mm-browse-filters .mm-field:first-child { grid-column: 1 / -1; }
++ .mm-browse-filters > .mm-btn { grid-column: 1 / -1; min-height: 48px; }
++ .mm-make-grid { gap: 14px; }
++ .mm-make-grid > .mm-form { padding: 14px; }
++ .mm-templates { grid-template-columns: repeat(2, minmax(0, 1fr)); }
++ .mm-template { min-height: 44px; }
++ #mm-caption-form .mm-actions { position: static; padding: 8px 0; border: 0; background: transparent; }
++ #mm-caption-form .mm-actions .mm-btn { min-height: 48px; flex: 1; }
++}
++@media (max-width: 380px) { .mm-browse-filters { grid-template-columns: minmax(0, 1fr); } .mm-browse-filters .mm-field:first-child, .mm-browse-filters > .mm-btn { grid-column: auto; } }
@media (prefers-reduced-motion: reduce) { .mm-tree-lines path, .mm-sticker, .mm-demo-bars i { animation: none !important; } }
html[data-ov-motion=reduced] .mm-tree-lines path, html[data-ov-motion=reduced] .mm-sticker, html[data-ov-motion=reduced] .mm-demo-bars i { animation: none !important; }
ddiff --git a/public/js/meme.js b/public/js/meme.js
iindex ee46c11..a873969 100644
---- a/public/js/meme.js
++++ b/public/js/meme.js
@@@ -30,6 +30,41 @@
}
form.addEventListener('input', draw);
form.addEventListener('change', draw);
++ var filter = document.getElementById('mm-template-filter');
++ if (filter) filter.addEventListener('input', function () {
++ var query = filter.value.trim().toLocaleLowerCase();
++ form.querySelectorAll('.mm-template').forEach(function (card) {
++ card.hidden = query && !card.textContent.toLocaleLowerCase().includes(query);
++ });
++ });
++ var download = aside.querySelector('[data-render-png]');
++ var status = aside.querySelector('[data-render-status]');
++ if (download) download.addEventListener('click', async function () {
++ var chosen = form.querySelector('input[name=template_id]:checked');
++ if (!chosen) { status.textContent = 'Choose a template first.'; return; }
++ download.disabled = true;
++ status.textContent = 'Rendering your PNG…';
++ try {
++ var lines = [];
++ for (var i = 1; i <= 4; i++) {
++ var input = form.querySelector('[name=line' + i + ']');
++ if (input && input.value.trim()) lines.push(input.value.trim());
++ }
++ if (!lines.length) throw new Error('Add a caption before exporting.');
++ var response = await fetch('/api/v1/render', {
++ method: 'POST', credentials: 'same-origin',
++ headers: { 'Content-Type': 'application/json', 'Accept': 'image/png' },
++ body: JSON.stringify({ template_id: chosen.value, lines: lines })
++ });
++ if (!response.ok) throw new Error(response.status === 401 ? 'Sign in to export a PNG.' : 'Could not render this caption. Try again.');
++ var url = URL.createObjectURL(await response.blob());
++ var link = document.createElement('a');
++ link.href = url; link.download = 'openvibe-meme.png'; link.click();
++ setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
++ status.textContent = 'PNG downloaded.';
++ } catch (error) { status.textContent = error && error.message ? error.message : 'Could not render this caption.'; }
++ finally { download.disabled = false; }
++ });
draw();
}
function run() { maker(); }
ddiff --git a/server/agent-pages.js b/server/agent-pages.js
iindex 33381ea..7d3fe28 100644
---- a/server/agent-pages.js
++++ b/server/agent-pages.js
@@@ -15,19 +15,19 @@ function createAgentPages(ctx) {
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'");
++ 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' AND e.scope = 'internet') AS citations FROM meme_entries WHERE visibility = 'public' AND scope = 'internet'");
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()]);
-- const feature = await entries.showcase(trending[0]);
++ const [trendResult, weekOut, c] = await Promise.all([entries.list({ limit: 8, scope: 'internet', sort: 'top', window: 'week' }), entries.week({ week }), counts()]);
++ const trending = trendResult.entries || [];
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.',
++ description: 'Explore an open meme encyclopedia: learn what memes mean, trace their origins and variations, and make your own with reusable templates.',
jsonLd: homeJsonLd(config),
-- body: raw(views.home({ trending, week: weekOut, counts: c, feature })),
++ body: raw(views.home({ trending, week: weekOut, counts: c })),
});
}
@@@ -39,8 +39,8 @@ function createAgentPages(ctx) {
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.',
++ index: true, title: 'Make a meme',
++ description: 'Choose a reusable meme template, write a caption, preview as you type, and save an editable link or export a PNG.',
body: raw(views.make({ templates, values: { ...values, template_id: chosen ? chosen.id : '' }, preview, signedIn: false, problem: null })),
});
}
@@@ -49,6 +49,16 @@ function createAgentPages(ctx) {
const p = String(rawPath || '/').replace(/\/+$/, '') || '/';
const q = query instanceof URLSearchParams ? query : new URLSearchParams();
if (p === '/') return home();
++ if (p === '/browse' || p === '/search') {
++ const filters = { q: String(q.get('q') || '').trim().slice(0, 120), scope: q.get('scope') === 'internet' ? 'internet' : null,
++ kind: ['classic', 'format', 'meme'].includes(q.get('kind')) ? q.get('kind') : null, sort: q.get('sort') === 'top' ? 'top' : 'newest' };
++ const result = await entries.list({ ...filters, limit: 50 });
++ const title = filters.q ? `Search: ${filters.q}` : 'Explore internet memes';
++ return page('/browse', { index: !filters.q && !result.error, title,
++ description: 'Search an open meme encyclopedia with researched origins, spread, variations, and reusable templates.',
++ jsonLd: [seo.jsonLd.webPage({ name: title, url: `${base()}/browse`, description: 'Explore researched internet memes and formats.', siteUrl: base() })],
++ body: raw(views.browse({ entries: result.error ? [] : result.entries, filters, error: result.error || null })) });
++ }
if (p === '/week') {
const { week } = currentWeek(s.now());
const out = await entries.week({ week });
@@@ -72,7 +82,7 @@ function createAgentPages(ctx) {
let slug;
try { slug = decodeURIComponent(entryMatch[1]); } catch { return null; }
const row = await entries.bySlug(slug);
-- if (!row || row.visibility !== 'public') return null;
++ if (!row || row.visibility !== 'public' || row.scope !== 'internet') return null;
const citations = await entries.citations(row.id);
const e = entries.wireEntry(row);
return page(p, {
ddiff --git a/server/classics/seed.js b/server/classics/seed.js
iindex 51eaf4e..cc3d23c 100644
---- a/server/classics/seed.js
++++ b/server/classics/seed.js
@@@ -211,6 +211,7 @@ function createClassicsJob({ config, s, entries, sources, judge, log = console }
slug,
title,
kind: 'classic',
++ scope: 'internet',
summary,
status: 'classic',
first_seen_at: built.inception,
ddiff --git a/server/config.js b/server/config.js
iindex 237c5f9..74a0a2b 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -134,14 +134,12 @@ function load(env = process.env) {
register: env.MEME_SOURCES_REGISTER === '1' || env.MEME_SOURCES_REGISTER === 'true',
},
-- // The candidate importer: every 30 minutes, off under MEME_DRILL (tests call runOnce()).
++ // The Live reference linker: every 30 minutes, off under MEME_DRILL (tests call runOnce()).
importer: {
intervalMs: Math.max(60_000, int(env.MEME_IMPORTER_INTERVAL_MS, 30 * 60_000)),
-- minUses: Math.max(2, int(env.MEME_IMPORTER_MIN_USES, 2)),
-- minDays: Math.max(2, int(env.MEME_IMPORTER_MIN_DAYS, 2)),
maxTopics: Math.min(50, Math.max(1, int(env.MEME_IMPORTER_MAX_TOPICS, 12))),
maxVods: Math.min(50, Math.max(1, int(env.MEME_IMPORTER_MAX_VODS, 12))),
-- maxCandidates: Math.min(30, Math.max(1, int(env.MEME_IMPORTER_MAX_CANDIDATES, 10))),
++ maxKnownMemes: Math.min(5000, Math.max(1, int(env.MEME_IMPORTER_MAX_KNOWN_MEMES, 5000))),
},
// Deep dives (M5): research.gather + ai.generate, with a daily budget of their own. Off without the AI URL
@@@ -187,7 +185,7 @@ function load(env = process.env) {
userAgent: env.MEME_WIKI_USER_AGENT || 'OpenVibe.Meme/0.2 (+https://openvibe.meme; [email protected])',
timeoutMs: Math.max(1000, int(env.MEME_WIKI_TIMEOUT_MS, 20_000)),
minIntervalMs: Math.max(0, int(env.MEME_WIKI_MIN_INTERVAL_MS, 250)),
-- limit: Math.min(1000, Math.max(1, int(env.MEME_ENCYCLOPEDIA_LIMIT, 400))),
++ limit: Math.min(1000, Math.max(1, int(env.MEME_ENCYCLOPEDIA_LIMIT, 300))),
// The lists the seeds are read from (Wikipedia, CC BY-SA): the phenomena list and the category behind it.
lists: String(env.MEME_ENCYCLOPEDIA_LISTS || 'List_of_Internet_phenomena').split(',').map((x) => x.trim()).filter(Boolean),
categories: String(env.MEME_ENCYCLOPEDIA_CATEGORIES || 'Internet memes,Memes').split(',').map((x) => x.trim()).filter(Boolean),
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 3b15567..d390d55 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -4,12 +4,14 @@
* /api/v1 — OpenVibe.Meme's JSON API.
*
* GET /entries?limit=&cursor=&kind=&status= public entries, newest first, cursor-paged
-- * GET /entries/:slug one entry with citations and the use timeline
++ * GET /memes?scope=internet&q=&sort=top internet meme library, defaults to internet scope
++ * GET /memes/:slug one public internet meme with citations and timeline
* GET /entries/:slug/series?days=30 uses per UTC day, zero-filled
* GET /trending rising first
* GET /week this ISO week's candidates and vote counts
* POST /week/vote one vote a week (signed in, same-origin)
* GET /templates caption templates
++ * POST /render render text lines to PNG without an AI call
* POST /captions save a caption (signed in; text only)
* GET /captions/:id one saved caption
* POST /entries/:slug/report a takedown/correction report (signed in)
@@@ -28,7 +30,8 @@ const { asyncRouter } = require('./router');
const { sameOrigin } = require('./principal');
const { isStaff } = require('../identity/staff');
const { currentWeek } = require('../week');
--const { KINDS, STATUSES, SCOPES, LABELS } = require('../store');
++const { KINDS, STATUSES, SCOPES, LABELS, captionLines } = require('../store');
++const { renderMeme } = require('../render-png');
function createApi(ctx) {
const { config, s, principal, entries, trends, analyzer, research, generator } = ctx;
@@@ -104,14 +107,14 @@ function createApi(ctx) {
// GET /entries?scope=&q=&status=&label=&kind=&sort=newest|top&window=day|week|month|all&channel=
// `scope=openvibe` is Live's "Memes" page: `sort=top&window=week&channel=<stream_id>` gives the top this week,
// `sort=newest` the newest; `label` and `q` search the encyclopedia.
-- r.get('/entries', async (req, res) => {
++ r.get(['/entries', '/memes'], async (req, res) => {
const limit = req.query.limit == null || req.query.limit === '' ? 20 : Number.parseInt(req.query.limit, 10);
if (!Number.isFinite(limit) || limit < 1) return invalid(res, 'limit must be from 1 to 50');
const kind = typeof req.query.kind === 'string' && req.query.kind ? req.query.kind : null;
if (kind && !KINDS.includes(kind)) return invalid(res, `kind must be one of ${KINDS.join(', ')}`);
const status = typeof req.query.status === 'string' && req.query.status ? req.query.status : null;
if (status && !STATUSES.includes(status)) return invalid(res, `status must be one of ${STATUSES.join(', ')}`);
-- const scope = typeof req.query.scope === 'string' && req.query.scope ? req.query.scope : null;
++ const scope = typeof req.query.scope === 'string' && req.query.scope ? req.query.scope : (req.path === '/memes' ? 'internet' : null);
if (scope && !SCOPES.includes(scope)) return invalid(res, `scope must be one of ${SCOPES.join(', ')}`);
const label = typeof req.query.label === 'string' && req.query.label ? req.query.label.toLowerCase() : null;
if (label && !LABELS.includes(label)) return invalid(res, `label must be one of ${LABELS.join(', ')}`);
@@@ -136,9 +139,10 @@ function createApi(ctx) {
return res.json({ count: checkout.entries.length, entries: checkout.entries, next_cursor: checkout.next_cursor });
});
-- r.get('/entries/:slug/series', async (req, res) => {
++ r.get(['/entries/:slug/series', '/memes/:slug/series'], async (req, res) => {
const row = await bySlugOrRedirect(String(req.params.slug || ''), req, res, { suffix: '/series' });
if (row === undefined) return;
++ if (req.path.startsWith('/memes/') && row && row.scope !== 'internet') return problem(res, 404, 'meme.not_found', 'No such internet meme.');
if (!row || (row.visibility === 'hidden' && !isStaff(req.principal))) return problem(res, 404, 'meme.not_found', 'No such meme.');
const days = req.query.days == null || req.query.days === '' ? 30 : Number.parseInt(req.query.days, 10);
if (!Number.isFinite(days) || days < 1 || days > 365) return invalid(res, 'days must be a whole number from 1 to 365');
@@@ -149,9 +153,10 @@ function createApi(ctx) {
// GET /entries/:slug — the origin, citations, spread, variations, Media examples, related memes and the
// approved revision history. A pending proposal is visible to its author and staff only.
-- r.get('/entries/:slug', async (req, res) => {
++ r.get(['/entries/:slug', '/memes/:slug'], async (req, res) => {
const row = await bySlugOrRedirect(String(req.params.slug || ''), req, res);
if (row === undefined) return;
++ if (req.path.startsWith('/memes/') && row && row.scope !== 'internet') return problem(res, 404, 'meme.not_found', 'No such internet meme.');
const staff = isStaff(req.principal);
const mine = Boolean(row && row.submitted_by && req.principal.kind === 'user' && req.principal.subject === row.submitted_by);
if (!row || (row.visibility === 'hidden' && !staff && !mine)) return problem(res, 404, 'meme.not_found', 'No such meme.');
@@@ -216,6 +221,25 @@ function createApi(ctx) {
return res.json({ count: counted.length, templates: counted });
});
++ // Render user-supplied lines as PNG with the same licensed template and bundled fonts as the maker.
++ // This path is deterministic and does not spend the AI research/generation budget.
++ r.post('/render', requirePerson, async (req, res) => {
++ const body = req.body && typeof req.body === 'object' ? req.body : {};
++ const checked = captionLines(body.lines);
++ if (!checked.ok) return problem(res, checked.status || 400, checked.code || 'meme.invalid', checked.message || 'Invalid caption lines.');
++ const templateId = typeof body.template_id === 'string' ? body.template_id.trim() : '';
++ if (!templateId || templateId.length > 80) return invalid(res, 'template_id is required and must be at most 80 characters');
++ const template = await entries.templateById(templateId);
++ if (!template) return problem(res, 404, 'meme.template_not_found', 'No such template.');
++ try {
++ const rendered = await renderMeme({ template, lines: checked.lines, maxPixels: 1600 });
++ res.set({ 'Cache-Control': 'private, no-store', 'X-Image-Width': String(rendered.width), 'X-Image-Height': String(rendered.height) });
++ return res.type('png').send(rendered.png);
++ } catch (err) {
++ return problem(res, 422, 'meme.render_failed', 'This template could not be rendered right now.');
++ }
++ });
++
r.get('/captions/:id', async (req, res) => {
const row = await entries.captionById(String(req.params.id || ''));
if (!row) return problem(res, 404, 'meme.not_found', 'No such caption.');
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex e6f9811..760ef4c 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -12,7 +12,7 @@ const cache = require('openvibe-shared/cache-policy');
const { asyncRouter } = require('./router');
const SITE_NAME = 'OpenVibe.Meme';
--const DESCRIPTION = 'OpenVibe.Meme is the open meme database, rebuilt with AI: every entry cites the moment it happened, follows its later uses, and is voted meme of the week.';
++const DESCRIPTION = 'Explore an open meme encyclopedia: learn what memes mean, trace their origins and variations, and make your own with reusable templates.';
const DISALLOW = ['/auth/', '/api/', '/internal', '/metrics'];
function dayOf(ts) {
@@@ -27,16 +27,17 @@ function homeJsonLd(config) {
const site = String(config.baseUrl).replace(/\/+$/, '');
return [
seo.jsonLd.website({ name: SITE_NAME, url: site, description: DESCRIPTION }),
-- seo.jsonLd.softwareApp({ name: SITE_NAME, url: site, description: DESCRIPTION, category: 'EntertainmentApplication', keywords: 'openvibe meme, meme database, running jokes, meme of the week, caption generator' }),
++ seo.jsonLd.softwareApp({ name: SITE_NAME, url: site, description: DESCRIPTION, category: 'EntertainmentApplication', keywords: 'internet memes, meme encyclopedia, meme origins, meme templates, meme maker' }),
seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),
];
}
function publicPages() {
return [
-- { path: '/', changefreq: 'daily', priority: 1.0, title: 'OpenVibe.Meme home', text: 'The open meme database: trending entries, meme of the week and the caption generator.' },
++ { path: '/', changefreq: 'daily', priority: 1.0, title: 'OpenVibe.Meme home', text: 'An open meme encyclopedia with researched entries, a weekly favorite, and a caption maker.' },
++ { path: '/browse', changefreq: 'daily', priority: 0.9, title: 'Explore internet memes', text: 'Search an open encyclopedia of internet memes, origins, variations, and reusable templates.' },
{ path: '/week', changefreq: 'daily', priority: 0.9, title: 'Meme of the week', text: 'This week’s candidates and the people’s vote.' },
-- { path: '/make', changefreq: 'weekly', priority: 0.7, title: 'The caption generator', text: 'Pick a template, type up to four lines and preview them over the image.' },
++ { path: '/make', changefreq: 'weekly', priority: 0.7, title: 'Meme maker', text: 'Pick a reusable template, write a caption, preview it, and export a PNG.' },
{ path: '/developers', changefreq: 'weekly', priority: 0.6, title: 'The OpenVibe.Meme API', text: 'The JSON API, the Search document and the importer’s public sources.' },
];
}
@@@ -63,13 +64,14 @@ function createDiscoveryRoutes(ctx) {
// /api/ paths and other sites' links as they are). What an agent may skip is in `optional`, written last.
markdown: true,
base: site,
-- summary: 'OpenVibe.Meme is the open meme database, rebuilt with AI. Every public entry cites the moment a running joke happened (a chat line, a VOD second, a Rundown segment), follows its later uses on a timeline, has a status (rising, peaked, classic, dead), and people vote for meme of the week. A caption generator puts plain text lines over a template image.',
-- details: 'GET /api/v1/entries lists public entries; GET /api/v1/entries/:slug adds every citation and the use timeline; GET /api/v1/entries/:slug/series?days=30 is uses per UTC day. GET /api/v1/week lists this ISO week’s candidates and vote counts. The importer reads only public APIs and never publishes: a candidate stays hidden until a staff person publishes it.',
++ summary: 'OpenVibe.Meme is an open encyclopedia of internet memes and a maker attached to each entry. Pages explain a format, cite its origin and spread, connect variations and related memes, and link reusable templates.',
++ details: 'GET /browse searches the server-rendered internet-meme library. GET /api/v1/memes lists public internet memes; GET /api/v1/memes/:slug adds citations and the use timeline. GET /api/v1/templates lists reusable templates, and signed-in POST /api/v1/render returns a PNG. Live references enrich a known meme only when its name appears in a public transcript; stream catchphrases do not become meme entries.',
sections: [
{ title: 'Start here', links: [
-- { title: 'OpenVibe.Meme', url: abs('/'), note: 'trending and meme of the week' },
++ { title: 'OpenVibe.Meme', url: abs('/'), note: 'internet meme encyclopedia and maker' },
++ { title: 'Explore memes', url: abs('/browse') },
{ title: 'The ballot', url: abs('/week') },
-- { title: 'The caption generator', url: abs('/make') },
++ { title: 'Meme maker', url: abs('/make') },
{ title: 'The API', url: abs('/developers') },
] },
{ title: 'Machine-readable', links: [
@@@ -90,7 +92,7 @@ function createDiscoveryRoutes(ctx) {
});
r.get('/llms-full.txt', async (_req, res) => {
-- const rows = await s.db.many("SELECT slug, title, summary, kind, status FROM meme_entries WHERE visibility = 'public' ORDER BY last_seen_at DESC LIMIT 2000");
++ const rows = await s.db.many("SELECT slug, title, summary, kind, status FROM meme_entries WHERE visibility = 'public' AND scope = 'internet' ORDER BY last_seen_at DESC LIMIT 2000");
const entries = rows.map((e) => ({ url: `/m/${e.slug}`, title: e.title, text: [e.kind, e.status, e.summary].filter(Boolean).join(' · ') }));
res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({
site: SITE_NAME,
@@@ -107,7 +109,7 @@ function createDiscoveryRoutes(ctx) {
r.get('/sitemap.xml', async (_req, res) => {
const lastmod = siteUpdated();
// An entry's licensed picture rides its <url> as an image sitemap entry (SEO-17), so image search can find it.
-- const rows = await s.db.many("SELECT slug, updated_at, image_url FROM meme_entries WHERE visibility = 'public' ORDER BY slug LIMIT 5000");
++ const rows = await s.db.many("SELECT slug, updated_at, image_url FROM meme_entries WHERE visibility = 'public' AND scope = 'internet' ORDER BY slug LIMIT 5000");
const urls = [
...pages.map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority })),
...rows.map((e) => ({ loc: abs(`/m/${e.slug}`), lastmod: dayOf(e.updated_at) || lastmod, changefreq: 'weekly', priority: 0.7,
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 3d23e9a..5c60abc 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -5,7 +5,8 @@
* Public pages are served `public, max-age=60` with Vary: Cookie; a page drawn for a signed-in person is
* `private, no-store`.
*
-- * / trending, meme of the week, the generator teaser
++ * / trending internet memes, the week teaser, the maker
++ * /browse|/search the public meme library and server-rendered filters
* /m/:slug an entry: origin with its citation link, the use timeline, examples, the report form
* /week this week's candidates and the vote form (POST /week/vote)
* /make the caption generator form (POST /make) with a server-rendered preview (GET /make)
@@@ -39,24 +40,53 @@ function createPageRoutes(ctx) {
const notFromHere = (req, res) => page(req, res, { title: 'Not from this site', body: raw(html`<div class="mm-wrap mm-page-pad mm-narrow"><div class="mm-empty"><h1>That came from somewhere else</h1><p class="mm-muted">Use the form on <a href="/">OpenVibe.Meme</a>.</p></div></div>`) }, 403);
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'");
++ 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' AND e.scope = 'internet') AS citations FROM meme_entries WHERE visibility = 'public' AND scope = 'internet'");
return rows[0] || { entries: 0, citations: 0 };
}
// ── Home ─────────────────────────────────────────────────
r.get('/', async (req, res) => {
const { week } = currentWeek(s.now());
-- const [trending, weekOut, c] = await Promise.all([entries.trending({ limit: 8 }), entries.week({ week }), counts()]);
-- const feature = await entries.showcase(trending[0]);
++ const [trendResult, weekOut, c] = await Promise.all([
++ entries.list({ limit: 8, scope: 'internet', sort: 'top', window: 'week' }),
++ entries.week({ week }), counts(),
++ ]);
++ const trending = trendResult.entries || [];
++ const feature = null;
return page(req, res, {
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.',
++ description: 'Explore an open meme encyclopedia: learn what memes mean, trace their origins and variations, and make your own with reusable templates.',
jsonLd: homeJsonLd(config),
body: raw(views.home({ trending, week: weekOut, counts: c, feature })),
});
});
++ // ── Public meme library ─────────────────────────────────
++ async function browsePage(req, res) {
++ const filters = {
++ q: String(req.query.q || '').trim().slice(0, 120),
++ scope: req.query.scope === 'internet' ? 'internet' : null,
++ kind: ['classic', 'format', 'meme'].includes(req.query.kind) ? req.query.kind : null,
++ sort: req.query.sort === 'top' ? 'top' : 'newest',
++ };
++ const result = await entries.list({ ...filters, limit: 50 });
++ const invalid = result.error || null;
++ const rows = invalid ? [] : result.entries;
++ const label = filters.q ? `Search: ${filters.q}` : filters.scope || filters.kind || filters.sort === 'top' ? 'Explore internet memes' : 'Explore memes';
++ return page(req, res, {
++ index: !filters.q && !invalid,
++ cache: PUBLIC_CACHE,
++ agentLayer: !filters.q && !invalid,
++ title: label,
++ description: 'Browse an open meme encyclopedia with researched origins, spread, variations, examples, and reusable templates.',
++ jsonLd: [seo.jsonLd.webPage({ name: label, url: `${base()}/browse`, description: 'Explore researched internet memes and formats.', siteUrl: base() })],
++ body: raw(views.browse({ entries: rows, filters, error: invalid })),
++ });
++ }
++ r.get(['/browse', '/search'], browsePage);
++ r.get('/memes', (req, res) => res.redirect(301, '/browse?scope=internet'));
++
// ── One entry ────────────────────────────────────────────
/** A search result and share-card line: the entry's own summary or first use, cut at a word to 155 characters. */
function metaDescription(e, citations = []) {
@@@ -85,6 +115,8 @@ function createPageRoutes(ctx) {
}
}
if (!row || (row.visibility === 'hidden' && !staff)) return notFound(req, res);
++ // OpenVibe-only catchphrases are not encyclopedia entries; retire their old public pages.
++ if (row.scope === 'openvibe') return res.redirect(301, '/');
const citations = await entries.citations(row.id);
const e = entries.wireEntry(row);
const description = metaDescription(e, citations);
@@@ -160,8 +192,8 @@ function createPageRoutes(ctx) {
const preview = chosen && previewLines.length ? { template: chosen, lines: previewLines } : null;
return page(req, res, {
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.',
++ title: 'Make a meme',
++ description: 'Choose a reusable meme template, write a caption, preview as you type, and save an editable link or export a PNG.',
body: raw(views.make({ templates, values: { ...values, template_id: chosen ? chosen.id : '' }, preview, signedIn: Boolean(signedIn(req)), problem })),
}, status);
}
ddiff --git a/server/importer.js b/server/importer.js
iindex 82b236e..e104113 100644
---- a/server/importer.js
++++ b/server/importer.js
@@@ -1,19 +1,17 @@
'use strict';
/**
-- * The candidate importer (v1). It reads only public APIs — OpenVibe.Live's Rundown and VOD transcripts, and
-- * OpenVibe.Chat's global timeline and history — and proposes a candidate entry when the same 3–6 word phrase
-- * appears in at least two uses on at least two different days. A candidate is inserted hidden, with one citation
-- * per use; it never publishes. Staff publish it through POST /api/v1/entries/:slug/publish.
++ * The Live reference linker reads only public APIs — OpenVibe.Live's Rundown and VOD transcripts, and
++ * OpenVibe.Chat's public history — and attaches a use citation only when a transcript explicitly names an existing
++ * public internet-meme entry. It never creates a meme entry from stream speech. The Chat global timeline is a
++ * paraphrase and is never treated as a citation.
*
* The job runs every 30 minutes (MEME_IMPORTER_INTERVAL_MS) and is off under MEME_DRILL; tests call runOnce().
-- * A failing upstream is logged and skipped: Chat down still lets the Rundown produce candidates, and vice versa.
-- * A phrase already in the table is enriched (new citations, an extended first/last range) rather than copied.
++ * A failing upstream is logged and skipped: Chat down still lets the Rundown produce references, and vice versa.
++ * A repeated reference is deduplicated by the citation store, and updates the entry's last-seen range.
*/
const { parseApiTime } = require('./upstreams');
const { isDenied } = require('./deny-list');
--const { cleanSummary } = require('./text/no-looks');
--const { slugify } = require('./store');
const MIN_WORDS = 3;
const MAX_WORDS = 6;
@@@ -115,11 +113,9 @@ function createImporter({ config, s, entries, upstreams, fetchImpl = globalThis.
async function collect() {
const errors = [];
const uses = [];
-- const hints = [];
const canon = resolver();
-- // Chat: the history is verbatim; the timeline is a model paraphrase, so it is a discovery hint only and
-- // never becomes a citation (there is no message id in it — research §2.1).
++ // Chat history is verbatim. The global timeline is a paraphrase and is deliberately not a source for meme matches.
try {
const history = await upstreams.chatHistory({ limit: 200 });
for (const m of history.messages) {
@@@ -135,11 +131,6 @@ function createImporter({ config, s, entries, upstreams, fetchImpl = globalThis.
}
} catch (err) { errors.push(`chat history: ${err.message}`); log.warn(`[OpenVibe.Meme] importer: ${err.message}`); }
-- try {
-- const timeline = await upstreams.chatTimeline({ limit: 60 });
-- for (const e of timeline.events) hints.push(normalize(`${e.label || ''} ${e.detail || ''}`));
-- } catch (err) { errors.push(`chat timeline: ${err.message}`); log.warn(`[OpenVibe.Meme] importer: ${err.message}`); }
--
// Rundown: every quote is verbatim speech with a VOD second already converted to VOD time.
const vods = new Set();
try {
@@@ -192,11 +183,11 @@ function createImporter({ config, s, entries, upstreams, fetchImpl = globalThis.
}
} catch (err) { errors.push(`transcripts: ${err.message}`); log.warn(`[OpenVibe.Meme] importer: ${err.message}`); }
-- return { uses, hints: hints.join(' '), errors };
++ return { uses, errors };
}
/** Canonicalise a candidate's citations (the joined-VOD redirect) and drop what is gone/denied. */
-- async function materialize(candidate, canon) {
++ async function materialize(candidate, canon, { origin = true } = {}) {
const picked = [...candidate.uses]
.sort((a, b) => Date.parse(a.observed_at) - Date.parse(b.observed_at) || (a.source_type < b.source_type ? -1 : 1))
.slice(0, MAX_USES_PER_PHRASE);
@@@ -214,53 +205,45 @@ function createImporter({ config, s, entries, upstreams, fetchImpl = globalThis.
}
}
if (isDenied(c)) continue;
-- out.push({ ...c, role: out.length === 0 ? 'origin' : 'use' });
++ out.push({ ...c, role: origin && out.length === 0 ? 'origin' : 'use' });
}
return out;
}
async function runOnce() {
const runAt = new Date(s.now()).toISOString();
-- const { uses, hints, errors } = await collect();
++ const { uses, errors } = await collect();
const canon = resolver();
-- const found = detectCandidates(uses, {
-- minUses: config.importer.minUses, minDays: config.importer.minDays, hints,
-- max: Math.max(1, Number(config.importer.maxCandidates) || 10),
-- });
-- let created = 0;
++ const known = await entries.internetEntries({ limit: config.importer.maxKnownMemes || 5000 });
++ const found = [];
let enriched = 0;
-- for (const candidate of found) {
++ for (const entry of known) {
++ const title = normalize(entry.title);
++ if (title.length < 4) continue;
++ const namedUses = uses.filter((use) => ` ${normalize(use.quote_text)} `.includes(` ${title} `));
++ if (!namedUses.length) continue;
++ found.push(entry.slug);
try {
-- const cites = await materialize(candidate, canon);
++ const cites = await materialize({ phrase: title, uses: namedUses }, canon, { origin: false });
if (!cites.length) continue;
const firstSeen = cites.reduce((m, c) => (Date.parse(c.observed_at) < Date.parse(m) ? c.observed_at : m), cites[0].observed_at);
const lastSeen = cites.reduce((m, c) => (Date.parse(c.observed_at) > Date.parse(m) ? c.observed_at : m), cites[0].observed_at);
-- const title = displayTitle(candidate.phrase);
-- const summary = cleanSummary(title, `"${title}" recurs in ${cites.length} moments across ${new Set(cites.map((c) => utcDay(c.observed_at))).size} days on OpenVibe, from ${firstSeen.slice(0, 10)} to ${lastSeen.slice(0, 10)}.${hints.includes(candidate.phrase) ? ' Chat\'s timeline names it too.' : ''}`);
-- // One entry per normalised title and kind: a phrase the table already has is enriched, whatever
-- // slug an earlier ingest gave it. A different slug never means a different joke.
-- const r = await entries.upsertCandidate({
-- slug: slugify(title), title, kind: candidate.phrase.split(' ').length <= 4 ? 'catchphrase' : 'meme',
-- summary, status: statusFor(firstSeen, lastSeen, s.now()), first_seen_at: firstSeen, last_seen_at: lastSeen,
-- visibility: 'hidden', citations: cites,
-- });
-- if (r.created) created++;
-- else if (r.changed) enriched++;
++ const result = await entries.enrich(entry.id, { first_seen_at: firstSeen, last_seen_at: lastSeen, cites });
++ if (result && result.changed) enriched++;
} catch (err) {
-- errors.push(`candidate ${candidate.phrase}: ${err.message}`);
-- log.warn(`[OpenVibe.Meme] importer candidate failed: ${err.message}`);
++ errors.push(`known meme ${entry.slug}: ${err.message}`);
++ log.warn(`[OpenVibe.Meme] importer known meme ${entry.slug} failed: ${err.message}`);
}
}
stats.runs++;
stats.uses = uses.length;
stats.candidates = found.length;
-- stats.created += created;
stats.enriched += enriched;
stats.errors += errors.length;
stats.last_run_at = runAt;
stats.last_error = errors[0] || null;
-- const out = { ok: errors.length === 0, uses: uses.length, candidates: found.length, created, enriched, errors };
-- log.log(`[OpenVibe.Meme] importer: ${uses.length} uses → ${found.length} candidates, ${created} created, ${enriched} enriched${errors.length ? `, ${errors.length} errors` : ''}`);
++ const out = { ok: errors.length === 0, uses: uses.length, candidates: found.length, created: 0, enriched, errors };
++ log.log(`[OpenVibe.Meme] importer: ${uses.length} uses → ${found.length} known memes matched, ${enriched} enriched${errors.length ? `, ${errors.length} errors` : ''}`);
return out;
}
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 1acb809..0d6fb84 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -16,12 +16,13 @@ const { render, esc } = require('./html');
const NETWORK_URL = 'https://openvibe.network';
const SITE_NAME = 'OpenVibe.Meme';
--const TAGLINE = 'The open meme database, rebuilt with AI.';
++const TAGLINE = 'The open meme encyclopedia and meme maker.';
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const NAV = [
{ label: 'Home', href: '/' },
++ { label: 'Explore', href: '/browse' },
{ label: 'This week', href: '/week' },
-- { label: 'Make a caption', href: '/make' },
++ { label: 'Make a meme', href: '/make' },
{ label: 'Developers', href: '/developers' },
];
ddiff --git a/server/search.js b/server/search.js
iindex 485e0a9..63d00b2 100644
---- a/server/search.js
++++ b/server/search.js
@@@ -43,7 +43,9 @@ function provenanceOf(citations) {
/** One entry + its live citations + its internet origin → the Search document (or a tombstone). */
function documentOf(entry, citations = [], { baseUrl = 'https://openvibe.meme', origin = null } = {}) {
const revision = Math.max(1, Number(entry.revision) || 1);
-- if (!entry || entry.visibility !== 'public') return { owner: OWNER, type: TYPE, id: entry.id, revision, deleted: true };
++ if (!entry || entry.visibility !== 'public' || (entry.scope || 'openvibe') !== 'internet') {
++ return { owner: OWNER, type: TYPE, id: entry && entry.id, revision, deleted: true };
++ }
const live = (citations || []).filter((c) => c.source_status === 'live');
const sources = [...new Set(live.map((c) => c.source_service))];
// The snippet reads the summary and the body together: the body leaves out the title and summary.
ddiff --git a/server/store.js b/server/store.js
iindex b027864..6c83395 100644
---- a/server/store.js
++++ b/server/store.js
@@@ -647,6 +647,15 @@ function createStore(s, { search }) {
return rows.map(wireEntry);
}
++ /** Public internet memes only, for matching named memes in Live transcripts without inventing new entries. */
++ async function internetEntries({ limit = 1000 } = {}) {
++ const n = Math.min(5000, Math.max(1, Number(limit) || 1000));
++ const rows = await db.many(
++ `SELECT * FROM meme_entries WHERE visibility = 'public' AND scope = 'internet'
++ ORDER BY title_key ASC, id ASC LIMIT $1`, [n]);
++ return rows.map(wireEntry);
++ }
++
/** The current week's candidates (public entries with a live use that week) and their ballot counts. */
async function week({ week: label, subject = null } = {}) {
const bounds = weekBounds(label);
@@@ -657,6 +666,7 @@ function createStore(s, { search }) {
(SELECT COUNT(*)::int FROM meme_votes v WHERE v.week = $1 AND v.entry_id = e.id AND v.subject = $2) AS mine
FROM meme_entries e
WHERE e.visibility = 'public'
++ AND e.scope = 'internet'
AND EXISTS (SELECT 1 FROM meme_citations c
WHERE c.entry_id = e.id AND c.source_status = 'live'
AND c.observed_at >= $3::timestamptz AND c.observed_at < $4::timestamptz)
@@@ -719,7 +729,7 @@ function createStore(s, { search }) {
* encyclopedia entry ('classic') is inserted public. Citations that name the deny list are dropped here too.
*/
async function createEntry({
-- slug = null, title, kind = 'meme', summary = null, status = 'rising', first_seen_at, last_seen_at,
++ slug = null, title, kind = 'meme', scope = 'openvibe', summary = null, status = 'rising', first_seen_at, last_seen_at,
visibility = 'hidden', citations: cites = [], image_url = null, image_credit = null, image_license = null,
image_license_url = null, image_source_url = null, attribution = null, external_id = null,
}, t = null) {
@@@ -732,11 +742,11 @@ function createStore(s, { search }) {
let last = iso(last_seen_at) || first;
if (Date.parse(first) > Date.parse(last)) { const swap = first; first = last; last = swap; }
const row = await q(tx).maybe(
-- `INSERT INTO meme_entries (id, slug, title, kind, summary, status, status_since, first_seen_at, last_seen_at, visibility, revision, created_at, updated_at,
++ `INSERT INTO meme_entries (id, slug, title, kind, scope, summary, status, status_since, first_seen_at, last_seen_at, visibility, revision, created_at, updated_at,
image_url, image_credit, image_license, image_license_url, image_source_url, attribution, external_id)
-- VALUES ($1, $2, $3, $4, $5, $6, $7::timestamptz, $8::timestamptz, $9::timestamptz, $10, 1, now(), now(),
-- $11, $12, $13, $14, $15, $16, $17) RETURNING *`,
-- [id, cleanSlug, cleanTitle, cleanKind, cleanSummary(cleanTitle, summary), status, iso(last), first, last, visibility,
++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8::timestamptz, $9::timestamptz, $10::timestamptz, $11, 1, now(), now(),
++ $12, $13, $14, $15, $16, $17, $18) RETURNING *`,
++ [id, cleanSlug, cleanTitle, cleanKind, scope, cleanSummary(cleanTitle, summary), status, iso(last), first, last, visibility,
image_url || null, image_credit || null, image_license || null, image_license_url || null, image_source_url || null,
attribution || null, external_id || null],
);
@@@ -744,7 +754,7 @@ function createStore(s, { search }) {
await q(tx).exec(
`INSERT INTO meme_entry_revisions (entry_id, revision, author, base_revision, change, diff, moderation_state, created_at, reviewed_at, reviewed_by)
VALUES ($1, 1, 'service:meme', NULL, $2::jsonb, '[]'::jsonb, 'approved', now(), now(), 'service:meme') ON CONFLICT DO NOTHING`,
-- [row.id, JSON.stringify({ title: cleanTitle, kind, summary: row.summary, status, scope: 'openvibe', labels: [] })],
++ [row.id, JSON.stringify({ title: cleanTitle, kind, summary: row.summary, status, scope, labels: [] })],
);
const entry = wireEntry(row);
if (entry.visibility === 'public') await search.sync(tx, entry, added);
@@@ -1389,7 +1399,7 @@ function createStore(s, { search }) {
wireEntry, wireCitation, wireOrigin, wireSpread, wireVariation, wireExample, wireRelated, wireRevision,
wireTemplate, wireCaption, wireReport, wireJudgement,
bySlug, byId, byTitleKey, redirectFor, citations, liveCitations, originOf, spreadOf, variationsOf, examplesOf, relatedOf, revisionsOf, detail,
-- list, trending, week, series, showcase, uniqueSlug,
++ list, trending, internetEntries, week, series, showcase, uniqueSlug,
createCandidate, createEntry, upsertCandidate, addCitations, enrich, reindex, publish, hide, patch,
createProposal, addRevision, reviewRevision, pendingRevisions,
templates, templateById, addTemplate, createCaption, captionById, captionCountForTemplate,
ddiff --git a/server/views/index.js b/server/views/index.js
iindex a6147ba..61edc79 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -2,13 +2,14 @@
/**
* The Meme room's pages (plan 2026-10-28 §5.7). Server-rendered and whole without JavaScript: every action is a
-- * plain form or link. public/js/meme.js is a one-comment placeholder the main session replaces; the preview and
-- * the vote work without it.
++ * plain form or link. public/js/meme.js upgrades the template search, live preview, and PNG export without becoming
++ * a requirement for reading the library or submitting the server-rendered form.
*
-- * home({ trending, week, feature }) trending, meme of the week, the first entry's real origin and uses
++ * home({ trending, week }) internet-meme discovery, meme of the week, and the maker
* entry({ e, citations, signedIn, staff }) the origin with its citation link, the use timeline, examples
* week({ out, signedIn }) this week's candidates and the vote form
-- * make({ templates, values, preview, signedIn }) the caption generator form
++ * browse({ entries, filters }) searchable public internet-meme library
++ * make({ templates, values, preview, signedIn }) the caption editor form
* caption({ caption, template, mine }) one saved caption, text over the image
* developers() the API
* signIn({ title, text, next }) a page that needs an account
@@@ -69,17 +70,13 @@ function empty(text, href = '/week', label = 'This week’s ballot') {
/** The room's picture: a meme's family tree growing from its first moment, stickers fanning off the branches. */
function treeMotif(feature = null) {
-- const e = feature && feature.entry;
-- const cites = feature && Array.isArray(feature.citations) ? feature.citations : [];
-- const c = cites.find((x) => x.role === 'origin') || cites[0] || null;
-- const at = c ? clock(c.offset_seconds) : null;
-- const root = e ? esc(e.title) : "A meme's first moment";
-- const moment = c ? `${esc(SOURCE_LABEL[c.source_type] || c.source_type)}${at ? ` · ${esc(at)}` : ''}` : 'a cited moment';
++ const root = "A meme's origin";
++ const moment = 'first format';
return `<div class="mm-tree" aria-hidden="true">
<svg class="mm-tree-lines" viewBox="0 0 400 280" preserveAspectRatio="none"><path d="M200 78 C200 128 92 118 92 176"/><path d="M200 78 L200 176"/><path d="M200 78 C200 128 308 118 308 176"/><path d="M92 206 C92 236 52 232 52 258"/><path d="M92 206 C92 236 132 232 132 258"/><path d="M308 206 C308 236 348 232 348 258"/></svg>
--<span class="mm-node mm-node-root"><b>${root}</b><small>${e ? 'the first moment' : 'chat · the first time'}</small></span>
--<span class="mm-node mm-n1">${moment}</span><span class="mm-node mm-n2">a clip</span><span class="mm-node mm-n3">the Rundown</span>
--<span class="mm-sticker mm-s1">classic</span><span class="mm-sticker mm-s2">lol</span><span class="mm-sticker mm-s3">rising</span>
++<span class="mm-node mm-node-root"><b>${root}</b><small>traced to sources</small></span>
++<span class="mm-node mm-n1">${moment}</span><span class="mm-node mm-n2">new variations</span><span class="mm-node mm-n3">shared culture</span>
++<span class="mm-sticker mm-s1">origin</span><span class="mm-sticker mm-s2">remix</span><span class="mm-sticker mm-s3">history</span>
</div>`;
}
@@@ -119,36 +116,39 @@ function home({ trending = [], week = null, counts = {}, feature = null } = {})
<ol class="mm-entries mm-entries-week">${candidates.map(card).join('')}</ol>
</section>` : '';
const firsts = [
-- !trending.length && ['The first entries are on their way', 'Each one is checked against the chat line or VOD second it started from before it is public.', '/developers', 'How entries are built'],
-- !candidates.length && ['Make the first caption', 'Pick a template, write the lines, and it is saved as text anyone can remix.', '/make', 'Open the generator'],
++ !trending.length && ['Start with a classic', 'Browse the internet meme encyclopedia and follow a format from its first trace to its latest variations.', '/browse?scope=internet', 'Explore internet memes'],
++ !candidates.length && ['Make a meme', 'Choose a reusable template, write a caption, and export a PNG or save a remixable link.', '/make', 'Open the meme maker'],
].filter(Boolean);
const beFirst = firsts.length ? `<section class="mm-section mm-first" aria-labelledby="mm-first-h"><h2 id="mm-first-h">Be the first</h2><ul class="mm-first-list">${firsts.map(([title, text, href, label]) => `<li><b>${esc(title)}</b><span>${esc(text)}</span><a href="${esc(href)}">${esc(label)}</a></li>`).join('')}</ul></section>` : '';
const live = `${trendingSec}${weekSec}`;
const room = sc.room({
room: 'meme',
hero: {
-- title: 'Every joke has a first time',
-- lede: 'The open meme database: each running joke with the moment it started and every later use. Vote for meme of the week, or caption a template.',
-- start: { href: '/week', label: 'Vote for meme of the week' },
-- chips: [{ label: 'Make a caption', href: '/make' }, ...(trending.length ? [{ label: 'Trending', href: '#mm-trending' }] : []), { label: 'The API', href: '/developers' }],
++ title: 'Know the meme. Make it yours.',
++ lede: 'An open encyclopedia of internet memes: what each one means, where it began, how it spread, and the formats people still remix.',
++ start: { href: '/browse', label: 'Explore the meme library' },
++ chips: [{ label: 'Make a meme', href: '/make' }, ...(trending.length ? [{ label: 'Trending', href: '#mm-trending' }] : []), { label: 'The API', href: '/developers' }],
proof: Number(counts.entries) ? [{ value: Number(counts.entries), label: Number(counts.entries) === 1 ? 'entry' : 'entries' }] : [],
motif: treeMotif(feature),
},
scenes: [
-- { title: 'A stream moment becomes an entry', text: 'An entry starts where the joke did: the chat line or the second of the VOD, quoted word for word and linked. A source that disappears is marked, and an entry without its origin goes back into hiding.',
-- html: homeCitation(feature),
-- action: feature ? { href: `/m/${feature.entry.slug}`, label: 'Open the entry' } : { href: '/developers', label: 'How entries are built' } },
-- { title: 'Watch a joke rise', text: 'Every use is dated, so you can see a joke catch on, peak and become a classic. Rising first on the trending list.',
-- html: homeRise(feature),
-- action: { href: '/week', label: 'See this week\'s ballot' } },
-- { title: 'Caption it', text: 'Pick a template, type up to four lines and see them over the picture. A caption is saved as text, so anyone can remix it.',
-- html: `<div><p class="mm-kicker">Example caption</p><figure class="mm-caption-card mm-demo-caption"><img src="/img/template-classic.svg" alt="An example caption on a template"><figcaption><span>when the robot is offline</span><span>and chat has to cope</span></figcaption></figure></div>`,
-- action: { href: '/make', label: 'Open the generator' } },
++ { title: 'Find the format behind the joke', text: 'Start with the name or a phrase you remember. Each entry explains the format in plain language and connects its origin, spread, variations, examples, and related memes.',
++ html: `<form class="mm-library-search" action="/browse" method="get" role="search"><label for="mm-home-q">Search memes and formats</label><div><input id="mm-home-q" name="q" type="search" maxlength="120" placeholder="Distracted Boyfriend, Rickroll…"><button class="mm-btn" type="submit">Search</button></div><p><a href="/browse?scope=internet">Browse internet memes</a> · <a href="/browse?sort=top">Most used</a></p></form>`,
++ action: { href: '/browse?scope=internet', label: 'Browse the encyclopedia' } },
++ { title: 'See how a meme changes', text: 'Origins and spread are cited, variations show how a format mutates, and related entries connect the larger family of memes.',
++ html: `<ol class="mm-history-strip"><li><b>Origin</b><span>Find the earliest traceable example</span></li><li><b>Spread</b><span>Follow how the format traveled</span></li><li><b>Remix</b><span>See what people made from it</span></li></ol>`,
++ action: { href: '/browse?scope=internet', label: 'Explore researched entries' } },
++ { title: 'Make a meme in seconds', text: 'Choose a reusable template, add your own caption, preview it instantly, and save a link others can remix.',
++ html: `<div class="mm-rise-card"><span class="mm-rise-mark" aria-hidden="true">↗</span><div><b>Fresh formats, lasting classics</b><p>Find new entries and familiar favorites in one searchable library.</p></div></div>`,
++ action: { href: '/make', label: 'Open the meme maker' } },
++ { title: 'Made for remixing', text: 'Captions stay editable as text. Templates carry their source and reuse licence, and uploaded examples go through moderation.',
++ html: `<div><p class="mm-kicker">Example caption</p><figure class="mm-caption-card mm-demo-caption"><img src="/img/template-classic.svg" alt="An example caption on a template"><figcaption><span>when you find the original meme</span><span>the repost has a million views</span></figcaption></figure></div>`,
++ action: { href: '/make', label: 'Make your own' } },
],
faq: { items: [
-- ['Where do the entries come from?', 'From OpenVibe itself: the Rundown, VOD transcripts and the public chat. An importer proposes a candidate when a phrase comes back on more than one day; a person checks it against its source before it is public.'],
-- ['Can I get something taken down?', 'Yes. Every entry has a takedown and correction form for signed-in people, and staff answer it. VOD 5709 and clip 483 are never cited.'],
-- ['Is the database open?', 'Yes: every public entry and citation is in the API, and in OpenVibe\'s Search.'],
++ ['What is in the meme library?', 'Internet memes and formats, documented with original sources, clear explanations, examples, variations, and links to related entries. Stream appearances are shown only when a known meme is named in a transcript.'],
++ ['Can I get something corrected or removed?', 'Yes. Every entry has a correction and takedown report path. Example uploads are reviewed before publication.'],
++ ['Can I reuse templates?', 'Each template page names its source and licence. Use licensed templates or artwork you have permission to upload.'],
] },
});
// What is real goes above the scenes; what is still missing goes after them.
@@@ -223,25 +223,45 @@ ${signedIn ? `<form method="post" action="/week/vote" class="mm-inline"><input t
const LINES = [1, 2, 3, 4];
++function browse({ entries = [], filters = {}, error = null } = {}) {
++ const q = filters.q || '';
++ const scope = filters.scope || '';
++ const sort = filters.sort || 'newest';
++ const kinds = [['', 'All types'], ['classic', 'Internet memes'], ['format', 'Formats'], ['meme', 'Memes']];
++ return `<div class="mm-wrap" id="mm-browse">
++<header class="mm-page-h"><p class="mm-kicker">The open meme encyclopedia</p><h1>Explore memes</h1><p class="mm-lede">Search meme names and formats, follow their origins and variations, and find a template to remix.</p></header>
++<form class="mm-browse-filters mm-card" action="/browse" method="get" role="search"><label class="mm-field"><span>Search</span><input type="search" name="q" value="${esc(q)}" maxlength="120" placeholder="Try a meme name or phrase"></label>
++<label class="mm-field"><span>Collection</span><select name="scope"><option value="">All collections</option><option value="internet"${scope === 'internet' ? ' selected' : ''}>Internet memes</option></select></label>
++<label class="mm-field"><span>Type</span><select name="kind">${kinds.map(([value, label]) => `<option value="${value}"${filters.kind === value ? ' selected' : ''}>${label}</option>`).join('')}</select></label>
++<label class="mm-field"><span>Order</span><select name="sort"><option value="newest"${sort === 'newest' ? ' selected' : ''}>Recently added</option><option value="top"${sort === 'top' ? ' selected' : ''}>Most used</option></select></label><button class="mm-btn" type="submit">Find memes</button></form>
++${error ? '<p class="mm-note is-bad" role="alert">Choose a supported filter and try again.</p>' : ''}
++<section class="mm-section" aria-live="polite"><div class="mm-head-row"><h2>${q ? `Results for “${esc(q)}”` : scope === 'internet' ? 'Internet memes' : 'The library'}</h2><span class="mm-count">${entries.length} ${entries.length === 1 ? 'entry' : 'entries'}</span></div>
++${entries.length ? `<ol class="mm-entries">${entries.map((e) => `<li class="mm-entry-card"><a class="mm-entry-link" href="/m/${esc(e.slug)}"><span class="mm-entry-title">${esc(e.title)}</span><span class="mm-entry-summary">${esc(e.summary || e.meaning || 'Explore the origin, spread, and examples.')}</span><span class="mm-entry-meta">${kindLabel(e.kind)} · ${statusBadge(e.status)}</span></a></li>`).join('')}</ol>` : empty(q ? 'No matching memes yet. Try a shorter name or browse the full library.' : 'No public entries match these filters yet.', '/make', 'Make a meme')}
++</section><p class="mm-fine">Entries cite their sources. Images appear only when their licence permits reuse or a member supplied them.</p>
++</div>`;
++}
++
function make({ templates = [], values = {}, preview = null, signedIn = false, problem = null } = {}) {
const v = { template_id: templates[0] ? templates[0].id : '', visibility: 'public', ...values };
const lines = LINES.map((i) => v[`line${i}`] || '');
return `<div class="mm-wrap" id="mm-make">
--<header class="mm-page-h"><p class="mm-kicker">The caption generator</p><h1>Put words on a template</h1>
--<p class="mm-lede">Pick a template and type up to four lines, ${LINES.length * 120} characters at most. The preview is HTML text over the image; saving stores the lines, never a picture.</p></header>
++<header class="mm-page-h"><p class="mm-kicker">Meme maker</p><h1>Make a meme</h1>
++<p class="mm-lede">Choose a reusable template, write up to four lines, and preview as you type. Sign in to save an editable link or export a PNG.</p></header>
${problem ? `<p class="mm-note is-bad" role="alert">${esc(problem)}</p>` : ''}
<div class="mm-make-grid">
<form id="mm-caption-form" class="mm-form mm-card" method="post" action="/make">
--<fieldset class="mm-templates"><legend>Template</legend>
++<label class="mm-field"><span>Find a template</span><input id="mm-template-filter" type="search" placeholder="Search templates" aria-controls="mm-template-grid" autocomplete="off"></label>
++<fieldset class="mm-templates" id="mm-template-grid"><legend>Choose a template</legend>
${templates.map((t) => `<label class="mm-template${t.id === v.template_id ? ' is-chosen' : ''}"><input type="radio" name="template_id" value="${esc(t.id)}"${t.id === v.template_id ? ' checked' : ''} required><img src="${esc(t.image_url)}" alt="" width="220" height="140" loading="lazy"><span><b>${esc(t.name)}</b><small>${Number(t.box_count)} ${Number(t.box_count) === 1 ? 'box' : 'boxes'} · ${esc(t.license)}</small></span></label>`).join('')}
</fieldset>
--${LINES.map((i) => `<label class="mm-field"><span>Line ${i}</span><input name="line${i}" type="text" maxlength="120" value="${esc(lines[i - 1])}" placeholder="${i === 1 ? 'when the robot is offline' : ''}"></label>`).join('')}
++${LINES.map((i) => `<label class="mm-field"><span>Line ${i}</span><input name="line${i}" type="text" maxlength="120" value="${esc(lines[i - 1])}" placeholder="${i === 1 ? 'when you recognize the format' : ''}"></label>`).join('')}
<label class="mm-field"><span>Who can see it?</span><select name="visibility"><option value="public"${v.visibility === 'public' ? ' selected' : ''}>Anyone</option><option value="unlisted"${v.visibility === 'unlisted' ? ' selected' : ''}>Anyone with the link</option><option value="hidden"${v.visibility === 'hidden' ? ' selected' : ''}>Only me</option></select></label>
<div class="mm-actions"><button class="mm-btn" type="submit">Save the caption</button><button class="mm-btn mm-btn-quiet" type="submit" formaction="/make" formmethod="get">Preview</button></div>
${signedIn ? '' : '<p class="mm-fine">You can preview without signing in. <a href="/auth/login?next=%2Fmake">Sign in</a> to save a caption.</p>'}
</form>
--<aside class="mm-preview" id="mm-preview" aria-labelledby="mm-preview-h"><h2 id="mm-preview-h">Preview</h2>
++<aside class="mm-preview" id="mm-preview" aria-labelledby="mm-preview-h"><h2 id="mm-preview-h">Your canvas</h2>
${preview ? `<figure class="mm-caption-card"><img src="${esc(preview.template.image_url)}" alt=""><figcaption>${preview.lines.map((l) => `<span>${esc(l)}</span>`).join('')}</figcaption></figure><p class="mm-fine">Text only, over ${esc(preview.template.name)}. Nothing is saved until you press Save.</p>` : '<p class="mm-muted">Press Preview to see your lines over the template.</p>'}
++${signedIn ? '<button class="mm-btn mm-btn-quiet" type="button" data-render-png>Download PNG</button><p class="mm-fine" data-render-status aria-live="polite"></p>' : '<p class="mm-fine">Sign in to export a PNG. Your preview is still free to use.</p>'}
</aside>
</div>
</div>`;
@@@ -271,11 +291,14 @@ function developers() {
<p class="mm-lede">JSON under <code>/api/v1</code>, with <code>application/problem+json</code> errors. A person is the <code>meme_at</code> session cookie or a Network user token as a Bearer.</p></header>
<section><h2>Read</h2><dl class="mm-eps">
${ep('GET', '/api/v1/entries?limit=&cursor=&kind=&status=', 'Public entries, newest first, cursor-paged (at most 50).')}
++${ep('GET', '/api/v1/memes?scope=internet&q=&sort=top&window=week', 'Meme-library alias for public entries with collection, search, and usage filters.')}
${ep('GET', '/api/v1/entries/:slug', 'One entry with every citation and the use timeline. A hidden one is 404 unless you are staff.')}
++${ep('GET', '/api/v1/memes/:slug', 'Meme-library alias for an entry, including its citations and timeline.')}
${ep('GET', '/api/v1/entries/:slug/series?days=30', 'Uses per UTC day, zero-filled (1–365 days).')}
${ep('GET', '/api/v1/trending', 'Public entries, rising first.')}
${ep('GET', '/api/v1/week', 'This ISO week’s candidates and their vote counts.')}
${ep('GET', '/api/v1/templates', 'Caption templates: image, box count, source and license.')}
++${ep('POST', '/api/v1/render', 'Render a caption to PNG from a licensed template and 1–4 text lines; same contract as /generate.')}
${ep('GET', '/api/v1/captions/:id', 'One saved caption (a hidden one is 404 unless it is yours or you are staff).')}
</dl></section>
<section><h2>Write (signed in)</h2><dl class="mm-eps">
@@@ -297,4 +320,4 @@ function notFound() {
return `<div class="mm-wrap mm-page-pad mm-narrow"><div class="mm-empty" id="mm-not-found"><p class="mm-kicker">404</p><h1>This meme is not here</h1><p>It may have been hidden, its origin source may be gone, or the address was never an entry.</p><a class="mm-btn" href="/">Back to the memes</a></div></div>`;
}
--module.exports = { home, entry, week, make, caption, signIn, developers, notFound, statusBadge, kindLabel, citation, clock, STATUS_LABEL, KIND_LABEL, SOURCE_LABEL };
++module.exports = { home, entry, week, browse, make, caption, signIn, developers, notFound, statusBadge, kindLabel, citation, clock, STATUS_LABEL, KIND_LABEL, SOURCE_LABEL };
ddiff --git a/test/agent-layer.test.js b/test/agent-layer.test.js
iindex 146a6ab..aa321f9 100644
---- a/test/agent-layer.test.js
++++ b/test/agent-layer.test.js
@@@ -32,7 +32,7 @@ const { boot, check, done } = require('./helpers/boot');
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']]) {
++ for (const [path, heading] of [['/developers.md', '# The OpenVibe.Meme API'], ['/week.md', '# Meme of the week'], ['/browse.md', '# Explore memes'], ['/make.md', '# Make a meme']]) {
const r = await t.get(path);
assert.strictEqual(r.status, 200, path);
assert.ok(r.text.includes(heading), `${path} missing ${heading}`);
ddiff --git a/test/home-real-content.test.js b/test/home-real-content.test.js
iindex 6bdf048..318df79 100644
---- a/test/home-real-content.test.js
++++ b/test/home-real-content.test.js
@@@ -1,7 +1,5 @@
'use strict';
--// The home's citation and use scenes show the first real trending entry: the quote, source, second and date of its
--// cited moment, and its own cited uses per day. Without one they say so; the caption stays a generator preview and is
--// labelled Example. No written-in quote, use series or cast.
++// The public homepage is about the internet meme encyclopedia and must not turn OpenVibe stream catchphrases into memes.
const assert = require('assert');
const views = require('../server/views');
@@@ -11,19 +9,16 @@ const feature = {
series: { series: [{ day: '2026-10-29', uses: 0 }, { day: '2026-10-30', uses: 2 }, { day: '2026-10-31', uses: 1 }] },
};
const page = views.home({ feature });
--assert.ok(page.includes('a real quoted line'), 'the real quote');
--assert.ok(page.includes('VOD') && page.includes('at 25:41') && page.includes('2026-11-01'), 'its real source, second and date');
--assert.ok(page.includes('href="https://openvibe.live/v/1"'), 'the citation links its moment');
--assert.ok(page.includes('uses per day, last 3 days') && page.includes('Cozmo is offline'), 'the real use series');
--assert.ok(page.includes('href="/m/cosmo"'), 'the entry is linked');
++assert.ok(page.includes('Know the meme. Make it yours.'), 'library-first positioning');
++assert.ok(page.includes('Search memes and formats'), 'server-rendered library search');
++assert.ok(page.includes('href="/browse?scope=internet"'), 'internet meme collection link');
++assert.ok(!page.includes('a real quoted line'), 'does not feature stream catchphrases');
++assert.ok(!page.includes('https://openvibe.live/v/1'), 'does not link a stream moment');
assert.ok(page.includes('Example caption'), 'the caption preview says Example');
--assert.ok(!/but yeah and Cosmo is broken now/.test(page), 'no written-in quote');
--assert.ok(page.includes('Cozmo is offline') && page.includes('VOD · 25:41'), 'the hero tree also uses the real entry');
++assert.ok(page.includes('A meme\'s origin') && page.includes('new variations'), 'the hero illustrates an internet meme lifecycle');
const empty = views.home({});
--assert.ok(empty.includes('No cited moment yet'), 'no citation: says so');
--assert.ok(empty.includes('No uses cited in the last 10 days'), 'no uses: says so');
--assert.ok(!/but yeah and Cosmo is broken now|\[2, 3, 2, 4, 6, 5, 8, 11, 9, 14\]|Cozmo is offline|25:41/.test(empty), 'no written-in quote, series, entry or timestamp');
++assert.ok(!/but yeah and Cosmo is broken now|\[2, 3, 2, 4, 6, 5, 8, 11, 9, 14\]|Cozmo is offline|25:41|VOD/.test(empty), 'no stream quote, series, entry or timestamp');
assert.ok(empty.includes('Example caption'), 'the caption preview still says Example');
console.log('meme home: the citation and rise scenes read the first real entry, or say there is none');
ddiff --git a/test/home-real-first.test.js b/test/home-real-first.test.js
iindex 0487a73..6c84506 100644
---- a/test/home-real-first.test.js
++++ b/test/home-real-first.test.js
@@@ -15,7 +15,7 @@ assert.ok(!empty.includes('id="mm-trending"') && !empty.includes('id="mm-week-te
assert.ok(!empty.includes('href="#mm-trending"'), 'no chip to a section that is not there');
const scenes = empty.indexOf('class="sc-room-scenes"');
assert.ok(scenes > 0 && empty.indexOf('Be the first') > scenes, 'the row follows the scenes');
--assert.ok(empty.includes('href="/make">Open the generator</a>') && empty.includes('href="/developers">How entries are built</a>'), 'each says how it starts');
++assert.ok(empty.includes('href="/make">Open the meme maker</a>') && empty.includes('href="/browse?scope=internet">Explore internet memes</a>'), 'each leads into the library or maker');
const entry = { slug: 'robot-offline', title: 'Robot is offline', kind: 'joke', summary: 'When the robot drops.', status: 'rising', uses: 4, citations: [] };
const some = views.home({ trending: [entry], week: { candidates: [entry] }, counts: { entries: 1 } });
ddiff --git a/test/importer.test.js b/test/importer.test.js
iindex ffa140c..fede659 100644
---- a/test/importer.test.js
++++ b/test/importer.test.js
@@@ -1,79 +1,56 @@
'use strict';
--/**
-- * The importer reads the fixture Chat history/timeline, Rundown topics and VOD transcripts, finds "robot is
-- * offline" across four uses on three days, canonicalises `/v/9001?t=60` → `/v/9002?t=120`, and inserts the
-- * candidate hidden. It never publishes; a second run enriches nothing and makes no second entry. The deny-list
-- * take (VOD 5709) never reaches a citation.
-- */
++/** Live mentions enrich a known internet meme with source-linked uses; repeated stream phrases never become entries. */
const assert = require('assert');
const { boot, check, done } = require('./helpers/boot');
(async () => {
const t = await boot();
try {
++ const now = new Date().toISOString();
++ const known = await t.ctx.entries.createCandidate({
++ slug: 'robot-is-offline', title: 'Robot is offline', kind: 'classic', scope: 'internet',
++ summary: 'A test encyclopedia entry with a sourced origin.', status: 'classic',
++ first_seen_at: '2026-10-20T00:00:00Z', last_seen_at: '2026-10-20T00:00:00Z', visibility: 'public',
++ citations: [{ role: 'origin', source_service: 'web', source_type: 'wikipedia', source_id: 'robot-is-offline',
++ quote_text: 'This test entry has an independently cited origin.', url: 'https://en.wikipedia.org/wiki/Internet_meme', observed_at: '2026-10-20T00:00:00Z' }],
++ });
let out = null;
-- await check('the fixture produces one candidate from ≥2 uses on ≥2 days, hidden', async () => {
++ await check('known meme references add cited uses without creating stream-meme entries', async () => {
out = await t.ctx.importer.runOnce();
assert.strictEqual(out.errors.length, 0, JSON.stringify(out.errors));
-- assert.ok(out.created >= 1);
-- const e = await t.ctx.s.db.maybe("SELECT * FROM meme_entries WHERE slug = 'robot-is-offline'");
-- assert.ok(e, 'the candidate entry exists');
-- assert.strictEqual(e.visibility, 'hidden');
-- assert.strictEqual(e.title, 'Robot is offline');
-- const cites = await t.ctx.s.db.many('SELECT * FROM meme_citations WHERE entry_id = $1 ORDER BY observed_at, id', [e.id]);
-- assert.ok(cites.length >= 3, `expected several citations, got ${cites.length}`);
-- const days = new Set(cites.map((c) => String(c.observed_at).slice(0, 10)));
-- assert.ok(days.size >= 2, 'uses are on at least two days');
++ assert.strictEqual(out.created, 0);
++ assert.ok(out.candidates >= 1, 'the known meme title was named in source text');
++ assert.ok(out.enriched >= 1, 'the known entry gained citations');
++ const entry = await t.ctx.s.db.maybe("SELECT * FROM meme_entries WHERE slug = 'robot-is-offline'");
++ assert.ok(entry);
++ assert.strictEqual(entry.id, known.entry.id);
++ assert.strictEqual(entry.visibility, 'public');
++ assert.strictEqual(entry.scope, 'internet');
++ const cites = await t.ctx.s.db.many('SELECT * FROM meme_citations WHERE entry_id = $1 ORDER BY observed_at, id', [entry.id]);
++ assert.ok(cites.length >= 4, `expected origin and several uses, got ${cites.length}`);
assert.strictEqual(cites.filter((c) => c.role === 'origin').length, 1);
-- assert.ok(cites.some((c) => c.source_type === 'chat_message'));
-- assert.ok(cites.some((c) => c.source_type === 'rundown_segment'));
-- assert.ok(cites.some((c) => c.source_type === 'vod'));
++ assert.ok(cites.some((c) => c.role === 'use' && c.source_type === 'chat_message'));
++ assert.ok(cites.some((c) => c.role === 'use' && c.source_type === 'rundown_segment'));
++ assert.ok(cites.some((c) => c.role === 'use' && c.source_type === 'vod'));
});
-- await check('a joined-VOD redirect is stored canonically (9002@120, not 9001@60)', async () => {
++ await check('joined-VOD references keep their canonical time and prohibited sources stay absent', async () => {
const c = await t.ctx.s.db.maybe("SELECT * FROM meme_citations WHERE source_type = 'rundown_segment' LIMIT 1");
assert.strictEqual(Number(c.vod_id), 9002);
assert.strictEqual(Number(c.offset_seconds), 120);
assert.match(c.url, /\/v\/9002\?t=120$/);
-- });
--
-- await check('no citation names VOD 5709 or clip 483', async () => {
const bad = await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM meme_citations WHERE vod_id = 5709 OR clip_id = 483');
assert.strictEqual(Number(bad), 0);
});
-- await check('the candidate is hidden from the public API and a second run does not duplicate it', async () => {
-- const list = await t.get('/api/v1/entries');
-- assert.strictEqual(list.json().count, 0);
-- const one = await t.get('/api/v1/entries/robot-is-offline');
-- assert.strictEqual(one.status, 404);
++ await check('repeated phrases remain absent and a repeated known reference does not duplicate citations', async () => {
++ const list = await t.get('/api/v1/memes');
++ assert.strictEqual(list.json().count, 1);
++ const before = Number(await t.ctx.s.db.value("SELECT COUNT(*)::int AS n FROM meme_citations WHERE source_type IN ('chat_message','rundown_segment','vod')"));
const again = await t.ctx.importer.runOnce();
assert.strictEqual(again.created, 0);
-- assert.strictEqual(Number(await t.ctx.s.db.value("SELECT COUNT(*)::int AS n FROM meme_entries WHERE slug = 'robot-is-offline'")), 1);
-- });
--
-- await check('two ingests of the same phrase make one entry, whatever the case, spaces or punctuation', async () => {
-- const before = await t.ctx.s.db.maybe("SELECT * FROM meme_entries WHERE slug = 'robot-is-offline'");
-- const cite = (id) => ({
-- source_service: 'chat', source_type: 'chat_message', source_id: String(id), chat_message_id: id,
-- quote_text: 'robot is offline', observed_at: '2026-10-24T12:00:00.000Z', url: 'https://openvibe.chat/',
-- });
-- const first = await t.ctx.entries.upsertCandidate({
-- slug: 'robot-is-offline-two', title: ' ROBOT is offline! ', kind: 'catchphrase',
-- visibility: 'hidden', first_seen_at: '2026-10-24T12:00:00.000Z', last_seen_at: '2026-10-24T12:00:00.000Z',
-- citations: [cite(9999)],
-- });
-- const second = await t.ctx.entries.upsertCandidate({
-- slug: 'robot-is-offline-three', title: 'robot is offline', kind: 'catchphrase',
-- visibility: 'hidden', first_seen_at: '2026-10-25T12:00:00.000Z', last_seen_at: '2026-10-25T12:00:00.000Z',
-- citations: [cite(10000)],
-- });
-- assert.strictEqual(first.created, false);
-- assert.strictEqual(second.created, false);
-- assert.strictEqual(first.entry.id, before.id);
-- assert.strictEqual(second.entry.id, before.id);
assert.strictEqual(Number(await t.ctx.s.db.value("SELECT COUNT(*)::int AS n FROM meme_entries WHERE title_key = 'robot is offline'")), 1);
-- assert.strictEqual(Number(await t.ctx.s.db.value("SELECT COUNT(*)::int AS n FROM meme_citations WHERE source_id IN ('9999', '10000')")), 2);
++ assert.strictEqual(Number(await t.ctx.s.db.value("SELECT COUNT(*)::int AS n FROM meme_citations WHERE source_type IN ('chat_message','rundown_segment','vod')")), before);
});
} finally {
await t.close();
ddiff --git a/test/pages.test.js b/test/pages.test.js
iindex eedb13f..b319b52 100644
---- a/test/pages.test.js
++++ b/test/pages.test.js
@@@ -27,12 +27,15 @@ function jsonLd(html) {
],
});
const describedEntry = await t.ctx.entries.createCandidate({
-- slug: 'cat-typing', title: 'Cat typing', kind: 'meme', status: 'rising',
++ slug: 'cat-typing', title: 'Cat typing', kind: 'meme', scope: 'internet', status: 'rising',
summary: 'A cat at a keyboard, typed as though the cat is answering a work email one slow paw at a time.',
-- first_seen_at: now, last_seen_at: now, visibility: 'public', citations: [],
++ first_seen_at: now, last_seen_at: now, visibility: 'public', citations: [
++ { role: 'origin', source_service: 'web', source_type: 'wikipedia', source_id: 'Cat_typing', quote_text: 'An early example of the cat-at-a-keyboard format.', url: 'https://en.wikipedia.org/wiki/Internet_meme', observed_at: now },
++ { role: 'use', source_service: 'web', source_type: 'commons', source_id: 'cat-typing-example', quote_text: 'A later remix of the cat-at-a-keyboard format.', url: 'https://example.com/cat-typing', observed_at: now },
++ ],
});
const hiddenEntry = await t.ctx.entries.createCandidate({
-- slug: 'not-yet', title: 'Not yet', kind: 'joke', summary: 'Hidden.', status: 'rising',
++ slug: 'not-yet', title: 'Not yet', kind: 'meme', scope: 'internet', summary: 'Hidden.', status: 'rising',
first_seen_at: now, last_seen_at: now, visibility: 'hidden',
citations: [{ role: 'origin', source_service: 'chat', source_type: 'chat_message', source_id: '1002', chat_message_id: 1002, quote_text: 'still the robot is offline, huh', url: `${t.upstreams.url}/`, observed_at: now }],
});
@@@ -41,15 +44,32 @@ function jsonLd(html) {
try {
await check('home: semantic, the entry, the week teaser, public cache with Vary: Cookie', async () => {
const r = await t.get('/');
-- assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');
assert.strictEqual(r.headers.get('vary'), 'Cookie');
-- for (const marker of ['id="mm-home"', 'id="mm-trending"', 'id="mm-week-teaser"', 'class="sc-room-scenes"', '<noscript><nav', '<footer id="ov-footer"', '<main id="main"', 'Robot is offline', '/make']) assert.ok(r.text.includes(marker), marker);
++ for (const marker of ['id="mm-home"', 'id="mm-trending"', 'id="mm-week-teaser"', 'class="sc-room-scenes"', '<noscript><nav', '<footer id="ov-footer"', '<main id="main"', 'Cat typing', '/browse', '/make']) assert.ok(r.text.includes(marker), marker);
const types = jsonLd(r.text).map((x) => x['@type']);
assert.ok(types.includes('WebSite'), types.join(','));
assert.ok(types.includes('WebApplication'), types.join(','));
});
++ await check('the server-rendered meme library searches internet entries', async () => {
++ const r = await t.get('/browse?q=cat');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.ok(r.text.includes('Explore memes'));
++ assert.ok(r.text.includes('Cat typing'));
++ assert.ok(r.text.includes('name="scope"'));
++ assert.ok(r.text.includes('<meta name="robots" content="noindex, nofollow">'));
++ const alias = await t.get('/search?q=cat');
++ assert.strictEqual(alias.status, 200);
++ assert.ok(alias.text.includes('Cat typing'));
++ const collection = await t.get('/browse?scope=internet');
++ assert.ok(collection.text.includes('Cat typing'));
++ const legacy = await t.get('/memes');
++ assert.strictEqual(legacy.status, 301);
++ assert.strictEqual(legacy.headers.get('location'), '/browse?scope=internet');
++ });
++
await check('a page drawn for a signed-in person is private, no-store', async () => {
const r = await t.get('/', { as: alice });
assert.strictEqual(r.status, 200);
@@@ -57,27 +77,42 @@ function jsonLd(html) {
assert.strictEqual(r.headers.get('vary'), 'Cookie');
});
-- await check('one entry page carries the origin link, the timeline and the examples section', async () => {
++ await check('OpenVibe-only catchphrase pages redirect to the library homepage', async () => {
const r = await t.get('/m/robot-is-offline');
++ assert.strictEqual(r.status, 301);
++ assert.strictEqual(r.headers.get('location'), '/');
++ });
++
++ await check('the meme API alias and PNG render use the internet collection and local renderer', async () => {
++ const list = await t.get('/api/v1/memes?scope=internet');
++ assert.strictEqual(list.status, 200);
++ assert.ok(list.json().entries.some((e) => e.slug === 'cat-typing'));
++ const stream = await t.get('/api/v1/memes/robot-is-offline');
++ assert.strictEqual(stream.status, 404);
++ const png = await t.get('/api/v1/render', { as: alice, json: { template_id: tpl.id, lines: ['a meme', 'in PNG'] } });
++ assert.strictEqual(png.status, 200, png.text);
++ assert.match(png.headers.get('content-type'), /image\/png/);
++ });
++
++ await check('an internet meme page carries its origin, timeline and examples', async () => {
++ const r = await t.get('/m/cat-typing');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');
assert.ok(r.text.includes('id="mm-entry"'));
assert.ok(r.text.includes('id="mm-origin"'));
assert.ok(r.text.includes('id="mm-timeline"'));
assert.ok(r.text.includes('id="mm-examples"'));
-- assert.ok(r.text.includes('the robot is offline again'));
-- assert.ok(r.text.includes('the robot is offline for good'));
-- assert.ok(r.text.includes('/v/9002?t=120'));
++ assert.ok(r.text.includes('An early example of the cat-at-a-keyboard format.'));
++ assert.ok(r.text.includes('A later remix of the cat-at-a-keyboard format.'));
++ assert.ok(r.text.includes('https://example.com/cat-typing'));
// The one Share sheet (openvibe-shared 4.114.0): one button, with the entry's own public address.
const share = r.text.match(/<button[^>]*data-ov-share-sheet[^>]*>/g) || [];
assert.strictEqual(share.length, 1, 'one Share button: ' + share.join(' | '));
-- assert.ok(share[0].includes('data-url="https://openvibe.meme/m/robot-is-offline"'), share[0]);
++ assert.ok(share[0].includes('data-url="https://openvibe.meme/m/cat-typing"'), share[0]);
assert.ok(share[0].includes('data-image="/shared/social/meme.jpg'), share[0]);
});
await check('one entry page describes itself for search and share cards', async () => {
-- const fallback = await t.get('/m/robot-is-offline');
-- assert.ok(fallback.text.includes('<meta name="description" content="Robot is offline: where the joke started, how it spread, and every use on OpenVibe.">'), 'the fallback line');
const own = await t.get('/m/cat-typing');
assert.ok(own.text.includes('<meta name="description" content="A cat at a keyboard, typed as though the cat is answering a work email one slow paw at a time.">'), 'the entry\'s own summary');
});
@@@ -105,7 +140,7 @@ function jsonLd(html) {
const r = await t.get('/week');
assert.strictEqual(r.status, 200);
assert.ok(r.text.includes('id="mm-week"'));
-- assert.ok(r.text.includes('Robot is offline'));
++ assert.ok(r.text.includes('Cat typing'));
const signed = await t.get('/week', { as: alice });
assert.strictEqual(signed.status, 200);
assert.strictEqual(signed.headers.get('cache-control'), 'private, no-store');
@@@ -187,12 +222,13 @@ function jsonLd(html) {
const robots = await t.get('/robots.txt');
assert.match(robots.text, /Disallow: \/api\//);
await t.ctx.entries.createCandidate({
-- slug: 'shake-weight', title: 'Shake Weight', kind: 'meme', summary: 'A dumbbell that shakes.', status: 'rising', first_seen_at: now, last_seen_at: now,
++ slug: 'shake-weight', title: 'Shake Weight', kind: 'meme', scope: 'internet', summary: 'A dumbbell that shakes.', status: 'rising', first_seen_at: now, last_seen_at: now,
visibility: 'public', citations: [], image_url: 'https://upload.wikimedia.org/wikipedia/commons/0/01/Shake_Weight.jpg', image_credit: 'Someone', image_license: 'CC BY 2.0',
});
const map = await t.get('/sitemap.xml');
assert.strictEqual(map.status, 200);
-- assert.ok(map.text.includes('https://openvibe.meme/m/robot-is-offline'));
++ assert.ok(map.text.includes('https://openvibe.meme/m/cat-typing'));
++ assert.ok(!map.text.includes('https://openvibe.meme/m/robot-is-offline'), 'stream catchphrases are not indexed');
// SEO-17: an entry's picture is in its <url>; an entry without one has no image entry.
assert.match(map.text, /<loc>https:\/\/openvibe\.meme\/m\/shake-weight<\/loc>[^\n]*<image:image><image:loc>https:\/\/upload\.wikimedia\.org\/wikipedia\/commons\/0\/01\/Shake_Weight\.jpg<\/image:loc><\/image:image>/);
assert.doesNotMatch(map.text, /<loc>https:\/\/openvibe\.meme\/m\/robot-is-offline<\/loc>[^\n]*<image:image>/);
ddiff --git a/test/reverify.test.js b/test/reverify.test.js
iindex a59802c..e1c1fee 100644
---- a/test/reverify.test.js
++++ b/test/reverify.test.js
@@@ -35,7 +35,7 @@ const { boot, check, done } = require('./helpers/boot');
await check('a gone use (not the origin) keeps the entry public and re-indexes it', async () => {
const e4 = await t.ctx.entries.createCandidate({
-- slug: 'use-gone', title: 'Use gone', kind: 'meme', summary: 'A proof.', status: 'rising',
++ slug: 'use-gone', title: 'Use gone', kind: 'classic', scope: 'internet', summary: 'A proof.', status: 'classic',
first_seen_at: '2026-10-20T10:00:00Z', last_seen_at: now, visibility: 'public',
citations: [
{ role: 'origin', source_service: 'chat', source_type: 'chat_message', source_id: '1001', chat_message_id: 1001, quote_text: 'the robot is offline again', url: `${t.upstreams.url}/`, observed_at: '2026-10-20T10:00:00Z' },
ddiff --git a/test/search-relay.test.js b/test/search-relay.test.js
iindex 9dbaf3f..e3ae0d7 100644
---- a/test/search-relay.test.js
++++ b/test/search-relay.test.js
@@@ -25,7 +25,7 @@ function fakeSearch() {
async function makeEntry(t, slug) {
return t.ctx.entries.createCandidate({
-- slug, title: 'Robot is offline', kind: 'catchphrase', summary: 'The robot is offline, again.', status: 'rising',
++ slug, title: 'Robot is offline', kind: 'classic', scope: 'internet', summary: 'The robot is offline, again.', status: 'classic',
first_seen_at: '2026-10-20T10:00:00Z', last_seen_at: new Date().toISOString(), visibility: 'hidden',
citations: [{ role: 'origin', source_service: 'chat', source_type: 'chat_message', source_id: '1001', chat_message_id: 1001, quote_text: 'the robot is offline again', url: `${t.upstreams.url}/`, observed_at: '2026-10-20T10:00:00Z' }],
});
ddiff --git a/test/search.test.js b/test/search.test.js
iindex 86efaff..862a178 100644
---- a/test/search.test.js
++++ b/test/search.test.js
@@@ -14,7 +14,7 @@ const { boot, check, done } = require('./helpers/boot');
const mod = t.network.addUser('mod', { role: 'global_mod' });
const now = new Date().toISOString();
const e = await t.ctx.entries.createCandidate({
-- slug: 'search-proof', title: 'Robot is offline', kind: 'catchphrase', summary: 'The robot is offline, again.', status: 'rising',
++ slug: 'search-proof', title: 'Robot is offline', kind: 'classic', scope: 'internet', summary: 'The robot is offline, again.', status: 'classic',
first_seen_at: '2026-10-20T10:00:00Z', last_seen_at: now, visibility: 'hidden',
citations: [
{ role: 'origin', source_service: 'chat', source_type: 'chat_message', source_id: '1001', chat_message_id: 1001, quote_text: 'the robot is offline again', url: `${t.upstreams.url}/`, observed_at: '2026-10-20T10:00:00Z' },
@@@ -42,8 +42,8 @@ const { boot, check, done } = require('./helpers/boot');
assert.strictEqual(doc.canonical_url, 'https://openvibe.meme/m/search-proof');
assert.strictEqual(doc.title, 'Robot is offline');
assert.strictEqual(doc.revision, 2);
-- assert.strictEqual(doc.facets.kind, 'catchphrase');
-- assert.strictEqual(doc.facets.status, 'rising');
++ assert.strictEqual(doc.facets.kind, 'classic');
++ assert.strictEqual(doc.facets.status, 'classic');
assert.deepStrictEqual(doc.facets.source.sort(), ['chat', 'live']);
assert.strictEqual(doc.authorship, 'ai_assisted');
assert.strictEqual(doc.provenance.length, 2);
ddiff --git a/test/votes.test.js b/test/votes.test.js
iindex da93d5a..4a6880b 100644
---- a/test/votes.test.js
++++ b/test/votes.test.js
@@@ -13,7 +13,7 @@ const { boot, check, done } = require('./helpers/boot');
const bob = t.network.addUser('bob');
const now = new Date().toISOString();
const mk = (slug, title, visibility = 'public', observed = now) => t.ctx.entries.createCandidate({
-- slug, title, kind: 'meme', summary: 'A candidate.', status: 'rising',
++ slug, title, kind: 'meme', scope: 'internet', summary: 'A candidate.', status: 'rising',
first_seen_at: observed, last_seen_at: observed, visibility,
citations: [{ role: 'origin', source_service: 'chat', source_type: 'chat_message', source_id: String(Math.floor(Math.random() * 1e9)), chat_message_id: 1, quote_text: 'a repeated phrase here', url: `${t.upstreams.url}/`, observed_at: observed }],
});