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
+165
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 };
}
+103
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,
};
}
+13 -20
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,
};
}
@@ -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;
},
};
-35
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;
},
};
-31
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;
},
};