Compare commits
11
Commits
| 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内容,并支持扩展的语法。
|
- markdown:解析以各种格式编写的ttrpg内容,并支持扩展的语法。
|
||||||
- ttrpg组件:用于ttrpg内容的各种UI组件,且可以在markdown中通过扩展语法插入。
|
- ttrpg组件:用于ttrpg内容的各种UI组件,且可以在markdown中通过扩展语法插入。
|
||||||
|
|
||||||
@@ -13,7 +13,8 @@
|
|||||||
cli使用子命令组织:
|
cli使用子命令组织:
|
||||||
|
|
||||||
- `serve`: 运行一个web服务器预览目录中的内容,并实时侦听更新。
|
- `serve`: 运行一个web服务器预览目录中的内容,并实时侦听更新。
|
||||||
- `compile`: 将目录中的内容输出为带hash路由、单个html入口的web应用。
|
- `check`: 静态校验目录中的 tttk 文档,输出结构化诊断(文件、行号、代码),存在错误时以退出码 1 结束。`--json` 输出机器可读结果。
|
||||||
|
- `compile`: [未实现] 存根命令,不会产生任何输出。
|
||||||
|
|
||||||
## 输入文件
|
## 输入文件
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
# TTTK — Tabletop Toolkit
|
# 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
|
```bash
|
||||||
# 无需安装,直接使用
|
# 无需安装,直接使用
|
||||||
npx tttk serve ./content
|
npx tttk serve ./content
|
||||||
|
|
||||||
|
# 校验内容(存在错误时退出码为 1)
|
||||||
|
npx tttk check ./content
|
||||||
```
|
```
|
||||||
|
|
||||||
## 文档导航
|
## 文档导航
|
||||||
@@ -14,6 +17,7 @@ npx tttk serve ./content
|
|||||||
| 文档 | 说明 |
|
| 文档 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| [📖 CLI 使用说明](./docs/cli.md) | CLI 安装、命令和用法 |
|
| [📖 CLI 使用说明](./docs/cli.md) | CLI 安装、命令和用法 |
|
||||||
|
| [🔤 语法参考](./docs/reference.md) | 自定义元素、代码块角色与属性目录(由 `known.ts` 生成) |
|
||||||
| [📦 npm](https://www.npmjs.com/package/tttk) | `npx tttk serve ./content` |
|
| [📦 npm](https://www.npmjs.com/package/tttk) | `npx tttk serve ./content` |
|
||||||
| [🛠️ 开发指南](./docs/development.md) | 项目结构、开发规范和构建 |
|
| [🛠️ 开发指南](./docs/development.md) | 项目结构、开发规范和构建 |
|
||||||
| [📝 Markdown 编写说明](./docs/markdown.md) | Markdown 语法和组件用法 |
|
| [📝 Markdown 编写说明](./docs/markdown.md) | Markdown 语法和组件用法 |
|
||||||
@@ -22,7 +26,7 @@ npx tttk serve ./content
|
|||||||
|
|
||||||
## 功能概览
|
## 功能概览
|
||||||
|
|
||||||
- **CLI 工具**: `tttk serve` 预览模式 和 `tttk compile` 编译模式
|
- **CLI 工具**: `tttk serve` 预览模式、`tttk check` 校验模式(`tttk compile` 尚未实现)
|
||||||
- **Markdown 解析**: 支持指令语法、YAML 标签、mermaid 图表
|
- **Markdown 解析**: 支持指令语法、YAML 标签、mermaid 图表
|
||||||
- **TTRPG 组件**: 骰子、表格、卡牌、标记、命令追踪器等
|
- **TTRPG 组件**: 骰子、表格、卡牌、标记、命令追踪器等
|
||||||
|
|
||||||
@@ -30,11 +34,11 @@ npx tttk serve ./content
|
|||||||
|
|
||||||
| 组件 | 语法 | 说明 |
|
| 组件 | 语法 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 🎲 骰子 | `:dice[2d6+d8]` | 掷骰并记录结果 |
|
| 🎲 骰子 | `:md-dice[2d6+d8]` | 掷骰并记录结果 |
|
||||||
| 📊 表格 | `:table[./data.csv]` | 标签页式表格 |
|
| 📊 表格 | `:md-table[./data.csv]` | 标签页式表格 |
|
||||||
| 🃏 卡牌 | `:md-deck[./cards.csv]` | 卡牌布局 |
|
| 🃏 卡牌 | `:md-deck[./cards.csv]` | 卡牌布局(推荐 `yaml role=tag` 写法) |
|
||||||
| 📍 标记 | `:md-pin[A]{x=40 y=40}` | 图片标记 |
|
| 📍 标记 | `:md-pins[./map.png]{pins="A:25,50"}` | 图片标记 |
|
||||||
| 📋 追踪器 | `:md-commander` | 命令历史和状态追踪 |
|
| 📋 追踪器 | `: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 使用说明
|
# CLI 使用说明
|
||||||
|
|
||||||
TTTK 提供一个 CLI 工具,用于将目录内的各种 TTRPG 文档编译为 HTML。
|
TTTK 提供一个 CLI 工具,用于预览和校验目录内的 TTRPG 文档。
|
||||||
|
|
||||||
|
> 将内容编译为 HTML 的 `compile` 命令尚未实现(见下方说明)。
|
||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
@@ -59,46 +61,118 @@ tttk serve ./my-ttrpg-content
|
|||||||
tttk serve ./docs -p 8080
|
tttk serve ./docs -p 8080
|
||||||
```
|
```
|
||||||
|
|
||||||
### compile - 编译模式
|
### check - 校验模式
|
||||||
|
|
||||||
将目录中的内容输出为带 hash 路由、单个 HTML 入口的 Web 应用。
|
静态校验目录内的 tttk 文档,输出结构化诊断(文件、行号、代码),存在错误时以退出码 `1` 结束。
|
||||||
|
|
||||||
```bash
|
```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 入口
|
| `0` | 无错误(`--strict` 下存在警告也会失败) |
|
||||||
- 复制引用的资源文件(图片、CSV 等)
|
| `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
|
```bash
|
||||||
# 编译当前目录
|
# 校验当前目录
|
||||||
tttk compile
|
tttk check
|
||||||
|
|
||||||
# 编译指定目录并输出到指定位置
|
# 校验指定目录并输出 JSON
|
||||||
tttk compile ./content -o ./build
|
tttk check ./content --json
|
||||||
|
|
||||||
# 编译并部署
|
# CI:只关注错误
|
||||||
tttk compile ./docs -o ./public && npm run preview
|
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 会搜索目录下的以下文件:
|
CLI 会搜索目录下的以下文件:
|
||||||
@@ -106,6 +180,7 @@ CLI 会搜索目录下的以下文件:
|
|||||||
- `.md` - Markdown 文档
|
- `.md` - Markdown 文档
|
||||||
- `.csv` - 表格数据
|
- `.csv` - 表格数据
|
||||||
- `.yarn` - Yarn Spinner 叙事文件
|
- `.yarn` - Yarn Spinner 叙事文件
|
||||||
|
- `.svg` - 矢量图标
|
||||||
|
|
||||||
### 文件组织建议
|
### 文件组织建议
|
||||||
|
|
||||||
@@ -126,19 +201,21 @@ my-ttrpg-content/
|
|||||||
|
|
||||||
### 相对路径引用
|
### 相对路径引用
|
||||||
|
|
||||||
若 Markdown 文件通过相对路径引用了其他文件(如图片、CSV),CLI 会在打包时自动处理这些引用:
|
若 Markdown 文件通过相对路径引用了其他文件(如图片、CSV),引用会相对于当前文档所在目录解析:
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
<!-- 引用同目录下的 CSV -->
|
<!-- 引用同目录下的 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
|
tttk serve -p 8080
|
||||||
```
|
```
|
||||||
|
|
||||||
### 编译输出为空
|
### check 报告了很多警告
|
||||||
|
|
||||||
检查输入目录是否包含 `.md` 文件:
|
先用 `--quiet` 只看错误,或在 CI 中配合 `--strict`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 查看目录内容
|
tttk check ./content --quiet
|
||||||
ls -R ./content
|
tttk check ./content --json
|
||||||
|
|
||||||
# 重新编译
|
|
||||||
tttk compile ./content -o ./dist/output
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
警告通常确有其事(例如锚点或图标不存在),建议逐条确认。
|
||||||
|
|
||||||
|
### compile 没有输出
|
||||||
|
|
||||||
|
`compile` 目前是未实现的存根命令,不会产生任何输出。请使用 `tttk serve` 预览,使用 `tttk check` 校验。
|
||||||
|
|||||||
+30
-1
@@ -31,7 +31,10 @@ npm test
|
|||||||
tttk/
|
tttk/
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── cli/ # CLI 工具源码
|
│ ├── cli/ # CLI 工具源码
|
||||||
│ │ ├── commands/ # 命令实现 (serve, compile)
|
│ │ ├── commands/ # 命令实现 (serve, check, compile)
|
||||||
|
│ │ ├── validate/ # tttk check 校验器(扫描、规则、诊断)
|
||||||
|
│ │ ├── content-registry.ts # 内容注册表(CLI / 前端共享)
|
||||||
|
│ │ ├── content-index.ts # 目录文件递归读取
|
||||||
│ │ ├── index.ts # CLI 入口
|
│ │ ├── index.ts # CLI 入口
|
||||||
│ │ └── types.ts # 类型定义
|
│ │ └── types.ts # 类型定义
|
||||||
│ ├── components/ # TTRPG 组件
|
│ ├── components/ # TTRPG 组件
|
||||||
@@ -57,6 +60,9 @@ tttk/
|
|||||||
│ ├── styles.css # 样式
|
│ ├── styles.css # 样式
|
||||||
│ ├── index.html # HTML 模板
|
│ ├── index.html # HTML 模板
|
||||||
│ └── global.d.ts # 类型声明
|
│ └── global.d.ts # 类型声明
|
||||||
|
├── .agents/
|
||||||
|
│ └── skills/
|
||||||
|
│ └── tttk-authoring/ # 随项目维护的 agent skill(见下文)
|
||||||
├── bin/
|
├── bin/
|
||||||
│ └── cli/ # CLI 二进制文件
|
│ └── cli/ # CLI 二进制文件
|
||||||
├── content/ # 示例内容
|
├── content/ # 示例内容
|
||||||
@@ -68,6 +74,29 @@ tttk/
|
|||||||
└── jest.config.js
|
└── 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",
|
"cli:build": "npm run build:cli",
|
||||||
"ttrpg": "node --loader ts-node/esm ./src/cli/index.ts",
|
"ttrpg": "node --loader ts-node/esm ./src/cli/index.ts",
|
||||||
"tttk": "node --loader ts-node/esm ./src/cli/index.ts",
|
"tttk": "node --loader ts-node/esm ./src/cli/index.ts",
|
||||||
|
"gen:reference": "node --loader ts-node/esm ./scripts/generate-reference.ts",
|
||||||
|
"gen:skill-reference": "node --loader ts-node/esm ./scripts/generate-reference.ts --out .agents/skills/tttk-authoring/reference.md",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"prepare": "npx husky",
|
"prepare": "npx husky",
|
||||||
"prepublishOnly": "npm run build"
|
"prepublishOnly": "npm run build"
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
rspack: {
|
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: {
|
module: {
|
||||||
rules: [
|
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,
|
deriveCompletions,
|
||||||
type ContentRegistry,
|
type ContentRegistry,
|
||||||
} from "../content-registry.js";
|
} from "../content-registry.js";
|
||||||
|
import { readContentIndex } from "../content-index.js";
|
||||||
import type { CompletionsPayload } from "../completions/types.js";
|
import type { CompletionsPayload } from "../completions/types.js";
|
||||||
|
|
||||||
interface ContentIndex {
|
interface ContentIndex {
|
||||||
@@ -86,39 +87,7 @@ function getBestIP(): string {
|
|||||||
* 扫描目录内的 .md 等文件,构建内容注册表(路径索引 + 每文档内联内容)
|
* 扫描目录内的 .md 等文件,构建内容注册表(路径索引 + 每文档内联内容)
|
||||||
*/
|
*/
|
||||||
export function buildRegistry(dir: string): ContentRegistry {
|
export function buildRegistry(dir: string): ContentRegistry {
|
||||||
const index: Record<string, string> = {};
|
return buildRegistryFromIndex(readContentIndex(dir));
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
* Shared by the CLI (`buildRegistry` in `commands/serve.ts`) and the browser
|
||||||
* folder scan (`scanClientSide` in `components/journal/completions.ts`) so
|
* 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`.
|
* both modes produce identical `pathIndex`/`docContent`.
|
||||||
*
|
*
|
||||||
* Keys are normalized via `normalizePathKey`; `.md` files are run through
|
* Keys are normalized via `normalizePathKey`; `.md` files are run through
|
||||||
@@ -280,7 +277,7 @@ function markdownTableToCsv(
|
|||||||
*/
|
*/
|
||||||
const MARKDOWN_TABLE_LANGS = new Set(["markdown", "md"]);
|
const MARKDOWN_TABLE_LANGS = new Set(["markdown", "md"]);
|
||||||
|
|
||||||
function isMarkdownTableLang(lang: string): boolean {
|
export function isMarkdownTableLang(lang: string): boolean {
|
||||||
return MARKDOWN_TABLE_LANGS.has(lang.toLowerCase());
|
return MARKDOWN_TABLE_LANGS.has(lang.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,7 +491,7 @@ export function deriveBlocks(registry: ContentRegistry): {
|
|||||||
* Remove fenced code blocks (backtick or tilde) from content, so text
|
* Remove fenced code blocks (backtick or tilde) from content, so text
|
||||||
* scanners (headings, dice directives) don't match example code.
|
* scanners (headings, dice directives) don't match example code.
|
||||||
*/
|
*/
|
||||||
function stripFencedBlocks(content: string): string {
|
export function stripFencedBlocks(content: string): string {
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
let fence: string | null = null;
|
let fence: string | null = null;
|
||||||
for (const line of content.split(/\r?\n/)) {
|
for (const line of content.split(/\r?\n/)) {
|
||||||
|
|||||||
+12
-1
@@ -2,6 +2,7 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { serveCommand } from "./commands/serve.js";
|
import { serveCommand } from "./commands/serve.js";
|
||||||
import { compileCommand } from "./commands/compile.js";
|
import { compileCommand } from "./commands/compile.js";
|
||||||
|
import { checkCommand } from "./commands/check.js";
|
||||||
import { mcpCommand } from "./commands/mcp.js";
|
import { mcpCommand } from "./commands/mcp.js";
|
||||||
import type { ServeOptions, CompileOptions } from "./types.js";
|
import type { ServeOptions, CompileOptions } from "./types.js";
|
||||||
|
|
||||||
@@ -22,9 +23,19 @@ program
|
|||||||
.option("--host <host>", "主机地址", "0.0.0.0")
|
.option("--host <host>", "主机地址", "0.0.0.0")
|
||||||
.action(serveCommand);
|
.action(serveCommand);
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("check")
|
||||||
|
.alias("lint")
|
||||||
|
.description("校验目录内的 tttk 文档,输出结构化诊断")
|
||||||
|
.argument("[dir]", "要校验的目录", ".")
|
||||||
|
.option("--json", "以 JSON 输出诊断", false)
|
||||||
|
.option("--strict", "将警告视为错误", false)
|
||||||
|
.option("--quiet", "只报告错误", false)
|
||||||
|
.action(checkCommand);
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("compile")
|
.command("compile")
|
||||||
.description("[实验性] 将目录内容编译为带 hash 路由的单页 Web 应用 (开发中)")
|
.description("[未实现] 存根命令:不会生成任何输出。预览内容请使用 tttk serve")
|
||||||
.argument("[dir]", "要编译的目录", ".")
|
.argument("[dir]", "要编译的目录", ".")
|
||||||
.option("-o, --output <dir>", "输出目录", "./dist/output")
|
.option("-o, --output <dir>", "输出目录", "./dist/output")
|
||||||
.action(compileCommand);
|
.action(compileCommand);
|
||||||
|
|||||||
@@ -16,6 +16,17 @@ export type CompileCommandHandler = (
|
|||||||
options: CompileOptions,
|
options: CompileOptions,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
|
export interface CheckOptions {
|
||||||
|
json: boolean;
|
||||||
|
strict: boolean;
|
||||||
|
quiet: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CheckCommandHandler = (
|
||||||
|
dir: string,
|
||||||
|
options: CheckOptions,
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
export interface MarkdownFile {
|
export interface MarkdownFile {
|
||||||
path: string;
|
path: string;
|
||||||
route: 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 { render } from "solid-js/web";
|
||||||
import { Router, Route } from "@solidjs/router";
|
import { Router, Route } from "@solidjs/router";
|
||||||
|
import mermaid from "mermaid";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import "./styles.css";
|
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");
|
const root = document.getElementById("root");
|
||||||
|
|
||||||
if (root) {
|
if (root) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { MarkedExtension } from "marked";
|
import type { MarkedExtension } from "marked";
|
||||||
import yaml from "js-yaml";
|
import yaml from "js-yaml";
|
||||||
import { parseBlockAttrs } from "./block-attrs";
|
import { parseBlockAttrs } from "./block-attrs.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* YAML-defined tag blocks:
|
* YAML-defined tag blocks:
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Marked, type MarkedExtension } from "marked";
|
import { Marked, type MarkedExtension } from "marked";
|
||||||
import { createDirectives, presetDirectiveConfigs } from "marked-directive";
|
import { createDirectives, presetDirectiveConfigs } from "marked-directive";
|
||||||
import markedAlert from "marked-alert";
|
import markedAlert from "marked-alert";
|
||||||
import markedMermaid from "./mermaid";
|
import markedMermaid from "./mermaid.js";
|
||||||
import { gfmHeadingId } from "marked-gfm-heading-id";
|
import { gfmHeadingId } from "marked-gfm-heading-id";
|
||||||
import markedColumns from "./columns";
|
import markedColumns from "./columns.js";
|
||||||
import markedCodeBlockYamlTag from "./code-block-yaml-tag";
|
import markedCodeBlockYamlTag from "./code-block-yaml-tag.js";
|
||||||
import { iconDirective, setIconBase } from "./icon";
|
import { iconDirective, setIconBase } from "./icon.js";
|
||||||
import { cmdDirective } from "./cmd";
|
import { cmdDirective } from "./cmd.js";
|
||||||
|
|
||||||
// 使用 marked-directive 来支持指令语法
|
// 使用 marked-directive 来支持指令语法
|
||||||
const marked = new Marked()
|
const marked = new Marked()
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
import type {MarkedExtension} from "marked";
|
import type {MarkedExtension} from "marked";
|
||||||
import mermaid from "mermaid";
|
|
||||||
|
|
||||||
mermaid.initialize({
|
|
||||||
theme: 'dark'
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function markedMermaid(): MarkedExtension {
|
export default function markedMermaid(): MarkedExtension {
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user