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:
@@ -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
@@ -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
@@ -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` 重新生成。
|
||||||
|
|
||||||
**实现说明:**
|
**实现说明:**
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}`);
|
||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user