ttrpg-tools/src/cli/inline-blocks.ts

106 lines
3.1 KiB
TypeScript

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<string, string> {
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, "");
}
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, string>,
): 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)
},
);
}