Compare commits

..
402 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
hypercross d4de95b465 refactor: fix layout for merged decks in PrintPreview
Update PrintPreview to correctly handle dimensions and layer
configurations when multiple decks are merged. Instead of using the
base store for all cards, it now maps each card to its specific
source store to ensure correct grid positioning and styling.
2026-05-05 16:46:10 +08:00
hypercross 4953d33f0f feat: implement multi-deck merging for print preview
Introduce a deck registry to track all active deck instances. This
allows users to select and merge multiple decks with matching
dimensions into a single print preview and export session.
2026-05-05 16:30:49 +08:00
hypercross d099cf5758 refactor: use npm yarn-spinner-loader package 2026-04-14 20:26:56 +08:00
hypercross 163aa8c783 refactor: move yarn-spinner-loader to external dependency 2026-04-14 16:35:18 +08:00
hypercross c110bb08b5 fix: label css typo 2026-04-09 12:35:54 +08:00
hypercross 348dff82f4 refactor: add pull-2, icon label, icon repeat 2026-04-09 12:31:07 +08:00
hypercross 2aaca2c56f fix: md-bg articlePath passing 2026-04-09 11:53:24 +08:00
hyper 385bcb16d8 fix: dev server not showing pngs 2026-04-08 19:56:44 +08:00
hyper afe2eb4af8 fix: cli file change detection now includes csv/yarn 2026-04-08 19:43:32 +08:00
hyper b09560a4e5 refactor: allow chinese layer names 2026-04-07 19:33:08 +08:00
hyper 4d1c40ed3e refactor: add support for non-png icons 2026-04-06 18:31:08 +08:00
hyper 1eaef04215 fix: package-lock.json 2026-04-06 17:55:18 +08:00
hyper 25d95e6990 refactor: remove inline-schema 2026-04-06 17:47:33 +08:00
hypercross 685ff08b78 fix: fix audit & inline schema to file? 2026-04-03 10:13:41 +08:00
hyper 12c2473aeb refactor: swithc to npm link based dependency 2026-04-02 21:09:42 +08:00
hypercross cd5741692a refactor: alignment icons 2026-03-31 22:32:53 +08:00
hypercross d8058fd224 refactor: hide original frame 2026-03-31 22:32:53 +08:00
hypercross 49fca8c18f fix: rotation 2026-03-31 22:32:53 +08:00
hypercross ceb2da8b1a refactor: layer transform controls 2026-03-31 22:32:53 +08:00
hypercross 56cabea109 refactor: layer editing ui 2026-03-31 22:32:53 +08:00
hypercross 831955e16e fix: csv loading 2026-03-31 22:32:52 +08:00
hypercross ea57cf8d2b refactor: layer alignment 2026-03-31 22:32:52 +08:00
hypercross 273f949839 refactor: details 2026-03-31 22:32:52 +08:00
hyper 18ea01b904 fix: plt/print preview window stacking 2026-03-31 17:28:46 +08:00
hyper 246d983ad2 chore: update inline-schema 2026-03-31 17:18:26 +08:00
hyper 14a692cd2c fix: inline-schema type file write 2026-03-31 16:20:42 +08:00
hyper 57b46fedc1 chore: skip content in builds 2026-03-31 15:42:19 +08:00
hyper eb1388c6bc refactor: add inline-schema 2026-03-31 15:40:06 +08:00
hypercross 0aaf81057a fix: parsing issues 2026-03-26 11:46:33 +08:00
hypercross faf179a4fd docs: update 2026-03-26 11:24:28 +08:00
hypercross 228327913e refactor: icon mini styling 2026-03-25 18:08:32 +08:00
hypercross 3cd8cc54b7 fix: mini icons 2026-03-25 17:36:19 +08:00
hypercross 64ac6c4516 fix: card layer assets 2026-03-25 17:29:56 +08:00
hypercross 8af559fe1c refactor: invert dice interaction 2026-03-25 14:08:29 +08:00
hypercross 33d29252dc refactor: more toc 2026-03-25 11:00:32 +08:00
hypercross a7477666c7 refactor: toc behaviour 2026-03-25 10:49:15 +08:00
hypercross 3aaa991bd8 refactor: toc toggle 2026-03-25 10:26:06 +08:00
hypercross 32e91f7dc8 refactor: sidebar color 2026-03-25 09:42:58 +08:00
hypercross 028074e4f1 fix: columns 2026-03-23 22:22:01 +08:00
hypercross 6f8be557b4 fix: jest 2026-03-23 20:24:25 +08:00
hypercross 30ddcfc32d refactor: layout 2026-03-23 19:01:42 +08:00
hypercross f658fd3380 refactor: convert xdx tables by default 2026-03-23 18:57:55 +08:00
hypercross 8f6a6b96e2 feat: mothership character store 2026-03-23 01:13:19 +08:00
hypercross c5e091da08 fix: toc uri 2026-03-22 10:11:41 +08:00
hypercross 206e799f34 fix: toc details 2026-03-22 01:47:26 +08:00
hypercross 94abd3e469 refactor: toc on right on xl 2026-03-22 01:41:37 +08:00
hypercross fbc67175ae fix: scrolling 2026-03-22 01:18:53 +08:00
hypercross 4839985154 fix: csv 2026-03-22 00:27:59 +08:00
hypercross 68d57ac049 refactor: style 2026-03-21 19:09:54 +08:00
hypercross fbdb427532 refactor: md-roll-label and md-remix-label 2026-03-21 18:58:42 +08:00
hypercross f9d0620d3e fix: chinese prop names 2026-03-21 18:52:40 +08:00
hypercross 66e8564a34 feat: md-table weighted random 2026-03-21 18:42:50 +08:00
hypercross 0261061bcb refactor: md-table body column gen 2026-03-21 18:30:40 +08:00
hypercross 617fd17def feat: md-pins allow numbers 2026-03-21 18:16:41 +08:00
hypercross 6f57970711 feat: auto-convert tables 2026-03-21 17:53:06 +08:00
hypercross 6b411c2f24 feat: inline md-table 2026-03-21 17:48:30 +08:00
hypercross 3d9617f06c fix: host addr 2026-03-21 00:29:36 +08:00
hypercross 7a490b82df fix: toc 2026-03-20 09:01:21 +08:00
hypercross d19401373f fix: slugging 2026-03-19 11:26:59 +08:00
hypercross 71ab7523da fix: shutdown 2026-03-18 15:27:06 +08:00
hypercross e5ce8c20b5 fix: mcp serve 2026-03-18 15:19:55 +08:00
hypercross 5389345a00 feat: mcp serve preview 2026-03-18 14:47:56 +08:00
hypercross 1d2b4b3e1e feat: layer docs 2026-03-18 14:28:44 +08:00
hypercross 301f499494 feat: resources? 2026-03-18 13:25:01 +08:00
hypercross 8213092bb6 fix: docs 2026-03-18 13:02:50 +08:00
hypercross 4f9d295bd5 feat: mcp prompts 2026-03-18 12:13:15 +08:00
hypercross 62aff91a86 fix: mcp 2026-03-18 12:08:28 +08:00
hypercross eecf429a20 fix: env and __CONTENT_INDEX 2026-03-18 11:31:13 +08:00
hyper 7b68504b1e feat: mcp server! 2026-03-17 22:49:47 +08:00
hyper 834db72ec4 feat: mcp server? 2026-03-17 20:01:04 +08:00
hyper b099d5695d refactor: move centering to 3mf gen 2026-03-17 12:49:49 +08:00
hyper c24c3b920d fix: url 2026-03-17 12:38:01 +08:00
hyper 352b17071c fix: cleanup 2026-03-17 12:19:15 +08:00
hyper c3f71f8be1 Merge branch 'master' of https://gitea.ayi-games.online/hypercross/ttrpg-tools 2026-03-17 12:10:43 +08:00
hyper 940caaf8e4 fix: display 2026-03-17 12:10:33 +08:00
hypercross a144af6015 fix: docs 2026-03-16 14:26:42 +08:00
hypercross 1f8f1b0c7d docs: usage 2026-03-16 13:58:13 +08:00
hypercross 342cf97a50 fix: parameters 2026-03-16 09:39:43 +08:00
hypercross cde2134b4b fix: svg parsing 2026-03-16 09:29:32 +08:00
hypercross c99f602efa refactor: some tweaks 2026-03-16 09:02:35 +08:00
hyper 2476659fc6 refactor: stl->3mf 2026-03-15 23:17:16 +08:00
hyper 7be206581d refactor: plotter code 2026-03-15 21:08:41 +08:00
hypercross a4e8e31921 fix: render issues 2026-03-15 19:24:40 +08:00
hypercross e92065d14c refactor: token viewer 2026-03-15 19:14:39 +08:00
hypercross 8c33dc282b feat: md-token 2026-03-15 19:01:39 +08:00
hypercross a840261ae0 feat: path closure 2026-03-15 18:28:52 +08:00
hypercross 2d4b719e10 refactor: better preview 2026-03-15 15:44:35 +08:00
hypercross 2e03ef3591 refactor: better sizing 2026-03-15 15:01:01 +08:00
hypercross 2b1dbd41e1 refactor: preview contour correctly 2026-03-15 14:55:50 +08:00
hypercross ee2fa057f6 fix: contour gen 2026-03-15 14:44:45 +08:00
hypercross 9f665fc403 chore: contour test 2026-03-15 11:45:35 +08:00
hypercross 92dac64326 chore: test suite 2026-03-15 11:44:23 +08:00
hypercross 0ec129c2be feat: hex/trig gen 2026-03-15 11:41:20 +08:00
hypercross 7aba5d0c81 refactor: visuals 2026-03-15 11:08:21 +08:00
hypercross f87ccb662d refactor: clean up 2026-03-15 10:57:18 +08:00
hypercross 57d2a7ed46 refactor: sizing 2026-03-15 10:14:14 +08:00
hypercross 8d8d4d004e fix: split with space 2026-03-15 10:02:42 +08:00
hypercross 9dbd406c0e fix: imports 2026-03-15 09:34:19 +08:00
hypercross 28794fd9f0 refactor: move code to plotcutter 2026-03-15 09:29:18 +08:00
hypercross 025f5a46b0 fix: contouring 2026-03-15 01:51:29 +08:00
hypercross acc9d79873 refactor: reorg 2026-03-15 01:43:25 +08:00
hypercross eef72a043b refactor: corner radius 2026-03-15 01:31:16 +08:00
hypercross fdc5a4656f refactor: pltrefactor: plt display 2026-03-15 01:19:58 +08:00
hypercross 7fd53650e3 refactor: plt preview 2026-03-15 01:07:23 +08:00
hypercross 1da5cfb2ea refactor: article iconPath 2026-03-15 00:40:06 +08:00
hyper 0ed95291ba feat: plt export 2026-03-14 16:20:55 +08:00
hyper 85ec3b9928 feat: plotcutter 2026-03-14 16:17:46 +08:00
hyper 107e6fd6a2 feat: shape for cards 2026-03-14 15:48:55 +08:00
hypercross 984b8aa1c8 fix: double side printing 2026-03-13 17:44:17 +08:00
hypercross 4b08a738a7 fix: generateCode 2026-03-13 17:34:00 +08:00
hypercross b3dc768786 feat: card back support? 2026-03-13 17:26:00 +08:00
hypercross 748f57dd55 fix: refactor stuff 2026-03-13 16:02:09 +08:00
hypercross 27bc2fc747 refactor: cached and reloaded file-index 2026-03-13 15:50:50 +08:00
hypercross 2e04934881 fix: typing 2026-03-13 15:36:35 +08:00
hypercross c668dce348 refactor: larger icons 2026-03-13 15:21:04 +08:00
hypercross 6fcd879287 fix: icon sizing 2026-03-13 12:40:30 +08:00
hypercross 29d8a76cd1 refactor: hide controls for hidden layers 2026-03-13 12:34:39 +08:00
hypercross 97923c8d35 fix: icons 2026-03-13 11:53:41 +08:00
hypercross 9e8d4e6388 feat: md icon prefix 2026-03-13 11:33:13 +08:00
hypercross 346b97153f fix: bug 2026-03-13 10:59:24 +08:00
hypercross b2e10d847e feat: front matter in decks 2026-03-13 10:50:08 +08:00
hypercross 1bd72bb58f docs: clean up 2026-03-10 23:17:49 +08:00
hypercross c3a49711e7 docs: readme 2026-03-10 01:47:17 +08:00
hypercross f736b7309e fix: case 2026-03-06 17:52:30 +08:00
hypercross bf18808aa8 fix: regex? 2026-03-06 17:51:22 +08:00
hypercross ae9c3aa59b fix: print preview 2026-03-06 17:43:41 +08:00
hypercross 0d00879186 refactor: design process 2026-03-05 10:55:49 +08:00
hypercross da2dfa75f9 refactor: design process 2026-03-05 09:48:47 +08:00
hypercross 7c8d9ec17f refactor: combat 2026-03-04 19:16:02 +08:00
hypercross 2003cdb022 refactor: design process 2026-03-04 18:43:14 +08:00
hypercross 292099819c refactor: adventure format 2026-03-04 17:37:45 +08:00
hypercross 02aad0415c fix: js-yaml types 2026-03-03 20:43:32 +08:00
hypercross f1aa536c4d refactor: ai's attempt 2026-03-03 18:29:59 +08:00
hypercross e13ead2309 refactor: yarn runner man 2026-03-03 15:09:25 +08:00
hypercross 8d5b15ad51 refactor: yarn format 2026-03-03 14:25:15 +08:00
hypercross 82876d47c0 feat: check function 2026-03-03 13:12:45 +08:00
hypercross a226a9516c feat: yarn return 2026-03-03 11:49:11 +08:00
hypercross 4d3faa3e4e refactor: dead code 2026-03-03 11:19:40 +08:00
hypercross 4280da9fec refactor: reorg 2026-03-03 10:54:20 +08:00
hypercross 6b77653d27 refactor: layout 2026-03-03 00:48:13 +08:00
hypercross a02edabc41 fix: yarn spinner runner 2026-03-02 17:54:40 +08:00
hypercross 0594fef9ac feat: ai's attempt 2026-03-02 16:49:55 +08:00
hypercross 2a9281c9dc feat: yarn runner 2026-03-02 16:22:54 +08:00
hypercross f64775e735 feat: yarn spinner parser 2026-03-02 16:18:10 +08:00
hypercross f27a6a8dfc feat: yarn spinner 2026-03-02 15:23:12 +08:00
hypercross 13007c8fa4 feat: gfm heading id 2026-03-02 13:43:10 +08:00
hypercross df990d4453 docs: for module writing 2026-03-02 13:19:14 +08:00
hypercross b737e9f4ea feat: mermaid 2026-03-02 11:03:51 +08:00
hypercross e8ce7b7216 feat: icon 2026-03-01 16:02:07 +08:00
hypercross 63b8c5d8d2 refactor: styline 2026-03-01 15:52:35 +08:00
hypercross 047bbf1cc5 fix: layout 2026-03-01 14:40:46 +08:00
hypercross e891193832 fix: layout 2026-03-01 14:39:29 +08:00
hypercross 306a46b62b refactor: uhu 2026-03-01 14:34:40 +08:00
hypercross fa1c6d19e9 refactor: styling 2026-03-01 14:21:04 +08:00
hypercross bda201ae81 refactor: impl 2026-03-01 13:54:48 +08:00
hypercross 62780e3e56 refactor: impl 2026-03-01 12:51:15 +08:00
hypercross 5d026dfd80 refactor: impl 2026-03-01 12:41:03 +08:00
hypercross c2c3956a82 refactor: impl 2026-03-01 12:33:55 +08:00
hypercross 3e14bff1e9 feat: command template csv 2026-03-01 11:52:09 +08:00
hypercross aac7fe575a wip: command template csv 2026-03-01 11:41:54 +08:00
hypercross 660f820f4c refactor: qwen 2026-03-01 11:38:19 +08:00
hypercross c977f366ea fix: issues 2026-03-01 11:38:14 +08:00
hypercross 05c93e0301 fix: completion 2026-03-01 11:11:56 +08:00
hypercross 90b0346b65 feat: templates 2026-03-01 11:04:31 +08:00
hypercross 1cd85adff9 refactor: clicking tag to edit 2026-03-01 10:53:17 +08:00
hypercross abaee79198 feat: edit entry classes 2026-03-01 10:51:07 +08:00
hypercross 80a6b4526c refactor: attribute editing 2026-03-01 10:28:44 +08:00
hypercross ca48d818cc refactor: display 2026-03-01 10:15:33 +08:00
hypercross ff091e2f31 fix: parsing 2026-03-01 10:05:56 +08:00
hypercross fdaf79e7ed fix: issues 2026-03-01 10:01:53 +08:00
hypercross c23bff5a89 feat: less completions 2026-03-01 09:52:53 +08:00
hypercross a55f55505c feat: details 2026-03-01 09:49:01 +08:00
hypercross f7eabbdeba feat: ai's attempt 2026-03-01 09:36:09 +08:00
262 changed files with 40620 additions and 3600 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
+3
View File
@@ -4,3 +4,6 @@ dist/
.DS_Store .DS_Store
.env .env
.env.local .env.local
*.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.
+15 -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`: [未实现] 存根命令,不会产生任何输出。
## 输入文件 ## 输入文件
@@ -48,3 +49,15 @@ cli应当搜索目录下的所有`.md`文件,并为每个文件创建一条路
使用`@tailwindcss/typography`来管理`markdown`样式。 使用`@tailwindcss/typography`来管理`markdown`样式。
编写自定义UI组件时,不要使用`shadow dom`,并且尽可能继承`tailwindcss`指定的样式。 编写自定义UI组件时,不要使用`shadow dom`,并且尽可能继承`tailwindcss`指定的样式。
## LLM开发须知
开发环境主要在windows上,编辑时需要注意windows的换行符,以及命令行工具。
完成开发后,不仅要使用`npm run build`检查验证,还应该检查是否有类型错误。
若遇到bug且可以通过独立测试样例来检验,则在开发中自行创建临时测试样例测试。
solid.js倾向于使用`createEffect`和/或`createMemo`,以及在jsx中inline引用`props`,来实现响应式处理。
如果可以,优先使用`createEffect`和`createMemo`,而不是`onMount`+`createSignal`。
+37 -53
View File
@@ -1,69 +1,53 @@
# TTRPG Tools # TTTK — Tabletop Toolkit
一个基于 `solid.js` 和 `rsbuild` 的 `ttrpg` 工具箱。 A TTRPG content preview and validation tool based on `solid.js` and `rsbuild`. Write markdown, preview in browser, validate before you ship.
## 功能 ## 快速开始
- **CLI**: 提供一个 cli 工具,用于将目录内的各种 ttrpg 文档编译为 html。
- **Markdown**: 解析以各种格式编写的 ttrpg 内容,并支持扩展的语法。
- **TTRPG 组件**: 用于 ttrpg 内容的各种 UI 组件,且可以在 markdown 中通过扩展语法插入。
## CLI 命令
```bash ```bash
# 克隆仓库后,npm link安装 # 无需安装,直接使用
npx tttk serve ./content
# 在项目文件夹内运行
ttrpg serve [dir] -p 3000
# 校验内容(存在错误时退出码为 1)
npx tttk check ./content
``` ```
## 组件语法 ## 文档导航
### 骰子组件 | 文档 | 说明 |
|------|------|
| [📖 CLI 使用说明](./docs/cli.md) | CLI 安装、命令和用法 |
| [🔤 语法参考](./docs/reference.md) | 自定义元素、代码块角色与属性目录(由 `known.ts` 生成) |
| [📦 npm](https://www.npmjs.com/package/tttk) | `npx tttk serve ./content` |
| [🛠️ 开发指南](./docs/development.md) | 项目结构、开发规范和构建 |
| [📝 Markdown 编写说明](./docs/markdown.md) | Markdown 语法和组件用法 |
| [📊 CSV 编写说明](./docs/csv.md) | CSV 文件格式、字段定义、变量语法 |
| [🤖 MCP 服务器说明](./docs/mcp.md) | AI 助手集成、卡牌生成工具 |
```markdown ## 功能概览
:dice[2d6+d8]
:dice[1d20+5]{key="attack"}
```
### 表格组件 - **CLI 工具**: `tttk serve` 预览模式、`tttk check` 校验模式(`tttk compile` 尚未实现)
- **Markdown 解析**: 支持指令语法、YAML 标签、mermaid 图表
- **TTRPG 组件**: 骰子、表格、卡牌、标记、命令追踪器等
```markdown ## 核心组件
:table[./sparks.csv]
:table[./sparks.csv]{roll=true}
:table[./sparks.csv]{roll=true remix=true}
```
## 开发 | 组件 | 语法 | 说明 |
|------|------|------|
| 🎲 骰子 | `:md-dice[2d6+d8]` | 掷骰并记录结果 |
| 📊 表格 | `:md-table[./data.csv]` | 标签页式表格 |
| 🃏 卡牌 | `:md-deck[./cards.csv]` | 卡牌布局(推荐 `yaml role=tag` 写法) |
| 🖨️ 打印版式 | `:md-print[./rules.md]` | A4 横版双 A5、多列排版并导出 PDF |
| 📍 标记 | `:md-pins[./map.png]{pins="A:25,50"}` | 图片标记 |
```bash ## 技术栈
# 安装依赖
npm install
# 开发模式 - **前端**: Solid.js 1.9+
npm run dev - **构建**: Rsbuild
- **样式**: Tailwind CSS 4
- **Markdown**: marked + marked-directive
- **测试**: Jest
# 构建 ## 许可证
npm run build
# 预览 MIT
npm run preview
```
## 项目结构
```
ttrpg-tools/
├── src/
│ ├── cli/ # CLI 工具源码
│ ├── components/ # TTRPG 组件
│ ├── markdown/ # Markdown 解析器
│ ├── App.tsx # 主应用组件
│ ├── main.tsx # 入口文件
│ └── styles.css # 样式文件
├── content/ # 示例内容
├── package.json
├── tsconfig.json
└── rsbuild.config.ts
```
+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;
+84
View File
@@ -0,0 +1,84 @@
# 要求
编写ttrpg冒险。冒险将要使用./docs/system.md描述的规则运行。
若为yarn spinner编写,参考./docs/yarn-spinner.md的内容。
每个冒险应该按顺序详细设定以下要素:
1. 背景:地点、环境。(./docs/setting.md)
2. 势力:任务中出现的势力。(./docs/faction.md)
3. 主题:玩家的身份以及任务目标。(./docs/theme.md)
4. 地点:玩家可以访问的地点。(./docs/location.md)
5. 路径:在地点间移动期间可能遇到的事件。(./docs/route.md)
## 设计流程
1. 为冒险创建一个文件夹,并创建 `design.md`: 设计笔记。
2. 遵守文档的要求,将每个步骤的设计记录在此文件中。
3. 生成冒险所需的csv文件:
- `locations.csv`:描述每个地点。
- `cast.csv`:描述冒险中出现的npc,势力,怪物。
- `curio.csv`:描述冒险中可能遇到的特殊物品或资源。
- `route.csv`:描述冒险中在特定路径上可能遭遇的事件。
4. 编写`adventure.md`冒险本体,包含冒险简报引入,以及引用其他文件。
csv文件使用以下格式:
```csv
label,body
1,markdown文本
2,markdown文本
...
```
npc名称等标题内容写在body里,而不是label里。子段落标题加粗而不是使用标题。
如:
```
### 艾拉
**类型**:NPC/盟友
**身份**:村庄的守护者
```
使用以下语法引用csv:
`:md-table[./locations.csv]`
`:md-table[./cast.csv]`
`:md-table[./route.csv]{roll}`
`:md-table[./curio.csv]`
每个文件只引用一次。不需要添加额外的解释。
## 世界观
冒险应当讲诉`中世纪剑与魔法`风格的故事。角色们通常具有一些超出常人的能力,较少受社会制度所约束,但也无法左右社会现状。
社会由`骑士`、`贵族`、`平民`、`先知`四种人群构成。
- `骑士`精通武艺,受`先知`授予各种特殊能力,四处游历,保卫王国。他们通常正直而受尊重。
- `贵族`掌握权力,确保王国的外交与内政秩序。他们有的依靠精明和圆滑上位,有的则依靠世袭头衔粗暴而无能。
- `平民`从事生产,为王国贡献粮食、手工制品与人力。
- `先知`裁定纠纷,为王国的命运提供预言与计划。他们通常性情古怪,每名先知都独居在一个特殊的地点。
此外,社会中还有各式各样的私人势力,在明里暗处中活跃,无法划分到上述角色中。
## 超自然现象
除了人类以外,王国中可能出现各种各样的奇异生物与超自然现象。它们会影响角色们的冒险,但是很难影响朝政与社会。
从欧洲民间传说以及童话故事中获取灵感。
超自然现象通常由一些奇异生物操控,他们可能具有智能,具有与人类达成交易的可能,甚至可以做到操纵与欺骗人类。与此同时,他们的能力可能具有不为人知的弱点。
许多先知也能引发超自然现象,但他们的动机通常无法被人类理解,甚至不会使用超自然现象来影响人类社会。有的骑士持有先知赋予的超能力,但会付出相应的牺牲或者代价。
## 势力设计
在冒险中,可将部分NPC设计为势力领袖,手下带领一群相互类似的NPC。
势力是势力领袖的衍生。通过为势力创建势力计划、掌握的资源、势力成员的典型特点,来塑造势力的特色。
- 势力目标:敛财/复仇/征服/保护/研究/创造/自保/探索/寻宝
## NPC设计
冒险中出现的NPC应当具有鲜明的特色,但不应该有过于复杂的人物动机。通过行动来展现NPC的个性,避免纠缠不清的爱恨情仇。
-3
View File
@@ -1,3 +0,0 @@
# Commander Test
:md-commander[]
+26 -45
View File
@@ -1,56 +1,37 @@
# yaml/tag 代码块格式测试 # yaml role=tag 代码块格式测试
```yaml/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"}
tag: md-deck
body: ./npcs.csv
layers: body:1,6-5,8
```
> [!NOTE] :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" }
> Highlights information that users should take into account, even when skimming.
> [!TIP] ## 使用 yaml role=tag 语法创建 md-deck(fence 行内联 tag,简洁写法)
> Optional information to help a user be more successful.
> [!IMPORTANT] ```yaml role=tag tag=md-deck
> Crucial information necessary for users to succeed. body: ./names.csv
> [!WARNING]
> Critical content demanding immediate user attention due to potential risks.
> [!CAUTION]
> Negative potential consequences of an action.
## 使用 yaml/tag 语法创建 md-deck
```yaml/tag
tag: md-deck
body: ./sparks.csv
size: 54x86 size: 54x86
grid: 5x8 grid: 5x8
bleed: 1 bleed: 1
padding: 2 padding: 2
font-size: 3 layers: name1:1,1-2,2f12 name2:4,7-5,8f12s num1:1,3-2,4f12 num2:4,5-5,6f12s
``` ```
## 使用 body 字段添加内容 ```yaml role=tag
tag: md-deck
```yaml/tag body: ./names.csv
tag: tag-box data-config:
class: note size: 54x86
body: | 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
``` ```
## 带引号的值
```yaml/tag
tag: tag-alert
type: warning
class: my-alert
body: 这是一个警告信息
```
## 旧的指令语法仍然可用
:md-deck[./sparks.csv]{size="54x86" grid="5x8"}
+24
View File
@@ -0,0 +1,24 @@
# 势力
冒险中应当出现1~2个势力。
为每个势力设定以下元素:
1. 领袖NPC:设计其背景故事、特殊能力、动机、缺陷。
2. 势力计划:该势力目前的目标和行动纲领。应该大致与领袖NPC的人物动机有关。
3. 势力障碍:势力计划目前遇到的障碍。应该大致与领袖NPC的缺陷有关。
4. 掌握资源:势力对当地社会的影响力,拥有的物资,以及成员技能。
5. 活动范围:势力成员会在哪些地点出没。
准备至少两个地点,来支持玩家与势力的交互:
- 指挥所:玩家可以在这里找到势力领袖,与其进行谈判,交易,或者冲突。
- 任务执行地:玩家可以在这里遇到正在执行计划人物的势力成员,可以加以帮助或者阻止。
## 格式
cast.csv的label直接使用npc名称
```csv
label,body
张三,markdown文本
老王,markdown文本
...
```
+42
View File
@@ -0,0 +1,42 @@
# 地点设计
使用`mermaid`语法来记录地点和路径的结构。然后,为每个地点创建一个段落来描述其遭遇内容。
1. 为主题中的每个遭遇设计一个地点。此外,再为每个势力的指挥所和任务执行地各设计一个地点。
2. 为地点遭遇安排合适的交互内容设计。从以下模板当中挑选,尽量不要重复。
a. 起始地点:起始地点应当提供NPC或地图,为玩家展开游戏提供充足信息,并提供初始挑战。
b. 枢纽地点:与入口相连,四通八达。玩家需要经常返回。
c. 档案地点:放满了笔记、个人物品、信件、日志,能够揭露NPC动机或角色关系的地点。
d. 杂物堆:堆满看似不起眼的物品,实际上隐藏着重要信息、物品或密道。
e. 储藏室:存放着许多医疗用品或武器,通常上锁或被堵住。
f. 设备损坏:重要设施损坏,需要花费时间或资源修复,之后可以解锁路径、揭露重要信息或者重要道具等等。
g. 事故现场:具有危险环境要素的房间,冒险调查可以回收重要线索等等。
h. 陷阱房间:意外或者人为制造了环境要素的房间,若不小心会遭受危险。
i. 守卫室:驻扎着敌人的房间,通常把守着要道。
j. 人质室:敌人正在袭击NPC的房间,救出NPC可以获得其帮助。
2. 将地点连接起来,形成地图。
a. 确保每个地点都处于某种环状路径中,允许玩家总是前进不用回头。
b. 确保地点中有一些岔路,允许玩家在选择前进方向时有所选择。
3. 为路径设计差异化。
a. 选择两条路径,使用锁、隐藏入口、障碍物等形式,封堵其入口。在其他地点安排钥匙、秘密提示、清障工具等,帮助玩家打开路径。
b. 添加一条秘密捷径。玩家必须在不明显的地方仔细探索,才能发现捷径,并通过捷径绕过危险或障碍。
c. 为部分路径添加危险元素,如陷阱、怪物袭击、悬崖等等,以平衡不同路线遇到的阻碍。
4. 为地点添加一些辅助物资。辅助物资应当能帮助玩家恢复损伤,或者更好执行某些检定。
5. 为地点和路径添加合适的挑战。
设计时,尽可能设计多样的挑战和特色。
## 格式
地点csv的label直接使用地点名称。
```csv
label,body
村口,markdown文本
森林空地,markdown文本
...
```
+27
View File
@@ -0,0 +1,27 @@
# 路径设计
根据冒险的背景设定,将一部分较为危险的路径划分为危险路径。
为每条危险路径设计6种随机遭遇:
- 随机遭遇通常为玩家带来风险,若不能完成豁免则会造成属性损伤。
- 在体验上希望让玩家无法分辨随机遭遇与普通的地点遭遇。不要把随机遭遇描述为单纯的危险豁免,而是要有完整的场景描述。
- 若两条路径的性质类似,可以共用随机遭遇。
随机遭遇中,可以出现NPC、怪物、物品等元素。可以设计只在随机遭遇中出现的元素。
在随机遭遇中,可以出现骑士、平民、一般动物,但避免出现本次冒险中没有明确铺垫的超自然元素。
设计风险时,尽可能覆盖多样的属性类型。
## 格式
路径需要特殊的csv格式,用于区分不同的路径。
```csv
group,label,body
森林小径,1,markdown文本
森林小径,2,markdown文本
洞穴密道,1,markdown文本
洞穴密道,2,markdown文本
...
```
+16
View File
@@ -0,0 +1,16 @@
# 背景
为冒险设定背景。应包含以下内容:
1. 环境风貌:当地社会文化形态,以及环境生态。
2. 风格特色:冒险发生的地点(通常是一座大型建筑),该地点当前的状态与特点。
3. 上下文:在冒险发生前,这里发生过什么事情。
## 环境风貌
- 人类活动:人迹罕至/偏僻的村落/城市郊区/城堡/富饶的农村/大型城市街区等等。
- 环境生态:寒冷/炎热/河边/海边/山顶/林间/草原,甚至水下。
- 氛围
## 风格特色
- 地点:家族庄园/大图书馆/集市/工坊/研究所/学院/军营/流民营地/藏宝库/工厂/墓穴/动物巢穴等等。
- 构造:木制/石制/砂石/活生生的植物/冰封/玻璃/血肉/泥土/金属/大理石/菌菇/丝网等等。
+81
View File
@@ -0,0 +1,81 @@
# 系统
## 玩家角色
只有一个玩家角色。
### 基本属性
玩家角色具有以下基本属性:
- 力量
- 体质
- 敏捷
- 感知
- 智力
- 魅力
每个基本属性范围1-19,会用于属性检定,掷D20小于等于属性则检定成功。
属性可能遭受损伤扣减,损伤需要通过休息来缓慢回复。
一次受到超过当前一半属性损伤时,会遭受严重创伤,该属性即使休息也不会回复损伤。
必须通过专家、专门地点或道具来解除严重创伤。
### 耐受
玩家具有一条耐受度属性,只需几分钟的调整即可完全恢复。
耐受度随玩家角色成长而提升,初始范围为1-6。
在玩家做好准备时,可消耗耐受用于抵抗属性损伤,每点耐受抵抗1点损伤。
### 疲劳与恢复
结算疲劳时,掷一个`D6`属性骰,每一面代表一种属性。掷出的属性受到1点损伤。
结算恢复时类似,掷出的属性恢复1点损伤。如果掷出的属性没有损伤或受到严重创伤,则恢复无效。
### 检定与进度
玩家可以通过检定来尝试交互。每条交互需要三种属性:
- 主属性:玩家需要使用什么属性来进行此交互。
- 难度:玩家需要积累多少成功进度来成功完成此交互。
- 机会:玩家积累多少失败进度后交互失败,且玩家不可再尝试。
如:`力量3/1`(力量,难度3,机会1)表示玩家使用力量属性检定,3成功进度后交互成功完成,1失败进度后交互失败且不可再尝试。
每次进行检定时,掷D20检定骰,以及D4进度骰。
若检定骰小于等于玩家当前主属性,则获得进度骰数量成功进度,否则获得对应数量失败进度。
根据交互在叙事上是否可以拆分步骤来设计:
- 若交互可以取得阶段性进展,比如开门、解谜,则可以设计较大数字的难度,否则使用`1`。
- 若交互失败可能连续发生,比如连续躲避陷阱失败可能连续受到伤害,则可以设计较大数字的机会,否则使用`1`。
检定的难度/机会不会告知玩家。若没有触发成功也没有触发失败,则检定后仅告知当前成功/失败进度。
### 战斗
有可能与玩家发生战斗的NPC或生物,需要为敌人准备以下设计:
- 耐受:玩家使用武器攻击后,会扣减敌人的耐受。范围大致在6-12之间。扣光之后敌人会被击倒。耐受当前值玩家不可知。
- 武器:敌人攻击玩家时,使用的骰子公式。在d6到d12之间。
- 战术:与玩家战斗时,可能会使用的特殊战术或技能。
如:`巨狼(10),d8撕咬,受伤后会嚎叫呼唤同伴`
### 后果
玩家状态
- 属性损伤:属性类型+当前值
- 严重创伤:属性类型
- 标签:类型+持续事件,用于疾病、祝福、中毒等
- 资源:类型+数量,用于物品、金钱等
世界状态
- 势力计划进度:id+成功进度
- 事件发展进度:id+阶段
+44
View File
@@ -0,0 +1,44 @@
# 主题
为冒险设计主题。主题描述了冒险故事的主要目标与困难。
- 保护商队/调查神秘事件/杀死怪物/寻找宝藏/窃取重要物品/抵达地点/逃离地点
从主题出发,为冒险设计以下遭遇:
- 1条最终决战
- 2条预兆
- 2条铺垫
- 2条转折
每个遭遇都应该发生在一个独立的地点。
在设定主题遭遇时,不要具体设定遭遇的NPC、物品、现象,或者玩家可以如何与其交互。仅仅粗略地描述场景即可。
比如:“一位衣着精致的骑士正趴在一口枯井旁哭泣”。不用具体指定骑士的身份,也不用描述其使用的武器或防具。
## 最终决战
在最终决战中,玩家必须面对主要困难,若是成功则可以完成主要目标。
## 预兆
在预兆遭遇中,玩家会遭遇一些看似与主要目标无关的场景或事件,主要是营造氛围。
进行调查会揭露关于任务的边缘信息,主要是任务事件对环境或者普通人的影响。
玩家与预兆的交互不会直接影响主要目标。
## 铺垫
在铺垫遭遇中,将冒险的主要困难,以及相关的NPC、物品、现象介绍给玩家。
可能会立即向玩家施加挑战。赢得铺垫遭遇将提升玩家完成主要目标的机会。
## 转折
在转折遭遇中,玩家与重要NPC、物品、现象的关系将发生改变。
NPC立场可能发生逆转、NPC能力发生变化,或者某种现象开始发生。
转折遭遇要么打破玩家预期,要么顺着玩家的预期展开但是冲突变得更激烈。
+75
View File
@@ -0,0 +1,75 @@
# Yarn Spinner 编写
Yarn Spinner是一种文字冒险文本格式,可用于描述ttrpg冒险。
不要使用`markdown`格式语法,如标题,加粗等。冒号也有特殊含义(代表台词)。只使用纯文本描述冒险内容。
## 基本结构
每个`地点`遭遇使用一个`节点`来描述。然后,为每个遭遇里的每个`交互`创建一个`节点`。
```yarn-spinner
title: 节点名称
---
描述文本
角色: 角色台词
-> 选项描述1
<<命令1>>
-> 选项描述2
<<命令2>>
===
```
对于`随机`遭遇而言,使用多个`title`相同的节点。
使用`when: `来为节点触发添加条件。
如果没有条件,则使用`when: always`。
若需要节点只触发一次,可以结合`变量`。
```yarn-spinner
title: 随机遭遇
when: $villager_encountered == false
---
村民: 森林里有一只大狼!
<<set $villager_encountered to true>>
===
```
## 跳转
`<<jump 节点名称>>`:跳转节点
`<<detour 节点名称>>`:跳转节点,并在节点执行结束后返回
`<<return>>`:返回`detour`跳转来源节点
玩家在地点之间移动时,根据路径内容,插入`<<detour 随机遭遇>>`来触发随机遭遇。
随机遭遇可以使用`<<return>>`来返回主线。
在玩家可以反复尝试的遭遇中,在结尾添加`<<jump 节点名称>>```,跳转到自身的开头。
## 命令和函数
使用命令读写时间。不要创建变量。
- `<<add_hour>>``<<add_minute>>``<<add_day>>`:触发时间流逝。
- `$time_day``$time_hour``$time_minute`:读取24小时制当前时间。
- `$hour_total`:总共流逝的时间。
使用以下命令操作角色属性:
- ```<<damage str 4>>```:造成角色属性损伤。
- ```<<heal str 4>>```:解除属性创伤,并恢复角色属性损伤。
- ```<<set $result to check("nodeId:checkId", "wis")>> ```:检定角色属性。成功返回正值代表成功进度,失败返回负值代表失败进度。
- ```<<if $result >= 3>>```:处理检定成功进度。
- ```<<if $result <= -2>>```:处理检定失败进度。
- ```<<if $result > 0>>```:如果既没有达成成功进度也没有达成失败进度,暗示玩家应该继续尝试。
- 检定后总是在文本中描述玩家当前的进度(但不要描述难度和机会)。使用`{$result}`来显示进度。
直接使用变量来记录玩家物品。没有背包限制。
- ```<<set $item_light_armor to $item_light_armor + 1>>```:添加物品。
- ```<<if $item_gold >= 100)>>```:检查物品。
直接使用变量来记录势力计划、地点、事件的进度。
## 文件分割
按照遭遇拆分文件,每个遭遇都是一个文件。
只要使用匹配的遭遇title就可以跳转到其他文件内的遭遇。
+34
View File
@@ -33,6 +33,40 @@ 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#啊什么]的啦
### 信息框
> [!NOTE]
> Highlights information that users should take into account, even when skimming.
> [!TIP]
> Optional information to help a user be more successful.
> [!IMPORTANT]
> Crucial information necessary for users to succeed.
> [!WARNING]
> Critical content demanding immediate user attention due to potential risks.
> [!CAUTION]
> Negative potential consequences of an action.
:md-embed[./style-test.md]
+2
View File
@@ -0,0 +1,2 @@
name1,name2,num1,num2
balhe,bler,1,2
1 name1 name2 num1 num2
2 balhe bler 1 2
+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
:::
+1
View File
@@ -0,0 +1 @@
:md-yarn-spinner[./adventures/test.yarn]
+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.
+265
View File
@@ -0,0 +1,265 @@
# CLI 使用说明
TTTK 提供一个 CLI 工具,用于预览和校验目录内的 TTRPG 文档。
> 将内容编译为 HTML 的 `compile` 命令尚未实现(见下方说明)。
## 安装
```bash
# 无需安装,直接运行
npx tttk serve ./content
# 或全局安装
npm install -g tttk
```
安装完成后,可在任意目录使用 `tttk` 命令。
## 命令
CLI 使用子命令组织:
### serve - 预览模式
运行一个 Web 服务器预览目录中的内容,并实时监听文件更新。
```bash
tttk serve [dir] -p 3000
```
**参数:**
| 参数 | 说明 | 默认值 |
|------|------|--------|
| `[dir]` | 要预览的目录 | `.` (当前目录) |
**选项:**
| 选项 | 说明 | 默认值 |
|------|------|--------|
| `-p, --port <port>` | 端口号 | `3000` |
| `--host <host>` | 主机地址 | `0.0.0.0` |
**功能:**
- 扫描目录下的 `.md`、`.csv`、`.yarn`、`.svg` 文件(跳过以 `.` 开头的文件和 `node_modules`)
- 把内容目录挂在 URL 根路径下,文档 `rules/combat.md` 对应路由 `/rules/combat`
- 监听文件变化并重建内存中的内容注册表
- 提供 `/__CONTENT_INDEX.json`、`/__CONTENT_REGISTRY.json`、`/__COMPLETIONS.json` 等接口(见[开发服务器特性](#开发服务器特性))
**示例:**
```bash
# 预览当前目录
tttk serve
# 预览指定目录
tttk serve ./my-ttrpg-content
# 指定端口
tttk serve ./docs -p 8080
```
### check - 校验模式
静态校验目录内的 tttk 文档,输出结构化诊断(文件、行号、代码),存在错误时以退出码 `1` 结束。
```bash
tttk check [dir] --json
```
`lint` 是 `check` 的别名。
**参数:**
| 参数 | 说明 | 默认值 |
|------|------|--------|
| `[dir]` | 要校验的目录 | `.` (当前目录) |
**选项:**
| 选项 | 说明 | 默认值 |
|------|------|--------|
| `--json` | 以 JSON 输出诊断 | `false` |
| `--strict` | 将警告视为错误 | `false` |
| `--quiet` | 只报告错误 | `false` |
**退出码:**
| 代码 | 含义 |
|------|------|
| `0` | 无错误(`--strict` 下存在警告也会失败) |
| `1` | 存在错误(或 `--strict` 下存在警告) |
| `2` | 用法 / IO 错误(例如目录不存在) |
**输出示例:**
```
content/adventures/hollow-mill/adventure.md
12:1 error TTTK-C001 :md-table reference "./npcs.csv" does not resolve
hint: expected a file relative to this document, or an inline id defined in it
40:8 warning TTTK-R002 spark-table first header "Name" is not a dice formula
2 errors, 1 warning
12 files checked
```
**诊断代码:**
| 代码 | 级别 | 说明 |
|------|------|------|
| `TTTK-S001` | error | Markdown 解析失败 |
| `TTTK-S002` | error | 容器指令 `:::` 未闭合 |
| `TTTK-D001` | error | 未知的 `:md-*` 元素名 |
| `TTTK-D002` | error | 属性块畸形(例如未加引号的值中含空格) |
| `TTTK-R001` | error | 未知的代码块 `role=`(块内容会被静默丢弃) |
| `TTTK-R002` | warning | `role=spark-table` 首列表头不是骰点公式 |
| `TTTK-R003` | error | `role=declare` 解析失败(YAML 非法或结构不符) |
| `TTTK-R004` | warning | `role=declare` 块没有声明任何变量或标签修饰符 |
| `TTTK-R005` | error | `role=tag` 的 YAML 无法解析 |
| `TTTK-R006` | error | `role=tag` 缺少 `tag:` 或元素未知 |
| `TTTK-C001` | error | 内容引用无法解析(文件不存在,或内联 id 未定义) |
| `TTTK-C002` | warning | 引用解析到的文件类型不符合预期 |
| `TTTK-C003` | info | 同一文档中重复引用同一 CSV |
| `TTTK-C004` | error | `:md-embed` 循环引用 |
| `TTTK-L001` | error | 链接目标文件不存在 |
| `TTTK-L002` | warning | `#锚点` 未匹配到任何标题 |
| `TTTK-A001` | warning | 图标在 `assets/` 中不存在 |
| `TTTK-A002` | warning | 图片不存在 |
| `TTTK-V001` | error | CSV 解析失败(引号未闭合等) |
| `TTTK-V002` | warning | 行的列数与表头不一致 |
| `TTTK-V003` | warning | `label` 值重复 |
| `TTTK-V004` | warning | `body` 中的 `{{prop}}` 不是列名或 front-matter 键 |
| `TTTK-V005` | warning | spark-table 行的骰点值不是数字或范围 |
完整的语法目录(元素、属性、代码块角色)由 `src/cli/validate/known.ts` 生成到 `docs/reference.md`,可用 `npm run gen:reference` 重新生成。
**实现说明:**
- 校验复用与应用相同的 `scanDoc` 与内容注册表,因此通过校验意味着应用也能解析。
- 定位基于**原始文件**(行号准确),围栏代码块与行内代码中的示例语句会被跳过。
- 图标校验假设校验根目录之上不存在 `assets/` 目录。
**示例:**
```bash
# 校验当前目录
tttk check
# 校验指定目录并输出 JSON
tttk check ./content --json
# CI:只关注错误
npx tttk check ./content --quiet
```
### compile - 编译模式(未实现)
> **存根命令。** `compile` 目前不会产生任何输出,`--help` 中也已标明。
> 请使用 `tttk serve` 预览内容、`tttk check` 校验内容。
原计划的用法如下(仅供参考,尚未实现):
```bash
tttk compile [dir] -o ./dist/output
```
| 参数 | 说明 | 默认值 |
|------|------|--------|
| `[dir]` | 要编译的目录 | `.` (当前目录) |
| 选项 | 说明 | 默认值 |
|------|------|--------|
| `-o, --output <dir>` | 输出目录 | `./dist/output` |
## 输入文件
CLI 会搜索目录下的以下文件:
- `.md` - Markdown 文档
- `.csv` - 表格数据
- `.yarn` - Yarn Spinner 叙事文件
- `.svg` - 矢量图标
### 文件组织建议
```
my-ttrpg-content/
├── index.md # 首页
├── rules/
│ ├── index.md # 规则首页
│ ├── combat.md # 战斗规则
│ └── magic.md # 魔法系统
├── characters/
│ ├── index.md
│ └── npc-list.csv # NPC 列表
└── assets/
├── images/ # 图片资源
└── icons/ # 图标资源
```
### 相对路径引用
若 Markdown 文件通过相对路径引用了其他文件(如图片、CSV),引用会相对于当前文档所在目录解析:
```markdown
<!-- 引用同目录下的 CSV -->
:md-table[./sparks.csv]
<!-- 引用子目录的图片 -->
![地图](./maps/city-map.png)
<!-- 引用上级目录的文件 -->
:md-deck[../data/cards.csv]
```
使用 `tttk check` 可以校验这些引用是否都能解析。
## 开发服务器特性
### 内容接口
- `/__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 路由
路由基于 History API(而非 hash)。直接访问深层链接或刷新页面时,未知路径会回退到 `index.html`,
因此浏览器前进/后退与深链接均可正常工作。
## 常见问题
### 端口被占用
```bash
# 使用其他端口
tttk serve -p 8080
```
### check 报告了很多警告
先用 `--quiet` 只看错误,或在 CI 中配合 `--strict`:
```bash
tttk check ./content --quiet
tttk check ./content --json
```
警告通常确有其事(例如锚点或图标不存在),建议逐条确认。
### compile 没有输出
`compile` 目前是未实现的存根命令,不会产生任何输出。请使用 `tttk serve` 预览,使用 `tttk check` 校验。
+432
View File
@@ -0,0 +1,432 @@
# CSV 编写说明
TTRPG Tools 使用带有 YAML Front Matter 的 CSV 格式来定义卡牌/表格数据。这种格式支持在 CSV 文件头部定义元数据、字段配置和共享属性。
## 文件结构
```csv
---
# YAML Front Matter(可选)
fields:
- name: 字段名
description: 字段描述
deck:
size: 54x86
grid: 5x8
shared_prop: 共享属性值
---
# CSV 数据
label,body,field1,field2
1,内容,值 1,值 2
```
---
## YAML Front Matter
Front Matter 是可选的,位于文件开头,使用 `---` 包裹的 YAML 格式内容。
### 基本结构
```yaml
---
fields:
- name: name
description: 卡牌名称
- name: effect
description: 效果描述
examples: ["造成 5d6 伤害", "恢复 2d4 生命值"]
deck:
size: 63x88
grid: 5x5
bleed: 1
padding: 2
custom_prop: 自定义属性
---
```
### Front Matter 属性
#### `fields` - 字段定义
定义 CSV 中各列的含义和配置。
```yaml
fields:
- name: name # 字段名称(英文,用于 CSV 列名)
description: 卡牌名称 # 字段描述
examples: # 示例值列表(可选)
- 火球术卷轴
- 治疗药水
dataType: word # 数据类型(可选):word/paragraph/number/symbol/symbol_list
function: identify # 字段功能(可选):identify/compare/flavor/rule
```
**数据类型说明:**
| 类型 | 说明 | 示例 |
|------|------|------|
| `word` | 词语 - 短文本 | "火球术"、"战士"、"传说" |
| `paragraph` | 文本段落 - 长描述 | "造成 5d6 点火焰伤害,范围内的所有生物进行敏捷豁免" |
| `number` | 数字 - 可比较的数值 | 3、5、10 |
| `symbol` | 单一符号 - 图标/标记 | 🔥、⚔️、🛡️ |
| `symbol_list` | 符号列表 - 多个图标/标记 | 🔥🔥🔥、⚔️🛡️💫 |
**字段功能说明:**
| 功能 | 说明 | 示例 |
|------|------|------|
| `identify` | 辨识 - 用于识别卡牌身份 | 名称、编号、唯一标识 |
| `compare` | 比较 - 用于游戏机制中的数值比较 | 费用、攻击力、防御力 |
| `flavor` | 游戏风味描述 - 提供背景故事/氛围 | 背景故事、引言、lore |
| `rule` | 规则确认 - 明确游戏规则相关的信息 | 效果文本、触发条件、限制 |
#### `deck` - 卡牌显示配置
用于 `:md-deck` 组件的默认配置。
```yaml
deck:
size: 63x88 # 卡牌尺寸(mm),格式 "宽 x 高"
grid: 5x5 # 网格布局,格式 "列 x 行"
bleed: 1 # 出血边距(mm)
padding: 2 # 内边距(mm)
shape: rectangle # 卡牌形状:rectangle/circle/hex/diamond
layers: "name:1-2,14" # 图层配置(可选)
back_layers: "" # 背面图层配置(可选)
```
图层配置语法遵循`layer:1,1-5,8f8s`格式:
- `layer`:图层名称,多个图层用空格分隔
- `1,1-5,8`:网格范围,覆盖卡牌排版网格中的指定区域。
- `f8`:字体大小,使用8mm字体。可选。
- `s`:图层朝向,有nwse东西南北四种方向,描述文字上侧的朝向,默认为n北方。
#### 自定义属性
可以在 Front Matter 中定义任意自定义属性,这些属性会被自动应用到所有卡牌行。
```yaml
---
game_system: 龙与地下城 5e
author: DM_Name
version: 1.0
---
```
---
## CSV 数据部分
### 基本格式
```csv
label,body,field1,field2
1,内容,值 1,值 2
```
### 必需列
#### `label`
每行的唯一标识,用于查找和修改卡牌。
- 可以是数字:`1`, `2`, `3`
- 可以是文本:`card_001`, `npc_villager`
- 支持分组前缀:`forest_1`, `dungeon_1`
#### `body`
卡牌的主要内容,支持 Markdown 格式。
```csv
label,body
1,"### 标题
**加粗文本**
- 列表项 1
- 列表项 2"
```
### 可选列
除 `label` 和 `body` 外,可以定义任意自定义列,列名在 Front Matter 的 `fields` 中定义。
```csv
label,body,name,type,cost,effect
1,完整内容,火球术,法术,3,造成 5d6 火焰伤害
```
---
## 变量语法
在 `body` 列或 Front Matter 中,可以使用 `{{prop}}` 语法引用其他列的值或 Front Matter 中的属性。
### 引用同行其他列
```csv
label,body,adj,noun
1,"**{{adj}}** 的{{noun}}",高大,战士
2,"{{adj}}的{{noun}}",矮小,法师
```
渲染后:
- 第 1 行:**高大** 的战士
- 第 2 行:矮小的法师
### 引用 Front Matter 属性
```yaml
---
game_system: D&D 5e
author: DM_Name
---
```
```csv
label,body
1,"这是一个{{game_system}}冒险,由{{author}}创建"
```
渲染后:这是一个 D&D 5e 冒险,由 DM_Name 创建
### 随机引用(remix 模式)
在 `:md-table` 组件中,可以使用 `remix` 属性启用随机引用:
```markdown
:md-table[./data.csv]{remix=true}
```
此时 `{{prop}}` 会从所有行中随机选择一行的值,而不是使用当前行的值。
---
## 分组支持
可以使用额外的列来对数据进行分组。
```csv
group,label,body
森林小径,1,狼群袭击
森林小径,2,荆棘陷阱
森林小径,3,迷路
塔楼楼梯,1,台阶坍塌
塔楼楼梯,2,落石
```
在 `:md-table` 组件中,可以使用 `group` 属性来筛选显示特定组的数据:
```markdown
:md-table[./encounters.csv]{group="森林小径"}
```
---
## 特殊字符处理
### 包含逗号的值
使用双引号包裹:
```csv
label,body
1,"Hello, world"
```
### 包含双引号的值
使用两个双引号转义:
```csv
label,body
1,"他说:""你好"""
```
### 包含换行的值
使用双引号包裹,直接写入换行:
```csv
label,body
1,"### 标题
这是第一段
这是第二段"
```
### 包含 `#` 注释
CSV 解析器会将 `#` 开头的行视为注释,但如果 `#` 在双引号内则正常处理:
```csv
label,body
1,"# 这不是注释"
# 这是真正的注释
```
---
## 完整示例
### 示例 1:魔法物品卡牌组
```csv
---
fields:
- name: name
description: 物品名称
dataType: word
function: identify
- name: type
description: 物品类型
dataType: word
function: identify
examples: [武器,防具,饰品,消耗品]
- name: rarity
description: 稀有度
dataType: word
function: compare
examples: [普通,稀有,史诗,传说]
- name: cost
description: 价格
dataType: number
function: compare
- name: effect
description: 效果描述
dataType: paragraph
function: rule
deck:
size: 63x88
grid: 5x5
shape: rectangle
game_system: D&D 5e
---
label,body,name,type,rarity,cost,effect
1,"### 火球术卷轴
一张泛黄的羊皮纸,上面绘有火焰符文。",火球术卷轴,消耗品,稀有,150,"投掷后对 20 尺内所有生物造成 8d6 火焰伤害(敏捷豁免减半)"
2,"### 治疗药水
红色液体在玻璃瓶中翻滚,散发着温暖的光芒。",治疗药水,消耗品,普通,50,"饮用后恢复 2d4+2 点生命值"
3,"### +1 长剑
剑刃闪烁着微光,握柄包裹着精致的皮革。",+1 长剑,武器,稀有,300,"攻击和伤害检定 +1"
```
### 示例 2:随机遭遇表
```csv
---
fields:
- name: group
description: 遭遇地点分组
- name: title
description: 遭遇标题
- name: type
description: 遭遇类型
deck:
size: 54x86
grid: 4x6
---
group,label,body,title,type
森林小径,1,"### 狼群袭击
三只灰狼从灌木丛中扑出。
**交互**:战斗(3 只巨狼)或逃脱(敏捷检定 3/2)
**风险**:失败受到 d8 撕咬伤害",狼群袭击,战斗
森林小径,2,"### 荆棘陷阱
带刺藤蔓缠住你的双腿。
**交互**:挣脱(力量检定 3/2)
**风险**:失败受到 1 点体质损伤",荆棘陷阱,陷阱
森林小径,3,"### 迷路
浓雾弥漫,你失去了方向。
**交互**:寻找路径(感知检定 3/2)
**风险**:失败受到 1 点感知损伤",迷路,事件
塔楼楼梯,1,"### 台阶坍塌
脚下的石阶突然碎裂。
**交互**:保持平衡(敏捷检定 3/1)
**风险**:失败受到 1 点敏捷损伤",台阶坍塌,陷阱
```
### 示例 3:NPC 名录
```csv
---
fields:
- name: name
description: NPC 名称
- name: role
description: 角色定位
- name: location
description: 出现地点
deck:
size: 54x86
grid: 4x6
---
label,body,name,role,location
1,"### 老妇人玛拉
**类型**:信息提供者/商人
**位置**:村庄入口
**出售**:草药、治疗药水
**情报**:知道关于森林的警告",老妇人玛拉,商人,村庄入口
2,"### 护卫队长塞里克
**类型**:任务发布者
**位置**:酒馆二楼
**任务**:调查废弃庄园
**秘密**:知道血契的真相",塞里克,任务发布者,酒馆
```
---
## 与组件集成
### `:md-deck` 组件
```markdown
:md-deck[./magic-items.csv]{size="63x88" grid="5x5" roll=true}
```
- `roll=true`:添加随机抽卡按钮
### `:md-table` 组件
```markdown
:md-table[./encounters.csv]{group="森林小径" remix=true}
```
- `group`:筛选特定组的数据
- `remix=true`:启用随机引用模式
---
## 最佳实践
1. **使用有意义的 label**:便于后续查找和修改
2. **在 Front Matter 中定义所有字段**:便于工具理解和处理
3. **使用 `{{prop}}` 变量**:减少重复内容,支持动态组合
4. **合理使用分组**:便于筛选和管理大量数据
5. **保持 body 的 Markdown 格式一致**:确保渲染效果统一
6. **为字段添加 `examples`**:帮助 AI 工具生成内容时参考
+338
View File
@@ -0,0 +1,338 @@
# 开发和项目说明
## 快速开始
```bash
# 安装依赖
npm install
# 开发模式(同时启动 CLI 内容服务器 :3000 与 Web 前端 :5173)
npm run dev
# 也可分别启动
npm run dev:cli
npm run dev:web
# 构建(Web)
npm run build
# 预览构建结果
npm run preview
# CLI 开发模式
npm run cli:dev
# CLI 构建
npm run cli:build
# 运行测试
npm test
```
## 项目结构
```
tttk/
├── src/
│ ├── cli/ # CLI 工具源码
│ │ ├── commands/ # 命令实现 (serve, check, compile)
│ │ ├── validate/ # tttk check 校验器(扫描、规则、诊断)
│ │ ├── content-registry.ts # 内容注册表(CLI / 前端共享)
│ │ ├── content-index.ts # 目录文件递归读取
│ │ ├── index.ts # CLI 入口
│ │ └── types.ts # 类型定义
│ ├── components/ # TTRPG 组件
│ │ ├── md-dice.tsx # 骰子组件
│ │ ├── md-table.tsx # 表格组件
│ │ ├── md-deck/ # 卡牌组件
│ │ ├── md-pins.tsx # 标记组件
│ │ ├── md-yarn-spinner.tsx # 叙事线组件
│ │ ├── Article.tsx # 文章组件
│ │ ├── Sidebar.tsx # 侧边栏
│ │ ├── FileTree.tsx # 文件树
│ │ └── utils/ # 工具函数
│ ├── markdown/ # Markdown 解析器
│ │ ├── index.ts # marked 配置
│ │ └── mermaid.ts # mermaid 支持
│ ├── data-loader/ # 数据加载器
│ ├── plotcutter/ # 剧情切割工具
│ ├── yarn-spinner/ # 叙事线解析
│ ├── App.tsx # 主应用
│ ├── main.tsx # 入口文件
│ ├── styles.css # 样式
│ ├── index.html # HTML 模板
│ └── global.d.ts # 类型声明
├── .agents/
│ └── skills/
│ └── tttk-authoring/ # 随项目维护的 agent skill(见下文)
├── bin/
│ └── cli/ # CLI 二进制文件
├── content/ # 示例内容
├── docs/ # 文档
├── package.json
├── tsconfig.json
├── tsconfig.cli.json
├── rsbuild.config.ts
└── 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`)
为手写内容。
## 技术栈
| 类别 | 技术 |
|------|------|
| 前端框架 | Solid.js 1.9+ |
| 构建工具 | Rsbuild |
| 样式 | Tailwind CSS 4 + @tailwindcss/typography |
| Markdown | marked + marked-directive + marked-alert + marked-gfm-heading-id |
| 图表 | mermaid |
| 测试 | Jest |
| CLI | commander + chokidar |
## 开发规范
### Solid.js 最佳实践
- **优先使用 `createEffect` 和 `createMemo`** 实现响应式
- 在 JSX 中直接引用 `props` 保持响应式
- 避免滥用 `onMount` + `createSignal`
```tsx
// ✅ 推荐
const doubled = createMemo(() => count() * 2);
createEffect(() => console.log(count()));
// ❌ 避免
onMount(() => {
const [value, setValue] = createSignal(0);
});
```
### 组件开发
- **不使用 Shadow DOM**:使用 `noShadowDOM()`
- **继承 Tailwind CSS 样式系统**
- 使用 `customElement` 注册自定义元素
```tsx
import { customElement, noShadowDOM } from "solid-element";
customElement("md-dice", { key: "" }, (props, { element }) => {
noShadowDOM();
// 组件逻辑
});
```
### 类型检查
开发完成后检查类型错误:
```bash
npx tsc --noEmit
```
### 代码风格
- 使用 TypeScript 严格模式
- 遵循 ESLint 配置(如有)
- 使用 2 空格缩进
## 添加新组件
### 1. 创建组件文件
```tsx
// src/components/md-new-component.tsx
import { customElement, noShadowDOM } from "solid-element";
import { createSignal } from "solid-js";
export interface NewComponentProps {
prop1?: string;
}
customElement("md-new-component", { prop1: "" }, (props, { element }) => {
noShadowDOM();
const [state, setState] = createSignal("");
return (
<div class="new-component">
{/* 组件内容 */}
</div>
);
});
```
### 2. 注册组件
```tsx
// src/components/index.ts
import './md-new-component';
```
### 3. 导出类型(可选)
```tsx
// src/components/index.ts
export type { NewComponentProps } from './md-new-component';
```
## 构建配置
### Rsbuild 配置
```ts
// rsbuild.config.ts
import { defineConfig } from '@rsbuild/core';
import { pluginBabel } from '@rsbuild/plugin-babel';
import { pluginSolid } from '@rsbuild/plugin-solid';
import tailwindcss from '@tailwindcss/postcss';
export default defineConfig({
plugins: [pluginBabel(), pluginSolid()],
tools: {
postcss: {
postcssOptions: {
plugins: [tailwindcss()],
},
},
},
html: {
template: './src/index.html',
},
});
```
### TypeScript 配置
- `tsconfig.json` - 主项目配置(Web)
- `tsconfig.cli.json` - CLI 工具配置(Node.js)
## 测试
```bash
# 运行所有测试
npm test
# 运行特定测试文件
npm test -- src/components/md-dice.test.ts
# 监听模式
npm test -- --watch
```
## 调试
开发环境由两个进程组成,必须使用不同端口:
- Web 前端:rsbuild 开发服务器,端口 **5173**
- CLI 内容服务器:`tttk serve`,端口 **3000**(内容 API + Journal)
rsbuild 会把 `/__CONTENT_INDEX.json`、`/__COMPLETIONS.json`、`/__CONTENT_REGISTRY.json`、`/.ttrpg`
以及内容目录中的静态资源(图片、CSS、CSV 等)代理到 CLI 的 3000 端口,因此必须先启动 CLI,
否则前端拿不到内容。
CLI 把内容目录挂在 URL 根路径下(例如 `/adventures/map.png`,**不是** `/content/...`),
所以 rsbuild 会按“请求路径是否对应内容目录中的真实文件”来决定是否代理;`/static/` 下的
前端构建产物仍由 web dev server 自己提供。
### Web 开发
`npm run dev` 会同时启动两个进程;也可以分开运行:
```bash
# 同时启动 CLI(3000)与 Web(5173)
npm run dev
# 或分别启动
npm run dev:cli # CLI 内容服务器,占用 3000
npm run dev:web # Web 前端,占用 5173
# 访问 http://localhost:5173
```
### CLI 调试
CLI 通过 ts-node 运行,加上 `--inspect` 与 `--enable-source-maps` 后即可在编辑器中附加断点。
如需调试启动/扫描逻辑,用 `--inspect-brk` 在用户代码执行前暂停:
```bash
node --inspect-brk --enable-source-maps --loader ts-node/esm ./src/cli/index.ts serve ./content
# 或使用脚本(不带 --inspect-brk)
npm run tttk:inspect -- serve ./content
```
然后在 Zed / VS Code 中附加到 `localhost:9229`,或打开 `chrome://inspect`。
CLI 只监听内容目录(`.md`/`.csv`/`.yarn`/`.svg`)并重建内存中的注册表,**不会**热重载自身源码;
修改 `src/cli/**` 后需要重启进程。
```bash
# 或构建后运行
npm run cli:build
node dist/cli/index.js serve ./content
```
## 发布流程
1. 更新版本号
2. 运行测试和类型检查
3. 构建 Web 和 CLI
4. 发布到 npm
```bash
npm run build
npm run cli:build
npm test
npx tsc --noEmit
npm publish
```
## 常见问题
### Windows 换行符
开发环境主要在 Windows 上,注意使用 CRLF 换行符。
### 依赖安装失败
```bash
# 清理缓存
npm cache clean --force
rm -rf node_modules package-lock.json
npm install
```
### 构建错误
```bash
# 检查类型
npx tsc --noEmit
# 重新构建
npm run build
```
+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.
+663
View File
@@ -0,0 +1,663 @@
# Markdown 编写说明
本文档介绍 TTRPG Tools 支持的 Markdown 扩展语法和组件用法。
## 基础语法
使用标准 Markdown 语法,支持以下扩展:
- [GFM](https://github.github.com/gfm/) - GitHub Flavored Markdown
- [marked-alert](https://github.com/Insidify/marked-alert) - 警告/提示块
- [marked-gfm-heading-id](https://github.com/markedjs/marked-gfm-heading-id) - 标题 ID
- [marked-directive](https://github.com/fengzilong/marked-directive) - 指令语法
### 代码块围栏
代码块围栏可使用反引号或波浪号(`~~~`)书写;闭合围栏必须与开启围栏使用相同字符,且不短于开启围栏。声明 `role=` 的代码块同理。
## 指令语法
通过 `marked-directive` 支持自定义组件插入:
```markdown
:component-name[content]{key="value" another="test"}
```
### 语法说明
| 部分 | 说明 | 示例 |
|------|------|------|
| `:name` | 组件名称 | `:dice` |
| `[content]` | 内容参数 | `[2d6+d8]` |
| `{attrs}` | 属性对象 | `{key="attack"}` |
支持多个嵌套指令:
```markdown
::: container
这里是容器内容
:dice[1d20]
:::
```
## 分栏布局
使用简单的分隔符语法创建多栏布局:
```markdown
|---
左侧栏内容
-|-
右侧栏内容
---
```
**语法说明:**
| 分隔符 | 说明 |
|--------|------|
| `\|---` | 开始分栏容器 |
| `-|-` | 分隔各栏 |
| `---` | 结束分栏容器 |
**多栏示例:**
```markdown
|---
第一栏
-|-
第二栏
-|--
第三栏(更宽)
---
```
`-|--` 中的额外 `-` 会添加 `-N` 后缀到 CSS 类名(如 `col-2`),可用于自定义宽度。
## 图标语法
使用简单的 `:[icon-name]` 语法插入图标:
```markdown
:[attack] :[defense] :[potion] :[sword]
```
图标会渲染为 `<icon class="icon-attack"></icon>` 形式。
### 支持多种图片格式
默认情况下,`:[icon-name]` 会查找 `.png` 文件。你也可以显式指定其他支持的格式:
```markdown
:[attack] <!-- 查找 attack.png -->
:[attack.svg] <!-- 查找 attack.svg -->
:[shield.gif] <!-- 查找 shield.gif -->
:[photo.jpg] <!-- 查找 photo.jpg -->
:[icon.webp] <!-- 查找 icon.webp -->
```
**支持的扩展名:** `.svg`、`.png`、`.gif`、`.jpg`、`.jpeg`、`.webp`
**注意:** CSS class 名称始终使用不带扩展名的图标名称(如 `icon-attack`),方便统一样式控制。
### 图标查找路径
图标没有单独的配置项。`:[name]` 会从当前文档所在目录开始,逐级向上查找 `assets/` 目录,
直到内容目录根:
```text
rules/combat.md 中的 :[attack]
→ rules/assets/attack.png
→ assets/attack.png
```
因此把图标放在内容目录(或其子目录)的 `assets/` 下即可。`tttk check` 会用同样的规则
校验图标是否存在(诊断 `TTTK-A001`)。
## 组件库
### 🎲 骰子组件 (md-dice)
```markdown
:md-dice[2d6+d8]
:md-dice[1d20+5]{key="attack"}
```
**功能:**
- 点击文本执行掷骰
- 点击骰子图标重置为公式
- `key` 属性将结果记录到 URL 参数中 (`?dice-attack=15`)
- 骰子数量可省略,`d20` 等价于 `1d20`
**属性:**
| 属性 | 类型 | 说明 |
|------|------|------|
| `key` | string | 用于 URL 参数记录的标识 |
**示例:**
```markdown
攻击检定 :md-dice[1d20+5]{key="attack"}
伤害掷骰 :md-dice[2d6+3]{key="damage"}
```
### 🔗 链接组件 (md-link)
```markdown
:md-link[./other-page.md]
:md-link[./rules.md#combat-section]
```
**功能:**
- 点击链接在当前页面内展开显示目标文章内容
- 支持 `#section` 语法显示特定章节
- 支持相对路径引用
**属性:**
| 属性 | 类型 | 说明 |
|------|------|------|
| (无) | - | 内容即为链接目标路径 |
**示例:**
```markdown
查看完整规则 :md-link[./rules.md]
查看战斗章节 :md-link[./rules.md#combat]
```
### 🖼️ 背景组件 (md-bg)
```markdown
:md-bg[./images/background.jpg]
:md-bg[./images/pattern.png]{fit="contain"}
:md-bg[#ff00dd]
:md-bg[rgb(100,200,50)]
```
**功能:**
- 将图片设置为当前文章卡片的背景
- 支持纯色背景(CSS 颜色值)
- 自动覆盖整个卡片区域
**属性:**
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `fit` | `cover` \| `contain` \| `fill` \| `none` \| `scale-down` | `cover` | 背景适配方式(仅图片时生效) |
**示例:**
```markdown
:md-bg[./images/dungeon-bg.jpg]{fit="cover"}
# 地牢探险
这里是地牢的描述内容...
: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)
```markdown
:md-pins[./images/map.png]{pins="A:30,40 B:10,30" fixed}
:md-pins[./images/city-map.png]
:md-pins[./images/dungeon.png]{labelStart="1"}
:md-pins[./images/quest.png]{labelStart="C"}
```
**功能:**
- 在图片上显示标记点(A-Z, AA-ZZ, AAA-ZZZ... 或数字)
- `fixed` 模式:仅显示标记,不可编辑
- 非 fixed 模式:点击图片添加标记,点击标记删除
- 提供复制按钮生成标记代码
- 支持自定义标签起始值(字母或数字)
**属性:**
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `pins` | string | `""` | 标记列表,格式 `"A:30,40 B:10,30"` (标签:x,y) |
| `fixed` | boolean | `false` | 是否为固定模式(只读) |
| `labelStart` | string | `"A"` | 标签起始值,支持字母(如 `"A"`, `"C"`)或数字(如 `"1"`, `"7"`) |
**标签生成规则:**
| 起始值类型 | 说明 | 示例 |
|------|------|------|
| 字母(默认) | 从起始字母开始,按 A-Z, AA-ZZ, AAA-ZZZ 顺序 | `A` → A, B, C...; `C` → C, D, E... |
| 数字 | 从起始数字开始递增 | `1` → 1, 2, 3...; `7` → 7, 8, 9... |
**字母标签序列(从 A 开始):**
- 0-25: A-Z
- 26-51: AA-ZZ
- 52-77: AAA-ZZZ
**示例:**
```markdown
<!-- 固定标记模式 - 字母标签(默认从 A 开始) -->
:md-pins[./images/battle-map.png]{pins="A:25,50 B:75,30 C:50,80" fixed}
<!-- 可编辑模式 - 点击添加标记 - 从 A 开始 -->
:md-pins[./images/blank-map.png]
<!-- 数字标签 - 从 1 开始 -->
:md-pins[./images/dungeon.png]{labelStart="1"}
<!-- 数字标签 - 从 7 开始 -->
:md-pins[./images/cave.png]{labelStart="7"}
<!-- 字母标签 - 从 C 开始 -->
:md-pins[./images/quest-map.png]{labelStart="C"}
<!-- 自定义标签 + 预设标记 -->
:md-pins[./images/map.png]{pins="5:30,40 6:75,30" labelStart="5" fixed}
<!-- 复制生成的格式 -->
:md-pins[./images/map.png]{pins="A:30,40 B:10,30" fixed}
```
### 📊 表格组件 (md-table)
```markdown
:md-table[./data.csv]
:md-table[./data.csv]{roll=true}
:md-table[./data.csv]{roll=true remix=true}
```
**CSV 格式要求:**
```csv
label,body,group
第一页,这是**第一页**的内容,group1
第二页,这是*第二页*的内容,group1
第三页,这是第三页的内容,group2
```
- 使用英文逗号分隔
- 支持 `#` 开头的注释行
- 包含逗号或 `#` 的字段用双引号包裹
**特殊列:**
| 列名 | 说明 |
|------|------|
| `label` | 标签页名称 |
| `body` | 标签页内容(支持 Markdown) |
| `group` | 分组标识(可选) |
**属性:**
| 属性 | 类型 | 说明 |
|------|------|------|
| `roll` | boolean | 添加随机切换按钮 |
| `remix` | boolean | `{{prop}}` 引用随机行内容 |
**变量引用语法:**
在 `body` 列中可使用 `{{prop}}` 引用同行其他列:
```csv
label,name,description,body
战士,约翰,勇敢的战士,{{name}}是一位{{description}}。
```
**Front Matter 支持:**
CSV 可包含 YAML front matter,所有行会继承这些属性:
```csv
---
source: 玩家手册
version: 1.0
---
label,name,description
1,战士,近战专家
2,法师,奥术施法者
```
**示例:**
```markdown
<!-- 基础表格 -->
:md-table[./npcs.csv]
<!-- 带随机切换 -->
:md-table[./encounters.csv]{roll=true}
<!-- 随机引用变量 -->
:md-table[./quests.csv]{roll=true remix=true}
```
**内联表格(显式声明):**
Markdown 表格不会自动转换。如需内联表格,用代码块并声明 `role=spark-table`(首列需为骰子公式,如 `d6`):
````markdown
```markdown role=spark-table
| d6 | 结果 |
|----|------|
| 1 | 遭遇强盗 |
| 2 | 平安无事 |
```
````
扫描时转换为 CSV 并渲染为 `md-table` 组件。CSV 格式的内联表格用 `csv` 语言:
````markdown
```csv role=spark-table
d6,结果
1,遭遇强盗
2,平安无事
```
````
普通 Markdown 表格(无 role 声明)始终按标准 GFM 表格渲染,不做任何转换。
### 🃏 卡牌组件 (md-deck)
```markdown
:md-deck[./cards.csv]{grid="5x8" layers="title:1,1-5,1f8 body:1,5-5,8f3"}
```
**CSV 格式:**
```csv
label,title,body
1,卡牌名称,这张卡牌的**效果**描述
2,另一张牌,更多效果
```
**属性:**
| 属性 | 类型 | 说明 |
|------|------|------|
| `grid` | string | 卡牌布局(行 x 列) |
| `layers` | string | 图层定义 |
**图层语法:**
```
layers="字段:起始行,起始列 - 结束列,字体大小"
```
示例:`title:1,1-5,1f8` 表示 title 字段从第 1 行开始,占据 1-5 列,8mm字体。
**结构化配置(yaml 代码块):**
推荐使用 ```yaml role=tag 代码块(`yaml` 语言可被语法高亮),通过 `body` 指定 CSV 路径,`data-config` 提供结构化配置(图层可以是字段或模板):
````markdown
```yaml role=tag
tag: md-deck
body: ./cards.csv
data-config:
size: 63x88
grid: 5x5
layers:
- prop: title
pos: 1,1-5,1
font: 12
- template: |
**{{name}}** — {{type}}
{{description}}
pos: 1,3-5,8
font: 3
align: l
```
````
每个图层通过 `prop`(读取 CSV 字段)或 `template`(渲染带 `{{变量}}` 替换的 markdown 模板)定义,`pos` 为网格位置 `x1,y1-x2,y2`(1-based,与紧凑 layers 字符串同格式),支持 `font`、`orientation`、`align`(水平 `l`/`c`/`r`,可加垂直前缀 `t`/`b` 组成 `tl`/`tc`/`tr`/`bl`/`bc`/`br`,如 `align: tl` 表示左上对齐)。`back_layers` 用于背面图层。
### 🖨️ 打印版式组件 (md-print)
将 Markdown 文档排版为可打印的 A4 横版页面:每张 A4 放两个 A5 页面,每个 A5 页面再分成多列。
```markdown
:md-print[./rules.md]
:md-print[./rules.md]{columns="2" margin="10" gap="5" font="3"}
```
**功能:**
- 自动分页:内容按块级元素流入 A5 页面,段落、列表和表格可在分页处拆分
- 每张 A4 横版放两个 A5 页面,每个 A5 页面默认两列
- 预览中可直接打印或另存为 PDF
- 支持页眉标题、页码、导出时间戳和中缝裁切线
**属性:**
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `columns` | number | `2` | 每个 A5 页面的列数(1–4) |
| `margin` | number | `10` | 页面边距(毫米) |
| `gap` | number | `5` | 列间距(毫米) |
| `font` | number | `3` | 正文字号(毫米) |
| `title` | string | - | 页眉标题 |
| `pageNumbers` | boolean | `true` | 是否在页面外侧角落显示页码 |
| `timestamp` | boolean | `false` | 是否在页码旁边显示导出时间 |
| `cutMarks` | boolean | `true` | 是否显示 A4 中缝裁切线 |
**YAML 写法:**
```yaml
tag: md-print
body: ./rules.md
columns: 2
margin: 10
gap: 5
font: 3
title: 规则书
timestamp: true
```
**导出时间戳:**
```markdown
:md-print[./rules.md]{timestamp=true}
```
开启后页脚会显示类似 `3 / 12 · 2026-10-23 14:05` 的内容。页码位于页面外侧底角:左侧 A5 在左下角,右侧 A5 在右下角。
**打印设置:** 在浏览器打印对话框中选择 A4、横向、无边距、100% 缩放。
### 🧶 叙事线组件 (md-yarn-spinner)
用于展示分支叙事结构,支持 Yarn Spinner 格式文件。
```markdown
:md-yarn-spinner[./story.yarn]
```
## YAML 标签
使用 ```yaml role=tag 代码块创建自定义标签(`yaml` 语言可被语法高亮):
````markdown
```yaml role=tag
tag: tag-name
class: custom-class
id: my-id
body: 标签内容
```
````
`tag`、`id` 等简单属性也可以直接写在 fence 行上(fence 行属性优先于 YAML 内容):
````markdown
```yaml role=tag tag=tag-name id=my-id
class: custom-class
body: 标签内容
```
````
渲染为:
```html
<tag-name id="my-id" class="custom-class">标签内容</tag-name>
```
## Mermaid 图表
支持 mermaid 流程图:
````markdown
```mermaid
graph TD
A[开始] --> B{选择}
B -->|选项 1| C[结果 1]
B -->|选项 2| D[结果 2]
```
````
支持的图表类型:
- flowchart - 流程图
- sequenceDiagram - 时序图
- classDiagram - 类图
- stateDiagram - 状态图
- erDiagram - ER 图
- pie - 饼图
## 警告/提示块
```markdown
> [!NOTE]
> 这是一条备注
> [!TIP]
> 这是一条提示
> [!IMPORTANT]
> 这是重要信息
> [!WARNING]
> 这是警告信息
> [!CAUTION]
> 这是危险警告
```
## 文件引用
### 相对路径
```markdown
<!-- 引用同目录文件 -->
:md-table[./data.csv]
<!-- 引用子目录文件 -->
:md-deck[./cards/deck.csv]
<!-- 引用上级目录文件 -->
:md-table[../shared/npcs.csv]
<!-- 引用图片 -->
![地图](./images/map.png)
```
### 路径解析规则
- 所有路径相对于当前 Markdown 文件
- 支持 `.` 和 `..` 导航
- 自动处理 URL 编码
## 样式定制
### 使用 Tailwind 类
组件支持 Tailwind CSS 类:
```markdown
:md-dice[1d20]{class="text-red-500"}
```
### 自定义 CSS
```css
/* 在 styles.css 中添加 */
.md-dice {
@apply text-blue-600 hover:text-blue-800;
}
```
## 最佳实践
### 内容组织
```markdown
# 章节标题
这里是章节内容。
## 子章节
### 规则说明
:md-dice[2d6] 掷骰决定结果。
:md-table[./options.csv]{roll=true}
> [!TIP]
> 这是一个有用的提示。
```
### 性能优化
- 大型 CSV 文件使用 `group` 列分组
- 图片使用适当分辨率
- 避免过多嵌套组件
### 可访问性
- 为图片添加 `alt` 文本
- 使用语义化标题层级
- 确保颜色对比度
+546
View File
@@ -0,0 +1,546 @@
# MCP 服务器使用说明
TTRPG Tools 提供了一个 MCP (Model Context Protocol) 服务器,用于与 AI 助手集成,自动化生成和管理卡牌内容。
## 命令结构
```bash
ttrpg mcp [command]
```
### 子命令
#### 1. `mcp serve` - 启动 MCP 服务器
启动 MCP 服务器,供 AI 助手连接使用。
```bash
# 使用 stdio 传输(默认)
ttrpg mcp serve
# 指定传输方式
ttrpg mcp serve stdio
```
**MCP 客户端配置示例:**
```json
{
"mcpServers": {
"ttrpg-card-generator": {
"command": "node",
"args": ["path/to/ttrpg-tools/dist/cli/index.js", "mcp", "serve"]
}
}
}
```
#### 2. `mcp generate-card-deck` - 快速生成卡牌组
通过命令行快速生成卡牌组,无需 MCP 客户端。
```bash
ttrpg mcp generate-card-deck --name "卡牌名称" --output "./输出目录" [选项]
```
**选项:**
| 选项 | 说明 | 默认值 |
|------|------|--------|
| `--name <name>` | 卡牌组名称(必需) | - |
| `--output <dir>` | 输出目录(必需) | - |
| `-c, --count <number>` | 卡牌数量 | `10` |
| `--fields <fields>` | 字段列表(逗号分隔) | `name,type,cost,description` |
| `--description <desc>` | 卡牌组描述 | - |
| `--size <size>` | 卡牌尺寸 | `54x86` |
| `--grid <grid>` | 网格布局 | `5x8` |
**示例:**
```bash
# 生成基础魔法物品卡牌
ttrpg mcp generate-card-deck --name "魔法物品" --output "./content"
# 生成自定义 NPC 卡牌
ttrpg mcp generate-card-deck \
--name "NPC Cards" \
--output "./content/npcs" \
--count 20 \
--fields "name,class,level,background" \
--description "快速生成游戏中的 NPC 角色"
# 生成塔罗牌组
ttrpg mcp generate-card-deck \
--name "Tarot Deck" \
--output "./content/tarot" \
--count 22 \
--fields "name,number,meaning" \
--size "70x120" \
--grid "3x4"
```
## MCP 工具
通过 MCP 协议,AI 助手可以调用以下工具:
### 快捷工具
#### `generate_card_deck` - 一站式生成卡牌组
快速生成完整的卡牌组,包括 Markdown 文件、CSV 数据文件和组件配置。
| 参数 | 类型 | 必需 | 说明 |
|------|------|------|------|
| `deck_name` | string | ✓ | 卡牌组名称 |
| `output_dir` | string | ✓ | 输出目录路径 |
| `card_count` | number | ✗ | 卡牌数量(默认:10) |
| `card_template` | object | ✗ | 卡牌模板定义 |
| `deck_config` | object | ✗ | md-deck 组件配置 |
| `description` | string | ✗ | 卡牌组描述 |
### 核心工具
#### `deck_frontmatter_read` - 读取 CSV frontmatter
读取 CSV 文件的 frontmatter(包含模板定义和 deck 配置)。
| 参数 | 类型 | 必需 | 说明 |
|------|------|------|------|
| `csv_file` | string | ✓ | CSV 文件路径 |
**返回示例:**
```json
{
"fields": [
{ "name": "name", "description": "卡牌名称" },
{ "name": "type", "description": "卡牌类型" }
],
"deck": {
"size": "54x86",
"grid": "5x8"
}
}
```
#### `deck_frontmatter_write` - 写入 CSV frontmatter
写入或更新 CSV 文件的 frontmatter(模板定义和 deck 配置)。
| 参数 | 类型 | 必需 | 说明 |
|------|------|------|------|
| `csv_file` | string | ✓ | CSV 文件路径 |
| `frontmatter` | object | ✓ | 要写入的 frontmatter 数据 |
| `merge` | boolean | ✗ | 是否合并现有 frontmatter(默认 true) |
**frontmatter 结构:**
```json
{
"fields": [
{
"name": "字段名称(英文,用于 CSV 列名)",
"description": "字段描述",
"examples": ["示例值 1", "示例值 2"]
}
],
"deck": {
"size": "54x86",
"grid": "5x8",
"bleed": 1,
"padding": 2,
"shape": "rectangle",
"layers": "name:1,2-3,12",
"back_layers": "back:1,2-8,12"
}
}
```
#### `deck_card_crud` - 卡牌 CRUD 操作
卡牌的创建、读取、更新、删除操作,支持批量操作。
| 参数 | 类型 | 必需 | 说明 |
|------|------|------|------|
| `csv_file` | string | ✓ | CSV 文件路径 |
| `action` | string | ✓ | 操作类型:`create` \| `read` \| `update` \| `delete` |
| `cards` | object\|array | ✗ | 卡牌数据(单张或数组) |
| `label` | string\|array | ✗ | 要操作的卡牌 label(用于 read/update/delete) |
**卡牌数据结构:**
```json
{
"label": "1",
"name": "火球术卷轴",
"type": "法术",
"cost": "3",
"description": "造成 5d6 火焰伤害"
}
```
**使用示例:**
```json
// 创建单张卡牌
{
"csv_file": "./content/magic-items.csv",
"action": "create",
"cards": {
"label": "1",
"name": "火球术卷轴",
"type": "法术",
"cost": "3",
"description": "造成 5d6 火焰伤害"
}
}
// 批量创建卡牌
{
"csv_file": "./content/magic-items.csv",
"action": "create",
"cards": [
{ "name": "火球术卷轴", "type": "法术", "cost": "3" },
{ "name": "治疗药水", "type": "物品", "cost": "2" }
]
}
// 读取所有卡牌
{
"csv_file": "./content/magic-items.csv",
"action": "read"
}
// 读取指定卡牌
{
"csv_file": "./content/magic-items.csv",
"action": "read",
"label": ["1", "2"]
}
// 更新卡牌
{
"csv_file": "./content/magic-items.csv",
"action": "update",
"cards": { "label": "1", "cost": "4" }
}
// 删除卡牌
{
"csv_file": "./content/magic-items.csv",
"action": "delete",
"label": ["1", "2"]
}
```
#### `deck_preview` - 保存并预览卡牌组
保存 CSV 对应的 Markdown 预览文件并打开浏览器预览。
| 参数 | 类型 | 必需 | 说明 |
|------|------|------|------|
| `csv_file` | string | ✓ | CSV 文件路径 |
| `md_file` | string | ✗ | Markdown 文件路径(可选,默认与 CSV 同名) |
| `title` | string | ✗ | 标题(可选,默认从 CSV 文件名推断) |
| `description` | string | ✗ | 描述(可选) |
**返回示例:**
```json
{
"success": true,
"message": "预览文件已创建",
"preview_url": "http://localhost:3000/cards"
}
```
`preview_url` 以服务器工作目录为基准,去掉了 `.md` 扩展名(需要 `tttk serve` 正在运行)。
## CSV 文件格式
CSV 文件使用 YAML frontmatter 定义模板和配置:
```csv
---
fields:
- name: name
description: 卡牌名称
- name: type
description: 卡牌类型
examples: [物品,法术,生物]
- name: cost
description: 费用
- name: description
description: 效果描述
deck:
size: 54x86
grid: 5x8
bleed: 1
padding: 2
---
label,name,type,cost,description
1,火球术卷轴,法术,3,造成 5d6 火焰伤害
2,治疗药水,物品,2,恢复 2d4+2 生命值
```
### Frontmatter 说明
- `fields`: 字段定义列表
- `name`: 字段名称(英文,用于 CSV 列名)
- `description`: 字段描述
- `examples`: 示例值列表(可选)
- `deck`: Deck 配置
- `size`: 卡牌尺寸,格式 "宽 x 高"(单位 mm)
- `grid`: 网格布局,格式 "列 x 行"
- `bleed`: 出血边距(mm)
- `padding`: 内边距(mm)
- `shape`: 卡牌形状(rectangle, circle, hex, diamond)
- `layers`: 正面图层配置
- `back_layers`: 背面图层配置
### CSV 数据说明
- `label`: 卡牌标签(唯一标识,用于查找和修改)
- 其他列:由 frontmatter 中的 `fields` 定义
- `body`: 卡牌 body 内容(可选,支持 `{{字段名}}` 语法)
## MCP Resources
MCP 服务器提供以下 Resources,包含 TTRPG Tools 的文档和参考材料:
### `ttrpg-docs://csv` - CSV 编写说明
**名称:** csv.md
**标题:** CSV 编写说明
**描述:** TTRPG Tools CSV 文件格式说明,包括 Front Matter、字段定义、变量语法等
**内容包含:**
- YAML Front Matter 结构(fields、deck、自定义属性)
- 数据类型(word、paragraph、number、symbol、symbol_list)
- 字段功能(identify、compare、flavor、rule)
- CSV 数据格式和特殊字符处理
- 变量语法(`{{prop}}`)
- 分组支持
- 完整示例(魔法物品、随机遭遇表、NPC 名录)
### `ttrpg-docs://markdown` - Markdown 编写说明
**名称:** markdown.md
**标题:** Markdown 编写说明
**描述:** TTRPG Tools Markdown 扩展语法和组件用法说明
**内容包含:**
- 基础语法(GFM、marked-alert、marked-directive)
- 指令语法格式
- 图标语法(`:[icon-name]`)
- 组件库:
- `:md-dice` - 骰子组件
- `:md-link` - 链接组件
- `:md-bg` - 背景组件
- `:md-pins` - 标记组件
- `:md-table` - 表格组件
- `:md-deck` - 卡牌组件
- `:md-yarn-spinner` - 叙事线组件
- YAML 标签
- Mermaid 图表
- 警告/提示块
- 文件引用规则
- 样式定制
### 使用示例
**列出所有 Resources:**
```json
{
"jsonrpc": "2.0",
"id": 1,
"method": "resources/list"
}
```
**读取 Resource:**
```json
{
"jsonrpc": "2.0",
"id": 2,
"method": "resources/read",
"params": {
"uri": "ttrpg-docs://csv"
}
}
```
---
## MCP Prompts
MCP 服务器提供以下 Prompts,用于引导用户完成卡牌设计工作流:
### `design-card-game` - 设计卡牌游戏
引导用户设计新的卡牌游戏系统,定义卡牌模板和字段结构。
**参数:**
| 参数 | 类型 | 必需 | 说明 |
|------|------|------|------|
| `deck_name` | string | ✗ | 卡牌组名称 |
| `output_dir` | string | ✗ | 输出目录(相对路径) |
| `game_theme` | string | ✗ | 游戏主题/类型(如:奇幻、科幻、恐怖等) |
**使用示例:**
```json
{
"name": "design-card-game",
"arguments": {
"deck_name": "魔法物品",
"output_dir": "./content",
"game_theme": "奇幻"
}
}
```
**返回内容:**
Prompt 会返回一个对话式的引导流程,帮助 AI 助手了解:
1. 卡牌类型(角色卡、物品卡、法术卡等)
2. 核心机制(费用系统、稀有度、阵营等)
3. 卡牌信息字段
并提供常见配置的示例参考。
---
### `populate-deck` - 填充卡牌内容
为已有的卡牌组生成和填充卡牌内容。
**参数:**
| 参数 | 类型 | 必需 | 说明 |
|------|------|------|------|
| `csv_file` | string | ✗ | CSV 文件路径 |
| `card_count` | number | ✗ | 要生成的卡牌数量 |
| `theme` | string | ✗ | 卡牌主题/描述 |
**使用示例:**
```json
{
"name": "populate-deck",
"arguments": {
"csv_file": "./content/magic-items.csv",
"card_count": 20,
"theme": "火焰主题法术"
}
}
```
**返回内容:**
Prompt 会引导 AI 助手:
1. 读取现有卡牌模板结构
2. 选择生成方式(随机/主题化/手动/扩展现有)
3. 生成符合主题的卡牌内容
---
### `setup-deck-display` - 配置卡牌显示
引导用户配置卡牌的显示参数(尺寸、布局、样式等)。
**参数:**
| 参数 | 类型 | 必需 | 说明 |
|------|------|------|------|
| `csv_file` | string | ✗ | CSV 文件路径 |
| `usage` | string | ✗ | 卡牌用途(如:桌面游戏、印刷、在线预览等) |
**使用示例:**
```json
{
"name": "setup-deck-display",
"arguments": {
"csv_file": "./content/magic-items.csv",
"usage": "桌面游戏"
}
}
```
**返回内容:**
Prompt 会引导 AI 助手配置:
1. 卡牌尺寸(桥牌尺寸、标准尺寸、塔罗尺寸等)
2. 网格布局(每张 A4 纸的排列)
3. 出血边距、内边距
4. 卡牌形状
5. 图层配置(自动排版文字)
---
## 工作流示例
### 创建新卡牌组
1. **设计模板**:调用 `design-card-game` Prompt 引导设计字段结构
2. **定义模板**:调用 `deck_frontmatter_write` 创建 CSV 和 frontmatter
3. **创建预览**:调用 `deck_preview` 创建 Markdown 预览文件
4. **填充内容**:调用 `populate-deck` Prompt 引导生成卡牌内容
5. **添加卡牌**:调用 `deck_card_crud`(action=create)添加卡牌
6. **配置显示**:调用 `setup-deck-display` Prompt 配置显示参数
7. **保存配置**:调用 `deck_frontmatter_write` 更新 deck 配置
### 修改现有卡牌组
1. **读取模板**:调用 `deck_frontmatter_read` 获取当前配置
2. **读取卡牌**:调用 `deck_card_crud`(action=read)获取卡牌数据
3. **修改卡牌**:调用 `deck_card_crud`(action=update)更新卡牌
4. **更新预览**:调用 `deck_preview` 更新 Markdown 文件
### 快捷生成
直接调用 `generate_card_deck` 一站式生成完整卡牌组。
## 与 TTRPG Tools 集成
生成的卡牌组可以直接使用 TTRPG Tools 预览和编译:
```bash
# 预览卡牌内容
ttrpg serve ./content
# 编译为静态网站
ttrpg compile ./content -o ./dist
```
## 开发说明
### 添加新的 MCP 工具
1. 在 `src/cli/tools/` 目录创建工具模块
2. 在 `src/cli/commands/mcp.ts` 中注册工具
3. 实现 `ListToolsRequestSchema` 和 `CallToolRequestSchema` 处理器
### 代码结构
```
src/cli/
├── commands/
│ ├── serve.ts
│ ├── compile.ts
│ └── mcp.ts # MCP 命令入口
├── tools/
│ ├── frontmatter/
│ │ ├── read-frontmatter.ts
│ │ └── write-frontmatter.ts
│ ├── card/
│ │ └── card-crud.ts
│ ├── deck-utils.ts
│ ├── preview-deck.ts
│ └── generate-card-deck.ts
└── index.ts
```
### 依赖
MCP 服务器使用 `@modelcontextprotocol/sdk` 包,已包含在项目依赖中。
+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.
+30
View File
@@ -0,0 +1,30 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
export default {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/*.test.ts'],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
moduleNameMapper: {
// Resolve .js imports to .ts source files (ESM convention in TS source)
'^(.+)\\.js$': ['$1.ts', '$1.tsx', '$1.js'],
// github-slugger v2 is ESM-only; jest's CJS runtime cannot require it.
'^github-slugger$': '<rootDir>/__mocks__/github-slugger.js',
// Same for the browser ESM build of csv-parse — map to the CJS build.
'^csv-parse/browser/esm/sync$': 'csv-parse/sync',
},
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
tsconfig: {
target: 'ES2020',
module: 'ESNext',
moduleResolution: 'node',
esModuleInterop: true,
strict: true,
skipLibCheck: true
}
}
]
}
};
+8273 -1029
View File
File diff suppressed because it is too large Load Diff
+61 -21
View File
@@ -1,25 +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",
"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",
@@ -27,30 +44,53 @@
"markdown", "markdown",
"toolbox" "toolbox"
], ],
"author": "", "author": "hypercross",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^0.5.0",
"@solidjs/router": "^0.15.0", "@solidjs/router": "^0.15.0",
"@thisbeyond/solid-dnd": "^0.7.5",
"aedes": "^1.1.1",
"chokidar": "^5.0.0", "chokidar": "^5.0.0",
"commander": "^12.1.0", "comlink": "^4.4.2",
"csv-parse": "^5.5.6", "commander": "^14.0.3",
"csv-parse": "^6.1.0",
"csv-stringify": "^6.7.0",
"js-yaml": "^4.1.1", "js-yaml": "^4.1.1",
"marked": "^14.1.0", "marked": "^17.0.3",
"marked-alert": "^2.1.2", "marked-alert": "^2.1.2",
"marked-directive": "^1.0.7", "marked-directive": "^1.0.7",
"marked-gfm-heading-id": "^4.1.3",
"mermaid": "^11.0.0",
"mqtt": "^5.15.1",
"solid-element": "^1.9.1", "solid-element": "^1.9.1",
"solid-js": "^1.9.3" "solid-icons": "^1.2.0",
"solid-js": "^1.9.3",
"uuid": "^14.0.1",
"ws": "^8.21.0",
"yarn-spinner-loader": "^0.1.0",
"zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@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.0.7", "@rsbuild/plugin-solid": "^1.1.0",
"@tailwindcss/postcss": "^4.2.1", "@tailwindcss/postcss": "^4.2.1",
"@tailwindcss/typography": "^0.5.15", "@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"@types/node": "^22.10.2", "@types/jest": "^30.0.0",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.19.13",
"@types/uuid": "^10.0.0",
"@types/ws": "^8.18.1",
"cross-env": "^10.1.0",
"husky": "^9.1.7",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"ts-jest": "^29.4.6",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.7.2" "typescript": "^5.9.3",
"webpack-bundle-analyzer": "^5.3.0"
} }
} }
+90 -16
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,28 +54,64 @@ export default defineConfig({
plugins: [tailwindcss()], plugins: [tailwindcss()],
}, },
}, },
bundlerChain: (chain) => { rspack: {
// 为 .md 文件配置 raw-loader,支持 webpackContext 加载 resolve: {
chain.module // Allow TypeScript sources to use explicit `.js` specifiers (required
.rule('md-raw') // by the CLI's NodeNext build) while the bundler resolves them to .ts.
.test(/\.md$/) extensionAlias: {
.type('asset/source'); ".js": [".ts", ".tsx", ".js"],
},
},
module: {
rules: [
{
// Built-in doc entries are statically imported as strings
test: /[\\/]doc-entries[\\/].*\.md$/,
type: "asset/source",
},
],
},
plugins: [
process.env.ANALYZE ? new BundleAnalyzerPlugin() : undefined,
].filter(Boolean),
}, },
}, },
html: { 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: [
{ 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}`);
+193 -18
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,30 +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>
<main class="max-w-4xl mx-auto px-4 py-8 pt-20 md:ml-64 2xl:ml-auto flex justify-center items-center">
<Article class="prose-sm max-w-none flex-1" src={currentPath()} /> {/* 移动端抽屉式侧边栏 (overlay) */}
</main> <MobileSidebar
isOpen={isSidebarOpen()}
onClose={() => setIsSidebarOpen(false)}
fileTree={fileTree()}
pathHeadings={pathHeadings()}
onDataSourceOpen={() => setIsDataSourceOpen(true)}
/>
{/* Body: sidebar + main + journal */}
<div class="flex flex-1 min-h-0">
{/* 桌面端侧边栏 — flex child,可折叠 */}
<div
class="hidden md:block md:shrink-0 md:overflow-hidden md:transition-all md:duration-300"
classList={{
"md:w-64": isDesktopSidebarOpen(),
"md:w-0": !isDesktopSidebarOpen(),
}}
inert={!isDesktopSidebarOpen()}
>
<DesktopSidebar
fileTree={fileTree()}
pathHeadings={pathHeadings()}
onDataSourceOpen={() => setIsDataSourceOpen(true)}
onClose={() => setIsDesktopSidebarOpen(false)}
/>
</div>
{/* Main content — the scrollable region */}
<ScrollContainerCtx.Provider value={() => mainRef}>
<main ref={mainRef} class="flex-1 min-w-0 overflow-y-auto">
<div class="max-w-4xl mx-auto px-4 py-8">
<Article
class="prose prose-sm max-w-full"
src={currentPath()}
>
<RevealManager />
<ReactiveVariableManager />
<CommandLinkManager />
</Article>
</div>
</main>
</ScrollContainerCtx.Provider>
{/* Journal panel wrapper — on desktop: flex child with width transition;
on mobile: passes through to JournalPanel's own overlay */}
<div
class="contents md:block md:shrink-0 md:overflow-hidden md:transition-all md:duration-300"
classList={{
"md:w-105": isJournalOpen(),
"md:w-0": !isJournalOpen(),
}}
>
<JournalPanel
open={isJournalOpen()}
onClose={() => setIsJournalOpen(false)}
/>
</div>
</div>
<DocDialog isOpen={isDocOpen()} onClose={() => setIsDocOpen(false)} />
<DataSourceDialog
isOpen={isDataSourceOpen()}
onClose={() => setIsDataSourceOpen(false)}
onSourceChanged={handleSourceChanged}
/>
</div> </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("编译完成!");
}; };
+578
View File
@@ -0,0 +1,578 @@
import { Command } from 'commander';
import { generateCardDeck, type GenerateCardDeckParams, type CardField } from '../tools/generate-card-deck.js';
import { readFrontmatter, type ReadFrontmatterParams, type DeckFrontmatter } from '../tools/frontmatter/read-frontmatter.js';
import { writeFrontmatter, type WriteFrontmatterParams } from '../tools/frontmatter/write-frontmatter.js';
import { cardCrud, type CardCrudParams, type CardData } from '../tools/card/card-crud.js';
import { previewDeck, type PreviewDeckParams } from '../tools/preview-deck.js';
import {
designCardGame,
getDesignCardGamePrompt,
type DesignCardGameOptions
} from '../prompts/design-card-game.js';
import {
populateDeck,
getPopulateDeckPrompt,
type PopulateDeckOptions
} from '../prompts/populate-deck.js';
import {
setupDeckDisplay,
getSetupDeckDisplayPrompt,
type SetupDeckDisplayOptions
} from '../prompts/setup-deck-display.js';
import {
listResources,
readResource,
type DocResource
} from '../resources/docs.js';
import { createContentServer, type ContentServer } from './serve.js';
/**
* MCP 服务器命令
*
* 提供 MCP (Model Context Protocol) 服务器功能,用于与 AI 助手集成
*/
export interface MCPOptions {
port?: string;
cwd?: string;
}
export const mcpCommand = new Command('mcp')
.description('MCP 服务器 - 用于 AI 助手集成的工具协议')
.addCommand(
new Command('serve')
.description('启动 MCP 服务器')
.argument('[host]', '服务器地址', 'stdio')
.option('-p, --port <port>', 'HTTP 端口(仅 HTTP 传输)', '3001')
.option('--cwd <dir>', '工作目录(工具调用的相对路径基准)', process.env.TTRPG_MCP_CWD || process.cwd())
.action(mcpServeAction)
)
.addCommand(
new Command('generate-card-deck')
.description('生成卡牌组(快速命令)')
.requiredOption('--name <name>', '卡牌组名称')
.requiredOption('--output <dir>', '输出目录')
.option('-c, --count <number>', '卡牌数量', '10')
.option('--fields <fields>', '字段列表(逗号分隔)', 'name,type,cost,description')
.option('--description <desc>', '卡牌组描述')
.option('--size <size>', '卡牌尺寸', '54x86')
.option('--grid <grid>', '网格布局', '5x8')
.action(generateCardDeckAction)
);
/**
* MCP 服务器启动处理函数
*/
async function mcpServeAction(host: string, options: MCPOptions) {
// 切换到指定的工作目录
const cwd = options.cwd || process.cwd();
process.chdir(cwd);
console.error(`MCP 服务器工作目录:${cwd}`);
// 启动内容服务器(预览服务器)
const contentServer: ContentServer = createContentServer(cwd, 3000);
// 动态导入 MCP SDK
const { Server } = await import('@modelcontextprotocol/sdk/server/index.js');
const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
const {
CallToolRequestSchema,
ListToolsRequestSchema,
ListPromptsRequestSchema,
GetPromptRequestSchema,
ListResourcesRequestSchema,
ReadResourceRequestSchema,
} = await import('@modelcontextprotocol/sdk/types.js');
const server = new Server(
{
name: 'ttrpg-card-generator',
version: '0.1.0',
},
{
capabilities: {
tools: {},
prompts: {},
resources: {},
},
}
);
// 处理工具列表请求
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'deck_frontmatter_read',
description: '读取 CSV 文件的 frontmatter(包含模板定义和 deck 配置)',
inputSchema: {
type: 'object',
properties: {
csv_file: {
type: 'string',
description: 'CSV 文件路径(相对路径相对于 MCP 服务器工作目录)',
},
},
required: ['csv_file'],
},
},
{
name: 'deck_frontmatter_write',
description: '写入/更新 CSV 文件的 frontmatter(模板定义和 deck 配置)',
inputSchema: {
type: 'object',
properties: {
csv_file: {
type: 'string',
description: 'CSV 文件路径',
},
frontmatter: {
type: 'object',
description: '要写入的 frontmatter 数据',
properties: {
fields: {
type: 'array',
description: '字段定义列表',
items: {
type: 'object',
properties: {
name: { type: 'string', description: '字段名称' },
description: { type: 'string', description: '字段描述' },
examples: { type: 'array', items: { type: 'string' }, description: '示例值列表' },
},
required: ['name'],
},
},
deck: {
type: 'object',
description: 'Deck 配置',
properties: {
size: { type: 'string', description: '卡牌尺寸,格式 "宽 x 高"' },
grid: { type: 'string', description: '网格布局,格式 "列 x 行",表示卡牌排版区域分成多少行多少列,用于显示字段。默认使用5x8。' },
bleed: { type: 'number', description: '出血边距(mm)' },
padding: { type: 'number', description: '内边距(mm)' },
shape: { type: 'string', enum: ['rectangle', 'circle', 'hex', 'diamond'] },
layers: { type: 'string', description: '字段的显示图层配置。如`title:1,1-5,1f8n body:1,3-5,8f3n`表示将title字段的内容,显示在第1列1行到第5列1行的区域,8mm字体,上侧朝向北,body覆盖1列3行到5列行,3mm字体,上侧朝向北。通常希望重要的辨识字段放在左侧,如`rank:1,1-1,1f6 suit:1,2-1,2f6`。' },
back_layers: { type: 'string', description: '背面显示图层配置,与正面类似' },
},
},
},
},
merge: {
type: 'boolean',
description: '是否合并现有 frontmatter(默认 true)',
default: true,
},
},
required: ['csv_file', 'frontmatter'],
},
},
{
name: 'deck_card_crud',
description: '卡牌 CRUD 操作(创建/读取/更新/删除),支持批量操作',
inputSchema: {
type: 'object',
properties: {
csv_file: {
type: 'string',
description: 'CSV 文件路径',
},
action: {
type: 'string',
description: '操作类型',
enum: ['create', 'read', 'update', 'delete'],
},
cards: {
type: ['array', 'object'],
description: '卡牌数据(单张或数组)',
items: {
type: 'object',
additionalProperties: { type: 'string', description: "字段内容可以使用markdown语法。使用:[attack]来表示名为attack的图标。使用{{prop}}来引用另一个字段,或者frontmatter里的内容。" },
},
},
label: {
type: ['string', 'array'],
description: '要操作的卡牌 label(用于 read/update/delete)',
items: { type: 'string' },
},
},
required: ['csv_file', 'action'],
},
},
{
name: 'deck_preview',
description: '保存 CSV 对应的 Markdown 预览文件并打开浏览器',
inputSchema: {
type: 'object',
properties: {
csv_file: {
type: 'string',
description: 'CSV 文件路径',
},
md_file: {
type: 'string',
description: 'Markdown 文件路径(可选,默认与 CSV 同名)',
},
title: {
type: 'string',
description: '标题(可选,默认从 CSV 文件名推断)',
},
description: {
type: 'string',
description: '描述(可选)',
},
},
required: ['csv_file'],
},
},
],
};
});
// 处理 Prompts 列表请求
server.setRequestHandler(ListPromptsRequestSchema, async () => {
return {
prompts: [
getDesignCardGamePrompt(),
getPopulateDeckPrompt(),
getSetupDeckDisplayPrompt(),
],
};
});
// 处理工具调用请求
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case 'generate_card_deck': {
const params = args as unknown as GenerateCardDeckParams;
// 验证必需参数
if (!params.deck_name || !params.output_dir) {
return {
content: [
{
type: 'text',
text: '错误:缺少必需参数 deck_name 或 output_dir',
},
],
isError: true,
};
}
// 生成卡牌组(使用当前工作目录)
const result = generateCardDeck(params);
return {
content: [
{
type: 'text',
text: result.message,
},
{
type: 'text',
text: `\n## 生成的组件代码\n\n\`\`\`markdown\n${result.deckComponent}\n\`\`\``,
},
],
};
}
case 'deck_frontmatter_read': {
const params = args as unknown as ReadFrontmatterParams;
if (!params.csv_file) {
return {
content: [{ type: 'text', text: '错误:缺少必需参数 csv_file' }],
isError: true,
};
}
const result = readFrontmatter(params);
return {
content: [
{
type: 'text',
text: result.message,
},
...(result.frontmatter ? [{
type: 'text',
text: `\n## Frontmatter\n\n\`\`\`json\n${JSON.stringify(result.frontmatter, null, 2)}\n\`\`\``,
}] : []),
],
};
}
case 'deck_frontmatter_write': {
const params = args as unknown as WriteFrontmatterParams;
if (!params.csv_file || !params.frontmatter) {
return {
content: [{ type: 'text', text: '错误:缺少必需参数 csv_file 或 frontmatter' }],
isError: true,
};
}
const result = writeFrontmatter(params);
return {
content: [{ type: 'text', text: result.success ? `✅ ${result.message}` : `❌ ${result.message}` }],
isError: !result.success,
};
}
case 'deck_card_crud': {
let params = args as unknown as CardCrudParams;
if (!params.csv_file || !params.action) {
return {
content: [{ type: 'text', text: '错误:缺少必需参数 csv_file 或 action' }],
isError: true,
};
}
// 解析 cards 参数(可能是 JSON 字符串)
if (params.cards && typeof params.cards === 'string') {
try {
params.cards = JSON.parse(params.cards);
} catch (e) {
// 如果不是 JSON,保持原样
}
}
const result = cardCrud(params);
return {
content: [
{
type: 'text',
text: result.success ? `✅ ${result.message}` : `❌ ${result.message}`,
},
...(result.cards && result.cards.length > 0 ? [{
type: 'text',
text: `\n## 卡牌数据\n\n\`\`\`json\n${JSON.stringify(result.cards, null, 2)}\n\`\`\``,
}] : []),
],
isError: !result.success,
};
}
case 'deck_preview': {
const params = args as unknown as PreviewDeckParams;
if (!params.csv_file) {
return {
content: [{ type: 'text', text: '错误:缺少必需参数 csv_file' }],
isError: true,
};
}
const result = previewDeck(params);
return {
content: [{
type: 'text',
text: result.success
? `✅ ${result.message}\n\n预览 URL: ${result.preview_url}`
: `❌ ${result.message}`
}],
isError: !result.success,
};
}
default:
return {
content: [{ type: 'text', text: `未知工具:${name}` }],
isError: true,
};
}
} catch (error) {
return {
content: [{ type: 'text', text: `工具调用失败:${error instanceof Error ? error.message : '未知错误'}` }],
isError: true,
};
}
});
// 处理 Prompts 获取请求
server.setRequestHandler(GetPromptRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case 'design-card-game': {
const options = args as unknown as DesignCardGameOptions;
const result = designCardGame(options);
return {
description: '引导用户设计新的卡牌游戏系统,定义卡牌模板和字段结构',
messages: result.messages,
};
}
case 'populate-deck': {
const options = args as unknown as PopulateDeckOptions;
const result = populateDeck(options);
return {
description: '为已有的卡牌组生成和填充卡牌内容',
messages: result.messages,
};
}
case 'setup-deck-display': {
const options = args as unknown as SetupDeckDisplayOptions;
const result = setupDeckDisplay(options);
return {
description: '引导用户配置卡牌的显示参数(尺寸、布局、样式等)',
messages: result.messages,
};
}
default:
throw new Error(`未知 prompt:${name}`);
}
} catch (error) {
throw error;
}
});
// 处理 Resources 列表请求
server.setRequestHandler(ListResourcesRequestSchema, async () => {
return {
resources: listResources().map(r => ({
uri: r.uri,
name: r.name,
title: r.title,
description: r.description,
mimeType: r.mimeType,
})),
};
});
// 处理 Resources 读取请求
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const { uri } = request.params;
const resource = readResource(uri, process.cwd());
if (!resource) {
throw new Error(`Resource not found: ${uri}`);
}
return {
contents: [resource],
};
});
// 启动服务器
if (host === 'stdio') {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('TTRPG 卡牌生成 MCP 服务器已启动(stdio)');
// 清理函数
let isShuttingDown = false;
async function shutdown() {
if (isShuttingDown) return;
isShuttingDown = true;
console.error('\n正在关闭服务器...');
try {
// 先关闭服务器,这会触发 transport 的关闭
await server.close();
console.error('MCP 服务器已关闭');
} catch (e) {
console.error('关闭 MCP 服务器时出错:', e);
}
try {
// 确保 transport 被关闭
await transport.close();
console.error('MCP Transport 已关闭');
} catch (e) {
console.error('关闭 MCP Transport 时出错:', e);
}
try {
// 关闭内容服务器
contentServer.close();
console.error('内容服务器已关闭');
} catch (e) {
console.error('关闭内容服务器时出错:', e);
}
// 给异步清理一些时间
setTimeout(() => {
process.exit(0);
}, 100);
}
// 监听进程退出事件
process.on('SIGINT', () => shutdown());
process.on('SIGTERM', () => shutdown());
// Windows 上 Ctrl+C 会触发 'SIGINT',但也监听 'exit' 事件作为后备
process.on('exit', () => {
contentServer.close();
});
// 监听 stdin 结束事件(当客户端关闭连接时)
process.stdin.on('end', () => {
console.error('客户端已断开连接,正在关闭...');
shutdown();
});
// 监听 stdin 关闭事件
process.stdin.on('close', () => {
console.error('stdin 已关闭,正在关闭服务器...');
shutdown();
});
} else {
// TODO: 支持 HTTP 传输
console.error('HTTP 传输模式尚未实现,请使用 stdio 模式');
process.exit(1);
}
}
/**
* 快速生成卡牌组命令处理函数
*/
async function generateCardDeckAction(options: {
name: string;
output: string;
count: string;
fields: string;
description?: string;
size: string;
grid: string;
}) {
const fieldNames = options.fields.split(',').map(f => f.trim());
const template = {
fields: fieldNames.map((name): CardField => ({
name,
examples: ['示例 1', '示例 2']
}))
};
const params: GenerateCardDeckParams = {
deck_name: options.name,
output_dir: options.output,
card_count: parseInt(options.count, 10),
card_template: template,
deck_config: {
size: options.size,
grid: options.grid
},
description: options.description
};
const result = generateCardDeck(params);
console.log('\n✅ 卡牌组生成成功!\n');
console.log(result.message);
console.log('\n📦 组件代码:');
console.log(` ${result.deckComponent}\n`);
}
+197 -70
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)
*/ */
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")) { 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,74 +202,175 @@ function createRequestHandler(
} }
/** /**
* 启动开发服务器 * 内容服务器实例接口
*/ */
export const serveCommand: ServeCommandHandler = async (dir, options) => { export interface ContentServer {
const contentDir = resolve(dir); /**
let contentIndex: ContentIndex = {}; * HTTP 服务器实例
*/
server: Server;
/**
* 文件监听器
*/
watcher: ReturnType<typeof watch>;
/**
* 内容索引
*/
index: ContentIndex;
/**
* 补全数据
*/
completions: CompletionsPayload;
/**
* 关闭服务器
*/
close(): void;
}
// 扫描内容目录生成索引 /**
console.log("扫描内容目录..."); * 创建内容服务器(HTTP + WebSocket + 文件监听)
contentIndex = scanDirectory(contentDir); */
console.log(`已索引 ${Object.keys(contentIndex).length} 个文件`); export function createContentServer(
contentDir: string,
port: number,
distPath: string = distDir,
host: string = "0.0.0.0",
): ContentServer {
let registry: ContentRegistry = { pathIndex: {}, docContent: {} };
let completionsIndex: CompletionsPayload = {
dice: [],
links: [],
sparkTables: [],
declarations: [],
tagModifiers: [],
};
/** 从当前注册表重新派生补全数据 */
function recomputeCompletions(): void {
completionsIndex = deriveCompletions(registry);
console.log(
`[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length} declarations=${completionsIndex.declarations.length} tagModifiers=${completionsIndex.tagModifiers.length}`,
);
}
// 扫描内容目录生成注册表
console.log("正在扫描内容目录...");
registry = buildRegistry(contentDir);
console.log(`已索引 ${Object.keys(registry.pathIndex).length} 个文件`);
recomputeCompletions();
// 监听文件变化 // 监听文件变化
console.log("监听文件变化..."); console.log("正在监听文件变化...");
const watcher = watch(contentDir, { 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")) { 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")) { 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")) { 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,
distDir, distPath,
() => contentIndex, () => registry.pathIndex,
() => completionsIndex,
() => registry,
); );
// 创建 HTTP 服务器
const server = createServer(handleRequest); const server = createServer(handleRequest);
const port = parseInt(options.port, 10); createJournalServer(contentDir, server).then((j) => {
journal = j;
server.listen(port, () => {
console.log(`\n开发服务器已启动:http://localhost:${port}`);
console.log(`内容目录:${contentDir}`);
console.log(`静态资源目录:${distDir}`);
console.log(`索引文件:http://localhost:${port}/__CONTENT_INDEX.json\n`);
}); });
server.listen(port, host, () => {
const bestIP = getBestIP();
const displayHost = host === "0.0.0.0" ? bestIP : host;
console.log(`\n开发服务器已启动:http://${displayHost}:${port}`);
console.log(`本机访问:http://localhost:${port}`);
if (bestIP !== "localhost") {
console.log(`局域网访问:http://${bestIP}:${port}`);
}
console.log(`内容目录:${contentDir}`);
console.log(`静态资源目录:${distPath}`);
console.log(`日志连接:ws://${bestIP}:${port}\n`);
});
return {
server,
watcher,
index: registry.pathIndex,
completions: completionsIndex,
close() {
console.log("正在关闭内容服务器...");
server.close();
watcher.close();
if (journal) journal.close();
},
};
}
/**
* 启动开发服务器
*/
export const serveCommand: ServeCommandHandler = async (dir, options) => {
const contentDir = resolve(dir);
if (!existsSync(contentDir)) {
console.error(`错误:目录不存在:${contentDir}`);
process.exit(1);
}
const port = parseInt(options.port, 10);
const host = options.host || "0.0.0.0";
createContentServer(contentDir, port, distDir, host);
}; };
@@ -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;
}
+48 -16
View File
@@ -1,28 +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 type { ServeOptions, CompileOptions } from './types.js'; import { checkCommand } from "./commands/check.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("--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]", "要校验的目录", ".")
.option("--json", "以 JSON 输出诊断", false)
.option("--strict", "将警告视为错误", false)
.option("--quiet", "只报告错误", false)
.action(checkCommand);
program
.command("compile")
.description("[未实现] 存根命令:不会生成任何输出。预览内容请使用 tttk serve")
.argument("[dir]", "要编译的目录", ".")
.option("-o, --output <dir>", "输出目录", "./dist/output")
.action(compileCommand); .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);
}
}
+299
View File
@@ -0,0 +1,299 @@
/**
* design-card-game Prompt
*
* 引导用户设计新的卡牌游戏系统,定义卡牌模板和字段结构
*/
export interface DesignCardGameOptions {
/**
* 卡牌组名称
*/
deck_name?: string;
/**
* 输出目录
*/
output_dir?: string;
/**
* 游戏类型/主题
*/
game_theme?: string;
}
export interface DesignCardGameResult {
messages: PromptMessage[];
}
export interface PromptMessage {
role: 'user' | 'assistant';
content: TextContent | ImageContent | AudioContent | ResourceContent;
}
export interface TextContent {
type: 'text';
text: string;
}
export interface ImageContent {
type: 'image';
data: string;
mimeType: string;
}
export interface AudioContent {
type: 'audio';
data: string;
mimeType: string;
}
export interface ResourceContent {
type: 'resource';
resource: {
uri: string;
mimeType: string;
text?: string;
blob?: string;
};
}
/**
* 数据类型
*/
export type DataType = 'word' | 'paragraph' | 'number' | 'symbol' | 'symbol_list';
/**
* 字段功能
*/
export type FieldFunction = 'identify' | 'compare' | 'flavor' | 'rule';
/**
* 字段定义
*/
export interface FieldDefinition {
name: string;
description: string;
dataType: DataType;
function: FieldFunction;
examples?: string[];
}
/**
* 数据类型说明
*/
const DATA_TYPE_DESCRIPTIONS: Record<DataType, string> = {
word: '词语 - 短文本(如名称、类型、职业等)',
paragraph: '文本段落 - 长描述(如效果说明、背景故事等)',
number: '数字 - 可比较的数值(如费用、强度、等级等)',
symbol: '单一符号 - 图标/标记(如阵营符号、元素标志等)',
symbol_list: '符号列表 - 多个图标/标记(如属性加成列表、技能图标等)'
};
/**
* 字段功能说明
*/
const FIELD_FUNCTION_DESCRIPTIONS: Record<FieldFunction, string> = {
identify: '辨识 - 用于识别卡牌身份(如名称、编号等)',
compare: '比较 - 用于游戏机制中的数值比较(如费用、攻击力等)',
flavor: '游戏风味描述 - 提供背景故事/氛围(如背景故事、引言等)',
rule: '规则确认 - 明确游戏规则相关的信息(如效果文本、触发条件等)'
};
/**
* 生成 design-card-game prompt 的消息
*/
export function designCardGame(options?: DesignCardGameOptions): DesignCardGameResult {
const deckName = options?.deck_name || '未命名卡牌组';
const outputDir = options?.output_dir || './content';
const gameTheme = options?.game_theme || '通用';
const messages: PromptMessage[] = [
{
role: 'user',
content: {
type: 'text',
text: `我想设计一个"${gameTheme}"主题的卡牌游戏,卡牌组名称为"${deckName}",输出目录为"${outputDir}"。请帮我设计卡牌的字段结构。`
}
},
{
role: 'assistant',
content: {
type: 'text',
text: `好的!我来帮你设计"${deckName}"卡牌组的字段结构。
我们将通过以下步骤完成设计:
---
## 步骤 1:列出卡牌属性
首先,请列出你的卡牌包含哪些属性(字段)。
**思考方向:**
- 玩家看到这张卡牌时,需要知道什么信息?
- 游戏机制需要用到哪些数据?
- 哪些信息用于区分不同的卡牌?
- 哪些信息用于游戏过程中的判断?
**示例:**
- 名称、费用、类型、效果、稀有度...
---
## 步骤 2:定义每个属性的数据类型
对于每个属性,选择其数据类型:
| 类型 | 说明 | 示例 |
|------|------|------|
| word(词语) | 短文本 | "火球术"、"战士"、"传说" |
| paragraph(文本段落) | 长描述 | "造成 5d6 点火焰伤害,范围内的所有生物进行敏捷豁免" |
| number(数字) | 可比较的数值 | 3、5、10 |
| symbol(单一符号) | 图标/标记 | 🔥、⚔️、🛡️ |
| symbol_list(符号列表) | 多个图标/标记 | 🔥🔥🔥、⚔️🛡️💫 |
---
## 步骤 3:定义每个字段的功能
对于每个属性,说明其在游戏中的功能:
| 功能 | 说明 | 示例 |
|------|------|------|
| identify(辨识) | 用于识别卡牌身份 | 名称、编号、唯一标识 |
| compare(比较) | 用于游戏机制中的数值比较 | 费用、攻击力、防御力 |
| flavor(游戏风味描述) | 提供背景故事/氛围 | 背景故事、引言、 lore |
| rule(规则确认) | 明确游戏规则相关的信息 | 效果文本、触发条件、限制 |
---
## 步骤 4:关于插画/美术设计
**默认设定:** 卡牌的背景部分视为插画或精美的装帧设计,不需要在数据中定义插画字段。
如果你需要在卡牌上显示特定的图标或符号,请使用 symbol 或 symbol_list 类型。
---
## 回复格式
请按以下格式回复(可以复制模板填写):
\`\`\`
### 属性 1:[属性名称]
- **描述**:[简短说明这个属性的用途]
- **数据类型**:[word/paragraph/number/symbol/symbol_list]
- **字段功能**:[identify/compare/flavor/rule]
- **示例值**:[2-3 个示例值]
### 属性 2:[属性名称]
...
\`\`\`
---
## 示例参考
### 魔法物品卡牌组
\`\`\`
### 属性 1:name(名称)
- **描述**:物品的名称
- **数据类型**:word
- **字段功能**:identify
- **示例值**:火球术卷轴、治疗药水、隐形斗篷
### 属性 2:cost(费用)
- **描述**:购买或使用该物品所需的资源
- **数据类型**:number
- **字段功能**:compare
- **示例值**:3、5、2
### 属性 3:type(类型)
- **描述**:物品的分类
- **数据类型**:word
- **字段功能**:identify
- **示例值**:武器、防具、饰品、消耗品
### 属性 4:rarity(稀有度)
- **描述**:物品的稀有程度
- **数据类型**:word
- **字段功能**:compare
- **示例值**:普通、稀有、史诗、传说
### 属性 5:effect(效果)
- **描述**:物品的效果或使用说明
- **数据类型**:paragraph
- **字段功能**:rule
- **示例值**:造成 5d6 点火焰伤害、恢复 2d4+2 点生命值
\`\`\`
### 塔罗牌组
\`\`\`
### 属性 1:name(牌名)
- **描述**:塔罗牌的名称
- **数据类型**:word
- **字段功能**:identify
- **示例值**:愚者、魔术师、女祭司
### 属性 2:number(编号)
- **描述**:在大阿卡纳中的序号
- **数据类型**:number
- **字段功能**:identify
- **示例值**:0、1、2
### 属性 3:element(元素)
- **描述**:对应的元素
- **数据类型**:symbol
- **字段功能**:flavor
- **示例值**:🔥、💧、💨、🌍
### 属性 4:meaning(含义)
- **描述**:正位时的含义
- **数据类型**:paragraph
- **字段功能**:rule
- **示例值**:新的开始、冒险、自发性
### 属性 5:reversed(逆位含义)
- **描述**:逆位时的含义
- **数据类型**:paragraph
- **字段功能**:rule
- **示例值**:鲁莽、冒险导致的失败、缺乏计划
\`\`\`
---
请开始描述你的卡牌属性吧!`
}
}
];
return { messages };
}
/**
* 获取 prompt 的元数据
*/
export function getDesignCardGamePrompt() {
return {
name: 'design-card-game',
title: '设计卡牌游戏',
description: '引导用户设计新的卡牌游戏系统,定义卡牌模板和字段结构',
arguments: [
{
name: 'deck_name',
description: '卡牌组名称',
required: false
},
{
name: 'output_dir',
description: '输出目录(相对路径)',
required: false
},
{
name: 'game_theme',
description: '游戏主题/类型(如:奇幻、科幻、恐怖等)',
required: false
}
]
};
}
+187
View File
@@ -0,0 +1,187 @@
/**
* populate-deck Prompt
*
* 引导用户为已有的卡牌组填充卡牌内容
*/
export interface PopulateDeckOptions {
/**
* CSV 文件路径
*/
csv_file?: string;
/**
* 要生成的卡牌数量
*/
card_count?: number;
/**
* 卡牌主题/描述
*/
theme?: string;
}
export interface PopulateDeckResult {
messages: PromptMessage[];
}
export interface PromptMessage {
role: 'user' | 'assistant';
content: TextContent | ImageContent | AudioContent | ResourceContent;
}
export interface TextContent {
type: 'text';
text: string;
}
export interface ImageContent {
type: 'image';
data: string;
mimeType: string;
}
export interface AudioContent {
type: 'audio';
data: string;
mimeType: string;
}
export interface ResourceContent {
type: 'resource';
resource: {
uri: string;
mimeType: string;
text?: string;
blob?: string;
};
}
/**
* 生成 populate-deck prompt 的消息
*/
export function populateDeck(options?: PopulateDeckOptions): PopulateDeckResult {
const csvFile = options?.csv_file || './content/deck.csv';
const cardCount = options?.card_count || 10;
const theme = options?.theme || '通用';
const messages: PromptMessage[] = [
{
role: 'user',
content: {
type: 'text',
text: `我想为卡牌组"${csvFile}"生成${cardCount}张卡牌,主题是"${theme}"。请帮我填充卡牌内容。`
}
},
{
role: 'assistant',
content: {
type: 'text',
text: `好的!我来帮你为"${csvFile}"生成${cardCount}张"${theme}"主题的卡牌。
首先,让我读取现有的卡牌模板结构...
---
**步骤 1:读取卡牌模板**
在开始生成卡牌之前,我需要了解卡牌组的字段结构。请确认以下信息:
## 卡牌字段
根据 CSV 文件的 frontmatter 定义,卡牌包含以下字段:
(这里会显示从 CSV 文件中读取的字段定义)
## 生成方式
你希望如何生成卡牌内容?
### 选项 A:完全随机生成
- 我会根据字段的 examples 随机组合生成卡牌
- 适合快速填充大量卡牌
### 选项 B:主题化生成
- 告诉我具体的主题(如:"森林生物"、"火焰法术"、"古代神器"等)
- 我会生成符合主题的连贯卡牌
### 选项 C:手动指定
- 你提供每张卡牌的具体内容
- 我帮你格式化并添加到 CSV 中
### 选项 D:基于现有卡牌扩展
- 如果已有部分卡牌,可以基于它们生成相似的卡牌
- 保持风格一致性
---
**示例输出:**
假设你的卡牌组有以下字段:name, type, cost, effect
生成 3 张"森林生物"主题的卡牌可能如下:
\`\`\`json
[
{
"label": "1",
"name": "荆棘狼",
"type": "生物",
"cost": "3",
"effect": "当荆棘狼进入战场时,对目标对手造成 1 点伤害"
},
{
"label": "2",
"name": "树精长老",
"type": "生物",
"cost": "5",
"effect": "树精长老具有 +0/+3 和'横置:回复 1 点生命值'"
},
{
"label": "3",
"name": "森林之灵",
"type": "生物",
"cost": "2",
"effect": "飞行,当森林之灵离场时,将一个 1/1 的孢子衍生物放入战场"
}
]
\`\`\`
---
请告诉我:
1. 你选择的生成方式(A/B/C/D)
2. 如果是选项 B,请提供具体主题
3. 是否有特殊要求(如:费用曲线、类型分布等)`
}
}
];
return { messages };
}
/**
* 获取 prompt 的元数据
*/
export function getPopulateDeckPrompt() {
return {
name: 'populate-deck',
title: '填充卡牌内容',
description: '为已有的卡牌组生成和填充卡牌内容',
arguments: [
{
name: 'csv_file',
description: 'CSV 文件路径',
required: false
},
{
name: 'card_count',
description: '要生成的卡牌数量',
required: false
},
{
name: 'theme',
description: '卡牌主题/描述',
required: false
}
]
};
}
+195
View File
@@ -0,0 +1,195 @@
/**
* setup-deck-display Prompt
*
* 引导用户配置卡牌的显示参数(尺寸、布局、样式等)
*/
export interface SetupDeckDisplayOptions {
/**
* CSV 文件路径
*/
csv_file?: string;
/**
* 卡牌用途
*/
usage?: string;
}
export interface SetupDeckDisplayResult {
messages: PromptMessage[];
}
export interface PromptMessage {
role: 'user' | 'assistant';
content: TextContent | ImageContent | AudioContent | ResourceContent;
}
export interface TextContent {
type: 'text';
text: string;
}
export interface ImageContent {
type: 'image';
data: string;
mimeType: string;
}
export interface AudioContent {
type: 'audio';
data: string;
mimeType: string;
}
export interface ResourceContent {
type: 'resource';
resource: {
uri: string;
mimeType: string;
text?: string;
blob?: string;
};
}
/**
* 生成 setup-deck-display prompt 的消息
*/
export function setupDeckDisplay(options?: SetupDeckDisplayOptions): SetupDeckDisplayResult {
const csvFile = options?.csv_file || './content/deck.csv';
const usage = options?.usage || '桌面游戏';
const messages: PromptMessage[] = [
{
role: 'user',
content: {
type: 'text',
text: `我想配置"${csvFile}"卡牌组的显示参数,主要用于"${usage}"。请帮我设置合适的尺寸和布局。`
}
},
{
role: 'assistant',
content: {
type: 'text',
text: `好的!我来帮你配置"${csvFile}"卡牌组的显示参数。
## 卡牌尺寸选择
首先,选择卡牌的标准尺寸:
### 常见卡牌尺寸
| 尺寸名称 | 规格 (mm) | 用途 |
|---------|----------|------|
| 桥牌尺寸 | 57x89 | 传统扑克牌、桌游手牌 |
| 标准尺寸 | 63x88 | 万智牌、游戏王等集换式卡牌 |
| 迷你尺寸 | 44x68 | 小型道具卡、资源标记 |
| 方形尺寸 | 57x57 | 特殊机制卡 |
| 塔罗尺寸 | 70x120 | 塔罗牌、神谕卡 |
| 自定义 | 输入尺寸 | 特殊需求 |
---
## 网格布局
选择打印时的网格布局(每张 A4 纸的排列):
| 卡牌尺寸 | 推荐布局 | 每张 A4 数量 |
|---------|---------|-------------|
| 57x89 | 5 列 x 6 行 | 30 张 |
| 63x88 | 5 列 x 5 行 | 25 张 |
| 44x68 | 6 列 x 8 行 | 48 张 |
| 57x57 | 6 列 x 6 行 | 36 张 |
| 70x120 | 3 列 x 4 行 | 12 张 |
---
## 高级选项
### 出血边距 (Bleed)
- 标准:1mm(推荐)
- 无出血:0mm
- 大出血:2-3mm(专业印刷)
### 内边距 (Padding)
- 标准:2mm(推荐)
- 紧凑:1mm
- 宽松:3-4mm
### 卡牌形状
- rectangle(矩形)- 默认
- circle(圆形)
- hex(六边形)
- diamond(菱形)
---
## 图层配置(可选)
如果你需要在卡牌上自动排版文字,可以配置图层:
格式:\`字段名:起始行 - 结束行,字体大小\`
示例:
\`\`\`
layers="name:1-2,14 type:3-3,10 effect:4-8,9"
\`\`\`
这会将:
- name 字段放在第 1-2 行,14 号字体
- type 字段放在第 3 行,10 号字体
- effect 字段放在第 4-8 行,9 号字体
---
**请告诉我:**
1. 选择的卡牌尺寸(或自定义尺寸)
2. 选择的网格布局
3. 是否需要调整出血/内边距
4. 是否需要配置自动排版图层
---
**示例配置:**
\`\`\`json
{
"deck": {
"size": "63x88",
"grid": "5x5",
"bleed": 1,
"padding": 2,
"shape": "rectangle",
"layers": "name:1-2,14 type:3-3,10 effect:4-8,9"
}
}
\`\`\``
}
}
];
return { messages };
}
/**
* 获取 prompt 的元数据
*/
export function getSetupDeckDisplayPrompt() {
return {
name: 'setup-deck-display',
title: '配置卡牌显示',
description: '引导用户配置卡牌的显示参数(尺寸、布局、样式等)',
arguments: [
{
name: 'csv_file',
description: 'CSV 文件路径',
required: false
},
{
name: 'usage',
description: '卡牌用途(如:桌面游戏、印刷、在线预览等)',
required: false
}
]
};
}
+75
View File
@@ -0,0 +1,75 @@
import { readFileSync, existsSync } from 'fs';
import { join } from 'path';
/**
* 文档资源定义
*/
export interface DocResource {
uri: string;
name: string;
title?: string;
description?: string;
mimeType?: string;
}
/**
* 预定义的文档资源列表
*/
export const DOC_RESOURCES: DocResource[] = [
{
uri: 'ttrpg-docs://csv',
name: 'csv.md',
title: 'CSV 编写说明',
description: 'TTRPG Tools CSV 文件格式说明,包括 Front Matter、字段定义、变量语法等',
mimeType: 'text/markdown'
},
{
uri: 'ttrpg-docs://markdown',
name: 'markdown.md',
title: 'Markdown 编写说明',
description: 'TTRPG Tools Markdown 扩展语法和组件用法说明',
mimeType: 'text/markdown'
}
];
/**
* 获取资源列表
*/
export function listResources(): DocResource[] {
return DOC_RESOURCES;
}
/**
* 读取资源内容
* @param uri 资源 URI
* @param cwd 工作目录
* @returns 资源内容
*/
export function readResource(uri: string, cwd: string): {
uri: string;
mimeType: string;
text: string;
} | null {
// 解析 URI
const docName = uri.replace('ttrpg-docs://', '');
const fileName = `${docName}.md`;
const filePath = join(cwd, 'docs', fileName);
// 检查文件是否存在
if (!existsSync(filePath)) {
return null;
}
// 读取文件内容
try {
const content = readFileSync(filePath, 'utf-8');
return {
uri,
mimeType: 'text/markdown',
text: content
};
} catch (error) {
console.warn(`Failed to read resource ${uri}:`, error);
return null;
}
}
+289
View File
@@ -0,0 +1,289 @@
import { readFileSync, writeFileSync, existsSync } from "fs";
import { parse } from "csv-parse/browser/esm/sync";
import { stringify } from "csv-stringify/browser/esm/sync";
import type { DeckFrontmatter } from "../frontmatter/read-frontmatter.js";
import {
parseFrontMatter,
serializeFrontMatter,
} from "../frontmatter/shared.js";
/**
* 卡牌数据
*/
export interface CardData {
label?: string;
[key: string]: string | undefined;
}
/**
* 卡牌 CRUD 参数
*/
export interface CardCrudParams {
/**
* CSV 文件路径
*/
csv_file: string;
/**
* 操作类型
*/
action: "create" | "read" | "update" | "delete";
/**
* 卡牌数据(单张或数组)
*/
cards?: CardData | CardData[];
/**
* 要读取/更新的卡牌 label(用于 read/update/delete)
*/
label?: string | string[];
}
/**
* 卡牌 CRUD 结果
*/
export interface CardCrudResult {
success: boolean;
message: string;
cards?: CardData[];
count?: number;
}
/**
* 加载 CSV 数据(包含 frontmatter)
*/
function loadCSVWithFrontmatter(filePath: string): {
frontmatter?: DeckFrontmatter;
records: CardData[];
headers: string[];
} {
const content = readFileSync(filePath, "utf-8");
const { frontmatter, csvContent } = parseFrontMatter(content);
const records = parse(csvContent, {
columns: true,
comment: "#",
trim: true,
skipEmptyLines: true,
}) as CardData[];
// 获取表头
const firstLine = csvContent.split("\n")[0];
const headers = firstLine.split(",").map((h) => h.trim());
return { frontmatter, records, headers };
}
/**
* 保存 CSV 数据(包含 frontmatter)
*/
function saveCSVWithFrontmatter(
filePath: string,
frontmatter: DeckFrontmatter | undefined,
records: CardData[],
headers?: string[],
): void {
// 序列化 frontmatter
const frontmatterStr = frontmatter ? serializeFrontMatter(frontmatter) : "";
// 确定表头
if (!headers || headers.length === 0) {
// 从 records 和 frontmatter.fields 推断表头
headers = ["label"];
if (frontmatter?.fields && Array.isArray(frontmatter.fields)) {
for (const field of frontmatter.fields) {
if (field.name && typeof field.name === "string") {
headers.push(field.name);
}
}
}
headers.push("body");
}
// 确保所有 record 都有 headers 中的列
for (const record of records) {
for (const header of headers) {
if (!(header in record)) {
record[header] = "";
}
}
}
// 序列化 CSV
const csvContent = stringify(records, {
header: true,
columns: headers,
});
// 写入文件
writeFileSync(filePath, frontmatterStr + csvContent, "utf-8");
}
/**
* 生成下一个 label
*/
function generateNextLabel(records: CardData[]): string {
const maxLabel = records.reduce((max, record) => {
const label = record.label ? parseInt(record.label, 10) : 0;
return label > max ? label : max;
}, 0);
return (maxLabel + 1).toString();
}
/**
* 卡牌 CRUD 操作
*/
export function cardCrud(params: CardCrudParams): CardCrudResult {
const { csv_file, action, cards, label } = params;
// 检查文件是否存在(create 操作可以不存在)
if (action !== "create" && !existsSync(csv_file)) {
return {
success: false,
message: `文件不存在:${csv_file}`,
};
}
try {
let frontmatter: DeckFrontmatter | undefined;
let records: CardData[] = [];
let headers: string[] = [];
// 加载现有数据
if (existsSync(csv_file)) {
const data = loadCSVWithFrontmatter(csv_file);
frontmatter = data.frontmatter;
records = data.records;
headers = data.headers;
}
// 执行操作
switch (action) {
case "create": {
const newCards = Array.isArray(cards) ? cards : cards ? [cards] : [];
for (const card of newCards) {
if (!card.label) {
card.label = generateNextLabel(records);
}
records.push(card);
}
saveCSVWithFrontmatter(csv_file, frontmatter, records, headers);
return {
success: true,
message: `成功创建 ${newCards.length} 张卡牌`,
cards: newCards,
count: newCards.length,
};
}
case "read": {
const labelsToRead = Array.isArray(label)
? label
: label
? [label]
: null;
let resultCards: CardData[];
if (labelsToRead && labelsToRead.length > 0) {
resultCards = records.filter((r) =>
labelsToRead.includes(r.label || ""),
);
} else {
resultCards = records;
}
return {
success: true,
message: `成功读取 ${resultCards.length} 张卡牌`,
cards: resultCards,
count: resultCards.length,
};
}
case "update": {
const labelsToUpdate = Array.isArray(label)
? label
: label
? [label]
: null;
const updateCards = Array.isArray(cards) ? cards : cards ? [cards] : [];
let updatedCount = 0;
if (labelsToUpdate && labelsToUpdate.length > 0) {
// 按 label 更新
for (const updateCard of updateCards) {
const targetLabel =
updateCard.label ||
labelsToUpdate[updatedCount % labelsToUpdate.length];
const index = records.findIndex((r) => r.label === targetLabel);
if (index !== -1) {
records[index] = { ...records[index], ...updateCard };
updatedCount++;
}
}
} else {
// 按 cards 中的 label 更新
for (const updateCard of updateCards) {
if (updateCard.label) {
const index = records.findIndex(
(r) => r.label === updateCard.label,
);
if (index !== -1) {
records[index] = { ...records[index], ...updateCard };
updatedCount++;
}
}
}
}
saveCSVWithFrontmatter(csv_file, frontmatter, records, headers);
return {
success: true,
message: `成功更新 ${updatedCount} 张卡牌`,
cards: updateCards,
count: updatedCount,
};
}
case "delete": {
const labelsToDelete = Array.isArray(label)
? label
: label
? [label]
: null;
let deletedCount = 0;
if (labelsToDelete && labelsToDelete.length > 0) {
const beforeCount = records.length;
records = records.filter(
(r) => !labelsToDelete.includes(r.label || ""),
);
deletedCount = beforeCount - records.length;
} else if (cards) {
const cardsToDelete = Array.isArray(cards) ? cards : [cards];
const beforeCount = records.length;
records = records.filter(
(r) => !cardsToDelete.some((c) => c.label && r.label === c.label),
);
deletedCount = beforeCount - records.length;
}
saveCSVWithFrontmatter(csv_file, frontmatter, records, headers);
return {
success: true,
message: `成功删除 ${deletedCount} 张卡牌`,
count: deletedCount,
};
}
default:
return {
success: false,
message: `未知操作:${action}`,
};
}
} catch (error) {
return {
success: false,
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");
}
@@ -0,0 +1,134 @@
import { readFileSync, existsSync } from "fs";
import { parseFrontMatter } from "./shared.js";
/**
* 读取 CSV frontmatter 的参数
*/
export interface ReadFrontmatterParams {
/**
* CSV 文件路径(相对路径相对于 MCP 服务器工作目录)
*/
csv_file: string;
}
/**
* Frontmatter 数据结构
*/
export interface DeckFrontmatter {
/**
* 字段定义
*/
fields?: CardField[];
/**
* Deck 配置
*/
deck?: DeckConfig;
/**
* 其他自定义属性
*/
[key: string]: unknown;
}
/**
* 字段样式配置
*
* 位置按照卡牌网格,安排 x,y,w,h 四个整数
* 如 5x8 网格中 1,1,5,8 表示覆盖整个卡牌的区域
*
* 字体使用 f:8 来表示 8mm 字体
*
* 朝向使用 u:n/w/s/e 表示字段的上侧朝向北西南东
*/
export interface CardFieldStyle {
/**
* 位置:[x, y, w, h] 网格坐标和尺寸
* 例如:[1, 1, 5, 8] 表示从 (1,1) 开始,宽 5 格,高 8 格
*/
pos?: [number, number, number, number];
/**
* 字体大小:格式 "f:8" 表示 8mm 字体
*/
font?: string;
/**
* 朝向:上侧朝向 "n" | "w" | "s" | "e"(北/西/南/东)
*/
up?: "n" | "w" | "s" | "e";
}
/**
* 卡牌字段定义
*/
export interface CardField {
name: string;
description?: string;
examples?: string[];
style?: CardFieldStyle;
[key: string]: unknown;
}
/**
* Deck 配置
*/
export interface DeckConfig {
size?: string;
grid?: string;
bleed?: number;
padding?: number;
shape?: "rectangle" | "circle" | "hex" | "diamond";
layers?: string;
back_layers?: string;
[key: string]: unknown;
}
/**
* 读取 CSV frontmatter 的结果
*/
export interface ReadFrontmatterResult {
success: boolean;
frontmatter?: DeckFrontmatter;
message: string;
}
/**
* 读取 CSV 文件的 frontmatter
*/
export function readFrontmatter(
params: ReadFrontmatterParams,
): ReadFrontmatterResult {
const { csv_file } = params;
// 检查文件是否存在
if (!existsSync(csv_file)) {
return {
success: false,
message: `文件不存在:${csv_file}`,
};
}
try {
// 读取文件内容
const content = readFileSync(csv_file, "utf-8");
// 解析 frontmatter
const { frontmatter } = parseFrontMatter(content);
if (!frontmatter) {
return {
success: true,
frontmatter: {},
message: `文件 ${csv_file} 没有 frontmatter,返回空对象`,
};
}
return {
success: true,
frontmatter,
message: `成功读取 ${csv_file} 的 frontmatter`,
};
} catch (error) {
return {
success: false,
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`;
}
@@ -0,0 +1,98 @@
import { readFileSync, writeFileSync, existsSync } from "fs";
import type { DeckFrontmatter } from "./read-frontmatter.js";
import { parseFrontMatter, serializeFrontMatter } from "./shared.js";
/**
* 写入 CSV frontmatter 的参数
*/
export interface WriteFrontmatterParams {
/**
* CSV 文件路径(相对路径相对于 MCP 服务器工作目录)
*/
csv_file: string;
/**
* 要写入的 frontmatter 数据
*/
frontmatter: DeckFrontmatter;
/**
* 是否合并现有 frontmatter(默认 true)
*/
merge?: boolean;
}
/**
* 写入 CSV frontmatter 的结果
*/
export interface WriteFrontmatterResult {
success: boolean;
message: string;
csv_file?: string;
}
/**
* 写入/更新 CSV 文件的 frontmatter
*/
export function writeFrontmatter(
params: WriteFrontmatterParams,
): WriteFrontmatterResult {
const { csv_file, frontmatter, merge = true } = params;
let csvContent = "";
let existingFrontmatter: DeckFrontmatter | undefined;
// 如果文件存在且需要合并,先读取现有内容
if (merge && existsSync(csv_file)) {
try {
const content = readFileSync(csv_file, "utf-8");
const result = parseFrontMatter(content);
existingFrontmatter = result.frontmatter;
csvContent = result.csvContent;
} catch (error) {
return {
success: false,
message: `读取现有文件失败:${error instanceof Error ? error.message : "未知错误"}`,
};
}
}
// 合并或替换 frontmatter
const finalFrontmatter =
merge && existingFrontmatter
? { ...existingFrontmatter, ...frontmatter }
: frontmatter;
// 序列化 frontmatter
const frontmatterStr = serializeFrontMatter(finalFrontmatter);
// 如果文件不存在或没有 CSV 内容,创建一个空的 CSV 内容
if (!csvContent.trim()) {
// 从 frontmatter 推断 CSV 表头
const headers = ["label"];
if (finalFrontmatter.fields && Array.isArray(finalFrontmatter.fields)) {
for (const field of finalFrontmatter.fields) {
if (field.name && typeof field.name === "string") {
headers.push(field.name);
}
}
}
headers.push("body");
csvContent = headers.join(",") + "\n";
}
// 写入文件
try {
const fullContent = frontmatterStr + csvContent;
writeFileSync(csv_file, fullContent, "utf-8");
return {
success: true,
message: `成功写入 frontmatter 到 ${csv_file}`,
csv_file,
};
} catch (error) {
return {
success: false,
message: `写入失败:${error instanceof Error ? error.message : "未知错误"}`,
};
}
}
+230
View File
@@ -0,0 +1,230 @@
import { writeFileSync, mkdirSync, existsSync } from "fs";
import { join } from "path";
import { buildDeckComponent, generateDeckPreviewMd } from "./deck-utils.js";
import type { DeckConfig } from "./frontmatter/read-frontmatter.js";
/**
* 字段样式配置
*
* 位置按照卡牌网格,安排 x,y,w,h 四个整数
* 如 5x8 网格中 1,1,5,8 表示覆盖整个卡牌的区域
*
* 字体使用 f:8 来表示 8mm 字体
*
* 朝向使用 u:n/w/s/e 表示字段的上侧朝向北西南东
*/
export interface CardFieldStyle {
/**
* 位置:[x, y, w, h] 网格坐标和尺寸
* 例如:[1, 1, 5, 8] 表示从 (1,1) 开始,宽 5 格,高 8 格
*/
pos?: [number, number, number, number];
/**
* 字体大小:格式 "f:8" 表示 8mm 字体
*/
font?: string;
/**
* 朝向:上侧朝向 "n" | "w" | "s" | "e"(北/西/南/东)
*/
up?: "n" | "w" | "s" | "e";
}
/**
* 卡牌字段定义
*/
export interface CardField {
name: string;
description?: string;
examples?: string[];
style?: CardFieldStyle;
}
/**
* 卡牌模板配置
*/
export interface CardTemplate {
fields: CardField[];
examples?: Record<string, string>[];
}
export type { DeckConfig };
/**
* 生成卡牌组的参数
*/
export interface GenerateCardDeckParams {
deck_name: string;
output_dir: string;
card_count?: number;
card_template?: CardTemplate;
deck_config?: DeckConfig;
description?: string;
}
/**
* 生成卡牌数据 CSV
*/
function generateCardCSV(template: CardTemplate, cardCount: number): string {
const fields = template.fields;
// 构建 CSV 表头
const headers = ["label", ...fields.map((f) => f.name), "body"];
// 生成示例数据
const rows: string[][] = [];
const examples = template.examples || [];
for (let i = 0; i < cardCount; i++) {
const row: string[] = [(i + 1).toString()];
// 为每个字段生成值
for (const field of fields) {
let value = "";
if (examples.length > 0) {
// 从示例中循环取值
const exampleIndex = i % examples.length;
const example = examples[exampleIndex];
value =
example[field.name] ||
field.examples?.[i % (field.examples?.length || 1)] ||
"";
} else if (field.examples && field.examples.length > 0) {
// 从字段的示例中取值
value = field.examples[i % field.examples.length];
} else {
// 默认占位符
value = `{{${field.name}_${i + 1}}}`;
}
row.push(value);
}
// body 列使用模板语法
const bodyParts: string[] = [];
for (const field of fields) {
bodyParts.push(`**${field.name}:** {{${field.name}}}`);
}
row.push(bodyParts.join("\n\n"));
rows.push(row);
}
// 组合 CSV 内容
const csvLines = [headers.join(",")];
for (const row of rows) {
csvLines.push(
row
.map((cell) => {
// 处理包含逗号或换行的单元格
if (cell.includes(",") || cell.includes("\n") || cell.includes('"')) {
return `"${cell.replace(/"/g, '""')}"`;
}
return cell;
})
.join(","),
);
}
return csvLines.join("\n");
}
/**
* 自动生成图层配置
*/
function autoGenerateLayers(fields: CardField[]): string {
if (fields.length === 0) return "";
const layers: string[] = [];
const totalHeight = 8;
const heightPerField = Math.floor((totalHeight - 2) / fields.length);
for (let i = 0; i < fields.length; i++) {
const field = fields[i];
const y1 = 2 + i * heightPerField;
const y2 = y1 + heightPerField - 1;
const fontSize = Math.min(12, Math.floor(80 / fields.length));
layers.push(`${field.name}:1,${y1}-${y2},${fontSize}`);
}
return layers.join(" ");
}
/**
* 生成卡牌组的主函数
*/
export function generateCardDeck(params: GenerateCardDeckParams): {
mdFile: string;
csvFile: string;
deckComponent: string;
message: string;
} {
const {
deck_name,
output_dir,
card_count = 10,
card_template,
deck_config = {},
description,
} = params;
// 确保输出目录存在
if (!existsSync(output_dir)) {
mkdirSync(output_dir, { recursive: true });
}
// 生成文件名
const safeName = deck_name.toLowerCase().replace(/\s+/g, "-");
const csvFileName = `${safeName}.csv`;
const mdFileName = `${safeName}.md`;
// 创建默认模板(如果没有提供)
const template: CardTemplate = card_template || {
fields: [
{
name: "name",
description: "卡牌名称",
examples: ["示例卡牌 1", "示例卡牌 2"],
},
{ name: "type", description: "卡牌类型", examples: ["物品", "法术"] },
{ name: "cost", description: "费用", examples: ["1", "2"] },
{
name: "description",
description: "效果描述",
examples: ["这是一个效果描述", "这是另一个效果"],
},
],
};
// 创建默认配置(如果没有提供)
const config: DeckConfig = {
size: deck_config.size || "54x86",
grid: deck_config.grid || "5x8",
bleed: deck_config.bleed ?? 1,
padding: deck_config.padding ?? 2,
shape: deck_config.shape || "rectangle",
layers: deck_config.layers || autoGenerateLayers(template.fields),
};
// 生成 CSV 内容
const csvContent = generateCardCSV(template, card_count);
const csvPath = join(output_dir, csvFileName);
writeFileSync(csvPath, csvContent, "utf-8");
// 生成 Markdown 内容
const deckComponent = buildDeckComponent(`./${csvFileName}`, config);
const mdContent = generateDeckPreviewMd(
deck_name,
deckComponent,
description,
);
const mdPath = join(output_dir, mdFileName);
writeFileSync(mdPath, mdContent, "utf-8");
return {
mdFile: mdPath,
csvFile: csvPath,
deckComponent,
message: `已生成卡牌组 "${deck_name}":\n- Markdown 文件:${mdPath}\n- CSV 数据:${csvPath}\n- 组件代码:${deckComponent}`,
};
}
+156
View File
@@ -0,0 +1,156 @@
import { readFileSync, writeFileSync, existsSync } from "fs";
import { relative } from "path";
import { execSync } from "child_process";
import {
buildDeckComponent,
titleFromCsvPath,
defaultMdPath,
readDeckConfig,
generateDeckPreviewMd,
} from "./deck-utils.js";
/**
* 预览 Deck 的参数
*/
export interface PreviewDeckParams {
/**
* CSV 文件路径
*/
csv_file: string;
/**
* Markdown 文件路径(可选,默认与 CSV 同名)
*/
md_file?: string;
/**
* 标题(可选,默认从 CSV 文件名推断)
*/
title?: string;
/**
* 描述(可选)
*/
description?: string;
}
/**
* 预览 Deck 的结果
*/
export interface PreviewDeckResult {
success: boolean;
message: string;
md_file?: string;
created?: boolean;
preview_url?: string;
}
/**
* 打开浏览器
*/
function openBrowser(url: string) {
try {
if (process.platform === "win32") {
execSync(`start "" "${url}"`, { stdio: "ignore" });
} else if (process.platform === "darwin") {
execSync(`open "${url}"`, { stdio: "ignore" });
} else {
execSync(`xdg-open "${url}"`, { stdio: "ignore" });
}
console.error(`已打开浏览器:${url}`);
} catch (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 并打开浏览器
*/
export function previewDeck(params: PreviewDeckParams): PreviewDeckResult {
const { csv_file, md_file, title, description } = params;
// 检查 CSV 文件是否存在
if (!existsSync(csv_file)) {
return {
success: false,
message: `CSV 文件不存在:${csv_file}`,
};
}
// 确定 Markdown 文件路径
const mdFilePath = md_file || defaultMdPath(csv_file);
const created = !existsSync(mdFilePath);
// 如果文件已存在,检查是否已有 md-deck 组件
if (!created) {
try {
const existingContent = readFileSync(mdFilePath, "utf-8");
if (
existingContent.includes(`:md-deck[${csv_file}]`) ||
existingContent.includes(`:md-deck[./${csv_file.split("/").pop()}]`)
) {
const previewUrl = computePreviewUrl(mdFilePath);
openBrowser(previewUrl);
return {
success: true,
message: `预览文件 ${mdFilePath} 已存在`,
md_file: mdFilePath,
created: false,
preview_url: previewUrl,
};
}
} catch {
// 读取失败,继续创建
}
}
// 读取 CSV 的 frontmatter 获取配置
const deckConfig = readDeckConfig(csv_file);
// 确定标题
const deckTitle = title || titleFromCsvPath(csv_file);
// 构建 md-deck 组件代码
const deckComponent = buildDeckComponent(
`./${csv_file.split("/").pop()}`,
deckConfig,
);
// 生成 Markdown 内容
const mdContent = generateDeckPreviewMd(
deckTitle,
deckComponent,
description,
);
// 写入文件
try {
writeFileSync(mdFilePath, mdContent, "utf-8");
const previewUrl = computePreviewUrl(mdFilePath);
openBrowser(previewUrl);
return {
success: true,
message: created
? `创建预览文件 ${mdFilePath}`
: `更新预览文件 ${mdFilePath}`,
md_file: mdFilePath,
created,
preview_url: previewUrl,
};
} catch (error) {
return {
success: false,
message: `写入失败:${error instanceof Error ? error.message : "未知错误"}`,
};
}
}
+20 -2
View File
@@ -1,13 +1,31 @@
export interface ServeOptions { export interface ServeOptions {
port: string; port: string;
host?: string;
} }
export interface CompileOptions { 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");
});
});
+111 -11
View File
@@ -1,44 +1,137 @@
import { Component, createSignal, createEffect, onCleanup, Show, createResource } from 'solid-js'; import {
import { parseMarkdown } from '../markdown'; Component,
import { fetchData, extractSection } from '../data-loader'; ParentProps,
createContext,
useContext,
createEffect,
onCleanup,
Show,
createResource,
createSignal,
} from "solid-js";
import { parseMarkdown } from "../markdown";
import { extractSection } from "../data-loader";
import mermaid from "mermaid";
import { getIndexedData } from "../data-loader/file-index";
import { useNavigateWithParams } from "./useNavigateWithParams";
export interface ArticleProps { export interface ArticleProps {
src: string; src: string;
section?: string; // 指定要显示的标题(不含 #) section?: string; // 指定要显示的标题(不含 #)
onLoaded?: () => void; onLoaded?: () => void;
onError?: (error: Error) => void; onError?: (error: Error) => void;
class?: string; // 额外的 class 用于样式控制 class?: string; // 额外的 class 用于样式控制
scrollToHash?: boolean; // 是否自动滚动到 hash
} }
async function fetchArticleContent(params: { src: string; section?: string }): Promise<string> { // ---------------------------------------------------------------------------
const text = await fetchData(params.src); // Article DOM context – lets child components react to the content container
// ---------------------------------------------------------------------------
const ArticleDomCtx = createContext<() => HTMLDivElement | undefined>();
/** Access the article's content DOM element from a child component. */
export function useArticleDom(): () => HTMLDivElement | undefined {
const ctx = useContext(ArticleDomCtx);
if (!ctx) throw new Error("useArticleDom must be used inside an <Article>");
return ctx;
}
async function fetchArticleContent(params: {
src: string;
section?: string;
}): Promise<string> {
const text = await getIndexedData(params.src);
// 如果指定了 section,提取对应内容 // 如果指定了 section,提取对应内容
return params.section ? extractSection(text, params.section) : text; return params.section ? extractSection(text, params.section) : text;
} }
/**
* 滚动到指定的 hash 元素
*/
function scrollToHash(hash: string) {
if (!hash) return;
// 移除 # 前缀
const id = hash.startsWith("#") ? hash.slice(1) : hash;
if (!id) return;
// 使用 decodeURIComponent 解码 ID(处理中文等特殊字符)
const decodedId = decodeURIComponent(id);
// 尝试查找元素
const element = document.getElementById(decodedId);
if (element) {
// 使用 scrollIntoView 滚动到元素
element.scrollIntoView({ behavior: "instant", block: "start" });
return true;
}
return false;
}
/** /**
* Article 组件 * Article 组件
* 用于将特定 src 位置的 md 文件显示为 markdown 文章 * 用于将特定 src 位置的 md 文件显示为 markdown 文章
*/ */
export const Article: Component<ArticleProps> = (props) => { export const Article: Component<ArticleProps & ParentProps> = (props) => {
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>();
createEffect(() => { createEffect(() => {
const data = content(); const data = content();
if (data) { if (data) {
props.onLoaded?.(); props.onLoaded?.();
// 内容加载完成后,渲染 mermaid 图表
void mermaid.run();
// 内容渲染后检查 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>
@@ -46,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()!)} /> <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;
+99 -27
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,35 +103,83 @@ 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 anchor = props.node.title.toLowerCase().replace(/\s+/g, "-"); const location = useLocation();
const href = `${props.basePath}#${anchor}`; const anchor = props.node.id || "";
const href = () => `${props.basePath}${location.search}#${anchor}`;
const hasChildren = !!props.node.children;
// 默认收起,除非当前锚点在该节点内
const [isExpanded, setIsExpanded] = createSignal(props.depth <= 0);
const handleExpand = (e: MouseEvent) => {
e.preventDefault();
if (hasChildren) {
setIsExpanded(!isExpanded());
}
};
const scrollContainer = useScrollContainer();
const handleClick = (e: MouseEvent) => { const handleClick = (e: MouseEvent) => {
e.preventDefault(); if (e.button === 0) {
navigate(href); // Left-click: use SPA navigation
e.preventDefault();
navigate(`${props.basePath}#${anchor}`);
}
// Middle-click / right-click: let the browser handle the <a> natively
// 滚动到目标元素,考虑导航栏高度偏移
requestAnimationFrame(() => {
const element = document.getElementById(anchor);
if (element) {
const scroller = scrollContainer();
const navBarHeight = 80;
const elementPosition = element.getBoundingClientRect().top;
const containerTop = scroller?.getBoundingClientRect().top ?? 0;
const relativePosition = elementPosition - containerTop;
const currentScroll = scroller?.scrollTop ?? window.scrollY;
const offsetPosition = currentScroll + relativePosition - navBarHeight;
if (scroller) {
scroller.scrollTo({ top: offsetPosition, behavior: "instant" });
} else {
window.scrollTo({ top: offsetPosition, behavior: "instant" });
}
}
});
}; };
const indent = props.depth * 12; const indent = props.depth * 12;
return ( return (
<div> <div class={props.isHidden?.(props.node) === true ? "hidden" : ""}>
<a <div class="flex flex-row">
href={href} <span
class="block py-0.5 px-2 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-50 rounded truncate" class={`cursor-pointer mr-1 text-gray-400 text-xs w-3 shrink-0`}
style={{ "padding-left": `${indent + 8}px` }} style={{ "margin-left": `${indent + 8}px` }}
onClick={handleClick} onClick={handleExpand}
> >
{props.node.title} <span
</a> class={`${hasChildren ? "" : "invisible"} ${isExpanded() ? "rotate-90" : ""} mt-1 inline-block transition-transform `}
<Show when={props.node.children}> >
<TbOutlineChevronRight class="inline-block align-[-0.125em]" />
</span>
</span>
<a
href={href()}
class="inline-flex items-center py-0.5 px-2 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-50 rounded truncate cursor-pointer"
onClick={handleClick}
>
{props.node.title}
</a>
</div>
<Show when={hasChildren && isExpanded()}>
<div> <div>
{props.node.children!.map((child) => ( {props.node.children!.map((child) => (
<HeadingNode <HeadingNode
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;

Some files were not shown because too many files have changed in this diff Show More