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" 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
tag: md-deck
```yaml role=tag tag=md-deck
body: ./names.csv
size: 54x86
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
```
## 使用 yaml role=tag 语法创建 md-deck(推荐,可语法高亮)
```yaml role=tag
tag: md-deck
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 role=tag 代码块创建自定义标签(推荐,`yaml` 语言可被语法高亮):
使用 ```yaml role=tag 代码块创建自定义标签(`yaml` 语言可被语法高亮):
````markdown
```yaml role=tag
@@ -500,11 +500,11 @@ body: 标签内容
```
````
也支持旧写法 ```yaml/tag(等价,但无语法高亮):
`tag`、`id` 等简单属性也可以直接写在 fence 行上(fence 行属性优先于 YAML 内容):
````markdown
```yaml/tag
tag: tag-name
```yaml role=tag tag=tag-name id=my-id
class: custom-class
body: 标签内容
```
````
+4 -29
View File
@@ -7,20 +7,12 @@
* - `role` dispatches to content scanners (declare, spark-table)
* - `as` controls rendering (none, codeblock, md-table, md-card, md-dice)
* - `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.
*/
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface BlockAttrs {
lang: string;
id?: string;
role?: string;
as?: string;
/** Any other attributes not in the standard set */
extra: Record<string, string>;
}
export { parseBlockAttrs, type BlockAttrs } from "../../markdown/block-attrs";
// ---------------------------------------------------------------------------
// Regex
@@ -29,23 +21,6 @@ export interface BlockAttrs {
/** 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;
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
+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 };
}
+20 -21
View File
@@ -13,7 +13,7 @@ function render(src: string): string {
describe("code-block-yaml-tag", () => {
test("renders body and scalar props as attributes", () => {
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>");
});
@@ -21,7 +21,7 @@ describe("code-block-yaml-tag", () => {
test("serializes data-config to a JSON attribute", () => {
const html = render(
[
"```yaml/tag",
"```yaml role=tag",
"tag: md-deck",
"body: ./cards.csv",
"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(
[
"```yaml role=tag",
"tag: md-deck",
"body: ./cards.csv",
"data-config:",
" grid: 5x5",
" layers:",
" - template: |",
" **{{name}}**",
" pos: 1,1-5,8",
"```",
].join("\n"),
"```yaml role=tag tag=md-deck id=my-deck\nbody: ./cards.csv\n```",
);
expect(html).toContain("<md-deck data-config=");
expect(html).toContain("./cards.csv</md-deck>");
const m = html.match(/data-config="([^"]*)"/);
const config = JSON.parse((m![1] || "").replace(/&quot;/g, '"'));
expect(config.layers[0].template).toBe("**{{name}}**\n");
expect(html).toContain("<md-deck id=\"my-deck\">./cards.csv</md-deck>");
});
test("fence attributes override YAML body values", () => {
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", () => {
@@ -76,8 +70,13 @@ describe("code-block-yaml-tag", () => {
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", () => {
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>");
});
});
+28 -11
View File
@@ -1,6 +1,20 @@
import type { MarkedExtension } from "marked";
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 {
return {
extensions: [
@@ -8,27 +22,30 @@ export default function markedCodeBlockYamlTag(): MarkedExtension {
name: "code-block-yaml-tag",
level: "block",
start(src: string) {
return (
src.match(/^```yaml\/tag\s*\n/m)?.index ??
src.match(/^```yaml\s+role=tag(?:\s|\n)/m)?.index
);
return src.match(/^```yaml\s+role=tag(?:\s|\n)/m)?.index;
},
tokenizer(src: string) {
// Both `yaml/tag` (legacy) and `yaml role=tag` (highlight-friendly)
// info strings identify a yaml-defined tag block.
const rule = /^```yaml(?:\/tag|\s+role=tag[^\n]*)\n([\s\S]*?)\n```/;
const rule = /^```yaml\s+role=tag([^\n]*)\n([\s\S]*?)\n```/;
const match = rule.exec(src);
if (match) {
const yamlContent = match[1]?.trim() || "";
let props: Record<string, unknown> = {};
const yamlContent = match[2]?.trim() || "";
let yamlProps: Record<string, unknown> = {};
try {
props =
yamlProps =
(yaml.load(yamlContent) as Record<string, unknown>) || {};
} catch (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 { tag, ...rest } = props;