Compare commits

...
3 Commits
Author SHA1 Message Date
hypercross 80e2b5b209 fix(yaml/tag): prevent yaml role=tag from being stripped & generalize
data-config
2026-09-03 10:39:05 +08:00
hypercross 5fc2bc5262 feat(md-deck): add vertical alignment options to layers
Expand align from l/c/r to a 3x3 grid with vertical prefixes:
tl/tc/tr/bl/bc/br. Vertical position renders via flex
justify-content; compact layers strings and YAML both parse the
new values, and the layer editor dropdown gains corner/edge
options.
2026-09-03 10:17:44 +08:00
hypercross d75df5280f feat(markdown): support yaml role=tag info string
Accept yaml role=tag alongside legacy yaml/tag so the block
body gets yaml syntax highlighting, matching the CLI role=
attribute convention. Copy button now emits the role=tag form.
2026-09-03 10:10:06 +08:00
14 changed files with 199 additions and 73 deletions
+23
View File
@@ -15,3 +15,26 @@ 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
```
## 使用 yaml role=tag 语法创建 md-deck(推荐,可语法高亮)
```yaml role=tag
tag: md-deck
body: ./names.csv
data-config:
size: 54x86
grid: 5x8
layers:
- prop: name1
pos: 1,1-2,2
font: 12
- prop: num2
pos: 4,5-5,6
font: 12
orientation: s
- template: |
**{{name1}}** / {{num1}}
pos: 1,3-5,4
font: 6
align: l
```
+16 -7
View File
@@ -423,12 +423,12 @@ layers="字段:起始行,起始列 - 结束列,字体大小"
示例:`title:1,1-5,1f8` 表示 title 字段从第 1 行开始,占据 1-5 列,8mm字体。
**结构化配置(yaml/tag 代码块):**
**结构化配置(yaml 代码块):**
可以使用 ```yaml/tag 代码块,通过 `body` 指定 CSV 路径,`data-config` 提供结构化配置(图层可以是字段或模板):
推荐使用 ```yaml role=tag 代码块`yaml` 语言可被语法高亮),通过 `body` 指定 CSV 路径,`data-config` 提供结构化配置(图层可以是字段或模板):
````markdown
```yaml/tag
```yaml role=tag
tag: md-deck
body: ./cards.csv
data-config:
@@ -447,7 +447,7 @@ data-config:
```
````
每个图层通过 `prop`(读取 CSV 字段)或 `template`(渲染带 `{{变量}}` 替换的 markdown 模板)定义,`pos` 为网格位置 `x1,y1-x2,y2`1-based,与紧凑 layers 字符串同格式),支持 `font`、`orientation`、`align`。`back_layers` 用于背面图层。
每个图层通过 `prop`(读取 CSV 字段)或 `template`(渲染带 `{{变量}}` 替换的 markdown 模板)定义,`pos` 为网格位置 `x1,y1-x2,y2`1-based,与紧凑 layers 字符串同格式),支持 `font`、`orientation`、`align`(水平 `l`/`c`/`r`,可加垂直前缀 `t`/`b` 组成 `tl`/`tc`/`tr`/`bl`/`bc`/`br`,如 `align: tl` 表示左上对齐)。`back_layers` 用于背面图层。
### 🧶 叙事线组件 (md-yarn-spinner)
@@ -489,13 +489,22 @@ track npc#john.dwarf.warrior[hp=4/4 ac=15 name="John"]
## YAML 标签
使用 ```yaml/tag 代码块创建自定义标签:
使用 ```yaml role=tag 代码块创建自定义标签(推荐,`yaml` 语言可被语法高亮)
````markdown
```yaml role=tag
tag: tag-name
class: custom-class
id: my-id
body: 标签内容
```
````
也支持旧写法 ```yaml/tag(等价,但无语法高亮):
````markdown
```yaml/tag
tag: tag-name
class: custom-class
id: my-id
body: 标签内容
```
````
+4
View File
@@ -56,12 +56,16 @@ export function parseBlockAttrs(info: string): BlockAttrs {
* Defaults:
* - role is set, no explicit as → "none" (strip — it's metadata)
* - role=spark-table → "md-table" (render as md-table directive)
* - role=tag → "codeblock" (kept for the marked yaml-tag extension)
* - no role, no as → "codeblock" (keep as visible code block)
*/
export function resolveBlockAs(role: string | undefined, as: string | undefined): string {
if (as) return as;
// spark-table blocks render as md-table by default
if (role === "spark-table") return "md-table";
// yaml-defined tag blocks must survive stripping so the
// code-block-yaml-tag marked extension can render them
if (role === "tag") return "codeblock";
if (role) return "none";
return "codeblock";
}
@@ -269,6 +269,10 @@ describe("resolveBlockAs", () => {
expect(resolveBlockAs("spark-table", undefined)).toBe("md-table");
});
test("defaults to codeblock for tag role", () => {
expect(resolveBlockAs("tag", undefined)).toBe("codeblock");
});
test("defaults to codeblock when no role and no as", () => {
expect(resolveBlockAs(undefined, undefined)).toBe("codeblock");
});
+18 -7
View File
@@ -1,7 +1,7 @@
import { createMemo, For, Show } from "solid-js";
import { createMemo, For, Show, type JSX } from "solid-js";
import { parseMarkdown } from "../../markdown";
import { getLayerStyle } from "./hooks/dimensions";
import type { CardData, CardSide, LayerConfig } from "./types";
import type { Align, CardData, CardSide, LayerConfig } from "./types";
import { DeckStore } from "./hooks/deckStore";
import { processVariables } from "../utils/csv-loader";
import type { LayerInteractionHandlers } from "./hooks/useLayerInteraction";
@@ -31,10 +31,21 @@ export function CardLayer(props: CardLayerProps) {
) as string;
}
const getAlignStyle = (align?: "l" | "c" | "r") => {
if (align === "l") return "left";
if (align === "r") return "right";
return "center";
const getAlignStyle = (align?: Align) => {
const horizontal = align?.includes("l")
? "left"
: align?.includes("r")
? "right"
: "center";
const vertical = align?.includes("t")
? "flex-start"
: align?.includes("b")
? "flex-end"
: "center";
return {
"text-align": horizontal,
"justify-content": vertical,
} as JSX.CSSProperties;
};
const isLayerSelected = (layerIndex: number) =>
@@ -87,7 +98,7 @@ export function CardLayer(props: CardLayerProps) {
style={{
...getLayerStyle(layer, dimensions()),
"font-size": `${layer.fontSize || 3}mm`,
"text-align": getAlignStyle(layer.align),
...getAlignStyle(layer.align),
}}
innerHTML={renderLayerContent(
layer.template ?? props.cardData[layer.prop ?? ""],
+19
View File
@@ -50,6 +50,25 @@ describe("layersToConfigs", () => {
});
});
test("parses combined vertical+horizontal align", () => {
const layers = layersToConfigs([
{ prop: "a", pos: "1,1-2,2", align: "tl" },
{ prop: "b", pos: "1,1-2,2", align: "br" },
{ prop: "c", pos: "1,1-2,2", align: "tc" },
{ prop: "d", pos: "1,1-2,2", align: "bc" },
]);
expect(layers[0].align).toBe("tl");
expect(layers[1].align).toBe("br");
expect(layers[2].align).toBe("tc");
expect(layers[3].align).toBe("bc");
});
test("parses vertical+horizontal align in compact layers string", () => {
const layers = layersToConfigs("title:1,1-4,1tl body:1,2-5,8bc");
expect(layers[0].align).toBe("tl");
expect(layers[1].align).toBe("bc");
});
test("defaults placement when pos is missing or invalid", () => {
const layers = layersToConfigs([{ prop: "x" }, { prop: "y", pos: "bogus" }]);
expect(layers[0]).toMatchObject({ x1: 1, y1: 1, x2: 2, y2: 2 });
+2 -2
View File
@@ -1,4 +1,4 @@
import type { CardShape, LayerConfig } from "./types";
import type { Align, CardShape, LayerConfig } from "./types";
import { parseLayers } from "./hooks/layer-parser";
/**
@@ -27,7 +27,7 @@ export interface DeckLayerYaml {
font?: number;
fontSize?: number;
orientation?: "n" | "s" | "e" | "w";
align?: "l" | "c" | "r";
align?: Align;
visible?: boolean;
}
@@ -1,7 +1,7 @@
import { For, createSignal, onCleanup, onMount } from "solid-js";
import { createSortable, useDragDropContext } from "@thisbeyond/solid-dnd";
import type { DeckStore } from "../hooks/deckStore";
import type { LayerConfig } from "../types";
import type { Align, LayerConfig } from "../types";
import alignLeftIcon from "./icons/align-left.png";
import alignCenterIcon from "./icons/align-center.png";
import alignRightIcon from "./icons/align-right.png";
@@ -17,7 +17,7 @@ export interface LayerRowProps {
setOpenDropdown: (val: string | null) => void;
onUpdateOrientation: (o: "n" | "s" | "e" | "w") => void;
onUpdateFontSize: (fs?: number) => void;
onUpdateAlign: (a?: "l" | "c" | "r") => void;
onUpdateAlign: (a?: Align) => void;
onSelect: () => void;
onRemove: () => void;
}
@@ -29,11 +29,17 @@ const ORIENTATIONS = [
{ value: "w" as const, label: "← 西" },
];
const ALIGNS = [
{ value: "" as const, icon: alignCenterIcon },
{ value: "l" as const, icon: alignLeftIcon },
{ value: "c" as const, icon: alignCenterIcon },
{ value: "r" as const, icon: alignRightIcon },
const ALIGNS: { value: Align | ""; icon: string }[] = [
{ value: "", icon: alignCenterIcon },
{ value: "l", icon: alignLeftIcon },
{ value: "c", icon: alignCenterIcon },
{ value: "r", icon: alignRightIcon },
{ value: "tl", icon: "↖" },
{ value: "tc", icon: "↑" },
{ value: "tr", icon: "↗" },
{ value: "bl", icon: "↙" },
{ value: "bc", icon: "↓" },
{ value: "br", icon: "↘" },
];
const FONT_PRESETS = [3, 5, 8, 12] as const;
@@ -53,14 +59,28 @@ function orientChar(v: string) {
}
}
function alignSrc(v: string) {
function alignIcon(v: string): import("solid-js").JSX.Element {
switch (v) {
case "tl":
return "↖";
case "tc":
return "↑";
case "tr":
return "↗";
case "bl":
return "↙";
case "bc":
return "↓";
case "br":
return "↘";
case "l":
return alignLeftIcon;
return <img src={alignLeftIcon} alt="align" class="w-5 h-5 not-prose" />;
case "r":
return alignRightIcon;
return <img src={alignRightIcon} alt="align" class="w-5 h-5 not-prose" />;
default:
return alignCenterIcon;
return (
<img src={alignCenterIcon} alt="align" class="w-5 h-5 not-prose" />
);
}
}
@@ -149,13 +169,7 @@ export function LayerRow(props: LayerRowProps) {
</DropdownButton>
<DropdownButton
icon={
<img
src={alignSrc(props.layer.align || "")}
alt="align"
class="w-5 h-5 not-prose"
/>
}
icon={alignIcon(props.layer.align || "")}
visible={props.layer.visible}
open={props.openDropdown === `align-${props.index}`}
onToggle={() =>
@@ -173,7 +187,13 @@ export function LayerRow(props: LayerRowProps) {
onClick={() => props.onUpdateAlign(o.value || undefined)}
class="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-gray-100 cursor-pointer whitespace-nowrap"
>
<img src={o.icon} alt="" class="w-4 h-4 not-prose max-w-none" />
{o.icon.endsWith(".png") ? (
<img src={o.icon} alt="" class="w-4 h-4 not-prose max-w-none" />
) : (
<span class="w-4 h-4 flex items-center justify-center not-prose">
{o.icon}
</span>
)}
</button>
)}
</For>
+4 -3
View File
@@ -521,7 +521,7 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
return parts.join("");
};
/** Serialize the deck back to a ```yaml/tag codeblock (round-trips templates). */
/** Serialize the deck back to a yaml codeblock (round-trips templates). */
const generateYamlCode = () => {
const toYamlLayer = (l: LayerConfig) => {
const out: Record<string, unknown> = {};
@@ -560,8 +560,9 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
lineWidth: -1,
noRefs: true,
});
const fence = "```";
return `${fence}yaml/tag\n${yamlStr}${fence}`;
const fence = "```yaml role=tag";
const backtick = "`";
return `${fence}\n${yamlStr}${backtick.repeat(3)}`;
};
const copyCode = async (fallback?: (code: string) => void) => {
+5 -4
View File
@@ -4,15 +4,16 @@ import { CSV } from "../../utils/csv-loader";
/**
* 解析 layers 字符串
* 格式:body:1,7-5,8 title:1,1-4,1f6.6sl
* f[fontSize] 表示字体大小(可选),方向字母(可选),对齐字母 l/c/r(可选)
* f[fontSize] 表示字体大小(可选),方向字母(可选),
* 对齐字母(可选):水平 l/c/r,可加垂直前缀 t/b 组成 tl/tr/bl/br
*/
export function parseLayers(layersStr: string): Layer[] {
if (!layersStr) return [];
const layers: Layer[] = [];
// 匹配:prop:x1,y1-x2,y2[ffontSize][direction][align]
// 匹配:prop:x1,y1-x2,y2[ffontSize][direction][[t|b]align]
const regex =
/([^: ]+):(\d+),(\d+)-(\d+),(\d+)(?:f([\d.]+))?([nsew])?([lcr])?/g;
/([^: ]+):(\d+),(\d+)-(\d+),(\d+)(?:f([\d.]+))?([nsew])?([tb])?([lcr])?/g;
let match;
while ((match = regex.exec(layersStr)) !== null) {
@@ -24,7 +25,7 @@ export function parseLayers(layersStr: string): Layer[] {
y2: parseInt(match[5]),
fontSize: match[6] ? parseFloat(match[6]) : undefined,
orientation: match[7] as "n" | "s" | "e" | "w" | undefined,
align: match[8] as "l" | "c" | "r" | undefined,
align: [match[8], match[9]].filter(Boolean).join("") as Layer["align"],
});
}
+18 -2
View File
@@ -4,6 +4,22 @@ export interface CardData {
export type CardSide = "front" | "back";
/**
* Layer alignment: horizontal (l/c/r) optionally combined with vertical
* (t/b). "tl" = top-left, "br" = bottom-right, "tc" = top-center,
* "bc" = bottom-center, etc. Plain "l"/"c"/"r" means vertically centered.
*/
export type Align =
| "l"
| "c"
| "r"
| "tl"
| "tc"
| "tr"
| "bl"
| "bc"
| "br";
export type { CardShape } from "../../plotcutter/contour";
export interface Layer {
@@ -17,7 +33,7 @@ export interface Layer {
y2: number;
orientation?: "n" | "s" | "e" | "w";
fontSize?: number;
align?: "l" | "c" | "r";
align?: Align;
}
export interface LayerConfig {
@@ -32,7 +48,7 @@ export interface LayerConfig {
y2: number;
orientation?: "n" | "s" | "e" | "w";
fontSize?: number;
align?: "l" | "c" | "r";
align?: Align;
_key?: number;
}
+5 -5
View File
@@ -6,7 +6,7 @@ title: 卡牌组件
将 CSV 数据渲染为可打印的卡牌布局,支持自定义尺寸、网格、图层和双面排版。
**语法:** `:md-deck[./cards.csv]{选项}````yaml/tag 代码块
**语法:** `:md-deck[./cards.csv]{选项}`yaml 代码块
**基础卡牌:**
:md-deck[./spells.csv]{grid="3x3"}
@@ -16,12 +16,12 @@ title: 卡牌组件
CSV 包含数据字段列,通过图层定义控制各字段的位置、大小和对齐。
## 结构化配置 (yaml/tag)
## 结构化配置 (yaml 代码块)
```yaml/tag 代码块可以用 `body` 指定 CSV 路径,`data-config` 提供结构化配置,图层可以是字段(`prop`)或 markdown 模板(`template`,支持 `{{变量}}` 替换):
推荐 ```yaml role=tag 代码块`yaml` 语言可被语法高亮),用 `body` 指定 CSV 路径,`data-config` 提供结构化配置,图层可以是字段(`prop`)或 markdown 模板(`template`,支持 `{{变量}}` 替换):
````markdown
```yaml/tag
```yaml role=tag
tag: md-deck
body: ./cards.csv
data-config:
@@ -40,7 +40,7 @@ data-config:
```
````
`pos` 为网格位置 `x1,y1-x2,y2`1-based,与紧凑 layers 字符串同格式)。
`pos` 为网格位置 `x1,y1-x2,y2`1-based,与紧凑 layers 字符串同格式)。也支持旧写法 ```yaml/tag。
## 图层格式
+29 -6
View File
@@ -28,10 +28,7 @@ describe("code-block-yaml-tag", () => {
" size: 63x88",
" layers:",
" - prop: title",
" x1: 1",
" y1: 1",
" x2: 5",
" y2: 1",
" pos: 1,1-5,1",
" font: 12",
"```",
].join("\n"),
@@ -47,12 +44,38 @@ describe("code-block-yaml-tag", () => {
expect(config.layers).toHaveLength(1);
expect(config.layers[0]).toMatchObject({
prop: "title",
x1: 1,
y1: 1,
pos: "1,1-5,1",
font: 12,
});
});
test("supports yaml role=tag info string (highlight-friendly)", () => {
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"),
);
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");
});
test("does not swallow plain yaml code blocks", () => {
const token = ext.tokenizer("```yaml\nsize: 54x86\n```");
expect(token).toBeUndefined();
});
test("handles missing tag and body", () => {
const html = render("```yaml/tag\nclass: foo\n```");
expect(html).toContain("<tag-unknown class=\"foo\"></tag-unknown>");
+13 -18
View File
@@ -8,10 +8,15 @@ export default function markedCodeBlockYamlTag(): MarkedExtension {
name: "code-block-yaml-tag",
level: "block",
start(src: string) {
return src.match(/^```yaml\/tag\s*\n/m)?.index;
return (
src.match(/^```yaml\/tag\s*\n/m)?.index ??
src.match(/^```yaml\s+role=tag(?:\s|\n)/m)?.index
);
},
tokenizer(src: string) {
const rule = /^```yaml\/tag\s*\n([\s\S]*?)\n```/;
// 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 match = rule.exec(src);
if (match) {
const yamlContent = match[1]?.trim() || "";
@@ -33,21 +38,13 @@ export default function markedCodeBlockYamlTag(): MarkedExtension {
delete (rest as Record<string, unknown>).body;
}
// A structured `data-config` object is serialized to JSON and
// passed as a single attribute (e.g. md-deck layers/templates).
let configAttr = "";
if ("data-config" in rest) {
const rawConfig = rest["data-config"];
delete (rest as Record<string, unknown>)["data-config"];
if (rawConfig !== undefined && rawConfig !== null) {
const json = JSON.stringify(rawConfig).replace(/"/g, "&quot;");
configAttr = ` data-config="${json}"`;
}
}
// `data-*` props may hold structured YAML values, so they are
// serialized to JSON strings (e.g. md-deck layers/templates).
const propsStr = Object.entries(rest)
.map(([key, value]) => {
const strValue = String(value);
const strValue = key.startsWith("data-")
? JSON.stringify(value)
: String(value);
if (strValue.includes(" ") || strValue.includes('"')) {
return `${key}="${strValue.replace(/"/g, "&quot;")}"`;
}
@@ -60,15 +57,13 @@ export default function markedCodeBlockYamlTag(): MarkedExtension {
raw: match[0],
tagName,
props: propsStr,
config: configAttr,
content,
};
}
},
renderer(token: any) {
const propsAttr = token.props ? ` ${token.props}` : "";
const configAttr = token.config || "";
return `<${token.tagName}${propsAttr}${configAttr}>${token.content || ""}</${token.tagName}>\n`;
return `<${token.tagName}${propsAttr}>${token.content || ""}</${token.tagName}>\n`;
},
},
],