import { posix } from "path"; import { createHash } from "crypto"; /** * Regex to match a fenced code block with attributes, e.g.: * * ```csv file="card-data.csv" * name, cost, effect * Fireball, 1, Blast em * ``` * * ```csv as="md-table" roll=true * name, cost, effect * Fireball, 1, Blast em * ``` * * 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+(.+?)\s*\n([\s\S]*?)```\s*$/gm; /** * 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" as="md-table" roll=true * content * ``` * * 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 processed */ export function extractInlineBlocks( content: string, fileRelativePath: string, index: Record, ): string { const fileDir = posix.dirname(fileRelativePath); return content.replace( INLINE_FILE_BLOCK_RE, (_match: string, lang: string, infoString: string, body: string) => { const attrs = parseAttrs(infoString); // Only process blocks that have `file` or `as`; leave others untouched if (!attrs.file && !attrs.as) { return _match; } // Determine filename: explicit `file` or content-hash generated const filename = attrs.file || `_inline_${contentHash(body)}.${lang || "txt"}`; const resolvedPath = posix.join(fileDir, filename); // Inject the body as a synthetic file entry index[resolvedPath] = body; console.log( `[inline] Extracted ${resolvedPath} from ${fileRelativePath}`, ); // 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}`; } return ""; // has `file` but no `as` — strip the block (body is already injected into index) }, ); }