/** * 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-=`.", 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-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-commander", body: "command template CSV", attrs: ["placeholder", "class", "height"], summary: "Interactive command history / tracker panel.", details: "The body is a CSV of command templates (`command,parameter,label,description,insertedText`).", refKind: "csv", examples: [':md-commander[./commands.csv]{placeholder="输入命令…" height="400px"}'], }, { 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 = new Set( KNOWN_ELEMENTS.map((e) => e.name), ); const ELEMENT_BY_NAME: ReadonlyMap = 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 = 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 = 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: "csv role=declare", summary: "Reactive variable / tag-modifier definitions.", details: "Columns (any order): `tag`, `threshold`, `key`, `expr`. A row with an empty `tag` declares a variable; a row with a `#tag` applies a modifier when that tag is active.", examples: [ "```csv role=declare\ntag,threshold,key,expr\n,,$hp,$con*5+$mod_hp\n#warrior,2,$mod_hp,20\n```", ], }, { role: "file", info: " role=file", summary: "Stored as inline text content and stripped.", details: "Referenced by the derived inline id, e.g. `:md-embed[./text_]`.", 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 = new Set( ROLE_SPECS.map((r) => r.role), ); /** Expected file extensions per reference kind. */ export const REF_EXTENSIONS: Record, 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: "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); }