Compare commits
231
Commits
d099cf5758
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae36125af0 | ||
|
|
2ba6ed74c7 | ||
|
|
8957e3d2f1 | ||
|
|
1ce76dca64 | ||
|
|
80da5364e5 | ||
|
|
4a19336445 | ||
|
|
bd7871a800 | ||
|
|
c81cf221ef | ||
|
|
e0e7250180 | ||
|
|
12f8d8542c | ||
|
|
49bed7d279 | ||
|
|
40b6fbe157 | ||
|
|
b5f25e6b60 | ||
|
|
be247cd48c | ||
|
|
c3cdc724bd | ||
|
|
421af125bb | ||
|
|
a65ca2ab93 | ||
|
|
68b976575a | ||
|
|
30ec8a43cf | ||
|
|
59f87bffda | ||
|
|
f9ea37427b | ||
|
|
ebb49ddfca | ||
|
|
322287da29 | ||
|
|
08bc97bec5 | ||
|
|
264038e27c | ||
|
|
e838f9a4b1 | ||
|
|
3532be2335 | ||
|
|
f17f09a8c4 | ||
|
|
3508286792 | ||
|
|
0b029b325e | ||
|
|
0ac9a833e4 | ||
|
|
fadfd4cba6 | ||
|
|
16f2038fd8 | ||
|
|
02442a1100 | ||
|
|
9f372fa2b0 | ||
|
|
a26d53cbeb | ||
|
|
0ce91afc49 | ||
|
|
c7f3b3425b | ||
|
|
b5591f2a78 | ||
|
|
2ed69d744a | ||
|
|
53620e0a92 | ||
|
|
a752fdc2f4 | ||
|
|
d8ccaecade | ||
|
|
6d94a99b42 | ||
|
|
71542e7453 | ||
|
|
ca6d8d1d14 | ||
|
|
d7cf4097ca | ||
|
|
9df6b9970a | ||
|
|
9def4d17aa | ||
|
|
d1384c2ccc | ||
|
|
f33761e24f | ||
|
|
d6ea385a26 | ||
|
|
49dd95ded8 | ||
|
|
9b83123b6e | ||
|
|
b7a804f1cf | ||
|
|
256c685f6c | ||
|
|
5061c4d19d | ||
|
|
638e8f6526 | ||
|
|
80e2b5b209 | ||
|
|
5fc2bc5262 | ||
|
|
d75df5280f | ||
|
|
f1ddc8fb8e | ||
|
|
16dfc8a88c | ||
|
|
023d3a0cb9 | ||
|
|
7c4865ba8c | ||
|
|
49260ae4f6 | ||
|
|
750af4a50c | ||
|
|
c03528a293 | ||
|
|
0a68122efd | ||
|
|
e8aa7165cb | ||
|
|
9e081891df | ||
|
|
e3daac3080 | ||
|
|
a69a41875d | ||
|
|
6bcdca3a77 | ||
|
|
18cad20d2c | ||
|
|
8c9506c106 | ||
|
|
a934901cce | ||
|
|
d78d15d8ec | ||
|
|
f877a58a31 | ||
|
|
99dd49b077 | ||
|
|
7cb28e631e | ||
|
|
d74ba69fdf | ||
|
|
c4038a5213 | ||
|
|
ffa023b92a | ||
|
|
c106b6f79c | ||
|
|
2c762b0411 | ||
|
|
f172da378a | ||
|
|
3137970b97 | ||
|
|
6f01a29723 | ||
|
|
4c2eb65470 | ||
|
|
c524dd5867 | ||
|
|
411f4d79ff | ||
|
|
ae44191b28 | ||
|
|
a2d9605f4b | ||
|
|
c9c977bee1 | ||
|
|
960e310208 | ||
|
|
981c829d0f | ||
|
|
dbe2f9d9d8 | ||
|
|
894f1735e5 | ||
|
|
2983aa4440 | ||
|
|
86abf34c10 | ||
|
|
ffbfa65716 | ||
|
|
d690d5922e | ||
|
|
42e8971ff7 | ||
|
|
182d7ff28d | ||
|
|
736bcf4bb2 | ||
|
|
722a8110e6 | ||
|
|
40f2190307 | ||
|
|
241c8609f1 | ||
|
|
2187b7ed82 | ||
|
|
fc6e37a13d | ||
|
|
e46cc879ae | ||
|
|
2068ecad10 | ||
|
|
719bb6ad8a | ||
|
|
b8cfb05e53 | ||
|
|
841fbc7bae | ||
|
|
6d0bd75cb6 | ||
|
|
e437d20d73 | ||
|
|
f71af6a06d | ||
|
|
75e862c310 | ||
|
|
c661d2a1d2 | ||
|
|
52563fd3ef | ||
|
|
9790205468 | ||
|
|
d4ebca5fd0 | ||
|
|
89b32ef15e | ||
|
|
3a20e9558b | ||
|
|
c071cad50a | ||
|
|
a20063c624 | ||
|
|
06c3916a95 | ||
|
|
c38d9bdc4f | ||
|
|
fe87b1f80c | ||
|
|
30d1c5dc1b | ||
|
|
4f3b03d082 | ||
|
|
ef71a43f0a | ||
|
|
d83256e049 | ||
|
|
a4cfcde613 | ||
|
|
1c69bf394c | ||
|
|
19c66640b5 | ||
|
|
ef8b56e5b6 | ||
|
|
45cd9a01ac | ||
|
|
138c089514 | ||
|
|
44e4e15f3f | ||
|
|
9747409a1f | ||
|
|
4faff9a391 | ||
|
|
ae3045f96b | ||
|
|
6ab6b76978 | ||
|
|
2dba19e1f8 | ||
|
|
749b1a6a4b | ||
|
|
2a9eee6410 | ||
|
|
9a5ee695c2 | ||
|
|
e0c3b8f4a0 | ||
|
|
d95615f210 | ||
|
|
5097aba842 | ||
|
|
b54f7ab1fa | ||
|
|
c13f66153a | ||
|
|
376dde44b5 | ||
|
|
03671583cd | ||
|
|
2c2b7b781d | ||
|
|
3d957706e9 | ||
|
|
57ffb35f2b | ||
|
|
3eba8b3279 | ||
|
|
c10b088fb1 | ||
|
|
1ef41f04fc | ||
|
|
9a41f541e9 | ||
|
|
45c8722af8 | ||
|
|
e6d74cc318 | ||
|
|
779722fe85 | ||
|
|
48776424a7 | ||
|
|
4eaf6aab16 | ||
|
|
aa326f38a0 | ||
|
|
1ef2560faa | ||
|
|
6b3a915350 | ||
|
|
56af7dea42 | ||
|
|
e801ac9b5f | ||
|
|
9a312f76ad | ||
|
|
97148aa010 | ||
|
|
2f29f8774d | ||
|
|
3690d13407 | ||
|
|
ef7295ff7a | ||
|
|
574d17f201 | ||
|
|
ced9a4f8f2 | ||
|
|
adf28a7cf1 | ||
|
|
c5e1167beb | ||
|
|
7cfc0fd4f3 | ||
|
|
1d0f2f7c3e | ||
|
|
0fc61e2f94 | ||
|
|
59236f6dba | ||
|
|
e216b94e25 | ||
|
|
276241f3b0 | ||
|
|
e0d61cf91e | ||
|
|
4c5add2414 | ||
|
|
8284f5caee | ||
|
|
307a3c8320 | ||
|
|
13f454de9c | ||
|
|
6b5fcdc051 | ||
|
|
98c4a195e8 | ||
|
|
34d647d611 | ||
|
|
862ba9c01b | ||
|
|
53c33587fb | ||
|
|
8194438816 | ||
|
|
21f8fbcd0b | ||
|
|
08389601ac | ||
|
|
d56e8ca23d | ||
|
|
0add3f27d5 | ||
|
|
186e3c0db6 | ||
|
|
bdc06cc49a | ||
|
|
b3d390dfd5 | ||
|
|
42c7c9a26e | ||
|
|
9794184e98 | ||
|
|
53c8ce0677 | ||
|
|
a05b0f4291 | ||
|
|
d72d4e7bde | ||
|
|
0886a406bb | ||
|
|
548d6a0510 | ||
|
|
fa57727abb | ||
|
|
f337acd142 | ||
|
|
936ccb7433 | ||
|
|
1b51370ad0 | ||
|
|
e4b980202e | ||
|
|
e861b80f57 | ||
|
|
110451ae04 | ||
|
|
f734da6acd | ||
|
|
f19c7ff77c | ||
|
|
1a4ae417fe | ||
|
|
771fe6a112 | ||
|
|
a5021ff5b4 | ||
|
|
1582191655 | ||
|
|
a19d848456 | ||
|
|
4038d67ea0 | ||
|
|
d4de95b465 | ||
|
|
4953d33f0f |
@@ -0,0 +1,68 @@
|
||||
---
|
||||
name: git-commits
|
||||
description: Write Git commit messages for this project using Conventional Commits, splitting work into one commit per concern. Use when staging changes, writing or amending commit messages, or deciding how to break a set of changes into commits.
|
||||
---
|
||||
|
||||
# Git Commits
|
||||
|
||||
Use this skill whenever you write a commit message or decide how to group changes into commits.
|
||||
|
||||
## Commit message style: Conventional Commits
|
||||
|
||||
Every commit message must follow the Conventional Commits format:
|
||||
|
||||
```
|
||||
<type>(<optional scope>): <description>
|
||||
|
||||
<optional body>
|
||||
|
||||
<optional footer(s)>
|
||||
```
|
||||
|
||||
### Types
|
||||
|
||||
- `feat`: a new feature
|
||||
- `fix`: a bug fix
|
||||
- `docs`: documentation only
|
||||
- `style`: formatting, whitespace, or other changes that do not affect behavior
|
||||
- `refactor`: a code change that neither fixes a bug nor adds a feature
|
||||
- `perf`: a performance improvement
|
||||
- `test`: adding or correcting tests
|
||||
- `build`: changes to the build system or dependencies
|
||||
- `ci`: changes to CI configuration and scripts
|
||||
- `chore`: other changes that do not modify source or tests
|
||||
- `revert`: revert a previous commit
|
||||
|
||||
### Rules
|
||||
|
||||
- Use the imperative mood in the description ("add", not "added" or "adds").
|
||||
- Keep the description lowercase and do not end it with a period.
|
||||
- Limit the subject line to 50 characters where practical; never exceed 72.
|
||||
- Add a scope in parentheses when it clarifies the area of change, e.g. `fix(parser): ...`.
|
||||
- Separate the subject from the body with a blank line.
|
||||
- Use the body only when it adds useful information; do not restate the subject.
|
||||
- Wrap the body at 72 characters.
|
||||
- Reference issues in the footer when relevant, e.g. `Closes #123`.
|
||||
- Mark breaking changes with `!` after the type/scope (e.g. `feat(api)!: ...`) and explain them in a `BREAKING CHANGE:` footer.
|
||||
|
||||
## Separate commits by concern
|
||||
|
||||
Do not bundle unrelated changes into a single commit. Split work so that each commit represents one logical concern:
|
||||
|
||||
- One type of change per commit (a feature and its unrelated refactor are separate commits).
|
||||
- Keep formatting-only changes separate from behavior changes.
|
||||
- Keep test additions separate from the implementation they cover when they are independently meaningful, but do not split so finely that a commit is not self-contained.
|
||||
- Each commit should build and pass tests on its own where feasible.
|
||||
- Stage files selectively (e.g. `git add -p`, `git add <path>`) to isolate concerns rather than committing everything at once.
|
||||
|
||||
When a set of changes spans multiple concerns, propose the split to the user before committing.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Inspect what changed with `git --no-optional-locks status` and `git --no-pager diff`.
|
||||
2. Group the changes by concern.
|
||||
3. For each group, stage only the relevant files or hunks.
|
||||
4. Write a Conventional Commits message for that group.
|
||||
5. Repeat until all changes are committed.
|
||||
|
||||
Do not commit or create branches unless the user explicitly asks.
|
||||
@@ -0,0 +1,147 @@
|
||||
---
|
||||
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-print, :md-pins, 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. Both
|
||||
backtick and tilde (`~~~`) fences work; the closing fence must reuse the opening
|
||||
marker, and fence attribute keys may contain hyphens (e.g. `data-foo=bar`):
|
||||
|
||||
| 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. |
|
||||
| ` ```yaml role=declare ` | YAML `vars:` / `tags:` maps defining reactive variables and 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.
|
||||
- To print a rules document, use `:md-print[./rules.md]` (or the `yaml role=tag`
|
||||
form). It paginates into A4 landscape sheets holding two A5 pages, each with
|
||||
`columns` columns (default 2). See `reference.md` for the attributes and
|
||||
`rules.md` for how to write the rules text itself.
|
||||
- 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` block declares nothing |
|
||||
| `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.
|
||||
- `rules.md` — conventions for writing rules documents: the five-part
|
||||
structure, the theme/players/time header, icon-first mentions, veteran-first
|
||||
prose with details boxed in alerts, and a timestamp.
|
||||
- `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,393 @@
|
||||
# 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-print` | markdown path, optional #section | `columns`, `margin`, `gap`, `font`, `title`, `pageNumbers`, `timestamp`, `cutMarks` |
|
||||
| `: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-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-print`
|
||||
|
||||
Paginates a markdown document into A4 landscape sheets for printing.
|
||||
|
||||
Each sheet holds two A5 pages side by side, and each A5 page flows the markdown into `columns` columns. `margin`, `gap`, and `font` are in millimetres (the body font defaults to 3mm). `pageNumbers` adds an `n / total` footer in the outer corner, `timestamp` appends the export time to that footer, and `cutMarks` draws the centre cut line. The preview modal exports through the browser's print dialog (choose A4 landscape, no margins, 100% scale).
|
||||
|
||||
- Body: markdown path, optional #section
|
||||
- Attributes: `columns`, `margin`, `gap`, `font`, `title`, `pageNumbers`, `timestamp`, `cutMarks`
|
||||
|
||||
```markdown
|
||||
:md-print[./rules.md]{columns="2" margin="10" gap="5" font="3"}
|
||||
```
|
||||
|
||||
```markdown
|
||||
:md-print[./rules.md]{timestamp=true}
|
||||
```
|
||||
|
||||
````markdown
|
||||
```yaml role=tag
|
||||
tag: md-print
|
||||
body: ./rules.md
|
||||
columns: 2
|
||||
margin: 10
|
||||
gap: 5
|
||||
font: 3
|
||||
title: 规则书
|
||||
timestamp: true
|
||||
```
|
||||
````
|
||||
|
||||
### `: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-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.
|
||||
|
||||
Fences may use backticks or tildes (`~~~`); the closing fence must reuse
|
||||
the opening marker. Fence attribute keys may contain hyphens (e.g.
|
||||
`data-foo=bar`).
|
||||
|
||||
### `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.
|
||||
|
||||
YAML with two optional maps. `vars:` maps a `$name` to its expression. `tags:` maps a tag name to a map of thresholds to `$target` modifiers: at that tag count the target gains the value, so sibling thresholds stack. Tag names may be written with or without the leading `#`. YAML resolves bare numbers to numbers and everything else to strings; both become expression text.
|
||||
|
||||
````markdown
|
||||
```yaml role=declare
|
||||
vars:
|
||||
$hp: $con*5 + $mod_hp
|
||||
$ac: 10 + $dex
|
||||
tags:
|
||||
warrior:
|
||||
1:
|
||||
$mod_hp: 20
|
||||
2:
|
||||
$mod_str: 1
|
||||
```
|
||||
````
|
||||
|
||||
### `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 |
|
||||
| `d20` | one twenty-sided die (count optional) |
|
||||
| `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,82 @@
|
||||
# Writing rules documents
|
||||
|
||||
A rules document is the markdown printed with `:md-print[./rules.md]` (see
|
||||
`reference.md` for the element). It is read *at the table*, so it is written to
|
||||
be scanned, not studied. These conventions keep it dense and fast to use.
|
||||
|
||||
## Document structure
|
||||
|
||||
Every rules document follows the same five-part skeleton, in order:
|
||||
|
||||
1. **Briefing** — what the game is and what the players are doing.
|
||||
2. **Components** — the pieces in play (cards, tokens, sheets, dice).
|
||||
3. **Setup** — how to get from the box to the first turn.
|
||||
4. **Game flow** — the loop of play, turn by turn.
|
||||
5. **Game end** — how the game finishes and who wins.
|
||||
|
||||
Use these as `##` headings so a reader can jump straight to the part they need.
|
||||
|
||||
## Theme, players, and time
|
||||
|
||||
Open the briefing with one sentence that explains the theme — the fiction the
|
||||
players step into — then state the player count range and the time estimate:
|
||||
|
||||
```markdown
|
||||
## Briefing
|
||||
|
||||
You are rival merchants trading spice across a desert that wants you dead.
|
||||
|
||||
**2–4 players · 30–45 minutes**
|
||||
```
|
||||
|
||||
## Icon-first mentions
|
||||
|
||||
When a paragraph mentions a concept for the first time, and that concept has an
|
||||
icon under `assets/`, prefix the concept with the icon. The icon becomes a
|
||||
visual anchor the reader can find without reading the whole sentence.
|
||||
|
||||
```markdown
|
||||
Spend a :[coin]coin to bribe the :[guard]guard.
|
||||
```
|
||||
|
||||
Write the icon immediately before the word, with no space: `:[coin]coin`, not
|
||||
`:[coin] coin`. Only the **first** mention in a paragraph gets the icon; later
|
||||
mentions stay plain so the prose does not turn into a wall of icons. See
|
||||
`reference.md` for the `:[name]` inline directive.
|
||||
|
||||
## Veteran-first prose
|
||||
|
||||
Write the default text for a veteran player who only needs to refresh their
|
||||
memory: the rule, the number, the trigger. Assume they already know the game.
|
||||
|
||||
Everything that is *not* needed to play — edge cases, worked examples, the
|
||||
reasoning behind a rule, theme and flavour — is look-up material for new players
|
||||
and corner cases. Box it in a GitHub alert so it stays out of the way of the
|
||||
main line:
|
||||
|
||||
```markdown
|
||||
A :[shield]shield reduces incoming damage by 2.
|
||||
|
||||
> [!NOTE]
|
||||
> Shields do not stack. A character carrying two shields still reduces damage
|
||||
> by 2, and a shield offers no protection against fire.
|
||||
```
|
||||
|
||||
Pick the alert type to signal intent: `[!NOTE]` for clarifications and edge
|
||||
cases, `[!TIP]` for advice, `[!IMPORTANT]` for rules that are easy to miss,
|
||||
`[!WARNING]` / `[!CAUTION]` for consequences. See `markdown.md` for the alert
|
||||
syntax.
|
||||
|
||||
The test: if a veteran can play correctly from the plain text alone, the split
|
||||
is right. If a rule only exists inside an alert, it belongs in the main text.
|
||||
|
||||
## Rule timestamp
|
||||
|
||||
End every document with a timestamp, so a printed copy can be checked against
|
||||
the current rules:
|
||||
|
||||
```markdown
|
||||
---
|
||||
|
||||
*2026-10-24*
|
||||
```
|
||||
@@ -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; both backtick and tilde (`~~~`)
|
||||
fences work. 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`, …).
|
||||
@@ -0,0 +1,6 @@
|
||||
# Generated reference docs are emitted with LF by scripts/generate-reference.ts.
|
||||
# Pin them to LF so the drift guard in src/cli/validate/known.test.ts compares
|
||||
# like-for-like on Windows, where core.autocrlf would otherwise check them out
|
||||
# as CRLF.
|
||||
docs/reference.md text eol=lf
|
||||
.agents/skills/tttk-authoring/reference.md text eol=lf
|
||||
+2
-1
@@ -5,4 +5,5 @@ dist/
|
||||
.env
|
||||
.env.local
|
||||
|
||||
*.csv.d.ts
|
||||
*.csv.d.ts
|
||||
.ttrpg/
|
||||
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
npm install --no-audit --no-fund
|
||||
npm run build
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 TTTK contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
具有以下功能:
|
||||
|
||||
- cli: 提供一个cli工具,用于将目录内的各种ttrpg文档编译为html。
|
||||
- cli: 提供一个cli工具,用于预览和校验目录内的各种ttrpg文档。
|
||||
- markdown:解析以各种格式编写的ttrpg内容,并支持扩展的语法。
|
||||
- ttrpg组件:用于ttrpg内容的各种UI组件,且可以在markdown中通过扩展语法插入。
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
cli使用子命令组织:
|
||||
|
||||
- `serve`: 运行一个web服务器预览目录中的内容,并实时侦听更新。
|
||||
- `compile`: 将目录中的内容输出为带hash路由、单个html入口的web应用。
|
||||
- `check`: 静态校验目录中的 tttk 文档,输出结构化诊断(文件、行号、代码),存在错误时以退出码 1 结束。`--json` 输出机器可读结果。
|
||||
- `compile`: [未实现] 存根命令,不会产生任何输出。
|
||||
|
||||
## 输入文件
|
||||
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
# TTRPG Tools
|
||||
# TTTK — Tabletop Toolkit
|
||||
|
||||
一个基于 `solid.js` 和 `rsbuild` 的 TTRPG 工具箱,支持 Markdown 解析、自定义组件和 CLI 工具。
|
||||
A TTRPG content preview and validation tool based on `solid.js` and `rsbuild`. Write markdown, preview in browser, validate before you ship.
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
npm install
|
||||
# 无需安装,直接使用
|
||||
npx tttk serve ./content
|
||||
|
||||
# 全局安装 CLI
|
||||
npm link
|
||||
|
||||
# 预览内容
|
||||
ttrpg serve ./content
|
||||
# 校验内容(存在错误时退出码为 1)
|
||||
npx tttk check ./content
|
||||
```
|
||||
|
||||
## 文档导航
|
||||
@@ -20,6 +17,8 @@ ttrpg serve ./content
|
||||
| 文档 | 说明 |
|
||||
|------|------|
|
||||
| [📖 CLI 使用说明](./docs/cli.md) | CLI 安装、命令和用法 |
|
||||
| [🔤 语法参考](./docs/reference.md) | 自定义元素、代码块角色与属性目录(由 `known.ts` 生成) |
|
||||
| [📦 npm](https://www.npmjs.com/package/tttk) | `npx tttk serve ./content` |
|
||||
| [🛠️ 开发指南](./docs/development.md) | 项目结构、开发规范和构建 |
|
||||
| [📝 Markdown 编写说明](./docs/markdown.md) | Markdown 语法和组件用法 |
|
||||
| [📊 CSV 编写说明](./docs/csv.md) | CSV 文件格式、字段定义、变量语法 |
|
||||
@@ -27,7 +26,7 @@ ttrpg serve ./content
|
||||
|
||||
## 功能概览
|
||||
|
||||
- **CLI 工具**: `serve` 预览模式 和 `compile` 编译模式
|
||||
- **CLI 工具**: `tttk serve` 预览模式、`tttk check` 校验模式(`tttk compile` 尚未实现)
|
||||
- **Markdown 解析**: 支持指令语法、YAML 标签、mermaid 图表
|
||||
- **TTRPG 组件**: 骰子、表格、卡牌、标记、命令追踪器等
|
||||
|
||||
@@ -35,11 +34,11 @@ ttrpg serve ./content
|
||||
|
||||
| 组件 | 语法 | 说明 |
|
||||
|------|------|------|
|
||||
| 🎲 骰子 | `:dice[2d6+d8]` | 掷骰并记录结果 |
|
||||
| 📊 表格 | `:table[./data.csv]` | 标签页式表格 |
|
||||
| 🃏 卡牌 | `:md-deck[./cards.csv]` | 卡牌布局 |
|
||||
| 📍 标记 | `:md-pin[A]{x=40 y=40}` | 图片标记 |
|
||||
| 📋 追踪器 | `:md-commander` | 命令历史和状态追踪 |
|
||||
| 🎲 骰子 | `:md-dice[2d6+d8]` | 掷骰并记录结果 |
|
||||
| 📊 表格 | `:md-table[./data.csv]` | 标签页式表格 |
|
||||
| 🃏 卡牌 | `:md-deck[./cards.csv]` | 卡牌布局(推荐 `yaml role=tag` 写法) |
|
||||
| 🖨️ 打印版式 | `:md-print[./rules.md]` | A4 横版双 A5、多列排版并导出 PDF |
|
||||
| 📍 标记 | `:md-pins[./map.png]{pins="A:25,50"}` | 图片标记 |
|
||||
|
||||
## 技术栈
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* CJS mock of `github-slugger` (v2 is ESM-only, which jest's CJS runtime
|
||||
* cannot require). Auto-applied to all test files via the root `__mocks__`
|
||||
* directory — no `jest.mock()` call needed.
|
||||
*/
|
||||
class Slugger {
|
||||
constructor() {
|
||||
this.seen = new Map();
|
||||
}
|
||||
|
||||
slug(value, maintainCase) {
|
||||
let slug = String(value).trim();
|
||||
if (!maintainCase) slug = slug.toLowerCase();
|
||||
slug = slug
|
||||
.replace(/[^\p{L}\p{N}\s_-]/gu, "")
|
||||
.replace(/\s/g, "-");
|
||||
|
||||
// github-slugger dedups repeated slugs with a -1, -2, ... suffix
|
||||
const count = this.seen.get(slug) || 0;
|
||||
this.seen.set(slug, count + 1);
|
||||
if (count > 0) return `${slug}-${count}`;
|
||||
return slug;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Slugger;
|
||||
@@ -1,15 +0,0 @@
|
||||
# Commander Test
|
||||
|
||||
:md-commander[./commands.csv]
|
||||
|
||||
## Lonelog
|
||||
|
||||
`我` 走进酒吧。
|
||||
|
||||
`oracle` 酒吧里人多吗?
|
||||
|
||||
`roll` d20 = 18 `prompt` 是的
|
||||
|
||||
`fact` 酒吧里有很多人。
|
||||
|
||||
:[blah]
|
||||
@@ -1,6 +0,0 @@
|
||||
command,parameter,label,description,insertedText
|
||||
roll,emmet,2d6,2D6,2d6
|
||||
roll,emmet,d20,D20,d20
|
||||
track,emmet,monk,破戒佛爷,"破戒佛爷.bandit.monk[gd=4/4,str=14/14,cla=10,spi=8]"
|
||||
track,emmet,bandit,龙虎寨山贼,"龙虎寨山贼.bandit[gd=2/2,str=10/10,cla=10,spi=10]"
|
||||
track,emmet,khitan,契丹护卫,"契丹护卫.khitan.guard[gd=4/4,ap=2,str=14/14,cla=14,spi=14]"
|
||||
|
+27
-5
@@ -1,15 +1,37 @@
|
||||
# yaml/tag 代码块格式测试
|
||||
# yaml role=tag 代码块格式测试
|
||||
|
||||
:md-deck[./names.csv]{size="54x86" grid="5x8" bleed="1" padding="2" layers="name1:1,1-2,2f12 name2:4,7-5,8f12s num1:1,3-2,4f12 num2:4,5-5,6f12s"}
|
||||
|
||||
## 使用 yaml/tag 语法创建 md-deck
|
||||
:md-deck[./names.csv]{size="54x86" grid="5x8" layers="num1:1,1-2,2 num2:4,5-5,6f12s name1:1,1-2,2 name2:1,1-2,2 name1:1,1-2,2" }
|
||||
|
||||
```yaml/tag
|
||||
tag: md-deck
|
||||
## 使用 yaml role=tag 语法创建 md-deck(fence 行内联 tag,简洁写法)
|
||||
|
||||
```yaml role=tag tag=md-deck
|
||||
body: ./names.csv
|
||||
size: 54x86
|
||||
grid: 5x8
|
||||
bleed: 1
|
||||
padding: 2
|
||||
layers: name1:1,1-2,2f12 name2:4,7-5,8f12s num1:1,3-2,4f12 num2:4,5-5,6f12s
|
||||
```
|
||||
```
|
||||
|
||||
```yaml role=tag
|
||||
tag: md-deck
|
||||
body: ./names.csv
|
||||
data-config:
|
||||
size: 54x86
|
||||
grid: 5x8
|
||||
layers:
|
||||
- prop: name1
|
||||
pos: 1,1-2,2
|
||||
font: 12
|
||||
- prop: num2
|
||||
pos: 4,5-5,6
|
||||
font: 12
|
||||
orientation: s
|
||||
- template: |
|
||||
**{{name1}}** / {{num1}}
|
||||
pos: 1,3-5,4
|
||||
font: 6
|
||||
align: l
|
||||
```
|
||||
|
||||
@@ -33,6 +33,21 @@ bler
|
||||
|
||||
:md-table[./npcs.csv]{roll}
|
||||
|
||||
```csv role=spark-table
|
||||
2d6,encounter
|
||||
2,Tomato
|
||||
3,Onion
|
||||
4,Cabbage
|
||||
5,Carrot
|
||||
6,Potato
|
||||
7,Leek
|
||||
8,Pumpkin
|
||||
9,Mushroom
|
||||
10,Radish
|
||||
11,Eggplant
|
||||
12,Pepper
|
||||
```
|
||||
|
||||
### 链接
|
||||
|
||||
文本里面可以有:md-link[./stuff.md#啊什么]的啦
|
||||
@@ -53,3 +68,5 @@ bler
|
||||
|
||||
> [!CAUTION]
|
||||
> Negative potential consequences of an action.
|
||||
|
||||
:md-embed[./style-test.md]
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
:md-font[Pacifico]{source=google color=crimson} hello?
|
||||
:md-dice[d6]{.bg-amber-100}
|
||||
|
||||
:md-border[crimson]{.b}
|
||||
|
||||
:::div{.flex.justify-stretch.gap-1}
|
||||
|
||||
foo
|
||||
|
||||
bar
|
||||
|
||||
:::
|
||||
@@ -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.
|
||||
+137
-50
@@ -1,18 +1,20 @@
|
||||
# CLI 使用说明
|
||||
|
||||
TTRPG Tools 提供一个 CLI 工具,用于将目录内的各种 TTRPG 文档编译为 HTML。
|
||||
TTTK 提供一个 CLI 工具,用于预览和校验目录内的 TTRPG 文档。
|
||||
|
||||
> 将内容编译为 HTML 的 `compile` 命令尚未实现(见下方说明)。
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
# 克隆仓库后安装依赖
|
||||
npm install
|
||||
# 无需安装,直接运行
|
||||
npx tttk serve ./content
|
||||
|
||||
# 全局链接 CLI 工具
|
||||
npm link
|
||||
# 或全局安装
|
||||
npm install -g tttk
|
||||
```
|
||||
|
||||
安装完成后,可在任意目录使用 `ttrpg` 命令。
|
||||
安装完成后,可在任意目录使用 `tttk` 命令。
|
||||
|
||||
## 命令
|
||||
|
||||
@@ -23,7 +25,7 @@ CLI 使用子命令组织:
|
||||
运行一个 Web 服务器预览目录中的内容,并实时监听文件更新。
|
||||
|
||||
```bash
|
||||
ttrpg serve [dir] -p 3000
|
||||
tttk serve [dir] -p 3000
|
||||
```
|
||||
|
||||
**参数:**
|
||||
@@ -37,68 +39,140 @@ ttrpg serve [dir] -p 3000
|
||||
| 选项 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `-p, --port <port>` | 端口号 | `3000` |
|
||||
| `-h, --host <host>` | 主机地址 | `0.0.0.0` |
|
||||
| `--host <host>` | 主机地址 | `0.0.0.0` |
|
||||
|
||||
**功能:**
|
||||
|
||||
- 扫描目录下的所有 `.md`、`.csv`、`.yarn` 文件
|
||||
- 为每个文件创建路由
|
||||
- 提供实时文件监听,修改后自动刷新
|
||||
- 通过 `/__CONTENT_INDEX.json` 提供文件索引
|
||||
- 扫描目录下的 `.md`、`.csv`、`.yarn`、`.svg` 文件(跳过以 `.` 开头的文件和 `node_modules`)
|
||||
- 把内容目录挂在 URL 根路径下,文档 `rules/combat.md` 对应路由 `/rules/combat`
|
||||
- 监听文件变化并重建内存中的内容注册表
|
||||
- 提供 `/__CONTENT_INDEX.json`、`/__CONTENT_REGISTRY.json`、`/__COMPLETIONS.json` 等接口(见[开发服务器特性](#开发服务器特性))
|
||||
|
||||
**示例:**
|
||||
|
||||
```bash
|
||||
# 预览当前目录
|
||||
ttrpg serve
|
||||
tttk serve
|
||||
|
||||
# 预览指定目录
|
||||
ttrpg serve ./my-ttrpg-content
|
||||
tttk serve ./my-ttrpg-content
|
||||
|
||||
# 指定端口
|
||||
ttrpg serve ./docs -p 8080
|
||||
tttk serve ./docs -p 8080
|
||||
```
|
||||
|
||||
### compile - 编译模式
|
||||
### check - 校验模式
|
||||
|
||||
将目录中的内容输出为带 hash 路由、单个 HTML 入口的 Web 应用。
|
||||
静态校验目录内的 tttk 文档,输出结构化诊断(文件、行号、代码),存在错误时以退出码 `1` 结束。
|
||||
|
||||
```bash
|
||||
ttrpg compile [dir] -o ./dist/output
|
||||
tttk check [dir] --json
|
||||
```
|
||||
|
||||
`lint` 是 `check` 的别名。
|
||||
|
||||
**参数:**
|
||||
|
||||
| 参数 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `[dir]` | 要编译的目录 | `.` (当前目录) |
|
||||
| `[dir]` | 要校验的目录 | `.` (当前目录) |
|
||||
|
||||
**选项:**
|
||||
|
||||
| 选项 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `-o, --output <dir>` | 输出目录 | `./dist/output` |
|
||||
| `--json` | 以 JSON 输出诊断 | `false` |
|
||||
| `--strict` | 将警告视为错误 | `false` |
|
||||
| `--quiet` | 只报告错误 | `false` |
|
||||
|
||||
**功能:**
|
||||
**退出码:**
|
||||
|
||||
- 扫描目录下的所有 `.md` 文件
|
||||
- 解析 Markdown 并生成路由
|
||||
- 打包为带 hash 路由的单个 HTML 入口
|
||||
- 复制引用的资源文件(图片、CSV 等)
|
||||
| 代码 | 含义 |
|
||||
|------|------|
|
||||
| `0` | 无错误(`--strict` 下存在警告也会失败) |
|
||||
| `1` | 存在错误(或 `--strict` 下存在警告) |
|
||||
| `2` | 用法 / IO 错误(例如目录不存在) |
|
||||
|
||||
**输出示例:**
|
||||
|
||||
```
|
||||
content/adventures/hollow-mill/adventure.md
|
||||
12:1 error TTTK-C001 :md-table reference "./npcs.csv" does not resolve
|
||||
hint: expected a file relative to this document, or an inline id defined in it
|
||||
40:8 warning TTTK-R002 spark-table first header "Name" is not a dice formula
|
||||
|
||||
2 errors, 1 warning
|
||||
12 files checked
|
||||
```
|
||||
|
||||
**诊断代码:**
|
||||
|
||||
| 代码 | 级别 | 说明 |
|
||||
|------|------|------|
|
||||
| `TTTK-S001` | error | Markdown 解析失败 |
|
||||
| `TTTK-S002` | error | 容器指令 `:::` 未闭合 |
|
||||
| `TTTK-D001` | error | 未知的 `:md-*` 元素名 |
|
||||
| `TTTK-D002` | error | 属性块畸形(例如未加引号的值中含空格) |
|
||||
| `TTTK-R001` | error | 未知的代码块 `role=`(块内容会被静默丢弃) |
|
||||
| `TTTK-R002` | warning | `role=spark-table` 首列表头不是骰点公式 |
|
||||
| `TTTK-R003` | error | `role=declare` 解析失败(YAML 非法或结构不符) |
|
||||
| `TTTK-R004` | warning | `role=declare` 块没有声明任何变量或标签修饰符 |
|
||||
| `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
|
||||
# 编译当前目录
|
||||
ttrpg compile
|
||||
# 校验当前目录
|
||||
tttk check
|
||||
|
||||
# 编译指定目录并输出到指定位置
|
||||
ttrpg compile ./content -o ./build
|
||||
# 校验指定目录并输出 JSON
|
||||
tttk check ./content --json
|
||||
|
||||
# 编译并部署
|
||||
ttrpg compile ./docs -o ./public && npm run preview
|
||||
# CI:只关注错误
|
||||
npx tttk check ./content --quiet
|
||||
```
|
||||
|
||||
### compile - 编译模式(未实现)
|
||||
|
||||
> **存根命令。** `compile` 目前不会产生任何输出,`--help` 中也已标明。
|
||||
> 请使用 `tttk serve` 预览内容、`tttk check` 校验内容。
|
||||
|
||||
原计划的用法如下(仅供参考,尚未实现):
|
||||
|
||||
```bash
|
||||
tttk compile [dir] -o ./dist/output
|
||||
```
|
||||
|
||||
| 参数 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `[dir]` | 要编译的目录 | `.` (当前目录) |
|
||||
|
||||
| 选项 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `-o, --output <dir>` | 输出目录 | `./dist/output` |
|
||||
|
||||
## 输入文件
|
||||
|
||||
CLI 会搜索目录下的以下文件:
|
||||
@@ -106,6 +180,7 @@ CLI 会搜索目录下的以下文件:
|
||||
- `.md` - Markdown 文档
|
||||
- `.csv` - 表格数据
|
||||
- `.yarn` - Yarn Spinner 叙事文件
|
||||
- `.svg` - 矢量图标
|
||||
|
||||
### 文件组织建议
|
||||
|
||||
@@ -126,35 +201,44 @@ my-ttrpg-content/
|
||||
|
||||
### 相对路径引用
|
||||
|
||||
若 Markdown 文件通过相对路径引用了其他文件(如图片、CSV),CLI 会在打包时自动处理这些引用:
|
||||
若 Markdown 文件通过相对路径引用了其他文件(如图片、CSV),引用会相对于当前文档所在目录解析:
|
||||
|
||||
```markdown
|
||||
<!-- 引用同目录下的 CSV -->
|
||||
:table[./sparks.csv]
|
||||
:md-table[./sparks.csv]
|
||||
|
||||
<!-- 引用子目录的图片 -->
|
||||

|
||||
|
||||
<!-- 引用上级目录的文件 -->
|
||||
:deck[../data/cards.csv]
|
||||
:md-deck[../data/cards.csv]
|
||||
```
|
||||
|
||||
使用 `tttk check` 可以校验这些引用是否都能解析。
|
||||
|
||||
## 开发服务器特性
|
||||
|
||||
### 实时索引
|
||||
### 内容接口
|
||||
|
||||
访问 `http://localhost:3000/__CONTENT_INDEX.json` 可获取当前内容目录的文件索引。
|
||||
- `/__CONTENT_INDEX.json` — 原始文件索引(`{ 路径: 文件内容 }`)
|
||||
- `/__CONTENT_REGISTRY.json` — 内容注册表(文件路径索引 + 文档内联内容)
|
||||
- `/__COMPLETIONS.json` — 补全数据(骰点、链接、spark 表格、声明、标签修饰符)
|
||||
|
||||
### 自动刷新
|
||||
### 内容目录与 URL 的对应关系
|
||||
|
||||
文件变化时,服务器会自动重新加载内容,无需手动刷新页面。
|
||||
内容目录挂在 URL 根路径下,文档去掉 `.md` 扩展名后即为路由:
|
||||
|
||||
- 文档 `rules/combat.md` → `http://localhost:3000/rules/combat`
|
||||
- 图片 `rules/maps/city.png` → `http://localhost:3000/rules/maps/city.png`
|
||||
|
||||
### 文件监听
|
||||
|
||||
服务器会监听内容目录的变化并重建内存中的注册表;重新加载页面即可看到最新内容。
|
||||
|
||||
### SPA 路由
|
||||
|
||||
使用 hash 路由支持单页应用导航:
|
||||
|
||||
- `/content/rules/combat.md` → `#/content/rules/combat.md`
|
||||
- 支持浏览器前进/后退
|
||||
路由基于 History API(而非 hash)。直接访问深层链接或刷新页面时,未知路径会回退到 `index.html`,
|
||||
因此浏览器前进/后退与深链接均可正常工作。
|
||||
|
||||
## 常见问题
|
||||
|
||||
@@ -162,17 +246,20 @@ my-ttrpg-content/
|
||||
|
||||
```bash
|
||||
# 使用其他端口
|
||||
ttrpg serve -p 8080
|
||||
tttk serve -p 8080
|
||||
```
|
||||
|
||||
### 编译输出为空
|
||||
### check 报告了很多警告
|
||||
|
||||
检查输入目录是否包含 `.md` 文件:
|
||||
先用 `--quiet` 只看错误,或在 CI 中配合 `--strict`:
|
||||
|
||||
```bash
|
||||
# 查看目录内容
|
||||
ls -R ./content
|
||||
|
||||
# 重新编译
|
||||
ttrpg compile ./content -o ./dist/output
|
||||
tttk check ./content --quiet
|
||||
tttk check ./content --json
|
||||
```
|
||||
|
||||
警告通常确有其事(例如锚点或图标不存在),建议逐条确认。
|
||||
|
||||
### compile 没有输出
|
||||
|
||||
`compile` 目前是未实现的存根命令,不会产生任何输出。请使用 `tttk serve` 预览,使用 `tttk check` 校验。
|
||||
|
||||
+74
-10
@@ -6,9 +6,13 @@
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 开发模式(Web)
|
||||
# 开发模式(同时启动 CLI 内容服务器 :3000 与 Web 前端 :5173)
|
||||
npm run dev
|
||||
|
||||
# 也可分别启动
|
||||
npm run dev:cli
|
||||
npm run dev:web
|
||||
|
||||
# 构建(Web)
|
||||
npm run build
|
||||
|
||||
@@ -28,10 +32,13 @@ npm test
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
ttrpg-tools/
|
||||
tttk/
|
||||
├── src/
|
||||
│ ├── cli/ # CLI 工具源码
|
||||
│ │ ├── commands/ # 命令实现 (serve, compile)
|
||||
│ │ ├── commands/ # 命令实现 (serve, check, compile)
|
||||
│ │ ├── validate/ # tttk check 校验器(扫描、规则、诊断)
|
||||
│ │ ├── content-registry.ts # 内容注册表(CLI / 前端共享)
|
||||
│ │ ├── content-index.ts # 目录文件递归读取
|
||||
│ │ ├── index.ts # CLI 入口
|
||||
│ │ └── types.ts # 类型定义
|
||||
│ ├── components/ # TTRPG 组件
|
||||
@@ -39,9 +46,8 @@ ttrpg-tools/
|
||||
│ │ ├── md-table.tsx # 表格组件
|
||||
│ │ ├── md-deck/ # 卡牌组件
|
||||
│ │ ├── md-pins.tsx # 标记组件
|
||||
│ │ ├── md-commander/ # 命令追踪器
|
||||
│ │ ├── md-yarn-spinner.tsx # 叙事线组件
|
||||
│ │ ├── md-token.tsx # 代币组件
|
||||
|
||||
│ │ ├── Article.tsx # 文章组件
|
||||
│ │ ├── Sidebar.tsx # 侧边栏
|
||||
│ │ ├── FileTree.tsx # 文件树
|
||||
@@ -57,6 +63,9 @@ ttrpg-tools/
|
||||
│ ├── styles.css # 样式
|
||||
│ ├── index.html # HTML 模板
|
||||
│ └── global.d.ts # 类型声明
|
||||
├── .agents/
|
||||
│ └── skills/
|
||||
│ └── tttk-authoring/ # 随项目维护的 agent skill(见下文)
|
||||
├── bin/
|
||||
│ └── cli/ # CLI 二进制文件
|
||||
├── content/ # 示例内容
|
||||
@@ -68,6 +77,29 @@ ttrpg-tools/
|
||||
└── 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`)
|
||||
为手写内容。
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 |
|
||||
@@ -77,7 +109,7 @@ ttrpg-tools/
|
||||
| 样式 | Tailwind CSS 4 + @tailwindcss/typography |
|
||||
| Markdown | marked + marked-directive + marked-alert + marked-gfm-heading-id |
|
||||
| 图表 | mermaid |
|
||||
| 3D | three.js |
|
||||
|
||||
| 测试 | Jest |
|
||||
| CLI | commander + chokidar |
|
||||
|
||||
@@ -215,19 +247,51 @@ npm test -- --watch
|
||||
|
||||
## 调试
|
||||
|
||||
开发环境由两个进程组成,必须使用不同端口:
|
||||
|
||||
- Web 前端:rsbuild 开发服务器,端口 **5173**
|
||||
- CLI 内容服务器:`tttk serve`,端口 **3000**(内容 API + Journal)
|
||||
|
||||
rsbuild 会把 `/__CONTENT_INDEX.json`、`/__COMPLETIONS.json`、`/__CONTENT_REGISTRY.json`、`/.ttrpg`
|
||||
以及内容目录中的静态资源(图片、CSS、CSV 等)代理到 CLI 的 3000 端口,因此必须先启动 CLI,
|
||||
否则前端拿不到内容。
|
||||
|
||||
CLI 把内容目录挂在 URL 根路径下(例如 `/adventures/map.png`,**不是** `/content/...`),
|
||||
所以 rsbuild 会按“请求路径是否对应内容目录中的真实文件”来决定是否代理;`/static/` 下的
|
||||
前端构建产物仍由 web dev server 自己提供。
|
||||
|
||||
### Web 开发
|
||||
|
||||
`npm run dev` 会同时启动两个进程;也可以分开运行:
|
||||
|
||||
```bash
|
||||
# 同时启动 CLI(3000)与 Web(5173)
|
||||
npm run dev
|
||||
# 访问 http://localhost:3000
|
||||
|
||||
# 或分别启动
|
||||
npm run dev:cli # CLI 内容服务器,占用 3000
|
||||
npm run dev:web # Web 前端,占用 5173
|
||||
# 访问 http://localhost:5173
|
||||
```
|
||||
|
||||
### CLI 调试
|
||||
|
||||
```bash
|
||||
# 使用 ts-node 直接运行
|
||||
npm run ttrpg serve ./content
|
||||
CLI 通过 ts-node 运行,加上 `--inspect` 与 `--enable-source-maps` 后即可在编辑器中附加断点。
|
||||
如需调试启动/扫描逻辑,用 `--inspect-brk` 在用户代码执行前暂停:
|
||||
|
||||
```bash
|
||||
node --inspect-brk --enable-source-maps --loader ts-node/esm ./src/cli/index.ts serve ./content
|
||||
|
||||
# 或使用脚本(不带 --inspect-brk)
|
||||
npm run tttk:inspect -- serve ./content
|
||||
```
|
||||
|
||||
然后在 Zed / VS Code 中附加到 `localhost:9229`,或打开 `chrome://inspect`。
|
||||
|
||||
CLI 只监听内容目录(`.md`/`.csv`/`.yarn`/`.svg`)并重建内存中的注册表,**不会**热重载自身源码;
|
||||
修改 `src/cli/**` 后需要重启进程。
|
||||
|
||||
```bash
|
||||
# 或构建后运行
|
||||
npm run cli:build
|
||||
node dist/cli/index.js serve ./content
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
# Icon migration — unicode/emoji → `solid-icons`
|
||||
|
||||
**Status: implemented.** This document is both the audit and the record of the
|
||||
migration. It lists the pack/conventions used and the per-file mapping.
|
||||
|
||||
## Goal
|
||||
|
||||
Replace ad-hoc unicode symbols and emoji in **UI chrome** with real SVG icon
|
||||
components from [`solid-icons`](https://solid-icons.vercel.app), so icons are
|
||||
consistent, themeable (`currentColor`, `size`), and render identically across
|
||||
platforms (emoji fonts vary wildly between OSes).
|
||||
|
||||
## Scope
|
||||
|
||||
**In scope — UI chrome** (`src/components/**`, `src/App.tsx`, doc-entry
|
||||
frontmatter, `RevealManager` inline SVGs, the editor-panel align PNGs).
|
||||
|
||||
**Out of scope — left as-is:**
|
||||
|
||||
| Thing | Why |
|
||||
|---|---|
|
||||
| `:[icon-name]` content directive (`src/markdown/icon.ts`, `assets/`) | Game-content icons resolved from user `assets/` dirs via CSS `background-image`. Not UI chrome. |
|
||||
| CLI console output (`✅ ❌ 📦` in `src/cli/**`) | Terminal output, not a browser. `solid-icons` is a SolidJS/browser library. |
|
||||
| Emoji in `README.md`, `docs/*.md` prose | Documentation text, not rendered UI. |
|
||||
| Dimension text `×` in `PltPreview` / `PrintPreviewHeader` | A multiplication sign in "100mm × 50mm", not an icon. |
|
||||
| `content/**` sample content | User-authored data. |
|
||||
| `PltPreview.tsx` / `PrintPreview.tsx` `<svg>` | Rendered card/page artwork, not icons. |
|
||||
|
||||
## Pack
|
||||
|
||||
**Tabler** (`solid-icons/tb`). `solid-icons@1.2.0` does **not** ship Lucide; the
|
||||
available packs are `ai, bi, bs, cg, fa, fi, hi, im, io, md, oc, ri, si, tb, ti,
|
||||
vs, wi`. Tabler was chosen because it is stroke-based, uses `currentColor`, and
|
||||
covers everything needed (dice, ruler, grip, alignment, arrows, …).
|
||||
|
||||
> Exports are prefixed `TbOutline*` (outline) and `TbFill*` (filled). Always use
|
||||
> `TbOutline*`.
|
||||
|
||||
## Conventions
|
||||
|
||||
```tsx
|
||||
import { TbOutlineFolderOpen, TbOutlineX } from "solid-icons/tb";
|
||||
|
||||
<TbOutlineFolderOpen size={16} class="text-gray-400" />
|
||||
```
|
||||
|
||||
- `size?: string | number` defaults to `"1em"`; `color` defaults to
|
||||
`currentColor`. Icons therefore inherit the surrounding font-size and text
|
||||
color, so existing Tailwind text-color/size classes keep working.
|
||||
- **Inline alignment:** an inline SVG sits on the text baseline, so icons placed
|
||||
inline next to text get `class="inline-block align-[-0.125em]"`. Icons inside
|
||||
a `flex`/`inline-flex` container don't need it.
|
||||
- **Fixed-size buttons** get an explicit `size` (`16` for `w-7 h-7`/`w-8 h-8`,
|
||||
`14` for `w-5 h-5`/`w-6 h-6`).
|
||||
- Text labels next to a glyph (e.g. `📥 导出 PDF`) are preserved; only the glyph
|
||||
is replaced.
|
||||
|
||||
## Shared modules
|
||||
|
||||
- **`src/components/doc-icons.ts`** — maps doc-entry frontmatter `icon:` keys
|
||||
(e.g. `dice`, `table`) to Tabler components. Frontmatter is YAML, so it can't
|
||||
hold a component; it stores a semantic key instead. `docIcon(key)` falls back
|
||||
to `TbOutlineFile`.
|
||||
- **`CompletionItem.icon`** (`src/components/journal/command-parser.ts`) — an
|
||||
optional `IconTypes` rendered by `CompletionsDropdown.tsx` before the label,
|
||||
so completion entries carry real icons instead of emoji in the label string.
|
||||
|
||||
## Inventory
|
||||
|
||||
### Shell / navigation
|
||||
|
||||
| File | Glyph | Purpose | Icon |
|
||||
|---|---|---|---|
|
||||
| `src/App.tsx` | `☰` | mobile menu | `TbOutlineMenu2` |
|
||||
| `src/App.tsx` | `📋` | journal panel toggle | `TbOutlineClipboardList` |
|
||||
| `src/App.tsx` | `?` | docs/help | `TbOutlineHelpCircle` |
|
||||
| `src/components/Sidebar.tsx` | `📂` | content source | `TbOutlineFolderOpen` |
|
||||
| `src/components/Sidebar.tsx` | `✕` | close sidebar | `TbOutlineX` |
|
||||
| `src/components/FileTree.tsx` | `📂` / `📁` | folder open / closed | `TbOutlineFolderOpen` / `TbOutlineFolder` |
|
||||
| `src/components/FileTree.tsx` | `📄` | file | `TbOutlineFile` |
|
||||
| `src/components/FileTree.tsx` | `▶` | expand chevron | `TbOutlineChevronRight` |
|
||||
| `src/components/DocDialog.tsx` | `▾` | dropdown caret | `TbOutlineChevronDown` |
|
||||
| `src/components/DocDialog.tsx` | `📖` | directives doc set | `TbOutlineBook2` |
|
||||
| `src/components/DocDialog.tsx` | `✓` | selected doc set | `TbOutlineCheck` |
|
||||
| `src/components/DocDialog.tsx` | `📋` | journal doc set | `TbOutlineClipboardList` |
|
||||
| `src/components/DocDialog.tsx` | `✕` | close dialog | `TbOutlineX` |
|
||||
| `src/components/DocDialog.tsx` | entry `icon:` | doc entry icon | `<Dynamic component={docIcon(entry.icon)} />` |
|
||||
|
||||
### md-* components
|
||||
|
||||
| File | Glyph | Purpose | Icon |
|
||||
|---|---|---|---|
|
||||
| `src/components/md-dice.tsx` | `🎲` | dice reset | `TbOutlineDice5` |
|
||||
| `src/components/md-table.tsx` | `🎲` | random row | `TbOutlineDice5` |
|
||||
| `src/components/md-pins.tsx` | `📋` | copy pin coords | `TbOutlineCopy` |
|
||||
| `src/components/md-yarn-spinner.tsx` | `🔄` | restart | `TbOutlineRotate` |
|
||||
| `src/components/md-yarn-spinner.tsx` | `⏩` | advance | `TbOutlinePlayerTrackNext` |
|
||||
| `src/components/md-yarn-spinner.tsx` | `→` | option bullet | `TbOutlineArrowRight` |
|
||||
|
||||
### Journal
|
||||
|
||||
| File | Glyph | Purpose | Icon |
|
||||
|---|---|---|---|
|
||||
| `src/components/journal/ConnectDialog.tsx` | `🗑` | delete session | `TbOutlineTrash` |
|
||||
| `src/components/journal/CreateSessionDialog.tsx` | `✕` | close | `TbOutlineX` |
|
||||
| `src/components/journal/DeleteSessionDialog.tsx` | `✕` | close | `TbOutlineX` |
|
||||
| `src/components/journal/ErrorPopup.tsx` | `✕` | close | `TbOutlineX` |
|
||||
| `src/components/journal/InviteDialog.tsx` | `✕` | close | `TbOutlineX` |
|
||||
| `src/components/journal/JournalHeader.tsx` | `←` | back to session list | `TbOutlineArrowLeft` |
|
||||
| `src/components/journal/JournalHeader.tsx` | `▸` | hide panel | `TbOutlineLayoutSidebarRightCollapse` |
|
||||
| `src/components/journal/SessionDropdown.tsx` | `▾` | dropdown caret | `TbOutlineChevronDown` |
|
||||
| `src/components/journal/SessionDropdown.tsx` | `✓` | active session | `TbOutlineCheck` |
|
||||
| `src/components/journal/SessionDropdown.tsx` | `🗑` | delete session | `TbOutlineTrash` |
|
||||
| `src/components/journal/StreamMessage.tsx` | `×` | revert message | `TbOutlineArrowBackUp` |
|
||||
| `src/components/journal/types/intent.tsx` | `🙋` | intent | `TbOutlineHandStop` |
|
||||
| `src/components/journal/types/intent.tsx` | `✅` | verdict | `TbOutlineCircleCheck` |
|
||||
| `src/components/journal/types/link.tsx` | `📄` | link | `TbOutlineFileText` |
|
||||
| `src/components/journal/types/roll.tsx` | `🎲` | roll | `TbOutlineDice5` |
|
||||
| `src/components/journal/types/roll.tsx` | `→` | notation → total | `TbOutlineArrowRight` |
|
||||
| `src/components/journal/types/var.tsx` | `🔢` | variable | `TbOutlineHash` |
|
||||
| `src/components/journal/command-completions.ts` | `🎲` | dice completion | `TbOutlineDice5` (via `item.icon`) |
|
||||
| `src/components/journal/command-completions.ts` | `✨` | spark completion | `TbOutlineSparkles` (via `item.icon`) |
|
||||
|
||||
### md-deck
|
||||
|
||||
| File | Glyph | Purpose | Icon |
|
||||
|---|---|---|---|
|
||||
| `src/components/md-deck/DeckHeader.tsx` | `✓` / `✏️` | editing toggle | `TbOutlineCheck` / `TbOutlinePencil` |
|
||||
| `src/components/md-deck/DeckHeader.tsx` | `📥` | export PDF | `TbOutlineDownload` |
|
||||
| `src/components/md-deck/DeckHeader.tsx` | `📋` | copy code | `TbOutlineCopy` |
|
||||
| `src/components/md-deck/PltPreview.tsx` | `📥` | download PLT | `TbOutlineDownload` |
|
||||
| `src/components/md-deck/PrintPreviewFooter.tsx` | `❌` | export error | `TbOutlineCircleX` |
|
||||
| `src/components/md-deck/PrintPreviewFooter.tsx` | `✕` | close | `TbOutlineX` |
|
||||
| `src/components/md-deck/PrintPreviewHeader.tsx` | `➕` | add deck | `TbOutlinePlus` |
|
||||
| `src/components/md-deck/PrintPreviewHeader.tsx` | `📐` | PLT preview | `TbOutlineRuler` |
|
||||
| `src/components/md-deck/PrintPreviewHeader.tsx` | `📥` | export PDF | `TbOutlineDownload` |
|
||||
| `src/components/md-deck/CardList.tsx` | `‹` | previous page | `TbOutlineChevronLeft` |
|
||||
| `src/components/md-deck/CardList.tsx` | `›` | next page | `TbOutlineChevronRight` |
|
||||
| `src/components/md-deck/editor-panel/LayerRow.tsx` | `⠿` | drag handle | `TbOutlineGripVertical` |
|
||||
| `src/components/md-deck/editor-panel/LayerRow.tsx` | `👁` / `👁🗨` | visible / hidden | `TbOutlineEye` / `TbOutlineEyeOff` |
|
||||
| `src/components/md-deck/editor-panel/LayerRow.tsx` | `↑ → ↓ ←` | orientation | `TbOutlineArrowUp` / `TbOutlineArrowRight` / `TbOutlineArrowDown` / `TbOutlineArrowLeft` |
|
||||
| `src/components/md-deck/editor-panel/LayerRow.tsx` | `↖ ↑ ↗ ↙ ↓ ↘` | alignment | `TbOutlineArrowUpLeft` / `TbOutlineArrowUp` / `TbOutlineArrowUpRight` / `TbOutlineArrowDownLeft` / `TbOutlineArrowDown` / `TbOutlineArrowDownRight` |
|
||||
| `src/components/md-deck/editor-panel/LayerRow.tsx` | `✕` | delete layer | `TbOutlineTrash` |
|
||||
| `src/components/md-deck/editor-panel/LayerRow.tsx` | `align-*.png` | align presets | `TbOutlineAlignLeft` / `TbOutlineAlignCenter` / `TbOutlineAlignRight` |
|
||||
|
||||
The three `align-*.png` assets were deleted; the `icons/` directory is gone.
|
||||
|
||||
### `RevealManager.tsx` (formerly inline SVG strings)
|
||||
|
||||
| Former | Purpose | Icon |
|
||||
|---|---|---|
|
||||
| `LINK_SVG` | floating `/link` action | `TbOutlineLink` |
|
||||
| `SPARK_SVG` | floating `/roll` spark action | `TbOutlineSparkles` |
|
||||
|
||||
`FloatingButton.svg: string` (rendered via `innerHTML`) became
|
||||
`FloatingButton.icon: IconTypes`, rendered as `<Dynamic component={icon} />`
|
||||
children.
|
||||
|
||||
### `src/doc-entries/*.md` frontmatter `icon:`
|
||||
|
||||
| File / tag | Key |
|
||||
|---|---|
|
||||
| `journal-gm.md` | `gm` |
|
||||
| `journal-link.md` | `link` |
|
||||
| `journal-player.md` | `player` |
|
||||
| `journal-roll.md` | `roll` |
|
||||
| `journal-set.md` | `set` |
|
||||
| `journal-spark.md` | `spark` |
|
||||
| `md-bg.md` | `background` |
|
||||
| `md-border.md` | `border` |
|
||||
| `md-deck.md` | `deck` |
|
||||
| `md-dice.md` | `dice` |
|
||||
| `md-embed.md` | `embed` |
|
||||
| `md-font.md` | `font` |
|
||||
| `md-link.md` | `link` |
|
||||
| `md-pins.md` | `pins` |
|
||||
| `md-table.md` | `table` |
|
||||
| `md-yarn-spinner.md` | `yarn` |
|
||||
|
||||
Keys are resolved through `docIcon()` in `src/components/doc-icons.ts`.
|
||||
|
||||
## Follow-ups / not migrated (by design)
|
||||
|
||||
- Doc-entry **body prose** that referenced a replaced button glyph was reworded
|
||||
to name the button (e.g. `md-dice.md` now says "点击骰子图标"). Remaining emoji
|
||||
in `src/doc-entries/*.md` are illustrative — coloured status dots
|
||||
(`🟢🟡🔴⚪` in `journal-gm.md`) — not UI icons.
|
||||
- CLI console / MCP output (`✅ ❌ 📦` in `src/cli/**`) keeps its emoji: it is
|
||||
terminal text, not rendered UI.
|
||||
- `command-completions.ts` `CompletionItem` now carries an optional `icon`; any
|
||||
future completion source can use it.
|
||||
+171
-75
@@ -11,6 +11,10 @@
|
||||
- [marked-gfm-heading-id](https://github.com/markedjs/marked-gfm-heading-id) - 标题 ID
|
||||
- [marked-directive](https://github.com/fengzilong/marked-directive) - 指令语法
|
||||
|
||||
### 代码块围栏
|
||||
|
||||
代码块围栏可使用反引号或波浪号(`~~~`)书写;闭合围栏必须与开启围栏使用相同字符,且不短于开启围栏。声明 `role=` 的代码块同理。
|
||||
|
||||
## 指令语法
|
||||
|
||||
通过 `marked-directive` 支持自定义组件插入:
|
||||
@@ -96,19 +100,19 @@
|
||||
|
||||
**注意:** CSS class 名称始终使用不带扩展名的图标名称(如 `icon-attack`),方便统一样式控制。
|
||||
|
||||
### 配置图标前缀
|
||||
### 图标查找路径
|
||||
|
||||
可通过 `iconPath` 属性指定图标目录:
|
||||
图标没有单独的配置项。`:[name]` 会从当前文档所在目录开始,逐级向上查找 `assets/` 目录,
|
||||
直到内容目录根:
|
||||
|
||||
```markdown
|
||||
<Article src="/content/page.md" iconPath="./assets/icons" />
|
||||
```text
|
||||
rules/combat.md 中的 :[attack]
|
||||
→ rules/assets/attack.png
|
||||
→ assets/attack.png
|
||||
```
|
||||
|
||||
或使用空字符串禁用图标前缀:
|
||||
|
||||
```markdown
|
||||
<Article src="/content/page.md" iconPath="" />
|
||||
```
|
||||
因此把图标放在内容目录(或其子目录)的 `assets/` 下即可。`tttk check` 会用同样的规则
|
||||
校验图标是否存在(诊断 `TTTK-A001`)。
|
||||
|
||||
## 组件库
|
||||
|
||||
@@ -123,6 +127,7 @@
|
||||
- 点击文本执行掷骰
|
||||
- 点击骰子图标重置为公式
|
||||
- `key` 属性将结果记录到 URL 参数中 (`?dice-attack=15`)
|
||||
- 骰子数量可省略,`d20` 等价于 `1d20`
|
||||
|
||||
**属性:**
|
||||
|
||||
@@ -168,17 +173,20 @@
|
||||
```markdown
|
||||
:md-bg[./images/background.jpg]
|
||||
:md-bg[./images/pattern.png]{fit="contain"}
|
||||
:md-bg[#ff00dd]
|
||||
:md-bg[rgb(100,200,50)]
|
||||
```
|
||||
|
||||
**功能:**
|
||||
- 将图片设置为当前文章卡片的背景
|
||||
- 支持纯色背景(CSS 颜色值)
|
||||
- 自动覆盖整个卡片区域
|
||||
|
||||
**属性:**
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `fit` | `cover` \| `contain` \| `fill` \| `none` \| `scale-down` | `cover` | 背景适配方式 |
|
||||
| `fit` | `cover` \| `contain` \| `fill` \| `none` \| `scale-down` | `cover` | 背景适配方式(仅图片时生效) |
|
||||
|
||||
**示例:**
|
||||
|
||||
@@ -188,6 +196,53 @@
|
||||
# 地牢探险
|
||||
|
||||
这里是地牢的描述内容...
|
||||
|
||||
:md-bg[#2a1a3a]
|
||||
|
||||
# 暗紫色背景
|
||||
|
||||
:md-bg[rgb(200,50,50)]
|
||||
|
||||
# 红色背景
|
||||
```
|
||||
|
||||
### 🔤 字体组件 (md-font)
|
||||
|
||||
```markdown
|
||||
:md-font[Noto Sans SC]{source="google" weight="700" color="#ff00dd"}
|
||||
:md-font[LXGW WenKai]{source="emfont"}
|
||||
:md-font[Arial]{source="local"}
|
||||
```
|
||||
|
||||
**功能:**
|
||||
- 设置当前文章卡片的字体
|
||||
- 支持从 Google Fonts、emfont 或本地加载字体
|
||||
- 支持设置字体颜色
|
||||
|
||||
**属性:**
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `source` | `google` \| `emfont` \| `local` | `local` | 字体来源 |
|
||||
| `weight` | string | `400` | 字体粗细 |
|
||||
| `color` | string | - | 字体颜色(CSS 颜色值,如 `#ff00dd`、`rgb(255,0,0)`) |
|
||||
|
||||
**示例:**
|
||||
|
||||
```markdown
|
||||
:md-font[Noto Sans SC]{source="google" weight="400"}
|
||||
|
||||
# 标题文本
|
||||
|
||||
这里是正文内容,使用 Noto Sans SC 字体。
|
||||
|
||||
:md-font[Noto Sans SC]{source="google" weight="400" color="#1a1a2e"}
|
||||
|
||||
# 深色文字
|
||||
|
||||
:md-font[Noto Sans SC]{source="google" weight="700" color="crimson"}
|
||||
|
||||
# 红色粗体标题
|
||||
```
|
||||
|
||||
### 📍 标记组件 (md-pins)
|
||||
@@ -323,26 +378,30 @@ label,name,description
|
||||
:md-table[./quests.csv]{roll=true remix=true}
|
||||
```
|
||||
|
||||
**自动表格转换:**
|
||||
**内联表格(显式声明):**
|
||||
|
||||
标准 Markdown 表格会自动转换为 `md-table` 组件,当表头包含 `label` 或 `md-table-label` 列时:
|
||||
Markdown 表格不会自动转换。如需内联表格,用代码块并声明 `role=spark-table`(首列需为骰子公式,如 `d6`):
|
||||
|
||||
```markdown
|
||||
| label | name | description |
|
||||
|-------|------|-------------|
|
||||
| 1 | 战士 | 近战专家 |
|
||||
| 2 | 法师 | 奥术施法者 |
|
||||
````markdown
|
||||
```markdown role=spark-table
|
||||
| d6 | 结果 |
|
||||
|----|------|
|
||||
| 1 | 遭遇强盗 |
|
||||
| 2 | 平安无事 |
|
||||
```
|
||||
````
|
||||
|
||||
自动转换为 `:md-table` 组件。
|
||||
扫描时转换为 CSV 并渲染为 `md-table` 组件。CSV 格式的内联表格用 `csv` 语言:
|
||||
|
||||
**特殊表头标识:**
|
||||
````markdown
|
||||
```csv role=spark-table
|
||||
d6,结果
|
||||
1,遭遇强盗
|
||||
2,平安无事
|
||||
```
|
||||
````
|
||||
|
||||
| 表头 | 效果 |
|
||||
|------|------|
|
||||
| `label` 或 `md-table-label` | 转换为 md-table |
|
||||
| `md-roll-label` 或骰子格式(如 `1d6`) | 添加 `roll=true` |
|
||||
| `md-remix-label` | 添加 `roll=true remix=true` |
|
||||
普通 Markdown 表格(无 role 声明)始终按标准 GFM 表格渲染,不做任何转换。
|
||||
|
||||
### 🃏 卡牌组件 (md-deck)
|
||||
|
||||
@@ -373,6 +432,83 @@ layers="字段:起始行,起始列 - 结束列,字体大小"
|
||||
|
||||
示例:`title:1,1-5,1f8` 表示 title 字段从第 1 行开始,占据 1-5 列,8mm字体。
|
||||
|
||||
**结构化配置(yaml 代码块):**
|
||||
|
||||
推荐使用 ```yaml role=tag 代码块(`yaml` 语言可被语法高亮),通过 `body` 指定 CSV 路径,`data-config` 提供结构化配置(图层可以是字段或模板):
|
||||
|
||||
````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
|
||||
```
|
||||
````
|
||||
|
||||
每个图层通过 `prop`(读取 CSV 字段)或 `template`(渲染带 `{{变量}}` 替换的 markdown 模板)定义,`pos` 为网格位置 `x1,y1-x2,y2`(1-based,与紧凑 layers 字符串同格式),支持 `font`、`orientation`、`align`(水平 `l`/`c`/`r`,可加垂直前缀 `t`/`b` 组成 `tl`/`tc`/`tr`/`bl`/`bc`/`br`,如 `align: tl` 表示左上对齐)。`back_layers` 用于背面图层。
|
||||
|
||||
### 🖨️ 打印版式组件 (md-print)
|
||||
|
||||
将 Markdown 文档排版为可打印的 A4 横版页面:每张 A4 放两个 A5 页面,每个 A5 页面再分成多列。
|
||||
|
||||
```markdown
|
||||
:md-print[./rules.md]
|
||||
:md-print[./rules.md]{columns="2" margin="10" gap="5" font="3"}
|
||||
```
|
||||
|
||||
**功能:**
|
||||
- 自动分页:内容按块级元素流入 A5 页面,段落、列表和表格可在分页处拆分
|
||||
- 每张 A4 横版放两个 A5 页面,每个 A5 页面默认两列
|
||||
- 预览中可直接打印或另存为 PDF
|
||||
- 支持页眉标题、页码、导出时间戳和中缝裁切线
|
||||
|
||||
**属性:**
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `columns` | number | `2` | 每个 A5 页面的列数(1–4) |
|
||||
| `margin` | number | `10` | 页面边距(毫米) |
|
||||
| `gap` | number | `5` | 列间距(毫米) |
|
||||
| `font` | number | `3` | 正文字号(毫米) |
|
||||
| `title` | string | - | 页眉标题 |
|
||||
| `pageNumbers` | boolean | `true` | 是否在页面外侧角落显示页码 |
|
||||
| `timestamp` | boolean | `false` | 是否在页码旁边显示导出时间 |
|
||||
| `cutMarks` | boolean | `true` | 是否显示 A4 中缝裁切线 |
|
||||
|
||||
**YAML 写法:**
|
||||
|
||||
```yaml
|
||||
tag: md-print
|
||||
body: ./rules.md
|
||||
columns: 2
|
||||
margin: 10
|
||||
gap: 5
|
||||
font: 3
|
||||
title: 规则书
|
||||
timestamp: true
|
||||
```
|
||||
|
||||
**导出时间戳:**
|
||||
|
||||
```markdown
|
||||
:md-print[./rules.md]{timestamp=true}
|
||||
```
|
||||
|
||||
开启后页脚会显示类似 `3 / 12 · 2026-10-23 14:05` 的内容。页码位于页面外侧底角:左侧 A5 在左下角,右侧 A5 在右下角。
|
||||
|
||||
**打印设置:** 在浏览器打印对话框中选择 A4、横向、无边距、100% 缩放。
|
||||
|
||||
### 🧶 叙事线组件 (md-yarn-spinner)
|
||||
|
||||
用于展示分支叙事结构,支持 Yarn Spinner 格式文件。
|
||||
@@ -381,61 +517,12 @@ layers="字段:起始行,起始列 - 结束列,字体大小"
|
||||
:md-yarn-spinner[./story.yarn]
|
||||
```
|
||||
|
||||
### 🪙 代币组件 (md-token)
|
||||
|
||||
用于展示游戏代币/棋子。
|
||||
|
||||
```markdown
|
||||
:md-token[./token.png]
|
||||
```
|
||||
|
||||
### 🎨 代币预览组件 (md-token-viewer)
|
||||
|
||||
用于 3D 预览 3MF 格式的代币模型。
|
||||
|
||||
```markdown
|
||||
:md-token-viewer[./token.3mf]
|
||||
```
|
||||
|
||||
**功能:**
|
||||
- 使用 Three.js 渲染 3D 模型
|
||||
- 支持鼠标拖拽旋转
|
||||
- 自动旋转展示
|
||||
|
||||
### 📋 命令追踪器 (md-commander)
|
||||
|
||||
支持命令历史和状态追踪。
|
||||
|
||||
```markdown
|
||||
:md-commander
|
||||
```
|
||||
|
||||
**追踪器命令:**
|
||||
|
||||
```
|
||||
track npc#john.dwarf.warrior[hp=4/4 ac=15 name="John"]
|
||||
```
|
||||
|
||||
**Emmet 简写语法:**
|
||||
|
||||
- `#id` - 设置 ID
|
||||
- `.class` - 添加类别
|
||||
- `[attr=value]` - 设置属性
|
||||
|
||||
**属性类型:**
|
||||
|
||||
| 类型 | 格式 | 显示 |
|
||||
|------|------|------|
|
||||
| progress | `x/y` | 进度条 |
|
||||
| count | 整数 | 数字计数器 |
|
||||
| string | 文本 | 文本字段 |
|
||||
|
||||
## YAML 标签
|
||||
|
||||
使用 ```yaml/tag 代码块创建自定义标签:
|
||||
使用 ```yaml role=tag 代码块创建自定义标签(`yaml` 语言可被语法高亮):
|
||||
|
||||
````markdown
|
||||
```yaml/tag
|
||||
```yaml role=tag
|
||||
tag: tag-name
|
||||
class: custom-class
|
||||
id: my-id
|
||||
@@ -443,6 +530,15 @@ body: 标签内容
|
||||
```
|
||||
````
|
||||
|
||||
`tag`、`id` 等简单属性也可以直接写在 fence 行上(fence 行属性优先于 YAML 内容):
|
||||
|
||||
````markdown
|
||||
```yaml role=tag tag=tag-name id=my-id
|
||||
class: custom-class
|
||||
body: 标签内容
|
||||
```
|
||||
````
|
||||
|
||||
渲染为:
|
||||
|
||||
```html
|
||||
|
||||
+5
-4
@@ -246,10 +246,12 @@ ttrpg mcp generate-card-deck \
|
||||
{
|
||||
"success": true,
|
||||
"message": "预览文件已创建",
|
||||
"preview_url": "http://localhost:3000/#/content/cards.md"
|
||||
"preview_url": "http://localhost:3000/cards"
|
||||
}
|
||||
```
|
||||
|
||||
`preview_url` 以服务器工作目录为基准,去掉了 `.md` 扩展名(需要 `tttk serve` 正在运行)。
|
||||
|
||||
## CSV 文件格式
|
||||
|
||||
CSV 文件使用 YAML frontmatter 定义模板和配置:
|
||||
@@ -335,8 +337,6 @@ MCP 服务器提供以下 Resources,包含 TTRPG Tools 的文档和参考材
|
||||
- `:md-table` - 表格组件
|
||||
- `:md-deck` - 卡牌组件
|
||||
- `:md-yarn-spinner` - 叙事线组件
|
||||
- `:md-token` - 代币组件
|
||||
- `:md-commander` - 命令追踪器
|
||||
- YAML 标签
|
||||
- Mermaid 图表
|
||||
- 警告/提示块
|
||||
@@ -535,7 +535,8 @@ src/cli/
|
||||
│ │ └── write-frontmatter.ts
|
||||
│ ├── card/
|
||||
│ │ └── card-crud.ts
|
||||
│ ├── ensure-deck-preview.ts
|
||||
│ ├── deck-utils.ts
|
||||
│ ├── preview-deck.ts
|
||||
│ └── generate-card-deck.ts
|
||||
└── index.ts
|
||||
```
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
# 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-print` | markdown path, optional #section | `columns`, `margin`, `gap`, `font`, `title`, `pageNumbers`, `timestamp`, `cutMarks` |
|
||||
| `: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-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-print`
|
||||
|
||||
Paginates a markdown document into A4 landscape sheets for printing.
|
||||
|
||||
Each sheet holds two A5 pages side by side, and each A5 page flows the markdown into `columns` columns. `margin`, `gap`, and `font` are in millimetres (the body font defaults to 3mm). `pageNumbers` adds an `n / total` footer in the outer corner, `timestamp` appends the export time to that footer, and `cutMarks` draws the centre cut line. The preview modal exports through the browser's print dialog (choose A4 landscape, no margins, 100% scale).
|
||||
|
||||
- Body: markdown path, optional #section
|
||||
- Attributes: `columns`, `margin`, `gap`, `font`, `title`, `pageNumbers`, `timestamp`, `cutMarks`
|
||||
|
||||
```markdown
|
||||
:md-print[./rules.md]{columns="2" margin="10" gap="5" font="3"}
|
||||
```
|
||||
|
||||
```markdown
|
||||
:md-print[./rules.md]{timestamp=true}
|
||||
```
|
||||
|
||||
````markdown
|
||||
```yaml role=tag
|
||||
tag: md-print
|
||||
body: ./rules.md
|
||||
columns: 2
|
||||
margin: 10
|
||||
gap: 5
|
||||
font: 3
|
||||
title: 规则书
|
||||
timestamp: true
|
||||
```
|
||||
````
|
||||
|
||||
### `: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-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.
|
||||
|
||||
Fences may use backticks or tildes (`~~~`); the closing fence must reuse
|
||||
the opening marker. Fence attribute keys may contain hyphens (e.g.
|
||||
`data-foo=bar`).
|
||||
|
||||
### `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.
|
||||
|
||||
YAML with two optional maps. `vars:` maps a `$name` to its expression. `tags:` maps a tag name to a map of thresholds to `$target` modifiers: at that tag count the target gains the value, so sibling thresholds stack. Tag names may be written with or without the leading `#`. YAML resolves bare numbers to numbers and everything else to strings; both become expression text.
|
||||
|
||||
````markdown
|
||||
```yaml role=declare
|
||||
vars:
|
||||
$hp: $con*5 + $mod_hp
|
||||
$ac: 10 + $dex
|
||||
tags:
|
||||
warrior:
|
||||
1:
|
||||
$mod_hp: 20
|
||||
2:
|
||||
$mod_str: 1
|
||||
```
|
||||
````
|
||||
|
||||
### `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 |
|
||||
| `d20` | one twenty-sided die (count optional) |
|
||||
| `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.
|
||||
@@ -0,0 +1,149 @@
|
||||
# Variable store — outstanding fixes
|
||||
|
||||
Notes from an audit of the client-side variable / tag reactivity system:
|
||||
|
||||
- `src/components/journal/var-reactivity.ts` — engine (dependency graph, base/mod
|
||||
separation, threshold-gated tag activation, cascade).
|
||||
- `src/components/journal/variable-expression.ts` — expression evaluator.
|
||||
- `src/components/journal/command-dispatcher.ts` — `/set`, `/rolltag`, cascade.
|
||||
- `src/components/journal/completions.ts` — init + seed.
|
||||
- `src/components/stores/journalStream.ts` — replay + hydration rebuild.
|
||||
- `src/components/journal/types/var.tsx` — `var` message + reducer.
|
||||
|
||||
## Already fixed (context)
|
||||
|
||||
Commit `a26d53c` (`fix(journal): keep variable bases in the stream`):
|
||||
|
||||
- Hydration double-counted active tag modifiers because the stream carried only
|
||||
combined values. `var` messages now carry `base` alongside `value`, the store
|
||||
keeps `variableBases`, and `rebuildReactivityFromStore` restores exact bases.
|
||||
- Seeding ran at import against an empty store (clobbering persisted values and
|
||||
appending duplicate `var` messages on every load). It now runs after hydration
|
||||
and only for keys absent from the store.
|
||||
|
||||
The items below are still open.
|
||||
|
||||
## Open issues
|
||||
|
||||
### 1. Modifier and declaration errors are swallowed — ✅ fixed
|
||||
|
||||
- **Where:** `computeInitialValues` (`catch {}`, "skip failed evaluations at init
|
||||
time") and `applyTagMapActivations` (`catch {}`, "skip failed modifier") in
|
||||
`var-reactivity.ts`; `initReactivity` throws are reduced to `console.warn` in
|
||||
`completions.ts`.
|
||||
- **Symptom:** a typo'd declaration or modifier expression silently yields a
|
||||
wrong value (often `0`) with no author-facing feedback.
|
||||
- **Fixed:** the engine now collects `ReactivityDiagnostic` entries for failed
|
||||
declarations (`computeInitialValues`, `reevaluateDependents`), failed modifier
|
||||
expressions (`applyTagMapActivations`), and circular declarations
|
||||
(`initReactivity`). They are deduped, logged via `console.warn` with
|
||||
key/expression/tag context, cleared on each `initReactivity`, and bridged into
|
||||
a signal exposed as `useJournalCompletions().diagnostics()`. `VariableView`
|
||||
renders them as a warning banner. A bad declaration no longer aborts the rest
|
||||
of a cascade.
|
||||
|
||||
### 2. `var del` does not reach the engine — ✅ fixed (dropped)
|
||||
|
||||
- **Where:** reducer in `types/var.tsx` supports `action: "del"`; the parser
|
||||
(`command-parser.ts`) has no `/del`; `var-reactivity.ts` never clears
|
||||
`baseValues` and `rebuildReactivityFromStore` never removes stale keys.
|
||||
- **Symptom:** a deleted variable still resolves locally via `getCombined`,
|
||||
because it reads `baseValues` first.
|
||||
- **Fixed:** `/del` was never reachable (no parser emits it, and replay does not
|
||||
validate), so the `del` action was removed from the `var` schema, render, and
|
||||
reducer. The wire type is now `action: "set"` only; a legacy `del` message in
|
||||
an old stream is a no-op on replay rather than resurrecting a deleted key.
|
||||
|
||||
### 3. Reactivity is initialized once, at import — ✅ fixed
|
||||
|
||||
- **Where:** `bootstrapReactivity` in `completions.ts` runs `initReactivity`
|
||||
once; nothing re-runs it when declare blocks change or a session/document is
|
||||
switched.
|
||||
- **Also:** `DispatchContext.declarations` / `tagModifiers`
|
||||
(`command-dispatcher.ts`) are declared but never read — a vestige of a
|
||||
per-dispatch init that was never wired up.
|
||||
- **Symptom:** editing `role=declare` content requires a full reload; stale
|
||||
graph/expressions persist.
|
||||
- **Fixed:** `bootstrapReactivity` is now `applyReactivity` behind an idempotent
|
||||
`ensureReactivity()` that reads the live completions signal, compares a
|
||||
signature, and re-initializes only when the declaration set changes.
|
||||
`dispatchCommand` calls it before every dispatch, so a content rescan takes
|
||||
effect on the next command without a reload. The unused
|
||||
`DispatchContext.declarations` / `tagModifiers` fields were removed (they were
|
||||
mount-time snapshots and could not be trusted). Session switches were already
|
||||
covered by the hydration rebuild, which now also passes
|
||||
`journalStreamState.variableBases` so bases are restored exactly instead of
|
||||
falling back to combined values.
|
||||
|
||||
### 4. `lookup` contract disagrees with its docs — ✅ fixed
|
||||
|
||||
- **Where:** `EvalContext.lookup` documents "Resolve `$var`", but
|
||||
`parseFactor` calls `ctx.lookup(varName.slice(1))` (bare name), and every
|
||||
caller re-adds `$`.
|
||||
- **Symptom:** a caller following the doc would key variables with `$` and
|
||||
silently get `0`s.
|
||||
- **Fixed:** `EvalContext.lookup` now receives the full `$var` name (including
|
||||
`$`), consistent with every other variable API (`getCombined`, `setBase`,
|
||||
`getMods`, `resolveVariable`). Callers no longer add or strip `$`.
|
||||
|
||||
### 5. Undefined variables evaluate to `0` — ✅ fixed (strict for commands)
|
||||
|
||||
- **Where:** `parseFactor` (`variable-expression.ts`) returns a numeric `0` for
|
||||
an unresolved `$var`.
|
||||
- **Symptom:** `/set $hp $cn*5` (typo) silently sets `0` instead of failing.
|
||||
- **Fixed:** `parseFactor` now throws `Unknown variable: $x` whenever `lookup`
|
||||
returns `undefined`, and `resolveVariable()` distinguishes unknown from a real
|
||||
`0`. `/set` and `/roll` use the strict resolver, so typos fail loudly via
|
||||
`dispatchError`. Declarations and tag modifiers stay lenient on purpose: they
|
||||
resolve unknown references to `0` through `getCombined`, because the
|
||||
documented pattern (`$hp = $con*5+$mod_hp`) relies on external/undeclared
|
||||
variables defaulting to 0.
|
||||
|
||||
### 6. Tokenizer accepts malformed numbers — ✅ fixed
|
||||
|
||||
- **Where:** the number branch of `tokenize` consumes `[0-9.]` greedily, so
|
||||
`1.2.3` becomes one "number" and `parseFloat` truncates it.
|
||||
- **Fixed:** the tokenizer now validates the scanned chunk against
|
||||
`^\d+(\.\d+)?$` and throws `Invalid number literal: "…"` instead of pushing a
|
||||
silently-truncated value. The error propagates through the diagnostics channel
|
||||
(#1) for declarations/modifiers and through `dispatchError` for `/set` and
|
||||
`/roll`.
|
||||
|
||||
### 7. `topoSortAffected` re-evaluates untouched declarations — ✅ fixed
|
||||
|
||||
- **Where:** in `var-reactivity.ts`, the visit condition
|
||||
`affected.has(dep) || declExprs.has(dep)` pulls declared dependencies that are
|
||||
not in `affected` into the result.
|
||||
- **Symptom:** `reevaluateDependents` re-evaluates and re-publishes variables
|
||||
that did not change. Mostly harmless, but noisy — and a re-evaluated
|
||||
declaration could overwrite a value that had just been `/set`.
|
||||
- **Fixed:** the visit condition now only follows dependencies in `affected`,
|
||||
so a cascade re-evaluates exactly the changed variable's dependents.
|
||||
|
||||
### 8. Failed `initReactivity` leaves partial state — ✅ fixed
|
||||
|
||||
- **Where:** `initReactivity` populates `depGraph` / `declExprs` / `tagModMap`
|
||||
before `checkCircular()` throws; `inFlight` is never cleared on re-init.
|
||||
- **Fixed:** `initReactivity` now builds the declaration map, dependency graph,
|
||||
and tag-modifier map into locals and commits them only after `checkCircular`
|
||||
passes, so a failed (re-)init leaves the previous engine intact. Runtime state
|
||||
(`baseValues`, mods, `sourceActivations`) and `inFlight` are reset on commit.
|
||||
|
||||
### 9. Residual base / tagmap-target edges — low
|
||||
|
||||
- **Base fallback:** `rebuildReactivityFromStore` falls back to the combined
|
||||
`value` when a message has no `base`, which double-counts mods. In-repo
|
||||
emitters always send `base`; only hand-crafted/external `var` messages hit
|
||||
this.
|
||||
- **Tagmap targets:** rebuild derives activations from each source's *base*
|
||||
tagmap (this fixed a feedback loop where a tagmap-valued target re-triggered
|
||||
its own modifier on every rebuild). If a variable's effective count crosses a
|
||||
threshold purely via mods, that activation is not reconstructed on hydration.
|
||||
Revisit if tagmap-valued modifier targets come into use.
|
||||
|
||||
## Suggested order
|
||||
|
||||
1. #3 (re-init) and #2 (`del`) — correctness of engine lifecycle.
|
||||
2. #1 (diagnostics) — authoring feedback.
|
||||
3. #4–#8 — cleanup and hardening.
|
||||
4. #9 — only if the tagmap-target pattern is adopted.
|
||||
@@ -4,6 +4,14 @@ export default {
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['**/*.test.ts'],
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
|
||||
moduleNameMapper: {
|
||||
// Resolve .js imports to .ts source files (ESM convention in TS source)
|
||||
'^(.+)\\.js$': ['$1.ts', '$1.tsx', '$1.js'],
|
||||
// github-slugger v2 is ESM-only; jest's CJS runtime cannot require it.
|
||||
'^github-slugger$': '<rootDir>/__mocks__/github-slugger.js',
|
||||
// Same for the browser ESM build of csv-parse — map to the CJS build.
|
||||
'^csv-parse/browser/esm/sync$': 'csv-parse/sync',
|
||||
},
|
||||
transform: {
|
||||
'^.+\\.tsx?$': [
|
||||
'ts-jest',
|
||||
|
||||
Generated
+869
-230
File diff suppressed because it is too large
Load Diff
+45
-20
@@ -1,26 +1,42 @@
|
||||
{
|
||||
"name": "ttrpg-tools",
|
||||
"name": "tttk",
|
||||
"version": "0.0.1",
|
||||
"description": "A TTRPG toolbox based on solid.js and rsbuild",
|
||||
"description": "A tabletop RPG toolkit for previewing and compiling TTRPG campaign content",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"ttrpg": "./dist/cli/index.js"
|
||||
"tttk": "./dist/cli/index.js"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"exports": {
|
||||
".": "./dist/index.js",
|
||||
"./components": "./dist/components/index.js",
|
||||
"./markdown": "./dist/markdown/index.js"
|
||||
"files": [
|
||||
"dist",
|
||||
"LICENSE",
|
||||
"README.md"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/hypercross/ttrpg-tools"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "rsbuild dev",
|
||||
"build": "rsbuild build",
|
||||
"dev": "node scripts/dev.mjs",
|
||||
"dev:web": "rsbuild dev",
|
||||
"dev:cli": "node --enable-source-maps --loader ts-node/esm ./src/cli/index.ts serve ./content",
|
||||
"build": "npm run build:web && npm run build:cli",
|
||||
"build:web": "rsbuild build",
|
||||
"build:cli": "tsc -p tsconfig.cli.json",
|
||||
"preview": "rsbuild preview",
|
||||
"analyze": "cross-env ANALYZE=true rsbuild build",
|
||||
"cli:dev": "tsc -p tsconfig.cli.json --watch",
|
||||
"cli:build": "tsc -p tsconfig.cli.json",
|
||||
"cli:build": "npm run build:cli",
|
||||
"ttrpg": "node --loader ts-node/esm ./src/cli/index.ts",
|
||||
"test": "jest"
|
||||
"tttk": "node --loader ts-node/esm ./src/cli/index.ts",
|
||||
"tttk:inspect": "node --inspect --enable-source-maps --loader ts-node/esm ./src/cli/index.ts",
|
||||
"gen:reference": "node --loader ts-node/esm ./scripts/generate-reference.ts",
|
||||
"gen:skill-reference": "node --loader ts-node/esm ./scripts/generate-reference.ts --out .agents/skills/tttk-authoring/reference.md",
|
||||
"test": "jest",
|
||||
"prepare": "npx husky",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"keywords": [
|
||||
"ttrpg",
|
||||
@@ -28,13 +44,15 @@
|
||||
"markdown",
|
||||
"toolbox"
|
||||
],
|
||||
"author": "",
|
||||
"author": "hypercross",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^0.5.0",
|
||||
"@solidjs/router": "^0.15.0",
|
||||
"@types/three": "^0.183.1",
|
||||
"@thisbeyond/solid-dnd": "^0.7.5",
|
||||
"aedes": "^1.1.1",
|
||||
"chokidar": "^5.0.0",
|
||||
"comlink": "^4.4.2",
|
||||
"commander": "^14.0.3",
|
||||
"csv-parse": "^6.1.0",
|
||||
"csv-stringify": "^6.7.0",
|
||||
@@ -44,14 +62,16 @@
|
||||
"marked-directive": "^1.0.7",
|
||||
"marked-gfm-heading-id": "^4.1.3",
|
||||
"mermaid": "^11.0.0",
|
||||
"mqtt": "^5.15.1",
|
||||
"solid-element": "^1.9.1",
|
||||
"solid-icons": "^1.2.0",
|
||||
"solid-js": "^1.9.3",
|
||||
"three": "^0.183.2",
|
||||
"three-3mf-exporter": "^45.1.0",
|
||||
"yarn-spinner-loader": "^0.1.0"
|
||||
"uuid": "^14.0.1",
|
||||
"ws": "^8.21.0",
|
||||
"yarn-spinner-loader": "^0.1.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@image-tracer-ts/core": "^1.0.2",
|
||||
"@rsbuild/core": "^1.1.8",
|
||||
"@rsbuild/plugin-babel": "^1.1.0",
|
||||
"@rsbuild/plugin-solid": "^1.1.0",
|
||||
@@ -61,11 +81,16 @@
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^22.19.13",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"husky": "^9.1.7",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"ts-jest": "^29.4.6",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "^5.9.3",
|
||||
"webpack-bundle-analyzer": "^5.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
+91
-21
@@ -1,7 +1,45 @@
|
||||
import { defineConfig } from '@rsbuild/core';
|
||||
import { pluginBabel } from '@rsbuild/plugin-babel';
|
||||
import { pluginSolid } from '@rsbuild/plugin-solid';
|
||||
import tailwindcss from '@tailwindcss/postcss';
|
||||
import { defineConfig } from "@rsbuild/core";
|
||||
import { pluginBabel } from "@rsbuild/plugin-babel";
|
||||
import { pluginSolid } from "@rsbuild/plugin-solid";
|
||||
import tailwindcss from "@tailwindcss/postcss";
|
||||
import { BundleAnalyzerPlugin } from "webpack-bundle-analyzer";
|
||||
import { statSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
// The CLI content server (`npm run dev:cli`, i.e. `tttk serve ./content`) keeps
|
||||
// port 3000. It serves the content directory at the URL *root*, so content
|
||||
// assets are requested as root-relative paths (e.g. `/adventures/map.png`),
|
||||
// not under `/content`.
|
||||
const CLI_TARGET = "http://localhost:3000";
|
||||
const CONTENT_DIR = resolve(process.cwd(), "content");
|
||||
|
||||
/**
|
||||
* True when a request targets a real file inside the content directory.
|
||||
*
|
||||
* Mirrors the CLI server's static handling: only files are served, so a
|
||||
* directory path falls through to the SPA fallback. Requests for content
|
||||
* assets must be proxied to the CLI; otherwise they reach
|
||||
* `historyApiFallback` and the browser receives `index.html` instead of the
|
||||
* asset (which is why images failed to render in dev).
|
||||
*/
|
||||
function isContentFile(pathname: string): boolean {
|
||||
// The web dev server owns its own bundled assets under /static/.
|
||||
if (pathname.startsWith("/static/")) return false;
|
||||
|
||||
let relativePath: string;
|
||||
try {
|
||||
relativePath = decodeURIComponent(pathname).replace(/^\/+/, "");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!relativePath) return false;
|
||||
|
||||
try {
|
||||
return statSync(join(CONTENT_DIR, relativePath)).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
@@ -16,32 +54,64 @@ export default defineConfig({
|
||||
plugins: [tailwindcss()],
|
||||
},
|
||||
},
|
||||
rspack: {
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.md|\.yarn|\.csv$/,
|
||||
exclude: /\.schema\.csv$/,
|
||||
type: 'asset/source'
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
rspack: {
|
||||
resolve: {
|
||||
// Allow TypeScript sources to use explicit `.js` specifiers (required
|
||||
// by the CLI's NodeNext build) while the bundler resolves them to .ts.
|
||||
extensionAlias: {
|
||||
".js": [".ts", ".tsx", ".js"],
|
||||
},
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
// Built-in doc entries are statically imported as strings
|
||||
test: /[\\/]doc-entries[\\/].*\.md$/,
|
||||
type: "asset/source",
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
process.env.ANALYZE ? new BundleAnalyzerPlugin() : undefined,
|
||||
].filter(Boolean),
|
||||
},
|
||||
},
|
||||
html: {
|
||||
template: './src/index.html',
|
||||
template: "./src/index.html",
|
||||
},
|
||||
source: {
|
||||
entry: {
|
||||
index: './src/main.tsx',
|
||||
index: "./src/main.tsx",
|
||||
},
|
||||
},
|
||||
server: {
|
||||
// Dev web server runs on its own port; the CLI content server keeps
|
||||
// port 3000 (see the proxy targets below).
|
||||
port: 5173,
|
||||
proxy: [
|
||||
{
|
||||
// Content API requests served by the CLI dev server (npm run dev:cli).
|
||||
context: [
|
||||
"/__CONTENT_INDEX.json",
|
||||
"/__COMPLETIONS.json",
|
||||
"/__CONTENT_REGISTRY.json",
|
||||
"/.ttrpg",
|
||||
],
|
||||
target: CLI_TARGET,
|
||||
},
|
||||
{
|
||||
// Content assets (images, css, csv, …) live at the URL root on the CLI
|
||||
// server, so forward any request that maps to a real file under
|
||||
// content/ instead of letting it hit the SPA fallback.
|
||||
context: (pathname: string) => isContentFile(pathname),
|
||||
target: CLI_TARGET,
|
||||
},
|
||||
],
|
||||
historyApiFallback: true,
|
||||
},
|
||||
output: {
|
||||
distPath: {
|
||||
root: 'dist/web',
|
||||
root: "dist/web",
|
||||
},
|
||||
copy: process.env.NODE_ENV === 'development' ? [
|
||||
{ from: './content', to: 'content' }
|
||||
] : [],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Dev runner: starts the CLI content server (port 3000) and the rsbuild web
|
||||
* dev server (port 5173) together, then tears both down when either exits.
|
||||
*
|
||||
* npm run dev # both (this script)
|
||||
* npm run dev:cli # CLI content server only
|
||||
* npm run dev:web # web dev server only
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const isWindows = process.platform === "win32";
|
||||
const children = new Set();
|
||||
let shuttingDown = false;
|
||||
|
||||
/** Terminate a child and everything it spawned. */
|
||||
function killTree(child) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
if (isWindows) {
|
||||
spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
// Negative pid targets the process group created via `detached`.
|
||||
process.kill(-child.pid, "SIGTERM");
|
||||
} catch {
|
||||
child.kill("SIGTERM");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function shutdown(code) {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
for (const child of children) killTree(child);
|
||||
process.exitCode = code ?? 0;
|
||||
}
|
||||
|
||||
for (const script of ["dev:cli", "dev:web"]) {
|
||||
const child = spawn("npm", ["run", script], {
|
||||
stdio: "inherit",
|
||||
shell: true,
|
||||
detached: !isWindows,
|
||||
});
|
||||
children.add(child);
|
||||
child.on("exit", (code) => shutdown(code ?? 0));
|
||||
child.on("error", (error) => {
|
||||
console.error(`[dev] Failed to start ${script}: ${error.message}`);
|
||||
shutdown(1);
|
||||
});
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => shutdown(0));
|
||||
process.on("SIGTERM", () => shutdown(0));
|
||||
@@ -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}`);
|
||||
+192
-20
@@ -1,13 +1,89 @@
|
||||
import { Component, createMemo, createSignal } from "solid-js";
|
||||
import {
|
||||
Component,
|
||||
createContext,
|
||||
useContext,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
} from "solid-js";
|
||||
import { useLocation } from "@solidjs/router";
|
||||
import {
|
||||
TbOutlineMenu2,
|
||||
TbOutlineClipboardList,
|
||||
TbOutlineHelpCircle,
|
||||
TbOutlineLayoutSidebarLeftCollapse,
|
||||
TbOutlineLayoutSidebarLeftExpand,
|
||||
} from "solid-icons/tb";
|
||||
import { useJournalStream } from "./components/stores/journalStream";
|
||||
|
||||
// 导入组件以注册自定义元素
|
||||
import "./components";
|
||||
import { Article, MobileSidebar, DesktopSidebar } from "./components";
|
||||
import {
|
||||
Article,
|
||||
MobileSidebar,
|
||||
DesktopSidebar,
|
||||
DocDialog,
|
||||
DataSourceDialog,
|
||||
RevealManager,
|
||||
ReactiveVariableManager,
|
||||
CommandLinkManager,
|
||||
} from "./components";
|
||||
import { generateToc, type FileNode, type TocNode } from "./data-loader";
|
||||
import { JournalPanel } from "./components/journal";
|
||||
import { useHeadingFlash } from "./components/useHeadingFlash";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scroll container context – lets child components (FileTree, RevealManager)
|
||||
// find the correct scrollable element instead of relying on window.scroll
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ScrollContainerCtx = createContext<() => HTMLElement | undefined>();
|
||||
|
||||
/** Access the main content scroll container from any descendant. */
|
||||
export function useScrollContainer(): () => HTMLElement | undefined {
|
||||
const ctx = useContext(ScrollContainerCtx);
|
||||
return ctx ?? (() => undefined);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const App: Component = () => {
|
||||
const location = useLocation();
|
||||
const stream = useJournalStream();
|
||||
const [isSidebarOpen, setIsSidebarOpen] = createSignal(false);
|
||||
const [isDesktopSidebarOpen, setIsDesktopSidebarOpen] = createSignal(true);
|
||||
const [isDocOpen, setIsDocOpen] = createSignal(false);
|
||||
const [isDataSourceOpen, setIsDataSourceOpen] = createSignal(false);
|
||||
const [isJournalOpen, setIsJournalOpen] = createSignal(false);
|
||||
|
||||
// Sidebar and TOC data — reload when source changes
|
||||
const [fileTree, setFileTree] = createSignal<FileNode[]>([]);
|
||||
const [pathHeadings, setPathHeadings] = createSignal<
|
||||
Record<string, TocNode[]>
|
||||
>({});
|
||||
const [tocKey, setTocKey] = createSignal(0);
|
||||
|
||||
let mainRef!: HTMLElement;
|
||||
|
||||
const loadToc = async () => {
|
||||
const toc = await generateToc();
|
||||
setFileTree(toc.fileTree);
|
||||
setPathHeadings(toc.pathHeadings);
|
||||
};
|
||||
|
||||
// Load TOC on mount and when source changes
|
||||
createEffect(() => {
|
||||
void tocKey();
|
||||
void loadToc();
|
||||
});
|
||||
|
||||
useHeadingFlash();
|
||||
|
||||
const handleSourceChanged = () => {
|
||||
setTocKey((k) => k + 1);
|
||||
};
|
||||
|
||||
const currentPath = createMemo(() => {
|
||||
// 根据路由加载对应的 markdown 文件
|
||||
@@ -21,33 +97,129 @@ const App: Component = () => {
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
{/* 桌面端固定侧边栏 */}
|
||||
<DesktopSidebar />
|
||||
{/* 移动端抽屉式侧边栏 */}
|
||||
<MobileSidebar
|
||||
isOpen={isSidebarOpen()}
|
||||
onClose={() => setIsSidebarOpen(false)}
|
||||
/>
|
||||
<header class="fixed top-0 left-0 right-0 bg-white shadow z-30">
|
||||
<div class="max-w-4xl mx-auto px-4 py-4 flex items-center gap-4">
|
||||
{/* 仅在移动端显示菜单按钮 */}
|
||||
<div class="h-screen flex flex-col overflow-hidden bg-gray-50">
|
||||
{/* Header */}
|
||||
<header class="shrink-0 h-16 bg-white shadow z-30">
|
||||
<div class="h-full max-w-4xl mx-auto px-4 flex items-center gap-4">
|
||||
{/* 移动端:打开抽屉式侧边栏 */}
|
||||
<button
|
||||
onClick={() => setIsSidebarOpen(true)}
|
||||
class="md:hidden text-gray-600 hover:text-gray-900 p-1 rounded hover:bg-gray-100"
|
||||
title="目录"
|
||||
>
|
||||
☰
|
||||
<TbOutlineMenu2 class="inline-block align-[-0.125em]" />
|
||||
</button>
|
||||
{/* 桌面端:折叠/展开固定侧边栏 */}
|
||||
<button
|
||||
onClick={() => setIsDesktopSidebarOpen((v) => !v)}
|
||||
class="hidden md:block text-gray-600 hover:text-gray-900 p-1 rounded hover:bg-gray-100"
|
||||
title={isDesktopSidebarOpen() ? "收起目录" : "展开目录"}
|
||||
>
|
||||
{isDesktopSidebarOpen() ? (
|
||||
<TbOutlineLayoutSidebarLeftCollapse class="inline-block align-[-0.125em]" />
|
||||
) : (
|
||||
<TbOutlineLayoutSidebarLeftExpand class="inline-block align-[-0.125em]" />
|
||||
)}
|
||||
</button>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Tabletop Toolkit</h1>
|
||||
<div class="flex-1" />
|
||||
<button
|
||||
onClick={() => setIsJournalOpen((v) => !v)}
|
||||
class={`relative w-8 h-8 rounded flex items-center justify-center text-sm ${
|
||||
isJournalOpen()
|
||||
? "bg-blue-100 text-blue-600"
|
||||
: "text-gray-500 hover:bg-gray-100"
|
||||
}`}
|
||||
title={`Journal (${stream.connectionStatus})`}
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 right-0.5 w-2 h-2 rounded-full border border-white"
|
||||
classList={{
|
||||
"bg-gray-400": stream.connectionStatus === "disconnected",
|
||||
"bg-yellow-400 animate-pulse":
|
||||
stream.connectionStatus === "connecting",
|
||||
"bg-green-500": stream.connectionStatus === "connected",
|
||||
"bg-red-500": stream.connectionStatus === "error",
|
||||
}}
|
||||
/>
|
||||
<TbOutlineClipboardList size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsDocOpen(true)}
|
||||
class="w-8 h-8 rounded-full border border-gray-300 text-gray-500 hover:text-gray-700 hover:border-gray-400 flex items-center justify-center text-sm font-medium"
|
||||
title="指令文档"
|
||||
>
|
||||
<TbOutlineHelpCircle size={16} />
|
||||
</button>
|
||||
<h1 class="text-2xl font-bold text-gray-900">TTRPG Tools</h1>
|
||||
</div>
|
||||
</header>
|
||||
{/* fill th rest of the space*/}
|
||||
<div class="fixed top-16 left-0 right-0 bottom-0 overflow-auto">
|
||||
<main class="max-w-4xl mx-auto px-4 py-8 pt-4 md:ml-64 2xl:ml-auto flex justify-center items-center">
|
||||
<Article class="prose-sm max-w-full flex-1" src={currentPath()} />
|
||||
</main>
|
||||
|
||||
{/* 移动端抽屉式侧边栏 (overlay) */}
|
||||
<MobileSidebar
|
||||
isOpen={isSidebarOpen()}
|
||||
onClose={() => setIsSidebarOpen(false)}
|
||||
fileTree={fileTree()}
|
||||
pathHeadings={pathHeadings()}
|
||||
onDataSourceOpen={() => setIsDataSourceOpen(true)}
|
||||
/>
|
||||
|
||||
{/* Body: sidebar + main + journal */}
|
||||
<div class="flex flex-1 min-h-0">
|
||||
{/* 桌面端侧边栏 — flex child,可折叠 */}
|
||||
<div
|
||||
class="hidden md:block md:shrink-0 md:overflow-hidden md:transition-all md:duration-300"
|
||||
classList={{
|
||||
"md:w-64": isDesktopSidebarOpen(),
|
||||
"md:w-0": !isDesktopSidebarOpen(),
|
||||
}}
|
||||
inert={!isDesktopSidebarOpen()}
|
||||
>
|
||||
<DesktopSidebar
|
||||
fileTree={fileTree()}
|
||||
pathHeadings={pathHeadings()}
|
||||
onDataSourceOpen={() => setIsDataSourceOpen(true)}
|
||||
onClose={() => setIsDesktopSidebarOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main content — the scrollable region */}
|
||||
<ScrollContainerCtx.Provider value={() => mainRef}>
|
||||
<main ref={mainRef} class="flex-1 min-w-0 overflow-y-auto">
|
||||
<div class="max-w-4xl mx-auto px-4 py-8">
|
||||
<Article
|
||||
class="prose prose-sm max-w-full"
|
||||
src={currentPath()}
|
||||
>
|
||||
<RevealManager />
|
||||
<ReactiveVariableManager />
|
||||
<CommandLinkManager />
|
||||
</Article>
|
||||
</div>
|
||||
</main>
|
||||
</ScrollContainerCtx.Provider>
|
||||
|
||||
{/* Journal panel wrapper — on desktop: flex child with width transition;
|
||||
on mobile: passes through to JournalPanel's own overlay */}
|
||||
<div
|
||||
class="contents md:block md:shrink-0 md:overflow-hidden md:transition-all md:duration-300"
|
||||
classList={{
|
||||
"md:w-105": isJournalOpen(),
|
||||
"md:w-0": !isJournalOpen(),
|
||||
}}
|
||||
>
|
||||
<JournalPanel
|
||||
open={isJournalOpen()}
|
||||
onClose={() => setIsJournalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DocDialog isOpen={isDocOpen()} onClose={() => setIsDocOpen(false)} />
|
||||
<DataSourceDialog
|
||||
isOpen={isDataSourceOpen()}
|
||||
onClose={() => setIsDataSourceOpen(false)}
|
||||
onSourceChanged={handleSourceChanged}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { CompileCommandHandler } from '../types.js';
|
||||
import type { CompileCommandHandler } from "../types.js";
|
||||
|
||||
export const compileCommand: CompileCommandHandler = async (dir, options) => {
|
||||
console.log(`开始编译...`);
|
||||
console.log(`目录:${dir}`);
|
||||
console.log("开始编译...");
|
||||
console.log(`源目录:${dir}`);
|
||||
console.log(`输出目录:${options.output}`);
|
||||
|
||||
|
||||
// TODO: 实现编译逻辑
|
||||
// 1. 扫描目录下的所有 .md 文件
|
||||
// 2. 解析 markdown 并生成路由
|
||||
// 3. 打包为带 hash 路由的单个 HTML 入口
|
||||
|
||||
console.log('编译完成!');
|
||||
|
||||
console.log("编译完成!");
|
||||
};
|
||||
|
||||
+145
-65
@@ -2,23 +2,33 @@ import type { ServeCommandHandler } from "../types.js";
|
||||
import { createServer, Server, IncomingMessage, ServerResponse } from "http";
|
||||
import { readdirSync, statSync, readFileSync, existsSync } from "fs";
|
||||
import { createReadStream } from "fs";
|
||||
import { join, resolve, extname, sep, relative, dirname } from "path";
|
||||
import { join, resolve, extname, relative, dirname } from "path";
|
||||
import { watch } from "chokidar";
|
||||
import { networkInterfaces } from "os";
|
||||
import { fileURLToPath } from "url";
|
||||
import { createJournalServer } from "../journal.js";
|
||||
import {
|
||||
buildRegistryFromIndex,
|
||||
normalizePathKey,
|
||||
deriveCompletions,
|
||||
type ContentRegistry,
|
||||
} from "../content-registry.js";
|
||||
import { readContentIndex } from "../content-index.js";
|
||||
import type { CompletionsPayload } from "../completions/types.js";
|
||||
|
||||
interface ContentIndex {
|
||||
[path: string]: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 CLI 脚本文件所在目录路径(用于定位 dist 文件夹)
|
||||
* 获取 CLI 脚本文件所在目录路径(用于定位 dist 目录)
|
||||
*/
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const distDir = resolve(__dirname, "..", "..", "..", "dist", "web");
|
||||
|
||||
/**
|
||||
* MIME 类型映射
|
||||
* MIME 类型映射表
|
||||
*/
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
".html": "text/html",
|
||||
@@ -37,7 +47,7 @@ const MIME_TYPES: Record<string, string> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取文件扩展名对应的 MIME 类型
|
||||
* 根据文件扩展名获取对应的 MIME 类型
|
||||
*/
|
||||
function getMimeType(filePath: string): string {
|
||||
const ext = extname(filePath).toLowerCase();
|
||||
@@ -45,41 +55,43 @@ function getMimeType(filePath: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描目录内的 .md 文件,生成索引
|
||||
* 获取最佳网络 IP 用于显示(优先局域网 IPv4,回退到 localhost)
|
||||
*/
|
||||
export function scanDirectory(dir: string): ContentIndex {
|
||||
const index: ContentIndex = {};
|
||||
function getBestIP(): string {
|
||||
const interfaces = networkInterfaces();
|
||||
let best: string | null = null;
|
||||
|
||||
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 = "/" + relPath.split(sep).join("/");
|
||||
|
||||
const stats = statSync(fullPath);
|
||||
if (stats.isDirectory()) {
|
||||
scan(fullPath, relPath);
|
||||
} else if (entry.endsWith(".md") || entry.endsWith(".csv") || entry.endsWith(".yarn")) {
|
||||
try {
|
||||
const content = readFileSync(fullPath, "utf-8");
|
||||
index[normalizedRelPath] = content;
|
||||
} catch (e) {
|
||||
console.error(`读取文件失败:${fullPath}`, e);
|
||||
}
|
||||
for (const [, addrs] of Object.entries(interfaces)) {
|
||||
if (!addrs) continue;
|
||||
for (const addr of addrs) {
|
||||
if (addr.family !== "IPv4" || addr.internal) continue;
|
||||
// Prefer a 192.168.x.x or 10.x.x.x address
|
||||
if (addr.address.startsWith("192.168.")) {
|
||||
best = addr.address;
|
||||
break;
|
||||
}
|
||||
if (
|
||||
!best &&
|
||||
(addr.address.startsWith("10.") || addr.address.startsWith("172."))
|
||||
) {
|
||||
best = addr.address;
|
||||
}
|
||||
}
|
||||
if (best?.startsWith("192.168.")) break;
|
||||
}
|
||||
|
||||
scan(dir, "");
|
||||
return index;
|
||||
return best || "localhost";
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送文件响应
|
||||
* 扫描目录内的 .md 等文件,构建内容注册表(路径索引 + 每文档内联内容)
|
||||
*/
|
||||
export function buildRegistry(dir: string): ContentRegistry {
|
||||
return buildRegistryFromIndex(readContentIndex(dir));
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送文件响应(流式传输)
|
||||
*/
|
||||
function sendFile(res: ServerResponse, filePath: string) {
|
||||
res.writeHead(200, {
|
||||
@@ -90,7 +102,7 @@ function sendFile(res: ServerResponse, filePath: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 404 响应
|
||||
* 发送 404 未找到响应
|
||||
*/
|
||||
function send404(res: ServerResponse) {
|
||||
res.writeHead(404, { "Content-Type": "text/plain" });
|
||||
@@ -98,7 +110,7 @@ function send404(res: ServerResponse) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 JSON 响应
|
||||
* 发送 JSON 格式的响应
|
||||
*/
|
||||
function sendJson(res: ServerResponse, data: unknown) {
|
||||
res.writeHead(200, {
|
||||
@@ -110,7 +122,7 @@ function sendJson(res: ServerResponse, data: unknown) {
|
||||
|
||||
/**
|
||||
* 尝试提供静态文件
|
||||
* @returns 如果文件存在并已成功发送则返回 true
|
||||
* @returns 文件存在并成功发送时返回 true
|
||||
*/
|
||||
function tryServeStatic(
|
||||
res: ServerResponse,
|
||||
@@ -133,12 +145,14 @@ function tryServeStatic(
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建请求处理器
|
||||
* 创建 HTTP 请求处理器
|
||||
*/
|
||||
function createRequestHandler(
|
||||
contentDir: string,
|
||||
distDir: string,
|
||||
getIndex: () => ContentIndex,
|
||||
getCompletions: () => CompletionsPayload,
|
||||
getRegistry: () => ContentRegistry,
|
||||
) {
|
||||
return (req: IncomingMessage, res: ServerResponse) => {
|
||||
const url = req.url || "/";
|
||||
@@ -150,6 +164,18 @@ function createRequestHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
// 1b. 处理 /__COMPLETIONS.json
|
||||
if (filePath === "/__COMPLETIONS.json") {
|
||||
sendJson(res, getCompletions());
|
||||
return;
|
||||
}
|
||||
|
||||
// 1c. 处理 /__CONTENT_REGISTRY.json(含每文档内联内容,供运行时解析)
|
||||
if (filePath === "/__CONTENT_REGISTRY.json") {
|
||||
sendJson(res, getRegistry());
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 处理 /static/ 目录(从 dist/web)
|
||||
if (filePath.startsWith("/static/")) {
|
||||
if (tryServeStatic(res, filePath, distDir)) {
|
||||
@@ -176,7 +202,7 @@ function createRequestHandler(
|
||||
}
|
||||
|
||||
/**
|
||||
* 内容服务器接口
|
||||
* 内容服务器实例接口
|
||||
*/
|
||||
export interface ContentServer {
|
||||
/**
|
||||
@@ -191,6 +217,10 @@ export interface ContentServer {
|
||||
* 内容索引
|
||||
*/
|
||||
index: ContentIndex;
|
||||
/**
|
||||
* 补全数据
|
||||
*/
|
||||
completions: CompletionsPayload;
|
||||
/**
|
||||
* 关闭服务器
|
||||
*/
|
||||
@@ -198,7 +228,7 @@ export interface ContentServer {
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建内容服务器
|
||||
* 创建内容服务器(HTTP + WebSocket + 文件监听)
|
||||
*/
|
||||
export function createContentServer(
|
||||
contentDir: string,
|
||||
@@ -206,80 +236,124 @@ export function createContentServer(
|
||||
distPath: string = distDir,
|
||||
host: string = "0.0.0.0",
|
||||
): ContentServer {
|
||||
let contentIndex: ContentIndex = {};
|
||||
let registry: ContentRegistry = { pathIndex: {}, docContent: {} };
|
||||
let completionsIndex: CompletionsPayload = {
|
||||
dice: [],
|
||||
links: [],
|
||||
sparkTables: [],
|
||||
declarations: [],
|
||||
tagModifiers: [],
|
||||
};
|
||||
|
||||
// 扫描内容目录生成索引
|
||||
console.log("扫描内容目录...");
|
||||
contentIndex = scanDirectory(contentDir);
|
||||
console.log(`已索引 ${Object.keys(contentIndex).length} 个文件`);
|
||||
/** 从当前注册表重新派生补全数据 */
|
||||
function recomputeCompletions(): void {
|
||||
completionsIndex = deriveCompletions(registry);
|
||||
console.log(
|
||||
`[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length} declarations=${completionsIndex.declarations.length} tagModifiers=${completionsIndex.tagModifiers.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 扫描内容目录生成注册表
|
||||
console.log("正在扫描内容目录...");
|
||||
registry = buildRegistry(contentDir);
|
||||
console.log(`已索引 ${Object.keys(registry.pathIndex).length} 个文件`);
|
||||
recomputeCompletions();
|
||||
|
||||
// 监听文件变化
|
||||
console.log("监听文件变化...");
|
||||
console.log("正在监听文件变化...");
|
||||
const watcher = watch(contentDir, {
|
||||
ignored: /(^|[\/\\])\../,
|
||||
ignored: /(^|[\/\\])\./,
|
||||
persistent: true,
|
||||
ignoreInitial: true,
|
||||
});
|
||||
|
||||
watcher
|
||||
.on("add", (path) => {
|
||||
if (path.endsWith(".md") || path.endsWith(".csv") || path.endsWith(".yarn")) {
|
||||
if (
|
||||
path.endsWith(".md") ||
|
||||
path.endsWith(".csv") ||
|
||||
path.endsWith(".yarn") ||
|
||||
path.endsWith(".svg")
|
||||
) {
|
||||
try {
|
||||
const content = readFileSync(path, "utf-8");
|
||||
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
|
||||
contentIndex[relPath] = content;
|
||||
console.log(`[新增] ${relPath}`);
|
||||
// 全量重建注册表以刷新跨文件派生(spark 注入)
|
||||
registry = buildRegistry(contentDir);
|
||||
recomputeCompletions();
|
||||
console.log(`[新增] ${path}`);
|
||||
} catch (e) {
|
||||
console.error(`读取新增文件失败:${path}`, e);
|
||||
}
|
||||
}
|
||||
})
|
||||
.on("change", (path) => {
|
||||
if (path.endsWith(".md") || path.endsWith(".csv") || path.endsWith(".yarn")) {
|
||||
if (
|
||||
path.endsWith(".md") ||
|
||||
path.endsWith(".csv") ||
|
||||
path.endsWith(".yarn") ||
|
||||
path.endsWith(".svg")
|
||||
) {
|
||||
try {
|
||||
const content = readFileSync(path, "utf-8");
|
||||
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
|
||||
contentIndex[relPath] = content;
|
||||
console.log(`[更新] ${relPath}`);
|
||||
registry = buildRegistry(contentDir);
|
||||
recomputeCompletions();
|
||||
console.log(`[更新] ${path}`);
|
||||
} catch (e) {
|
||||
console.error(`读取更新文件失败:${path}`, e);
|
||||
}
|
||||
}
|
||||
})
|
||||
.on("unlink", (path) => {
|
||||
if (path.endsWith(".md") || path.endsWith(".csv") || path.endsWith(".yarn")) {
|
||||
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
|
||||
delete contentIndex[relPath];
|
||||
console.log(`[删除] ${relPath}`);
|
||||
if (
|
||||
path.endsWith(".md") ||
|
||||
path.endsWith(".csv") ||
|
||||
path.endsWith(".yarn") ||
|
||||
path.endsWith(".svg")
|
||||
) {
|
||||
registry = buildRegistry(contentDir);
|
||||
recomputeCompletions();
|
||||
console.log(`[删除] ${path}`);
|
||||
}
|
||||
});
|
||||
|
||||
// 创建请求处理器
|
||||
// ---- Journal / MQTT broker ----
|
||||
let journal: Awaited<ReturnType<typeof createJournalServer>> | null = null;
|
||||
|
||||
// 在 journal 之前创建 HTTP 服务器,以便传入给 journal
|
||||
const handleRequest = createRequestHandler(
|
||||
contentDir,
|
||||
distPath,
|
||||
() => contentIndex,
|
||||
() => registry.pathIndex,
|
||||
() => completionsIndex,
|
||||
() => registry,
|
||||
);
|
||||
|
||||
// 创建 HTTP 服务器
|
||||
const server = createServer(handleRequest);
|
||||
|
||||
createJournalServer(contentDir, server).then((j) => {
|
||||
journal = j;
|
||||
});
|
||||
|
||||
server.listen(port, host, () => {
|
||||
const displayHost = host === "0.0.0.0" ? "localhost" : host;
|
||||
const bestIP = getBestIP();
|
||||
const displayHost = host === "0.0.0.0" ? bestIP : host;
|
||||
console.log(`\n开发服务器已启动:http://${displayHost}:${port}`);
|
||||
console.log(`本机访问:http://localhost:${port}`);
|
||||
if (bestIP !== "localhost") {
|
||||
console.log(`局域网访问:http://${bestIP}:${port}`);
|
||||
}
|
||||
console.log(`内容目录:${contentDir}`);
|
||||
console.log(`静态资源目录:${distPath}`);
|
||||
console.log(`索引文件:http://${displayHost}:${port}/__CONTENT_INDEX.json\n`);
|
||||
console.log(`日志连接:ws://${bestIP}:${port}\n`);
|
||||
});
|
||||
|
||||
return {
|
||||
server,
|
||||
watcher,
|
||||
index: contentIndex,
|
||||
index: registry.pathIndex,
|
||||
completions: completionsIndex,
|
||||
close() {
|
||||
console.log("关闭内容服务器...");
|
||||
console.log("正在关闭内容服务器...");
|
||||
server.close();
|
||||
watcher.close();
|
||||
if (journal) journal.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -289,6 +363,12 @@ export function createContentServer(
|
||||
*/
|
||||
export const serveCommand: ServeCommandHandler = async (dir, options) => {
|
||||
const contentDir = resolve(dir);
|
||||
|
||||
if (!existsSync(contentDir)) {
|
||||
console.error(`错误:目录不存在:${contentDir}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const port = parseInt(options.port, 10);
|
||||
const host = options.host || "0.0.0.0";
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Jest mock for csv-parse/browser/esm/sync
|
||||
*
|
||||
* The real import path is csv-parse/browser/esm/sync which only works in
|
||||
* browser bundlers. In Node (Jest), we redirect to csv-parse/sync.
|
||||
*/
|
||||
export { parse } from "csv-parse/sync";
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Shared block scanning utilities — safe for both CLI and browser.
|
||||
*
|
||||
* Parses fenced code blocks with attributes:
|
||||
* ```lang id=xxx role=xxx key=value
|
||||
*
|
||||
* The `role` fully determines block behavior (see `scanDoc` in
|
||||
* `content-registry.ts`):
|
||||
* - spark-table → stored as CSV, rendered as an `:md-table` directive
|
||||
* - declare / file → stored, block stripped
|
||||
* - tag → block kept intact for the yaml-tag render extension
|
||||
* - unknown role → warned about and stripped
|
||||
* - no role → kept as a visible code block
|
||||
*
|
||||
* Attribute parsing itself lives in `src/markdown/block-attrs.ts` so render
|
||||
* extensions can share the same syntax.
|
||||
*/
|
||||
|
||||
export { parseBlockAttrs, type BlockAttrs } from "../../markdown/block-attrs.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Regex
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Matches fenced code blocks with an info string (at least one attr).
|
||||
* Supports both backtick and tilde fences; the closing fence reuses the
|
||||
* opening marker character and must be at least as long.
|
||||
*/
|
||||
export const FENCED_BLOCK_RE = /^([`~])\1{2,}\s*(\w*)\s+(\S.*?)\s*\n([\s\S]*?)\1{3,}\s*$/gm;
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Declare parser — parses ```yaml role=declare code blocks from markdown.
|
||||
*
|
||||
* Shared between the CLI scanner/validator and the browser-side journal.
|
||||
*
|
||||
* The body is YAML with two optional maps (`declareSchema` below):
|
||||
*
|
||||
* vars:
|
||||
* $hp: $con*5 + $mod_hp
|
||||
* $ac: 10 + $dex
|
||||
* tags:
|
||||
* warrior:
|
||||
* 1:
|
||||
* $mod_hp: 20
|
||||
* 2:
|
||||
* $mod_str: 1
|
||||
*
|
||||
* `vars` entries are reactively computed variables. `tags` entries are
|
||||
* modifiers: when a source variable's tag map holds `#warrior` at a count
|
||||
* >= the threshold key, each `$target` gains the modifier value. Tag names
|
||||
* may be written with or without the leading `#`.
|
||||
*/
|
||||
|
||||
import yaml from "js-yaml";
|
||||
import { z } from "zod";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface VarDeclaration {
|
||||
key: string; // "$hp" (always starts with $)
|
||||
expression: string; // "$con*5+$mod_hp"
|
||||
}
|
||||
|
||||
export interface TagModifier {
|
||||
tag: string; // "#warrior"
|
||||
target: string; // "$mod_hp"
|
||||
expression: string; // "20"
|
||||
threshold: number; // minimum tagmap count to activate (default 1)
|
||||
}
|
||||
|
||||
export interface DeclareResult {
|
||||
variables: VarDeclaration[];
|
||||
tagModifiers: TagModifier[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A `$name` variable key. */
|
||||
const VAR_NAME = /^\$[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
/** A tag name, optionally carrying its leading `#`. */
|
||||
const TAG_NAME = /^#?[A-Za-z_][A-Za-z0-9_-]*$/;
|
||||
/** Threshold map keys are positive integers (1, 2, 3, …). */
|
||||
const THRESHOLD = /^[1-9][0-9]*$/;
|
||||
|
||||
/**
|
||||
* Expression text. YAML resolves bare numbers to `number`, so both strings and
|
||||
* numbers are accepted and normalized to a trimmed string. A missing value
|
||||
* loads as `null` and becomes empty text, which the refine rejects. Anything
|
||||
* else (boolean, date) is a mistake in the source. Unknown references inside
|
||||
* the expression are resolved by the reactivity engine, not here.
|
||||
*/
|
||||
const exprSchema = z
|
||||
.union([z.string(), z.number(), z.null()])
|
||||
.transform((value) => (value === null ? "" : String(value).trim()))
|
||||
.refine((value) => value.length > 0, "expression must not be empty");
|
||||
|
||||
/** A map whose keys must look like `$name`. */
|
||||
function varMap(message: string) {
|
||||
return z.record(z.string(), exprSchema).superRefine((obj, ctx) => {
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (!VAR_NAME.test(key)) {
|
||||
ctx.addIssue({ code: "custom", message, path: [key] });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** `threshold -> { $target: expression }`. */
|
||||
const thresholdMap = z
|
||||
.record(
|
||||
z.string(),
|
||||
varMap("tag modifier targets must start with $, e.g. `$mod_hp`"),
|
||||
)
|
||||
.superRefine((obj, ctx) => {
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (!THRESHOLD.test(key)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "threshold must be a positive integer",
|
||||
path: [key],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/** The accepted `role=declare` shape. Exported for tests and reuse. */
|
||||
export const declareSchema = z.strictObject({
|
||||
vars: varMap("variable names must start with $, e.g. `$hp`").optional(),
|
||||
tags: z
|
||||
.record(z.string(), thresholdMap)
|
||||
.superRefine((obj, ctx) => {
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (!TAG_NAME.test(key)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "tag names may not contain spaces or punctuation",
|
||||
path: [key],
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse a single ```yaml role=declare block body.
|
||||
*
|
||||
* Throws with a `path: message` summary when the body is not valid YAML or
|
||||
* does not match the schema. Callers attach the file/line context.
|
||||
*/
|
||||
export function parseDeclare(body: string): DeclareResult {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) return { variables: [], tagModifiers: [] };
|
||||
|
||||
let data: unknown;
|
||||
try {
|
||||
data = yaml.load(trimmed);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`role=declare body is not valid YAML: ${errorText(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = declareSchema.safeParse(data ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new Error(`role=declare: ${formatIssues(parsed.error)}`);
|
||||
}
|
||||
|
||||
const variables: VarDeclaration[] = [];
|
||||
for (const [key, expression] of Object.entries(parsed.data.vars ?? {})) {
|
||||
variables.push({ key, expression });
|
||||
}
|
||||
|
||||
const tagModifiers: TagModifier[] = [];
|
||||
for (const [name, thresholds] of Object.entries(parsed.data.tags ?? {})) {
|
||||
const tag = name.startsWith("#") ? name : `#${name}`;
|
||||
for (const [rawThreshold, targets] of Object.entries(thresholds)) {
|
||||
const threshold = Number(rawThreshold);
|
||||
for (const [target, expression] of Object.entries(targets)) {
|
||||
tagModifiers.push({ tag, target, expression, threshold });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { variables, tagModifiers };
|
||||
}
|
||||
|
||||
function formatIssues(error: z.ZodError): string {
|
||||
return error.issues
|
||||
.map((issue) =>
|
||||
issue.path.length
|
||||
? `${issue.path.join(".")}: ${issue.message}`
|
||||
: issue.message,
|
||||
)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
function errorText(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Completion source types — shared between CLI scanner and frontend consumer.
|
||||
*/
|
||||
|
||||
import type { VarDeclaration, TagModifier } from "./declare-parser.js";
|
||||
|
||||
export type { VarDeclaration, TagModifier };
|
||||
|
||||
/** A single dice expression found in a markdown file */
|
||||
export interface DiceCompletion {
|
||||
/** Display text shown in the dropdown */
|
||||
label: string;
|
||||
/** Dice notation, e.g. "3d6kh1" or "1d20+5" */
|
||||
notation: string;
|
||||
/** Source file for attribution */
|
||||
source: string;
|
||||
}
|
||||
|
||||
/** A single link target (article path, optionally scoped to a heading) */
|
||||
export interface LinkCompletion {
|
||||
/** Route-relative path without .md extension, e.g. "/rules/combat" */
|
||||
path: string;
|
||||
/** Human-readable label */
|
||||
label: string;
|
||||
/** Heading slug anchor (without #), or null for the whole file */
|
||||
section: string | null;
|
||||
}
|
||||
|
||||
/** A spark table found in a markdown file */
|
||||
export interface SparkTableCompletion {
|
||||
/** Display label: "file § slug" */
|
||||
label: string;
|
||||
/** Dice notation (e.g. "d6", "d20") parsed from the first column header */
|
||||
notation: string;
|
||||
/** Concatenated slug of data column headers */
|
||||
slug: string;
|
||||
/** File path of the containing .md file (without extension) */
|
||||
filePath: string;
|
||||
/** Path of the containing .md file (with extension) — for inline lookup */
|
||||
docPath: string;
|
||||
/** Resolved path to the .csv file backing this spark table */
|
||||
csvPath: string;
|
||||
/** Data column headers for display */
|
||||
headers: string[];
|
||||
/** Whether to roll each column independently (remix mode) */
|
||||
remix: boolean;
|
||||
}
|
||||
|
||||
export interface CompletionsPayload {
|
||||
dice: DiceCompletion[];
|
||||
links: LinkCompletion[];
|
||||
sparkTables: SparkTableCompletion[];
|
||||
declarations: VarDeclaration[];
|
||||
tagModifiers: TagModifier[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A completion source plugin. Add new sources by implementing this
|
||||
* interface and registering in the barrel.
|
||||
*/
|
||||
export interface CompletionSource {
|
||||
/** Unique key — becomes the top-level key in CompletionsPayload */
|
||||
key: string;
|
||||
/** Scan the content index and return structured completion items */
|
||||
scan(index: Record<string, string>): unknown[];
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import {
|
||||
scanDoc,
|
||||
resolveContent,
|
||||
resolveContentEntry,
|
||||
buildRegistryFromIndex,
|
||||
deriveCompletions,
|
||||
type ContentRegistry,
|
||||
} from "./content-registry";
|
||||
|
||||
function emptyRegistry(): ContentRegistry {
|
||||
return { pathIndex: {}, docContent: {} };
|
||||
}
|
||||
|
||||
describe("scanDoc", () => {
|
||||
test("csv role=spark-table block becomes an :md-table directive", () => {
|
||||
const md = [
|
||||
"```csv role=spark-table",
|
||||
"d6,Name",
|
||||
"1,Alice",
|
||||
"2,Bob",
|
||||
"```",
|
||||
].join("\n");
|
||||
const { stripped, content } = scanDoc(md, "test.md");
|
||||
|
||||
expect(stripped).toMatch(/^:md-table\[\.\/csv_/);
|
||||
const [entry] = Object.values(content);
|
||||
expect(entry.kind).toBe("csv");
|
||||
expect(entry.body).toContain("d6,Name");
|
||||
expect(entry.role).toBe("spark-table");
|
||||
});
|
||||
|
||||
test("tilde-fenced role blocks are scanned", () => {
|
||||
const md = [
|
||||
"~~~csv role=spark-table",
|
||||
"d6,Name",
|
||||
"1,Alice",
|
||||
"~~~",
|
||||
].join("\n");
|
||||
const { stripped, content } = scanDoc(md, "test.md");
|
||||
|
||||
expect(stripped).toMatch(/^:md-table\[\.\/csv_/);
|
||||
const [entry] = Object.values(content);
|
||||
expect(entry.body).toContain("d6,Name");
|
||||
});
|
||||
|
||||
test("tilde-fenced declare blocks are scanned", () => {
|
||||
const md = [
|
||||
"~~~yaml role=declare",
|
||||
"vars:",
|
||||
" $hp: 10",
|
||||
"~~~",
|
||||
].join("\n");
|
||||
const { stripped, content } = scanDoc(md, "test.md");
|
||||
|
||||
expect(stripped).toBe("");
|
||||
const [entry] = Object.values(content);
|
||||
expect(entry.kind).toBe("declare");
|
||||
expect(entry.body).toContain("$hp: 10");
|
||||
});
|
||||
|
||||
test("markdown role=spark-table body is converted to CSV", () => {
|
||||
const md = [
|
||||
"```markdown role=spark-table",
|
||||
"| d6 | Name |",
|
||||
"|----|------|",
|
||||
"| 1 | Alice |",
|
||||
"| 2 | Bob |",
|
||||
"```",
|
||||
].join("\n");
|
||||
const { stripped, content } = scanDoc(md, "test.md");
|
||||
|
||||
expect(stripped).toMatch(/^:md-table\[\.\/csv_/);
|
||||
const [entry] = Object.values(content);
|
||||
expect(entry.body).toBe("d6,Name\n1,Alice\n2,Bob");
|
||||
});
|
||||
|
||||
test("markdown role=spark-table with non-dice first column warns but still stores", () => {
|
||||
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const md = [
|
||||
"```markdown role=spark-table",
|
||||
"| Name | Value |",
|
||||
"|------|-------|",
|
||||
"| Alice | 10 |",
|
||||
"```",
|
||||
].join("\n");
|
||||
const { content } = scanDoc(md, "test.md");
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("is not a dice formula"),
|
||||
);
|
||||
const [entry] = Object.values(content);
|
||||
expect(entry.body).toBe("Name,Value\nAlice,10");
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
test("plain markdown tables are left untouched", () => {
|
||||
const md = [
|
||||
"| d6 | Name |",
|
||||
"|----|------|",
|
||||
"| 1 | Alice |",
|
||||
].join("\n");
|
||||
const { stripped, content } = scanDoc(md, "test.md");
|
||||
|
||||
expect(stripped).toBe(md);
|
||||
expect(Object.keys(content)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("plain markdown tables with label headers are left untouched", () => {
|
||||
const md = [
|
||||
"| md-table-label | body |",
|
||||
"|----------------|------|",
|
||||
"| 1 | text |",
|
||||
].join("\n");
|
||||
const { stripped, content } = scanDoc(md, "test.md");
|
||||
|
||||
expect(stripped).toBe(md);
|
||||
expect(Object.keys(content)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("tag blocks are kept intact", () => {
|
||||
const md = "```yaml role=tag\ntag: md-deck\nbody: ./cards.csv\n```";
|
||||
const { stripped, content } = scanDoc(md, "test.md");
|
||||
|
||||
expect(stripped).toBe(md);
|
||||
expect(Object.keys(content)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("blocks without role are kept as code blocks", () => {
|
||||
const md = "```csv\nlabel,body\n1,text\n```";
|
||||
const { stripped, content } = scanDoc(md, "test.md");
|
||||
|
||||
expect(stripped).toBe(md);
|
||||
expect(Object.keys(content)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("unknown roles warn and strip", () => {
|
||||
const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const md = "```csv role=frobnicate\nlabel,body\n```";
|
||||
const { stripped, content } = scanDoc(md, "test.md");
|
||||
|
||||
expect(stripped).toBe("");
|
||||
expect(Object.keys(content)).toHaveLength(0);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('unknown role "frobnicate"'),
|
||||
);
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
test("legacy as= attribute is ignored", () => {
|
||||
const md = [
|
||||
"```csv role=spark-table as=codeblock",
|
||||
"d6,Name",
|
||||
"1,Alice",
|
||||
"```",
|
||||
].join("\n");
|
||||
const { stripped } = scanDoc(md, "test.md");
|
||||
|
||||
// Role wins — the block still becomes a directive.
|
||||
expect(stripped).toMatch(/^:md-table\[\.\/csv_/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveContent", () => {
|
||||
test("resolves inline content ids", () => {
|
||||
const registry = emptyRegistry();
|
||||
registry.docContent["test.md"] = {
|
||||
csv_abc: {
|
||||
id: "csv_abc",
|
||||
kind: "csv",
|
||||
body: "d6,Name\n1,Alice",
|
||||
role: "spark-table",
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveContent(registry, "test.md", "./csv_abc")).toBe(
|
||||
"d6,Name\n1,Alice",
|
||||
);
|
||||
});
|
||||
|
||||
test("does not sniff refs as inline CSV", () => {
|
||||
const registry = emptyRegistry();
|
||||
// A CSV-looking ref is treated as a relative path, not content.
|
||||
expect(
|
||||
resolveContent(registry, "test.md", "d6,Name\n1,Alice"),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("resolveContentEntry reports inline vs file resolution", () => {
|
||||
const registry = emptyRegistry();
|
||||
registry.pathIndex["/test.md"] = "# Doc";
|
||||
registry.pathIndex["/data/table.csv"] = "d6,Name\n1,Alice";
|
||||
registry.docContent["/test.md"] = {
|
||||
csv_abc: {
|
||||
id: "csv_abc",
|
||||
kind: "csv",
|
||||
body: "d6,Name\n1,Bob",
|
||||
role: "spark-table",
|
||||
},
|
||||
};
|
||||
|
||||
const inline = resolveContentEntry(registry, "/test.md", "./csv_abc");
|
||||
expect(inline).toMatchObject({ path: "csv_abc", inline: true });
|
||||
|
||||
const file = resolveContentEntry(registry, "/test.md", "./data/table.csv");
|
||||
expect(file).toMatchObject({ path: "/data/table.csv", inline: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveCompletions", () => {
|
||||
test("headings inside fenced code blocks do not become link completions", () => {
|
||||
const md = [
|
||||
"# Real Heading",
|
||||
"",
|
||||
"```markdown",
|
||||
"# Fake Heading In Code",
|
||||
"```",
|
||||
].join("\n");
|
||||
const registry = buildRegistryFromIndex({ "test.md": md });
|
||||
const { links } = deriveCompletions(registry);
|
||||
|
||||
const testLinks = links.filter((l) => l.path === "/test");
|
||||
expect(testLinks.map((l) => l.label)).toEqual([
|
||||
"test",
|
||||
"test § Real Heading",
|
||||
]);
|
||||
expect(testLinks.some((l) => l.label.includes("Fake"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,626 @@
|
||||
/**
|
||||
* Content registry — the single source of truth for all content in a
|
||||
* TTRPG Tools project.
|
||||
*
|
||||
* Two stores:
|
||||
* - `pathIndex`: real files on disk, keyed by path (`.md`, `.csv`, `.yarn`, `.svg`)
|
||||
* - `docContent`: inline content *defined inside* a markdown doc, keyed by
|
||||
* a stable id and owned by that doc.
|
||||
*
|
||||
* Everything structured (completions, declarations, tag modifiers) is a
|
||||
* *derived* view over this registry — see `deriveCompletions`.
|
||||
*
|
||||
* This module is browser-safe (no Node-only imports) so the CLI and the
|
||||
* frontend share one implementation. The filesystem walk lives in the CLI
|
||||
* (`buildRegistry` in `commands/serve.ts`).
|
||||
*/
|
||||
|
||||
import Slugger from "github-slugger";
|
||||
import {
|
||||
parseDeclare,
|
||||
type VarDeclaration,
|
||||
type TagModifier,
|
||||
} from "./completions/declare-parser.js";
|
||||
import {
|
||||
FENCED_BLOCK_RE,
|
||||
parseBlockAttrs,
|
||||
} from "./completions/block-scanner.js";
|
||||
import type {
|
||||
CompletionsPayload,
|
||||
DiceCompletion,
|
||||
LinkCompletion,
|
||||
SparkTableCompletion,
|
||||
} from "./completions/types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ContentKind = "csv" | "text" | "declare";
|
||||
|
||||
/** A single piece of inline content defined inside a doc. */
|
||||
export interface DocContent {
|
||||
/** Stable id — author-supplied or derived from the body. */
|
||||
id: string;
|
||||
kind: ContentKind;
|
||||
body: string;
|
||||
/** Origin role that produced this content, for debugging. */
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface ContentRegistry {
|
||||
/** Real files on disk, keyed by path. */
|
||||
pathIndex: Record<string, string>;
|
||||
/** Inline content defined inside each doc, keyed by id. */
|
||||
docContent: Record<string, Record<string, DocContent>>;
|
||||
}
|
||||
|
||||
export const EMPTY_REGISTRY: ContentRegistry = {
|
||||
pathIndex: {},
|
||||
docContent: {},
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize a file path into a stable registry key: forward slashes and a
|
||||
* single leading `/`. Both the CLI filesystem walk and the browser folder
|
||||
* scan funnel through this so identical files always share a key.
|
||||
*/
|
||||
export function normalizePathKey(path: string): string {
|
||||
const normalized = path.split("\\").join("/");
|
||||
return normalized.startsWith("/") ? normalized : `/${normalized}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Id / hash derivation (single source of truth)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Browser-safe content hash — stable across CLI and frontend. */
|
||||
export function contentHash(body: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const ch = body.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash + ch) | 0;
|
||||
}
|
||||
return Math.abs(hash).toString(16).slice(0, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a stable content id.
|
||||
* Author-supplied `id` wins; otherwise `{kind}_{hash}`.
|
||||
*/
|
||||
export function deriveContentId(
|
||||
kind: ContentKind,
|
||||
body: string,
|
||||
id?: string,
|
||||
): string {
|
||||
if (id) return id;
|
||||
return `${kind}_${contentHash(body)}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-doc scanning
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DocScanResult {
|
||||
/** Content with blocks processed (stripped or replaced with directives). */
|
||||
stripped: string;
|
||||
/** Inline content defined in this doc, keyed by id. */
|
||||
content: Record<string, DocContent>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single markdown doc:
|
||||
* - dispatches attributed fenced code blocks by `role`
|
||||
* - converts `markdown`-lang `role=spark-table` bodies (pipe tables) to CSV
|
||||
* - collects inline content (spark-table, declare, file) into the doc's
|
||||
* content store
|
||||
*
|
||||
* Does NOT touch the path index — the caller assembles the registry.
|
||||
*/
|
||||
export function scanDoc(content: string, docPath: string): DocScanResult {
|
||||
const contentStore: Record<string, DocContent> = {};
|
||||
|
||||
const stripped = content.replace(
|
||||
FENCED_BLOCK_RE,
|
||||
(
|
||||
match: string,
|
||||
_marker: string,
|
||||
lang: string,
|
||||
infoString: string,
|
||||
body: string,
|
||||
): string => {
|
||||
const attrs = parseBlockAttrs(infoString);
|
||||
attrs.lang = attrs.lang || lang;
|
||||
|
||||
switch (attrs.role) {
|
||||
case "declare": {
|
||||
const id = deriveContentId("declare", body, attrs.id);
|
||||
contentStore[id] = { id, kind: "declare", body, role: attrs.role };
|
||||
return "";
|
||||
}
|
||||
|
||||
case "file": {
|
||||
const id = deriveContentId("text", body, attrs.id);
|
||||
contentStore[id] = { id, kind: "text", body, role: attrs.role };
|
||||
return "";
|
||||
}
|
||||
|
||||
case "spark-table": {
|
||||
let blockBody = body;
|
||||
if (isMarkdownTableLang(attrs.lang)) {
|
||||
blockBody = markdownTableBodyToCsv(body, docPath);
|
||||
}
|
||||
const id = deriveContentId("csv", blockBody, attrs.id);
|
||||
contentStore[id] = {
|
||||
id,
|
||||
kind: "csv",
|
||||
body: blockBody,
|
||||
role: attrs.role,
|
||||
};
|
||||
|
||||
const extraStr = Object.entries(attrs.extra)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(" ");
|
||||
return `:md-table[./${id}]${extraStr ? `{${extraStr}}` : ""}`;
|
||||
}
|
||||
|
||||
case "tag":
|
||||
// Kept intact so the code-block-yaml-tag render extension sees it.
|
||||
return match;
|
||||
|
||||
case undefined:
|
||||
// No role declared — plain visible code block.
|
||||
return match;
|
||||
|
||||
default:
|
||||
console.warn(
|
||||
`[content-registry] ${docPath}: unknown role "${attrs.role}" — stripping block`,
|
||||
);
|
||||
return "";
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return { stripped, content: contentStore };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `ContentRegistry` from a raw `{ path: content }` index.
|
||||
*
|
||||
* Shared by the CLI (`buildRegistry` in `commands/serve.ts`) and the browser
|
||||
* folder scan (`scanClientSide` in `components/journal/completions.ts`) so
|
||||
* both modes produce identical `pathIndex`/`docContent`.
|
||||
*
|
||||
* Keys are normalized via `normalizePathKey`; `.md` files are run through
|
||||
* `scanDoc`, other extensions are stored raw.
|
||||
*/
|
||||
export function buildRegistryFromIndex(
|
||||
index: Record<string, string>,
|
||||
): ContentRegistry {
|
||||
const registry: ContentRegistry = { pathIndex: {}, docContent: {} };
|
||||
|
||||
for (const [rawPath, content] of Object.entries(index)) {
|
||||
const path = normalizePathKey(rawPath);
|
||||
if (path.endsWith(".md")) {
|
||||
const result = scanDoc(content, path);
|
||||
registry.pathIndex[path] = result.stripped;
|
||||
registry.docContent[path] = result.content;
|
||||
} else {
|
||||
registry.pathIndex[path] = content;
|
||||
}
|
||||
}
|
||||
|
||||
return registry;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spark table coercion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Whether a table header cell is a dice formula (a "spark table" first
|
||||
* column). Used to validate `role=spark-table` blocks (CSV or markdown
|
||||
* pipe-table bodies).
|
||||
*/
|
||||
export function isSparkTableHeader(header: string): boolean {
|
||||
return /^\d*d\d+(?:[+-]\d+)?$/i.test(header.trim());
|
||||
}
|
||||
|
||||
/** Split a markdown table row into cells. */
|
||||
function splitTableRow(row: string): string[] {
|
||||
return row
|
||||
.replace(/^\|/, "")
|
||||
.replace(/\|$/, "")
|
||||
.split("|")
|
||||
.map((c) => c.trim());
|
||||
}
|
||||
|
||||
/** Escape a cell value for CSV output. */
|
||||
function escapeCsvCell(cell: string): string {
|
||||
if (
|
||||
cell.includes(",") ||
|
||||
cell.includes("\n") ||
|
||||
cell.includes('"') ||
|
||||
cell.includes("#")
|
||||
) {
|
||||
return `"${cell.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return cell;
|
||||
}
|
||||
|
||||
/** Convert a markdown table (header + separator + rows) to a CSV string. */
|
||||
function markdownTableToCsv(
|
||||
headerRow: string,
|
||||
separatorRow: string,
|
||||
bodyRows: string[],
|
||||
): string | null {
|
||||
const headers = splitTableRow(headerRow);
|
||||
if (headers.length === 0) return null;
|
||||
|
||||
const sepCells = splitTableRow(separatorRow);
|
||||
if (!sepCells.every((c) => /^:?-{3,}:?$/.test(c))) return null;
|
||||
if (sepCells.length !== headers.length) return null;
|
||||
|
||||
const csvHeader = headers.map(escapeCsvCell).join(",");
|
||||
const csvRows = bodyRows.map((row) => {
|
||||
const cells = splitTableRow(row);
|
||||
while (cells.length < headers.length) cells.push("");
|
||||
return cells.slice(0, headers.length).map(escapeCsvCell).join(",");
|
||||
});
|
||||
|
||||
return [csvHeader, ...csvRows].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Languages whose fenced-block bodies are markdown pipe tables. Used with
|
||||
* `role=spark-table` to convert the table to CSV at scan time — explicitly
|
||||
* authorized by the role, never by content shape.
|
||||
*/
|
||||
const MARKDOWN_TABLE_LANGS = new Set(["markdown", "md"]);
|
||||
|
||||
export function isMarkdownTableLang(lang: string): boolean {
|
||||
return MARKDOWN_TABLE_LANGS.has(lang.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a fenced markdown pipe-table body to CSV for `role=spark-table`
|
||||
* blocks. Validates the dice-formula first column (warning only — the role
|
||||
* already declared intent) and falls back to storing the body as-is when it
|
||||
* is not a recognizable pipe table.
|
||||
*/
|
||||
function markdownTableBodyToCsv(body: string, docPath: string): string {
|
||||
const lines = body
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.filter((l) => l.trim().startsWith("|"));
|
||||
if (lines.length < 2) {
|
||||
console.warn(
|
||||
`[content-registry] ${docPath}: role=spark-table markdown body is not a pipe table; storing as-is`,
|
||||
);
|
||||
return body;
|
||||
}
|
||||
|
||||
const [headerRow, separatorRow, ...rows] = lines;
|
||||
const headers = splitTableRow(headerRow);
|
||||
if (!isSparkTableHeader(headers[0] || "")) {
|
||||
console.warn(
|
||||
`[content-registry] ${docPath}: spark table first column "${headers[0]}" is not a dice formula`,
|
||||
);
|
||||
}
|
||||
|
||||
return markdownTableToCsv(headerRow, separatorRow, rows) ?? body;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A resolved content reference: the body plus how it was addressed.
|
||||
*/
|
||||
export interface ResolvedContent {
|
||||
body: string;
|
||||
/** Content id for inline content, path-index key for real files. */
|
||||
path: string;
|
||||
/** True when resolved from the doc's inline content store. */
|
||||
inline: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a content reference from within a doc.
|
||||
*
|
||||
* - `ref` is an absolute path → path index.
|
||||
* - `ref` is a relative path → resolved against the doc directory; checks
|
||||
* the doc's inline content store first, then the path index.
|
||||
*
|
||||
* Refs are always ids or paths — inline content must be defined in a fenced
|
||||
* block and referenced by its `./{id}`.
|
||||
*
|
||||
* Returns `null` when nothing matches.
|
||||
*/
|
||||
export function resolveContentEntry(
|
||||
registry: ContentRegistry,
|
||||
docPath: string,
|
||||
ref: string,
|
||||
): ResolvedContent | null {
|
||||
const trimmed = ref.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
if (trimmed.startsWith("/")) {
|
||||
const body = registry.pathIndex[trimmed];
|
||||
return body == null ? null : { body, path: trimmed, inline: false };
|
||||
}
|
||||
|
||||
// Inline content in the same doc (e.g. ./{id}).
|
||||
const docStore = registry.docContent[docPath];
|
||||
const id = trimmed.startsWith("./") ? trimmed.slice(2) : trimmed;
|
||||
const entry = docStore?.[id];
|
||||
if (entry) return { body: entry.body, path: id, inline: true };
|
||||
|
||||
// Relative path resolved against the doc directory (`./` already stripped).
|
||||
const resolved = posixJoin(posixDir(docPath), id);
|
||||
const body = registry.pathIndex[resolved];
|
||||
return body != null ? { body, path: resolved, inline: false } : null;
|
||||
}
|
||||
|
||||
/** Resolve a content reference to its body only. */
|
||||
export function resolveContent(
|
||||
registry: ContentRegistry,
|
||||
docPath: string,
|
||||
ref: string,
|
||||
): string | null {
|
||||
return resolveContentEntry(registry, docPath, ref)?.body ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a *resolved* path (e.g. `/content/csv_abc123`) to inline content
|
||||
* by searching every doc's content store for a matching id. Used by the
|
||||
* frontend when a directive ref has already been resolved to a path.
|
||||
*
|
||||
* Returns `null` when no inline content matches.
|
||||
*/
|
||||
export function resolveInlineByPath(
|
||||
registry: ContentRegistry,
|
||||
resolvedPath: string,
|
||||
): string | null {
|
||||
const id = resolvedPath.split("/").filter(Boolean).pop() || "";
|
||||
if (!id) return null;
|
||||
for (const store of Object.values(registry.docContent)) {
|
||||
const entry = store[id];
|
||||
if (entry) return entry.body;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived completions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Derive the completions payload from the registry.
|
||||
* Pure function — recompute on any file change.
|
||||
*
|
||||
* Dice + spark completions come from scanning each doc's stripped content
|
||||
* for directives (resolving CSV refs through the registry), so both inline
|
||||
* and real-file spark tables are covered. Declarations come from the doc
|
||||
* content store.
|
||||
*/
|
||||
export function deriveCompletions(
|
||||
registry: ContentRegistry,
|
||||
): CompletionsPayload {
|
||||
const links = deriveLinks(registry.pathIndex);
|
||||
const dice: DiceCompletion[] = [];
|
||||
const sparkTables: SparkTableCompletion[] = [];
|
||||
const declarations: VarDeclaration[] = [];
|
||||
const tagModifiers: TagModifier[] = [];
|
||||
|
||||
for (const [docPath, content] of Object.entries(registry.pathIndex)) {
|
||||
if (!docPath.endsWith(".md")) continue;
|
||||
const found = scanDocDirectives(content, docPath, registry);
|
||||
dice.push(...found.dice);
|
||||
sparkTables.push(...found.sparkTables);
|
||||
}
|
||||
|
||||
const blocks = deriveBlocks(registry);
|
||||
declarations.push(...blocks.declarations);
|
||||
tagModifiers.push(...blocks.tagModifiers);
|
||||
|
||||
return { dice, links, sparkTables, declarations, tagModifiers };
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a doc's stripped content for `:md-dice` and `:md-table`/`:md-card`
|
||||
* directives, resolving CSV refs through the registry.
|
||||
*/
|
||||
function scanDocDirectives(
|
||||
content: string,
|
||||
docPath: string,
|
||||
registry: ContentRegistry,
|
||||
): { dice: DiceCompletion[]; sparkTables: SparkTableCompletion[] } {
|
||||
const dice = scanDice(content, docPath);
|
||||
const sparkTables: SparkTableCompletion[] = [];
|
||||
|
||||
const tableDirectiveRegex = /:md-(table|card)\[([^\[\]]+)\](?:\{([^}]*)\})?/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = tableDirectiveRegex.exec(content)) !== null) {
|
||||
const [, , ref, extraStr] = m;
|
||||
const resolved = resolveContentEntry(registry, docPath, ref);
|
||||
if (!resolved) continue;
|
||||
|
||||
const attrs = parseBlockAttrs(extraStr || "").extra;
|
||||
const st = buildSparkTableCompletion(
|
||||
resolved.body,
|
||||
docPath,
|
||||
resolved.path,
|
||||
attrs["remix"] === "true",
|
||||
);
|
||||
if (st) sparkTables.push(st);
|
||||
}
|
||||
|
||||
return { dice, sparkTables };
|
||||
}
|
||||
|
||||
/** Derive variable declarations + tag modifiers from the registry. */
|
||||
export function deriveBlocks(registry: ContentRegistry): {
|
||||
declarations: VarDeclaration[];
|
||||
tagModifiers: TagModifier[];
|
||||
} {
|
||||
const declarations: VarDeclaration[] = [];
|
||||
const tagModifiers: TagModifier[] = [];
|
||||
|
||||
for (const [docPath, store] of Object.entries(registry.docContent)) {
|
||||
for (const entry of Object.values(store)) {
|
||||
if (entry.kind !== "declare") continue;
|
||||
try {
|
||||
const result = parseDeclare(entry.body);
|
||||
declarations.push(...result.variables);
|
||||
tagModifiers.push(...result.tagModifiers);
|
||||
} catch (e) {
|
||||
console.warn(`[content-registry] ${docPath}: ${e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { declarations, tagModifiers };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derivation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Remove fenced code blocks (backtick or tilde) from content, so text
|
||||
* scanners (headings, dice directives) don't match example code.
|
||||
*/
|
||||
export function stripFencedBlocks(content: string): string {
|
||||
const out: string[] = [];
|
||||
let fence: string | null = null;
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const fenceMatch = /^(`{3,}|~{3,})/.exec(line);
|
||||
if (fence) {
|
||||
if (line.startsWith(fence)) fence = null;
|
||||
continue;
|
||||
}
|
||||
if (fenceMatch) {
|
||||
fence = fenceMatch[1];
|
||||
continue;
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
/** Extract headings from all `.md` files as link completions. */
|
||||
function deriveLinks(pathIndex: Record<string, string>): LinkCompletion[] {
|
||||
const items: LinkCompletion[] = [];
|
||||
for (const [filePath, rawContent] of Object.entries(pathIndex)) {
|
||||
if (!filePath.endsWith(".md")) continue;
|
||||
|
||||
// Headings inside fenced code blocks (e.g. markdown examples) are not
|
||||
// real headings — exclude them from link completions.
|
||||
const content = stripFencedBlocks(rawContent);
|
||||
const basePath = filePath.replace(/\.md$/, "");
|
||||
const fileName = fileNameFromPath(basePath);
|
||||
const slugger = new Slugger();
|
||||
|
||||
items.push({ path: basePath, label: fileName, section: null });
|
||||
|
||||
const headingRegex = /^(#{1,6})\s+(.+)$/gm;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = headingRegex.exec(content)) !== null) {
|
||||
const title = match[2].trim();
|
||||
const id = slugger.slug(title.toLowerCase());
|
||||
items.push({
|
||||
path: basePath,
|
||||
label: `${fileName} § ${title}`,
|
||||
section: id,
|
||||
});
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
const DICE_DIRECTIVE_RE = /:md-dice\[([^[\]]+)\]/gi;
|
||||
|
||||
function looksLikeDice(raw: string): boolean {
|
||||
if (raw.length > 80) return false;
|
||||
return /^\d*d\d+/i.test(raw) || /^[+-]/.test(raw);
|
||||
}
|
||||
|
||||
/** Scan a text body for `:md-dice[...]` directives. */
|
||||
function scanDice(body: string, source: string): DiceCompletion[] {
|
||||
const dice: DiceCompletion[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = DICE_DIRECTIVE_RE.exec(body)) !== null) {
|
||||
const raw = m[1].trim();
|
||||
if (!raw || !looksLikeDice(raw)) continue;
|
||||
dice.push({ label: raw, notation: raw, source });
|
||||
}
|
||||
return dice;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect a CSV body and return its data-column headers if it's a spark
|
||||
* table (first column header is a dice formula), or null otherwise.
|
||||
*/
|
||||
export function inspectSparkTableCsv(csv: string): string[] | null {
|
||||
const lines = csv.trim().split(/\r?\n/);
|
||||
if (lines.length < 2) return null;
|
||||
|
||||
const headers = lines[0].split(",").map((h) => h.trim());
|
||||
if (headers.length < 2) return null;
|
||||
if (!isSparkTableHeader(headers[0])) return null;
|
||||
|
||||
return headers.slice(1);
|
||||
}
|
||||
|
||||
/** Build a SparkTableCompletion from a CSV body. */
|
||||
export function buildSparkTableCompletion(
|
||||
csv: string,
|
||||
docPath: string,
|
||||
contentId: string,
|
||||
remix: boolean,
|
||||
): SparkTableCompletion | null {
|
||||
const dataHeaders = inspectSparkTableCsv(csv);
|
||||
if (!dataHeaders) return null;
|
||||
|
||||
const notation = csv.trim().split(/\r?\n/)[0].split(",")[0].trim();
|
||||
const slugger = new Slugger();
|
||||
const slug = dataHeaders.map((h) => slugger.slug(h.toLowerCase())).join("-");
|
||||
|
||||
const basePath = docPath.replace(/\.md$/, "");
|
||||
const fileName = basePath.split("/").filter(Boolean).pop() || basePath;
|
||||
const combinedSlug = `${fileName}-${slug}`;
|
||||
|
||||
return {
|
||||
label: `${fileName} § ${slug}`,
|
||||
notation,
|
||||
slug: combinedSlug,
|
||||
filePath: basePath,
|
||||
docPath,
|
||||
csvPath: contentId,
|
||||
headers: dataHeaders,
|
||||
remix,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path helpers (browser-safe posix)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function posixDir(path: string): string {
|
||||
const idx = path.lastIndexOf("/");
|
||||
return idx >= 0 ? path.slice(0, idx) : ".";
|
||||
}
|
||||
|
||||
function posixJoin(dir: string, rel: string): string {
|
||||
if (dir === ".") return rel.startsWith("/") ? rel : `/${rel}`;
|
||||
const base = dir.replace(/\/+$/, "");
|
||||
const r = rel.replace(/^\/+/, "");
|
||||
return `${base}/${r}`;
|
||||
}
|
||||
|
||||
function fileNameFromPath(path: string): string {
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
return parts[parts.length - 1] || path;
|
||||
}
|
||||
+47
-20
@@ -1,33 +1,60 @@
|
||||
#!/usr/bin/env node
|
||||
import { Command } from 'commander';
|
||||
import { serveCommand } from './commands/serve.js';
|
||||
import { compileCommand } from './commands/compile.js';
|
||||
import { mcpCommand } from './commands/mcp.js';
|
||||
import type { ServeOptions, CompileOptions } from './types.js';
|
||||
import { Command } from "commander";
|
||||
import { serveCommand } from "./commands/serve.js";
|
||||
import { compileCommand } from "./commands/compile.js";
|
||||
import { checkCommand } from "./commands/check.js";
|
||||
import { mcpCommand } from "./commands/mcp.js";
|
||||
import type { ServeOptions, CompileOptions } from "./types.js";
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name('ttrpg')
|
||||
.description('TTRPG 工具箱 - 用于编译和预览 TTRPG 文档')
|
||||
.version('0.0.1');
|
||||
.name("tttk")
|
||||
.description(
|
||||
"TTRPG 内容预览服务器。不带参数运行时默认启动当前目录的预览服务。",
|
||||
)
|
||||
.version("0.0.1");
|
||||
|
||||
program
|
||||
.command('serve')
|
||||
.description('运行一个 web 服务器预览目录中的内容,并实时监听更新')
|
||||
.argument('[dir]', '要预览的目录', '.')
|
||||
.option('-p, --port <port>', '端口号', '3000')
|
||||
.option('-h, --host <host>', '主机地址', '0.0.0.0')
|
||||
.command("serve")
|
||||
.description("启动 web 服务器预览目录中的内容,并实时监听文件更新")
|
||||
.argument("[dir]", "要预览的目录", ".")
|
||||
.option("-p, --port <port>", "HTTP 端口号", "3000")
|
||||
.option("--host <host>", "主机地址", "0.0.0.0")
|
||||
.action(serveCommand);
|
||||
|
||||
program
|
||||
.command('compile')
|
||||
.description('将目录中的内容输出为带 hash 路由、单个 html 入口的 web 应用')
|
||||
.argument('[dir]', '要编译的目录', '.')
|
||||
.option('-o, --output <dir>', '输出目录', './dist/output')
|
||||
.action(compileCommand);
|
||||
.command("check")
|
||||
.alias("lint")
|
||||
.description("校验目录内的 tttk 文档,输出结构化诊断")
|
||||
.argument("[dir]", "要校验的目录", ".")
|
||||
.option("--json", "以 JSON 输出诊断", false)
|
||||
.option("--strict", "将警告视为错误", false)
|
||||
.option("--quiet", "只报告错误", false)
|
||||
.action(checkCommand);
|
||||
|
||||
program
|
||||
.addCommand(mcpCommand);
|
||||
.command("compile")
|
||||
.description("[未实现] 存根命令:不会生成任何输出。预览内容请使用 tttk serve")
|
||||
.argument("[dir]", "要编译的目录", ".")
|
||||
.option("-o, --output <dir>", "输出目录", "./dist/output")
|
||||
.action(compileCommand);
|
||||
|
||||
program.parse();
|
||||
program.addCommand(mcpCommand);
|
||||
|
||||
// 无子命令时默认执行 serve(即 `npx tttk` 等同于 `npx tttk serve .`)
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length > 0) {
|
||||
const first = args[0];
|
||||
const isKnownCommand =
|
||||
!first.startsWith("-") &&
|
||||
program.commands.some(
|
||||
(c) => c.name() === first || c.aliases().includes(first),
|
||||
);
|
||||
if (!isKnownCommand) {
|
||||
args.unshift("serve");
|
||||
}
|
||||
} else {
|
||||
args.unshift("serve");
|
||||
}
|
||||
program.parse([process.argv[0], process.argv[1], ...args]);
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import {
|
||||
appendStream,
|
||||
extractSessionId,
|
||||
handleMetaChange,
|
||||
metaPath,
|
||||
migrateLegacyManifest,
|
||||
repairStreamTail,
|
||||
scanSessions,
|
||||
sessionDir,
|
||||
streamPath,
|
||||
writeFileAtomic,
|
||||
type SessionMeta,
|
||||
} from "./journal";
|
||||
|
||||
function makeRoot(): string {
|
||||
return mkdtempSync(join(tmpdir(), "ttrpg-journal-"));
|
||||
}
|
||||
|
||||
function cleanup(root: string): void {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
describe("writeFileAtomic", () => {
|
||||
test("writes content and leaves no temp file behind", () => {
|
||||
const root = makeRoot();
|
||||
const p = join(root, "meta.json");
|
||||
writeFileAtomic(p, '{"a":1}');
|
||||
expect(readFileSync(p, "utf-8")).toBe('{"a":1}');
|
||||
expect(readdirSync(root)).toEqual(["meta.json"]);
|
||||
cleanup(root);
|
||||
});
|
||||
});
|
||||
|
||||
describe("repairStreamTail", () => {
|
||||
test("truncates a partial trailing line", () => {
|
||||
const root = makeRoot();
|
||||
const p = join(root, "stream.jsonl");
|
||||
writeFileSync(p, '{"a":1}\n{"b":2');
|
||||
repairStreamTail(p);
|
||||
expect(readFileSync(p, "utf-8")).toBe('{"a":1}\n');
|
||||
cleanup(root);
|
||||
});
|
||||
|
||||
test("leaves a well-formed file untouched", () => {
|
||||
const root = makeRoot();
|
||||
const p = join(root, "stream.jsonl");
|
||||
writeFileSync(p, '{"a":1}\n');
|
||||
repairStreamTail(p);
|
||||
expect(readFileSync(p, "utf-8")).toBe('{"a":1}\n');
|
||||
cleanup(root);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scanSessions", () => {
|
||||
test("adopts a directory that has a stream but no meta", () => {
|
||||
const root = makeRoot();
|
||||
mkdirSync(sessionDir(root, "default"), { recursive: true });
|
||||
writeFileSync(streamPath(root, "default"), '{"id":"gm-1"}\n');
|
||||
|
||||
const index = scanSessions(root);
|
||||
|
||||
expect(index["default"]).toMatchObject({ name: "default", players: [] });
|
||||
expect(existsSync(metaPath(root, "default"))).toBe(true);
|
||||
cleanup(root);
|
||||
});
|
||||
|
||||
test("quarantines corrupt meta and adopts the session", () => {
|
||||
const root = makeRoot();
|
||||
const dir = sessionDir(root, "broken");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(metaPath(root, "broken"), "{ not json");
|
||||
writeFileSync(streamPath(root, "broken"), '{"id":"gm-1"}\n');
|
||||
|
||||
const index = scanSessions(root);
|
||||
|
||||
expect(index["broken"]).toBeDefined();
|
||||
const quarantined = readdirSync(dir).filter((f) =>
|
||||
f.startsWith("meta.json.corrupt-"),
|
||||
);
|
||||
expect(quarantined).toHaveLength(1);
|
||||
cleanup(root);
|
||||
});
|
||||
|
||||
test("keeps a valid session that has no stream yet", () => {
|
||||
const root = makeRoot();
|
||||
mkdirSync(sessionDir(root, "test"), { recursive: true });
|
||||
writeFileAtomic(
|
||||
metaPath(root, "test"),
|
||||
JSON.stringify({ name: "test", created: 1, players: [] }),
|
||||
);
|
||||
|
||||
const index = scanSessions(root);
|
||||
|
||||
expect(index["test"]).toEqual({ name: "test", created: 1, players: [] });
|
||||
cleanup(root);
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrateLegacyManifest", () => {
|
||||
test("splits a legacy manifest into per-session meta and renames it", () => {
|
||||
const root = makeRoot();
|
||||
writeFileSync(
|
||||
join(root, "manifest.json"),
|
||||
JSON.stringify({
|
||||
sessions: {
|
||||
aaa: { name: "aaa", created: 1, players: [] },
|
||||
bbb: { name: "bbb", created: 2, players: [] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
migrateLegacyManifest(root);
|
||||
|
||||
expect(existsSync(join(root, "manifest.json"))).toBe(false);
|
||||
expect(existsSync(join(root, "manifest.json.migrated"))).toBe(true);
|
||||
expect(existsSync(metaPath(root, "aaa"))).toBe(true);
|
||||
expect(existsSync(metaPath(root, "bbb"))).toBe(true);
|
||||
cleanup(root);
|
||||
});
|
||||
});
|
||||
|
||||
describe("appendStream", () => {
|
||||
test("drops unknown sessions and appends known ones", () => {
|
||||
const root = makeRoot();
|
||||
const index = { known: { name: "known", created: 1, players: [] } };
|
||||
mkdirSync(sessionDir(root, "known"), { recursive: true });
|
||||
|
||||
appendStream(root, "ghost", index, '{"x":1}');
|
||||
expect(existsSync(streamPath(root, "ghost"))).toBe(false);
|
||||
|
||||
appendStream(root, "known", index, '{"x":1}');
|
||||
expect(readFileSync(streamPath(root, "known"), "utf-8")).toBe('{"x":1}\n');
|
||||
cleanup(root);
|
||||
});
|
||||
|
||||
test("refuses to build a path from a malformed id", () => {
|
||||
const root = makeRoot();
|
||||
mkdirSync(join(root, "sessions"), { recursive: true });
|
||||
const index: Record<string, SessionMeta> = {
|
||||
"..": { name: "evil", created: 1, players: [] },
|
||||
};
|
||||
|
||||
appendStream(root, "..", index, '{"x":1}');
|
||||
|
||||
expect(existsSync(join(root, "stream.jsonl"))).toBe(false);
|
||||
cleanup(root);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractSessionId", () => {
|
||||
test("returns a valid id from a session topic", () => {
|
||||
expect(extractSessionId("ttrpg/my-game/stream")).toBe("my-game");
|
||||
expect(extractSessionId("ttrpg/A_1/meta")).toBe("A_1");
|
||||
});
|
||||
|
||||
test("rejects traversal, empty and malformed ids", () => {
|
||||
expect(extractSessionId("ttrpg/../meta")).toBeNull();
|
||||
expect(extractSessionId("ttrpg/./meta")).toBeNull();
|
||||
expect(extractSessionId("ttrpg/..\\..\\evil/meta")).toBeNull();
|
||||
expect(extractSessionId("ttrpg/a b/meta")).toBeNull();
|
||||
expect(extractSessionId("ttrpg//meta")).toBeNull();
|
||||
expect(extractSessionId("ttrpg/meta")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleMetaChange", () => {
|
||||
// Minimal broker stub: handleMetaChange only needs publish() for the
|
||||
// manifest broadcast.
|
||||
function makeBroker() {
|
||||
const published: { topic: string; payload: string }[] = [];
|
||||
const broker = {
|
||||
publish: (
|
||||
packet: { topic: string; payload: Buffer },
|
||||
cb?: () => void,
|
||||
) => {
|
||||
published.push({
|
||||
topic: packet.topic,
|
||||
payload: packet.payload.toString(),
|
||||
});
|
||||
cb?.();
|
||||
},
|
||||
} as unknown as import("aedes").Aedes;
|
||||
return { broker, published };
|
||||
}
|
||||
|
||||
test("writes validated meta, updates the index and publishes the manifest", () => {
|
||||
const root = makeRoot();
|
||||
const index: Record<string, SessionMeta> = {};
|
||||
const { broker, published } = makeBroker();
|
||||
|
||||
handleMetaChange(
|
||||
root,
|
||||
"game",
|
||||
broker,
|
||||
index,
|
||||
JSON.stringify({ name: "Game", created: 10, players: ["alice"] }),
|
||||
);
|
||||
|
||||
expect(index["game"]).toEqual({
|
||||
name: "Game",
|
||||
created: 10,
|
||||
players: ["alice"],
|
||||
});
|
||||
expect(readFileSync(metaPath(root, "game"), "utf-8")).toContain("Game");
|
||||
expect(published.some((p) => p.topic === "ttrpg/$SESSIONS")).toBe(true);
|
||||
cleanup(root);
|
||||
});
|
||||
|
||||
test("deletes a session on an empty payload", () => {
|
||||
const root = makeRoot();
|
||||
mkdirSync(sessionDir(root, "doomed"), { recursive: true });
|
||||
writeFileAtomic(
|
||||
metaPath(root, "doomed"),
|
||||
JSON.stringify({ name: "Doomed", created: 1, players: [] }),
|
||||
);
|
||||
const index: Record<string, SessionMeta> = {
|
||||
doomed: { name: "Doomed", created: 1, players: [] },
|
||||
};
|
||||
const { broker } = makeBroker();
|
||||
|
||||
handleMetaChange(root, "doomed", broker, index, "");
|
||||
|
||||
expect(index["doomed"]).toBeUndefined();
|
||||
expect(existsSync(sessionDir(root, "doomed"))).toBe(false);
|
||||
cleanup(root);
|
||||
});
|
||||
|
||||
test("refuses malformed ids and never touches the filesystem", () => {
|
||||
const root = makeRoot();
|
||||
mkdirSync(join(root, "sessions"), { recursive: true });
|
||||
const index: Record<string, SessionMeta> = {};
|
||||
const { broker } = makeBroker();
|
||||
|
||||
handleMetaChange(root, "..", broker, index, JSON.stringify({ name: "evil" }));
|
||||
handleMetaChange(root, ".", broker, index, "");
|
||||
handleMetaChange(root, "..\\..\\evil", broker, index, "");
|
||||
|
||||
expect(existsSync(join(root, "meta.json"))).toBe(false);
|
||||
expect(existsSync(join(root, "sessions", "meta.json"))).toBe(false);
|
||||
expect(Object.keys(index)).toHaveLength(0);
|
||||
cleanup(root);
|
||||
});
|
||||
|
||||
test("ignores malformed meta without deleting the session", () => {
|
||||
const root = makeRoot();
|
||||
mkdirSync(sessionDir(root, "keep"), { recursive: true });
|
||||
writeFileAtomic(
|
||||
metaPath(root, "keep"),
|
||||
JSON.stringify({ name: "Keep", created: 1, players: [] }),
|
||||
);
|
||||
const index: Record<string, SessionMeta> = {
|
||||
keep: { name: "Keep", created: 1, players: [] },
|
||||
};
|
||||
const { broker } = makeBroker();
|
||||
|
||||
handleMetaChange(
|
||||
root,
|
||||
"keep",
|
||||
broker,
|
||||
index,
|
||||
JSON.stringify({ players: "not-an-array" }),
|
||||
);
|
||||
|
||||
expect(index["keep"]).toEqual({ name: "Keep", created: 1, players: [] });
|
||||
expect(existsSync(metaPath(root, "keep"))).toBe(true);
|
||||
cleanup(root);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,465 @@
|
||||
/**
|
||||
* 日志流持久化 — MQTT 代理 + JSONL 追加写入 + 按会话元数据
|
||||
*
|
||||
* 在现有 HTTP 服务器上挂载 aedes MQTT 代理(WebSocket 传输),
|
||||
* 使用 ws v8 内置的 createWebSocketStream。
|
||||
*
|
||||
* 实现参考 aedes 文档:
|
||||
* https://github.com/moscajs/aedes#mqtt-server-over-websocket
|
||||
*
|
||||
* 持久化使用 aedes 内部 subscribe/publish API,无需服务器进程
|
||||
* 以 MQTT 客户端身份连接自身。
|
||||
*
|
||||
* 存储模型:目录即事实来源。会话存在当且仅当
|
||||
* `sessions/<id>/meta.json` 存在且可解析;不再维护中心化清单,
|
||||
* 会话列表在内存中派生并通过保留主题 `ttrpg/$SESSIONS` 发布。
|
||||
* 元数据写入使用「临时文件 + fsync + 原子重命名」,避免崩溃时
|
||||
* 截断损坏;损坏的元数据会被隔离而非静默丢弃。
|
||||
*
|
||||
* 会话 ID 会直接决定文件系统路径,因此在 MQTT 边界严格校验
|
||||
* (见 shared/session-id)。
|
||||
*/
|
||||
|
||||
import { join, resolve } from "path";
|
||||
import {
|
||||
appendFileSync,
|
||||
closeSync,
|
||||
existsSync,
|
||||
fsyncSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
readSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
truncateSync,
|
||||
writeSync,
|
||||
} from "fs";
|
||||
import type { Server as HttpServer } from "http";
|
||||
import { WebSocketServer, createWebSocketStream } from "ws";
|
||||
import { z } from "zod";
|
||||
import { isValidSessionId } from "../shared/session-id.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 类型定义
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SessionMetaSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
created: z.number().default(() => Date.now()),
|
||||
players: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
type SessionMeta = z.infer<typeof SessionMetaSchema>;
|
||||
|
||||
export type { SessionMeta };
|
||||
|
||||
interface SessionManifest {
|
||||
sessions: Record<string, SessionMeta>;
|
||||
}
|
||||
|
||||
export interface JournalServer {
|
||||
broker: import("aedes").Aedes;
|
||||
wsServer: import("ws").WebSocketServer;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 路径工具函数
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function dataDir(contentDir: string): string {
|
||||
const d = resolve(contentDir, ".ttrpg");
|
||||
mkdirSync(d, { recursive: true });
|
||||
return d;
|
||||
}
|
||||
|
||||
export function sessionDir(dataRoot: string, id: string): string {
|
||||
return join(dataRoot, "sessions", id);
|
||||
}
|
||||
|
||||
export function metaPath(dataRoot: string, id: string): string {
|
||||
return join(sessionDir(dataRoot, id), "meta.json");
|
||||
}
|
||||
|
||||
export function streamPath(dataRoot: string, id: string): string {
|
||||
return join(sessionDir(dataRoot, id), "stream.jsonl");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 原子写入
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _tmpCounter = 0;
|
||||
|
||||
/**
|
||||
* 原子写入:先写临时文件并 fsync,再重命名覆盖目标。
|
||||
* rename 在同一文件系统上是原子操作(Windows 上由 libuv 以
|
||||
* MOVEFILE_REPLACE_EXISTING 实现),因此崩溃时目标文件要么是旧内容,
|
||||
* 要么是新内容,绝不会是截断的中间态。
|
||||
*/
|
||||
export function writeFileAtomic(path: string, data: string): void {
|
||||
const tmp = `${path}.tmp-${process.pid}-${Date.now()}-${_tmpCounter++}`;
|
||||
const fd = openSync(tmp, "w");
|
||||
try {
|
||||
writeSync(fd, data);
|
||||
fsyncSync(fd);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
renameSync(tmp, path);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 流文件修复
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 修复崩溃时被截断的尾行:若文件不以换行结尾,则截断到最后一个换行。
|
||||
* 否则后续追加会与残行粘连,产生一条损坏记录。
|
||||
*/
|
||||
export function repairStreamTail(path: string): void {
|
||||
if (!existsSync(path)) return;
|
||||
try {
|
||||
const size = statSync(path).size;
|
||||
if (size === 0) return;
|
||||
|
||||
const fd = openSync(path, "r");
|
||||
const last = Buffer.alloc(1);
|
||||
try {
|
||||
readSync(fd, last, 0, 1, size - 1);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
if (last[0] === 0x0a) return;
|
||||
|
||||
const buf = readFileSync(path);
|
||||
const lastNl = buf.lastIndexOf(0x0a);
|
||||
truncateSync(path, lastNl === -1 ? 0 : lastNl + 1);
|
||||
console.warn(`[日志] 已修复截断的流尾行:${path}`);
|
||||
} catch (e) {
|
||||
console.error(`[日志] 修复流文件失败:${path}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 会话扫描 / 迁移
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function safeMtime(path: string): number {
|
||||
try {
|
||||
return statSync(path).mtimeMs;
|
||||
} catch {
|
||||
return Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描 sessions/ 目录,构建内存索引。
|
||||
*
|
||||
* - 有合法 meta.json 的目录 → 直接纳入索引;
|
||||
* - meta.json 损坏 → 隔离为 meta.json.corrupt-<ts>,再尝试接管;
|
||||
* - 无 meta.json 但有 stream.jsonl → 接管(用目录名与流文件 mtime 合成元数据);
|
||||
* - 其余目录 → 忽略。
|
||||
*/
|
||||
export function scanSessions(dataRoot: string): Record<string, SessionMeta> {
|
||||
const sessionsRoot = join(dataRoot, "sessions");
|
||||
mkdirSync(sessionsRoot, { recursive: true });
|
||||
|
||||
const index: Record<string, SessionMeta> = {};
|
||||
|
||||
for (const entry of readdirSync(sessionsRoot, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const id = entry.name;
|
||||
if (!isValidSessionId(id)) {
|
||||
console.warn(`[日志] 跳过非法会话目录:${id}`);
|
||||
continue;
|
||||
}
|
||||
const mp = metaPath(dataRoot, id);
|
||||
const sp = streamPath(dataRoot, id);
|
||||
|
||||
if (existsSync(mp)) {
|
||||
try {
|
||||
const meta = SessionMetaSchema.parse(
|
||||
JSON.parse(readFileSync(mp, "utf-8")),
|
||||
);
|
||||
index[id] = meta;
|
||||
repairStreamTail(sp);
|
||||
continue;
|
||||
} catch (e) {
|
||||
const quarantine = `${mp}.corrupt-${Date.now()}`;
|
||||
try {
|
||||
renameSync(mp, quarantine);
|
||||
console.error(
|
||||
`[日志] 会话 ${id} 元数据损坏,已隔离到 ${quarantine}:`,
|
||||
e,
|
||||
);
|
||||
} catch (renameErr) {
|
||||
console.error(`[日志] 会话 ${id} 元数据损坏且隔离失败:`, renameErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync(sp)) {
|
||||
const meta: SessionMeta = {
|
||||
name: id,
|
||||
created: safeMtime(sp),
|
||||
players: [],
|
||||
};
|
||||
try {
|
||||
writeFileAtomic(mp, JSON.stringify(meta, null, 2));
|
||||
index[id] = meta;
|
||||
repairStreamTail(sp);
|
||||
console.log(`[日志] 已接管孤立会话:${id}`);
|
||||
} catch (e) {
|
||||
console.error(`[日志] 接管孤立会话失败:${id}`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* 迁移旧的中心化 manifest.json:把每个会话条目拆分为
|
||||
* sessions/<id>/meta.json,然后把旧文件重命名为 manifest.json.migrated。
|
||||
* 已存在 meta.json 的会话会被跳过(支持中断后重跑)。
|
||||
*/
|
||||
export function migrateLegacyManifest(dataRoot: string): void {
|
||||
const legacy = join(dataRoot, "manifest.json");
|
||||
if (!existsSync(legacy)) return;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(legacy, "utf-8")) as SessionManifest;
|
||||
const sessions = parsed?.sessions ?? {};
|
||||
let migrated = 0;
|
||||
|
||||
for (const [id, raw] of Object.entries(sessions)) {
|
||||
if (!isValidSessionId(id)) {
|
||||
console.warn(`[日志] 跳过非法会话 ID:${id}`);
|
||||
continue;
|
||||
}
|
||||
const mp = metaPath(dataRoot, id);
|
||||
if (existsSync(mp)) continue;
|
||||
try {
|
||||
const meta = SessionMetaSchema.parse(raw);
|
||||
mkdirSync(sessionDir(dataRoot, id), { recursive: true });
|
||||
writeFileAtomic(mp, JSON.stringify(meta, null, 2));
|
||||
migrated++;
|
||||
} catch (e) {
|
||||
console.error(`[日志] 跳过无法迁移的会话 ${id}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
renameSync(legacy, `${legacy}.migrated`);
|
||||
console.log(
|
||||
`[日志] 已迁移 ${migrated} 个会话到按会话元数据;旧清单重命名为 manifest.json.migrated`,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("[日志] 旧清单迁移失败,保留原文件:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 服务器工厂
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const $SESSIONS = "ttrpg/$SESSIONS";
|
||||
|
||||
function publishManifest(
|
||||
broker: import("aedes").Aedes,
|
||||
index: Record<string, SessionMeta>,
|
||||
): void {
|
||||
const manifest: SessionManifest = { sessions: index };
|
||||
broker.publish(
|
||||
{
|
||||
topic: $SESSIONS,
|
||||
payload: Buffer.from(JSON.stringify(manifest)),
|
||||
qos: 1,
|
||||
retain: true,
|
||||
cmd: "publish" as const,
|
||||
dup: false,
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
|
||||
export async function createJournalServer(
|
||||
contentDir: string,
|
||||
httpServer: HttpServer,
|
||||
): Promise<JournalServer> {
|
||||
const root = dataDir(contentDir);
|
||||
console.log(`[日志] 数据目录:${root}`);
|
||||
|
||||
// 迁移旧清单并扫描现有会话,构建内存索引(目录即事实来源)
|
||||
migrateLegacyManifest(root);
|
||||
const index = scanSessions(root);
|
||||
console.log(`[日志] 已加载 ${Object.keys(index).length} 个会话`);
|
||||
|
||||
// ---- MQTT 代理 ----
|
||||
const { Aedes: AedesFactory } = await import("aedes");
|
||||
// aedes 需要先 listen() 初始化持久化,再处理连接
|
||||
const broker = await AedesFactory.createBroker();
|
||||
|
||||
// ---- WebSocket 服务器(挂载到现有 HTTP 服务器上) ----
|
||||
// 参考 aedes 文档:给 WSS 传入 { server },使用 'connection' 事件
|
||||
const wss = new WebSocketServer({ server: httpServer });
|
||||
|
||||
wss.on("connection", (ws, req) => {
|
||||
const stream = createWebSocketStream(ws);
|
||||
broker.handle(stream, req);
|
||||
});
|
||||
|
||||
broker.on("client", (client: import("aedes").Client) => {
|
||||
console.log(`[日志] 客户端已连接:${client.id}`);
|
||||
});
|
||||
broker.on("clientDisconnect", (client: import("aedes").Client) => {
|
||||
console.log(`[日志] 客户端已断开:${client.id}`);
|
||||
});
|
||||
|
||||
// ---- 持久化(内部订阅,无需独立 MQTT 客户端) ----
|
||||
broker.subscribe(
|
||||
"ttrpg/+/stream",
|
||||
(packet, cb) => {
|
||||
const sessionId = extractSessionId(packet.topic);
|
||||
if (sessionId) {
|
||||
try {
|
||||
appendStream(root, sessionId, index, packet.payload.toString());
|
||||
} catch (e) {
|
||||
console.error(`[日志] 会话 ${sessionId} 流记录写入失败:`, e);
|
||||
}
|
||||
}
|
||||
cb();
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
|
||||
broker.subscribe(
|
||||
"ttrpg/+/meta",
|
||||
(packet, cb) => {
|
||||
const sessionId = extractSessionId(packet.topic);
|
||||
if (sessionId) {
|
||||
handleMetaChange(
|
||||
root,
|
||||
sessionId,
|
||||
broker,
|
||||
index,
|
||||
packet.payload.toString(),
|
||||
);
|
||||
}
|
||||
cb();
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
|
||||
// 启动时发布初始清单,让客户端能看到已有会话
|
||||
publishManifest(broker, index);
|
||||
|
||||
console.log("[日志] 持久化监听已启动");
|
||||
console.log("[日志] MQTT 代理已就绪:ws://<host>:<port>");
|
||||
|
||||
return {
|
||||
broker,
|
||||
wsServer: wss,
|
||||
async close() {
|
||||
console.log("[日志] 正在关闭...");
|
||||
await new Promise<void>((r) => wss.close(() => r()));
|
||||
await new Promise<void>((r) => broker.close(() => r()));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 工具函数
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function extractSessionId(topic: string): string | null {
|
||||
const parts = topic.split("/");
|
||||
if (parts.length < 3) return null;
|
||||
const id = parts[1];
|
||||
if (!isValidSessionId(id)) {
|
||||
console.warn(`[日志] 拒绝非法会话 ID:${JSON.stringify(id)}`);
|
||||
return null;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 会话生命周期管理
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 追加一条流记录。会话必须已在索引中(即存在合法 meta.json),
|
||||
* 否则丢弃——这样已删除的会话不会被迟到的消息「复活」。
|
||||
* 目录由元数据处理函数创建,此处不再 mkdir。
|
||||
*/
|
||||
export function appendStream(
|
||||
root: string,
|
||||
id: string,
|
||||
index: Record<string, SessionMeta>,
|
||||
line: string,
|
||||
): void {
|
||||
if (!isValidSessionId(id) || !index[id]) {
|
||||
console.warn(`[日志] 丢弃未知会话 ${id} 的流记录`);
|
||||
return;
|
||||
}
|
||||
appendFileSync(streamPath(root, id), line + "\n", "utf-8");
|
||||
}
|
||||
|
||||
export function handleMetaChange(
|
||||
root: string,
|
||||
sessionId: string,
|
||||
broker: import("aedes").Aedes,
|
||||
index: Record<string, SessionMeta>,
|
||||
rawPayload: string,
|
||||
): void {
|
||||
// The id reaches us straight from an MQTT topic level; never build a path
|
||||
// from it without validating, or `..` can escape the sessions directory.
|
||||
if (!isValidSessionId(sessionId)) {
|
||||
console.warn(
|
||||
`[日志] 拒绝非法会话 ID 的元数据操作:${JSON.stringify(sessionId)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = rawPayload.trim();
|
||||
|
||||
// 空载荷(或 JSON null)= 删除会话:递归删除目录,移除索引项。
|
||||
// 必须在 JSON.parse 之前判断:JSON.parse("") 会抛错,导致删除永远无法生效。
|
||||
if (payload === "" || payload === "null") {
|
||||
delete index[sessionId];
|
||||
rmSync(sessionDir(root, sessionId), { recursive: true, force: true });
|
||||
console.log(`[日志] 会话已删除:${sessionId}`);
|
||||
publishManifest(broker, index);
|
||||
return;
|
||||
}
|
||||
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(payload);
|
||||
} catch (e) {
|
||||
console.error(`[日志] 会话 ${sessionId} 元数据解析失败:`, e);
|
||||
return;
|
||||
}
|
||||
|
||||
// 用 schema 校验,避免写入无法回读的数据(例如非数组的 players),
|
||||
// 那会让下次启动扫描时把元数据隔离掉。
|
||||
const parsed = SessionMetaSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
console.error(`[日志] 会话 ${sessionId} 元数据无效,已忽略:`, parsed.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const meta = parsed.data;
|
||||
try {
|
||||
mkdirSync(sessionDir(root, sessionId), { recursive: true });
|
||||
writeFileAtomic(metaPath(root, sessionId), JSON.stringify(meta, null, 2));
|
||||
index[sessionId] = meta;
|
||||
console.log(`[日志] 会话已更新:${sessionId} (${meta.name})`);
|
||||
publishManifest(broker, index);
|
||||
} catch (e) {
|
||||
console.error(`[日志] 会话 ${sessionId} 元数据写入失败:`, e);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import { parse } from 'csv-parse/browser/esm/sync';
|
||||
import { stringify } from 'csv-stringify/browser/esm/sync';
|
||||
import yaml from 'js-yaml';
|
||||
import type { DeckFrontmatter } from '../frontmatter/read-frontmatter.js';
|
||||
import { readFileSync, writeFileSync, existsSync } from "fs";
|
||||
import { parse } from "csv-parse/browser/esm/sync";
|
||||
import { stringify } from "csv-stringify/browser/esm/sync";
|
||||
import type { DeckFrontmatter } from "../frontmatter/read-frontmatter.js";
|
||||
import {
|
||||
parseFrontMatter,
|
||||
serializeFrontMatter,
|
||||
} from "../frontmatter/shared.js";
|
||||
|
||||
/**
|
||||
* 卡牌数据
|
||||
@@ -23,7 +26,7 @@ export interface CardCrudParams {
|
||||
/**
|
||||
* 操作类型
|
||||
*/
|
||||
action: 'create' | 'read' | 'update' | 'delete';
|
||||
action: "create" | "read" | "update" | "delete";
|
||||
/**
|
||||
* 卡牌数据(单张或数组)
|
||||
*/
|
||||
@@ -44,41 +47,6 @@ export interface CardCrudResult {
|
||||
count?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 CSV 文件的 frontmatter
|
||||
*/
|
||||
function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } {
|
||||
const parts = content.trim().split(/(?:^|\n)---\s*\n/g);
|
||||
|
||||
if (parts.length !== 3 || parts[0] !== '') {
|
||||
return { csvContent: content };
|
||||
}
|
||||
|
||||
try {
|
||||
const frontmatterStr = parts[1].trim();
|
||||
const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined;
|
||||
const csvContent = parts.slice(2).join('---\n').trimStart();
|
||||
return { frontmatter, csvContent };
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse front matter:', error);
|
||||
return { csvContent: content };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 序列化 frontmatter 为 YAML 字符串
|
||||
*/
|
||||
function serializeFrontMatter(frontmatter: DeckFrontmatter): string {
|
||||
const yamlStr = yaml.dump(frontmatter, {
|
||||
indent: 2,
|
||||
lineWidth: -1,
|
||||
noRefs: true,
|
||||
quotingType: '"',
|
||||
forceQuotes: false
|
||||
});
|
||||
return `---\n${yamlStr}---\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载 CSV 数据(包含 frontmatter)
|
||||
*/
|
||||
@@ -87,19 +55,19 @@ function loadCSVWithFrontmatter(filePath: string): {
|
||||
records: CardData[];
|
||||
headers: string[];
|
||||
} {
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
const { frontmatter, csvContent } = parseFrontMatter(content);
|
||||
|
||||
const records = parse(csvContent, {
|
||||
columns: true,
|
||||
comment: '#',
|
||||
comment: "#",
|
||||
trim: true,
|
||||
skipEmptyLines: true
|
||||
skipEmptyLines: true,
|
||||
}) as CardData[];
|
||||
|
||||
// 获取表头
|
||||
const firstLine = csvContent.split('\n')[0];
|
||||
const headers = firstLine.split(',').map(h => h.trim());
|
||||
const firstLine = csvContent.split("\n")[0];
|
||||
const headers = firstLine.split(",").map((h) => h.trim());
|
||||
|
||||
return { frontmatter, records, headers };
|
||||
}
|
||||
@@ -111,30 +79,30 @@ function saveCSVWithFrontmatter(
|
||||
filePath: string,
|
||||
frontmatter: DeckFrontmatter | undefined,
|
||||
records: CardData[],
|
||||
headers?: string[]
|
||||
headers?: string[],
|
||||
): void {
|
||||
// 序列化 frontmatter
|
||||
const frontmatterStr = frontmatter ? serializeFrontMatter(frontmatter) : '';
|
||||
const frontmatterStr = frontmatter ? serializeFrontMatter(frontmatter) : "";
|
||||
|
||||
// 确定表头
|
||||
if (!headers || headers.length === 0) {
|
||||
// 从 records 和 frontmatter.fields 推断表头
|
||||
headers = ['label'];
|
||||
headers = ["label"];
|
||||
if (frontmatter?.fields && Array.isArray(frontmatter.fields)) {
|
||||
for (const field of frontmatter.fields) {
|
||||
if (field.name && typeof field.name === 'string') {
|
||||
if (field.name && typeof field.name === "string") {
|
||||
headers.push(field.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
headers.push('body');
|
||||
headers.push("body");
|
||||
}
|
||||
|
||||
// 确保所有 record 都有 headers 中的列
|
||||
for (const record of records) {
|
||||
for (const header of headers) {
|
||||
if (!(header in record)) {
|
||||
record[header] = '';
|
||||
record[header] = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -142,11 +110,11 @@ function saveCSVWithFrontmatter(
|
||||
// 序列化 CSV
|
||||
const csvContent = stringify(records, {
|
||||
header: true,
|
||||
columns: headers
|
||||
columns: headers,
|
||||
});
|
||||
|
||||
// 写入文件
|
||||
writeFileSync(filePath, frontmatterStr + csvContent, 'utf-8');
|
||||
writeFileSync(filePath, frontmatterStr + csvContent, "utf-8");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,10 +135,10 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
|
||||
const { csv_file, action, cards, label } = params;
|
||||
|
||||
// 检查文件是否存在(create 操作可以不存在)
|
||||
if (action !== 'create' && !existsSync(csv_file)) {
|
||||
if (action !== "create" && !existsSync(csv_file)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `文件不存在:${csv_file}`
|
||||
message: `文件不存在:${csv_file}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -189,8 +157,8 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
|
||||
|
||||
// 执行操作
|
||||
switch (action) {
|
||||
case 'create': {
|
||||
const newCards = Array.isArray(cards) ? cards : (cards ? [cards] : []);
|
||||
case "create": {
|
||||
const newCards = Array.isArray(cards) ? cards : cards ? [cards] : [];
|
||||
for (const card of newCards) {
|
||||
if (!card.label) {
|
||||
card.label = generateNextLabel(records);
|
||||
@@ -202,16 +170,22 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
|
||||
success: true,
|
||||
message: `成功创建 ${newCards.length} 张卡牌`,
|
||||
cards: newCards,
|
||||
count: newCards.length
|
||||
count: newCards.length,
|
||||
};
|
||||
}
|
||||
|
||||
case 'read': {
|
||||
const labelsToRead = Array.isArray(label) ? label : (label ? [label] : null);
|
||||
case "read": {
|
||||
const labelsToRead = Array.isArray(label)
|
||||
? label
|
||||
: label
|
||||
? [label]
|
||||
: null;
|
||||
let resultCards: CardData[];
|
||||
|
||||
if (labelsToRead && labelsToRead.length > 0) {
|
||||
resultCards = records.filter(r => labelsToRead.includes(r.label || ''));
|
||||
resultCards = records.filter((r) =>
|
||||
labelsToRead.includes(r.label || ""),
|
||||
);
|
||||
} else {
|
||||
resultCards = records;
|
||||
}
|
||||
@@ -220,20 +194,26 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
|
||||
success: true,
|
||||
message: `成功读取 ${resultCards.length} 张卡牌`,
|
||||
cards: resultCards,
|
||||
count: resultCards.length
|
||||
count: resultCards.length,
|
||||
};
|
||||
}
|
||||
|
||||
case 'update': {
|
||||
const labelsToUpdate = Array.isArray(label) ? label : (label ? [label] : null);
|
||||
const updateCards = Array.isArray(cards) ? cards : (cards ? [cards] : []);
|
||||
case "update": {
|
||||
const labelsToUpdate = Array.isArray(label)
|
||||
? label
|
||||
: label
|
||||
? [label]
|
||||
: null;
|
||||
const updateCards = Array.isArray(cards) ? cards : cards ? [cards] : [];
|
||||
let updatedCount = 0;
|
||||
|
||||
if (labelsToUpdate && labelsToUpdate.length > 0) {
|
||||
// 按 label 更新
|
||||
for (const updateCard of updateCards) {
|
||||
const targetLabel = updateCard.label || labelsToUpdate[updatedCount % labelsToUpdate.length];
|
||||
const index = records.findIndex(r => r.label === targetLabel);
|
||||
const targetLabel =
|
||||
updateCard.label ||
|
||||
labelsToUpdate[updatedCount % labelsToUpdate.length];
|
||||
const index = records.findIndex((r) => r.label === targetLabel);
|
||||
if (index !== -1) {
|
||||
records[index] = { ...records[index], ...updateCard };
|
||||
updatedCount++;
|
||||
@@ -243,7 +223,9 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
|
||||
// 按 cards 中的 label 更新
|
||||
for (const updateCard of updateCards) {
|
||||
if (updateCard.label) {
|
||||
const index = records.findIndex(r => r.label === updateCard.label);
|
||||
const index = records.findIndex(
|
||||
(r) => r.label === updateCard.label,
|
||||
);
|
||||
if (index !== -1) {
|
||||
records[index] = { ...records[index], ...updateCard };
|
||||
updatedCount++;
|
||||
@@ -257,23 +239,29 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
|
||||
success: true,
|
||||
message: `成功更新 ${updatedCount} 张卡牌`,
|
||||
cards: updateCards,
|
||||
count: updatedCount
|
||||
count: updatedCount,
|
||||
};
|
||||
}
|
||||
|
||||
case 'delete': {
|
||||
const labelsToDelete = Array.isArray(label) ? label : (label ? [label] : null);
|
||||
case "delete": {
|
||||
const labelsToDelete = Array.isArray(label)
|
||||
? label
|
||||
: label
|
||||
? [label]
|
||||
: null;
|
||||
let deletedCount = 0;
|
||||
|
||||
if (labelsToDelete && labelsToDelete.length > 0) {
|
||||
const beforeCount = records.length;
|
||||
records = records.filter(r => !labelsToDelete.includes(r.label || ''));
|
||||
records = records.filter(
|
||||
(r) => !labelsToDelete.includes(r.label || ""),
|
||||
);
|
||||
deletedCount = beforeCount - records.length;
|
||||
} else if (cards) {
|
||||
const cardsToDelete = Array.isArray(cards) ? cards : [cards];
|
||||
const beforeCount = records.length;
|
||||
records = records.filter(r =>
|
||||
!cardsToDelete.some(c => c.label && r.label === c.label)
|
||||
records = records.filter(
|
||||
(r) => !cardsToDelete.some((c) => c.label && r.label === c.label),
|
||||
);
|
||||
deletedCount = beforeCount - records.length;
|
||||
}
|
||||
@@ -282,20 +270,20 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
|
||||
return {
|
||||
success: true,
|
||||
message: `成功删除 ${deletedCount} 张卡牌`,
|
||||
count: deletedCount
|
||||
count: deletedCount,
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
success: false,
|
||||
message: `未知操作:${action}`
|
||||
message: `未知操作:${action}`,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `操作失败:${error instanceof Error ? error.message : '未知错误'}`
|
||||
message: `操作失败:${error instanceof Error ? error.message : "未知错误"}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Shared deck utility functions — used by generate-card-deck and preview-deck.
|
||||
*/
|
||||
|
||||
import { dirname, join, basename, extname, relative } from "path";
|
||||
import type {
|
||||
DeckConfig,
|
||||
DeckFrontmatter,
|
||||
} from "./frontmatter/read-frontmatter.js";
|
||||
import { parseFrontMatter } from "./frontmatter/shared.js";
|
||||
import { readFileSync } from "fs";
|
||||
|
||||
/**
|
||||
* Build a `:md-deck[path]{attrs}` directive string from a CSV path and config.
|
||||
*/
|
||||
export function buildDeckComponent(
|
||||
csvPath: string,
|
||||
config?: DeckConfig,
|
||||
): string {
|
||||
const parts = [`:md-deck[${csvPath}]`];
|
||||
const attrs: string[] = [];
|
||||
|
||||
if (config?.size) {
|
||||
attrs.push(`size="${config.size}"`);
|
||||
}
|
||||
|
||||
if (config?.grid) {
|
||||
attrs.push(`grid="${config.grid}"`);
|
||||
}
|
||||
|
||||
if (config?.bleed !== undefined && config.bleed !== 1) {
|
||||
attrs.push(`bleed="${config.bleed}"`);
|
||||
}
|
||||
|
||||
if (config?.padding !== undefined && config.padding !== 2) {
|
||||
attrs.push(`padding="${config.padding}"`);
|
||||
}
|
||||
|
||||
if (config?.shape && config.shape !== "rectangle") {
|
||||
attrs.push(`shape="${config.shape}"`);
|
||||
}
|
||||
|
||||
if (config?.layers) {
|
||||
attrs.push(`layers="${config.layers}"`);
|
||||
}
|
||||
|
||||
if (config?.back_layers) {
|
||||
attrs.push(`back-layers="${config.back_layers}"`);
|
||||
}
|
||||
|
||||
if (attrs.length > 0) {
|
||||
parts.push(`{${attrs.join(" ")}}`);
|
||||
}
|
||||
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a human-readable title from a CSV filename.
|
||||
* e.g. "my-deck.csv" → "My Deck"
|
||||
*/
|
||||
export function titleFromCsvPath(csvPath: string): string {
|
||||
const name = basename(csvPath, extname(csvPath));
|
||||
return name.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the relative path from a markdown file to its CSV.
|
||||
* Handles the case where they are in the same or different directories.
|
||||
*/
|
||||
export function relativeCsvPath(
|
||||
mdFilePath: string,
|
||||
csvFilePath: string,
|
||||
): string {
|
||||
const mdDir = dirname(mdFilePath);
|
||||
const rel = relative(mdDir, csvFilePath);
|
||||
return rel.startsWith(".") ? rel : `./${rel}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the default markdown file path for a CSV.
|
||||
* e.g. "foo/bar.csv" → "foo/bar.md"
|
||||
*/
|
||||
export function defaultMdPath(csvFilePath: string): string {
|
||||
const dir = dirname(csvFilePath);
|
||||
const name = basename(csvFilePath, extname(csvFilePath));
|
||||
return join(dir, `${name}.md`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read DeckConfig from a CSV file's frontmatter, if present.
|
||||
*/
|
||||
export function readDeckConfig(csvFilePath: string): DeckConfig | undefined {
|
||||
try {
|
||||
const content = readFileSync(csvFilePath, "utf-8");
|
||||
const { frontmatter } = parseFrontMatter(content);
|
||||
return frontmatter?.deck;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the standard markdown content for a deck preview file.
|
||||
*/
|
||||
export function generateDeckPreviewMd(
|
||||
deckTitle: string,
|
||||
deckComponent: string,
|
||||
description?: string,
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(`# ${deckTitle}`);
|
||||
lines.push("");
|
||||
|
||||
if (description) {
|
||||
lines.push(description);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push("## 卡牌预览");
|
||||
lines.push("");
|
||||
lines.push(deckComponent);
|
||||
lines.push("");
|
||||
|
||||
lines.push("## 使用说明");
|
||||
lines.push("");
|
||||
lines.push("- 点击卡牌可以查看详情");
|
||||
lines.push("- 使用右上角的按钮可以随机抽取卡牌");
|
||||
lines.push("- 可以通过编辑面板调整卡牌样式和布局");
|
||||
lines.push("");
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import { dirname, join, basename, extname } from 'path';
|
||||
import yaml from 'js-yaml';
|
||||
import type { DeckFrontmatter, DeckConfig } from './frontmatter/read-frontmatter.js';
|
||||
|
||||
/**
|
||||
* 确保 Deck 预览文件的参数
|
||||
*/
|
||||
export interface EnsureDeckPreviewParams {
|
||||
/**
|
||||
* CSV 文件路径
|
||||
*/
|
||||
csv_file: string;
|
||||
/**
|
||||
* Markdown 文件路径(可选,默认与 CSV 同名)
|
||||
*/
|
||||
md_file?: string;
|
||||
/**
|
||||
* 标题(可选,默认从 CSV 文件名推断)
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* 描述(可选)
|
||||
*/
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保 Deck 预览文件的结果
|
||||
*/
|
||||
export interface EnsureDeckPreviewResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
md_file?: string;
|
||||
created?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 CSV 文件的 frontmatter
|
||||
*/
|
||||
function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } {
|
||||
const parts = content.trim().split(/(?:^|\n)---\s*\n/g);
|
||||
|
||||
if (parts.length !== 3 || parts[0] !== '') {
|
||||
return { csvContent: content };
|
||||
}
|
||||
|
||||
try {
|
||||
const frontmatterStr = parts[1].trim();
|
||||
const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined;
|
||||
const csvContent = parts.slice(2).join('---\n').trimStart();
|
||||
return { frontmatter, csvContent };
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse front matter:', error);
|
||||
return { csvContent: content };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 md-deck 组件代码
|
||||
*/
|
||||
function buildDeckComponent(csvPath: string, config?: DeckConfig): string {
|
||||
const parts = [`:md-deck[${csvPath}]`];
|
||||
const attrs: string[] = [];
|
||||
|
||||
if (config?.size) {
|
||||
attrs.push(`size="${config.size}"`);
|
||||
}
|
||||
|
||||
if (config?.grid) {
|
||||
attrs.push(`grid="${config.grid}"`);
|
||||
}
|
||||
|
||||
if (config?.bleed !== undefined && config.bleed !== 1) {
|
||||
attrs.push(`bleed="${config.bleed}"`);
|
||||
}
|
||||
|
||||
if (config?.padding !== undefined && config.padding !== 2) {
|
||||
attrs.push(`padding="${config.padding}"`);
|
||||
}
|
||||
|
||||
if (config?.shape && config.shape !== 'rectangle') {
|
||||
attrs.push(`shape="${config.shape}"`);
|
||||
}
|
||||
|
||||
if (config?.layers) {
|
||||
attrs.push(`layers="${config.layers}"`);
|
||||
}
|
||||
|
||||
if (config?.back_layers) {
|
||||
attrs.push(`back-layers="${config.back_layers}"`);
|
||||
}
|
||||
|
||||
if (attrs.length > 0) {
|
||||
parts.push(`{${attrs.join(' ')}}`);
|
||||
}
|
||||
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保 Markdown 预览文件存在
|
||||
*/
|
||||
export function ensureDeckPreview(params: EnsureDeckPreviewParams): EnsureDeckPreviewResult {
|
||||
const { csv_file, md_file, title, description } = params;
|
||||
|
||||
// 检查 CSV 文件是否存在
|
||||
if (!existsSync(csv_file)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `CSV 文件不存在:${csv_file}`
|
||||
};
|
||||
}
|
||||
|
||||
// 确定 Markdown 文件路径
|
||||
let mdFilePath = md_file;
|
||||
if (!mdFilePath) {
|
||||
// 使用与 CSV 相同的路径和文件名,只是扩展名不同
|
||||
const dir = dirname(csv_file);
|
||||
const name = basename(csv_file, extname(csv_file));
|
||||
mdFilePath = join(dir, `${name}.md`);
|
||||
}
|
||||
|
||||
const created = !existsSync(mdFilePath);
|
||||
|
||||
// 如果文件已存在,检查是否已有 md-deck 组件
|
||||
if (!created) {
|
||||
try {
|
||||
const existingContent = readFileSync(mdFilePath, 'utf-8');
|
||||
// 如果已有 md-deck 组件引用该 CSV,直接返回
|
||||
if (existingContent.includes(`:md-deck[${csv_file}]`) ||
|
||||
existingContent.includes(`:md-deck[./${basename(csv_file)}]`)) {
|
||||
return {
|
||||
success: true,
|
||||
message: `预览文件 ${mdFilePath} 已存在`,
|
||||
md_file: mdFilePath,
|
||||
created: false
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
// 读取失败,继续创建
|
||||
}
|
||||
}
|
||||
|
||||
// 读取 CSV 的 frontmatter 获取配置
|
||||
let deckConfig: DeckConfig | undefined;
|
||||
try {
|
||||
const csvContent = readFileSync(csv_file, 'utf-8');
|
||||
const { frontmatter } = parseFrontMatter(csvContent);
|
||||
deckConfig = frontmatter?.deck;
|
||||
} catch (error) {
|
||||
// 忽略错误,使用默认配置
|
||||
}
|
||||
|
||||
// 确定标题
|
||||
let deckTitle = title;
|
||||
if (!deckTitle) {
|
||||
// 从 CSV 文件名推断
|
||||
const name = basename(csv_file, extname(csv_file));
|
||||
deckTitle = name.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
}
|
||||
|
||||
// 构建 md-deck 组件代码(使用相对路径)
|
||||
const mdDir = dirname(mdFilePath);
|
||||
const csvDir = dirname(csv_file);
|
||||
let relativeCsvPath: string;
|
||||
|
||||
if (mdDir === csvDir) {
|
||||
relativeCsvPath = `./${basename(csv_file)}`;
|
||||
} else {
|
||||
relativeCsvPath = `./${basename(csv_file)}`;
|
||||
}
|
||||
|
||||
const deckComponent = buildDeckComponent(relativeCsvPath, deckConfig);
|
||||
|
||||
// 生成 Markdown 内容
|
||||
const mdLines: string[] = [];
|
||||
|
||||
mdLines.push(`# ${deckTitle}`);
|
||||
mdLines.push('');
|
||||
|
||||
if (description) {
|
||||
mdLines.push(description);
|
||||
mdLines.push('');
|
||||
}
|
||||
|
||||
mdLines.push('## 卡牌预览');
|
||||
mdLines.push('');
|
||||
mdLines.push(deckComponent);
|
||||
mdLines.push('');
|
||||
|
||||
mdLines.push('## 使用说明');
|
||||
mdLines.push('');
|
||||
mdLines.push('- 点击卡牌可以查看详情');
|
||||
mdLines.push('- 使用右上角的按钮可以随机抽取卡牌');
|
||||
mdLines.push('- 可以通过编辑面板调整卡牌样式和布局');
|
||||
mdLines.push('');
|
||||
|
||||
// 写入文件
|
||||
try {
|
||||
writeFileSync(mdFilePath, mdLines.join('\n'), 'utf-8');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: created
|
||||
? `创建预览文件 ${mdFilePath}`
|
||||
: `更新预览文件 ${mdFilePath}`,
|
||||
md_file: mdFilePath,
|
||||
created
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `写入失败:${error instanceof Error ? error.message : '未知错误'}`
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import yaml from 'js-yaml';
|
||||
import { readFileSync, existsSync } from "fs";
|
||||
import { parseFrontMatter } from "./shared.js";
|
||||
|
||||
/**
|
||||
* 读取 CSV frontmatter 的参数
|
||||
@@ -52,7 +52,7 @@ export interface CardFieldStyle {
|
||||
/**
|
||||
* 朝向:上侧朝向 "n" | "w" | "s" | "e"(北/西/南/东)
|
||||
*/
|
||||
up?: 'n' | 'w' | 's' | 'e';
|
||||
up?: "n" | "w" | "s" | "e";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,7 +74,7 @@ export interface DeckConfig {
|
||||
grid?: string;
|
||||
bleed?: number;
|
||||
padding?: number;
|
||||
shape?: 'rectangle' | 'circle' | 'hex' | 'diamond';
|
||||
shape?: "rectangle" | "circle" | "hex" | "diamond";
|
||||
layers?: string;
|
||||
back_layers?: string;
|
||||
[key: string]: unknown;
|
||||
@@ -89,45 +89,25 @@ export interface ReadFrontmatterResult {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 CSV 文件的 frontmatter
|
||||
*/
|
||||
function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } {
|
||||
const parts = content.trim().split(/(?:^|\n)---\s*\n/g);
|
||||
|
||||
// 至少需要三个部分:空字符串、front matter、CSV 内容
|
||||
if (parts.length !== 3 || parts[0] !== '') {
|
||||
return { csvContent: content };
|
||||
}
|
||||
|
||||
try {
|
||||
const frontmatterStr = parts[1].trim();
|
||||
const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined;
|
||||
const csvContent = parts.slice(2).join('---\n').trimStart();
|
||||
return { frontmatter, csvContent };
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse front matter:', error);
|
||||
return { csvContent: content };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 CSV 文件的 frontmatter
|
||||
*/
|
||||
export function readFrontmatter(params: ReadFrontmatterParams): ReadFrontmatterResult {
|
||||
export function readFrontmatter(
|
||||
params: ReadFrontmatterParams,
|
||||
): ReadFrontmatterResult {
|
||||
const { csv_file } = params;
|
||||
|
||||
// 检查文件是否存在
|
||||
if (!existsSync(csv_file)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `文件不存在:${csv_file}`
|
||||
message: `文件不存在:${csv_file}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// 读取文件内容
|
||||
const content = readFileSync(csv_file, 'utf-8');
|
||||
const content = readFileSync(csv_file, "utf-8");
|
||||
|
||||
// 解析 frontmatter
|
||||
const { frontmatter } = parseFrontMatter(content);
|
||||
@@ -136,19 +116,19 @@ export function readFrontmatter(params: ReadFrontmatterParams): ReadFrontmatterR
|
||||
return {
|
||||
success: true,
|
||||
frontmatter: {},
|
||||
message: `文件 ${csv_file} 没有 frontmatter,返回空对象`
|
||||
message: `文件 ${csv_file} 没有 frontmatter,返回空对象`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
frontmatter,
|
||||
message: `成功读取 ${csv_file} 的 frontmatter`
|
||||
message: `成功读取 ${csv_file} 的 frontmatter`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `读取失败:${error instanceof Error ? error.message : '未知错误'}`
|
||||
message: `读取失败:${error instanceof Error ? error.message : "未知错误"}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Shared frontmatter utilities — used by read-frontmatter, write-frontmatter,
|
||||
* card-crud, preview-deck, and generate-card-deck.
|
||||
*/
|
||||
|
||||
import yaml from "js-yaml";
|
||||
import type { DeckFrontmatter } from "./read-frontmatter.js";
|
||||
|
||||
/**
|
||||
* Parse YAML frontmatter from a CSV file's raw content.
|
||||
* Expects `---\n...\n---\n` delimited frontmatter at the top of the file.
|
||||
*/
|
||||
export function parseFrontMatter(content: string): {
|
||||
frontmatter?: DeckFrontmatter;
|
||||
csvContent: string;
|
||||
} {
|
||||
const parts = content.trim().split(/(?:^|\n)---\s*\n/g);
|
||||
|
||||
if (parts.length !== 3 || parts[0] !== "") {
|
||||
return { csvContent: content };
|
||||
}
|
||||
|
||||
try {
|
||||
const frontmatterStr = parts[1].trim();
|
||||
const frontmatter = yaml.load(frontmatterStr) as
|
||||
| DeckFrontmatter
|
||||
| undefined;
|
||||
const csvContent = parts.slice(2).join("---\n").trimStart();
|
||||
return { frontmatter, csvContent };
|
||||
} catch (error) {
|
||||
console.warn("Failed to parse front matter:", error);
|
||||
return { csvContent: content };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a DeckFrontmatter object to YAML frontmatter string.
|
||||
*/
|
||||
export function serializeFrontMatter(frontmatter: DeckFrontmatter): string {
|
||||
const yamlStr = yaml.dump(frontmatter, {
|
||||
indent: 2,
|
||||
lineWidth: -1,
|
||||
noRefs: true,
|
||||
quotingType: '"',
|
||||
forceQuotes: false,
|
||||
});
|
||||
return `---\n${yamlStr}---\n`;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import yaml from 'js-yaml';
|
||||
import type { DeckFrontmatter } from './read-frontmatter.js';
|
||||
import { readFileSync, writeFileSync, existsSync } from "fs";
|
||||
import type { DeckFrontmatter } from "./read-frontmatter.js";
|
||||
import { parseFrontMatter, serializeFrontMatter } from "./shared.js";
|
||||
|
||||
/**
|
||||
* 写入 CSV frontmatter 的参数
|
||||
@@ -29,70 +29,37 @@ export interface WriteFrontmatterResult {
|
||||
csv_file?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 CSV 文件的 frontmatter
|
||||
*/
|
||||
function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } {
|
||||
const parts = content.trim().split(/(?:^|\n)---\s*\n/g);
|
||||
|
||||
// 至少需要三个部分:空字符串、front matter、CSV 内容
|
||||
if (parts.length !== 3 || parts[0] !== '') {
|
||||
return { csvContent: content };
|
||||
}
|
||||
|
||||
try {
|
||||
const frontmatterStr = parts[1].trim();
|
||||
const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined;
|
||||
const csvContent = parts.slice(2).join('---\n').trimStart();
|
||||
return { frontmatter, csvContent };
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse front matter:', error);
|
||||
return { csvContent: content };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 序列化 frontmatter 为 YAML 字符串
|
||||
*/
|
||||
function serializeFrontMatter(frontmatter: DeckFrontmatter): string {
|
||||
const yamlStr = yaml.dump(frontmatter, {
|
||||
indent: 2,
|
||||
lineWidth: -1, // 不限制行宽
|
||||
noRefs: true, // 不使用引用
|
||||
quotingType: '"',
|
||||
forceQuotes: false
|
||||
});
|
||||
return `---\n${yamlStr}---\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入/更新 CSV 文件的 frontmatter
|
||||
*/
|
||||
export function writeFrontmatter(params: WriteFrontmatterParams): WriteFrontmatterResult {
|
||||
export function writeFrontmatter(
|
||||
params: WriteFrontmatterParams,
|
||||
): WriteFrontmatterResult {
|
||||
const { csv_file, frontmatter, merge = true } = params;
|
||||
|
||||
let csvContent = '';
|
||||
let csvContent = "";
|
||||
let existingFrontmatter: DeckFrontmatter | undefined;
|
||||
|
||||
// 如果文件存在且需要合并,先读取现有内容
|
||||
if (merge && existsSync(csv_file)) {
|
||||
try {
|
||||
const content = readFileSync(csv_file, 'utf-8');
|
||||
const content = readFileSync(csv_file, "utf-8");
|
||||
const result = parseFrontMatter(content);
|
||||
existingFrontmatter = result.frontmatter;
|
||||
csvContent = result.csvContent;
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `读取现有文件失败:${error instanceof Error ? error.message : '未知错误'}`
|
||||
message: `读取现有文件失败:${error instanceof Error ? error.message : "未知错误"}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 合并或替换 frontmatter
|
||||
const finalFrontmatter = merge && existingFrontmatter
|
||||
? { ...existingFrontmatter, ...frontmatter }
|
||||
: frontmatter;
|
||||
const finalFrontmatter =
|
||||
merge && existingFrontmatter
|
||||
? { ...existingFrontmatter, ...frontmatter }
|
||||
: frontmatter;
|
||||
|
||||
// 序列化 frontmatter
|
||||
const frontmatterStr = serializeFrontMatter(finalFrontmatter);
|
||||
@@ -100,32 +67,32 @@ export function writeFrontmatter(params: WriteFrontmatterParams): WriteFrontmatt
|
||||
// 如果文件不存在或没有 CSV 内容,创建一个空的 CSV 内容
|
||||
if (!csvContent.trim()) {
|
||||
// 从 frontmatter 推断 CSV 表头
|
||||
const headers = ['label'];
|
||||
const headers = ["label"];
|
||||
if (finalFrontmatter.fields && Array.isArray(finalFrontmatter.fields)) {
|
||||
for (const field of finalFrontmatter.fields) {
|
||||
if (field.name && typeof field.name === 'string') {
|
||||
if (field.name && typeof field.name === "string") {
|
||||
headers.push(field.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
headers.push('body');
|
||||
csvContent = headers.join(',') + '\n';
|
||||
headers.push("body");
|
||||
csvContent = headers.join(",") + "\n";
|
||||
}
|
||||
|
||||
// 写入文件
|
||||
try {
|
||||
const fullContent = frontmatterStr + csvContent;
|
||||
writeFileSync(csv_file, fullContent, 'utf-8');
|
||||
writeFileSync(csv_file, fullContent, "utf-8");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `成功写入 frontmatter 到 ${csv_file}`,
|
||||
csv_file
|
||||
csv_file,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `写入失败:${error instanceof Error ? error.message : '未知错误'}`
|
||||
message: `写入失败:${error instanceof Error ? error.message : "未知错误"}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { writeFileSync, mkdirSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { writeFileSync, mkdirSync, existsSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { buildDeckComponent, generateDeckPreviewMd } from "./deck-utils.js";
|
||||
import type { DeckConfig } from "./frontmatter/read-frontmatter.js";
|
||||
|
||||
/**
|
||||
* 字段样式配置
|
||||
@@ -24,7 +26,7 @@ export interface CardFieldStyle {
|
||||
/**
|
||||
* 朝向:上侧朝向 "n" | "w" | "s" | "e"(北/西/南/东)
|
||||
*/
|
||||
up?: 'n' | 'w' | 's' | 'e';
|
||||
up?: "n" | "w" | "s" | "e";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,18 +47,7 @@ export interface CardTemplate {
|
||||
examples?: Record<string, string>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deck 配置
|
||||
*/
|
||||
export interface DeckConfig {
|
||||
size?: string;
|
||||
grid?: string;
|
||||
bleed?: number;
|
||||
padding?: number;
|
||||
shape?: 'rectangle' | 'circle' | 'hex' | 'diamond';
|
||||
layers?: string;
|
||||
backLayers?: string;
|
||||
}
|
||||
export type { DeckConfig };
|
||||
|
||||
/**
|
||||
* 生成卡牌组的参数
|
||||
@@ -73,31 +64,31 @@ export interface GenerateCardDeckParams {
|
||||
/**
|
||||
* 生成卡牌数据 CSV
|
||||
*/
|
||||
function generateCardCSV(
|
||||
template: CardTemplate,
|
||||
cardCount: number
|
||||
): string {
|
||||
function generateCardCSV(template: CardTemplate, cardCount: number): string {
|
||||
const fields = template.fields;
|
||||
|
||||
|
||||
// 构建 CSV 表头
|
||||
const headers = ['label', ...fields.map(f => f.name), 'body'];
|
||||
|
||||
const headers = ["label", ...fields.map((f) => f.name), "body"];
|
||||
|
||||
// 生成示例数据
|
||||
const rows: string[][] = [];
|
||||
const examples = template.examples || [];
|
||||
|
||||
|
||||
for (let i = 0; i < cardCount; i++) {
|
||||
const row: string[] = [(i + 1).toString()];
|
||||
|
||||
|
||||
// 为每个字段生成值
|
||||
for (const field of fields) {
|
||||
let value = '';
|
||||
|
||||
let value = "";
|
||||
|
||||
if (examples.length > 0) {
|
||||
// 从示例中循环取值
|
||||
const exampleIndex = i % examples.length;
|
||||
const example = examples[exampleIndex];
|
||||
value = example[field.name] || field.examples?.[i % (field.examples?.length || 1)] || '';
|
||||
value =
|
||||
example[field.name] ||
|
||||
field.examples?.[i % (field.examples?.length || 1)] ||
|
||||
"";
|
||||
} else if (field.examples && field.examples.length > 0) {
|
||||
// 从字段的示例中取值
|
||||
value = field.examples[i % field.examples.length];
|
||||
@@ -105,131 +96,49 @@ function generateCardCSV(
|
||||
// 默认占位符
|
||||
value = `{{${field.name}_${i + 1}}}`;
|
||||
}
|
||||
|
||||
|
||||
row.push(value);
|
||||
}
|
||||
|
||||
|
||||
// body 列使用模板语法
|
||||
const bodyParts: string[] = [];
|
||||
for (const field of fields) {
|
||||
bodyParts.push(`**${field.name}:** {{${field.name}}}`);
|
||||
}
|
||||
row.push(bodyParts.join('\n\n'));
|
||||
|
||||
row.push(bodyParts.join("\n\n"));
|
||||
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
|
||||
// 组合 CSV 内容
|
||||
const csvLines = [headers.join(',')];
|
||||
const csvLines = [headers.join(",")];
|
||||
for (const row of rows) {
|
||||
csvLines.push(row.map(cell => {
|
||||
// 处理包含逗号或换行的单元格
|
||||
if (cell.includes(',') || cell.includes('\n') || cell.includes('"')) {
|
||||
return `"${cell.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return cell;
|
||||
}).join(','));
|
||||
csvLines.push(
|
||||
row
|
||||
.map((cell) => {
|
||||
// 处理包含逗号或换行的单元格
|
||||
if (cell.includes(",") || cell.includes("\n") || cell.includes('"')) {
|
||||
return `"${cell.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return cell;
|
||||
})
|
||||
.join(","),
|
||||
);
|
||||
}
|
||||
|
||||
return csvLines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成卡牌介绍的 Markdown 文件
|
||||
*/
|
||||
function generateDeckMarkdown(
|
||||
deckName: string,
|
||||
csvFileName: string,
|
||||
deckConfig: DeckConfig,
|
||||
description?: string
|
||||
): string {
|
||||
const mdLines: string[] = [];
|
||||
|
||||
// 标题
|
||||
mdLines.push(`# ${deckName}`);
|
||||
mdLines.push('');
|
||||
|
||||
// 描述
|
||||
if (description) {
|
||||
mdLines.push(description);
|
||||
mdLines.push('');
|
||||
}
|
||||
|
||||
// 卡牌预览组件
|
||||
mdLines.push('## 卡牌预览');
|
||||
mdLines.push('');
|
||||
|
||||
// 构建 :md-deck 组件代码
|
||||
const deckComponent = buildDeckComponent(csvFileName, deckConfig);
|
||||
mdLines.push(deckComponent);
|
||||
mdLines.push('');
|
||||
|
||||
// 使用说明
|
||||
mdLines.push('## 使用说明');
|
||||
mdLines.push('');
|
||||
mdLines.push('- 点击卡牌可以查看详情');
|
||||
mdLines.push('- 使用右上角的按钮可以随机抽取卡牌');
|
||||
mdLines.push('- 可以通过编辑面板调整卡牌样式和布局');
|
||||
mdLines.push('');
|
||||
|
||||
return mdLines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 :md-deck 组件代码
|
||||
*/
|
||||
function buildDeckComponent(
|
||||
csvFileName: string,
|
||||
config: DeckConfig
|
||||
): string {
|
||||
const parts = [`:md-deck[${csvFileName}]`];
|
||||
const attrs: string[] = [];
|
||||
|
||||
if (config.size) {
|
||||
attrs.push(`size="${config.size}"`);
|
||||
}
|
||||
|
||||
if (config.grid) {
|
||||
attrs.push(`grid="${config.grid}"`);
|
||||
}
|
||||
|
||||
if (config.bleed !== undefined && config.bleed !== 1) {
|
||||
attrs.push(`bleed="${config.bleed}"`);
|
||||
}
|
||||
|
||||
if (config.padding !== undefined && config.padding !== 2) {
|
||||
attrs.push(`padding="${config.padding}"`);
|
||||
}
|
||||
|
||||
if (config.shape && config.shape !== 'rectangle') {
|
||||
attrs.push(`shape="${config.shape}"`);
|
||||
}
|
||||
|
||||
if (config.layers) {
|
||||
attrs.push(`layers="${config.layers}"`);
|
||||
}
|
||||
|
||||
if (config.backLayers) {
|
||||
attrs.push(`back-layers="${config.backLayers}"`);
|
||||
}
|
||||
|
||||
if (attrs.length > 0) {
|
||||
parts.push(`{${attrs.join(' ')}}`);
|
||||
}
|
||||
|
||||
return parts.join('');
|
||||
return csvLines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动生成图层配置
|
||||
*/
|
||||
function autoGenerateLayers(fields: CardField[]): string {
|
||||
if (fields.length === 0) return '';
|
||||
|
||||
if (fields.length === 0) return "";
|
||||
|
||||
const layers: string[] = [];
|
||||
const totalHeight = 8;
|
||||
const heightPerField = Math.floor((totalHeight - 2) / fields.length);
|
||||
|
||||
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
const field = fields[i];
|
||||
const y1 = 2 + i * heightPerField;
|
||||
@@ -237,8 +146,8 @@ function autoGenerateLayers(fields: CardField[]): string {
|
||||
const fontSize = Math.min(12, Math.floor(80 / fields.length));
|
||||
layers.push(`${field.name}:1,${y1}-${y2},${fontSize}`);
|
||||
}
|
||||
|
||||
return layers.join(' ');
|
||||
|
||||
return layers.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -256,61 +165,66 @@ export function generateCardDeck(params: GenerateCardDeckParams): {
|
||||
card_count = 10,
|
||||
card_template,
|
||||
deck_config = {},
|
||||
description
|
||||
description,
|
||||
} = params;
|
||||
|
||||
|
||||
// 确保输出目录存在
|
||||
if (!existsSync(output_dir)) {
|
||||
mkdirSync(output_dir, { recursive: true });
|
||||
}
|
||||
|
||||
|
||||
// 生成文件名
|
||||
const safeName = deck_name.toLowerCase().replace(/\s+/g, '-');
|
||||
const safeName = deck_name.toLowerCase().replace(/\s+/g, "-");
|
||||
const csvFileName = `${safeName}.csv`;
|
||||
const mdFileName = `${safeName}.md`;
|
||||
|
||||
|
||||
// 创建默认模板(如果没有提供)
|
||||
const template: CardTemplate = card_template || {
|
||||
fields: [
|
||||
{ name: 'name', description: '卡牌名称', examples: ['示例卡牌 1', '示例卡牌 2'] },
|
||||
{ name: 'type', description: '卡牌类型', examples: ['物品', '法术'] },
|
||||
{ name: 'cost', description: '费用', examples: ['1', '2'] },
|
||||
{ name: 'description', description: '效果描述', examples: ['这是一个效果描述', '这是另一个效果'] }
|
||||
]
|
||||
{
|
||||
name: "name",
|
||||
description: "卡牌名称",
|
||||
examples: ["示例卡牌 1", "示例卡牌 2"],
|
||||
},
|
||||
{ name: "type", description: "卡牌类型", examples: ["物品", "法术"] },
|
||||
{ name: "cost", description: "费用", examples: ["1", "2"] },
|
||||
{
|
||||
name: "description",
|
||||
description: "效果描述",
|
||||
examples: ["这是一个效果描述", "这是另一个效果"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
// 创建默认配置(如果没有提供)
|
||||
const config: DeckConfig = {
|
||||
size: deck_config.size || '54x86',
|
||||
grid: deck_config.grid || '5x8',
|
||||
size: deck_config.size || "54x86",
|
||||
grid: deck_config.grid || "5x8",
|
||||
bleed: deck_config.bleed ?? 1,
|
||||
padding: deck_config.padding ?? 2,
|
||||
shape: deck_config.shape || 'rectangle',
|
||||
layers: deck_config.layers || autoGenerateLayers(template.fields)
|
||||
shape: deck_config.shape || "rectangle",
|
||||
layers: deck_config.layers || autoGenerateLayers(template.fields),
|
||||
};
|
||||
|
||||
|
||||
// 生成 CSV 内容
|
||||
const csvContent = generateCardCSV(template, card_count);
|
||||
const csvPath = join(output_dir, csvFileName);
|
||||
writeFileSync(csvPath, csvContent, 'utf-8');
|
||||
|
||||
writeFileSync(csvPath, csvContent, "utf-8");
|
||||
|
||||
// 生成 Markdown 内容
|
||||
const mdContent = generateDeckMarkdown(
|
||||
const deckComponent = buildDeckComponent(`./${csvFileName}`, config);
|
||||
const mdContent = generateDeckPreviewMd(
|
||||
deck_name,
|
||||
`./${csvFileName}`,
|
||||
config,
|
||||
description
|
||||
deckComponent,
|
||||
description,
|
||||
);
|
||||
const mdPath = join(output_dir, mdFileName);
|
||||
writeFileSync(mdPath, mdContent, 'utf-8');
|
||||
|
||||
// 构建完整的 deck 组件代码
|
||||
const deckComponent = buildDeckComponent(`./${csvFileName}`, config);
|
||||
|
||||
writeFileSync(mdPath, mdContent, "utf-8");
|
||||
|
||||
return {
|
||||
mdFile: mdPath,
|
||||
csvFile: csvPath,
|
||||
deckComponent,
|
||||
message: `已生成卡牌组 "${deck_name}":\n- Markdown 文件:${mdPath}\n- CSV 数据:${csvPath}\n- 组件代码:${deckComponent}`
|
||||
message: `已生成卡牌组 "${deck_name}":\n- Markdown 文件:${mdPath}\n- CSV 数据:${csvPath}\n- 组件代码:${deckComponent}`,
|
||||
};
|
||||
}
|
||||
|
||||
+53
-148
@@ -1,8 +1,13 @@
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import { dirname, join, basename, extname, relative } from 'path';
|
||||
import { execSync } from 'child_process';
|
||||
import yaml from 'js-yaml';
|
||||
import type { DeckFrontmatter, DeckConfig } from './frontmatter/read-frontmatter.js';
|
||||
import { readFileSync, writeFileSync, existsSync } from "fs";
|
||||
import { relative } from "path";
|
||||
import { execSync } from "child_process";
|
||||
import {
|
||||
buildDeckComponent,
|
||||
titleFromCsvPath,
|
||||
defaultMdPath,
|
||||
readDeckConfig,
|
||||
generateDeckPreviewMd,
|
||||
} from "./deck-utils.js";
|
||||
|
||||
/**
|
||||
* 预览 Deck 的参数
|
||||
@@ -37,88 +42,35 @@ export interface PreviewDeckResult {
|
||||
preview_url?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 CSV 文件的 frontmatter
|
||||
*/
|
||||
function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } {
|
||||
const parts = content.trim().split(/(?:^|\n)---\s*\n/g);
|
||||
|
||||
if (parts.length !== 3 || parts[0] !== '') {
|
||||
return { csvContent: content };
|
||||
}
|
||||
|
||||
try {
|
||||
const frontmatterStr = parts[1].trim();
|
||||
const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined;
|
||||
const csvContent = parts.slice(2).join('---\n').trimStart();
|
||||
return { frontmatter, csvContent };
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse front matter:', error);
|
||||
return { csvContent: content };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 md-deck 组件代码
|
||||
*/
|
||||
function buildDeckComponent(csvPath: string, config?: DeckConfig): string {
|
||||
const parts = [`:md-deck[${csvPath}]`];
|
||||
const attrs: string[] = [];
|
||||
|
||||
if (config?.size) {
|
||||
attrs.push(`size="${config.size}"`);
|
||||
}
|
||||
|
||||
if (config?.grid) {
|
||||
attrs.push(`grid="${config.grid}"`);
|
||||
}
|
||||
|
||||
if (config?.bleed !== undefined && config.bleed !== 1) {
|
||||
attrs.push(`bleed="${config.bleed}"`);
|
||||
}
|
||||
|
||||
if (config?.padding !== undefined && config.padding !== 2) {
|
||||
attrs.push(`padding="${config.padding}"`);
|
||||
}
|
||||
|
||||
if (config?.shape && config.shape !== 'rectangle') {
|
||||
attrs.push(`shape="${config.shape}"`);
|
||||
}
|
||||
|
||||
if (config?.layers) {
|
||||
attrs.push(`layers="${config.layers}"`);
|
||||
}
|
||||
|
||||
if (config?.back_layers) {
|
||||
attrs.push(`back-layers="${config.back_layers}"`);
|
||||
}
|
||||
|
||||
if (attrs.length > 0) {
|
||||
parts.push(`{${attrs.join(' ')}}`);
|
||||
}
|
||||
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开浏览器
|
||||
*/
|
||||
function openBrowser(url: string) {
|
||||
try {
|
||||
// Windows 使用 start 命令
|
||||
if (process.platform === 'win32') {
|
||||
execSync(`start "" "${url}"`, { stdio: 'ignore' });
|
||||
} else if (process.platform === 'darwin') {
|
||||
execSync(`open "${url}"`, { stdio: 'ignore' });
|
||||
if (process.platform === "win32") {
|
||||
execSync(`start "" "${url}"`, { stdio: "ignore" });
|
||||
} else if (process.platform === "darwin") {
|
||||
execSync(`open "${url}"`, { stdio: "ignore" });
|
||||
} else {
|
||||
execSync(`xdg-open "${url}"`, { stdio: 'ignore' });
|
||||
execSync(`xdg-open "${url}"`, { stdio: "ignore" });
|
||||
}
|
||||
console.error(`已打开浏览器:${url}`);
|
||||
} catch (error) {
|
||||
console.error('打开浏览器失败:', error);
|
||||
console.error("打开浏览器失败:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算预览 URL
|
||||
*/
|
||||
function computePreviewUrl(mdFilePath: string): string {
|
||||
const relativeMdPath = relative(process.cwd(), mdFilePath)
|
||||
.split("\\")
|
||||
.join("/")
|
||||
.replace(/\.md$/, "");
|
||||
return `http://localhost:3000/${relativeMdPath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览 Deck - 保存 Markdown 并打开浏览器
|
||||
*/
|
||||
@@ -129,108 +81,61 @@ export function previewDeck(params: PreviewDeckParams): PreviewDeckResult {
|
||||
if (!existsSync(csv_file)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `CSV 文件不存在:${csv_file}`
|
||||
message: `CSV 文件不存在:${csv_file}`,
|
||||
};
|
||||
}
|
||||
|
||||
// 确定 Markdown 文件路径
|
||||
let mdFilePath = md_file;
|
||||
if (!mdFilePath) {
|
||||
// 使用与 CSV 相同的路径和文件名,只是扩展名不同
|
||||
const dir = dirname(csv_file);
|
||||
const name = basename(csv_file, extname(csv_file));
|
||||
mdFilePath = join(dir, `${name}.md`);
|
||||
}
|
||||
|
||||
const mdFilePath = md_file || defaultMdPath(csv_file);
|
||||
const created = !existsSync(mdFilePath);
|
||||
|
||||
// 如果文件已存在,检查是否已有 md-deck 组件
|
||||
if (!created) {
|
||||
try {
|
||||
const existingContent = readFileSync(mdFilePath, 'utf-8');
|
||||
// 如果已有 md-deck 组件引用该 CSV,直接返回
|
||||
if (existingContent.includes(`:md-deck[${csv_file}]`) ||
|
||||
existingContent.includes(`:md-deck[./${basename(csv_file)}]`)) {
|
||||
// 仍然打开浏览器
|
||||
const relativeMdPath = relative(process.cwd(), mdFilePath).split('\\').join('/');
|
||||
const previewUrl = `http://localhost:3000/${relativeMdPath}`;
|
||||
const existingContent = readFileSync(mdFilePath, "utf-8");
|
||||
if (
|
||||
existingContent.includes(`:md-deck[${csv_file}]`) ||
|
||||
existingContent.includes(`:md-deck[./${csv_file.split("/").pop()}]`)
|
||||
) {
|
||||
const previewUrl = computePreviewUrl(mdFilePath);
|
||||
openBrowser(previewUrl);
|
||||
return {
|
||||
success: true,
|
||||
message: `预览文件 ${mdFilePath} 已存在`,
|
||||
md_file: mdFilePath,
|
||||
created: false,
|
||||
preview_url: previewUrl
|
||||
preview_url: previewUrl,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// 读取失败,继续创建
|
||||
}
|
||||
}
|
||||
|
||||
// 读取 CSV 的 frontmatter 获取配置
|
||||
let deckConfig: DeckConfig | undefined;
|
||||
try {
|
||||
const csvContent = readFileSync(csv_file, 'utf-8');
|
||||
const { frontmatter } = parseFrontMatter(csvContent);
|
||||
deckConfig = frontmatter?.deck;
|
||||
} catch (error) {
|
||||
// 忽略错误,使用默认配置
|
||||
}
|
||||
const deckConfig = readDeckConfig(csv_file);
|
||||
|
||||
// 确定标题
|
||||
let deckTitle = title;
|
||||
if (!deckTitle) {
|
||||
// 从 CSV 文件名推断
|
||||
const name = basename(csv_file, extname(csv_file));
|
||||
deckTitle = name.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
}
|
||||
const deckTitle = title || titleFromCsvPath(csv_file);
|
||||
|
||||
// 构建 md-deck 组件代码(使用相对路径)
|
||||
const mdDir = dirname(mdFilePath);
|
||||
const csvDir = dirname(csv_file);
|
||||
let relativeCsvPath: string;
|
||||
|
||||
if (mdDir === csvDir) {
|
||||
relativeCsvPath = `./${basename(csv_file)}`;
|
||||
} else {
|
||||
relativeCsvPath = `./${basename(csv_file)}`;
|
||||
}
|
||||
|
||||
const deckComponent = buildDeckComponent(relativeCsvPath, deckConfig);
|
||||
// 构建 md-deck 组件代码
|
||||
const deckComponent = buildDeckComponent(
|
||||
`./${csv_file.split("/").pop()}`,
|
||||
deckConfig,
|
||||
);
|
||||
|
||||
// 生成 Markdown 内容
|
||||
const mdLines: string[] = [];
|
||||
|
||||
mdLines.push(`# ${deckTitle}`);
|
||||
mdLines.push('');
|
||||
|
||||
if (description) {
|
||||
mdLines.push(description);
|
||||
mdLines.push('');
|
||||
}
|
||||
|
||||
mdLines.push('## 卡牌预览');
|
||||
mdLines.push('');
|
||||
mdLines.push(deckComponent);
|
||||
mdLines.push('');
|
||||
|
||||
mdLines.push('## 使用说明');
|
||||
mdLines.push('');
|
||||
mdLines.push('- 点击卡牌可以查看详情');
|
||||
mdLines.push('- 使用右上角的按钮可以随机抽取卡牌');
|
||||
mdLines.push('- 可以通过编辑面板调整卡牌样式和布局');
|
||||
mdLines.push('');
|
||||
const mdContent = generateDeckPreviewMd(
|
||||
deckTitle,
|
||||
deckComponent,
|
||||
description,
|
||||
);
|
||||
|
||||
// 写入文件
|
||||
try {
|
||||
writeFileSync(mdFilePath, mdLines.join('\n'), 'utf-8');
|
||||
writeFileSync(mdFilePath, mdContent, "utf-8");
|
||||
|
||||
// 计算预览 URL
|
||||
const relativeMdPath = relative(process.cwd(), mdFilePath).split('\\').join('/');
|
||||
const previewUrl = `http://localhost:3000/${relativeMdPath.slice(0, -3)}`;
|
||||
|
||||
// 打开浏览器
|
||||
const previewUrl = computePreviewUrl(mdFilePath);
|
||||
openBrowser(previewUrl);
|
||||
|
||||
return {
|
||||
@@ -240,12 +145,12 @@ export function previewDeck(params: PreviewDeckParams): PreviewDeckResult {
|
||||
: `更新预览文件 ${mdFilePath}`,
|
||||
md_file: mdFilePath,
|
||||
created,
|
||||
preview_url: previewUrl
|
||||
preview_url: previewUrl,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `写入失败:${error instanceof Error ? error.message : '未知错误'}`
|
||||
message: `写入失败:${error instanceof Error ? error.message : "未知错误"}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+19
-2
@@ -7,8 +7,25 @@ export interface CompileOptions {
|
||||
output: string;
|
||||
}
|
||||
|
||||
export type ServeCommandHandler = (dir: string, options: ServeOptions) => Promise<void>;
|
||||
export type CompileCommandHandler = (dir: string, options: CompileOptions) => Promise<void>;
|
||||
export type ServeCommandHandler = (
|
||||
dir: string,
|
||||
options: ServeOptions,
|
||||
) => Promise<void>;
|
||||
export type CompileCommandHandler = (
|
||||
dir: string,
|
||||
options: CompileOptions,
|
||||
) => Promise<void>;
|
||||
|
||||
export interface CheckOptions {
|
||||
json: boolean;
|
||||
strict: boolean;
|
||||
quiet: boolean;
|
||||
}
|
||||
|
||||
export type CheckCommandHandler = (
|
||||
dir: string,
|
||||
options: CheckOptions,
|
||||
) => Promise<void>;
|
||||
|
||||
export interface MarkdownFile {
|
||||
path: string;
|
||||
|
||||
@@ -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,181 @@
|
||||
/**
|
||||
* Code-block role checks.
|
||||
*/
|
||||
|
||||
import { parseDeclare, type DeclareResult } 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;
|
||||
|
||||
if (/^csv$/i.test(fence.lang)) {
|
||||
sink.report(
|
||||
diagnosticAt(
|
||||
file,
|
||||
source,
|
||||
fence.offset,
|
||||
"TTTK-R003",
|
||||
"error",
|
||||
"role=declare blocks are YAML, not CSV",
|
||||
"use ```yaml role=declare with `vars:` and `tags:` maps",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let result: DeclareResult;
|
||||
try {
|
||||
result = parseDeclare(fence.body);
|
||||
} catch (error) {
|
||||
sink.report(
|
||||
diagnosticAt(
|
||||
file,
|
||||
source,
|
||||
fence.offset,
|
||||
"TTTK-R003",
|
||||
"error",
|
||||
error instanceof Error ? error.message : String(error),
|
||||
"role=declare is YAML: `vars:` maps `$name` to an expression; `tags:` maps a tag name to threshold maps",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.variables.length === 0 && result.tagModifiers.length === 0) {
|
||||
sink.report(
|
||||
diagnosticAt(
|
||||
file,
|
||||
source,
|
||||
fence.offset,
|
||||
"TTTK-R004",
|
||||
"warning",
|
||||
"role=declare block declares nothing",
|
||||
"add a `vars:` or `tags:` mapping; the block has no effect",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,324 @@
|
||||
/**
|
||||
* 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-print",
|
||||
body: "markdown path, optional #section",
|
||||
attrs: [
|
||||
"columns",
|
||||
"margin",
|
||||
"gap",
|
||||
"font",
|
||||
"title",
|
||||
"pageNumbers",
|
||||
"timestamp",
|
||||
"cutMarks",
|
||||
],
|
||||
summary:
|
||||
"Paginates a markdown document into A4 landscape sheets for printing.",
|
||||
details:
|
||||
"Each sheet holds two A5 pages side by side, and each A5 page flows the markdown into `columns` columns. `margin`, `gap`, and `font` are in millimetres (the body font defaults to 3mm). `pageNumbers` adds an `n / total` footer in the outer corner, `timestamp` appends the export time to that footer, and `cutMarks` draws the centre cut line. The preview modal exports through the browser's print dialog (choose A4 landscape, no margins, 100% scale).",
|
||||
refKind: "md",
|
||||
examples: [
|
||||
':md-print[./rules.md]{columns="2" margin="10" gap="5" font="3"}',
|
||||
':md-print[./rules.md]{timestamp=true}',
|
||||
"```yaml role=tag\ntag: md-print\nbody: ./rules.md\ncolumns: 2\nmargin: 10\ngap: 5\nfont: 3\ntitle: 规则书\ntimestamp: true\n```",
|
||||
],
|
||||
},
|
||||
{
|
||||
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-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: "yaml role=declare",
|
||||
summary: "Reactive variable / tag-modifier definitions.",
|
||||
details:
|
||||
"YAML with two optional maps. `vars:` maps a `$name` to its expression. `tags:` maps a tag name to a map of thresholds to `$target` modifiers: at that tag count the target gains the value, so sibling thresholds stack. Tag names may be written with or without the leading `#`. YAML resolves bare numbers to numbers and everything else to strings; both become expression text.",
|
||||
examples: [
|
||||
"```yaml role=declare\nvars:\n $hp: $con*5 + $mod_hp\n $ac: 10 + $dex\ntags:\n warrior:\n 1:\n $mod_hp: 20\n 2:\n $mod_str: 1\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: "d20", meaning: "one twenty-sided die (count optional)" },
|
||||
{ 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,216 @@
|
||||
/**
|
||||
* 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("");
|
||||
lines.push(
|
||||
"Fences may use backticks or tildes (`~~~`); the closing fence must reuse",
|
||||
);
|
||||
lines.push(
|
||||
"the opening marker. Fence attribute keys may contain hyphens (e.g.",
|
||||
);
|
||||
lines.push("`data-foo=bar`).");
|
||||
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,327 @@
|
||||
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 with an invalid shape", () => {
|
||||
const dir = fixture({
|
||||
"doc.md": "```yaml role=declare\nvars:\n hp: 1\n```\n",
|
||||
});
|
||||
|
||||
expect(codes(runCheck(dir))).toContain("TTTK-R003");
|
||||
});
|
||||
|
||||
test("R003 flags a CSV role=declare block with a migration hint", () => {
|
||||
const dir = fixture({
|
||||
"doc.md": "```csv role=declare\ntag,key,expr\n,,$hp,1\n```\n",
|
||||
});
|
||||
|
||||
const report = runCheck(dir);
|
||||
const diagnostic = report.diagnostics.find((d) => d.code === "TTTK-R003");
|
||||
|
||||
expect(diagnostic).toBeDefined();
|
||||
expect(diagnostic!.message).toContain("YAML, not CSV");
|
||||
});
|
||||
|
||||
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 block that declares nothing", () => {
|
||||
const dir = fixture({
|
||||
"doc.md": "```yaml role=declare\n# nothing here\n```\n",
|
||||
});
|
||||
|
||||
const report = runCheck(dir);
|
||||
const diagnostic = report.diagnostics.find((d) => d.code === "TTTK-R004");
|
||||
|
||||
expect(diagnostic).toBeDefined();
|
||||
expect(diagnostic!.line).toBe(1);
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
+88
-28
@@ -1,22 +1,46 @@
|
||||
import { Component, createEffect, onCleanup, Show, createResource, createMemo } from 'solid-js';
|
||||
import { parseMarkdown } from '../markdown';
|
||||
import { extractSection } from '../data-loader';
|
||||
import mermaid from 'mermaid';
|
||||
import {getIndexedData} from "../data-loader/file-index";
|
||||
import { resolvePath } from './utils/path';
|
||||
import { useLocation } from '@solidjs/router';
|
||||
import {
|
||||
Component,
|
||||
ParentProps,
|
||||
createContext,
|
||||
useContext,
|
||||
createEffect,
|
||||
onCleanup,
|
||||
Show,
|
||||
createResource,
|
||||
createSignal,
|
||||
} from "solid-js";
|
||||
import { parseMarkdown } from "../markdown";
|
||||
import { extractSection } from "../data-loader";
|
||||
import mermaid from "mermaid";
|
||||
import { getIndexedData } from "../data-loader/file-index";
|
||||
import { useNavigateWithParams } from "./useNavigateWithParams";
|
||||
|
||||
export interface ArticleProps {
|
||||
src: string;
|
||||
section?: string; // 指定要显示的标题(不含 #)
|
||||
iconPath?: string; // 图标路径前缀,默认为 "./assets",空字符串表示禁用
|
||||
section?: string; // 指定要显示的标题(不含 #)
|
||||
onLoaded?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
class?: string; // 额外的 class 用于样式控制
|
||||
class?: string; // 额外的 class 用于样式控制
|
||||
scrollToHash?: boolean; // 是否自动滚动到 hash
|
||||
}
|
||||
|
||||
async function fetchArticleContent(params: { src: string; section?: string }): Promise<string> {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Article DOM context – lets child components react to the content container
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ArticleDomCtx = createContext<() => HTMLDivElement | undefined>();
|
||||
|
||||
/** Access the article's content DOM element from a child component. */
|
||||
export function useArticleDom(): () => HTMLDivElement | undefined {
|
||||
const ctx = useContext(ArticleDomCtx);
|
||||
if (!ctx) throw new Error("useArticleDom must be used inside an <Article>");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
async function fetchArticleContent(params: {
|
||||
src: string;
|
||||
section?: string;
|
||||
}): Promise<string> {
|
||||
const text = await getIndexedData(params.src);
|
||||
// 如果指定了 section,提取对应内容
|
||||
return params.section ? extractSection(text, params.section) : text;
|
||||
@@ -28,17 +52,17 @@ async function fetchArticleContent(params: { src: string; section?: string }): P
|
||||
function scrollToHash(hash: string) {
|
||||
if (!hash) return;
|
||||
// 移除 # 前缀
|
||||
const id = hash.startsWith('#') ? hash.slice(1) : hash;
|
||||
const id = hash.startsWith("#") ? hash.slice(1) : hash;
|
||||
if (!id) return;
|
||||
|
||||
|
||||
// 使用 decodeURIComponent 解码 ID(处理中文等特殊字符)
|
||||
const decodedId = decodeURIComponent(id);
|
||||
|
||||
|
||||
// 尝试查找元素
|
||||
const element = document.getElementById(decodedId);
|
||||
if (element) {
|
||||
// 使用 scrollIntoView 滚动到元素
|
||||
element.scrollIntoView({ behavior: 'instant', block: 'start' });
|
||||
element.scrollIntoView({ behavior: "instant", block: "start" });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -48,18 +72,13 @@ function scrollToHash(hash: string) {
|
||||
* Article 组件
|
||||
* 用于将特定 src 位置的 md 文件显示为 markdown 文章
|
||||
*/
|
||||
export const Article: Component<ArticleProps> = (props) => {
|
||||
const location = useLocation();
|
||||
export const Article: Component<ArticleProps & ParentProps> = (props) => {
|
||||
const navigate = useNavigateWithParams();
|
||||
const [content, { refetch }] = createResource(
|
||||
() => ({ src: props.src, section: props.section }),
|
||||
fetchArticleContent
|
||||
fetchArticleContent,
|
||||
);
|
||||
|
||||
// 解析 iconPath,默认为 "./assets",空字符串表示禁用
|
||||
const iconPrefix = createMemo(() => {
|
||||
if (props.iconPath === '') return undefined; // 空字符串禁用图标前缀
|
||||
return resolvePath(props.src, props.iconPath ?? "./assets");
|
||||
});
|
||||
const [contentDom, setContentDom] = createSignal<HTMLDivElement>();
|
||||
|
||||
createEffect(() => {
|
||||
const data = content();
|
||||
@@ -67,18 +86,52 @@ export const Article: Component<ArticleProps> = (props) => {
|
||||
props.onLoaded?.();
|
||||
// 内容加载完成后,渲染 mermaid 图表
|
||||
void mermaid.run();
|
||||
|
||||
|
||||
// 内容渲染后检查 hash 并滚动
|
||||
scrollToHash(location.hash);
|
||||
scrollToHash(window.location.hash);
|
||||
}
|
||||
});
|
||||
|
||||
// Intercept markdown <a> links to use client-side navigation with
|
||||
// preserved URL search params.
|
||||
createEffect(() => {
|
||||
const dom = contentDom();
|
||||
if (!dom) return;
|
||||
|
||||
const onClick = (e: MouseEvent) => {
|
||||
const anchor = (e.target as HTMLElement).closest("a[href]");
|
||||
if (!anchor) return;
|
||||
const href = anchor.getAttribute("href");
|
||||
if (!href) return;
|
||||
// Only intercept same-origin navigation links (not external, not anchors)
|
||||
if (href.startsWith("http") || href.startsWith("//")) return;
|
||||
if (href.startsWith("#")) return;
|
||||
|
||||
e.preventDefault();
|
||||
// Resolve relative hrefs against the current page path so that
|
||||
// e.g. "blah.md" on /rules/page becomes /rules/blah
|
||||
const resolved = new URL(href, window.location.origin + window.location.pathname);
|
||||
let pathname = resolved.pathname;
|
||||
// Strip .md extension so refreshes hit the SPA fallback, not the raw file
|
||||
if (pathname.endsWith(".md")) {
|
||||
pathname = pathname.slice(0, -3);
|
||||
}
|
||||
navigate(pathname + resolved.hash);
|
||||
};
|
||||
|
||||
dom.addEventListener("click", onClick);
|
||||
onCleanup(() => dom.removeEventListener("click", onClick));
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
// 清理时清空内容,触发内部组件的销毁
|
||||
});
|
||||
|
||||
return (
|
||||
<article class={`prose ${props.class || ''}`} data-src={props.src}>
|
||||
<article
|
||||
class={`prose ${props.class || ""}`}
|
||||
data-src={props.src}
|
||||
>
|
||||
<Show when={content.loading}>
|
||||
<div class="text-gray-500">加载中...</div>
|
||||
</Show>
|
||||
@@ -86,7 +139,14 @@ export const Article: Component<ArticleProps> = (props) => {
|
||||
<div class="text-red-500">加载失败:{content.error?.message}</div>
|
||||
</Show>
|
||||
<Show when={!content.loading && !content.error && content()}>
|
||||
<div class="relative" innerHTML={parseMarkdown(content()!, iconPrefix())} />
|
||||
<ArticleDomCtx.Provider value={contentDom}>
|
||||
<div
|
||||
class="relative"
|
||||
ref={setContentDom}
|
||||
innerHTML={parseMarkdown(content()!, props.src)}
|
||||
/>
|
||||
{props.children}
|
||||
</ArticleDomCtx.Provider>
|
||||
</Show>
|
||||
</article>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* CommandLinkManager — mounts as a child of <Article> and intercepts
|
||||
* clicks on :cmd[] directive spans to dispatch them via the shared
|
||||
* command dispatcher. Errors are surfaced through the shared
|
||||
* dispatchError signal, shown by JournalInput above the textarea.
|
||||
*/
|
||||
|
||||
import { Component, onCleanup } from "solid-js";
|
||||
import { useArticleDom } from "./Article";
|
||||
import { useJournalStream } from "./stores/journalStream";
|
||||
import { useJournalCompletions } from "./journal/completions";
|
||||
import { dispatchCommand, setDispatchError } from "./journal/command-dispatcher";
|
||||
|
||||
export const CommandLinkManager: Component = () => {
|
||||
const contentDom = useArticleDom();
|
||||
const stream = useJournalStream();
|
||||
const comp = useJournalCompletions();
|
||||
|
||||
const onClick = (e: MouseEvent) => {
|
||||
const cmdSpan = (e.target as HTMLElement).closest("[data-cmd]");
|
||||
if (!cmdSpan) return;
|
||||
|
||||
const command = cmdSpan.getAttribute("data-cmd");
|
||||
if (!command) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
|
||||
dispatchCommand({
|
||||
role: stream.myRole as "gm" | "player" | "observer",
|
||||
myName: stream.myName,
|
||||
command,
|
||||
sparkTables: comp.data.sparkTables,
|
||||
variables: stream.variables,
|
||||
}).then((result) => {
|
||||
if (!result.ok) {
|
||||
setDispatchError(result.error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const dom = contentDom();
|
||||
if (dom) {
|
||||
dom.addEventListener("click", onClick, true);
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
const d = contentDom();
|
||||
if (d) d.removeEventListener("click", onClick, true);
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default CommandLinkManager;
|
||||
@@ -0,0 +1,159 @@
|
||||
import { Component, createSignal, Show } from "solid-js";
|
||||
import {
|
||||
loadFromUserFolder,
|
||||
switchToBuiltInSource,
|
||||
getActiveSource,
|
||||
clearIndex,
|
||||
} from "../data-loader/file-index";
|
||||
|
||||
export interface DataSourceDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSourceChanged: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog for choosing how to source content files.
|
||||
* Supports:
|
||||
* - Built-in content (CLI / webpack bundled files)
|
||||
* - Local folder (via File System Access API)
|
||||
*/
|
||||
export const DataSourceDialog: Component<DataSourceDialogProps> = (props) => {
|
||||
const [loading, setLoading] = createSignal(false);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [folderPath, setFolderPath] = createSignal<string | null>(null);
|
||||
|
||||
const currentSource = () => getActiveSource();
|
||||
|
||||
const handleFolderPick = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const paths = await loadFromUserFolder();
|
||||
|
||||
if (paths === null) {
|
||||
// User cancelled or API not supported — don't show error for cancellation
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (paths.length === 0) {
|
||||
setError(
|
||||
"No supported files (.md, .yarn, .csv) found in the selected folder.",
|
||||
);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setFolderPath(`${paths.length} files loaded`);
|
||||
setLoading(false);
|
||||
props.onSourceChanged();
|
||||
props.onClose();
|
||||
};
|
||||
|
||||
const handleBuiltIn = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
await switchToBuiltInSource();
|
||||
// Force re-index from built-in sources
|
||||
clearIndex();
|
||||
|
||||
setLoading(false);
|
||||
props.onSourceChanged();
|
||||
props.onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Show when={props.isOpen}>
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center"
|
||||
onClick={props.onClose}
|
||||
>
|
||||
<div
|
||||
class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 class="text-lg font-bold text-gray-900 mb-4">Content Source</h2>
|
||||
|
||||
<div class="space-y-3">
|
||||
{/* Built-in option */}
|
||||
<button
|
||||
onClick={handleBuiltIn}
|
||||
disabled={loading()}
|
||||
class="w-full text-left p-4 rounded-lg border transition-colors"
|
||||
classList={{
|
||||
"border-blue-500 bg-blue-50":
|
||||
currentSource() === "cli" || currentSource() === "webpack",
|
||||
"border-gray-200 hover:border-gray-300":
|
||||
currentSource() !== "cli" && currentSource() !== "webpack",
|
||||
"opacity-50 cursor-not-allowed": loading(),
|
||||
}}
|
||||
>
|
||||
<div class="font-medium text-gray-900">Built-in Content</div>
|
||||
<div class="text-sm text-gray-500 mt-1">
|
||||
Use the bundled content files shipped with the app.
|
||||
</div>
|
||||
<Show
|
||||
when={
|
||||
currentSource() === "cli" || currentSource() === "webpack"
|
||||
}
|
||||
>
|
||||
<div class="text-xs text-blue-600 mt-1 font-medium">Active</div>
|
||||
</Show>
|
||||
</button>
|
||||
|
||||
{/* Folder picker option */}
|
||||
<button
|
||||
onClick={handleFolderPick}
|
||||
disabled={
|
||||
loading() ||
|
||||
typeof window === "undefined" ||
|
||||
!("showDirectoryPicker" in window)
|
||||
}
|
||||
class="w-full text-left p-4 rounded-lg border transition-colors"
|
||||
classList={{
|
||||
"border-blue-500 bg-blue-50": currentSource() === "folder",
|
||||
"border-gray-200 hover:border-gray-300":
|
||||
currentSource() !== "folder",
|
||||
"opacity-50 cursor-not-allowed":
|
||||
loading() || !("showDirectoryPicker" in window),
|
||||
}}
|
||||
>
|
||||
<div class="font-medium text-gray-900">Local Folder</div>
|
||||
<div class="text-sm text-gray-500 mt-1">
|
||||
Pick a folder on your computer. Supports .md, .yarn, and .csv
|
||||
files.
|
||||
{"showDirectoryPicker" in window
|
||||
? " Will be remembered across sessions."
|
||||
: " (Not supported in this browser)"}
|
||||
</div>
|
||||
<Show when={currentSource() === "folder"}>
|
||||
<div class="text-xs text-blue-600 mt-1 font-medium">
|
||||
Active{folderPath() ? ` — ${folderPath()}` : ""}
|
||||
</div>
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={error()}>
|
||||
<div class="mt-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">
|
||||
{error()}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button
|
||||
onClick={props.onClose}
|
||||
class="px-4 py-2 text-sm text-gray-600 hover:text-gray-900 rounded hover:bg-gray-100"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataSourceDialog;
|
||||
@@ -0,0 +1,194 @@
|
||||
import {
|
||||
Component,
|
||||
createSignal,
|
||||
For,
|
||||
Show,
|
||||
onCleanup,
|
||||
onMount,
|
||||
} from "solid-js";
|
||||
import { Dynamic } from "solid-js/web";
|
||||
import {
|
||||
TbOutlineBook2,
|
||||
TbOutlineCheck,
|
||||
TbOutlineChevronDown,
|
||||
TbOutlineClipboardList,
|
||||
TbOutlineX,
|
||||
} from "solid-icons/tb";
|
||||
import { docEntries, type DocEntry } from "./doc-data";
|
||||
import { journalDocEntries, type JournalDocEntry } from "./journal-doc-data";
|
||||
import { docIcon } from "./doc-icons";
|
||||
import { parseMarkdown } from "../markdown";
|
||||
|
||||
type DocSet = "directives" | "journal";
|
||||
type AnyEntry = DocEntry | JournalDocEntry;
|
||||
|
||||
export interface DocDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const DocDialog: Component<DocDialogProps> = (props) => {
|
||||
const [docSet, setDocSet] = createSignal<DocSet>("directives");
|
||||
const [selectedTag, setSelectedTag] = createSignal(docEntries[0]?.tag ?? "");
|
||||
const [dropdownOpen, setDropdownOpen] = createSignal(false);
|
||||
let dropdownRef!: HTMLDivElement;
|
||||
|
||||
const currentEntries = () =>
|
||||
docSet() === "directives"
|
||||
? (docEntries as AnyEntry[])
|
||||
: (journalDocEntries as AnyEntry[]);
|
||||
|
||||
const selectedEntry = () =>
|
||||
currentEntries().find((e) => e.tag === selectedTag()) ??
|
||||
currentEntries()[0];
|
||||
|
||||
const docSetLabel = () =>
|
||||
docSet() === "directives" ? "指令组件文档" : "Journal 服务器文档";
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") props.onClose();
|
||||
};
|
||||
|
||||
const switchDocSet = (set: DocSet) => {
|
||||
setDocSet(set);
|
||||
setDropdownOpen(false);
|
||||
const entries = set === "directives" ? docEntries : journalDocEntries;
|
||||
setSelectedTag(entries[0]?.tag ?? "");
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
// Close dropdown on outside click
|
||||
document.addEventListener("click", (e) => {
|
||||
if (dropdownRef && !dropdownRef.contains(e.target as Node)) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
});
|
||||
|
||||
return (
|
||||
<Show when={props.isOpen}>
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 z-60 flex items-center justify-center p-4"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) props.onClose();
|
||||
}}
|
||||
>
|
||||
<div class="bg-white rounded-lg shadow-2xl w-full max-w-4xl max-h-[85vh] flex flex-col overflow-hidden">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 shrink-0">
|
||||
<div ref={dropdownRef} class="relative">
|
||||
<button
|
||||
onClick={() => setDropdownOpen((v) => !v)}
|
||||
class="text-lg font-bold text-gray-900 hover:text-blue-600 flex items-center gap-1"
|
||||
>
|
||||
{docSetLabel()}
|
||||
<TbOutlineChevronDown class="text-gray-400 text-sm" />
|
||||
</button>
|
||||
<Show when={dropdownOpen()}>
|
||||
<div class="absolute top-full left-0 mt-1 w-52 bg-white border border-gray-200 rounded shadow-lg z-60 py-1">
|
||||
<button
|
||||
onClick={() => switchDocSet("directives")}
|
||||
class={`w-full text-left px-3 py-2 text-sm hover:bg-gray-100 flex items-center gap-2 ${
|
||||
docSet() === "directives"
|
||||
? "bg-blue-50 text-blue-700"
|
||||
: "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
<TbOutlineBook2 />
|
||||
<span>指令组件文档</span>
|
||||
<Show when={docSet() === "directives"}>
|
||||
<TbOutlineCheck class="ml-auto text-blue-500" />
|
||||
</Show>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => switchDocSet("journal")}
|
||||
class={`w-full text-left px-3 py-2 text-sm hover:bg-gray-100 flex items-center gap-2 ${
|
||||
docSet() === "journal"
|
||||
? "bg-blue-50 text-blue-700"
|
||||
: "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
<TbOutlineClipboardList />
|
||||
<span>Journal 服务器文档</span>
|
||||
<Show when={docSet() === "journal"}>
|
||||
<TbOutlineCheck class="ml-auto text-blue-500" />
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<button
|
||||
onClick={props.onClose}
|
||||
class="text-gray-400 hover:text-gray-600 text-xl leading-none p-1"
|
||||
title="关闭"
|
||||
>
|
||||
<TbOutlineX />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 min-h-0">
|
||||
<nav class="w-48 shrink-0 border-r border-gray-200 overflow-y-auto p-3 bg-gray-50">
|
||||
<For each={currentEntries()}>
|
||||
{(entry) => (
|
||||
<button
|
||||
onClick={() => setSelectedTag(entry.tag)}
|
||||
class={`w-full text-left px-3 py-2 rounded mb-1 text-sm transition-colors ${
|
||||
selectedTag() === entry.tag
|
||||
? "bg-blue-100 text-blue-800 font-medium"
|
||||
: "text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
<Dynamic
|
||||
component={docIcon(entry.icon)}
|
||||
class="mr-2 inline-block align-[-0.125em]"
|
||||
/>
|
||||
<Show
|
||||
when={docSet() === "directives"}
|
||||
fallback={<span class="text-xs">{entry.title}</span>}
|
||||
>
|
||||
<span class="font-mono text-xs">{`:${entry.tag}`}</span>
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</nav>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<Show when={selectedEntry()} keyed>
|
||||
{(entry) => <DocContent entry={entry} />}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
/** Single entry documentation content */
|
||||
const DocContent: Component<{ entry: AnyEntry }> = (props) => {
|
||||
const e = props.entry;
|
||||
|
||||
return (
|
||||
<div class="max-w-none">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-6">
|
||||
<Dynamic
|
||||
component={docIcon(e.icon)}
|
||||
class="inline-block align-[-0.125em]"
|
||||
/>{" "}
|
||||
{e.title}
|
||||
</h2>
|
||||
|
||||
<article
|
||||
class="prose prose-sm max-w-none text-gray-800"
|
||||
innerHTML={parseMarkdown(e.body)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocDialog;
|
||||
+72
-25
@@ -1,13 +1,21 @@
|
||||
import { Component, createMemo, createSignal, Show } from "solid-js";
|
||||
import { useLocation } from "@solidjs/router";
|
||||
import {
|
||||
TbOutlineFolderOpen,
|
||||
TbOutlineFolder,
|
||||
TbOutlineFile,
|
||||
TbOutlineChevronRight,
|
||||
} from "solid-icons/tb";
|
||||
import { type FileNode, type TocNode } from "../data-loader";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
import { useNavigateWithParams } from "./useNavigateWithParams";
|
||||
import { useScrollContainer } from "../App";
|
||||
|
||||
/**
|
||||
* 检查当前文件路径是否在文件夹内
|
||||
*/
|
||||
function isPathInDir(currentPath: string, dirPath: string): boolean {
|
||||
// 确保 dirPath 以 / 结尾,用于前缀匹配
|
||||
const dirPathPrefix = dirPath.endsWith('/') ? dirPath : dirPath + '/';
|
||||
const dirPathPrefix = dirPath.endsWith("/") ? dirPath : dirPath + "/";
|
||||
return currentPath.startsWith(dirPathPrefix);
|
||||
}
|
||||
|
||||
@@ -20,41 +28,56 @@ export const FileTreeNode: Component<{
|
||||
pathHeadings: Record<string, TocNode[]>;
|
||||
depth: number;
|
||||
onClose: () => void;
|
||||
isHidden?: (node: FileNode) => boolean;
|
||||
}> = (props) => {
|
||||
const navigate = useNavigate();
|
||||
const navigate = useNavigateWithParams();
|
||||
const location = useLocation();
|
||||
const isDir = !!props.node.children;
|
||||
const isActive = createMemo(() => props.currentPath === props.node.path);
|
||||
// 默认收起,除非当前文件在该文件夹内
|
||||
const [isExpanded, setIsExpanded] = createSignal(isDir && isPathInDir(props.currentPath, props.node.path));
|
||||
const [isExpanded, setIsExpanded] = createSignal(
|
||||
isDir && isPathInDir(props.currentPath, props.node.path),
|
||||
);
|
||||
|
||||
const handleClick = () => {
|
||||
const href = () => props.node.path + location.search;
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (isDir) {
|
||||
e.preventDefault();
|
||||
setIsExpanded(!isExpanded());
|
||||
} else {
|
||||
} else if (e.button === 0) {
|
||||
// Left-click: use SPA navigation
|
||||
e.preventDefault();
|
||||
navigate(props.node.path);
|
||||
props.onClose();
|
||||
}
|
||||
// Middle-click / right-click: let the browser handle the <a> natively
|
||||
};
|
||||
|
||||
const indent = props.depth * 12;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
class={`flex items-center py-1 px-2 cursor-pointer hover:bg-gray-100 rounded ${
|
||||
<div class={props.isHidden?.(props.node) === true ? "hidden" : ""}>
|
||||
<a
|
||||
href={href()}
|
||||
class={`flex items-center py-1 px-2 cursor-pointer hover:bg-gray-100 rounded no-underline ${
|
||||
isActive() ? "bg-blue-50 text-blue-700" : "text-gray-700"
|
||||
}`}
|
||||
style={{ "padding-left": `${indent + 8}px` }}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Show when={isDir}>
|
||||
<span class="mr-1 text-gray-400">{isExpanded() ? "📂" : "📁"}</span>
|
||||
{isExpanded() ? (
|
||||
<TbOutlineFolderOpen class="mr-1 text-gray-400" />
|
||||
) : (
|
||||
<TbOutlineFolder class="mr-1 text-gray-400" />
|
||||
)}
|
||||
</Show>
|
||||
<Show when={!isDir}>
|
||||
<span class="mr-1 text-gray-400">📄</span>
|
||||
<TbOutlineFile class="mr-1 text-gray-400" />
|
||||
</Show>
|
||||
<span class="text-sm truncate">{props.node.name}</span>
|
||||
</div>
|
||||
</a>
|
||||
<Show when={isDir && isExpanded() && props.node.children}>
|
||||
<div>
|
||||
{props.node.children!.map((child) => (
|
||||
@@ -64,6 +87,7 @@ export const FileTreeNode: Component<{
|
||||
pathHeadings={props.pathHeadings}
|
||||
depth={props.depth + 1}
|
||||
onClose={props.onClose}
|
||||
isHidden={props.isHidden}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -79,10 +103,12 @@ export const HeadingNode: Component<{
|
||||
node: TocNode;
|
||||
basePath: string;
|
||||
depth: number;
|
||||
isHidden?: (node: TocNode) => boolean;
|
||||
}> = (props) => {
|
||||
const navigate = useNavigate();
|
||||
const navigate = useNavigateWithParams();
|
||||
const location = useLocation();
|
||||
const anchor = props.node.id || "";
|
||||
const href = `${props.basePath}#${anchor}`;
|
||||
const href = () => `${props.basePath}${location.search}#${anchor}`;
|
||||
const hasChildren = !!props.node.children;
|
||||
// 默认收起,除非当前锚点在该节点内
|
||||
const [isExpanded, setIsExpanded] = createSignal(props.depth <= 0);
|
||||
@@ -93,16 +119,31 @@ export const HeadingNode: Component<{
|
||||
setIsExpanded(!isExpanded());
|
||||
}
|
||||
};
|
||||
const scrollContainer = useScrollContainer();
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
navigate(href);
|
||||
if (e.button === 0) {
|
||||
// Left-click: use SPA navigation
|
||||
e.preventDefault();
|
||||
navigate(`${props.basePath}#${anchor}`);
|
||||
}
|
||||
// Middle-click / right-click: let the browser handle the <a> natively
|
||||
// 滚动到目标元素,考虑导航栏高度偏移
|
||||
requestAnimationFrame(() => {
|
||||
const element = document.getElementById(anchor);
|
||||
if (element) {
|
||||
const scroller = scrollContainer();
|
||||
const navBarHeight = 80;
|
||||
const elementPosition = element.getBoundingClientRect().top;
|
||||
const offsetPosition = window.scrollY + elementPosition - navBarHeight;
|
||||
window.scrollTo({ top: offsetPosition, behavior: "instant" });
|
||||
const containerTop = scroller?.getBoundingClientRect().top ?? 0;
|
||||
const relativePosition = elementPosition - containerTop;
|
||||
const currentScroll = scroller?.scrollTop ?? window.scrollY;
|
||||
const offsetPosition = currentScroll + relativePosition - navBarHeight;
|
||||
if (scroller) {
|
||||
scroller.scrollTo({ top: offsetPosition, behavior: "instant" });
|
||||
} else {
|
||||
window.scrollTo({ top: offsetPosition, behavior: "instant" });
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -110,18 +151,23 @@ export const HeadingNode: Component<{
|
||||
const indent = props.depth * 12;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div class={props.isHidden?.(props.node) === true ? "hidden" : ""}>
|
||||
<div class="flex flex-row">
|
||||
<span class={`cursor-pointer mr-1 text-gray-400 text-xs w-3 flex-shrink-0`}
|
||||
style={{ "margin-left": `${indent + 8}px` }}
|
||||
onClick={handleExpand}
|
||||
<span
|
||||
class={`cursor-pointer mr-1 text-gray-400 text-xs w-3 shrink-0`}
|
||||
style={{ "margin-left": `${indent + 8}px` }}
|
||||
onClick={handleExpand}
|
||||
>
|
||||
<span class={`${hasChildren ? "" : "invisible" } ${isExpanded() ? "rotate-90" : ""} mt-1 inline-block transition-transform `}>▶</span>
|
||||
<span
|
||||
class={`${hasChildren ? "" : "invisible"} ${isExpanded() ? "rotate-90" : ""} mt-1 inline-block transition-transform `}
|
||||
>
|
||||
<TbOutlineChevronRight class="inline-block align-[-0.125em]" />
|
||||
</span>
|
||||
</span>
|
||||
<a
|
||||
href={href}
|
||||
class="inline-flex items-center py-0.5 px-2 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-50 rounded truncate cursor-pointer"
|
||||
onClick={handleClick}
|
||||
href={href()}
|
||||
class="inline-flex items-center py-0.5 px-2 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-50 rounded truncate cursor-pointer"
|
||||
onClick={handleClick}
|
||||
>
|
||||
{props.node.title}
|
||||
</a>
|
||||
@@ -133,6 +179,7 @@ export const HeadingNode: Component<{
|
||||
node={child}
|
||||
basePath={props.basePath}
|
||||
depth={props.depth + 1}
|
||||
isHidden={props.isHidden}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* RevealManager — mounts as a child of <Article> and reactively applies
|
||||
* reveal classes (non-GM) or injects action buttons (GM) whenever the
|
||||
* content DOM, stream connection state, role, or completions change.
|
||||
*
|
||||
* All action buttons are proper Solid components rendered via Portal.
|
||||
* Nothing is injected into the custom element DOM — listeners are attached
|
||||
* via addEventListener with proper cleanup.
|
||||
*/
|
||||
|
||||
import {
|
||||
Component,
|
||||
createEffect,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
Show,
|
||||
} from "solid-js";
|
||||
import { Portal, Dynamic } from "solid-js/web";
|
||||
import { useLocation } from "@solidjs/router";
|
||||
import type { IconTypes } from "solid-icons";
|
||||
import { TbOutlineLink, TbOutlineSparkles } from "solid-icons/tb";
|
||||
import { useArticleDom } from "./Article";
|
||||
import { journalStreamState } from "./stores/journalStream";
|
||||
import { useJournalCompletions } from "./journal/completions";
|
||||
import {
|
||||
addRevealedClasses,
|
||||
cleanupInjections,
|
||||
setActionPrefill,
|
||||
normalizePath,
|
||||
} from "./stores/reveal";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Floating button state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FloatingButton {
|
||||
rect: DOMRect;
|
||||
action: () => void;
|
||||
title: string;
|
||||
icon: IconTypes;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const [floating, setFloating] = createSignal<FloatingButton | null>(null);
|
||||
let hideTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function show(btn: FloatingButton) {
|
||||
clearTimeout(hideTimer);
|
||||
setFloating(btn);
|
||||
}
|
||||
function hide() {
|
||||
hideTimer = setTimeout(() => setFloating(null), 200);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RevealManager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const RevealManager: Component = () => {
|
||||
const contentDom = useArticleDom();
|
||||
const location = useLocation();
|
||||
const comp = useJournalCompletions();
|
||||
|
||||
// ---- Reveal / conceal classes ----
|
||||
createEffect(() => {
|
||||
const dom = contentDom();
|
||||
if (!dom) return;
|
||||
|
||||
const state = journalStreamState;
|
||||
void state.connected;
|
||||
void state.myRole;
|
||||
|
||||
addRevealedClasses(dom, location.pathname);
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
const dom = contentDom();
|
||||
if (dom) cleanupInjections(dom);
|
||||
});
|
||||
|
||||
// ---- GM hover listeners (only when connected as GM) ----
|
||||
createEffect(() => {
|
||||
const dom = contentDom();
|
||||
const state = journalStreamState;
|
||||
if (!dom || state.myRole !== "gm" || !state.connected) return;
|
||||
|
||||
const normalized = normalizePath(decodeURIComponent(location.pathname));
|
||||
const data = comp.data;
|
||||
|
||||
// Spark tables on this page — matched by suffix on column slug.
|
||||
// filePath in completions may have a leading slash; normalize both sides.
|
||||
const pageSparkSlugs = data.sparkTables
|
||||
.filter((st) => st.filePath.replace(/^\//, "") === normalized)
|
||||
.map((st) => st.slug);
|
||||
|
||||
// Single delegated handler for both link and spark
|
||||
const onMouseOver = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
// Spark table
|
||||
const sparkTable = target.closest("md-table[data-spark]");
|
||||
if (sparkTable) {
|
||||
const colSlug = sparkTable.getAttribute("data-spark");
|
||||
const combinedSlug = colSlug
|
||||
? pageSparkSlugs.find((s) => s.endsWith(`-${colSlug}`))
|
||||
: undefined;
|
||||
if (combinedSlug) {
|
||||
show({
|
||||
rect: sparkTable.getBoundingClientRect(),
|
||||
action: () =>
|
||||
setActionPrefill({
|
||||
command: "/roll",
|
||||
text: combinedSlug,
|
||||
}),
|
||||
title: "Roll spark table",
|
||||
icon: TbOutlineSparkles,
|
||||
color: "text-purple-400 hover:text-purple-600 hover:bg-purple-50",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Heading
|
||||
const heading = target.closest("h1, h2, h3, h4, h5, h6");
|
||||
if (heading) {
|
||||
const text = heading.id || heading.textContent?.trim() || "";
|
||||
if (text) {
|
||||
show({
|
||||
rect: heading.getBoundingClientRect(),
|
||||
action: () =>
|
||||
setActionPrefill({
|
||||
command: "/link",
|
||||
text: `${normalized}#${text}`,
|
||||
}),
|
||||
title: "Send /link to stream",
|
||||
icon: TbOutlineLink,
|
||||
color: "text-blue-500 hover:bg-blue-50",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseOut = (e: MouseEvent) => {
|
||||
// Hide only if the mouse actually left the content area
|
||||
const related = e.relatedTarget as HTMLElement | null;
|
||||
if (!dom.contains(related)) hide();
|
||||
};
|
||||
|
||||
dom.addEventListener("mouseover", onMouseOver);
|
||||
dom.addEventListener("mouseout", onMouseOut);
|
||||
|
||||
onCleanup(() => {
|
||||
dom.removeEventListener("mouseover", onMouseOver);
|
||||
dom.removeEventListener("mouseout", onMouseOut);
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<Show when={floating()}>
|
||||
{(btn) => (
|
||||
<Portal>
|
||||
<button
|
||||
class={`fixed z-50 inline-flex items-center justify-center w-5 h-5 rounded cursor-pointer ${btn().color}`}
|
||||
style={{
|
||||
top: `${btn().rect.top - 6}px`,
|
||||
left: `${btn().rect.left - 20}px`,
|
||||
}}
|
||||
title={btn().title}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
btn().action();
|
||||
}}
|
||||
onMouseEnter={() => clearTimeout(hideTimer)}
|
||||
onMouseLeave={hide}
|
||||
>
|
||||
<Dynamic component={btn().icon} size={12} />
|
||||
</button>
|
||||
</Portal>
|
||||
)}
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
export default RevealManager;
|
||||
+67
-21
@@ -1,11 +1,16 @@
|
||||
import { Component, createMemo, createSignal, onMount, Show } from "solid-js";
|
||||
import { generateToc, type FileNode, type TocNode } from "../data-loader";
|
||||
import { useLocation } from "@solidjs/router";
|
||||
import { TbOutlineFolderOpen, TbOutlineX } from "solid-icons/tb";
|
||||
import { FileTreeNode, HeadingNode } from "./FileTree";
|
||||
import { isPathRevealed } from "./stores/reveal";
|
||||
|
||||
export interface SidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
fileTree?: FileNode[];
|
||||
pathHeadings?: Record<string, TocNode[]>;
|
||||
onDataSourceOpen?: () => void;
|
||||
}
|
||||
|
||||
interface SidebarContentProps {
|
||||
@@ -13,7 +18,8 @@ interface SidebarContentProps {
|
||||
pathHeadings: Record<string, TocNode[]>;
|
||||
currentPath: string;
|
||||
onClose: () => void;
|
||||
isDesktop?: boolean;
|
||||
onNavigate?: () => void;
|
||||
onDataSourceOpen?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,23 +31,43 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
|
||||
// 响应式获取当前文件的标题列表
|
||||
const currentFileHeadings = createMemo(() => {
|
||||
const pathname = decodeURIComponent(location.pathname);
|
||||
return props.pathHeadings[pathname] || props.pathHeadings[`${pathname}.md`] || [];
|
||||
return (
|
||||
props.pathHeadings[pathname] || props.pathHeadings[`${pathname}.md`] || []
|
||||
);
|
||||
});
|
||||
const isFileHidden = (node: FileNode): boolean => {
|
||||
if (isPathRevealed(node.path)) return false;
|
||||
if (node.children?.some((child) => !isFileHidden(child))) return false;
|
||||
return true;
|
||||
};
|
||||
const isHeadingHidden = (node: TocNode): boolean => {
|
||||
const pathname = decodeURIComponent(location.pathname);
|
||||
if (isPathRevealed(pathname, node.id ?? node.title)) return false;
|
||||
if (node.children?.some((child) => !isHeadingHidden(child))) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="p-4 border-b border-b-gray-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-gray-900">目录</h2>
|
||||
<Show when={!props.isDesktop}>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
onClick={props.onDataSourceOpen}
|
||||
class="text-gray-400 hover:text-gray-600 p-1 rounded hover:bg-gray-100"
|
||||
title="Content Source"
|
||||
>
|
||||
<TbOutlineFolderOpen class="inline-block align-[-0.125em]" />
|
||||
</button>
|
||||
<button
|
||||
onClick={props.onClose}
|
||||
class="text-gray-500 hover:text-gray-700"
|
||||
title="关闭"
|
||||
>
|
||||
✕
|
||||
<TbOutlineX class="inline-block align-[-0.125em]" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -56,7 +82,8 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
|
||||
currentPath={props.currentPath}
|
||||
pathHeadings={props.pathHeadings}
|
||||
depth={0}
|
||||
onClose={props.onClose}
|
||||
onClose={() => props.onNavigate?.()}
|
||||
isHidden={isFileHidden}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -72,6 +99,7 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
|
||||
node={node}
|
||||
basePath={location.pathname}
|
||||
depth={0}
|
||||
isHidden={isHeadingHidden}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -85,14 +113,20 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
|
||||
*/
|
||||
export const MobileSidebar: Component<SidebarProps> = (props) => {
|
||||
const location = useLocation();
|
||||
const [fileTree, setFileTree] = createSignal<FileNode[]>([]);
|
||||
const [pathHeadings, setPathHeadings] = createSignal<Record<string, TocNode[]>>({});
|
||||
const [selfFileTree, setSelfFileTree] = createSignal<FileNode[]>([]);
|
||||
const [selfPathHeadings, setSelfPathHeadings] = createSignal<
|
||||
Record<string, TocNode[]>
|
||||
>({});
|
||||
|
||||
// 加载目录数据
|
||||
const fileTree = () => props.fileTree ?? selfFileTree();
|
||||
const pathHeadings = () => props.pathHeadings ?? selfPathHeadings();
|
||||
|
||||
// 加载目录数据 (only if props not provided)
|
||||
onMount(async () => {
|
||||
if (props.fileTree && props.pathHeadings) return;
|
||||
const toc = await generateToc();
|
||||
setFileTree(toc.fileTree);
|
||||
setPathHeadings(toc.pathHeadings);
|
||||
setSelfFileTree(toc.fileTree);
|
||||
setSelfPathHeadings(toc.pathHeadings);
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -115,6 +149,8 @@ export const MobileSidebar: Component<SidebarProps> = (props) => {
|
||||
pathHeadings={pathHeadings()}
|
||||
currentPath={location.pathname}
|
||||
onClose={props.onClose}
|
||||
onNavigate={props.onClose}
|
||||
onDataSourceOpen={props.onDataSourceOpen}
|
||||
/>
|
||||
</aside>
|
||||
</>
|
||||
@@ -122,28 +158,38 @@ export const MobileSidebar: Component<SidebarProps> = (props) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* 桌面端固定侧边栏
|
||||
* 桌面端侧边栏 — flex child, no fixed positioning
|
||||
*/
|
||||
export const DesktopSidebar: Component<{}> = () => {
|
||||
export const DesktopSidebar: Component<{
|
||||
fileTree?: FileNode[];
|
||||
pathHeadings?: Record<string, TocNode[]>;
|
||||
onClose: () => void;
|
||||
onDataSourceOpen?: () => void;
|
||||
}> = (props) => {
|
||||
const location = useLocation();
|
||||
const [fileTree, setFileTree] = createSignal<FileNode[]>([]);
|
||||
const [pathHeadings, setPathHeadings] = createSignal<Record<string, TocNode[]>>({});
|
||||
const [selfFileTree, setSelfFileTree] = createSignal<FileNode[]>([]);
|
||||
const [selfPathHeadings, setSelfPathHeadings] = createSignal<
|
||||
Record<string, TocNode[]>
|
||||
>({});
|
||||
|
||||
const fileTree = () => props.fileTree ?? selfFileTree();
|
||||
const pathHeadings = () => props.pathHeadings ?? selfPathHeadings();
|
||||
|
||||
// 加载目录数据
|
||||
onMount(async () => {
|
||||
if (props.fileTree && props.pathHeadings) return;
|
||||
const toc = await generateToc();
|
||||
setFileTree(toc.fileTree);
|
||||
setPathHeadings(toc.pathHeadings);
|
||||
setSelfFileTree(toc.fileTree);
|
||||
setSelfPathHeadings(toc.pathHeadings);
|
||||
});
|
||||
|
||||
return (
|
||||
<aside class="hidden md:block fixed top-0 left-0 h-full w-64 bg-white shadow-lg z-30 overflow-hidden pt-16">
|
||||
<aside class="hidden md:flex flex-col h-full w-64 shrink-0 bg-white border-r border-gray-200 overflow-hidden">
|
||||
<SidebarContent
|
||||
fileTree={fileTree()}
|
||||
pathHeadings={pathHeadings()}
|
||||
currentPath={location.pathname}
|
||||
onClose={() => {}}
|
||||
isDesktop
|
||||
onClose={props.onClose}
|
||||
onDataSourceOpen={props.onDataSourceOpen}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { tokenize, parse, roll, applyModifier, rollFormula } from "./index";
|
||||
import type { DieResult } from "./index";
|
||||
|
||||
describe("dice engine — tokenizer", () => {
|
||||
test("tokenizes NdX", () => {
|
||||
expect(tokenize("3d6")).toEqual([{ type: "dice", count: 3, sides: 6 }]);
|
||||
});
|
||||
|
||||
test("tokenizes bare dX as a single die", () => {
|
||||
expect(tokenize("d20")).toEqual([{ type: "dice", count: 1, sides: 20 }]);
|
||||
});
|
||||
|
||||
test("tokenizes a pool literal", () => {
|
||||
expect(tokenize("{d4,d8}")).toEqual([
|
||||
{ type: "pool_start" },
|
||||
{ type: "dice", count: 1, sides: 4 },
|
||||
{ type: "comma" },
|
||||
{ type: "dice", count: 1, sides: 8 },
|
||||
{ type: "pool_end" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("tokenizes a keep-highest modifier", () => {
|
||||
expect(tokenize("3d6kh2")).toEqual([
|
||||
{ type: "dice", count: 3, sides: 6 },
|
||||
{ type: "modifier", modType: "kh", count: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("ignores whitespace", () => {
|
||||
expect(tokenize(" 3d6 + 5 ")).toEqual([
|
||||
{ type: "dice", count: 3, sides: 6 },
|
||||
{ type: "plus" },
|
||||
{ type: "number", value: 5 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dice engine — parser", () => {
|
||||
test("builds one pool for NdX", () => {
|
||||
const pools = parse(tokenize("3d6"));
|
||||
expect(pools).toHaveLength(1);
|
||||
expect(pools[0].dice).toHaveLength(3);
|
||||
expect(pools[0].dice.every((d) => d.sides === 6)).toBe(true);
|
||||
});
|
||||
|
||||
test("builds a combined pool list for +", () => {
|
||||
const pools = parse(tokenize("3d6+5"));
|
||||
expect(pools).toHaveLength(2);
|
||||
expect(pools[1].dice[0]).toMatchObject({ sides: 0, value: 5 });
|
||||
});
|
||||
|
||||
test("marks a subtracted pool negative", () => {
|
||||
const pools = parse(tokenize("3d6-2"));
|
||||
expect(pools).toHaveLength(2);
|
||||
expect(pools[1].isNegative).toBe(true);
|
||||
expect(pools[1].dice[0].isNegative).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dice engine — applyModifier", () => {
|
||||
const dice = (...values: number[]): DieResult[] =>
|
||||
values.map((value) => ({ sides: 6, value, isNegative: false }));
|
||||
|
||||
test("keeps the highest N", () => {
|
||||
const { keptRolls, droppedRolls } = applyModifier(dice(2, 6, 4), {
|
||||
type: "kh",
|
||||
count: 2,
|
||||
});
|
||||
expect(keptRolls.map((r) => r.value).sort()).toEqual([4, 6]);
|
||||
expect(droppedRolls.map((r) => r.value)).toEqual([2]);
|
||||
});
|
||||
|
||||
test("drops the lowest N", () => {
|
||||
const { keptRolls, droppedRolls } = applyModifier(dice(2, 6, 4, 1), {
|
||||
type: "dl",
|
||||
count: 1,
|
||||
});
|
||||
expect(keptRolls.map((r) => r.value).sort()).toEqual([2, 4, 6]);
|
||||
expect(droppedRolls.map((r) => r.value)).toEqual([1]);
|
||||
});
|
||||
|
||||
test("keeps all when there is no modifier", () => {
|
||||
const { keptRolls, droppedRolls } = applyModifier(dice(1, 2), undefined);
|
||||
expect(keptRolls).toHaveLength(2);
|
||||
expect(droppedRolls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dice engine — roll", () => {
|
||||
test("rolls 3d6 within range", () => {
|
||||
for (let i = 0; i < 25; i++) {
|
||||
const result = roll("3d6");
|
||||
expect(result.total).toBeGreaterThanOrEqual(3);
|
||||
expect(result.total).toBeLessThanOrEqual(18);
|
||||
expect(result.pools).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
test("rolls d20 within range", () => {
|
||||
const result = roll("d20");
|
||||
expect(result.total).toBeGreaterThanOrEqual(1);
|
||||
expect(result.total).toBeLessThanOrEqual(20);
|
||||
});
|
||||
|
||||
test("adds fixed numbers to the total", () => {
|
||||
const result = roll("3d6+5");
|
||||
expect(result.total).toBeGreaterThanOrEqual(8);
|
||||
expect(result.total).toBeLessThanOrEqual(23);
|
||||
});
|
||||
|
||||
test("keep-highest keeps N dice and totals only those", () => {
|
||||
const result = roll("3d6kh2");
|
||||
const [pool] = result.pools;
|
||||
expect(pool.keptRolls).toHaveLength(2);
|
||||
expect(pool.droppedRolls).toHaveLength(1);
|
||||
expect(result.total).toBe(pool.subtotal);
|
||||
});
|
||||
|
||||
test("emits matching HTML and plain-text detail", () => {
|
||||
const result = roll("2d6");
|
||||
expect(result.detail).toContain(String(result.total));
|
||||
expect(result.detail).toContain("<strong>");
|
||||
expect(result.plainDetail).toContain(String(result.total));
|
||||
});
|
||||
});
|
||||
|
||||
describe("dice engine — rollFormula", () => {
|
||||
test("normalizes a successful roll", () => {
|
||||
const result = rollFormula("3d6");
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.formula).toBe("3d6");
|
||||
expect(result.result.total).toBeGreaterThanOrEqual(3);
|
||||
expect(result.result.pools[0].rolls).toHaveLength(3);
|
||||
expect(result.result.pools[0].keptRolls).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("supports pool literals and modifiers", () => {
|
||||
const result = rollFormula("{d4,d8}kh2-5");
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.result.pools.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Dice engine — shared infrastructure for every dice-rolling feature
|
||||
* (`:md-dice`, spark tables, journal `/roll`, and dice inside variable
|
||||
* expressions).
|
||||
*
|
||||
* `rollFormula` is the normalized entry point; the tokenizer/parser/roller are
|
||||
* exported for tests and for callers that need the structured pool results.
|
||||
*/
|
||||
|
||||
export { rollFormula } from "./rollFormula";
|
||||
export type { DiceRollerResult } from "./rollFormula";
|
||||
|
||||
export { tokenize, parse } from "./parser";
|
||||
export { roll, rollDie, rollDicePool, applyModifier } from "./roller";
|
||||
export type {
|
||||
DieResult,
|
||||
DicePool,
|
||||
RollResult,
|
||||
DicePoolResult,
|
||||
Token,
|
||||
} from "./types";
|
||||
+11
-30
@@ -1,10 +1,11 @@
|
||||
import { roll as rollDice } from "./dice-engine";
|
||||
import { roll as rollDice } from "./roller";
|
||||
|
||||
export interface DiceRollerResult {
|
||||
formula: string;
|
||||
result: {
|
||||
total: number;
|
||||
detail: string;
|
||||
plainDetail: string;
|
||||
pools: Array<{
|
||||
rolls: number[];
|
||||
keptRolls: number[];
|
||||
@@ -15,19 +16,14 @@ export interface DiceRollerResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 骰子引擎 Hook
|
||||
* 提供高级骰子功能:骰池、修饰符、组合等
|
||||
*/
|
||||
/**
|
||||
* 执行掷骰
|
||||
* @param formula 骰子公式,支持:
|
||||
* - 3d6: 标准骰子
|
||||
* - {d4, d8, 2d6}: 骰池字面量
|
||||
* - kh2/dl1: 修饰符
|
||||
* - +: 组合骰池
|
||||
* - -: 负数组合
|
||||
* - 5: 固定数字
|
||||
*
|
||||
* Supports pools, modifiers and combinations. For example:
|
||||
* - `3d6`: standard dice
|
||||
* - `{d4, d8, 2d6}`: pool literal
|
||||
* - `kh2` / `dl1`: keep-high / drop-low modifiers
|
||||
* - `+` / `-`: combine pools; a bare number is a fixed value
|
||||
*/
|
||||
export function rollFormula(formula: string): DiceRollerResult {
|
||||
try {
|
||||
@@ -38,6 +34,7 @@ export function rollFormula(formula: string): DiceRollerResult {
|
||||
result: {
|
||||
total: result.total,
|
||||
detail: result.detail,
|
||||
plainDetail: result.plainDetail,
|
||||
pools: result.pools.map((pool) => ({
|
||||
rolls: pool.rolls.map((r) => r.value),
|
||||
keptRolls: pool.keptRolls.map((r) => r.value),
|
||||
@@ -52,6 +49,7 @@ export function rollFormula(formula: string): DiceRollerResult {
|
||||
result: {
|
||||
total: 0,
|
||||
detail: "",
|
||||
plainDetail: "",
|
||||
pools: [],
|
||||
},
|
||||
success: false,
|
||||
@@ -60,21 +58,4 @@ export function rollFormula(formula: string): DiceRollerResult {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 简化版掷骰,返回 HTML 格式结果
|
||||
* 格式:[1] [2] [3] = <strong>6</strong>
|
||||
*/
|
||||
export function rollSimple(formula: string): { text: string; isHtml?: boolean } {
|
||||
try {
|
||||
const result = rollDice(formula);
|
||||
return {
|
||||
text: result.detail,
|
||||
isHtml: true, // detail 包含 HTML
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
text: `错误:${e instanceof Error ? e.message : String(e)}`,
|
||||
isHtml: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+24
-13
@@ -38,7 +38,10 @@ export function rollDicePool(pool: DicePool): DicePoolResult {
|
||||
const { keptRolls, droppedRolls } = applyModifier(rolls, pool.modifier);
|
||||
|
||||
// 固定数字总是保留
|
||||
const finalKeptRolls = [...keptRolls, ...allRolls.filter((r) => r.sides === 0)];
|
||||
const finalKeptRolls = [
|
||||
...keptRolls,
|
||||
...allRolls.filter((r) => r.sides === 0),
|
||||
];
|
||||
|
||||
// 计算小计
|
||||
const subtotal = finalKeptRolls.reduce((sum, roll) => {
|
||||
@@ -59,7 +62,7 @@ export function rollDicePool(pool: DicePool): DicePoolResult {
|
||||
*/
|
||||
export function applyModifier(
|
||||
rolls: DieResult[],
|
||||
modifier: DicePool["modifier"]
|
||||
modifier: DicePool["modifier"],
|
||||
): { keptRolls: DieResult[]; droppedRolls: DieResult[] } {
|
||||
if (!modifier) {
|
||||
return { keptRolls: rolls, droppedRolls: [] };
|
||||
@@ -70,7 +73,8 @@ export function applyModifier(
|
||||
|
||||
switch (modifier.type) {
|
||||
case "kh": // Keep Highest - 保留最大的 N 个
|
||||
case "dh": { // Drop Highest - 丢弃最大的 N 个
|
||||
case "dh": {
|
||||
// Drop Highest - 丢弃最大的 N 个
|
||||
const count = modifier.count;
|
||||
const higher = sorted.slice(-count);
|
||||
const rest = sorted.slice(0, -count);
|
||||
@@ -81,7 +85,8 @@ export function applyModifier(
|
||||
}
|
||||
}
|
||||
case "kl": // Keep Lowest - 保留最小的 N 个
|
||||
case "dl": { // Drop Lowest - 丢弃最小的 N 个
|
||||
case "dl": {
|
||||
// Drop Lowest - 丢弃最小的 N 个
|
||||
const count = modifier.count;
|
||||
const lower = sorted.slice(0, count);
|
||||
const rest = sorted.slice(count);
|
||||
@@ -105,29 +110,35 @@ export function roll(formula: string): RollResult {
|
||||
|
||||
const total = poolResults.reduce((sum, result) => sum + result.subtotal, 0);
|
||||
|
||||
// 生成详细表达式 - 使用 HTML 格式
|
||||
const rollSpans: string[] = [];
|
||||
// Generate detail expressions (both HTML and plain text)
|
||||
const htmlSpans: string[] = [];
|
||||
const textSpans: string[] = [];
|
||||
for (const result of poolResults) {
|
||||
// 构建骰子结果 HTML
|
||||
for (const roll of result.rolls) {
|
||||
const isKept = result.keptRolls.some((kept) => kept === roll);
|
||||
if (roll.sides === 0) {
|
||||
// 固定数字,不显示括号
|
||||
rollSpans.push(`<strong>${roll.isNegative ? '-' : ''}${roll.value}</strong>`);
|
||||
const sign = roll.isNegative ? "-" : "";
|
||||
htmlSpans.push(`<strong>${sign}${roll.value}</strong>`);
|
||||
textSpans.push(`${sign}${roll.value}`);
|
||||
} else if (isKept) {
|
||||
rollSpans.push(`<strong>[${roll.value}]</strong>`);
|
||||
htmlSpans.push(`<strong>[${roll.value}]</strong>`);
|
||||
textSpans.push(`[${roll.value}]`);
|
||||
} else {
|
||||
rollSpans.push(`<span class="text-gray-400">[${roll.value}]</span>`);
|
||||
htmlSpans.push(`<span class="text-gray-400">[${roll.value}]</span>`);
|
||||
textSpans.push(`[${roll.value}]`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rollsStr = rollSpans.join(" + ");
|
||||
const detail = `<strong>${total}</strong> = ${rollsStr}`;
|
||||
const rollHtml = htmlSpans.join(" + ");
|
||||
const rollText = textSpans.join(" + ");
|
||||
const detail = `<strong>${total}</strong> = ${rollHtml}`;
|
||||
const plainDetail = `${total} = ${rollText}`;
|
||||
|
||||
return {
|
||||
pools: poolResults,
|
||||
total,
|
||||
detail,
|
||||
plainDetail,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* 骰子引擎类型定义
|
||||
*
|
||||
* 支持语法:
|
||||
* - 3d6: 标准骰子表示
|
||||
* - {d4, d8}: 骰池字面量
|
||||
* - kh2/dl1: 修饰符 (keep high / drop low)
|
||||
* - +: 组合骰池
|
||||
* - -: 转换负数后组合
|
||||
* - 数字字面量:单一数字的骰池
|
||||
*/
|
||||
|
||||
/**
|
||||
* 单个骰子结果
|
||||
*/
|
||||
export interface DieResult {
|
||||
sides: number; // 骰子面数,0 表示固定数字
|
||||
value: number; // 掷骰结果
|
||||
isNegative: boolean; // 是否为负数
|
||||
}
|
||||
|
||||
/**
|
||||
* 骰池
|
||||
*/
|
||||
export interface DicePool {
|
||||
dice: DieResult[]; // 骰子列表(未掷骰前为模板)
|
||||
modifier?: {
|
||||
type: "kh" | "kl" | "dh" | "dl";
|
||||
count: number;
|
||||
};
|
||||
isNegative: boolean; // 整个骰池是否为负
|
||||
}
|
||||
|
||||
/**
|
||||
* 掷骰结果
|
||||
*/
|
||||
export interface RollResult {
|
||||
pools: DicePoolResult[]; // 各骰池结果
|
||||
total: number; // 总和
|
||||
detail: string; // 详细表达式 (HTML)
|
||||
plainDetail: string; // 详细表达式 (plain text)
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个骰池的掷骰结果
|
||||
*/
|
||||
export interface DicePoolResult {
|
||||
pool: DicePool; // 原始骰池定义
|
||||
rolls: DieResult[]; // 掷骰结果
|
||||
keptRolls: DieResult[]; // 保留的骰子(应用修饰符后)
|
||||
droppedRolls: DieResult[]; // 丢弃的骰子
|
||||
subtotal: number; // 该骰池小计
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析后的令牌
|
||||
*/
|
||||
export type Token =
|
||||
| { type: "number"; value: number }
|
||||
| { type: "dice"; count: number; sides: number }
|
||||
| { type: "pool_start" }
|
||||
| { type: "pool_end" }
|
||||
| { type: "comma" }
|
||||
| { type: "plus" }
|
||||
| { type: "minus" }
|
||||
| { type: "modifier"; modType: "kh" | "kl" | "dh" | "dl"; count: number };
|
||||
@@ -0,0 +1,77 @@
|
||||
import yaml from "js-yaml";
|
||||
|
||||
export interface DocEntry {
|
||||
tag: string;
|
||||
icon: string;
|
||||
title: string;
|
||||
/** Full markdown body (everything after frontmatter ---) */
|
||||
body: string;
|
||||
}
|
||||
|
||||
/** Splits frontmatter and markdown body from a raw .md string. */
|
||||
function parseFrontmatter(raw: string): Record<string, unknown> | null {
|
||||
const normalized = raw.replace(/\r\n/g, "\n");
|
||||
const match = normalized.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
||||
if (!match) return null;
|
||||
try {
|
||||
return yaml.load(match[1]) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getBody(raw: string): string {
|
||||
const normalized = raw.replace(/\r\n/g, "\n");
|
||||
const match = normalized.match(/^---\n[\s\S]*?\n---\n?([\s\S]*)$/);
|
||||
return (match ? match[1] : raw).trim();
|
||||
}
|
||||
|
||||
function parseEntry(raw: string): DocEntry | null {
|
||||
const fm = parseFrontmatter(raw);
|
||||
if (!fm) return null;
|
||||
return {
|
||||
tag: fm.tag as string,
|
||||
icon: fm.icon as string,
|
||||
title: fm.title as string,
|
||||
body: getBody(raw),
|
||||
};
|
||||
}
|
||||
|
||||
// Static imports – each .md file is asset/source so imports are strings.
|
||||
// Add a new import here when creating a new doc entry.
|
||||
import mdDiceRaw from "../doc-entries/md-dice.md";
|
||||
import mdTableRaw from "../doc-entries/md-table.md";
|
||||
import mdLinkRaw from "../doc-entries/md-link.md";
|
||||
import mdPinsRaw from "../doc-entries/md-pins.md";
|
||||
import mdFontRaw from "../doc-entries/md-font.md";
|
||||
import mdBgRaw from "../doc-entries/md-bg.md";
|
||||
import mdBorderRaw from "../doc-entries/md-border.md";
|
||||
import mdEmbedRaw from "../doc-entries/md-embed.md";
|
||||
import mdDeckRaw from "../doc-entries/md-deck.md";
|
||||
import mdPrintRaw from "../doc-entries/md-print.md";
|
||||
import mdYarnRaw from "../doc-entries/md-yarn-spinner.md";
|
||||
|
||||
const rawDocuments: string[] = [
|
||||
mdDiceRaw,
|
||||
mdTableRaw,
|
||||
mdLinkRaw,
|
||||
mdPinsRaw,
|
||||
mdFontRaw,
|
||||
mdBgRaw,
|
||||
mdBorderRaw,
|
||||
mdEmbedRaw,
|
||||
mdDeckRaw,
|
||||
mdPrintRaw,
|
||||
mdYarnRaw,
|
||||
];
|
||||
|
||||
let _entries: DocEntry[] | null = null;
|
||||
|
||||
function loadDocEntries(): DocEntry[] {
|
||||
if (_entries) return _entries;
|
||||
_entries = rawDocuments.map(parseEntry).filter(Boolean) as DocEntry[];
|
||||
_entries.sort((a, b) => a.tag.localeCompare(b.tag));
|
||||
return _entries;
|
||||
}
|
||||
|
||||
export const docEntries = loadDocEntries();
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Icon registry for built-in doc entries.
|
||||
*
|
||||
* Doc-entry frontmatter `icon:` values are semantic keys (e.g. `dice`), not
|
||||
* glyphs — YAML can't reference a component. This maps each key to a Tabler
|
||||
* icon component; unknown keys fall back to a generic file icon.
|
||||
*/
|
||||
|
||||
import type { IconTypes } from "solid-icons";
|
||||
import {
|
||||
TbOutlineCards,
|
||||
TbOutlineDice5,
|
||||
TbOutlineFile,
|
||||
TbOutlineFileText,
|
||||
TbOutlineGitBranch,
|
||||
TbOutlineHash,
|
||||
TbOutlineLink,
|
||||
TbOutlineMapPin,
|
||||
TbOutlinePhoto,
|
||||
TbOutlinePrinter,
|
||||
TbOutlineSparkles,
|
||||
TbOutlineSquare,
|
||||
TbOutlineTable,
|
||||
TbOutlineTheater,
|
||||
TbOutlineTypography,
|
||||
} from "solid-icons/tb";
|
||||
|
||||
const DOC_ICONS: Record<string, IconTypes> = {
|
||||
dice: TbOutlineDice5,
|
||||
table: TbOutlineTable,
|
||||
link: TbOutlineLink,
|
||||
pins: TbOutlineMapPin,
|
||||
font: TbOutlineTypography,
|
||||
background: TbOutlinePhoto,
|
||||
border: TbOutlineSquare,
|
||||
embed: TbOutlineFileText,
|
||||
deck: TbOutlineCards,
|
||||
print: TbOutlinePrinter,
|
||||
yarn: TbOutlineGitBranch,
|
||||
gm: TbOutlineTheater,
|
||||
player: TbOutlineDice5,
|
||||
roll: TbOutlineDice5,
|
||||
set: TbOutlineHash,
|
||||
spark: TbOutlineSparkles,
|
||||
};
|
||||
|
||||
/** Resolve a frontmatter icon key to a Tabler icon component. */
|
||||
export function docIcon(key: string): IconTypes {
|
||||
return DOC_ICONS[key] ?? TbOutlineFile;
|
||||
}
|
||||
+30
-43
@@ -1,48 +1,35 @@
|
||||
// 导入自定义元素以注册
|
||||
import './md-dice';
|
||||
import './md-table';
|
||||
import './md-link';
|
||||
import './md-pins';
|
||||
import './md-bg';
|
||||
import './md-deck';
|
||||
import './md-commander/index';
|
||||
import './md-yarn-spinner';
|
||||
import './md-token';
|
||||
import './md-token-viewer';
|
||||
import "./md-dice";
|
||||
import "./md-table";
|
||||
import "./md-link";
|
||||
import "./md-pins";
|
||||
import "./md-bg";
|
||||
import "./md-border";
|
||||
import "./md-font";
|
||||
import "./md-embed";
|
||||
import "./md-deck";
|
||||
import "./md-print";
|
||||
import "./md-yarn-spinner";
|
||||
|
||||
// 导出组件
|
||||
export { Article } from './Article';
|
||||
export type { ArticleProps } from './Article';
|
||||
export { MobileSidebar, DesktopSidebar } from './Sidebar';
|
||||
export type { SidebarProps } from './Sidebar';
|
||||
export { FileTreeNode, HeadingNode } from './FileTree';
|
||||
export { Article } from "./Article";
|
||||
export type { ArticleProps } from "./Article";
|
||||
export { RevealManager } from "./RevealManager";
|
||||
export { CommandLinkManager } from "./CommandLinkManager";
|
||||
export { ReactiveVariableManager } from "./journal/ReactiveVariableManager";
|
||||
export { MobileSidebar, DesktopSidebar } from "./Sidebar";
|
||||
export type { SidebarProps } from "./Sidebar";
|
||||
export { FileTreeNode, HeadingNode } from "./FileTree";
|
||||
export { default as DocDialog } from "./DocDialog";
|
||||
export type { DocDialogProps } from "./DocDialog";
|
||||
export { default as DataSourceDialog } from "./DataSourceDialog";
|
||||
export type { DataSourceDialogProps } from "./DataSourceDialog";
|
||||
|
||||
// 导出数据类型
|
||||
export type { DiceProps } from './md-dice';
|
||||
export type { TableProps } from './md-table';
|
||||
export type { BgProps } from './md-bg';
|
||||
export type { YarnSpinnerProps } from './md-yarn-spinner';
|
||||
export type { TokenProps } from './md-token';
|
||||
|
||||
// 导出 md-commander 相关
|
||||
export type {
|
||||
MdCommanderProps,
|
||||
MdCommanderCommand,
|
||||
MdCommanderCommandMap,
|
||||
MdCommanderParameter,
|
||||
MdCommanderOption,
|
||||
MdCommanderOptionType,
|
||||
CommanderEntry,
|
||||
CompletionItem,
|
||||
TrackerItem,
|
||||
TrackerAttribute,
|
||||
TrackerAttributeType,
|
||||
TrackerCommand,
|
||||
TrackerViewMode,
|
||||
} from './md-commander/types';
|
||||
export { TabBar } from './md-commander/TabBar';
|
||||
export { TrackerView } from './md-commander/TrackerView';
|
||||
export { CommanderEntries } from './md-commander/CommanderEntries';
|
||||
export { CommanderInput } from './md-commander/CommanderInput';
|
||||
export { useCommander } from './md-commander/hooks';
|
||||
export { initializeCommands, getCommands } from './md-commander/stores/commandsStore';
|
||||
export type { DiceProps } from "./md-dice";
|
||||
export type { TableProps } from "./md-table";
|
||||
export type { BgProps } from "./md-bg";
|
||||
export type { FontProps } from "./md-font";
|
||||
export type { BorderProps } from "./md-border";
|
||||
export type { EmbedProps } from "./md-embed";
|
||||
export type { YarnSpinnerProps } from "./md-yarn-spinner";
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import yaml from "js-yaml";
|
||||
|
||||
export interface JournalDocEntry {
|
||||
tag: string;
|
||||
icon: string;
|
||||
title: string;
|
||||
/** Full markdown body (everything after frontmatter ---) */
|
||||
body: string;
|
||||
}
|
||||
|
||||
function parseFrontmatter(raw: string): Record<string, unknown> | null {
|
||||
const normalized = raw.replace(/\r\n/g, "\n");
|
||||
const match = normalized.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
||||
if (!match) return null;
|
||||
try {
|
||||
return yaml.load(match[1]) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getBody(raw: string): string {
|
||||
const normalized = raw.replace(/\r\n/g, "\n");
|
||||
const match = normalized.match(/^---\n[\s\S]*?\n---\n?([\s\S]*)$/);
|
||||
return (match ? match[1] : raw).trim();
|
||||
}
|
||||
|
||||
function parseEntry(raw: string): JournalDocEntry | null {
|
||||
const fm = parseFrontmatter(raw);
|
||||
if (!fm) return null;
|
||||
return {
|
||||
tag: fm.tag as string,
|
||||
icon: fm.icon as string,
|
||||
title: fm.title as string,
|
||||
body: getBody(raw),
|
||||
};
|
||||
}
|
||||
|
||||
import journalGmRaw from "../doc-entries/journal-gm.md";
|
||||
import journalPlayerRaw from "../doc-entries/journal-player.md";
|
||||
import journalSetRaw from "../doc-entries/journal-set.md";
|
||||
import journalRollRaw from "../doc-entries/journal-roll.md";
|
||||
import journalLinkRaw from "../doc-entries/journal-link.md";
|
||||
|
||||
const rawDocuments: string[] = [
|
||||
journalGmRaw,
|
||||
journalPlayerRaw,
|
||||
journalSetRaw,
|
||||
journalRollRaw,
|
||||
journalLinkRaw,
|
||||
];
|
||||
|
||||
let _entries: JournalDocEntry[] | null = null;
|
||||
|
||||
function loadJournalDocEntries(): JournalDocEntry[] {
|
||||
if (_entries) return _entries;
|
||||
_entries = rawDocuments.map(parseEntry).filter(Boolean) as JournalDocEntry[];
|
||||
return _entries;
|
||||
}
|
||||
|
||||
export const journalDocEntries = loadJournalDocEntries();
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* CompletionsDropdown — autocomplete popover for journal command input.
|
||||
*/
|
||||
|
||||
import { Component, For, Show, createEffect, onMount, onCleanup } from "solid-js";
|
||||
import { Dynamic } from "solid-js/web";
|
||||
import type { CompletionItem } from "./command-parser";
|
||||
import type { CompletionsState } from "./completions";
|
||||
|
||||
export interface CompletionsDropdownProps {
|
||||
show: boolean;
|
||||
items: CompletionItem[];
|
||||
selectedIdx: number;
|
||||
onSelect: (item: CompletionItem) => void;
|
||||
onHover: (idx: number) => void;
|
||||
onClose: () => void;
|
||||
state: CompletionsState;
|
||||
}
|
||||
|
||||
export const CompletionsDropdown: Component<CompletionsDropdownProps> = (
|
||||
props,
|
||||
) => {
|
||||
let containerRef!: HTMLDivElement;
|
||||
|
||||
// Scroll selected into view
|
||||
createEffect(() => {
|
||||
const idx = props.selectedIdx;
|
||||
if (!props.show || !containerRef) return;
|
||||
const el = containerRef.querySelector(`[data-comp-idx="${idx}"]`);
|
||||
if (el) el.scrollIntoView({ block: "nearest" });
|
||||
});
|
||||
|
||||
// Click outside
|
||||
onMount(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef && !containerRef.contains(e.target as Node)) {
|
||||
props.onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
onCleanup(() => document.removeEventListener("mousedown", handler));
|
||||
});
|
||||
|
||||
return (
|
||||
<Show when={props.show}>
|
||||
<Show
|
||||
when={props.state.status === "loaded" || props.state.status === "empty"}
|
||||
fallback={
|
||||
<Show
|
||||
when={props.state.status === "loading"}
|
||||
fallback={
|
||||
<div
|
||||
ref={containerRef}
|
||||
class="absolute bottom-full left-0 right-0 bg-white border border-red-200 rounded-t shadow-lg mb-0.5 z-50 px-3 py-2 text-xs text-red-500"
|
||||
>
|
||||
{(props.state as any).message || "无法加载补全数据"}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
class="absolute bottom-full left-0 right-0 bg-white border border-gray-200 rounded-t shadow-lg mb-0.5 z-50 px-3 py-2 text-xs text-gray-400 italic"
|
||||
>
|
||||
正在加载补全数据…
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Show when={props.items.length > 0}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
class="absolute bottom-full left-0 right-0 bg-white border border-gray-200 rounded-t shadow-lg mb-0.5 max-h-40 overflow-y-auto z-50"
|
||||
>
|
||||
<For each={props.items}>
|
||||
{(item, idx) => (
|
||||
<div
|
||||
data-comp-idx={idx()}
|
||||
class={`flex items-center gap-2 px-3 py-1.5 cursor-pointer text-sm ${
|
||||
item.kind === "no-results"
|
||||
? "text-gray-400 italic cursor-default"
|
||||
: idx() === props.selectedIdx
|
||||
? "bg-blue-50 text-blue-700"
|
||||
: "hover:bg-gray-100 text-gray-700"
|
||||
}`}
|
||||
onClick={() => props.onSelect(item)}
|
||||
onMouseEnter={() =>
|
||||
item.kind !== "no-results" && props.onHover(idx())
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={item.kind !== "no-results"}
|
||||
fallback={
|
||||
<span class="text-xs text-gray-300 shrink-0">—</span>
|
||||
}
|
||||
>
|
||||
<span
|
||||
class={`text-xs px-1 rounded shrink-0 ${
|
||||
item.kind === "command"
|
||||
? "bg-purple-100 text-purple-700"
|
||||
: "bg-gray-100 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{item.kind === "command" ? "cmd" : "val"}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={item.icon}>
|
||||
{(icon) => (
|
||||
<Dynamic component={icon()} size={14} class="shrink-0" />
|
||||
)}
|
||||
</Show>
|
||||
<span class="font-mono truncate flex-1">{item.label}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* ComposePanel — type picker + dynamic form + send button
|
||||
*/
|
||||
|
||||
import { Component, createSignal, For, Show, createMemo } from "solid-js";
|
||||
import { registeredTypes, canEmit } from "./registry";
|
||||
import { sendMessage, useJournalStream } from "../stores/journalStream";
|
||||
import { DynamicForm } from "./DynamicForm";
|
||||
|
||||
export const ComposePanel: Component = () => {
|
||||
const stream = useJournalStream();
|
||||
const [selectedType, setSelectedType] = createSignal<string>("chat");
|
||||
const [formData, setFormData] = createSignal<Record<string, unknown>>({});
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [sending, setSending] = createSignal(false);
|
||||
|
||||
const role = () => stream.myRole;
|
||||
|
||||
// Available types filtered by role
|
||||
const availableTypes = createMemo(() => {
|
||||
const types: string[] = [];
|
||||
for (const type of registeredTypes()) {
|
||||
if (canEmit(type, role())) {
|
||||
types.push(type);
|
||||
}
|
||||
}
|
||||
// Default to chat if available, otherwise first
|
||||
if (types.includes("chat")) {
|
||||
return types;
|
||||
}
|
||||
return types;
|
||||
});
|
||||
|
||||
const handleSend = () => {
|
||||
setError(null);
|
||||
setSending(true);
|
||||
|
||||
const result = sendMessage(selectedType(), formData());
|
||||
if (!result.success) {
|
||||
setError(result.error);
|
||||
} else {
|
||||
setFormData({}); // Reset on success
|
||||
}
|
||||
|
||||
setSending(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="border-t border-gray-200 p-2 space-y-2 bg-white">
|
||||
{/* Type picker */}
|
||||
<div class="flex items-center gap-2">
|
||||
<select
|
||||
value={selectedType()}
|
||||
onChange={(e) => {
|
||||
setSelectedType(e.currentTarget.value);
|
||||
setFormData({});
|
||||
}}
|
||||
class="text-xs border border-gray-300 rounded px-2 py-1 bg-white"
|
||||
>
|
||||
<For each={availableTypes()}>
|
||||
{(type) => <option value={type}>{type}</option>}
|
||||
</For>
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={sending()}
|
||||
class="ml-auto bg-blue-600 text-white rounded px-3 py-1 text-xs hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{sending() ? "..." : "发送"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Dynamic form */}
|
||||
<DynamicForm
|
||||
type={selectedType()}
|
||||
value={formData()}
|
||||
onChange={setFormData}
|
||||
/>
|
||||
|
||||
<Show when={error()}>
|
||||
<p class="text-red-500 text-xs">{error()}</p>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* ConnectDialog — broker connection + session lobby (list / create / join)
|
||||
*
|
||||
* The broker connection and the active session are decoupled: this dialog
|
||||
* connects to the broker, which makes the session list available, and joining
|
||||
* or creating a session is a separate step that reuses that connection.
|
||||
*/
|
||||
import { Component, createSignal, For, onMount, Show } from "solid-js";
|
||||
import { TbOutlineTrash } from "solid-icons/tb";
|
||||
import {
|
||||
connectBroker,
|
||||
createSession,
|
||||
joinSession,
|
||||
resolveBrokerUrl,
|
||||
sessions,
|
||||
setMyName,
|
||||
setMyRole,
|
||||
useJournalStream,
|
||||
} from "../stores/journalStream";
|
||||
import { DeleteSessionDialog } from "./DeleteSessionDialog";
|
||||
|
||||
export const ConnectDialog: Component = () => {
|
||||
const stream = useJournalStream();
|
||||
const manifest = sessions;
|
||||
const [playerName, setPlayerName] = createSignal(stream.myName);
|
||||
const [role, setRole] = createSignal<"gm" | "player" | "observer">(
|
||||
stream.myRole,
|
||||
);
|
||||
const [newSessionName, setNewSessionName] = createSignal("");
|
||||
const [busy, setBusy] = createSignal(false);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [autoJoined, setAutoJoined] = createSignal(false);
|
||||
const [sessionToDelete, setSessionToDelete] = createSignal<{
|
||||
id: string;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
|
||||
const ensureBroker = async () => {
|
||||
if (stream.connected) return;
|
||||
await connectBroker(resolveBrokerUrl());
|
||||
};
|
||||
|
||||
const identity = () => ({
|
||||
name: playerName().trim() || stream.myName,
|
||||
role: role(),
|
||||
});
|
||||
|
||||
const applyIdentity = () => {
|
||||
const { name, role: r } = identity();
|
||||
setMyName(name);
|
||||
setMyRole(r);
|
||||
};
|
||||
|
||||
const handleJoin = async (sessionId: string) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
applyIdentity();
|
||||
await ensureBroker();
|
||||
await joinSession(sessionId, identity());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Connection failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
const name = newSessionName().trim();
|
||||
if (!name) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
applyIdentity();
|
||||
await ensureBroker();
|
||||
const id = await createSession(name);
|
||||
if (id) await joinSession(id, identity());
|
||||
setNewSessionName("");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Connection failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-join when the URL carries session + player + autojoin params;
|
||||
// otherwise enter the lobby so the session list is available up front.
|
||||
onMount(() => {
|
||||
const params = new URL(window.location.href).searchParams;
|
||||
if (
|
||||
params.has("session") &&
|
||||
params.has("player") &&
|
||||
params.has("autojoin")
|
||||
) {
|
||||
setPlayerName(params.get("player") || "");
|
||||
setRole("player");
|
||||
setAutoJoined(true);
|
||||
handleJoin(params.get("session")!);
|
||||
return;
|
||||
}
|
||||
ensureBroker().catch((e) => {
|
||||
setError(e instanceof Error ? e.message : "Connection failed");
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={!autoJoined() || error()}
|
||||
fallback={
|
||||
<p class="text-sm text-gray-500 text-center">
|
||||
{busy() ? "正在加入会话…" : "已加入!"}
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<div class="w-full max-w-sm space-y-3">
|
||||
<p class="text-sm text-gray-600 text-center">
|
||||
输入你的名字和角色,选择或新建一个会话。
|
||||
<br />
|
||||
<span class="text-xs text-gray-400">
|
||||
连接到 {window.location.host}
|
||||
</span>
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={playerName()}
|
||||
onInput={(e) => setPlayerName(e.currentTarget.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
||||
placeholder="你的名字"
|
||||
class="w-full border border-gray-300 rounded px-3 py-1.5 text-sm"
|
||||
autofocus
|
||||
/>
|
||||
{/* Role selector */}
|
||||
<div class="flex gap-1">
|
||||
{(["gm", "player", "observer"] as const).map((r) => (
|
||||
<button
|
||||
onClick={() => setRole(r)}
|
||||
class={`flex-1 rounded px-2 py-1.5 text-xs font-medium border transition-colors ${
|
||||
role() === r
|
||||
? "bg-blue-600 text-white border-blue-600"
|
||||
: "bg-white text-gray-600 border-gray-300 hover:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
{r === "gm" ? "主持人" : r === "player" ? "玩家" : "观察者"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Session list */}
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs text-gray-500">会话</p>
|
||||
<div class="max-h-40 overflow-y-auto border border-gray-200 rounded divide-y divide-gray-100">
|
||||
<For each={Object.entries(manifest().sessions)}>
|
||||
{([id, meta]) => (
|
||||
<div class="flex items-center">
|
||||
<button
|
||||
onClick={() => handleJoin(id)}
|
||||
disabled={busy()}
|
||||
class="flex-1 min-w-0 text-left px-2 py-1.5 text-xs text-gray-700 hover:bg-gray-50 disabled:opacity-50 truncate"
|
||||
>
|
||||
{meta.name || id}
|
||||
</button>
|
||||
<Show when={role() === "gm"}>
|
||||
<button
|
||||
onClick={() =>
|
||||
setSessionToDelete({ id, name: meta.name || id })
|
||||
}
|
||||
disabled={busy()}
|
||||
class="shrink-0 px-2 py-1.5 text-[10px] text-gray-300 hover:text-red-500 disabled:opacity-50"
|
||||
title="删除会话"
|
||||
>
|
||||
<TbOutlineTrash />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<Show when={Object.keys(manifest().sessions).length === 0}>
|
||||
<p class="px-2 py-2 text-xs text-gray-400">
|
||||
{stream.connected ? "还没有会话" : "正在加载会话…"}
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create a new session and join it (GM only) */}
|
||||
<Show when={role() === "gm"}>
|
||||
<div class="flex gap-1">
|
||||
<input
|
||||
type="text"
|
||||
value={newSessionName()}
|
||||
onInput={(e) => setNewSessionName(e.currentTarget.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
||||
placeholder="新会话名称"
|
||||
class="flex-1 border border-gray-300 rounded px-2 py-1.5 text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={busy() || !newSessionName().trim()}
|
||||
class="bg-blue-600 text-white rounded px-3 py-1.5 text-sm font-medium
|
||||
hover:bg-blue-700 disabled:opacity-50 whitespace-nowrap"
|
||||
>
|
||||
新建并加入
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={error()}>
|
||||
<p class="text-red-500 text-sm text-center">{error()}</p>
|
||||
</Show>
|
||||
|
||||
<Show when={sessionToDelete()}>
|
||||
{(target) => (
|
||||
<DeleteSessionDialog
|
||||
sessionId={target().id}
|
||||
sessionName={target().name}
|
||||
active={stream.sessionId === target().id}
|
||||
onClose={() => setSessionToDelete(null)}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user