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 { 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") {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 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("/") || ".";
|
||||
|
|
|
|||
Loading…
Reference in New Issue