Add an md-print element that paginates markdown into A4 landscape sheets holding two A5 pages, each with configurable columns. The preview modal exports through the browser print dialog. Paragraphs, lists, and tables split at word, item, and row boundaries; headings are kept with following content.
322 lines
11 KiB
TypeScript
322 lines
11 KiB
TypeScript
/**
|
|
* Catalog of tttk syntax: custom elements, inline directives, and code-block
|
|
* roles.
|
|
*
|
|
* This is the single source of truth for validation. `docs/reference.md` is
|
|
* generated from it (see docs/check-plan.md §7.11); a drift test asserts this
|
|
* list matches the `customElement(...)` call sites.
|
|
*/
|
|
|
|
/** What a directive's body refers to, used for reference checks. */
|
|
export type RefKind = "csv" | "md" | "yarn" | "image" | "none";
|
|
|
|
export interface ElementSpec {
|
|
/** Registered custom element name, e.g. "md-dice". */
|
|
name: string;
|
|
/** Human description of the body. */
|
|
body: string;
|
|
/** Key attribute names. */
|
|
attrs: string[];
|
|
summary: string;
|
|
/** Authoring prose rendered under the summary. */
|
|
details?: string;
|
|
refKind: RefKind;
|
|
/** Canonical usage examples, rendered into the reference. */
|
|
examples: string[];
|
|
}
|
|
|
|
/** The registered `md-*` custom elements (mirrors `customElement(...)` calls). */
|
|
export const KNOWN_ELEMENTS: readonly ElementSpec[] = [
|
|
{
|
|
name: "md-dice",
|
|
body: "dice formula",
|
|
attrs: ["key"],
|
|
summary: "Clickable dice roller; `key` stores the result in the URL.",
|
|
details:
|
|
"Clicking the text rolls; clicking the 🎲 resets to the formula. With `key`, the result is stored in the URL as `?dice-<key>=<value>`.",
|
|
refKind: "none",
|
|
examples: [
|
|
':md-dice[1d20+5]{key="attack"}',
|
|
":md-dice[2d6+3]",
|
|
":md-dice[3d6kh1]",
|
|
],
|
|
},
|
|
{
|
|
name: "md-table",
|
|
body: "CSV path or inline id",
|
|
attrs: ["roll", "remix"],
|
|
summary: "Tab-separated view of a CSV with optional group tabs.",
|
|
details:
|
|
"`roll` adds a 🎲 random-tab button; `remix` makes `{{prop}}` references pull from a random row. A `group` column in the CSV produces group tabs automatically.",
|
|
refKind: "csv",
|
|
examples: [
|
|
":md-table[./npcs.csv]",
|
|
":md-table[./encounters.csv]{roll=true}",
|
|
":md-table[./quests.csv]{roll=true remix=true}",
|
|
],
|
|
},
|
|
{
|
|
name: "md-deck",
|
|
body: "CSV path",
|
|
attrs: ["size", "grid", "layers"],
|
|
summary: "Card layout from a CSV (legacy; prefer `yaml role=tag`).",
|
|
details:
|
|
"Prefer the `yaml role=tag` form — it is the only form that supports template layers, per-layer alignment, and card backs. The directive form is legacy and only supports the compact `layers` string.",
|
|
refKind: "csv",
|
|
examples: [
|
|
':md-deck[./cards.csv]{size="63x88" grid="5x5" layers="title:1,1-5,1f8 body:1,5-5,8f3"}',
|
|
],
|
|
},
|
|
{
|
|
name: "md-print",
|
|
body: "markdown path, optional #section",
|
|
attrs: [
|
|
"columns",
|
|
"margin",
|
|
"gap",
|
|
"font",
|
|
"title",
|
|
"pageNumbers",
|
|
"cutMarks",
|
|
],
|
|
summary:
|
|
"Paginates a markdown document into A4 landscape sheets for printing.",
|
|
details:
|
|
"Each sheet holds two A5 pages side by side, and each A5 page flows the markdown into `columns` columns. `margin` and `gap` are in millimetres, `font` is in points. `pageNumbers` adds an `n / total` footer and `cutMarks` draws the centre cut line. The preview modal exports through the browser's print dialog (choose A4 landscape, no margins, 100% scale).",
|
|
refKind: "md",
|
|
examples: [
|
|
':md-print[./rules.md]{columns="2" margin="10" gap="5" font="9"}',
|
|
"```yaml role=tag\ntag: md-print\nbody: ./rules.md\ncolumns: 2\nmargin: 10\ngap: 5\nfont: 9\ntitle: 规则书\n```",
|
|
],
|
|
},
|
|
{
|
|
name: "md-link",
|
|
body: "path, optional #section",
|
|
attrs: [],
|
|
summary: "Clickable link that expands the target article inline.",
|
|
refKind: "md",
|
|
examples: [":md-link[./rules.md]", ":md-link[./rules.md#combat]"],
|
|
},
|
|
{
|
|
name: "md-embed",
|
|
body: "path, optional #section",
|
|
attrs: ["headingBase"],
|
|
summary: "Renders the target article or section inline.",
|
|
details: "`headingBase` shifts the heading levels of the embedded content.",
|
|
refKind: "md",
|
|
examples: [":md-embed[./rules.md#combat]{headingBase=1}"],
|
|
},
|
|
{
|
|
name: "md-bg",
|
|
body: "image path or CSS color",
|
|
attrs: ["fit"],
|
|
summary: "Sets the surrounding article card background.",
|
|
details:
|
|
"`fit` is one of `cover` (default), `contain`, `fill`, `none`, `scale-down`.",
|
|
refKind: "image",
|
|
examples: [':md-bg[./images/dungeon-bg.jpg]{fit="cover"}', ":md-bg[#2a1a3a]"],
|
|
},
|
|
{
|
|
name: "md-border",
|
|
body: "color or border image",
|
|
attrs: ["width", "slice"],
|
|
summary: "Applies a border to the article card.",
|
|
details:
|
|
"Body is a CSS color or a border-image path. Class flags select sides (`t b l r all`), style (`solid dashed dotted double groove ridge inset outset none`), and image repeat (`stretch repeat round space`).",
|
|
refKind: "image",
|
|
examples: [
|
|
":md-border[crimson]{.b}",
|
|
':md-border[./frame.png]{width="2mm" slice="10" .all.round}',
|
|
],
|
|
},
|
|
{
|
|
name: "md-font",
|
|
body: "font family",
|
|
attrs: ["source", "weight", "color"],
|
|
summary: "Sets the article font.",
|
|
details: "`source` is `google`, `emfont`, or `local` (default).",
|
|
refKind: "none",
|
|
examples: [
|
|
':md-font[Noto Sans SC]{source="google" weight="700" color="#1a1a2e"}',
|
|
],
|
|
},
|
|
{
|
|
name: "md-pins",
|
|
body: "image path",
|
|
attrs: ["pins", "fixed", "labelStart"],
|
|
summary: "Numbered markers on an image.",
|
|
details:
|
|
"`pins` is a space-separated list of `label:x,y` (percent coordinates). `fixed` makes it read-only; otherwise click to add and click a pin to remove. `labelStart` sets the first label (letter or number).",
|
|
refKind: "image",
|
|
examples: [
|
|
':md-pins[./images/battle-map.png]{pins="A:25,50 B:75,30 C:50,80" fixed}',
|
|
':md-pins[./images/dungeon.png]{labelStart="1"}',
|
|
],
|
|
},
|
|
{
|
|
name: "md-yarn-spinner",
|
|
body: "Yarn Spinner file",
|
|
attrs: ["start"],
|
|
summary: "Runs a Yarn Spinner dialogue file.",
|
|
details: "`start` names the entry node (default `start`).",
|
|
refKind: "yarn",
|
|
examples: [':md-yarn-spinner[./story.yarn]{start="start"}'],
|
|
},
|
|
];
|
|
|
|
export const ELEMENT_NAMES: ReadonlySet<string> = new Set(
|
|
KNOWN_ELEMENTS.map((e) => e.name),
|
|
);
|
|
|
|
const ELEMENT_BY_NAME: ReadonlyMap<string, ElementSpec> = new Map(
|
|
KNOWN_ELEMENTS.map((e) => [e.name, e]),
|
|
);
|
|
|
|
export function elementFor(name: string): ElementSpec | undefined {
|
|
return ELEMENT_BY_NAME.get(name);
|
|
}
|
|
|
|
/** Elements whose body may also be a CSS color rather than a file path. */
|
|
export const COLOR_BODY_ELEMENTS: ReadonlySet<string> = new Set([
|
|
"md-bg",
|
|
"md-border",
|
|
]);
|
|
|
|
export interface InlineSpec {
|
|
/** Directive form, e.g. ":[name]". */
|
|
name: string;
|
|
body: string;
|
|
attrs: string[];
|
|
summary: string;
|
|
details?: string;
|
|
examples: string[];
|
|
}
|
|
|
|
/** Inline directives that are not custom elements. */
|
|
export const INLINE_SPECS: readonly InlineSpec[] = [
|
|
{
|
|
name: ":[name]",
|
|
body: "icon name",
|
|
attrs: ["label", "repeat", "join", "class"],
|
|
summary:
|
|
"Inserts an icon looked up under `assets/` (searching upward from the file).",
|
|
details:
|
|
"Default extension is `.png`; `.svg`, `.gif`, `.jpg`, `.jpeg`, and `.webp` are also supported. `label` overlays text, `repeat` duplicates N times, and `join` places a tag name between repeats.",
|
|
examples: [":[attack] :[shield.svg] :[potion]", ":[attack]{label=ATK repeat=3 join=br}"],
|
|
},
|
|
{
|
|
name: ":cmd[command]",
|
|
body: "journal command",
|
|
attrs: ["label"],
|
|
summary: "Renders a clickable span that dispatches a journal command.",
|
|
examples: [
|
|
":cmd[roll 1d20+5]{label=Roll initiative}",
|
|
":cmd[spark dungeon room-type]{label=Random room}",
|
|
],
|
|
},
|
|
];
|
|
|
|
export const KNOWN_INLINE: ReadonlySet<string> = new Set(["cmd"]);
|
|
|
|
export interface RoleSpec {
|
|
role: string;
|
|
/** Example info string, e.g. "csv role=spark-table". */
|
|
info: string;
|
|
summary: string;
|
|
/** Authoring prose rendered under the summary. */
|
|
details?: string;
|
|
/** Canonical fenced-block examples, rendered into the reference. */
|
|
examples: string[];
|
|
}
|
|
|
|
/** Code-block `role=` values the scanner understands. */
|
|
export const ROLE_SPECS: readonly RoleSpec[] = [
|
|
{
|
|
role: "spark-table",
|
|
info: "csv role=spark-table",
|
|
summary: "CSV (or markdown pipe table) rendered as `:md-table`.",
|
|
details:
|
|
"The first column header must be a dice formula; each row's first cell is the roll value (a number, or a range like `1-3`). Extra fence attributes are forwarded to the generated `:md-table`, e.g. ` ```csv role=spark-table roll=true `.",
|
|
examples: [
|
|
"```csv role=spark-table\nd6,结果\n1,遭遇强盗\n2,平安无事\n```",
|
|
"```markdown role=spark-table\n| d6 | 结果 |\n|----|------|\n| 1 | 遭遇强盗 |\n| 2 | 平安无事 |\n```",
|
|
],
|
|
},
|
|
{
|
|
role: "declare",
|
|
info: "yaml role=declare",
|
|
summary: "Reactive variable / tag-modifier definitions.",
|
|
details:
|
|
"YAML with two optional maps. `vars:` maps a `$name` to its expression. `tags:` maps a tag name to a map of thresholds to `$target` modifiers: at that tag count the target gains the value, so sibling thresholds stack. Tag names may be written with or without the leading `#`. YAML resolves bare numbers to numbers and everything else to strings; both become expression text.",
|
|
examples: [
|
|
"```yaml role=declare\nvars:\n $hp: $con*5 + $mod_hp\n $ac: 10 + $dex\ntags:\n warrior:\n 1:\n $mod_hp: 20\n 2:\n $mod_str: 1\n```",
|
|
],
|
|
},
|
|
{
|
|
role: "file",
|
|
info: "<lang> role=file",
|
|
summary: "Stored as inline text content and stripped.",
|
|
details:
|
|
"Referenced by the derived inline id, e.g. `:md-embed[./text_<hash>]`.",
|
|
examples: ["```text role=file\nStored as inline content.\n```"],
|
|
},
|
|
{
|
|
role: "tag",
|
|
info: "yaml role=tag",
|
|
summary: "YAML rendered as a custom element.",
|
|
details:
|
|
"`tag:` names the element, `body:` becomes its content, and any other key becomes an attribute. Fence-line attributes override the YAML body. See `yaml-tag.md`.",
|
|
examples: [
|
|
"```yaml role=tag\ntag: md-deck\nbody: ./cards.csv\nsize: 63x88\ngrid: 5x5\n```",
|
|
],
|
|
},
|
|
];
|
|
|
|
export const KNOWN_ROLES: ReadonlySet<string> = new Set(
|
|
ROLE_SPECS.map((r) => r.role),
|
|
);
|
|
|
|
/** Expected file extensions per reference kind. */
|
|
export const REF_EXTENSIONS: Record<Exclude<RefKind, "none">, readonly string[]> =
|
|
{
|
|
csv: [".csv"],
|
|
md: [".md"],
|
|
yarn: [".yarn"],
|
|
image: [".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"],
|
|
};
|
|
|
|
export interface DiceNotation {
|
|
notation: string;
|
|
meaning: string;
|
|
}
|
|
|
|
/** Dice notation accepted by `:md-dice`, spark-table headers, and `/roll`. */
|
|
export const DICE_NOTATION: readonly DiceNotation[] = [
|
|
{ notation: "2d6", meaning: "two six-sided dice" },
|
|
{ notation: "1d20+5", meaning: "d20 plus modifier" },
|
|
{ notation: "d20", meaning: "one twenty-sided die (count optional)" },
|
|
{ notation: "3d6kh1", meaning: "keep highest 1" },
|
|
{ notation: "3d6dl2-1", meaning: "drop lowest 2, minus 1" },
|
|
{ notation: "{d4, d8}", meaning: "choose one of the listed dice" },
|
|
];
|
|
|
|
/** Whether a directive body is a CSS color rather than a path. */
|
|
export function isColorValue(value: string): boolean {
|
|
const v = value.trim();
|
|
return (
|
|
/^#([0-9a-f]{3,8})$/i.test(v) ||
|
|
/^(rgb|rgba|hsl|hsla|var)\(/i.test(v) ||
|
|
v === "transparent" ||
|
|
v === "currentColor"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Whether a color-capable element body looks like a color rather than a path.
|
|
* A single alphabetic word is treated as a CSS named color (e.g. "crimson");
|
|
* image paths contain a separator or an extension.
|
|
*/
|
|
export function looksLikeColor(value: string): boolean {
|
|
const v = value.trim();
|
|
return isColorValue(v) || /^[A-Za-z]+$/.test(v);
|
|
} |