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