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:
parent
1c69bf394c
commit
a4cfcde613
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue