diff --git a/src/cli/commands/serve.ts b/src/cli/commands/serve.ts index 45b1ee8..e469a49 100644 --- a/src/cli/commands/serve.ts +++ b/src/cli/commands/serve.ts @@ -11,7 +11,10 @@ import { scanCompletions, type CompletionsPayload, } from "../completions/index.js"; -import { extractInlineBlocks } from "../inline-blocks.js"; +import { + processBlocks, + type ProcessedBlocks, +} from "../completions/block-processor.js"; interface ContentIndex { [path: string]: string; @@ -83,8 +86,16 @@ function getBestIP(): string { /** * 扫描目录内的 .md 文件,生成索引 */ -export function scanDirectory(dir: string): ContentIndex { +export function scanDirectory(dir: string): { + index: ContentIndex; + blocks: ProcessedBlocks; +} { const index: ContentIndex = {}; + const blocks: ProcessedBlocks = { + stats: [], + statTemplates: [], + sparkTables: [], + }; function scan(currentPath: string, relativePath: string) { const entries = readdirSync(currentPath); @@ -107,12 +118,11 @@ export function scanDirectory(dir: string): ContentIndex { try { const content = readFileSync(fullPath, "utf-8"); if (entry.endsWith(".md")) { - const stripped = extractInlineBlocks( - content, - normalizedRelPath, - index, - ); - index[normalizedRelPath] = stripped; + const result = processBlocks(content, normalizedRelPath, index); + index[normalizedRelPath] = result.stripped; + blocks.stats.push(...result.blocks.stats); + blocks.statTemplates.push(...result.blocks.statTemplates); + blocks.sparkTables.push(...result.blocks.sparkTables); } else { index[normalizedRelPath] = content; } @@ -124,7 +134,7 @@ export function scanDirectory(dir: string): ContentIndex { } scan(dir, ""); - return index; + return { index, blocks }; } /** @@ -267,6 +277,11 @@ export function createContentServer( host: string = "0.0.0.0", ): ContentServer { let contentIndex: ContentIndex = {}; + let collectedBlocks: ProcessedBlocks = { + stats: [], + statTemplates: [], + sparkTables: [], + }; let completionsIndex: CompletionsPayload = { dice: [], links: [], @@ -275,9 +290,9 @@ export function createContentServer( statTemplates: [], }; - /** Re-scan completions from current content index (cached) */ + /** Re-scan completions from current content index and collected blocks */ function recomputeCompletions(): void { - completionsIndex = scanCompletions(contentIndex); + completionsIndex = scanCompletions(contentIndex, collectedBlocks); console.log( `[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length} stats=${completionsIndex.stats.length} templates=${completionsIndex.statTemplates.length}`, ); @@ -285,7 +300,9 @@ export function createContentServer( // 扫描内容目录生成索引 console.log("扫描内容目录..."); - contentIndex = scanDirectory(contentDir); + const scanResult = scanDirectory(contentDir); + contentIndex = scanResult.index; + collectedBlocks = scanResult.blocks; console.log(`已索引 ${Object.keys(contentIndex).length} 个文件`); recomputeCompletions(); @@ -308,12 +325,11 @@ export function createContentServer( const content = readFileSync(path, "utf-8"); const relPath = "/" + relative(contentDir, path).split(sep).join("/"); if (relPath.endsWith(".md")) { - const stripped = extractInlineBlocks( - content, - relPath, - contentIndex, - ); - contentIndex[relPath] = stripped; + const result = processBlocks(content, relPath, contentIndex); + contentIndex[relPath] = result.stripped; + // Re-scan to get fresh blocks (simpler than per-file merge) + const rescan = scanDirectory(contentDir); + collectedBlocks = rescan.blocks; recomputeCompletions(); } else { contentIndex[relPath] = content; @@ -334,12 +350,10 @@ export function createContentServer( const content = readFileSync(path, "utf-8"); const relPath = "/" + relative(contentDir, path).split(sep).join("/"); if (relPath.endsWith(".md")) { - const stripped = extractInlineBlocks( - content, - relPath, - contentIndex, - ); - contentIndex[relPath] = stripped; + const result = processBlocks(content, relPath, contentIndex); + contentIndex[relPath] = result.stripped; + const rescan = scanDirectory(contentDir); + collectedBlocks = rescan.blocks; recomputeCompletions(); } else { contentIndex[relPath] = content; @@ -359,7 +373,11 @@ export function createContentServer( const relPath = "/" + relative(contentDir, path).split(sep).join("/"); delete contentIndex[relPath]; console.log(`[删除] ${relPath}`); - if (relPath.endsWith(".md")) recomputeCompletions(); + if (relPath.endsWith(".md")) { + const rescan = scanDirectory(contentDir); + collectedBlocks = rescan.blocks; + recomputeCompletions(); + } } }); diff --git a/src/cli/completions/block-processor.ts b/src/cli/completions/block-processor.ts new file mode 100644 index 0000000..014c32e --- /dev/null +++ b/src/cli/completions/block-processor.ts @@ -0,0 +1,165 @@ +/** + * 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 Slugger from "github-slugger"; +import { + parseStatYaml, + parseStatCsv, + parseTemplateCsv, + type StatDef, + type StatTemplate, +} from "./stat-parser.js"; +import { + FENCED_BLOCK_RE, + parseBlockAttrs, + resolveBlockAs, + parseSparkTableCsv, +} from "./block-scanner.js"; +import type { SparkTableCompletion } from "./types.js"; + +// Re-export shared pieces for convenience +export { + FENCED_BLOCK_RE, + parseBlockAttrs, + resolveBlockAs, + parseSparkTableCsv, + type BlockAttrs, +} from "./block-scanner.js"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ProcessedBlocks { + stats: StatDef[]; + statTemplates: StatTemplate[]; + sparkTables: SparkTableCompletion[]; +} + +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 stat/template/spark-table blocks for completions + */ +export function processBlocks( + content: string, + fileRelativePath: string, + index: Record, +): BlockResult { + const fileDir = posix.dirname(fileRelativePath); + const slugger = new Slugger(); + + const blocks: ProcessedBlocks = { + stats: [], + statTemplates: [], + sparkTables: [], + }; + + 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 === "stat") { + if (attrs.lang === "yaml" || attrs.lang === "yml") { + blocks.stats.push(...parseStatYaml(body, fileRelativePath)); + } else if (attrs.lang === "csv") { + blocks.stats.push(...parseStatCsv(body, fileRelativePath)); + } + } + + if (attrs.role === "stat-template") { + const name = attrs.id || `_tpl_${contentHash(body)}`; + blocks.statTemplates.push( + parseTemplateCsv(body, fileRelativePath, name), + ); + } + + if (attrs.role === "spark-table") { + const parsed = parseSparkTableCsv(body, fileRelativePath, slugger); + if (parsed) blocks.sparkTables.push(parsed); + } + + 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 }; +} diff --git a/src/cli/completions/block-scanner.ts b/src/cli/completions/block-scanner.ts new file mode 100644 index 0000000..e0001e8 --- /dev/null +++ b/src/cli/completions/block-scanner.ts @@ -0,0 +1,103 @@ +/** + * Shared block scanning utilities — safe for both CLI and browser. + * + * Parses fenced code blocks with attributes: + * ```lang id=xxx role=xxx as=xxx + * + * - `role` dispatches to content scanners (stat, stat-template, spark-table) + * - `as` controls rendering (none, codeblock, md-table, md-card, md-dice) + * - `id` is used for cross-references (template names, file paths) + */ + +import Slugger from "github-slugger"; +import type { SparkTableCompletion } from "./types.js"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface BlockAttrs { + lang: string; + id?: string; + role?: string; + as?: string; + /** Any other attributes not in the standard set */ + extra: Record; +} + +// --------------------------------------------------------------------------- +// Regex +// --------------------------------------------------------------------------- + +/** Matches fenced code blocks with an info string (at least one attr). */ +export const FENCED_BLOCK_RE = /^```(\w*)\s+(\S.*?)\s*\n([\s\S]*?)```\s*$/gm; + +// --------------------------------------------------------------------------- +// Attribute parsing +// --------------------------------------------------------------------------- + +/** Parse key="value" and key=value pairs from an attribute string. */ +export function parseBlockAttrs(info: string): BlockAttrs { + 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, ""); + } + + const { lang, id, role, as, ...extra } = attrs; + return { lang: lang || "", id, role, as, extra }; +} + +// --------------------------------------------------------------------------- +// as resolution +// --------------------------------------------------------------------------- + +/** + * Determine the effective `as` value. + * + * Defaults: + * - role is set, no explicit as → "none" (strip — it's metadata) + * - no role, no as → "codeblock" (keep as visible code block) + */ +export function resolveBlockAs(role: string | undefined, as: string | undefined): string { + if (as) return as; + if (role) return "none"; + return "codeblock"; +} + +// --------------------------------------------------------------------------- +// Spark table parsing +// --------------------------------------------------------------------------- + +const DICE_RE = /^d\d+$/i; + +export function parseSparkTableCsv( + body: string, + filePath: string, + slugger: Slugger, +): SparkTableCompletion | null { + const lines = body.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_RE.test(headers[0])) return null; + + const dataHeaders = headers.slice(1); + 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, + headers: dataHeaders, + }; +} diff --git a/src/cli/completions/index.ts b/src/cli/completions/index.ts index efd056b..bd55528 100644 --- a/src/cli/completions/index.ts +++ b/src/cli/completions/index.ts @@ -4,10 +4,8 @@ import { diceSource } from "./sources/dice.js"; import { linksSource } from "./sources/links.js"; -import { sparkTablesSource } from "./sources/spark-tables.js"; -import { statsSource } from "./sources/stats.js"; -import { templatesSource } from "./sources/templates.js"; -import type { CompletionSource, CompletionsPayload } from "./types.js"; +import type { ProcessedBlocks } from "./block-processor.js"; +import type { CompletionsPayload } from "./types.js"; export type { CompletionsPayload, @@ -18,27 +16,22 @@ export type { StatTemplate, } from "./types.js"; -/** Registered sources — open for extension */ -const sources: CompletionSource[] = [ - diceSource, - linksSource, - sparkTablesSource, - statsSource, - templatesSource, -]; - /** - * Scan the full content index and return structured completion data. + * Build completions from the content index and pre-collected blocks. * Called at server startup and on any file change. */ export function scanCompletions( index: Record, + blocks: ProcessedBlocks, ): CompletionsPayload { - const payload: Record = {}; + const dice = diceSource.scan(index) as CompletionsPayload["dice"]; + const links = linksSource.scan(index) as CompletionsPayload["links"]; - for (const source of sources) { - payload[source.key] = source.scan(index); - } - - return payload as unknown as CompletionsPayload; + return { + dice, + links, + sparkTables: blocks.sparkTables, + stats: blocks.stats, + statTemplates: blocks.statTemplates, + }; } diff --git a/src/cli/completions/sources/spark-tables.ts b/src/cli/completions/sources/spark-tables.ts deleted file mode 100644 index ebfc26d..0000000 --- a/src/cli/completions/sources/spark-tables.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Spark table completion source — extracts spark tables (markdown tables - * whose first column header is a dice formula like d6, d20, etc.) from all - * .md files. - */ - -import Slugger from "github-slugger"; -import type { CompletionSource, SparkTableCompletion } from "../types.js"; - -/** Regex: matches a pipe-delimited markdown table row */ -function splitTableRow(line: string): string[] | null { - const trimmed = line.trim(); - if (!trimmed.includes("|")) return null; - 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()); -} - -const SEP_RE = /^:?-{3,}:?$/; -const DICE_RE = /^d\d+$/i; - -export const sparkTablesSource: CompletionSource = { - key: "sparkTables", - - scan(index) { - const items: SparkTableCompletion[] = []; - - for (const [filePath, content] of Object.entries(index)) { - if (!filePath.endsWith(".md")) continue; - - // Fresh slugger per file so duplicate headers across files don't - // pollute each other. (github-slugger is stateful.) - const slugger = new Slugger(); - - const lines = content.split(/\r?\n/); - for (let i = 0; i < lines.length; i++) { - const headerCells = splitTableRow(lines[i]); - if (!headerCells || headerCells.length < 2) continue; - if (!DICE_RE.test(headerCells[0])) continue; - - // Check separator row - if (i + 1 >= lines.length) continue; - const sepCells = splitTableRow(lines[i + 1]); - if (!sepCells || !sepCells.every((c) => SEP_RE.test(c))) continue; - - // Collect body rows - let j = i + 2; - while (j < lines.length) { - const rowCells = splitTableRow(lines[j]); - if (!rowCells) break; - j++; - } - if (j <= i + 2) continue; // No body rows - - // Build slug from data columns - const dataHeaders = headerCells.slice(1); - const slug = dataHeaders - .map((h: string) => slugger.slug(h.toLowerCase())) - .join("-"); - - const basePath = filePath.replace(/\.md$/, ""); - const fileName = basePath.split("/").filter(Boolean).pop() || basePath; - - // Combined key: pageName-columnSlug (what user types after /spark) - const combinedSlug = `${fileName}-${slug}`; - - items.push({ - label: `${fileName} § ${slug}`, - notation: headerCells[0], - slug: combinedSlug, - filePath: basePath, - headers: dataHeaders, - }); - - i = j - 1; - } - } - - return items; - }, -}; diff --git a/src/cli/completions/sources/stats.ts b/src/cli/completions/sources/stats.ts deleted file mode 100644 index 44890ca..0000000 --- a/src/cli/completions/sources/stats.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Stat completion source — extracts ```yaml role=stat and ```csv role=stat - * blocks from markdown files. - */ - -import type { CompletionSource } from "../types.js"; -import { parseStatYaml, parseStatCsv } from "../stat-parser.js"; - -const statBlockRegex = /```yaml\s+role=stat\s*\n([\s\S]*?)```/gi; -const statCsvRegex = /```csv\s+role=stat\s*\n([\s\S]*?)```/gi; - -export const statsSource: CompletionSource = { - key: "stats", - - scan(index) { - const items: ReturnType = []; - - for (const [filePath, content] of Object.entries(index)) { - if (!filePath.endsWith(".md")) continue; - - statBlockRegex.lastIndex = 0; - let match: RegExpExecArray | null; - while ((match = statBlockRegex.exec(content)) !== null) { - items.push(...parseStatYaml(match[1], filePath)); - } - - statCsvRegex.lastIndex = 0; - while ((match = statCsvRegex.exec(content)) !== null) { - items.push(...parseStatCsv(match[1], filePath)); - } - } - - return items; - }, -}; diff --git a/src/cli/completions/sources/templates.ts b/src/cli/completions/sources/templates.ts deleted file mode 100644 index 5ae741c..0000000 --- a/src/cli/completions/sources/templates.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Stat template completion source — extracts ```csv role=stat-template file=xxx - * blocks from markdown files. - */ - -import type { CompletionSource } from "../types.js"; -import { parseTemplateCsv } from "../stat-parser.js"; - -const templateBlockRegex = /```csv\s+role=stat-template\s+file=(\S+)\s*\n([\s\S]*?)```/gi; - -export const templatesSource: CompletionSource = { - key: "statTemplates", - - scan(index) { - const items: ReturnType[] = []; - - for (const [filePath, content] of Object.entries(index)) { - if (!filePath.endsWith(".md")) continue; - - templateBlockRegex.lastIndex = 0; - let match: RegExpExecArray | null; - while ((match = templateBlockRegex.exec(content)) !== null) { - const name = match[1]; - const csv = match[2]; - items.push(parseTemplateCsv(csv, filePath, name)); - } - } - - return items; - }, -}; diff --git a/src/cli/inline-blocks.ts b/src/cli/inline-blocks.ts deleted file mode 100644 index 5e6dc7b..0000000 --- a/src/cli/inline-blocks.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { posix } from "path"; -import { createHash } from "crypto"; - -/** - * Regex to match a fenced code block with attributes, e.g.: - * - * ```csv file="card-data.csv" - * name, cost, effect - * Fireball, 1, Blast em - * ``` - * - * ```csv as="md-table" roll=true - * name, cost, effect - * Fireball, 1, Blast em - * ``` - * - * Group 1: language identifier (e.g. "csv") - * Group 2: attribute string (e.g. `file="card-data.csv" as="md-table" roll=true`) - * Group 3: the code block body - */ -const INLINE_FILE_BLOCK_RE = /^```(\w*)\s+(.+?)\s*\n([\s\S]*?)```\s*$/gm; - -/** - * Parse key="value" and key=value pairs from an attribute string. - */ -function parseAttrs(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; -} - -/** - * Short content hash for stable generated filenames. - */ -function contentHash(body: string): string { - return createHash("md5").update(body).digest("hex").slice(0, 8); -} - -/** - * Process fenced code blocks that contain attributes. - * - * For each block like: - * ```lang file="card-data.csv" as="md-table" roll=true - * content - * ``` - * - * 1. Strips the block from the returned text - * 2. Injects a synthetic entry into `index` at the resolved file path - * 3. If `as` is present, replaces the block with a directive like - * `:md-table[./file.csv]{roll=true}` - * - * @param content - Raw file content - * @param fileRelativePath - Normalized path of the containing file (e.g. "/rules/combat.md") - * @param index - The content index to inject synthetic entries into - * @returns Content with inline blocks processed - */ -export function extractInlineBlocks( - content: string, - fileRelativePath: string, - index: Record, -): string { - const fileDir = posix.dirname(fileRelativePath); - - return content.replace( - INLINE_FILE_BLOCK_RE, - (_match: string, lang: string, infoString: string, body: string) => { - const attrs = parseAttrs(infoString); - - // Only process blocks that have `file` or `as`; leave others untouched - if (!attrs.file && !attrs.as) { - return _match; - } - - // Determine filename: explicit `file` or content-hash generated - const filename = - attrs.file || `_inline_${contentHash(body)}.${lang || "txt"}`; - const resolvedPath = posix.join(fileDir, filename); - - // Inject the body as a synthetic file entry - index[resolvedPath] = body; - console.log( - `[inline] Extracted ${resolvedPath} from ${fileRelativePath}`, - ); - - // If `as` is set, emit a directive instead of stripping - if (attrs.as) { - const extra = { ...attrs }; - delete extra.file; - delete extra.as; - const extraStr = Object.keys(extra).length - ? `{${Object.entries(extra) - .map(([k, v]) => `${k}=${v}`) - .join(" ")}}` - : ""; - return `:${attrs.as}[./${filename}]${extraStr}`; - } - - return ""; // has `file` but no `as` — strip the block (body is already injected into index) - }, - ); -} diff --git a/src/components/journal/completions.ts b/src/components/journal/completions.ts index 119f440..4828670 100644 --- a/src/components/journal/completions.ts +++ b/src/components/journal/completions.ts @@ -21,6 +21,11 @@ import { parseTemplateCsv, } from "../../cli/completions/stat-parser"; import type { StatDef, StatTemplate } from "../../cli/completions/stat-parser"; +import { + FENCED_BLOCK_RE, + parseBlockAttrs, + parseSparkTableCsv, +} from "../../cli/completions/block-scanner"; export type { StatDef, StatTemplate }; @@ -95,19 +100,17 @@ async function scanClientSide(): Promise { const links: LinkCompletion[] = []; const sparkTables: SparkTableCompletion[] = []; const stats: StatDef[] = []; - const tagRegex = /]*>\s*([\s\S]*?)\s*<\/md-dice>/gi; - const statBlockRegex = /```yaml\s+role=stat\s*\n([\s\S]*?)```/gi; - const statCsvRegex = /```csv\s+role=stat\s*\n([\s\S]*?)```/gi; + const statTemplates: StatTemplate[] = []; + const tagRegex = /:md-dice\[([^[]+)\]/gi; for (const filePath of paths) { const content = await getIndexedData(filePath); if (!content) continue; - // Fresh slugger per file so duplicate headers across files don't - // pollute each other. + // Fresh slugger per file const slugger = new Slugger(); - // Dice scan + // ---- Dice directives ---- let match: RegExpExecArray | null; tagRegex.lastIndex = 0; while ((match = tagRegex.exec(content)) !== null) { @@ -117,7 +120,7 @@ async function scanClientSide(): Promise { dice.push({ label: raw, notation: raw, source: filePath }); } - // Link scan (headings) + // ---- Links (headings) ---- const basePath = filePath.replace(/\.md$/, ""); const fileName = basePath.split("/").filter(Boolean).pop() || basePath; links.push({ path: basePath, label: fileName, section: null }); @@ -129,87 +132,37 @@ async function scanClientSide(): Promise { }); } - // Spark table scan - const sparkLines = content.split(/\r?\n/); - for (let i = 0; i < sparkLines.length; i++) { - const headerCells = splitTableRow(sparkLines[i]); - if (!headerCells || headerCells.length < 2) continue; - if (!/^d\d+$/i.test(headerCells[0])) continue; + // ---- Unified block scanning ---- + 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 (i + 1 >= sparkLines.length) continue; - const sepCells = splitTableRow(sparkLines[i + 1]); - if (!sepCells || !sepCells.every((c) => /^:?-{3,}:?$/.test(c))) continue; + if (attrs.role === "stat") { + if (attrs.lang === "yaml" || attrs.lang === "yml") { + stats.push(...parseStatYaml(body, filePath)); + } else if (attrs.lang === "csv") { + stats.push(...parseStatCsv(body, filePath)); + } + } - let j = i + 2; - while (j < sparkLines.length && splitTableRow(sparkLines[j])) j++; - if (j <= i + 2) continue; + if (attrs.role === "stat-template") { + const name = attrs.id || `_tpl_${filePath}_${stats.length}`; + statTemplates.push(parseTemplateCsv(body, filePath, name)); + } - const dataHeaders = headerCells.slice(1); - const stSlug = dataHeaders - .map((h) => slugger.slug(h.toLowerCase())) - .join("-"); - - // Combined key: pageName-columnSlug - const combinedSlug = `${fileName}-${stSlug}`; - - sparkTables.push({ - label: `${fileName} § ${stSlug}`, - notation: headerCells[0], - slug: combinedSlug, - filePath: basePath, - headers: dataHeaders, - }); - - i = j - 1; - } - - // Stat block scan (YAML) - statBlockRegex.lastIndex = 0; - let statMatch: RegExpExecArray | null; - while ((statMatch = statBlockRegex.exec(content)) !== null) { - const yaml = statMatch[1]; - const parsed = parseStatYaml(yaml, filePath); - stats.push(...parsed); - } - - // Stat block scan (CSV) - statCsvRegex.lastIndex = 0; - while ((statMatch = statCsvRegex.exec(content)) !== null) { - const csv = statMatch[1]; - const parsed = parseStatCsv(csv, filePath); - stats.push(...parsed); - } - } - - // Stat template scan - const statTemplates: StatTemplate[] = []; - const templateBlockRegex = - /```csv\s+role=stat-template\s+file=(\S+)\s*\n([\s\S]*?)```/gi; - for (const filePath of paths) { - const content = await getIndexedData(filePath); - if (!content) continue; - - templateBlockRegex.lastIndex = 0; - let tMatch: RegExpExecArray | null; - while ((tMatch = templateBlockRegex.exec(content)) !== null) { - const name = tMatch[1]; - const csv = tMatch[2]; - statTemplates.push(parseTemplateCsv(csv, filePath, name)); + if (attrs.role === "spark-table") { + const parsed = parseSparkTableCsv(body, filePath, slugger); + if (parsed) sparkTables.push(parsed); + } } } return { dice, links, sparkTables, stats, statTemplates }; } -function splitTableRow(line: string): string[] | null { - const trimmed = line.trim(); - if (!trimmed.includes("|")) return null; - 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()); -} - // ------------------- Init (runs eagerly at import time) ------------------- // Using a top-level IIFE so the promise starts immediately diff --git a/src/doc-entries/journal-stat.md b/src/doc-entries/journal-stat.md index b1b48c9..03d7ca6 100644 --- a/src/doc-entries/journal-stat.md +++ b/src/doc-entries/journal-stat.md @@ -234,10 +234,10 @@ weather,天气,enum,晴天\|阴天\|雨天\|暴风雨 模板属性用于查表掷骰,例如年龄表、职业表等。 -模板在独立的 CSV 代码块中定义,使用 `role=stat-template` 和 `file=模板名`。 +模板在独立的 CSV 代码块中定义,使用 `role=stat-template` 和 `id=模板名`。 **第一列是骰子表达式**(如 `1d10`),第二列是 `label`,其余列为修饰符: -```csv role=stat-template file=年龄 +```csv id=年龄 role=stat-template 1d10,label,heart,mind,strength,speed 1-3,青少年,+20,-10,, 4-7,成年,,-10,,+20