refactor(markdown): unify yaml tag block syntax on role=tag

Drop legacy yaml/tag info string; tag blocks now require
```yaml role=tag. Add fence-line tag=/id= attributes that
override YAML body values, and move parseBlockAttrs to
src/markdown/block-attrs.ts so scanner and render extensions
share one parser.
This commit is contained in:
2026-09-08 20:56:14 +08:00
parent 80e2b5b209
commit 638e8f6526
6 changed files with 90 additions and 73 deletions
+3 -6
View File
@@ -1,13 +1,12 @@
# yaml/tag 代码块格式测试 # yaml role=tag 代码块格式测试
:md-deck[./names.csv]{size="54x86" grid="5x8" bleed="1" padding="2" layers="name1:1,1-2,2f12 name2:4,7-5,8f12s num1:1,3-2,4f12 num2:4,5-5,6f12s"} :md-deck[./names.csv]{size="54x86" grid="5x8" bleed="1" padding="2" layers="name1:1,1-2,2f12 name2:4,7-5,8f12s num1:1,3-2,4f12 num2:4,5-5,6f12s"}
:md-deck[./names.csv]{size="54x86" grid="5x8" layers="num1:1,1-2,2 num2:4,5-5,6f12s name1:1,1-2,2 name2:1,1-2,2 name1:1,1-2,2" } :md-deck[./names.csv]{size="54x86" grid="5x8" layers="num1:1,1-2,2 num2:4,5-5,6f12s name1:1,1-2,2 name2:1,1-2,2 name1:1,1-2,2" }
## 使用 yaml/tag 语法创建 md-deck ## 使用 yaml role=tag 语法创建 md-deck(fence 行内联 tag,简洁写法)
```yaml/tag ```yaml role=tag tag=md-deck
tag: md-deck
body: ./names.csv body: ./names.csv
size: 54x86 size: 54x86
grid: 5x8 grid: 5x8
@@ -16,8 +15,6 @@ padding: 2
layers: name1:1,1-2,2f12 name2:4,7-5,8f12s num1:1,3-2,4f12 num2:4,5-5,6f12s layers: name1:1,1-2,2f12 name2:4,7-5,8f12s num1:1,3-2,4f12 num2:4,5-5,6f12s
``` ```
## 使用 yaml role=tag 语法创建 md-deck(推荐,可语法高亮)
```yaml role=tag ```yaml role=tag
tag: md-deck tag: md-deck
body: ./names.csv body: ./names.csv
+4 -4
View File
@@ -489,7 +489,7 @@ track npc#john.dwarf.warrior[hp=4/4 ac=15 name="John"]
## YAML 标签 ## YAML 标签
使用 ```yaml role=tag 代码块创建自定义标签(推荐,`yaml` 语言可被语法高亮): 使用 ```yaml role=tag 代码块创建自定义标签(`yaml` 语言可被语法高亮):
````markdown ````markdown
```yaml role=tag ```yaml role=tag
@@ -500,11 +500,11 @@ body: 标签内容
``` ```
```` ````
也支持旧写法 ```yaml/tag(等价,但无语法高亮): `tag`、`id` 等简单属性也可以直接写在 fence 行上(fence 行属性优先于 YAML 内容):
````markdown ````markdown
```yaml/tag ```yaml role=tag tag=tag-name id=my-id
tag: tag-name class: custom-class
body: 标签内容 body: 标签内容
``` ```
```` ````
+4 -29
View File
@@ -7,20 +7,12 @@
* - `role` dispatches to content scanners (declare, spark-table) * - `role` dispatches to content scanners (declare, spark-table)
* - `as` controls rendering (none, codeblock, md-table, md-card, md-dice) * - `as` controls rendering (none, codeblock, md-table, md-card, md-dice)
* - `id` is used for cross-references (file paths) * - `id` is used for cross-references (file paths)
*
* Attribute parsing itself lives in `src/markdown/block-attrs.ts` so render
* extensions can share the same syntax.
*/ */
// --------------------------------------------------------------------------- export { parseBlockAttrs, type BlockAttrs } from "../../markdown/block-attrs";
// 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 // Regex
@@ -29,23 +21,6 @@ export interface BlockAttrs {
/** Matches fenced code blocks with an info string (at least one attr). */ /** 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; 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 // as resolution
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+29
View File
@@ -0,0 +1,29 @@
/**
* Fenced code block attribute parsing — shared by the content scanner
* (CLI + browser registry) and the markdown render extensions.
*
* Parses info strings like:
* ```lang id=xxx role=xxx as=xxx key=value
*/
export interface BlockAttrs {
lang: string;
id?: string;
role?: string;
as?: string;
/** Any other attributes not in the standard set */
extra: Record<string, string>;
}
/** 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 };
}
+21 -22
View File
@@ -13,7 +13,7 @@ function render(src: string): string {
describe("code-block-yaml-tag", () => { describe("code-block-yaml-tag", () => {
test("renders body and scalar props as attributes", () => { test("renders body and scalar props as attributes", () => {
const html = render( const html = render(
"```yaml/tag\ntag: md-deck\nbody: ./cards.csv\nsize: 54x86\n```", "```yaml role=tag\ntag: md-deck\nbody: ./cards.csv\nsize: 54x86\n```",
); );
expect(html).toContain("<md-deck size=\"54x86\">./cards.csv</md-deck>"); expect(html).toContain("<md-deck size=\"54x86\">./cards.csv</md-deck>");
}); });
@@ -21,7 +21,7 @@ describe("code-block-yaml-tag", () => {
test("serializes data-config to a JSON attribute", () => { test("serializes data-config to a JSON attribute", () => {
const html = render( const html = render(
[ [
"```yaml/tag", "```yaml role=tag",
"tag: md-deck", "tag: md-deck",
"body: ./cards.csv", "body: ./cards.csv",
"data-config:", "data-config:",
@@ -49,26 +49,20 @@ describe("code-block-yaml-tag", () => {
}); });
}); });
test("supports yaml role=tag info string (highlight-friendly)", () => { test("supports tag= and id= on the info string", () => {
const html = render( const html = render(
[ "```yaml role=tag tag=md-deck id=my-deck\nbody: ./cards.csv\n```",
"```yaml role=tag",
"tag: md-deck",
"body: ./cards.csv",
"data-config:",
" grid: 5x5",
" layers:",
" - template: |",
" **{{name}}**",
" pos: 1,1-5,8",
"```",
].join("\n"),
); );
expect(html).toContain("<md-deck data-config="); expect(html).toContain("<md-deck id=\"my-deck\">./cards.csv</md-deck>");
expect(html).toContain("./cards.csv</md-deck>"); });
const m = html.match(/data-config="([^"]*)"/);
const config = JSON.parse((m![1] || "").replace(/&quot;/g, '"')); test("fence attributes override YAML body values", () => {
expect(config.layers[0].template).toBe("**{{name}}**\n"); const html = render(
"```yaml role=tag tag=md-deck size=54x86\ntag: md-other\nsize: 63x88\n```",
);
expect(html).toContain("<md-deck size=\"54x86\"></md-deck>");
expect(html).not.toContain("md-other");
expect(html).not.toContain("63x88");
}); });
test("does not swallow plain yaml code blocks", () => { test("does not swallow plain yaml code blocks", () => {
@@ -76,8 +70,13 @@ describe("code-block-yaml-tag", () => {
expect(token).toBeUndefined(); expect(token).toBeUndefined();
}); });
test("does not swallow yaml blocks without role=tag", () => {
const token = ext.tokenizer("```yaml tag=md-deck\nsize: 54x86\n```");
expect(token).toBeUndefined();
});
test("handles missing tag and body", () => { test("handles missing tag and body", () => {
const html = render("```yaml/tag\nclass: foo\n```"); const html = render("```yaml role=tag\nclass: foo\n```");
expect(html).toContain("<tag-unknown class=\"foo\"></tag-unknown>"); expect(html).toContain("<tag-unknown class=\"foo\"></tag-unknown>");
}); });
}); });
+29 -12
View File
@@ -1,6 +1,20 @@
import type { MarkedExtension } from "marked"; import type { MarkedExtension } from "marked";
import yaml from "js-yaml"; import yaml from "js-yaml";
import { parseBlockAttrs } from "./block-attrs";
/**
* YAML-defined tag blocks:
*
* ```yaml role=tag tag=md-deck id=my-deck
* body: ./cards.csv
* data-config: ...
* ```
*
* `role=tag` on the info string marks the block (and lets the content
* scanner keep it intact). `tag=` / `id=` / any other `key=value` fence
* attributes override the same keys in the YAML body, so simple cases
* stay on one line.
*/
export default function markedCodeBlockYamlTag(): MarkedExtension { export default function markedCodeBlockYamlTag(): MarkedExtension {
return { return {
extensions: [ extensions: [
@@ -8,27 +22,30 @@ export default function markedCodeBlockYamlTag(): MarkedExtension {
name: "code-block-yaml-tag", name: "code-block-yaml-tag",
level: "block", level: "block",
start(src: string) { start(src: string) {
return ( return src.match(/^```yaml\s+role=tag(?:\s|\n)/m)?.index;
src.match(/^```yaml\/tag\s*\n/m)?.index ??
src.match(/^```yaml\s+role=tag(?:\s|\n)/m)?.index
);
}, },
tokenizer(src: string) { tokenizer(src: string) {
// Both `yaml/tag` (legacy) and `yaml role=tag` (highlight-friendly) const rule = /^```yaml\s+role=tag([^\n]*)\n([\s\S]*?)\n```/;
// info strings identify a yaml-defined tag block.
const rule = /^```yaml(?:\/tag|\s+role=tag[^\n]*)\n([\s\S]*?)\n```/;
const match = rule.exec(src); const match = rule.exec(src);
if (match) { if (match) {
const yamlContent = match[1]?.trim() || ""; const yamlContent = match[2]?.trim() || "";
let props: Record<string, unknown> = {}; let yamlProps: Record<string, unknown> = {};
try { try {
props = yamlProps =
(yaml.load(yamlContent) as Record<string, unknown>) || {}; (yaml.load(yamlContent) as Record<string, unknown>) || {};
} catch (e) { } catch (e) {
console.error("YAML Parse Error in code-block-yaml-tag:", e); console.error("YAML Parse Error in code-block-yaml-tag:", e);
props = { error: "Invalid YAML content" }; yamlProps = { error: "Invalid YAML content" };
} }
// Fence attributes override YAML body values.
const attrs = parseBlockAttrs(match[1] || "");
const fenceProps: Record<string, unknown> = {
...(attrs.id ? { id: attrs.id } : {}),
...attrs.extra,
};
const props: Record<string, unknown> = { ...yamlProps, ...fenceProps };
const tagName = (props.tag as string) || "tag-unknown"; const tagName = (props.tag as string) || "tag-unknown";
const { tag, ...rest } = props; const { tag, ...rest } = props;
@@ -68,4 +85,4 @@ export default function markedCodeBlockYamlTag(): MarkedExtension {
}, },
], ],
}; };
} }