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:
2026-07-09 10:56:54 +08:00
parent 1c69bf394c
commit a4cfcde613
10 changed files with 358 additions and 379 deletions
+32 -79
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;
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