feat(cli): Complete the check rule catalog

Add D002 (malformed attribute blocks), R004 (skipped declare rows), and V001-V005 (CSV data files). CSV checks run once per referenced file and also cover inline spark-table bodies.

Generate docs/reference.md from the known.ts catalog via npm run gen:reference, and add drift guards asserting the catalog matches the customElement call sites and the committed reference.
This commit is contained in:
2026-09-12 17:01:49 +08:00
parent ca6d8d1d14
commit 71542e7453
17 changed files with 1167 additions and 16 deletions
+1
View File
@@ -17,6 +17,7 @@ npx tttk check ./content
| 文档 | 说明 |
|------|------|
| [📖 CLI 使用说明](./docs/cli.md) | CLI 安装、命令和用法 |
| [🔤 语法参考](./docs/reference.md) | 自定义元素、代码块角色与属性目录(由 `known.ts` 生成) |
| [📦 npm](https://www.npmjs.com/package/tttk) | `npx tttk serve ./content` |
| [🛠️ 开发指南](./docs/development.md) | 项目结构、开发规范和构建 |
| [📝 Markdown 编写说明](./docs/markdown.md) | Markdown 语法和组件用法 |
+10 -6
View File
@@ -1,15 +1,19 @@
# `tttk check` — implementation plan (validator-first)
Status: **implemented (partial).** Supersedes `docs/compile-plan.md`.
Status: **implemented.** Supersedes `docs/compile-plan.md`.
Landed: `S001`/`S002`, `D001`, `R001`/`R002`/`R003`/`R005`/`R006`,
`C001`–`C004`, `L001`/`L002`, `A001`/`A002`, with tests. The `check` command,
Landed: `S001`/`S002`, `D001`/`D002`, `R001`–`R006`, `C001`–`C004`,
`L001`/`L002`, `A001`/`A002`, `V001`–`V005`, with tests. The `check` command,
`--json`/`--strict`/`--quiet`, and exit codes are live; the markdown module is
the browser-safe one described in §4, and `readContentIndex` backs the walk.
The reference generator (§7.11) and both drift guards (§7.12) are in place:
`docs/reference.md` is generated from `known.ts` via `npm run gen:reference`,
and `known.test.ts` asserts the catalog matches the `customElement` call sites
and the committed reference.
Not yet implemented: CSV data checks (`V001`–`V005`), `D002`, `R004`, and the
reference generator + drift guard (§7.11–§7.12). The rule catalog in §5, the
CLI surface in §6, and the resolved decisions in §10 remain the specification.
Not implemented: `D003` (P2 — the legacy `:md-deck` directive is not flagged).
The rule catalog in §5, the CLI surface in §6, and the resolved decisions in §10
remain the specification.
## 1. Goal
+8 -1
View File
@@ -112,9 +112,11 @@ content/adventures/hollow-mill/adventure.md
| `TTTK-S001` | error | Markdown 解析失败 |
| `TTTK-S002` | error | 容器指令 `:::` 未闭合 |
| `TTTK-D001` | error | 未知的 `:md-*` 元素名 |
| `TTTK-D002` | error | 属性块畸形(例如未加引号的值中含空格) |
| `TTTK-R001` | error | 未知的代码块 `role=`(块内容会被静默丢弃) |
| `TTTK-R002` | warning | `role=spark-table` 首列表头不是骰点公式 |
| `TTTK-R003` | error | `role=declare` 解析失败(缺少 `key`/`expr` 等) |
| `TTTK-R004` | warning | `role=declare` 中 `key`/`expr` 为空的行被跳过 |
| `TTTK-R005` | error | `role=tag` 的 YAML 无法解析 |
| `TTTK-R006` | error | `role=tag` 缺少 `tag:` 或元素未知 |
| `TTTK-C001` | error | 内容引用无法解析(文件不存在,或内联 id 未定义) |
@@ -125,8 +127,13 @@ content/adventures/hollow-mill/adventure.md
| `TTTK-L002` | warning | `#锚点` 未匹配到任何标题 |
| `TTTK-A001` | warning | 图标在 `assets/` 中不存在 |
| `TTTK-A002` | warning | 图片不存在 |
| `TTTK-V001` | error | CSV 解析失败(引号未闭合等) |
| `TTTK-V002` | warning | 行的列数与表头不一致 |
| `TTTK-V003` | warning | `label` 值重复 |
| `TTTK-V004` | warning | `body` 中的 `{{prop}}` 不是列名或 front-matter 键 |
| `TTTK-V005` | warning | spark-table 行的骰点值不是数字或范围 |
**尚未实现的校验:** CSV 数据校验(解析错误、列数不一致、`label` 重复、`{{prop}}` 引用无效列)、畸形属性块、`role=declare` 被跳过的行。
完整的语法目录(元素、属性、代码块角色)由 `src/cli/validate/known.ts` 生成到 `docs/reference.md`,可用 `npm run gen:reference` 重新生成。
**实现说明:**
+286
View File
@@ -0,0 +1,286 @@
# tttk syntax reference
Generated from `src/cli/validate/known.ts` by `npm run gen:reference`.
Do not edit by hand.
Everything not listed here is standard GFM markdown. Element names are
prefixed `md-` and must match exactly.
## Directive syntax
```text
:name[content]{key="value" .class #id}
```
- `[content]` is the directive body — a path, dice formula, color, etc.
- `{key="value"}` sets attributes; `{.class}` and `{#id}` are shorthands.
- Container directives use `::: name` … `:::`; nest with `::::`, `:::::`.
## Custom elements
| Directive | Body | Key attributes |
|---|---|---|
| `:md-dice` | dice formula | `key` |
| `:md-table` | CSV path or inline id | `roll`, `remix` |
| `:md-deck` | CSV path | `size`, `grid`, `layers` |
| `:md-link` | path, optional #section | — |
| `:md-embed` | path, optional #section | `headingBase` |
| `:md-bg` | image path or CSS color | `fit` |
| `:md-border` | color or border image | `width`, `slice` |
| `:md-font` | font family | `source`, `weight`, `color` |
| `:md-pins` | image path | `pins`, `fixed`, `labelStart` |
| `:md-commander` | command template CSV | `placeholder`, `class`, `height` |
| `:md-yarn-spinner` | Yarn Spinner file | `start` |
### `:md-dice`
Clickable dice roller; `key` stores the result in the URL.
- Body: dice formula
- Attributes: `key`
```markdown
:md-dice[1d20+5]{key="attack"}
```
```markdown
:md-dice[2d6+3]
```
```markdown
:md-dice[3d6kh1]
```
### `:md-table`
Tab-separated view of a CSV with optional group tabs.
- Body: CSV path or inline id
- Attributes: `roll`, `remix`
```markdown
:md-table[./npcs.csv]
```
```markdown
:md-table[./encounters.csv]{roll=true}
```
```markdown
:md-table[./quests.csv]{roll=true remix=true}
```
### `:md-deck`
Card layout from a CSV (legacy; prefer `yaml role=tag`).
- Body: CSV path
- Attributes: `size`, `grid`, `layers`
```markdown
:md-deck[./cards.csv]{size="63x88" grid="5x5" layers="title:1,1-5,1f8 body:1,5-5,8f3"}
```
### `:md-link`
Clickable link that expands the target article inline.
- Body: path, optional #section
```markdown
:md-link[./rules.md]
```
```markdown
:md-link[./rules.md#combat]
```
### `:md-embed`
Renders the target article or section inline.
- Body: path, optional #section
- Attributes: `headingBase`
```markdown
:md-embed[./rules.md#combat]{headingBase=1}
```
### `:md-bg`
Sets the surrounding article card background.
- Body: image path or CSS color
- Attributes: `fit`
- Body may also be a CSS color (e.g. `#2a1a3a`, `crimson`).
```markdown
:md-bg[./images/dungeon-bg.jpg]{fit="cover"}
```
```markdown
:md-bg[#2a1a3a]
```
### `:md-border`
Applies a border to the article card.
- Body: color or border image
- Attributes: `width`, `slice`
- Body may also be a CSS color (e.g. `#2a1a3a`, `crimson`).
```markdown
:md-border[crimson]{.b}
```
```markdown
:md-border[./frame.png]{width="2mm" slice="10" .all.round}
```
### `:md-font`
Sets the article font.
- Body: font family
- Attributes: `source`, `weight`, `color`
```markdown
:md-font[Noto Sans SC]{source="google" weight="700" color="#1a1a2e"}
```
### `:md-pins`
Numbered markers on an image.
- Body: image path
- Attributes: `pins`, `fixed`, `labelStart`
```markdown
:md-pins[./images/battle-map.png]{pins="A:25,50 B:75,30 C:50,80" fixed}
```
```markdown
:md-pins[./images/dungeon.png]{labelStart="1"}
```
### `:md-commander`
Interactive command history / tracker panel.
- Body: command template CSV
- Attributes: `placeholder`, `class`, `height`
```markdown
:md-commander[./commands.csv]{placeholder="输入命令…" height="400px"}
```
### `:md-yarn-spinner`
Runs a Yarn Spinner dialogue file.
- Body: Yarn Spinner file
- Attributes: `start`
```markdown
:md-yarn-spinner[./story.yarn]{start="start"}
```
## Inline directives
### Icons — `:[name]`
Inserts an icon looked up under `assets/` (searching upward from the file).
Default extension is `.png`; `.svg`, `.gif`, `.jpg`, `.jpeg`, and `.webp`
are also supported.
```markdown
:[attack] :[shield.svg] :[potion]
```
### Command links — `:cmd[command]{label=...}`
Renders a clickable span that dispatches a journal command.
```markdown
:cmd[roll 1d20+5]{label=Roll initiative}
```
## Code block roles
The `role=` attribute on the info string determines how a fenced block is
handled.
### `role=spark-table`
CSV (or markdown pipe table) rendered as `:md-table`.
````markdown
```csv role=spark-table
d6,结果
1,遭遇强盗
2,平安无事
```
````
````markdown
```markdown role=spark-table
| d6 | 结果 |
|----|------|
| 1 | 遭遇强盗 |
| 2 | 平安无事 |
```
````
### `role=declare`
Reactive variable / tag-modifier definitions.
````markdown
```csv role=declare
tag,threshold,key,expr
,,$hp,$con*5+$mod_hp
#warrior,2,$mod_hp,20
```
````
### `role=file`
Stored as inline text content and stripped.
````markdown
```text role=file
Stored as inline content.
```
````
### `role=tag`
YAML rendered as a custom element.
````markdown
```yaml role=tag
tag: md-deck
body: ./cards.csv
size: 63x88
grid: 5x5
```
````
### No role / unknown role
No role → a normal visible code block. Unknown role → warned about and
stripped.
## CSV data files
Keep CSV files clean — a header row and data rows. Put deck configuration
and templates in a `yaml role=tag` block's `data-config`.
- `label` — unique row id (number or text).
- `body` — main content, supports markdown.
- Any other columns are custom fields.
- `{{prop}}` in `body` interpolates another column (or front-matter key)
from the same row; with `{remix}` it pulls from a random row.
- Wrap values containing `,`, `"`, or newlines in double quotes; escape
`"` as `""`. Lines beginning with `#` are comments.
+1
View File
@@ -29,6 +29,7 @@
"cli:build": "npm run build:cli",
"ttrpg": "node --loader ts-node/esm ./src/cli/index.ts",
"tttk": "node --loader ts-node/esm ./src/cli/index.ts",
"gen:reference": "node --loader ts-node/esm ./scripts/generate-reference.ts",
"test": "jest",
"prepare": "npx husky",
"prepublishOnly": "npm run build"
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env node
/**
* Regenerate `docs/reference.md` from the `known.ts` catalog.
*
* npm run gen:reference
* npm run gen:reference -- --out ~/.agents/skills/tttk-authoring/reference.md
*/
import { mkdirSync, writeFileSync } from "fs";
import { dirname, resolve } from "path";
import { renderReference } from "../src/cli/validate/reference.js";
function argValue(flag: string): string | undefined {
const index = process.argv.indexOf(flag);
return index >= 0 ? process.argv[index + 1] : undefined;
}
const out = resolve(argValue("--out") ?? "docs/reference.md");
mkdirSync(dirname(out), { recursive: true });
writeFileSync(out, renderReference(), "utf-8");
console.log(`wrote ${out}`);
+1 -1
View File
@@ -277,7 +277,7 @@ function markdownTableToCsv(
*/
const MARKDOWN_TABLE_LANGS = new Set(["markdown", "md"]);
function isMarkdownTableLang(lang: string): boolean {
export function isMarkdownTableLang(lang: string): boolean {
return MARKDOWN_TABLE_LANGS.has(lang.toLowerCase());
}
+308
View File
@@ -0,0 +1,308 @@
/**
* CSV data-file checks (V001–V005).
*
* Two entry points:
* - `csvFilesCheck` validates every CSV referenced by a content directive,
* once per file (a shared CSV must not be reported twice).
* - `sparkTableCheck` validates inline `role=spark-table` bodies, which live
* in the document itself and so carry document line numbers.
*
* Parsing mirrors `parseCSVString` (`src/components/utils/csv-loader.ts`):
* BOM stripped, `#` lines are comments, cells trimmed, empty lines skipped,
* and YAML front matter split off first.
*/
import { parse } from "csv-parse/browser/esm/sync";
import yaml from "js-yaml";
import {
isMarkdownTableLang,
isSparkTableHeader,
resolveContentEntry,
type ContentRegistry,
} from "../../content-registry.js";
import { diagnosticAt, type DiagnosticSink } from "../diagnostics.js";
import { elementFor } from "../known.js";
import type { RawFence, ScanResult } from "../scan.js";
import { SourceFile } from "../source.js";
import type { Check, CheckContext } from "./context.js";
import { displayPath } from "./context.js";
import { readTagBlock } from "./tag-block.js";
/** A CSV body to validate, plus where it lives in its source. */
export interface CsvTarget {
/** Display path for diagnostics. */
file: string;
source: SourceFile;
body: string;
/** Offset in `source` where `body` begins. */
bodyOffset: number;
}
const PARSE_OPTIONS = {
columns: false as const,
relax_column_count: true,
info: true as const,
comment: "#",
trim: true,
skipEmptyLines: true,
bom: true,
};
interface CsvRecord {
record: string[];
info: { lines: number };
}
/** Run V001–V005 over one CSV body. */
export function checkCsvBody(target: CsvTarget, sink: DiagnosticSink): void {
const { file, source, body, bodyOffset } = target;
const { csv, csvOffset, frontmatter } = splitFrontMatter(body);
const baseLine = source.position(bodyOffset + csvOffset).line;
const offsetAt = (csvLine: number): number =>
source.lineStart(baseLine + csvLine - 1);
let records: CsvRecord[];
try {
records = parse(csv, PARSE_OPTIONS) as unknown as CsvRecord[];
} catch (error) {
const detail = error as { lines?: number; message?: string };
sink.report(
diagnosticAt(
file,
source,
offsetAt(detail.lines ?? 1),
"TTTK-V001",
"error",
`CSV failed to parse: ${detail.message ?? String(error)}`,
"check quoting; wrap values containing commas or quotes in double quotes",
),
);
return;
}
if (records.length === 0) return;
const header = records[0].record;
const headerSet = new Set(header);
const labelIndex = header.indexOf("label");
const bodyIndex = header.indexOf("body");
const sparkTable = isSparkTableHeader(header[0] ?? "");
const seenLabels = new Map<string, number>();
for (let i = 1; i < records.length; i++) {
const { record, info } = records[i];
const line = info.lines;
const offset = offsetAt(line);
if (record.length !== header.length) {
sink.report(
diagnosticAt(
file,
source,
offset,
"TTTK-V002",
"warning",
`row has ${record.length} column${record.length === 1 ? "" : "s"}, header has ${header.length}`,
"align the row with the header, or quote cells containing commas",
),
);
}
if (labelIndex >= 0) {
const label = (record[labelIndex] ?? "").trim();
if (label) {
const first = seenLabels.get(label);
if (first !== undefined) {
sink.report(
diagnosticAt(
file,
source,
offset,
"TTTK-V003",
"warning",
`duplicate label "${label}" (first at line ${first})`,
"labels identify rows; make each unique",
),
);
} else {
seenLabels.set(label, line);
}
}
}
if (bodyIndex >= 0) {
const cell = record[bodyIndex] ?? "";
for (const match of cell.matchAll(/\{\{([^}]+)\}\}/g)) {
const key = match[1].trim();
if (headerSet.has(key) || frontmatter.has(key)) continue;
sink.report(
diagnosticAt(
file,
source,
offset,
"TTTK-V004",
"warning",
`{{${key}}} is not a column or front-matter key`,
`available: ${[...headerSet].join(", ")}`,
),
);
}
}
if (sparkTable) {
const roll = (record[0] ?? "").trim();
if (roll && !/^\d+(\s*-\s*\d+)?$/.test(roll)) {
sink.report(
diagnosticAt(
file,
source,
offset,
"TTTK-V005",
"warning",
`spark-table roll value "${roll}" is not a number or range`,
"use a number (3) or a range (3-5)",
),
);
}
}
}
}
/**
* Validate every CSV referenced by a content directive, once per file.
* Inline spark-table bodies are handled by `sparkTableCheck`.
*/
export function csvFilesCheck(
mdKeys: string[],
scans: Map<string, ScanResult>,
registry: ContentRegistry,
index: Record<string, string>,
sink: DiagnosticSink,
): void {
const targets = new Map<string, CsvTarget>();
const add = (docKey: string, ref: string): void => {
const resolved = resolveContentEntry(registry, docKey, ref);
if (!resolved || resolved.inline) return;
if (!resolved.path.endsWith(".csv")) return;
if (targets.has(resolved.path)) return;
const body = index[resolved.path];
if (body == null) return;
targets.set(resolved.path, {
file: displayPath(resolved.path),
source: new SourceFile(body),
body,
bodyOffset: 0,
});
};
for (const docKey of mdKeys) {
const scan = scans.get(docKey);
if (!scan) continue;
for (const directive of scan.directives) {
if (directive.icon) continue;
const spec = elementFor(directive.name);
if (!spec || spec.refKind !== "csv") continue;
const ref = directive.body.trim();
if (ref) add(docKey, ref);
}
for (const fence of scan.fences) {
if (fence.attrs.role !== "tag") continue;
const { tag, body } = readTagBlock(fence);
if (!tag || !body) continue;
const spec = elementFor(tag);
if (!spec || spec.refKind !== "csv") continue;
add(docKey, body.trim());
}
}
for (const target of targets.values()) checkCsvBody(target, sink);
}
/**
* Validate inline `role=spark-table` bodies. CSV bodies get the full V001–V005
* treatment; markdown pipe tables only get V005 (their structure is markdown,
* not CSV).
*/
export const sparkTableCheck: Check = (ctx) => {
const { scan, source, file, sink } = ctx;
for (const fence of scan.fences) {
if (fence.attrs.role !== "spark-table") continue;
if (isMarkdownTableLang(fence.lang)) {
checkMarkdownSparkTable(fence, ctx);
continue;
}
checkCsvBody(
{ file, source, body: fence.body, bodyOffset: fence.bodyOffset },
sink,
);
}
};
/** V005 for a markdown pipe-table spark table. */
function checkMarkdownSparkTable(fence: RawFence, ctx: CheckContext): void {
const { source, file, sink } = ctx;
const baseLine = source.position(fence.bodyOffset).line;
const lines = fence.body.split(/\r?\n/);
let dataRows = 0;
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim();
if (!trimmed.startsWith("|")) continue;
dataRows++;
if (dataRows <= 2) continue; // header + separator
const cells = trimmed
.split("|")
.map((cell) => cell.trim())
.filter(Boolean);
const roll = cells[0] ?? "";
if (roll && !/^\d+(\s*-\s*\d+)?$/.test(roll)) {
sink.report(
diagnosticAt(
file,
source,
source.lineStart(baseLine + i),
"TTTK-V005",
"warning",
`spark-table roll value "${roll}" is not a number or range`,
"use a number (3) or a range (3-5)",
),
);
}
}
}
/**
* Split YAML front matter off a CSV body, mirroring `parseFrontMatter`
* (`src/cli/tools/frontmatter/shared.ts`). Returns the remaining CSV, its
* offset within `content`, and the front-matter keys.
*/
function splitFrontMatter(content: string): {
csv: string;
csvOffset: number;
frontmatter: Set<string>;
} {
const trimmed = content.trim();
const parts = trimmed.split(/(?:^|\n)---\s*\n/g);
if (parts.length !== 3 || parts[0] !== "") {
return { csv: content, csvOffset: 0, frontmatter: new Set() };
}
let frontmatter = new Set<string>();
try {
const data = yaml.load(parts[1].trim());
if (data && typeof data === "object" && !Array.isArray(data)) {
frontmatter = new Set(Object.keys(data as Record<string, unknown>));
}
} catch {
return { csv: content, csvOffset: 0, frontmatter: new Set() };
}
const csv = parts.slice(2).join("---\n").trimStart();
const lead = content.length - content.trimStart().length;
const csvOffset = lead + (trimmed.length - csv.length);
return { csv, csvOffset, frontmatter };
}
+67 -2
View File
@@ -1,15 +1,19 @@
/**
* Directive checks: `:md-*` element names must be registered.
* Directive checks: `:md-*` element names must be registered, and attribute
* blocks must be well formed.
*/
import { diagnosticAt } from "../diagnostics.js";
import { ELEMENT_NAMES, KNOWN_ELEMENTS } from "../known.js";
import type { Check } from "./context.js";
import type { RawDirective } from "../scan.js";
import type { Check, CheckContext } from "./context.js";
export const directivesCheck: Check = (ctx) => {
const { scan, source, file, sink } = ctx;
for (const directive of scan.directives) {
checkAttrs(directive, ctx);
if (directive.icon) continue;
if (!directive.name.startsWith("md-")) continue;
if (ELEMENT_NAMES.has(directive.name)) continue;
@@ -31,6 +35,67 @@ export const directivesCheck: Check = (ctx) => {
}
};
/**
* D002 — an attribute block contains a token that is neither `key=value`, a
* `.class`/`#id` shorthand, nor a bare boolean flag. This is what an unquoted
* value with a space looks like: `{pins=A:30, 40}` parses as `pins=A:30,` plus
* a stray `40`, silently dropping the rest of the value.
*/
function checkAttrs(directive: RawDirective, ctx: CheckContext): void {
const raw = directive.rawAttrs;
if (!raw.trim()) return;
for (const { token, index } of attrTokens(raw)) {
if (/^[.#][\w-]+(?:[.#][\w-]+)*$/.test(token)) continue; // .class / #id
if (/^[A-Za-z_][\w-]*=/.test(token)) continue; // key=value
if (/^[A-Za-z_][\w-]*$/.test(token)) continue; // boolean flag
ctx.sink.report(
diagnosticAt(
ctx.file,
ctx.source,
attrBlockOffset(directive) + index,
"TTTK-D002",
"error",
`malformed attribute \`${token}\` in the attribute block`,
"quote values containing spaces, e.g. {pins=\"A:30, 40\"}",
),
);
}
}
/** Offset of the `{` that opens a directive's attribute block. */
function attrBlockOffset(directive: RawDirective): number {
// `:name[body]{` or `:[body]{`
const prefix = directive.icon
? 3 + directive.body.length
: 2 + directive.name.length + 1 + directive.body.length;
return directive.offset + prefix;
}
/** Split an attribute block on whitespace, honoring quoted values. */
function* attrTokens(raw: string): Generator<{ token: string; index: number }> {
let i = 0;
while (i < raw.length) {
while (i < raw.length && /\s/.test(raw[i])) i++;
if (i >= raw.length) break;
const start = i;
let quote: string | null = null;
while (i < raw.length) {
const ch = raw[i];
if (quote) {
if (ch === quote) quote = null;
} else if (ch === '"' || ch === "'") {
quote = ch;
} else if (/\s/.test(ch)) {
break;
}
i++;
}
yield { token: raw.slice(start, i), index: start };
}
}
/** Return the closest candidate within edit distance 2, if any. */
function closestName(name: string, candidates: string[]): string | null {
let best: string | null = null;
+45
View File
@@ -2,6 +2,7 @@
* Code-block role checks.
*/
import { parse } from "csv-parse/browser/esm/sync";
import { parseDeclareCsv } from "../../completions/declare-parser.js";
import { diagnosticAt } from "../diagnostics.js";
import { ELEMENT_NAMES, KNOWN_ROLES } from "../known.js";
@@ -73,6 +74,50 @@ function checkDeclare(fence: RawFence, ctx: CheckContext): void {
"role=declare needs `key` and `expr` columns; `tag` and `threshold` are optional",
),
);
return;
}
checkDeclareRows(fence, ctx);
}
/**
* R004 — `parseDeclareCsv` silently skips rows with an empty `key` or `expr`.
* Re-parse with line info so the skipped row can be localized.
*/
function checkDeclareRows(fence: RawFence, ctx: CheckContext): void {
const { source, file, sink } = ctx;
let records: Array<{ record: Record<string, string>; info: { lines: number } }>;
try {
records = parse(fence.body, {
columns: true,
trim: true,
skipEmptyLines: true,
info: true,
}) as unknown as typeof records;
} catch {
return; // R003 already reported the parse failure
}
if (records.length === 0) return;
const headers = Object.keys(records[0].record).map((h) => h.toLowerCase());
if (!headers.includes("key") || !headers.includes("expr")) return;
const baseLine = source.position(fence.bodyOffset).line;
for (const { record, info } of records) {
const key = record.key ?? "";
const expr = record.expr ?? "";
if (key && expr) continue;
sink.report(
diagnosticAt(
file,
source,
source.lineStart(baseLine + info.lines - 1),
"TTTK-R004",
"warning",
`declare row skipped: ${!key ? "empty key" : "empty expr"}`,
"every declare row needs both a key and an expression",
),
);
}
}
+9
View File
@@ -17,6 +17,7 @@ import { readContentIndex } from "../content-index.js";
import { assetsCheck } from "./checks/assets.js";
import type { Check, CheckContext } from "./checks/context.js";
import { displayPath, resolveKey } from "./checks/context.js";
import { csvFilesCheck, sparkTableCheck } from "./checks/csv.js";
import { directivesCheck } from "./checks/directives.js";
import { linksCheck } from "./checks/links.js";
import { refsCheck } from "./checks/refs.js";
@@ -35,6 +36,7 @@ const CHECKS: readonly Check[] = [
structuralCheck,
directivesCheck,
rolesCheck,
sparkTableCheck,
refsCheck,
linksCheck,
assetsCheck,
@@ -81,6 +83,13 @@ export function runCheck(dir: string): CheckReport {
}
checkEmbedCycles(mdKeys, docs, registry, sink);
csvFilesCheck(
mdKeys,
new Map(mdKeys.map((key) => [key, docs.get(key)!.scan])),
registry,
index,
sink,
);
return buildReport(rootDir, mdKeys.length, sink.items);
}
+52
View File
@@ -0,0 +1,52 @@
/**
* Drift guard: `KNOWN_ELEMENTS` in `known.ts` must match the `customElement`
* registrations under `src/components`. This is what would have caught
* `:md-card` — matched by the completion scanner but registered nowhere.
*/
import { readdirSync, readFileSync } from "fs";
import { join } from "path";
import { ELEMENT_NAMES } from "./known.js";
import { renderReference } from "./reference.js";
const COMPONENTS_DIR = join(process.cwd(), "src", "components");
const CUSTOM_ELEMENT_RE = /customElement(?:<[^>]*>)?\(\s*["'](md-[\w-]+)["']/g;
function sourceFiles(dir: string): string[] {
const files: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) files.push(...sourceFiles(full));
else if (/\.tsx?$/.test(entry.name)) files.push(full);
}
return files;
}
function registeredElements(): Set<string> {
const names = new Set<string>();
for (const file of sourceFiles(COMPONENTS_DIR)) {
const source = readFileSync(file, "utf-8");
for (const match of source.matchAll(CUSTOM_ELEMENT_RE)) {
names.add(match[1]);
}
}
return names;
}
describe("known element catalog", () => {
test("matches the customElement registrations under src/components", () => {
const registered = [...registeredElements()].sort();
const known = [...ELEMENT_NAMES].sort();
expect(known).toEqual(registered);
});
test("docs/reference.md is up to date", () => {
const committed = readFileSync(
join(process.cwd(), "docs", "reference.md"),
"utf-8",
);
expect(committed).toBe(renderReference());
});
});
+68 -6
View File
@@ -3,8 +3,8 @@
* 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.
* 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. */
@@ -19,6 +19,8 @@ export interface ElementSpec {
attrs: string[];
summary: string;
refKind: RefKind;
/** Canonical usage examples, rendered into the reference. */
examples: string[];
}
/** The registered `md-*` custom elements (mirrors `customElement(...)` calls). */
@@ -29,6 +31,11 @@ export const KNOWN_ELEMENTS: readonly ElementSpec[] = [
attrs: ["key"],
summary: "Clickable dice roller; `key` stores the result in the URL.",
refKind: "none",
examples: [
':md-dice[1d20+5]{key="attack"}',
":md-dice[2d6+3]",
":md-dice[3d6kh1]",
],
},
{
name: "md-table",
@@ -36,6 +43,11 @@ export const KNOWN_ELEMENTS: readonly ElementSpec[] = [
attrs: ["roll", "remix"],
summary: "Tab-separated view of a CSV with optional group tabs.",
refKind: "csv",
examples: [
":md-table[./npcs.csv]",
":md-table[./encounters.csv]{roll=true}",
":md-table[./quests.csv]{roll=true remix=true}",
],
},
{
name: "md-deck",
@@ -43,6 +55,9 @@ export const KNOWN_ELEMENTS: readonly ElementSpec[] = [
attrs: ["size", "grid", "layers"],
summary: "Card layout from a CSV (legacy; prefer `yaml role=tag`).",
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",
@@ -50,6 +65,7 @@ export const KNOWN_ELEMENTS: readonly ElementSpec[] = [
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",
@@ -57,6 +73,7 @@ export const KNOWN_ELEMENTS: readonly ElementSpec[] = [
attrs: ["headingBase"],
summary: "Renders the target article or section inline.",
refKind: "md",
examples: [":md-embed[./rules.md#combat]{headingBase=1}"],
},
{
name: "md-bg",
@@ -64,6 +81,7 @@ export const KNOWN_ELEMENTS: readonly ElementSpec[] = [
attrs: ["fit"],
summary: "Sets the surrounding article card background.",
refKind: "image",
examples: [':md-bg[./images/dungeon-bg.jpg]{fit="cover"}', ":md-bg[#2a1a3a]"],
},
{
name: "md-border",
@@ -71,6 +89,10 @@ export const KNOWN_ELEMENTS: readonly ElementSpec[] = [
attrs: ["width", "slice"],
summary: "Applies a border to the article card.",
refKind: "image",
examples: [
":md-border[crimson]{.b}",
':md-border[./frame.png]{width="2mm" slice="10" .all.round}',
],
},
{
name: "md-font",
@@ -78,6 +100,9 @@ export const KNOWN_ELEMENTS: readonly ElementSpec[] = [
attrs: ["source", "weight", "color"],
summary: "Sets the article font.",
refKind: "none",
examples: [
':md-font[Noto Sans SC]{source="google" weight="700" color="#1a1a2e"}',
],
},
{
name: "md-pins",
@@ -85,6 +110,10 @@ export const KNOWN_ELEMENTS: readonly ElementSpec[] = [
attrs: ["pins", "fixed", "labelStart"],
summary: "Numbered markers on an image.",
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",
@@ -92,6 +121,7 @@ export const KNOWN_ELEMENTS: readonly ElementSpec[] = [
attrs: ["placeholder", "class", "height"],
summary: "Interactive command history / tracker panel.",
refKind: "csv",
examples: [':md-commander[./commands.csv]{placeholder="输入命令…" height="400px"}'],
},
{
name: "md-yarn-spinner",
@@ -99,6 +129,7 @@ export const KNOWN_ELEMENTS: readonly ElementSpec[] = [
attrs: ["start"],
summary: "Runs a Yarn Spinner dialogue file.",
refKind: "yarn",
examples: [':md-yarn-spinner[./story.yarn]{start="start"}'],
},
];
@@ -122,15 +153,46 @@ export const COLOR_BODY_ELEMENTS: ReadonlySet<string> = new Set([
export interface RoleSpec {
role: string;
/** Example info string, e.g. "csv role=spark-table". */
info: string;
summary: 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", 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." },
{
role: "spark-table",
info: "csv role=spark-table",
summary: "CSV (or markdown pipe table) rendered as `:md-table`.",
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.",
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.",
examples: ["```text role=file\nStored as inline content.\n```"],
},
{
role: "tag",
info: "yaml role=tag",
summary: "YAML rendered as a custom element.",
examples: [
"```yaml role=tag\ntag: md-deck\nbody: ./cards.csv\nsize: 63x88\ngrid: 5x5\n```",
],
},
];
export const KNOWN_ROLES: ReadonlySet<string> = new Set(
+152
View File
@@ -0,0 +1,152 @@
/**
* Render the `known.ts` catalog to markdown.
*
* `docs/reference.md` is generated from this; do not hand-edit it. Regenerate
* with `npm run gen:reference`. The skill copy can be refreshed with
* `npm run gen:reference -- --out <path>`.
*/
import {
COLOR_BODY_ELEMENTS,
KNOWN_ELEMENTS,
ROLE_SPECS,
type ElementSpec,
} from "./known.js";
/** Wrap `code` in a fenced block, widening the fence when it contains one. */
function fence(code: string, lang = "markdown"): string {
const ticks = code.includes("```") ? "````" : "```";
return `${ticks}${lang}\n${code}\n${ticks}`;
}
function elementSection(element: ElementSpec): string {
const lines: string[] = [];
lines.push(`### \`:${element.name}\``);
lines.push("");
lines.push(element.summary);
lines.push("");
lines.push(`- Body: ${element.body}`);
if (element.attrs.length) {
lines.push(`- Attributes: ${element.attrs.map((a) => `\`${a}\``).join(", ")}`);
}
if (COLOR_BODY_ELEMENTS.has(element.name)) {
lines.push("- Body may also be a CSS color (e.g. `#2a1a3a`, `crimson`).");
}
lines.push("");
for (const example of element.examples) {
lines.push(fence(example));
lines.push("");
}
return lines.join("\n").trimEnd();
}
/** Render the full reference document. */
export function renderReference(): string {
const lines: string[] = [];
lines.push("# tttk syntax reference");
lines.push("");
lines.push(
"Generated from `src/cli/validate/known.ts` by `npm run gen:reference`.",
);
lines.push("Do not edit by hand.");
lines.push("");
lines.push(
"Everything not listed here is standard GFM markdown. Element names are",
);
lines.push("prefixed `md-` and must match exactly.");
lines.push("");
lines.push("## Directive syntax");
lines.push("");
lines.push(fence(":name[content]{key=\"value\" .class #id}", "text"));
lines.push("");
lines.push("- `[content]` is the directive body — a path, dice formula, color, etc.");
lines.push("- `{key=\"value\"}` sets attributes; `{.class}` and `{#id}` are shorthands.");
lines.push("- Container directives use `::: name` … `:::`; nest with `::::`, `:::::`.");
lines.push("");
lines.push("## Custom elements");
lines.push("");
lines.push("| Directive | Body | Key attributes |");
lines.push("|---|---|---|");
for (const element of KNOWN_ELEMENTS) {
const attrs = element.attrs.length
? element.attrs.map((a) => `\`${a}\``).join(", ")
: "—";
lines.push(`| \`:${element.name}\` | ${element.body} | ${attrs} |`);
}
lines.push("");
for (const element of KNOWN_ELEMENTS) {
lines.push(elementSection(element));
lines.push("");
}
lines.push("## Inline directives");
lines.push("");
lines.push("### Icons — `:[name]`");
lines.push("");
lines.push(
"Inserts an icon looked up under `assets/` (searching upward from the file).",
);
lines.push(
"Default extension is `.png`; `.svg`, `.gif`, `.jpg`, `.jpeg`, and `.webp`",
);
lines.push("are also supported.");
lines.push("");
lines.push(fence(":[attack] :[shield.svg] :[potion]"));
lines.push("");
lines.push("### Command links — `:cmd[command]{label=...}`");
lines.push("");
lines.push("Renders a clickable span that dispatches a journal command.");
lines.push("");
lines.push(fence(":cmd[roll 1d20+5]{label=Roll initiative}"));
lines.push("");
lines.push("## Code block roles");
lines.push("");
lines.push(
"The `role=` attribute on the info string determines how a fenced block is",
);
lines.push("handled.");
lines.push("");
for (const role of ROLE_SPECS) {
lines.push(`### \`role=${role.role}\``);
lines.push("");
lines.push(role.summary);
lines.push("");
for (const example of role.examples) {
lines.push(fence(example));
lines.push("");
}
}
lines.push("### No role / unknown role");
lines.push("");
lines.push(
"No role → a normal visible code block. Unknown role → warned about and",
);
lines.push("stripped.");
lines.push("");
lines.push("## CSV data files");
lines.push("");
lines.push(
"Keep CSV files clean — a header row and data rows. Put deck configuration",
);
lines.push("and templates in a `yaml role=tag` block's `data-config`.");
lines.push("");
lines.push("- `label` — unique row id (number or text).");
lines.push("- `body` — main content, supports markdown.");
lines.push("- Any other columns are custom fields.");
lines.push(
"- `{{prop}}` in `body` interpolates another column (or front-matter key)",
);
lines.push(" from the same row; with `{remix}` it pulls from a random row.");
lines.push(
"- Wrap values containing `,`, `\"`, or newlines in double quotes; escape",
);
lines.push(" `\"` as `\"\"`. Lines beginning with `#` are comments.");
lines.push("");
return lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
}
+4
View File
@@ -15,6 +15,8 @@ export interface RawDirective {
body: string;
/** Parsed `{key=value}` attributes (shorthands such as `.class` ignored). */
attrs: Record<string, string>;
/** Raw attribute-block contents, without the braces; "" when absent. */
rawAttrs: string;
/** Offset of the leading ":". */
offset: number;
/** True for the icon form `:[name]`. */
@@ -172,6 +174,7 @@ function scanLine(line: Line, result: ScanResult): void {
name: m[1],
body: m[2],
attrs: parseDirectiveAttrs(m[3] || ""),
rawAttrs: m[3] || "",
offset: line.offset + m.index,
icon: false,
});
@@ -183,6 +186,7 @@ function scanLine(line: Line, result: ScanResult): void {
name: "",
body: m[1],
attrs: parseDirectiveAttrs(m[2] || ""),
rawAttrs: m[2] || "",
offset: line.offset + m.index,
icon: true,
});
+6
View File
@@ -35,6 +35,12 @@ export class SourceFile {
return { line: lo + 1, column: clamped - this.lineStarts[lo] + 1 };
}
/** Offset of the start of a 1-based line (clamped to the file). */
lineStart(line: number): number {
const index = Math.max(0, Math.min(line - 1, this.lineStarts.length - 1));
return this.lineStarts[index];
}
/** The text of a 1-based line, without its terminator. */
lineText(line: number): string {
const start = this.lineStarts[line - 1] ?? 0;
+128
View File
@@ -144,6 +144,134 @@ describe("runCheck", () => {
expect(codes(runCheck(dir))).toContain("TTTK-C004");
});
test("D002 flags an unquoted attribute value containing a space", () => {
const dir = fixture({
"doc.md": ":md-pins[./map.png]{pins=A:30, 40}\n",
});
expect(codes(runCheck(dir))).toContain("TTTK-D002");
});
test("D002 accepts a quoted attribute value containing a space", () => {
const dir = fixture({
"doc.md": ':md-pins[./map.png]{pins="A:30, 40"}\n',
});
expect(codes(runCheck(dir))).not.toContain("TTTK-D002");
});
test("D002 accepts concatenated class shorthands and boolean flags", () => {
const dir = fixture({
"doc.md": ':md-border[./frame.png]{width="2mm" slice="10" .all.round}\n',
});
expect(codes(runCheck(dir))).not.toContain("TTTK-D002");
});
test("R004 flags a declare row with an empty key or expr", () => {
const dir = fixture({
"doc.md": "```csv role=declare\nkey,expr\n$hp,1\n,2\n```\n",
});
const report = runCheck(dir);
const diagnostic = report.diagnostics.find((d) => d.code === "TTTK-R004");
expect(diagnostic).toBeDefined();
expect(diagnostic!.line).toBe(4);
});
test("V001 flags a CSV that fails to parse", () => {
const dir = fixture({
"doc.md": "See :md-table[./bad.csv].\n",
"bad.csv": 'label,body\n1,"unterminated\n',
});
const report = runCheck(dir);
const diagnostic = report.diagnostics.find((d) => d.code === "TTTK-V001");
expect(diagnostic).toBeDefined();
expect(diagnostic!.file).toBe("bad.csv");
});
test("V002 flags a row whose column count differs from the header", () => {
const dir = fixture({
"doc.md": "See :md-table[./data.csv].\n",
"data.csv": "label,body\n1,a\n2,b,c\n",
});
const report = runCheck(dir);
const diagnostic = report.diagnostics.find((d) => d.code === "TTTK-V002");
expect(diagnostic).toBeDefined();
expect(diagnostic!.line).toBe(3);
});
test("V003 flags duplicate label values", () => {
const dir = fixture({
"doc.md": "See :md-table[./data.csv].\n",
"data.csv": "label,body\n1,a\n1,b\n",
});
const report = runCheck(dir);
const diagnostic = report.diagnostics.find((d) => d.code === "TTTK-V003");
expect(diagnostic).toBeDefined();
expect(diagnostic!.line).toBe(3);
});
test("V004 flags an unresolved {{prop}} in the body column", () => {
const dir = fixture({
"doc.md": "See :md-table[./data.csv].\n",
"data.csv": "label,body\n1,{{missing}}\n",
});
expect(codes(runCheck(dir))).toContain("TTTK-V004");
});
test("V004 accepts a {{prop}} naming a column or front-matter key", () => {
const dir = fixture({
"doc.md": "See :md-table[./data.csv].\n",
"data.csv": "---\nsystem: D&D\n---\nlabel,body,name\n1,{{name}} of {{system}}\n",
});
expect(codes(runCheck(dir))).not.toContain("TTTK-V004");
});
test("V005 flags a spark-table roll value that is not a number or range", () => {
const dir = fixture({
"doc.md": "```csv role=spark-table\nd6,result\nx,foo\n```\n",
});
const report = runCheck(dir);
const diagnostic = report.diagnostics.find((d) => d.code === "TTTK-V005");
expect(diagnostic).toBeDefined();
expect(diagnostic!.line).toBe(3);
});
test("a clean CSV produces no diagnostics", () => {
const dir = fixture({
"doc.md": "See :md-table[./data.csv].\n",
"data.csv": "label,body\n1,a\n2,b\n",
});
expect(runCheck(dir).diagnostics).toEqual([]);
});
test("a CSV referenced by two documents is only reported once", () => {
const dir = fixture({
"a.md": "See :md-table[./data.csv].\n",
"b.md": "See :md-table[./data.csv].\n",
"data.csv": "label,body\n1,a\n1,b\n",
});
const v003 = runCheck(dir).diagnostics.filter(
(d) => d.code === "TTTK-V003",
);
expect(v003).toHaveLength(1);
});
test("directives inside fenced blocks are not live content", () => {
const dir = fixture({
"doc.md": "```\n:md-table[./missing.csv]\n:md-card[x]\n```\n",