openvibers/openvibe.meme Commits
Rebuild Meme library and maker foundation (#123)
Changes
@@@ -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 stathe 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 summaryfalls 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 owncitations — with the chat lines and transcript lines around each quote — and to write the entry from that materialonly: 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`, a401/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 stayspending, 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 arecanonicalised 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` listthe public pages and every public entry.@@@ -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; }@@@ -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(); }@@@ -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, {@@@ -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,@@@ -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),@@@ -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.');@@@ -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,@@@ -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);}@@@ -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;}@@@ -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' },];@@@ -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.@@@ -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 mineFROM meme_entries eWHERE e.visibility = 'public'++ AND e.scope = 'internet'AND EXISTS (SELECT 1 FROM meme_citations cWHERE 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,@@@ -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 tconst 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 };@@@ -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}`);@@@ -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');@@@ -15,7 +15,7 @@ assert.ok(!empty.includes('id="mm-trending"') && !empty.includes('id="mm-week-teassert.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 } });@@@ -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();@@@ -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>/);@@@ -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' },@@@ -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' }],});@@@ -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);@@@ -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 }],});
@@ -6,9 +6,11 @@
@@ -6,9 +6,11 @@
**Domain:** `openvibe.meme` · **Port:** 5120 · **Service id:** `meme` · **Env prefix:** `MEME`
**Domain:** `openvibe.meme` · **Port:** 5120 · **Service id:** `meme` · **Env prefix:** `MEME`
**License:** AGPL-3.0-only (same as every OpenVibe service).
**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
## Purpose
@@ -16,9 +18,9 @@ a chat line, a VOD second, a Rundown segment — a timeline of later uses, a sta
@@ -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`),
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
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.
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
- **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
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.
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
@@ -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
- **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).
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.
- **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`),
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
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
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`
@@ -142,6 +144,8 @@ also gets per-actor minute/hour limits (`MEME_LIMITS_MINUTE`, `MEME_LIMITS_HOUR`
| Route | What it does |
| 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 /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` | 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/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 |
| `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`
@@ -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 |
| `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 |
| `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` |
| `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 /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 |
| `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 |
| `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
@@ -272,22 +277,17 @@ The saved caption carries the labels and the `med_…` id. Two calls per request
## The importer
## The importer
`server/importer.js` runs every 30 minutes (`MEME_IMPORTER_INTERVAL_MS`), off under `MEME_DRILL`; tests call
`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.
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.
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
## Citations and re-verification
@@ -321,6 +321,8 @@ signed-in person is `private, no-store`.
@@ -321,6 +321,8 @@ signed-in person is `private, no-store`.
| Page | What it is |
| Page | What it is |
|---|---|
|---|---|
| `/` | trending, meme of the week, the generator teaser |
| `/` | 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 |
| `/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`) |
| `/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`) |
| `/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`.
@@ -328,8 +330,8 @@ signed-in person is `private, no-store`.
| `/developers` | the API, the Search document, the importer's sources |
| `/developers` | the API, the Search document, the importer's sources |
| 404 | the not-found page |
| 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
`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.
the public pages and every public entry.
@@ -68,6 +68,23 @@ html.ov-motion .mm-sticker { animation: mm-fan .7s cubic-bezier(.2,.9,.3,1.3) bo
@@ -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-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-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-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 */
/* 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); }
.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
@@ -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 { 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-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 { 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 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 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; }
.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)
@@ -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. */
/* 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). */
/* 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: 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; } }
@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; }
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; }
@@ -30,6 +30,41 @@
@@ -30,6 +30,41 @@
}
}
form.addEventListener('input', draw);
form.addEventListener('input', draw);
form.addEventListener('change', 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();
draw();
}
}
function run() { maker(); }
function run() { maker(); }
@@ -15,19 +15,19 @@ function createAgentPages(ctx) {
@@ -15,19 +15,19 @@ function createAgentPages(ctx) {
const page = (path, o) => ({ html: renderPage({ viewer: { kind: 'anonymous' }, config, path, agentLayer: true, ...o }), title: o.title });
const page = (path, o) => ({ html: renderPage({ viewer: { kind: 'anonymous' }, config, path, agentLayer: true, ...o }), title: o.title });
async function counts() {
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 };
return rows[0] || { entries: 0, citations: 0 };
}
}
async function home() {
async function home() {
const { week } = currentWeek(s.now());
const { week } = currentWeek(s.now());
- const [trending, weekOut, c] = await Promise.all([entries.trending({ limit: 8 }), entries.week({ week }), counts()]);
- const 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('/', {
return page('/', {
index: true, title: null,
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),
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) {
@@ -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 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;
const preview = chosen && previewLines.length ? { template: chosen, lines: previewLines } : null;
return page('/make', {
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 })),
body: raw(views.make({ templates, values: { ...values, template_id: chosen ? chosen.id : '' }, preview, signedIn: false, problem: null })),
});
});
}
}
@@ -49,6 +49,16 @@ function createAgentPages(ctx) {
@@ -49,6 +49,16 @@ function createAgentPages(ctx) {
const p = String(rawPath || '/').replace(/\/+$/, '') || '/';
const p = String(rawPath || '/').replace(/\/+$/, '') || '/';
const q = query instanceof URLSearchParams ? query : new URLSearchParams();
const q = query instanceof URLSearchParams ? query : new URLSearchParams();
if (p === '/') return home();
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') {
if (p === '/week') {
const { week } = currentWeek(s.now());
const { week } = currentWeek(s.now());
const out = await entries.week({ week });
const out = await entries.week({ week });
@@ -72,7 +82,7 @@ function createAgentPages(ctx) {
@@ -72,7 +82,7 @@ function createAgentPages(ctx) {
let slug;
let slug;
try { slug = decodeURIComponent(entryMatch[1]); } catch { return null; }
try { slug = decodeURIComponent(entryMatch[1]); } catch { return null; }
const row = await entries.bySlug(slug);
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 citations = await entries.citations(row.id);
const e = entries.wireEntry(row);
const e = entries.wireEntry(row);
return page(p, {
return page(p, {
@@ -211,6 +211,7 @@ function createClassicsJob({ config, s, entries, sources, judge, log = console }
@@ -211,6 +211,7 @@ function createClassicsJob({ config, s, entries, sources, judge, log = console }
slug,
slug,
title,
title,
kind: 'classic',
kind: 'classic',
+ scope: 'internet',
summary,
summary,
status: 'classic',
status: 'classic',
first_seen_at: built.inception,
first_seen_at: built.inception,
@@ -134,14 +134,12 @@ function load(env = process.env) {
@@ -134,14 +134,12 @@ function load(env = process.env) {
register: env.MEME_SOURCES_REGISTER === '1' || env.MEME_SOURCES_REGISTER === 'true',
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: {
importer: {
intervalMs: Math.max(60_000, int(env.MEME_IMPORTER_INTERVAL_MS, 30 * 60_000)),
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))),
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))),
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
// 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) {
@@ -187,7 +185,7 @@ function load(env = process.env) {
userAgent: env.MEME_WIKI_USER_AGENT || 'OpenVibe.Meme/0.2 (+https://openvibe.meme; [email protected])',
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)),
timeoutMs: Math.max(1000, int(env.MEME_WIKI_TIMEOUT_MS, 20_000)),
minIntervalMs: Math.max(0, int(env.MEME_WIKI_MIN_INTERVAL_MS, 250)),
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.
// 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),
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),
categories: String(env.MEME_ENCYCLOPEDIA_CATEGORIES || 'Internet memes,Memes').split(',').map((x) => x.trim()).filter(Boolean),
@@ -4,12 +4,14 @@
@@ -4,12 +4,14 @@
* /api/v1 — OpenVibe.Meme's JSON API.
* /api/v1 — OpenVibe.Meme's JSON API.
*
*
* GET /entries?limit=&cursor=&kind=&status= public entries, newest first, cursor-paged
* 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 /entries/:slug/series?days=30 uses per UTC day, zero-filled
* GET /trending rising first
* GET /trending rising first
* GET /week this ISO week's candidates and vote counts
* GET /week this ISO week's candidates and vote counts
* POST /week/vote one vote a week (signed in, same-origin)
* POST /week/vote one vote a week (signed in, same-origin)
* GET /templates caption templates
* GET /templates caption templates
+ * POST /render render text lines to PNG without an AI call
* POST /captions save a caption (signed in; text only)
* POST /captions save a caption (signed in; text only)
* GET /captions/:id one saved caption
* GET /captions/:id one saved caption
* POST /entries/:slug/report a takedown/correction report (signed in)
* POST /entries/:slug/report a takedown/correction report (signed in)
@@ -28,7 +30,8 @@ const { asyncRouter } = require('./router');
@@ -28,7 +30,8 @@ const { asyncRouter } = require('./router');
const { sameOrigin } = require('./principal');
const { sameOrigin } = require('./principal');
const { isStaff } = require('../identity/staff');
const { isStaff } = require('../identity/staff');
const { currentWeek } = require('../week');
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) {
function createApi(ctx) {
const { config, s, principal, entries, trends, analyzer, research, generator } = ctx;
const { config, s, principal, entries, trends, analyzer, research, generator } = ctx;
@@ -104,14 +107,14 @@ function createApi(ctx) {
@@ -104,14 +107,14 @@ function createApi(ctx) {
// GET /entries?scope=&q=&status=&label=&kind=&sort=newest|top&window=day|week|month|all&channel=
// 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,
// `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.
// `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);
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');
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;
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(', ')}`);
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;
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(', ')}`);
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(', ')}`);
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;
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(', ')}`);
if (label && !LABELS.includes(label)) return invalid(res, `label must be one of ${LABELS.join(', ')}`);
@@ -136,9 +139,10 @@ function createApi(ctx) {
@@ -136,9 +139,10 @@ function createApi(ctx) {
return res.json({ count: checkout.entries.length, entries: checkout.entries, next_cursor: checkout.next_cursor });
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' });
const row = await bySlugOrRedirect(String(req.params.slug || ''), req, res, { suffix: '/series' });
if (row === undefined) return;
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.');
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);
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');
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) {
@@ -149,9 +153,10 @@ function createApi(ctx) {
// GET /entries/:slug — the origin, citations, spread, variations, Media examples, related memes and the
// 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.
// 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);
const row = await bySlugOrRedirect(String(req.params.slug || ''), req, res);
if (row === undefined) return;
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 staff = isStaff(req.principal);
const mine = Boolean(row && row.submitted_by && req.principal.kind === 'user' && req.principal.subject === row.submitted_by);
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.');
if (!row || (row.visibility === 'hidden' && !staff && !mine)) return problem(res, 404, 'meme.not_found', 'No such meme.');
@@ -216,6 +221,25 @@ function createApi(ctx) {
@@ -216,6 +221,25 @@ function createApi(ctx) {
return res.json({ count: counted.length, templates: counted });
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) => {
r.get('/captions/:id', async (req, res) => {
const row = await entries.captionById(String(req.params.id || ''));
const row = await entries.captionById(String(req.params.id || ''));
if (!row) return problem(res, 404, 'meme.not_found', 'No such caption.');
if (!row) return problem(res, 404, 'meme.not_found', 'No such caption.');
@@ -12,7 +12,7 @@ const cache = require('openvibe-shared/cache-policy');
@@ -12,7 +12,7 @@ const cache = require('openvibe-shared/cache-policy');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const SITE_NAME = 'OpenVibe.Meme';
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'];
const DISALLOW = ['/auth/', '/api/', '/internal', '/metrics'];
function dayOf(ts) {
function dayOf(ts) {
@@ -27,16 +27,17 @@ function homeJsonLd(config) {
@@ -27,16 +27,17 @@ function homeJsonLd(config) {
const site = String(config.baseUrl).replace(/\/+$/, '');
const site = String(config.baseUrl).replace(/\/+$/, '');
return [
return [
seo.jsonLd.website({ name: SITE_NAME, url: site, description: DESCRIPTION }),
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 }),
seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),
];
];
}
}
function publicPages() {
function publicPages() {
return [
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: '/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.' },
{ 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) {
@@ -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.
// /api/ paths and other sites' links as they are). What an agent may skip is in `optional`, written last.
markdown: true,
markdown: true,
base: site,
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: [
sections: [
{ title: 'Start here', links: [
{ 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 ballot', url: abs('/week') },
- { title: 'The caption generator', url: abs('/make') },
+ { title: 'Meme maker', url: abs('/make') },
{ title: 'The API', url: abs('/developers') },
{ title: 'The API', url: abs('/developers') },
] },
] },
{ title: 'Machine-readable', links: [
{ title: 'Machine-readable', links: [
@@ -90,7 +92,7 @@ function createDiscoveryRoutes(ctx) {
@@ -90,7 +92,7 @@ function createDiscoveryRoutes(ctx) {
});
});
r.get('/llms-full.txt', async (_req, res) => {
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(' · ') }));
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({
res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({
site: SITE_NAME,
site: SITE_NAME,
@@ -107,7 +109,7 @@ function createDiscoveryRoutes(ctx) {
@@ -107,7 +109,7 @@ function createDiscoveryRoutes(ctx) {
r.get('/sitemap.xml', async (_req, res) => {
r.get('/sitemap.xml', async (_req, res) => {
const lastmod = siteUpdated();
const lastmod = siteUpdated();
// An entry's licensed picture rides its <url> as an image sitemap entry (SEO-17), so image search can find it.
// 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 = [
const urls = [
...pages.map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority })),
...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,
...rows.map((e) => ({ loc: abs(`/m/${e.slug}`), lastmod: dayOf(e.updated_at) || lastmod, changefreq: 'weekly', priority: 0.7,
@@ -5,7 +5,8 @@
@@ -5,7 +5,8 @@
* Public pages are served `public, max-age=60` with Vary: Cookie; a page drawn for a signed-in person is
* Public pages are served `public, max-age=60` with Vary: Cookie; a page drawn for a signed-in person is
* `private, no-store`.
* `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
* /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)
* /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)
* /make the caption generator form (POST /make) with a server-rendered preview (GET /make)
@@ -39,24 +40,53 @@ function createPageRoutes(ctx) {
@@ -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);
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() {
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 };
return rows[0] || { entries: 0, citations: 0 };
}
}
// ── Home ─────────────────────────────────────────────────
// ── Home ─────────────────────────────────────────────────
r.get('/', async (req, res) => {
r.get('/', async (req, res) => {
const { week } = currentWeek(s.now());
const { week } = currentWeek(s.now());
- const [trending, weekOut, c] = await Promise.all([entries.trending({ limit: 8 }), entries.week({ week }), counts()]);
- const 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, {
return page(req, res, {
index: true, cache: PUBLIC_CACHE, agentLayer: true,
index: true, cache: PUBLIC_CACHE, agentLayer: true,
title: null,
title: null,
- description: 'The open meme database, rebuilt with AI: every entry cites the moment it happened, follows its later uses, and people vote for meme of the week.',
+ description: 'Explore an open meme encyclopedia: learn what memes mean, trace their origins and variations, and make your own with reusable templates.',
jsonLd: homeJsonLd(config),
jsonLd: homeJsonLd(config),
body: raw(views.home({ trending, week: weekOut, counts: c, feature })),
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 ────────────────────────────────────────────
// ── One entry ────────────────────────────────────────────
/** A search result and share-card line: the entry's own summary or first use, cut at a word to 155 characters. */
/** 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 = []) {
function metaDescription(e, citations = []) {
@@ -85,6 +115,8 @@ function createPageRoutes(ctx) {
@@ -85,6 +115,8 @@ function createPageRoutes(ctx) {
}
}
}
}
if (!row || (row.visibility === 'hidden' && !staff)) return notFound(req, res);
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 citations = await entries.citations(row.id);
const e = entries.wireEntry(row);
const e = entries.wireEntry(row);
const description = metaDescription(e, citations);
const description = metaDescription(e, citations);
@@ -160,8 +192,8 @@ function createPageRoutes(ctx) {
@@ -160,8 +192,8 @@ function createPageRoutes(ctx) {
const preview = chosen && previewLines.length ? { template: chosen, lines: previewLines } : null;
const preview = chosen && previewLines.length ? { template: chosen, lines: previewLines } : null;
return page(req, res, {
return page(req, res, {
index: true, cache: PUBLIC_CACHE, agentLayer: true,
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 })),
body: raw(views.make({ templates, values: { ...values, template_id: chosen ? chosen.id : '' }, preview, signedIn: Boolean(signedIn(req)), problem })),
}, status);
}, status);
}
}
@@ -1,19 +1,17 @@
@@ -1,19 +1,17 @@
'use strict';
'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().
* 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 { parseApiTime } = require('./upstreams');
const { isDenied } = require('./deny-list');
const { isDenied } = require('./deny-list');
-const { cleanSummary } = require('./text/no-looks');
-const { slugify } = require('./store');
const MIN_WORDS = 3;
const MIN_WORDS = 3;
const MAX_WORDS = 6;
const MAX_WORDS = 6;
@@ -115,11 +113,9 @@ function createImporter({ config, s, entries, upstreams, fetchImpl = globalThis.
@@ -115,11 +113,9 @@ function createImporter({ config, s, entries, upstreams, fetchImpl = globalThis.
async function collect() {
async function collect() {
const errors = [];
const errors = [];
const uses = [];
const uses = [];
- const hints = [];
const canon = resolver();
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 {
try {
const history = await upstreams.chatHistory({ limit: 200 });
const history = await upstreams.chatHistory({ limit: 200 });
for (const m of history.messages) {
for (const m of history.messages) {
@@ -135,11 +131,6 @@ function createImporter({ config, s, entries, upstreams, fetchImpl = globalThis.
@@ -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}`); }
} 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.
// Rundown: every quote is verbatim speech with a VOD second already converted to VOD time.
const vods = new Set();
const vods = new Set();
try {
try {
@@ -192,11 +183,11 @@ function createImporter({ config, s, entries, upstreams, fetchImpl = globalThis.
@@ -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}`); }
} 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. */
/** 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]
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))
.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);
.slice(0, MAX_USES_PER_PHRASE);
@@ -214,53 +205,45 @@ function createImporter({ config, s, entries, upstreams, fetchImpl = globalThis.
@@ -214,53 +205,45 @@ function createImporter({ config, s, entries, upstreams, fetchImpl = globalThis.
}
}
}
}
if (isDenied(c)) continue;
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;
return out;
}
}
async function runOnce() {
async function runOnce() {
const runAt = new Date(s.now()).toISOString();
const runAt = new Date(s.now()).toISOString();
- const { uses, hints, errors } = await collect();
+ const { uses, errors } = await collect();
const canon = resolver();
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;
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 {
try {
- const cites = await materialize(candidate, canon);
+ const cites = await materialize({ phrase: title, uses: namedUses }, canon, { origin: false });
if (!cites.length) continue;
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 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 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) {
} 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.runs++;
stats.uses = uses.length;
stats.uses = uses.length;
stats.candidates = found.length;
stats.candidates = found.length;
- stats.created += created;
stats.enriched += enriched;
stats.enriched += enriched;
stats.errors += errors.length;
stats.errors += errors.length;
stats.last_run_at = runAt;
stats.last_run_at = runAt;
stats.last_error = errors[0] || null;
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;
return out;
}
}
@@ -16,12 +16,13 @@ const { render, esc } = require('./html');
@@ -16,12 +16,13 @@ const { render, esc } = require('./html');
const NETWORK_URL = 'https://openvibe.network';
const NETWORK_URL = 'https://openvibe.network';
const SITE_NAME = 'OpenVibe.Meme';
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 PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const NAV = [
const NAV = [
{ label: 'Home', href: '/' },
{ label: 'Home', href: '/' },
+ { label: 'Explore', href: '/browse' },
{ label: 'This week', href: '/week' },
{ label: 'This week', href: '/week' },
- { label: 'Make a caption', href: '/make' },
+ { label: 'Make a meme', href: '/make' },
{ label: 'Developers', href: '/developers' },
{ label: 'Developers', href: '/developers' },
];
];
@@ -43,7 +43,9 @@ function provenanceOf(citations) {
@@ -43,7 +43,9 @@ function provenanceOf(citations) {
/** One entry + its live citations + its internet origin → the Search document (or a tombstone). */
/** One entry + its live citations + its internet origin → the Search document (or a tombstone). */
function documentOf(entry, citations = [], { baseUrl = 'https://openvibe.meme', origin = null } = {}) {
function documentOf(entry, citations = [], { baseUrl = 'https://openvibe.meme', origin = null } = {}) {
const revision = Math.max(1, Number(entry.revision) || 1);
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 live = (citations || []).filter((c) => c.source_status === 'live');
const sources = [...new Set(live.map((c) => c.source_service))];
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.
// The snippet reads the summary and the body together: the body leaves out the title and summary.
@@ -647,6 +647,15 @@ function createStore(s, { search }) {
@@ -647,6 +647,15 @@ function createStore(s, { search }) {
return rows.map(wireEntry);
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. */
/** The current week's candidates (public entries with a live use that week) and their ballot counts. */
async function week({ week: label, subject = null } = {}) {
async function week({ week: label, subject = null } = {}) {
const bounds = weekBounds(label);
const bounds = weekBounds(label);
@@ -657,6 +666,7 @@ function createStore(s, { search }) {
@@ -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
(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
FROM meme_entries e
WHERE e.visibility = 'public'
WHERE e.visibility = 'public'
+ AND e.scope = 'internet'
AND EXISTS (SELECT 1 FROM meme_citations c
AND EXISTS (SELECT 1 FROM meme_citations c
WHERE c.entry_id = e.id AND c.source_status = 'live'
WHERE c.entry_id = e.id AND c.source_status = 'live'
AND c.observed_at >= $3::timestamptz AND c.observed_at < $4::timestamptz)
AND c.observed_at >= $3::timestamptz AND c.observed_at < $4::timestamptz)
@@ -719,7 +729,7 @@ function createStore(s, { search }) {
@@ -719,7 +729,7 @@ function createStore(s, { search }) {
* encyclopedia entry ('classic') is inserted public. Citations that name the deny list are dropped here too.
* encyclopedia entry ('classic') is inserted public. Citations that name the deny list are dropped here too.
*/
*/
async function createEntry({
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,
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,
image_license_url = null, image_source_url = null, attribution = null, external_id = null,
}, t = null) {
}, t = null) {
@@ -732,11 +742,11 @@ function createStore(s, { search }) {
@@ -732,11 +742,11 @@ function createStore(s, { search }) {
let last = iso(last_seen_at) || first;
let last = iso(last_seen_at) || first;
if (Date.parse(first) > Date.parse(last)) { const swap = first; first = last; last = swap; }
if (Date.parse(first) > Date.parse(last)) { const swap = first; first = last; last = swap; }
const row = await q(tx).maybe(
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)
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,
image_url || null, image_credit || null, image_license || null, image_license_url || null, image_source_url || null,
attribution || null, external_id || null],
attribution || null, external_id || null],
);
);
@@ -744,7 +754,7 @@ function createStore(s, { search }) {
@@ -744,7 +754,7 @@ function createStore(s, { search }) {
await q(tx).exec(
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)
`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`,
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);
const entry = wireEntry(row);
if (entry.visibility === 'public') await search.sync(tx, entry, added);
if (entry.visibility === 'public') await search.sync(tx, entry, added);
@@ -1389,7 +1399,7 @@ function createStore(s, { search }) {
@@ -1389,7 +1399,7 @@ function createStore(s, { search }) {
wireEntry, wireCitation, wireOrigin, wireSpread, wireVariation, wireExample, wireRelated, wireRevision,
wireEntry, wireCitation, wireOrigin, wireSpread, wireVariation, wireExample, wireRelated, wireRevision,
wireTemplate, wireCaption, wireReport, wireJudgement,
wireTemplate, wireCaption, wireReport, wireJudgement,
bySlug, byId, byTitleKey, redirectFor, citations, liveCitations, originOf, spreadOf, variationsOf, examplesOf, relatedOf, revisionsOf, detail,
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,
createCandidate, createEntry, upsertCandidate, addCitations, enrich, reindex, publish, hide, patch,
createProposal, addRevision, reviewRevision, pendingRevisions,
createProposal, addRevision, reviewRevision, pendingRevisions,
templates, templateById, addTemplate, createCaption, captionById, captionCountForTemplate,
templates, templateById, addTemplate, createCaption, captionById, captionCountForTemplate,
@@ -2,13 +2,14 @@
@@ -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
* 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
* 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
* 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
* caption({ caption, template, mine }) one saved caption, text over the image
* developers() the API
* developers() the API
* signIn({ title, text, next }) a page that needs an account
* signIn({ title, text, next }) a page that needs an account
@@ -69,17 +70,13 @@ function empty(text, href = '/week', label = 'This week’s ballot') {
@@ -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. */
/** The room's picture: a meme's family tree growing from its first moment, stickers fanning off the branches. */
function treeMotif(feature = null) {
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">
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>
<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>`;
</div>`;
}
}
@@ -119,36 +116,39 @@ function home({ trending = [], week = null, counts = {}, feature = null } = {})
@@ -119,36 +116,39 @@ function home({ trending = [], week = null, counts = {}, feature = null } = {})
<ol class="mm-entries mm-entries-week">${candidates.map(card).join('')}</ol>
<ol class="mm-entries mm-entries-week">${candidates.map(card).join('')}</ol>
</section>` : '';
</section>` : '';
const firsts = [
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);
].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 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 live = `${trendingSec}${weekSec}`;
const room = sc.room({
const room = sc.room({
room: 'meme',
room: 'meme',
hero: {
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' }] : [],
proof: Number(counts.entries) ? [{ value: Number(counts.entries), label: Number(counts.entries) === 1 ? 'entry' : 'entries' }] : [],
motif: treeMotif(feature),
motif: treeMotif(feature),
},
},
scenes: [
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: [
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.
// 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
@@ -223,25 +223,45 @@ ${signedIn ? `<form method="post" action="/week/vote" class="mm-inline"><input t
const LINES = [1, 2, 3, 4];
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 } = {}) {
function make({ templates = [], values = {}, preview = null, signedIn = false, problem = null } = {}) {
const v = { template_id: templates[0] ? templates[0].id : '', visibility: 'public', ...values };
const v = { template_id: templates[0] ? templates[0].id : '', visibility: 'public', ...values };
const lines = LINES.map((i) => v[`line${i}`] || '');
const lines = LINES.map((i) => v[`line${i}`] || '');
return `<div class="mm-wrap" id="mm-make">
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>` : ''}
${problem ? `<p class="mm-note is-bad" role="alert">${esc(problem)}</p>` : ''}
<div class="mm-make-grid">
<div class="mm-make-grid">
<form id="mm-caption-form" class="mm-form mm-card" method="post" action="/make">
<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('')}
${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>
</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>
<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>
<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>'}
${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>
</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>'}
${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>
</aside>
</div>
</div>
</div>`;
</div>`;
@@ -271,11 +291,14 @@ function developers() {
@@ -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>
<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">
<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/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/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/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/trending', 'Public entries, rising first.')}
${ep('GET', '/api/v1/week', 'This ISO week’s candidates and their vote counts.')}
${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('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).')}
${ep('GET', '/api/v1/captions/:id', 'One saved caption (a hidden one is 404 unless it is yours or you are staff).')}
</dl></section>
</dl></section>
<section><h2>Write (signed in)</h2><dl class="mm-eps">
<section><h2>Write (signed in)</h2><dl class="mm-eps">
@@ -297,4 +320,4 @@ function notFound() {
@@ -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>`;
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 };
@@ -32,7 +32,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -32,7 +32,7 @@ const { boot, check, done } = require('./helpers/boot');
assert.strictEqual(home.status, 200);
assert.strictEqual(home.status, 200);
assert.match(home.headers.get('content-type'), /text\/markdown/);
assert.match(home.headers.get('content-type'), /text\/markdown/);
assert.ok(home.text.startsWith('---\ntitle:'));
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);
const r = await t.get(path);
assert.strictEqual(r.status, 200, path);
assert.strictEqual(r.status, 200, path);
assert.ok(r.text.includes(heading), `${path} missing ${heading}`);
assert.ok(r.text.includes(heading), `${path} missing ${heading}`);
@@ -1,7 +1,5 @@
@@ -1,7 +1,5 @@
'use strict';
'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 assert = require('assert');
const views = require('../server/views');
const views = require('../server/views');
@@ -11,19 +9,16 @@ const feature = {
@@ -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 }] },
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 });
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(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({});
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');
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');
console.log('meme home: the citation and rise scenes read the first real entry, or say there is none');
@@ -15,7 +15,7 @@ assert.ok(!empty.includes('id="mm-trending"') && !empty.includes('id="mm-week-te
@@ -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');
assert.ok(!empty.includes('href="#mm-trending"'), 'no chip to a section that is not there');
const scenes = empty.indexOf('class="sc-room-scenes"');
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(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 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 } });
const some = views.home({ trending: [entry], week: { candidates: [entry] }, counts: { entries: 1 } });
@@ -1,79 +1,56 @@
@@ -1,79 +1,56 @@
'use strict';
'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 assert = require('assert');
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
(async () => {
(async () => {
const t = await boot();
const t = await boot();
try {
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;
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();
out = await t.ctx.importer.runOnce();
assert.strictEqual(out.errors.length, 0, JSON.stringify(out.errors));
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.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");
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.vod_id), 9002);
assert.strictEqual(Number(c.offset_seconds), 120);
assert.strictEqual(Number(c.offset_seconds), 120);
assert.match(c.url, /\/v\/9002\?t=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');
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);
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();
const again = await t.ctx.importer.runOnce();
assert.strictEqual(again.created, 0);
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_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 {
} finally {
await t.close();
await t.close();
@@ -27,12 +27,15 @@ function jsonLd(html) {
@@ -27,12 +27,15 @@ function jsonLd(html) {
],
],
});
});
const describedEntry = await t.ctx.entries.createCandidate({
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.',
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({
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',
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 }],
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) {
@@ -41,15 +44,32 @@ function jsonLd(html) {
try {
try {
await check('home: semantic, the entry, the week teaser, public cache with Vary: Cookie', async () => {
await check('home: semantic, the entry, the week teaser, public cache with Vary: Cookie', async () => {
const r = await t.get('/');
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('cache-control'), 'public, max-age=60');
assert.strictEqual(r.headers.get('vary'), 'Cookie');
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']);
const types = jsonLd(r.text).map((x) => x['@type']);
assert.ok(types.includes('WebSite'), types.join(','));
assert.ok(types.includes('WebSite'), types.join(','));
assert.ok(types.includes('WebApplication'), 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 () => {
await check('a page drawn for a signed-in person is private, no-store', async () => {
const r = await t.get('/', { as: alice });
const r = await t.get('/', { as: alice });
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
@@ -57,27 +77,42 @@ function jsonLd(html) {
@@ -57,27 +77,42 @@ function jsonLd(html) {
assert.strictEqual(r.headers.get('vary'), 'Cookie');
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');
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.status, 200);
assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');
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-entry"'));
assert.ok(r.text.includes('id="mm-origin"'));
assert.ok(r.text.includes('id="mm-origin"'));
assert.ok(r.text.includes('id="mm-timeline"'));
assert.ok(r.text.includes('id="mm-timeline"'));
assert.ok(r.text.includes('id="mm-examples"'));
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.
// 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) || [];
const share = r.text.match(/<button[^>]*data-ov-share-sheet[^>]*>/g) || [];
assert.strictEqual(share.length, 1, 'one Share button: ' + share.join(' | '));
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]);
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 () => {
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');
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');
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) {
@@ -105,7 +140,7 @@ function jsonLd(html) {
const r = await t.get('/week');
const r = await t.get('/week');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.ok(r.text.includes('id="mm-week"'));
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 });
const signed = await t.get('/week', { as: alice });
assert.strictEqual(signed.status, 200);
assert.strictEqual(signed.status, 200);
assert.strictEqual(signed.headers.get('cache-control'), 'private, no-store');
assert.strictEqual(signed.headers.get('cache-control'), 'private, no-store');
@@ -187,12 +222,13 @@ function jsonLd(html) {
@@ -187,12 +222,13 @@ function jsonLd(html) {
const robots = await t.get('/robots.txt');
const robots = await t.get('/robots.txt');
assert.match(robots.text, /Disallow: \/api\//);
assert.match(robots.text, /Disallow: \/api\//);
await t.ctx.entries.createCandidate({
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',
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');
const map = await t.get('/sitemap.xml');
assert.strictEqual(map.status, 200);
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.
// 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.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>/);
assert.doesNotMatch(map.text, /<loc>https:\/\/openvibe\.meme\/m\/robot-is-offline<\/loc>[^\n]*<image:image>/);
@@ -35,7 +35,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -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 () => {
await check('a gone use (not the origin) keeps the entry public and re-indexes it', async () => {
const e4 = await t.ctx.entries.createCandidate({
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',
first_seen_at: '2026-10-20T10:00:00Z', last_seen_at: now, visibility: 'public',
citations: [
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' },
{ 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' },
@@ -25,7 +25,7 @@ function fakeSearch() {
@@ -25,7 +25,7 @@ function fakeSearch() {
async function makeEntry(t, slug) {
async function makeEntry(t, slug) {
return t.ctx.entries.createCandidate({
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',
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' }],
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' }],
});
});
@@ -14,7 +14,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -14,7 +14,7 @@ const { boot, check, done } = require('./helpers/boot');
const mod = t.network.addUser('mod', { role: 'global_mod' });
const mod = t.network.addUser('mod', { role: 'global_mod' });
const now = new Date().toISOString();
const now = new Date().toISOString();
const e = await t.ctx.entries.createCandidate({
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',
first_seen_at: '2026-10-20T10:00:00Z', last_seen_at: now, visibility: 'hidden',
citations: [
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' },
{ 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');
@@ -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.canonical_url, 'https://openvibe.meme/m/search-proof');
assert.strictEqual(doc.title, 'Robot is offline');
assert.strictEqual(doc.title, 'Robot is offline');
assert.strictEqual(doc.revision, 2);
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.deepStrictEqual(doc.facets.source.sort(), ['chat', 'live']);
assert.strictEqual(doc.authorship, 'ai_assisted');
assert.strictEqual(doc.authorship, 'ai_assisted');
assert.strictEqual(doc.provenance.length, 2);
assert.strictEqual(doc.provenance.length, 2);
@@ -13,7 +13,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -13,7 +13,7 @@ const { boot, check, done } = require('./helpers/boot');
const bob = t.network.addUser('bob');
const bob = t.network.addUser('bob');
const now = new Date().toISOString();
const now = new Date().toISOString();
const mk = (slug, title, visibility = 'public', observed = now) => t.ctx.entries.createCandidate({
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,
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 }],
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 }],
});
});