Enrich known.ts with element/role details, inline specs, and dice notation, and render them from reference.ts. Add gen:skill-reference and ~ expansion in --out so the skill copy can be regenerated. The skill's reference.md is now generated; its extra authoring content moved to layout.md, yaml-tag.md, csv.md, and markdown.md.
309 lines
11 KiB
TypeScript
309 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-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<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: "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: "<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: "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);
|
|
} |