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 安装、命令和用法 | | [📖 CLI 使用说明](./docs/cli.md) | CLI 安装、命令和用法 |
| [🔤 语法参考](./docs/reference.md) | 自定义元素、代码块角色与属性目录(由 `known.ts` 生成) |
| [📦 npm](https://www.npmjs.com/package/tttk) | `npx tttk serve ./content` | | [📦 npm](https://www.npmjs.com/package/tttk) | `npx tttk serve ./content` |
| [🛠️ 开发指南](./docs/development.md) | 项目结构、开发规范和构建 | | [🛠️ 开发指南](./docs/development.md) | 项目结构、开发规范和构建 |
| [📝 Markdown 编写说明](./docs/markdown.md) | Markdown 语法和组件用法 | | [📝 Markdown 编写说明](./docs/markdown.md) | Markdown 语法和组件用法 |
+10 -6
View File
@@ -1,15 +1,19 @@
# `tttk check` — implementation plan (validator-first) # `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`, Landed: `S001`/`S002`, `D001`/`D002`, `R001`–`R006`, `C001`–`C004`,
`C001`–`C004`, `L001`/`L002`, `A001`/`A002`, with tests. The `check` command, `L001`/`L002`, `A001`/`A002`, `V001`–`V005`, with tests. The `check` command,
`--json`/`--strict`/`--quiet`, and exit codes are live; the markdown module is `--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 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 Not implemented: `D003` (P2 — the legacy `:md-deck` directive is not flagged).
reference generator + drift guard (§7.11–§7.12). The rule catalog in §5, the The rule catalog in §5, the CLI surface in §6, and the resolved decisions in §10
CLI surface in §6, and the resolved decisions in §10 remain the specification. remain the specification.
## 1. Goal ## 1. Goal
+8 -1
View File
@@ -112,9 +112,11 @@ content/adventures/hollow-mill/adventure.md
| `TTTK-S001` | error | Markdown 解析失败 | | `TTTK-S001` | error | Markdown 解析失败 |
| `TTTK-S002` | error | 容器指令 `:::` 未闭合 | | `TTTK-S002` | error | 容器指令 `:::` 未闭合 |
| `TTTK-D001` | error | 未知的 `:md-*` 元素名 | | `TTTK-D001` | error | 未知的 `:md-*` 元素名 |
| `TTTK-D002` | error | 属性块畸形(例如未加引号的值中含空格) |
| `TTTK-R001` | error | 未知的代码块 `role=`(块内容会被静默丢弃) | | `TTTK-R001` | error | 未知的代码块 `role=`(块内容会被静默丢弃) |
| `TTTK-R002` | warning | `role=spark-table` 首列表头不是骰点公式 | | `TTTK-R002` | warning | `role=spark-table` 首列表头不是骰点公式 |
| `TTTK-R003` | error | `role=declare` 解析失败(缺少 `key`/`expr` 等) | | `TTTK-R003` | error | `role=declare` 解析失败(缺少 `key`/`expr` 等) |
| `TTTK-R004` | warning | `role=declare` 中 `key`/`expr` 为空的行被跳过 |
| `TTTK-R005` | error | `role=tag` 的 YAML 无法解析 | | `TTTK-R005` | error | `role=tag` 的 YAML 无法解析 |
| `TTTK-R006` | error | `role=tag` 缺少 `tag:` 或元素未知 | | `TTTK-R006` | error | `role=tag` 缺少 `tag:` 或元素未知 |
| `TTTK-C001` | error | 内容引用无法解析(文件不存在,或内联 id 未定义) | | `TTTK-C001` | error | 内容引用无法解析(文件不存在,或内联 id 未定义) |
@@ -125,8 +127,13 @@ content/adventures/hollow-mill/adventure.md
| `TTTK-L002` | warning | `#锚点` 未匹配到任何标题 | | `TTTK-L002` | warning | `#锚点` 未匹配到任何标题 |
| `TTTK-A001` | warning | 图标在 `assets/` 中不存在 | | `TTTK-A001` | warning | 图标在 `assets/` 中不存在 |
| `TTTK-A002` | warning | 图片不存在 | | `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", "cli:build": "npm run build:cli",
"ttrpg": "node --loader ts-node/esm ./src/cli/index.ts", "ttrpg": "node --loader ts-node/esm ./src/cli/index.ts",
"tttk": "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", "test": "jest",
"prepare": "npx husky", "prepare": "npx husky",
"prepublishOnly": "npm run build" "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"]); 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()); 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 { diagnosticAt } from "../diagnostics.js";
import { ELEMENT_NAMES, KNOWN_ELEMENTS } from "../known.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) => { export const directivesCheck: Check = (ctx) => {
const { scan, source, file, sink } = ctx; const { scan, source, file, sink } = ctx;
for (const directive of scan.directives) { for (const directive of scan.directives) {
checkAttrs(directive, ctx);
if (directive.icon) continue; if (directive.icon) continue;
if (!directive.name.startsWith("md-")) continue; if (!directive.name.startsWith("md-")) continue;
if (ELEMENT_NAMES.has(directive.name)) 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. */ /** Return the closest candidate within edit distance 2, if any. */
function closestName(name: string, candidates: string[]): string | null { function closestName(name: string, candidates: string[]): string | null {
let best: string | null = null; let best: string | null = null;
+45
View File
@@ -2,6 +2,7 @@
* Code-block role checks. * Code-block role checks.
*/ */
import { parse } from "csv-parse/browser/esm/sync";
import { parseDeclareCsv } from "../../completions/declare-parser.js"; import { parseDeclareCsv } from "../../completions/declare-parser.js";
import { diagnosticAt } from "../diagnostics.js"; import { diagnosticAt } from "../diagnostics.js";
import { ELEMENT_NAMES, KNOWN_ROLES } from "../known.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", "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 { assetsCheck } from "./checks/assets.js";
import type { Check, CheckContext } from "./checks/context.js"; import type { Check, CheckContext } from "./checks/context.js";
import { displayPath, resolveKey } 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 { directivesCheck } from "./checks/directives.js";
import { linksCheck } from "./checks/links.js"; import { linksCheck } from "./checks/links.js";
import { refsCheck } from "./checks/refs.js"; import { refsCheck } from "./checks/refs.js";
@@ -35,6 +36,7 @@ const CHECKS: readonly Check[] = [
structuralCheck, structuralCheck,
directivesCheck, directivesCheck,
rolesCheck, rolesCheck,
sparkTableCheck,
refsCheck, refsCheck,
linksCheck, linksCheck,
assetsCheck, assetsCheck,
@@ -81,6 +83,13 @@ export function runCheck(dir: string): CheckReport {
} }
checkEmbedCycles(mdKeys, docs, registry, sink); 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); 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. * roles.
* *
* This is the single source of truth for validation. `docs/reference.md` is * 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 * generated from it (see docs/check-plan.md §7.11); a drift test asserts this
* test asserts this list matches the `customElement(...)` call sites. * list matches the `customElement(...)` call sites.
*/ */
/** What a directive's body refers to, used for reference checks. */ /** What a directive's body refers to, used for reference checks. */
@@ -19,6 +19,8 @@ export interface ElementSpec {
attrs: string[]; attrs: string[];
summary: string; summary: string;
refKind: RefKind; refKind: RefKind;
/** Canonical usage examples, rendered into the reference. */
examples: string[];
} }
/** The registered `md-*` custom elements (mirrors `customElement(...)` calls). */ /** The registered `md-*` custom elements (mirrors `customElement(...)` calls). */
@@ -29,6 +31,11 @@ export const KNOWN_ELEMENTS: readonly ElementSpec[] = [
attrs: ["key"], attrs: ["key"],
summary: "Clickable dice roller; `key` stores the result in the URL.", summary: "Clickable dice roller; `key` stores the result in the URL.",
refKind: "none", refKind: "none",
examples: [
':md-dice[1d20+5]{key="attack"}',
":md-dice[2d6+3]",
":md-dice[3d6kh1]",
],
}, },
{ {
name: "md-table", name: "md-table",
@@ -36,6 +43,11 @@ export const KNOWN_ELEMENTS: readonly ElementSpec[] = [
attrs: ["roll", "remix"], attrs: ["roll", "remix"],
summary: "Tab-separated view of a CSV with optional group tabs.", summary: "Tab-separated view of a CSV with optional group tabs.",
refKind: "csv", refKind: "csv",
examples: [
":md-table[./npcs.csv]",
":md-table[./encounters.csv]{roll=true}",
":md-table[./quests.csv]{roll=true remix=true}",
],
}, },
{ {
name: "md-deck", name: "md-deck",
@@ -43,6 +55,9 @@ export const KNOWN_ELEMENTS: readonly ElementSpec[] = [
attrs: ["size", "grid", "layers"], attrs: ["size", "grid", "layers"],
summary: "Card layout from a CSV (legacy; prefer `yaml role=tag`).", summary: "Card layout from a CSV (legacy; prefer `yaml role=tag`).",
refKind: "csv", 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", name: "md-link",
@@ -50,6 +65,7 @@ export const KNOWN_ELEMENTS: readonly ElementSpec[] = [
attrs: [], attrs: [],
summary: "Clickable link that expands the target article inline.", summary: "Clickable link that expands the target article inline.",
refKind: "md", refKind: "md",
examples: [":md-link[./rules.md]", ":md-link[./rules.md#combat]"],
}, },
{ {
name: "md-embed", name: "md-embed",
@@ -57,6 +73,7 @@ export const KNOWN_ELEMENTS: readonly ElementSpec[] = [
attrs: ["headingBase"], attrs: ["headingBase"],
summary: "Renders the target article or section inline.", summary: "Renders the target article or section inline.",
refKind: "md", refKind: "md",
examples: [":md-embed[./rules.md#combat]{headingBase=1}"],
}, },
{ {
name: "md-bg", name: "md-bg",
@@ -64,6 +81,7 @@ export const KNOWN_ELEMENTS: readonly ElementSpec[] = [
attrs: ["fit"], attrs: ["fit"],
summary: "Sets the surrounding article card background.", summary: "Sets the surrounding article card background.",
refKind: "image", refKind: "image",
examples: [':md-bg[./images/dungeon-bg.jpg]{fit="cover"}', ":md-bg[#2a1a3a]"],
}, },
{ {
name: "md-border", name: "md-border",
@@ -71,6 +89,10 @@ export const KNOWN_ELEMENTS: readonly ElementSpec[] = [
attrs: ["width", "slice"], attrs: ["width", "slice"],
summary: "Applies a border to the article card.", summary: "Applies a border to the article card.",
refKind: "image", refKind: "image",
examples: [
":md-border[crimson]{.b}",
':md-border[./frame.png]{width="2mm" slice="10" .all.round}',
],
}, },
{ {
name: "md-font", name: "md-font",
@@ -78,6 +100,9 @@ export const KNOWN_ELEMENTS: readonly ElementSpec[] = [
attrs: ["source", "weight", "color"], attrs: ["source", "weight", "color"],
summary: "Sets the article font.", summary: "Sets the article font.",
refKind: "none", refKind: "none",
examples: [
':md-font[Noto Sans SC]{source="google" weight="700" color="#1a1a2e"}',
],
}, },
{ {
name: "md-pins", name: "md-pins",
@@ -85,6 +110,10 @@ export const KNOWN_ELEMENTS: readonly ElementSpec[] = [
attrs: ["pins", "fixed", "labelStart"], attrs: ["pins", "fixed", "labelStart"],
summary: "Numbered markers on an image.", summary: "Numbered markers on an image.",
refKind: "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", name: "md-commander",
@@ -92,6 +121,7 @@ export const KNOWN_ELEMENTS: readonly ElementSpec[] = [
attrs: ["placeholder", "class", "height"], attrs: ["placeholder", "class", "height"],
summary: "Interactive command history / tracker panel.", summary: "Interactive command history / tracker panel.",
refKind: "csv", refKind: "csv",
examples: [':md-commander[./commands.csv]{placeholder="输入命令…" height="400px"}'],
}, },
{ {
name: "md-yarn-spinner", name: "md-yarn-spinner",
@@ -99,6 +129,7 @@ export const KNOWN_ELEMENTS: readonly ElementSpec[] = [
attrs: ["start"], attrs: ["start"],
summary: "Runs a Yarn Spinner dialogue file.", summary: "Runs a Yarn Spinner dialogue file.",
refKind: "yarn", 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 { export interface RoleSpec {
role: string; role: string;
/** Example info string, e.g. "csv role=spark-table". */
info: string;
summary: string; summary: string;
/** Canonical fenced-block examples, rendered into the reference. */
examples: string[];
} }
/** Code-block `role=` values the scanner understands. */ /** Code-block `role=` values the scanner understands. */
export const ROLE_SPECS: readonly RoleSpec[] = [ 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: "spark-table",
{ role: "file", summary: "Stored as inline text content and stripped." }, info: "csv role=spark-table",
{ role: "tag", summary: "YAML rendered as a custom element." }, 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( 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; body: string;
/** Parsed `{key=value}` attributes (shorthands such as `.class` ignored). */ /** Parsed `{key=value}` attributes (shorthands such as `.class` ignored). */
attrs: Record<string, string>; attrs: Record<string, string>;
/** Raw attribute-block contents, without the braces; "" when absent. */
rawAttrs: string;
/** Offset of the leading ":". */ /** Offset of the leading ":". */
offset: number; offset: number;
/** True for the icon form `:[name]`. */ /** True for the icon form `:[name]`. */
@@ -172,6 +174,7 @@ function scanLine(line: Line, result: ScanResult): void {
name: m[1], name: m[1],
body: m[2], body: m[2],
attrs: parseDirectiveAttrs(m[3] || ""), attrs: parseDirectiveAttrs(m[3] || ""),
rawAttrs: m[3] || "",
offset: line.offset + m.index, offset: line.offset + m.index,
icon: false, icon: false,
}); });
@@ -183,6 +186,7 @@ function scanLine(line: Line, result: ScanResult): void {
name: "", name: "",
body: m[1], body: m[1],
attrs: parseDirectiveAttrs(m[2] || ""), attrs: parseDirectiveAttrs(m[2] || ""),
rawAttrs: m[2] || "",
offset: line.offset + m.index, offset: line.offset + m.index,
icon: true, icon: true,
}); });
+6
View File
@@ -35,6 +35,12 @@ export class SourceFile {
return { line: lo + 1, column: clamped - this.lineStarts[lo] + 1 }; 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. */ /** The text of a 1-based line, without its terminator. */
lineText(line: number): string { lineText(line: number): string {
const start = this.lineStarts[line - 1] ?? 0; const start = this.lineStarts[line - 1] ?? 0;
+128
View File
@@ -144,6 +144,134 @@ describe("runCheck", () => {
expect(codes(runCheck(dir))).toContain("TTTK-C004"); 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", () => { test("directives inside fenced blocks are not live content", () => {
const dir = fixture({ const dir = fixture({
"doc.md": "```\n:md-table[./missing.csv]\n:md-card[x]\n```\n", "doc.md": "```\n:md-table[./missing.csv]\n:md-card[x]\n```\n",