refactor(completions): centralize content scanning

Add a browser-safe ContentRegistry owning path and inline content
stores, then derive completions from it. Remove the old block-processor,
directive-scanner, and link-source modules in favor of scanDoc,
deriveCompletions, and injectSparkDirectives, and register an inline
resolver for the frontend file index.
This commit is contained in:
hyper
2026-08-07 12:17:35 +08:00
parent 9e081891df
commit e8aa7165cb
13 changed files with 985 additions and 848 deletions
+48 -105
View File
@@ -8,17 +8,12 @@ import { networkInterfaces } from "os";
import { fileURLToPath } from "url";
import { createJournalServer } from "../journal.js";
import {
scanCompletions,
type CompletionsPayload,
} from "../completions/index.js";
import {
processBlocks,
type ProcessedBlocks,
} from "../completions/block-processor.js";
import {
scanDirectives,
type DirectiveScanResult,
} from "../completions/directive-scanner.js";
scanDoc,
deriveCompletions,
injectSparkDirectives,
type ContentRegistry,
} from "../content-registry.js";
import type { CompletionsPayload } from "../completions/types.js";
interface ContentIndex {
[path: string]: string;
@@ -88,20 +83,10 @@ function getBestIP(): string {
}
/**
* 扫描目录内的 .md 等文件,生成内容索引与块数据
* 扫描目录内的 .md 等文件,构建内容注册表(路径索引 + 每文档内联内容)
*/
export function scanDirectory(dir: string): {
index: ContentIndex;
blocks: ProcessedBlocks;
directiveResults: DirectiveScanResult[];
} {
const index: ContentIndex = {};
const blocks: ProcessedBlocks = {
declarations: [],
tagModifiers: [],
};
const directiveResults: DirectiveScanResult[] = [];
const mdFiles: { content: string; relPath: string }[] = [];
export function buildRegistry(dir: string): ContentRegistry {
const registry: ContentRegistry = { pathIndex: {}, docContent: {} };
function scan(currentPath: string, relativePath: string) {
const entries = readdirSync(currentPath);
@@ -125,13 +110,11 @@ export function scanDirectory(dir: string): {
try {
const content = readFileSync(fullPath, "utf-8");
if (entry.endsWith(".md")) {
const result = processBlocks(content, normalizedRelPath, index);
index[normalizedRelPath] = result.stripped;
blocks.declarations.push(...result.blocks.declarations);
blocks.tagModifiers.push(...result.blocks.tagModifiers);
mdFiles.push({ content: result.stripped, relPath: normalizedRelPath });
const result = scanDoc(content, normalizedRelPath);
registry.pathIndex[normalizedRelPath] = result.stripped;
registry.docContent[normalizedRelPath] = result.content;
} else {
index[normalizedRelPath] = content;
registry.pathIndex[normalizedRelPath] = content;
}
} catch (e) {
console.error(`读取文件失败:${fullPath}`, e);
@@ -142,31 +125,17 @@ export function scanDirectory(dir: string): {
scan(dir, "");
// ---- Directive scanning pass (after all blocks processed) ----
const posixDir = dir.split(sep).join("/");
for (const { content, relPath } of mdFiles) {
const fileDir = posixRelDir(relPath);
const result = scanDirectives(content, relPath, index, fileDir);
// Apply rewritten content back to index
index[relPath] = result.rewritten;
// Inject new index entries (inline CSV bodies)
for (const [key, value] of Object.entries(result.newIndexEntries)) {
index[key] = value;
}
directiveResults.push(result);
// ---- Inject data-spark into real-file spark table directives ----
for (const [relPath, content] of Object.entries(registry.pathIndex)) {
if (!relPath.endsWith(".md")) continue;
registry.pathIndex[relPath] = injectSparkDirectives(
content,
relPath,
registry,
);
}
return { index, blocks, directiveResults };
}
/** Get the POSIX directory of a file path */
function posixRelDir(filePath: string): string {
const parts = filePath.split("/");
parts.pop();
return parts.join("/") || ".";
return registry;
}
/**
@@ -231,6 +200,7 @@ function createRequestHandler(
distDir: string,
getIndex: () => ContentIndex,
getCompletions: () => CompletionsPayload,
getRegistry: () => ContentRegistry,
) {
return (req: IncomingMessage, res: ServerResponse) => {
const url = req.url || "/";
@@ -248,6 +218,12 @@ function createRequestHandler(
return;
}
// 1c. 处理 /__CONTENT_REGISTRY.json(含每文档内联内容,供运行时解析)
if (filePath === "/__CONTENT_REGISTRY.json") {
sendJson(res, getRegistry());
return;
}
// 2. 处理 /static/ 目录(从 dist/web)
if (filePath.startsWith("/static/")) {
if (tryServeStatic(res, filePath, distDir)) {
@@ -308,12 +284,7 @@ export function createContentServer(
distPath: string = distDir,
host: string = "0.0.0.0",
): ContentServer {
let contentIndex: ContentIndex = {};
let collectedBlocks: ProcessedBlocks = {
declarations: [],
tagModifiers: [],
};
let directiveResults: DirectiveScanResult[] = [];
let registry: ContentRegistry = { pathIndex: {}, docContent: {} };
let completionsIndex: CompletionsPayload = {
dice: [],
links: [],
@@ -322,21 +293,18 @@ export function createContentServer(
tagModifiers: [],
};
/** 从当前内容索引和已收集的块重新扫描补全数据 */
/** 从当前注册表重新派生补全数据 */
function recomputeCompletions(): void {
completionsIndex = scanCompletions(contentIndex, collectedBlocks, directiveResults);
completionsIndex = deriveCompletions(registry);
console.log(
`[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length} declarations=${completionsIndex.declarations.length} tagModifiers=${completionsIndex.tagModifiers.length}`,
);
}
// 扫描内容目录生成索引
// 扫描内容目录生成注册表
console.log("正在扫描内容目录...");
const scanResult = scanDirectory(contentDir);
contentIndex = scanResult.index;
collectedBlocks = scanResult.blocks;
directiveResults = scanResult.directiveResults;
console.log(`已索引 ${Object.keys(contentIndex).length} 个文件`);
registry = buildRegistry(contentDir);
console.log(`已索引 ${Object.keys(registry.pathIndex).length} 个文件`);
recomputeCompletions();
// 监听文件变化
@@ -356,20 +324,10 @@ export function createContentServer(
path.endsWith(".svg")
) {
try {
const content = readFileSync(path, "utf-8");
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
if (relPath.endsWith(".md")) {
const result = processBlocks(content, relPath, contentIndex);
contentIndex[relPath] = result.stripped;
// Re-scan to get fresh blocks (simpler than per-file merge)
const rescan = scanDirectory(contentDir);
collectedBlocks = rescan.blocks;
directiveResults = rescan.directiveResults;
recomputeCompletions();
} else {
contentIndex[relPath] = content;
}
console.log(`[新增] ${relPath}`);
// 全量重建注册表以刷新跨文件派生(spark 注入)
registry = buildRegistry(contentDir);
recomputeCompletions();
console.log(`[新增] ${path}`);
} catch (e) {
console.error(`读取新增文件失败:${path}`, e);
}
@@ -383,19 +341,9 @@ export function createContentServer(
path.endsWith(".svg")
) {
try {
const content = readFileSync(path, "utf-8");
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
if (relPath.endsWith(".md")) {
const result = processBlocks(content, relPath, contentIndex);
contentIndex[relPath] = result.stripped;
const rescan = scanDirectory(contentDir);
collectedBlocks = rescan.blocks;
directiveResults = rescan.directiveResults;
recomputeCompletions();
} else {
contentIndex[relPath] = content;
}
console.log(`[更新] ${relPath}`);
registry = buildRegistry(contentDir);
recomputeCompletions();
console.log(`[更新] ${path}`);
} catch (e) {
console.error(`读取更新文件失败:${path}`, e);
}
@@ -408,15 +356,9 @@ export function createContentServer(
path.endsWith(".yarn") ||
path.endsWith(".svg")
) {
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
delete contentIndex[relPath];
console.log(`[删除] ${relPath}`);
if (relPath.endsWith(".md")) {
const rescan = scanDirectory(contentDir);
collectedBlocks = rescan.blocks;
directiveResults = rescan.directiveResults;
recomputeCompletions();
}
registry = buildRegistry(contentDir);
recomputeCompletions();
console.log(`[删除] ${path}`);
}
});
@@ -427,8 +369,9 @@ export function createContentServer(
const handleRequest = createRequestHandler(
contentDir,
distPath,
() => contentIndex,
() => registry.pathIndex,
() => completionsIndex,
() => registry,
);
const server = createServer(handleRequest);
@@ -452,7 +395,7 @@ export function createContentServer(
return {
server,
watcher,
index: contentIndex,
index: registry.pathIndex,
completions: completionsIndex,
close() {
console.log("正在关闭内容服务器...");
-144
View File
@@ -1,144 +0,0 @@
/**
* CLI block processor — wraps block-scanner with Node-specific index injection
* and content stripping.
*
* Uses Node `crypto` and `path` — not safe for browser import.
*/
import { posix } from "path";
import { createHash } from "crypto";
import { parseDeclareCsv, type VarDeclaration, type TagModifier } from "./declare-parser.js";
import {
FENCED_BLOCK_RE,
parseBlockAttrs,
resolveBlockAs,
} from "./block-scanner.js";
// Re-export shared pieces for convenience
export {
FENCED_BLOCK_RE,
parseBlockAttrs,
resolveBlockAs,
type BlockAttrs,
} from "./block-scanner.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface ProcessedBlocks {
declarations: VarDeclaration[];
tagModifiers: TagModifier[];
}
export interface BlockResult {
/** Content with blocks processed (stripped or replaced with directives) */
stripped: string;
/** Parsed blocks for completions */
blocks: ProcessedBlocks;
}
// ---------------------------------------------------------------------------
// Content hash
// ---------------------------------------------------------------------------
function contentHash(body: string): string {
return createHash("md5").update(body).digest("hex").slice(0, 8);
}
// ---------------------------------------------------------------------------
// Main processor
// ---------------------------------------------------------------------------
/**
* Process all attributed fenced code blocks in a markdown file.
*
* - Strips/replaces blocks based on `as`
* - Injects `role=file` bodies into the content index
* - Collects declare blocks for completions
* - role=spark-table blocks are converted to :md-table directives
* (spark table completions are collected later by the directive scanner)
*/
export function processBlocks(
content: string,
fileRelativePath: string,
index: Record<string, string>,
): BlockResult {
const fileDir = posix.dirname(fileRelativePath);
const blocks: ProcessedBlocks = {
declarations: [],
tagModifiers: [],
};
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;
const effectiveAs = resolveBlockAs(attrs.role, attrs.as);
// ---- Dispatch by role ----
if (attrs.role === "declare") {
try {
const result = parseDeclareCsv(body, fileRelativePath);
blocks.declarations.push(...result.variables);
blocks.tagModifiers.push(...result.tagModifiers);
} catch (e) {
console.warn(`[block-processor] ${fileRelativePath}: ${e}`);
}
}
if (attrs.role === "file") {
const filename = attrs.id
? `${attrs.id}.${attrs.lang || "txt"}`
: `_inline_${contentHash(body)}.${attrs.lang || "txt"}`;
const resolvedPath = posix.join(fileDir, filename);
index[resolvedPath] = body;
}
// ---- Render by as ----
if (effectiveAs === "codeblock") {
return _match; // keep as-is
}
if (effectiveAs === "none") {
return ""; // strip
}
// Directive: :md-table[./file.csv], :md-card[./file.csv], :md-dice[./file.csv]
if (effectiveAs.startsWith("md-")) {
const filename = attrs.id
? `${attrs.id}.${attrs.lang || "txt"}`
: `_inline_${contentHash(body)}.${attrs.lang || "txt"}`;
const resolvedPath = posix.join(fileDir, filename);
// Ensure body is in the index for directive rendering
if (!index[resolvedPath]) {
index[resolvedPath] = body;
}
// Collect extra attrs for the directive
const extra = { ...attrs.extra };
const extraStr = Object.keys(extra).length
? `{${Object.entries(extra)
.map(([k, v]) => `${k}=${v}`)
.join(" ")}}`
: "";
return `:${effectiveAs}[./${filename}]${extraStr}`;
}
// Unknown as → strip
return "";
},
);
return { stripped, blocks };
}
+8 -42
View File
@@ -16,49 +16,21 @@
*/
import { parse } from "csv-parse/browser/esm/sync";
import { FENCED_BLOCK_RE, parseBlockAttrs } from "./block-scanner.js";
/**
* Scan markdown content for ```csv role=declare blocks and return
* parsed declarations and tag modifiers. Shared between CLI and client.
*/
export function scanDeclareBlocks(content: string, filePath: string): DeclareResult {
const variables: VarDeclaration[] = [];
const tagModifiers: TagModifier[] = [];
FENCED_BLOCK_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = FENCED_BLOCK_RE.exec(content)) !== null) {
const [, , infoString, body] = m;
const attrs = parseBlockAttrs(infoString);
if (attrs.role !== "declare") continue;
try {
const result = parseDeclareCsv(body, filePath);
variables.push(...result.variables);
tagModifiers.push(...result.tagModifiers);
} catch (e) {
console.warn(`[declare-parser] ${filePath}: ${e}`);
}
}
return { variables, tagModifiers };
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface VarDeclaration {
key: string; // "$hp" (always starts with $)
key: string; // "$hp" (always starts with $)
expression: string; // "$con*5+$mod_hp"
}
export interface TagModifier {
tag: string; // "#warrior"
target: string; // "$mod_hp"
tag: string; // "#warrior"
target: string; // "$mod_hp"
expression: string; // "20"
threshold: number; // minimum tagmap count to activate (default 1)
threshold: number; // minimum tagmap count to activate (default 1)
}
export interface DeclareResult {
@@ -104,14 +76,10 @@ export function parseDeclareCsv(csv: string, source: string): DeclareResult {
if (tag) {
// Tag modifier
if (!tag.startsWith("#")) {
throw new Error(
`${source}: tag must start with #, got "${tag}"`,
);
throw new Error(`${source}: tag must start with #, got "${tag}"`);
}
if (!key.startsWith("$")) {
throw new Error(
`${source}: key must start with $, got "${key}"`,
);
throw new Error(`${source}: key must start with $, got "${key}"`);
}
const thresholdRaw = row.threshold?.trim() ?? "";
const threshold = thresholdRaw ? parseInt(thresholdRaw, 10) : 1;
@@ -124,13 +92,11 @@ export function parseDeclareCsv(csv: string, source: string): DeclareResult {
} else {
// Variable declaration
if (!key.startsWith("$")) {
throw new Error(
`${source}: key must start with $, got "${key}"`,
);
throw new Error(`${source}: key must start with $, got "${key}"`);
}
variables.push({ key, expression: expr });
}
}
return { variables, tagModifiers };
}
}
-341
View File
@@ -1,341 +0,0 @@
/**
* Unified directive scanner — shared between CLI and browser.
*
* One pass over stripped markdown content that:
* 1. Detects markdown tables that look like spark tables → coerces to
* :md-table[./_inline_{hash}.csv] directives
* 2. Scans :md-dice[...] directives → collects DiceCompletion
* 3. Scans :md-table[...] directives → resolves CSV, checks if spark table
* → collects SparkTableCompletion
* 4. Scans :md-card[...] directives → same as md-table
*
* Safe for both Node and browser. No Node-specific imports.
*/
import Slugger from "github-slugger";
import type { DiceCompletion, SparkTableCompletion } from "./types.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface DirectiveScanResult {
/** Rewritten content with markdown tables coerced to directives */
rewritten: string;
/** Dice completions discovered */
dice: DiceCompletion[];
/** Spark table completions discovered */
sparkTables: SparkTableCompletion[];
/** New index entries for inline CSV bodies (key → CSV content) */
newIndexEntries: Record<string, string>;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const DICE_HEADER_RE = /^\d*d\d+$/i;
function contentHash(body: string): string {
// Simple hash suitable for both Node and browser
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);
}
function looksLikeDice(raw: string): boolean {
if (raw.length > 80) return false;
return /^\d*d\d+/i.test(raw) || /^[+-]/.test(raw);
}
/** Parse key=value pairs from directive extra attrs string */
function parseDirectiveAttrs(extraStr: string | undefined): Record<string, string> {
if (!extraStr) return {};
const attrs: Record<string, string> = {};
const re = /(\w+)\s*=\s*("[^"]*"|\S+)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(extraStr)) !== null) {
attrs[m[1]] = m[2].replace(/^"|"$/g, "");
}
return attrs;
}
// ---------------------------------------------------------------------------
// Markdown table → CSV conversion
// ---------------------------------------------------------------------------
/**
* Split a markdown table row into cells.
* Handles leading/trailing pipes and trims whitespace.
*/
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;
// Validate separator row (must contain dashes)
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);
// Pad to match header length
while (cells.length < headers.length) cells.push("");
return cells.slice(0, headers.length).map(escapeCsvCell).join(",");
});
return [csvHeader, ...csvRows].join("\n");
}
// ---------------------------------------------------------------------------
// Spark table CSV inspection
// ---------------------------------------------------------------------------
/**
* Check if a CSV body represents a spark table.
* Returns the data column headers (excluding the dice column) if so, or null.
*/
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 (!DICE_HEADER_RE.test(headers[0])) return null;
return headers.slice(1);
}
/**
* Build a SparkTableCompletion from CSV data and file path.
*/
export function buildSparkTableCompletion(
csv: string,
filePath: string,
csvPath: string,
remix: boolean,
slugger: Slugger,
): SparkTableCompletion | null {
const dataHeaders = inspectSparkTableCsv(csv);
if (!dataHeaders) return null;
const lines = csv.trim().split(/\r?\n/);
const headers = lines[0].split(",").map((h) => h.trim());
const slug = dataHeaders
.map((h) => slugger.slug(h.toLowerCase()))
.join("-");
const basePath = filePath.replace(/\.md$/, "");
const fileName = basePath.split("/").filter(Boolean).pop() || basePath;
const combinedSlug = `${fileName}-${slug}`;
return {
label: `${fileName} § ${slug}`,
notation: headers[0],
slug: combinedSlug,
filePath: basePath,
csvPath,
headers: dataHeaders,
remix,
};
}
// ---------------------------------------------------------------------------
// Main scanner
// ---------------------------------------------------------------------------
/**
* Scan a single markdown file's stripped content for directives and
* spark-shaped markdown tables.
*
* @param content - Stripped markdown content (after block processing)
* @param filePath - The file's path (e.g. "/rules/combat.md")
* @param index - The content index for resolving CSV paths
* @param fileDir - Directory of the file (for resolving relative paths)
*/
export function scanDirectives(
content: string,
filePath: string,
index: Record<string, string>,
fileDir: string,
): DirectiveScanResult {
const slugger = new Slugger();
const dice: DiceCompletion[] = [];
const sparkTables: SparkTableCompletion[] = [];
const newIndexEntries: Record<string, string> = {};
// ------------------------------------------------------------------
// Pass 1: Coerce spark-shaped markdown tables to :md-table directives
// ------------------------------------------------------------------
const mdTableRegex =
/^(\|.+\|)\n(\|[-: |]+\|)\n((?:\|.+\|\n?)+)/gm;
let rewritten = content;
let mdMatch: RegExpExecArray | null;
// Collect matches first (rewriting while iterating is tricky with regex)
interface TableMatch {
fullMatch: string;
headerRow: string;
separatorRow: string;
bodyRowsText: string;
index: number;
}
const tableMatches: TableMatch[] = [];
while ((mdMatch = mdTableRegex.exec(content)) !== null) {
const [, headerRow, separatorRow, bodyRowsText] = mdMatch;
const headers = splitTableRow(headerRow);
// Check if this looks like a spark table: first column is a dice formula
const isSpark = DICE_HEADER_RE.test(headers[0]);
if (!isSpark) continue;
const bodyRows = bodyRowsText
.trim()
.split(/\n/)
.filter((r) => r.trim().startsWith("|"));
const csv = markdownTableToCsv(headerRow, separatorRow, bodyRows);
if (!csv) continue;
tableMatches.push({
fullMatch: mdMatch[0],
headerRow,
separatorRow,
bodyRowsText,
index: mdMatch.index,
});
}
// Replace matches from end to start to preserve indices
for (let i = tableMatches.length - 1; i >= 0; i--) {
const m = tableMatches[i];
const bodyRows = m.bodyRowsText
.trim()
.split(/\n/)
.filter((r) => r.trim().startsWith("|"));
const csv = markdownTableToCsv(m.headerRow, m.separatorRow, bodyRows)!;
const hash = contentHash(csv);
const filename = `_spark_md_${hash}.csv`;
const resolvedPath = `${fileDir}/${filename}`;
newIndexEntries[resolvedPath] = csv;
// Collect spark table completion
const st = buildSparkTableCompletion(csv, filePath, resolvedPath, false, slugger);
if (st) {
sparkTables.push(st);
}
// Replace markdown table with :md-table directive
const directive = `:md-table[./${filename}]{data-spark="${st?.slug ?? ""}"}`;
rewritten =
rewritten.slice(0, m.index) +
directive +
rewritten.slice(m.index + m.fullMatch.length);
}
// ------------------------------------------------------------------
// Pass 2: Scan :md-dice[...] directives
// ------------------------------------------------------------------
const diceRegex = /:md-dice\[([^[\]]+)\]/gi;
let diceMatch: RegExpExecArray | null;
while ((diceMatch = diceRegex.exec(rewritten)) !== null) {
const raw = diceMatch[1].trim();
if (!raw || !looksLikeDice(raw)) continue;
dice.push({ label: raw, notation: raw, source: filePath });
}
// ------------------------------------------------------------------
// Pass 3: Scan :md-table[...] and :md-card[...] directives
// ------------------------------------------------------------------
const tableDirectiveRegex = /:md-(table|card)\[([^[\]]+)\](?:\{([^}]*)\})?/gi;
let tableMatch: RegExpExecArray | null;
while ((tableMatch = tableDirectiveRegex.exec(rewritten)) !== null) {
const [, /* type */ , path, extraStr] = tableMatch;
// Resolve the CSV path
const csvPath = path.startsWith("./")
? `${fileDir}/${path.slice(2)}`
: path;
let csv = index[csvPath] ?? newIndexEntries[csvPath];
if (!csv) continue;
// Parse extra attrs for remix flag
const attrs = parseDirectiveAttrs(extraStr);
const isRemix = attrs["remix"] === "true";
const st = buildSparkTableCompletion(csv, filePath, csvPath, isRemix, slugger);
if (!st) continue;
// Check if data-spark is already set in extra attrs
if (!extraStr || !extraStr.includes("data-spark=")) {
// Inject data-spark attribute into the directive
const fullMatch = tableMatch[0];
const insertPos = fullMatch.indexOf("]") + 1;
const before = fullMatch.slice(0, insertPos);
const after = fullMatch.slice(insertPos);
const sparkAttr = `{data-spark="${st.slug}"}`;
let replacement: string;
if (after.startsWith("{")) {
// Merge into existing attrs
replacement = before + after.replace(/^\{/, `{data-spark="${st.slug}" `);
} else {
replacement = before + sparkAttr + after;
}
rewritten =
rewritten.slice(0, tableMatch.index) +
replacement +
rewritten.slice(tableMatch.index + fullMatch.length);
}
sparkTables.push(st);
}
return { rewritten, dice, sparkTables, newIndexEntries };
}
-46
View File
@@ -1,46 +0,0 @@
/**
* Completion index — orchestrates all registered completion sources.
*/
import { linksSource } from "./sources/links.js";
import type { ProcessedBlocks } from "./block-processor.js";
import type { CompletionsPayload } from "./types.js";
import type { DirectiveScanResult } from "./directive-scanner.js";
export type {
CompletionsPayload,
DiceCompletion,
LinkCompletion,
SparkTableCompletion,
VarDeclaration,
TagModifier,
} from "./types.js";
/**
* Build completions from the content index, pre-collected blocks,
* and directive scan results.
* Called at server startup and on any file change.
*/
export function scanCompletions(
index: Record<string, string>,
blocks: ProcessedBlocks,
directiveResults: DirectiveScanResult[],
): CompletionsPayload {
const links = linksSource.scan(index) as CompletionsPayload["links"];
// Merge all directive scan results
const dice: CompletionsPayload["dice"] = [];
const sparkTables: CompletionsPayload["sparkTables"] = [];
for (const dr of directiveResults) {
dice.push(...dr.dice);
sparkTables.push(...dr.sparkTables);
}
return {
dice,
links,
sparkTables,
declarations: blocks.declarations,
tagModifiers: blocks.tagModifiers,
};
}
-55
View File
@@ -1,55 +0,0 @@
/**
* Link completion source — extracts markdown headings from all .md files.
*
* Produces two entries per heading section, plus one for the file itself.
* Uses github-slugger to match marked-gfm-heading-id's generated IDs.
*/
import Slugger from "github-slugger";
import type { CompletionSource, LinkCompletion } from "../types.js";
export const linksSource: CompletionSource = {
key: "links",
scan(index) {
const items: LinkCompletion[] = [];
for (const [filePath, content] of Object.entries(index)) {
if (!filePath.endsWith(".md")) continue;
// Strip .md extension for the router-friendly path
const basePath = filePath.replace(/\.md$/, "");
const fileName = fileNameFromPath(basePath);
const slugger = new Slugger();
// Add the file itself as a link (whole article)
items.push({
path: basePath,
label: fileName,
section: null,
});
// Parse headings for section-scoped links
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;
},
};
function fileNameFromPath(path: string): string {
const parts = path.split("/").filter(Boolean);
return parts[parts.length - 1] || path;
}
+3 -1
View File
@@ -36,6 +36,8 @@ export interface SparkTableCompletion {
slug: string;
/** File path of the containing .md file (without extension) */
filePath: string;
/** Path of the containing .md file (with extension) — for inline lookup */
docPath: string;
/** Resolved path to the .csv file backing this spark table */
csvPath: string;
/** Data column headers for display */
@@ -61,4 +63,4 @@ export interface CompletionSource {
key: string;
/** Scan the content index and return structured completion items */
scan(index: Record<string, string>): unknown[];
}
}
+100 -43
View File
@@ -5,7 +5,7 @@
* - variable-expression (expression evaluation)
* - var-reactivity (dependency graph, cascade, tag activation)
* - command-parser (input parsing)
* - directive-scanner (spark table detection)
* - content-registry (spark table detection)
*/
// ---------------------------------------------------------------------------
@@ -47,8 +47,7 @@ import { parseInput } from "../../components/journal/command-parser";
import {
inspectSparkTableCsv,
buildSparkTableCompletion,
} from "./directive-scanner";
import Slugger from "github-slugger";
} from "../content-registry";
// ---------------------------------------------------------------------------
// Helpers
@@ -70,7 +69,10 @@ describe("parseDeclareCsv", () => {
,$ac,10+$dex`;
const result = parseDeclareCsv(csv, "test.md");
expect(result.variables).toHaveLength(2);
expect(result.variables[0]).toEqual({ key: "$hp", expression: "$con*5+$mod_hp" });
expect(result.variables[0]).toEqual({
key: "$hp",
expression: "$con*5+$mod_hp",
});
expect(result.variables[1]).toEqual({ key: "$ac", expression: "10+$dex" });
expect(result.tagModifiers).toHaveLength(0);
});
@@ -154,7 +156,7 @@ describe("parseDeclareCsv", () => {
const csv = `tag,key,expr
warrior,$mod_hp,20`;
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
'tag must start with #',
"tag must start with #",
);
});
@@ -162,7 +164,7 @@ warrior,$mod_hp,20`;
const csv = `tag,key,expr
,hp,$con*5`;
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
'key must start with $',
"key must start with $",
);
});
@@ -170,7 +172,7 @@ warrior,$mod_hp,20`;
const csv = `tag,key,expr
#warrior,mod_hp,20`;
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
'key must start with $',
"key must start with $",
);
});
@@ -219,8 +221,8 @@ describe("parseBlockAttrs", () => {
test("parses standard attributes", () => {
// parseBlockAttrs receives the info string AFTER the lang.
// The lang is extracted from the fenced block regex capture group
// and applied separately in block-processor.
const attrs = parseBlockAttrs('id=stats role=declare as=none');
// and applied separately in content-registry.
const attrs = parseBlockAttrs("id=stats role=declare as=none");
expect(attrs.id).toBe("stats");
expect(attrs.role).toBe("declare");
expect(attrs.as).toBe("none");
@@ -233,7 +235,7 @@ describe("parseBlockAttrs", () => {
});
test("collects unknown attributes in extra", () => {
const attrs = parseBlockAttrs('csv role=declare foo=bar baz=42');
const attrs = parseBlockAttrs("csv role=declare foo=bar baz=42");
expect(attrs.extra).toEqual({ foo: "bar", baz: "42" });
});
@@ -282,7 +284,9 @@ describe("evaluateExpression", () => {
});
test("evaluates with parentheses", () => {
const result = evaluateExpression("(2 + 3) * 4", { lookup: () => undefined });
const result = evaluateExpression("(2 + 3) * 4", {
lookup: () => undefined,
});
expect(result.value).toBe(20);
});
@@ -309,11 +313,13 @@ describe("evaluateExpression", () => {
evaluateExpression("$class + 5", {
lookup: (name) => (name === "class" ? "#warrior" : undefined),
}),
).toThrow('$class is a tag');
).toThrow("$class is a tag");
});
test("evaluates floor function", () => {
const result = evaluateExpression("floor(3.7)", { lookup: () => undefined });
const result = evaluateExpression("floor(3.7)", {
lookup: () => undefined,
});
expect(result.value).toBe(3);
});
@@ -323,7 +329,9 @@ describe("evaluateExpression", () => {
});
test("evaluates round function", () => {
const result = evaluateExpression("round(3.5)", { lookup: () => undefined });
const result = evaluateExpression("round(3.5)", {
lookup: () => undefined,
});
expect(result.value).toBe(4);
});
@@ -424,9 +432,7 @@ describe("var-reactivity", () => {
test("detects self-referencing circular dependency", () => {
expect(() =>
initReactivity({
declarations: [
{ key: "$a", expression: "$a + 1" },
],
declarations: [{ key: "$a", expression: "$a + 1" }],
tagModifiers: [],
}),
).toThrow("Circular dependency");
@@ -527,14 +533,28 @@ describe("var-reactivity", () => {
initReactivity({
declarations: [],
tagModifiers: [
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
{ tag: "#warrior", target: "$mod_str", expression: "5", threshold: 1 },
{
tag: "#warrior",
target: "$mod_hp",
expression: "20",
threshold: 1,
},
{
tag: "#warrior",
target: "$mod_str",
expression: "5",
threshold: 1,
},
],
});
// Set $class to #warrior:1 — should activate both modifiers
setBase("$class", "#warrior:1");
const cascade = computeCascade("$class", undefined, store({ $class: "#warrior:1" }));
const cascade = computeCascade(
"$class",
undefined,
store({ $class: "#warrior:1" }),
);
// Should produce combined values for both targets
const modHp = cascade.find((r) => r.key === "$mod_hp");
@@ -547,7 +567,12 @@ describe("var-reactivity", () => {
initReactivity({
declarations: [],
tagModifiers: [
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
{
tag: "#warrior",
target: "$mod_hp",
expression: "20",
threshold: 1,
},
],
});
@@ -572,7 +597,12 @@ describe("var-reactivity", () => {
initReactivity({
declarations: [],
tagModifiers: [
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
{
tag: "#warrior",
target: "$mod_hp",
expression: "20",
threshold: 1,
},
{ tag: "#mage", target: "$mod_hp", expression: "10", threshold: 1 },
],
});
@@ -600,19 +630,32 @@ describe("var-reactivity", () => {
initReactivity({
declarations: [],
tagModifiers: [
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 2 },
{
tag: "#warrior",
target: "$mod_hp",
expression: "20",
threshold: 2,
},
],
});
// Count 1 < threshold 2 — should NOT activate
setBase("$class", "#warrior:1");
const cascade1 = computeCascade("$class", undefined, store({ $class: "#warrior:1" }));
const cascade1 = computeCascade(
"$class",
undefined,
store({ $class: "#warrior:1" }),
);
const modHp1 = cascade1.find((r) => r.key === "$mod_hp");
expect(modHp1).toBeUndefined();
// Increase to count 2 >= threshold 2 — should activate
setBase("$class", "#warrior:2");
const cascade2 = computeCascade("$class", "#warrior:1", store({ $class: "#warrior:2" }));
const cascade2 = computeCascade(
"$class",
"#warrior:1",
store({ $class: "#warrior:2" }),
);
const modHp2 = cascade2.find((r) => r.key === "$mod_hp");
expect(modHp2?.value).toBe("20");
});
@@ -621,7 +664,12 @@ describe("var-reactivity", () => {
initReactivity({
declarations: [],
tagModifiers: [
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 2 },
{
tag: "#warrior",
target: "$mod_hp",
expression: "20",
threshold: 2,
},
],
});
@@ -645,13 +693,22 @@ describe("var-reactivity", () => {
initReactivity({
declarations: [],
tagModifiers: [
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
{
tag: "#warrior",
target: "$mod_hp",
expression: "20",
threshold: 1,
},
{ tag: "#druid", target: "$mod_mp", expression: "15", threshold: 1 },
],
});
setBase("$class", "#warrior:2;#druid:1");
const cascade = computeCascade("$class", undefined, store({ $class: "#warrior:2;#druid:1" }));
const cascade = computeCascade(
"$class",
undefined,
store({ $class: "#warrior:2;#druid:1" }),
);
const modHp = cascade.find((r) => r.key === "$mod_hp");
const modMp = cascade.find((r) => r.key === "$mod_mp");
@@ -663,9 +720,7 @@ describe("var-reactivity", () => {
describe("computeCascade — declaration re-evaluation", () => {
test("re-evaluates dependents when a dependency changes", () => {
initReactivity({
declarations: [
{ key: "$hp", expression: "$con * 5" },
],
declarations: [{ key: "$hp", expression: "$con * 5" }],
tagModifiers: [],
});
@@ -701,11 +756,14 @@ describe("var-reactivity", () => {
test("handles tagmap transition during re-evaluation", () => {
initReactivity({
declarations: [
{ key: "$class", expression: "0" },
],
declarations: [{ key: "$class", expression: "0" }],
tagModifiers: [
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
{
tag: "#warrior",
target: "$mod_hp",
expression: "20",
threshold: 1,
},
{ tag: "#novice", target: "$mod_hp", expression: "5", threshold: 1 },
],
});
@@ -744,7 +802,12 @@ describe("var-reactivity", () => {
initReactivity({
declarations: [],
tagModifiers: [
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
{
tag: "#warrior",
target: "$mod_hp",
expression: "20",
threshold: 1,
},
],
});
@@ -861,7 +924,7 @@ describe("parseInput", () => {
});
// ---------------------------------------------------------------------------
// directive-scanner
// content-registry (spark tables)
// ---------------------------------------------------------------------------
describe("inspectSparkTableCsv", () => {
@@ -915,13 +978,11 @@ describe("buildSparkTableCompletion", () => {
test("builds completion from CSV data", () => {
const csv = `d6,Name,Description
1,Alice,The brave`;
const slugger = new Slugger();
const result = buildSparkTableCompletion(
csv,
"/rules/combat.md",
"/rules/combat/test.csv",
false,
slugger,
);
expect(result).not.toBeNull();
expect(result!.notation).toBe("d6");
@@ -933,13 +994,11 @@ describe("buildSparkTableCompletion", () => {
test("returns null for non-spark CSV", () => {
const csv = `Name,Value
Alice,10`;
const slugger = new Slugger();
const result = buildSparkTableCompletion(
csv,
"/test.md",
"/test.csv",
false,
slugger,
);
expect(result).toBeNull();
});
@@ -947,13 +1006,11 @@ Alice,10`;
test("sets remix flag", () => {
const csv = `d6,Result
1,Yes`;
const slugger = new Slugger();
const result = buildSparkTableCompletion(
csv,
"/test.md",
"/test.csv",
true,
slugger,
);
expect(result!.remix).toBe(true);
});
+689
View File
@@ -0,0 +1,689 @@
/**
* 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,
resolveBlockAs,
} 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;
/** Origin `as` value, for debugging. */
as?: string;
}
export interface ContentRegistry {
/** Real files on disk, keyed by path. */
pathIndex: Record<string, string>;
/** Inline content defined inside each doc, keyed by id. */
docContent: Record<string, Record<string, DocContent>>;
}
export const EMPTY_REGISTRY: ContentRegistry = {
pathIndex: {},
docContent: {},
};
// ---------------------------------------------------------------------------
// 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<string, DocContent>;
}
/**
* Process a single markdown doc:
* - strips/replaces attributed fenced code blocks based on `as`
* - coerces spark-shaped markdown tables to `:md-table` directives
* - collects inline content (role=file, md-* bodies, spark tables, declare)
* 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<string, DocContent> = {};
// ---- Pass 1: attributed fenced code blocks ----
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;
const effectiveAs = resolveBlockAs(attrs.role, attrs.as);
if (attrs.role === "declare") {
const id = deriveContentId("declare", body, attrs.id);
contentStore[id] = {
id,
kind: "declare",
body,
role: attrs.role,
as: effectiveAs,
};
}
if (attrs.role === "file") {
const id = deriveContentId("text", body, attrs.id);
contentStore[id] = {
id,
kind: "text",
body,
role: attrs.role,
as: effectiveAs,
};
}
if (effectiveAs === "codeblock") {
return _match;
}
if (effectiveAs === "none") {
return "";
}
if (effectiveAs.startsWith("md-")) {
const id = deriveContentId("csv", body, attrs.id);
contentStore[id] = {
id,
kind: "csv",
body,
role: attrs.role,
as: effectiveAs,
};
const extra = { ...attrs.extra };
const extraStr = Object.keys(extra).length
? `{${Object.entries(extra)
.map(([k, v]) => `${k}=${v}`)
.join(" ")}}`
: "";
return `:${effectiveAs}[./${id}]${extraStr}`;
}
return "";
},
);
// ---- Pass 2: coerce spark-shaped markdown tables to :md-table ----
const rewritten = coerceSparkTables(stripped, contentStore);
return { stripped: rewritten, content: contentStore };
}
// ---------------------------------------------------------------------------
// Spark table coercion
// ---------------------------------------------------------------------------
const DICE_HEADER_RE = /^\d*d\d+$/i;
/** 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");
}
/**
* Coerce spark-shaped markdown tables (first column header is a dice formula)
* into `:md-table` directives, storing the CSV in the doc's content store and
* injecting `data-spark` for the reveal feature.
*/
function coerceSparkTables(
content: string,
contentStore: Record<string, DocContent>,
): string {
const mdTableRegex = /^(\|.+\|)\n(\|[-: |]+\|)\n((?:\|.+\|\n?)+)/gm;
interface TableMatch {
fullMatch: string;
headerRow: string;
separatorRow: string;
bodyRowsText: string;
index: number;
}
const tableMatches: TableMatch[] = [];
let mdMatch: RegExpExecArray | null;
while ((mdMatch = mdTableRegex.exec(content)) !== null) {
const [, headerRow, separatorRow, bodyRowsText] = mdMatch;
const headers = splitTableRow(headerRow);
if (!DICE_HEADER_RE.test(headers[0])) continue;
const bodyRows = bodyRowsText
.trim()
.split(/\n/)
.filter((r) => r.trim().startsWith("|"));
const csv = markdownTableToCsv(headerRow, separatorRow, bodyRows);
if (!csv) continue;
tableMatches.push({
fullMatch: mdMatch[0],
headerRow,
separatorRow,
bodyRowsText,
index: mdMatch.index,
});
}
let rewritten = content;
for (let i = tableMatches.length - 1; i >= 0; i--) {
const m = tableMatches[i];
const bodyRows = m.bodyRowsText
.trim()
.split(/\n/)
.filter((r) => r.trim().startsWith("|"));
const csv = markdownTableToCsv(m.headerRow, m.separatorRow, bodyRows)!;
const id = deriveContentId("csv", csv);
contentStore[id] = {
id,
kind: "csv",
body: csv,
role: "spark-table",
as: "md-table",
};
const slug = sparkSlug(csv);
const directive = `:md-table[./${id}]{data-spark="${slug}"}`;
rewritten =
rewritten.slice(0, m.index) +
directive +
rewritten.slice(m.index + m.fullMatch.length);
}
return rewritten;
}
// ---------------------------------------------------------------------------
// Directive spark injection (real-file `:md-table` / `:md-card` references)
// ---------------------------------------------------------------------------
/**
* Scan a doc's stripped content for `:md-table[...]` / `:md-card[...]`
* directives that resolve to a spark table, and inject `data-spark` so the
* reveal feature can match them. Idempotent — only injects when missing.
*
* Returns the possibly-rewritten content.
*/
export function injectSparkDirectives(
content: string,
docPath: string,
registry: ContentRegistry,
): string {
const tableDirectiveRegex = /:md-(table|card)\[([^[\]]+)\](?:\{([^}]*)\})?/gi;
let rewritten = content;
let m: RegExpExecArray | null;
while ((m = tableDirectiveRegex.exec(content)) !== null) {
const [, , ref, extraStr] = m;
if (extraStr && extraStr.includes("data-spark=")) continue;
const csv = resolveContent(registry, docPath, ref);
if (!csv) continue;
const slug = sparkSlug(csv);
if (!slug) continue;
const fullMatch = m[0];
const insertPos = fullMatch.indexOf("]") + 1;
const before = fullMatch.slice(0, insertPos);
const after = fullMatch.slice(insertPos);
let replacement: string;
if (after.startsWith("{")) {
replacement = before + after.replace(/^\{/, `{data-spark="${slug}" `);
} else {
replacement = before + `{data-spark="${slug}"}` + after;
}
rewritten =
rewritten.slice(0, m.index) +
replacement +
rewritten.slice(m.index + fullMatch.length);
}
return rewritten;
}
// ---------------------------------------------------------------------------
// Resolution
// ---------------------------------------------------------------------------
/**
* Resolve a content reference from within a doc.
*
* - `ref` is inline CSV → returned as-is.
* - `ref` is an absolute path → path index.
* - `ref` is a relative path → resolved against the doc directory; checks
* the path index first, then the doc's inline content store.
*
* Returns `null` when nothing matches.
*/
export function resolveContent(
registry: ContentRegistry,
docPath: string,
ref: string,
): string | null {
const trimmed = ref.trim();
if (!trimmed) return null;
// Inline CSV body.
if (looksLikeCsv(trimmed)) return trimmed;
if (trimmed.startsWith("/")) {
return registry.pathIndex[trimmed] ?? null;
}
// Inline content in the same doc (e.g. ./{id}).
const docStore = registry.docContent[docPath];
if (docStore) {
const id = trimmed.startsWith("./") ? trimmed.slice(2) : trimmed;
const entry = docStore[id];
if (entry) return entry.body;
}
// Relative path resolved against the doc directory.
const resolved = posixJoin(posixDir(docPath), trimmed);
return registry.pathIndex[resolved] ?? 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;
}
/** Naive CSV sniff — matches the frontend `isCSV` heuristic. */
function looksLikeCsv(str: string): boolean {
const trimmed = str.trim();
if (trimmed.startsWith("---\n") || trimmed.startsWith("---\r\n")) return true;
const lines = trimmed.split(/\r?\n/).filter((line) => line.trim() !== "");
if (lines.length < 2) return false;
const separators = [",", "\t", ";", "|"];
const firstLine = lines[0];
for (const sep of separators) {
if (firstLine.includes(sep)) {
const hasInOthers = lines.slice(1).some((line) => line.includes(sep));
if (hasInOthers) return true;
}
}
return false;
}
// ---------------------------------------------------------------------------
// 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 csv = resolveContent(registry, docPath, ref);
if (!csv) continue;
// csvPath: content id for inline content, resolved path for real files.
const docStore = registry.docContent[docPath];
const id = ref.startsWith("./") ? ref.slice(2) : ref;
const csvPath =
docStore && docStore[id]
? id
: resolveContentPath(registry, docPath, ref);
const attrs = parseDirectiveAttrs(extraStr);
const st = buildSparkTableCompletion(
csv,
docPath,
csvPath,
attrs["remix"] === "true",
);
if (st) sparkTables.push(st);
}
return { dice, sparkTables };
}
/** Resolve a directive ref to a path-index key (for real files). */
function resolveContentPath(
registry: ContentRegistry,
docPath: string,
ref: string,
): string {
const trimmed = ref.trim();
if (trimmed.startsWith("/")) return trimmed;
return posixJoin(posixDir(docPath), trimmed.replace(/^\.\//, ""));
}
/** Parse key=value pairs from a directive extra-attrs string. */
function parseDirectiveAttrs(
extraStr: string | undefined,
): Record<string, string> {
if (!extraStr) return {};
const attrs: Record<string, string> = {};
const re = /(\w+)\s*=\s*("[^"]*"|\S+)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(extraStr)) !== null) {
attrs[m[1]] = m[2].replace(/^"|"$/g, "");
}
return attrs;
}
/** 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
// ---------------------------------------------------------------------------
/** Extract headings from all `.md` files as link completions. */
function deriveLinks(pathIndex: Record<string, string>): LinkCompletion[] {
const items: LinkCompletion[] = [];
for (const [filePath, content] of Object.entries(pathIndex)) {
if (!filePath.endsWith(".md")) continue;
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 (!DICE_HEADER_RE.test(headers[0])) return null;
return headers.slice(1);
}
/** Compute the spark slug for a CSV body, or null if it's not a spark table. */
function sparkSlug(csv: string): string | null {
const dataHeaders = inspectSparkTableCsv(csv);
if (!dataHeaders) return null;
const slugger = new Slugger();
return dataHeaders.map((h) => slugger.slug(h.toLowerCase())).join("-");
}
/** 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;
}