Compare commits

...
229 Commits
Author SHA1 Message Date
hyper ae36125af0 docs(skill): add rules-writing conventions
Add rules.md covering document structure, theme/players/time header, icon-first mentions, veteran-first prose, and a closing timestamp. Link it from SKILL.md.
2026-10-24 16:51:59 +08:00
hypercross 2ba6ed74c7 fix(md-print): correct alert padding 2026-10-23 20:19:39 +08:00
hypercross 8957e3d2f1 feat(md-print): add timestamp footer option 2026-10-23 13:26:34 +08:00
hypercross 1ce76dca64 feat(md-print): use mm font and corner page numbers
Body font size is now specified in millimetres and defaults to 3mm. Page numbers move from the centre footer to the outer bottom corners, alternating left and right per A5 page.
2026-10-23 11:28:22 +08:00
hypercross 80da5364e5 docs: document md-print usage 2026-10-23 11:01:16 +08:00
hypercross 4a19336445 feat(md-print): add A4/A5 multi-column print layout
Add an md-print element that paginates markdown into A4 landscape sheets holding two A5 pages, each with configurable columns. The preview modal exports through the browser print dialog.

Paragraphs, lists, and tables split at word, item, and row boundaries; headings are kept with following content.
2026-10-23 11:00:59 +08:00
hypercross bd7871a800 feat(md-deck): rework layer align/orientation pickers
Replace the flat dropdown lists with 3x3 icon grids. Alignment uses
arrow icons for the 8 edge/corner positions and a center-align icon
for the middle cell; orientation places its 4 arrows on a cross. Cells
use fixed-width tracks so they no longer collapse inside the
shrink-to-fit dropdown.
2026-10-03 17:16:52 +08:00
hypercross c81cf221ef docs: correct stale routing and url references
Document that the CLI mounts the content directory at the URL root and
that the app uses History API routing, not the old /content hash scheme.
Replace the removed <Article> iconPath prop with the actual upward
assets/ lookup, and fix the deck_preview preview_url example.
2026-09-13 14:24:34 +08:00
hypercross e0e7250180 fix(dev): proxy content assets to cli server
The CLI content server serves the content directory at the URL root, so
images and other content assets are requested as root-relative paths
(e.g. /adventures/map.png). The dev proxy only forwarded /content, so
those requests fell through to historyApiFallback and the browser got
index.html instead of the asset.

Forward any request that maps to a real file under content/ to the CLI
server, keeping /static/ bundles on the web dev server.
2026-09-13 14:24:29 +08:00
hypercross 12f8d8542c feat(ui): make desktop sidebar collapsible
Add a header toggle and a panel close button so the desktop file
sidebar collapses with a width transition, mirroring the journal
panel.

- split onClose (close button) from onNavigate (file selection) so
  clicking a file no longer collapses the sidebar on desktop
- give the aside h-full so the file tree and outline keep bounded
  scroll regions inside the new wrapper
- swap the aside shadow for a gray right border to match the journal
2026-09-13 14:09:18 +08:00
hypercross 49bed7d279 docs: add icon migration reference
Record the Tabler pack choice, icon conventions, and the per-file
glyph-to-icon mapping for the unicode-to-solid-icons migration.
2026-09-13 14:01:10 +08:00
hypercross 40b6fbe157 feat(ui): replace unicode icons with solid-icons
Swap emoji and unicode glyphs for Tabler components so icons inherit
currentColor and font-size and render consistently across platforms.

- add src/components/doc-icons.ts to resolve doc-entry frontmatter icon
  keys to components; frontmatter now stores semantic keys
- replace the align-*.png assets with TbOutlineAlign* icons
- render RevealManager action SVGs as components instead of injected
  innerHTML strings
- carry completion icons via an optional CompletionItem.icon field
2026-09-13 14:01:03 +08:00
hypercross b5f25e6b60 build(deps): add solid-icons 2026-09-13 14:00:52 +08:00
hypercross be247cd48c chore: pin generated reference docs to LF
The generator emits LF, but with core.autocrlf=true git checked the files out as CRLF, causing the drift guard in known.test.ts to fail on Windows.
2026-09-13 13:43:54 +08:00
hypercross c3cdc724bd docs(skills): add git-commits skill
Document Conventional Commits style and one-commit-per-concern splitting for this project.
2026-09-13 13:42:00 +08:00
hypercross 421af125bb feat(journal): delete sessions from the ui
Add a confirmation dialog that spells out the removal is irreversible, and expose delete from the GM session dropdown and the lobby session list.
2026-09-13 13:36:42 +08:00
hypercross a65ca2ab93 fix(session): validate ids and repair deletion
Validate the id extracted from ttrpg/+/stream and ttrpg/+/meta before it is used to build a path, closing a traversal where a .. topic level (or ..\.. on Windows) could escape the sessions directory and delete the whole data root. Meta writes now go through the schema, so malformed payloads are ignored instead of deleting.

Deletion was also a no-op: an empty payload threw in JSON.parse before the delete branch ran, so sessions were never removed. Check for an empty/null payload before parsing. The worker validates ids and surfaces publish/connection failures.
2026-09-13 13:36:38 +08:00
hypercross 68b976575a feat(session): add shared session id validation
Session ids become directory names server-side and topic levels on the broker, so they must reject path separators, ./.., whitespace and Windows reserved device names. toSessionId derives a safe id from a display name; isValidSessionId is the boundary check shared by the CLI and the web client.
2026-09-13 13:36:33 +08:00
hypercross 30ec8a43cf fix(journal): decouple broker from session
The broker connection now persists across session changes. Joining or switching a session swaps subscriptions instead of reconnecting, and a lobby lets sessions be listed and created before joining one.

Fixes session switching being a no-op (the store only updated local state) and session creation being unreachable before connecting. Also removes the dead ConnectionBar and the no-op hydrateFromServer/setSessionId store calls.
2026-09-13 13:23:50 +08:00
hypercross 59f87bffda fix(journal): make session dirs source of truth
Replaces the central manifest.json with per-session meta.json so the filesystem is the single source of truth. Metadata writes are now atomic (temp file + fsync + rename), startup adopts orphan dirs, corrupt meta is quarantined instead of silently wiped, truncated stream tails are repaired, and appends to unknown sessions are dropped so deleted sessions cannot be resurrected.

Existing manifests are migrated to per-session metadata on first run.
2026-09-13 13:17:14 +08:00
hyper f9ea37427b docs(content): add inline spark-table example 2026-09-12 23:58:49 +08:00
hyper ebb49ddfca docs(todo): track variable store audit 2026-09-12 23:58:44 +08:00
hyper 322287da29 docs(reference): cover bare dice and tilde fences
Add the bare d-form (d20) to the dice notation table, and note that role fences accept backticks or tildes with hyphen-capable attribute keys. Regenerate both reference copies from known.ts and mirror the notes in the skill and markdown docs.
2026-09-12 23:56:12 +08:00
hyper 08bc97bec5 fix(markdown): accept tilde fences when scanning
Fenced block scanning was backtick-only in FENCED_BLOCK_RE (used by scanDoc) and in the yaml role=tag render extension, while stripFencedBlocks and the validator already accepted tildes. Match both marker styles and require the closing fence to reuse the opening marker.
2026-09-12 23:45:01 +08:00
hyper 264038e27c fix(markdown): keep hyphenated block attribute keys
The key pattern \w+ could not cross a hyphen, so data-config=foo was parsed as config=foo and the data- prefix was lost. Allow hyphens in keys, matching parseDirectiveAttrs.
2026-09-12 23:44:43 +08:00
hyper e838f9a4b1 fix(journal): tokenize bare dice notation
The tokenizer only recognized dice when the pattern started with a count (3d6), so a bare d20 fell through to the identifier branch and failed as an unknown identifier. Add a dedicated branch for the bare d-form; DICE_RE already accepted it.
2026-09-12 23:44:24 +08:00
hyper 3532be2335 fix(journal): re-publish only changed declarations
topoSortAffected followed declared dependencies even when they were not in the affected set, so reevaluateDependents re-evaluated and re-published variables that had not changed. This could also overwrite a value just set via /set with the declaration's own expression.
2026-09-12 23:44:06 +08:00
hyper f17f09a8c4 refactor(components): remove md-commander widget
The journal now owns command history and tracking, so retire the md-commander widget. Its dice engine was extracted to src/components/dice in the previous commit.

Removes the custom element, tracker/command stores, completions, useCommander, Emmet parser, the doc entry, test fixtures, and the known-element catalog entry (reference docs regenerated).

BREAKING CHANGE: :md-commander is no longer a known element; content using it now fails tttk check with TTTK-D001.
2026-09-12 23:29:26 +08:00
hyper 3508286792 refactor(dice): extract engine from md-commander
The dice engine lived under md-commander/hooks but is shared by :md-dice, spark tables, the journal /roll command, and dice inside variable expressions. Move it to src/components/dice so the md-commander widget can be retired without losing rolling.

Also promote the browser-console harness to a real jest suite; the file was named test.ts, so jest never ran it.
2026-09-12 23:14:28 +08:00
hyper 0b029b325e docs(declare): document the YAML declare format
Update the journal-set doc entry, the CLI diagnostic table, and the
authoring skill, along with the in-app empty-state hint and the var
message comment.
2026-09-12 22:51:12 +08:00
hyper 0ac9a833e4 docs(reference): regenerate declare block docs
Regenerate `docs/reference.md` and the skill copy from `known.ts`.
2026-09-12 22:51:00 +08:00
hyper fadfd4cba6 feat(declare)!: parse role=declare blocks as YAML
Replace the tag/threshold/key/expr CSV body with YAML `vars:` and
`tags:` maps. `parseDeclare` validates the body with an exported Zod
schema and returns the same declaration and modifier shapes the
reactivity engine already consumes.

The breaking rename from `parseDeclareCsv` updates the registry, the
journal re-exports, and the `tttk check` role checks, where R003 now
reports schema errors with their path and R004 warns when a block
declares nothing.
2026-09-12 22:50:48 +08:00
hyper 16f2038fd8 fix(journal): unify $var lookup and error
EvalContext.lookup now receives the full $var name, matching getCombined,
setBase, and getMods, and parseFactor throws "Unknown variable" when the
lookup returns undefined. /set and /roll use the new resolveVariable() so a
typo errors instead of silently evaluating to 0.

Declarations and tag modifiers stay lenient (unknown -> 0): the documented
$hp = $con*5+$mod_hp pattern depends on external/undeclared variables
defaulting to 0.
2026-09-12 22:34:12 +08:00
hyper 02442a1100 fix(journal): re-init reactivity and drop var del
Make the reactivity engine re-initialize when the loaded declarations
change instead of only at import. ensureReactivity() reads the live
completions signal, compares a signature, and rebuilds from the store
(with exact bases) only when needed; dispatchCommand calls it before each
dispatch. Remove the unused DispatchContext.declarations/tagModifiers
fields, which were mount-time snapshots and could not be trusted.

Build initReactivity's maps into locals and commit only after the
circular check passes, so a failed re-init leaves the previous engine
intact; clear inFlight on commit.

Drop the unreachable var "del" action from the schema, render, and
reducer.
2026-09-12 22:29:25 +08:00
hyper 9f372fa2b0 fix(journal): surface variable eval errors
Collect and report failed declarations, failed tag modifier expressions, and
circular dependencies instead of swallowing them. Diagnostics are deduped,
logged with key/expression context, cleared on re-init, and exposed through
useJournalCompletions and the VariableView warning banner. A bad declaration
no longer aborts the rest of a cascade.

Reject malformed numeric literals in the tokenizer so inputs like "1.2.3"
throw instead of being truncated by parseFloat.
2026-09-12 22:22:18 +08:00
hyper a26d53cbeb fix(journal): keep variable bases in the stream
The stream only carried combined values, so hydrating a session lost the raw base and rebuildReactivityFromStore re-applied tag modifiers on top of already-combined values (e.g. $hp 70 became 90).

Publish the raw base alongside the combined value on var messages, persist it in variableBases, and rebuild engine state from exact bases. Derive tag activations from base tagmaps so tagmap-valued modifier targets are not re-triggered on every rebuild.

Seed declared variables only after hydration and only for keys absent from the store, so persisted values are no longer clobbered on load and reloads stop appending duplicate var messages.
2026-09-12 21:59:45 +08:00
hyper 0ce91afc49 feat(journal): show spark roll dice and markdown
The spark message never displayed the dice, and its rollResult came from a
second, independent roll that could disagree with the row actually
selected.

rollSparkTable now reports the roll(s) it used, so the message can show
'header / notation -> roll' in the purple box and render each rolled value
as markdown (its own div) below it.
2026-09-12 20:43:03 +08:00
hyper c7f3b3425b fix(dev): proxy content registry to CLI server
Inline spark tables (role=spark-table) live in the content registry, but the
rsbuild dev proxy forwarded only /__COMPLETIONS.json and
/__CONTENT_INDEX.json. The registry fetch fell back to the SPA index.html,
so /roll resolved the inline CSV as HTML and csv-parse failed with an
invalid opening quote.

Load the registry before publishing the loaded completions state so a roll
cannot fire while inline content is still unresolvable.
2026-09-12 20:30:57 +08:00
hyper b5591f2a78 chore(cli): ignore .ttrpg session logs 2026-09-12 20:13:53 +08:00
hyper 2ed69d744a chore(dev): Add dev runner and split dev ports
Add dev:web (rsbuild) and dev:cli (tttk serve ./content), with dev
running both via scripts/dev.mjs, which shuts both down when either
exits. Also add tttk:inspect for --inspect debugging.

Both servers previously defaulted to port 3000, so the CLI could not
bind while the web dev server was running and the proxy targeted
itself. Pin the web dev server to 5173; the CLI keeps 3000 to match
the proxy. Document the two-process setup and inspector debugging in
docs/development.md.
2026-09-12 20:11:34 +08:00
hyper 53620e0a92 docs(doc-entries): Show examples as code blocks
Directive examples sat bare in the doc bodies, so rendering them as
markdown executed the directives: live components, missing-file
fetches, and style side effects on the dialog. Wrap each example in
a markdown fence, matching the convention already used by md-table.
2026-09-12 19:58:37 +08:00
hyper a752fdc2f4 fix(DocDialog): Render doc bodies as markdown
DocContent rendered the raw body in a whitespace-pre-wrap div, so
markdown syntax and fenced code blocks appeared literally. Parse
through the shared markdown pipeline and style with prose.

Wrap the content in an <article> so md-bg and md-border, which
resolve element.closest("article"), get a valid style target
instead of throwing.
2026-09-12 19:48:57 +08:00
hypercross d8ccaecade chore(skill): Own tttk-authoring in the repo
Move the skill to .agents/skills/tttk-authoring and point gen:skill-reference at it. Activate it by symlinking ~/.agents/skills/tttk-authoring to the in-repo copy.

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

The skill's reference.md is now generated; its extra authoring content moved to layout.md, yaml-tag.md, csv.md, and markdown.md.
2026-09-12 17:13:42 +08:00
hypercross 71542e7453 feat(cli): Complete the check rule catalog
Add D002 (malformed attribute blocks), R004 (skipped declare rows), and V001-V005 (CSV data files). CSV checks run once per referenced file and also cover inline spark-table bodies.

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

Covers the whole-tree registry pass, raw-source diagnostics with line numbers, the rule catalog, and exit codes, plus resolved decisions on the element catalog, mermaid refactor, icon search scope, and CSV checks.
2026-09-12 16:10:28 +08:00
hypercross 9b83123b6e fix: heading scan skips code fences; unify content resolution
- deriveLinks strips fenced code blocks so markdown examples don't
  produce phantom link completions
- Add resolveContentEntry returning body + path + inline flag;
  scanDocDirectives uses it instead of re-deriving resolution, and
  parseDirectiveAttrs is replaced by the shared parseBlockAttrs
- Fix relative file refs with ./ prefix never resolving through
  resolveContent (pathIndex lookup joined the ./ verbatim)
2026-09-08 22:30:14 +08:00
hypercross b7a804f1cf refactor: remove as= override and render data-spark at runtime
- Drop resolveBlockAs and the as= attribute; scanDoc now switches
  directly on role and warns on unknown roles instead of silently
  stripping
- md-table derives data-spark from its loaded CSV via
  parseSparkTableCsv; delete injectSparkDirectives and the second
  registry pass so pathIndex is exactly scanDoc output
- Align spark-table dice header regex with isSparkTableHeader
2026-09-08 22:18:12 +08:00
hypercross 256c685f6c refactor: remove implicit content sniffing and table conversion
- Split loadCSV into parseCSVString (content) and loadCSVFromPath
  (path); drop isCSV/looksLikeCsv heuristics
- Delete coerceSparkTables and markedTable label-header magic;
  plain markdown tables now render as plain tables
- Add explicit markdown role=spark-table fence syntax that converts
  pipe tables to CSV at scan time, with dice-header validation
- Map ESM-only github-slugger and csv-parse browser build to CJS in
  jest config; add content-registry tests
2026-09-08 21:33:12 +08:00
hypercross 5061c4d19d docs: remove remaining yaml/tag legacy references 2026-09-08 20:59:30 +08:00
hypercross 638e8f6526 refactor(markdown): unify yaml tag block syntax on role=tag
Drop legacy yaml/tag info string; tag blocks now require
```yaml role=tag. Add fence-line tag=/id= attributes that
override YAML body values, and move parseBlockAttrs to
src/markdown/block-attrs.ts so scanner and render extensions
share one parser.
2026-09-08 20:56:14 +08:00
hypercross 80e2b5b209 fix(yaml/tag): prevent yaml role=tag from being stripped & generalize
data-config
2026-09-03 10:39:05 +08:00
hypercross 5fc2bc5262 feat(md-deck): add vertical alignment options to layers
Expand align from l/c/r to a 3x3 grid with vertical prefixes:
tl/tc/tr/bl/bc/br. Vertical position renders via flex
justify-content; compact layers strings and YAML both parse the
new values, and the layer editor dropdown gains corner/edge
options.
2026-09-03 10:17:44 +08:00
hypercross d75df5280f feat(markdown): support yaml role=tag info string
Accept yaml role=tag alongside legacy yaml/tag so the block
body gets yaml syntax highlighting, matching the CLI role=
attribute convention. Copy button now emits the role=tag form.
2026-09-03 10:10:06 +08:00
hypercross f1ddc8fb8e refactor(md-deck): pos string and yaml copy output
Replace per-axis x1/y1/x2/y2 layer fields with a single
pos string (x1,y1-x2,y2) matching the compact layers format.

When a deck is configured via a yaml/tag codeblock (data-config),
the copy button now emits a yaml/tag block serialized from current
store state so template layers round-trip.
2026-09-02 18:30:32 +08:00
hypercross 16dfc8a88c feat(md-deck): support structured data-config with template layers
Allow md-deck layers to render markdown templates via {{var}}
substitution instead of only CSV props. Add config.ts to normalize
a JSON data-config attribute (size, grid, shape, layers) emitted by
the yaml code-block tag, taking precedence over legacy string props.
2026-09-02 18:15:51 +08:00
hypercross 023d3a0cb9 style(md-embed): black text and prose-sm 2026-09-01 15:50:50 +08:00
hypercross 7c4865ba8c feat(journal): var stuff 2026-09-01 15:39:36 +08:00
hyper 49260ae4f6 fix(md-embed): proper font size/weight inheritence 2026-08-29 16:23:43 +08:00
hyper 750af4a50c refactor(markdown): Simplify table header detection logic
Remove unused `isSparkTableHeader` import and simplify condition
since spark tables are handled upstream by `coerceSparkTables`.
2026-08-07 13:21:11 +08:00
hyper c03528a293 feat: Unify spark table detection and content resolution
Export isSparkTableHeader from content-registry and use it in both CLI
and frontend table parsing. Add resolveContentRef to resolve content
references consistently. Write processed registry content back to the
file index so browser mode renders identically to CLI mode.
2026-08-07 13:15:51 +08:00
hyper 0a68122efd refactor(cli): centralize content registry building
Extract buildRegistryFromIndex and normalizePathKey from the CLI serve
command and the browser completions module into the shared
content-registry module. This ensures both paths produce identical
pathIndex/docContent, including the spark-injection pass that was
previously only applied in the CLI.
2026-08-07 12:46:08 +08:00
hyper e8aa7165cb refactor(completions): centralize content scanning
Add a browser-safe ContentRegistry owning path and inline content
stores, then derive completions from it. Remove the old block-processor,
directive-scanner, and link-source modules in favor of scanDoc,
deriveCompletions, and injectSparkDirectives, and register an inline
resolver for the frontend file index.
2026-08-07 12:17:35 +08:00
hyper 9e081891df feat(md-deck): Add fallback copy dialog for clipboard failure 2026-08-07 12:13:40 +08:00
hyper e3daac3080 fix(md-deck): Make active card state reactive
Wrap active in a getter so it updates when activeTab changes.
2026-08-07 12:07:54 +08:00
hyper a69a41875d fix: Prevent layer interactions when not editing
Guard layer selection, cursor, and resize interactions behind
the editing state to avoid unintended modifications in view mode.
2026-08-07 11:24:57 +08:00
hyper 6bcdca3a77 feat(md-deck): Add card list navigation and tabbed editor panels
- Move card tab selector to a left sidebar (CardList) with pagination
- Replace separate side toggle and layer panels with EditorTabs
- Move copy code button from layer panel to DeckHeader
- Simplify PropertiesEditorPanel by removing front/back toggle and
  using a select for shape
2026-08-07 11:20:26 +08:00
hyper 18cad20d2c feat: add PLT double cut option to plotter export 2026-07-23 20:08:51 +08:00
hypercross 8c9506c106 fix: strip .md extension from resolved article paths
Ensures that navigation to markdown files strips the extension,
allowing the SPA fallback to handle refreshes instead of requesting
the raw file.
2026-07-14 15:56:38 +08:00
hypercross a934901cce feat: add heading flash animation on hash change 2026-07-14 15:38:39 +08:00
hypercross d78d15d8ec fix(FileTree): use base path for anchor navigation 2026-07-14 15:23:30 +08:00
hypercross f877a58a31 fix(Article): resolve relative hrefs during navigation 2026-07-14 14:18:26 +08:00
hypercross 99dd49b077 refactor: simplify DocDialog and move documentation to markdown body
Simplify the `DocDialog` component by removing hardcoded property
tables and syntax blocks. Instead, rely on the raw markdown body
provided in the `DocEntry` to render documentation content. This
shifts the responsibility of formatting (syntax, props, etc.) from
the component to the individual markdown files, allowing for more
flexible and detailed documentation.
2026-07-13 15:29:08 +08:00
hypercross 7cb28e631e feat: add threshold support and tagmap support to variable system
Introduce a `threshold` column to tag modifiers in CSV declarations,
allowing modifiers to activate only when a tag's count meets a minimum
requirement.

Implement support for "tagmap" variables (e.g., `#warrior:1;#druid:2`)
which allow for complex, multi-tag state tracking. These variables
can receive specialized tagmap modifications rather than simple
numeric additions.
2026-07-13 15:09:50 +08:00
hypercross d74ba69fdf refactor: merge /spark command into /roll
Consolidate the `/spark` command into `/roll` to simplify the CLI.
The `/roll` command now handles both dice notation and spark table
slugs.

- Update `parseInput` to treat `/roll` arguments as a generic string.
- Update `dispatchCommand` to check for spark table matches before
  evaluating dice notation or variable expressions.
- Update command completions to show both dice and spark table
  suggestions under `/roll`.
- Update documentation and UI placeholders to reflect the change.
2026-07-13 14:43:55 +08:00
hypercross c4038a5213 refactor: clean up unused imports and styles
- Remove unused `MessageTypeDef` imports in `ComposePanel` and
  `DynamicForm`
- Remove unused `schema` memo in `DynamicForm`
- Update `FieldInput` label class to use `shrink-0` instead of
  `flex-shrink-0`
- Exclude test files from `tsconfig.cli.json`
2026-07-13 13:32:49 +08:00
hypercross ffa023b92a refactor: optimize reactive variable updates and parsing
- Replace `scanDeclareBlocks` with `parseDeclareCsv` in block processor
- Add error handling for declare block parsing
- Optimize `ReactiveVariableManager` by pre-computing variable keys
- Store template text and keys in data attributes for faster updates
- Adjust `VariableView` popup width via CSS class
2026-07-13 11:13:07 +08:00
hypercross c106b6f79c test: add variable system tests and jest config updates
- Add comprehensive tests for variable system, including parsing,
  expression evaluation, and reactivity.
- Add `moduleNameMapper` to `jest.config.js` to resolve `.js` imports
  to `.ts` source files.
- Add a mock for `csv-parse/browser/esm/sync` to support Node-based
  testing.
2026-07-13 11:08:48 +08:00
hypercross 2c762b0411 refactor: move block scanning logic to shared module
Import `FENCED_BLOCK_RE` and `parseBlockAttrs` from `block-scanner.js`
instead of defining them locally in `declare-parser.ts`.
2026-07-13 10:49:40 +08:00
hypercross f172da378a refactor: unify declare block scanning between CLI and client
Introduce `scanDeclareBlocks` in `declare-parser.ts` to provide a
single source of truth for parsing `role=declare` blocks. This
replaces redundant scanning logic in both the CLI and the journal
completions.

Also remove unused `dice.ts` and `spark-scanner.ts` files.
2026-07-13 10:43:44 +08:00
hypercross 3137970b97 feat: rebuild reactivity state from store after replay
Add `rebuildReactivityFromStore` to synchronize local reactivity
state with the hydrated variable store. This ensures tag activations
are correctly tracked following a reducer replay.

Also remove the unused `invalidateCompletions` function.
2026-07-13 10:34:44 +08:00
hypercross 6f01a29723 refactor(journal): simplify fallback variable retrieval
Return the fallback value as-is instead of applying local
modifications, as the stream value is already authoritative for
untracked variables.
2026-07-13 10:24:33 +08:00
hypercross 4c2eb65470 refactor: pass variable store explicitly to getCombined
Remove the mutable `streamFallback` global state and instead pass the
`VariableStore` as an explicit argument to `getCombined` and related
functions. This ensures correctness by avoiding reliance on mutable
module state during reactivity calculations.
2026-07-13 10:22:22 +08:00
hypercross c524dd5867 refactor: implement base/modifier separation for variable reactivity
Introduce a distinction between base values (set via `/set` or
declarations) and active modifiers (applied via tags). This ensures
tag transitions and numeric modifiers are calculated correctly during
cascades.

- Refactor `VariableView` to use a flat list with hover popups for
  declarations and active modifiers.
- Update `command-dispatcher` to use `getCombined` for accurate
  value lookups and `setBase` for variable updates.
- Update `var-reactivity` to manage `baseValues`, `activeMods`, and
  `sourceActivations` separately.
- Export new utility functions for accessing combined values,
  modifiers, and declaration expressions.
2026-07-13 10:10:47 +08:00
hypercross 411f4d79ff fix(journal): update command trigger condition
Change the command prefix check from "/stat" to "/set" in JournalInput.
2026-07-13 09:41:27 +08:00
hypercross ae44191b28 refactor: use csv-parse for declare block parsing
Replace manual CSV splitting with `csv-parse` to allow for
order-agnostic columns and better handling of quoted values.
The `tag` column is now optional.
2026-07-13 09:38:09 +08:00
hypercross a2d9605f4b feat(journal): seed declared variables on initialization
Implement `computeInitialValues` to calculate the starting state of
declared variables and seed them into the journal stream store during
the completions initialization process.
2026-07-13 01:07:13 +08:00
hypercross c9c977bee1 refactor: remove unused unsubscribe function from journalStream 2026-07-13 00:54:20 +08:00
hypercross 960e310208 feat(dev-server): enable historyApiFallback 2026-07-13 00:49:24 +08:00
hypercross 981c829d0f refactor: replace stat-parser with declare-parser
Replace the old stat-parser logic with a new declare-parser to handle
variable declarations and tag modifiers. This includes moving the
declare-parser logic to a shared location in cli/completions to avoid
duplication between the CLI and the journal component.
2026-07-12 19:14:35 +08:00
hypercross dbe2f9d9d8 docs: add new journal command documentation
Replace the attribute system documentation with new documentation for
the variable system, roll command, link command, and spark command.
2026-07-12 19:09:02 +08:00
hypercross 894f1735e5 refactor: replace stat system with variable system
Replaces the specialized `stat` system with a more general-purpose
variable system. This includes:

- Renaming `ReactiveStatManager` to `ReactiveVariableManager`
- Changing template syntax from `${key}` to `{{$key}}`
- Replacing `StatsView` with `VariableView` to show declared, plain,
  and tag-typed variables
- Updating command `/stat` to `/set`
- Refactoring the reactivity engine to support variable declarations
  and tag modifiers via `csv role=declare` blocks
2026-07-12 17:29:03 +08:00
hypercross 2983aa4440 feat: implement variable reactivity and expression engine
Add support for `role=declare` blocks in markdown to allow for
dynamic variable declarations and tag-based modifiers.

- Implement `declare-parser` to parse CSV-formatted declaration blocks.
- Implement `var-reactivity` to manage dependency graphs, topological
  sorting, and cascading updates when variables change.
- Implement `variable-expression` to evaluate arithmetic, dice rolls,
  and math functions within expressions.
2026-07-12 16:52:04 +08:00
hypercross 86abf34c10 feat: implement shared dispatch error signal
Introduce a shared `dispatchError` signal in the command dispatcher
to unify error reporting across different components. This allows
`CommandLinkManager` to surface errors through the `JournalInput`
UI, ensuring that errors from `:cmd[]` directive clicks are visible
to the user.
2026-07-12 14:38:06 +08:00
hypercross ffbfa65716 refactor: extract command dispatch logic to shared module
Move command parsing and dispatching logic from `CommandLinkManager`
and `JournalInput` into a new `command-dispatcher.ts` module. This
unifies how commands are handled whether they are typed manually or
triggered via `:cmd[]` directive clicks.
2026-07-12 14:26:32 +08:00
hypercross d690d5922e refactor: simplify command dispatch and update cmd-link
- Simplify `dispatchCommand` by removing role-based logic and ensuring
  commands are prefixed with a slash.
- Change `:cmd[]` directive output from a `span` to an `a` tag.
- Update `.cmd-link` styles to improve link appearance and prevent
  text selection.
2026-07-12 14:18:01 +08:00
hypercross 42e8971ff7 refactor: replace command link interception with :cmd[] directive
Replace the previous mechanism of intercepting specific command-prefixed
anchor tags with a dedicated `:cmd[]` markdown directive.

