// The READER side of the published /tags.json. // // `lib/curatedTags.ts` is the authoring model — defs, rules, assignments, and // the folds the index build runs. This file is the other end of the wire: what // a viewer, an MCP source or any other client does with the small presentation // document compose-site writes out. It is deliberately a separate module from // the frozen one: // // - the authoring model never travels to a browser (rules, regexes, // provenance and every assignment stay server-side), and // - a published document is UNTRUSTED input to its reader — a federated hub // reads /tags.json from origins it does not control — so the coercion has // to be as tolerant as coerceAliasConfig, dropping anything malformed // rather than throwing. // // Absence is a legitimate empty state at every layer: compose-site writes no // file for a site with nothing shippable (exactly like duplicates.json), and a // site built before corpus spec 4 has never heard of tags at all. Both arrive // here as `[]`, and every caller must treat that as "this site publishes no // curated tags" rather than as an error. import type { PublishedTag, PublishedTags } from "./curatedTags"; import { TAG_ID_RE } from "./curatedTags"; function str(v: unknown): string | undefined { if (typeof v !== "string") return undefined; const s = v.trim(); return s === "" ? undefined : s; } function num(v: unknown): number | undefined { return typeof v === "number" && Number.isFinite(v) ? v : undefined; } function coerceChannels(v: unknown): Record { const out: Record = {}; if (!v || typeof v !== "object") return out; for (const [slug, n] of Object.entries(v as Record)) { const count = num(n); if (!slug.trim() || count === undefined || count < 0) continue; out[slug] = Math.floor(count); } return out; } function coerceTag(raw: unknown): PublishedTag | null { if (!raw || typeof raw !== "object") return null; const r = raw as Record; const id = str(r.id)?.toLowerCase(); if (!id || !TAG_ID_RE.test(id)) return null; // A count is the one field the chip row cannot invent: the operator's rule // is that a tag is offered only when it has matches ON THIS SITE, so a // countless entry is dropped rather than shown as a chip that finds nothing. const count = num(r.count); if (count === undefined || count < 0) return null; return { id, label: str(r.label) ?? id, ...(str(r.group) ? { group: str(r.group) } : {}), ...(str(r.groupLabel) ? { groupLabel: str(r.groupLabel) } : {}), ...(str(r.color) ? { color: str(r.color) } : {}), ...(num(r.order) !== undefined ? { order: num(r.order) } : {}), count: Math.floor(count), channels: coerceChannels(r.channels), }; } // Parse a published /tags.json into the tag list, sorted the way chips render: // by `order` (defaulting to the document's own position) then by id, so two // sites that ship the same vocabulary show it in the same order. NEVER throws; // anything unrecognisable yields []. export function coercePublishedTags(raw: unknown): PublishedTag[] { if (!raw || typeof raw !== "object") return []; const list = (raw as Partial).tags; if (!Array.isArray(list)) return []; const out: PublishedTag[] = []; const seen = new Set(); list.forEach((entry) => { const tag = coerceTag(entry); if (!tag) return; if (seen.has(tag.id)) return; // first definition of an id wins seen.add(tag.id); out.push(tag); }); const rank = new Map(); out.forEach((t, i) => rank.set(t.id, t.order ?? i)); return out.sort((a, b) => { const ra = rank.get(a.id)!; const rb = rank.get(b.id)!; if (ra !== rb) return ra - rb; return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; }); } // The chips the UI may offer: visible tags with at least one video ON THIS // SITE. compose-site already drops hidden and zero-count tags, so this is a // belt-and-braces re-check on the reader side — a hand-written or stale // /tags.json must not be able to put a chip that matches nothing in front of // someone. export function selectableTags( tags: readonly PublishedTag[], ): PublishedTag[] { return tags.filter((t) => t.count > 0); } // Group the selectable tags for a chip row, preserving the tag order within // each group and ordering the groups by their first member. Ungrouped tags // fall into one trailing bucket with no label, which is what a site using // free-form ids and no `group` gets. export type PublishedTagGroup = { // "" for the ungrouped bucket. id: string; label: string; tags: PublishedTag[]; }; export function groupPublishedTags( tags: readonly PublishedTag[], ): PublishedTagGroup[] { const out: PublishedTagGroup[] = []; const byId = new Map(); for (const tag of tags) { const id = tag.group ?? ""; let group = byId.get(id); if (!group) { group = { id, label: "", tags: [] }; byId.set(id, group); out.push(group); } // The first EXPLICIT groupLabel wins; a later member may carry the one the // first omitted, so the id fallback is applied after the whole walk rather // than on first sight (which would make the upgrade unreachable). if (id && !group.label && tag.groupLabel) group.label = tag.groupLabel; group.tags.push(tag); } for (const group of out) { if (group.id && !group.label) group.label = group.id; } // The ungrouped bucket always sorts last: it is the leftovers, not a group. return out.sort((a, b) => (a.id === "" ? 1 : b.id === "" ? -1 : 0)); } // id -> label, for rendering a record's `curatedTags` on a card. A tag the // site does not publish (hidden, zero-count here, or defined after this page // was built) falls back to its id — the fact is on the record either way, and // showing the raw id is more honest than dropping it. export function tagLabels( tags: readonly PublishedTag[], ): ReadonlyMap { return new Map(tags.map((t) => [t.id, t.label])); }