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.
This commit is contained in:
hypercross 2026-07-13 10:43:44 +08:00
parent 3137970b97
commit f172da378a
5 changed files with 73 additions and 220 deletions

View File

@ -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") {

View File

@ -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<string, string> {
const attrs: Record<string, string> = {};
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 };
}
}

View File

@ -1,38 +0,0 @@
/**
* Dice completion source extracts `<md-dice>` 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;
},
};

View File

@ -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),
}));
}

View File

@ -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<JournalCompletions> {
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<JournalCompletions> {
});
}
// ---- 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("/") || ".";