- Implement `cmdDirective` for `marked-directive` support.
- Update `CommandLinkManager` to listen for clicks on `[data-cmd]` spans
  instead of parsing `href` attributes.
- Add CSS styles for the new `.cmd-link` class.
2026-07-12 14:05:07 +08:00
hypercross 182d7ff28d feat: add CommandLinkManager to intercept command links
Implement a component that uses event delegation to intercept clicks
on markdown links starting with command prefixes (e.g., >roll, >spark).
It dispatches these as journal stream messages based on the user's
role (Observer, Player, or GM).
2026-07-12 13:48:34 +08:00
hypercross 736bcf4bb2 feat: add ReactiveStatManager for markdown template support
Introduce ReactiveStatManager to allow dynamic stat replacement within
rendered markdown content. This component scans the article DOM for
`${key}` patterns and reactively updates them based on the journal
stream.

As part of this change, the SVG-based StatSheet system is removed in
favor of this more flexible text-based approach.
2026-07-12 13:32:49 +08:00
hypercross 722a8110e6 feat(file-tree): use anchor tags for better navigation
Convert div elements to anchor tags in FileTree and HeadingNode to
enable native browser features like middle-click to open in new tabs.
The implementation preserves SPA navigation for left-clicks while
ensuring search parameters are preserved in the href.
2026-07-12 10:46:35 +08:00
hypercross 40f2190307 feat: sync player name and role on state patch
Persist name and role to localStorage and URL parameters when
received via patch to ensure synchronization. Broadcast full state
upon successful connection.
2026-07-12 10:39:53 +08:00
hypercross 241c8609f1 refactor: expose Comlink API via MessagePort in SharedWorker
Update the worker to use the 'onconnect' event to handle incoming
MessagePorts, ensuring each connecting tab receives its own port.
2026-07-12 10:14:36 +08:00
hypercross 2187b7ed82 feat: implement journal stream via Shared Worker
Move MQTT connection and message log management to a Shared Worker
using Comlink. This allows multiple browser tabs to share a single
connection and synchronized state.
2026-07-12 10:08:14 +08:00
hypercross fc6e37a13d fix(journal): update min-height for JournalInput textarea 2026-07-12 09:46:11 +08:00
hypercross e46cc879ae refactor: remove mothership journal module 2026-07-12 00:37:10 +08:00
hypercross 2068ecad10 feat: add remix mode for spark table rolling
Introduces a `remix` option that allows each data column in a spark
table to be rolled independently. This is enabled via a `remix=true`
attribute in the directive's extra attributes.

Also refactors spark table scanning by moving markdown parsing logic
from the frontend to a new CLI-side `spark-scanner.ts` utility,
improving separation of concerns.
2026-07-11 11:58:01 +08:00
hypercross 719bb6ad8a refactor: use CSV path instead of markdown file for spark tables
Switch the spark table resolution logic to use the direct path to the
backing CSV file rather than attempting to parse the containing
markdown file. This simplifies the lookup process and improves
reliability.

Also refactor `SparkTableMeta` to store rows as objects keyed by
header instead of raw string arrays.
2026-07-11 11:45:38 +08:00
hypercross b8cfb05e53 feat(journal): add ErrorPopup to display full error messages
Introduces an ErrorPopup component that allows users to view the
complete error message by clicking on the truncated error text in
the JournalInput.
2026-07-11 10:00:05 +08:00
hypercross 841fbc7bae refactor: simplify spark table detection logic 2026-07-11 09:56:18 +08:00
hypercross 6d0bd75cb6 refactor: simplify button positioning in RevealManager
Remove unused scroll container dependency and use relative rect
coordinates for button positioning.
2026-07-11 09:49:48 +08:00
hypercross e437d20d73 feat: update session creation to hydrate data immediately
Update `createSession` to return the session ID, allowing the UI to
automatically set the active session and trigger a server hydration
immediately after creation. This ensures the UI reflects the new
session state without a manual refresh.
2026-07-11 09:32:26 +08:00
hypercross f71af6a06d fix: update proxy path and MQTT broker URL in dev
Change the proxy path from `/journal` to `/.ttrpg` and ensure the
MQTT broker URL points to localhost:3000 during development to
match the CLI server configuration.
2026-07-11 09:29:16 +08:00
hypercross 75e862c310 feat: treat doc-entries markdown files as asset/source 2026-07-11 09:27:57 +08:00
hypercross c661d2a1d2 refactor: replace webpack context loading with dev server proxy
Remove the webpack-based file indexing strategy in favor of a proxy
approach. The dev server now proxies content requests to a local CLI
server, simplifying the data loading logic and removing webpack-specific
types and loaders.
2026-07-11 09:19:41 +08:00
hypercross 52563fd3ef fix(journal): close completions on Enter key press 2026-07-10 15:57:35 +08:00
hypercross 9790205468 style: remove hardcoded text color from Article components 2026-07-10 15:56:51 +08:00
hypercross d4ebca5fd0 refactor: move spark table parsing to directive scanner
Relocate spark table logic from the block processor to a dedicated
directive scanner. This allows spark tables to be treated as
`:md-table` directives and ensures they are processed in a second pass
after the content index is fully populated.
2026-07-10 15:55:45 +08:00
hypercross 89b32ef15e chore: rename project to Tabletop Toolkit
- Update package name and binary from ttrpg-tools/ttrpg to tttk
- Update UI titles in App.tsx and index.html
- Add Node.js engine requirement to package.json
2026-07-10 11:35:43 +08:00
hyper 3a20e9558b chore: Prepare package for npm publication 2026-07-09 16:23:40 +08:00
hyper c071cad50a refactor: Reformat code and update z-index class
Reformat md-table.tsx to use double quotes and consistent code style.
Change z-index class in PrintPreview from `z-[60]` to `z-60`.
2026-07-09 16:03:41 +08:00
hyper a20063c624 refactor(cli): Extract shared deck and frontmatter utils
Create deck-utils.ts and frontmatter/shared.ts to consolidate
duplicated logic. Rename ensure-deck-preview.ts to preview-deck.ts.
2026-07-09 14:31:08 +08:00
hyper 06c3916a95 feat: rename project to TTTK
- Update package name and CLI binary to 'tttk'
- Revise documentation to reflect new name
- Add default serve behavior when no subcommand is given
- Add directory existence check in serve command
- Refactor inline comments to Chinese
2026-07-09 14:14:45 +08:00
hypercross c38d9bdc4f feat(journal): improve sheet rendering and URL persistence
- Update SheetView to use full-width SVG and auto-height for better
  scaling
- Center template nodes in SheetView to maintain text alignment
- Enable overflow-y-auto in SheetView to support long sheets
- Persist selected sheet ID in URL search parameters in StatsView
2026-07-09 12:43:45 +08:00
hypercross fe87b1f80c feat(journal): support <tspan> in stat sheet templates
Update the stat sheet parser to scan <tspan> elements within <text>
nodes. This allows for individual binding of template patterns to
specific tspans, preserving their position attributes.
2026-07-09 12:30:36 +08:00
hypercross 30d1c5dc1b feat: add support for stat sheet SVG rendering
Introduces the ability to discover and render `*.sheet.svg` files.
These files can contain `<text>` elements with `${key}` templates
that reactively update based on the current journal stats.

- Add `StatSheet` type and CLI scanning logic
- Implement `SheetView` for rendering SVGs with live bindings
- Add `parseSheet` utility to extract template patterns from SVG
- Update file indexer to include `.svg` files
2026-07-09 12:04:25 +08:00
hypercross 4f3b03d082 feat: support custom labels for stat modifiers
Allows passing an optional label to `parseStatModifiers` to override
the default ID-based label. This enables more descriptive names in
the UI and improves how modifier labels are derived in the journal
view.
2026-07-09 11:28:17 +08:00
hypercross ef71a43f0a feat: add stat-modifiers role for automatic stat generation
Introduces a new `stat-modifiers` CSV role that simplifies the
creation of template stats and their associated modifier stats.
A single CSV block now automatically generates:
- A template stat definition.
- Multiple modifier stat definitions (prefixed with the template ID).
- The corresponding template table.

This reduces the need for manual YAML definitions for every
modifier associated with a template.
2026-07-09 11:16:39 +08:00
hypercross d83256e049 docs: refactor journal-stat documentation structure 2026-07-09 10:59:37 +08:00
hypercross a4cfcde613 refactor: consolidate block processing logic
Introduce a centralized `block-processor` and `block-scanner` to
handle markdown fenced code blocks. This replaces the previous
`inline-blocks.ts` and individual completion sources with a unified
approach that handles block stripping, directive replacement, and
metadata extraction (stats, templates, and spark tables) in a single
pass.
2026-07-09 10:56:54 +08:00
hypercross 1c69bf394c feat: add support for stat templates
Introduces a new `template` stat type that allows for table-based
lookups via CSV blocks in markdown files. When a template stat is
rolled, it uses a dice expression defined in the template to select
an entry, applies a label, and automatically updates associated
modifier stats using relative values.
2026-07-09 10:26:54 +08:00
hypercross 19c66640b5 refactor: extract stat parsing logic to shared module
Move YAML and CSV stat parsing logic from CLI and journal components
into a new shared `stat-parser.ts` module to enable reuse between
the CLI scanner and the client-side frontend.
2026-07-09 10:00:17 +08:00
hypercross ef8b56e5b6 feat: add stat completion source
Implement a new completion source that extracts stat definitions
from markdown files using `yaml` or `csv` code blocks with
`role=stat`.
2026-07-09 09:52:34 +08:00
hypercross 45cd9a01ac feat: add CSV support for journal stat blocks 2026-07-09 09:47:17 +08:00
hypercross 138c089514 feat: implement scoped stat system with player/global support
Introduces a scoping mechanism for statistics, allowing properties
to be defined as either "player" (prefixed with player name) or
"global".

- Adds `scope` property to StatDef
- Implements key resolution to handle bare keys vs full keys
- Updates `canModifyStat` to enforce player-specific permissions
- Enhances `makeStatLookup` to resolve scoped references in formulas
- Adds documentation for the new stat system
2026-07-09 09:41:16 +08:00
hypercross 44e4e15f3f feat: implement command autocomplete for journal input
Refactor journal input command parsing into a dedicated module and
add a `CompletionsDropdown` component to provide real-time suggestions
for commands (/roll, /spark, /link, /stat) and their arguments.
2026-07-09 09:31:19 +08:00
hypercross 9747409a1f feat: implement stat system and StatsView
Introduces a comprehensive stat management system including:
- `/stat` command support (set, del, roll)
- `StatsView` component for displaying grouped stat tables
- Formula evaluation for derived stats (supporting arithmetic and
  functions)
- Stat definition parsing from YAML blocks
- Permission checking for stat modification based on roles
2026-07-09 09:22:14 +08:00
hypercross 4faff9a391 feat(journal): allow players to emit roll messages 2026-07-09 09:06:25 +08:00
hypercross ae3045f96b fix(cli): preserve inline blocks without file or as attributes 2026-07-08 16:49:01 +08:00
hypercross 6ab6b76978 feat: pass source path to markdown parser for icon resolution
Pass the file's base path through the markdown parsing pipeline to
allow the icon directive to resolve relative asset paths correctly
using an absolute fallback chain.
2026-07-08 16:38:19 +08:00
hypercross 2dba19e1f8 feat: implement icon directive with relative path fallback
Refactor icon rendering to use a custom `marked-directive` instead of
a global icon prefix. This allows icons to use a CSS-based fallback
chain (searching up to 10 directory levels for an `assets/` folder),
making icon paths more robust and removing the need to pass path
prefixes through the component tree.
2026-07-08 16:24:09 +08:00
hypercross 749b1a6a4b feat: support multiple attributes and as directive in inline blocks
Update the inline block parser to support arbitrary key-value pairs.
If an `as` attribute is provided, the block is replaced with a
directive (e.g., `:directive[path]{attrs}`) instead of being
stripped. If no `as` attribute is present, the block is removed.
Additionally, filenames are now automatically generated using a
content hash if the `file` attribute is missing.
2026-07-08 16:02:19 +08:00
hypercross 2a9eee6410 feat: extract and index inline file blocks in markdown 2026-07-08 15:46:19 +08:00
hypercross 9a5ee695c2 docs: clarify dev environment index loading behavior
Update file-index.ts documentation and implementation to specify
that webpackContext loading is restricted to development mode.
This ensures the code block is tree-shaken during production builds.
2026-07-08 15:19:50 +08:00
hypercross e0c3b8f4a0 feat: add bundle analysis support
Add `webpack-bundle-analyzer` and a new `analyze` script to allow
visualizing bundle composition via `npm run analyze`.
2026-07-08 15:16:12 +08:00
hypercross d95615f210 refactor: remove token and token-viewer components
Remove the `md-token` and `md-token-viewer` components along with their
associated 3D generation and image tracing utilities. Update
documentation and project metadata to reflect these changes and rename
the project title to "Tabletop Tools".
2026-07-08 15:09:22 +08:00
hypercross 5097aba842 refactor: implement scroll container context and flex layout
Switch from fixed positioning to a flexbox-based layout for the
main application structure. This introduces a `ScrollContainerCtx` to
provide access to the main scrollable element, allowing child
components like `FileTree` and `RevealManager` to perform accurate
scrolling and positioning relative to the container instead of the
window.

refactor: set JournalPanel height to full
2026-07-08 15:03:31 +08:00
hypercross b54f7ab1fa chore: add uuid dependency 2026-07-08 11:48:25 +08:00
hypercross c13f66153a feat: implement session creation flow
Refactor `CreateSessionDialog` to be controlled by its parent instead
of using an internal `open` prop. Lift the state up to `JournalPanel`
and pass the creation callback through `JournalHeader` to
`SessionDropdown`.
2026-07-08 00:39:29 +08:00
hypercross 376dde44b5 feat: add range support for spark table cell matching 2026-07-08 00:37:01 +08:00
hypercross 03671583cd feat: allow closing dialogs by clicking backdrop 2026-07-08 00:35:01 +08:00
hypercross 2c2b7b781d feat(journal): implement session management UI components
Add a suite of dialogs and header components to manage journal
sessions, including:

- ConnectDialog for joining sessions with name and role selection
- CreateSessionDialog for starting new sessions
- InviteDialog for generating and copying player invite links
- SessionDropdown for switching between sessions (GM only)
- JournalHeader for displaying connection status and user info
2026-07-08 00:31:35 +08:00
hypercross 3d957706e9 feat(journal): add JournalContext to allow closing panel on mobile 2026-07-08 00:11:16 +08:00
hypercross 57ffb35f2b feat: publish initial manifest on startup and improve UI focus
- Publish the existing session manifest via the broker on startup
- Automatically focus the new session input field when it is shown
- Prevent event propagation when clicking to create a new session
2026-07-08 00:08:12 +08:00
hypercross 3eba8b3279 fix(ui): increase z-index for DocDialog elements
Update z-index from 50 to 60 for the dialog overlay and dropdown
to ensure they appear above other UI components.
2026-07-07 23:59:57 +08:00
hypercross c10b088fb1 feat: add Journal server documentation to DocDialog
Introduce a new documentation set for Journal server functionality,
including a dropdown selector in the DocDialog to switch between
"directives" and "journal" documentation modes.
2026-07-07 23:57:31 +08:00
hypercross 1ef41f04fc feat: localize journal components to Chinese 2026-07-07 23:51:35 +08:00
hypercross 9a41f541e9 feat: improve server startup logs and simplify journal input labels
- Add logic to detect the best LAN IPv4 address for easier network
  access
- Display both localhost and LAN access URLs in the CLI
- Remove file path from journal input autocomplete labels
2026-07-07 23:48:04 +08:00
hypercross 45c8722af8 fix: decode URI components before path processing
Ensure path normalization and encoding handle URI-encoded segments
correctly by decoding them first.
2026-07-07 23:43:02 +08:00
hypercross e6d74cc318 fix: remove command prefix from action prefill text 2026-07-07 23:32:33 +08:00
hypercross 779722fe85 refactor: preserve URL search params during navigation
Introduce `useNavigateWithParams` to ensure that existing URL search
parameters are maintained when navigating between pages. This is
applied to the Article component, FileTree, and journal links to
prevent losing state (like session or player info) during client-side
navigation.

Also intercepts markdown anchor links in the Article component to use
client-side navigation.
2026-07-07 23:26:44 +08:00
hypercross 48776424a7 fix(RevealManager): prevent GM listeners when disconnected
Only initialize GM hover listeners if the user is both a GM and
connected.
2026-07-07 23:17:57 +08:00
hypercross 4eaf6aab16 refactor: move GM action buttons to Solid components
Migrate GM action button injection from imperative DOM manipulation to
a reactive Solid component (`RevealManager`) using `Portal`. This
improves cleanup reliability and ensures buttons are proper Solid
components rather than raw HTML strings injected into the DOM.

Also adds URI encoding for link paths and simplifies the reveal
store logic.
2026-07-07 23:14:04 +08:00
hypercross aa326f38a0 refactor: introduce RevealManager and Article DOM context
Decouple the reveal logic from the `Article` component by introducing
a `RevealManager` component and an `ArticleDomCtx`. This allows the
reveal logic to reactively manage DOM injections and classes without
polluting the `Article` component's props or lifecycle.

Additionally, implements a robust `cleanupInjections` mechanism to
ensure that injected buttons and styling artifacts are properly removed
during navigation, role changes, or disconnections.
2026-07-07 22:19:04 +08:00
hypercross 1ef2560faa fix: normalize line endings in frontmatter parsing
Normalize CRLF line endings to LF before parsing frontmatter
to ensure regex matching works consistently across different platforms.
2026-07-07 21:47:25 +08:00
hypercross 6b3a915350 refactor: simplify spark button injection logic
Update the spark button injection to use `data-spark` slugs on
`md-table` elements instead of manual table parsing. This improves
reliability by leveraging the slug generated during markdown
rendering.
2026-07-07 21:44:00 +08:00
hypercross 56af7dea42 feat: add spark table roll buttons for GMs
Introduces the ability for GMs to inject spark roll buttons into
tables that match existing spark completions.

- Replaces `linkPrefill` with a more flexible `actionPrefill` system
  to support multiple command types (e.g., `/link`, `/spark`).
- Implements `injectSparkButtons` to identify spark tables in the
  DOM and insert action buttons.
- Updates `JournalInput` to handle structured prefill actions.
2026-07-07 21:38:43 +08:00
hypercross e801ac9b5f refactor: improve spark table lookup and error reporting
Replace the heuristic-based slug parsing with a more robust
combined-slug lookup to correctly handle page names containing
hyphens. Added `scanSparkTables` to provide better error messages
when a table is not found.
2026-07-07 19:13:11 +08:00
hypercross 9a312f76ad fix(completions): instantiate Slugger per file
Move Slugger instantiation inside the file loops to prevent
state leakage between files, as Slugger is stateful.
2026-07-07 19:07:37 +08:00
hypercross 97148aa010 refactor: improve column slug extraction logic 2026-07-07 19:05:47 +08:00
hypercross 2f29f8774d feat: add spark table completion and rolling support
Implement "spark tables" functionality, which allows users to roll
dice against markdown tables to retrieve specific values.

- Add `sparkTablesSource` to scan markdown files for tables starting
  with a dice notation (e.g., d6, d20).
- Implement `/spark` command in the journal to resolve and roll
  spark tables.
- Add a new message type `spark` with a dedicated UI component to
  render the results.
- Update completions API to include spark table metadata.
2026-07-07 19:02:17 +08:00
hypercross 3690d13407 refactor: extract persistence and reveal logic into new stores
Move localStorage and URL parameter management from `journalStream`
to a dedicated `persistence` store. Relocate DOM reveal and heading
injection logic to a new `reveal` store to improve separation of
concerns.
2026-07-07 18:10:43 +08:00
hypercross ef7295ff7a fix(sidebar): add correct node identity to heading set 2026-07-07 13:26:35 +08:00
hypercross 574d17f201 refactor: improve heading revelation logic
Rewrite `addRevealedClasses` to use a two-pass approach that correctly
cascades revealed status from child headings to parent headings. This
ensures that if a sub-heading is revealed, its parent headings are
also treated as revealed, maintaining proper visibility hierarchy.
2026-07-07 12:56:28 +08:00
hypercross ced9a4f8f2 feat: implement content reveal mechanism via DOM traversal
Add `addRevealedClasses` to apply `revealed` or `concealed` CSS classes
to article elements based on the current heading hierarchy and
revealed paths. Update `Article` to expose its DOM element via an
`onDom` callback.
2026-07-07 10:56:19 +08:00
hypercross adf28a7cf1 feat: implement visibility logic for sidebar nodes
Add support for hiding file tree nodes and headings based on path
revelation status. This allows the sidebar to dynamically show only
the relevant parts of the file structure and table of contents.
2026-07-07 09:27:04 +08:00
hypercross c5e1167beb feat: implement granular section visibility control
Introduce the ability to reveal specific sections of a document
rather than entire files. This includes:

- Updating `revealedPaths` to map normalized file paths to sets of
  revealed section slugs.
- Adding `isPathRevealed` to handle visibility logic for connected
  clients and GMs.
- Enhancing `extractHeadings` to calculate `startLine` and `endLine`
  for each TOC node to support precise section identification.
2026-07-07 07:44:57 +08:00
hypercross 7cfc0fd4f3 feat: add player invitation and autojoin functionality
Implement a system to invite players via generated URLs that
automatically configure their name and role.

- Add `InviteDialog` to allow users to generate and copy invite links
- Implement autojoin logic in `ConnectDialog` based on URL parameters
- Add an "+ invite" button to the `JournalPanel` player list
- Ensure `autojoin` parameter is stripped on disconnect to allow
  manual connection later
2026-07-06 18:41:40 +08:00
hypercross 1d0f2f7c3e feat: add plain text dice roll detail
Introduces `plainDetail` to the dice engine to provide a non-HTML
version of the roll results. This allows components to display
the roll breakdown without needing to parse or render HTML.
2026-07-06 18:18:16 +08:00
hypercross 0fc61e2f94 refactor: simplify journal message types and implement GM rolls
Refactor journal message types to use flatter, more intuitive names:
- `narrative` becomes `chat`
- `article.reveal` becomes `reveal`
- `roll.request`/`roll.result` becomes a single `roll` type

Implement client-side dice roll resolution for GMs. When a GM uses
the `/roll` command, the result is now calculated locally using
`resolveRollPayload` before being sent to the stream, rather than
requesting a roll from the server.
2026-07-06 18:06:32 +08:00
hypercross 59236f6dba feat: implement role-based access control
Introduce 'gm', 'player', and 'observer' roles to the journal system.
This includes:
- Restricting command usage and completions to the GM role.
- Implementing role-based message emission permissions.
- Adding a player list and role indicators in the UI.
- Persisting the user's role in localStorage.
- Updating the connection logic to include the role in the client ID.
2026-07-06 17:52:56 +08:00
hypercross e216b94e25 feat: move revert action to message cards and improve article links
- Move the revert functionality from the journal input to individual
  message cards for better UX.
- Implement `RevealLink` component for article reveals to allow
  navigation without losing URL parameters.
- Add helper functions to format article paths and slugs into
  human-readable titles.
2026-07-06 17:26:37 +08:00
hypercross 276241f3b0 feat(journal): improve command completion behavior
- Scroll the selected completion item into view
- Filter commands based on prefix when typing a slash command
- Keep completion menu open while typing a command
- Add data attributes to track completion indices for scrolling
2026-07-06 17:20:20 +08:00
hypercross e0d61cf91e feat: improve completions loading and UI handling
Refactor the completions system to support both CLI mode (via
`/__COMPLETIONS.json`) and a client-side fallback scan for dev mode.

- Implement client-side scanning of the file index for dice and headings
- Add `ensureCompletions` to allow components to await data readiness
- Update `JournalInput` to handle "no results" states in the dropdown
- Improve keyboard navigation (Tab to accept, Enter to select)
- Update dice regex to support `:md-dice[...]` syntax
2026-07-06 17:13:11 +08:00
hypercross 4c5add2414 feat(journal): implement JournalInput with command autocomplete 2026-07-06 16:57:36 +08:00
hypercross 8284f5caee feat: add completions API and scanning logic
Implement a new completions system that scans markdown files for
dice notation and headings. The server now exposes a
`/__COMPLETIONS.json`
endpoint and automatically recomputes the completion index when
files are added, updated, or deleted.
2026-07-06 16:50:45 +08:00
hypercross 307a3c8320 refactor: extract JournalHeader from JournalPanel
Extract the header logic into a standalone `JournalHeader` component.
This new component manages the connection status display, session
dropdown for switching between sessions, and the ability to create
new sessions or disconnect.
2026-07-06 16:22:06 +08:00
hypercross 13f454de9c feat: add URL parameter syncing for session and player
Implement URL search parameter synchronization for `session` and
`player`
to allow for bookmarkable links. Added `sessionName` to the journal
stream state to support displaying human-readable session names in the
UI. Improved layout transitions and updated the JournalPanel to show
more detailed connection information.
2026-07-06 16:16:01 +08:00
hypercross 6b5fcdc051 fix(cli): use async broker creation in journal server
Use `AedesFactory.createBroker()` instead of the constructor to ensure
persistence is properly initialized before handling connections.
2026-07-06 16:01:29 +08:00
hypercross 98c4a195e8 refactor: replace websocket-stream with native ws utility
Remove the `websocket-stream` dependency in favor of
`createWebSocketStream`
from the `ws` package. This simplifies the MQTT broker setup in the
journal server and removes unnecessary transitive dependencies.

Also improve the JournalPanel UI with smoother transitions and better
visibility handling.
2026-07-06 15:53:52 +08:00
hypercross 34d647d611 feat: implement websocket-stream for journal server
Integrate `websocket-stream` to handle MQTT broker upgrades in the
CLI and add granular connection status tracking to the UI.

- Add `websocket-stream` dependency
- Update CLI to use `websocket-stream` for socket upgrades
- Enhance `journalStream` store with `connectionStatus` and
  `connectionError` states
- Add visual connection status indicators to `App` and `JournalPanel`
2026-07-06 15:38:28 +08:00
hypercross 862ba9c01b feat: simplify journal connection via WebSocket upgrade
Refactor the journal server to run over WebSockets on the existing
HTTP server instead of a separate MQTT TCP port. This removes the need
for a separate port configuration and simplifies the connection flow
by auto-connecting to the current host.

- Remove `--mqtt-port` CLI option
- Attach Aedes broker to the HTTP server via WebSocket upgrade
- Update UI to remove manual broker URL and player name inputs
- Add "Content Source" access from the sidebar
- Update JournalPanel to auto-connect to the local server
2026-07-06 15:30:21 +08:00
hypercross 53c33587fb feat(journal): implement journal UI components
Introduce a complete set of journal components including:
- JournalPanel: main container with mobile/desktop support
- ConnectionBar: MQTT connection and session management
- StreamView and StreamMessage: message log and individual message cards
- ComposePanel: message composition with type selection
- DynamicForm: recursive form generator based on Zod schemas
2026-07-06 15:06:19 +08:00
hypercross 8194438816 feat(journal): implement message registry and stream store
Implement a robust journal system featuring a type-safe message
registry and an MQTT-backed stream store.

- Add `registry.ts` to manage message type definitions, Zod schemas,
  and custom reducers.
- Implement `journalStream.ts` to manage MQTT connections, session
  lifecycle, and reactive message state.
- Add built-in message types for `narrative`, `intent`, `roll`,
  and `article`.
- Provide utilities for message validation, optimistic local updates,
  and message reversion.
2026-07-06 14:48:18 +08:00
hypercross 21f8fbcd0b feat: add MQTT journal server for session persistence
Implement a journal server that uses an Aedes MQTT broker to
persist stream messages to JSONL files and manage session metadata
via a manifest.

- Add `aedes`, `mqtt`, and `zod` dependencies
- Implement `createJournalServer` in `src/cli/journal.ts`
- Integrate journal server into the `serve` command
- Add `--mqtt-port` option to the CLI
- Add session lifecycle management (create, update, delete) via
  retained MQTT topics
2026-07-06 14:19:16 +08:00
hypercross 08389601ac style: reformat and improve CSS structure
- Add Tailwind source scanning for color utilities
- Implement icon masking support
- Reformat CSS for better readability and consistency
- Expand utility definitions into multi-line blocks
2026-07-06 13:28:01 +08:00
hyper d56e8ca23d fix(ui): Add text-black to prose elements 2026-07-03 22:11:50 +08:00
hyper 0add3f27d5 fix(layer-editor): use stable _key for drag-and-drop sorting
Add a unique `_key` to each layer config to maintain stable sortable IDs
across reorder operations. Update LayerRow to use the sortableId prop
and the `use:sortable` directive. Refactor layer-crud utilities to
generate and propagate `_key` values on creation and initialization.
2026-07-03 21:49:18 +08:00
hyper 186e3c0db6 feat(layer-editor): Stable sortable IDs, drag opacity
- Use createMemo for stable IDs to prevent re-renders
- Add opacity and transition styles during drag
- Remove "所有字段已添加" message when no available fields
2026-06-30 21:44:59 +08:00
hyper bdc06cc49a style(md-deck): Use double quotes and shrink-0 class 2026-06-30 21:35:39 +08:00
hyper b3d390dfd5 refactor(editor-panel): extract LayerRow and layer crud logic 2026-06-30 21:34:10 +08:00
hyper 42c7c9a26e feat(editor): Add dnd reordering and layer add/remove 2026-06-30 20:55:13 +08:00
hyper 9794184e98 style(file-tree): Reformat JSX and fix class name 2026-06-30 20:26:11 +08:00
hyper 53c8ce0677 feat(viewer): Add data source dialog
Introduce a dialog for switching between built-in content and a
user-selected local folder. Persist the folder handle in IndexedDB
so the selection survives page refreshes. Scan the folder for .md,
.yarn, and .csv files to build the file index.

