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:
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user