diff --git a/src/cli/inline-blocks.ts b/src/cli/inline-blocks.ts index aa78e8b..98756ec 100644 --- a/src/cli/inline-blocks.ts +++ b/src/cli/inline-blocks.ts @@ -1,68 +1,100 @@ import { posix } from "path"; +import { createHash } from "crypto"; /** - * Regex to match a fenced code block with a `file="..."` attribute, e.g.: + * Regex to match a fenced code block with attributes, e.g.: * * ```csv file="card-data.csv" * name, cost, effect * Fireball, 1, Blast em * ``` * - * ```yaml file="monsters.yaml" - * goblin: - * hp: 7 + * ```csv as="md-table" roll=true + * name, cost, effect + * Fireball, 1, Blast em * ``` * - * The language identifier is optional — only `file="..."` matters. - * - * Group 1: the info string after the opening fence (e.g. `csv file="card-data.csv"`) - * Group 2: the code block body + * 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+file="([^"]+)"(?:\s+\w+="[^"]*")*\s*\n([\s\S]*?)```\s*$/gm; +const INLINE_FILE_BLOCK_RE = /^```(\w*)\s+(.+?)\s*\n([\s\S]*?)```\s*$/gm; /** - * Process fenced code blocks that contain a `file="..."` attribute. + * Parse key="value" and key=value pairs from an attribute string. + */ +function parseAttrs(info: string): Record { + const attrs: Record = {}; + 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" + * ```lang file="card-data.csv" as="md-table" roll=true * content * ``` * - * 1. Strips the block from the returned text (so it doesn't render) + * 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 removed + * @returns Content with inline blocks processed */ export function extractInlineBlocks( content: string, fileRelativePath: string, index: Record, ): string { - const matches: { filename: string; body: string }[] = []; - let match: RegExpExecArray | null; + const fileDir = posix.dirname(fileRelativePath); - INLINE_FILE_BLOCK_RE.lastIndex = 0; + return content.replace( + INLINE_FILE_BLOCK_RE, + (_match: string, lang: string, infoString: string, body: string) => { + const attrs = parseAttrs(infoString); - while ((match = INLINE_FILE_BLOCK_RE.exec(content)) !== null) { - matches.push({ filename: match[1], body: match[2] }); - } + // Determine filename: explicit `file` or content-hash generated + const filename = + attrs.file || `_inline_${contentHash(body)}.${lang || "txt"}`; + const resolvedPath = posix.join(fileDir, filename); - if (matches.length === 0) return content; + // Inject the body as a synthetic file entry + index[resolvedPath] = body; + console.log( + `[inline] Extracted ${resolvedPath} from ${fileRelativePath}`, + ); - const stripped = content.replace(INLINE_FILE_BLOCK_RE, ""); + // 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}`; + } - for (const { filename, body } of matches) { - const fileDir = posix.dirname(fileRelativePath); - const resolvedPath = posix.join(fileDir, filename); - - index[resolvedPath] = body; - console.log( - `[inline] Extracted ${resolvedPath} from ${fileRelativePath}`, - ); - } - - return stripped; + return ""; // no `as` — strip the block entirely + }, + ); }