Update App and Sidebar to pass file tree and heading data, and
add TypeScript declarations for the File System Access API.
2026-06-30 19:20:08 +08:00
hyper a05b0f4291 refactor(doc-data): Use static imports for doc entries 2026-06-30 18:54:34 +08:00
hyper d72d4e7bde feat(docs): add component documentation entries and refactor dialog
rendering
2026-06-30 18:47:14 +08:00
hyper 0886a406bb feat(doc-data): Add md-font component and update md-bg 2026-06-30 18:40:22 +08:00
hyper 548d6a0510 feat(md-font): Add color attribute 2026-06-30 18:37:46 +08:00
hyper fa57727abb feat(md-font): Add color prop support 2026-06-30 18:28:40 +08:00
hyper f337acd142 feat(md-bg): Add solid color background support 2026-06-30 18:24:38 +08:00
hyper 936ccb7433 chore(deps): add husky dev dependency
Update prepare script to use npx husky
2026-06-26 11:33:28 +08:00
hyper 1b51370ad0 feat(husky): Add post-merge hook and prepare script 2026-06-26 11:22:15 +08:00
hyper e4b980202e feat(doc): Add md-border entry and reformat existing props 2026-06-26 11:11:25 +08:00
hyper e861b80f57 feat(components): add md-border custom element 2026-06-26 11:10:06 +08:00
hyper 110451ae04 feat: Add command documentation dialog
Introduce a modal dialog that displays documentation for all available
markdown commands, accessible via a '?' button in the header.
2026-06-25 21:04:53 +08:00
hyper f734da6acd feat(viewer): Add md-embed component 2026-06-25 20:43:40 +08:00
hyper f19c7ff77c refactor(markdown): Replace using with try-finally 2026-06-25 20:33:08 +08:00
hyper 1a4ae417fe feat: Add multi-source font and style manager
Rename md-emfont to md-font with support for Google, emfont, and local
sources. Introduce article-style-manager.ts for centralized lifecycle
management of article-level styles and external stylesheet link
injection with reference counting. Refactor md-bg to use the new
manager, removing direct createEffect on article styles.
2026-06-25 20:26:34 +08:00
hypercross 771fe6a112 style: add mask properties to icon class 2026-06-16 16:40:44 +08:00
hyper a5021ff5b4 style: reformat PltPreview and fix SVG rendering
- Reformat code to use double quotes and consistent indentation
- Improve SVG path styling for travel paths and borders
- Fix layout nesting and indentation in the component template
- Add missing circle element to card index labels
2026-05-18 22:19:48 +08:00
hypercross 1582191655 feat: add custom YAML tag code block extension 2026-05-11 12:05:19 +08:00
hypercross a19d848456 refactor: remove words prop from md-emfont
Removes the `words` property from `EmfontProps` and simplifies the
URL construction by removing the font subsetting logic.
2026-05-11 11:56:22 +08:00
hypercross 4038d67ea0 feat: add md-emfont component and format code
- Add `md-emfont` custom element for dynamic font loading
- Register `md-emfont` in component index
- Reformat `Article.tsx` and `index.ts` using double quotes and
  consistent spacing
