Compare commits

...
11 Commits
Author SHA1 Message Date
hypercross d8ccaecade chore(skill): Own tttk-authoring in the repo
Move the skill to .agents/skills/tttk-authoring and point gen:skill-reference at it. Activate it by symlinking ~/.agents/skills/tttk-authoring to the in-repo copy.

Extend the reference drift guard to cover the skill copy now that it is versioned, and document activation in docs/development.md.
2026-09-12 17:19:38 +08:00
hypercross 6d94a99b42 docs(skill): Generate the tttk reference catalog
Enrich known.ts with element/role details, inline specs, and dice notation, and render them from reference.ts. Add gen:skill-reference and ~ expansion in --out so the skill copy can be regenerated.

The skill's reference.md is now generated; its extra authoring content moved to layout.md, yaml-tag.md, csv.md, and markdown.md.
2026-09-12 17:13:42 +08:00
hypercross 71542e7453 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.
2026-09-12 17:01:49 +08:00
hypercross ca6d8d1d14 docs(cli): Remove duplicated doc comment 2026-09-12 16:42:39 +08:00
hypercross d7cf4097ca docs: Record tttk check implementation status
Update the plan status to implemented (partial), listing the landed diagnostic codes and the deferred CSV, D002, R004, and reference-generator work.
2026-09-12 16:42:38 +08:00
hypercross 9df6b9970a docs: Document the tttk check command
Add check to the CLI guide, README, QWEN, and project structure: options, exit codes, diagnostic codes, and the checks not yet implemented. Mark compile as an unimplemented stub and fix stale element syntax in examples.
2026-09-12 16:42:37 +08:00
hypercross 9def4d17aa feat(cli): Add tttk check validator
Statically validate a directory of tttk documents and report structured diagnostics with file/line positions, exit codes, and --json output. Checks cover code-block roles, unresolved content references, tag blocks, element names, links, anchors, and assets. Runs over the same content registry as the app.
2026-09-12 16:38:21 +08:00
hypercross d1384c2ccc refactor(cli): Share the content-index walk
Extract readContentIndex from buildRegistry so the validator can read raw files while the server keeps the scanned registry, both from one walk.
2026-09-12 16:38:05 +08:00
hypercross f33761e24f refactor(markdown): Make module CLI-importable
Drop the mermaid import from the markdown module. The extension only emits <pre class="mermaid">, so initialize mermaid in the app entry instead. Use explicit .js specifiers plus a bundler extensionAlias so both the NodeNext CLI build and the web build resolve the sources.
2026-09-12 16:38:03 +08:00
hypercross d6ea385a26 fix(cli): Mark compile as an unimplemented stub 2026-09-12 16:22:22 +08:00
hypercross 49dd95ded8 docs: Add validator-first tttk check plan
Replaces the render-first compile plan with a diagnostics-first design for a new tttk check command.

