From 71542e7453a7167df52575530b63beef559b5593 Mon Sep 17 00:00:00 2001 From: hypercross Date: Sat, 12 Sep 2026 17:01:49 +0800 Subject: [PATCH] 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. --- README.md | 1 + docs/check-plan.md | 16 +- docs/cli.md | 9 +- docs/reference.md | 286 ++++++++++++++++++++++++ package.json | 1 + scripts/generate-reference.ts | 21 ++ src/cli/content-registry.ts | 2 +- src/cli/validate/checks/csv.ts | 308 ++++++++++++++++++++++++++ src/cli/validate/checks/directives.ts | 69 +++++- src/cli/validate/checks/roles.ts | 45 ++++ src/cli/validate/index.ts | 9 + src/cli/validate/known.test.ts | 52 +++++ src/cli/validate/known.ts | 74 ++++++- src/cli/validate/reference.ts | 152 +++++++++++++ src/cli/validate/scan.ts | 4 + src/cli/validate/source.ts | 6 + src/cli/validate/validate.test.ts | 128 +++++++++++ 17 files changed, 1167 insertions(+), 16 deletions(-) create mode 100644 docs/reference.md create mode 100644 scripts/generate-reference.ts create mode 100644 src/cli/validate/checks/csv.ts create mode 100644 src/cli/validate/known.test.ts create mode 100644 src/cli/validate/reference.ts diff --git a/README.md b/README.md index 376d43c..20fc0bb 100644 --- a/README.md +++ b/README.md @@ -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 语法和组件用法 | diff --git a/docs/check-plan.md b/docs/check-plan.md index 027bdc1..dd12347 100644 --- a/docs/check-plan.md +++ b/docs/check-plan.md @@ -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 diff --git a/docs/cli.md b/docs/cli.md index 1f5977f..9855e78 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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` 重新生成。 **实现说明:** diff --git a/docs/reference.md b/docs/reference.md new file mode 100644 index 0000000..882254c --- /dev/null +++ b/docs/reference.md @@ -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. diff --git a/package.json b/package.json index 9e80a2e..841c3cb 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/scripts/generate-reference.ts b/scripts/generate-reference.ts new file mode 100644 index 0000000..3b427df --- /dev/null +++ b/scripts/generate-reference.ts @@ -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}`); diff --git a/src/cli/content-registry.ts b/src/cli/content-registry.ts index 800259c..9cf2b56 100644 --- a/src/cli/content-registry.ts +++ b/src/cli/content-registry.ts @@ -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()); } diff --git a/src/cli/validate/checks/csv.ts b/src/cli/validate/checks/csv.ts new file mode 100644 index 0000000..415869f --- /dev/null +++ b/src/cli/validate/checks/csv.ts @@ -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(); + + 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, + registry: ContentRegistry, + index: Record, + sink: DiagnosticSink, +): void { + const targets = new Map(); + + 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; +} { + 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(); + try { + const data = yaml.load(parts[1].trim()); + if (data && typeof data === "object" && !Array.isArray(data)) { + frontmatter = new Set(Object.keys(data as Record)); + } + } 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 }; +} + diff --git a/src/cli/validate/checks/directives.ts b/src/cli/validate/checks/directives.ts index bf6a018..a31dde8 100644 --- a/src/cli/validate/checks/directives.ts +++ b/src/cli/validate/checks/directives.ts @@ -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; diff --git a/src/cli/validate/checks/roles.ts b/src/cli/validate/checks/roles.ts index be2c061..3664858 100644 --- a/src/cli/validate/checks/roles.ts +++ b/src/cli/validate/checks/roles.ts @@ -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; 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", + ), + ); } } diff --git a/src/cli/validate/index.ts b/src/cli/validate/index.ts index be8e37b..b113a11 100644 --- a/src/cli/validate/index.ts +++ b/src/cli/validate/index.ts @@ -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); } diff --git a/src/cli/validate/known.test.ts b/src/cli/validate/known.test.ts new file mode 100644 index 0000000..5e1ca89 --- /dev/null +++ b/src/cli/validate/known.test.ts @@ -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 { + const names = new Set(); + 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()); + }); +}); diff --git a/src/cli/validate/known.ts b/src/cli/validate/known.ts index 1f586c8..9c7fb50 100644 --- a/src/cli/validate/known.ts +++ b/src/cli/validate/known.ts @@ -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 = 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: " 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 = new Set( diff --git a/src/cli/validate/reference.ts b/src/cli/validate/reference.ts new file mode 100644 index 0000000..568d512 --- /dev/null +++ b/src/cli/validate/reference.ts @@ -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 `. + */ + +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"; +} diff --git a/src/cli/validate/scan.ts b/src/cli/validate/scan.ts index bd67673..5110356 100644 --- a/src/cli/validate/scan.ts +++ b/src/cli/validate/scan.ts @@ -15,6 +15,8 @@ export interface RawDirective { body: string; /** Parsed `{key=value}` attributes (shorthands such as `.class` ignored). */ attrs: Record; + /** 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, }); diff --git a/src/cli/validate/source.ts b/src/cli/validate/source.ts index 3217433..061353e 100644 --- a/src/cli/validate/source.ts +++ b/src/cli/validate/source.ts @@ -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; diff --git a/src/cli/validate/validate.test.ts b/src/cli/validate/validate.test.ts index 2463f9e..93323c1 100644 --- a/src/cli/validate/validate.test.ts +++ b/src/cli/validate/validate.test.ts @@ -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",