- Fix indentation in `index.html`
2026-05-11 11:25:57 +08:00
251 changed files with 25025 additions and 6411 deletions
+68
View File
@@ -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.
+147
View File
@@ -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.
+42
View File
@@ -0,0 +1,42 @@
# CSV data files
**Keep CSV files clean** — a header row and data rows, nothing else. Put deck
configuration and templates in the `yaml role=tag` block's `data-config`, not in
the CSV. This keeps data portable and presentation in one place. See
`reference.md` for the element and role catalog.
```csv
label,body,name,type
1,"### 火球术卷轴
一张泛黄的羊皮纸。",火球术卷轴,消耗品
```
- `label` — unique row id (number or text).
- `body` — main content, supports markdown.
- Any other columns are custom fields.
- `{{prop}}` in `body` interpolates another column from the same row (or a
random row when the table uses `{remix}`).
- Wrap values containing `,`, `"`, or newlines in double quotes; escape `"` as
`""`. Lines beginning with `#` are comments.
## Front matter (avoid)
The parser also accepts YAML front matter delimited by `---`, which can declare
`fields`, a `deck:` block, and arbitrary keys merged into every row. **Prefer
not to use it** — it mixes presentation into the data file. Use the YAML tag
block instead.
```csv
---
fields:
- name: name
description: 卡牌名称
deck:
size: 63x88
grid: 5x5
game_system: D&D 5e
---
label,body,name,type
1,"### 火球术卷轴",火球术卷轴,消耗品
```
@@ -0,0 +1,68 @@
# The Hollow Mill
## Adventure brief
The mill on the village edge has stood silent for a year. Last week its wheel
began turning again — on its own.
> [!WARNING]
> The mill's guardian is not hostile by default. Attacking it forfeits the
> peaceful resolution.
## Locations
:md-table[./locations.csv]
## Random encounters
:md-table[./encounters.csv]{roll=true}
## The mill guardian
:md-dice[1d20+3]{key="guardian-attack"}
The guardian strikes with :[sword] for :md-dice[2d6+2]{key="guardian-damage"}.
## Encounter table (inline)
```csv role=spark-table roll=true
d6,Encounter
1,A crow watches from the rafters.
2,The floorboards groan underfoot.
3,A cold draft carries the smell of grain.
4,The wheel outside stops for one breath.
5,A sack of flour splits open.
6,The guardian's lantern flickers to life.
```
## Route
```mermaid
graph TD
A[Village] --> B[Mill Road]
B --> C{Mill Door}
C -->|Force| D[Ground Floor]
C -->|Listen| E[Secret Cellar]
D --> F[Wheel Room]
E --> F
```
## Guardian card
```yaml role=tag
tag: md-deck
body: ./guardian.csv
data-config:
size: 63x88
grid: 5x5
layers:
- prop: name
pos: 1,1-5,1
font: 12
- template: |
**{{type}}** — {{hp}} HP
{{description}}
pos: 1,3-5,8
font: 3
align: l
```
@@ -0,0 +1,36 @@
group,label,body
Mill Road,1,"### A crow on the fence
A crow fixes you with one eye, then flies toward the mill.
**Interaction**: Follow it (Perception 3/1)
**Risk**: None"
Mill Road,2,"### Broken cart
An overturned cart blocks half the road.
**Interaction**: Clear it (Strength 3/2)
**Risk**: Failure costs 1 Stamina"
Mill Road,3,"### Cold spot
The air turns suddenly cold.
**Interaction**: Press on (Constitution 3/1)
**Risk**: Failure deals 1 Constitution damage"
Wheel Room,1,"### The turning wheel
The wheel grinds without water.
**Interaction**: Jam the gears (Strength 3/3)
**Risk**: Failure deals 1 Agility damage"
Wheel Room,2,"### Flour dust
Thick dust fills the air.
**Interaction**: Cover your mouth (Constitution 3/1)
**Risk**: Failure deals 1 Constitution damage"
1 group label body
2 Mill Road 1 ### A crow on the fence A crow fixes you with one eye, then flies toward the mill. **Interaction**: Follow it (Perception 3/1) **Risk**: None
3 Mill Road 2 ### Broken cart An overturned cart blocks half the road. **Interaction**: Clear it (Strength 3/2) **Risk**: Failure costs 1 Stamina
4 Mill Road 3 ### Cold spot The air turns suddenly cold. **Interaction**: Press on (Constitution 3/1) **Risk**: Failure deals 1 Constitution damage
5 Wheel Room 1 ### The turning wheel The wheel grinds without water. **Interaction**: Jam the gears (Strength 3/3) **Risk**: Failure deals 1 Agility damage
6 Wheel Room 2 ### Flour dust Thick dust fills the air. **Interaction**: Cover your mouth (Constitution 3/1) **Risk**: Failure deals 1 Constitution damage
@@ -0,0 +1,2 @@
label,name,type,hp,description
1,The Hollow Miller,Spirit,10,"Bound to the wheel by an old promise. Speaks only in the sound of grinding stone."
1 label name type hp description
2 1 The Hollow Miller Spirit 10 Bound to the wheel by an old promise. Speaks only in the sound of grinding stone.
@@ -0,0 +1,47 @@
label,body
Village,"### Village
**Type**: Starting location
A quiet village at the edge of the marsh. The miller's daughter asks after her
father.
**Interaction**: Ask about the mill (Charisma 3/1)
**Supplies**: Rope, torches, one healing draught"
Mill Road,"### Mill Road
**Type**: Route
A muddy track winding between hedgerows toward the mill.
**Interaction**: Spot tracks (Perception 3/1)
**Encounters**: 3 random encounters"
Ground Floor,"### Ground Floor
**Type**: Hub
Sacks of grain, a cold hearth, and a ladder to the wheel room.
**Interaction**:
- Search the hearth (Perception 3/2) — find a brass key
- Climb the ladder (Agility 3/1)"
Secret Cellar,"### Secret Cellar
**Type**: Archive
A low cellar beneath the millstones. Ledgers line the walls.
**Interaction**: Read the ledgers (Intelligence 3/2) — learn the guardian's name
**Clue**: The last entry is dated one year ago, in the miller's hand"
Wheel Room,"### Wheel Room
**Type**: Finale
The great wheel turns in silence, though no water reaches it.
**Interaction**:
- Speak the guardian's name (Charisma 3/3) — peaceful resolution
- Break the wheel (Strength 3/4) — hostile resolution"
1 label body
2 Village ### Village **Type**: Starting location A quiet village at the edge of the marsh. The miller's daughter asks after her father. **Interaction**: Ask about the mill (Charisma 3/1) **Supplies**: Rope, torches, one healing draught
3 Mill Road ### Mill Road **Type**: Route A muddy track winding between hedgerows toward the mill. **Interaction**: Spot tracks (Perception 3/1) **Encounters**: 3 random encounters
4 Ground Floor ### Ground Floor **Type**: Hub Sacks of grain, a cold hearth, and a ladder to the wheel room. **Interaction**: - Search the hearth (Perception 3/2) — find a brass key - Climb the ladder (Agility 3/1)
5 Secret Cellar ### Secret Cellar **Type**: Archive A low cellar beneath the millstones. Ledgers line the walls. **Interaction**: Read the ledgers (Intelligence 3/2) — learn the guardian's name **Clue**: The last entry is dated one year ago, in the miller's hand
6 Wheel Room ### Wheel Room **Type**: Finale The great wheel turns in silence, though no water reaches it. **Interaction**: - Speak the guardian's name (Charisma 3/3) — peaceful resolution - Break the wheel (Strength 3/4) — hostile resolution
+20
View File
@@ -0,0 +1,20 @@
# Column layout
Wrap content in columns using line markers. This is a tttk extension on top of
standard markdown; see `reference.md` for the element and role catalog.
```markdown
|---
Left column
-|-
Right column
--|
```
| Marker | Meaning |
|---|---|
| `\|---` | Start the column container |
| `-\|-` | Divider between columns |
| `--\|` | End the container |
Extra dashes add a `-N` class suffix (e.g. `-|--` → `col-2`) for custom widths.
+39
View File
@@ -0,0 +1,39 @@
# Alerts and mermaid
Standard markdown features that `tttk` renders. They are not tttk syntax — see
`reference.md` for the element and role catalog.
## GitHub-style alerts
```markdown
> [!NOTE]
> 这是一条备注
> [!TIP]
> 提示内容
> [!IMPORTANT]
> 重要信息
> [!WARNING]
> 警告信息
> [!CAUTION]
> 危险警告
```
## Mermaid diagrams
Fenced `mermaid` blocks render as diagrams:
````markdown
```mermaid
graph TD
A[开始] --> B{选择}
B -->|选项 1| C[结果 1]
B -->|选项 2| D[结果 2]
```
````
Supported: flowchart, sequenceDiagram, classDiagram, stateDiagram, erDiagram,
pie.
+393
View File
@@ -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.
+82
View File
@@ -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*
```
+66
View File
@@ -0,0 +1,66 @@
# YAML tag blocks
`` ```yaml role=tag `` renders a custom element from YAML. `tag` names the
element, `body` becomes its content, and any other key becomes an attribute.
Fence-line attributes override the YAML body; 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`, …).
+6
View File
@@ -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
View File
@@ -5,4 +5,5 @@ dist/
.env .env
.env.local .env.local
*.csv.d.ts *.csv.d.ts
.ttrpg/
+2
View File
@@ -0,0 +1,2 @@
npm install --no-audit --no-fund
npm run build
+21
View File
@@ -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.
+3 -2
View File
@@ -4,7 +4,7 @@
具有以下功能: 具有以下功能:
- cli: 提供一个cli工具,用于将目录内的各种ttrpg文档编译为html。 - cli: 提供一个cli工具,用于预览和校验目录内的各种ttrpg文档。
- markdown:解析以各种格式编写的ttrpg内容,并支持扩展的语法。 - markdown:解析以各种格式编写的ttrpg内容,并支持扩展的语法。
- ttrpg组件:用于ttrpg内容的各种UI组件,且可以在markdown中通过扩展语法插入。 - ttrpg组件:用于ttrpg内容的各种UI组件,且可以在markdown中通过扩展语法插入。
@@ -13,7 +13,8 @@
cli使用子命令组织: cli使用子命令组织:
- `serve`: 运行一个web服务器预览目录中的内容,并实时侦听更新。 - `serve`: 运行一个web服务器预览目录中的内容,并实时侦听更新。
- `compile`: 将目录中的内容输出为带hash路由、单个html入口的web应用。 - `check`: 静态校验目录中的 tttk 文档,输出结构化诊断(文件、行号、代码),存在错误时以退出码 1 结束。`--json` 输出机器可读结果。
- `compile`: [未实现] 存根命令,不会产生任何输出。
## 输入文件 ## 输入文件
+14 -15
View File
@@ -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 ```bash
# 安装依赖 # 无需安装,直接使用
npm install npx tttk serve ./content
# 全局安装 CLI # 校验内容(存在错误时退出码为 1)
npm link npx tttk check ./content
# 预览内容
ttrpg serve ./content
``` ```
## 文档导航 ## 文档导航
@@ -20,6 +17,8 @@ ttrpg serve ./content
| 文档 | 说明 | | 文档 | 说明 |
|------|------| |------|------|
| [📖 CLI 使用说明](./docs/cli.md) | CLI 安装、命令和用法 | | [📖 CLI 使用说明](./docs/cli.md) | CLI 安装、命令和用法 |
| [🔤 语法参考](./docs/reference.md) | 自定义元素、代码块角色与属性目录(由 `known.ts` 生成) |
| [📦 npm](https://www.npmjs.com/package/tttk) | `npx tttk serve ./content` |
| [🛠️ 开发指南](./docs/development.md) | 项目结构、开发规范和构建 | | [🛠️ 开发指南](./docs/development.md) | 项目结构、开发规范和构建 |
| [📝 Markdown 编写说明](./docs/markdown.md) | Markdown 语法和组件用法 | | [📝 Markdown 编写说明](./docs/markdown.md) | Markdown 语法和组件用法 |
| [📊 CSV 编写说明](./docs/csv.md) | CSV 文件格式、字段定义、变量语法 | | [📊 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 图表 - **Markdown 解析**: 支持指令语法、YAML 标签、mermaid 图表
- **TTRPG 组件**: 骰子、表格、卡牌、标记、命令追踪器等 - **TTRPG 组件**: 骰子、表格、卡牌、标记、命令追踪器等
@@ -35,11 +34,11 @@ ttrpg serve ./content
| 组件 | 语法 | 说明 | | 组件 | 语法 | 说明 |
|------|------|------| |------|------|------|
| 🎲 骰子 | `:dice[2d6+d8]` | 掷骰并记录结果 | | 🎲 骰子 | `:md-dice[2d6+d8]` | 掷骰并记录结果 |
| 📊 表格 | `:table[./data.csv]` | 标签页式表格 | | 📊 表格 | `:md-table[./data.csv]` | 标签页式表格 |
| 🃏 卡牌 | `:md-deck[./cards.csv]` | 卡牌布局 | | 🃏 卡牌 | `:md-deck[./cards.csv]` | 卡牌布局(推荐 `yaml role=tag` 写法) |
| 📍 标记 | `:md-pin[A]{x=40 y=40}` | 图片标记 | | 🖨️ 打印版式 | `:md-print[./rules.md]` | A4 横版双 A5、多列排版并导出 PDF |
| 📋 追踪器 | `:md-commander` | 命令历史和状态追踪 | | 📍 标记 | `:md-pins[./map.png]{pins="A:25,50"}` | 图片标记 |
## 技术栈 ## 技术栈
+26
View File
@@ -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;
-15
View File
@@ -1,15 +0,0 @@
# Commander Test
:md-commander[./commands.csv]
## Lonelog
`我` 走进酒吧。
`oracle` 酒吧里人多吗?
`roll` d20 = 18 `prompt` 是的
`fact` 酒吧里有很多人。
:[blah]
-6
View File
@@ -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]"
1 command parameter label description insertedText
2 roll emmet 2d6 2D6 2d6
3 roll emmet d20 D20 d20
4 track emmet monk 破戒佛爷 破戒佛爷.bandit.monk[gd=4/4,str=14/14,cla=10,spi=8]
5 track emmet bandit 龙虎寨山贼 龙虎寨山贼.bandit[gd=2/2,str=10/10,cla=10,spi=10]
6 track emmet khitan 契丹护卫 契丹护卫.khitan.guard[gd=4/4,ap=2,str=14/14,cla=14,spi=14]
+27 -5
View File
@@ -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"} :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 ## 使用 yaml role=tag 语法创建 md-deck(fence 行内联 tag,简洁写法)
tag: md-deck
```yaml role=tag tag=md-deck
body: ./names.csv body: ./names.csv
size: 54x86 size: 54x86
grid: 5x8 grid: 5x8
bleed: 1 bleed: 1
padding: 2 padding: 2
layers: name1:1,1-2,2f12 name2:4,7-5,8f12s num1:1,3-2,4f12 num2:4,5-5,6f12s 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
```
+17
View File
@@ -33,6 +33,21 @@ bler
:md-table[./npcs.csv]{roll} :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#啊什么]的啦 文本里面可以有:md-link[./stuff.md#啊什么]的啦
@@ -53,3 +68,5 @@ bler
> [!CAUTION] > [!CAUTION]
> Negative potential consequences of an action. > Negative potential consequences of an action.
:md-embed[./style-test.md]
+12
View File
@@ -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
:::
+405
View File
@@ -0,0 +1,405 @@
# `tttk check` — implementation plan (validator-first)
Status: **implemented.** Supersedes `docs/compile-plan.md`.
Landed: `S001`/`S002`, `D001`/`D002`, `R001`–`R006`, `C001`–`C004`,
`L001`/`L002`, `A001`/`A002`, `V001`–`V005`, with tests. The `check` command,
`--json`/`--strict`/`--quiet`, and exit codes are live; the markdown module is
the browser-safe one described in §4, and `readContentIndex` backs the walk.
The reference generator (§7.11) and both drift guards (§7.12) are in place:
`docs/reference.md` is generated from `known.ts` via `npm run gen:reference`,
and `known.test.ts` asserts the catalog matches the `customElement` call sites
and the committed reference.
Not implemented: `D003` (P2 — the legacy `:md-deck` directive is not flagged).
The rule catalog in §5, the CLI surface in §6, and the resolved decisions in §10
remain the specification.
## 1. Goal
`tttk check [dir]` statically validates a directory of tttk markdown and
reports **structured diagnostics** with file, line, severity, and a stable
code. It exits non-zero when the content is broken.
The user is an **authoring agent**. The tool's job is to answer, in one
command: *"Does this document set render the way I intend, and if not, exactly
where and why?"* HTML rendering is not the deliverable — a fix list is.
Success criteria:
- Every authoring mistake that would silently drop, mangle, or fail to
resolve content is reported with `file:line:col`.
- Output is machine-readable (`--json`) so an agent can parse and act on it
without scraping human prose.
- Exit code distinguishes clean / warnings-only / errors, so the command is
usable directly in a loop: write → check → fix → repeat.
- Checks run over the **same registry the app uses**, so a clean check implies
the app resolves the content too.
Non-goals (v1): rendering HTML, a site build, hydrating custom elements,
journal/multiplayer, MCP, live reload, and automatic fixes. `check` reports
problems; it never edits content.
## 2. Why not reuse `compile`
The previous plan framed this as `tttk compile` producing HTML. That optimizes
for a human browsing output. An agent cannot easily tell "correct" from
"plausible but inert" by reading `<md-table>./npcs.csv</md-table>` — an
unresolved reference looks identical to a resolved one.
The failure modes that actually hurt authors are invisible in inert HTML and
are currently logged to `console.warn` or thrown:
| Failure | Current behavior | Location |
|---|---|---|
| Unknown `role=` | `console.warn`, block stripped → content vanishes | `src/cli/content-registry.ts:174-179` |
| Missing `md`/`csv`/`yarn`/`svg` asset ref | `resolveContent` returns `null`; fails at runtime only | `src/cli/content-registry.ts:340-372` |
| `role=declare` missing `key`/`expr` | **throws** (uncaught) | `src/cli/completions/declare-parser.ts:44-54` |
| `role=declare` bad row | `console.warn`, silently skipped | `src/cli/content-registry.ts:473-479` |
| spark-table first column not dice | `console.warn`, still stored | `src/cli/content-registry.ts:285-288` |
| Unknown element name (`:md-card`, `:dice`) | renders an inert unknown tag | none |
A validator turns all of these into diagnostics. That is the product.
Rendering, if ever built, is a separate `tttk render` command (see §11).
## 3. Design decisions
### 3.1 Whole-directory registry pass first
References only resolve against a full `ContentRegistry`: inline ids are
doc-scoped (`docContent[docPath]`), and file refs resolve relative to the doc
directory via `posixJoin(posixDir(docPath), id)`
(`src/cli/content-registry.ts:340-372`). So the pipeline is:
```
buildRegistry(dir) // reuse src/cli/commands/serve.ts:88
→ { pathIndex, docContent }
for each raw .md on disk:
→ run checks(source, registry) // §3.2
→ collect Diagnostics
sort by (file, line, col) → report → exit code
```
`buildRegistry` already walks the tree and collects `.md`, `.csv`, `.yarn`,
`.svg` — exactly what reference resolution needs. Reuse it; do not re-walk.
Only `.md` files are validated; non-md files are inputs to resolution.
### 3.2 Validate the **raw** source, resolve with the registry
Do not run checks over `registry.pathIndex`. That string has fences already
replaced/stripped (`scanDoc`), so offsets no longer map to the author's file
and line numbers would be wrong.
Instead: run checks over the **raw file contents**, tracking byte offsets, and
use the registry purely to *resolve* refs. This works because:
- Directives (`:md-table[./npcs.csv]`, `:md-embed[./x.md#sec]`) appear
literally in raw source — that is where we locate and validate them.
- `role=spark-table` blocks are validated from their raw fence; the generated
`:md-table[./csv_<hash>]` never appears in the author's source, so it needs
no validation (the generator is trusted).
- Inline ids referenced by hand (`:md-embed[./text_<hash>]`) resolve through
`docContent`.
Offsets are cheap: the `String.replace` callback in `scanDoc` already receives
the match offset as a trailing argument, and the directive/fence scanners we
add can capture `RegExp.exec` index directly. Convert offset → line/col with a
line-start table built once per source (`src/cli/validate/source.ts`).
### 3.3 Diagnostics on raw source must skip fenced examples
Docs legitimately contain example directives inside fenced blocks
(`reference.md` does). The scanner must track fence state (like
`stripFencedBlocks`, `content-registry.ts:494-510`) and mark matches as
`inFence`; refs/roles inside fences are examples, not live content. Roles are
only live on the fence *info line*; directive refs are only live outside
fences.
## 4. Diagnostic model
```ts
export type Severity = "error" | "warning" | "info";
export interface Diagnostic {
code: string; // stable, e.g. "TTTK-R001"
severity: Severity;
message: string; // one line, actionable
file: string; // project-relative, posix
line: number; // 1-based
column: number; // 1-based
hint?: string; // concrete fix
snippet?: string; // offending source line
}
```
Rules:
- Codes are stable and documented; agents may key off them.
- Messages name the offending value (`unknown role "spark-tabel"`), not just
the category.
- `hint` is imperative and concrete (`did you mean \`spark-table\`?`).
## 5. Rule catalog
Priority: **P0** ships in v1, **P1** follows, **P2** experimental.
### Structural
| Code | Sev | Rule | Prio |
|---|---|---|---|
| `TTTK-S001` | error | Markdown pipeline throws while parsing the doc | P1 |
| `TTTK-S002` | error | Unterminated container directive (`:::` with no close) | P1 |
### Directives
| Code | Sev | Rule | Prio |
|---|---|---|---|
| `TTTK-D001` | error | Unknown `:md-*` element name | **P0** |
| `TTTK-D002` | error | Malformed attribute block (`{pins=A:30,40}` unquoted with spaces) | P1 |
| `TTTK-D003` | info | Legacy `:md-deck[./x.csv]` directive — prefer `yaml role=tag` | P2 |
`D001` uses an explicit allowlist of the **registered** custom elements. From
`customElement(...)` call sites today (verified):
```
md-bg md-border md-commander md-deck md-dice md-embed
md-font md-link md-pins md-table md-yarn-spinner
```
Free-form names are allowed for **container** directives (`::: name`) — those
are CSS classes, not elements. Inline `:[name]` is the icon form and is not
governed by this list.
> **Already-real finding:** `:md-card` is matched by the completion scanner
> (`content-registry.ts:442`) but is registered nowhere, so it renders as an
> inert tag. `D001` would flag it. This is the class of bug the tool exists for.
### Code-block roles
| Code | Sev | Rule | Prio |
|---|---|---|---|
| `TTTK-R001` | error | Unknown `role=` value (block is silently stripped) | **P0** |
| `TTTK-R002` | warning | `role=spark-table` first column header is not a dice formula | P1 |
| `TTTK-R003` | error | `role=declare` missing required `key`/`expr` columns | **P0** |
| `TTTK-R004` | warning | `role=declare` row skipped by the parser | P1 |
| `TTTK-R005` | error | `role=tag` body is not valid YAML | **P0** |
| `TTTK-R006` | error | `role=tag` missing `tag:` or names an unknown element | **P0** |
`R003`/`R004` come from `parseDeclareCsv` (`declare-parser.ts:44-54`) — it
throws today; the check must catch and localize it rather than crash.
`R005`/`R006` parse with `js-yaml` (already a dependency) and reuse the `D001`
allowlist.
### Content references
| Code | Sev | Rule | Prio |
|---|---|---|---|
| `TTTK-C001` | error | Reference does not resolve (missing file or undefined inline id) | **P0** |
| `TTTK-C002` | warning | Reference resolves to an unexpected file type | P1 |
| `TTTK-C003` | info | Same CSV referenced by two `:md-table` in one doc | P1 |
| `TTTK-C004` | error | `:md-embed` cycle | P1 |
`C001` covers every ref-bearing directive — `:md-table`, `:md-commander`,
`:md-pins`, `:md-bg`, `:md-border`(image), `:md-yarn-spinner`,
`:md-link`/`:md-embed` path part, and `role=tag` `body:` — by calling
`resolveContentEntry` (`content-registry.ts:340`) and flagging `null`. This is
the single highest-value check.
### Links and anchors
| Code | Sev | Rule | Prio |
|---|---|---|---|
| `TTTK-L001` | error | `:md-link`/`:md-embed`/markdown link target file missing | **P0** |
| `TTTK-L002` | warning | `#section` anchor matches no heading slug | P1 |
Slugs come from the same `Slugger` used by `deriveLinks`
(`content-registry.ts:523-536`) so the validator and the app agree.
### Assets
| Code | Sev | Rule | Prio |
|---|---|---|---|
| `TTTK-A001` | warning | `:[icon]` not found under any `assets/` dir within the check root | P1 |
| `TTTK-A002` | warning | `:md-bg`/`:md-pins`/`:md-border` image path missing | P1 |
`A001` mirrors `buildIconSrc` (`src/markdown/icon.ts:23-40`): search
`<root>/<docdir>/assets/`, then each ancestor down to `<root>/assets/`. We
**assume no `assets/` directory exists above the check root**, which makes the
lookup definitive rather than a guess — hence a `warning`, not `info`.
### CSV data files
| Code | Sev | Rule | Prio |
|---|---|---|---|
| `TTTK-V001` | error | CSV fails to parse (bad quoting) | P1 |
| `TTTK-V002` | warning | Row column count ≠ header column count | P1 |
| `TTTK-V003` | warning | Duplicate `label` values | P1 |
| `TTTK-V004` | warning | `{{prop}}` key is not a CSV column or front-matter key | P1 |
| `TTTK-V005` | warning | spark-table row roll value is not a number/range | P2 |
`V004` mirrors `processVariables` (`src/components/utils/csv-loader.ts:86-99`):
a `{{key}}` resolves when `key` is a CSV header or a front-matter key (front
matter is merged into every row, `csv-loader.ts:56-61`). Unresolved keys are
left verbatim in the output, so the check is definitive. Honor the parser's
options: BOM stripped, `#` lines are comments, headers/cells trimmed, empty
lines skipped (`csv-loader.ts:45-51`).
## 6. CLI surface
```
tttk check [dir] [options]
Arguments:
[dir] directory to validate (default ".")
Options:
--json emit a JSON report (see §6.1)
--strict treat warnings as errors
--quiet report errors only
--no-color disable ANSI color
-h, --help
```
Wire in `src/cli/index.ts` alongside `serve` (commander pattern at
`src/cli/index.ts:17-30`).
### 6.1 Output
Human (default), grouped by file:
```
content/adventures/hollow-mill/adventure.md
12:1 error TTTK-C001 :md-table reference "./npcs.csv" does not resolve
hint: expected content/adventures/hollow-mill/npcs.csv
40:8 warning TTTK-R002 spark-table first header "Name" is not a dice formula
hint: use d6, 2d6+1, …
2 errors, 1 warning
```
`--json`:
```json
{
"root": "content",
"filesChecked": 12,
"diagnostics": [ { "code": "TTTK-C001", "severity": "error", "message": "…",
"file": "adventures/hollow-mill/adventure.md",
"line": 12, "column": 1, "hint": "…" } ],
"summary": { "errors": 2, "warnings": 1, "infos": 0 }
}
```
### 6.2 Exit codes
| Code | Meaning |
|---|---|
| `0` | No errors (warnings allowed unless `--strict`) |
| `1` | At least one error, or any warning under `--strict` |
| `2` | Usage / IO / internal failure |
Exit code is the contract that makes `check` usable in an agent loop.
## 7. Implementation steps
1. **`src/cli/validate/source.ts`** — line-start table; `offsetToPosition`.
2. **`src/cli/validate/diagnostics.ts`** — `Diagnostic`, `Sink` (collector),
sort, human + JSON formatters.
3. **`src/cli/validate/known.ts`** — the catalog is the single source of
truth: `KNOWN_ELEMENTS` (the 11 verified names, with body/attrs/description/
examples), `KNOWN_ROLES = { spark-table, declare, file, tag }`, expected
file-type map for `C002`. `docs/reference.md` is generated from it (§7.11).
4. **`src/cli/validate/scan.ts`** — fence-aware scanner yielding
`{ name, body, attrs, offset, inFence }` for directives, plus role info
lines. Reuse `parseBlockAttrs` (`src/markdown/block-attrs.ts`); do not
reimplement attribute parsing.
5. **Checks** — `checks/roles.ts`, `checks/directives.ts`, `checks/refs.ts`,
`checks/links.ts`, `checks/csv.ts`, `checks/parse.ts`. Pure functions
`(source, registry, docPath, sink) => void`.
6. **`src/cli/validate/index.ts`** — `runCheck(dir)`: `buildRegistry`, iterate
raw `.md` sorted, run checks, sort diagnostics, compute summary.
7. **`src/cli/commands/check.ts`** — commander handler: parse options, run,
print, `process.exitCode = …`. Add `CheckCommandHandler` to
`src/cli/types.ts`.
8. **`src/cli/index.ts`** — register `check` (alias `lint`).
9. **Refactor `scanDoc` to report into a sink** (optional but removes drift):
give `scanDoc(content, docPath, sink?)` an optional sink, replace
`console.warn` at `content-registry.ts:175-179` and `:285-288` with
`sink.report(...)`, and pass the match offset through. Default no-op keeps
existing callers and tests unchanged. This lets the CLI share one code path
for role diagnostics instead of duplicating the switch.
10. **Prereq for `S001`** — make `src/markdown/mermaid.ts` browser-safe so
`parseMarkdown` is importable from the CLI: **delete** `import mermaid`
and the top-level `mermaid.initialize` (the extension only emits
`<pre class="mermaid">`), then call `mermaid.initialize({ theme: "dark" })`
in `src/main.tsx` before the existing `mermaid.run()` calls
(`Article.tsx:89`, `md-embed.tsx:40`). Required for v1.
11. **Reference generator** — `scripts/generate-reference.ts` renders the
`known.ts` catalog to markdown. Canonical output `docs/reference.md`
(versioned, testable); `--out` can target the skill copy at
`.agents/skills/tttk-authoring/reference.md`. Add `npm run
gen:reference`.
12. **Drift guard** — a test that greps `customElement("md-*")` call sites
under `src/components` and asserts each appears in `known.ts` (and vice
versa). This is what would have caught `:md-card`.
## 8. Tests
`src/cli/validate/*.test.ts`, fixture-driven (temp dirs), covering:
- **Positive:** a valid multi-file fixture produces zero diagnostics; exit 0.
- **Negative, one per P0 code:** unknown role; missing declare columns;
invalid tag YAML; unknown tag element; unresolved `:md-table`; missing
`:md-link` target; unknown `:md-*` element. Assert `code`, `file`, `line`.
- **Fence awareness:** an example directive inside a fenced block is *not*
reported; the same directive outside a fence is.
- **No false positives:** a doc that round-trips cleanly, including
`:md-card`-free legacy content.
- **Exit codes:** 0 / 1 / 1-under-`--strict` / 2 on a nonexistent dir.
- **JSON shape:** stable, sorted, one entry per diagnostic.
## 9. Docs and skill
- Update `docs/cli.md`: replace the `compile` section with `check`; document
codes, `--json`, exit codes.
- Update `README.md`.
- Update `.agents/skills/tttk-authoring/SKILL.md`: the verification step
becomes **`npx tttk check ./content`**, and the workflow becomes
write → check → fix. Remove the "compile is a no-op stub" note. Cross-link
the code catalog (`reference.md`) with the diagnostic codes.
- `reference.md` is generated from `known.ts` (§7.11) — do not hand-edit. The
skill's copy is refreshed by `npm run gen:skill-reference`; deeper authoring
topics live in the skill's `yaml-tag.md`, `csv.md`, `layout.md`, and
`markdown.md` (see `docs/skill-reference-plan.md`).
## 10. Resolved decisions
1. **`known.ts` is the source of truth; `reference.md` is generated from it**
(§7.11). A drift test (§7.12) keeps both honest.
2. **`S001` is in scope.** Do the mermaid refactor (§7.10).
3. **Icon existence is checked** (`A001`), assuming no `assets/` directory
above the check root, which makes the lookup definitive.
4. **`V004` (`{{prop}}` keys) is in scope for v1.**
5. **Report only — no `--fix`.** `check` never edits content.
6. **`compile` stays a stub,** and its `--help` text now says so explicitly
(`src/cli/index.ts:27`).
## 11. Risks
- **Icon check assumes no parent assets.** `A001` treats `<root>` as the top
of the icon search. If a project keeps shared `assets/` above the check root,
`A001` will false-positive; the assumption is documented in §5.
- **False positives are fatal to adoption.** An agent that gets spurious
errors stops trusting the tool. Prefer P0 precision over coverage; gate
noisy checks behind `--strict` or `info`.
- **Divergence from the app.** Do not reimplement attribute parsing, dice
detection, slugging, or role dispatch — import `parseBlockAttrs`,
`resolveContentEntry`, `parseDeclareCsv`, `inspectSparkTableCsv`,
`Slugger`. Reuse `buildRegistry` for the walk.
- **Offsets vs. stripped content.** Validate raw source only (§3.2); mixing in
`pathIndex` will produce wrong line numbers.
- **Windows paths.** Use `node:path`; normalize to posix keys via
`normalizePathKey` for all registry lookups. Repo targets Windows.
- **Encoding.** Read UTF-8 explicitly.
- **Declare parser throws.** Wrap `parseDeclareCsv`; a throw must become a
localized diagnostic, never a stack trace.
+137 -50
View File
@@ -1,18 +1,20 @@
# CLI 使用说明 # CLI 使用说明
TTRPG Tools 提供一个 CLI 工具,用于将目录内的各种 TTRPG 文档编译为 HTML。 TTTK 提供一个 CLI 工具,用于预览和校验目录内的 TTRPG 文档。
> 将内容编译为 HTML 的 `compile` 命令尚未实现(见下方说明)。
## 安装 ## 安装
```bash ```bash
# 克隆仓库后安装依赖 # 无需安装,直接运行
npm install npx tttk serve ./content
# 全局链接 CLI 工具 # 或全局安装
npm link npm install -g tttk
``` ```
安装完成后,可在任意目录使用 `ttrpg` 命令。 安装完成后,可在任意目录使用 `tttk` 命令。
## 命令 ## 命令
@@ -23,7 +25,7 @@ CLI 使用子命令组织:
运行一个 Web 服务器预览目录中的内容,并实时监听文件更新。 运行一个 Web 服务器预览目录中的内容,并实时监听文件更新。
```bash ```bash
ttrpg serve [dir] -p 3000 tttk serve [dir] -p 3000
``` ```
**参数:** **参数:**
@@ -37,68 +39,140 @@ ttrpg serve [dir] -p 3000
| 选项 | 说明 | 默认值 | | 选项 | 说明 | 默认值 |
|------|------|--------| |------|------|--------|
| `-p, --port <port>` | 端口号 | `3000` | | `-p, --port <port>` | 端口号 | `3000` |
| `-h, --host <host>` | 主机地址 | `0.0.0.0` | | `--host <host>` | 主机地址 | `0.0.0.0` |
**功能:** **功能:**
- 扫描目录下的所有 `.md`、`.csv`、`.yarn` 文件 - 扫描目录下的 `.md`、`.csv`、`.yarn`、`.svg` 文件(跳过以 `.` 开头的文件和 `node_modules`)
- 为每个文件创建路由 - 把内容目录挂在 URL 根路径下,文档 `rules/combat.md` 对应路由 `/rules/combat`
- 提供实时文件监听,修改后自动刷新 - 监听文件变化并重建内存中的内容注册表
- 通过 `/__CONTENT_INDEX.json` 提供文件索引 - 提供 `/__CONTENT_INDEX.json`、`/__CONTENT_REGISTRY.json`、`/__COMPLETIONS.json` 等接口(见[开发服务器特性](#开发服务器特性))
**示例:** **示例:**
```bash ```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 ```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 入口 | `0` | 无错误(`--strict` 下存在警告也会失败) |
- 复制引用的资源文件(图片、CSV 等) | `1` | 存在错误(或 `--strict` 下存在警告) |
| `2` | 用法 / IO 错误(例如目录不存在) |
**输出示例:**
```
content/adventures/hollow-mill/adventure.md
12:1 error TTTK-C001 :md-table reference "./npcs.csv" does not resolve
hint: expected a file relative to this document, or an inline id defined in it
40:8 warning TTTK-R002 spark-table first header "Name" is not a dice formula
2 errors, 1 warning
12 files checked
```
**诊断代码:**
| 代码 | 级别 | 说明 |
|------|------|------|
| `TTTK-S001` | error | Markdown 解析失败 |
| `TTTK-S002` | error | 容器指令 `:::` 未闭合 |
| `TTTK-D001` | error | 未知的 `:md-*` 元素名 |
| `TTTK-D002` | error | 属性块畸形(例如未加引号的值中含空格) |
| `TTTK-R001` | error | 未知的代码块 `role=`(块内容会被静默丢弃) |
| `TTTK-R002` | warning | `role=spark-table` 首列表头不是骰点公式 |
| `TTTK-R003` | error | `role=declare` 解析失败(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 ```bash
# 编译当前目录 # 校验当前目录
ttrpg compile tttk check
# 编译指定目录并输出到指定位置 # 校验指定目录并输出 JSON
ttrpg compile ./content -o ./build tttk check ./content --json
# 编译并部署 # CI:只关注错误
ttrpg compile ./docs -o ./public && npm run preview npx tttk check ./content --quiet
``` ```
### compile - 编译模式(未实现)
> **存根命令。** `compile` 目前不会产生任何输出,`--help` 中也已标明。
> 请使用 `tttk serve` 预览内容、`tttk check` 校验内容。
原计划的用法如下(仅供参考,尚未实现):
```bash
tttk compile [dir] -o ./dist/output
```
| 参数 | 说明 | 默认值 |
|------|------|--------|
| `[dir]` | 要编译的目录 | `.` (当前目录) |
| 选项 | 说明 | 默认值 |
|------|------|--------|
| `-o, --output <dir>` | 输出目录 | `./dist/output` |
## 输入文件 ## 输入文件
CLI 会搜索目录下的以下文件: CLI 会搜索目录下的以下文件:
@@ -106,6 +180,7 @@ CLI 会搜索目录下的以下文件:
- `.md` - Markdown 文档 - `.md` - Markdown 文档
- `.csv` - 表格数据 - `.csv` - 表格数据
- `.yarn` - Yarn Spinner 叙事文件 - `.yarn` - Yarn Spinner 叙事文件
- `.svg` - 矢量图标
### 文件组织建议 ### 文件组织建议
@@ -126,35 +201,44 @@ my-ttrpg-content/
### 相对路径引用 ### 相对路径引用
若 Markdown 文件通过相对路径引用了其他文件(如图片、CSV),CLI 会在打包时自动处理这些引用: 若 Markdown 文件通过相对路径引用了其他文件(如图片、CSV),引用会相对于当前文档所在目录解析:
```markdown ```markdown
<!-- 引用同目录下的 CSV --> <!-- 引用同目录下的 CSV -->
:table[./sparks.csv] :md-table[./sparks.csv]
<!-- 引用子目录的图片 --> <!-- 引用子目录的图片 -->
![地图](./maps/city-map.png) ![地图](./maps/city-map.png)
<!-- 引用上级目录的文件 --> <!-- 引用上级目录的文件 -->
: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 路由 ### SPA 路由
使用 hash 路由支持单页应用导航: 路由基于 History API(而非 hash)。直接访问深层链接或刷新页面时,未知路径会回退到 `index.html`,
因此浏览器前进/后退与深链接均可正常工作。
- `/content/rules/combat.md` → `#/content/rules/combat.md`
- 支持浏览器前进/后退
## 常见问题 ## 常见问题
@@ -162,17 +246,20 @@ my-ttrpg-content/
```bash ```bash
# 使用其他端口 # 使用其他端口
ttrpg serve -p 8080 tttk serve -p 8080
``` ```
### 编译输出为空 ### check 报告了很多警告
检查输入目录是否包含 `.md` 文件: 先用 `--quiet` 只看错误,或在 CI 中配合 `--strict`:
```bash ```bash
# 查看目录内容 tttk check ./content --quiet
ls -R ./content tttk check ./content --json
# 重新编译
ttrpg compile ./content -o ./dist/output
``` ```
警告通常确有其事(例如锚点或图标不存在),建议逐条确认。
### compile 没有输出
`compile` 目前是未实现的存根命令,不会产生任何输出。请使用 `tttk serve` 预览,使用 `tttk check` 校验。
+74 -10
View File
@@ -6,9 +6,13 @@
# 安装依赖 # 安装依赖
npm install npm install
# 开发模式(Web) # 开发模式(同时启动 CLI 内容服务器 :3000 与 Web 前端 :5173)
npm run dev npm run dev
# 也可分别启动
npm run dev:cli
npm run dev:web
# 构建(Web) # 构建(Web)
npm run build npm run build
@@ -28,10 +32,13 @@ npm test
## 项目结构 ## 项目结构
``` ```
ttrpg-tools/ tttk/
├── src/ ├── src/
│ ├── cli/ # CLI 工具源码 │ ├── cli/ # CLI 工具源码
│ │ ├── commands/ # 命令实现 (serve, compile) │ │ ├── commands/ # 命令实现 (serve, check, compile)
│ │ ├── validate/ # tttk check 校验器(扫描、规则、诊断)
│ │ ├── content-registry.ts # 内容注册表(CLI / 前端共享)
│ │ ├── content-index.ts # 目录文件递归读取
│ │ ├── index.ts # CLI 入口 │ │ ├── index.ts # CLI 入口
│ │ └── types.ts # 类型定义 │ │ └── types.ts # 类型定义
│ ├── components/ # TTRPG 组件 │ ├── components/ # TTRPG 组件
@@ -39,9 +46,8 @@ ttrpg-tools/
│ │ ├── md-table.tsx # 表格组件 │ │ ├── md-table.tsx # 表格组件
│ │ ├── md-deck/ # 卡牌组件 │ │ ├── md-deck/ # 卡牌组件
│ │ ├── md-pins.tsx # 标记组件 │ │ ├── md-pins.tsx # 标记组件
│ │ ├── md-commander/ # 命令追踪器
│ │ ├── md-yarn-spinner.tsx # 叙事线组件 │ │ ├── md-yarn-spinner.tsx # 叙事线组件
│ │ ├── md-token.tsx # 代币组件
│ │ ├── Article.tsx # 文章组件 │ │ ├── Article.tsx # 文章组件
│ │ ├── Sidebar.tsx # 侧边栏 │ │ ├── Sidebar.tsx # 侧边栏
│ │ ├── FileTree.tsx # 文件树 │ │ ├── FileTree.tsx # 文件树
@@ -57,6 +63,9 @@ ttrpg-tools/
│ ├── styles.css # 样式 │ ├── styles.css # 样式
│ ├── index.html # HTML 模板 │ ├── index.html # HTML 模板
│ └── global.d.ts # 类型声明 │ └── global.d.ts # 类型声明
├── .agents/
│ └── skills/
│ └── tttk-authoring/ # 随项目维护的 agent skill(见下文)
├── bin/ ├── bin/
│ └── cli/ # CLI 二进制文件 │ └── cli/ # CLI 二进制文件
├── content/ # 示例内容 ├── content/ # 示例内容
@@ -68,6 +77,29 @@ ttrpg-tools/
└── jest.config.js └── jest.config.js
``` ```
## Agent skills
`tttk-authoring` skill 由本项目维护,源码位于
`.agents/skills/tttk-authoring/`。要让 agent 加载它,把全局 skill 目录
链接到仓库内的副本:
```bash
# Windows(无需管理员权限的目录联接)
cmd //c mklink /J %USERPROFILE%\.agents\skills\tttk-authoring "%CD%\.agents\skills\tttk-authoring"
# macOS / Linux
ln -s "$PWD/.agents/skills/tttk-authoring" ~/.agents/skills/tttk-authoring
```
`reference.md` 由 `known.ts` 生成,不要手改:
```bash
npm run gen:skill-reference
```
其余文件(`SKILL.md`、`yaml-tag.md`、`csv.md`、`layout.md`、`markdown.md`)
为手写内容。
## 技术栈 ## 技术栈
| 类别 | 技术 | | 类别 | 技术 |
@@ -77,7 +109,7 @@ ttrpg-tools/
| 样式 | Tailwind CSS 4 + @tailwindcss/typography | | 样式 | Tailwind CSS 4 + @tailwindcss/typography |
| Markdown | marked + marked-directive + marked-alert + marked-gfm-heading-id | | Markdown | marked + marked-directive + marked-alert + marked-gfm-heading-id |
| 图表 | mermaid | | 图表 | mermaid |
| 3D | three.js |
| 测试 | Jest | | 测试 | Jest |
| CLI | commander + chokidar | | 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 开发 ### Web 开发
`npm run dev` 会同时启动两个进程;也可以分开运行:
```bash ```bash
# 同时启动 CLI(3000)与 Web(5173)
npm run dev npm run dev
# 访问 http://localhost:3000
# 或分别启动
npm run dev:cli # CLI 内容服务器,占用 3000
npm run dev:web # Web 前端,占用 5173
# 访问 http://localhost:5173
``` ```
### CLI 调试 ### CLI 调试
```bash CLI 通过 ts-node 运行,加上 `--inspect` 与 `--enable-source-maps` 后即可在编辑器中附加断点。
# 使用 ts-node 直接运行 如需调试启动/扫描逻辑,用 `--inspect-brk` 在用户代码执行前暂停:
npm run ttrpg serve ./content
```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 npm run cli:build
node dist/cli/index.js serve ./content node dist/cli/index.js serve ./content
+191
View File
@@ -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
View File
@@ -11,6 +11,10 @@
- [marked-gfm-heading-id](https://github.com/markedjs/marked-gfm-heading-id) - 标题 ID - [marked-gfm-heading-id](https://github.com/markedjs/marked-gfm-heading-id) - 标题 ID
- [marked-directive](https://github.com/fengzilong/marked-directive) - 指令语法 - [marked-directive](https://github.com/fengzilong/marked-directive) - 指令语法
### 代码块围栏
代码块围栏可使用反引号或波浪号(`~~~`)书写;闭合围栏必须与开启围栏使用相同字符,且不短于开启围栏。声明 `role=` 的代码块同理。
## 指令语法 ## 指令语法
通过 `marked-directive` 支持自定义组件插入: 通过 `marked-directive` 支持自定义组件插入:
@@ -96,19 +100,19 @@
**注意:** CSS class 名称始终使用不带扩展名的图标名称(如 `icon-attack`),方便统一样式控制。 **注意:** CSS class 名称始终使用不带扩展名的图标名称(如 `icon-attack`),方便统一样式控制。
### 配置图标前缀 ### 图标查找路径
可通过 `iconPath` 属性指定图标目录: 图标没有单独的配置项。`:[name]` 会从当前文档所在目录开始,逐级向上查找 `assets/` 目录,
直到内容目录根:
```markdown ```text
<Article src="/content/page.md" iconPath="./assets/icons" /> rules/combat.md 中的 :[attack]
→ rules/assets/attack.png
→ assets/attack.png
``` ```
或使用空字符串禁用图标前缀: 因此把图标放在内容目录(或其子目录)的 `assets/` 下即可。`tttk check` 会用同样的规则
校验图标是否存在(诊断 `TTTK-A001`)。
```markdown
<Article src="/content/page.md" iconPath="" />
```
## 组件库 ## 组件库
@@ -123,6 +127,7 @@
- 点击文本执行掷骰 - 点击文本执行掷骰
- 点击骰子图标重置为公式 - 点击骰子图标重置为公式
- `key` 属性将结果记录到 URL 参数中 (`?dice-attack=15`) - `key` 属性将结果记录到 URL 参数中 (`?dice-attack=15`)
- 骰子数量可省略,`d20` 等价于 `1d20`
**属性:** **属性:**
@@ -168,17 +173,20 @@
```markdown ```markdown
:md-bg[./images/background.jpg] :md-bg[./images/background.jpg]
:md-bg[./images/pattern.png]{fit="contain"} :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) ### 📍 标记组件 (md-pins)
@@ -323,26 +378,30 @@ label,name,description
:md-table[./quests.csv]{roll=true remix=true} :md-table[./quests.csv]{roll=true remix=true}
``` ```
**自动表格转换:** **内联表格(显式声明):**
标准 Markdown 表格会自动转换为 `md-table` 组件,当表头包含 `label` 或 `md-table-label` 列时: Markdown 表格不会自动转换。如需内联表格,用代码块并声明 `role=spark-table`(首列需为骰子公式,如 `d6`):
```markdown ````markdown
| label | name | description | ```markdown role=spark-table
|-------|------|-------------| | d6 | 结果 |
| 1 | 战士 | 近战专家 | |----|------|
| 2 | 法师 | 奥术施法者 | | 1 | 遭遇强盗 |
| 2 | 平安无事 |
``` ```
````
自动转换为 `:md-table` 组件。 扫描时转换为 CSV 并渲染为 `md-table` 组件。CSV 格式的内联表格用 `csv` 语言:
**特殊表头标识:** ````markdown
```csv role=spark-table
d6,结果
1,遭遇强盗
2,平安无事
```
````
| 表头 | 效果 | 普通 Markdown 表格(无 role 声明)始终按标准 GFM 表格渲染,不做任何转换。
|------|------|
| `label` 或 `md-table-label` | 转换为 md-table |
| `md-roll-label` 或骰子格式(如 `1d6`) | 添加 `roll=true` |
| `md-remix-label` | 添加 `roll=true remix=true` |
### 🃏 卡牌组件 (md-deck) ### 🃏 卡牌组件 (md-deck)
@@ -373,6 +432,83 @@ layers="字段:起始行,起始列 - 结束列,字体大小"
示例:`title:1,1-5,1f8` 表示 title 字段从第 1 行开始,占据 1-5 列,8mm字体。 示例:`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) ### 🧶 叙事线组件 (md-yarn-spinner)
用于展示分支叙事结构,支持 Yarn Spinner 格式文件。 用于展示分支叙事结构,支持 Yarn Spinner 格式文件。
@@ -381,61 +517,12 @@ layers="字段:起始行,起始列 - 结束列,字体大小"
:md-yarn-spinner[./story.yarn] :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 标签
使用 ```yaml/tag 代码块创建自定义标签: 使用 ```yaml role=tag 代码块创建自定义标签(`yaml` 语言可被语法高亮):
````markdown ````markdown
```yaml/tag ```yaml role=tag
tag: tag-name tag: tag-name
class: custom-class class: custom-class
id: my-id 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 ```html
+5 -4
View File
@@ -246,10 +246,12 @@ ttrpg mcp generate-card-deck \
{ {
"success": true, "success": true,
"message": "预览文件已创建", "message": "预览文件已创建",
"preview_url": "http://localhost:3000/#/content/cards.md" "preview_url": "http://localhost:3000/cards"
} }
``` ```
`preview_url` 以服务器工作目录为基准,去掉了 `.md` 扩展名(需要 `tttk serve` 正在运行)。
## CSV 文件格式 ## CSV 文件格式
CSV 文件使用 YAML frontmatter 定义模板和配置: CSV 文件使用 YAML frontmatter 定义模板和配置:
@@ -335,8 +337,6 @@ MCP 服务器提供以下 Resources,包含 TTRPG Tools 的文档和参考材
- `:md-table` - 表格组件 - `:md-table` - 表格组件
- `:md-deck` - 卡牌组件 - `:md-deck` - 卡牌组件
- `:md-yarn-spinner` - 叙事线组件 - `:md-yarn-spinner` - 叙事线组件
- `:md-token` - 代币组件
- `:md-commander` - 命令追踪器
- YAML 标签 - YAML 标签
- Mermaid 图表 - Mermaid 图表
- 警告/提示块 - 警告/提示块
@@ -535,7 +535,8 @@ src/cli/
│ │ └── write-frontmatter.ts │ │ └── write-frontmatter.ts
│ ├── card/ │ ├── card/
│ │ └── card-crud.ts │ │ └── card-crud.ts
│ ├── ensure-deck-preview.ts │ ├── deck-utils.ts
│ ├── preview-deck.ts
│ └── generate-card-deck.ts │ └── generate-card-deck.ts
└── index.ts └── index.ts
``` ```
+393
View File
@@ -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.
+169
View File
@@ -0,0 +1,169 @@
# tttk skill reference — reorganization plan
Status: **implemented.** Follows the `tttk check` work that made
`docs/reference.md` a generated artifact (see `docs/check-plan.md` §7.11).
Landed: `known.ts` carries element/role `details`, inline specs, and dice
notation; `reference.ts` renders them; `scripts/generate-reference.ts` expands
`~` in `--out`; `npm run gen:skill-reference` refreshes the skill copy. The
skill's `reference.md` is now generated and byte-identical to
`docs/reference.md`, with the extra content moved to `layout.md`, `yaml-tag.md`,
`csv.md`, and `markdown.md`.
The skill is now **owned by the repo** at `.agents/skills/tttk-authoring/` and
activated by symlinking `~/.agents/skills/tttk-authoring` to it (see
`docs/development.md` § Agent skills).
## 1. Goal
Make the `tttk-authoring` skill's `reference.md` a **generated** file — the same
catalog the validator uses — and relocate the authoring content it does not
cover into focused companion files. One source of truth (`known.ts`), no
hand-maintained element list, and no content lost in the swap.
## 2. Current state
The skill (originally `~/.agents/skills/tttk-authoring/`, now
`.agents/skills/tttk-authoring/`) had a hand-written `reference.md` (~465 lines)
and `SKILL.md`. The repo generates `docs/reference.md` from
`src/cli/validate/known.ts` via `npm run gen:reference`.
What the generated doc already covers:
- Directive syntax (brief)
- Custom elements: table + per-element summary, body, attrs, examples
- Inline directives: icons, `:cmd` (brief)
- Code block roles: `spark-table`, `declare`, `file`, `tag` (brief)
- CSV data files (brief)
What only the hand-written skill copy has (the "extra content"):
| # | Section | Notes |
|---|---|---|
| A | Directive syntax table + container example | richer than generated |
| B | Per-element prose | md-dice behavior, md-table `roll`/`remix`/`group`, md-deck legacy layer syntax, md-bg `fit` values, md-border class flags, md-font `source` values, md-pins behavior, md-commander CSV columns |
| C | Icon attributes | `label`, `repeat`, `join`, `class` |
| D | Column layout | `\|---` / `-\|-` / `--\|` markers |
| E | `role=declare` column semantics | `tag`/`threshold`/`key`/`expr` |
| F | YAML tag blocks | `data-config`, layers, `back_layers`, fence-line overrides |
| G | CSV authoring | clean-CSV guidance, `label`/`body`, front matter (avoid) |
| H | Alerts and mermaid | GitHub alerts, mermaid diagrams |
| I | Dice notation | notation table |
| J | Validation codes | already duplicated in `SKILL.md` |
## 3. Target skill layout
```
tttk-authoring/
├── SKILL.md # workflow, rules, validation codes, links
├── reference.md # GENERATED from known.ts — elements, roles, inline, dice, CSV basics
├── layout.md # column layout
├── yaml-tag.md # role=tag, data-config, deck layers and backs
├── csv.md # CSV authoring and front matter
├── markdown.md # alerts and mermaid
└── examples/ # unchanged
```
`reference.md` is the only generated file; the rest are hand-written and linked
from `SKILL.md`.
## 4. Content mapping
| Source | Destination |
|---|---|
| A directive syntax | generator (`reference.ts`) — add the part/meaning table + container example |
| B per-element prose | `known.ts` `details` field → rendered into `reference.md` |
| C icon attributes | `known.ts` inline specs → rendered into `reference.md` |
| E declare semantics | `known.ts` `RoleSpec.details` → rendered into `reference.md` |
| I dice notation | `known.ts` `DICE_NOTATION` → rendered into `reference.md` |
| D column layout | `layout.md` |
| F YAML tag blocks | `yaml-tag.md` |
| G CSV authoring | `csv.md` |
| H alerts and mermaid | `markdown.md` |
| J validation codes | dropped — `SKILL.md` owns the code tables |
**Decision (recommended):** fold B/C/E/I into `known.ts` rather than a separate
`components.md`. It keeps the generated reference self-sufficient and avoids a
second, drifting description of the same elements. The alternative — a
hand-written `components.md` — is rejected because it reintroduces the drift the
generator exists to prevent.
## 5. Generator changes
`src/cli/validate/known.ts`:
- `ElementSpec.details?: string` — one paragraph of authoring prose.
- `ElementSpec.attrs` → optional richer `{ name, description }[]` so the
reference can render an attribute table (keep the plain list if not worth it).
- `InlineSpec` for `:[icon]` and `:cmd` with body/attrs/summary/examples.
- `RoleSpec.details?: string` for `declare` column semantics.
- `DICE_NOTATION: { notation, meaning }[]`.
`src/cli/validate/reference.ts`:
- Render `details`, the attribute table, inline specs, and dice notation.
- Add a `Contents` TOC.
- Upgrade the directive-syntax section (table + container example).
`scripts/generate-reference.ts`:
- Expand a leading `~` in `--out` to `os.homedir()` so the skill copy can be
regenerated cross-platform.
`package.json`:
- `gen:reference` — unchanged (writes `docs/reference.md`).
- `gen:skill-reference` — `gen:reference -- --out .agents/skills/tttk-authoring/reference.md`.
## 6. New skill file outlines
- **`layout.md`** — column markers, the `-N` width suffix, a worked example.
- **`yaml-tag.md`** — `role=tag` shape (`tag:`, `body:`, other keys as attrs),
fence-line overrides, `data-config` layer fields (`prop`/`template`, `pos`,
`font`/`fontSize`, `orientation`, `align`, `visible`), `back_layers`, and the
legacy `:md-deck` compact layer syntax.
- **`csv.md`** — clean-CSV guidance, `label`/`body` conventions, `group` tabs,
`{{prop}}` interpolation, quoting rules, and the discouraged front-matter form.
- **`markdown.md`** — GitHub alerts and mermaid (standard markdown, not tttk
syntax).
## 7. SKILL.md changes
- Update the "Reference" section to link `reference.md` plus the four new files.
- Keep the validation code tables in `SKILL.md` (they are the agent's quick
reference); `reference.md` no longer carries them.
- Reword "check `reference.md` when unsure" to point at the right file per topic.
## 8. Steps
1. Enrich `known.ts` (details, inline specs, role details, dice notation).
2. Update `reference.ts` and `generate-reference.ts`; regenerate
`docs/reference.md`; keep the `known.test.ts` drift guard green.
3. Extract D/F/G/H from the old skill `reference.md` into `layout.md`,
`yaml-tag.md`, `csv.md`, `markdown.md`.
4. Regenerate the skill's `reference.md` with `npm run gen:skill-reference`.
5. Update `SKILL.md` links.
6. Verify (§9).
## 9. Verification
- `npm run gen:reference` leaves `docs/reference.md` unchanged (idempotent).
- `npx jest` passes, including the reference drift guard.
- Diff the old skill `reference.md` against the new file set and confirm every
section in §2's table has a destination — no content silently dropped.
- `npx tttk check ./content` still runs clean (the catalog changes are
documentation-only, but the drift guard protects `known.ts`).
## 10. Risks
- **Content loss during extraction.** Mitigate with the §4 mapping as a
checklist and a manual diff before deleting the old file.
- **`known.ts` bloat.** Keep `details` to one paragraph per element; long-form
material belongs in the companion files.
- **Skill path portability.** The skill now lives in the repo, so
`gen:skill-reference` writes to a stable path; `~` expansion in `--out`
remains for ad-hoc targets. Activation is a symlink, which is environment
setup rather than repo state — documented in `docs/development.md`.
- **Two references.** `docs/reference.md` and the skill copy are both generated
from `known.ts`; they must be regenerated together. Note this in
`docs/check-plan.md` §9.
+149
View File
@@ -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.
+8
View File
@@ -4,6 +4,14 @@ export default {
testEnvironment: 'node', testEnvironment: 'node',
testMatch: ['**/*.test.ts'], testMatch: ['**/*.test.ts'],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], 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: { transform: {
'^.+\\.tsx?$': [ '^.+\\.tsx?$': [
'ts-jest', 'ts-jest',
+869 -230
View File
File diff suppressed because it is too large Load Diff
+45 -20
View File
@@ -1,26 +1,42 @@
{ {
"name": "ttrpg-tools", "name": "tttk",
"version": "0.0.1", "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", "type": "module",
"bin": { "bin": {
"ttrpg": "./dist/cli/index.js" "tttk": "./dist/cli/index.js"
}, },
"main": "./dist/index.js", "files": [
"module": "./dist/index.js", "dist",
"exports": { "LICENSE",
".": "./dist/index.js", "README.md"
"./components": "./dist/components/index.js", ],
"./markdown": "./dist/markdown/index.js" "repository": {
"type": "git",
"url": "https://github.com/hypercross/ttrpg-tools"
},
"engines": {
"node": ">=18"
}, },
"scripts": { "scripts": {
"dev": "rsbuild dev", "dev": "node scripts/dev.mjs",
"build": "rsbuild build", "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", "preview": "rsbuild preview",
"analyze": "cross-env ANALYZE=true rsbuild build",
"cli:dev": "tsc -p tsconfig.cli.json --watch", "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", "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": [ "keywords": [
"ttrpg", "ttrpg",
@@ -28,13 +44,15 @@
"markdown", "markdown",
"toolbox" "toolbox"
], ],
"author": "", "author": "hypercross",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^0.5.0", "@modelcontextprotocol/sdk": "^0.5.0",
"@solidjs/router": "^0.15.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", "chokidar": "^5.0.0",
"comlink": "^4.4.2",
"commander": "^14.0.3", "commander": "^14.0.3",
"csv-parse": "^6.1.0", "csv-parse": "^6.1.0",
"csv-stringify": "^6.7.0", "csv-stringify": "^6.7.0",
@@ -44,14 +62,16 @@
"marked-directive": "^1.0.7", "marked-directive": "^1.0.7",
"marked-gfm-heading-id": "^4.1.3", "marked-gfm-heading-id": "^4.1.3",
"mermaid": "^11.0.0", "mermaid": "^11.0.0",
"mqtt": "^5.15.1",
"solid-element": "^1.9.1", "solid-element": "^1.9.1",
"solid-icons": "^1.2.0",
"solid-js": "^1.9.3", "solid-js": "^1.9.3",
"three": "^0.183.2", "uuid": "^14.0.1",
"three-3mf-exporter": "^45.1.0", "ws": "^8.21.0",
"yarn-spinner-loader": "^0.1.0" "yarn-spinner-loader": "^0.1.0",
"zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@image-tracer-ts/core": "^1.0.2",
"@rsbuild/core": "^1.1.8", "@rsbuild/core": "^1.1.8",
"@rsbuild/plugin-babel": "^1.1.0", "@rsbuild/plugin-babel": "^1.1.0",
"@rsbuild/plugin-solid": "^1.1.0", "@rsbuild/plugin-solid": "^1.1.0",
@@ -61,11 +81,16 @@
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@types/node": "^22.19.13", "@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": "^29.7.0",
"jest-environment-jsdom": "^29.7.0", "jest-environment-jsdom": "^29.7.0",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"ts-jest": "^29.4.6", "ts-jest": "^29.4.6",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.9.3" "typescript": "^5.9.3",
"webpack-bundle-analyzer": "^5.3.0"
} }
} }
+91 -21
View File
@@ -1,7 +1,45 @@
import { defineConfig } from '@rsbuild/core'; import { defineConfig } from "@rsbuild/core";
import { pluginBabel } from '@rsbuild/plugin-babel'; import { pluginBabel } from "@rsbuild/plugin-babel";
import { pluginSolid } from '@rsbuild/plugin-solid'; import { pluginSolid } from "@rsbuild/plugin-solid";
import tailwindcss from '@tailwindcss/postcss'; 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({ export default defineConfig({
plugins: [ plugins: [
@@ -16,32 +54,64 @@ export default defineConfig({
plugins: [tailwindcss()], plugins: [tailwindcss()],
}, },
}, },
rspack: { rspack: {
module: { resolve: {
rules: [ // Allow TypeScript sources to use explicit `.js` specifiers (required
{ // by the CLI's NodeNext build) while the bundler resolves them to .ts.
test: /\.md|\.yarn|\.csv$/, extensionAlias: {
exclude: /\.schema\.csv$/, ".js": [".ts", ".tsx", ".js"],
type: 'asset/source' },
}, },
] 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: { html: {
template: './src/index.html', template: "./src/index.html",
}, },
source: { source: {
entry: { 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: { output: {
distPath: { distPath: {
root: 'dist/web', root: "dist/web",
}, },
copy: process.env.NODE_ENV === 'development' ? [
{ from: './content', to: 'content' }
] : [],
}, },
}); });
+55
View File
@@ -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));
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env node
/**
* Regenerate `docs/reference.md` from the `known.ts` catalog.
*
* npm run gen:reference
* npm run gen:reference -- --out ~/.agents/skills/tttk-authoring/reference.md
*/
import { mkdirSync, writeFileSync } from "fs";
import { homedir } from "os";
import { dirname, resolve } from "path";
import { renderReference } from "../src/cli/validate/reference.js";
function argValue(flag: string): string | undefined {
const index = process.argv.indexOf(flag);
return index >= 0 ? process.argv[index + 1] : undefined;
}
/** Expand a leading `~` so the skill copy can be targeted cross-platform. */
function expandHome(path: string): string {
if (path === "~") return homedir();
if (path.startsWith("~/") || path.startsWith("~\\")) {
return resolve(homedir(), path.slice(2));
}
return path;
}
const out = resolve(expandHome(argValue("--out") ?? "docs/reference.md"));
mkdirSync(dirname(out), { recursive: true });
writeFileSync(out, renderReference(), "utf-8");
console.log(`wrote ${out}`);
+192 -20
View File
@@ -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 { useLocation } from "@solidjs/router";
import {
TbOutlineMenu2,
TbOutlineClipboardList,
TbOutlineHelpCircle,
TbOutlineLayoutSidebarLeftCollapse,
TbOutlineLayoutSidebarLeftExpand,
} from "solid-icons/tb";
import { useJournalStream } from "./components/stores/journalStream";
// 导入组件以注册自定义元素 // 导入组件以注册自定义元素
import "./components"; 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 App: Component = () => {
const location = useLocation(); const location = useLocation();
const stream = useJournalStream();
const [isSidebarOpen, setIsSidebarOpen] = createSignal(false); 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(() => { const currentPath = createMemo(() => {
// 根据路由加载对应的 markdown 文件 // 根据路由加载对应的 markdown 文件
@@ -21,33 +97,129 @@ const App: Component = () => {
}); });
return ( return (
<div class="min-h-screen bg-gray-50"> <div class="h-screen flex flex-col overflow-hidden bg-gray-50">
{/* 桌面端固定侧边栏 */} {/* Header */}
<DesktopSidebar /> <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">
<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">
{/* 仅在移动端显示菜单按钮 */}
<button <button
onClick={() => setIsSidebarOpen(true)} onClick={() => setIsSidebarOpen(true)}
class="md:hidden text-gray-600 hover:text-gray-900 p-1 rounded hover:bg-gray-100" class="md:hidden text-gray-600 hover:text-gray-900 p-1 rounded hover:bg-gray-100"
title="目录" 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> </button>
<h1 class="text-2xl font-bold text-gray-900">TTRPG Tools</h1>
</div> </div>
</header> </header>
{/* fill th rest of the space*/}
<div class="fixed top-16 left-0 right-0 bottom-0 overflow-auto"> {/* 移动端抽屉式侧边栏 (overlay) */}
<main class="max-w-4xl mx-auto px-4 py-8 pt-4 md:ml-64 2xl:ml-auto flex justify-center items-center"> <MobileSidebar
<Article class="prose-sm max-w-full flex-1" src={currentPath()} /> isOpen={isSidebarOpen()}
</main> 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> </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> </div>
); );
}; };
+23
View File
@@ -0,0 +1,23 @@
import type { CheckCommandHandler } from "../types.js";
import { formatHuman, formatJson } from "../validate/diagnostics.js";
import { runCheck } from "../validate/index.js";
export const checkCommand: CheckCommandHandler = async (dir, options) => {
let report;
try {
report = runCheck(dir);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 2;
return;
}
console.log(
options.json ? formatJson(report) : formatHuman(report, options.quiet),
);
const failed =
report.summary.errors > 0 ||
(options.strict && report.summary.warnings > 0);
if (failed) process.exitCode = 1;
};
+6 -6
View File
@@ -1,14 +1,14 @@
import type { CompileCommandHandler } from '../types.js'; import type { CompileCommandHandler } from "../types.js";
export const compileCommand: CompileCommandHandler = async (dir, options) => { export const compileCommand: CompileCommandHandler = async (dir, options) => {
console.log(`开始编译...`); console.log("开始编译...");
console.log(`目录:${dir}`); console.log(`源目录:${dir}`);
console.log(`输出目录:${options.output}`); console.log(`输出目录:${options.output}`);
// TODO: 实现编译逻辑 // TODO: 实现编译逻辑
// 1. 扫描目录下的所有 .md 文件 // 1. 扫描目录下的所有 .md 文件
// 2. 解析 markdown 并生成路由 // 2. 解析 markdown 并生成路由
// 3. 打包为带 hash 路由的单个 HTML 入口 // 3. 打包为带 hash 路由的单个 HTML 入口
console.log('编译完成!'); console.log("编译完成!");
}; };
+145 -65
View File
@@ -2,23 +2,33 @@ import type { ServeCommandHandler } from "../types.js";
import { createServer, Server, IncomingMessage, ServerResponse } from "http"; import { createServer, Server, IncomingMessage, ServerResponse } from "http";
import { readdirSync, statSync, readFileSync, existsSync } from "fs"; import { readdirSync, statSync, readFileSync, existsSync } from "fs";
import { createReadStream } 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 { watch } from "chokidar";
import { networkInterfaces } from "os";
import { fileURLToPath } from "url"; 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 { interface ContentIndex {
[path: string]: string; [path: string]: string;
} }
/** /**
* 获取 CLI 脚本文件所在目录路径(用于定位 dist 文件夹) * 获取 CLI 脚本文件所在目录路径(用于定位 dist 目录)
*/ */
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
const distDir = resolve(__dirname, "..", "..", "..", "dist", "web"); const distDir = resolve(__dirname, "..", "..", "..", "dist", "web");
/** /**
* MIME 类型映射 * MIME 类型映射表
*/ */
const MIME_TYPES: Record<string, string> = { const MIME_TYPES: Record<string, string> = {
".html": "text/html", ".html": "text/html",
@@ -37,7 +47,7 @@ const MIME_TYPES: Record<string, string> = {
}; };
/** /**
* 获取文件扩展名对应的 MIME 类型 * 根据文件扩展名获取对应的 MIME 类型
*/ */
function getMimeType(filePath: string): string { function getMimeType(filePath: string): string {
const ext = extname(filePath).toLowerCase(); const ext = extname(filePath).toLowerCase();
@@ -45,41 +55,43 @@ function getMimeType(filePath: string): string {
} }
/** /**
* 扫描目录内的 .md 文件,生成索引 * 获取最佳网络 IP 用于显示(优先局域网 IPv4,回退到 localhost)
*/ */
export function scanDirectory(dir: string): ContentIndex { function getBestIP(): string {
const index: ContentIndex = {}; const interfaces = networkInterfaces();
let best: string | null = null;
function scan(currentPath: string, relativePath: string) { for (const [, addrs] of Object.entries(interfaces)) {
const entries = readdirSync(currentPath); if (!addrs) continue;
for (const addr of addrs) {
for (const entry of entries) { if (addr.family !== "IPv4" || addr.internal) continue;
if (entry.startsWith(".") || entry === "node_modules") continue; // Prefer a 192.168.x.x or 10.x.x.x address
if (addr.address.startsWith("192.168.")) {
const fullPath = join(currentPath, entry); best = addr.address;
const relPath = relativePath ? join(relativePath, entry) : entry; break;
const normalizedRelPath = "/" + relPath.split(sep).join("/"); }
if (
const stats = statSync(fullPath); !best &&
if (stats.isDirectory()) { (addr.address.startsWith("10.") || addr.address.startsWith("172."))
scan(fullPath, relPath); ) {
} else if (entry.endsWith(".md") || entry.endsWith(".csv") || entry.endsWith(".yarn")) { best = addr.address;
try {
const content = readFileSync(fullPath, "utf-8");
index[normalizedRelPath] = content;
} catch (e) {
console.error(`读取文件失败:${fullPath}`, e);
}
} }
} }
if (best?.startsWith("192.168.")) break;
} }
scan(dir, ""); return best || "localhost";
return index;
} }
/** /**
* 发送文件响应 * 扫描目录内的 .md 等文件,构建内容注册表(路径索引 + 每文档内联内容)
*/
export function buildRegistry(dir: string): ContentRegistry {
return buildRegistryFromIndex(readContentIndex(dir));
}
/**
* 发送文件响应(流式传输)
*/ */
function sendFile(res: ServerResponse, filePath: string) { function sendFile(res: ServerResponse, filePath: string) {
res.writeHead(200, { res.writeHead(200, {
@@ -90,7 +102,7 @@ function sendFile(res: ServerResponse, filePath: string) {
} }
/** /**
* 发送 404 响应 * 发送 404 未找到响应
*/ */
function send404(res: ServerResponse) { function send404(res: ServerResponse) {
res.writeHead(404, { "Content-Type": "text/plain" }); res.writeHead(404, { "Content-Type": "text/plain" });
@@ -98,7 +110,7 @@ function send404(res: ServerResponse) {
} }
/** /**
* 发送 JSON 响应 * 发送 JSON 格式的响应
*/ */
function sendJson(res: ServerResponse, data: unknown) { function sendJson(res: ServerResponse, data: unknown) {
res.writeHead(200, { res.writeHead(200, {
@@ -110,7 +122,7 @@ function sendJson(res: ServerResponse, data: unknown) {
/** /**
* 尝试提供静态文件 * 尝试提供静态文件
* @returns 如果文件存在并已成功发送则返回 true * @returns 文件存在并成功发送时返回 true
*/ */
function tryServeStatic( function tryServeStatic(
res: ServerResponse, res: ServerResponse,
@@ -133,12 +145,14 @@ function tryServeStatic(
} }
/** /**
* 创建请求处理器 * 创建 HTTP 请求处理器
*/ */
function createRequestHandler( function createRequestHandler(
contentDir: string, contentDir: string,
distDir: string, distDir: string,
getIndex: () => ContentIndex, getIndex: () => ContentIndex,
getCompletions: () => CompletionsPayload,
getRegistry: () => ContentRegistry,
) { ) {
return (req: IncomingMessage, res: ServerResponse) => { return (req: IncomingMessage, res: ServerResponse) => {
const url = req.url || "/"; const url = req.url || "/";
@@ -150,6 +164,18 @@ function createRequestHandler(
return; 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) // 2. 处理 /static/ 目录(从 dist/web)
if (filePath.startsWith("/static/")) { if (filePath.startsWith("/static/")) {
if (tryServeStatic(res, filePath, distDir)) { if (tryServeStatic(res, filePath, distDir)) {
@@ -176,7 +202,7 @@ function createRequestHandler(
} }
/** /**
* 内容服务器接口 * 内容服务器实例接口
*/ */
export interface ContentServer { export interface ContentServer {
/** /**
@@ -191,6 +217,10 @@ export interface ContentServer {
* 内容索引 * 内容索引
*/ */
index: ContentIndex; index: ContentIndex;
/**
* 补全数据
*/
completions: CompletionsPayload;
/** /**
* 关闭服务器 * 关闭服务器
*/ */
@@ -198,7 +228,7 @@ export interface ContentServer {
} }
/** /**
* 创建内容服务器 * 创建内容服务器(HTTP + WebSocket + 文件监听)
*/ */
export function createContentServer( export function createContentServer(
contentDir: string, contentDir: string,
@@ -206,80 +236,124 @@ export function createContentServer(
distPath: string = distDir, distPath: string = distDir,
host: string = "0.0.0.0", host: string = "0.0.0.0",
): ContentServer { ): ContentServer {
let contentIndex: ContentIndex = {}; let registry: ContentRegistry = { pathIndex: {}, docContent: {} };
let completionsIndex: CompletionsPayload = {
dice: [],
links: [],
sparkTables: [],
declarations: [],
tagModifiers: [],
};
// 扫描内容目录生成索引 /** 从当前注册表重新派生补全数据 */
console.log("扫描内容目录..."); function recomputeCompletions(): void {
contentIndex = scanDirectory(contentDir); completionsIndex = deriveCompletions(registry);
console.log(`已索引 ${Object.keys(contentIndex).length} 个文件`); 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, { const watcher = watch(contentDir, {
ignored: /(^|[\/\\])\../, ignored: /(^|[\/\\])\./,
persistent: true, persistent: true,
ignoreInitial: true, ignoreInitial: true,
}); });
watcher watcher
.on("add", (path) => { .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 { try {
const content = readFileSync(path, "utf-8"); // 全量重建注册表以刷新跨文件派生(spark 注入)
const relPath = "/" + relative(contentDir, path).split(sep).join("/"); registry = buildRegistry(contentDir);
contentIndex[relPath] = content; recomputeCompletions();
console.log(`[新增] ${relPath}`); console.log(`[新增] ${path}`);
} catch (e) { } catch (e) {
console.error(`读取新增文件失败:${path}`, e); console.error(`读取新增文件失败:${path}`, e);
} }
} }
}) })
.on("change", (path) => { .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 { try {
const content = readFileSync(path, "utf-8"); registry = buildRegistry(contentDir);
const relPath = "/" + relative(contentDir, path).split(sep).join("/"); recomputeCompletions();
contentIndex[relPath] = content; console.log(`[更新] ${path}`);
console.log(`[更新] ${relPath}`);
} catch (e) { } catch (e) {
console.error(`读取更新文件失败:${path}`, e); console.error(`读取更新文件失败:${path}`, e);
} }
} }
}) })
.on("unlink", (path) => { .on("unlink", (path) => {
if (path.endsWith(".md") || path.endsWith(".csv") || path.endsWith(".yarn")) { if (
const relPath = "/" + relative(contentDir, path).split(sep).join("/"); path.endsWith(".md") ||
delete contentIndex[relPath]; path.endsWith(".csv") ||
console.log(`[删除] ${relPath}`); 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( const handleRequest = createRequestHandler(
contentDir, contentDir,
distPath, distPath,
() => contentIndex, () => registry.pathIndex,
() => completionsIndex,
() => registry,
); );
// 创建 HTTP 服务器
const server = createServer(handleRequest); const server = createServer(handleRequest);
createJournalServer(contentDir, server).then((j) => {
journal = j;
});
server.listen(port, host, () => { 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(`\n开发服务器已启动:http://${displayHost}:${port}`);
console.log(`本机访问:http://localhost:${port}`);
if (bestIP !== "localhost") {
console.log(`局域网访问:http://${bestIP}:${port}`);
}
console.log(`内容目录:${contentDir}`); console.log(`内容目录:${contentDir}`);
console.log(`静态资源目录:${distPath}`); console.log(`静态资源目录:${distPath}`);
console.log(`索引文件:http://${displayHost}:${port}/__CONTENT_INDEX.json\n`); console.log(`日志连接:ws://${bestIP}:${port}\n`);
}); });
return { return {
server, server,
watcher, watcher,
index: contentIndex, index: registry.pathIndex,
completions: completionsIndex,
close() { close() {
console.log("关闭内容服务器..."); console.log("正在关闭内容服务器...");
server.close(); server.close();
watcher.close(); watcher.close();
if (journal) journal.close();
}, },
}; };
} }
@@ -289,6 +363,12 @@ export function createContentServer(
*/ */
export const serveCommand: ServeCommandHandler = async (dir, options) => { export const serveCommand: ServeCommandHandler = async (dir, options) => {
const contentDir = resolve(dir); const contentDir = resolve(dir);
if (!existsSync(contentDir)) {
console.error(`错误:目录不存在:${contentDir}`);
process.exit(1);
}
const port = parseInt(options.port, 10); const port = parseInt(options.port, 10);
const host = options.host || "0.0.0.0"; 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";
+30
View File
@@ -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;
+178
View File
@@ -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);
}
+66
View File
@@ -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
+52
View File
@@ -0,0 +1,52 @@
/**
* Filesystem walk that builds a raw `{ path: content }` index.
*
* Extracted from `buildRegistry` in `commands/serve.ts` so the validator can
* obtain the *raw* file contents (needed for accurate line numbers) while the
* server gets the scanned registry — both from the same walk.
*/
import { readdirSync, statSync, readFileSync } from "fs";
import { join } from "path";
import { normalizePathKey } from "./content-registry.js";
export interface ContentIndex {
[path: string]: string;
}
/** Recursively read indexable files (`.md`, `.csv`, `.yarn`, `.svg`). */
export function readContentIndex(dir: string): ContentIndex {
const index: ContentIndex = {};
function scan(currentPath: string, relativePath: string) {
const entries = readdirSync(currentPath);
for (const entry of entries) {
if (entry.startsWith(".") || entry === "node_modules") continue;
const fullPath = join(currentPath, entry);
const relPath = relativePath ? join(relativePath, entry) : entry;
const normalizedRelPath = normalizePathKey(relPath);
const stats = statSync(fullPath);
if (stats.isDirectory()) {
scan(fullPath, relPath);
} else if (
entry.endsWith(".md") ||
entry.endsWith(".csv") ||
entry.endsWith(".yarn") ||
entry.endsWith(".svg")
) {
try {
index[normalizedRelPath] = readFileSync(fullPath, "utf-8");
} catch (e) {
console.error(`读取文件失败:${fullPath}`, e);
}
}
}
}
scan(dir, "");
return index;
}
+228
View File
@@ -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);
});
});
+626
View File
@@ -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
View File
@@ -1,33 +1,60 @@
#!/usr/bin/env node #!/usr/bin/env node
import { Command } from 'commander'; import { Command } from "commander";
import { serveCommand } from './commands/serve.js'; import { serveCommand } from "./commands/serve.js";
import { compileCommand } from './commands/compile.js'; import { compileCommand } from "./commands/compile.js";
import { mcpCommand } from './commands/mcp.js'; import { checkCommand } from "./commands/check.js";
import type { ServeOptions, CompileOptions } from './types.js'; import { mcpCommand } from "./commands/mcp.js";
import type { ServeOptions, CompileOptions } from "./types.js";
const program = new Command(); const program = new Command();
program program
.name('ttrpg') .name("tttk")
.description('TTRPG 工具箱 - 用于编译和预览 TTRPG 文档') .description(
.version('0.0.1'); "TTRPG 内容预览服务器。不带参数运行时默认启动当前目录的预览服务。",
)
.version("0.0.1");
program program
.command('serve') .command("serve")
.description('运行一个 web 服务器预览目录中的内容,并实时监听更新') .description("启动 web 服务器预览目录中的内容,并实时监听文件更新")
.argument('[dir]', '要预览的目录', '.') .argument("[dir]", "要预览的目录", ".")
.option('-p, --port <port>', '端口号', '3000') .option("-p, --port <port>", "HTTP 端口号", "3000")
.option('-h, --host <host>', '主机地址', '0.0.0.0') .option("--host <host>", "主机地址", "0.0.0.0")
.action(serveCommand); .action(serveCommand);
program program
.command('compile') .command("check")
.description('将目录中的内容输出为带 hash 路由、单个 html 入口的 web 应用') .alias("lint")
.argument('[dir]', '要编译的目录', '.') .description("校验目录内的 tttk 文档,输出结构化诊断")
.option('-o, --output <dir>', '输出目录', './dist/output') .argument("[dir]", "要校验的目录", ".")
.action(compileCommand); .option("--json", "以 JSON 输出诊断", false)
.option("--strict", "将警告视为错误", false)
.option("--quiet", "只报告错误", false)
.action(checkCommand);
program 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]);
+279
View File
@@ -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);
});
});
+465
View File
@@ -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);
}
}
+66 -78
View File
@@ -1,8 +1,11 @@
import { readFileSync, writeFileSync, existsSync } from 'fs'; import { readFileSync, writeFileSync, existsSync } from "fs";
import { parse } from 'csv-parse/browser/esm/sync'; import { parse } from "csv-parse/browser/esm/sync";
import { stringify } from 'csv-stringify/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 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; 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) * 加载 CSV 数据(包含 frontmatter)
*/ */
@@ -87,19 +55,19 @@ function loadCSVWithFrontmatter(filePath: string): {
records: CardData[]; records: CardData[];
headers: string[]; headers: string[];
} { } {
const content = readFileSync(filePath, 'utf-8'); const content = readFileSync(filePath, "utf-8");
const { frontmatter, csvContent } = parseFrontMatter(content); const { frontmatter, csvContent } = parseFrontMatter(content);
const records = parse(csvContent, { const records = parse(csvContent, {
columns: true, columns: true,
comment: '#', comment: "#",
trim: true, trim: true,
skipEmptyLines: true skipEmptyLines: true,
}) as CardData[]; }) as CardData[];
// 获取表头 // 获取表头
const firstLine = csvContent.split('\n')[0]; const firstLine = csvContent.split("\n")[0];
const headers = firstLine.split(',').map(h => h.trim()); const headers = firstLine.split(",").map((h) => h.trim());
return { frontmatter, records, headers }; return { frontmatter, records, headers };
} }
@@ -111,30 +79,30 @@ function saveCSVWithFrontmatter(
filePath: string, filePath: string,
frontmatter: DeckFrontmatter | undefined, frontmatter: DeckFrontmatter | undefined,
records: CardData[], records: CardData[],
headers?: string[] headers?: string[],
): void { ): void {
// 序列化 frontmatter // 序列化 frontmatter
const frontmatterStr = frontmatter ? serializeFrontMatter(frontmatter) : ''; const frontmatterStr = frontmatter ? serializeFrontMatter(frontmatter) : "";
// 确定表头 // 确定表头
if (!headers || headers.length === 0) { if (!headers || headers.length === 0) {
// 从 records 和 frontmatter.fields 推断表头 // 从 records 和 frontmatter.fields 推断表头
headers = ['label']; headers = ["label"];
if (frontmatter?.fields && Array.isArray(frontmatter.fields)) { if (frontmatter?.fields && Array.isArray(frontmatter.fields)) {
for (const field of 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(field.name);
} }
} }
} }
headers.push('body'); headers.push("body");
} }
// 确保所有 record 都有 headers 中的列 // 确保所有 record 都有 headers 中的列
for (const record of records) { for (const record of records) {
for (const header of headers) { for (const header of headers) {
if (!(header in record)) { if (!(header in record)) {
record[header] = ''; record[header] = "";
} }
} }
} }
@@ -142,11 +110,11 @@ function saveCSVWithFrontmatter(
// 序列化 CSV // 序列化 CSV
const csvContent = stringify(records, { const csvContent = stringify(records, {
header: true, 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; const { csv_file, action, cards, label } = params;
// 检查文件是否存在(create 操作可以不存在) // 检查文件是否存在(create 操作可以不存在)
if (action !== 'create' && !existsSync(csv_file)) { if (action !== "create" && !existsSync(csv_file)) {
return { return {
success: false, success: false,
message: `文件不存在:${csv_file}` message: `文件不存在:${csv_file}`,
}; };
} }
@@ -189,8 +157,8 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
// 执行操作 // 执行操作
switch (action) { switch (action) {
case 'create': { case "create": {
const newCards = Array.isArray(cards) ? cards : (cards ? [cards] : []); const newCards = Array.isArray(cards) ? cards : cards ? [cards] : [];
for (const card of newCards) { for (const card of newCards) {
if (!card.label) { if (!card.label) {
card.label = generateNextLabel(records); card.label = generateNextLabel(records);
@@ -202,16 +170,22 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
success: true, success: true,
message: `成功创建 ${newCards.length} 张卡牌`, message: `成功创建 ${newCards.length} 张卡牌`,
cards: newCards, cards: newCards,
count: newCards.length count: newCards.length,
}; };
} }
case 'read': { case "read": {
const labelsToRead = Array.isArray(label) ? label : (label ? [label] : null); const labelsToRead = Array.isArray(label)
? label
: label
? [label]
: null;
let resultCards: CardData[]; let resultCards: CardData[];
if (labelsToRead && labelsToRead.length > 0) { if (labelsToRead && labelsToRead.length > 0) {
resultCards = records.filter(r => labelsToRead.includes(r.label || '')); resultCards = records.filter((r) =>
labelsToRead.includes(r.label || ""),
);
} else { } else {
resultCards = records; resultCards = records;
} }
@@ -220,20 +194,26 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
success: true, success: true,
message: `成功读取 ${resultCards.length} 张卡牌`, message: `成功读取 ${resultCards.length} 张卡牌`,
cards: resultCards, cards: resultCards,
count: resultCards.length count: resultCards.length,
}; };
} }
case 'update': { case "update": {
const labelsToUpdate = Array.isArray(label) ? label : (label ? [label] : null); const labelsToUpdate = Array.isArray(label)
const updateCards = Array.isArray(cards) ? cards : (cards ? [cards] : []); ? label
: label
? [label]
: null;
const updateCards = Array.isArray(cards) ? cards : cards ? [cards] : [];
let updatedCount = 0; let updatedCount = 0;
if (labelsToUpdate && labelsToUpdate.length > 0) { if (labelsToUpdate && labelsToUpdate.length > 0) {
// 按 label 更新 // 按 label 更新
for (const updateCard of updateCards) { for (const updateCard of updateCards) {
const targetLabel = updateCard.label || labelsToUpdate[updatedCount % labelsToUpdate.length]; const targetLabel =
const index = records.findIndex(r => r.label === targetLabel); updateCard.label ||
labelsToUpdate[updatedCount % labelsToUpdate.length];
const index = records.findIndex((r) => r.label === targetLabel);
if (index !== -1) { if (index !== -1) {
records[index] = { ...records[index], ...updateCard }; records[index] = { ...records[index], ...updateCard };
updatedCount++; updatedCount++;
@@ -243,7 +223,9 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
// 按 cards 中的 label 更新 // 按 cards 中的 label 更新
for (const updateCard of updateCards) { for (const updateCard of updateCards) {
if (updateCard.label) { if (updateCard.label) {
const index = records.findIndex(r => r.label === updateCard.label); const index = records.findIndex(
(r) => r.label === updateCard.label,
);
if (index !== -1) { if (index !== -1) {
records[index] = { ...records[index], ...updateCard }; records[index] = { ...records[index], ...updateCard };
updatedCount++; updatedCount++;
@@ -257,23 +239,29 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
success: true, success: true,
message: `成功更新 ${updatedCount} 张卡牌`, message: `成功更新 ${updatedCount} 张卡牌`,
cards: updateCards, cards: updateCards,
count: updatedCount count: updatedCount,
}; };
} }
case 'delete': { case "delete": {
const labelsToDelete = Array.isArray(label) ? label : (label ? [label] : null); const labelsToDelete = Array.isArray(label)
? label
: label
? [label]
: null;
let deletedCount = 0; let deletedCount = 0;
if (labelsToDelete && labelsToDelete.length > 0) { if (labelsToDelete && labelsToDelete.length > 0) {
const beforeCount = records.length; const beforeCount = records.length;
records = records.filter(r => !labelsToDelete.includes(r.label || '')); records = records.filter(
(r) => !labelsToDelete.includes(r.label || ""),
);
deletedCount = beforeCount - records.length; deletedCount = beforeCount - records.length;
} else if (cards) { } else if (cards) {
const cardsToDelete = Array.isArray(cards) ? cards : [cards]; const cardsToDelete = Array.isArray(cards) ? cards : [cards];
const beforeCount = records.length; const beforeCount = records.length;
records = records.filter(r => records = records.filter(
!cardsToDelete.some(c => c.label && r.label === c.label) (r) => !cardsToDelete.some((c) => c.label && r.label === c.label),
); );
deletedCount = beforeCount - records.length; deletedCount = beforeCount - records.length;
} }
@@ -282,20 +270,20 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
return { return {
success: true, success: true,
message: `成功删除 ${deletedCount} 张卡牌`, message: `成功删除 ${deletedCount} 张卡牌`,
count: deletedCount count: deletedCount,
}; };
} }
default: default:
return { return {
success: false, success: false,
message: `未知操作:${action}` message: `未知操作:${action}`,
}; };
} }
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
message: `操作失败:${error instanceof Error ? error.message : '未知错误'}` message: `操作失败:${error instanceof Error ? error.message : "未知错误"}`,
}; };
} }
} }
+134
View File
@@ -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");
}
-217
View File
@@ -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 : '未知错误'}`
};
}
}
+12 -32
View File
@@ -1,5 +1,5 @@
import { readFileSync, existsSync } from 'fs'; import { readFileSync, existsSync } from "fs";
import yaml from 'js-yaml'; import { parseFrontMatter } from "./shared.js";
/** /**
* 读取 CSV frontmatter 的参数 * 读取 CSV frontmatter 的参数
@@ -52,7 +52,7 @@ export interface CardFieldStyle {
/** /**
* 朝向:上侧朝向 "n" | "w" | "s" | "e"(北/西/南/东) * 朝向:上侧朝向 "n" | "w" | "s" | "e"(北/西/南/东)
*/ */
up?: 'n' | 'w' | 's' | 'e'; up?: "n" | "w" | "s" | "e";
} }
/** /**
@@ -74,7 +74,7 @@ export interface DeckConfig {
grid?: string; grid?: string;
bleed?: number; bleed?: number;
padding?: number; padding?: number;
shape?: 'rectangle' | 'circle' | 'hex' | 'diamond'; shape?: "rectangle" | "circle" | "hex" | "diamond";
layers?: string; layers?: string;
back_layers?: string; back_layers?: string;
[key: string]: unknown; [key: string]: unknown;
@@ -89,45 +89,25 @@ export interface ReadFrontmatterResult {
message: string; 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 * 读取 CSV 文件的 frontmatter
*/ */
export function readFrontmatter(params: ReadFrontmatterParams): ReadFrontmatterResult { export function readFrontmatter(
params: ReadFrontmatterParams,
): ReadFrontmatterResult {
const { csv_file } = params; const { csv_file } = params;
// 检查文件是否存在 // 检查文件是否存在
if (!existsSync(csv_file)) { if (!existsSync(csv_file)) {
return { return {
success: false, success: false,
message: `文件不存在:${csv_file}` message: `文件不存在:${csv_file}`,
}; };
} }
try { try {
// 读取文件内容 // 读取文件内容
const content = readFileSync(csv_file, 'utf-8'); const content = readFileSync(csv_file, "utf-8");
// 解析 frontmatter // 解析 frontmatter
const { frontmatter } = parseFrontMatter(content); const { frontmatter } = parseFrontMatter(content);
@@ -136,19 +116,19 @@ export function readFrontmatter(params: ReadFrontmatterParams): ReadFrontmatterR
return { return {
success: true, success: true,
frontmatter: {}, frontmatter: {},
message: `文件 ${csv_file} 没有 frontmatter,返回空对象` message: `文件 ${csv_file} 没有 frontmatter,返回空对象`,
}; };
} }
return { return {
success: true, success: true,
frontmatter, frontmatter,
message: `成功读取 ${csv_file} 的 frontmatter` message: `成功读取 ${csv_file} 的 frontmatter`,
}; };
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
message: `读取失败:${error instanceof Error ? error.message : '未知错误'}` message: `读取失败:${error instanceof Error ? error.message : "未知错误"}`,
}; };
} }
} }
+48
View File
@@ -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`;
}
+20 -53
View File
@@ -1,6 +1,6 @@
import { readFileSync, writeFileSync, existsSync } from 'fs'; import { readFileSync, writeFileSync, existsSync } from "fs";
import yaml from 'js-yaml'; import type { DeckFrontmatter } from "./read-frontmatter.js";
import type { DeckFrontmatter } from './read-frontmatter.js'; import { parseFrontMatter, serializeFrontMatter } from "./shared.js";
/** /**
* 写入 CSV frontmatter 的参数 * 写入 CSV frontmatter 的参数
@@ -29,70 +29,37 @@ export interface WriteFrontmatterResult {
csv_file?: string; 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 * 写入/更新 CSV 文件的 frontmatter
*/ */
export function writeFrontmatter(params: WriteFrontmatterParams): WriteFrontmatterResult { export function writeFrontmatter(
params: WriteFrontmatterParams,
): WriteFrontmatterResult {
const { csv_file, frontmatter, merge = true } = params; const { csv_file, frontmatter, merge = true } = params;
let csvContent = ''; let csvContent = "";
let existingFrontmatter: DeckFrontmatter | undefined; let existingFrontmatter: DeckFrontmatter | undefined;
// 如果文件存在且需要合并,先读取现有内容 // 如果文件存在且需要合并,先读取现有内容
if (merge && existsSync(csv_file)) { if (merge && existsSync(csv_file)) {
try { try {
const content = readFileSync(csv_file, 'utf-8'); const content = readFileSync(csv_file, "utf-8");
const result = parseFrontMatter(content); const result = parseFrontMatter(content);
existingFrontmatter = result.frontmatter; existingFrontmatter = result.frontmatter;
csvContent = result.csvContent; csvContent = result.csvContent;
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
message: `读取现有文件失败:${error instanceof Error ? error.message : '未知错误'}` message: `读取现有文件失败:${error instanceof Error ? error.message : "未知错误"}`,
}; };
} }
} }
// 合并或替换 frontmatter // 合并或替换 frontmatter
const finalFrontmatter = merge && existingFrontmatter const finalFrontmatter =
? { ...existingFrontmatter, ...frontmatter } merge && existingFrontmatter
: frontmatter; ? { ...existingFrontmatter, ...frontmatter }
: frontmatter;
// 序列化 frontmatter // 序列化 frontmatter
const frontmatterStr = serializeFrontMatter(finalFrontmatter); const frontmatterStr = serializeFrontMatter(finalFrontmatter);
@@ -100,32 +67,32 @@ export function writeFrontmatter(params: WriteFrontmatterParams): WriteFrontmatt
// 如果文件不存在或没有 CSV 内容,创建一个空的 CSV 内容 // 如果文件不存在或没有 CSV 内容,创建一个空的 CSV 内容
if (!csvContent.trim()) { if (!csvContent.trim()) {
// 从 frontmatter 推断 CSV 表头 // 从 frontmatter 推断 CSV 表头
const headers = ['label']; const headers = ["label"];
if (finalFrontmatter.fields && Array.isArray(finalFrontmatter.fields)) { if (finalFrontmatter.fields && Array.isArray(finalFrontmatter.fields)) {
for (const field of 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(field.name);
} }
} }
} }
headers.push('body'); headers.push("body");
csvContent = headers.join(',') + '\n'; csvContent = headers.join(",") + "\n";
} }
// 写入文件 // 写入文件
try { try {
const fullContent = frontmatterStr + csvContent; const fullContent = frontmatterStr + csvContent;
writeFileSync(csv_file, fullContent, 'utf-8'); writeFileSync(csv_file, fullContent, "utf-8");
return { return {
success: true, success: true,
message: `成功写入 frontmatter 到 ${csv_file}`, message: `成功写入 frontmatter 到 ${csv_file}`,
csv_file csv_file,
}; };
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
message: `写入失败:${error instanceof Error ? error.message : '未知错误'}` message: `写入失败:${error instanceof Error ? error.message : "未知错误"}`,
}; };
} }
} }
+74 -160
View File
@@ -1,5 +1,7 @@
import { writeFileSync, mkdirSync, existsSync } from 'fs'; import { writeFileSync, mkdirSync, existsSync } from "fs";
import { join } from 'path'; 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"(北/西/南/东) * 朝向:上侧朝向 "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>[]; examples?: Record<string, string>[];
} }
/** export type { DeckConfig };
* Deck 配置
*/
export interface DeckConfig {
size?: string;
grid?: string;
bleed?: number;
padding?: number;
shape?: 'rectangle' | 'circle' | 'hex' | 'diamond';
layers?: string;
backLayers?: string;
}
/** /**
* 生成卡牌组的参数 * 生成卡牌组的参数
@@ -73,31 +64,31 @@ export interface GenerateCardDeckParams {
/** /**
* 生成卡牌数据 CSV * 生成卡牌数据 CSV
*/ */
function generateCardCSV( function generateCardCSV(template: CardTemplate, cardCount: number): string {
template: CardTemplate,
cardCount: number
): string {
const fields = template.fields; const fields = template.fields;
// 构建 CSV 表头 // 构建 CSV 表头
const headers = ['label', ...fields.map(f => f.name), 'body']; const headers = ["label", ...fields.map((f) => f.name), "body"];
// 生成示例数据 // 生成示例数据
const rows: string[][] = []; const rows: string[][] = [];
const examples = template.examples || []; const examples = template.examples || [];
for (let i = 0; i < cardCount; i++) { for (let i = 0; i < cardCount; i++) {
const row: string[] = [(i + 1).toString()]; const row: string[] = [(i + 1).toString()];
// 为每个字段生成值 // 为每个字段生成值
for (const field of fields) { for (const field of fields) {
let value = ''; let value = "";
if (examples.length > 0) { if (examples.length > 0) {
// 从示例中循环取值 // 从示例中循环取值
const exampleIndex = i % examples.length; const exampleIndex = i % examples.length;
const example = examples[exampleIndex]; 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) { } else if (field.examples && field.examples.length > 0) {
// 从字段的示例中取值 // 从字段的示例中取值
value = field.examples[i % field.examples.length]; value = field.examples[i % field.examples.length];
@@ -105,131 +96,49 @@ function generateCardCSV(
// 默认占位符 // 默认占位符
value = `{{${field.name}_${i + 1}}}`; value = `{{${field.name}_${i + 1}}}`;
} }
row.push(value); row.push(value);
} }
// body 列使用模板语法 // body 列使用模板语法
const bodyParts: string[] = []; const bodyParts: string[] = [];
for (const field of fields) { for (const field of fields) {
bodyParts.push(`**${field.name}:** {{${field.name}}}`); bodyParts.push(`**${field.name}:** {{${field.name}}}`);
} }
row.push(bodyParts.join('\n\n')); row.push(bodyParts.join("\n\n"));
rows.push(row); rows.push(row);
} }
// 组合 CSV 内容 // 组合 CSV 内容
const csvLines = [headers.join(',')]; const csvLines = [headers.join(",")];
for (const row of rows) { for (const row of rows) {
csvLines.push(row.map(cell => { csvLines.push(
// 处理包含逗号或换行的单元格 row
if (cell.includes(',') || cell.includes('\n') || cell.includes('"')) { .map((cell) => {
return `"${cell.replace(/"/g, '""')}"`; // 处理包含逗号或换行的单元格
} if (cell.includes(",") || cell.includes("\n") || cell.includes('"')) {
return cell; return `"${cell.replace(/"/g, '""')}"`;
}).join(',')); }
return cell;
})
.join(","),
);
} }
return csvLines.join('\n');
}
/** 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('');
} }
/** /**
* 自动生成图层配置 * 自动生成图层配置
*/ */
function autoGenerateLayers(fields: CardField[]): string { function autoGenerateLayers(fields: CardField[]): string {
if (fields.length === 0) return ''; if (fields.length === 0) return "";
const layers: string[] = []; const layers: string[] = [];
const totalHeight = 8; const totalHeight = 8;
const heightPerField = Math.floor((totalHeight - 2) / fields.length); const heightPerField = Math.floor((totalHeight - 2) / fields.length);
for (let i = 0; i < fields.length; i++) { for (let i = 0; i < fields.length; i++) {
const field = fields[i]; const field = fields[i];
const y1 = 2 + i * heightPerField; const y1 = 2 + i * heightPerField;
@@ -237,8 +146,8 @@ function autoGenerateLayers(fields: CardField[]): string {
const fontSize = Math.min(12, Math.floor(80 / fields.length)); const fontSize = Math.min(12, Math.floor(80 / fields.length));
layers.push(`${field.name}:1,${y1}-${y2},${fontSize}`); 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_count = 10,
card_template, card_template,
deck_config = {}, deck_config = {},
description description,
} = params; } = params;
// 确保输出目录存在 // 确保输出目录存在
if (!existsSync(output_dir)) { if (!existsSync(output_dir)) {
mkdirSync(output_dir, { recursive: true }); 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 csvFileName = `${safeName}.csv`;
const mdFileName = `${safeName}.md`; const mdFileName = `${safeName}.md`;
// 创建默认模板(如果没有提供) // 创建默认模板(如果没有提供)
const template: CardTemplate = card_template || { const template: CardTemplate = card_template || {
fields: [ fields: [
{ name: 'name', description: '卡牌名称', examples: ['示例卡牌 1', '示例卡牌 2'] }, {
{ name: 'type', description: '卡牌类型', examples: ['物品', '法术'] }, name: "name",
{ name: 'cost', description: '费用', examples: ['1', '2'] }, description: "卡牌名称",
{ name: 'description', description: '效果描述', examples: ['这是一个效果描述', '这是另一个效果'] } examples: ["示例卡牌 1", "示例卡牌 2"],
] },
{ name: "type", description: "卡牌类型", examples: ["物品", "法术"] },
{ name: "cost", description: "费用", examples: ["1", "2"] },
{
name: "description",
description: "效果描述",
examples: ["这是一个效果描述", "这是另一个效果"],
},
],
}; };
// 创建默认配置(如果没有提供) // 创建默认配置(如果没有提供)
const config: DeckConfig = { const config: DeckConfig = {
size: deck_config.size || '54x86', size: deck_config.size || "54x86",
grid: deck_config.grid || '5x8', grid: deck_config.grid || "5x8",
bleed: deck_config.bleed ?? 1, bleed: deck_config.bleed ?? 1,
padding: deck_config.padding ?? 2, padding: deck_config.padding ?? 2,
shape: deck_config.shape || 'rectangle', shape: deck_config.shape || "rectangle",
layers: deck_config.layers || autoGenerateLayers(template.fields) layers: deck_config.layers || autoGenerateLayers(template.fields),
}; };
// 生成 CSV 内容 // 生成 CSV 内容
const csvContent = generateCardCSV(template, card_count); const csvContent = generateCardCSV(template, card_count);
const csvPath = join(output_dir, csvFileName); const csvPath = join(output_dir, csvFileName);
writeFileSync(csvPath, csvContent, 'utf-8'); writeFileSync(csvPath, csvContent, "utf-8");
// 生成 Markdown 内容 // 生成 Markdown 内容
const mdContent = generateDeckMarkdown( const deckComponent = buildDeckComponent(`./${csvFileName}`, config);
const mdContent = generateDeckPreviewMd(
deck_name, deck_name,
`./${csvFileName}`, deckComponent,
config, description,
description
); );
const mdPath = join(output_dir, mdFileName); const mdPath = join(output_dir, mdFileName);
writeFileSync(mdPath, mdContent, 'utf-8'); writeFileSync(mdPath, mdContent, "utf-8");
// 构建完整的 deck 组件代码
const deckComponent = buildDeckComponent(`./${csvFileName}`, config);
return { return {
mdFile: mdPath, mdFile: mdPath,
csvFile: csvPath, csvFile: csvPath,
deckComponent, 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
View File
@@ -1,8 +1,13 @@
import { readFileSync, writeFileSync, existsSync } from 'fs'; import { readFileSync, writeFileSync, existsSync } from "fs";
import { dirname, join, basename, extname, relative } from 'path'; import { relative } from "path";
import { execSync } from 'child_process'; import { execSync } from "child_process";
import yaml from 'js-yaml'; import {
import type { DeckFrontmatter, DeckConfig } from './frontmatter/read-frontmatter.js'; buildDeckComponent,
titleFromCsvPath,
defaultMdPath,
readDeckConfig,
generateDeckPreviewMd,
} from "./deck-utils.js";
/** /**
* 预览 Deck 的参数 * 预览 Deck 的参数
@@ -37,88 +42,35 @@ export interface PreviewDeckResult {
preview_url?: string; 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) { function openBrowser(url: string) {
try { try {
// Windows 使用 start 命令 if (process.platform === "win32") {
if (process.platform === 'win32') { execSync(`start "" "${url}"`, { stdio: "ignore" });
execSync(`start "" "${url}"`, { stdio: 'ignore' }); } else if (process.platform === "darwin") {
} else if (process.platform === 'darwin') { execSync(`open "${url}"`, { stdio: "ignore" });
execSync(`open "${url}"`, { stdio: 'ignore' });
} else { } else {
execSync(`xdg-open "${url}"`, { stdio: 'ignore' }); execSync(`xdg-open "${url}"`, { stdio: "ignore" });
} }
console.error(`已打开浏览器:${url}`); console.error(`已打开浏览器:${url}`);
} catch (error) { } 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 并打开浏览器 * 预览 Deck - 保存 Markdown 并打开浏览器
*/ */
@@ -129,108 +81,61 @@ export function previewDeck(params: PreviewDeckParams): PreviewDeckResult {
if (!existsSync(csv_file)) { if (!existsSync(csv_file)) {
return { return {
success: false, success: false,
message: `CSV 文件不存在:${csv_file}` message: `CSV 文件不存在:${csv_file}`,
}; };
} }
// 确定 Markdown 文件路径 // 确定 Markdown 文件路径
let mdFilePath = md_file; const mdFilePath = md_file || defaultMdPath(csv_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); const created = !existsSync(mdFilePath);
// 如果文件已存在,检查是否已有 md-deck 组件 // 如果文件已存在,检查是否已有 md-deck 组件
if (!created) { if (!created) {
try { try {
const existingContent = readFileSync(mdFilePath, 'utf-8'); const existingContent = readFileSync(mdFilePath, "utf-8");
// 如果已有 md-deck 组件引用该 CSV,直接返回 if (
if (existingContent.includes(`:md-deck[${csv_file}]`) || existingContent.includes(`:md-deck[${csv_file}]`) ||
existingContent.includes(`:md-deck[./${basename(csv_file)}]`)) { existingContent.includes(`:md-deck[./${csv_file.split("/").pop()}]`)
// 仍然打开浏览器 ) {
const relativeMdPath = relative(process.cwd(), mdFilePath).split('\\').join('/'); const previewUrl = computePreviewUrl(mdFilePath);
const previewUrl = `http://localhost:3000/${relativeMdPath}`;
openBrowser(previewUrl); openBrowser(previewUrl);
return { return {
success: true, success: true,
message: `预览文件 ${mdFilePath} 已存在`, message: `预览文件 ${mdFilePath} 已存在`,
md_file: mdFilePath, md_file: mdFilePath,
created: false, created: false,
preview_url: previewUrl preview_url: previewUrl,
}; };
} }
} catch (error) { } catch {
// 读取失败,继续创建 // 读取失败,继续创建
} }
} }
// 读取 CSV 的 frontmatter 获取配置 // 读取 CSV 的 frontmatter 获取配置
let deckConfig: DeckConfig | undefined; const deckConfig = readDeckConfig(csv_file);
try {
const csvContent = readFileSync(csv_file, 'utf-8');
const { frontmatter } = parseFrontMatter(csvContent);
deckConfig = frontmatter?.deck;
} catch (error) {
// 忽略错误,使用默认配置
}
// 确定标题 // 确定标题
let deckTitle = title; const deckTitle = title || titleFromCsvPath(csv_file);
if (!deckTitle) {
// 从 CSV 文件名推断
const name = basename(csv_file, extname(csv_file));
deckTitle = name.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}
// 构建 md-deck 组件代码(使用相对路径) // 构建 md-deck 组件代码
const mdDir = dirname(mdFilePath); const deckComponent = buildDeckComponent(
const csvDir = dirname(csv_file); `./${csv_file.split("/").pop()}`,
let relativeCsvPath: string; deckConfig,
);
if (mdDir === csvDir) {
relativeCsvPath = `./${basename(csv_file)}`;
} else {
relativeCsvPath = `./${basename(csv_file)}`;
}
const deckComponent = buildDeckComponent(relativeCsvPath, deckConfig);
// 生成 Markdown 内容 // 生成 Markdown 内容
const mdLines: string[] = []; const mdContent = generateDeckPreviewMd(
deckTitle,
mdLines.push(`# ${deckTitle}`); deckComponent,
mdLines.push(''); description,
);
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 { try {
writeFileSync(mdFilePath, mdLines.join('\n'), 'utf-8'); writeFileSync(mdFilePath, mdContent, "utf-8");
// 计算预览 URL const previewUrl = computePreviewUrl(mdFilePath);
const relativeMdPath = relative(process.cwd(), mdFilePath).split('\\').join('/');
const previewUrl = `http://localhost:3000/${relativeMdPath.slice(0, -3)}`;
// 打开浏览器
openBrowser(previewUrl); openBrowser(previewUrl);
return { return {
@@ -240,12 +145,12 @@ export function previewDeck(params: PreviewDeckParams): PreviewDeckResult {
: `更新预览文件 ${mdFilePath}`, : `更新预览文件 ${mdFilePath}`,
md_file: mdFilePath, md_file: mdFilePath,
created, created,
preview_url: previewUrl preview_url: previewUrl,
}; };
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
message: `写入失败:${error instanceof Error ? error.message : '未知错误'}` message: `写入失败:${error instanceof Error ? error.message : "未知错误"}`,
}; };
} }
} }
+19 -2
View File
@@ -7,8 +7,25 @@ export interface CompileOptions {
output: string; output: string;
} }
export type ServeCommandHandler = (dir: string, options: ServeOptions) => Promise<void>; export type ServeCommandHandler = (
export type CompileCommandHandler = (dir: string, options: CompileOptions) => Promise<void>; 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 { export interface MarkdownFile {
path: string; path: string;
+108
View File
@@ -0,0 +1,108 @@
/**
* Asset checks: icons and image references.
*
* Uses the filesystem, since `png`/`jpg` files are not in the content index.
* The icon search assumes no `assets/` directory exists above the check root
* (see docs/check-plan.md §5), which makes the lookup definitive.
*/
import { existsSync } from "fs";
import { join, posix } from "path";
import { diagnosticAt } from "../diagnostics.js";
import { elementFor, looksLikeColor, COLOR_BODY_ELEMENTS } from "../known.js";
import type { Check, CheckContext } from "./context.js";
import { resolveKey } from "./context.js";
import { readTagBlock } from "./tag-block.js";
const ICON_EXTENSIONS = ["svg", "png", "gif", "jpg", "jpeg", "webp"];
export const assetsCheck: Check = (ctx) => {
const { scan, source, file, pathKey, rootDir, sink } = ctx;
for (const directive of scan.directives) {
if (directive.icon) {
const value = directive.body.trim();
if (!value) continue;
const { name, ext } = splitIcon(value);
if (!iconExists(pathKey, name, ext, rootDir)) {
sink.report(
diagnosticAt(
file,
source,
directive.offset,
"TTTK-A001",
"warning",
`icon "${value}" not found`,
`looked for assets/${name}.${ext} from this file up to the project root`,
),
);
}
continue;
}
const spec = elementFor(directive.name);
if (!spec || spec.refKind !== "image") continue;
const ref = directive.body.trim();
if (!ref) continue;
if (COLOR_BODY_ELEMENTS.has(spec.name) && looksLikeColor(ref)) continue;
reportMissingImage(ref, directive.offset, ctx);
}
for (const fence of scan.fences) {
if (fence.attrs.role !== "tag") continue;
const { tag, body } = readTagBlock(fence);
if (!tag || !body) continue;
const spec = elementFor(tag);
if (!spec || spec.refKind !== "image") continue;
if (COLOR_BODY_ELEMENTS.has(tag) && looksLikeColor(body)) continue;
reportMissingImage(body.trim(), fence.offset, ctx);
}
};
function reportMissingImage(
ref: string,
offset: number,
ctx: CheckContext,
): void {
const { source, file, pathKey, rootDir, sink } = ctx;
const key = resolveKey(pathKey, ref);
if (existsSync(join(rootDir, key.replace(/^\//, "")))) return;
sink.report(
diagnosticAt(
file,
source,
offset,
"TTTK-A002",
"warning",
`image "${ref}" does not exist`,
),
);
}
function splitIcon(value: string): { name: string; ext: string } {
const dot = value.lastIndexOf(".");
if (dot > 0) {
const ext = value.slice(dot + 1).toLowerCase();
if (ICON_EXTENSIONS.includes(ext)) {
return { name: value.slice(0, dot), ext };
}
}
return { name: value, ext: "png" };
}
/** Mirror `buildIconSrc`: search `assets/` from the doc dir up to the root. */
function iconExists(
pathKey: string,
name: string,
ext: string,
rootDir: string,
): boolean {
const parts = posix.dirname(pathKey).split("/").filter(Boolean);
for (let i = parts.length; i >= 0; i--) {
const prefix = `/${parts.slice(0, i).join("/")}`;
const base = prefix === "/" ? "/assets" : `${prefix}/assets`;
const rel = `${base}/${name}.${ext}`.replace(/^\//, "");
if (existsSync(join(rootDir, rel))) return true;
}
return false;
}
+39
View File
@@ -0,0 +1,39 @@
/**
* Shared context passed to every per-document check.
*/
import { posix } from "path";
import type { ContentRegistry } from "../../content-registry.js";
import type { DiagnosticSink } from "../diagnostics.js";
import type { ScanResult } from "../scan.js";
import type { SourceFile } from "../source.js";
export interface CheckContext {
source: SourceFile;
scan: ScanResult;
/** Display path: relative to the root, posix, no leading slash. */
file: string;
/** Registry key: posix with a leading slash. */
pathKey: string;
registry: ContentRegistry;
/** Raw contents of every indexable file, keyed like the registry. */
index: Record<string, string>;
/** Absolute root directory of the check. */
rootDir: string;
sink: DiagnosticSink;
}
export type Check = (ctx: CheckContext) => void;
/** Strip the registry key's leading slash for display. */
export function displayPath(key: string): string {
return key.replace(/^\//, "");
}
/** Resolve a reference written in `pathKey` to a registry key. */
export function resolveKey(pathKey: string, ref: string): string {
if (ref.startsWith("/")) return posix.normalize(ref);
const dir = posix.dirname(pathKey);
const joined = posix.normalize(posix.join(dir, ref));
return joined.startsWith("/") ? joined : `/${joined}`;
}
+308
View File
@@ -0,0 +1,308 @@
/**
* CSV data-file checks (V001–V005).
*
* Two entry points:
* - `csvFilesCheck` validates every CSV referenced by a content directive,
* once per file (a shared CSV must not be reported twice).
* - `sparkTableCheck` validates inline `role=spark-table` bodies, which live
* in the document itself and so carry document line numbers.
*
* Parsing mirrors `parseCSVString` (`src/components/utils/csv-loader.ts`):
* BOM stripped, `#` lines are comments, cells trimmed, empty lines skipped,
* and YAML front matter split off first.
*/
import { parse } from "csv-parse/browser/esm/sync";
import yaml from "js-yaml";
import {
isMarkdownTableLang,
isSparkTableHeader,
resolveContentEntry,
type ContentRegistry,
} from "../../content-registry.js";
import { diagnosticAt, type DiagnosticSink } from "../diagnostics.js";
import { elementFor } from "../known.js";
import type { RawFence, ScanResult } from "../scan.js";
import { SourceFile } from "../source.js";
import type { Check, CheckContext } from "./context.js";
import { displayPath } from "./context.js";
import { readTagBlock } from "./tag-block.js";
/** A CSV body to validate, plus where it lives in its source. */
export interface CsvTarget {
/** Display path for diagnostics. */
file: string;
source: SourceFile;
body: string;
/** Offset in `source` where `body` begins. */
bodyOffset: number;
}
const PARSE_OPTIONS = {
columns: false as const,
relax_column_count: true,
info: true as const,
comment: "#",
trim: true,
skipEmptyLines: true,
bom: true,
};
interface CsvRecord {
record: string[];
info: { lines: number };
}
/** Run V001–V005 over one CSV body. */
export function checkCsvBody(target: CsvTarget, sink: DiagnosticSink): void {
const { file, source, body, bodyOffset } = target;
const { csv, csvOffset, frontmatter } = splitFrontMatter(body);
const baseLine = source.position(bodyOffset + csvOffset).line;
const offsetAt = (csvLine: number): number =>
source.lineStart(baseLine + csvLine - 1);
let records: CsvRecord[];
try {
records = parse(csv, PARSE_OPTIONS) as unknown as CsvRecord[];
} catch (error) {
const detail = error as { lines?: number; message?: string };
sink.report(
diagnosticAt(
file,
source,
offsetAt(detail.lines ?? 1),
"TTTK-V001",
"error",
`CSV failed to parse: ${detail.message ?? String(error)}`,
"check quoting; wrap values containing commas or quotes in double quotes",
),
);
return;
}
if (records.length === 0) return;
const header = records[0].record;
const headerSet = new Set(header);
const labelIndex = header.indexOf("label");
const bodyIndex = header.indexOf("body");
const sparkTable = isSparkTableHeader(header[0] ?? "");
const seenLabels = new Map<string, number>();
for (let i = 1; i < records.length; i++) {
const { record, info } = records[i];
const line = info.lines;
const offset = offsetAt(line);
if (record.length !== header.length) {
sink.report(
diagnosticAt(
file,
source,
offset,
"TTTK-V002",
"warning",
`row has ${record.length} column${record.length === 1 ? "" : "s"}, header has ${header.length}`,
"align the row with the header, or quote cells containing commas",
),
);
}
if (labelIndex >= 0) {
const label = (record[labelIndex] ?? "").trim();
if (label) {
const first = seenLabels.get(label);
if (first !== undefined) {
sink.report(
diagnosticAt(
file,
source,
offset,
"TTTK-V003",
"warning",
`duplicate label "${label}" (first at line ${first})`,
"labels identify rows; make each unique",
),
);
} else {
seenLabels.set(label, line);
}
}
}
if (bodyIndex >= 0) {
const cell = record[bodyIndex] ?? "";
for (const match of cell.matchAll(/\{\{([^}]+)\}\}/g)) {
const key = match[1].trim();
if (headerSet.has(key) || frontmatter.has(key)) continue;
sink.report(
diagnosticAt(
file,
source,
offset,
"TTTK-V004",
"warning",
`{{${key}}} is not a column or front-matter key`,
`available: ${[...headerSet].join(", ")}`,
),
);
}
}
if (sparkTable) {
const roll = (record[0] ?? "").trim();
if (roll && !/^\d+(\s*-\s*\d+)?$/.test(roll)) {
sink.report(
diagnosticAt(
file,
source,
offset,
"TTTK-V005",
"warning",
`spark-table roll value "${roll}" is not a number or range`,
"use a number (3) or a range (3-5)",
),
);
}
}
}
}
/**
* Validate every CSV referenced by a content directive, once per file.
* Inline spark-table bodies are handled by `sparkTableCheck`.
*/
export function csvFilesCheck(
mdKeys: string[],
scans: Map<string, ScanResult>,
registry: ContentRegistry,
index: Record<string, string>,
sink: DiagnosticSink,
): void {
const targets = new Map<string, CsvTarget>();
const add = (docKey: string, ref: string): void => {
const resolved = resolveContentEntry(registry, docKey, ref);
if (!resolved || resolved.inline) return;
if (!resolved.path.endsWith(".csv")) return;
if (targets.has(resolved.path)) return;
const body = index[resolved.path];
if (body == null) return;
targets.set(resolved.path, {
file: displayPath(resolved.path),
source: new SourceFile(body),
body,
bodyOffset: 0,
});
};
for (const docKey of mdKeys) {
const scan = scans.get(docKey);
if (!scan) continue;
for (const directive of scan.directives) {
if (directive.icon) continue;
const spec = elementFor(directive.name);
if (!spec || spec.refKind !== "csv") continue;
const ref = directive.body.trim();
if (ref) add(docKey, ref);
}
for (const fence of scan.fences) {
if (fence.attrs.role !== "tag") continue;
const { tag, body } = readTagBlock(fence);
if (!tag || !body) continue;
const spec = elementFor(tag);
if (!spec || spec.refKind !== "csv") continue;
add(docKey, body.trim());
}
}
for (const target of targets.values()) checkCsvBody(target, sink);
}
/**
* Validate inline `role=spark-table` bodies. CSV bodies get the full V001–V005
* treatment; markdown pipe tables only get V005 (their structure is markdown,
* not CSV).
*/
export const sparkTableCheck: Check = (ctx) => {
const { scan, source, file, sink } = ctx;
for (const fence of scan.fences) {
if (fence.attrs.role !== "spark-table") continue;
if (isMarkdownTableLang(fence.lang)) {
checkMarkdownSparkTable(fence, ctx);
continue;
}
checkCsvBody(
{ file, source, body: fence.body, bodyOffset: fence.bodyOffset },
sink,
);
}
};
/** V005 for a markdown pipe-table spark table. */
function checkMarkdownSparkTable(fence: RawFence, ctx: CheckContext): void {
const { source, file, sink } = ctx;
const baseLine = source.position(fence.bodyOffset).line;
const lines = fence.body.split(/\r?\n/);
let dataRows = 0;
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim();
if (!trimmed.startsWith("|")) continue;
dataRows++;
if (dataRows <= 2) continue; // header + separator
const cells = trimmed
.split("|")
.map((cell) => cell.trim())
.filter(Boolean);
const roll = cells[0] ?? "";
if (roll && !/^\d+(\s*-\s*\d+)?$/.test(roll)) {
sink.report(
diagnosticAt(
file,
source,
source.lineStart(baseLine + i),
"TTTK-V005",
"warning",
`spark-table roll value "${roll}" is not a number or range`,
"use a number (3) or a range (3-5)",
),
);
}
}
}
/**
* Split YAML front matter off a CSV body, mirroring `parseFrontMatter`
* (`src/cli/tools/frontmatter/shared.ts`). Returns the remaining CSV, its
* offset within `content`, and the front-matter keys.
*/
function splitFrontMatter(content: string): {
csv: string;
csvOffset: number;
frontmatter: Set<string>;
} {
const trimmed = content.trim();
const parts = trimmed.split(/(?:^|\n)---\s*\n/g);
if (parts.length !== 3 || parts[0] !== "") {
return { csv: content, csvOffset: 0, frontmatter: new Set() };
}
let frontmatter = new Set<string>();
try {
const data = yaml.load(parts[1].trim());
if (data && typeof data === "object" && !Array.isArray(data)) {
frontmatter = new Set(Object.keys(data as Record<string, unknown>));
}
} catch {
return { csv: content, csvOffset: 0, frontmatter: new Set() };
}
const csv = parts.slice(2).join("---\n").trimStart();
const lead = content.length - content.trimStart().length;
const csvOffset = lead + (trimmed.length - csv.length);
return { csv, csvOffset, frontmatter };
}
+126
View File
@@ -0,0 +1,126 @@
/**
* Directive checks: `:md-*` element names must be registered, and attribute
* blocks must be well formed.
*/
import { diagnosticAt } from "../diagnostics.js";
import { ELEMENT_NAMES, KNOWN_ELEMENTS } from "../known.js";
import type { RawDirective } from "../scan.js";
import type { Check, CheckContext } from "./context.js";
export const directivesCheck: Check = (ctx) => {
const { scan, source, file, sink } = ctx;
for (const directive of scan.directives) {
checkAttrs(directive, ctx);
if (directive.icon) continue;
if (!directive.name.startsWith("md-")) continue;
if (ELEMENT_NAMES.has(directive.name)) continue;
const suggestion = closestName(directive.name, [...ELEMENT_NAMES]);
sink.report(
diagnosticAt(
file,
source,
directive.offset,
"TTTK-D001",
"error",
`unknown element ":${directive.name}"`,
suggestion
? `did you mean ":${suggestion}"?`
: `custom elements are: ${KNOWN_ELEMENTS.map((e) => e.name).join(", ")}`,
),
);
}
};
/**
* D002 — an attribute block contains a token that is neither `key=value`, a
* `.class`/`#id` shorthand, nor a bare boolean flag. This is what an unquoted
* value with a space looks like: `{pins=A:30, 40}` parses as `pins=A:30,` plus
* a stray `40`, silently dropping the rest of the value.
*/
function checkAttrs(directive: RawDirective, ctx: CheckContext): void {
const raw = directive.rawAttrs;
if (!raw.trim()) return;
for (const { token, index } of attrTokens(raw)) {
if (/^[.#][\w-]+(?:[.#][\w-]+)*$/.test(token)) continue; // .class / #id
if (/^[A-Za-z_][\w-]*=/.test(token)) continue; // key=value
if (/^[A-Za-z_][\w-]*$/.test(token)) continue; // boolean flag
ctx.sink.report(
diagnosticAt(
ctx.file,
ctx.source,
attrBlockOffset(directive) + index,
"TTTK-D002",
"error",
`malformed attribute \`${token}\` in the attribute block`,
"quote values containing spaces, e.g. {pins=\"A:30, 40\"}",
),
);
}
}
/** Offset of the `{` that opens a directive's attribute block. */
function attrBlockOffset(directive: RawDirective): number {
// `:name[body]{` or `:[body]{`
const prefix = directive.icon
? 3 + directive.body.length
: 2 + directive.name.length + 1 + directive.body.length;
return directive.offset + prefix;
}
/** Split an attribute block on whitespace, honoring quoted values. */
function* attrTokens(raw: string): Generator<{ token: string; index: number }> {
let i = 0;
while (i < raw.length) {
while (i < raw.length && /\s/.test(raw[i])) i++;
if (i >= raw.length) break;
const start = i;
let quote: string | null = null;
while (i < raw.length) {
const ch = raw[i];
if (quote) {
if (ch === quote) quote = null;
} else if (ch === '"' || ch === "'") {
quote = ch;
} else if (/\s/.test(ch)) {
break;
}
i++;
}
yield { token: raw.slice(start, i), index: start };
}
}
/** Return the closest candidate within edit distance 2, if any. */
function closestName(name: string, candidates: string[]): string | null {
let best: string | null = null;
let bestDistance = 3;
for (const candidate of candidates) {
const distance = editDistance(name, candidate);
if (distance < bestDistance) {
bestDistance = distance;
best = candidate;
}
}
return best;
}
function editDistance(a: string, b: string): number {
const prev = new Array<number>(b.length + 1);
const curr = new Array<number>(b.length + 1);
for (let j = 0; j <= b.length; j++) prev[j] = j;
for (let i = 1; i <= a.length; i++) {
curr[0] = i;
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
}
for (let j = 0; j <= b.length; j++) prev[j] = curr[j];
}
return prev[b.length];
}
+102
View File
@@ -0,0 +1,102 @@
/**
* Markdown link and image checks.
*
* File existence uses the filesystem (not the registry) because images are not
* indexed. Section anchors are validated against heading slugs.
*/
import { existsSync } from "fs";
import { join } from "path";
import Slugger from "github-slugger";
import { stripFencedBlocks } from "../../content-registry.js";
import { diagnosticAt } from "../diagnostics.js";
import type { Check } from "./context.js";
import { resolveKey } from "./context.js";
const EXTERNAL_RE = /^[a-z][a-z0-9+.-]*:/i;
export const linksCheck: Check = (ctx) => {
const { scan, source, file, pathKey, registry, index, rootDir, sink } = ctx;
const slugCache = new Map<string, Set<string>>();
const slugsFor = (key: string): Set<string> => {
const cached = slugCache.get(key);
if (cached) return cached;
const markdown = index[key] ?? registry.pathIndex[key] ?? "";
const slugs = headingSlugs(markdown);
slugCache.set(key, slugs);
return slugs;
};
const exists = (key: string): boolean => {
if (key in registry.pathIndex || key in index) return true;
return existsSync(join(rootDir, key.replace(/^\//, "")));
};
for (const link of scan.links) {
const target = link.target.trim();
if (!target || EXTERNAL_RE.test(target) || target.startsWith("//")) continue;
const hashIndex = target.indexOf("#");
const pathPart = hashIndex >= 0 ? target.slice(0, hashIndex) : target;
const section = hashIndex >= 0 ? target.slice(hashIndex + 1) : "";
const baseKey = pathPart ? resolveKey(pathKey, pathPart) : pathKey;
if (pathPart && !exists(baseKey)) {
sink.report(
diagnosticAt(
file,
source,
link.targetOffset,
link.image ? "TTTK-A002" : "TTTK-L001",
link.image ? "warning" : "error",
`${link.image ? "image" : "link target"} "${target}" does not exist`,
),
);
continue;
}
if (section && baseKey.endsWith(".md")) {
const decoded = decodeURIComponent(section).toLowerCase();
if (!sectionMatches(slugsFor(baseKey), decoded)) {
sink.report(
diagnosticAt(
file,
source,
link.targetOffset,
"TTTK-L002",
"warning",
`section "#${section}" not found in "${pathPart || file}"`,
"anchors come from headings, lowercased and hyphenated",
),
);
}
}
}
};
/**
* A section matches an exact heading slug, or a heading slug with a suffix
* (e.g. `#珊瑚粉` for the heading `珊瑚粉 - 成瘾`) — a common authoring habit.
*/
function sectionMatches(slugs: Set<string>, section: string): boolean {
if (slugs.has(section)) return true;
for (const slug of slugs) {
if (!slug.startsWith(section)) continue;
const next = slug[section.length];
if (next === undefined || !/[A-Za-z0-9]/.test(next)) return true;
}
return false;
}
function headingSlugs(markdown: string): Set<string> {
const slugger = new Slugger();
const slugs = new Set<string>();
const content = stripFencedBlocks(markdown);
const re = /^(#{1,6})\s+(.+?)\s*$/gm;
let match: RegExpExecArray | null;
while ((match = re.exec(content)) !== null) {
slugs.add(slugger.slug(match[2].trim().toLowerCase()));
}
return slugs;
}
+104
View File
@@ -0,0 +1,104 @@
/**
* Content reference checks for directives and `yaml role=tag` blocks.
*
* Image references are handled by the assets check, because image files are
* not part of the content registry (only `.md`, `.csv`, `.yarn`, `.svg` are
* indexed).
*/
import { extname } from "path";
import { resolveContentEntry } from "../../content-registry.js";
import { diagnosticAt } from "../diagnostics.js";
import { REF_EXTENSIONS, elementFor, type RefKind } from "../known.js";
import type { Check, CheckContext } from "./context.js";
import { readTagBlock } from "./tag-block.js";
export const refsCheck: Check = (ctx) => {
const { scan, source, file, pathKey, registry, sink } = ctx;
const tableSeen = new Map<string, { count: number; offset: number }>();
const verify = (
ref: string,
offset: number,
kind: RefKind,
element: string,
): boolean => {
const resolved = resolveContentEntry(registry, pathKey, ref);
if (!resolved) {
sink.report(
diagnosticAt(
file,
source,
offset,
"TTTK-C001",
"error",
`:${element} reference "${ref}" does not resolve`,
"expected a file relative to this document, or an inline id defined in it",
),
);
return false;
}
if (!resolved.inline && kind !== "none") {
const ext = extname(resolved.path).toLowerCase();
const allowed = REF_EXTENSIONS[kind as Exclude<RefKind, "none">];
if (allowed && !allowed.includes(ext)) {
sink.report(
diagnosticAt(
file,
source,
offset,
"TTTK-C002",
"warning",
`:${element} reference "${ref}" resolves to "${ext || "no extension"}", expected ${allowed.join(" / ")}`,
),
);
}
}
return true;
};
for (const directive of scan.directives) {
if (directive.icon) continue;
const spec = elementFor(directive.name);
if (!spec || spec.refKind === "none" || spec.refKind === "image") continue;
let ref = directive.body.trim();
if (spec.name === "md-link" || spec.name === "md-embed") {
ref = ref.split("#")[0];
}
if (!ref) continue;
const ok = verify(ref, directive.offset, spec.refKind, spec.name);
if (spec.name === "md-table" && ok) {
const seen = tableSeen.get(ref);
if (seen) seen.count++;
else tableSeen.set(ref, { count: 1, offset: directive.offset });
}
}
for (const fence of scan.fences) {
if (fence.attrs.role !== "tag") continue;
const { tag, body } = readTagBlock(fence);
if (!tag || !body) continue;
const spec = elementFor(tag);
if (!spec || spec.refKind === "none" || spec.refKind === "image") continue;
verify(body.trim(), fence.offset, spec.refKind, tag);
}
for (const [ref, seen] of tableSeen) {
if (seen.count > 1) {
sink.report(
diagnosticAt(
file,
source,
seen.offset,
"TTTK-C003",
"info",
`:md-table reference "${ref}" is used ${seen.count} times in this document`,
"reference each CSV once; use group tabs instead of repeating the table",
),
);
}
}
};
+181
View File
@@ -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;
}
+55
View File
@@ -0,0 +1,55 @@
/**
* Structural checks: the markdown pipeline parses, and container directives
* are balanced.
*/
import { parseMarkdown } from "../../../markdown/index.js";
import { diagnosticAt } from "../diagnostics.js";
import type { RawContainerOpen } from "../scan.js";
import type { Check } from "./context.js";
export const structuralCheck: Check = (ctx) => {
const { source, scan, file, pathKey, sink } = ctx;
try {
parseMarkdown(source.text, pathKey);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
sink.report(
diagnosticAt(
file,
source,
0,
"TTTK-S001",
"error",
`markdown failed to parse: ${detail}`,
"check for an unterminated directive or malformed attribute block",
),
);
}
const events = [
...scan.containerOpens.map((open) => ({ offset: open.offset, open })),
...scan.containerCloses.map((close) => ({ offset: close.offset, open: null })),
].sort((a, b) => a.offset - b.offset);
const stack: RawContainerOpen[] = [];
for (const event of events) {
if (event.open) stack.push(event.open);
else stack.pop();
}
for (const open of stack) {
sink.report(
diagnosticAt(
file,
source,
open.offset,
"TTTK-S002",
"error",
`container directive "::: ${open.name}" is never closed`,
`add a closing "${":".repeat(open.colons)}" line`,
),
);
}
};
+38
View File
@@ -0,0 +1,38 @@
/**
* Read a `yaml role=tag` block: the element name and body, honoring fence-line
* attribute overrides (which win over the YAML mapping).
*/
import yaml from "js-yaml";
import type { RawFence } from "../scan.js";
export interface TagBlock {
tag?: string;
body?: string;
/** Set when the YAML itself failed to parse. */
error?: Error;
}
export function readTagBlock(fence: RawFence): TagBlock {
let data: unknown;
try {
data = yaml.load(fence.body);
} catch (error) {
return { error: error instanceof Error ? error : new Error(String(error)) };
}
const mapping =
data && typeof data === "object" && !Array.isArray(data)
? (data as Record<string, unknown>)
: {};
const fenceTag = fence.attrs.extra.tag;
const fenceBody = fence.attrs.extra.body;
const yamlTag = mapping.tag;
const yamlBody = mapping.body;
return {
tag: fenceTag ?? (typeof yamlTag === "string" ? yamlTag : undefined),
body: fenceBody ?? (typeof yamlBody === "string" ? yamlBody : undefined),
};
}
+146
View File
@@ -0,0 +1,146 @@
/**
* Diagnostic model, collection, and reporting.
*
* `check` reports problems; it never edits content. Diagnostics carry stable
* codes so an authoring agent can key off them.
*/
import type { SourceFile } from "./source.js";
export type Severity = "error" | "warning" | "info";
export interface Diagnostic {
/** Stable code, e.g. "TTTK-C001". */
code: string;
severity: Severity;
message: string;
/** Path relative to the check root, posix, no leading slash. */
file: string;
line: number;
column: number;
/** Concrete, actionable fix. */
hint?: string;
/** The offending source line, for context. */
snippet?: string;
}
export interface DiagnosticSink {
report(diagnostic: Diagnostic): void;
}
/** Collects diagnostics in memory. */
export class Collector implements DiagnosticSink {
readonly items: Diagnostic[] = [];
report(diagnostic: Diagnostic): void {
this.items.push(diagnostic);
}
}
export function diagnosticAt(
file: string,
source: SourceFile,
offset: number,
code: string,
severity: Severity,
message: string,
hint?: string,
): Diagnostic {
const { line, column } = source.position(offset);
return {
code,
severity,
message,
file,
line,
column,
hint,
snippet: source.lineText(line).trimEnd() || undefined,
};
}
const SEVERITY_ORDER: Record<Severity, number> = {
error: 0,
warning: 1,
info: 2,
};
export function compareDiagnostics(a: Diagnostic, b: Diagnostic): number {
if (a.file !== b.file) return a.file < b.file ? -1 : 1;
if (a.line !== b.line) return a.line - b.line;
if (a.column !== b.column) return a.column - b.column;
if (a.severity !== b.severity) {
return SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity];
}
if (a.code !== b.code) return a.code < b.code ? -1 : 1;
return 0;
}
export interface DiagnosticSummary {
errors: number;
warnings: number;
infos: number;
}
export interface CheckReport {
root: string;
filesChecked: number;
diagnostics: Diagnostic[];
summary: DiagnosticSummary;
}
export function summarize(diagnostics: Diagnostic[]): DiagnosticSummary {
const summary: DiagnosticSummary = { errors: 0, warnings: 0, infos: 0 };
for (const d of diagnostics) {
if (d.severity === "error") summary.errors++;
else if (d.severity === "warning") summary.warnings++;
else summary.infos++;
}
return summary;
}
export function buildReport(
root: string,
filesChecked: number,
diagnostics: Diagnostic[],
): CheckReport {
const sorted = [...diagnostics].sort(compareDiagnostics);
return {
root,
filesChecked,
diagnostics: sorted,
summary: summarize(sorted),
};
}
/** Human-readable, grouped by file. */
export function formatHuman(report: CheckReport, quiet = false): string {
const lines: string[] = [];
let currentFile: string | null = null;
for (const d of report.diagnostics) {
if (quiet && d.severity !== "error") continue;
if (d.file !== currentFile) {
if (lines.length) lines.push("");
lines.push(d.file);
currentFile = d.file;
}
const loc = `${d.line}:${d.column}`.padEnd(8);
lines.push(` ${loc} ${d.severity.padEnd(7)} ${d.code} ${d.message}`);
if (d.hint) lines.push(` ${" ".repeat(8)} hint: ${d.hint}`);
}
if (lines.length) lines.push("");
const { errors, warnings, infos } = report.summary;
lines.push(
`${errors} error${errors === 1 ? "" : "s"}, ` +
`${warnings} warning${warnings === 1 ? "" : "s"}, ` +
`${infos} info`,
`${report.filesChecked} file${report.filesChecked === 1 ? "" : "s"} checked`,
);
return lines.join("\n");
}
export function formatJson(report: CheckReport): string {
return JSON.stringify(report, null, 2);
}
+151
View File
@@ -0,0 +1,151 @@
/**
* `tttk check` — run all validation checks over a directory and return a
* sorted report.
*
* Design notes live in docs/check-plan.md:
* - §3.1 build the whole-tree registry first, so cross-file refs resolve
* - §3.2 diagnose the *raw* source so line numbers are accurate
*/
import { existsSync, statSync } from "fs";
import { resolve } from "path";
import {
buildRegistryFromIndex,
type ContentRegistry,
} from "../content-registry.js";
import { readContentIndex } from "../content-index.js";
import { assetsCheck } from "./checks/assets.js";
import type { Check, CheckContext } from "./checks/context.js";
import { displayPath, resolveKey } from "./checks/context.js";
import { csvFilesCheck, sparkTableCheck } from "./checks/csv.js";
import { directivesCheck } from "./checks/directives.js";
import { linksCheck } from "./checks/links.js";
import { refsCheck } from "./checks/refs.js";
import { rolesCheck } from "./checks/roles.js";
import { structuralCheck } from "./checks/structural.js";
import {
buildReport,
Collector,
diagnosticAt,
type CheckReport,
} from "./diagnostics.js";
import { scanSource, type ScanResult } from "./scan.js";
import { SourceFile } from "./source.js";
const CHECKS: readonly Check[] = [
structuralCheck,
directivesCheck,
rolesCheck,
sparkTableCheck,
refsCheck,
linksCheck,
assetsCheck,
];
interface ScannedDoc {
source: SourceFile;
scan: ScanResult;
}
export function runCheck(dir: string): CheckReport {
const rootDir = resolve(dir);
if (!existsSync(rootDir) || !statSync(rootDir).isDirectory()) {
throw new Error(`not a directory: ${rootDir}`);
}
const index = readContentIndex(rootDir);
const registry = buildRegistryFromIndex(index);
const sink = new Collector();
const mdKeys = Object.keys(index)
.filter((key) => key.endsWith(".md"))
.sort();
const docs = new Map<string, ScannedDoc>();
for (const key of mdKeys) {
const text = index[key];
docs.set(key, { source: new SourceFile(text), scan: scanSource(text) });
}
for (const key of mdKeys) {
const doc = docs.get(key)!;
const ctx: CheckContext = {
source: doc.source,
scan: doc.scan,
file: displayPath(key),
pathKey: key,
registry,
index,
rootDir,
sink,
};
for (const check of CHECKS) check(ctx);
}
checkEmbedCycles(mdKeys, docs, registry, sink);
csvFilesCheck(
mdKeys,
new Map(mdKeys.map((key) => [key, docs.get(key)!.scan])),
registry,
index,
sink,
);
return buildReport(rootDir, mdKeys.length, sink.items);
}
/** Detect `:md-embed` cycles, which would expand infinitely. */
function checkEmbedCycles(
mdKeys: string[],
docs: Map<string, ScannedDoc>,
registry: ContentRegistry,
sink: Collector,
): void {
const graph = new Map<string, { target: string; offset: number }[]>();
for (const key of mdKeys) {
const edges: { target: string; offset: number }[] = [];
for (const directive of docs.get(key)!.scan.directives) {
if (directive.icon || directive.name !== "md-embed") continue;
const ref = directive.body.trim().split("#")[0];
if (!ref) continue;
const target = resolveKey(key, ref);
if (!target.endsWith(".md")) continue;
if (!(target in registry.pathIndex)) continue; // C001 already reports
edges.push({ target, offset: directive.offset });
}
graph.set(key, edges);
}
const state = new Map<string, 1 | 2>();
const reported = new Set<string>();
const visit = (key: string): void => {
state.set(key, 1);
for (const edge of graph.get(key) ?? []) {
const targetState = state.get(edge.target);
if (targetState === 1) {
const id = `${key}->${edge.target}`;
if (reported.has(id)) continue;
reported.add(id);
sink.report(
diagnosticAt(
displayPath(key),
docs.get(key)!.source,
edge.offset,
"TTTK-C004",
"error",
`:md-embed cycle: "${displayPath(key)}" embeds "${displayPath(edge.target)}", which embeds it back`,
"break the cycle by linking instead of embedding",
),
);
} else if (targetState === undefined) {
visit(edge.target);
}
}
state.set(key, 2);
};
for (const key of mdKeys) {
if (!state.has(key)) visit(key);
}
}
+52
View File
@@ -0,0 +1,52 @@
/**
* Drift guard: `KNOWN_ELEMENTS` in `known.ts` must match the `customElement`
* registrations under `src/components`. This is what would have caught
* `:md-card` — matched by the completion scanner but registered nowhere.
*/
import { readdirSync, readFileSync } from "fs";
import { join } from "path";
import { ELEMENT_NAMES } from "./known.js";
import { renderReference } from "./reference.js";
const COMPONENTS_DIR = join(process.cwd(), "src", "components");
const CUSTOM_ELEMENT_RE = /customElement(?:<[^>]*>)?\(\s*["'](md-[\w-]+)["']/g;
function sourceFiles(dir: string): string[] {
const files: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) files.push(...sourceFiles(full));
else if (/\.tsx?$/.test(entry.name)) files.push(full);
}
return files;
}
function registeredElements(): Set<string> {
const names = new Set<string>();
for (const file of sourceFiles(COMPONENTS_DIR)) {
const source = readFileSync(file, "utf-8");
for (const match of source.matchAll(CUSTOM_ELEMENT_RE)) {
names.add(match[1]);
}
}
return names;
}
describe("known element catalog", () => {
test("matches the customElement registrations under src/components", () => {
const registered = [...registeredElements()].sort();
const known = [...ELEMENT_NAMES].sort();
expect(known).toEqual(registered);
});
test.each([
["docs/reference.md"],
[join(".agents", "skills", "tttk-authoring", "reference.md")],
])("%s is up to date", (relativePath) => {
const committed = readFileSync(join(process.cwd(), relativePath), "utf-8");
expect(committed).toBe(renderReference());
});
});
+324
View File
@@ -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);
}
+216
View File
@@ -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";
}
+250
View File
@@ -0,0 +1,250 @@
/**
* Raw-source scanner for the validator.
*
* Unlike `scanDoc`, which rewrites fenced blocks and loses offsets, this walks
* the *raw* markdown and records character offsets so diagnostics can report
* accurate line/column positions. Directives inside fenced code blocks are
* skipped (documentation examples are not live content).
*/
import { parseBlockAttrs, type BlockAttrs } from "../completions/block-scanner.js";
export interface RawDirective {
/** Directive name, e.g. "md-dice"; "" for the bare icon form `:[name]`. */
name: string;
body: string;
/** Parsed `{key=value}` attributes (shorthands such as `.class` ignored). */
attrs: Record<string, string>;
/** Raw attribute-block contents, without the braces; "" when absent. */
rawAttrs: string;
/** Offset of the leading ":". */
offset: number;
/** True for the icon form `:[name]`. */
icon: boolean;
}
export interface RawFence {
lang: string;
info: string;
attrs: BlockAttrs;
/** Offset of the opening fence line. */
offset: number;
body: string;
/** Offset of the first body character. */
bodyOffset: number;
}
export interface RawLink {
image: boolean;
/** Link target as written, e.g. "./rules.md#combat". */
target: string;
/** Offset of the target text. */
targetOffset: number;
}
export interface RawContainerOpen {
name: string;
colons: number;
offset: number;
}
export interface RawContainerClose {
colons: number;
offset: number;
}
export interface ScanResult {
directives: RawDirective[];
fences: RawFence[];
links: RawLink[];
containerOpens: RawContainerOpen[];
containerCloses: RawContainerClose[];
}
interface Line {
text: string;
offset: number;
end: number;
}
const FENCE_OPEN_RE = /^\s*(`{3,}|~{3,})(.*)$/;
const FENCE_CLOSE_RE = /^\s*(`{3,}|~{3,})\s*$/;
const CONTAINER_OPEN_RE = /^\s*(:{3,})\s*([A-Za-z][\w-]*)\s*$/;
const CONTAINER_CLOSE_RE = /^\s*(:{3,})\s*$/;
const NAMED_DIRECTIVE_RE = /:([a-zA-Z][a-zA-Z0-9-]*)\[([^\[\]]*)\](?:\{([^}]*)\})?/g;
const ICON_DIRECTIVE_RE = /:\[([^\[\]]*)\](?:\{([^}]*)\})?/g;
const LINK_RE = /(!?)\[([^\]]*)\]\(\s*([^)\s]+)(?:\s+"[^"]*")?\s*\)/g;
export function scanSource(text: string): ScanResult {
const result: ScanResult = {
directives: [],
fences: [],
links: [],
containerOpens: [],
containerCloses: [],
};
let fence:
| {
marker: string;
lang: string;
info: string;
attrs: BlockAttrs;
offset: number;
lineEnd: number;
}
| null = null;
for (const line of splitLines(text)) {
if (!fence) {
const open = FENCE_OPEN_RE.exec(line.text);
if (open) {
const rest = open[2];
const langMatch = /^\s*(\w*)\s*/.exec(rest)!;
const lang = langMatch[1] || "";
const info = rest.slice(langMatch[0].length).trim();
fence = {
marker: open[1],
lang,
info,
attrs: parseBlockAttrs(info),
offset: line.offset,
lineEnd: line.end,
};
continue;
}
scanLine(line, result);
continue;
}
const close = FENCE_CLOSE_RE.exec(line.text);
if (close && close[1][0] === fence.marker[0] && close[1].length >= fence.marker.length) {
result.fences.push({
lang: fence.lang,
info: fence.info,
attrs: fence.attrs,
offset: fence.offset,
body: text.slice(fence.lineEnd, line.offset),
bodyOffset: fence.lineEnd,
});
fence = null;
}
}
if (fence) {
result.fences.push({
lang: fence.lang,
info: fence.info,
attrs: fence.attrs,
offset: fence.offset,
body: text.slice(fence.lineEnd),
bodyOffset: fence.lineEnd,
});
}
return result;
}
function scanLine(line: Line, result: ScanResult): void {
const open = CONTAINER_OPEN_RE.exec(line.text);
if (open) {
result.containerOpens.push({
name: open[2],
colons: open[1].length,
offset: line.offset + line.text.indexOf(":"),
});
return;
}
const close = CONTAINER_CLOSE_RE.exec(line.text);
if (close) {
result.containerCloses.push({
colons: close[1].length,
offset: line.offset + line.text.indexOf(":"),
});
return;
}
// Directives and links inside inline code or HTML comments are examples.
const inert = maskedRanges(line.text);
for (const m of line.text.matchAll(NAMED_DIRECTIVE_RE)) {
if (inRanges(m.index, inert)) continue;
result.directives.push({
name: m[1],
body: m[2],
attrs: parseDirectiveAttrs(m[3] || ""),
rawAttrs: m[3] || "",
offset: line.offset + m.index,
icon: false,
});
}
for (const m of line.text.matchAll(ICON_DIRECTIVE_RE)) {
if (inRanges(m.index, inert)) continue;
result.directives.push({
name: "",
body: m[1],
attrs: parseDirectiveAttrs(m[2] || ""),
rawAttrs: m[2] || "",
offset: line.offset + m.index,
icon: true,
});
}
for (const m of line.text.matchAll(LINK_RE)) {
if (inRanges(m.index, inert)) continue;
const target = m[3];
result.links.push({
image: m[1] === "!",
target,
targetOffset: line.offset + m.index + m[0].lastIndexOf(target),
});
}
}
/** Ranges of a line whose contents are inert (inline code, HTML comments). */
function maskedRanges(line: string): Array<[number, number]> {
const ranges: Array<[number, number]> = [];
const patterns = [/`+[^`]*`+/g, /<!--.*?-->/g];
for (const re of patterns) {
let m: RegExpExecArray | null;
while ((m = re.exec(line)) !== null) {
ranges.push([m.index, m.index + m[0].length]);
}
}
return ranges;
}
function inRanges(index: number, ranges: Array<[number, number]>): boolean {
return ranges.some(([start, end]) => index >= start && index < end);
}
/** Parse `{key="value" key=value}` ignoring `.class`/`#id` shorthands. */
function parseDirectiveAttrs(raw: string): Record<string, string> {
const attrs: Record<string, string> = {};
const re = /([A-Za-z_][\w-]*)\s*=\s*("[^"]*"|'[^']*'|\S+)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(raw)) !== null) {
attrs[m[1]] = m[2].replace(/^["']|["']$/g, "");
}
return attrs;
}
function splitLines(text: string): Line[] {
const lines: Line[] = [];
let i = 0;
while (i <= text.length) {
let j = i;
while (j < text.length && text[j] !== "\n" && text[j] !== "\r") j++;
let next = j;
if (next < text.length) {
if (text[next] === "\r" && text[next + 1] === "\n") next += 2;
else next += 1;
}
lines.push({ text: text.slice(i, j), offset: i, end: next });
if (next >= text.length) break;
i = next;
}
return lines;
}
+50
View File
@@ -0,0 +1,50 @@
/**
* Source position mapping for diagnostics.
*
* Validation runs over raw file text and reports offsets; this converts an
* offset into a 1-based line/column and can read back the offending line for
* snippet display.
*/
export interface Position {
line: number;
column: number;
}
export class SourceFile {
private readonly lineStarts: number[];
constructor(readonly text: string) {
const starts = [0];
for (let i = 0; i < text.length; i++) {
if (text.charCodeAt(i) === 10) starts.push(i + 1);
}
this.lineStarts = starts;
}
/** Map a character offset to a 1-based line/column. */
position(offset: number): Position {
const clamped = Math.max(0, Math.min(offset, this.text.length));
let lo = 0;
let hi = this.lineStarts.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (this.lineStarts[mid] <= clamped) lo = mid;
else hi = mid - 1;
}
return { line: lo + 1, column: clamped - this.lineStarts[lo] + 1 };
}
/** Offset of the start of a 1-based line (clamped to the file). */
lineStart(line: number): number {
const index = Math.max(0, Math.min(line - 1, this.lineStarts.length - 1));
return this.lineStarts[index];
}
/** The text of a 1-based line, without its terminator. */
lineText(line: number): string {
const start = this.lineStarts[line - 1] ?? 0;
const end = this.lineStarts[line] ?? this.text.length;
return this.text.slice(start, end).replace(/\r?\n$/, "");
}
}
+327
View File
@@ -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
View File
@@ -1,22 +1,46 @@
import { Component, createEffect, onCleanup, Show, createResource, createMemo } from 'solid-js'; import {
import { parseMarkdown } from '../markdown'; Component,
import { extractSection } from '../data-loader'; ParentProps,
import mermaid from 'mermaid'; createContext,
import {getIndexedData} from "../data-loader/file-index"; useContext,
import { resolvePath } from './utils/path'; createEffect,
import { useLocation } from '@solidjs/router'; 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 { export interface ArticleProps {
src: string; src: string;
section?: string; // 指定要显示的标题(不含 #) section?: string; // 指定要显示的标题(不含 #)
iconPath?: string; // 图标路径前缀,默认为 "./assets",空字符串表示禁用
onLoaded?: () => void; onLoaded?: () => void;
onError?: (error: Error) => void; onError?: (error: Error) => void;
class?: string; // 额外的 class 用于样式控制 class?: string; // 额外的 class 用于样式控制
scrollToHash?: boolean; // 是否自动滚动到 hash 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); const text = await getIndexedData(params.src);
// 如果指定了 section,提取对应内容 // 如果指定了 section,提取对应内容
return params.section ? extractSection(text, params.section) : text; 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) { function scrollToHash(hash: string) {
if (!hash) return; if (!hash) return;
// 移除 # 前缀 // 移除 # 前缀
const id = hash.startsWith('#') ? hash.slice(1) : hash; const id = hash.startsWith("#") ? hash.slice(1) : hash;
if (!id) return; if (!id) return;
// 使用 decodeURIComponent 解码 ID(处理中文等特殊字符) // 使用 decodeURIComponent 解码 ID(处理中文等特殊字符)
const decodedId = decodeURIComponent(id); const decodedId = decodeURIComponent(id);
// 尝试查找元素 // 尝试查找元素
const element = document.getElementById(decodedId); const element = document.getElementById(decodedId);
if (element) { if (element) {
// 使用 scrollIntoView 滚动到元素 // 使用 scrollIntoView 滚动到元素
element.scrollIntoView({ behavior: 'instant', block: 'start' }); element.scrollIntoView({ behavior: "instant", block: "start" });
return true; return true;
} }
return false; return false;
@@ -48,18 +72,13 @@ function scrollToHash(hash: string) {
* Article 组件 * Article 组件
* 用于将特定 src 位置的 md 文件显示为 markdown 文章 * 用于将特定 src 位置的 md 文件显示为 markdown 文章
*/ */
export const Article: Component<ArticleProps> = (props) => { export const Article: Component<ArticleProps & ParentProps> = (props) => {
const location = useLocation(); const navigate = useNavigateWithParams();
const [content, { refetch }] = createResource( const [content, { refetch }] = createResource(
() => ({ src: props.src, section: props.section }), () => ({ src: props.src, section: props.section }),
fetchArticleContent fetchArticleContent,
); );
const [contentDom, setContentDom] = createSignal<HTMLDivElement>();
// 解析 iconPath,默认为 "./assets",空字符串表示禁用
const iconPrefix = createMemo(() => {
if (props.iconPath === '') return undefined; // 空字符串禁用图标前缀
return resolvePath(props.src, props.iconPath ?? "./assets");
});
createEffect(() => { createEffect(() => {
const data = content(); const data = content();
@@ -67,18 +86,52 @@ export const Article: Component<ArticleProps> = (props) => {
props.onLoaded?.(); props.onLoaded?.();
// 内容加载完成后,渲染 mermaid 图表 // 内容加载完成后,渲染 mermaid 图表
void mermaid.run(); void mermaid.run();
// 内容渲染后检查 hash 并滚动 // 内容渲染后检查 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(() => { onCleanup(() => {
// 清理时清空内容,触发内部组件的销毁 // 清理时清空内容,触发内部组件的销毁
}); });
return ( return (
<article class={`prose ${props.class || ''}`} data-src={props.src}> <article
class={`prose ${props.class || ""}`}
data-src={props.src}
>
<Show when={content.loading}> <Show when={content.loading}>
<div class="text-gray-500">加载中...</div> <div class="text-gray-500">加载中...</div>
</Show> </Show>
@@ -86,7 +139,14 @@ export const Article: Component<ArticleProps> = (props) => {
<div class="text-red-500">加载失败:{content.error?.message}</div> <div class="text-red-500">加载失败:{content.error?.message}</div>
</Show> </Show>
<Show when={!content.loading && !content.error && content()}> <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> </Show>
</article> </article>
); );
+56
View File
@@ -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;
+159
View File
@@ -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;
+194
View File
@@ -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
View File
@@ -1,13 +1,21 @@
import { Component, createMemo, createSignal, Show } from "solid-js"; 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 { 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 { function isPathInDir(currentPath: string, dirPath: string): boolean {
// 确保 dirPath 以 / 结尾,用于前缀匹配 // 确保 dirPath 以 / 结尾,用于前缀匹配
const dirPathPrefix = dirPath.endsWith('/') ? dirPath : dirPath + '/'; const dirPathPrefix = dirPath.endsWith("/") ? dirPath : dirPath + "/";
return currentPath.startsWith(dirPathPrefix); return currentPath.startsWith(dirPathPrefix);
} }
@@ -20,41 +28,56 @@ export const FileTreeNode: Component<{
pathHeadings: Record<string, TocNode[]>; pathHeadings: Record<string, TocNode[]>;
depth: number; depth: number;
onClose: () => void; onClose: () => void;
isHidden?: (node: FileNode) => boolean;
}> = (props) => { }> = (props) => {
const navigate = useNavigate(); const navigate = useNavigateWithParams();
const location = useLocation();
const isDir = !!props.node.children; const isDir = !!props.node.children;
const isActive = createMemo(() => props.currentPath === props.node.path); 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) { if (isDir) {
e.preventDefault();
setIsExpanded(!isExpanded()); setIsExpanded(!isExpanded());
} else { } else if (e.button === 0) {
// Left-click: use SPA navigation
e.preventDefault();
navigate(props.node.path); navigate(props.node.path);
props.onClose(); props.onClose();
} }
// Middle-click / right-click: let the browser handle the <a> natively
}; };
const indent = props.depth * 12; const indent = props.depth * 12;
return ( return (
<div> <div class={props.isHidden?.(props.node) === true ? "hidden" : ""}>
<div <a
class={`flex items-center py-1 px-2 cursor-pointer hover:bg-gray-100 rounded ${ 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" isActive() ? "bg-blue-50 text-blue-700" : "text-gray-700"
}`} }`}
style={{ "padding-left": `${indent + 8}px` }} style={{ "padding-left": `${indent + 8}px` }}
onClick={handleClick} onClick={handleClick}
> >
<Show when={isDir}> <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>
<Show when={!isDir}> <Show when={!isDir}>
<span class="mr-1 text-gray-400">📄</span> <TbOutlineFile class="mr-1 text-gray-400" />
</Show> </Show>
<span class="text-sm truncate">{props.node.name}</span> <span class="text-sm truncate">{props.node.name}</span>
</div> </a>
<Show when={isDir && isExpanded() && props.node.children}> <Show when={isDir && isExpanded() && props.node.children}>
<div> <div>
{props.node.children!.map((child) => ( {props.node.children!.map((child) => (
@@ -64,6 +87,7 @@ export const FileTreeNode: Component<{
pathHeadings={props.pathHeadings} pathHeadings={props.pathHeadings}
depth={props.depth + 1} depth={props.depth + 1}
onClose={props.onClose} onClose={props.onClose}
isHidden={props.isHidden}
/> />
))} ))}
</div> </div>
@@ -79,10 +103,12 @@ export const HeadingNode: Component<{
node: TocNode; node: TocNode;
basePath: string; basePath: string;
depth: number; depth: number;
isHidden?: (node: TocNode) => boolean;
}> = (props) => { }> = (props) => {
const navigate = useNavigate(); const navigate = useNavigateWithParams();
const location = useLocation();
const anchor = props.node.id || ""; const anchor = props.node.id || "";
const href = `${props.basePath}#${anchor}`; const href = () => `${props.basePath}${location.search}#${anchor}`;
const hasChildren = !!props.node.children; const hasChildren = !!props.node.children;
// 默认收起,除非当前锚点在该节点内 // 默认收起,除非当前锚点在该节点内
const [isExpanded, setIsExpanded] = createSignal(props.depth <= 0); const [isExpanded, setIsExpanded] = createSignal(props.depth <= 0);
@@ -93,16 +119,31 @@ export const HeadingNode: Component<{
setIsExpanded(!isExpanded()); setIsExpanded(!isExpanded());
} }
}; };
const scrollContainer = useScrollContainer();
const handleClick = (e: MouseEvent) => { 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(() => { requestAnimationFrame(() => {
const element = document.getElementById(anchor); const element = document.getElementById(anchor);
if (element) { if (element) {
const scroller = scrollContainer();
const navBarHeight = 80; const navBarHeight = 80;
const elementPosition = element.getBoundingClientRect().top; const elementPosition = element.getBoundingClientRect().top;
const offsetPosition = window.scrollY + elementPosition - navBarHeight; const containerTop = scroller?.getBoundingClientRect().top ?? 0;
window.scrollTo({ top: offsetPosition, behavior: "instant" }); 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; const indent = props.depth * 12;
return ( return (
<div> <div class={props.isHidden?.(props.node) === true ? "hidden" : ""}>
<div class="flex flex-row"> <div class="flex flex-row">
<span class={`cursor-pointer mr-1 text-gray-400 text-xs w-3 flex-shrink-0`} <span
style={{ "margin-left": `${indent + 8}px` }} class={`cursor-pointer mr-1 text-gray-400 text-xs w-3 shrink-0`}
onClick={handleExpand} 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> </span>
<a <a
href={href} 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" 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} onClick={handleClick}
> >
{props.node.title} {props.node.title}
</a> </a>
@@ -133,6 +179,7 @@ export const HeadingNode: Component<{
node={child} node={child}
basePath={props.basePath} basePath={props.basePath}
depth={props.depth + 1} depth={props.depth + 1}
isHidden={props.isHidden}
/> />
))} ))}
</div> </div>
+184
View File
@@ -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
View File
@@ -1,11 +1,16 @@
import { Component, createMemo, createSignal, onMount, Show } from "solid-js"; import { Component, createMemo, createSignal, onMount, Show } from "solid-js";
import { generateToc, type FileNode, type TocNode } from "../data-loader"; import { generateToc, type FileNode, type TocNode } from "../data-loader";
import { useLocation } from "@solidjs/router"; import { useLocation } from "@solidjs/router";
import { TbOutlineFolderOpen, TbOutlineX } from "solid-icons/tb";
import { FileTreeNode, HeadingNode } from "./FileTree"; import { FileTreeNode, HeadingNode } from "./FileTree";
import { isPathRevealed } from "./stores/reveal";
export interface SidebarProps { export interface SidebarProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
fileTree?: FileNode[];
pathHeadings?: Record<string, TocNode[]>;
onDataSourceOpen?: () => void;
} }
interface SidebarContentProps { interface SidebarContentProps {
@@ -13,7 +18,8 @@ interface SidebarContentProps {
pathHeadings: Record<string, TocNode[]>; pathHeadings: Record<string, TocNode[]>;
currentPath: string; currentPath: string;
onClose: () => void; onClose: () => void;
isDesktop?: boolean; onNavigate?: () => void;
onDataSourceOpen?: () => void;
} }
/** /**
@@ -25,23 +31,43 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
// 响应式获取当前文件的标题列表 // 响应式获取当前文件的标题列表
const currentFileHeadings = createMemo(() => { const currentFileHeadings = createMemo(() => {
const pathname = decodeURIComponent(location.pathname); 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 ( return (
<div class="flex flex-col h-full"> <div class="flex flex-col h-full">
<div class="p-4 border-b border-b-gray-200"> <div class="p-4 border-b border-b-gray-200">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-gray-900">目录</h2> <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 <button
onClick={props.onClose} onClick={props.onClose}
class="text-gray-500 hover:text-gray-700" class="text-gray-500 hover:text-gray-700"
title="关闭" title="关闭"
> >
✕ <TbOutlineX class="inline-block align-[-0.125em]" />
</button> </button>
</Show> </div>
</div> </div>
</div> </div>
@@ -56,7 +82,8 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
currentPath={props.currentPath} currentPath={props.currentPath}
pathHeadings={props.pathHeadings} pathHeadings={props.pathHeadings}
depth={0} depth={0}
onClose={props.onClose} onClose={() => props.onNavigate?.()}
isHidden={isFileHidden}
/> />
))} ))}
</div> </div>
@@ -72,6 +99,7 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
node={node} node={node}
basePath={location.pathname} basePath={location.pathname}
depth={0} depth={0}
isHidden={isHeadingHidden}
/> />
))} ))}
</div> </div>
@@ -85,14 +113,20 @@ const SidebarContent: Component<SidebarContentProps> = (props) => {
*/ */
export const MobileSidebar: Component<SidebarProps> = (props) => { export const MobileSidebar: Component<SidebarProps> = (props) => {
const location = useLocation(); const location = useLocation();
const [fileTree, setFileTree] = createSignal<FileNode[]>([]); const [selfFileTree, setSelfFileTree] = createSignal<FileNode[]>([]);
const [pathHeadings, setPathHeadings] = createSignal<Record<string, TocNode[]>>({}); const [selfPathHeadings, setSelfPathHeadings] = createSignal<
Record<string, TocNode[]>
>({});
// 加载目录数据 const fileTree = () => props.fileTree ?? selfFileTree();
const pathHeadings = () => props.pathHeadings ?? selfPathHeadings();
// 加载目录数据 (only if props not provided)
onMount(async () => { onMount(async () => {
if (props.fileTree && props.pathHeadings) return;
const toc = await generateToc(); const toc = await generateToc();
setFileTree(toc.fileTree); setSelfFileTree(toc.fileTree);
setPathHeadings(toc.pathHeadings); setSelfPathHeadings(toc.pathHeadings);
}); });
return ( return (
@@ -115,6 +149,8 @@ export const MobileSidebar: Component<SidebarProps> = (props) => {
pathHeadings={pathHeadings()} pathHeadings={pathHeadings()}
currentPath={location.pathname} currentPath={location.pathname}
onClose={props.onClose} onClose={props.onClose}
onNavigate={props.onClose}
onDataSourceOpen={props.onDataSourceOpen}
/> />
</aside> </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 location = useLocation();
const [fileTree, setFileTree] = createSignal<FileNode[]>([]); const [selfFileTree, setSelfFileTree] = createSignal<FileNode[]>([]);
const [pathHeadings, setPathHeadings] = createSignal<Record<string, TocNode[]>>({}); const [selfPathHeadings, setSelfPathHeadings] = createSignal<
Record<string, TocNode[]>
>({});
const fileTree = () => props.fileTree ?? selfFileTree();
const pathHeadings = () => props.pathHeadings ?? selfPathHeadings();
// 加载目录数据
onMount(async () => { onMount(async () => {
if (props.fileTree && props.pathHeadings) return;
const toc = await generateToc(); const toc = await generateToc();
setFileTree(toc.fileTree); setSelfFileTree(toc.fileTree);
setPathHeadings(toc.pathHeadings); setSelfPathHeadings(toc.pathHeadings);
}); });
return ( 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 <SidebarContent
fileTree={fileTree()} fileTree={fileTree()}
pathHeadings={pathHeadings()} pathHeadings={pathHeadings()}
currentPath={location.pathname} currentPath={location.pathname}
onClose={() => {}} onClose={props.onClose}
isDesktop onDataSourceOpen={props.onDataSourceOpen}
/> />
</aside> </aside>
); );
+143
View File
@@ -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);
});
});
+21
View File
@@ -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";
@@ -1,10 +1,11 @@
import { roll as rollDice } from "./dice-engine"; import { roll as rollDice } from "./roller";
export interface DiceRollerResult { export interface DiceRollerResult {
formula: string; formula: string;
result: { result: {
total: number; total: number;
detail: string; detail: string;
plainDetail: string;
pools: Array<{ pools: Array<{
rolls: number[]; rolls: number[];
keptRolls: number[]; keptRolls: number[];
@@ -15,19 +16,14 @@ export interface DiceRollerResult {
error?: string; error?: string;
} }
/**
* 骰子引擎 Hook
* 提供高级骰子功能:骰池、修饰符、组合等
*/
/** /**
* 执行掷骰 * 执行掷骰
* @param formula 骰子公式,支持: *
* - 3d6: 标准骰子 * Supports pools, modifiers and combinations. For example:
* - {d4, d8, 2d6}: 骰池字面量 * - `3d6`: standard dice
* - kh2/dl1: 修饰符 * - `{d4, d8, 2d6}`: pool literal
* - +: 组合骰池 * - `kh2` / `dl1`: keep-high / drop-low modifiers
* - -: 负数组合 * - `+` / `-`: combine pools; a bare number is a fixed value
* - 5: 固定数字
*/ */
export function rollFormula(formula: string): DiceRollerResult { export function rollFormula(formula: string): DiceRollerResult {
try { try {
@@ -38,6 +34,7 @@ export function rollFormula(formula: string): DiceRollerResult {
result: { result: {
total: result.total, total: result.total,
detail: result.detail, detail: result.detail,
plainDetail: result.plainDetail,
pools: result.pools.map((pool) => ({ pools: result.pools.map((pool) => ({
rolls: pool.rolls.map((r) => r.value), rolls: pool.rolls.map((r) => r.value),
keptRolls: pool.keptRolls.map((r) => r.value), keptRolls: pool.keptRolls.map((r) => r.value),
@@ -52,6 +49,7 @@ export function rollFormula(formula: string): DiceRollerResult {
result: { result: {
total: 0, total: 0,
detail: "", detail: "",
plainDetail: "",
pools: [], pools: [],
}, },
success: false, 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,
};
}
}
@@ -38,7 +38,10 @@ export function rollDicePool(pool: DicePool): DicePoolResult {
const { keptRolls, droppedRolls } = applyModifier(rolls, pool.modifier); 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) => { const subtotal = finalKeptRolls.reduce((sum, roll) => {
@@ -59,7 +62,7 @@ export function rollDicePool(pool: DicePool): DicePoolResult {
*/ */
export function applyModifier( export function applyModifier(
rolls: DieResult[], rolls: DieResult[],
modifier: DicePool["modifier"] modifier: DicePool["modifier"],
): { keptRolls: DieResult[]; droppedRolls: DieResult[] } { ): { keptRolls: DieResult[]; droppedRolls: DieResult[] } {
if (!modifier) { if (!modifier) {
return { keptRolls: rolls, droppedRolls: [] }; return { keptRolls: rolls, droppedRolls: [] };
@@ -70,7 +73,8 @@ export function applyModifier(
switch (modifier.type) { switch (modifier.type) {
case "kh": // Keep Highest - 保留最大的 N 个 case "kh": // Keep Highest - 保留最大的 N 个
case "dh": { // Drop Highest - 丢弃最大的 N 个 case "dh": {
// Drop Highest - 丢弃最大的 N 个
const count = modifier.count; const count = modifier.count;
const higher = sorted.slice(-count); const higher = sorted.slice(-count);
const rest = sorted.slice(0, -count); const rest = sorted.slice(0, -count);
@@ -81,7 +85,8 @@ export function applyModifier(
} }
} }
case "kl": // Keep Lowest - 保留最小的 N 个 case "kl": // Keep Lowest - 保留最小的 N 个
case "dl": { // Drop Lowest - 丢弃最小的 N 个 case "dl": {
// Drop Lowest - 丢弃最小的 N 个
const count = modifier.count; const count = modifier.count;
const lower = sorted.slice(0, count); const lower = sorted.slice(0, count);
const rest = sorted.slice(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); const total = poolResults.reduce((sum, result) => sum + result.subtotal, 0);
// 生成详细表达式 - 使用 HTML 格式 // Generate detail expressions (both HTML and plain text)
const rollSpans: string[] = []; const htmlSpans: string[] = [];
const textSpans: string[] = [];
for (const result of poolResults) { for (const result of poolResults) {
// 构建骰子结果 HTML
for (const roll of result.rolls) { for (const roll of result.rolls) {
const isKept = result.keptRolls.some((kept) => kept === roll); const isKept = result.keptRolls.some((kept) => kept === roll);
if (roll.sides === 0) { if (roll.sides === 0) {
// 固定数字,不显示括号 const sign = roll.isNegative ? "-" : "";
rollSpans.push(`<strong>${roll.isNegative ? '-' : ''}${roll.value}</strong>`); htmlSpans.push(`<strong>${sign}${roll.value}</strong>`);
textSpans.push(`${sign}${roll.value}`);
} else if (isKept) { } else if (isKept) {
rollSpans.push(`<strong>[${roll.value}]</strong>`); htmlSpans.push(`<strong>[${roll.value}]</strong>`);
textSpans.push(`[${roll.value}]`);
} else { } 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 rollHtml = htmlSpans.join(" + ");
const detail = `<strong>${total}</strong> = ${rollsStr}`; const rollText = textSpans.join(" + ");
const detail = `<strong>${total}</strong> = ${rollHtml}`;
const plainDetail = `${total} = ${rollText}`;
return { return {
pools: poolResults, pools: poolResults,
total, total,
detail, detail,
plainDetail,
}; };
} }
+66
View File
@@ -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 };
+77
View File
@@ -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();
+50
View File
@@ -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
View File
@@ -1,48 +1,35 @@
// 导入自定义元素以注册 // 导入自定义元素以注册
import './md-dice'; import "./md-dice";
import './md-table'; import "./md-table";
import './md-link'; import "./md-link";
import './md-pins'; import "./md-pins";
import './md-bg'; import "./md-bg";
import './md-deck'; import "./md-border";
import './md-commander/index'; import "./md-font";
import './md-yarn-spinner'; import "./md-embed";
import './md-token'; import "./md-deck";
import './md-token-viewer'; import "./md-print";
import "./md-yarn-spinner";
// 导出组件 // 导出组件
export { Article } from './Article'; export { Article } from "./Article";
export type { ArticleProps } from './Article'; export type { ArticleProps } from "./Article";
export { MobileSidebar, DesktopSidebar } from './Sidebar'; export { RevealManager } from "./RevealManager";
export type { SidebarProps } from './Sidebar'; export { CommandLinkManager } from "./CommandLinkManager";
export { FileTreeNode, HeadingNode } from './FileTree'; 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 { DiceProps } from "./md-dice";
export type { TableProps } from './md-table'; export type { TableProps } from "./md-table";
export type { BgProps } from './md-bg'; export type { BgProps } from "./md-bg";
export type { YarnSpinnerProps } from './md-yarn-spinner'; export type { FontProps } from "./md-font";
export type { TokenProps } from './md-token'; export type { BorderProps } from "./md-border";
export type { EmbedProps } from "./md-embed";
// 导出 md-commander 相关 export type { YarnSpinnerProps } from "./md-yarn-spinner";
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';
+61
View File
@@ -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>
);
};
+86
View File
@@ -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>
);
};
+224
View File
@@ -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