Covers the whole-tree registry pass, raw-source diagnostics with line numbers, the rule catalog, and exit codes, plus resolved decisions on the element catalog, mermaid refactor, icon search scope, and CSV checks.
2026-09-12 16:10:28 +08:00
47 changed files with 4621 additions and 87 deletions
+138
View File
@@ -0,0 +1,138 @@
---
name: tttk-authoring
description: Author and edit TTRPG game design documents for the tttk (Tabletop Toolkit) CLI. Use when writing or modifying markdown content that uses tttk custom elements (:md-dice, :md-table, :md-deck, :md-pins, :md-commander, etc.), custom fenced code blocks (role=spark-table / declare / file / tag), companion CSV data files, or when previewing content with `tttk serve` and validating it with `tttk check`.
---
# Authoring tttk game design documents
`tttk` (Tabletop Toolkit) renders a folder of markdown into an interactive TTRPG
web app. A game design document is **ordinary GFM markdown** plus exactly two
extensions:
1. **Custom elements** written with `marked-directive` syntax: `:name[content]{attrs}`.
2. **Custom fenced code blocks** whose info string carries a `role=` attribute.
Everything else — headings, lists, tables, blockquotes, alerts, mermaid — is
standard markdown. Do not invent element names, props, or roles; check
`reference.md` when unsure. Deeper topics have their own files: `yaml-tag.md`
(deck config), `csv.md` (data files), `layout.md` (columns), and `markdown.md`
(alerts and mermaid).
## Workflow
- Content lives in a directory (commonly `content/`). Preview with hot reload:
`npx tttk serve ./content` (default port 3000, override with `-p 8080`).
- **Validate after every edit:** `npx tttk check ./content`. It exits `1` when
there are errors and prints `file:line` diagnostics. Use `--json` for
machine-readable output and `--quiet` for errors only. This is the primary
verification loop: write → check → fix.
- The scanner picks up `.md`, `.csv`, `.yarn`, and `.svg` files.
- All paths in directives are **relative to the current markdown file**
(`./data.csv`, `../shared/npcs.csv`, `./images/map.png`).
- A typical game/adventure folder:
```
my-adventure/
├── design.md # design notes (plain markdown)
├── adventure.md # the document players/GM read
├── locations.csv # data referenced by :md-table
├── cast.csv
└── route.csv
```
## Directive syntax
```
: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 deeper with `::::`, `:::::`.
Use the **registered element names**: `:md-dice`, not `:dice`. The full list is
in `reference.md`.
## Code block roles
Fenced blocks are dispatched by the `role=` attribute in the info string:
| Info string | Behavior |
|---|---|
| ` ```csv role=spark-table ` | Body is CSV; replaced with `:md-table[./csv_<hash>]`. First column header must be a dice formula (`d6`, `2d6+1`, …). |
| ` ```markdown role=spark-table ` | Body is a GFM pipe table; converted to CSV, then same as above. |
| ` ```csv role=declare ` | Defines reactive variables / tag modifiers. Block is stripped. |
| ` ```<lang> role=file ` | Body stored as text content, block stripped, referenced by its derived id. |
| ` ```yaml role=tag ` | Kept intact; rendered as a custom element from YAML (`tag:`, `body:`, …). |
| ` ```<lang> ` (no role) | Rendered as a normal visible code block. |
| unknown role | Warned about and stripped. |
## Rules that prevent broken output
- Quote any attribute value containing spaces or `"`:
`{pins="A:30,40 B:10,30"}`.
- In CSV, wrap fields containing `,`, `"`, or a newline in double quotes and
escape `"` as `""`. Lines starting with `#` are comments.
- `label` and `body` are the conventional CSV columns. Put titles inside `body`
as markdown headings; use **bold labels** for sub-fields, not headings.
- A `group` column in a table CSV automatically produces group tabs — no
attribute needed.
- Reference each CSV once per document; don't repeat the same `:md-table`.
- For card decks, use the `yaml role=tag` form with `tag: md-deck` (see
`yaml-tag.md`). The `:md-deck[...]{...}` directive is legacy and can't express
template layers or card backs.
- Keep CSV files clean — header row plus data. Put deck config and templates in
the YAML block's `data-config`, not in CSV front matter (see `csv.md`).
- `{{prop}}` in a CSV `body` interpolates another column (or front-matter key)
from the same row; with `{remix}` it pulls from a random row instead.
## Validation
`npx tttk check ./content` validates against the same content registry the app
uses, so a clean check means the app resolves the content too. Errors exit `1`:
| Code | Meaning |
|---|---|
| `TTTK-S002` | container directive `:::` never closed |
| `TTTK-D001` | unknown `:md-*` element name |
| `TTTK-D002` | malformed attribute block (e.g. an unquoted value with a space) |
| `TTTK-R001` | unknown code-block `role=` (the block is silently dropped) |
| `TTTK-R003` | `role=declare` failed to parse |
| `TTTK-R005` | `role=tag` YAML is invalid |
| `TTTK-R006` | `role=tag` missing or naming an unknown element |
| `TTTK-C001` | content reference does not resolve |
| `TTTK-C004` | `:md-embed` cycle |
| `TTTK-L001` | link target file does not exist |
| `TTTK-V001` | CSV failed to parse (e.g. an unclosed quote) |
Warnings are usually real too:
| Code | Meaning |
|---|---|
| `TTTK-R002` | spark-table first header is not a dice formula |
| `TTTK-R004` | `role=declare` row skipped (empty `key` or `expr`) |
| `TTTK-C002` | reference resolves to an unexpected file type |
| `TTTK-L002` | `#section` matches no heading |
| `TTTK-A001` | icon missing under `assets/` |
| `TTTK-A002` | image missing |
| `TTTK-V002` | CSV row column count differs from the header |
| `TTTK-V003` | duplicate `label` values |
| `TTTK-V004` | `{{prop}}` is not a CSV column or front-matter key |
| `TTTK-V005` | spark-table roll value is not a number or range |
`TTTK-C003` (info) flags the same CSV referenced twice by `:md-table` in one
document. Icon checks assume no `assets/` directory exists above the checked
directory. The full code catalog lives in `reference.md`.
## Reference
- `reference.md` — **generated** catalog of every custom element, inline
directive, code block role, and dice notation. Do not edit by hand;
regenerate from the repo with `npm run gen:skill-reference`.
- `yaml-tag.md` — `role=tag` blocks, `data-config` layers, card backs, and the
legacy `:md-deck` layer syntax.
- `csv.md` — CSV authoring: columns, `{{prop}}` interpolation, quoting, and the
discouraged front-matter form.
- `layout.md` — column layout markers.
- `markdown.md` — GitHub alerts and mermaid diagrams.
- `examples/adventure.md` and `examples/encounters.csv` — a complete minimal
document and its data file.
+42
View File
@@ -0,0 +1,42 @@
# CSV data files
**Keep CSV files clean** — a header row and data rows, nothing else. Put deck
configuration and templates in the `yaml role=tag` block's `data-config`, not in
the CSV. This keeps data portable and presentation in one place. See
`reference.md` for the element and role catalog.
```csv
label,body,name,type
1,"### 火球术卷轴
一张泛黄的羊皮纸。",火球术卷轴,消耗品
```
- `label` — unique row id (number or text).
- `body` — main content, supports markdown.
- Any other columns are custom fields.
- `{{prop}}` in `body` interpolates another column from the same row (or a
random row when the table uses `{remix}`).
- Wrap values containing `,`, `"`, or newlines in double quotes; escape `"` as
`""`. Lines beginning with `#` are comments.
## Front matter (avoid)
The parser also accepts YAML front matter delimited by `---`, which can declare
`fields`, a `deck:` block, and arbitrary keys merged into every row. **Prefer
not to use it** — it mixes presentation into the data file. Use the YAML tag
block instead.
```csv
---
fields:
- name: name
description: 卡牌名称
deck:
size: 63x88
grid: 5x5
game_system: D&D 5e
---
label,body,name,type
1,"### 火球术卷轴",火球术卷轴,消耗品
```
@@ -0,0 +1,68 @@
# The Hollow Mill
## Adventure brief
The mill on the village edge has stood silent for a year. Last week its wheel
began turning again — on its own.
> [!WARNING]
> The mill's guardian is not hostile by default. Attacking it forfeits the
> peaceful resolution.
## Locations
:md-table[./locations.csv]
## Random encounters
:md-table[./encounters.csv]{roll=true}
## The mill guardian
:md-dice[1d20+3]{key="guardian-attack"}
The guardian strikes with :[sword] for :md-dice[2d6+2]{key="guardian-damage"}.
## Encounter table (inline)
```csv role=spark-table roll=true
d6,Encounter
1,A crow watches from the rafters.
2,The floorboards groan underfoot.
3,A cold draft carries the smell of grain.
4,The wheel outside stops for one breath.
5,A sack of flour splits open.
6,The guardian's lantern flickers to life.
```
## Route
```mermaid
graph TD
A[Village] --> B[Mill Road]
B --> C{Mill Door}
C -->|Force| D[Ground Floor]
C -->|Listen| E[Secret Cellar]
D --> F[Wheel Room]
E --> F
```
## Guardian card
```yaml role=tag
tag: md-deck
body: ./guardian.csv
data-config:
size: 63x88
grid: 5x5
layers:
- prop: name
pos: 1,1-5,1
font: 12
- template: |
**{{type}}** — {{hp}} HP
{{description}}
pos: 1,3-5,8
font: 3
align: l
```
@@ -0,0 +1,36 @@
group,label,body
Mill Road,1,"### A crow on the fence
A crow fixes you with one eye, then flies toward the mill.
**Interaction**: Follow it (Perception 3/1)
**Risk**: None"
Mill Road,2,"### Broken cart
An overturned cart blocks half the road.
**Interaction**: Clear it (Strength 3/2)
**Risk**: Failure costs 1 Stamina"
Mill Road,3,"### Cold spot
The air turns suddenly cold.
**Interaction**: Press on (Constitution 3/1)
**Risk**: Failure deals 1 Constitution damage"
Wheel Room,1,"### The turning wheel
The wheel grinds without water.
**Interaction**: Jam the gears (Strength 3/3)
**Risk**: Failure deals 1 Agility damage"
Wheel Room,2,"### Flour dust
Thick dust fills the air.
**Interaction**: Cover your mouth (Constitution 3/1)
**Risk**: Failure deals 1 Constitution damage"
1 group label body
2 Mill Road 1 ### A crow on the fence A crow fixes you with one eye, then flies toward the mill. **Interaction**: Follow it (Perception 3/1) **Risk**: None
3 Mill Road 2 ### Broken cart An overturned cart blocks half the road. **Interaction**: Clear it (Strength 3/2) **Risk**: Failure costs 1 Stamina
4 Mill Road 3 ### Cold spot The air turns suddenly cold. **Interaction**: Press on (Constitution 3/1) **Risk**: Failure deals 1 Constitution damage
5 Wheel Room 1 ### The turning wheel The wheel grinds without water. **Interaction**: Jam the gears (Strength 3/3) **Risk**: Failure deals 1 Agility damage
6 Wheel Room 2 ### Flour dust Thick dust fills the air. **Interaction**: Cover your mouth (Constitution 3/1) **Risk**: Failure deals 1 Constitution damage
@@ -0,0 +1,2 @@
label,name,type,hp,description
1,The Hollow Miller,Spirit,10,"Bound to the wheel by an old promise. Speaks only in the sound of grinding stone."
1 label name type hp description
2 1 The Hollow Miller Spirit 10 Bound to the wheel by an old promise. Speaks only in the sound of grinding stone.
@@ -0,0 +1,47 @@
label,body
Village,"### Village
**Type**: Starting location
A quiet village at the edge of the marsh. The miller's daughter asks after her
father.
**Interaction**: Ask about the mill (Charisma 3/1)
**Supplies**: Rope, torches, one healing draught"
Mill Road,"### Mill Road
**Type**: Route
A muddy track winding between hedgerows toward the mill.
**Interaction**: Spot tracks (Perception 3/1)
**Encounters**: 3 random encounters"
Ground Floor,"### Ground Floor
**Type**: Hub
Sacks of grain, a cold hearth, and a ladder to the wheel room.
**Interaction**:
- Search the hearth (Perception 3/2) — find a brass key
- Climb the ladder (Agility 3/1)"
Secret Cellar,"### Secret Cellar
**Type**: Archive
A low cellar beneath the millstones. Ledgers line the walls.
**Interaction**: Read the ledgers (Intelligence 3/2) — learn the guardian's name
**Clue**: The last entry is dated one year ago, in the miller's hand"
Wheel Room,"### Wheel Room
**Type**: Finale
The great wheel turns in silence, though no water reaches it.
**Interaction**:
- Speak the guardian's name (Charisma 3/3) — peaceful resolution
- Break the wheel (Strength 3/4) — hostile resolution"
1 label body
2 Village ### Village **Type**: Starting location A quiet village at the edge of the marsh. The miller's daughter asks after her father. **Interaction**: Ask about the mill (Charisma 3/1) **Supplies**: Rope, torches, one healing draught
3 Mill Road ### Mill Road **Type**: Route A muddy track winding between hedgerows toward the mill. **Interaction**: Spot tracks (Perception 3/1) **Encounters**: 3 random encounters
4 Ground Floor ### Ground Floor **Type**: Hub Sacks of grain, a cold hearth, and a ladder to the wheel room. **Interaction**: - Search the hearth (Perception 3/2) — find a brass key - Climb the ladder (Agility 3/1)
5 Secret Cellar ### Secret Cellar **Type**: Archive A low cellar beneath the millstones. Ledgers line the walls. **Interaction**: Read the ledgers (Intelligence 3/2) — learn the guardian's name **Clue**: The last entry is dated one year ago, in the miller's hand
6 Wheel Room ### Wheel Room **Type**: Finale The great wheel turns in silence, though no water reaches it. **Interaction**: - Speak the guardian's name (Charisma 3/3) — peaceful resolution - Break the wheel (Strength 3/4) — hostile resolution
+20
View File
@@ -0,0 +1,20 @@
# Column layout
Wrap content in columns using line markers. This is a tttk extension on top of
standard markdown; see `reference.md` for the element and role catalog.
```markdown
|---
Left column
-|-
Right column
--|
```
| Marker | Meaning |
|---|---|
| `\|---` | Start the column container |
| `-\|-` | Divider between columns |
| `--\|` | End the container |
Extra dashes add a `-N` class suffix (e.g. `-|--` → `col-2`) for custom widths.
+39
View File
@@ -0,0 +1,39 @@
# Alerts and mermaid
Standard markdown features that `tttk` renders. They are not tttk syntax — see
`reference.md` for the element and role catalog.
## GitHub-style alerts
```markdown
> [!NOTE]
> 这是一条备注
> [!TIP]
> 提示内容
> [!IMPORTANT]
> 重要信息
> [!WARNING]
> 警告信息
> [!CAUTION]
> 危险警告
```
## Mermaid diagrams
Fenced `mermaid` blocks render as diagrams:
````markdown
```mermaid
graph TD
A[开始] --> B{选择}
B -->|选项 1| C[结果 1]
B -->|选项 2| D[结果 2]
```
````
Supported: flowchart, sequenceDiagram, classDiagram, stateDiagram, erDiagram,
pie.
+365
View File
@@ -0,0 +1,365 @@
# tttk syntax reference
Generated from `src/cli/validate/known.ts` by `npm run gen:reference`.
Do not edit by hand.
Complete catalog of the custom elements, inline directives, and code block
roles that `tttk` understands. Everything not listed here is standard GFM
markdown. Element names are prefixed `md-` and must match exactly.
## Contents
- [Directive syntax](#directive-syntax)
- [Custom elements](#custom-elements)
- [Inline directives](#inline-directives)
- [Code block roles](#code-block-roles)
- [Dice notation](#dice-notation)
- [CSV data files](#csv-data-files)
## Directive syntax
```text
:name[content]{key="value" .class #id}
```
| Part | Meaning | Example |
|---|---|---|
| `:name` | Registered element name | `:md-dice` |
| `[content]` | Directive body | `[2d6+d8]` |
| `{attrs}` | Attributes | `{key="attack"}` |
| `{.class}` | Shorthand for `class` | `{.text-red-500}` |
| `{#id}` | Shorthand for `id` | `{#my-dice}` |
Container directives:
```markdown
::: container
Content here, including nested directives.
:dice[1d20]
:::
```
Nest with more colons (`::::`, `:::::`).
## 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.
Clicking the text rolls; clicking the 🎲 resets to the formula. With `key`, the result is stored in the URL as `?dice-<key>=<value>`.
- 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.
`roll` adds a 🎲 random-tab button; `remix` makes `{{prop}}` references pull from a random row. A `group` column in the CSV produces group tabs automatically.
- 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`).
Prefer the `yaml role=tag` form — it is the only form that supports template layers, per-layer alignment, and card backs. The directive form is legacy and only supports the compact `layers` string.
- 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.
`headingBase` shifts the heading levels of the embedded content.
- Body: path, optional #section
- Attributes: `headingBase`
```markdown
:md-embed[./rules.md#combat]{headingBase=1}
```
### `:md-bg`
Sets the surrounding article card background.
`fit` is one of `cover` (default), `contain`, `fill`, `none`, `scale-down`.
- 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 is a CSS color or a border-image path. Class flags select sides (`t b l r all`), style (`solid dashed dotted double groove ridge inset outset none`), and image repeat (`stretch repeat round space`).
- 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.
`source` is `google`, `emfont`, or `local` (default).
- 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.
`pins` is a space-separated list of `label:x,y` (percent coordinates). `fixed` makes it read-only; otherwise click to add and click a pin to remove. `labelStart` sets the first label (letter or number).
- 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.
The body is a CSV of command templates (`command,parameter,label,description,insertedText`).
- 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.
`start` names the entry node (default `start`).
- Body: Yarn Spinner file
- Attributes: `start`
```markdown
:md-yarn-spinner[./story.yarn]{start="start"}
```
## Inline directives
### `:[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. `label` overlays text, `repeat` duplicates N times, and `join` places a tag name between repeats.
- Body: icon name
- Attributes: `label`, `repeat`, `join`, `class`
```markdown
:[attack] :[shield.svg] :[potion]
```
```markdown
:[attack]{label=ATK repeat=3 join=br}
```
### `:cmd[command]`
Renders a clickable span that dispatches a journal command.
- Body: journal command
- Attributes: `label`
```markdown
:cmd[roll 1d20+5]{label=Roll initiative}
```
```markdown
:cmd[spark dungeon room-type]{label=Random room}
```
## 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`.
The first column header must be a dice formula; each row's first cell is the roll value (a number, or a range like `1-3`). Extra fence attributes are forwarded to the generated `:md-table`, e.g. ` ```csv role=spark-table roll=true `.
````markdown
```csv role=spark-table
d6,结果
1,遭遇强盗
2,平安无事
```
````
````markdown
```markdown role=spark-table
| d6 | 结果 |
|----|------|
| 1 | 遭遇强盗 |
| 2 | 平安无事 |
```
````
### `role=declare`
Reactive variable / tag-modifier definitions.
Columns (any order): `tag`, `threshold`, `key`, `expr`. A row with an empty `tag` declares a variable; a row with a `#tag` applies a modifier when that tag is active.
````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.
Referenced by the derived inline id, e.g. `:md-embed[./text_<hash>]`.
````markdown
```text role=file
Stored as inline content.
```
````
### `role=tag`
YAML rendered as a custom element.
`tag:` names the element, `body:` becomes its content, and any other key becomes an attribute. Fence-line attributes override the YAML body. See `yaml-tag.md`.
````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.
## Dice notation
Used by `:md-dice`, spark-table headers, and the journal `/roll`:
| Notation | Meaning |
|---|---|
| `2d6` | two six-sided dice |
| `1d20+5` | d20 plus modifier |
| `3d6kh1` | keep highest 1 |
| `3d6dl2-1` | drop lowest 2, minus 1 |
| `{d4, d8}` | choose one of the listed dice |
## 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.
+66
View File
@@ -0,0 +1,66 @@
# YAML tag blocks
`` ```yaml role=tag `` renders a custom element from YAML. `tag` names the
element, `body` becomes its content, and any other key becomes an attribute.
Fence-line attributes override the YAML body. See `reference.md` for the
element and role catalog.
````markdown
```yaml role=tag
tag: md-deck
body: ./cards.csv
data-config:
size: 63x88
grid: 5x5
layers:
- prop: title
pos: 1,1-5,1
font: 12
- template: |
**{{name}}** — {{type}}
{{description}}
pos: 1,3-5,8
font: 3
align: l
```
````
Simple cases can stay on the fence line:
````markdown
```yaml role=tag tag=md-dice body=3d6
```
````
## `data-config` layers
Layers accept:
- `prop` — read a CSV column, or `template` — markdown with `{{var}}`
substitution.
- `pos` — `x1,y1-x2,y2` grid span (1-based).
- `font` / `fontSize` — font size in mm.
- `orientation` — `n` / `s` / `e` / `w`.
- `align` — `l` / `c` / `r`, optionally prefixed `t` / `b`.
- `visible` — show or hide the layer.
Use `back_layers` for card backs.
## Legacy `:md-deck` directive
`:md-deck[./cards.csv]{...}` still works but is **legacy** — prefer the YAML tag
block above. It only supports the compact `layers` string and cannot express
template layers or backs.
```markdown
:md-deck[./cards.csv]{size="63x88" grid="5x5" layers="title:1,1-5,1f8 body:1,5-5,8f3"}
```
Compact layer syntax: `prop:x1,y1-x2,y2[f<size>][nsew][[t|b]lcr]`
- `prop` — CSV column to render.
- `x1,y1-x2,y2` — grid span (1-based).
- `f<size>` — font size in mm, e.g. `f8`.
- `nsew` — text orientation (default `n`).
- `l` / `c` / `r` — horizontal align, optionally prefixed `t` / `b` for vertical
(`tl`, `bc`, …).
+3 -2
View File
@@ -4,7 +4,7 @@
具有以下功能:
- cli: 提供一个cli工具,用于将目录内的各种ttrpg文档编译为html。
- cli: 提供一个cli工具,用于预览和校验目录内的各种ttrpg文档。
- markdown:解析以各种格式编写的ttrpg内容,并支持扩展的语法。
- ttrpg组件:用于ttrpg内容的各种UI组件,且可以在markdown中通过扩展语法插入。
@@ -13,7 +13,8 @@
cli使用子命令组织:
- `serve`: 运行一个web服务器预览目录中的内容,并实时侦听更新。
- `compile`: 将目录中的内容输出为带hash路由、单个html入口的web应用。
- `check`: 静态校验目录中的 tttk 文档,输出结构化诊断(文件、行号、代码),存在错误时以退出码 1 结束。`--json` 输出机器可读结果。
- `compile`: [未实现] 存根命令,不会产生任何输出。
## 输入文件
+11 -7
View File
@@ -1,12 +1,15 @@
# TTTK — Tabletop Toolkit
A TTRPG content preview and compilation tool based on `solid.js` and `rsbuild`. Write markdown, preview in browser.
A TTRPG content preview and validation tool based on `solid.js` and `rsbuild`. Write markdown, preview in browser, validate before you ship.
## 快速开始
```bash
# 无需安装,直接使用
npx tttk serve ./content
# 校验内容(存在错误时退出码为 1)
npx tttk check ./content
```
## 文档导航
@@ -14,6 +17,7 @@ npx tttk serve ./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 语法和组件用法 |
@@ -22,7 +26,7 @@ npx tttk serve ./content
## 功能概览
- **CLI 工具**: `tttk serve` 预览模式 和 `tttk compile` 编译模式
- **CLI 工具**: `tttk serve` 预览模式、`tttk check` 校验模式(`tttk compile` 尚未实现)
- **Markdown 解析**: 支持指令语法、YAML 标签、mermaid 图表
- **TTRPG 组件**: 骰子、表格、卡牌、标记、命令追踪器等
@@ -30,11 +34,11 @@ npx tttk serve ./content
| 组件 | 语法 | 说明 |
|------|------|------|
| 🎲 骰子 | `:dice[2d6+d8]` | 掷骰并记录结果 |
| 📊 表格 | `:table[./data.csv]` | 标签页式表格 |
| 🃏 卡牌 | `:md-deck[./cards.csv]` | 卡牌布局 |
| 📍 标记 | `:md-pin[A]{x=40 y=40}` | 图片标记 |
| 📋 追踪器 | `:md-commander` | 命令历史和状态追踪 |
| 🎲 骰子 | `:md-dice[2d6+d8]` | 掷骰并记录结果 |
| 📊 表格 | `:md-table[./data.csv]` | 标签页式表格 |
| 🃏 卡牌 | `:md-deck[./cards.csv]` | 卡牌布局(推荐 `yaml role=tag` 写法) |
| 📍 标记 | `:md-pins[./map.png]{pins="A:25,50"}` | 图片标记 |
| 📋 追踪器 | `:md-commander[./commands.csv]` | 命令历史和状态追踪 |
## 技术栈
+405
View File
@@ -0,0 +1,405 @@
# `tttk check` — implementation plan (validator-first)
Status: **implemented.** Supersedes `docs/compile-plan.md`.
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 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
`tttk check [dir]` statically validates a directory of tttk markdown and
reports **structured diagnostics** with file, line, severity, and a stable
code. It exits non-zero when the content is broken.
The user is an **authoring agent**. The tool's job is to answer, in one
command: *"Does this document set render the way I intend, and if not, exactly
where and why?"* HTML rendering is not the deliverable — a fix list is.
Success criteria:
- Every authoring mistake that would silently drop, mangle, or fail to
resolve content is reported with `file:line:col`.
- Output is machine-readable (`--json`) so an agent can parse and act on it
without scraping human prose.
- Exit code distinguishes clean / warnings-only / errors, so the command is
usable directly in a loop: write → check → fix → repeat.
- Checks run over the **same registry the app uses**, so a clean check implies
the app resolves the content too.
Non-goals (v1): rendering HTML, a site build, hydrating custom elements,
journal/multiplayer, MCP, live reload, and automatic fixes. `check` reports
problems; it never edits content.
## 2. Why not reuse `compile`
The previous plan framed this as `tttk compile` producing HTML. That optimizes
for a human browsing output. An agent cannot easily tell "correct" from
"plausible but inert" by reading `<md-table>./npcs.csv</md-table>` — an
unresolved reference looks identical to a resolved one.
The failure modes that actually hurt authors are invisible in inert HTML and
are currently logged to `console.warn` or thrown:
| Failure | Current behavior | Location |
|---|---|---|
| Unknown `role=` | `console.warn`, block stripped → content vanishes | `src/cli/content-registry.ts:174-179` |
| Missing `md`/`csv`/`yarn`/`svg` asset ref | `resolveContent` returns `null`; fails at runtime only | `src/cli/content-registry.ts:340-372` |
| `role=declare` missing `key`/`expr` | **throws** (uncaught) | `src/cli/completions/declare-parser.ts:44-54` |
| `role=declare` bad row | `console.warn`, silently skipped | `src/cli/content-registry.ts:473-479` |
| spark-table first column not dice | `console.warn`, still stored | `src/cli/content-registry.ts:285-288` |
| Unknown element name (`:md-card`, `:dice`) | renders an inert unknown tag | none |
A validator turns all of these into diagnostics. That is the product.
Rendering, if ever built, is a separate `tttk render` command (see §11).
## 3. Design decisions
### 3.1 Whole-directory registry pass first
References only resolve against a full `ContentRegistry`: inline ids are
doc-scoped (`docContent[docPath]`), and file refs resolve relative to the doc
directory via `posixJoin(posixDir(docPath), id)`
(`src/cli/content-registry.ts:340-372`). So the pipeline is:
```
buildRegistry(dir) // reuse src/cli/commands/serve.ts:88
→ { pathIndex, docContent }
for each raw .md on disk:
→ run checks(source, registry) // §3.2
→ collect Diagnostics
sort by (file, line, col) → report → exit code
```
`buildRegistry` already walks the tree and collects `.md`, `.csv`, `.yarn`,
`.svg` — exactly what reference resolution needs. Reuse it; do not re-walk.
Only `.md` files are validated; non-md files are inputs to resolution.
### 3.2 Validate the **raw** source, resolve with the registry
Do not run checks over `registry.pathIndex`. That string has fences already
replaced/stripped (`scanDoc`), so offsets no longer map to the author's file
and line numbers would be wrong.
Instead: run checks over the **raw file contents**, tracking byte offsets, and
use the registry purely to *resolve* refs. This works because:
- Directives (`:md-table[./npcs.csv]`, `:md-embed[./x.md#sec]`) appear
literally in raw source — that is where we locate and validate them.
- `role=spark-table` blocks are validated from their raw fence; the generated
`:md-table[./csv_<hash>]` never appears in the author's source, so it needs
no validation (the generator is trusted).
- Inline ids referenced by hand (`:md-embed[./text_<hash>]`) resolve through
`docContent`.
Offsets are cheap: the `String.replace` callback in `scanDoc` already receives
the match offset as a trailing argument, and the directive/fence scanners we
add can capture `RegExp.exec` index directly. Convert offset → line/col with a
line-start table built once per source (`src/cli/validate/source.ts`).
### 3.3 Diagnostics on raw source must skip fenced examples
Docs legitimately contain example directives inside fenced blocks
(`reference.md` does). The scanner must track fence state (like
`stripFencedBlocks`, `content-registry.ts:494-510`) and mark matches as
`inFence`; refs/roles inside fences are examples, not live content. Roles are
only live on the fence *info line*; directive refs are only live outside
fences.
## 4. Diagnostic model
```ts
export type Severity = "error" | "warning" | "info";
export interface Diagnostic {
code: string; // stable, e.g. "TTTK-R001"
severity: Severity;
message: string; // one line, actionable
file: string; // project-relative, posix
line: number; // 1-based
column: number; // 1-based
hint?: string; // concrete fix
snippet?: string; // offending source line
}
```
Rules:
- Codes are stable and documented; agents may key off them.
- Messages name the offending value (`unknown role "spark-tabel"`), not just
the category.
- `hint` is imperative and concrete (`did you mean \`spark-table\`?`).
## 5. Rule catalog
Priority: **P0** ships in v1, **P1** follows, **P2** experimental.
### Structural
| Code | Sev | Rule | Prio |
|---|---|---|---|
| `TTTK-S001` | error | Markdown pipeline throws while parsing the doc | P1 |
| `TTTK-S002` | error | Unterminated container directive (`:::` with no close) | P1 |
### Directives
| Code | Sev | Rule | Prio |
|---|---|---|---|
| `TTTK-D001` | error | Unknown `:md-*` element name | **P0** |
| `TTTK-D002` | error | Malformed attribute block (`{pins=A:30,40}` unquoted with spaces) | P1 |
| `TTTK-D003` | info | Legacy `:md-deck[./x.csv]` directive — prefer `yaml role=tag` | P2 |
`D001` uses an explicit allowlist of the **registered** custom elements. From
`customElement(...)` call sites today (verified):
```
md-bg md-border md-commander md-deck md-dice md-embed
md-font md-link md-pins md-table md-yarn-spinner
```
Free-form names are allowed for **container** directives (`::: name`) — those
are CSS classes, not elements. Inline `:[name]` is the icon form and is not
governed by this list.
> **Already-real finding:** `:md-card` is matched by the completion scanner
> (`content-registry.ts:442`) but is registered nowhere, so it renders as an
> inert tag. `D001` would flag it. This is the class of bug the tool exists for.
### Code-block roles
| Code | Sev | Rule | Prio |
|---|---|---|---|
| `TTTK-R001` | error | Unknown `role=` value (block is silently stripped) | **P0** |
| `TTTK-R002` | warning | `role=spark-table` first column header is not a dice formula | P1 |
| `TTTK-R003` | error | `role=declare` missing required `key`/`expr` columns | **P0** |
| `TTTK-R004` | warning | `role=declare` row skipped by the parser | P1 |
| `TTTK-R005` | error | `role=tag` body is not valid YAML | **P0** |
| `TTTK-R006` | error | `role=tag` missing `tag:` or names an unknown element | **P0** |
`R003`/`R004` come from `parseDeclareCsv` (`declare-parser.ts:44-54`) — it
throws today; the check must catch and localize it rather than crash.
`R005`/`R006` parse with `js-yaml` (already a dependency) and reuse the `D001`
allowlist.
### Content references
| Code | Sev | Rule | Prio |
|---|---|---|---|
| `TTTK-C001` | error | Reference does not resolve (missing file or undefined inline id) | **P0** |
| `TTTK-C002` | warning | Reference resolves to an unexpected file type | P1 |
| `TTTK-C003` | info | Same CSV referenced by two `:md-table` in one doc | P1 |
| `TTTK-C004` | error | `:md-embed` cycle | P1 |
`C001` covers every ref-bearing directive — `:md-table`, `:md-commander`,
`:md-pins`, `:md-bg`, `:md-border`(image), `:md-yarn-spinner`,
`:md-link`/`:md-embed` path part, and `role=tag` `body:` — by calling
`resolveContentEntry` (`content-registry.ts:340`) and flagging `null`. This is
the single highest-value check.
### Links and anchors
| Code | Sev | Rule | Prio |
|---|---|---|---|
| `TTTK-L001` | error | `:md-link`/`:md-embed`/markdown link target file missing | **P0** |
| `TTTK-L002` | warning | `#section` anchor matches no heading slug | P1 |
Slugs come from the same `Slugger` used by `deriveLinks`
(`content-registry.ts:523-536`) so the validator and the app agree.
### Assets
| Code | Sev | Rule | Prio |
|---|---|---|---|
| `TTTK-A001` | warning | `:[icon]` not found under any `assets/` dir within the check root | P1 |
| `TTTK-A002` | warning | `:md-bg`/`:md-pins`/`:md-border` image path missing | P1 |
`A001` mirrors `buildIconSrc` (`src/markdown/icon.ts:23-40`): search
`<root>/<docdir>/assets/`, then each ancestor down to `<root>/assets/`. We
**assume no `assets/` directory exists above the check root**, which makes the
lookup definitive rather than a guess — hence a `warning`, not `info`.
### CSV data files
| Code | Sev | Rule | Prio |
|---|---|---|---|
| `TTTK-V001` | error | CSV fails to parse (bad quoting) | P1 |
| `TTTK-V002` | warning | Row column count ≠ header column count | P1 |
| `TTTK-V003` | warning | Duplicate `label` values | P1 |
| `TTTK-V004` | warning | `{{prop}}` key is not a CSV column or front-matter key | P1 |
| `TTTK-V005` | warning | spark-table row roll value is not a number/range | P2 |
`V004` mirrors `processVariables` (`src/components/utils/csv-loader.ts:86-99`):
a `{{key}}` resolves when `key` is a CSV header or a front-matter key (front
matter is merged into every row, `csv-loader.ts:56-61`). Unresolved keys are
left verbatim in the output, so the check is definitive. Honor the parser's
options: BOM stripped, `#` lines are comments, headers/cells trimmed, empty
lines skipped (`csv-loader.ts:45-51`).
## 6. CLI surface
```
tttk check [dir] [options]
Arguments:
[dir] directory to validate (default ".")
Options:
--json emit a JSON report (see §6.1)
--strict treat warnings as errors
--quiet report errors only
--no-color disable ANSI color
-h, --help
```
Wire in `src/cli/index.ts` alongside `serve` (commander pattern at
`src/cli/index.ts:17-30`).
### 6.1 Output
Human (default), grouped by file:
```
content/adventures/hollow-mill/adventure.md
12:1 error TTTK-C001 :md-table reference "./npcs.csv" does not resolve
hint: expected content/adventures/hollow-mill/npcs.csv
40:8 warning TTTK-R002 spark-table first header "Name" is not a dice formula
hint: use d6, 2d6+1, …
2 errors, 1 warning
```
`--json`:
```json
{
"root": "content",
"filesChecked": 12,
"diagnostics": [ { "code": "TTTK-C001", "severity": "error", "message": "…",
"file": "adventures/hollow-mill/adventure.md",
"line": 12, "column": 1, "hint": "…" } ],
"summary": { "errors": 2, "warnings": 1, "infos": 0 }
}
```
### 6.2 Exit codes
| Code | Meaning |
|---|---|
| `0` | No errors (warnings allowed unless `--strict`) |
| `1` | At least one error, or any warning under `--strict` |
| `2` | Usage / IO / internal failure |
Exit code is the contract that makes `check` usable in an agent loop.
## 7. Implementation steps
1. **`src/cli/validate/source.ts`** — line-start table; `offsetToPosition`.
2. **`src/cli/validate/diagnostics.ts`** — `Diagnostic`, `Sink` (collector),
sort, human + JSON formatters.
3. **`src/cli/validate/known.ts`** — the catalog is the single source of
truth: `KNOWN_ELEMENTS` (the 11 verified names, with body/attrs/description/
examples), `KNOWN_ROLES = { spark-table, declare, file, tag }`, expected
file-type map for `C002`. `docs/reference.md` is generated from it (§7.11).
4. **`src/cli/validate/scan.ts`** — fence-aware scanner yielding
`{ name, body, attrs, offset, inFence }` for directives, plus role info
lines. Reuse `parseBlockAttrs` (`src/markdown/block-attrs.ts`); do not
reimplement attribute parsing.
5. **Checks** — `checks/roles.ts`, `checks/directives.ts`, `checks/refs.ts`,
`checks/links.ts`, `checks/csv.ts`, `checks/parse.ts`. Pure functions
`(source, registry, docPath, sink) => void`.
6. **`src/cli/validate/index.ts`** — `runCheck(dir)`: `buildRegistry`, iterate
raw `.md` sorted, run checks, sort diagnostics, compute summary.
7. **`src/cli/commands/check.ts`** — commander handler: parse options, run,
print, `process.exitCode = …`. Add `CheckCommandHandler` to
`src/cli/types.ts`.
8. **`src/cli/index.ts`** — register `check` (alias `lint`).
9. **Refactor `scanDoc` to report into a sink** (optional but removes drift):
give `scanDoc(content, docPath, sink?)` an optional sink, replace
`console.warn` at `content-registry.ts:175-179` and `:285-288` with
`sink.report(...)`, and pass the match offset through. Default no-op keeps
existing callers and tests unchanged. This lets the CLI share one code path
for role diagnostics instead of duplicating the switch.
10. **Prereq for `S001`** — make `src/markdown/mermaid.ts` browser-safe so
`parseMarkdown` is importable from the CLI: **delete** `import mermaid`
and the top-level `mermaid.initialize` (the extension only emits
`<pre class="mermaid">`), then call `mermaid.initialize({ theme: "dark" })`
in `src/main.tsx` before the existing `mermaid.run()` calls
(`Article.tsx:89`, `md-embed.tsx:40`). Required for v1.
11. **Reference generator** — `scripts/generate-reference.ts` renders the
`known.ts` catalog to markdown. Canonical output `docs/reference.md`
(versioned, testable); `--out` can target the skill copy at
`.agents/skills/tttk-authoring/reference.md`. Add `npm run
gen:reference`.
12. **Drift guard** — a test that greps `customElement("md-*")` call sites
under `src/components` and asserts each appears in `known.ts` (and vice
versa). This is what would have caught `:md-card`.
## 8. Tests
`src/cli/validate/*.test.ts`, fixture-driven (temp dirs), covering:
- **Positive:** a valid multi-file fixture produces zero diagnostics; exit 0.
- **Negative, one per P0 code:** unknown role; missing declare columns;
invalid tag YAML; unknown tag element; unresolved `:md-table`; missing
`:md-link` target; unknown `:md-*` element. Assert `code`, `file`, `line`.
- **Fence awareness:** an example directive inside a fenced block is *not*
reported; the same directive outside a fence is.
- **No false positives:** a doc that round-trips cleanly, including
`:md-card`-free legacy content.
- **Exit codes:** 0 / 1 / 1-under-`--strict` / 2 on a nonexistent dir.
- **JSON shape:** stable, sorted, one entry per diagnostic.
## 9. Docs and skill
- Update `docs/cli.md`: replace the `compile` section with `check`; document
codes, `--json`, exit codes.
- Update `README.md`.
- Update `.agents/skills/tttk-authoring/SKILL.md`: the verification step
becomes **`npx tttk check ./content`**, and the workflow becomes
write → check → fix. Remove the "compile is a no-op stub" note. Cross-link
the code catalog (`reference.md`) with the diagnostic codes.
- `reference.md` is generated from `known.ts` (§7.11) — do not hand-edit. The
skill's copy is refreshed by `npm run gen:skill-reference`; deeper authoring
topics live in the skill's `yaml-tag.md`, `csv.md`, `layout.md`, and
`markdown.md` (see `docs/skill-reference-plan.md`).
## 10. Resolved decisions
1. **`known.ts` is the source of truth; `reference.md` is generated from it**
(§7.11). A drift test (§7.12) keeps both honest.
2. **`S001` is in scope.** Do the mermaid refactor (§7.10).
3. **Icon existence is checked** (`A001`), assuming no `assets/` directory
above the check root, which makes the lookup definitive.
4. **`V004` (`{{prop}}` keys) is in scope for v1.**
5. **Report only — no `--fix`.** `check` never edits content.
6. **`compile` stays a stub,** and its `--help` text now says so explicitly
(`src/cli/index.ts:27`).
## 11. Risks
- **Icon check assumes no parent assets.** `A001` treats `<root>` as the top
of the icon search. If a project keeps shared `assets/` above the check root,
`A001` will false-positive; the assumption is documented in §5.
- **False positives are fatal to adoption.** An agent that gets spurious
errors stops trusting the tool. Prefer P0 precision over coverage; gate
noisy checks behind `--strict` or `info`.
- **Divergence from the app.** Do not reimplement attribute parsing, dice
detection, slugging, or role dispatch — import `parseBlockAttrs`,
`resolveContentEntry`, `parseDeclareCsv`, `inspectSparkTableCsv`,
`Slugger`. Reuse `buildRegistry` for the walk.
- **Offsets vs. stripped content.** Validate raw source only (§3.2); mixing in
`pathIndex` will produce wrong line numbers.
- **Windows paths.** Use `node:path`; normalize to posix keys via
`normalizePathKey` for all registry lookups. Repo targets Windows.
- **Encoding.** Read UTF-8 explicitly.
- **Declare parser throws.** Wrap `parseDeclareCsv`; a throw must become a
localized diagnostic, never a stack trace.
+107 -27
View File
@@ -1,6 +1,8 @@
# CLI 使用说明
TTTK 提供一个 CLI 工具,用于将目录内的各种 TTRPG 文档编译为 HTML。
TTTK 提供一个 CLI 工具,用于预览和校验目录内的 TTRPG 文档。
> 将内容编译为 HTML 的 `compile` 命令尚未实现(见下方说明)。
## 安装
@@ -59,46 +61,118 @@ tttk serve ./my-ttrpg-content
tttk serve ./docs -p 8080
```
### compile - 编译模式
### check - 校验模式
将目录中的内容输出为带 hash 路由、单个 HTML 入口的 Web 应用。
静态校验目录内的 tttk 文档,输出结构化诊断(文件、行号、代码),存在错误时以退出码 `1` 结束。
```bash
tttk compile [dir] -o ./dist/output
tttk check [dir] --json
```
`lint` 是 `check` 的别名。
**参数:**
| 参数 | 说明 | 默认值 |
|------|------|--------|
| `[dir]` | 要编译的目录 | `.` (当前目录) |
| `[dir]` | 要校验的目录 | `.` (当前目录) |
**选项:**
| 选项 | 说明 | 默认值 |
|------|------|--------|
| `-o, --output <dir>` | 输出目录 | `./dist/output` |
| `--json` | 以 JSON 输出诊断 | `false` |
| `--strict` | 将警告视为错误 | `false` |
| `--quiet` | 只报告错误 | `false` |
**功能:**
**退出码:**
- 扫描目录下的所有 `.md` 文件
- 解析 Markdown 并生成路由
- 打包为带 hash 路由的单个 HTML 入口
- 复制引用的资源文件(图片、CSV 等)
| 代码 | 含义 |
|------|------|
| `0` | 无错误(`--strict` 下存在警告也会失败) |
| `1` | 存在错误(或 `--strict` 下存在警告) |
| `2` | 用法 / IO 错误(例如目录不存在) |
**输出示例:**
```
content/adventures/hollow-mill/adventure.md
12:1 error TTTK-C001 :md-table reference "./npcs.csv" does not resolve
hint: expected a file relative to this document, or an inline id defined in it
40:8 warning TTTK-R002 spark-table first header "Name" is not a dice formula
2 errors, 1 warning
12 files checked
```
**诊断代码:**
| 代码 | 级别 | 说明 |
|------|------|------|
| `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 未定义) |
| `TTTK-C002` | warning | 引用解析到的文件类型不符合预期 |
| `TTTK-C003` | info | 同一文档中重复引用同一 CSV |
| `TTTK-C004` | error | `:md-embed` 循环引用 |
| `TTTK-L001` | error | 链接目标文件不存在 |
| `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 行的骰点值不是数字或范围 |
完整的语法目录(元素、属性、代码块角色)由 `src/cli/validate/known.ts` 生成到 `docs/reference.md`,可用 `npm run gen:reference` 重新生成。
**实现说明:**
- 校验复用与应用相同的 `scanDoc` 与内容注册表,因此通过校验意味着应用也能解析。
- 定位基于**原始文件**(行号准确),围栏代码块与行内代码中的示例语句会被跳过。
- 图标校验假设校验根目录之上不存在 `assets/` 目录。
**示例:**
```bash
# 编译当前目录
tttk compile
# 校验当前目录
tttk check
# 编译指定目录并输出到指定位置
tttk compile ./content -o ./build
# 校验指定目录并输出 JSON
tttk check ./content --json
# 编译并部署
tttk compile ./docs -o ./public && npm run preview
# CI:只关注错误
npx tttk check ./content --quiet
```
### compile - 编译模式(未实现)
> **存根命令。** `compile` 目前不会产生任何输出,`--help` 中也已标明。
> 请使用 `tttk serve` 预览内容、`tttk check` 校验内容。
原计划的用法如下(仅供参考,尚未实现):
```bash
tttk compile [dir] -o ./dist/output
```
| 参数 | 说明 | 默认值 |
|------|------|--------|
| `[dir]` | 要编译的目录 | `.` (当前目录) |
| 选项 | 说明 | 默认值 |
|------|------|--------|
| `-o, --output <dir>` | 输出目录 | `./dist/output` |
## 输入文件
CLI 会搜索目录下的以下文件:
@@ -106,6 +180,7 @@ CLI 会搜索目录下的以下文件:
- `.md` - Markdown 文档
- `.csv` - 表格数据
- `.yarn` - Yarn Spinner 叙事文件
- `.svg` - 矢量图标
### 文件组织建议
@@ -126,19 +201,21 @@ my-ttrpg-content/
### 相对路径引用
若 Markdown 文件通过相对路径引用了其他文件(如图片、CSV),CLI 会在打包时自动处理这些引用:
若 Markdown 文件通过相对路径引用了其他文件(如图片、CSV),引用会相对于当前文档所在目录解析:
```markdown
<!-- 引用同目录下的 CSV -->
:table[./sparks.csv]
:md-table[./sparks.csv]
<!-- 引用子目录的图片 -->
![地图](./maps/city-map.png)
<!-- 引用上级目录的文件 -->
:deck[../data/cards.csv]
:md-deck[../data/cards.csv]
```
使用 `tttk check` 可以校验这些引用是否都能解析。
## 开发服务器特性
### 实时索引
@@ -165,14 +242,17 @@ my-ttrpg-content/
tttk serve -p 8080
```
### 编译输出为空
### check 报告了很多警告
检查输入目录是否包含 `.md` 文件:
先用 `--quiet` 只看错误,或在 CI 中配合 `--strict`:
```bash
# 查看目录内容
ls -R ./content
# 重新编译
tttk compile ./content -o ./dist/output
tttk check ./content --quiet
tttk check ./content --json
```
警告通常确有其事(例如锚点或图标不存在),建议逐条确认。
### compile 没有输出
`compile` 目前是未实现的存根命令,不会产生任何输出。请使用 `tttk serve` 预览,使用 `tttk check` 校验。
+30 -1
View File
@@ -31,7 +31,10 @@ npm test
tttk/
├── src/
│ ├── cli/ # CLI 工具源码
│ │ ├── commands/ # 命令实现 (serve, compile)
│ │ ├── commands/ # 命令实现 (serve, check, compile)
│ │ ├── validate/ # tttk check 校验器(扫描、规则、诊断)
│ │ ├── content-registry.ts # 内容注册表(CLI / 前端共享)
│ │ ├── content-index.ts # 目录文件递归读取
│ │ ├── index.ts # CLI 入口
│ │ └── types.ts # 类型定义
│ ├── components/ # TTRPG 组件
@@ -57,6 +60,9 @@ tttk/
│ ├── styles.css # 样式
│ ├── index.html # HTML 模板
│ └── global.d.ts # 类型声明
├── .agents/
│ └── skills/
│ └── tttk-authoring/ # 随项目维护的 agent skill(见下文)
├── bin/
│ └── cli/ # CLI 二进制文件
├── content/ # 示例内容
@@ -68,6 +74,29 @@ tttk/
└── jest.config.js
```
## Agent skills
`tttk-authoring` skill 由本项目维护,源码位于
`.agents/skills/tttk-authoring/`。要让 agent 加载它,把全局 skill 目录
链接到仓库内的副本:
```bash
# Windows(无需管理员权限的目录联接)
cmd //c mklink /J %USERPROFILE%\.agents\skills\tttk-authoring "%CD%\.agents\skills\tttk-authoring"
# macOS / Linux
ln -s "$PWD/.agents/skills/tttk-authoring" ~/.agents/skills/tttk-authoring
```
`reference.md` 由 `known.ts` 生成,不要手改:
```bash
npm run gen:skill-reference
```
其余文件(`SKILL.md`、`yaml-tag.md`、`csv.md`、`layout.md`、`markdown.md`)
为手写内容。
## 技术栈
| 类别 | 技术 |
+365
View File
@@ -0,0 +1,365 @@
# tttk syntax reference
Generated from `src/cli/validate/known.ts` by `npm run gen:reference`.
Do not edit by hand.
Complete catalog of the custom elements, inline directives, and code block
roles that `tttk` understands. Everything not listed here is standard GFM
markdown. Element names are prefixed `md-` and must match exactly.
## Contents
- [Directive syntax](#directive-syntax)
- [Custom elements](#custom-elements)
- [Inline directives](#inline-directives)
- [Code block roles](#code-block-roles)
- [Dice notation](#dice-notation)
- [CSV data files](#csv-data-files)
## Directive syntax
```text
:name[content]{key="value" .class #id}
```
| Part | Meaning | Example |
|---|---|---|
| `:name` | Registered element name | `:md-dice` |
| `[content]` | Directive body | `[2d6+d8]` |
| `{attrs}` | Attributes | `{key="attack"}` |
| `{.class}` | Shorthand for `class` | `{.text-red-500}` |
| `{#id}` | Shorthand for `id` | `{#my-dice}` |
Container directives:
```markdown
::: container
Content here, including nested directives.
:dice[1d20]
:::
```
Nest with more colons (`::::`, `:::::`).
## 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.
Clicking the text rolls; clicking the 🎲 resets to the formula. With `key`, the result is stored in the URL as `?dice-<key>=<value>`.
- 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.
`roll` adds a 🎲 random-tab button; `remix` makes `{{prop}}` references pull from a random row. A `group` column in the CSV produces group tabs automatically.
- 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`).
Prefer the `yaml role=tag` form — it is the only form that supports template layers, per-layer alignment, and card backs. The directive form is legacy and only supports the compact `layers` string.
- 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.
`headingBase` shifts the heading levels of the embedded content.
- Body: path, optional #section
- Attributes: `headingBase`
```markdown
:md-embed[./rules.md#combat]{headingBase=1}
```
### `:md-bg`
Sets the surrounding article card background.
`fit` is one of `cover` (default), `contain`, `fill`, `none`, `scale-down`.
- 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 is a CSS color or a border-image path. Class flags select sides (`t b l r all`), style (`solid dashed dotted double groove ridge inset outset none`), and image repeat (`stretch repeat round space`).
- 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.
`source` is `google`, `emfont`, or `local` (default).
- 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.
`pins` is a space-separated list of `label:x,y` (percent coordinates). `fixed` makes it read-only; otherwise click to add and click a pin to remove. `labelStart` sets the first label (letter or number).
- 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.
The body is a CSV of command templates (`command,parameter,label,description,insertedText`).
- 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.
`start` names the entry node (default `start`).
- Body: Yarn Spinner file
- Attributes: `start`
```markdown
:md-yarn-spinner[./story.yarn]{start="start"}
```
## Inline directives
### `:[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. `label` overlays text, `repeat` duplicates N times, and `join` places a tag name between repeats.
- Body: icon name
- Attributes: `label`, `repeat`, `join`, `class`
```markdown
:[attack] :[shield.svg] :[potion]
```
```markdown
:[attack]{label=ATK repeat=3 join=br}
```
### `:cmd[command]`
Renders a clickable span that dispatches a journal command.
- Body: journal command
- Attributes: `label`
```markdown
:cmd[roll 1d20+5]{label=Roll initiative}
```
```markdown
:cmd[spark dungeon room-type]{label=Random room}
```
## 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`.
The first column header must be a dice formula; each row's first cell is the roll value (a number, or a range like `1-3`). Extra fence attributes are forwarded to the generated `:md-table`, e.g. ` ```csv role=spark-table roll=true `.
````markdown
```csv role=spark-table
d6,结果
1,遭遇强盗
2,平安无事
```
````
````markdown
```markdown role=spark-table
| d6 | 结果 |
|----|------|
| 1 | 遭遇强盗 |
| 2 | 平安无事 |
```
````
### `role=declare`
Reactive variable / tag-modifier definitions.
Columns (any order): `tag`, `threshold`, `key`, `expr`. A row with an empty `tag` declares a variable; a row with a `#tag` applies a modifier when that tag is active.
````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.
Referenced by the derived inline id, e.g. `:md-embed[./text_<hash>]`.
````markdown
```text role=file
Stored as inline content.
```
````
### `role=tag`
YAML rendered as a custom element.
`tag:` names the element, `body:` becomes its content, and any other key becomes an attribute. Fence-line attributes override the YAML body. See `yaml-tag.md`.
````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.
## Dice notation
Used by `:md-dice`, spark-table headers, and the journal `/roll`:
| Notation | Meaning |
|---|---|
| `2d6` | two six-sided dice |
| `1d20+5` | d20 plus modifier |
| `3d6kh1` | keep highest 1 |
| `3d6dl2-1` | drop lowest 2, minus 1 |
| `{d4, d8}` | choose one of the listed dice |
## 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.
+169
View File
@@ -0,0 +1,169 @@
# tttk skill reference — reorganization plan
Status: **implemented.** Follows the `tttk check` work that made
`docs/reference.md` a generated artifact (see `docs/check-plan.md` §7.11).
Landed: `known.ts` carries element/role `details`, inline specs, and dice
notation; `reference.ts` renders them; `scripts/generate-reference.ts` expands
`~` in `--out`; `npm run gen:skill-reference` refreshes the skill copy. The
skill's `reference.md` is now generated and byte-identical to
`docs/reference.md`, with the extra content moved to `layout.md`, `yaml-tag.md`,
`csv.md`, and `markdown.md`.
The skill is now **owned by the repo** at `.agents/skills/tttk-authoring/` and
activated by symlinking `~/.agents/skills/tttk-authoring` to it (see
`docs/development.md` § Agent skills).
## 1. Goal
Make the `tttk-authoring` skill's `reference.md` a **generated** file — the same
catalog the validator uses — and relocate the authoring content it does not
cover into focused companion files. One source of truth (`known.ts`), no
hand-maintained element list, and no content lost in the swap.
## 2. Current state
The skill (originally `~/.agents/skills/tttk-authoring/`, now
`.agents/skills/tttk-authoring/`) had a hand-written `reference.md` (~465 lines)
and `SKILL.md`. The repo generates `docs/reference.md` from
`src/cli/validate/known.ts` via `npm run gen:reference`.
What the generated doc already covers:
- Directive syntax (brief)
- Custom elements: table + per-element summary, body, attrs, examples
- Inline directives: icons, `:cmd` (brief)
- Code block roles: `spark-table`, `declare`, `file`, `tag` (brief)
- CSV data files (brief)
What only the hand-written skill copy has (the "extra content"):
| # | Section | Notes |
|---|---|---|
| A | Directive syntax table + container example | richer than generated |
| B | Per-element prose | md-dice behavior, md-table `roll`/`remix`/`group`, md-deck legacy layer syntax, md-bg `fit` values, md-border class flags, md-font `source` values, md-pins behavior, md-commander CSV columns |
| C | Icon attributes | `label`, `repeat`, `join`, `class` |
| D | Column layout | `\|---` / `-\|-` / `--\|` markers |
| E | `role=declare` column semantics | `tag`/`threshold`/`key`/`expr` |
| F | YAML tag blocks | `data-config`, layers, `back_layers`, fence-line overrides |
| G | CSV authoring | clean-CSV guidance, `label`/`body`, front matter (avoid) |
| H | Alerts and mermaid | GitHub alerts, mermaid diagrams |
| I | Dice notation | notation table |
| J | Validation codes | already duplicated in `SKILL.md` |
## 3. Target skill layout
```
tttk-authoring/
├── SKILL.md # workflow, rules, validation codes, links
├── reference.md # GENERATED from known.ts — elements, roles, inline, dice, CSV basics
├── layout.md # column layout
├── yaml-tag.md # role=tag, data-config, deck layers and backs
├── csv.md # CSV authoring and front matter
├── markdown.md # alerts and mermaid
└── examples/ # unchanged
```
`reference.md` is the only generated file; the rest are hand-written and linked
from `SKILL.md`.
## 4. Content mapping
| Source | Destination |
|---|---|
| A directive syntax | generator (`reference.ts`) — add the part/meaning table + container example |
| B per-element prose | `known.ts` `details` field → rendered into `reference.md` |
| C icon attributes | `known.ts` inline specs → rendered into `reference.md` |
| E declare semantics | `known.ts` `RoleSpec.details` → rendered into `reference.md` |
| I dice notation | `known.ts` `DICE_NOTATION` → rendered into `reference.md` |
| D column layout | `layout.md` |
| F YAML tag blocks | `yaml-tag.md` |
| G CSV authoring | `csv.md` |
| H alerts and mermaid | `markdown.md` |
| J validation codes | dropped — `SKILL.md` owns the code tables |
**Decision (recommended):** fold B/C/E/I into `known.ts` rather than a separate
`components.md`. It keeps the generated reference self-sufficient and avoids a
second, drifting description of the same elements. The alternative — a
hand-written `components.md` — is rejected because it reintroduces the drift the
generator exists to prevent.
## 5. Generator changes
`src/cli/validate/known.ts`:
- `ElementSpec.details?: string` — one paragraph of authoring prose.
- `ElementSpec.attrs` → optional richer `{ name, description }[]` so the
reference can render an attribute table (keep the plain list if not worth it).
- `InlineSpec` for `:[icon]` and `:cmd` with body/attrs/summary/examples.
- `RoleSpec.details?: string` for `declare` column semantics.
- `DICE_NOTATION: { notation, meaning }[]`.
`src/cli/validate/reference.ts`:
- Render `details`, the attribute table, inline specs, and dice notation.
- Add a `Contents` TOC.
- Upgrade the directive-syntax section (table + container example).
`scripts/generate-reference.ts`:
- Expand a leading `~` in `--out` to `os.homedir()` so the skill copy can be
regenerated cross-platform.
`package.json`:
- `gen:reference` — unchanged (writes `docs/reference.md`).
- `gen:skill-reference` — `gen:reference -- --out .agents/skills/tttk-authoring/reference.md`.
## 6. New skill file outlines
- **`layout.md`** — column markers, the `-N` width suffix, a worked example.
- **`yaml-tag.md`** — `role=tag` shape (`tag:`, `body:`, other keys as attrs),
fence-line overrides, `data-config` layer fields (`prop`/`template`, `pos`,
`font`/`fontSize`, `orientation`, `align`, `visible`), `back_layers`, and the
legacy `:md-deck` compact layer syntax.
- **`csv.md`** — clean-CSV guidance, `label`/`body` conventions, `group` tabs,
`{{prop}}` interpolation, quoting rules, and the discouraged front-matter form.
- **`markdown.md`** — GitHub alerts and mermaid (standard markdown, not tttk
syntax).
## 7. SKILL.md changes
- Update the "Reference" section to link `reference.md` plus the four new files.
- Keep the validation code tables in `SKILL.md` (they are the agent's quick
reference); `reference.md` no longer carries them.
- Reword "check `reference.md` when unsure" to point at the right file per topic.
## 8. Steps
1. Enrich `known.ts` (details, inline specs, role details, dice notation).
2. Update `reference.ts` and `generate-reference.ts`; regenerate
`docs/reference.md`; keep the `known.test.ts` drift guard green.
3. Extract D/F/G/H from the old skill `reference.md` into `layout.md`,
`yaml-tag.md`, `csv.md`, `markdown.md`.
4. Regenerate the skill's `reference.md` with `npm run gen:skill-reference`.
5. Update `SKILL.md` links.
6. Verify (§9).
## 9. Verification
- `npm run gen:reference` leaves `docs/reference.md` unchanged (idempotent).
- `npx jest` passes, including the reference drift guard.
- Diff the old skill `reference.md` against the new file set and confirm every
section in §2's table has a destination — no content silently dropped.
- `npx tttk check ./content` still runs clean (the catalog changes are
documentation-only, but the drift guard protects `known.ts`).
## 10. Risks
- **Content loss during extraction.** Mitigate with the §4 mapping as a
checklist and a manual diff before deleting the old file.
- **`known.ts` bloat.** Keep `details` to one paragraph per element; long-form
material belongs in the companion files.
- **Skill path portability.** The skill now lives in the repo, so
`gen:skill-reference` writes to a stable path; `~` expansion in `--out`
remains for ad-hoc targets. Activation is a symlink, which is environment
setup rather than repo state — documented in `docs/development.md`.
- **Two references.** `docs/reference.md` and the skill copy are both generated
from `known.ts`; they must be regenerated together. Note this in
`docs/check-plan.md` §9.
+2
View File
@@ -29,6 +29,8 @@
"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",
"gen:skill-reference": "node --loader ts-node/esm ./scripts/generate-reference.ts --out .agents/skills/tttk-authoring/reference.md",
"test": "jest",
"prepare": "npx husky",
"prepublishOnly": "npm run build"
+7
View File
@@ -18,6 +18,13 @@ export default defineConfig({
},
},
rspack: {
resolve: {
// Allow TypeScript sources to use explicit `.js` specifiers (required
// by the CLI's NodeNext build) while the bundler resolves them to .ts.
extensionAlias: {
".js": [".ts", ".tsx", ".js"],
},
},
module: {
rules: [
{
+31
View File
@@ -0,0 +1,31 @@
#!/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 { homedir } from "os";
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;
}
/** Expand a leading `~` so the skill copy can be targeted cross-platform. */
function expandHome(path: string): string {
if (path === "~") return homedir();
if (path.startsWith("~/") || path.startsWith("~\\")) {
return resolve(homedir(), path.slice(2));
}
return path;
}
const out = resolve(expandHome(argValue("--out") ?? "docs/reference.md"));
mkdirSync(dirname(out), { recursive: true });
writeFileSync(out, renderReference(), "utf-8");
console.log(`wrote ${out}`);
+23
View File
@@ -0,0 +1,23 @@
import type { CheckCommandHandler } from "../types.js";
import { formatHuman, formatJson } from "../validate/diagnostics.js";
import { runCheck } from "../validate/index.js";
export const checkCommand: CheckCommandHandler = async (dir, options) => {
let report;
try {
report = runCheck(dir);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 2;
return;
}
console.log(
options.json ? formatJson(report) : formatHuman(report, options.quiet),
);
const failed =
report.summary.errors > 0 ||
(options.strict && report.summary.warnings > 0);
if (failed) process.exitCode = 1;
};
+2 -33
View File
@@ -13,6 +13,7 @@ import {
deriveCompletions,
type ContentRegistry,
} from "../content-registry.js";
import { readContentIndex } from "../content-index.js";
import type { CompletionsPayload } from "../completions/types.js";
interface ContentIndex {
@@ -86,39 +87,7 @@ function getBestIP(): string {
* 扫描目录内的 .md 等文件,构建内容注册表(路径索引 + 每文档内联内容)
*/
export function buildRegistry(dir: string): ContentRegistry {
const index: Record<string, string> = {};
function scan(currentPath: string, relativePath: string) {
const entries = readdirSync(currentPath);
for (const entry of entries) {
if (entry.startsWith(".") || entry === "node_modules") continue;
const fullPath = join(currentPath, entry);
const relPath = relativePath ? join(relativePath, entry) : entry;
const normalizedRelPath = normalizePathKey(relPath);
const stats = statSync(fullPath);
if (stats.isDirectory()) {
scan(fullPath, relPath);
} else if (
entry.endsWith(".md") ||
entry.endsWith(".csv") ||
entry.endsWith(".yarn") ||
entry.endsWith(".svg")
) {
try {
index[normalizedRelPath] = readFileSync(fullPath, "utf-8");
} catch (e) {
console.error(`读取文件失败:${fullPath}`, e);
}
}
}
}
scan(dir, "");
return buildRegistryFromIndex(index);
return buildRegistryFromIndex(readContentIndex(dir));
}
/**
+52
View File
@@ -0,0 +1,52 @@
/**
* Filesystem walk that builds a raw `{ path: content }` index.
*
* Extracted from `buildRegistry` in `commands/serve.ts` so the validator can
* obtain the *raw* file contents (needed for accurate line numbers) while the
* server gets the scanned registry — both from the same walk.
*/
import { readdirSync, statSync, readFileSync } from "fs";
import { join } from "path";
import { normalizePathKey } from "./content-registry.js";
export interface ContentIndex {
[path: string]: string;
}
/** Recursively read indexable files (`.md`, `.csv`, `.yarn`, `.svg`). */
export function readContentIndex(dir: string): ContentIndex {
const index: ContentIndex = {};
function scan(currentPath: string, relativePath: string) {
const entries = readdirSync(currentPath);
for (const entry of entries) {
if (entry.startsWith(".") || entry === "node_modules") continue;
const fullPath = join(currentPath, entry);
const relPath = relativePath ? join(relativePath, entry) : entry;
const normalizedRelPath = normalizePathKey(relPath);
const stats = statSync(fullPath);
if (stats.isDirectory()) {
scan(fullPath, relPath);
} else if (
entry.endsWith(".md") ||
entry.endsWith(".csv") ||
entry.endsWith(".yarn") ||
entry.endsWith(".svg")
) {
try {
index[normalizedRelPath] = readFileSync(fullPath, "utf-8");
} catch (e) {
console.error(`读取文件失败:${fullPath}`, e);
}
}
}
}
scan(dir, "");
return index;
}
+2 -5
View File
@@ -188,9 +188,6 @@ export function scanDoc(content: string, docPath: string): DocScanResult {
*
* Shared by the CLI (`buildRegistry` in `commands/serve.ts`) and the browser
* folder scan (`scanClientSide` in `components/journal/completions.ts`) so
* both modes produce identical `pathIndex`/`docContent` — including the
* Shared by the CLI (`buildRegistry` in `commands/serve.ts`) and the browser
* folder scan (`scanClientSide` in `components/journal/completions.ts`) so
* both modes produce identical `pathIndex`/`docContent`.
*
* Keys are normalized via `normalizePathKey`; `.md` files are run through
@@ -280,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());
}
@@ -494,7 +491,7 @@ export function deriveBlocks(registry: ContentRegistry): {
* Remove fenced code blocks (backtick or tilde) from content, so text
* scanners (headings, dice directives) don't match example code.
*/
function stripFencedBlocks(content: string): string {
export function stripFencedBlocks(content: string): string {
const out: string[] = [];
let fence: string | null = null;
for (const line of content.split(/\r?\n/)) {
+12 -1
View File
@@ -2,6 +2,7 @@
import { Command } from "commander";
import { serveCommand } from "./commands/serve.js";
import { compileCommand } from "./commands/compile.js";
import { checkCommand } from "./commands/check.js";
import { mcpCommand } from "./commands/mcp.js";
import type { ServeOptions, CompileOptions } from "./types.js";
@@ -22,9 +23,19 @@ program
.option("--host <host>", "主机地址", "0.0.0.0")
.action(serveCommand);
program
.command("check")
.alias("lint")
.description("校验目录内的 tttk 文档,输出结构化诊断")
.argument("[dir]", "要校验的目录", ".")
.option("--json", "以 JSON 输出诊断", false)
.option("--strict", "将警告视为错误", false)
.option("--quiet", "只报告错误", false)
.action(checkCommand);
program
.command("compile")
.description("[实验性] 将目录内容编译为带 hash 路由的单页 Web 应用 (开发中)")
.description("[未实现] 存根命令:不会生成任何输出。预览内容请使用 tttk serve")
.argument("[dir]", "要编译的目录", ".")
.option("-o, --output <dir>", "输出目录", "./dist/output")
.action(compileCommand);
+11
View File
@@ -16,6 +16,17 @@ export type CompileCommandHandler = (
options: CompileOptions,
) => Promise<void>;
export interface CheckOptions {
json: boolean;
strict: boolean;
quiet: boolean;
}
export type CheckCommandHandler = (
dir: string,
options: CheckOptions,
) => Promise<void>;
export interface MarkdownFile {
path: string;
route: string;
+108
View File
@@ -0,0 +1,108 @@
/**
* Asset checks: icons and image references.
*
* Uses the filesystem, since `png`/`jpg` files are not in the content index.
* The icon search assumes no `assets/` directory exists above the check root
* (see docs/check-plan.md §5), which makes the lookup definitive.
*/
import { existsSync } from "fs";
import { join, posix } from "path";
import { diagnosticAt } from "../diagnostics.js";
import { elementFor, looksLikeColor, COLOR_BODY_ELEMENTS } from "../known.js";
import type { Check, CheckContext } from "./context.js";
import { resolveKey } from "./context.js";
import { readTagBlock } from "./tag-block.js";
const ICON_EXTENSIONS = ["svg", "png", "gif", "jpg", "jpeg", "webp"];
export const assetsCheck: Check = (ctx) => {
const { scan, source, file, pathKey, rootDir, sink } = ctx;
for (const directive of scan.directives) {
if (directive.icon) {
const value = directive.body.trim();
if (!value) continue;
const { name, ext } = splitIcon(value);
if (!iconExists(pathKey, name, ext, rootDir)) {
sink.report(
diagnosticAt(
file,
source,
directive.offset,
"TTTK-A001",
"warning",
`icon "${value}" not found`,
`looked for assets/${name}.${ext} from this file up to the project root`,
),
);
}
continue;
}
const spec = elementFor(directive.name);
if (!spec || spec.refKind !== "image") continue;
const ref = directive.body.trim();
if (!ref) continue;
if (COLOR_BODY_ELEMENTS.has(spec.name) && looksLikeColor(ref)) continue;
reportMissingImage(ref, directive.offset, ctx);
}
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 !== "image") continue;
if (COLOR_BODY_ELEMENTS.has(tag) && looksLikeColor(body)) continue;
reportMissingImage(body.trim(), fence.offset, ctx);
}
};
function reportMissingImage(
ref: string,
offset: number,
ctx: CheckContext,
): void {
const { source, file, pathKey, rootDir, sink } = ctx;
const key = resolveKey(pathKey, ref);
if (existsSync(join(rootDir, key.replace(/^\//, "")))) return;
sink.report(
diagnosticAt(
file,
source,
offset,
"TTTK-A002",
"warning",
`image "${ref}" does not exist`,
),
);
}
function splitIcon(value: string): { name: string; ext: string } {
const dot = value.lastIndexOf(".");
if (dot > 0) {
const ext = value.slice(dot + 1).toLowerCase();
if (ICON_EXTENSIONS.includes(ext)) {
return { name: value.slice(0, dot), ext };
}
}
return { name: value, ext: "png" };
}
/** Mirror `buildIconSrc`: search `assets/` from the doc dir up to the root. */
function iconExists(
pathKey: string,
name: string,
ext: string,
rootDir: string,
): boolean {
const parts = posix.dirname(pathKey).split("/").filter(Boolean);
for (let i = parts.length; i >= 0; i--) {
const prefix = `/${parts.slice(0, i).join("/")}`;
const base = prefix === "/" ? "/assets" : `${prefix}/assets`;
const rel = `${base}/${name}.${ext}`.replace(/^\//, "");
if (existsSync(join(rootDir, rel))) return true;
}
return false;
}
+39
View File
@@ -0,0 +1,39 @@
/**
* Shared context passed to every per-document check.
*/
import { posix } from "path";
import type { ContentRegistry } from "../../content-registry.js";
import type { DiagnosticSink } from "../diagnostics.js";
import type { ScanResult } from "../scan.js";
import type { SourceFile } from "../source.js";
export interface CheckContext {
source: SourceFile;
scan: ScanResult;
/** Display path: relative to the root, posix, no leading slash. */
file: string;
/** Registry key: posix with a leading slash. */
pathKey: string;
registry: ContentRegistry;
/** Raw contents of every indexable file, keyed like the registry. */
index: Record<string, string>;
/** Absolute root directory of the check. */
rootDir: string;
sink: DiagnosticSink;
}
export type Check = (ctx: CheckContext) => void;
/** Strip the registry key's leading slash for display. */
export function displayPath(key: string): string {
return key.replace(/^\//, "");
}
/** Resolve a reference written in `pathKey` to a registry key. */
export function resolveKey(pathKey: string, ref: string): string {
if (ref.startsWith("/")) return posix.normalize(ref);
const dir = posix.dirname(pathKey);
const joined = posix.normalize(posix.join(dir, ref));
return joined.startsWith("/") ? joined : `/${joined}`;
}
+308
View File
@@ -0,0 +1,308 @@
/**
* CSV data-file checks (V001–V005).
*
* Two entry points:
* - `csvFilesCheck` validates every CSV referenced by a content directive,
* once per file (a shared CSV must not be reported twice).
* - `sparkTableCheck` validates inline `role=spark-table` bodies, which live
* in the document itself and so carry document line numbers.
*
* Parsing mirrors `parseCSVString` (`src/components/utils/csv-loader.ts`):
* BOM stripped, `#` lines are comments, cells trimmed, empty lines skipped,
* and YAML front matter split off first.
*/
import { parse } from "csv-parse/browser/esm/sync";
import yaml from "js-yaml";
import {
isMarkdownTableLang,
isSparkTableHeader,
resolveContentEntry,
type ContentRegistry,
} from "../../content-registry.js";
import { diagnosticAt, type DiagnosticSink } from "../diagnostics.js";
import { elementFor } from "../known.js";
import type { RawFence, ScanResult } from "../scan.js";
import { SourceFile } from "../source.js";
import type { Check, CheckContext } from "./context.js";
import { displayPath } from "./context.js";
import { readTagBlock } from "./tag-block.js";
/** A CSV body to validate, plus where it lives in its source. */
export interface CsvTarget {
/** Display path for diagnostics. */
file: string;
source: SourceFile;
body: string;
/** Offset in `source` where `body` begins. */
bodyOffset: number;
}
const PARSE_OPTIONS = {
columns: false as const,
relax_column_count: true,
info: true as const,
comment: "#",
trim: true,
skipEmptyLines: true,
bom: true,
};
interface CsvRecord {
record: string[];
info: { lines: number };
}
/** Run V001–V005 over one CSV body. */
export function checkCsvBody(target: CsvTarget, sink: DiagnosticSink): void {
const { file, source, body, bodyOffset } = target;
const { csv, csvOffset, frontmatter } = splitFrontMatter(body);
const baseLine = source.position(bodyOffset + csvOffset).line;
const offsetAt = (csvLine: number): number =>
source.lineStart(baseLine + csvLine - 1);
let records: CsvRecord[];
try {
records = parse(csv, PARSE_OPTIONS) as unknown as CsvRecord[];
} catch (error) {
const detail = error as { lines?: number; message?: string };
sink.report(
diagnosticAt(
file,
source,
offsetAt(detail.lines ?? 1),
"TTTK-V001",
"error",
`CSV failed to parse: ${detail.message ?? String(error)}`,
"check quoting; wrap values containing commas or quotes in double quotes",
),
);
return;
}
if (records.length === 0) return;
const header = records[0].record;
const headerSet = new Set(header);
const labelIndex = header.indexOf("label");
const bodyIndex = header.indexOf("body");
const sparkTable = isSparkTableHeader(header[0] ?? "");
const seenLabels = new Map<string, number>();
for (let i = 1; i < records.length; i++) {
const { record, info } = records[i];
const line = info.lines;
const offset = offsetAt(line);
if (record.length !== header.length) {
sink.report(
diagnosticAt(
file,
source,
offset,
"TTTK-V002",
"warning",
`row has ${record.length} column${record.length === 1 ? "" : "s"}, header has ${header.length}`,
"align the row with the header, or quote cells containing commas",
),
);
}
if (labelIndex >= 0) {
const label = (record[labelIndex] ?? "").trim();
if (label) {
const first = seenLabels.get(label);
if (first !== undefined) {
sink.report(
diagnosticAt(
file,
source,
offset,
"TTTK-V003",
"warning",
`duplicate label "${label}" (first at line ${first})`,
"labels identify rows; make each unique",
),
);
} else {
seenLabels.set(label, line);
}
}
}
if (bodyIndex >= 0) {
const cell = record[bodyIndex] ?? "";
for (const match of cell.matchAll(/\{\{([^}]+)\}\}/g)) {
const key = match[1].trim();
if (headerSet.has(key) || frontmatter.has(key)) continue;
sink.report(
diagnosticAt(
file,
source,
offset,
"TTTK-V004",
"warning",
`{{${key}}} is not a column or front-matter key`,
`available: ${[...headerSet].join(", ")}`,
),
);
}
}
if (sparkTable) {
const roll = (record[0] ?? "").trim();
if (roll && !/^\d+(\s*-\s*\d+)?$/.test(roll)) {
sink.report(
diagnosticAt(
file,
source,
offset,
"TTTK-V005",
"warning",
`spark-table roll value "${roll}" is not a number or range`,
"use a number (3) or a range (3-5)",
),
);
}
}
}
}
/**
* Validate every CSV referenced by a content directive, once per file.
* Inline spark-table bodies are handled by `sparkTableCheck`.
*/
export function csvFilesCheck(
mdKeys: string[],
scans: Map<string, ScanResult>,
registry: ContentRegistry,
index: Record<string, string>,
sink: DiagnosticSink,
): void {
const targets = new Map<string, CsvTarget>();
const add = (docKey: string, ref: string): void => {
const resolved = resolveContentEntry(registry, docKey, ref);
if (!resolved || resolved.inline) return;
if (!resolved.path.endsWith(".csv")) return;
if (targets.has(resolved.path)) return;
const body = index[resolved.path];
if (body == null) return;
targets.set(resolved.path, {
file: displayPath(resolved.path),
source: new SourceFile(body),
body,
bodyOffset: 0,
});
};
for (const docKey of mdKeys) {
const scan = scans.get(docKey);
if (!scan) continue;
for (const directive of scan.directives) {
if (directive.icon) continue;
const spec = elementFor(directive.name);
if (!spec || spec.refKind !== "csv") continue;
const ref = directive.body.trim();
if (ref) add(docKey, ref);
}
for (const fence of scan.fences) {
if (fence.attrs.role !== "tag") continue;
const { tag, body } = readTagBlock(fence);
if (!tag || !body) continue;
const spec = elementFor(tag);
if (!spec || spec.refKind !== "csv") continue;
add(docKey, body.trim());
}
}
for (const target of targets.values()) checkCsvBody(target, sink);
}
/**
* Validate inline `role=spark-table` bodies. CSV bodies get the full V001–V005
* treatment; markdown pipe tables only get V005 (their structure is markdown,
* not CSV).
*/
export const sparkTableCheck: Check = (ctx) => {
const { scan, source, file, sink } = ctx;
for (const fence of scan.fences) {
if (fence.attrs.role !== "spark-table") continue;
if (isMarkdownTableLang(fence.lang)) {
checkMarkdownSparkTable(fence, ctx);
continue;
}
checkCsvBody(
{ file, source, body: fence.body, bodyOffset: fence.bodyOffset },
sink,
);
}
};
/** V005 for a markdown pipe-table spark table. */
function checkMarkdownSparkTable(fence: RawFence, ctx: CheckContext): void {
const { source, file, sink } = ctx;
const baseLine = source.position(fence.bodyOffset).line;
const lines = fence.body.split(/\r?\n/);
let dataRows = 0;
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim();
if (!trimmed.startsWith("|")) continue;
dataRows++;
if (dataRows <= 2) continue; // header + separator
const cells = trimmed
.split("|")
.map((cell) => cell.trim())
.filter(Boolean);
const roll = cells[0] ?? "";
if (roll && !/^\d+(\s*-\s*\d+)?$/.test(roll)) {
sink.report(
diagnosticAt(
file,
source,
source.lineStart(baseLine + i),
"TTTK-V005",
"warning",
`spark-table roll value "${roll}" is not a number or range`,
"use a number (3) or a range (3-5)",
),
);
}
}
}
/**
* Split YAML front matter off a CSV body, mirroring `parseFrontMatter`
* (`src/cli/tools/frontmatter/shared.ts`). Returns the remaining CSV, its
* offset within `content`, and the front-matter keys.
*/
function splitFrontMatter(content: string): {
csv: string;
csvOffset: number;
frontmatter: Set<string>;
} {
const trimmed = content.trim();
const parts = trimmed.split(/(?:^|\n)---\s*\n/g);
if (parts.length !== 3 || parts[0] !== "") {
return { csv: content, csvOffset: 0, frontmatter: new Set() };
}
let frontmatter = new Set<string>();
try {
const data = yaml.load(parts[1].trim());
if (data && typeof data === "object" && !Array.isArray(data)) {
frontmatter = new Set(Object.keys(data as Record<string, unknown>));
}
} catch {
return { csv: content, csvOffset: 0, frontmatter: new Set() };
}
const csv = parts.slice(2).join("---\n").trimStart();
const lead = content.length - content.trimStart().length;
const csvOffset = lead + (trimmed.length - csv.length);
return { csv, csvOffset, frontmatter };
}
+126
View File
@@ -0,0 +1,126 @@
/**
* 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 { 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;
const suggestion = closestName(directive.name, [...ELEMENT_NAMES]);
sink.report(
diagnosticAt(
file,
source,
directive.offset,
"TTTK-D001",
"error",
`unknown element ":${directive.name}"`,
suggestion
? `did you mean ":${suggestion}"?`
: `custom elements are: ${KNOWN_ELEMENTS.map((e) => e.name).join(", ")}`,
),
);
}
};
/**
* 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;
let bestDistance = 3;
for (const candidate of candidates) {
const distance = editDistance(name, candidate);
if (distance < bestDistance) {
bestDistance = distance;
best = candidate;
}
}
return best;
}
function editDistance(a: string, b: string): number {
const prev = new Array<number>(b.length + 1);
const curr = new Array<number>(b.length + 1);
for (let j = 0; j <= b.length; j++) prev[j] = j;
for (let i = 1; i <= a.length; i++) {
curr[0] = i;
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
}
for (let j = 0; j <= b.length; j++) prev[j] = curr[j];
}
return prev[b.length];
}
+102
View File
@@ -0,0 +1,102 @@
/**
* Markdown link and image checks.
*
* File existence uses the filesystem (not the registry) because images are not
* indexed. Section anchors are validated against heading slugs.
*/
import { existsSync } from "fs";
import { join } from "path";
import Slugger from "github-slugger";
import { stripFencedBlocks } from "../../content-registry.js";
import { diagnosticAt } from "../diagnostics.js";
import type { Check } from "./context.js";
import { resolveKey } from "./context.js";
const EXTERNAL_RE = /^[a-z][a-z0-9+.-]*:/i;
export const linksCheck: Check = (ctx) => {
const { scan, source, file, pathKey, registry, index, rootDir, sink } = ctx;
const slugCache = new Map<string, Set<string>>();
const slugsFor = (key: string): Set<string> => {
const cached = slugCache.get(key);
if (cached) return cached;
const markdown = index[key] ?? registry.pathIndex[key] ?? "";
const slugs = headingSlugs(markdown);
slugCache.set(key, slugs);
return slugs;
};
const exists = (key: string): boolean => {
if (key in registry.pathIndex || key in index) return true;
return existsSync(join(rootDir, key.replace(/^\//, "")));
};
for (const link of scan.links) {
const target = link.target.trim();
if (!target || EXTERNAL_RE.test(target) || target.startsWith("//")) continue;
const hashIndex = target.indexOf("#");
const pathPart = hashIndex >= 0 ? target.slice(0, hashIndex) : target;
const section = hashIndex >= 0 ? target.slice(hashIndex + 1) : "";
const baseKey = pathPart ? resolveKey(pathKey, pathPart) : pathKey;
if (pathPart && !exists(baseKey)) {
sink.report(
diagnosticAt(
file,
source,
link.targetOffset,
link.image ? "TTTK-A002" : "TTTK-L001",
link.image ? "warning" : "error",
`${link.image ? "image" : "link target"} "${target}" does not exist`,
),
);
continue;
}
if (section && baseKey.endsWith(".md")) {
const decoded = decodeURIComponent(section).toLowerCase();
if (!sectionMatches(slugsFor(baseKey), decoded)) {
sink.report(
diagnosticAt(
file,
source,
link.targetOffset,
"TTTK-L002",
"warning",
`section "#${section}" not found in "${pathPart || file}"`,
"anchors come from headings, lowercased and hyphenated",
),
);
}
}
}
};
/**
* A section matches an exact heading slug, or a heading slug with a suffix
* (e.g. `#珊瑚粉` for the heading `珊瑚粉 - 成瘾`) — a common authoring habit.
*/
function sectionMatches(slugs: Set<string>, section: string): boolean {
if (slugs.has(section)) return true;
for (const slug of slugs) {
if (!slug.startsWith(section)) continue;
const next = slug[section.length];
if (next === undefined || !/[A-Za-z0-9]/.test(next)) return true;
}
return false;
}
function headingSlugs(markdown: string): Set<string> {
const slugger = new Slugger();
const slugs = new Set<string>();
const content = stripFencedBlocks(markdown);
const re = /^(#{1,6})\s+(.+?)\s*$/gm;
let match: RegExpExecArray | null;
while ((match = re.exec(content)) !== null) {
slugs.add(slugger.slug(match[2].trim().toLowerCase()));
}
return slugs;
}
+104
View File
@@ -0,0 +1,104 @@
/**
* Content reference checks for directives and `yaml role=tag` blocks.
*
* Image references are handled by the assets check, because image files are
* not part of the content registry (only `.md`, `.csv`, `.yarn`, `.svg` are
* indexed).
*/
import { extname } from "path";
import { resolveContentEntry } from "../../content-registry.js";
import { diagnosticAt } from "../diagnostics.js";
import { REF_EXTENSIONS, elementFor, type RefKind } from "../known.js";
import type { Check, CheckContext } from "./context.js";
import { readTagBlock } from "./tag-block.js";
export const refsCheck: Check = (ctx) => {
const { scan, source, file, pathKey, registry, sink } = ctx;
const tableSeen = new Map<string, { count: number; offset: number }>();
const verify = (
ref: string,
offset: number,
kind: RefKind,
element: string,
): boolean => {
const resolved = resolveContentEntry(registry, pathKey, ref);
if (!resolved) {
sink.report(
diagnosticAt(
file,
source,
offset,
"TTTK-C001",
"error",
`:${element} reference "${ref}" does not resolve`,
"expected a file relative to this document, or an inline id defined in it",
),
);
return false;
}
if (!resolved.inline && kind !== "none") {
const ext = extname(resolved.path).toLowerCase();
const allowed = REF_EXTENSIONS[kind as Exclude<RefKind, "none">];
if (allowed && !allowed.includes(ext)) {
sink.report(
diagnosticAt(
file,
source,
offset,
"TTTK-C002",
"warning",
`:${element} reference "${ref}" resolves to "${ext || "no extension"}", expected ${allowed.join(" / ")}`,
),
);
}
}
return true;
};
for (const directive of scan.directives) {
if (directive.icon) continue;
const spec = elementFor(directive.name);
if (!spec || spec.refKind === "none" || spec.refKind === "image") continue;
let ref = directive.body.trim();
if (spec.name === "md-link" || spec.name === "md-embed") {
ref = ref.split("#")[0];
}
if (!ref) continue;
const ok = verify(ref, directive.offset, spec.refKind, spec.name);
if (spec.name === "md-table" && ok) {
const seen = tableSeen.get(ref);
if (seen) seen.count++;
else tableSeen.set(ref, { count: 1, offset: directive.offset });
}
}
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 === "none" || spec.refKind === "image") continue;
verify(body.trim(), fence.offset, spec.refKind, tag);
}
for (const [ref, seen] of tableSeen) {
if (seen.count > 1) {
sink.report(
diagnosticAt(
file,
source,
seen.offset,
"TTTK-C003",
"info",
`:md-table reference "${ref}" is used ${seen.count} times in this document`,
"reference each CSV once; use group tabs instead of repeating the table",
),
);
}
}
};
+194
View File
@@ -0,0 +1,194 @@
/**
* 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";
import type { RawFence } from "../scan.js";
import type { Check, CheckContext } from "./context.js";
import { readTagBlock } from "./tag-block.js";
export const rolesCheck: Check = (ctx) => {
const { scan, source, file, sink } = ctx;
for (const fence of scan.fences) {
const role = fence.attrs.role;
if (!role) continue;
if (!KNOWN_ROLES.has(role)) {
sink.report(
diagnosticAt(
file,
source,
fence.offset,
"TTTK-R001",
"error",
`unknown code-block role "${role}"`,
`known roles: ${[...KNOWN_ROLES].join(", ")}. The block is stripped, silently dropping its content`,
),
);
continue;
}
if (role === "spark-table") checkSparkTable(fence, ctx);
else if (role === "declare") checkDeclare(fence, ctx);
else if (role === "tag") checkTag(fence, ctx);
}
};
function checkSparkTable(fence: RawFence, ctx: CheckContext): void {
const { source, file, sink } = ctx;
const header = firstHeaderCell(fence.body, fence.lang);
if (!header) return;
if (!/^\s*\d*d\d+/i.test(header)) {
sink.report(
diagnosticAt(
file,
source,
fence.offset,
"TTTK-R002",
"warning",
`spark-table first header "${header}" is not a dice formula`,
"use a notation like d6, 2d6+1, or 3d6kh1",
),
);
}
}
function checkDeclare(fence: RawFence, ctx: CheckContext): void {
const { source, file, sink } = ctx;
try {
parseDeclareCsv(fence.body, file);
} catch (error) {
sink.report(
diagnosticAt(
file,
source,
fence.offset,
"TTTK-R003",
"error",
error instanceof Error ? error.message : String(error),
"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",
),
);
}
}
function checkTag(fence: RawFence, ctx: CheckContext): void {
const { source, file, sink } = ctx;
const { tag, error } = readTagBlock(fence);
if (error) {
sink.report(
diagnosticAt(
file,
source,
fence.offset,
"TTTK-R005",
"error",
`role=tag body is not valid YAML: ${error.message}`,
"check the indentation and quoting of the YAML mapping",
),
);
return;
}
if (!tag) {
sink.report(
diagnosticAt(
file,
source,
fence.offset,
"TTTK-R006",
"error",
"role=tag block has no `tag:`",
"set `tag:` to a registered element, e.g. `tag: md-deck`",
),
);
return;
}
if (!ELEMENT_NAMES.has(tag)) {
sink.report(
diagnosticAt(
file,
source,
fence.offset,
"TTTK-R006",
"error",
`role=tag names unknown element "${tag}"`,
`known elements: ${[...ELEMENT_NAMES].join(", ")}`,
),
);
}
}
/** First header cell of a spark-table body (CSV or markdown pipe table). */
function firstHeaderCell(body: string, lang: string): string | null {
if (/^markdown$/i.test(lang)) {
for (const line of body.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed.startsWith("|")) continue;
const cells = trimmed
.split("|")
.map((cell) => cell.trim())
.filter(Boolean);
return cells[0] ?? null;
}
return null;
}
for (const line of body.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
return trimmed.split(",")[0].trim();
}
return null;
}
+55
View File
@@ -0,0 +1,55 @@
/**
* Structural checks: the markdown pipeline parses, and container directives
* are balanced.
*/
import { parseMarkdown } from "../../../markdown/index.js";
import { diagnosticAt } from "../diagnostics.js";
import type { RawContainerOpen } from "../scan.js";
import type { Check } from "./context.js";
export const structuralCheck: Check = (ctx) => {
const { source, scan, file, pathKey, sink } = ctx;
try {
parseMarkdown(source.text, pathKey);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
sink.report(
diagnosticAt(
file,
source,
0,
"TTTK-S001",
"error",
`markdown failed to parse: ${detail}`,
"check for an unterminated directive or malformed attribute block",
),
);
}
const events = [
...scan.containerOpens.map((open) => ({ offset: open.offset, open })),
...scan.containerCloses.map((close) => ({ offset: close.offset, open: null })),
].sort((a, b) => a.offset - b.offset);
const stack: RawContainerOpen[] = [];
for (const event of events) {
if (event.open) stack.push(event.open);
else stack.pop();
}
for (const open of stack) {
sink.report(
diagnosticAt(
file,
source,
open.offset,
"TTTK-S002",
"error",
`container directive "::: ${open.name}" is never closed`,
`add a closing "${":".repeat(open.colons)}" line`,
),
);
}
};
+38
View File
@@ -0,0 +1,38 @@
/**
* Read a `yaml role=tag` block: the element name and body, honoring fence-line
* attribute overrides (which win over the YAML mapping).
*/
import yaml from "js-yaml";
import type { RawFence } from "../scan.js";
export interface TagBlock {
tag?: string;
body?: string;
/** Set when the YAML itself failed to parse. */
error?: Error;
}
export function readTagBlock(fence: RawFence): TagBlock {
let data: unknown;
try {
data = yaml.load(fence.body);
} catch (error) {
return { error: error instanceof Error ? error : new Error(String(error)) };
}
const mapping =
data && typeof data === "object" && !Array.isArray(data)
? (data as Record<string, unknown>)
: {};
const fenceTag = fence.attrs.extra.tag;
const fenceBody = fence.attrs.extra.body;
const yamlTag = mapping.tag;
const yamlBody = mapping.body;
return {
tag: fenceTag ?? (typeof yamlTag === "string" ? yamlTag : undefined),
body: fenceBody ?? (typeof yamlBody === "string" ? yamlBody : undefined),
};
}
+146
View File
@@ -0,0 +1,146 @@
/**
* Diagnostic model, collection, and reporting.
*
* `check` reports problems; it never edits content. Diagnostics carry stable
* codes so an authoring agent can key off them.
*/
import type { SourceFile } from "./source.js";
export type Severity = "error" | "warning" | "info";
export interface Diagnostic {
/** Stable code, e.g. "TTTK-C001". */
code: string;
severity: Severity;
message: string;
/** Path relative to the check root, posix, no leading slash. */
file: string;
line: number;
column: number;
/** Concrete, actionable fix. */
hint?: string;
/** The offending source line, for context. */
snippet?: string;
}
export interface DiagnosticSink {
report(diagnostic: Diagnostic): void;
}
/** Collects diagnostics in memory. */
export class Collector implements DiagnosticSink {
readonly items: Diagnostic[] = [];
report(diagnostic: Diagnostic): void {
this.items.push(diagnostic);
}
}
export function diagnosticAt(
file: string,
source: SourceFile,
offset: number,
code: string,
severity: Severity,
message: string,
hint?: string,
): Diagnostic {
const { line, column } = source.position(offset);
return {
code,
severity,
message,
file,
line,
column,
hint,
snippet: source.lineText(line).trimEnd() || undefined,
};
}
const SEVERITY_ORDER: Record<Severity, number> = {
error: 0,
warning: 1,
info: 2,
};
export function compareDiagnostics(a: Diagnostic, b: Diagnostic): number {
if (a.file !== b.file) return a.file < b.file ? -1 : 1;
if (a.line !== b.line) return a.line - b.line;
if (a.column !== b.column) return a.column - b.column;
if (a.severity !== b.severity) {
return SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity];
}
if (a.code !== b.code) return a.code < b.code ? -1 : 1;
return 0;
}
export interface DiagnosticSummary {
errors: number;
warnings: number;
infos: number;
}
export interface CheckReport {
root: string;
filesChecked: number;
diagnostics: Diagnostic[];
summary: DiagnosticSummary;
}
export function summarize(diagnostics: Diagnostic[]): DiagnosticSummary {
const summary: DiagnosticSummary = { errors: 0, warnings: 0, infos: 0 };
for (const d of diagnostics) {
if (d.severity === "error") summary.errors++;
else if (d.severity === "warning") summary.warnings++;
else summary.infos++;
}
return summary;
}
export function buildReport(
root: string,
filesChecked: number,
diagnostics: Diagnostic[],
): CheckReport {
const sorted = [...diagnostics].sort(compareDiagnostics);
return {
root,
filesChecked,
diagnostics: sorted,
summary: summarize(sorted),
};
}
/** Human-readable, grouped by file. */
export function formatHuman(report: CheckReport, quiet = false): string {
const lines: string[] = [];
let currentFile: string | null = null;
for (const d of report.diagnostics) {
if (quiet && d.severity !== "error") continue;
if (d.file !== currentFile) {
if (lines.length) lines.push("");
lines.push(d.file);
currentFile = d.file;
}
const loc = `${d.line}:${d.column}`.padEnd(8);
lines.push(` ${loc} ${d.severity.padEnd(7)} ${d.code} ${d.message}`);
if (d.hint) lines.push(` ${" ".repeat(8)} hint: ${d.hint}`);
}
if (lines.length) lines.push("");
const { errors, warnings, infos } = report.summary;
lines.push(
`${errors} error${errors === 1 ? "" : "s"}, ` +
`${warnings} warning${warnings === 1 ? "" : "s"}, ` +
`${infos} info`,
`${report.filesChecked} file${report.filesChecked === 1 ? "" : "s"} checked`,
);
return lines.join("\n");
}
export function formatJson(report: CheckReport): string {
return JSON.stringify(report, null, 2);
}
+151
View File
@@ -0,0 +1,151 @@
/**
* `tttk check` — run all validation checks over a directory and return a
* sorted report.
*
* Design notes live in docs/check-plan.md:
* - §3.1 build the whole-tree registry first, so cross-file refs resolve
* - §3.2 diagnose the *raw* source so line numbers are accurate
*/
import { existsSync, statSync } from "fs";
import { resolve } from "path";
import {
buildRegistryFromIndex,
type ContentRegistry,
} from "../content-registry.js";
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";
import { rolesCheck } from "./checks/roles.js";
import { structuralCheck } from "./checks/structural.js";
import {
buildReport,
Collector,
diagnosticAt,
type CheckReport,
} from "./diagnostics.js";
import { scanSource, type ScanResult } from "./scan.js";
import { SourceFile } from "./source.js";
const CHECKS: readonly Check[] = [
structuralCheck,
directivesCheck,
rolesCheck,
sparkTableCheck,
refsCheck,
linksCheck,
assetsCheck,
];
interface ScannedDoc {
source: SourceFile;
scan: ScanResult;
}
export function runCheck(dir: string): CheckReport {
const rootDir = resolve(dir);
if (!existsSync(rootDir) || !statSync(rootDir).isDirectory()) {
throw new Error(`not a directory: ${rootDir}`);
}
const index = readContentIndex(rootDir);
const registry = buildRegistryFromIndex(index);
const sink = new Collector();
const mdKeys = Object.keys(index)
.filter((key) => key.endsWith(".md"))
.sort();
const docs = new Map<string, ScannedDoc>();
for (const key of mdKeys) {
const text = index[key];
docs.set(key, { source: new SourceFile(text), scan: scanSource(text) });
}
for (const key of mdKeys) {
const doc = docs.get(key)!;
const ctx: CheckContext = {
source: doc.source,
scan: doc.scan,
file: displayPath(key),
pathKey: key,
registry,
index,
rootDir,
sink,
};
for (const check of CHECKS) check(ctx);
}
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);
}
/** Detect `:md-embed` cycles, which would expand infinitely. */
function checkEmbedCycles(
mdKeys: string[],
docs: Map<string, ScannedDoc>,
registry: ContentRegistry,
sink: Collector,
): void {
const graph = new Map<string, { target: string; offset: number }[]>();
for (const key of mdKeys) {
const edges: { target: string; offset: number }[] = [];
for (const directive of docs.get(key)!.scan.directives) {
if (directive.icon || directive.name !== "md-embed") continue;
const ref = directive.body.trim().split("#")[0];
if (!ref) continue;
const target = resolveKey(key, ref);
if (!target.endsWith(".md")) continue;
if (!(target in registry.pathIndex)) continue; // C001 already reports
edges.push({ target, offset: directive.offset });
}
graph.set(key, edges);
}
const state = new Map<string, 1 | 2>();
const reported = new Set<string>();
const visit = (key: string): void => {
state.set(key, 1);
for (const edge of graph.get(key) ?? []) {
const targetState = state.get(edge.target);
if (targetState === 1) {
const id = `${key}->${edge.target}`;
if (reported.has(id)) continue;
reported.add(id);
sink.report(
diagnosticAt(
displayPath(key),
docs.get(key)!.source,
edge.offset,
"TTTK-C004",
"error",
`:md-embed cycle: "${displayPath(key)}" embeds "${displayPath(edge.target)}", which embeds it back`,
"break the cycle by linking instead of embedding",
),
);
} else if (targetState === undefined) {
visit(edge.target);
}
}
state.set(key, 2);
};
for (const key of mdKeys) {
if (!state.has(key)) visit(key);
}
}
+52
View File
@@ -0,0 +1,52 @@
/**
* Drift guard: `KNOWN_ELEMENTS` in `known.ts` must match the `customElement`
* registrations under `src/components`. This is what would have caught
* `:md-card` — matched by the completion scanner but registered nowhere.
*/
import { readdirSync, readFileSync } from "fs";
import { join } from "path";
import { ELEMENT_NAMES } from "./known.js";
import { renderReference } from "./reference.js";
const COMPONENTS_DIR = join(process.cwd(), "src", "components");
const CUSTOM_ELEMENT_RE = /customElement(?:<[^>]*>)?\(\s*["'](md-[\w-]+)["']/g;
function sourceFiles(dir: string): string[] {
const files: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) files.push(...sourceFiles(full));
else if (/\.tsx?$/.test(entry.name)) files.push(full);
}
return files;
}
function registeredElements(): Set<string> {
const names = new Set<string>();
for (const file of sourceFiles(COMPONENTS_DIR)) {
const source = readFileSync(file, "utf-8");
for (const match of source.matchAll(CUSTOM_ELEMENT_RE)) {
names.add(match[1]);
}
}
return names;
}
describe("known element catalog", () => {
test("matches the customElement registrations under src/components", () => {
const registered = [...registeredElements()].sort();
const known = [...ELEMENT_NAMES].sort();
expect(known).toEqual(registered);
});
test.each([
["docs/reference.md"],
[join(".agents", "skills", "tttk-authoring", "reference.md")],
])("%s is up to date", (relativePath) => {
const committed = readFileSync(join(process.cwd(), relativePath), "utf-8");
expect(committed).toBe(renderReference());
});
});
+309
View File
@@ -0,0 +1,309 @@
/**
* Catalog of tttk syntax: custom elements, inline directives, and code-block
* roles.
*
* This is the single source of truth for validation. `docs/reference.md` is
* generated from it (see docs/check-plan.md §7.11); a drift test asserts this
* list matches the `customElement(...)` call sites.
*/
/** What a directive's body refers to, used for reference checks. */
export type RefKind = "csv" | "md" | "yarn" | "image" | "none";
export interface ElementSpec {
/** Registered custom element name, e.g. "md-dice". */
name: string;
/** Human description of the body. */
body: string;
/** Key attribute names. */
attrs: string[];
summary: string;
/** Authoring prose rendered under the summary. */
details?: string;
refKind: RefKind;
/** Canonical usage examples, rendered into the reference. */
examples: string[];
}
/** The registered `md-*` custom elements (mirrors `customElement(...)` calls). */
export const KNOWN_ELEMENTS: readonly ElementSpec[] = [
{
name: "md-dice",
body: "dice formula",
attrs: ["key"],
summary: "Clickable dice roller; `key` stores the result in the URL.",
details:
"Clicking the text rolls; clicking the 🎲 resets to the formula. With `key`, the result is stored in the URL as `?dice-<key>=<value>`.",
refKind: "none",
examples: [
':md-dice[1d20+5]{key="attack"}',
":md-dice[2d6+3]",
":md-dice[3d6kh1]",
],
},
{
name: "md-table",
body: "CSV path or inline id",
attrs: ["roll", "remix"],
summary: "Tab-separated view of a CSV with optional group tabs.",
details:
"`roll` adds a 🎲 random-tab button; `remix` makes `{{prop}}` references pull from a random row. A `group` column in the CSV produces group tabs automatically.",
refKind: "csv",
examples: [
":md-table[./npcs.csv]",
":md-table[./encounters.csv]{roll=true}",
":md-table[./quests.csv]{roll=true remix=true}",
],
},
{
name: "md-deck",
body: "CSV path",
attrs: ["size", "grid", "layers"],
summary: "Card layout from a CSV (legacy; prefer `yaml role=tag`).",
details:
"Prefer the `yaml role=tag` form — it is the only form that supports template layers, per-layer alignment, and card backs. The directive form is legacy and only supports the compact `layers` string.",
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",
body: "path, optional #section",
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",
body: "path, optional #section",
attrs: ["headingBase"],
summary: "Renders the target article or section inline.",
details: "`headingBase` shifts the heading levels of the embedded content.",
refKind: "md",
examples: [":md-embed[./rules.md#combat]{headingBase=1}"],
},
{
name: "md-bg",
body: "image path or CSS color",
attrs: ["fit"],
summary: "Sets the surrounding article card background.",
details:
"`fit` is one of `cover` (default), `contain`, `fill`, `none`, `scale-down`.",
refKind: "image",
examples: [':md-bg[./images/dungeon-bg.jpg]{fit="cover"}', ":md-bg[#2a1a3a]"],
},
{
name: "md-border",
body: "color or border image",
attrs: ["width", "slice"],
summary: "Applies a border to the article card.",
details:
"Body is a CSS color or a border-image path. Class flags select sides (`t b l r all`), style (`solid dashed dotted double groove ridge inset outset none`), and image repeat (`stretch repeat round space`).",
refKind: "image",
examples: [
":md-border[crimson]{.b}",
':md-border[./frame.png]{width="2mm" slice="10" .all.round}',
],
},
{
name: "md-font",
body: "font family",
attrs: ["source", "weight", "color"],
summary: "Sets the article font.",
details: "`source` is `google`, `emfont`, or `local` (default).",
refKind: "none",
examples: [
':md-font[Noto Sans SC]{source="google" weight="700" color="#1a1a2e"}',
],
},
{
name: "md-pins",
body: "image path",
attrs: ["pins", "fixed", "labelStart"],
summary: "Numbered markers on an image.",
details:
"`pins` is a space-separated list of `label:x,y` (percent coordinates). `fixed` makes it read-only; otherwise click to add and click a pin to remove. `labelStart` sets the first label (letter or number).",
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",
body: "command template CSV",
attrs: ["placeholder", "class", "height"],
summary: "Interactive command history / tracker panel.",
details:
"The body is a CSV of command templates (`command,parameter,label,description,insertedText`).",
refKind: "csv",
examples: [':md-commander[./commands.csv]{placeholder="输入命令…" height="400px"}'],
},
{
name: "md-yarn-spinner",
body: "Yarn Spinner file",
attrs: ["start"],
summary: "Runs a Yarn Spinner dialogue file.",
details: "`start` names the entry node (default `start`).",
refKind: "yarn",
examples: [':md-yarn-spinner[./story.yarn]{start="start"}'],
},
];
export const ELEMENT_NAMES: ReadonlySet<string> = new Set(
KNOWN_ELEMENTS.map((e) => e.name),
);
const ELEMENT_BY_NAME: ReadonlyMap<string, ElementSpec> = new Map(
KNOWN_ELEMENTS.map((e) => [e.name, e]),
);
export function elementFor(name: string): ElementSpec | undefined {
return ELEMENT_BY_NAME.get(name);
}
/** Elements whose body may also be a CSS color rather than a file path. */
export const COLOR_BODY_ELEMENTS: ReadonlySet<string> = new Set([
"md-bg",
"md-border",
]);
export interface InlineSpec {
/** Directive form, e.g. ":[name]". */
name: string;
body: string;
attrs: string[];
summary: string;
details?: string;
examples: string[];
}
/** Inline directives that are not custom elements. */
export const INLINE_SPECS: readonly InlineSpec[] = [
{
name: ":[name]",
body: "icon name",
attrs: ["label", "repeat", "join", "class"],
summary:
"Inserts an icon looked up under `assets/` (searching upward from the file).",
details:
"Default extension is `.png`; `.svg`, `.gif`, `.jpg`, `.jpeg`, and `.webp` are also supported. `label` overlays text, `repeat` duplicates N times, and `join` places a tag name between repeats.",
examples: [":[attack] :[shield.svg] :[potion]", ":[attack]{label=ATK repeat=3 join=br}"],
},
{
name: ":cmd[command]",
body: "journal command",
attrs: ["label"],
summary: "Renders a clickable span that dispatches a journal command.",
examples: [
":cmd[roll 1d20+5]{label=Roll initiative}",
":cmd[spark dungeon room-type]{label=Random room}",
],
},
];
export const KNOWN_INLINE: ReadonlySet<string> = new Set(["cmd"]);
export interface RoleSpec {
role: string;
/** Example info string, e.g. "csv role=spark-table". */
info: string;
summary: string;
/** Authoring prose rendered under the summary. */
details?: 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",
info: "csv role=spark-table",
summary: "CSV (or markdown pipe table) rendered as `:md-table`.",
details:
"The first column header must be a dice formula; each row's first cell is the roll value (a number, or a range like `1-3`). Extra fence attributes are forwarded to the generated `:md-table`, e.g. ` ```csv role=spark-table roll=true `.",
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.",
details:
"Columns (any order): `tag`, `threshold`, `key`, `expr`. A row with an empty `tag` declares a variable; a row with a `#tag` applies a modifier when that tag is active.",
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.",
details:
"Referenced by the derived inline id, e.g. `:md-embed[./text_<hash>]`.",
examples: ["```text role=file\nStored as inline content.\n```"],
},
{
role: "tag",
info: "yaml role=tag",
summary: "YAML rendered as a custom element.",
details:
"`tag:` names the element, `body:` becomes its content, and any other key becomes an attribute. Fence-line attributes override the YAML body. See `yaml-tag.md`.",
examples: [
"```yaml role=tag\ntag: md-deck\nbody: ./cards.csv\nsize: 63x88\ngrid: 5x5\n```",
],
},
];
export const KNOWN_ROLES: ReadonlySet<string> = new Set(
ROLE_SPECS.map((r) => r.role),
);
/** Expected file extensions per reference kind. */
export const REF_EXTENSIONS: Record<Exclude<RefKind, "none">, readonly string[]> =
{
csv: [".csv"],
md: [".md"],
yarn: [".yarn"],
image: [".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"],
};
export interface DiceNotation {
notation: string;
meaning: string;
}
/** Dice notation accepted by `:md-dice`, spark-table headers, and `/roll`. */
export const DICE_NOTATION: readonly DiceNotation[] = [
{ notation: "2d6", meaning: "two six-sided dice" },
{ notation: "1d20+5", meaning: "d20 plus modifier" },
{ notation: "3d6kh1", meaning: "keep highest 1" },
{ notation: "3d6dl2-1", meaning: "drop lowest 2, minus 1" },
{ notation: "{d4, d8}", meaning: "choose one of the listed dice" },
];
/** Whether a directive body is a CSS color rather than a path. */
export function isColorValue(value: string): boolean {
const v = value.trim();
return (
/^#([0-9a-f]{3,8})$/i.test(v) ||
/^(rgb|rgba|hsl|hsla|var)\(/i.test(v) ||
v === "transparent" ||
v === "currentColor"
);
}
/**
* Whether a color-capable element body looks like a color rather than a path.
* A single alphabetic word is treated as a CSS named color (e.g. "crimson");
* image paths contain a separator or an extension.
*/
export function looksLikeColor(value: string): boolean {
const v = value.trim();
return isColorValue(v) || /^[A-Za-z]+$/.test(v);
}
+208
View File
@@ -0,0 +1,208 @@
/**
* 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 is refreshed with
* `npm run gen:skill-reference`.
*/
import {
COLOR_BODY_ELEMENTS,
DICE_NOTATION,
INLINE_SPECS,
KNOWN_ELEMENTS,
ROLE_SPECS,
type ElementSpec,
type InlineSpec,
} 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}`;
}
/** `- Body: …` / `- Attributes: …` bullet list shared by all directive specs. */
function specBullets(body: string, attrs: string[]): string[] {
const lines = [`- Body: ${body}`];
if (attrs.length) {
lines.push(`- Attributes: ${attrs.map((a) => `\`${a}\``).join(", ")}`);
}
return lines;
}
function elementSection(element: ElementSpec): string {
const lines: string[] = [];
lines.push(`### \`:${element.name}\``);
lines.push("");
lines.push(element.summary);
lines.push("");
if (element.details) {
lines.push(element.details);
lines.push("");
}
lines.push(...specBullets(element.body, element.attrs));
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();
}
function inlineSection(spec: InlineSpec): string {
const lines: string[] = [];
lines.push(`### \`${spec.name}\``);
lines.push("");
lines.push(spec.summary);
lines.push("");
if (spec.details) {
lines.push(spec.details);
lines.push("");
}
lines.push(...specBullets(spec.body, spec.attrs));
lines.push("");
for (const example of spec.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(
"Complete catalog of the custom elements, inline directives, and code block",
);
lines.push(
"roles that `tttk` understands. Everything not listed here is standard GFM",
);
lines.push("markdown. Element names are prefixed `md-` and must match exactly.");
lines.push("");
lines.push("## Contents");
lines.push("");
lines.push("- [Directive syntax](#directive-syntax)");
lines.push("- [Custom elements](#custom-elements)");
lines.push("- [Inline directives](#inline-directives)");
lines.push("- [Code block roles](#code-block-roles)");
lines.push("- [Dice notation](#dice-notation)");
lines.push("- [CSV data files](#csv-data-files)");
lines.push("");
lines.push("## Directive syntax");
lines.push("");
lines.push(fence(':name[content]{key="value" .class #id}', "text"));
lines.push("");
lines.push("| Part | Meaning | Example |");
lines.push("|---|---|---|");
lines.push("| `:name` | Registered element name | `:md-dice` |");
lines.push("| `[content]` | Directive body | `[2d6+d8]` |");
lines.push("| `{attrs}` | Attributes | `{key=\"attack\"}` |");
lines.push("| `{.class}` | Shorthand for `class` | `{.text-red-500}` |");
lines.push("| `{#id}` | Shorthand for `id` | `{#my-dice}` |");
lines.push("");
lines.push("Container directives:");
lines.push("");
lines.push(fence("::: container\nContent here, including nested directives.\n:dice[1d20]\n:::"));
lines.push("");
lines.push("Nest with more colons (`::::`, `:::::`).");
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("");
for (const spec of INLINE_SPECS) {
lines.push(inlineSection(spec));
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("");
if (role.details) {
lines.push(role.details);
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("## Dice notation");
lines.push("");
lines.push("Used by `:md-dice`, spark-table headers, and the journal `/roll`:");
lines.push("");
lines.push("| Notation | Meaning |");
lines.push("|---|---|");
for (const dice of DICE_NOTATION) {
lines.push(`| \`${dice.notation}\` | ${dice.meaning} |`);
}
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";
}
+250
View File
@@ -0,0 +1,250 @@
/**
* Raw-source scanner for the validator.
*
* Unlike `scanDoc`, which rewrites fenced blocks and loses offsets, this walks
* the *raw* markdown and records character offsets so diagnostics can report
* accurate line/column positions. Directives inside fenced code blocks are
* skipped (documentation examples are not live content).
*/
import { parseBlockAttrs, type BlockAttrs } from "../completions/block-scanner.js";
export interface RawDirective {
/** Directive name, e.g. "md-dice"; "" for the bare icon form `:[name]`. */
name: string;
body: string;
/** Parsed `{key=value}` attributes (shorthands such as `.class` ignored). */
attrs: Record<string, string>;
/** Raw attribute-block contents, without the braces; "" when absent. */
rawAttrs: string;
/** Offset of the leading ":". */
offset: number;
/** True for the icon form `:[name]`. */
icon: boolean;
}
export interface RawFence {
lang: string;
info: string;
attrs: BlockAttrs;
/** Offset of the opening fence line. */
offset: number;
body: string;
/** Offset of the first body character. */
bodyOffset: number;
}
export interface RawLink {
image: boolean;
/** Link target as written, e.g. "./rules.md#combat". */
target: string;
/** Offset of the target text. */
targetOffset: number;
}
export interface RawContainerOpen {
name: string;
colons: number;
offset: number;
}
export interface RawContainerClose {
colons: number;
offset: number;
}
export interface ScanResult {
directives: RawDirective[];
fences: RawFence[];
links: RawLink[];
containerOpens: RawContainerOpen[];
containerCloses: RawContainerClose[];
}
interface Line {
text: string;
offset: number;
end: number;
}
const FENCE_OPEN_RE = /^\s*(`{3,}|~{3,})(.*)$/;
const FENCE_CLOSE_RE = /^\s*(`{3,}|~{3,})\s*$/;
const CONTAINER_OPEN_RE = /^\s*(:{3,})\s*([A-Za-z][\w-]*)\s*$/;
const CONTAINER_CLOSE_RE = /^\s*(:{3,})\s*$/;
const NAMED_DIRECTIVE_RE = /:([a-zA-Z][a-zA-Z0-9-]*)\[([^\[\]]*)\](?:\{([^}]*)\})?/g;
const ICON_DIRECTIVE_RE = /:\[([^\[\]]*)\](?:\{([^}]*)\})?/g;
const LINK_RE = /(!?)\[([^\]]*)\]\(\s*([^)\s]+)(?:\s+"[^"]*")?\s*\)/g;
export function scanSource(text: string): ScanResult {
const result: ScanResult = {
directives: [],
fences: [],
links: [],
containerOpens: [],
containerCloses: [],
};
let fence:
| {
marker: string;
lang: string;
info: string;
attrs: BlockAttrs;
offset: number;
lineEnd: number;
}
| null = null;
for (const line of splitLines(text)) {
if (!fence) {
const open = FENCE_OPEN_RE.exec(line.text);
if (open) {
const rest = open[2];
const langMatch = /^\s*(\w*)\s*/.exec(rest)!;
const lang = langMatch[1] || "";
const info = rest.slice(langMatch[0].length).trim();
fence = {
marker: open[1],
lang,
info,
attrs: parseBlockAttrs(info),
offset: line.offset,
lineEnd: line.end,
};
continue;
}
scanLine(line, result);
continue;
}
const close = FENCE_CLOSE_RE.exec(line.text);
if (close && close[1][0] === fence.marker[0] && close[1].length >= fence.marker.length) {
result.fences.push({
lang: fence.lang,
info: fence.info,
attrs: fence.attrs,
offset: fence.offset,
body: text.slice(fence.lineEnd, line.offset),
bodyOffset: fence.lineEnd,
});
fence = null;
}
}
if (fence) {
result.fences.push({
lang: fence.lang,
info: fence.info,
attrs: fence.attrs,
offset: fence.offset,
body: text.slice(fence.lineEnd),
bodyOffset: fence.lineEnd,
});
}
return result;
}
function scanLine(line: Line, result: ScanResult): void {
const open = CONTAINER_OPEN_RE.exec(line.text);
if (open) {
result.containerOpens.push({
name: open[2],
colons: open[1].length,
offset: line.offset + line.text.indexOf(":"),
});
return;
}
const close = CONTAINER_CLOSE_RE.exec(line.text);
if (close) {
result.containerCloses.push({
colons: close[1].length,
offset: line.offset + line.text.indexOf(":"),
});
return;
}
// Directives and links inside inline code or HTML comments are examples.
const inert = maskedRanges(line.text);
for (const m of line.text.matchAll(NAMED_DIRECTIVE_RE)) {
if (inRanges(m.index, inert)) continue;
result.directives.push({
name: m[1],
body: m[2],
attrs: parseDirectiveAttrs(m[3] || ""),
rawAttrs: m[3] || "",
offset: line.offset + m.index,
icon: false,
});
}
for (const m of line.text.matchAll(ICON_DIRECTIVE_RE)) {
if (inRanges(m.index, inert)) continue;
result.directives.push({
name: "",
body: m[1],
attrs: parseDirectiveAttrs(m[2] || ""),
rawAttrs: m[2] || "",
offset: line.offset + m.index,
icon: true,
});
}
for (const m of line.text.matchAll(LINK_RE)) {
if (inRanges(m.index, inert)) continue;
const target = m[3];
result.links.push({
image: m[1] === "!",
target,
targetOffset: line.offset + m.index + m[0].lastIndexOf(target),
});
}
}
/** Ranges of a line whose contents are inert (inline code, HTML comments). */
function maskedRanges(line: string): Array<[number, number]> {
const ranges: Array<[number, number]> = [];
const patterns = [/`+[^`]*`+/g, /<!--.*?-->/g];
for (const re of patterns) {
let m: RegExpExecArray | null;
while ((m = re.exec(line)) !== null) {
ranges.push([m.index, m.index + m[0].length]);
}
}
return ranges;
}
function inRanges(index: number, ranges: Array<[number, number]>): boolean {
return ranges.some(([start, end]) => index >= start && index < end);
}
/** Parse `{key="value" key=value}` ignoring `.class`/`#id` shorthands. */
function parseDirectiveAttrs(raw: string): Record<string, string> {
const attrs: Record<string, string> = {};
const re = /([A-Za-z_][\w-]*)\s*=\s*("[^"]*"|'[^']*'|\S+)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(raw)) !== null) {
attrs[m[1]] = m[2].replace(/^["']|["']$/g, "");
}
return attrs;
}
function splitLines(text: string): Line[] {
const lines: Line[] = [];
let i = 0;
while (i <= text.length) {
let j = i;
while (j < text.length && text[j] !== "\n" && text[j] !== "\r") j++;
let next = j;
if (next < text.length) {
if (text[next] === "\r" && text[next + 1] === "\n") next += 2;
else next += 1;
}
lines.push({ text: text.slice(i, j), offset: i, end: next });
if (next >= text.length) break;
i = next;
}
return lines;
}
+50
View File
@@ -0,0 +1,50 @@
/**
* Source position mapping for diagnostics.
*
* Validation runs over raw file text and reports offsets; this converts an
* offset into a 1-based line/column and can read back the offending line for
* snippet display.
*/
export interface Position {
line: number;
column: number;
}
export class SourceFile {
private readonly lineStarts: number[];
constructor(readonly text: string) {
const starts = [0];
for (let i = 0; i < text.length; i++) {
if (text.charCodeAt(i) === 10) starts.push(i + 1);
}
this.lineStarts = starts;
}
/** Map a character offset to a 1-based line/column. */
position(offset: number): Position {
const clamped = Math.max(0, Math.min(offset, this.text.length));
let lo = 0;
let hi = this.lineStarts.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (this.lineStarts[mid] <= clamped) lo = mid;
else hi = mid - 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. */
lineText(line: number): string {
const start = this.lineStarts[line - 1] ?? 0;
const end = this.lineStarts[line] ?? this.text.length;
return this.text.slice(start, end).replace(/\r?\n$/, "");
}
}
+315
View File
@@ -0,0 +1,315 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { dirname, join } from "path";
import { parseMarkdown } from "../../markdown/index.js";
import { runCheck } from "./index.js";
import { formatHuman, type CheckReport } from "./diagnostics.js";
// `marked` ships ESM only, which jest's CJS runtime cannot load. The markdown
// pipeline is mocked here; its real behavior is covered outside jest.
jest.mock("../../markdown/index.js", () => ({
parseMarkdown: jest.fn((content: string) => content),
}));
const dirs: string[] = [];
function fixture(files: Record<string, string>): string {
const dir = mkdtempSync(join(tmpdir(), "tttk-check-"));
dirs.push(dir);
for (const [rel, content] of Object.entries(files)) {
const full = join(dir, rel);
mkdirSync(dirname(full), { recursive: true });
writeFileSync(full, content, "utf-8");
}
return dir;
}
function codes(report: CheckReport): string[] {
return report.diagnostics.map((d) => d.code);
}
afterEach(() => {
jest.restoreAllMocks();
while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true });
});
beforeEach(() => {
// The registry legitimately warns about unknown roles; keep test output clean.
jest.spyOn(console, "warn").mockImplementation(() => {});
jest.spyOn(console, "log").mockImplementation(() => {});
(parseMarkdown as jest.Mock).mockImplementation((content: string) => content);
});
describe("runCheck", () => {
test("a clean multi-file document produces no diagnostics", () => {
const dir = fixture({
"adventure.md":
"# Adventure\n\n:md-dice[2d6]{key=\"dmg\"}\n\n:md-table[./npcs.csv]\n",
"npcs.csv": "label,body\n1,Alice\n",
});
const report = runCheck(dir);
expect(report.diagnostics).toEqual([]);
expect(report.filesChecked).toBe(1);
});
test("R001 flags an unknown code-block role", () => {
const dir = fixture({
"doc.md": "```csv role=spark-tabel\nd6,Name\n1,Alice\n```\n",
});
const report = runCheck(dir);
expect(codes(report)).toContain("TTTK-R001");
expect(report.diagnostics[0].line).toBe(1);
});
test("R003 flags a declare block missing key/expr columns", () => {
const dir = fixture({
"doc.md": "```csv role=declare\nfoo,bar\n1,2\n```\n",
});
expect(codes(runCheck(dir))).toContain("TTTK-R003");
});
test("R005 flags invalid YAML in a role=tag block", () => {
const dir = fixture({
"doc.md": "```yaml role=tag\ntag: [unclosed\n```\n",
});
expect(codes(runCheck(dir))).toContain("TTTK-R005");
});
test("R006 flags a role=tag naming an unknown element", () => {
const dir = fixture({
"doc.md": "```yaml role=tag\ntag: md-nope\n```\n",
});
expect(codes(runCheck(dir))).toContain("TTTK-R006");
});
test("S001 reports when the markdown pipeline throws", () => {
(parseMarkdown as jest.Mock).mockImplementation(() => {
throw new Error("boom");
});
expect(codes(runCheck(fixture({ "doc.md": "# ok\n" })))).toContain(
"TTTK-S001",
);
});
test("C001 flags an unresolved content reference", () => {
const dir = fixture({
"doc.md": "See :md-table[./missing.csv].\n",
});
const report = runCheck(dir);
const diagnostic = report.diagnostics.find((d) => d.code === "TTTK-C001");
expect(diagnostic).toBeDefined();
expect(diagnostic!.line).toBe(1);
});
test("D001 flags an unregistered :md-* element", () => {
const dir = fixture({
"doc.md": "A card: :md-card[./cards.csv]\n",
});
expect(codes(runCheck(dir))).toContain("TTTK-D001");
});
test("L001 flags a missing link target", () => {
const dir = fixture({
"doc.md": "See [rules](./rules.md).\n",
});
expect(codes(runCheck(dir))).toContain("TTTK-L001");
});
test("S002 flags an unterminated container directive", () => {
const dir = fixture({
"doc.md": "::: box\nsome content\n",
});
expect(codes(runCheck(dir))).toContain("TTTK-S002");
});
test("C004 flags an embed cycle", () => {
const dir = fixture({
"a.md": ":md-embed[./b.md]\n",
"b.md": ":md-embed[./a.md]\n",
});
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",
});
expect(runCheck(dir).diagnostics).toEqual([]);
});
test("directives inside inline code are not live content", () => {
const dir = fixture({
"doc.md": "Use `:md-table[./missing.csv]` to embed a table.\n",
});
expect(runCheck(dir).diagnostics).toEqual([]);
});
test("diagnostics are sorted by file, then line", () => {
const dir = fixture({
"b.md": "See [x](./missing.md).\n",
"a.md": "\n\nSee [y](./missing.md).\n",
});
const files = runCheck(dir).diagnostics.map((d) => d.file);
expect(files).toEqual([...files].sort());
expect(files[0]).toBe("a.md");
});
});
describe("formatHuman", () => {
test("groups by file and prints a summary", () => {
const dir = fixture({ "doc.md": "See :md-table[./missing.csv].\n" });
const text = formatHuman(runCheck(dir));
expect(text).toContain("doc.md");
expect(text).toContain("TTTK-C001");
expect(text).toContain("1 error");
expect(text).toContain("1 file checked");
});
});
+5
View File
@@ -1,8 +1,13 @@
import { render } from "solid-js/web";
import { Router, Route } from "@solidjs/router";
import mermaid from "mermaid";
import App from "./App";
import "./styles.css";
// Mermaid is a runtime concern; the markdown module only emits
// <pre class="mermaid"> placeholders. Initialize before any `mermaid.run()`.
mermaid.initialize({ theme: "dark" });
const root = document.getElementById("root");
if (root) {
+1 -1
View File
@@ -1,6 +1,6 @@
import type { MarkedExtension } from "marked";
import yaml from "js-yaml";
import { parseBlockAttrs } from "./block-attrs";
import { parseBlockAttrs } from "./block-attrs.js";
/**
* YAML-defined tag blocks:
+5 -5
View File
@@ -1,12 +1,12 @@
import { Marked, type MarkedExtension } from "marked";
import { createDirectives, presetDirectiveConfigs } from "marked-directive";
import markedAlert from "marked-alert";
import markedMermaid from "./mermaid";
import markedMermaid from "./mermaid.js";
import { gfmHeadingId } from "marked-gfm-heading-id";
import markedColumns from "./columns";
import markedCodeBlockYamlTag from "./code-block-yaml-tag";
import { iconDirective, setIconBase } from "./icon";
import { cmdDirective } from "./cmd";
import markedColumns from "./columns.js";
import markedCodeBlockYamlTag from "./code-block-yaml-tag.js";
import { iconDirective, setIconBase } from "./icon.js";
import { cmdDirective } from "./cmd.js";
// 使用 marked-directive 来支持指令语法
const marked = new Marked()
-5
View File
@@ -1,9 +1,4 @@
import type {MarkedExtension} from "marked";
import mermaid from "mermaid";
mermaid.initialize({
theme: 'dark'
});
export default function markedMermaid(): MarkedExtension {
return {