From f172da378ac38268a4efcc1a0f5861653928b527 Mon Sep 17 00:00:00 2001 From: hypercross Date: Mon, 13 Jul 2026 10:43:44 +0800 Subject: [PATCH] refactor: unify declare block scanning between CLI and client Introduce `scanDeclareBlocks` in `declare-parser.ts` to provide a single source of truth for parsing `role=declare` blocks. This replaces redundant scanning logic in both the CLI and the journal completions. Also remove unused `dice.ts` and `spark-scanner.ts` files. --- src/cli/completions/block-processor.ts | 12 +-- src/cli/completions/declare-parser.ts | 54 +++++++++++- src/cli/completions/sources/dice.ts | 38 --------- src/cli/completions/spark-scanner.ts | 114 ------------------------- src/components/journal/completions.ts | 75 ++++------------ 5 files changed, 73 insertions(+), 220 deletions(-) delete mode 100644 src/cli/completions/sources/dice.ts delete mode 100644 src/cli/completions/spark-scanner.ts diff --git a/src/cli/completions/block-processor.ts b/src/cli/completions/block-processor.ts index 9b777de..9ee59b0 100644 --- a/src/cli/completions/block-processor.ts +++ b/src/cli/completions/block-processor.ts @@ -7,7 +7,7 @@ import { posix } from "path"; import { createHash } from "crypto"; -import { parseDeclareCsv, type VarDeclaration, type TagModifier } from "./declare-parser.js"; +import { scanDeclareBlocks, type VarDeclaration, type TagModifier } from "./declare-parser.js"; import { FENCED_BLOCK_RE, parseBlockAttrs, @@ -86,13 +86,9 @@ export function processBlocks( // ---- 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}`); - } + const result = scanDeclareBlocks(_match, fileRelativePath); + blocks.declarations.push(...result.variables); + blocks.tagModifiers.push(...result.tagModifiers); } if (attrs.role === "file") { diff --git a/src/cli/completions/declare-parser.ts b/src/cli/completions/declare-parser.ts index c37a36a..711dea2 100644 --- a/src/cli/completions/declare-parser.ts +++ b/src/cli/completions/declare-parser.ts @@ -16,6 +16,56 @@ import { parse } from "csv-parse/browser/esm/sync"; +// --------------------------------------------------------------------------- +// Shared block scanning — used by both CLI (block-processor) and client +// (completions.ts) so there's a single source of truth for declare parsing. +// --------------------------------------------------------------------------- + +/** Matches fenced code blocks with an info string. */ +const FENCED_BLOCK_RE = /^```(\w*)\s+(\S.*?)\s*\n([\s\S]*?)```\s*$/gm; + +/** Parse key="value" and key=value pairs from an attribute string. */ +function parseBlockAttrs(info: string): Record { + const attrs: Record = {}; + const re = /(\w+)\s*=\s*("[^"]*"|\S+)/g; + let m: RegExpExecArray | null; + while ((m = re.exec(info)) !== null) { + attrs[m[1]] = m[2].replace(/^"|"$/g, ""); + } + return attrs; +} + +/** + * 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 $) expression: string; // "$con*5+$mod_hp" @@ -33,7 +83,7 @@ export interface DeclareResult { } /** - * Parse a ```csv role=declare block. + * Parse a single ```csv role=declare block body. */ export function parseDeclareCsv(csv: string, source: string): DeclareResult { const trimmed = csv.trim(); @@ -92,4 +142,4 @@ export function parseDeclareCsv(csv: string, source: string): DeclareResult { } return { variables, tagModifiers }; -} +} \ No newline at end of file diff --git a/src/cli/completions/sources/dice.ts b/src/cli/completions/sources/dice.ts deleted file mode 100644 index 42d1e2d..0000000 --- a/src/cli/completions/sources/dice.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Dice completion source — extracts `` text content from markdown files. - */ - -import type { CompletionSource, DiceCompletion } from "../types.js"; - -function looksLikeDice(raw: string): boolean { - if (raw.length > 80) return false; - return /^\d*d\d+/i.test(raw) || /^[+-]/.test(raw); -} - -export const diceSource: CompletionSource = { - key: "dice", - - scan(index) { - const items: DiceCompletion[] = []; - const tagRegex = /:md-dice\[([^[]+)\]/gi; - - for (const [path, content] of Object.entries(index)) { - if (!path.endsWith(".md")) continue; - - let match: RegExpExecArray | null; - tagRegex.lastIndex = 0; - while ((match = tagRegex.exec(content)) !== null) { - const raw = match[1].trim(); - if (!raw || !looksLikeDice(raw)) continue; - - items.push({ - label: raw, - notation: raw, - source: path, - }); - } - } - - return items; - }, -}; diff --git a/src/cli/completions/spark-scanner.ts b/src/cli/completions/spark-scanner.ts deleted file mode 100644 index 44c2c50..0000000 --- a/src/cli/completions/spark-scanner.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * Spark table scanner — CLI-side markdown table detection and conversion. - * - * Parses markdown tables, detects spark tables (first column header is a dice - * formula), and converts them to CSV for the directive scanner. - * - * Not imported at runtime — the frontend only needs CSV parsing + rolling. - */ - -import Slugger from "github-slugger"; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export interface MarkdownTable { - headers: string[]; - rows: string[][]; -} - -// --------------------------------------------------------------------------- -// Markdown table parser -// --------------------------------------------------------------------------- - -/** - * Parse all markdown tables from a markdown string. - * Handles both leading/trailing `|` styles and bare styles. - */ -export function parseMarkdownTables(markdown: string): MarkdownTable[] { - const tables: MarkdownTable[] = []; - const lines = markdown.split(/\r?\n/); - - for (let i = 0; i < lines.length; i++) { - const headerCells = splitTableRow(lines[i]); - if (!headerCells || headerCells.length < 2) continue; - - // Peek at the next line — must be a separator row - if (i + 1 >= lines.length) continue; - const sepCells = splitTableRow(lines[i + 1]); - if (!sepCells || sepCells.length < headerCells.length) continue; - if (!sepCells.every((c) => /^:?-{3,}:?$/.test(c))) continue; - - // Valid table header + separator — collect body rows - const rows: string[][] = []; - let j = i + 2; - while (j < lines.length) { - const rowCells = splitTableRow(lines[j]); - if (!rowCells) break; - // Allow rows with fewer cells (unfilled trailing columns) - rows.push(rowCells); - j++; - } - - // Only include tables with at least one data row - if (rows.length > 0) { - tables.push({ headers: headerCells, rows }); - } - i = j - 1; - } - - return tables; -} - -/** Split a pipe-delimited table row, stripping optional leading/trailing `|` */ -function splitTableRow(line: string): string[] | null { - const trimmed = line.trim(); - if (!trimmed.includes("|")) return null; - - // Strip optional leading and trailing `|` - let inner = trimmed; - if (inner.startsWith("|")) inner = inner.slice(1); - if (inner.endsWith("|")) inner = inner.slice(0, -1); - - return inner.split("|").map((c) => c.trim()); -} - -// --------------------------------------------------------------------------- -// Spark table detection & metadata -// --------------------------------------------------------------------------- - -const DICE_HEADER_RE = /^d\d+$/i; - -/** Check whether a table is a spark table (first header is a dice formula) */ -export function isSparkTable(table: MarkdownTable): boolean { - if (table.headers.length < 2) return false; - return DICE_HEADER_RE.test(table.headers[0]); -} - -/** - * Generate the spark table slug by concatenating slugs of all data column - * headers (excluding the dice column). - */ -export function sparkTableSlug(table: MarkdownTable): string { - const slugger = new Slugger(); - return table.headers - .slice(1) - .map((h) => slugger.slug(h.toLowerCase())) - .join("-"); -} - -/** - * Scan all spark tables in a markdown file and return their metadata - * (without rows — suitable for listing available tables). - */ -export function scanSparkTables( - markdown: string, -): { notation: string; slug: string; dataHeaders: string[] }[] { - const tables = parseMarkdownTables(markdown); - return tables.filter(isSparkTable).map((table) => ({ - notation: table.headers[0], - slug: sparkTableSlug(table), - dataHeaders: table.headers.slice(1), - })); -} diff --git a/src/components/journal/completions.ts b/src/components/journal/completions.ts index d7ddffd..27e2d9e 100644 --- a/src/components/journal/completions.ts +++ b/src/components/journal/completions.ts @@ -10,57 +10,33 @@ */ import { createSignal } from "solid-js"; -import Slugger from "github-slugger"; import { extractHeadings } from "../../data-loader/toc"; import { getPathsByExtension, getIndexedData, } from "../../data-loader/file-index"; -import { - FENCED_BLOCK_RE, - parseBlockAttrs, -} from "../../cli/completions/block-scanner"; import { scanDirectives, } from "../../cli/completions/directive-scanner"; -import { parseDeclareCsv } from "./declare-parser"; -import type { VarDeclaration, TagModifier } from "./declare-parser"; +import { scanDeclareBlocks } from "../../cli/completions/declare-parser"; +import type { + CompletionsPayload, + DiceCompletion, + LinkCompletion, + SparkTableCompletion, + VarDeclaration, + TagModifier, +} from "../../cli/completions/types"; import { initReactivity, computeInitialValues } from "./var-reactivity"; import { sendMessage, journalStreamState } from "../stores/journalStream"; export type { VarDeclaration, TagModifier }; -// ------------------- Types (mirrors CLI) ------------------- +// Re-export CLI types so consumers don't need to know about the CLI path +export type { DiceCompletion, LinkCompletion, SparkTableCompletion }; -export interface DiceCompletion { - label: string; - notation: string; - source: string; -} - -export interface LinkCompletion { - path: string; - label: string; - section: string | null; -} - -export interface SparkTableCompletion { - label: string; - notation: string; - slug: string; - filePath: string; - csvPath: string; - headers: string[]; - remix: boolean; -} - -export interface JournalCompletions { - dice: DiceCompletion[]; - links: LinkCompletion[]; - sparkTables: SparkTableCompletion[]; - declarations: VarDeclaration[]; - tagModifiers: TagModifier[]; -} +/** Convenience alias — same shape as the CLI's CompletionsPayload. */ +export type JournalCompletions = CompletionsPayload; export type CompletionsState = | { status: "loading" } @@ -120,9 +96,6 @@ async function scanClientSide(): Promise { const content = tempIndex[filePath]; if (!content) continue; - // Fresh slugger per file - const slugger = new Slugger(); - // ---- Links (headings) - from original content ---- const basePath = filePath.replace(/\.md$/, ""); const fileName = basePath.split("/").filter(Boolean).pop() || basePath; @@ -135,24 +108,10 @@ async function scanClientSide(): Promise { }); } - // ---- Unified block scanning (declare) ---- - FENCED_BLOCK_RE.lastIndex = 0; - let blockMatch: RegExpExecArray | null; - while ((blockMatch = FENCED_BLOCK_RE.exec(content)) !== null) { - const [, lang, infoString, body] = blockMatch; - const attrs = parseBlockAttrs(infoString); - attrs.lang = attrs.lang || lang; - - if (attrs.role === "declare") { - try { - const result = parseDeclareCsv(body, filePath); - declarations.push(...result.variables); - tagModifiers.push(...result.tagModifiers); - } catch (e) { - console.warn(`[completions] ${filePath}: ${e}`); - } - } - } + // ---- Declare block scanning (shared with CLI) ---- + const declareResult = scanDeclareBlocks(content, filePath); + declarations.push(...declareResult.variables); + tagModifiers.push(...declareResult.tagModifiers); // ---- Directive scanning (dice + spark tables) ---- const fileDir = filePath.split("/").slice(0, -1).join("/") || ".";