refactor: consolidate block processing logic

Introduce a centralized `block-processor` and `block-scanner` to
handle markdown fenced code blocks. This replaces the previous
`inline-blocks.ts` and individual completion sources with a unified
approach that handles block stripping, directive replacement, and
metadata extraction (stats, templates, and spark tables) in a single
pass.
This commit is contained in:
hypercross 2026-07-09 10:56:54 +08:00
parent 1c69bf394c
commit a4cfcde613
10 changed files with 358 additions and 379 deletions

View File

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

View File

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

View File

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

View File

@ -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<string, string>,
blocks: ProcessedBlocks,
): CompletionsPayload {
const payload: Record<string, unknown[]> = {};
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,
};
}

View File

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

View File

@ -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<typeof parseStatYaml> = [];
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;
},
};

View File

@ -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<typeof parseTemplateCsv>[] = [];
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;
},
};

View File

@ -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<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;
}
/**
* 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, string>,
): 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)
},
);
}

View File

@ -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<JournalCompletions> {
const links: LinkCompletion[] = [];
const sparkTables: SparkTableCompletion[] = [];
const stats: StatDef[] = [];
const tagRegex = /<md-dice[^>]*>\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<JournalCompletions> {
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<JournalCompletions> {
});
}
// 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;
let j = i + 2;
while (j < sparkLines.length && splitTableRow(sparkLines[j])) j++;
if (j <= i + 2) continue;
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);
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));
}
}
// 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;
if (attrs.role === "stat-template") {
const name = attrs.id || `_tpl_${filePath}_${stats.length}`;
statTemplates.push(parseTemplateCsv(body, filePath, name));
}
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

View File

@ -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