feat: support multiple attributes and `as` directive in inline blocks
Update the inline block parser to support arbitrary key-value pairs.
If an `as` attribute is provided, the block is replaced with a
directive (e.g., `:directive[path]{attrs}`) instead of being
stripped. If no `as` attribute is present, the block is removed.
Additionally, filenames are now automatically generated using a
content hash if the `file` attribute is missing.
This commit is contained in:
parent
2a9eee6410
commit
749b1a6a4b
|
|
@ -1,68 +1,100 @@
|
||||||
import { posix } from "path";
|
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"
|
* ```csv file="card-data.csv"
|
||||||
* name, cost, effect
|
* name, cost, effect
|
||||||
* Fireball, 1, Blast em
|
* Fireball, 1, Blast em
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* ```yaml file="monsters.yaml"
|
* ```csv as="md-table" roll=true
|
||||||
* goblin:
|
* name, cost, effect
|
||||||
* hp: 7
|
* Fireball, 1, Blast em
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* The language identifier is optional — only `file="..."` matters.
|
* Group 1: language identifier (e.g. "csv")
|
||||||
*
|
* Group 2: attribute string (e.g. `file="card-data.csv" as="md-table" roll=true`)
|
||||||
* Group 1: the info string after the opening fence (e.g. `csv file="card-data.csv"`)
|
* Group 3: the code block body
|
||||||
* Group 2: 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<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:
|
* For each block like:
|
||||||
* ```lang file="card-data.csv"
|
* ```lang file="card-data.csv" as="md-table" roll=true
|
||||||
* content
|
* 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
|
* 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 content - Raw file content
|
||||||
* @param fileRelativePath - Normalized path of the containing file (e.g. "/rules/combat.md")
|
* @param fileRelativePath - Normalized path of the containing file (e.g. "/rules/combat.md")
|
||||||
* @param index - The content index to inject synthetic entries into
|
* @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(
|
export function extractInlineBlocks(
|
||||||
content: string,
|
content: string,
|
||||||
fileRelativePath: string,
|
fileRelativePath: string,
|
||||||
index: Record<string, string>,
|
index: Record<string, string>,
|
||||||
): string {
|
): string {
|
||||||
const matches: { filename: string; body: string }[] = [];
|
const fileDir = posix.dirname(fileRelativePath);
|
||||||
let match: RegExpExecArray | null;
|
|
||||||
|
|
||||||
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) {
|
// Determine filename: explicit `file` or content-hash generated
|
||||||
matches.push({ filename: match[1], body: match[2] });
|
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) {
|
return ""; // no `as` — strip the block entirely
|
||||||
const fileDir = posix.dirname(fileRelativePath);
|
},
|
||||||
const resolvedPath = posix.join(fileDir, filename);
|
);
|
||||||
|
|
||||||
index[resolvedPath] = body;
|
|
||||||
console.log(
|
|
||||||
`[inline] Extracted ${resolvedPath} from ${fileRelativePath}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return stripped;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue