Compare commits
11
Commits
9b83123b6e
...
d8ccaecade
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8ccaecade | ||
|
|
6d94a99b42 | ||
|
|
71542e7453 | ||
|
|
ca6d8d1d14 | ||
|
|
d7cf4097ca | ||
|
|
9df6b9970a | ||
|
|
9def4d17aa | ||
|
|
d1384c2ccc | ||
|
|
f33761e24f | ||
|
|
d6ea385a26 | ||
|
|
49dd95ded8 |
@@ -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.
|
||||
@@ -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"
|
||||
|
@@ -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."
|
||||
|
@@ -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"
|
||||
|
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`, …).
|
||||
@@ -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`: [未实现] 存根命令,不会产生任何输出。
|
||||
|
||||
## 输入文件
|
||||
|
||||
|
||||
@@ -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]` | 命令历史和状态追踪 |
|
||||
|
||||
## 技术栈
|
||||
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
<!-- 引用子目录的图片 -->
|
||||

|
||||
|
||||
<!-- 引用上级目录的文件 -->
|
||||
: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
@@ -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`)
|
||||
为手写内容。
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 |
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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}`);
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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$/, "");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import type {MarkedExtension} from "marked";
|
||||
import mermaid from "mermaid";
|
||||
|
||||
mermaid.initialize({
|
||||
theme: 'dark'
|
||||
});
|
||||
|
||||
export default function markedMermaid(): MarkedExtension {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user