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