commit c3530f9e764af713bcb578bb76df5271e740835b
parent 36bcb340e4dce8a2b0161a827e24f14a06ada6c7
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 6 Jul 2026 22:44:15 -0400
Add search alias suggestions (global + per-site)
A curated dictionary of known search aliases surfaces a non-forcing
suggestion when a typed term matches a known trigger. One concept groups
many trigger spellings (transcription mangles them — AI expands `loli`
to `lolly`/`loly`) with one robust replacement regex.
- common/lib/searchAliases.ts: pure model + token matching + merge +
coerce + seeded DEFAULT_ALIASES (unit-tested).
- common/lib/aliasesStore.ts: global + per-site JSON persistence, defaults
when the global file is absent, build-time merge (integration-tested).
- compose-site.ts ships a merged /search-aliases.json per site (+ CORS).
- Viewer: SearchDataContext fetches it client-side; QueryLeafView shows a
quiet chip with Apply/Dismiss in the existing hint slot. Whole-word
match; suppressed in regex mode.
- editor/app/aliases: authoring page (Global + per-site sections) cloned
from the charts page pattern; nav link added.
Verified: 13 unit + 5 store tests; 3 export e2e (chip behavior) + 3 editor
e2e (CRUD round-trip); typecheck clean across common/editor/export.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diffstat:
19 files changed, 1267 insertions(+), 2 deletions(-)
diff --git a/common/bin/compose-site.ts b/common/bin/compose-site.ts
@@ -25,6 +25,7 @@ import {
} from "../lib/duplicates";
import type { Manifest, SubsManifest } from "../lib/manifest";
import { buildSiteDescriptor, type PublicSiteDescriptor } from "../lib/siteDescriptor";
+import { effectiveSiteAliases } from "../lib/aliasesStore";
import {
buildSiteCorpus,
renderSiteLlmsTxt,
@@ -49,6 +50,8 @@ import {
const CORS_HEADERS = `# Generated by compose-site.ts — do not edit by hand.
/site.json
Access-Control-Allow-Origin: *
+/search-aliases.json
+ Access-Control-Allow-Origin: *
/summaries/*
Access-Control-Allow-Origin: *
/subs/*
@@ -471,6 +474,16 @@ async function main(): Promise<void> {
await cp(templatesSrc, templatesDest);
}
+ // --- search aliases (global merged with per-site overrides) ---
+ // Read from source at compose time (like duplicates.json) rather than a
+ // staging step. Always emitted — a fresh install ships the seeded defaults so
+ // the viewer's suggestion chip works out of the box.
+ const aliases = effectiveSiteAliases(paths, siteId);
+ await writeFile(
+ path.join(paths.exportPublicDir, "search-aliases.json"),
+ JSON.stringify({ aliases }),
+ );
+
// --- duplicate-shorts report (global → site-filtered) ---
// The detector writes one global duplicates.json over the whole channel pool;
// each site only serves its own channels, so filter clusters to the site's
diff --git a/common/components/QueryLeafView.tsx b/common/components/QueryLeafView.tsx
@@ -8,8 +8,11 @@
// scope dropdown, query input, regex / show-hits / NOT toggles, a live
// "→ N videos" count, a cached badge when applicable, and a delete button.
+import { useState } from "react";
import type { LeafNode, LayerScope } from "../lib/searchQuery";
import type { LeafState } from "../lib/searchEval";
+import { matchAliases } from "../lib/searchAliases";
+import { useSearchData } from "./SearchDataContext";
import { LayerSwatch, swatchSoftBgStyle } from "./LayerSwatch";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
@@ -55,6 +58,87 @@ export default function QueryLeafView({
const regexInvalid =
leaf.useRegex && leaf.query.trim() !== "" && !isValidRegex(leaf.query);
+ // Known-alias suggestions. A curated concept ("loli") that a typed term
+ // matches offers a better regex the user can Apply — never forced. Suppressed
+ // in regex mode (the user is writing their own pattern). Dismissals are scoped
+ // to the current query text so a new term re-offers. See ../lib/searchAliases.
+ const { aliases } = useSearchData();
+ const [dismissed, setDismissed] = useState<{ q: string; ids: Set<string> }>({
+ q: "",
+ ids: new Set(),
+ });
+ const activeDismissed = dismissed.q === leaf.query ? dismissed.ids : null;
+ const suggestions = leaf.useRegex
+ ? []
+ : matchAliases(leaf.query, leaf.scope, aliases).filter(
+ (a) => !activeDismissed?.has(a.id),
+ );
+ const dismissAlias = (id: string) =>
+ setDismissed((prev) => {
+ const ids =
+ prev.q === leaf.query ? new Set(prev.ids) : new Set<string>();
+ ids.add(id);
+ return { q: leaf.query, ids };
+ });
+ const applyAlias = (suggestion: string, useRegex: boolean) =>
+ update({ query: suggestion, useRegex });
+
+ const aliasSuggestions =
+ suggestions.length > 0 ? (
+ <div
+ className="flex flex-col gap-1"
+ role="status"
+ aria-live="polite"
+ data-testid={`leaf-alias-suggestions-${leaf.id}`}
+ >
+ {suggestions.map((a) => (
+ <div
+ key={a.id}
+ className="flex flex-wrap items-center gap-x-2 gap-y-1 rounded-md border-l-2 border-primary/60 bg-primary/5 px-2 py-1 text-xs animate-in fade-in slide-in-from-top-1 motion-reduce:animate-none"
+ data-testid={`leaf-alias-${leaf.id}-${a.id}`}
+ >
+ <span className="text-muted-foreground">
+ <span aria-hidden="true">💡</span> alias{" "}
+ <span className="font-medium text-foreground">“{a.label}”</span>
+ </span>
+ <span aria-hidden="true" className="text-muted-foreground">
+ →
+ </span>
+ <code className="min-w-0 max-w-[16rem] truncate font-mono text-foreground/90">
+ {a.suggestion}
+ </code>
+ <div className="ml-auto flex items-center gap-1">
+ <Button
+ type="button"
+ size="xs"
+ variant="secondary"
+ onClick={() => applyAlias(a.suggestion, a.useRegex)}
+ data-testid={`leaf-alias-apply-${leaf.id}`}
+ >
+ Apply
+ </Button>
+ <Button
+ type="button"
+ size="xs"
+ variant="ghost"
+ className="text-muted-foreground"
+ onClick={() => dismissAlias(a.id)}
+ aria-label={`Dismiss ${a.label} suggestion`}
+ data-testid={`leaf-alias-dismiss-${leaf.id}`}
+ >
+ Dismiss
+ </Button>
+ </div>
+ {a.note && (
+ <p className="w-full text-[11px] leading-snug text-muted-foreground">
+ {a.note}
+ </p>
+ )}
+ </div>
+ ))}
+ </div>
+ ) : null;
+
const placeholder =
leaf.scope === "metadata"
? leaf.useRegex
@@ -154,6 +238,7 @@ export default function QueryLeafView({
{countBadge}
{cachedBadge}
</div>
+ {aliasSuggestions}
</div>
);
}
@@ -227,6 +312,7 @@ export default function QueryLeafView({
{regexInvalid && (
<p className="text-xs text-destructive font-mono">Invalid regex</p>
)}
+ {aliasSuggestions}
</div>
);
}
diff --git a/common/components/SearchDataContext.tsx b/common/components/SearchDataContext.tsx
@@ -17,6 +17,8 @@ import {
import { useQueries } from "@tanstack/react-query";
import { useSummaries, type SummariesState } from "./summariesCache";
import { useSubsManifest } from "./subsCache";
+import { useSearchAliases, fetchAliases } from "./aliasesCache";
+import { mergeAliases, type SearchAlias } from "../lib/searchAliases";
import { idBaseUrl, makeId } from "./originId";
import type { DisplaySummary } from "../lib/transcripts";
import type { Manifest, SubsManifest } from "../lib/manifest";
@@ -57,6 +59,9 @@ export type SearchDataValue = {
// only in hub mode (each federated site's accent); undefined in single-site
// mode, so no provenance marker renders. Stable reference in single-site mode.
accentOf: (origin: string) => string | undefined;
+ // Known search-alias dictionary for the leaf suggestion chip. Single-site: the
+ // site's own list. Hub: merged across federated origins. Empty when unauthored.
+ aliases: SearchAlias[];
};
// Single-site mode has no provenance accents; a module constant keeps the
@@ -79,6 +84,7 @@ export function useSearchData(): SearchDataValue {
export function SingleSiteDataProvider({ children }: { children: ReactNode }) {
const summariesState = useSummaries("");
const subsManifest = useSubsManifest("").data ?? null;
+ const aliases = useSearchAliases("");
const manifest = summariesState.manifest;
const groups = useMemo<ChannelGroup[]>(() => {
@@ -124,8 +130,9 @@ export function SingleSiteDataProvider({ children }: { children: ReactNode }) {
defaultGroupId,
channelKeyOf: (t) => t.channel,
accentOf: NO_ACCENT,
+ aliases,
}),
- [summariesState, subsManifest, channels, groups, defaultGroupId],
+ [summariesState, subsManifest, channels, groups, defaultGroupId, aliases],
);
return (
@@ -309,6 +316,23 @@ export function MultiSiteDataProvider({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sites, subsSettled]);
+ // Alias dictionaries per federated origin, merged into one list (later origins
+ // shadow earlier ones by id). Missing files resolve to [] — additive only.
+ const aliasQueries = useQueries({
+ queries: sites.map((s) => ({
+ queryKey: ["search-aliases", s.origin],
+ queryFn: () => fetchAliases(s.origin),
+ staleTime: Infinity,
+ })),
+ });
+ const aliasesSettled = aliasQueries.every((q) => q.isSuccess || q.isError);
+ const aliases = useMemo<SearchAlias[]>(() => {
+ let merged: SearchAlias[] = [];
+ for (const q of aliasQueries) if (q.data) merged = mergeAliases(merged, q.data);
+ return merged;
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [aliasesSettled]);
+
// Synthetic merged summaries manifest. TranscriptSearch reads channels/groups
// from the context (above), not from here, but the field is part of the
// SummariesState contract, so provide a coherent merged view.
@@ -368,8 +392,9 @@ export function MultiSiteDataProvider({
// the selection key (matches ChannelOption.key).
channelKeyOf: (t) => t.channelSlug,
accentOf,
+ aliases,
}),
- [summariesState, subsManifest, channels, groups, defaultGroupId, accentOf],
+ [summariesState, subsManifest, channels, groups, defaultGroupId, accentOf, aliases],
);
return (
diff --git a/common/components/aliasesCache.ts b/common/components/aliasesCache.ts
@@ -0,0 +1,28 @@
+"use client";
+
+// Client fetch for a site's shipped /search-aliases.json (the global dictionary
+// merged with per-site overrides at compose time). Mirrors summariesCache /
+// subsCache. A missing file (older bundle, or a dev server without the composed
+// asset) resolves to an empty list rather than an error — alias suggestions are
+// purely additive, so their absence must never break search.
+
+import { useQuery } from "@tanstack/react-query";
+import { idBaseUrl } from "./originId";
+import { coerceAliasConfig, type SearchAlias } from "../lib/searchAliases";
+
+const EMPTY: SearchAlias[] = [];
+
+export async function fetchAliases(origin = ""): Promise<SearchAlias[]> {
+ const r = await fetch(`${idBaseUrl(origin)}/search-aliases.json`);
+ if (!r.ok) return [];
+ return coerceAliasConfig(await r.json()).aliases;
+}
+
+export function useSearchAliases(origin = ""): SearchAlias[] {
+ const { data } = useQuery<SearchAlias[]>({
+ queryKey: ["search-aliases", origin],
+ queryFn: () => fetchAliases(origin),
+ staleTime: Infinity, // static per export build
+ });
+ return data ?? EMPTY;
+}
diff --git a/common/lib/aliasesStore.test.ts b/common/lib/aliasesStore.test.ts
@@ -0,0 +1,101 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import type { Paths } from "./paths";
+import {
+ readGlobalAliases,
+ writeGlobalAliases,
+ readSiteAliases,
+ writeSiteAliases,
+ effectiveSiteAliases,
+} from "./aliasesStore";
+import { DEFAULT_ALIASES } from "./searchAliases";
+
+function tempPaths(): { paths: Paths; cleanup: () => void } {
+ const dir = mkdtempSync(path.join(tmpdir(), "aliases-store-"));
+ // Only the fields aliasesStore touches need to be real.
+ const paths = {
+ sitesDir: path.join(dir, "sites"),
+ globalAliasesFile: path.join(dir, "search-aliases.json"),
+ } as Paths;
+ return { paths, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
+}
+
+test("readGlobalAliases returns the seeded defaults when no file exists", () => {
+ const { paths, cleanup } = tempPaths();
+ try {
+ assert.deepEqual(readGlobalAliases(paths).aliases, DEFAULT_ALIASES);
+ } finally {
+ cleanup();
+ }
+});
+
+test("global aliases round-trip through write/read", () => {
+ const { paths, cleanup } = tempPaths();
+ try {
+ const cfg = {
+ aliases: [
+ {
+ id: "x",
+ label: "x",
+ triggers: ["a", "b"],
+ suggestion: "s",
+ useRegex: true,
+ },
+ ],
+ };
+ writeGlobalAliases(paths, cfg);
+ const back = readGlobalAliases(paths);
+ assert.equal(back.aliases.length, 1);
+ assert.equal(back.aliases[0].id, "x");
+ assert.deepEqual(back.aliases[0].triggers, ["a", "b"]);
+ } finally {
+ cleanup();
+ }
+});
+
+test("an empty-but-present global file yields no aliases (defaults deleted)", () => {
+ const { paths, cleanup } = tempPaths();
+ try {
+ writeGlobalAliases(paths, { aliases: [] });
+ assert.deepEqual(readGlobalAliases(paths).aliases, []);
+ } finally {
+ cleanup();
+ }
+});
+
+test("readSiteAliases is empty when unauthored", () => {
+ const { paths, cleanup } = tempPaths();
+ try {
+ assert.deepEqual(readSiteAliases(paths, "mysite").aliases, []);
+ } finally {
+ cleanup();
+ }
+});
+
+test("effectiveSiteAliases merges global with per-site overrides by id", () => {
+ const { paths, cleanup } = tempPaths();
+ try {
+ writeGlobalAliases(paths, {
+ aliases: [
+ { id: "g1", label: "g1", triggers: ["g"], suggestion: "G", useRegex: true },
+ { id: "shared", label: "shared", triggers: ["s"], suggestion: "GLOBAL", useRegex: true },
+ ],
+ });
+ writeSiteAliases(paths, "mysite", {
+ aliases: [
+ { id: "shared", label: "shared", triggers: ["s"], suggestion: "SITE", useRegex: true },
+ { id: "s1", label: "s1", triggers: ["x"], suggestion: "S", useRegex: true },
+ ],
+ });
+ const eff = effectiveSiteAliases(paths, "mysite");
+ assert.equal(eff.length, 3);
+ assert.equal(eff.find((a) => a.id === "shared")?.suggestion, "SITE");
+ assert.ok(eff.some((a) => a.id === "g1"));
+ assert.ok(eff.some((a) => a.id === "s1"));
+ } finally {
+ cleanup();
+ }
+});
diff --git a/common/lib/aliasesStore.ts b/common/lib/aliasesStore.ts
@@ -0,0 +1,73 @@
+// Server-side persistence for search aliases. Two source files:
+// - global: paths.globalAliasesFile (<transcriptsDir>/search-aliases.json)
+// - per-site: sites/<siteId>/search-aliases.json
+// The editor authors both; the export build merges them (per-site shadows
+// global by id) into each site's shipped /search-aliases.json. When the global
+// file is absent it falls back to the seeded DEFAULT_ALIASES so a fresh install
+// ships useful suggestions out of the box; a missing per-site file = no
+// overrides. See common/lib/searchAliases.ts for the pure model + merge.
+
+import path from "node:path";
+import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
+import type { Paths } from "./paths";
+import { siteAliasesFile } from "./site";
+import {
+ coerceAliasConfig,
+ mergeAliases,
+ DEFAULT_ALIASES,
+ type AliasConfig,
+ type SearchAlias,
+} from "./searchAliases";
+
+function writeJsonAtomic(filePath: string, value: unknown): void {
+ mkdirSync(path.dirname(filePath), { recursive: true });
+ const tmp = `${filePath}.tmp-${process.pid}`;
+ writeFileSync(tmp, JSON.stringify(value, null, 2));
+ renameSync(tmp, filePath);
+}
+
+// Global dictionary. Absent/unreadable → the seeded defaults (a fresh install
+// still offers suggestions). An empty-but-present file → no aliases (the
+// operator deleted the defaults deliberately).
+export function readGlobalAliases(paths: Paths): AliasConfig {
+ try {
+ return coerceAliasConfig(
+ JSON.parse(readFileSync(paths.globalAliasesFile, "utf8")),
+ );
+ } catch {
+ return { aliases: [...DEFAULT_ALIASES] };
+ }
+}
+
+export function writeGlobalAliases(paths: Paths, config: AliasConfig): void {
+ writeJsonAtomic(paths.globalAliasesFile, config);
+}
+
+// Per-site overrides. Absent/unreadable → empty (no overrides).
+export function readSiteAliases(paths: Paths, siteId: string): AliasConfig {
+ try {
+ return coerceAliasConfig(
+ JSON.parse(readFileSync(siteAliasesFile(paths, siteId), "utf8")),
+ );
+ } catch {
+ return { aliases: [] };
+ }
+}
+
+export function writeSiteAliases(
+ paths: Paths,
+ siteId: string,
+ config: AliasConfig,
+): void {
+ writeJsonAtomic(siteAliasesFile(paths, siteId), config);
+}
+
+// The list a site actually ships: global merged with its per-site overrides.
+// Read directly by compose-site.ts at build time (source files are available
+// there, like duplicates.json), so there's no separate staging step.
+export function effectiveSiteAliases(paths: Paths, siteId: string): SearchAlias[] {
+ return mergeAliases(
+ readGlobalAliases(paths).aliases,
+ readSiteAliases(paths, siteId).aliases,
+ );
+}
diff --git a/common/lib/paths.ts b/common/lib/paths.ts
@@ -75,6 +75,12 @@ export type Paths = {
exportBuildsDir: string;
settingsFile: string;
chartsConfigFile: string;
+ // Global (cross-site) search-alias dictionary. Per-site aliases live at
+ // sitesDir/<siteId>/search-aliases.json (see common/lib/site.ts); the two are
+ // merged into each site's shipped /search-aliases.json at compose time. In the
+ // data dir (not monorepoRoot — the legacy chartsConfigFile location there is
+ // migration-only). See common/lib/aliasesStore.ts.
+ globalAliasesFile: string;
ytdlpBin: string;
whisperBin: string;
whisperModel: string;
@@ -150,6 +156,9 @@ export function getPaths(): Paths {
chartsConfigFile:
process.env.CHARTS_CONFIG_FILE ??
path.join(monorepoRoot, "chart-templates.json"),
+ globalAliasesFile:
+ process.env.SEARCH_ALIASES_FILE ??
+ path.join(transcriptsDir, "search-aliases.json"),
ytdlpBin: process.env.YTDLP_BIN ?? "yt-dlp",
whisperBin: process.env.WHISPER_BIN ?? "whisper-cli",
whisperModel:
diff --git a/common/lib/searchAliases.test.ts b/common/lib/searchAliases.test.ts
@@ -0,0 +1,125 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ matchAliases,
+ mergeAliases,
+ coerceAliasConfig,
+ tokenizeQuery,
+ slugifyAliasId,
+ DEFAULT_ALIASES,
+ type SearchAlias,
+} from "./searchAliases";
+
+const loli: SearchAlias = {
+ id: "loli",
+ label: "loli",
+ triggers: ["loli", "lolly", "loly"],
+ suggestion: "\\blol(i|ly)",
+ useRegex: true,
+};
+
+test("tokenizeQuery splits on non-word chars and lowercases", () => {
+ assert.deepEqual(tokenizeQuery("Loli Clips!"), ["loli", "clips"]);
+ assert.deepEqual(tokenizeQuery(" "), []);
+});
+
+test("matchAliases fires on a whole-token match, any spelling", () => {
+ assert.deepEqual(matchAliases("loli", "transcripts", [loli]), [loli]);
+ assert.deepEqual(matchAliases("lolly", "transcripts", [loli]), [loli]);
+ // Fires when the trigger is one token among several.
+ assert.deepEqual(matchAliases("loli clips", "transcripts", [loli]), [loli]);
+ // Case-insensitive.
+ assert.deepEqual(matchAliases("LOLI", "transcripts", [loli]), [loli]);
+});
+
+test("matchAliases does NOT fire on a substring (whole-token only)", () => {
+ assert.deepEqual(matchAliases("lolight", "transcripts", [loli]), []);
+ assert.deepEqual(matchAliases("deloli", "transcripts", [loli]), []);
+});
+
+test("matchAliases respects the enabled flag", () => {
+ const off = { ...loli, enabled: false };
+ assert.deepEqual(matchAliases("loli", "transcripts", [off]), []);
+});
+
+test("matchAliases respects an optional scope restriction", () => {
+ const scoped = { ...loli, scopes: ["transcripts" as const] };
+ assert.deepEqual(matchAliases("loli", "transcripts", [scoped]), [scoped]);
+ assert.deepEqual(matchAliases("loli", "tags", [scoped]), []);
+ // Absent scopes = every scope.
+ assert.deepEqual(matchAliases("loli", "tags", [loli]), [loli]);
+});
+
+test("matchAliases returns nothing for an empty query", () => {
+ assert.deepEqual(matchAliases("", "transcripts", [loli]), []);
+});
+
+test("mergeAliases: per-site shadows global by id, else appends", () => {
+ const global = [loli, { ...loli, id: "youtube", label: "YouTube" }];
+ const override = { ...loli, suggestion: "OVERRIDDEN" };
+ const extra = { ...loli, id: "extra", label: "extra" };
+ const merged = mergeAliases(global, [override, extra]);
+ assert.equal(merged.length, 3);
+ assert.equal(merged.find((a) => a.id === "loli")?.suggestion, "OVERRIDDEN");
+ assert.ok(merged.some((a) => a.id === "extra"));
+ assert.ok(merged.some((a) => a.id === "youtube"));
+});
+
+test("mergeAliases: a per-site disable hides a global alias", () => {
+ const merged = mergeAliases([loli], [{ ...loli, enabled: false }]);
+ assert.equal(matchAliases("loli", "transcripts", merged).length, 0);
+});
+
+test("coerceAliasConfig drops malformed entries and defaults flags", () => {
+ const cfg = coerceAliasConfig({
+ aliases: [
+ { label: "ok", triggers: ["a"], suggestion: "x" }, // valid, id derived
+ { label: "no triggers", triggers: [], suggestion: "x" }, // dropped
+ { label: "no suggestion", triggers: ["a"] }, // dropped
+ "garbage",
+ null,
+ ],
+ });
+ assert.equal(cfg.aliases.length, 1);
+ const a = cfg.aliases[0];
+ assert.equal(a.id, "ok"); // slugified from label
+ assert.equal(a.useRegex, true); // default
+ assert.equal(a.enabled, true); // default
+});
+
+test("coerceAliasConfig preserves explicit useRegex:false and enabled:false", () => {
+ const cfg = coerceAliasConfig({
+ aliases: [
+ {
+ label: "x",
+ triggers: ["a"],
+ suggestion: "s",
+ useRegex: false,
+ enabled: false,
+ },
+ ],
+ });
+ assert.equal(cfg.aliases[0].useRegex, false);
+ assert.equal(cfg.aliases[0].enabled, false);
+});
+
+test("coerceAliasConfig on non-object input yields an empty list", () => {
+ assert.deepEqual(coerceAliasConfig(null), { aliases: [] });
+ assert.deepEqual(coerceAliasConfig("nope"), { aliases: [] });
+ assert.deepEqual(coerceAliasConfig({}), { aliases: [] });
+});
+
+test("slugifyAliasId produces a stable slug", () => {
+ assert.equal(slugifyAliasId("Hello World!"), "hello-world");
+ assert.equal(slugifyAliasId(" "), "alias");
+});
+
+test("DEFAULT_ALIASES include the canonical loli concept and are well-formed", () => {
+ const coerced = coerceAliasConfig({ aliases: DEFAULT_ALIASES });
+ assert.equal(coerced.aliases.length, DEFAULT_ALIASES.length);
+ assert.ok(DEFAULT_ALIASES.some((a) => a.id === "loli"));
+ assert.deepEqual(
+ matchAliases("lolly", "transcripts", DEFAULT_ALIASES).map((a) => a.id),
+ ["loli"],
+ );
+});
diff --git a/common/lib/searchAliases.ts b/common/lib/searchAliases.ts
@@ -0,0 +1,175 @@
+// Known search aliases — a curated dictionary that offers a *non-forcing*
+// suggestion when a typed term matches a known trigger. A concept ("loli") can
+// be spelled many ways (AI transcription in particular expands it to "lolly"/
+// "loly"); one alias entry groups all those trigger spellings and offers one
+// robust replacement regex (e.g. "\\blol(i|ly)"). Nothing is auto-applied — the
+// viewer surfaces the suggestion and the user chooses to apply it.
+//
+// This module is PURE (no I/O) so it can be unit-tested and shared by the
+// server-side store (common/lib/aliasesStore.ts) and the client viewer
+// (common/components/QueryLeafView.tsx). Disk/merge/defaulting policy lives in
+// the store; fetch/coerce policy lives with the client fetch.
+
+import type { LayerScope } from "./searchQuery";
+
+export type SearchAlias = {
+ // Stable identity. Seeded defaults keep fixed ids so a per-site entry with the
+ // same id can shadow (override or disable) a global one. See mergeAliases.
+ id: string;
+ // Human name of the concept, shown on the suggestion chip.
+ label: string;
+ // Spellings that fire the suggestion. Matched case-insensitively, whole-token.
+ triggers: string[];
+ // The query to swap in on Apply, e.g. "\\blol(i|ly)".
+ suggestion: string;
+ // What to set the leaf's `useRegex` to on Apply (usually true).
+ useRegex: boolean;
+ // Optional author note ("AI euphemism variants"), shown muted on the chip.
+ note?: string;
+ // Defaults true; a per-site entry can set false to turn off a seeded default
+ // without deleting it.
+ enabled?: boolean;
+ // Optional scope restriction; absent (or empty) = suggest in every scope.
+ scopes?: LayerScope[];
+};
+
+export type AliasConfig = { aliases: SearchAlias[] };
+
+const LAYER_SCOPES: readonly LayerScope[] = [
+ "transcripts",
+ "chat",
+ "metadata",
+ "description",
+ "tags",
+];
+
+function isLayerScope(v: unknown): v is LayerScope {
+ return typeof v === "string" && (LAYER_SCOPES as readonly string[]).includes(v);
+}
+
+// Split a query into lowercased word tokens on any non-alphanumeric boundary.
+// Whole-token matching is deliberate: "loli" fires inside "loli clips" but NOT
+// inside "lolight" (substring matching would be too noisy and erode trust).
+export function tokenizeQuery(query: string): string[] {
+ return query
+ .toLowerCase()
+ .split(/[^\p{L}\p{N}]+/u)
+ .filter(Boolean);
+}
+
+// Which aliases match the typed query for a given scope. Returns every match
+// (usually 0 or 1). The caller is responsible for suppressing suggestions when
+// the leaf is already in regex mode — the user is crafting their own pattern.
+export function matchAliases(
+ query: string,
+ scope: LayerScope,
+ aliases: SearchAlias[],
+): SearchAlias[] {
+ const tokens = new Set(tokenizeQuery(query));
+ if (tokens.size === 0) return [];
+ const out: SearchAlias[] = [];
+ for (const a of aliases) {
+ if (a.enabled === false) continue;
+ if (a.scopes && a.scopes.length > 0 && !a.scopes.includes(scope)) continue;
+ if (a.triggers.some((t) => tokens.has(t.toLowerCase()))) out.push(a);
+ }
+ return out;
+}
+
+// Merge a global list with a per-site list. A per-site entry with the same id
+// REPLACES the matching global one (so a site can override or disable a global
+// alias); otherwise it appends. This is the effective list a site ships.
+export function mergeAliases(
+ global: SearchAlias[],
+ perSite: SearchAlias[],
+): SearchAlias[] {
+ const byId = new Map<string, SearchAlias>();
+ for (const a of global) byId.set(a.id, a);
+ for (const a of perSite) byId.set(a.id, a);
+ return Array.from(byId.values());
+}
+
+// Turn an id-less label into a stable slug id (for hand-authored files that
+// omit `id`). Deterministic — no Date.now()/Math.random().
+export function slugifyAliasId(label: string): string {
+ const slug = label
+ .toLowerCase()
+ .replace(/[^\p{L}\p{N}]+/gu, "-")
+ .replace(/^-+|-+$/g, "");
+ return slug || "alias";
+}
+
+function coerceAlias(raw: unknown): SearchAlias | null {
+ if (!raw || typeof raw !== "object") return null;
+ const r = raw as Record<string, unknown>;
+ const label = typeof r.label === "string" ? r.label.trim() : "";
+ const suggestion = typeof r.suggestion === "string" ? r.suggestion : "";
+ const triggers = Array.isArray(r.triggers)
+ ? r.triggers
+ .filter((t): t is string => typeof t === "string" && t.trim() !== "")
+ .map((t) => t.trim())
+ : [];
+ // An entry without a label, a suggestion, or at least one trigger is unusable.
+ if (!label || !suggestion || triggers.length === 0) return null;
+ const id =
+ typeof r.id === "string" && r.id.trim() !== ""
+ ? r.id.trim()
+ : slugifyAliasId(label);
+ const scopes = Array.isArray(r.scopes)
+ ? r.scopes.filter(isLayerScope)
+ : undefined;
+ const note =
+ typeof r.note === "string" && r.note.trim() !== ""
+ ? r.note.trim()
+ : undefined;
+ return {
+ id,
+ label,
+ triggers,
+ suggestion,
+ useRegex: r.useRegex !== false, // default true
+ enabled: r.enabled !== false, // default true
+ ...(note ? { note } : {}),
+ ...(scopes && scopes.length > 0 ? { scopes } : {}),
+ };
+}
+
+// Coerce a parsed JSON value into a valid AliasConfig, dropping malformed
+// entries. Missing/invalid input yields an EMPTY list — the "seed defaults when
+// the global file is absent" policy lives in the store, and the client fetch
+// treats a 404 as empty. This keeps coercion a pure shape-guard.
+export function coerceAliasConfig(raw: unknown): AliasConfig {
+ if (!raw || typeof raw !== "object") return { aliases: [] };
+ const arr = Array.isArray((raw as { aliases?: unknown }).aliases)
+ ? ((raw as { aliases: unknown[] }).aliases)
+ : [];
+ return {
+ aliases: arr
+ .map(coerceAlias)
+ .filter((a): a is SearchAlias => a !== null),
+ };
+}
+
+// Seeded global defaults, returned by readGlobalAliases when no global file
+// exists yet (and shown pre-filled in the editor's Global section). Kept small
+// and clearly labeled; operators prune/edit from here. Stable ids so a site can
+// shadow them. The first entry is the canonical content-moderation case; the
+// second is a benign demonstrator of the whole-word / multi-spelling mechanic.
+export const DEFAULT_ALIASES: SearchAlias[] = [
+ {
+ id: "loli",
+ label: "loli",
+ triggers: ["loli", "lolly", "loly", "lolli"],
+ suggestion: "\\blol(i|ly)",
+ useRegex: true,
+ note: "AI transcription often expands this to “lolly”/“loly”.",
+ },
+ {
+ id: "youtube",
+ label: "YouTube",
+ triggers: ["youtube", "yt"],
+ suggestion: "\\byou ?tube\\b|\\byt\\b",
+ useRegex: true,
+ note: "Catches “you tube” and the “yt” abbreviation.",
+ },
+];
diff --git a/common/lib/site.ts b/common/lib/site.ts
@@ -125,6 +125,13 @@ export function siteChartTemplatesFile(paths: Paths, siteId: string): string {
return path.join(siteDir(paths, siteId), "chart-templates.json");
}
+// Per-site search-alias overrides. Merged over the global dictionary
+// (paths.globalAliasesFile) into the site's shipped /search-aliases.json at
+// compose time. See common/lib/aliasesStore.ts.
+export function siteAliasesFile(paths: Paths, siteId: string): string {
+ return path.join(siteDir(paths, siteId), "search-aliases.json");
+}
+
// Staging output dirs (NOT served) where per-site aggregates are built before
// composition into export/public. See common/lib/paths.ts.
export function siteIndexDir(paths: Paths, siteId: string): string {
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **New "Search aliases" page for authoring known-term suggestions.** A concept is often spelled many ways — transcription in particular mangles them (AI expands `loli` to `lolly`/`loly`) — so searching one spelling silently misses the rest. This page authors a curated dictionary where one entry groups all the trigger spellings of a concept with a single robust replacement (e.g. `\blol(i|ly)`). A **Global** section applies to every site and ships a small seeded default set you can edit or remove; a **per-site** section (shown when a site is selected in the sidebar) adds to or overrides the global list by id — reuse a global id and mark it disabled to hide that alias for just that site. Nothing is forced: the entries only surface a *suggestion* in the viewer's search box, which the searcher can apply or ignore. Stored as `search-aliases.json` (global under the data dir, per-site under `sites/<id>/`) and merged into each site's bundle at build time. See `editor/app/aliases/{page.tsx,actions.ts,EditorAliasesClient.tsx}`, `common/lib/{searchAliases,aliasesStore}.ts`, `editor/app/lib/nav.ts`, and `editor/e2e/aliases.spec.ts`.
- **Fixed: the editor (and every public site) always loaded in light mode until you toggled the theme.** Dark styling is driven purely by a `.dark` class on `<html>`; the pre-paint `<ThemeScript>` set it correctly before first paint, but `<html>` is server-rendered with a static class that omits `dark`, so that class was lost across React's hydration boundary and nothing put it back — the page fell to the light palette on every load until a manual toggle re-applied it directly (which is why toggling then "stuck"). `ThemeProvider` now re-asserts the persisted, resolved family + mode to `<html>` on mount via `useLayoutEffect` (before paint) — idempotent with the script, so there's no flash and no toggle needed. Explicit dark, `system` on a dark OS, and non-Base families all now survive a refresh. See `common/components/ThemeProvider.tsx` and `editor/e2e/theme.spec.ts`.
- **Search results scroll smoothly again on large result sets.** The results list windows one card per matching video (only the on-screen cards are mounted), but each visible card and every one of its hit rows was re-rendering on *every* scroll frame — and each hit row re-ran its `<mark>` highlighting, so a single video with hundreds of hits meant hundreds of redundant highlight passes per frame while scrolling. The result cards and individual hit rows are now memoized so an unchanged card/row is skipped during scroll, and opening the modal on a hit only re-renders the two rows whose highlight state actually changes. No visible/behavioral change — same DOM, same results, just far less work per frame. See `common/components/TranscriptSearch.tsx` (`ResultCard`/`HitRow` memoization, `openWithMode` stabilized via `useCallback`).
- **The monitor widget gains a needs-work channel list, more interaction buttons, and an in-place settings gear.** Three additions, all driveable from the widget builder. **(1) A "Needs work" list** (URL flag `act=1`) — a compact, per-channel worklist of videos to download (`↓ N`) or transcribe (`✎ N`), reusing the same `loadActionableSummary` that powers the `/actionable` page via a new `/api/widget/actionable` route; it polls on a 15s floor (the backlog changes on job completions, not seconds) and caps at 6 channels with a `+N more` line. **(2) More interactions** behind the existing `controls=1` switch: each needs-work row gains the same per-channel **Download missing** / **Transcribe pending** buttons as the actionable page (reusing `InlineActionButton`), and the controls row adds **Retry all failed** alongside Pause/Resume + Drain. **(3) An in-place settings gear** (on by default; URL flag `gear=0` to hide, or a **Show settings gear** builder checkbox) — clicking it opens the builder's own form *inside the widget window*, so a pinned widget can be reconfigured live without opening the builder page; edits apply immediately and mirror into the address bar via `history.replaceState`, so a reload preserves them and the link stays copyable. The builder form is extracted into a shared `WidgetConfigForm` used by both the builder and the overlay, and the widget's poller now fetches immediately on (re)subscribe instead of after one interval, so newly-enabled sections render at once. Existing links render unchanged (the two new flags default to their old behavior; the gear is the one new default-visible affordance and is read-only — it mutates no server state). See `editor/app/widget/lib/config.ts`, the new `editor/app/widget/components/WidgetConfigForm.tsx` and `editor/app/api/widget/actionable/route.ts`, `editor/app/widget/components/{MonitorWidget,WidgetControls}.tsx`, `editor/app/widget/builder/components/WidgetBuilder.tsx`, and `editor/e2e/widget.spec.ts`.
diff --git a/editor/app/aliases/EditorAliasesClient.tsx b/editor/app/aliases/EditorAliasesClient.tsx
@@ -0,0 +1,361 @@
+"use client";
+
+// Authoring UI for search aliases. Two independent sections — the global
+// dictionary and (when a site is selected) that site's overrides — each edits a
+// list of concept rows and saves the whole list via a server action. Triggers
+// are entered comma-separated and shown back as chips. Suggestions are
+// validated as regex when the row is in regex mode, mirroring the viewer's
+// leaf-input guard.
+
+import { useRef, useState } from "react";
+import { Button } from "yt-dlp-transcript-common/components/ui/button";
+import { Input } from "yt-dlp-transcript-common/components/ui/input";
+import { Checkbox } from "yt-dlp-transcript-common/components/ui/checkbox";
+import {
+ slugifyAliasId,
+ type AliasConfig,
+ type SearchAlias,
+} from "yt-dlp-transcript-common/lib/searchAliases";
+import { saveGlobalAliasesAction, saveSiteAliasesAction } from "./actions";
+
+type Row = {
+ key: string;
+ id: string;
+ label: string;
+ triggersText: string;
+ suggestion: string;
+ useRegex: boolean;
+ enabled: boolean;
+ note: string;
+};
+
+function toRow(a: SearchAlias, key: string): Row {
+ return {
+ key,
+ id: a.id,
+ label: a.label,
+ triggersText: a.triggers.join(", "),
+ suggestion: a.suggestion,
+ useRegex: a.useRegex,
+ enabled: a.enabled !== false,
+ note: a.note ?? "",
+ };
+}
+
+function parseTriggers(text: string): string[] {
+ return text
+ .split(",")
+ .map((t) => t.trim())
+ .filter(Boolean);
+}
+
+function toAlias(r: Row): SearchAlias {
+ const triggers = parseTriggers(r.triggersText);
+ const label = r.label.trim();
+ const note = r.note.trim();
+ return {
+ id: r.id.trim() || slugifyAliasId(label),
+ label,
+ triggers,
+ suggestion: r.suggestion,
+ useRegex: r.useRegex,
+ enabled: r.enabled,
+ ...(note ? { note } : {}),
+ };
+}
+
+function isValidRegex(s: string): boolean {
+ try {
+ new RegExp(s);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+export function EditorAliasesClient({
+ globalConfig,
+ siteConfig,
+ siteId,
+}: {
+ globalConfig: AliasConfig;
+ siteConfig: AliasConfig | null;
+ siteId: string | null;
+}) {
+ return (
+ <div className="flex flex-col gap-8">
+ <AliasSection
+ title="Global"
+ subtitle="Suggested on every site."
+ initial={globalConfig.aliases}
+ onSave={(cfg) => saveGlobalAliasesAction(cfg)}
+ testid="global"
+ />
+ {siteId && siteConfig && (
+ <AliasSection
+ title={`This site — ${siteId}`}
+ subtitle="Adds to the global list. An entry that reuses a global id overrides it (set it disabled to hide that global alias here)."
+ initial={siteConfig.aliases}
+ onSave={(cfg) => saveSiteAliasesAction(siteId, cfg)}
+ testid="site"
+ />
+ )}
+ </div>
+ );
+}
+
+function AliasSection({
+ title,
+ subtitle,
+ initial,
+ onSave,
+ testid,
+}: {
+ title: string;
+ subtitle: string;
+ initial: SearchAlias[];
+ onSave: (config: AliasConfig) => Promise<{ ok: true }>;
+ testid: string;
+}) {
+ const keyRef = useRef(0);
+ const mkKey = () => `r${keyRef.current++}`;
+ const [rows, setRows] = useState<Row[]>(() =>
+ initial.map((a) => toRow(a, mkKey())),
+ );
+ const [dirty, setDirty] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [saved, setSaved] = useState(false);
+
+ const patch = (key: string, next: Partial<Row>) => {
+ setRows((rs) => rs.map((r) => (r.key === key ? { ...r, ...next } : r)));
+ setDirty(true);
+ setSaved(false);
+ };
+ const remove = (key: string) => {
+ setRows((rs) => rs.filter((r) => r.key !== key));
+ setDirty(true);
+ setSaved(false);
+ };
+ const add = () => {
+ setRows((rs) => [
+ ...rs,
+ {
+ key: mkKey(),
+ id: "",
+ label: "",
+ triggersText: "",
+ suggestion: "",
+ useRegex: true,
+ enabled: true,
+ note: "",
+ },
+ ]);
+ setDirty(true);
+ setSaved(false);
+ };
+
+ const anyRegexInvalid = rows.some(
+ (r) => r.useRegex && r.suggestion.trim() !== "" && !isValidRegex(r.suggestion),
+ );
+
+ const save = async () => {
+ setSaving(true);
+ try {
+ // Drop rows missing a label, triggers, or a suggestion — coerce would too.
+ const aliases = rows
+ .map(toAlias)
+ .filter((a) => a.label && a.triggers.length > 0 && a.suggestion);
+ await onSave({ aliases });
+ setDirty(false);
+ setSaved(true);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+ <section
+ className="flex flex-col gap-3"
+ data-testid={`alias-section-${testid}`}
+ >
+ <div className="flex items-baseline justify-between gap-4">
+ <div>
+ <h2 className="text-lg font-semibold">{title}</h2>
+ <p className="text-xs text-muted-foreground">{subtitle}</p>
+ </div>
+ <div className="flex items-center gap-2">
+ {saved && !dirty && (
+ <span className="text-xs text-success" data-testid={`alias-saved-${testid}`}>
+ Saved
+ </span>
+ )}
+ <Button
+ type="button"
+ size="sm"
+ onClick={save}
+ disabled={!dirty || saving || anyRegexInvalid}
+ data-testid={`alias-save-${testid}`}
+ >
+ {saving ? "Saving…" : "Save"}
+ </Button>
+ </div>
+ </div>
+
+ {rows.length === 0 ? (
+ <p className="rounded-md border border-dashed px-3 py-6 text-center text-sm text-muted-foreground">
+ No aliases yet. Add one to start suggesting better queries.
+ </p>
+ ) : (
+ <ul className="flex flex-col gap-3">
+ {rows.map((r) => (
+ <AliasRow
+ key={r.key}
+ row={r}
+ onPatch={(next) => patch(r.key, next)}
+ onRemove={() => remove(r.key)}
+ testid={testid}
+ />
+ ))}
+ </ul>
+ )}
+
+ <div>
+ <Button
+ type="button"
+ size="sm"
+ variant="outline"
+ onClick={add}
+ data-testid={`alias-add-${testid}`}
+ >
+ + Add alias
+ </Button>
+ </div>
+ </section>
+ );
+}
+
+function AliasRow({
+ row,
+ onPatch,
+ onRemove,
+ testid,
+}: {
+ row: Row;
+ onPatch: (next: Partial<Row>) => void;
+ onRemove: () => void;
+ testid: string;
+}) {
+ const triggers = parseTriggers(row.triggersText);
+ const regexInvalid =
+ row.useRegex && row.suggestion.trim() !== "" && !isValidRegex(row.suggestion);
+
+ return (
+ <li
+ className={cnRow(row.enabled)}
+ data-testid={`alias-row-${testid}`}
+ >
+ <div className="flex flex-wrap items-center gap-3">
+ <label className="flex flex-1 min-w-[10rem] flex-col gap-1 text-xs text-muted-foreground">
+ Concept
+ <Input
+ value={row.label}
+ onChange={(e) => onPatch({ label: e.target.value })}
+ placeholder="e.g. loli"
+ data-testid={`alias-label-${testid}`}
+ />
+ </label>
+ <label className="flex select-none items-center gap-1.5 text-xs">
+ <Checkbox
+ checked={row.enabled}
+ onCheckedChange={(c) => onPatch({ enabled: c === true })}
+ data-testid={`alias-enabled-${testid}`}
+ />
+ Enabled
+ </label>
+ <Button
+ type="button"
+ variant="outline"
+ size="icon-xs"
+ onClick={onRemove}
+ aria-label={`Remove ${row.label || "alias"}`}
+ data-testid={`alias-remove-${testid}`}
+ >
+ ×
+ </Button>
+ </div>
+
+ <label className="flex flex-col gap-1 text-xs text-muted-foreground">
+ Triggers <span className="text-[11px]">(comma-separated spellings)</span>
+ <Input
+ value={row.triggersText}
+ onChange={(e) => onPatch({ triggersText: e.target.value })}
+ placeholder="loli, lolly, loly"
+ data-testid={`alias-triggers-${testid}`}
+ />
+ </label>
+ {triggers.length > 0 && (
+ <div className="flex flex-wrap gap-1">
+ {triggers.map((t, i) => (
+ <span
+ key={`${t}-${i}`}
+ className="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground"
+ >
+ {t}
+ </span>
+ ))}
+ </div>
+ )}
+
+ <div className="flex flex-wrap items-end gap-3">
+ <label className="flex flex-1 min-w-[12rem] flex-col gap-1 text-xs text-muted-foreground">
+ Suggested query
+ <Input
+ value={row.suggestion}
+ onChange={(e) => onPatch({ suggestion: e.target.value })}
+ placeholder="\blol(i|ly)"
+ aria-invalid={regexInvalid || undefined}
+ className={row.useRegex ? "font-mono" : undefined}
+ data-testid={`alias-suggestion-${testid}`}
+ />
+ </label>
+ <label className="flex select-none items-center gap-1.5 pb-2 text-xs">
+ <Checkbox
+ checked={row.useRegex}
+ onCheckedChange={(c) => onPatch({ useRegex: c === true })}
+ data-testid={`alias-regex-${testid}`}
+ />
+ Regex
+ </label>
+ </div>
+ {regexInvalid && (
+ <p className="font-mono text-xs text-destructive">Invalid regex</p>
+ )}
+
+ <label className="flex flex-col gap-1 text-xs text-muted-foreground">
+ Note <span className="text-[11px]">(optional)</span>
+ <Input
+ value={row.note}
+ onChange={(e) => onPatch({ note: e.target.value })}
+ placeholder="Why this alias exists"
+ data-testid={`alias-note-${testid}`}
+ />
+ </label>
+
+ {triggers[0] && row.suggestion && (
+ <p className="text-[11px] text-muted-foreground">
+ Preview: typing <code className="font-mono">{triggers[0]}</code> suggests{" "}
+ <code className="font-mono">{row.suggestion}</code>
+ </p>
+ )}
+ </li>
+ );
+}
+
+function cnRow(enabled: boolean): string {
+ return [
+ "flex flex-col gap-2 rounded-lg border bg-card/60 px-3 py-3",
+ enabled ? "" : "opacity-60",
+ ]
+ .filter(Boolean)
+ .join(" ");
+}
diff --git a/editor/app/aliases/actions.ts b/editor/app/aliases/actions.ts
@@ -0,0 +1,30 @@
+"use server";
+
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import {
+ writeGlobalAliases,
+ writeSiteAliases,
+} from "yt-dlp-transcript-common/lib/aliasesStore";
+import {
+ coerceAliasConfig,
+ type AliasConfig,
+} from "yt-dlp-transcript-common/lib/searchAliases";
+
+// Persist the global alias dictionary. Coerced server-side so a malformed entry
+// from the client can never corrupt the file.
+export async function saveGlobalAliasesAction(
+ config: AliasConfig,
+): Promise<{ ok: true }> {
+ writeGlobalAliases(getPaths(), coerceAliasConfig(config));
+ return { ok: true };
+}
+
+// Persist one site's alias overrides. The merged effective list is recomputed
+// at compose time, so there's nothing to sync here.
+export async function saveSiteAliasesAction(
+ siteId: string,
+ config: AliasConfig,
+): Promise<{ ok: true }> {
+ writeSiteAliases(getPaths(), siteId, coerceAliasConfig(config));
+ return { ok: true };
+}
diff --git a/editor/app/aliases/page.tsx b/editor/app/aliases/page.tsx
@@ -0,0 +1,62 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import {
+ readGlobalAliases,
+ readSiteAliases,
+} from "yt-dlp-transcript-common/lib/aliasesStore";
+import { listSiteIds } from "yt-dlp-transcript-common/lib/site";
+import { EditorAliasesClient } from "./EditorAliasesClient";
+import { resolveActiveSite } from "../lib/activeSite";
+
+export const dynamic = "force-dynamic";
+export const metadata: Metadata = { title: "Search aliases" };
+
+export default async function AliasesPage({
+ searchParams,
+}: {
+ searchParams: Promise<{ site?: string }>;
+}) {
+ const paths = getPaths();
+ const { site } = await searchParams;
+ const siteIds = listSiteIds(paths);
+ const active = resolveActiveSite(site, siteIds);
+ // Per-site overrides need a specific site; global aliases are editable always.
+ const siteId = active.isAll ? null : active.siteId ?? null;
+
+ const globalConfig = readGlobalAliases(paths);
+ const siteConfig = siteId ? readSiteAliases(paths, siteId) : null;
+
+ return (
+ <div className="flex flex-col gap-4">
+ <h1 className="text-2xl font-semibold">Search aliases</h1>
+ <p className="max-w-2xl text-sm text-muted-foreground">
+ When a searcher types a known term, the viewer offers a better regex they
+ can apply — never forced. A concept groups the spellings that trigger it
+ (transcription often mangles them) with one replacement pattern. Global
+ aliases apply everywhere;{" "}
+ {siteId ? (
+ <>
+ per-site aliases below add to or override them for{" "}
+ <strong>{siteId}</strong>.
+ </>
+ ) : (
+ <>
+ pick a site in the sidebar to add per-site overrides. Changes bake
+ into a site's next{" "}
+ <Link className="underline" href="/build">
+ export build
+ </Link>
+ .
+ </>
+ )}
+ </p>
+ <EditorAliasesClient
+ key={siteId ?? "__global__"}
+ globalConfig={globalConfig}
+ siteConfig={siteConfig}
+ siteId={siteId}
+ />
+ </div>
+ );
+}
diff --git a/editor/app/lib/nav.ts b/editor/app/lib/nav.ts
@@ -11,6 +11,7 @@ import {
ListChecks,
ListPlus,
Monitor,
+ Regex,
Rocket,
ScrollText,
Settings,
@@ -49,6 +50,7 @@ export const NAV_GROUPS: NavGroup[] = [
{ href: "/", label: "Dashboard", icon: LayoutDashboard, keywords: "home overview" },
{ href: "/channels", label: "Channels", icon: Tv },
{ href: "/charts", label: "Charts", icon: ChartColumnBig, keywords: "stats graphs" },
+ { href: "/aliases", label: "Search aliases", icon: Regex, keywords: "synonyms suggestions regex search terms" },
{ href: "/deploy", label: "Deploy", icon: Rocket, keywords: "publish release" },
],
},
diff --git a/editor/e2e/aliases.spec.ts b/editor/e2e/aliases.spec.ts
@@ -0,0 +1,68 @@
+import { expect, test } from "@playwright/test";
+import { resetData, writeSite, readJson } from "./helpers";
+
+// Search-aliases authoring page. Global aliases seed with defaults; per-site
+// overrides round-trip to sites/<id>/search-aliases.json via a server action.
+
+type AliasFile = { aliases: { id: string; triggers: string[] }[] };
+
+test.describe("search aliases authoring", () => {
+ test.beforeEach(async () => {
+ await resetData();
+ await writeSite("testsite", { siteTitle: "Test Site" });
+ });
+
+ test("Global section pre-fills the seeded defaults", async ({ page }) => {
+ await page.goto("/aliases");
+ await expect(page.getByTestId("alias-section-global")).toBeVisible();
+ // The canonical seeded default is the first global row.
+ await expect(
+ page.locator('[data-testid="alias-label-global"]').first(),
+ ).toHaveValue("loli");
+ });
+
+ test("adding a per-site alias round-trips to disk", async ({ page }) => {
+ await page.goto("/aliases?site=testsite");
+ await expect(page.getByTestId("alias-section-site")).toBeVisible();
+
+ await page.getByTestId("alias-add-site").click();
+ await page.locator('[data-testid="alias-label-site"]').last().fill("testconcept");
+ await page
+ .locator('[data-testid="alias-triggers-site"]')
+ .last()
+ .fill("foo, bar");
+ await page
+ .locator('[data-testid="alias-suggestion-site"]')
+ .last()
+ .fill("\\bfoo\\b");
+ await page.getByTestId("alias-save-site").click();
+ await expect(page.getByTestId("alias-saved-site")).toBeVisible();
+
+ await expect(async () => {
+ const cfg = await readJson<AliasFile>(
+ "test-transcripts/sites/testsite/search-aliases.json",
+ );
+ const entry = cfg.aliases.find((a) => a.id === "testconcept");
+ expect(entry).toBeTruthy();
+ expect(entry?.triggers).toEqual(["foo", "bar"]);
+ }).toPass({ timeout: 5000 });
+ });
+
+ test("editing then saving the Global list persists it", async ({ page }) => {
+ await page.goto("/aliases");
+ // Change the first default's suggestion, then save.
+ const firstSuggestion = page
+ .locator('[data-testid="alias-suggestion-global"]')
+ .first();
+ await firstSuggestion.fill("\\bCHANGED\\b");
+ await page.getByTestId("alias-save-global").click();
+ await expect(page.getByTestId("alias-saved-global")).toBeVisible();
+
+ await expect(async () => {
+ const cfg = await readJson<AliasFile & { aliases: { suggestion: string }[] }>(
+ "test-transcripts/search-aliases.json",
+ );
+ expect(cfg.aliases[0].suggestion).toBe("\\bCHANGED\\b");
+ }).toPass({ timeout: 5000 });
+ });
+});
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -1,5 +1,8 @@
# Changelog
+## [Unreleased]
+- **Search now suggests a better query when you type a known term.** When a search term matches a curated alias — e.g. typing `loli`, `lolly`, or `loly` — a quiet chip appears under the box offering a more robust regex like `\blol(i|ly)`. Click **Apply** to swap it in (and switch that layer to regex mode) or **Dismiss** to ignore it; a new term re-offers. It's never forced, matching is whole-word (so `lolight` won't trigger it), and it's suppressed while you're already writing a regex. The dictionary is authored in the editor's new Search aliases page and shipped per site as `/search-aliases.json` (the global list merged with per-site overrides). See `common/components/QueryLeafView.tsx`, `common/components/{aliasesCache,SearchDataContext}.tsx`, `common/lib/searchAliases.ts`, and `export/e2e/alias-suggestion.spec.ts`.
+
## [0.7.0] - 2026-07-06
- **Bring-your-own-AI: the archive is now machine-navigable for AI tools.** Every site publishes a small fixed set of discovery files — `llms.txt` (an LLM-readable overview) and `corpus.json` (a documented index of the channels and *how to fetch any transcript* from the existing paginated JSON shards), plus `robots.txt` and a `sitemap.xml`. Nothing is generated per video (the shard scheme is documented instead), so the file count stays constant no matter how large the corpus grows. This lets Claude Code and other tools browse and answer questions about the archive by fetching a couple of URLs. The federated hub publishes an aggregate `corpus.json`/`llms.txt` spanning every member site.
- **New MCP server (`mcp/`) for Claude Code, Cursor, and other MCP clients.** A local tool that exposes the archive as MCP tools — `list_channels`, `search_transcripts` (timestamped snippets), `get_transcript`, `get_video_metadata` — reading the same static shards over disk or HTTP. It can point at one site or federate a whole hub. It never changes or hosts the site; see `mcp/README.md` for setup.
diff --git a/export/e2e/alias-suggestion.spec.ts b/export/e2e/alias-suggestion.spec.ts
@@ -0,0 +1,90 @@
+import { expect, test, type Page } from "@playwright/test";
+import { installRoutes } from "./helpers";
+
+// Alias suggestion chip in the search builder. The viewer fetches
+// /search-aliases.json (global merged with per-site at compose time); when a
+// typed term matches a known trigger, a non-forcing chip offers a better regex.
+
+const ALIASES = {
+ aliases: [
+ {
+ id: "loli",
+ label: "loli",
+ triggers: ["loli", "lolly", "loly"],
+ suggestion: "\\blol(i|ly)",
+ useRegex: true,
+ note: "AI transcription often expands this.",
+ },
+ ],
+};
+
+async function seedAliases(page: Page) {
+ await installRoutes(page);
+ // Registered after installRoutes so it wins over the empty default.
+ await page.route("**/search-aliases.json", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(ALIASES),
+ });
+ });
+}
+
+const firstLeaf = (page: Page) =>
+ page.locator('input[data-testid^="leaf-query-"]').first();
+
+test.describe("search alias suggestions", () => {
+ test.beforeEach(async ({ page }) => {
+ await seedAliases(page);
+ });
+
+ test("typing a known trigger offers the suggestion; Apply swaps in the regex", async ({
+ page,
+ }) => {
+ await page.goto("/");
+ await firstLeaf(page).fill("loli");
+
+ // The chip appears, labeled by concept and showing the suggested pattern.
+ const chip = page.locator('[data-testid^="leaf-alias-suggestions-"]');
+ await expect(chip).toBeVisible();
+ await expect(chip).toContainText("loli");
+ await expect(chip.locator("code")).toHaveText("\\blol(i|ly)");
+
+ // Apply swaps the query and flips on regex mode.
+ await page.locator('[data-testid^="leaf-alias-apply-"]').click();
+ await expect(firstLeaf(page)).toHaveValue("\\blol(i|ly)");
+ await expect(
+ page.locator('[data-testid^="leaf-regex-"]').first(),
+ ).toHaveAttribute("aria-checked", "true");
+
+ // In regex mode the suggestion is suppressed (the user owns their pattern).
+ await expect(chip).toHaveCount(0);
+ });
+
+ test("does not fire on a mere substring (whole-token match only)", async ({
+ page,
+ }) => {
+ await page.goto("/");
+ await firstLeaf(page).fill("lolight");
+ await expect(
+ page.locator('[data-testid^="leaf-alias-suggestions-"]'),
+ ).toHaveCount(0);
+ });
+
+ test("Dismiss hides the suggestion; a new term re-offers", async ({
+ page,
+ }) => {
+ await page.goto("/");
+ const leaf = firstLeaf(page);
+ await leaf.fill("loli");
+ const chip = page.locator('[data-testid^="leaf-alias-suggestions-"]');
+ await expect(chip).toBeVisible();
+
+ await page.locator('[data-testid^="leaf-alias-dismiss-"]').click();
+ await expect(chip).toHaveCount(0);
+
+ // A different trigger for the same concept re-offers it.
+ await leaf.fill("lolly");
+ await expect(chip).toBeVisible();
+ });
+});
diff --git a/export/e2e/helpers.ts b/export/e2e/helpers.ts
@@ -46,6 +46,12 @@ export async function installRoutes(page: Page) {
await page.route(/\/subs\/[^/]+\/page-\d+\.json$/, async (route) => {
await fulfillJson(route, subsPage());
});
+ // Search-alias dictionary — empty by default; alias-suggestion.spec overrides
+ // this with a populated list. Kept here so other search tests get a clean
+ // intercept instead of a real 404.
+ await page.route("**/search-aliases.json", async (route) => {
+ await fulfillJson(route, { aliases: [] });
+ });
}
// Charts page fetches: stats dataset + baked templates. Also installs the