feat(cli): Add tttk check validator

Statically validate a directory of tttk documents and report structured diagnostics with file/line positions, exit codes, and --json output. Checks cover code-block roles, unresolved content references, tag blocks, element names, links, anchors, and assets. Runs over the same content registry as the app.
This commit is contained in:
2026-09-12 16:38:21 +08:00
parent d1384c2ccc
commit 9def4d17aa
18 changed files with 1638 additions and 1 deletions
+171
View File
@@ -0,0 +1,171 @@
/**
* 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
* intended to be 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;
refKind: RefKind;
}
/** 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.",
refKind: "none",
},
{
name: "md-table",
body: "CSV path or inline id",
attrs: ["roll", "remix"],
summary: "Tab-separated view of a CSV with optional group tabs.",
refKind: "csv",
},
{
name: "md-deck",
body: "CSV path",
attrs: ["size", "grid", "layers"],
summary: "Card layout from a CSV (legacy; prefer `yaml role=tag`).",
refKind: "csv",
},
{
name: "md-link",
body: "path, optional #section",
attrs: [],
summary: "Clickable link that expands the target article inline.",
refKind: "md",
},
{
name: "md-embed",
body: "path, optional #section",
attrs: ["headingBase"],
summary: "Renders the target article or section inline.",
refKind: "md",
},
{
name: "md-bg",
body: "image path or CSS color",
attrs: ["fit"],
summary: "Sets the surrounding article card background.",
refKind: "image",
},
{
name: "md-border",
body: "color or border image",
attrs: ["width", "slice"],
summary: "Applies a border to the article card.",
refKind: "image",
},
{
name: "md-font",
body: "font family",
attrs: ["source", "weight", "color"],
summary: "Sets the article font.",
refKind: "none",
},
{
name: "md-pins",
body: "image path",
attrs: ["pins", "fixed", "labelStart"],
summary: "Numbered markers on an image.",
refKind: "image",
},
{
name: "md-commander",
body: "command template CSV",
attrs: ["placeholder", "class", "height"],
summary: "Interactive command history / tracker panel.",
refKind: "csv",
},
{
name: "md-yarn-spinner",
body: "Yarn Spinner file",
attrs: ["start"],
summary: "Runs a Yarn Spinner dialogue file.",
refKind: "yarn",
},
];
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 RoleSpec {
role: string;
summary: string;
}
/** Code-block `role=` values the scanner understands. */
export const ROLE_SPECS: readonly RoleSpec[] = [
{ role: "spark-table", summary: "CSV (or markdown pipe table) rendered as `:md-table`." },
{ role: "declare", summary: "Reactive variable / tag-modifier definitions." },
{ role: "file", summary: "Stored as inline text content and stripped." },
{ role: "tag", summary: "YAML rendered as a custom element." },
];
export const KNOWN_ROLES: ReadonlySet<string> = new Set(
ROLE_SPECS.map((r) => r.role),
);
/** Inline directives that are not custom elements. */
export const KNOWN_INLINE: ReadonlySet<string> = new Set(["cmd"]);
/** 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"],
};
/** 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);
}