/** * Content registry — the single source of truth for all content in a * TTRPG Tools project. * * Two stores: * - `pathIndex`: real files on disk, keyed by path (`.md`, `.csv`, `.yarn`, `.svg`) * - `docContent`: inline content *defined inside* a markdown doc, keyed by * a stable id and owned by that doc. * * Everything structured (completions, declarations, tag modifiers) is a * *derived* view over this registry — see `deriveCompletions`. * * This module is browser-safe (no Node-only imports) so the CLI and the * frontend share one implementation. The filesystem walk lives in the CLI * (`buildRegistry` in `commands/serve.ts`). */ import Slugger from "github-slugger"; import { parseDeclareCsv, type VarDeclaration, type TagModifier, } from "./completions/declare-parser.js"; import { FENCED_BLOCK_RE, parseBlockAttrs, } from "./completions/block-scanner.js"; import type { CompletionsPayload, DiceCompletion, LinkCompletion, SparkTableCompletion, } from "./completions/types.js"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export type ContentKind = "csv" | "text" | "declare"; /** A single piece of inline content defined inside a doc. */ export interface DocContent { /** Stable id — author-supplied or derived from the body. */ id: string; kind: ContentKind; body: string; /** Origin role that produced this content, for debugging. */ role?: string; } export interface ContentRegistry { /** Real files on disk, keyed by path. */ pathIndex: Record; /** Inline content defined inside each doc, keyed by id. */ docContent: Record>; } export const EMPTY_REGISTRY: ContentRegistry = { pathIndex: {}, docContent: {}, }; /** * Normalize a file path into a stable registry key: forward slashes and a * single leading `/`. Both the CLI filesystem walk and the browser folder * scan funnel through this so identical files always share a key. */ export function normalizePathKey(path: string): string { const normalized = path.split("\\").join("/"); return normalized.startsWith("/") ? normalized : `/${normalized}`; } // --------------------------------------------------------------------------- // Id / hash derivation (single source of truth) // --------------------------------------------------------------------------- /** Browser-safe content hash — stable across CLI and frontend. */ export function contentHash(body: string): string { let hash = 0; for (let i = 0; i < body.length; i++) { const ch = body.charCodeAt(i); hash = ((hash << 5) - hash + ch) | 0; } return Math.abs(hash).toString(16).slice(0, 8); } /** * Derive a stable content id. * Author-supplied `id` wins; otherwise `{kind}_{hash}`. */ export function deriveContentId( kind: ContentKind, body: string, id?: string, ): string { if (id) return id; return `${kind}_${contentHash(body)}`; } // --------------------------------------------------------------------------- // Per-doc scanning // --------------------------------------------------------------------------- export interface DocScanResult { /** Content with blocks processed (stripped or replaced with directives). */ stripped: string; /** Inline content defined in this doc, keyed by id. */ content: Record; } /** * Process a single markdown doc: * - dispatches attributed fenced code blocks by `role` * - converts `markdown`-lang `role=spark-table` bodies (pipe tables) to CSV * - collects inline content (spark-table, declare, file) into the doc's * content store * * Does NOT touch the path index — the caller assembles the registry. */ export function scanDoc(content: string, docPath: string): DocScanResult { const contentStore: Record = {}; const stripped = content.replace( FENCED_BLOCK_RE, ( match: string, lang: string, infoString: string, body: string, ): string => { const attrs = parseBlockAttrs(infoString); attrs.lang = attrs.lang || lang; switch (attrs.role) { case "declare": { const id = deriveContentId("declare", body, attrs.id); contentStore[id] = { id, kind: "declare", body, role: attrs.role }; return ""; } case "file": { const id = deriveContentId("text", body, attrs.id); contentStore[id] = { id, kind: "text", body, role: attrs.role }; return ""; } case "spark-table": { let blockBody = body; if (isMarkdownTableLang(attrs.lang)) { blockBody = markdownTableBodyToCsv(body, docPath); } const id = deriveContentId("csv", blockBody, attrs.id); contentStore[id] = { id, kind: "csv", body: blockBody, role: attrs.role, }; const extraStr = Object.entries(attrs.extra) .map(([k, v]) => `${k}=${v}`) .join(" "); return `:md-table[./${id}]${extraStr ? `{${extraStr}}` : ""}`; } case "tag": // Kept intact so the code-block-yaml-tag render extension sees it. return match; case undefined: // No role declared — plain visible code block. return match; default: console.warn( `[content-registry] ${docPath}: unknown role "${attrs.role}" — stripping block`, ); return ""; } }, ); return { stripped, content: contentStore }; } /** * Build a `ContentRegistry` from a raw `{ path: content }` index. * * Shared by the CLI (`buildRegistry` in `commands/serve.ts`) and the browser * folder scan (`scanClientSide` in `components/journal/completions.ts`) so * both modes produce identical `pathIndex`/`docContent` — including the * Shared by the CLI (`buildRegistry` in `commands/serve.ts`) and the browser * folder scan (`scanClientSide` in `components/journal/completions.ts`) so * both modes produce identical `pathIndex`/`docContent`. * * Keys are normalized via `normalizePathKey`; `.md` files are run through * `scanDoc`, other extensions are stored raw. */ export function buildRegistryFromIndex( index: Record, ): ContentRegistry { const registry: ContentRegistry = { pathIndex: {}, docContent: {} }; for (const [rawPath, content] of Object.entries(index)) { const path = normalizePathKey(rawPath); if (path.endsWith(".md")) { const result = scanDoc(content, path); registry.pathIndex[path] = result.stripped; registry.docContent[path] = result.content; } else { registry.pathIndex[path] = content; } } return registry; } // --------------------------------------------------------------------------- // Spark table coercion // --------------------------------------------------------------------------- /** * Whether a table header cell is a dice formula (a "spark table" first * column). Used to validate `role=spark-table` blocks (CSV or markdown * pipe-table bodies). */ export function isSparkTableHeader(header: string): boolean { return /^\d*d\d+(?:[+-]\d+)?$/i.test(header.trim()); } /** Split a markdown table row into cells. */ function splitTableRow(row: string): string[] { return row .replace(/^\|/, "") .replace(/\|$/, "") .split("|") .map((c) => c.trim()); } /** Escape a cell value for CSV output. */ function escapeCsvCell(cell: string): string { if ( cell.includes(",") || cell.includes("\n") || cell.includes('"') || cell.includes("#") ) { return `"${cell.replace(/"/g, '""')}"`; } return cell; } /** Convert a markdown table (header + separator + rows) to a CSV string. */ function markdownTableToCsv( headerRow: string, separatorRow: string, bodyRows: string[], ): string | null { const headers = splitTableRow(headerRow); if (headers.length === 0) return null; const sepCells = splitTableRow(separatorRow); if (!sepCells.every((c) => /^:?-{3,}:?$/.test(c))) return null; if (sepCells.length !== headers.length) return null; const csvHeader = headers.map(escapeCsvCell).join(","); const csvRows = bodyRows.map((row) => { const cells = splitTableRow(row); while (cells.length < headers.length) cells.push(""); return cells.slice(0, headers.length).map(escapeCsvCell).join(","); }); return [csvHeader, ...csvRows].join("\n"); } /** * Languages whose fenced-block bodies are markdown pipe tables. Used with * `role=spark-table` to convert the table to CSV at scan time — explicitly * authorized by the role, never by content shape. */ const MARKDOWN_TABLE_LANGS = new Set(["markdown", "md"]); function isMarkdownTableLang(lang: string): boolean { return MARKDOWN_TABLE_LANGS.has(lang.toLowerCase()); } /** * Convert a fenced markdown pipe-table body to CSV for `role=spark-table` * blocks. Validates the dice-formula first column (warning only — the role * already declared intent) and falls back to storing the body as-is when it * is not a recognizable pipe table. */ function markdownTableBodyToCsv(body: string, docPath: string): string { const lines = body .trim() .split(/\r?\n/) .filter((l) => l.trim().startsWith("|")); if (lines.length < 2) { console.warn( `[content-registry] ${docPath}: role=spark-table markdown body is not a pipe table; storing as-is`, ); return body; } const [headerRow, separatorRow, ...rows] = lines; const headers = splitTableRow(headerRow); if (!isSparkTableHeader(headers[0] || "")) { console.warn( `[content-registry] ${docPath}: spark table first column "${headers[0]}" is not a dice formula`, ); } return markdownTableToCsv(headerRow, separatorRow, rows) ?? body; } // --------------------------------------------------------------------------- // Resolution // --------------------------------------------------------------------------- /** * A resolved content reference: the body plus how it was addressed. */ export interface ResolvedContent { body: string; /** Content id for inline content, path-index key for real files. */ path: string; /** True when resolved from the doc's inline content store. */ inline: boolean; } /** * Resolve a content reference from within a doc. * * - `ref` is an absolute path → path index. * - `ref` is a relative path → resolved against the doc directory; checks * the doc's inline content store first, then the path index. * * Refs are always ids or paths — inline content must be defined in a fenced * block and referenced by its `./{id}`. * * Returns `null` when nothing matches. */ export function resolveContentEntry( registry: ContentRegistry, docPath: string, ref: string, ): ResolvedContent | null { const trimmed = ref.trim(); if (!trimmed) return null; if (trimmed.startsWith("/")) { const body = registry.pathIndex[trimmed]; return body == null ? null : { body, path: trimmed, inline: false }; } // Inline content in the same doc (e.g. ./{id}). const docStore = registry.docContent[docPath]; const id = trimmed.startsWith("./") ? trimmed.slice(2) : trimmed; const entry = docStore?.[id]; if (entry) return { body: entry.body, path: id, inline: true }; // Relative path resolved against the doc directory (`./` already stripped). const resolved = posixJoin(posixDir(docPath), id); const body = registry.pathIndex[resolved]; return body != null ? { body, path: resolved, inline: false } : null; } /** Resolve a content reference to its body only. */ export function resolveContent( registry: ContentRegistry, docPath: string, ref: string, ): string | null { return resolveContentEntry(registry, docPath, ref)?.body ?? null; } /** * Resolve a *resolved* path (e.g. `/content/csv_abc123`) to inline content * by searching every doc's content store for a matching id. Used by the * frontend when a directive ref has already been resolved to a path. * * Returns `null` when no inline content matches. */ export function resolveInlineByPath( registry: ContentRegistry, resolvedPath: string, ): string | null { const id = resolvedPath.split("/").filter(Boolean).pop() || ""; if (!id) return null; for (const store of Object.values(registry.docContent)) { const entry = store[id]; if (entry) return entry.body; } return null; } // --------------------------------------------------------------------------- // Derived completions // --------------------------------------------------------------------------- /** * Derive the completions payload from the registry. * Pure function — recompute on any file change. * * Dice + spark completions come from scanning each doc's stripped content * for directives (resolving CSV refs through the registry), so both inline * and real-file spark tables are covered. Declarations come from the doc * content store. */ export function deriveCompletions( registry: ContentRegistry, ): CompletionsPayload { const links = deriveLinks(registry.pathIndex); const dice: DiceCompletion[] = []; const sparkTables: SparkTableCompletion[] = []; const declarations: VarDeclaration[] = []; const tagModifiers: TagModifier[] = []; for (const [docPath, content] of Object.entries(registry.pathIndex)) { if (!docPath.endsWith(".md")) continue; const found = scanDocDirectives(content, docPath, registry); dice.push(...found.dice); sparkTables.push(...found.sparkTables); } const blocks = deriveBlocks(registry); declarations.push(...blocks.declarations); tagModifiers.push(...blocks.tagModifiers); return { dice, links, sparkTables, declarations, tagModifiers }; } /** * Scan a doc's stripped content for `:md-dice` and `:md-table`/`:md-card` * directives, resolving CSV refs through the registry. */ function scanDocDirectives( content: string, docPath: string, registry: ContentRegistry, ): { dice: DiceCompletion[]; sparkTables: SparkTableCompletion[] } { const dice = scanDice(content, docPath); const sparkTables: SparkTableCompletion[] = []; const tableDirectiveRegex = /:md-(table|card)\[([^\[\]]+)\](?:\{([^}]*)\})?/gi; let m: RegExpExecArray | null; while ((m = tableDirectiveRegex.exec(content)) !== null) { const [, , ref, extraStr] = m; const resolved = resolveContentEntry(registry, docPath, ref); if (!resolved) continue; const attrs = parseBlockAttrs(extraStr || "").extra; const st = buildSparkTableCompletion( resolved.body, docPath, resolved.path, attrs["remix"] === "true", ); if (st) sparkTables.push(st); } return { dice, sparkTables }; } /** Derive variable declarations + tag modifiers from the registry. */ export function deriveBlocks(registry: ContentRegistry): { declarations: VarDeclaration[]; tagModifiers: TagModifier[]; } { const declarations: VarDeclaration[] = []; const tagModifiers: TagModifier[] = []; for (const [docPath, store] of Object.entries(registry.docContent)) { for (const entry of Object.values(store)) { if (entry.kind !== "declare") continue; try { const result = parseDeclareCsv(entry.body, docPath); declarations.push(...result.variables); tagModifiers.push(...result.tagModifiers); } catch (e) { console.warn(`[content-registry] ${docPath}: ${e}`); } } } return { declarations, tagModifiers }; } // --------------------------------------------------------------------------- // Derivation helpers // --------------------------------------------------------------------------- /** * Remove fenced code blocks (backtick or tilde) from content, so text * scanners (headings, dice directives) don't match example code. */ function stripFencedBlocks(content: string): string { const out: string[] = []; let fence: string | null = null; for (const line of content.split(/\r?\n/)) { const fenceMatch = /^(`{3,}|~{3,})/.exec(line); if (fence) { if (line.startsWith(fence)) fence = null; continue; } if (fenceMatch) { fence = fenceMatch[1]; continue; } out.push(line); } return out.join("\n"); } /** Extract headings from all `.md` files as link completions. */ function deriveLinks(pathIndex: Record): LinkCompletion[] { const items: LinkCompletion[] = []; for (const [filePath, rawContent] of Object.entries(pathIndex)) { if (!filePath.endsWith(".md")) continue; // Headings inside fenced code blocks (e.g. markdown examples) are not // real headings — exclude them from link completions. const content = stripFencedBlocks(rawContent); const basePath = filePath.replace(/\.md$/, ""); const fileName = fileNameFromPath(basePath); const slugger = new Slugger(); items.push({ path: basePath, label: fileName, section: null }); const headingRegex = /^(#{1,6})\s+(.+)$/gm; let match: RegExpExecArray | null; while ((match = headingRegex.exec(content)) !== null) { const title = match[2].trim(); const id = slugger.slug(title.toLowerCase()); items.push({ path: basePath, label: `${fileName} § ${title}`, section: id, }); } } return items; } const DICE_DIRECTIVE_RE = /:md-dice\[([^[\]]+)\]/gi; function looksLikeDice(raw: string): boolean { if (raw.length > 80) return false; return /^\d*d\d+/i.test(raw) || /^[+-]/.test(raw); } /** Scan a text body for `:md-dice[...]` directives. */ function scanDice(body: string, source: string): DiceCompletion[] { const dice: DiceCompletion[] = []; let m: RegExpExecArray | null; while ((m = DICE_DIRECTIVE_RE.exec(body)) !== null) { const raw = m[1].trim(); if (!raw || !looksLikeDice(raw)) continue; dice.push({ label: raw, notation: raw, source }); } return dice; } /** * Inspect a CSV body and return its data-column headers if it's a spark * table (first column header is a dice formula), or null otherwise. */ export function inspectSparkTableCsv(csv: string): string[] | null { const lines = csv.trim().split(/\r?\n/); if (lines.length < 2) return null; const headers = lines[0].split(",").map((h) => h.trim()); if (headers.length < 2) return null; if (!isSparkTableHeader(headers[0])) return null; return headers.slice(1); } /** Build a SparkTableCompletion from a CSV body. */ export function buildSparkTableCompletion( csv: string, docPath: string, contentId: string, remix: boolean, ): SparkTableCompletion | null { const dataHeaders = inspectSparkTableCsv(csv); if (!dataHeaders) return null; const notation = csv.trim().split(/\r?\n/)[0].split(",")[0].trim(); const slugger = new Slugger(); const slug = dataHeaders.map((h) => slugger.slug(h.toLowerCase())).join("-"); const basePath = docPath.replace(/\.md$/, ""); const fileName = basePath.split("/").filter(Boolean).pop() || basePath; const combinedSlug = `${fileName}-${slug}`; return { label: `${fileName} § ${slug}`, notation, slug: combinedSlug, filePath: basePath, docPath, csvPath: contentId, headers: dataHeaders, remix, }; } // --------------------------------------------------------------------------- // Path helpers (browser-safe posix) // --------------------------------------------------------------------------- function posixDir(path: string): string { const idx = path.lastIndexOf("/"); return idx >= 0 ? path.slice(0, idx) : "."; } function posixJoin(dir: string, rel: string): string { if (dir === ".") return rel.startsWith("/") ? rel : `/${rel}`; const base = dir.replace(/\/+$/, ""); const r = rel.replace(/^\/+/, ""); return `${base}/${r}`; } function fileNameFromPath(path: string): string { const parts = path.split("/").filter(Boolean); return parts[parts.length - 1] || path; }