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:
parent
3137970b97
commit
f172da378a
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
import { posix } from "path";
|
import { posix } from "path";
|
||||||
import { createHash } from "crypto";
|
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 {
|
import {
|
||||||
FENCED_BLOCK_RE,
|
FENCED_BLOCK_RE,
|
||||||
parseBlockAttrs,
|
parseBlockAttrs,
|
||||||
|
|
@ -86,13 +86,9 @@ export function processBlocks(
|
||||||
// ---- Dispatch by role ----
|
// ---- Dispatch by role ----
|
||||||
|
|
||||||
if (attrs.role === "declare") {
|
if (attrs.role === "declare") {
|
||||||
try {
|
const result = scanDeclareBlocks(_match, fileRelativePath);
|
||||||
const result = parseDeclareCsv(body, fileRelativePath);
|
blocks.declarations.push(...result.variables);
|
||||||
blocks.declarations.push(...result.variables);
|
blocks.tagModifiers.push(...result.tagModifiers);
|
||||||
blocks.tagModifiers.push(...result.tagModifiers);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`[block-processor] ${fileRelativePath}: ${e}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attrs.role === "file") {
|
if (attrs.role === "file") {
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,56 @@
|
||||||
|
|
||||||
import { parse } from "csv-parse/browser/esm/sync";
|
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 {
|
export interface VarDeclaration {
|
||||||
key: string; // "$hp" (always starts with $)
|
key: string; // "$hp" (always starts with $)
|
||||||
expression: string; // "$con*5+$mod_hp"
|
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 {
|
export function parseDeclareCsv(csv: string, source: string): DeclareResult {
|
||||||
const trimmed = csv.trim();
|
const trimmed = csv.trim();
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -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),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
@ -10,57 +10,33 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal } from "solid-js";
|
import { createSignal } from "solid-js";
|
||||||
import Slugger from "github-slugger";
|
|
||||||
import { extractHeadings } from "../../data-loader/toc";
|
import { extractHeadings } from "../../data-loader/toc";
|
||||||
import {
|
import {
|
||||||
getPathsByExtension,
|
getPathsByExtension,
|
||||||
getIndexedData,
|
getIndexedData,
|
||||||
} from "../../data-loader/file-index";
|
} from "../../data-loader/file-index";
|
||||||
import {
|
|
||||||
FENCED_BLOCK_RE,
|
|
||||||
parseBlockAttrs,
|
|
||||||
} from "../../cli/completions/block-scanner";
|
|
||||||
import {
|
import {
|
||||||
scanDirectives,
|
scanDirectives,
|
||||||
} from "../../cli/completions/directive-scanner";
|
} from "../../cli/completions/directive-scanner";
|
||||||
import { parseDeclareCsv } from "./declare-parser";
|
import { scanDeclareBlocks } from "../../cli/completions/declare-parser";
|
||||||
import type { VarDeclaration, TagModifier } from "./declare-parser";
|
import type {
|
||||||
|
CompletionsPayload,
|
||||||
|
DiceCompletion,
|
||||||
|
LinkCompletion,
|
||||||
|
SparkTableCompletion,
|
||||||
|
VarDeclaration,
|
||||||
|
TagModifier,
|
||||||
|
} from "../../cli/completions/types";
|
||||||
import { initReactivity, computeInitialValues } from "./var-reactivity";
|
import { initReactivity, computeInitialValues } from "./var-reactivity";
|
||||||
import { sendMessage, journalStreamState } from "../stores/journalStream";
|
import { sendMessage, journalStreamState } from "../stores/journalStream";
|
||||||
|
|
||||||
export type { VarDeclaration, TagModifier };
|
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 {
|
/** Convenience alias — same shape as the CLI's CompletionsPayload. */
|
||||||
label: string;
|
export type JournalCompletions = CompletionsPayload;
|
||||||
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[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CompletionsState =
|
export type CompletionsState =
|
||||||
| { status: "loading" }
|
| { status: "loading" }
|
||||||
|
|
@ -120,9 +96,6 @@ async function scanClientSide(): Promise<JournalCompletions> {
|
||||||
const content = tempIndex[filePath];
|
const content = tempIndex[filePath];
|
||||||
if (!content) continue;
|
if (!content) continue;
|
||||||
|
|
||||||
// Fresh slugger per file
|
|
||||||
const slugger = new Slugger();
|
|
||||||
|
|
||||||
// ---- Links (headings) - from original content ----
|
// ---- Links (headings) - from original content ----
|
||||||
const basePath = filePath.replace(/\.md$/, "");
|
const basePath = filePath.replace(/\.md$/, "");
|
||||||
const fileName = basePath.split("/").filter(Boolean).pop() || basePath;
|
const fileName = basePath.split("/").filter(Boolean).pop() || basePath;
|
||||||
|
|
@ -135,24 +108,10 @@ async function scanClientSide(): Promise<JournalCompletions> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Unified block scanning (declare) ----
|
// ---- Declare block scanning (shared with CLI) ----
|
||||||
FENCED_BLOCK_RE.lastIndex = 0;
|
const declareResult = scanDeclareBlocks(content, filePath);
|
||||||
let blockMatch: RegExpExecArray | null;
|
declarations.push(...declareResult.variables);
|
||||||
while ((blockMatch = FENCED_BLOCK_RE.exec(content)) !== null) {
|
tagModifiers.push(...declareResult.tagModifiers);
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Directive scanning (dice + spark tables) ----
|
// ---- Directive scanning (dice + spark tables) ----
|
||||||
const fileDir = filePath.split("/").slice(0, -1).join("/") || ".";
|
const fileDir = filePath.split("/").slice(0, -1).join("/") || ".";
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue