feat(md-deck): support structured data-config with template layers

Allow md-deck layers to render markdown templates via {{var}}
substitution instead of only CSV props. Add config.ts to normalize
a JSON data-config attribute (size, grid, shape, layers) emitted by
the yaml code-block tag, taking precedence over legacy string props.
This commit is contained in:
2026-09-02 18:15:51 +08:00
parent 023d3a0cb9
commit 16dfc8a88c
12 changed files with 450 additions and 41 deletions
+32
View File
@@ -423,6 +423,38 @@ layers="字段:起始行,起始列 - 结束列,字体大小"
示例:`title:1,1-5,1f8` 表示 title 字段从第 1 行开始,占据 1-5 列,8mm字体。
**结构化配置(yaml/tag 代码块):**
可以使用 ```yaml/tag 代码块,通过 `body` 指定 CSV 路径,`data-config` 提供结构化配置(图层可以是字段或模板):
````markdown
```yaml/tag
tag: md-deck
body: ./cards.csv
data-config:
size: 63x88
grid: 5x5
layers:
- prop: title
x1: 1
y1: 1
x2: 5
y2: 1
font: 12
- template: |
**{{name}}** — {{type}}
{{description}}
x1: 1
y1: 3
x2: 5
y2: 8
font: 3
align: l
```
````
每个图层通过 `prop`(读取 CSV 字段)或 `template`(渲染带 `{{变量}}` 替换的 markdown 模板)定义,支持 `x1/y1/x2/y2`、`font`、`orientation`、`align`。`back_layers` 用于背面图层。
### 🧶 叙事线组件 (md-yarn-spinner)
用于展示分支叙事结构,支持 Yarn Spinner 格式文件。
+3 -1
View File
@@ -89,7 +89,9 @@ export function CardLayer(props: CardLayerProps) {
"font-size": `${layer.fontSize || 3}mm`,
"text-align": getAlignStyle(layer.align),
}}
innerHTML={renderLayerContent(props.cardData[layer.prop])}
innerHTML={renderLayerContent(
layer.template ?? props.cardData[layer.prop ?? ""],
)}
onClick={(e) => handleLayerClick(index(), e)}
/>
<Show when={isSelected() && isEditing()}>
+106
View File
@@ -0,0 +1,106 @@
import { layersToConfigs, normalizeDeckConfig } from "./config";
describe("layersToConfigs", () => {
test("parses compact string format (legacy)", () => {
const layers = layersToConfigs("title:1,1-5,1f8 body:1,5-8,8f3");
expect(layers).toHaveLength(2);
expect(layers[0]).toMatchObject({
prop: "title",
visible: true,
x1: 1,
y1: 1,
x2: 5,
y2: 1,
fontSize: 8,
});
expect(layers[1].prop).toBe("body");
});
test("parses structured list with prop layers", () => {
const layers = layersToConfigs([
{ prop: "name", x1: 1, y1: 1, x2: 5, y2: 2, font: 12 },
]);
expect(layers).toHaveLength(1);
expect(layers[0]).toMatchObject({
prop: "name",
template: undefined,
visible: true,
x1: 1,
y1: 1,
x2: 5,
y2: 2,
fontSize: 12,
});
});
test("parses structured list with template layers", () => {
const layers = layersToConfigs([
{ template: "**{{name}}**", x1: 1, y1: 3, x2: 5, y2: 8, align: "l" },
]);
expect(layers).toHaveLength(1);
expect(layers[0]).toMatchObject({
prop: undefined,
template: "**{{name}}**",
visible: true,
x1: 1,
y1: 3,
x2: 5,
y2: 8,
align: "l",
});
});
test("returns [] for absent/empty input", () => {
expect(layersToConfigs()).toEqual([]);
expect(layersToConfigs("")).toEqual([]);
expect(layersToConfigs([])).toEqual([]);
});
});
describe("normalizeDeckConfig", () => {
test("parses string dimensions", () => {
const cfg = normalizeDeckConfig({ size: "54x86", grid: "5x8" });
expect(cfg.sizeW).toBe(54);
expect(cfg.sizeH).toBe(86);
expect(cfg.gridW).toBe(5);
expect(cfg.gridH).toBe(8);
});
test("parses array dimensions", () => {
const cfg = normalizeDeckConfig({ size: [63, 88] });
expect(cfg.sizeW).toBe(63);
expect(cfg.sizeH).toBe(88);
});
test("coerces numeric strings for bleed/padding", () => {
const cfg = normalizeDeckConfig({ bleed: "2", padding: "3" });
expect(cfg.bleed).toBe(2);
expect(cfg.padding).toBe(3);
});
test("normalizes layers and back_layers", () => {
const cfg = normalizeDeckConfig({
layers: [
{ prop: "name", x1: 1, y1: 1, x2: 5, y2: 1 },
{ template: "{{body}}", x1: 1, y1: 2, x2: 5, y2: 8 },
],
back_layers: "logo:1,1-2,2",
});
expect(cfg.frontLayers).toHaveLength(2);
expect(cfg.frontLayers[1].template).toBe("{{body}}");
expect(cfg.backLayers).toHaveLength(1);
expect(cfg.backLayers[0].prop).toBe("logo");
});
test("missing fields stay undefined", () => {
const cfg = normalizeDeckConfig({});
expect(cfg.sizeW).toBeUndefined();
expect(cfg.sizeH).toBeUndefined();
expect(cfg.gridW).toBeUndefined();
expect(cfg.gridH).toBeUndefined();
expect(cfg.bleed).toBeUndefined();
expect(cfg.padding).toBeUndefined();
expect(cfg.frontLayers).toEqual([]);
expect(cfg.backLayers).toEqual([]);
});
});
+134
View File
@@ -0,0 +1,134 @@
import type { CardShape, LayerConfig } from "./types";
import { parseLayers } from "./hooks/layer-parser";
/**
* YAML/JSON shape of an `md-deck` `data-config`.
*
* Mirrors the frontmatter `deck:` block and the `:md-deck` directive attrs,
* but allows `layers`/`back_layers` as structured lists where each layer is
* either a CSV `prop` or a markdown `template`.
*/
export interface DeckConfigYaml {
size?: string | [number, number];
grid?: string | [number, number];
bleed?: number | string;
padding?: number | string;
shape?: CardShape;
fixed?: boolean;
layers?: string | DeckLayerYaml[];
back_layers?: string | DeckLayerYaml[];
}
export interface DeckLayerYaml {
prop?: string;
template?: string;
x1?: number;
y1?: number;
x2?: number;
y2?: number;
font?: number;
fontSize?: number;
orientation?: "n" | "s" | "e" | "w";
align?: "l" | "c" | "r";
visible?: boolean;
}
export interface NormalizedDeckConfig {
sizeW?: number;
sizeH?: number;
gridW?: number;
gridH?: number;
bleed?: number;
padding?: number;
shape?: CardShape;
fixed?: boolean;
frontLayers: LayerConfig[];
backLayers: LayerConfig[];
}
/** Parse a "54x86" string or [54, 86] array into [w, h]. */
function parseDimension(
v?: string | [number, number],
): [number, number] | undefined {
if (!v) return undefined;
if (Array.isArray(v)) {
const [w, h] = v;
if (typeof w === "number" && typeof h === "number") return [w, h];
return undefined;
}
const parts = String(v)
.toLowerCase()
.split("x")
.map((n) => Number(n.trim()));
if (
parts.length === 2 &&
Number.isFinite(parts[0]) &&
Number.isFinite(parts[1])
) {
return [parts[0], parts[1]];
}
return undefined;
}
/** Map a structured YAML layer to a LayerConfig. */
function layerYamlToConfig(l: DeckLayerYaml): LayerConfig {
return {
prop: l.prop,
template: l.template,
visible: l.visible ?? true,
x1: l.x1 ?? 1,
y1: l.y1 ?? 1,
x2: l.x2 ?? 2,
y2: l.y2 ?? 2,
orientation: l.orientation,
fontSize: l.fontSize ?? l.font,
align: l.align,
};
}
/**
* Normalize a `layers`/`back_layers` value (compact string or structured list)
* into LayerConfig[]. Empty/absent → [].
*/
export function layersToConfigs(
layers?: string | DeckLayerYaml[],
): LayerConfig[] {
if (!layers) return [];
if (typeof layers === "string") {
return parseLayers(layers).map((l) => ({
prop: l.prop,
visible: true,
x1: l.x1,
y1: l.y1,
x2: l.x2,
y2: l.y2,
orientation: l.orientation,
fontSize: l.fontSize,
align: l.align,
}));
}
return layers.map(layerYamlToConfig);
}
/**
* Normalize a parsed `data-config` object into concrete numeric config +
* ready-to-use layer lists. Missing fields are left undefined so callers can
* apply defaults.
*/
export function normalizeDeckConfig(cfg: DeckConfigYaml): NormalizedDeckConfig {
const size = parseDimension(cfg.size);
const grid = parseDimension(cfg.grid);
return {
sizeW: size?.[0],
sizeH: size?.[1],
gridW: grid?.[0],
gridH: grid?.[1],
bleed: typeof cfg.bleed === "string" ? Number(cfg.bleed) : cfg.bleed,
padding:
typeof cfg.padding === "string" ? Number(cfg.padding) : cfg.padding,
shape: cfg.shape,
fixed: cfg.fixed,
frontLayers: layersToConfigs(cfg.layers),
backLayers: layersToConfigs(cfg.back_layers),
};
}
@@ -120,7 +120,7 @@ export function LayerRow(props: LayerRowProps) {
class="text-sm flex-1 truncate cursor-pointer hover:text-blue-600 select-none"
onClick={props.onSelect}
>
{props.layer.prop}
{props.layer.prop || (props.layer.template ? "(模板)" : "")}
</span>
<DropdownButton
+7 -11
View File
@@ -1,7 +1,7 @@
import { createStore } from "solid-js/store";
import { calculateDimensions } from "./dimensions";
import { loadCSV, CSV } from "../../utils/csv-loader";
import { formatLayers, initLayerConfigsForSide } from "./layer-parser";
import { formatLayers } from "./layer-parser";
import * as layerCrud from "./layer-crud";
import type {
CardData,
@@ -138,8 +138,8 @@ export interface DeckActions {
loadCardsFromPath: (
path: string,
rawSrc: string,
layersStr?: string,
backLayersStr?: string,
frontLayers?: LayerConfig[],
backLayers?: LayerConfig[],
) => Promise<void>;
setError: (error: string | null) => void;
clearError: () => void;
@@ -442,8 +442,8 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
const loadCardsFromPath = async (
path: string,
rawSrc: string,
layersStr: string = "",
backLayersStr: string = "",
frontLayers: LayerConfig[] = [],
backLayers: LayerConfig[] = [],
) => {
if (!path) {
setState({ error: "未指定 CSV 文件路径" });
@@ -466,12 +466,8 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
setState({
cards: data,
activeTab: 0,
frontLayerConfigs: layerCrud.withKeys(
initLayerConfigsForSide(data, layersStr),
),
backLayerConfigs: layerCrud.withKeys(
initLayerConfigsForSide(data, backLayersStr),
),
frontLayerConfigs: layerCrud.withKeys(frontLayers),
backLayerConfigs: layerCrud.withKeys(backLayers),
isLoading: false,
});
updateDimensions();
+3 -1
View File
@@ -35,8 +35,10 @@ export function parseLayers(layersStr: string): Layer[] {
* 格式化 layers 为字符串
*/
export function formatLayers(layers: LayerConfig[]): string {
// Template-only layers have no prop and can't be represented in the
// compact string format, so they are skipped here.
return layers
.filter((l) => l.visible)
.filter((l) => l.visible && l.prop)
.map((l) => {
let str = `${l.prop}:${l.x1},${l.y1}-${l.x2},${l.y2}`;
if (l.fontSize) {
+46 -19
View File
@@ -2,9 +2,10 @@ import { customElement, noShadowDOM } from "solid-element";
import { Show, onCleanup } from "solid-js";
import { resolvePath } from "../utils/path";
import { v4 as uuidv4 } from "uuid";
import { createDeckStore } from "./hooks/deckStore";
import { createDeckStore, DECK_DEFAULTS } from "./hooks/deckStore";
import { registerDeck, unregisterDeck } from "./hooks/deck-registry";
import type { CardShape } from "./types";
import type { CardShape, LayerConfig } from "./types";
import { normalizeDeckConfig, layersToConfigs } from "./config";
import { DeckHeader } from "./DeckHeader";
import { CardList } from "./CardList";
import { DeckContent } from "./DeckContent";
@@ -68,49 +69,75 @@ customElement<DeckProps>(
const deckId = `deck-${uuidv4()}`;
registerDeck(deckId, store, resolvedSrc, csvPath);
// 读取 data-configyaml/tag 代码块方式):结构化配置优先
let config:
| ReturnType<typeof normalizeDeckConfig>
| undefined;
const dataConfigAttr = element?.getAttribute("data-config");
if (dataConfigAttr) {
try {
config = normalizeDeckConfig(JSON.parse(dataConfigAttr));
} catch (e) {
console.error("Invalid data-config on md-deck:", e);
}
}
// 解析 size 属性(支持旧格式 "54x86" 和新格式)
if (props.size && props.size.includes("x")) {
if (config?.sizeW !== undefined && config.sizeH !== undefined) {
store.actions.setSizeW(config.sizeW);
store.actions.setSizeH(config.sizeH);
} else if (props.size && props.size.includes("x")) {
const [w, h] = props.size.split("x").map(Number);
store.actions.setSizeW(w);
store.actions.setSizeH(h);
} else {
store.actions.setSizeW(props.sizeW ?? 54);
store.actions.setSizeH(props.sizeH ?? 86);
store.actions.setSizeW(props.sizeW ?? DECK_DEFAULTS.SIZE_W);
store.actions.setSizeH(props.sizeH ?? DECK_DEFAULTS.SIZE_H);
}
// 解析 grid 属性(支持旧格式 "5x8" 和新格式)
if (props.grid && props.grid.includes("x")) {
if (config?.gridW !== undefined && config.gridH !== undefined) {
store.actions.setGridW(config.gridW);
store.actions.setGridH(config.gridH);
} else if (props.grid && props.grid.includes("x")) {
const [w, h] = props.grid.split("x").map(Number);
store.actions.setGridW(w);
store.actions.setGridH(h);
} else {
store.actions.setGridW(props.gridW ?? 5);
store.actions.setGridH(props.gridH ?? 8);
store.actions.setGridW(props.gridW ?? DECK_DEFAULTS.GRID_W);
store.actions.setGridH(props.gridH ?? DECK_DEFAULTS.GRID_H);
}
// 解析 bleed 和 padding(支持旧字符串格式和新数字格式)
if (typeof props.bleed === "string") {
if (config?.bleed !== undefined) {
store.actions.setBleed(config.bleed);
} else if (typeof props.bleed === "string") {
store.actions.setBleed(Number(props.bleed));
} else {
store.actions.setBleed(props.bleed ?? 1);
store.actions.setBleed(props.bleed ?? DECK_DEFAULTS.BLEED);
}
if (typeof props.padding === "string") {
if (config?.padding !== undefined) {
store.actions.setPadding(config.padding);
} else if (typeof props.padding === "string") {
store.actions.setPadding(Number(props.padding));
} else {
store.actions.setPadding(props.padding ?? 2);
store.actions.setPadding(props.padding ?? DECK_DEFAULTS.PADDING);
}
// 设置形状
store.actions.setShape(props.shape ?? "rectangle");
store.actions.setShape(config?.shape ?? props.shape ?? "rectangle");
// 确定前后图层(data-config 优先,回退旧 layers 字符串)
const frontLayers: LayerConfig[] = config
? config.frontLayers
: layersToConfigs((props.layers as string) || "");
const backLayers: LayerConfig[] = config
? config.backLayers
: layersToConfigs((props.backLayers as string) || "");
// 加载 CSV 数据
store.actions.loadCardsFromPath(
resolvedSrc,
csvPath,
(props.layers as string) || "",
(props.backLayers as string) || "",
);
store.actions.loadCardsFromPath(resolvedSrc, csvPath, frontLayers, backLayers);
// 清理函数
onCleanup(() => {
+12 -6
View File
@@ -7,7 +7,10 @@ export type CardSide = "front" | "back";
export type { CardShape } from "../../plotcutter/contour";
export interface Layer {
prop: string;
/** CSV column the layer reads, when it renders a prop value. */
prop?: string;
/** Markdown template (with {{var}} substitution) when the layer renders a template. */
template?: string;
x1: number;
y1: number;
x2: number;
@@ -18,12 +21,15 @@ export interface Layer {
}
export interface LayerConfig {
prop: string;
/** CSV column the layer reads, when it renders a prop value. */
prop?: string;
/** Markdown template (with {{var}} substitution) when the layer renders a template. */
template?: string;
visible: boolean;
x1: number;
y1: number;
x2: number;
y2: number;
x1?: number;
y1?: number;
x2?: number;
y2?: number;
orientation?: "n" | "s" | "e" | "w";
fontSize?: number;
align?: "l" | "c" | "r";
+31 -1
View File
@@ -6,7 +6,7 @@ title: 卡牌组件
将 CSV 数据渲染为可打印的卡牌布局,支持自定义尺寸、网格、图层和双面排版。
**语法:** `:md-deck[./cards.csv]{选项}`
**语法:** `:md-deck[./cards.csv]{选项}` 或 ```yaml/tag 代码块
**基础卡牌:**
:md-deck[./spells.csv]{grid="3x3"}
@@ -16,6 +16,36 @@ title: 卡牌组件
CSV 包含数据字段列,通过图层定义控制各字段的位置、大小和对齐。
## 结构化配置 (yaml/tag)
```yaml/tag 代码块可以用 `body` 指定 CSV 路径,`data-config` 提供结构化配置,图层可以是字段(`prop`)或 markdown 模板(`template`,支持 `{{变量}}` 替换):
````markdown
```yaml/tag
tag: md-deck
body: ./cards.csv
data-config:
size: 63x88
grid: 5x5
layers:
- prop: title
x1: 1
y1: 1
x2: 5
y2: 1
font: 12
- template: |
**{{name}}** — {{type}}
{{description}}
x1: 1
y1: 3
x2: 5
y2: 8
font: 3
align: l
```
````
## 图层格式
`layers` 属性格式为 `字段:起始行,起始列-结束列,字号`,多个图层用空格分隔:
+60
View File
@@ -0,0 +1,60 @@
import markedCodeBlockYamlTag from "./code-block-yaml-tag";
const ext = markedCodeBlockYamlTag().extensions?.[0] as unknown as {
tokenizer: (src: string) => any;
renderer: (token: any) => string;
};
function render(src: string): string {
const token = ext.tokenizer(src);
return ext.renderer(token);
}
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```",
);
expect(html).toContain("<md-deck size=\"54x86\">./cards.csv</md-deck>");
});
test("serializes data-config to a JSON attribute", () => {
const html = render(
[
"```yaml/tag",
"tag: md-deck",
"body: ./cards.csv",
"data-config:",
" size: 63x88",
" layers:",
" - prop: title",
" x1: 1",
" y1: 1",
" x2: 5",
" y2: 1",
" font: 12",
"```",
].join("\n"),
);
expect(html).toContain("<md-deck data-config=");
expect(html).toContain("./cards.csv</md-deck>");
// data-config must be valid JSON with escaped quotes for the attribute
const m = html.match(/data-config="([^"]*)"/);
expect(m).not.toBeNull();
const decoded = (m![1] || "").replace(/&quot;/g, '"');
const config = JSON.parse(decoded);
expect(config.size).toBe("63x88");
expect(config.layers).toHaveLength(1);
expect(config.layers[0]).toMatchObject({
prop: "title",
x1: 1,
y1: 1,
font: 12,
});
});
test("handles missing tag and body", () => {
const html = render("```yaml/tag\nclass: foo\n```");
expect(html).toContain("<tag-unknown class=\"foo\"></tag-unknown>");
});
});
+15 -1
View File
@@ -33,6 +33,18 @@ 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}"`;
}
}
const propsStr = Object.entries(rest)
.map(([key, value]) => {
const strValue = String(value);
@@ -48,13 +60,15 @@ export default function markedCodeBlockYamlTag(): MarkedExtension {
raw: match[0],
tagName,
props: propsStr,
config: configAttr,
content,
};
}
},
renderer(token: any) {
const propsAttr = token.props ? ` ${token.props}` : "";
return `<${token.tagName}${propsAttr}>${token.content || ""}</${token.tagName}>\n`;
const configAttr = token.config || "";
return `<${token.tagName}${propsAttr}${configAttr}>${token.content || ""}</${token.tagName}>\n`;
},
},
],