Compare commits

..
82 Commits
Author SHA1 Message Date
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
128 changed files with 7897 additions and 4909 deletions
+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.
+6 -11
View File
@@ -1,18 +1,12 @@
# TTRPG Tools
# TTTK — Tabletop Toolkit
一个基于 `solid.js` `rsbuild` 的 TTRPG 工具箱,支持 Markdown 解析、自定义组件和 CLI 工具。
A TTRPG content preview and compilation tool based on `solid.js` and `rsbuild`. Write markdown, preview in browser.
## 快速开始
```bash
# 安装依赖
npm install
# 全局安装 CLI
npm link
# 预览内容
ttrpg serve ./content
# 无需安装,直接使用
npx tttk serve ./content
```
## 文档导航
@@ -20,6 +14,7 @@ ttrpg serve ./content
| 文档 | 说明 |
|------|------|
| [📖 CLI 使用说明](./docs/cli.md) | CLI 安装、命令和用法 |
| [📦 npm](https://www.npmjs.com/package/tttk) | `npx tttk serve ./content` |
| [🛠️ 开发指南](./docs/development.md) | 项目结构、开发规范和构建 |
| [📝 Markdown 编写说明](./docs/markdown.md) | Markdown 语法和组件用法 |
| [📊 CSV 编写说明](./docs/csv.md) | CSV 文件格式、字段定义、变量语法 |
@@ -27,7 +22,7 @@ ttrpg serve ./content
## 功能概览
- **CLI 工具**: `serve` 预览模式 和 `compile` 编译模式
- **CLI 工具**: `tttk serve` 预览模式 和 `tttk compile` 编译模式
- **Markdown 解析**: 支持指令语法、YAML 标签、mermaid 图表
- **TTRPG 组件**: 骰子、表格、卡牌、标记、命令追踪器等
+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;
+24 -4
View File
@@ -1,13 +1,12 @@
# yaml/tag 代码块格式测试
# yaml role=tag 代码块格式测试
:md-deck[./names.csv]{size="54x86" grid="5x8" bleed="1" padding="2" layers="name1:1,1-2,2f12 name2:4,7-5,8f12s num1:1,3-2,4f12 num2:4,5-5,6f12s"}
:md-deck[./names.csv]{size="54x86" grid="5x8" layers="num1:1,1-2,2 num2:4,5-5,6f12s name1:1,1-2,2 name2:1,1-2,2 name1:1,1-2,2" }
## 使用 yaml/tag 语法创建 md-deck
## 使用 yaml role=tag 语法创建 md-deck(fence 行内联 tag,简洁写法)
```yaml/tag
tag: md-deck
```yaml role=tag tag=md-deck
body: ./names.csv
size: 54x86
grid: 5x8
@@ -15,3 +14,24 @@ bleed: 1
padding: 2
layers: name1:1,1-2,2f12 name2:4,7-5,8f12s num1:1,3-2,4f12 num2:4,5-5,6f12s
```
```yaml role=tag
tag: md-deck
body: ./names.csv
data-config:
size: 54x86
grid: 5x8
layers:
- prop: name1
pos: 1,1-2,2
font: 12
- prop: num2
pos: 4,5-5,6
font: 12
orientation: s
- template: |
**{{name1}}** / {{num1}}
pos: 1,3-5,4
font: 6
align: l
```
+16 -16
View File
@@ -1,18 +1,18 @@
# CLI 使用说明
TTRPG Tools 提供一个 CLI 工具,用于将目录内的各种 TTRPG 文档编译为 HTML。
TTTK 提供一个 CLI 工具,用于将目录内的各种 TTRPG 文档编译为 HTML。
## 安装
```bash
# 克隆仓库后安装依赖
npm install
# 无需安装,直接运行
npx tttk serve ./content
# 全局链接 CLI 工具
npm link
# 全局安装
npm install -g tttk
```
安装完成后,可在任意目录使用 `ttrpg` 命令。
安装完成后,可在任意目录使用 `tttk` 命令。
## 命令
@@ -23,7 +23,7 @@ CLI 使用子命令组织:
运行一个 Web 服务器预览目录中的内容,并实时监听文件更新。
```bash
ttrpg serve [dir] -p 3000
tttk serve [dir] -p 3000
```
**参数:**
@@ -50,13 +50,13 @@ ttrpg serve [dir] -p 3000
```bash
# 预览当前目录
ttrpg serve
tttk serve
# 预览指定目录
ttrpg serve ./my-ttrpg-content
tttk serve ./my-ttrpg-content
# 指定端口
ttrpg serve ./docs -p 8080
tttk serve ./docs -p 8080
```
### compile - 编译模式
@@ -64,7 +64,7 @@ ttrpg serve ./docs -p 8080
将目录中的内容输出为带 hash 路由、单个 HTML 入口的 Web 应用。
```bash
ttrpg compile [dir] -o ./dist/output
tttk compile [dir] -o ./dist/output
```
**参数:**
@@ -90,13 +90,13 @@ ttrpg compile [dir] -o ./dist/output
```bash
# 编译当前目录
ttrpg compile
tttk compile
# 编译指定目录并输出到指定位置
ttrpg compile ./content -o ./build
tttk compile ./content -o ./build
# 编译并部署
ttrpg compile ./docs -o ./public && npm run preview
tttk compile ./docs -o ./public && npm run preview
```
## 输入文件
@@ -162,7 +162,7 @@ my-ttrpg-content/
```bash
# 使用其他端口
ttrpg serve -p 8080
tttk serve -p 8080
```
### 编译输出为空
@@ -174,5 +174,5 @@ ttrpg serve -p 8080
ls -R ./content
# 重新编译
ttrpg compile ./content -o ./dist/output
tttk compile ./content -o ./dist/output
```
+1 -1
View File
@@ -28,7 +28,7 @@ npm test
## 项目结构
```
ttrpg-tools/
tttk/
├── src/
│ ├── cli/ # CLI 工具源码
│ │ ├── commands/ # 命令实现 (serve, compile)
+55 -16
View File
@@ -373,26 +373,30 @@ label,name,description
:md-table[./quests.csv]{roll=true remix=true}
```
**自动表格转换**
**内联表格(显式声明)**
标准 Markdown 表格会自动转换`md-table` 组件,当表头包含 `label``md-table-label` 列时
Markdown 表格会自动转换。如需内联表格,用代码块并声明 `role=spark-table`(首列需为骰子公式,如 `d6`
```markdown
| label | name | description |
|-------|------|-------------|
| 1 | 战士 | 近战专家 |
| 2 | 法师 | 奥术施法者 |
````markdown
```markdown role=spark-table
| d6 | 结果 |
|----|------|
| 1 | 遭遇强盗 |
| 2 | 平安无事 |
```
````
自动转换为 `:md-table` 组件。
扫描时转换为 CSV 并渲染为 `md-table` 组件。CSV 格式的内联表格用 `csv` 语言:
**特殊表头标识:**
````markdown
```csv role=spark-table
d6,结果
1,遭遇强盗
2,平安无事
```
````
| 表头 | 效果 |
|------|------|
| `label``md-table-label` | 转换为 md-table |
| `md-roll-label` 或骰子格式(如 `1d6` | 添加 `roll=true` |
| `md-remix-label` | 添加 `roll=true remix=true` |
普通 Markdown 表格(无 role 声明)始终按标准 GFM 表格渲染,不做任何转换。
### 🃏 卡牌组件 (md-deck)
@@ -423,6 +427,32 @@ 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-yarn-spinner)
用于展示分支叙事结构,支持 Yarn Spinner 格式文件。
@@ -463,10 +493,10 @@ track npc#john.dwarf.warrior[hp=4/4 ac=15 name="John"]
## YAML 标签
使用 ```yaml/tag 代码块创建自定义标签:
使用 ```yaml role=tag 代码块创建自定义标签`yaml` 语言可被语法高亮)
````markdown
```yaml/tag
```yaml role=tag
tag: tag-name
class: custom-class
id: my-id
@@ -474,6 +504,15 @@ body: 标签内容
```
````
`tag`、`id` 等简单属性也可以直接写在 fence 行上(fence 行属性优先于 YAML 内容):
````markdown
```yaml role=tag tag=tag-name id=my-id
class: custom-class
body: 标签内容
```
````
渲染为:
```html
+2 -1
View File
@@ -535,7 +535,8 @@ src/cli/
│ │ └── write-frontmatter.ts
│ ├── card/
│ │ └── card-crud.ts
│ ├── ensure-deck-preview.ts
│ ├── deck-utils.ts
│ ├── preview-deck.ts
│ └── generate-card-deck.ts
└── index.ts
```
+8
View File
@@ -4,6 +4,14 @@ export default {
testEnvironment: 'node',
testMatch: ['**/*.test.ts'],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
moduleNameMapper: {
// Resolve .js imports to .ts source files (ESM convention in TS source)
'^(.+)\\.js$': ['$1.ts', '$1.tsx', '$1.js'],
// github-slugger v2 is ESM-only; jest's CJS runtime cannot require it.
'^github-slugger$': '<rootDir>/__mocks__/github-slugger.js',
// Same for the browser ESM build of csv-parse — map to the CJS build.
'^csv-parse/browser/esm/sync$': 'csv-parse/sync',
},
transform: {
'^.+\\.tsx?$': [
'ts-jest',
+13 -4
View File
@@ -1,11 +1,11 @@
{
"name": "ttrpg-tools",
"name": "tttk",
"version": "0.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ttrpg-tools",
"name": "tttk",
"version": "0.0.1",
"license": "MIT",
"dependencies": {
@@ -14,6 +14,7 @@
"@thisbeyond/solid-dnd": "^0.7.5",
"aedes": "^1.1.1",
"chokidar": "^5.0.0",
"comlink": "^4.4.2",
"commander": "^14.0.3",
"csv-parse": "^6.1.0",
"csv-stringify": "^6.7.0",
@@ -32,8 +33,7 @@
"zod": "^4.4.3"
},
"bin": {
"ttrpg": "dist/cli/index.js",
"ttt": "dist/cli/index.js"
"tttk": "dist/cli/index.js"
},
"devDependencies": {
"@rsbuild/core": "^1.1.8",
@@ -56,6 +56,9 @@
"ts-node": "^10.9.2",
"typescript": "^5.9.3",
"webpack-bundle-analyzer": "^5.3.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@alloc/quick-lru": {
@@ -4330,6 +4333,12 @@
"node": ">= 0.8"
}
},
"node_modules/comlink": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/comlink/-/comlink-4.4.2.tgz",
"integrity": "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g==",
"license": "Apache-2.0"
},
"node_modules/commander": {
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
+23 -14
View File
@@ -1,29 +1,37 @@
{
"name": "ttrpg-tools",
"name": "tttk",
"version": "0.0.1",
"description": "A tabletop RPG toolbox based on solid.js and rsbuild",
"description": "A tabletop RPG toolkit for previewing and compiling TTRPG campaign content",
"type": "module",
"bin": {
"ttrpg": "./dist/cli/index.js",
"ttt": "./dist/cli/index.js"
"tttk": "./dist/cli/index.js"
},
"main": "./dist/index.js",
"module": "./dist/index.js",
"exports": {
".": "./dist/index.js",
"./components": "./dist/components/index.js",
"./markdown": "./dist/markdown/index.js"
"files": [
"dist",
"LICENSE",
"README.md"
],
"repository": {
"type": "git",
"url": "https://github.com/hypercross/ttrpg-tools"
},
"engines": {
"node": ">=18"
},
"scripts": {
"dev": "rsbuild dev",
"build": "rsbuild build",
"build": "npm run build:web && npm run build:cli",
"build:web": "rsbuild build",
"build:cli": "tsc -p tsconfig.cli.json",
"preview": "rsbuild preview",
"analyze": "cross-env ANALYZE=true rsbuild build",
"cli:dev": "tsc -p tsconfig.cli.json --watch",
"cli:build": "tsc -p tsconfig.cli.json",
"cli:build": "npm run build:cli",
"ttrpg": "node --loader ts-node/esm ./src/cli/index.ts",
"tttk": "node --loader ts-node/esm ./src/cli/index.ts",
"test": "jest",
"prepare": "npx husky"
"prepare": "npx husky",
"prepublishOnly": "npm run build"
},
"keywords": [
"ttrpg",
@@ -31,7 +39,7 @@
"markdown",
"toolbox"
],
"author": "",
"author": "hypercross",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^0.5.0",
@@ -39,6 +47,7 @@
"@thisbeyond/solid-dnd": "^0.7.5",
"aedes": "^1.1.1",
"chokidar": "^5.0.0",
"comlink": "^4.4.2",
"commander": "^14.0.3",
"csv-parse": "^6.1.0",
"csv-stringify": "^6.7.0",
+12 -6
View File
@@ -21,8 +21,8 @@ export default defineConfig({
module: {
rules: [
{
test: /\.md|\.yarn|\.csv$/,
exclude: /\.schema\.csv$/,
// Built-in doc entries are statically imported as strings
test: /[\\/]doc-entries[\\/].*\.md$/,
type: "asset/source",
},
],
@@ -40,13 +40,19 @@ export default defineConfig({
index: "./src/main.tsx",
},
},
server: {
proxy: {
// Proxy content API requests to the CLI dev server (npm run tttk)
"/__CONTENT_INDEX.json": "http://localhost:3000",
"/__COMPLETIONS.json": "http://localhost:3000",
"/content": "http://localhost:3000",
"/.ttrpg": "http://localhost:3000",
},
historyApiFallback: true,
},
output: {
distPath: {
root: "dist/web",
},
copy:
process.env.NODE_ENV === "development"
? [{ from: "./content", to: "content" }]
: [],
},
});
+9 -2
View File
@@ -18,9 +18,12 @@ import {
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)
@@ -68,6 +71,8 @@ const App: Component = () => {
void loadToc();
});
useHeadingFlash();
const handleSourceChanged = () => {
setTocKey((k) => k + 1);
};
@@ -96,7 +101,7 @@ const App: Component = () => {
>
</button>
<h1 class="text-2xl font-bold text-gray-900">Tabletop Tools</h1>
<h1 class="text-2xl font-bold text-gray-900">Tabletop Toolkit</h1>
<div class="flex-1" />
<button
onClick={() => setIsJournalOpen((v) => !v)}
@@ -152,10 +157,12 @@ const App: Component = () => {
<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 text-black prose-sm max-w-full"
class="prose prose-sm max-w-full"
src={currentPath()}
>
<RevealManager />
<ReactiveVariableManager />
<CommandLinkManager />
</Article>
</div>
</main>
+4 -4
View File
@@ -1,8 +1,8 @@
import type { CompileCommandHandler } from '../types.js';
import type { CompileCommandHandler } from "../types.js";
export const compileCommand: CompileCommandHandler = async (dir, options) => {
console.log(`开始编译...`);
console.log(`目录:${dir}`);
console.log("开始编译...");
console.log(`目录:${dir}`);
console.log(`输出目录:${options.output}`);
// TODO: 实现编译逻辑
@@ -10,5 +10,5 @@ export const compileCommand: CompileCommandHandler = async (dir, options) => {
// 2. 解析 markdown 并生成路由
// 3. 打包为带 hash 路由的单个 HTML 入口
console.log('编译完成!');
console.log("编译完成!");
};
+70 -99
View File
@@ -2,33 +2,32 @@ import type { ServeCommandHandler } from "../types.js";
import { createServer, Server, IncomingMessage, ServerResponse } from "http";
import { readdirSync, statSync, readFileSync, existsSync } from "fs";
import { createReadStream } from "fs";
import { join, resolve, extname, sep, relative, dirname } from "path";
import { join, resolve, extname, relative, dirname } from "path";
import { watch } from "chokidar";
import { networkInterfaces } from "os";
import { fileURLToPath } from "url";
import { createJournalServer } from "../journal.js";
import {
scanCompletions,
type CompletionsPayload,
} from "../completions/index.js";
import {
processBlocks,
type ProcessedBlocks,
} from "../completions/block-processor.js";
buildRegistryFromIndex,
normalizePathKey,
deriveCompletions,
type ContentRegistry,
} from "../content-registry.js";
import type { CompletionsPayload } from "../completions/types.js";
interface ContentIndex {
[path: string]: string;
}
/**
* 获取 CLI 脚本文件所在目录路径(用于定位 dist 文件夹
* 获取 CLI 脚本文件所在目录路径(用于定位 dist 目录
*/
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const distDir = resolve(__dirname, "..", "..", "..", "dist", "web");
/**
* MIME 类型映射
* MIME 类型映射
*/
const MIME_TYPES: Record<string, string> = {
".html": "text/html",
@@ -47,7 +46,7 @@ const MIME_TYPES: Record<string, string> = {
};
/**
* 获取文件扩展名对应的 MIME 类型
* 根据文件扩展名获取对应的 MIME 类型
*/
function getMimeType(filePath: string): string {
const ext = extname(filePath).toLowerCase();
@@ -55,7 +54,7 @@ function getMimeType(filePath: string): string {
}
/**
* Get the best network IP for display (prefer LAN IPv4, fallback to localhost).
* 获取最佳网络 IP 用于显示(优先局域网 IPv4,回退到 localhost
*/
function getBestIP(): string {
const interfaces = networkInterfaces();
@@ -84,18 +83,10 @@ function getBestIP(): string {
}
/**
* 扫描目录内的 .md 文件,生成索引
* 扫描目录内的 .md 文件,构建内容注册表(路径索引 + 每文档内联内容)
*/
export function scanDirectory(dir: string): {
index: ContentIndex;
blocks: ProcessedBlocks;
} {
const index: ContentIndex = {};
const blocks: ProcessedBlocks = {
stats: [],
statTemplates: [],
sparkTables: [],
};
export function buildRegistry(dir: string): ContentRegistry {
const index: Record<string, string> = {};
function scan(currentPath: string, relativePath: string) {
const entries = readdirSync(currentPath);
@@ -105,7 +96,7 @@ export function scanDirectory(dir: string): {
const fullPath = join(currentPath, entry);
const relPath = relativePath ? join(relativePath, entry) : entry;
const normalizedRelPath = "/" + relPath.split(sep).join("/");
const normalizedRelPath = normalizePathKey(relPath);
const stats = statSync(fullPath);
if (stats.isDirectory()) {
@@ -113,19 +104,11 @@ export function scanDirectory(dir: string): {
} else if (
entry.endsWith(".md") ||
entry.endsWith(".csv") ||
entry.endsWith(".yarn")
entry.endsWith(".yarn") ||
entry.endsWith(".svg")
) {
try {
const content = readFileSync(fullPath, "utf-8");
if (entry.endsWith(".md")) {
const result = processBlocks(content, normalizedRelPath, index);
index[normalizedRelPath] = result.stripped;
blocks.stats.push(...result.blocks.stats);
blocks.statTemplates.push(...result.blocks.statTemplates);
blocks.sparkTables.push(...result.blocks.sparkTables);
} else {
index[normalizedRelPath] = content;
}
index[normalizedRelPath] = readFileSync(fullPath, "utf-8");
} catch (e) {
console.error(`读取文件失败:${fullPath}`, e);
}
@@ -134,11 +117,12 @@ export function scanDirectory(dir: string): {
}
scan(dir, "");
return { index, blocks };
return buildRegistryFromIndex(index);
}
/**
* 发送文件响应
* 发送文件响应(流式传输)
*/
function sendFile(res: ServerResponse, filePath: string) {
res.writeHead(200, {
@@ -149,7 +133,7 @@ function sendFile(res: ServerResponse, filePath: string) {
}
/**
* 发送 404 响应
* 发送 404 未找到响应
*/
function send404(res: ServerResponse) {
res.writeHead(404, { "Content-Type": "text/plain" });
@@ -157,7 +141,7 @@ function send404(res: ServerResponse) {
}
/**
* 发送 JSON 响应
* 发送 JSON 格式的响应
*/
function sendJson(res: ServerResponse, data: unknown) {
res.writeHead(200, {
@@ -169,7 +153,7 @@ function sendJson(res: ServerResponse, data: unknown) {
/**
* 尝试提供静态文件
* @returns 如果文件存在并成功发送返回 true
* @returns 文件存在并成功发送返回 true
*/
function tryServeStatic(
res: ServerResponse,
@@ -192,13 +176,14 @@ function tryServeStatic(
}
/**
* 创建请求处理器
* 创建 HTTP 请求处理器
*/
function createRequestHandler(
contentDir: string,
distDir: string,
getIndex: () => ContentIndex,
getCompletions: () => CompletionsPayload,
getRegistry: () => ContentRegistry,
) {
return (req: IncomingMessage, res: ServerResponse) => {
const url = req.url || "/";
@@ -216,6 +201,12 @@ function createRequestHandler(
return;
}
// 1c. 处理 /__CONTENT_REGISTRY.json(含每文档内联内容,供运行时解析)
if (filePath === "/__CONTENT_REGISTRY.json") {
sendJson(res, getRegistry());
return;
}
// 2. 处理 /static/ 目录(从 dist/web
if (filePath.startsWith("/static/")) {
if (tryServeStatic(res, filePath, distDir)) {
@@ -242,7 +233,7 @@ function createRequestHandler(
}
/**
* 内容服务器接口
* 内容服务器实例接口
*/
export interface ContentServer {
/**
@@ -268,7 +259,7 @@ export interface ContentServer {
}
/**
* 创建内容服务器
* 创建内容服务器HTTP + WebSocket + 文件监听)
*/
export function createContentServer(
contentDir: string,
@@ -276,38 +267,31 @@ export function createContentServer(
distPath: string = distDir,
host: string = "0.0.0.0",
): ContentServer {
let contentIndex: ContentIndex = {};
let collectedBlocks: ProcessedBlocks = {
stats: [],
statTemplates: [],
sparkTables: [],
};
let registry: ContentRegistry = { pathIndex: {}, docContent: {} };
let completionsIndex: CompletionsPayload = {
dice: [],
links: [],
sparkTables: [],
stats: [],
statTemplates: [],
declarations: [],
tagModifiers: [],
};
/** Re-scan completions from current content index and collected blocks */
/** 从当前注册表重新派生补全数据 */
function recomputeCompletions(): void {
completionsIndex = scanCompletions(contentIndex, collectedBlocks);
completionsIndex = deriveCompletions(registry);
console.log(
`[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length} stats=${completionsIndex.stats.length} templates=${completionsIndex.statTemplates.length}`,
`[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length} declarations=${completionsIndex.declarations.length} tagModifiers=${completionsIndex.tagModifiers.length}`,
);
}
// 扫描内容目录生成索引
console.log("扫描内容目录...");
const scanResult = scanDirectory(contentDir);
contentIndex = scanResult.index;
collectedBlocks = scanResult.blocks;
console.log(`已索引 ${Object.keys(contentIndex).length} 个文件`);
// 扫描内容目录生成注册表
console.log("正在扫描内容目录...");
registry = buildRegistry(contentDir);
console.log(`已索引 ${Object.keys(registry.pathIndex).length} 个文件`);
recomputeCompletions();
// 监听文件变化
console.log("监听文件变化...");
console.log("正在监听文件变化...");
const watcher = watch(contentDir, {
ignored: /(^|[\/\\])\./,
persistent: true,
@@ -319,22 +303,14 @@ export function createContentServer(
if (
path.endsWith(".md") ||
path.endsWith(".csv") ||
path.endsWith(".yarn")
path.endsWith(".yarn") ||
path.endsWith(".svg")
) {
try {
const content = readFileSync(path, "utf-8");
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
if (relPath.endsWith(".md")) {
const result = processBlocks(content, relPath, contentIndex);
contentIndex[relPath] = result.stripped;
// Re-scan to get fresh blocks (simpler than per-file merge)
const rescan = scanDirectory(contentDir);
collectedBlocks = rescan.blocks;
// 全量重建注册表以刷新跨文件派生(spark 注入)
registry = buildRegistry(contentDir);
recomputeCompletions();
} else {
contentIndex[relPath] = content;
}
console.log(`[新增] ${relPath}`);
console.log(`[新增] ${path}`);
} catch (e) {
console.error(`读取新增文件失败:${path}`, e);
}
@@ -344,21 +320,13 @@ export function createContentServer(
if (
path.endsWith(".md") ||
path.endsWith(".csv") ||
path.endsWith(".yarn")
path.endsWith(".yarn") ||
path.endsWith(".svg")
) {
try {
const content = readFileSync(path, "utf-8");
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
if (relPath.endsWith(".md")) {
const result = processBlocks(content, relPath, contentIndex);
contentIndex[relPath] = result.stripped;
const rescan = scanDirectory(contentDir);
collectedBlocks = rescan.blocks;
registry = buildRegistry(contentDir);
recomputeCompletions();
} else {
contentIndex[relPath] = content;
}
console.log(`[更新] ${relPath}`);
console.log(`[更新] ${path}`);
} catch (e) {
console.error(`读取更新文件失败:${path}`, e);
}
@@ -368,28 +336,25 @@ export function createContentServer(
if (
path.endsWith(".md") ||
path.endsWith(".csv") ||
path.endsWith(".yarn")
path.endsWith(".yarn") ||
path.endsWith(".svg")
) {
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
delete contentIndex[relPath];
console.log(`[删除] ${relPath}`);
if (relPath.endsWith(".md")) {
const rescan = scanDirectory(contentDir);
collectedBlocks = rescan.blocks;
registry = buildRegistry(contentDir);
recomputeCompletions();
}
console.log(`[删除] ${path}`);
}
});
// ---- Journal / MQTT broker ----
let journal: Awaited<ReturnType<typeof createJournalServer>> | null = null;
// 创建 HTTP 服务器 BEFORE journal so we can pass it in
// 在 journal 之前创建 HTTP 服务器,以便传入给 journal
const handleRequest = createRequestHandler(
contentDir,
distPath,
() => contentIndex,
() => registry.pathIndex,
() => completionsIndex,
() => registry,
);
const server = createServer(handleRequest);
@@ -407,16 +372,16 @@ export function createContentServer(
}
console.log(`内容目录:${contentDir}`);
console.log(`静态资源目录:${distPath}`);
console.log(`Journal 连接:ws://${bestIP}:${port}\n`);
console.log(`日志连接:ws://${bestIP}:${port}\n`);
});
return {
server,
watcher,
index: contentIndex,
index: registry.pathIndex,
completions: completionsIndex,
close() {
console.log("关闭内容服务器...");
console.log("正在关闭内容服务器...");
server.close();
watcher.close();
if (journal) journal.close();
@@ -429,6 +394,12 @@ export function createContentServer(
*/
export const serveCommand: ServeCommandHandler = async (dir, options) => {
const contentDir = resolve(dir);
if (!existsSync(contentDir)) {
console.error(`错误:目录不存在:${contentDir}`);
process.exit(1);
}
const port = parseInt(options.port, 10);
const host = options.host || "0.0.0.0";
@@ -0,0 +1,7 @@
/**
* Jest mock for csv-parse/browser/esm/sync
*
* The real import path is csv-parse/browser/esm/sync which only works in
* browser bundlers. In Node (Jest), we redirect to csv-parse/sync.
*/
export { parse } from "csv-parse/sync";
-165
View File
@@ -1,165 +0,0 @@
/**
* CLI block processor — wraps block-scanner with Node-specific index injection
* and content stripping.
*
* Uses Node `crypto` and `path` — not safe for browser import.
*/
import { posix } from "path";
import { createHash } from "crypto";
import Slugger from "github-slugger";
import {
parseStatYaml,
parseStatCsv,
parseTemplateCsv,
type StatDef,
type StatTemplate,
} from "./stat-parser.js";
import {
FENCED_BLOCK_RE,
parseBlockAttrs,
resolveBlockAs,
parseSparkTableCsv,
} from "./block-scanner.js";
import type { SparkTableCompletion } from "./types.js";
// Re-export shared pieces for convenience
export {
FENCED_BLOCK_RE,
parseBlockAttrs,
resolveBlockAs,
parseSparkTableCsv,
type BlockAttrs,
} from "./block-scanner.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface ProcessedBlocks {
stats: StatDef[];
statTemplates: StatTemplate[];
sparkTables: SparkTableCompletion[];
}
export interface BlockResult {
/** Content with blocks processed (stripped or replaced with directives) */
stripped: string;
/** Parsed blocks for completions */
blocks: ProcessedBlocks;
}
// ---------------------------------------------------------------------------
// Content hash
// ---------------------------------------------------------------------------
function contentHash(body: string): string {
return createHash("md5").update(body).digest("hex").slice(0, 8);
}
// ---------------------------------------------------------------------------
// Main processor
// ---------------------------------------------------------------------------
/**
* Process all attributed fenced code blocks in a markdown file.
*
* - Strips/replaces blocks based on `as`
* - Injects `role=file` bodies into the content index
* - Collects stat/template/spark-table blocks for completions
*/
export function processBlocks(
content: string,
fileRelativePath: string,
index: Record<string, string>,
): BlockResult {
const fileDir = posix.dirname(fileRelativePath);
const slugger = new Slugger();
const blocks: ProcessedBlocks = {
stats: [],
statTemplates: [],
sparkTables: [],
};
const stripped = content.replace(
FENCED_BLOCK_RE,
(
_match: string,
lang: string,
infoString: string,
body: string,
): string => {
const attrs = parseBlockAttrs(infoString);
attrs.lang = attrs.lang || lang;
const effectiveAs = resolveBlockAs(attrs.role, attrs.as);
// ---- Dispatch by role ----
if (attrs.role === "stat") {
if (attrs.lang === "yaml" || attrs.lang === "yml") {
blocks.stats.push(...parseStatYaml(body, fileRelativePath));
} else if (attrs.lang === "csv") {
blocks.stats.push(...parseStatCsv(body, fileRelativePath));
}
}
if (attrs.role === "stat-template") {
const name = attrs.id || `_tpl_${contentHash(body)}`;
blocks.statTemplates.push(
parseTemplateCsv(body, fileRelativePath, name),
);
}
if (attrs.role === "spark-table") {
const parsed = parseSparkTableCsv(body, fileRelativePath, slugger);
if (parsed) blocks.sparkTables.push(parsed);
}
if (attrs.role === "file") {
const filename = attrs.id
? `${attrs.id}.${attrs.lang || "txt"}`
: `_inline_${contentHash(body)}.${attrs.lang || "txt"}`;
const resolvedPath = posix.join(fileDir, filename);
index[resolvedPath] = body;
}
// ---- Render by as ----
if (effectiveAs === "codeblock") {
return _match; // keep as-is
}
if (effectiveAs === "none") {
return ""; // strip
}
// Directive: :md-table[./file.csv], :md-card[./file.csv], :md-dice[./file.csv]
if (effectiveAs.startsWith("md-")) {
const filename = attrs.id
? `${attrs.id}.${attrs.lang || "txt"}`
: `_inline_${contentHash(body)}.${attrs.lang || "txt"}`;
const resolvedPath = posix.join(fileDir, filename);
// Ensure body is in the index for directive rendering
if (!index[resolvedPath]) {
index[resolvedPath] = body;
}
// Collect extra attrs for the directive
const extra = { ...attrs.extra };
const extraStr = Object.keys(extra).length
? `{${Object.entries(extra)
.map(([k, v]) => `${k}=${v}`)
.join(" ")}}`
: "";
return `:${effectiveAs}[./${filename}]${extraStr}`;
}
// Unknown as → strip
return "";
},
);
return { stripped, blocks };
}
+12 -89
View File
@@ -2,28 +2,21 @@
* Shared block scanning utilities — safe for both CLI and browser.
*
* Parses fenced code blocks with attributes:
* ```lang id=xxx role=xxx as=xxx
* ```lang id=xxx role=xxx key=value
*
* - `role` dispatches to content scanners (stat, stat-template, spark-table)
* - `as` controls rendering (none, codeblock, md-table, md-card, md-dice)
* - `id` is used for cross-references (template names, file paths)
* 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.
*/
import Slugger from "github-slugger";
import type { SparkTableCompletion } from "./types.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface BlockAttrs {
lang: string;
id?: string;
role?: string;
as?: string;
/** Any other attributes not in the standard set */
extra: Record<string, string>;
}
export { parseBlockAttrs, type BlockAttrs } from "../../markdown/block-attrs.js";
// ---------------------------------------------------------------------------
// Regex
@@ -31,73 +24,3 @@ export interface BlockAttrs {
/** Matches fenced code blocks with an info string (at least one attr). */
export const FENCED_BLOCK_RE = /^```(\w*)\s+(\S.*?)\s*\n([\s\S]*?)```\s*$/gm;
// ---------------------------------------------------------------------------
// Attribute parsing
// ---------------------------------------------------------------------------
/** Parse key="value" and key=value pairs from an attribute string. */
export function parseBlockAttrs(info: string): BlockAttrs {
const attrs: Record<string, string> = {};
const re = /(\w+)\s*=\s*("[^"]*"|\S+)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(info)) !== null) {
attrs[m[1]] = m[2].replace(/^"|"$/g, "");
}
const { lang, id, role, as, ...extra } = attrs;
return { lang: lang || "", id, role, as, extra };
}
// ---------------------------------------------------------------------------
// as resolution
// ---------------------------------------------------------------------------
/**
* Determine the effective `as` value.
*
* Defaults:
* - role is set, no explicit as → "none" (strip — it's metadata)
* - no role, no as → "codeblock" (keep as visible code block)
*/
export function resolveBlockAs(role: string | undefined, as: string | undefined): string {
if (as) return as;
if (role) return "none";
return "codeblock";
}
// ---------------------------------------------------------------------------
// Spark table parsing
// ---------------------------------------------------------------------------
const DICE_RE = /^d\d+$/i;
export function parseSparkTableCsv(
body: string,
filePath: string,
slugger: Slugger,
): SparkTableCompletion | null {
const lines = body.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 (!DICE_RE.test(headers[0])) return null;
const dataHeaders = headers.slice(1);
const slug = dataHeaders
.map((h) => slugger.slug(h.toLowerCase()))
.join("-");
const basePath = filePath.replace(/\.md$/, "");
const fileName = basePath.split("/").filter(Boolean).pop() || basePath;
const combinedSlug = `${fileName}-${slug}`;
return {
label: `${fileName} § ${slug}`,
notation: headers[0],
slug: combinedSlug,
filePath: basePath,
headers: dataHeaders,
};
}
+102
View File
@@ -0,0 +1,102 @@
/**
* Declare parser — parses ```csv role=declare code blocks from markdown.
*
* Shared between CLI completions scanner and browser-side completions.
*
* Format (columns can be in any order; `tag` and `threshold` are optional):
* tag,threshold,key,expr
* ,,$hp,$con*5+$mod_hp ← variable declaration
* ,,$ac,10+$dex ← variable declaration
* #warrior,2,$mod_hp,20 ← tag modifier (threshold 2)
* #warrior,,$mod_str,1 ← tag modifier (threshold defaults to 1)
*
* When `tag` is empty: $key is a reactively computed variable.
* When `tag` is present: when #tag met (source's tagmap value >= threshold),
* $key gets expr added to its base.
*/
import { parse } from "csv-parse/browser/esm/sync";
// ---------------------------------------------------------------------------
// 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[];
}
/**
* Parse a single ```csv role=declare block body.
*/
export function parseDeclareCsv(csv: string, source: string): DeclareResult {
const trimmed = csv.trim();
if (!trimmed) return { variables: [], tagModifiers: [] };
// Validate that required columns exist (order-agnostic, tag is optional)
const firstLine = trimmed.split(/\r?\n/)[0];
const headers = firstLine.split(",").map((h) => h.trim().toLowerCase());
if (!headers.includes("key") || !headers.includes("expr")) {
throw new Error(
`${source}: role=declare blocks must have "key" and "expr" columns. Got: ${headers.join(",")}`,
);
}
const records = parse(trimmed, {
columns: true,
trim: true,
skipEmptyLines: true,
}) as Array<{ tag?: string; threshold?: string; key: string; expr: string }>;
const variables: VarDeclaration[] = [];
const tagModifiers: TagModifier[] = [];
for (const row of records) {
const tag = row.tag ?? "";
const key = row.key ?? "";
const expr = row.expr ?? "";
if (!key || !expr) {
console.warn(`${source}: skipping row with empty key or expr`);
continue;
}
if (tag) {
// Tag modifier
if (!tag.startsWith("#")) {
throw new Error(`${source}: tag must start with #, got "${tag}"`);
}
if (!key.startsWith("$")) {
throw new Error(`${source}: key must start with $, got "${key}"`);
}
const thresholdRaw = row.threshold?.trim() ?? "";
const threshold = thresholdRaw ? parseInt(thresholdRaw, 10) : 1;
if (isNaN(threshold) || threshold < 1) {
throw new Error(
`${source}: threshold must be a positive integer, got "${row.threshold}"`,
);
}
tagModifiers.push({ tag, target: key, expression: expr, threshold });
} else {
// Variable declaration
if (!key.startsWith("$")) {
throw new Error(`${source}: key must start with $, got "${key}"`);
}
variables.push({ key, expression: expr });
}
}
return { variables, tagModifiers };
}
-37
View File
@@ -1,37 +0,0 @@
/**
* Completion index — orchestrates all registered completion sources.
*/
import { diceSource } from "./sources/dice.js";
import { linksSource } from "./sources/links.js";
import type { ProcessedBlocks } from "./block-processor.js";
import type { CompletionsPayload } from "./types.js";
export type {
CompletionsPayload,
DiceCompletion,
LinkCompletion,
SparkTableCompletion,
StatDef,
StatTemplate,
} from "./types.js";
/**
* Build completions from the content index and pre-collected blocks.
* Called at server startup and on any file change.
*/
export function scanCompletions(
index: Record<string, string>,
blocks: ProcessedBlocks,
): CompletionsPayload {
const dice = diceSource.scan(index) as CompletionsPayload["dice"];
const links = linksSource.scan(index) as CompletionsPayload["links"];
return {
dice,
links,
sparkTables: blocks.sparkTables,
stats: blocks.stats,
statTemplates: blocks.statTemplates,
};
}
-38
View File
@@ -1,38 +0,0 @@
/**
* Dice completion source — extracts `<md-dice>` text content from markdown files.
*/
import type { CompletionSource, DiceCompletion } from "../types.js";
function looksLikeDice(raw: string): boolean {
if (raw.length > 80) return false;
return /^\d*d\d+/i.test(raw) || /^[+-]/.test(raw);
}
export const diceSource: CompletionSource = {
key: "dice",
scan(index) {
const items: DiceCompletion[] = [];
const tagRegex = /:md-dice\[([^[]+)\]/gi;
for (const [path, content] of Object.entries(index)) {
if (!path.endsWith(".md")) continue;
let match: RegExpExecArray | null;
tagRegex.lastIndex = 0;
while ((match = tagRegex.exec(content)) !== null) {
const raw = match[1].trim();
if (!raw || !looksLikeDice(raw)) continue;
items.push({
label: raw,
notation: raw,
source: path,
});
}
}
return items;
},
};
-55
View File
@@ -1,55 +0,0 @@
/**
* Link completion source — extracts markdown headings from all .md files.
*
* Produces two entries per heading section, plus one for the file itself.
* Uses github-slugger to match marked-gfm-heading-id's generated IDs.
*/
import Slugger from "github-slugger";
import type { CompletionSource, LinkCompletion } from "../types.js";
export const linksSource: CompletionSource = {
key: "links",
scan(index) {
const items: LinkCompletion[] = [];
for (const [filePath, content] of Object.entries(index)) {
if (!filePath.endsWith(".md")) continue;
// Strip .md extension for the router-friendly path
const basePath = filePath.replace(/\.md$/, "");
const fileName = fileNameFromPath(basePath);
const slugger = new Slugger();
// Add the file itself as a link (whole article)
items.push({
path: basePath,
label: fileName,
section: null,
});
// Parse headings for section-scoped links
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;
},
};
function fileNameFromPath(path: string): string {
const parts = path.split("/").filter(Boolean);
return parts[parts.length - 1] || path;
}
-262
View File
@@ -1,262 +0,0 @@
/**
* Shared stat block parser — used by both the CLI completions scanner and
* the client-side completions scanner.
*
* Parses ```yaml role=stat and ```csv role=stat blocks from markdown content.
*/
export interface StatDef {
key: string;
label: string;
type: "number" | "string" | "enum" | "modifier" | "derived" | "template";
scope: "player" | "global";
default?: string;
target?: string;
options?: string[];
roll?: string;
formula?: string;
/** Name of a StatTemplate to use for template-type stats */
template?: string;
source: string;
}
/** A single row in a stat template table */
export interface TemplateEntry {
range: string;
label: string;
modifiers: Record<string, string>;
}
/** A named stat template (from ```csv role=stat-template file=xxx) */
export interface StatTemplate {
name: string;
/** Dice notation from the first header, e.g. "1d10" */
notation: string;
entries: TemplateEntry[];
source: string;
}
// ---------------------------------------------------------------------------
// YAML parser
// ---------------------------------------------------------------------------
export function parseStatYaml(yaml: string, source: string): StatDef[] {
const defs: StatDef[] = [];
const lines = yaml.split(/\r?\n/);
let current: Record<string, string> | null = null;
let collectingOptions = false;
let options: string[] = [];
function flushCurrent() {
if (!current || !current.key) return;
const type = (current.type || "number") as StatDef["type"];
const scope = (current.scope || "global") as StatDef["scope"];
defs.push({
key: current.key,
label: current.label || current.key,
type,
scope,
default: current.default,
target: current.target,
template: current.template,
options:
type === "enum" && options.length > 0
? [...options]
: current.options
? current.options
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: undefined,
roll: current.roll,
formula: current.formula,
source,
});
current = null;
options = [];
collectingOptions = false;
}
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
if (trimmed.startsWith("- ")) {
flushCurrent();
current = {};
collectingOptions = false;
const rest = trimmed.slice(2);
const colonIdx = rest.indexOf(":");
if (colonIdx === -1) continue;
const k = rest.slice(0, colonIdx).trim();
const v = rest.slice(colonIdx + 1).trim();
current[k] = v;
continue;
}
if (current && trimmed.match(/^\w/)) {
const colonIdx = trimmed.indexOf(":");
if (colonIdx === -1) continue;
const k = trimmed.slice(0, colonIdx).trim();
const v = trimmed.slice(colonIdx + 1).trim();
if (k === "options") {
if (v.startsWith("[") && v.endsWith("]")) {
current[k] = v.slice(1, -1);
} else {
collectingOptions = true;
options = [];
}
} else {
current[k] = v;
}
continue;
}
if (collectingOptions && trimmed.startsWith("- ")) {
options.push(trimmed.slice(2).trim());
continue;
}
}
flushCurrent();
return defs;
}
// ---------------------------------------------------------------------------
// CSV parser
// ---------------------------------------------------------------------------
export function parseStatCsv(csv: string, source: string): StatDef[] {
const lines = csv.trim().split(/\r?\n/);
if (lines.length === 0) return [];
const headers = lines[0].split(",").map((h) => h.trim().toLowerCase());
const idx = (name: string) => {
const i = headers.indexOf(name);
return i === -1 ? null : i;
};
const defs: StatDef[] = [];
for (let i = 1; i < lines.length; i++) {
const row = lines[i].trim();
if (!row || row.startsWith("#")) continue;
const cols = splitCsvRow(row);
if (cols.length === 0) continue;
const get = (name: string) => {
const colIdx = idx(name);
if (colIdx === null || colIdx >= cols.length) return undefined;
return cols[colIdx].trim() || undefined;
};
const key = get("key");
if (!key) continue;
const type = (get("type") || "number") as StatDef["type"];
const scope = (get("scope") || "player") as StatDef["scope"];
let options: string[] | undefined;
const optRaw = get("options");
if (optRaw) {
options = optRaw
.split("|")
.map((s) => s.trim())
.filter(Boolean);
}
defs.push({
key,
label: get("label") || key,
type,
scope,
default: get("default"),
roll: get("roll"),
target: get("target"),
template: get("template"),
formula: get("formula"),
options,
source,
});
}
return defs;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Parse a ```csv role=stat-template file=xxx block.
*
* The first header is the dice notation (e.g. "1d10").
* The second header is "label".
* Remaining headers are modifier keys.
*/
export function parseTemplateCsv(
csv: string,
source: string,
name: string,
): StatTemplate {
const lines = csv.trim().split(/\r?\n/);
if (lines.length === 0) return { name, notation: "", entries: [], source };
const headers = lines[0].split(",").map((h) => h.trim().toLowerCase());
if (headers.length < 2) return { name, notation: "", entries: [], source };
const notation = headers[0]; // e.g. "1d10"
const labelIdx = headers.indexOf("label");
// All columns after the notation & label are modifier keys
const modKeys = headers.filter((h, i) => i !== 0 && i !== labelIdx);
const entries: TemplateEntry[] = [];
for (let i = 1; i < lines.length; i++) {
const row = lines[i].trim();
if (!row || row.startsWith("#")) continue;
const cols = splitCsvRow(row);
if (cols.length === 0) continue;
const range = cols[0]?.trim();
if (!range) continue;
const label = labelIdx !== -1 ? cols[labelIdx]?.trim() || range : range;
const modifiers: Record<string, string> = {};
for (const mk of modKeys) {
const mkIdx = headers.indexOf(mk);
if (mkIdx !== -1 && mkIdx < cols.length) {
const val = cols[mkIdx].trim();
if (val) modifiers[mk] = val;
}
}
entries.push({ range, label, modifiers });
}
return { name, notation, entries, source };
}
export function splitCsvRow(row: string): string[] {
const cols: string[] = [];
let current = "";
let inQuote = false;
for (let i = 0; i < row.length; i++) {
const ch = row[i];
if (ch === '"') {
inQuote = !inQuote;
} else if (ch === "," && !inQuote) {
cols.push(current);
current = "";
} else {
current += ch;
}
}
cols.push(current);
return cols;
}
+11 -5
View File
@@ -2,9 +2,9 @@
* Completion source types — shared between CLI scanner and frontend consumer.
*/
import type { StatDef, StatTemplate } from "./stat-parser.js";
import type { VarDeclaration, TagModifier } from "./declare-parser.js";
export type { StatDef, StatTemplate };
export type { VarDeclaration, TagModifier };
/** A single dice expression found in a markdown file */
export interface DiceCompletion {
@@ -34,18 +34,24 @@ export interface SparkTableCompletion {
notation: string;
/** Concatenated slug of data column headers */
slug: string;
/** File path of the containing .md file */
/** 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[];
stats: StatDef[];
statTemplates: StatTemplate[];
declarations: VarDeclaration[];
tagModifiers: TagModifier[];
}
/**
File diff suppressed because it is too large Load Diff
+199
View File
@@ -0,0 +1,199 @@
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("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);
});
});
+628
View File
@@ -0,0 +1,628 @@
/**
* 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 {
parseDeclareCsv,
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,
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` including the
* Shared by the CLI (`buildRegistry` in `commands/serve.ts`) and the browser
* folder scan (`scanClientSide` in `components/journal/completions.ts`) so
* both modes produce identical `pathIndex`/`docContent`.
*
* Keys are normalized via `normalizePathKey`; `.md` files are run through
* `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"]);
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 = parseDeclareCsv(entry.body, docPath);
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.
*/
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;
}
+23 -6
View File
@@ -8,25 +8,42 @@ import type { ServeOptions, CompileOptions } from "./types.js";
const program = new Command();
program
.name("ttrpg")
.description("TTRPG 工具箱 - 用于编译和预览 TTRPG 文档")
.name("tttk")
.description(
"TTRPG 内容预览服务器。不带参数运行时默认启动当前目录的预览服务。",
)
.version("0.0.1");
program
.command("serve")
.description("运行一个 web 服务器预览目录中的内容,并实时监听更新")
.description("启动 web 服务器预览目录中的内容,并实时监听文件更新")
.argument("[dir]", "要预览的目录", ".")
.option("-p, --port <port>", "HTTP 端口号", "3000")
.option("-h, --host <host>", "主机地址", "0.0.0.0")
.option("--host <host>", "主机地址", "0.0.0.0")
.action(serveCommand);
program
.command("compile")
.description("将目录中的内容输出为带 hash 路由、单个 html 入口的 web 应用")
.description("[实验性] 将目录内容编译为带 hash 路由的单页 Web 应用 (开发中)")
.argument("[dir]", "要编译的目录", ".")
.option("-o, --output <dir>", "输出目录", "./dist/output")
.action(compileCommand);
program.addCommand(mcpCommand);
program.parse();
// 无子命令时默认执行 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]);
+27 -27
View File
@@ -1,14 +1,14 @@
/**
* Journal stream persistence MQTT broker + JSONL append + session manifest
* MQTT + JSONL +
*
* Runs an aedes MQTT broker over WebSocket, attached to the existing
* HTTP server. Uses ws v8's built-in createWebSocketStream.
* HTTP aedes MQTT WebSocket
* 使 ws v8 createWebSocketStream
*
* Pattern from aedes docs:
* aedes
* https://github.com/moscajs/aedes#mqtt-server-over-websocket
*
* Persistence uses aedes's internal subscribe/publish API, so the
* server process doesn't need to connect to itself as an MQTT client.
* 使 aedes subscribe/publish API
* MQTT
*/
import { join, resolve } from "path";
@@ -26,7 +26,7 @@ import type { AedesPublishPacket } from "aedes";
import { WebSocketServer, createWebSocketStream } from "ws";
// ---------------------------------------------------------------------------
// Types
// 类型定义
// ---------------------------------------------------------------------------
interface SessionMeta {
@@ -46,7 +46,7 @@ export interface JournalServer {
}
// ---------------------------------------------------------------------------
// Path helpers
// 路径工具函数
// ---------------------------------------------------------------------------
function dataDir(contentDir: string): string {
@@ -88,7 +88,7 @@ function appendStream(dataRoot: string, id: string, line: string): void {
}
// ---------------------------------------------------------------------------
// Server factory
// 服务器工厂
// ---------------------------------------------------------------------------
const $SESSIONS = "ttrpg/$SESSIONS";
@@ -98,15 +98,15 @@ export async function createJournalServer(
httpServer: HttpServer,
): Promise<JournalServer> {
const root = dataDir(contentDir);
console.log(`[journal] data dir: ${root}`);
console.log(`[日志] 数据目录:${root}`);
// ---- MQTT Broker ----
// ---- MQTT 代理 ----
const { Aedes: AedesFactory } = await import("aedes");
// aedes requires listen() to initialize persistence before handling connections.
// aedes 需要先 listen() 初始化持久化,再处理连接
const broker = await AedesFactory.createBroker();
// ---- WebSocket server (attached to the existing HTTP server) ----
// Pattern from aedes docs: pass { server } to WSS, use 'connection' event
// ---- WebSocket 服务器(挂载到现有 HTTP 服务器上) ----
// 参考 aedes 文档:给 WSS 传入 { server },使用 'connection' 事件
const wss = new WebSocketServer({ server: httpServer });
wss.on("connection", (ws, req) => {
@@ -115,13 +115,13 @@ export async function createJournalServer(
});
broker.on("client", (client: import("aedes").Client) => {
console.log(`[journal] client ready: ${client.id}`);
console.log(`[日志] 客户端已连接:${client.id}`);
});
broker.on("clientDisconnect", (client: import("aedes").Client) => {
console.log(`[journal] client disconnected: ${client.id}`);
console.log(`[日志] 客户端已断开:${client.id}`);
});
// ---- Persistence (internal subscribe, no separate MQTT client) ----
// ---- 持久化(内部订阅,无需独立 MQTT 客户端) ----
broker.subscribe(
"ttrpg/+/stream",
(packet, cb) => {
@@ -130,7 +130,7 @@ export async function createJournalServer(
try {
appendStream(root, sessionId, packet.payload.toString());
} catch (e) {
console.error(`[journal] stream append err for ${sessionId}:`, e);
console.error(`[日志] 会话 ${sessionId} 流记录写入失败:`, e);
}
}
cb();
@@ -150,7 +150,7 @@ export async function createJournalServer(
() => {},
);
// Publish the initial manifest on startup so clients see existing sessions
// 启动时发布初始清单,让客户端能看到已有会话
const initialManifest = loadManifest(root);
broker.publish(
{
@@ -164,14 +164,14 @@ export async function createJournalServer(
() => {},
);
console.log("[journal] persistence listener active");
console.log("[journal] broker available at ws://<host>:<port>");
console.log("[日志] 持久化监听已启动");
console.log("[日志] MQTT 代理已就绪:ws://<host>:<port>");
return {
broker,
wsServer: wss,
async close() {
console.log("[journal] shutting down...");
console.log("[日志] 正在关闭...");
await new Promise<void>((r) => wss.close(() => r()));
await new Promise<void>((r) => broker.close(() => r()));
},
@@ -179,7 +179,7 @@ export async function createJournalServer(
}
// ---------------------------------------------------------------------------
// Helpers
// 工具函数
// ---------------------------------------------------------------------------
function extractSessionId(topic: string): string | null {
@@ -188,7 +188,7 @@ function extractSessionId(topic: string): string | null {
}
// ---------------------------------------------------------------------------
// Session lifecycle
// 会话生命周期管理
// ---------------------------------------------------------------------------
function handleMetaChange(
@@ -212,7 +212,7 @@ function handleMetaChange(
} catch {
/* */
}
console.log(`[journal] session deleted: ${sessionId}`);
console.log(`[日志] 会话已删除:${sessionId}`);
}
} else {
manifest.sessions[sessionId] = {
@@ -220,7 +220,7 @@ function handleMetaChange(
created: meta.created || Date.now(),
players: meta.players || [],
};
console.log(`[journal] session updated: ${sessionId} (${meta.name})`);
console.log(`[日志] 会话已更新:${sessionId} (${meta.name})`);
}
saveManifest(root, manifest);
@@ -236,6 +236,6 @@ function handleMetaChange(
() => {},
);
} catch (e) {
console.error(`[journal] meta parse err for ${sessionId}:`, e);
console.error(`[日志] 会话 ${sessionId} 元数据解析失败:`, e);
}
}
+66 -78
View File
@@ -1,8 +1,11 @@
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { parse } from 'csv-parse/browser/esm/sync';
import { stringify } from 'csv-stringify/browser/esm/sync';
import yaml from 'js-yaml';
import type { DeckFrontmatter } from '../frontmatter/read-frontmatter.js';
import { readFileSync, writeFileSync, existsSync } from "fs";
import { parse } from "csv-parse/browser/esm/sync";
import { stringify } from "csv-stringify/browser/esm/sync";
import type { DeckFrontmatter } from "../frontmatter/read-frontmatter.js";
import {
parseFrontMatter,
serializeFrontMatter,
} from "../frontmatter/shared.js";
/**
*
@@ -23,7 +26,7 @@ export interface CardCrudParams {
/**
*
*/
action: 'create' | 'read' | 'update' | 'delete';
action: "create" | "read" | "update" | "delete";
/**
*
*/
@@ -44,41 +47,6 @@ export interface CardCrudResult {
count?: number;
}
/**
* CSV frontmatter
*/
function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } {
const parts = content.trim().split(/(?:^|\n)---\s*\n/g);
if (parts.length !== 3 || parts[0] !== '') {
return { csvContent: content };
}
try {
const frontmatterStr = parts[1].trim();
const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined;
const csvContent = parts.slice(2).join('---\n').trimStart();
return { frontmatter, csvContent };
} catch (error) {
console.warn('Failed to parse front matter:', error);
return { csvContent: content };
}
}
/**
* frontmatter YAML
*/
function serializeFrontMatter(frontmatter: DeckFrontmatter): string {
const yamlStr = yaml.dump(frontmatter, {
indent: 2,
lineWidth: -1,
noRefs: true,
quotingType: '"',
forceQuotes: false
});
return `---\n${yamlStr}---\n`;
}
/**
* CSV frontmatter
*/
@@ -87,19 +55,19 @@ function loadCSVWithFrontmatter(filePath: string): {
records: CardData[];
headers: string[];
} {
const content = readFileSync(filePath, 'utf-8');
const content = readFileSync(filePath, "utf-8");
const { frontmatter, csvContent } = parseFrontMatter(content);
const records = parse(csvContent, {
columns: true,
comment: '#',
comment: "#",
trim: true,
skipEmptyLines: true
skipEmptyLines: true,
}) as CardData[];
// 获取表头
const firstLine = csvContent.split('\n')[0];
const headers = firstLine.split(',').map(h => h.trim());
const firstLine = csvContent.split("\n")[0];
const headers = firstLine.split(",").map((h) => h.trim());
return { frontmatter, records, headers };
}
@@ -111,30 +79,30 @@ function saveCSVWithFrontmatter(
filePath: string,
frontmatter: DeckFrontmatter | undefined,
records: CardData[],
headers?: string[]
headers?: string[],
): void {
// 序列化 frontmatter
const frontmatterStr = frontmatter ? serializeFrontMatter(frontmatter) : '';
const frontmatterStr = frontmatter ? serializeFrontMatter(frontmatter) : "";
// 确定表头
if (!headers || headers.length === 0) {
// 从 records 和 frontmatter.fields 推断表头
headers = ['label'];
headers = ["label"];
if (frontmatter?.fields && Array.isArray(frontmatter.fields)) {
for (const field of frontmatter.fields) {
if (field.name && typeof field.name === 'string') {
if (field.name && typeof field.name === "string") {
headers.push(field.name);
}
}
}
headers.push('body');
headers.push("body");
}
// 确保所有 record 都有 headers 中的列
for (const record of records) {
for (const header of headers) {
if (!(header in record)) {
record[header] = '';
record[header] = "";
}
}
}
@@ -142,11 +110,11 @@ function saveCSVWithFrontmatter(
// 序列化 CSV
const csvContent = stringify(records, {
header: true,
columns: headers
columns: headers,
});
// 写入文件
writeFileSync(filePath, frontmatterStr + csvContent, 'utf-8');
writeFileSync(filePath, frontmatterStr + csvContent, "utf-8");
}
/**
@@ -167,10 +135,10 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
const { csv_file, action, cards, label } = params;
// 检查文件是否存在(create 操作可以不存在)
if (action !== 'create' && !existsSync(csv_file)) {
if (action !== "create" && !existsSync(csv_file)) {
return {
success: false,
message: `文件不存在:${csv_file}`
message: `文件不存在:${csv_file}`,
};
}
@@ -189,8 +157,8 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
// 执行操作
switch (action) {
case 'create': {
const newCards = Array.isArray(cards) ? cards : (cards ? [cards] : []);
case "create": {
const newCards = Array.isArray(cards) ? cards : cards ? [cards] : [];
for (const card of newCards) {
if (!card.label) {
card.label = generateNextLabel(records);
@@ -202,16 +170,22 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
success: true,
message: `成功创建 ${newCards.length} 张卡牌`,
cards: newCards,
count: newCards.length
count: newCards.length,
};
}
case 'read': {
const labelsToRead = Array.isArray(label) ? label : (label ? [label] : null);
case "read": {
const labelsToRead = Array.isArray(label)
? label
: label
? [label]
: null;
let resultCards: CardData[];
if (labelsToRead && labelsToRead.length > 0) {
resultCards = records.filter(r => labelsToRead.includes(r.label || ''));
resultCards = records.filter((r) =>
labelsToRead.includes(r.label || ""),
);
} else {
resultCards = records;
}
@@ -220,20 +194,26 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
success: true,
message: `成功读取 ${resultCards.length} 张卡牌`,
cards: resultCards,
count: resultCards.length
count: resultCards.length,
};
}
case 'update': {
const labelsToUpdate = Array.isArray(label) ? label : (label ? [label] : null);
const updateCards = Array.isArray(cards) ? cards : (cards ? [cards] : []);
case "update": {
const labelsToUpdate = Array.isArray(label)
? label
: label
? [label]
: null;
const updateCards = Array.isArray(cards) ? cards : cards ? [cards] : [];
let updatedCount = 0;
if (labelsToUpdate && labelsToUpdate.length > 0) {
// 按 label 更新
for (const updateCard of updateCards) {
const targetLabel = updateCard.label || labelsToUpdate[updatedCount % labelsToUpdate.length];
const index = records.findIndex(r => r.label === targetLabel);
const targetLabel =
updateCard.label ||
labelsToUpdate[updatedCount % labelsToUpdate.length];
const index = records.findIndex((r) => r.label === targetLabel);
if (index !== -1) {
records[index] = { ...records[index], ...updateCard };
updatedCount++;
@@ -243,7 +223,9 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
// 按 cards 中的 label 更新
for (const updateCard of updateCards) {
if (updateCard.label) {
const index = records.findIndex(r => r.label === updateCard.label);
const index = records.findIndex(
(r) => r.label === updateCard.label,
);
if (index !== -1) {
records[index] = { ...records[index], ...updateCard };
updatedCount++;
@@ -257,23 +239,29 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
success: true,
message: `成功更新 ${updatedCount} 张卡牌`,
cards: updateCards,
count: updatedCount
count: updatedCount,
};
}
case 'delete': {
const labelsToDelete = Array.isArray(label) ? label : (label ? [label] : null);
case "delete": {
const labelsToDelete = Array.isArray(label)
? label
: label
? [label]
: null;
let deletedCount = 0;
if (labelsToDelete && labelsToDelete.length > 0) {
const beforeCount = records.length;
records = records.filter(r => !labelsToDelete.includes(r.label || ''));
records = records.filter(
(r) => !labelsToDelete.includes(r.label || ""),
);
deletedCount = beforeCount - records.length;
} else if (cards) {
const cardsToDelete = Array.isArray(cards) ? cards : [cards];
const beforeCount = records.length;
records = records.filter(r =>
!cardsToDelete.some(c => c.label && r.label === c.label)
records = records.filter(
(r) => !cardsToDelete.some((c) => c.label && r.label === c.label),
);
deletedCount = beforeCount - records.length;
}
@@ -282,20 +270,20 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
return {
success: true,
message: `成功删除 ${deletedCount} 张卡牌`,
count: deletedCount
count: deletedCount,
};
}
default:
return {
success: false,
message: `未知操作:${action}`
message: `未知操作:${action}`,
};
}
} catch (error) {
return {
success: false,
message: `操作失败:${error instanceof Error ? error.message : '未知错误'}`
message: `操作失败:${error instanceof Error ? error.message : "未知错误"}`,
};
}
}
+134
View File
@@ -0,0 +1,134 @@
/**
* Shared deck utility functions used by generate-card-deck and preview-deck.
*/
import { dirname, join, basename, extname, relative } from "path";
import type {
DeckConfig,
DeckFrontmatter,
} from "./frontmatter/read-frontmatter.js";
import { parseFrontMatter } from "./frontmatter/shared.js";
import { readFileSync } from "fs";
/**
* Build a `:md-deck[path]{attrs}` directive string from a CSV path and config.
*/
export function buildDeckComponent(
csvPath: string,
config?: DeckConfig,
): string {
const parts = [`:md-deck[${csvPath}]`];
const attrs: string[] = [];
if (config?.size) {
attrs.push(`size="${config.size}"`);
}
if (config?.grid) {
attrs.push(`grid="${config.grid}"`);
}
if (config?.bleed !== undefined && config.bleed !== 1) {
attrs.push(`bleed="${config.bleed}"`);
}
if (config?.padding !== undefined && config.padding !== 2) {
attrs.push(`padding="${config.padding}"`);
}
if (config?.shape && config.shape !== "rectangle") {
attrs.push(`shape="${config.shape}"`);
}
if (config?.layers) {
attrs.push(`layers="${config.layers}"`);
}
if (config?.back_layers) {
attrs.push(`back-layers="${config.back_layers}"`);
}
if (attrs.length > 0) {
parts.push(`{${attrs.join(" ")}}`);
}
return parts.join("");
}
/**
* Derive a human-readable title from a CSV filename.
* e.g. "my-deck.csv" "My Deck"
*/
export function titleFromCsvPath(csvPath: string): string {
const name = basename(csvPath, extname(csvPath));
return name.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
/**
* Compute the relative path from a markdown file to its CSV.
* Handles the case where they are in the same or different directories.
*/
export function relativeCsvPath(
mdFilePath: string,
csvFilePath: string,
): string {
const mdDir = dirname(mdFilePath);
const rel = relative(mdDir, csvFilePath);
return rel.startsWith(".") ? rel : `./${rel}`;
}
/**
* Determine the default markdown file path for a CSV.
* e.g. "foo/bar.csv" "foo/bar.md"
*/
export function defaultMdPath(csvFilePath: string): string {
const dir = dirname(csvFilePath);
const name = basename(csvFilePath, extname(csvFilePath));
return join(dir, `${name}.md`);
}
/**
* Read DeckConfig from a CSV file's frontmatter, if present.
*/
export function readDeckConfig(csvFilePath: string): DeckConfig | undefined {
try {
const content = readFileSync(csvFilePath, "utf-8");
const { frontmatter } = parseFrontMatter(content);
return frontmatter?.deck;
} catch {
return undefined;
}
}
/**
* Generate the standard markdown content for a deck preview file.
*/
export function generateDeckPreviewMd(
deckTitle: string,
deckComponent: string,
description?: string,
): string {
const lines: string[] = [];
lines.push(`# ${deckTitle}`);
lines.push("");
if (description) {
lines.push(description);
lines.push("");
}
lines.push("## 卡牌预览");
lines.push("");
lines.push(deckComponent);
lines.push("");
lines.push("## 使用说明");
lines.push("");
lines.push("- 点击卡牌可以查看详情");
lines.push("- 使用右上角的按钮可以随机抽取卡牌");
lines.push("- 可以通过编辑面板调整卡牌样式和布局");
lines.push("");
return lines.join("\n");
}
-217
View File
@@ -1,217 +0,0 @@
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { dirname, join, basename, extname } from 'path';
import yaml from 'js-yaml';
import type { DeckFrontmatter, DeckConfig } from './frontmatter/read-frontmatter.js';
/**
* Deck
*/
export interface EnsureDeckPreviewParams {
/**
* CSV
*/
csv_file: string;
/**
* Markdown CSV
*/
md_file?: string;
/**
* CSV
*/
title?: string;
/**
*
*/
description?: string;
}
/**
* Deck
*/
export interface EnsureDeckPreviewResult {
success: boolean;
message: string;
md_file?: string;
created?: boolean;
}
/**
* CSV frontmatter
*/
function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } {
const parts = content.trim().split(/(?:^|\n)---\s*\n/g);
if (parts.length !== 3 || parts[0] !== '') {
return { csvContent: content };
}
try {
const frontmatterStr = parts[1].trim();
const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined;
const csvContent = parts.slice(2).join('---\n').trimStart();
return { frontmatter, csvContent };
} catch (error) {
console.warn('Failed to parse front matter:', error);
return { csvContent: content };
}
}
/**
* md-deck
*/
function buildDeckComponent(csvPath: string, config?: DeckConfig): string {
const parts = [`:md-deck[${csvPath}]`];
const attrs: string[] = [];
if (config?.size) {
attrs.push(`size="${config.size}"`);
}
if (config?.grid) {
attrs.push(`grid="${config.grid}"`);
}
if (config?.bleed !== undefined && config.bleed !== 1) {
attrs.push(`bleed="${config.bleed}"`);
}
if (config?.padding !== undefined && config.padding !== 2) {
attrs.push(`padding="${config.padding}"`);
}
if (config?.shape && config.shape !== 'rectangle') {
attrs.push(`shape="${config.shape}"`);
}
if (config?.layers) {
attrs.push(`layers="${config.layers}"`);
}
if (config?.back_layers) {
attrs.push(`back-layers="${config.back_layers}"`);
}
if (attrs.length > 0) {
parts.push(`{${attrs.join(' ')}}`);
}
return parts.join('');
}
/**
* Markdown
*/
export function ensureDeckPreview(params: EnsureDeckPreviewParams): EnsureDeckPreviewResult {
const { csv_file, md_file, title, description } = params;
// 检查 CSV 文件是否存在
if (!existsSync(csv_file)) {
return {
success: false,
message: `CSV 文件不存在:${csv_file}`
};
}
// 确定 Markdown 文件路径
let mdFilePath = md_file;
if (!mdFilePath) {
// 使用与 CSV 相同的路径和文件名,只是扩展名不同
const dir = dirname(csv_file);
const name = basename(csv_file, extname(csv_file));
mdFilePath = join(dir, `${name}.md`);
}
const created = !existsSync(mdFilePath);
// 如果文件已存在,检查是否已有 md-deck 组件
if (!created) {
try {
const existingContent = readFileSync(mdFilePath, 'utf-8');
// 如果已有 md-deck 组件引用该 CSV,直接返回
if (existingContent.includes(`:md-deck[${csv_file}]`) ||
existingContent.includes(`:md-deck[./${basename(csv_file)}]`)) {
return {
success: true,
message: `预览文件 ${mdFilePath} 已存在`,
md_file: mdFilePath,
created: false
};
}
} catch (error) {
// 读取失败,继续创建
}
}
// 读取 CSV 的 frontmatter 获取配置
let deckConfig: DeckConfig | undefined;
try {
const csvContent = readFileSync(csv_file, 'utf-8');
const { frontmatter } = parseFrontMatter(csvContent);
deckConfig = frontmatter?.deck;
} catch (error) {
// 忽略错误,使用默认配置
}
// 确定标题
let deckTitle = title;
if (!deckTitle) {
// 从 CSV 文件名推断
const name = basename(csv_file, extname(csv_file));
deckTitle = name.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}
// 构建 md-deck 组件代码(使用相对路径)
const mdDir = dirname(mdFilePath);
const csvDir = dirname(csv_file);
let relativeCsvPath: string;
if (mdDir === csvDir) {
relativeCsvPath = `./${basename(csv_file)}`;
} else {
relativeCsvPath = `./${basename(csv_file)}`;
}
const deckComponent = buildDeckComponent(relativeCsvPath, deckConfig);
// 生成 Markdown 内容
const mdLines: string[] = [];
mdLines.push(`# ${deckTitle}`);
mdLines.push('');
if (description) {
mdLines.push(description);
mdLines.push('');
}
mdLines.push('## 卡牌预览');
mdLines.push('');
mdLines.push(deckComponent);
mdLines.push('');
mdLines.push('## 使用说明');
mdLines.push('');
mdLines.push('- 点击卡牌可以查看详情');
mdLines.push('- 使用右上角的按钮可以随机抽取卡牌');
mdLines.push('- 可以通过编辑面板调整卡牌样式和布局');
mdLines.push('');
// 写入文件
try {
writeFileSync(mdFilePath, mdLines.join('\n'), 'utf-8');
return {
success: true,
message: created
? `创建预览文件 ${mdFilePath}`
: `更新预览文件 ${mdFilePath}`,
md_file: mdFilePath,
created
};
} catch (error) {
return {
success: false,
message: `写入失败:${error instanceof Error ? error.message : '未知错误'}`
};
}
}
+12 -32
View File
@@ -1,5 +1,5 @@
import { readFileSync, existsSync } from 'fs';
import yaml from 'js-yaml';
import { readFileSync, existsSync } from "fs";
import { parseFrontMatter } from "./shared.js";
/**
* CSV frontmatter
@@ -52,7 +52,7 @@ export interface CardFieldStyle {
/**
* "n" | "w" | "s" | "e"/西//
*/
up?: 'n' | 'w' | 's' | 'e';
up?: "n" | "w" | "s" | "e";
}
/**
@@ -74,7 +74,7 @@ export interface DeckConfig {
grid?: string;
bleed?: number;
padding?: number;
shape?: 'rectangle' | 'circle' | 'hex' | 'diamond';
shape?: "rectangle" | "circle" | "hex" | "diamond";
layers?: string;
back_layers?: string;
[key: string]: unknown;
@@ -89,45 +89,25 @@ export interface ReadFrontmatterResult {
message: string;
}
/**
* CSV frontmatter
*/
function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } {
const parts = content.trim().split(/(?:^|\n)---\s*\n/g);
// 至少需要三个部分:空字符串、front matter、CSV 内容
if (parts.length !== 3 || parts[0] !== '') {
return { csvContent: content };
}
try {
const frontmatterStr = parts[1].trim();
const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined;
const csvContent = parts.slice(2).join('---\n').trimStart();
return { frontmatter, csvContent };
} catch (error) {
console.warn('Failed to parse front matter:', error);
return { csvContent: content };
}
}
/**
* CSV frontmatter
*/
export function readFrontmatter(params: ReadFrontmatterParams): ReadFrontmatterResult {
export function readFrontmatter(
params: ReadFrontmatterParams,
): ReadFrontmatterResult {
const { csv_file } = params;
// 检查文件是否存在
if (!existsSync(csv_file)) {
return {
success: false,
message: `文件不存在:${csv_file}`
message: `文件不存在:${csv_file}`,
};
}
try {
// 读取文件内容
const content = readFileSync(csv_file, 'utf-8');
const content = readFileSync(csv_file, "utf-8");
// 解析 frontmatter
const { frontmatter } = parseFrontMatter(content);
@@ -136,19 +116,19 @@ export function readFrontmatter(params: ReadFrontmatterParams): ReadFrontmatterR
return {
success: true,
frontmatter: {},
message: `文件 ${csv_file} 没有 frontmatter,返回空对象`
message: `文件 ${csv_file} 没有 frontmatter,返回空对象`,
};
}
return {
success: true,
frontmatter,
message: `成功读取 ${csv_file} 的 frontmatter`
message: `成功读取 ${csv_file} 的 frontmatter`,
};
} catch (error) {
return {
success: false,
message: `读取失败:${error instanceof Error ? error.message : '未知错误'}`
message: `读取失败:${error instanceof Error ? error.message : "未知错误"}`,
};
}
}
+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`;
}
+18 -51
View File
@@ -1,6 +1,6 @@
import { readFileSync, writeFileSync, existsSync } from 'fs';
import yaml from 'js-yaml';
import type { DeckFrontmatter } from './read-frontmatter.js';
import { readFileSync, writeFileSync, existsSync } from "fs";
import type { DeckFrontmatter } from "./read-frontmatter.js";
import { parseFrontMatter, serializeFrontMatter } from "./shared.js";
/**
* CSV frontmatter
@@ -29,68 +29,35 @@ export interface WriteFrontmatterResult {
csv_file?: string;
}
/**
* CSV frontmatter
*/
function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } {
const parts = content.trim().split(/(?:^|\n)---\s*\n/g);
// 至少需要三个部分:空字符串、front matter、CSV 内容
if (parts.length !== 3 || parts[0] !== '') {
return { csvContent: content };
}
try {
const frontmatterStr = parts[1].trim();
const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined;
const csvContent = parts.slice(2).join('---\n').trimStart();
return { frontmatter, csvContent };
} catch (error) {
console.warn('Failed to parse front matter:', error);
return { csvContent: content };
}
}
/**
* frontmatter YAML
*/
function serializeFrontMatter(frontmatter: DeckFrontmatter): string {
const yamlStr = yaml.dump(frontmatter, {
indent: 2,
lineWidth: -1, // 不限制行宽
noRefs: true, // 不使用引用
quotingType: '"',
forceQuotes: false
});
return `---\n${yamlStr}---\n`;
}
/**
* / CSV frontmatter
*/
export function writeFrontmatter(params: WriteFrontmatterParams): WriteFrontmatterResult {
export function writeFrontmatter(
params: WriteFrontmatterParams,
): WriteFrontmatterResult {
const { csv_file, frontmatter, merge = true } = params;
let csvContent = '';
let csvContent = "";
let existingFrontmatter: DeckFrontmatter | undefined;
// 如果文件存在且需要合并,先读取现有内容
if (merge && existsSync(csv_file)) {
try {
const content = readFileSync(csv_file, 'utf-8');
const content = readFileSync(csv_file, "utf-8");
const result = parseFrontMatter(content);
existingFrontmatter = result.frontmatter;
csvContent = result.csvContent;
} catch (error) {
return {
success: false,
message: `读取现有文件失败:${error instanceof Error ? error.message : '未知错误'}`
message: `读取现有文件失败:${error instanceof Error ? error.message : "未知错误"}`,
};
}
}
// 合并或替换 frontmatter
const finalFrontmatter = merge && existingFrontmatter
const finalFrontmatter =
merge && existingFrontmatter
? { ...existingFrontmatter, ...frontmatter }
: frontmatter;
@@ -100,32 +67,32 @@ export function writeFrontmatter(params: WriteFrontmatterParams): WriteFrontmatt
// 如果文件不存在或没有 CSV 内容,创建一个空的 CSV 内容
if (!csvContent.trim()) {
// 从 frontmatter 推断 CSV 表头
const headers = ['label'];
const headers = ["label"];
if (finalFrontmatter.fields && Array.isArray(finalFrontmatter.fields)) {
for (const field of finalFrontmatter.fields) {
if (field.name && typeof field.name === 'string') {
if (field.name && typeof field.name === "string") {
headers.push(field.name);
}
}
}
headers.push('body');
csvContent = headers.join(',') + '\n';
headers.push("body");
csvContent = headers.join(",") + "\n";
}
// 写入文件
try {
const fullContent = frontmatterStr + csvContent;
writeFileSync(csv_file, fullContent, 'utf-8');
writeFileSync(csv_file, fullContent, "utf-8");
return {
success: true,
message: `成功写入 frontmatter 到 ${csv_file}`,
csv_file
csv_file,
};
} catch (error) {
return {
success: false,
message: `写入失败:${error instanceof Error ? error.message : '未知错误'}`
message: `写入失败:${error instanceof Error ? error.message : "未知错误"}`,
};
}
}
+51 -137
View File
@@ -1,5 +1,7 @@
import { writeFileSync, mkdirSync, existsSync } from 'fs';
import { join } from 'path';
import { writeFileSync, mkdirSync, existsSync } from "fs";
import { join } from "path";
import { buildDeckComponent, generateDeckPreviewMd } from "./deck-utils.js";
import type { DeckConfig } from "./frontmatter/read-frontmatter.js";
/**
*
@@ -24,7 +26,7 @@ export interface CardFieldStyle {
/**
* "n" | "w" | "s" | "e"/西//
*/
up?: 'n' | 'w' | 's' | 'e';
up?: "n" | "w" | "s" | "e";
}
/**
@@ -45,18 +47,7 @@ export interface CardTemplate {
examples?: Record<string, string>[];
}
/**
* Deck
*/
export interface DeckConfig {
size?: string;
grid?: string;
bleed?: number;
padding?: number;
shape?: 'rectangle' | 'circle' | 'hex' | 'diamond';
layers?: string;
backLayers?: string;
}
export type { DeckConfig };
/**
*
@@ -73,14 +64,11 @@ export interface GenerateCardDeckParams {
/**
* CSV
*/
function generateCardCSV(
template: CardTemplate,
cardCount: number
): string {
function generateCardCSV(template: CardTemplate, cardCount: number): string {
const fields = template.fields;
// 构建 CSV 表头
const headers = ['label', ...fields.map(f => f.name), 'body'];
const headers = ["label", ...fields.map((f) => f.name), "body"];
// 生成示例数据
const rows: string[][] = [];
@@ -91,13 +79,16 @@ function generateCardCSV(
// 为每个字段生成值
for (const field of fields) {
let value = '';
let value = "";
if (examples.length > 0) {
// 从示例中循环取值
const exampleIndex = i % examples.length;
const example = examples[exampleIndex];
value = example[field.name] || field.examples?.[i % (field.examples?.length || 1)] || '';
value =
example[field.name] ||
field.examples?.[i % (field.examples?.length || 1)] ||
"";
} else if (field.examples && field.examples.length > 0) {
// 从字段的示例中取值
value = field.examples[i % field.examples.length];
@@ -114,117 +105,35 @@ function generateCardCSV(
for (const field of fields) {
bodyParts.push(`**${field.name}:** {{${field.name}}}`);
}
row.push(bodyParts.join('\n\n'));
row.push(bodyParts.join("\n\n"));
rows.push(row);
}
// 组合 CSV 内容
const csvLines = [headers.join(',')];
const csvLines = [headers.join(",")];
for (const row of rows) {
csvLines.push(row.map(cell => {
csvLines.push(
row
.map((cell) => {
// 处理包含逗号或换行的单元格
if (cell.includes(',') || cell.includes('\n') || cell.includes('"')) {
if (cell.includes(",") || cell.includes("\n") || cell.includes('"')) {
return `"${cell.replace(/"/g, '""')}"`;
}
return cell;
}).join(','));
})
.join(","),
);
}
return csvLines.join('\n');
}
/**
* Markdown
*/
function generateDeckMarkdown(
deckName: string,
csvFileName: string,
deckConfig: DeckConfig,
description?: string
): string {
const mdLines: string[] = [];
// 标题
mdLines.push(`# ${deckName}`);
mdLines.push('');
// 描述
if (description) {
mdLines.push(description);
mdLines.push('');
}
// 卡牌预览组件
mdLines.push('## 卡牌预览');
mdLines.push('');
// 构建 :md-deck 组件代码
const deckComponent = buildDeckComponent(csvFileName, deckConfig);
mdLines.push(deckComponent);
mdLines.push('');
// 使用说明
mdLines.push('## 使用说明');
mdLines.push('');
mdLines.push('- 点击卡牌可以查看详情');
mdLines.push('- 使用右上角的按钮可以随机抽取卡牌');
mdLines.push('- 可以通过编辑面板调整卡牌样式和布局');
mdLines.push('');
return mdLines.join('\n');
}
/**
* 构建 :md-deck
*/
function buildDeckComponent(
csvFileName: string,
config: DeckConfig
): string {
const parts = [`:md-deck[${csvFileName}]`];
const attrs: string[] = [];
if (config.size) {
attrs.push(`size="${config.size}"`);
}
if (config.grid) {
attrs.push(`grid="${config.grid}"`);
}
if (config.bleed !== undefined && config.bleed !== 1) {
attrs.push(`bleed="${config.bleed}"`);
}
if (config.padding !== undefined && config.padding !== 2) {
attrs.push(`padding="${config.padding}"`);
}
if (config.shape && config.shape !== 'rectangle') {
attrs.push(`shape="${config.shape}"`);
}
if (config.layers) {
attrs.push(`layers="${config.layers}"`);
}
if (config.backLayers) {
attrs.push(`back-layers="${config.backLayers}"`);
}
if (attrs.length > 0) {
parts.push(`{${attrs.join(' ')}}`);
}
return parts.join('');
return csvLines.join("\n");
}
/**
*
*/
function autoGenerateLayers(fields: CardField[]): string {
if (fields.length === 0) return '';
if (fields.length === 0) return "";
const layers: string[] = [];
const totalHeight = 8;
@@ -238,7 +147,7 @@ function autoGenerateLayers(fields: CardField[]): string {
layers.push(`${field.name}:1,${y1}-${y2},${fontSize}`);
}
return layers.join(' ');
return layers.join(" ");
}
/**
@@ -256,7 +165,7 @@ export function generateCardDeck(params: GenerateCardDeckParams): {
card_count = 10,
card_template,
deck_config = {},
description
description,
} = params;
// 确保输出目录存在
@@ -265,52 +174,57 @@ export function generateCardDeck(params: GenerateCardDeckParams): {
}
// 生成文件名
const safeName = deck_name.toLowerCase().replace(/\s+/g, '-');
const safeName = deck_name.toLowerCase().replace(/\s+/g, "-");
const csvFileName = `${safeName}.csv`;
const mdFileName = `${safeName}.md`;
// 创建默认模板(如果没有提供)
const template: CardTemplate = card_template || {
fields: [
{ name: 'name', description: '卡牌名称', examples: ['示例卡牌 1', '示例卡牌 2'] },
{ name: 'type', description: '卡牌类型', examples: ['物品', '法术'] },
{ name: 'cost', description: '费用', examples: ['1', '2'] },
{ name: 'description', description: '效果描述', examples: ['这是一个效果描述', '这是另一个效果'] }
]
{
name: "name",
description: "卡牌名称",
examples: ["示例卡牌 1", "示例卡牌 2"],
},
{ name: "type", description: "卡牌类型", examples: ["物品", "法术"] },
{ name: "cost", description: "费用", examples: ["1", "2"] },
{
name: "description",
description: "效果描述",
examples: ["这是一个效果描述", "这是另一个效果"],
},
],
};
// 创建默认配置(如果没有提供)
const config: DeckConfig = {
size: deck_config.size || '54x86',
grid: deck_config.grid || '5x8',
size: deck_config.size || "54x86",
grid: deck_config.grid || "5x8",
bleed: deck_config.bleed ?? 1,
padding: deck_config.padding ?? 2,
shape: deck_config.shape || 'rectangle',
layers: deck_config.layers || autoGenerateLayers(template.fields)
shape: deck_config.shape || "rectangle",
layers: deck_config.layers || autoGenerateLayers(template.fields),
};
// 生成 CSV 内容
const csvContent = generateCardCSV(template, card_count);
const csvPath = join(output_dir, csvFileName);
writeFileSync(csvPath, csvContent, 'utf-8');
writeFileSync(csvPath, csvContent, "utf-8");
// 生成 Markdown 内容
const mdContent = generateDeckMarkdown(
const deckComponent = buildDeckComponent(`./${csvFileName}`, config);
const mdContent = generateDeckPreviewMd(
deck_name,
`./${csvFileName}`,
config,
description
deckComponent,
description,
);
const mdPath = join(output_dir, mdFileName);
writeFileSync(mdPath, mdContent, 'utf-8');
// 构建完整的 deck 组件代码
const deckComponent = buildDeckComponent(`./${csvFileName}`, config);
writeFileSync(mdPath, mdContent, "utf-8");
return {
mdFile: mdPath,
csvFile: csvPath,
deckComponent,
message: `已生成卡牌组 "${deck_name}":\n- Markdown 文件:${mdPath}\n- CSV 数据:${csvPath}\n- 组件代码:${deckComponent}`
message: `已生成卡牌组 "${deck_name}":\n- Markdown 文件:${mdPath}\n- CSV 数据:${csvPath}\n- 组件代码:${deckComponent}`,
};
}
+53 -148
View File
@@ -1,8 +1,13 @@
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { dirname, join, basename, extname, relative } from 'path';
import { execSync } from 'child_process';
import yaml from 'js-yaml';
import type { DeckFrontmatter, DeckConfig } from './frontmatter/read-frontmatter.js';
import { readFileSync, writeFileSync, existsSync } from "fs";
import { relative } from "path";
import { execSync } from "child_process";
import {
buildDeckComponent,
titleFromCsvPath,
defaultMdPath,
readDeckConfig,
generateDeckPreviewMd,
} from "./deck-utils.js";
/**
* Deck
@@ -37,88 +42,35 @@ export interface PreviewDeckResult {
preview_url?: string;
}
/**
* CSV frontmatter
*/
function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } {
const parts = content.trim().split(/(?:^|\n)---\s*\n/g);
if (parts.length !== 3 || parts[0] !== '') {
return { csvContent: content };
}
try {
const frontmatterStr = parts[1].trim();
const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined;
const csvContent = parts.slice(2).join('---\n').trimStart();
return { frontmatter, csvContent };
} catch (error) {
console.warn('Failed to parse front matter:', error);
return { csvContent: content };
}
}
/**
* md-deck
*/
function buildDeckComponent(csvPath: string, config?: DeckConfig): string {
const parts = [`:md-deck[${csvPath}]`];
const attrs: string[] = [];
if (config?.size) {
attrs.push(`size="${config.size}"`);
}
if (config?.grid) {
attrs.push(`grid="${config.grid}"`);
}
if (config?.bleed !== undefined && config.bleed !== 1) {
attrs.push(`bleed="${config.bleed}"`);
}
if (config?.padding !== undefined && config.padding !== 2) {
attrs.push(`padding="${config.padding}"`);
}
if (config?.shape && config.shape !== 'rectangle') {
attrs.push(`shape="${config.shape}"`);
}
if (config?.layers) {
attrs.push(`layers="${config.layers}"`);
}
if (config?.back_layers) {
attrs.push(`back-layers="${config.back_layers}"`);
}
if (attrs.length > 0) {
parts.push(`{${attrs.join(' ')}}`);
}
return parts.join('');
}
/**
*
*/
function openBrowser(url: string) {
try {
// Windows 使用 start 命令
if (process.platform === 'win32') {
execSync(`start "" "${url}"`, { stdio: 'ignore' });
} else if (process.platform === 'darwin') {
execSync(`open "${url}"`, { stdio: 'ignore' });
if (process.platform === "win32") {
execSync(`start "" "${url}"`, { stdio: "ignore" });
} else if (process.platform === "darwin") {
execSync(`open "${url}"`, { stdio: "ignore" });
} else {
execSync(`xdg-open "${url}"`, { stdio: 'ignore' });
execSync(`xdg-open "${url}"`, { stdio: "ignore" });
}
console.error(`已打开浏览器:${url}`);
} catch (error) {
console.error('打开浏览器失败:', error);
console.error("打开浏览器失败:", error);
}
}
/**
* URL
*/
function computePreviewUrl(mdFilePath: string): string {
const relativeMdPath = relative(process.cwd(), mdFilePath)
.split("\\")
.join("/")
.replace(/\.md$/, "");
return `http://localhost:3000/${relativeMdPath}`;
}
/**
* Deck - Markdown
*/
@@ -129,108 +81,61 @@ export function previewDeck(params: PreviewDeckParams): PreviewDeckResult {
if (!existsSync(csv_file)) {
return {
success: false,
message: `CSV 文件不存在:${csv_file}`
message: `CSV 文件不存在:${csv_file}`,
};
}
// 确定 Markdown 文件路径
let mdFilePath = md_file;
if (!mdFilePath) {
// 使用与 CSV 相同的路径和文件名,只是扩展名不同
const dir = dirname(csv_file);
const name = basename(csv_file, extname(csv_file));
mdFilePath = join(dir, `${name}.md`);
}
const mdFilePath = md_file || defaultMdPath(csv_file);
const created = !existsSync(mdFilePath);
// 如果文件已存在,检查是否已有 md-deck 组件
if (!created) {
try {
const existingContent = readFileSync(mdFilePath, 'utf-8');
// 如果已有 md-deck 组件引用该 CSV,直接返回
if (existingContent.includes(`:md-deck[${csv_file}]`) ||
existingContent.includes(`:md-deck[./${basename(csv_file)}]`)) {
// 仍然打开浏览器
const relativeMdPath = relative(process.cwd(), mdFilePath).split('\\').join('/');
const previewUrl = `http://localhost:3000/${relativeMdPath}`;
const existingContent = readFileSync(mdFilePath, "utf-8");
if (
existingContent.includes(`:md-deck[${csv_file}]`) ||
existingContent.includes(`:md-deck[./${csv_file.split("/").pop()}]`)
) {
const previewUrl = computePreviewUrl(mdFilePath);
openBrowser(previewUrl);
return {
success: true,
message: `预览文件 ${mdFilePath} 已存在`,
md_file: mdFilePath,
created: false,
preview_url: previewUrl
preview_url: previewUrl,
};
}
} catch (error) {
} catch {
// 读取失败,继续创建
}
}
// 读取 CSV 的 frontmatter 获取配置
let deckConfig: DeckConfig | undefined;
try {
const csvContent = readFileSync(csv_file, 'utf-8');
const { frontmatter } = parseFrontMatter(csvContent);
deckConfig = frontmatter?.deck;
} catch (error) {
// 忽略错误,使用默认配置
}
const deckConfig = readDeckConfig(csv_file);
// 确定标题
let deckTitle = title;
if (!deckTitle) {
// 从 CSV 文件名推断
const name = basename(csv_file, extname(csv_file));
deckTitle = name.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}
const deckTitle = title || titleFromCsvPath(csv_file);
// 构建 md-deck 组件代码(使用相对路径)
const mdDir = dirname(mdFilePath);
const csvDir = dirname(csv_file);
let relativeCsvPath: string;
if (mdDir === csvDir) {
relativeCsvPath = `./${basename(csv_file)}`;
} else {
relativeCsvPath = `./${basename(csv_file)}`;
}
const deckComponent = buildDeckComponent(relativeCsvPath, deckConfig);
// 构建 md-deck 组件代码
const deckComponent = buildDeckComponent(
`./${csv_file.split("/").pop()}`,
deckConfig,
);
// 生成 Markdown 内容
const mdLines: string[] = [];
mdLines.push(`# ${deckTitle}`);
mdLines.push('');
if (description) {
mdLines.push(description);
mdLines.push('');
}
mdLines.push('## 卡牌预览');
mdLines.push('');
mdLines.push(deckComponent);
mdLines.push('');
mdLines.push('## 使用说明');
mdLines.push('');
mdLines.push('- 点击卡牌可以查看详情');
mdLines.push('- 使用右上角的按钮可以随机抽取卡牌');
mdLines.push('- 可以通过编辑面板调整卡牌样式和布局');
mdLines.push('');
const mdContent = generateDeckPreviewMd(
deckTitle,
deckComponent,
description,
);
// 写入文件
try {
writeFileSync(mdFilePath, mdLines.join('\n'), 'utf-8');
writeFileSync(mdFilePath, mdContent, "utf-8");
// 计算预览 URL
const relativeMdPath = relative(process.cwd(), mdFilePath).split('\\').join('/');
const previewUrl = `http://localhost:3000/${relativeMdPath.slice(0, -3)}`;
// 打开浏览器
const previewUrl = computePreviewUrl(mdFilePath);
openBrowser(previewUrl);
return {
@@ -240,12 +145,12 @@ export function previewDeck(params: PreviewDeckParams): PreviewDeckResult {
: `更新预览文件 ${mdFilePath}`,
md_file: mdFilePath,
created,
preview_url: previewUrl
preview_url: previewUrl,
};
} catch (error) {
return {
success: false,
message: `写入失败:${error instanceof Error ? error.message : '未知错误'}`
message: `写入失败:${error instanceof Error ? error.message : "未知错误"}`,
};
}
}
+10 -2
View File
@@ -108,7 +108,15 @@ export const Article: Component<ArticleProps & ParentProps> = (props) => {
if (href.startsWith("#")) return;
e.preventDefault();
navigate(href);
// Resolve relative hrefs against the current page path so that
// e.g. "blah.md" on /content/page.md becomes /content/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);
@@ -121,7 +129,7 @@ export const Article: Component<ArticleProps & ParentProps> = (props) => {
return (
<article
class={`prose text-black ${props.class || ""}`}
class={`prose ${props.class || ""}`}
data-src={props.src}
>
<Show when={content.loading}>
+58
View File
@@ -0,0 +1,58 @@
/**
* 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,
declarations: comp.data.declarations,
tagModifiers: comp.data.tagModifiers,
}).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;
+2 -63
View File
@@ -162,75 +162,14 @@ const DocContent: Component<{ entry: AnyEntry }> = (props) => {
return (
<div class="max-w-none">
<h2 class="text-2xl font-bold text-gray-900 mb-2">
<h2 class="text-2xl font-bold text-gray-900 mb-6">
{e.icon} {e.title}
<code class="ml-3 text-base font-mono bg-gray-100 px-2 py-0.5 rounded text-blue-700">
:{e.tag}
</code>
</h2>
<p class="text-gray-600 mb-6">{e.description}</p>
<div class="mb-6">
<h3 class="text-sm font-semibold text-gray-500 uppercase mb-2">
</h3>
<pre class="bg-gray-900 text-gray-100 px-4 py-3 rounded-lg text-sm overflow-x-auto">
<code>{e.syntax}</code>
</pre>
</div>
<Show when={e.props.length > 0}>
<div class="mb-6">
<h3 class="text-sm font-semibold text-gray-500 uppercase mb-2">
</h3>
<table class="w-full text-sm border-collapse">
<thead>
<tr class="border-b border-gray-200">
<th class="text-left py-2 pr-4 font-medium text-gray-600">
</th>
<th class="text-left py-2 pr-4 font-medium text-gray-600">
</th>
<th class="text-left py-2 pr-4 font-medium text-gray-600">
</th>
<th class="text-left py-2 font-medium text-gray-600"></th>
</tr>
</thead>
<tbody>
<For each={e.props}>
{(prop) => (
<tr class="border-b border-gray-100">
<td class="py-2 pr-4">
<code class="bg-gray-100 px-1.5 py-0.5 rounded text-xs text-pink-700">
{prop.name}
</code>
</td>
<td class="py-2 pr-4">
<code class="text-xs text-gray-600">{prop.type}</code>
</td>
<td class="py-2 pr-4 text-xs text-gray-400">
{prop.default ?? "—"}
</td>
<td class="py-2 text-sm text-gray-600">{prop.desc}</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</Show>
<div class="prose-sm max-w-none">
<h3 class="text-sm font-semibold text-gray-500 uppercase mb-2"></h3>
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 text-sm leading-relaxed whitespace-pre-wrap font-mono text-gray-800">
<div class="text-sm leading-relaxed whitespace-pre-wrap text-gray-800">
{e.body}
</div>
</div>
</div>
);
};
+22 -8
View File
@@ -1,4 +1,5 @@
import { Component, createMemo, createSignal, Show } from "solid-js";
import { useLocation } from "@solidjs/router";
import { type FileNode, type TocNode } from "../data-loader";
import { useNavigateWithParams } from "./useNavigateWithParams";
import { useScrollContainer } from "../App";
@@ -24,6 +25,7 @@ export const FileTreeNode: Component<{
isHidden?: (node: FileNode) => boolean;
}> = (props) => {
const navigate = useNavigateWithParams();
const location = useLocation();
const isDir = !!props.node.children;
const isActive = createMemo(() => props.currentPath === props.node.path);
// 默认收起,除非当前文件在该文件夹内
@@ -31,21 +33,28 @@ export const FileTreeNode: Component<{
isDir && isPathInDir(props.currentPath, props.node.path),
);
const handleClick = () => {
const href = () => props.node.path + location.search;
const handleClick = (e: MouseEvent) => {
if (isDir) {
e.preventDefault();
setIsExpanded(!isExpanded());
} else {
} else if (e.button === 0) {
// Left-click: use SPA navigation
e.preventDefault();
navigate(props.node.path);
props.onClose();
}
// Middle-click / right-click: let the browser handle the <a> natively
};
const indent = props.depth * 12;
return (
<div class={props.isHidden?.(props.node) === true ? "hidden" : ""}>
<div
class={`flex items-center py-1 px-2 cursor-pointer hover:bg-gray-100 rounded ${
<a
href={href()}
class={`flex items-center py-1 px-2 cursor-pointer hover:bg-gray-100 rounded no-underline ${
isActive() ? "bg-blue-50 text-blue-700" : "text-gray-700"
}`}
style={{ "padding-left": `${indent + 8}px` }}
@@ -58,7 +67,7 @@ export const FileTreeNode: Component<{
<span class="mr-1 text-gray-400">📄</span>
</Show>
<span class="text-sm truncate">{props.node.name}</span>
</div>
</a>
<Show when={isDir && isExpanded() && props.node.children}>
<div>
{props.node.children!.map((child) => (
@@ -87,8 +96,9 @@ export const HeadingNode: Component<{
isHidden?: (node: TocNode) => boolean;
}> = (props) => {
const navigate = useNavigateWithParams();
const location = useLocation();
const anchor = props.node.id || "";
const href = `${props.basePath}#${anchor}`;
const href = () => `${props.basePath}${location.search}#${anchor}`;
const hasChildren = !!props.node.children;
// 默认收起,除非当前锚点在该节点内
const [isExpanded, setIsExpanded] = createSignal(props.depth <= 0);
@@ -102,8 +112,12 @@ export const HeadingNode: Component<{
const scrollContainer = useScrollContainer();
const handleClick = (e: MouseEvent) => {
if (e.button === 0) {
// Left-click: use SPA navigation
e.preventDefault();
navigate(href);
navigate(`${props.basePath}#${anchor}`);
}
// Middle-click / right-click: let the browser handle the <a> natively
// 滚动到目标元素,考虑导航栏高度偏移
requestAnimationFrame(() => {
const element = document.getElementById(anchor);
@@ -141,7 +155,7 @@ export const HeadingNode: Component<{
</span>
</span>
<a
href={href}
href={href()}
class="inline-flex items-center py-0.5 px-2 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-50 rounded truncate cursor-pointer"
onClick={handleClick}
>
+3 -13
View File
@@ -18,7 +18,6 @@ import {
import { Portal } from "solid-js/web";
import { useLocation } from "@solidjs/router";
import { useArticleDom } from "./Article";
import { useScrollContainer } from "../App";
import { journalStreamState } from "./stores/journalStream";
import { useJournalCompletions } from "./journal/completions";
import {
@@ -76,7 +75,6 @@ function hide() {
export const RevealManager: Component = () => {
const contentDom = useArticleDom();
const scrollContainer = useScrollContainer();
const location = useLocation();
const comp = useJournalCompletions();
@@ -128,7 +126,7 @@ export const RevealManager: Component = () => {
rect: sparkTable.getBoundingClientRect(),
action: () =>
setActionPrefill({
command: "/spark",
command: "/roll",
text: combinedSlug,
}),
title: "Roll spark table",
@@ -181,16 +179,8 @@ export const RevealManager: Component = () => {
<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 +
(scrollContainer()?.scrollTop ?? window.scrollY) -
6
}px`,
left: `${
btn().rect.left +
(scrollContainer()?.scrollLeft ?? window.scrollX) -
20
}px`,
top: `${btn().rect.top - 6}px`,
left: `${btn().rect.left - 20}px`,
}}
title={btn().title}
innerHTML={btn().svg}
+2 -9
View File
@@ -4,16 +4,12 @@ export interface DocEntry {
tag: string;
icon: string;
title: string;
description: string;
syntax: string;
props: { name: string; type: string; default?: string; desc: string }[];
/** Markdown body (everything after frontmatter ---) */
/** Full markdown body (everything after frontmatter ---) */
body: string;
}
/** Splits frontmatter and markdown body from a raw .md string. */
function parseFrontmatter(raw: string): Record<string, unknown> | null {
// Handle CRLF line endings by normalizing to LF first
const normalized = raw.replace(/\r\n/g, "\n");
const match = normalized.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
if (!match) return null;
@@ -27,7 +23,7 @@ function parseFrontmatter(raw: string): Record<string, unknown> | null {
function getBody(raw: string): string {
const normalized = raw.replace(/\r\n/g, "\n");
const match = normalized.match(/^---\n[\s\S]*?\n---\n?([\s\S]*)$/);
return match ? match[1] : raw;
return (match ? match[1] : raw).trim();
}
function parseEntry(raw: string): DocEntry | null {
@@ -37,9 +33,6 @@ function parseEntry(raw: string): DocEntry | null {
tag: fm.tag as string,
icon: fm.icon as string,
title: fm.title as string,
description: fm.description as string,
syntax: fm.syntax as string,
props: (fm.props as DocEntry["props"]) ?? [],
body: getBody(raw),
};
}
+2
View File
@@ -15,6 +15,8 @@ import "./md-yarn-spinner";
export { Article } from "./Article";
export type { ArticleProps } from "./Article";
export { RevealManager } from "./RevealManager";
export { CommandLinkManager } from "./CommandLinkManager";
export { ReactiveVariableManager } from "./journal/ReactiveVariableManager";
export { MobileSidebar, DesktopSidebar } from "./Sidebar";
export type { SidebarProps } from "./Sidebar";
export { FileTreeNode, HeadingNode } from "./FileTree";
+12 -9
View File
@@ -4,9 +4,7 @@ export interface JournalDocEntry {
tag: string;
icon: string;
title: string;
description: string;
syntax: string;
props: { name: string; type: string; default?: string; desc: string }[];
/** Full markdown body (everything after frontmatter ---) */
body: string;
}
@@ -24,7 +22,7 @@ function parseFrontmatter(raw: string): Record<string, unknown> | null {
function getBody(raw: string): string {
const normalized = raw.replace(/\r\n/g, "\n");
const match = normalized.match(/^---\n[\s\S]*?\n---\n?([\s\S]*)$/);
return match ? match[1] : raw;
return (match ? match[1] : raw).trim();
}
function parseEntry(raw: string): JournalDocEntry | null {
@@ -34,18 +32,23 @@ function parseEntry(raw: string): JournalDocEntry | null {
tag: fm.tag as string,
icon: fm.icon as string,
title: fm.title as string,
description: fm.description as string,
syntax: fm.syntax as string,
props: (fm.props as JournalDocEntry["props"]) ?? [],
body: getBody(raw),
};
}
import journalGmRaw from "../doc-entries/journal-gm.md";
import journalPlayerRaw from "../doc-entries/journal-player.md";
import journalStatRaw from "../doc-entries/journal-stat.md";
import journalSetRaw from "../doc-entries/journal-set.md";
import journalRollRaw from "../doc-entries/journal-roll.md";
import journalLinkRaw from "../doc-entries/journal-link.md";
const rawDocuments: string[] = [journalGmRaw, journalPlayerRaw, journalStatRaw];
const rawDocuments: string[] = [
journalGmRaw,
journalPlayerRaw,
journalSetRaw,
journalRollRaw,
journalLinkRaw,
];
let _entries: JournalDocEntry[] | null = null;
-1
View File
@@ -5,7 +5,6 @@
import { Component, createSignal, For, Show, createMemo } from "solid-js";
import { registeredTypes, canEmit } from "./registry";
import { sendMessage, useJournalStream } from "../stores/journalStream";
import type { MessageTypeDef } from "./registry";
import { DynamicForm } from "./DynamicForm";
export const ComposePanel: Component = () => {
+6 -1
View File
@@ -40,7 +40,12 @@ export const ConnectDialog: Component = () => {
const name = playerName().trim() || stream.myName;
if (!name) return;
const brokerUrl = `ws://${window.location.host}`;
// In dev, the MQTT broker lives on the CLI server (port 3000), not the
// rsbuild dev server. In production, the CLI serves everything itself.
const brokerUrl =
process.env.NODE_ENV === "development"
? `ws://localhost:3000`
: `ws://${window.location.host}`;
setConnecting(true);
setError(null);
+5 -1
View File
@@ -25,7 +25,11 @@ export const ConnectionBar: Component = () => {
const handleCreateSession = () => {
const name = newSessionName().trim();
if (!name) return;
createSession(name);
const id = createSession(name);
if (id) {
setSessionId(id);
hydrateFromServer(id).catch(() => {});
}
setNewSessionName("");
setShowNewSession(false);
};
+11 -3
View File
@@ -2,7 +2,7 @@
* CreateSessionDialog modal for naming a new session
*/
import { Component, createSignal, onMount } from "solid-js";
import { createSession } from "../stores/journalStream";
import { createSession, setSessionId, hydrateFromServer } from "../stores/journalStream";
interface CreateSessionDialogProps {
onClose: () => void;
@@ -12,13 +12,21 @@ export const CreateSessionDialog: Component<CreateSessionDialogProps> = (
props,
) => {
const [name, setName] = createSignal("");
const [creating, setCreating] = createSignal(false);
let inputRef!: HTMLInputElement;
const handleCreate = () => {
const handleCreate = async () => {
const trimmed = name().trim();
if (!trimmed) return;
createSession(trimmed);
setCreating(true);
const id = createSession(trimmed);
if (id) {
setSessionId(id);
// Hydrate the new (empty) session so the UI reflects it immediately
await hydrateFromServer(id).catch(() => {});
}
setName("");
setCreating(false);
props.onClose();
};
+1 -5
View File
@@ -9,7 +9,6 @@
import { Component, For, Show, createMemo } from "solid-js";
import { z } from "zod";
import { getMessageType } from "./registry";
import type { MessageTypeDef } from "./registry";
type FormData = Record<string, unknown>;
@@ -22,9 +21,6 @@ interface DynamicFormProps {
export const DynamicForm: Component<DynamicFormProps> = (props) => {
const def = createMemo(() => getMessageType(props.type));
// Generate default on type change
const schema = createMemo(() => def()?.schema);
return (
<div class="space-y-1.5">
<Show when={def()}>
@@ -136,7 +132,7 @@ const FieldInput: Component<{
return (
<div class="flex items-center gap-1">
<label
class="text-xs text-gray-500 w-20 flex-shrink-0 truncate"
class="text-xs text-gray-500 w-20 shrink-0 truncate"
title={props.label}
>
{props.label}
+30
View File
@@ -0,0 +1,30 @@
/**
* ErrorPopup shows the full error message in a floating panel above
* the journal input area. Toggled by clicking the truncated error text.
*/
import { Component, Show } from "solid-js";
interface ErrorPopupProps {
message: string | null;
show: boolean;
onClose: () => void;
}
export const ErrorPopup: Component<ErrorPopupProps> = (props) => {
return (
<Show when={props.show && props.message}>
<div class="absolute bottom-full left-2 right-2 mb-1 bg-red-50 border border-red-200 rounded shadow-lg z-50">
<div class="flex items-start justify-between px-2 pt-2 pb-1">
<p class="text-red-700 text-xs break-words">{props.message}</p>
<button
onClick={props.onClose}
class="text-red-400 hover:text-red-600 text-xs leading-none ml-2 shrink-0"
title="关闭"
>
</button>
</div>
</div>
</Show>
);
};
+58 -175
View File
@@ -7,25 +7,20 @@
* - `/link path#section` "link" type
* - `/stat set key=value` "stat" type
* - `/` alone opens completions dropdown
*
* Delegates command dispatch to the shared command-dispatcher module.
*/
import { Component, createSignal, createEffect, onMount, Show } from "solid-js";
import { sendMessage, useJournalStream } from "../stores/journalStream";
import { actionPrefill, setActionPrefill } from "../stores/reveal";
import { useJournalCompletions, ensureCompletions } from "./completions";
import { resolveRollPayload } from "./types/roll";
import { resolveSparkPayload } from "./types/spark";
import {
resolveStatRoll,
resolveTemplateSet,
canModifyStat,
fullKey,
findStatDef,
} from "./stat-helpers";
import { parseInput } from "./command-parser";
import type { CompletionItem } from "./command-parser";
import { buildCompletions } from "./command-completions";
import { CompletionsDropdown } from "./CompletionsDropdown";
import { ErrorPopup } from "./ErrorPopup";
import { dispatchCommand, dispatchError, setDispatchError } from "./command-dispatcher";
// ---- Component ----
@@ -38,7 +33,7 @@ export const JournalInput: Component = () => {
const isGm = () => stream.myRole === "gm";
const [text, setText] = createSignal("");
const [error, setError] = createSignal<string | null>(null);
const [showErrorPopup, setShowErrorPopup] = createSignal(false);
const [sending, setSending] = createSignal(false);
const [showCompletions, setShowCompletions] = createSignal(false);
const [selectedIdx, setSelectedIdx] = createSignal(0);
@@ -77,200 +72,75 @@ export const JournalInput: Component = () => {
// Observers: everything is plain chat
if (isObserver()) {
const result = sendMessage("chat", { text: raw });
const r = unwrap(result);
finish(r.ok, r.err);
const r = unwrapSendResult(result);
finish(r.ok, r.error);
return;
}
const parsed = parseInput(raw);
if (parsed.error) {
setError(parsed.error);
setDispatchError(parsed.error);
return;
}
setError(null);
setDispatchError(null);
// Players: chat + stat commands only
// Players: chat + set commands only
if (isPlayer()) {
if (parsed.type === "chat") {
const result = sendMessage("chat", { text: raw });
const r = unwrap(result);
finish(r.ok, r.err);
const r = unwrapSendResult(result);
finish(r.ok, r.error);
return;
}
if (parsed.type === "stat") {
handleStat(parsed.payload);
if (parsed.type !== "set" && parsed.type !== "rolltag") {
setDispatchError("玩家只能发送聊天消息或使用 /set 命令");
return;
}
setError("玩家只能发送聊天消息或使用 /stat 命令");
return;
}
// GM: all commands
// GM: all commands, Player: set commands
setSending(true);
if (parsed.type === "roll") {
const p = resolveRollPayload(
parsed.payload as { notation: string; label?: string },
);
const result = sendMessage("roll", p);
const r = unwrap(result);
finish(r.ok, r.err);
return;
}
await dispatchCommand({
role: stream.myRole as "gm" | "player" | "observer",
myName: stream.myName,
command: raw,
sparkTables: comp.data.sparkTables,
variables: stream.variables,
declarations: comp.data.declarations,
tagModifiers: comp.data.tagModifiers,
});
if (parsed.type === "spark") {
try {
const key = (parsed.payload as { key: string }).key;
const match = comp.data.sparkTables.find((s) => s.slug === key);
const filePath = match?.filePath ?? "";
const p = await resolveSparkPayload({ key, filePath });
const result = sendMessage("spark", p);
const r = unwrap(result);
finish(r.ok, r.err);
} catch (e) {
setError(e instanceof Error ? e.message : "种子表掷骰失败");
// dispatchCommand already set the shared error if it failed.
// Only clear text on success (no error is shown).
if (!dispatchError()) {
setText("");
}
setSending(false);
}
return;
textareaRef?.focus();
}
if (parsed.type === "stat") {
handleStat(parsed.payload);
setSending(false);
return;
}
const result = sendMessage(parsed.type, parsed.payload);
const r = unwrap(result);
finish(r.ok, r.err);
}
/** Handle a /stat command payload (set/del/roll) */
function handleStat(payload: Record<string, unknown>) {
const p = payload as { action?: string; key?: string; value?: string };
if (p.action === "roll" && p.key) {
const resolved = resolveStatRoll(
p.key,
comp.data.stats,
stream.stats,
stream.myName,
comp.data.statTemplates,
);
if (resolved.error) {
setError(resolved.error);
} else {
// Send the primary stat value
const result = sendMessage("stat", {
action: "set",
key: resolved.fullKey,
value: resolved.value,
});
const r = unwrap(result);
if (!r.ok) {
finish(false, r.err);
return;
}
// Send any modifier overrides from template
if (resolved.modifiers) {
for (const [mk, mv] of Object.entries(resolved.modifiers)) {
sendMessage("stat", {
action: "set",
key: mk,
value: mv,
});
}
}
finish(true);
}
return;
}
if (!p.action || !p.key) {
setError("无效的 stat 命令");
return;
}
// Resolve bare key to full key for set/del
const fk = resolveKey(p.key, comp.data.stats, stream.myName);
if (!canModifyStat(stream.myRole, stream.myName, fk, comp.data.stats)) {
setError(`无权修改属性: ${p.key}`);
return;
}
const result = sendMessage("stat", {
action: p.action,
key: fk,
value: p.value,
});
const r = unwrap(result);
if (!r.ok) {
finish(false, r.err);
return;
}
// If setting a template-type stat, apply its modifier overrides
if (p.action === "set" && p.value) {
const def = findStatDef(fk, comp.data.stats, stream.myName);
if (def) {
const modifiers = resolveTemplateSet(
def,
p.value,
comp.data.stats,
stream.stats,
stream.myName,
comp.data.statTemplates,
);
if (modifiers) {
for (const [mk, mv] of Object.entries(modifiers)) {
sendMessage("stat", { action: "set", key: mk, value: mv });
}
}
}
}
finish(true);
}
/** Resolve a bare or full key to the actual runtime key. */
function resolveKey(
inputKey: string,
statDefs: typeof comp.data.stats,
playerName: string,
): string {
// Exact match
if (statDefs.some((d) => fullKey(d, playerName) === inputKey))
return inputKey;
// Bare key → full key
const def = statDefs.find((d) => d.key === inputKey);
if (def) return fullKey(def, playerName);
return inputKey;
}
/** Clear text + error on success, or set error on failure. */
/** Clear text or set shared dispatch error on failure. */
function finish(success: boolean, err?: string) {
if (success) {
setText("");
setDispatchError(null);
} else if (err) {
setError(err);
setDispatchError(err);
}
setSending(false);
textareaRef?.focus();
}
/** Unwrap a sendMessage result into (success, error?) for finish(). */
function unwrap<R>(
function unwrapSendResult<R>(
r: { success: true; msg: R } | { success: false; error: string },
) {
return r.success
? ({ ok: true, err: undefined } as const)
: ({ ok: false, err: r.error } as const);
? ({ ok: true, error: undefined } as const)
: ({ ok: false, error: r.error } as const);
}
// ---- Completions ----
@@ -311,7 +181,7 @@ export const JournalInput: Component = () => {
return;
}
const raw = text();
if ((isGm() || raw.startsWith("/stat")) && raw.startsWith("/")) {
if ((isGm() || raw.startsWith("/set")) && raw.startsWith("/")) {
e.preventDefault();
openCompletions();
return;
@@ -330,9 +200,9 @@ export const JournalInput: Component = () => {
selectCompletion("up");
return;
}
if (e.key === "Enter" && comps[selectedIdx()].kind !== "no-results") {
if (e.key === "Enter") {
e.preventDefault();
acceptCompletion(comps[selectedIdx()]);
setShowCompletions(false);
return;
}
if (e.key === "Escape") {
@@ -352,7 +222,7 @@ export const JournalInput: Component = () => {
const raw = input.value;
setText(raw);
if ((isGm() || raw.startsWith("/stat")) && raw.startsWith("/")) {
if ((isGm() || raw.startsWith("/set")) && raw.startsWith("/")) {
setShowCompletions(true);
setSelectedIdx(0);
} else {
@@ -395,18 +265,24 @@ export const JournalInput: Component = () => {
onKeyDown={handleKeyDown}
placeholder={
isGm()
? "输入消息,或使用 /roll、/spark、/link、/stat 命令..."
: "输入消息,或使用 /stat 命令..."
? "输入消息,或使用 /roll、/link、/set 命令..."
: "输入消息,或使用 /set 命令..."
}
rows={2}
class="w-full resize-none border-0 px-3 pt-2.5 pb-1 text-sm
text-gray-800 placeholder-gray-400 focus:outline-none
bg-transparent min-h-[60px]"
bg-transparent min-h-15"
/>
<div class="flex items-center justify-between px-2 pb-2">
<Show when={error()}>
<p class="text-red-500 text-xs truncate max-w-[60%]">{error()}</p>
<Show when={dispatchError()}>
<button
onClick={() => setShowErrorPopup((v) => !v)}
class="text-red-500 text-xs truncate max-w-[60%] text-left hover:underline"
title={dispatchError() ?? undefined}
>
{dispatchError()}
</button>
</Show>
<div class="flex items-center gap-1 ml-auto">
<button
@@ -422,6 +298,13 @@ export const JournalInput: Component = () => {
</div>
</Show>
</div>
{/* Error popup */}
<ErrorPopup
message={dispatchError()}
show={showErrorPopup()}
onClose={() => setShowErrorPopup(false)}
/>
</div>
);
};
+6 -6
View File
@@ -15,7 +15,7 @@ import { JournalHeader } from "./JournalHeader";
import { ConnectDialog } from "./ConnectDialog";
import { InviteDialog } from "./InviteDialog";
import { CreateSessionDialog } from "./CreateSessionDialog";
import { StatsView } from "./StatsView";
import { VariableView } from "./VariableView";
export interface JournalPanelProps {
open: boolean;
@@ -26,7 +26,7 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
const stream = useJournalStream();
const [showInvite, setShowInvite] = createSignal(false);
const [showCreateSession, setShowCreateSession] = createSignal(false);
const [viewMode, setViewMode] = createSignal<"stream" | "stats">("stream");
const [viewMode, setViewMode] = createSignal<"stream" | "variables">("stream");
// Player list (exclude gm and observer)
const playerEntries = () =>
@@ -99,18 +99,18 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
</button>
<button
onClick={() => setViewMode("stats")}
onClick={() => setViewMode("variables")}
class={`flex-1 text-xs py-1.5 transition-colors ${
viewMode() === "stats"
viewMode() === "variables"
? "bg-white text-blue-600 font-medium border-b-2 border-blue-600"
: "text-gray-500 hover:text-gray-700"
}`}
>
</button>
</div>
<div class="flex-1 min-h-0">
<Show when={viewMode() === "stream"} fallback={<StatsView />}>
<Show when={viewMode() === "stream"} fallback={<VariableView />}>
<StreamView />
</Show>
</div>
@@ -0,0 +1,156 @@
/**
* ReactiveVariableManager mounts as a child of <Article> and reactively
* replaces {{$key}} template patterns in the rendered markdown DOM with
* live variable values from the journal stream.
*
* On mount, walks all text nodes in the article content looking for
* {{$key}} placeholders. Each match is wrapped in a <span> with a
* data-reactive-var attribute so that subsequent updates (driven by
* a Solid effect) only touch those spans.
*/
import { Component, createEffect, onCleanup } from "solid-js";
import { useArticleDom } from "../Article";
import { useJournalStream } from "../stores/journalStream";
const TEMPLATE_RE = /\{\{(\$[a-zA-Z_][a-zA-Z0-9_]*)\}\}/g;
/** Attribute storing the original template text on marker spans. */
const ATTR_TEMPLATE = "data-reactive-var";
/** Attribute storing pre-computed key list (comma-separated) for fast updates. */
const ATTR_KEYS = "data-reactive-keys";
export const ReactiveVariableManager: Component = () => {
const contentDom = useArticleDom();
const stream = useJournalStream();
const values = () => stream.variables;
// ---- Initial scan: wrap {{$key}} text in marker spans ----
createEffect(() => {
const dom = contentDom();
if (!dom) return;
// Only scan once — after the first scan, the spans exist and we
// switch to the reactive update effect below.
if (dom.querySelector(`[${ATTR_TEMPLATE}]`)) return;
scanAndWrap(dom);
});
onCleanup(() => {
const dom = contentDom();
if (dom) unwrapAll(dom);
});
// ---- Reactive updates: whenever variable values change, update all spans ----
createEffect(() => {
const dom = contentDom();
if (!dom) return;
const v = values();
const spans = dom.querySelectorAll<HTMLElement>(`[${ATTR_TEMPLATE}]`);
for (const span of spans) {
const template = span.getAttribute(ATTR_TEMPLATE) ?? "";
const keys = span.getAttribute(ATTR_KEYS)?.split(",") ?? [];
let result = template;
for (const key of keys) {
result = result.replaceAll(`{{${key}}}`, v[key] ?? "");
}
if (span.textContent !== result) {
span.textContent = result;
}
}
});
return null; // renders nothing — works purely via DOM manipulation
};
// ---------------------------------------------------------------------------
// DOM helpers
// ---------------------------------------------------------------------------
/**
* Walk all text nodes in a subtree and wrap `{{$key}}` patterns in
* `<span data-reactive-var="originalText">` elements.
*/
function scanAndWrap(root: Element): void {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const replacements: { node: Text; html: string }[] = [];
let textNode: Text | null;
while ((textNode = walker.nextNode() as Text | null)) {
const text = textNode.textContent;
if (!text) continue;
TEMPLATE_RE.lastIndex = 0;
if (!TEMPLATE_RE.test(text)) continue;
// Build replacement HTML: split on {{$key}}, wrap each match in a span
// with pre-computed key for fast updates.
TEMPLATE_RE.lastIndex = 0;
const parts: string[] = [];
let lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = TEMPLATE_RE.exec(text)) !== null) {
if (m.index > lastIndex) {
parts.push(escapeHtml(text.slice(lastIndex, m.index)));
}
const key = m[1]; // includes $ prefix
parts.push(
`<span ${ATTR_TEMPLATE}="${escapeAttr(m[0])}" ${ATTR_KEYS}="${escapeAttr(key)}">${escapeHtml(m[0])}</span>`,
);
lastIndex = TEMPLATE_RE.lastIndex;
}
if (lastIndex < text.length) {
parts.push(escapeHtml(text.slice(lastIndex)));
}
replacements.push({ node: textNode, html: parts.join("") });
}
for (const { node, html } of replacements) {
const wrapper = document.createElement("span");
wrapper.innerHTML = html;
node.replaceWith(wrapper);
}
}
/**
* Reverse scanAndWrap remove marker spans, restoring plain text.
* Called on cleanup so subsequent remounts get a clean slate.
*/
function unwrapAll(root: Element): void {
const spans = root.querySelectorAll<HTMLElement>(`[${ATTR_TEMPLATE}]`);
// Process in reverse to avoid DOM mutation issues
for (let i = spans.length - 1; i >= 0; i--) {
const span = spans[i];
const parent = span.parentNode;
if (!parent) continue;
const text = span.getAttribute(ATTR_TEMPLATE) ?? span.textContent ?? "";
span.replaceWith(document.createTextNode(text));
}
// Normalize to merge adjacent text nodes
root.normalize();
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function escapeAttr(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
export default ReactiveVariableManager;
-234
View File
@@ -1,234 +0,0 @@
/**
* StatsView table view of all stat key-value pairs
*
* Groups stats by scope (global, then per-player). Shows computed values
* (base + modifiers) and inline roll buttons for stats with roll/table/formula.
*/
import { Component, For, createMemo, Show } from "solid-js";
import { useJournalStream } from "../stores/journalStream";
import { useJournalCompletions } from "./completions";
import { fullKey } from "./stat-helpers";
import type { StatDef } from "./completions";
export const StatsView: Component = () => {
const stream = useJournalStream();
const comp = useJournalCompletions();
const statDefs = createMemo(() => comp.data.stats);
const values = createMemo(() => stream.stats);
/** Build a lookup: fullKey -> StatDef */
const defMap = createMemo(() => {
const map = new Map<string, StatDef>();
for (const def of statDefs()) {
const fk = fullKey(def, stream.myName);
map.set(fk, def);
}
return map;
});
/** Compute the effective value of a stat (base + modifiers) */
function computedValue(key: string): string {
const def = defMap().get(key);
if (!def) return values()[key] ?? "";
const base = values()[key] ?? def.default ?? "";
const baseNum = parseFloat(base);
if (def.type === "number" || def.type === "derived") {
// Sum modifiers that target this key (fullKey matching)
const modKeys: string[] = [];
for (const [fk, d] of defMap()) {
if (
d.type === "modifier" &&
d.target &&
fullKeyTarget(d.target, d, def)
) {
modKeys.push(fk);
}
}
let total = isNaN(baseNum) ? 0 : baseNum;
for (const mk of modKeys) {
const mv = parseFloat(values()[mk] ?? defMap().get(mk)?.default ?? "0");
if (!isNaN(mv)) total += mv;
}
return String(total);
}
return base || "—";
}
/** Check if a modifier's target matches a given def (scoped correctly). */
function fullKeyTarget(
target: string,
modDef: StatDef,
targetDef: StatDef,
): boolean {
if (modDef.scope === targetDef.scope) {
return (
fullKey({ ...modDef, key: target }, stream.myName) ===
fullKey(targetDef, stream.myName)
);
}
return false;
}
/** Group stats by scope */
const groups = createMemo(() => {
const result: {
scope: string;
label: string;
entries: { fullKey: string; def: StatDef }[];
}[] = [];
const globalEntries: { fullKey: string; def: StatDef }[] = [];
const playerEntries: { fullKey: string; def: StatDef }[] = [];
for (const def of statDefs()) {
const fk = fullKey(def, stream.myName);
if (def.scope === "global") {
globalEntries.push({ fullKey: fk, def });
} else {
playerEntries.push({ fullKey: fk, def });
}
}
if (globalEntries.length > 0) {
result.push({ scope: "global", label: "全局", entries: globalEntries });
}
if (playerEntries.length > 0) {
result.push({
scope: "player",
label: `玩家 (${stream.myName})`,
entries: playerEntries,
});
}
return result;
});
return (
<div class="h-full overflow-y-auto bg-gray-50">
<Show
when={statDefs().length > 0}
fallback={
<p class="text-center text-gray-400 text-xs py-8">
使 ```yaml role=stat 代码块定义属性。
</p>
}
>
<For each={groups()}>
{(group) => (
<div class="mb-3">
<div class="sticky top-0 bg-gray-100 px-3 py-1.5 border-b border-gray-200">
<span class="text-xs font-semibold text-gray-600">
{group.label}
</span>
</div>
<div class="divide-y divide-gray-100">
<For each={group.entries}>
{({ fullKey: fk, def }) => {
const val = () => values()[fk];
const comp = () => computedValue(fk);
const hasModifiers = () => {
for (const [mfk, md] of defMap()) {
if (
md.type === "modifier" &&
md.target &&
fullKeyTarget(md.target, md, def)
) {
return true;
}
}
return false;
};
const canRoll = () =>
def.roll ||
def.formula ||
def.type === "enum" ||
def.type === "template";
return (
<div class="flex items-center px-3 py-1.5 hover:bg-white/50 transition-colors">
<div class="flex-1 min-w-0 flex items-center gap-1.5">
<span class="text-sm text-gray-800 truncate">
{def.label}
</span>
<span class="text-[10px] text-gray-400 bg-gray-100 px-1 rounded shrink-0">
{def.key}
</span>
<Show when={def.type === "modifier"}>
<span class="text-[10px] text-blue-400 bg-blue-50 px-1 rounded shrink-0">
{def.target}
</span>
</Show>
</div>
<div class="flex items-center gap-2 shrink-0">
<Show
when={val() !== undefined}
fallback={
<span class="text-sm text-gray-300">
{def.default ?? "—"}
</span>
}
>
<span class="text-sm font-mono text-gray-700">
{val()}
</span>
</Show>
<Show
when={
hasModifiers() &&
comp() !== (val() ?? def.default ?? "")
}
>
<span class="text-xs text-gray-400">
= {comp()}
</span>
</Show>
<Show when={canRoll()}>
<button
class="text-[10px] bg-green-100 text-green-700 hover:bg-green-200 px-1.5 py-0.5 rounded transition-colors"
title="掷骰"
onClick={() => {
const textarea =
document.querySelector<HTMLTextAreaElement>(
"#journal-input-textarea",
);
if (textarea) {
const nativeInputValueSetter =
Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
"value",
)?.set;
nativeInputValueSetter?.call(
textarea,
`/stat roll ${def.key}`,
);
textarea.dispatchEvent(
new Event("input", { bubbles: true }),
);
textarea.focus();
}
}}
>
🎲
</button>
</Show>
</div>
</div>
);
}}
</For>
</div>
</div>
)}
</For>
</Show>
</div>
);
};
+124
View File
@@ -0,0 +1,124 @@
/**
* VariableView flat list of all variable key-value pairs.
*
* Hovering a value shows a popup with:
* - Declaration expression (if any), styled distinctly
* - Active tag modifiers: #tag +N (from $source)
*/
import { Component, For, createMemo, Show, createSignal } from "solid-js";
import { useJournalStream } from "../stores/journalStream";
import { useJournalCompletions } from "./completions";
import { getMods, getDeclExpr } from "./var-reactivity";
export const VariableView: Component = () => {
const stream = useJournalStream();
const comp = useJournalCompletions();
const values = createMemo(() => stream.variables);
/** Flat list of all variables */
const items = createMemo(() => {
const vars = values();
return Object.entries(vars).map(([key, value]) => ({
key,
value,
isTag: value.startsWith("#"),
}));
});
const hasAnyData = () =>
comp.data.declarations.length > 0 || Object.keys(values()).length > 0;
return (
<div class="h-full overflow-y-auto bg-gray-50">
<Show
when={hasAnyData()}
fallback={
<p class="text-center text-gray-400 text-xs py-8">
使 ```csv role=declare 代码块定义变量。
</p>
}
>
<div class="divide-y divide-gray-100">
<For each={items()}>
{(item) => (
<VariableRow
key={item.key}
value={item.value}
isTag={item.isTag}
/>
)}
</For>
</div>
</Show>
</div>
);
};
// ---------------------------------------------------------------------------
// VariableRow — single row with hover popup
// ---------------------------------------------------------------------------
const VariableRow: Component<{
key: string;
value: string;
isTag: boolean;
}> = (props) => {
const [hovered, setHovered] = createSignal(false);
const declExpr = createMemo(() => getDeclExpr(props.key));
const mods = createMemo(() => getMods(props.key));
const hasPopup = () => !!declExpr() || mods().length > 0;
return (
<div
class="relative flex items-center px-3 py-1.5 hover:bg-white/50 transition-colors"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<span class="flex-1 text-sm font-mono text-gray-800">{props.key}</span>
<span
class={props.isTag
? "text-sm font-mono text-purple-700 font-semibold"
: "text-sm font-mono text-gray-700 font-semibold"
}
>
{props.value}
</span>
{/* Hover popup */}
<Show when={hasPopup() && hovered()}>
<div class="absolute right-0 top-full mt-1 z-50 bg-white border border-gray-200 rounded-md shadow-lg p-2 min-w-45 text-xs">
{/* Declaration expression */}
<Show when={declExpr()}>
<div class="mb-1">
<span class="text-gray-400">expr: </span>
<span class="font-mono text-gray-500 bg-gray-50 px-1 rounded">
{declExpr()}
</span>
</div>
</Show>
{/* Tag modifiers */}
<Show when={mods().length > 0}>
<div class={declExpr() ? "border-t border-gray-100 pt-1 mt-1" : ""}>
<For each={mods()}>
{(mod) => (
<div class="flex items-center gap-1 text-gray-500">
<span class="font-mono text-purple-600">{mod.tag}</span>
<span>+{mod.value}</span>
<span class="text-gray-400">(from {mod.source})</span>
</div>
)}
</For>
</div>
</Show>
</div>
</Show>
</div>
);
};
export default VariableView;
+61 -63
View File
@@ -21,9 +21,8 @@ export function buildCompletions(
const commands = [
{ label: "/roll", kind: "command" as const, insertText: "/roll " },
{ label: "/spark", kind: "command" as const, insertText: "/spark " },
{ label: "/link", kind: "command" as const, insertText: "/link " },
{ label: "/stat", kind: "command" as const, insertText: "/stat " },
{ label: "/set", kind: "command" as const, insertText: "/set " },
];
// Show commands when user types / or starts typing a command name
@@ -33,53 +32,59 @@ export function buildCompletions(
c.label.toLowerCase().startsWith(prefix),
);
if (!isGm) {
matches = matches.filter((c) => c.label === "/stat");
matches = matches.filter((c) => c.label === "/set");
}
return matches.length > 0
? matches
: [{ label: "未知命令", kind: "no-results", insertText: "" }];
}
// After /roll — show dice suggestions
// After /roll — show dice AND spark table suggestions
if (raw.startsWith("/roll ")) {
const prefix = raw.slice("/roll ".length).toLowerCase();
const matches = data.dice
const diceMatches = data.dice
.filter(
(d) =>
d.notation.toLowerCase().includes(prefix) ||
d.label.toLowerCase().includes(prefix),
)
.slice(0, 8);
if (matches.length === 0) {
return [{ label: "未找到骰子", kind: "no-results", insertText: "" }];
}
return matches.map((d) => ({
label: d.notation,
kind: "value" as const,
insertText: "/roll " + d.notation,
}));
}
.slice(0, 4);
// After /spark — show spark table suggestions
if (raw.startsWith("/spark ")) {
const prefix = raw.slice("/spark ".length).toLowerCase();
const matches = data.sparkTables
const sparkMatches = data.sparkTables
.filter(
(s) =>
s.slug.toLowerCase().includes(prefix) ||
s.label.toLowerCase().includes(prefix),
)
.slice(0, 8);
if (matches.length === 0) {
return [{ label: "未找到种子表", kind: "no-results", insertText: "" }];
.slice(0, 4);
const items: CompletionItem[] = [];
for (const d of diceMatches) {
items.push({
label: `🎲 ${d.notation}`,
kind: "value",
insertText: "/roll " + d.notation,
});
}
return matches.map((s) => ({
label: `${s.slug} (${s.notation})`,
kind: "value" as const,
insertText: `/spark ${s.slug}`,
}));
for (const s of sparkMatches) {
items.push({
label: ` ${s.slug} (${s.notation})`,
kind: "value",
insertText: `/roll ${s.slug}`,
});
}
if (items.length === 0) {
return [{ label: "输入骰子表达式", kind: "no-results", insertText: "" }];
}
return items;
}
// After /link — show article and heading suggestions
if (raw.startsWith("/link ")) {
const prefix = raw.slice("/link ".length).toLowerCase();
@@ -101,50 +106,43 @@ export function buildCompletions(
});
}
// After /stat — show subcommands or stat keys
if (raw.startsWith("/stat ")) {
const rest = raw.slice("/stat ".length).trim();
// After /set — show known variable and tag names
if (raw.startsWith("/set ")) {
const rest = raw.slice("/set ".length).trim();
if (!rest || rest.length < 3) {
const subs = ["set", "del", "roll"];
const matches = subs.filter((s) => s.startsWith(rest.toLowerCase()));
if (matches.length === 0)
return [{ label: "set/del/roll", kind: "no-results", insertText: "" }];
return matches.map((s) => ({
label: `/stat ${s}`,
kind: "value" as const,
insertText: `/stat ${s} `,
}));
}
if (rest.startsWith("set ")) {
const prefix = rest.slice("set ".length).toLowerCase();
const matches = data.stats
.filter((s) => s.key.toLowerCase().includes(prefix))
// Already has a key? Show tag suggestions
if (rest.includes(" ")) {
const afterSpace = rest.slice(rest.indexOf(" ") + 1).toLowerCase();
const tags = data.tagModifiers.map((tm) => tm.tag);
// Also gather unique tags from declarations
const uniqueTags = [...new Set(tags)];
const matches = uniqueTags
.filter((t) => t.toLowerCase().startsWith(afterSpace))
.slice(0, 8);
if (matches.length === 0)
return [{ label: "未找到属性", kind: "no-results", insertText: "" }];
return matches.map((s) => ({
label: `${s.key} (${s.label})`,
if (matches.length === 0) {
return [{ label: "输入表达式", kind: "no-results", insertText: "" }];
}
return matches.map((t) => ({
label: t,
kind: "value" as const,
insertText: `/stat set ${s.key}=`,
insertText: raw + t,
}));
}
if (rest.startsWith("del ") || rest.startsWith("roll ")) {
const [cmd, ...restParts] = rest.split(" ");
const prefix = restParts.join(" ").toLowerCase();
const matches = data.stats
.filter((s) => s.key.toLowerCase().startsWith(prefix))
// Show variable name suggestions
const prefix = rest.toLowerCase();
const varNames = data.declarations.map((d) => d.key);
const matches = varNames
.filter((v) => v.toLowerCase().startsWith(prefix))
.slice(0, 8);
if (matches.length === 0)
return [{ label: "未找到属性", kind: "no-results", insertText: "" }];
return matches.map((s) => ({
label: `${s.key} (${s.label})`,
kind: "value" as const,
insertText: `/stat ${cmd} ${s.key}`,
}));
if (matches.length === 0) {
return [{ label: "$variable", kind: "no-results", insertText: "" }];
}
return matches.map((v) => ({
label: v,
kind: "value" as const,
insertText: `/set ${v} `,
}));
}
return [];
@@ -0,0 +1,266 @@
/**
* Command dispatcher shared logic for parsing & dispatching commands
* from text input or :cmd[] directive spans.
*
* Used by both JournalInput (manual typing) and CommandLinkManager (click).
*/
import { sendMessage } from "../stores/journalStream";
import { createSignal } from "solid-js";
import { parseInput } from "./command-parser";
import { resolveRollPayload } from "./types/roll";
import { resolveSparkPayload } from "./types/spark";
import { evaluateExpression, exprValueToString } from "./variable-expression";
import { computeCascade, getCombined, setBase } from "./var-reactivity";
import type { VarDeclaration, TagModifier } from "./declare-parser";
// Tagmap pattern: "#warrior:1;#druid:2". Also accepts bare "#tag" as shorthand.
const BARE_TAG_PATTERN = /^#[a-zA-Z_][a-zA-Z0-9_]*$/;
function normalizeTagMap(expr: string): string {
const t = expr.trim();
if (BARE_TAG_PATTERN.test(t)) return t + ":1";
return t;
}
// ---------------------------------------------------------------------------
// Result
// ---------------------------------------------------------------------------
export type DispatchResult = { ok: true } | { ok: false; error: string };
/**
* Shared signal for dispatch errors from any source (typed or cmd-link clicks).
* Components that show errors (JournalInput) read from here; callers that
* want errors surfaced (CommandLinkManager) write to it.
*/
export const [dispatchError, setDispatchError] = createSignal<string | null>(
null,
);
// ---------------------------------------------------------------------------
// Main dispatch
// ---------------------------------------------------------------------------
export interface DispatchContext {
/** The role of the current user */
role: "gm" | "player" | "observer";
/** The user's name */
myName: string;
/** The raw text to dispatch (with or without leading `/`) */
command: string;
/** Spark table lookup data (from completions) */
sparkTables: {
slug: string;
csvPath?: string;
docPath?: string;
remix?: boolean;
}[];
/** Current runtime variable values */
variables: Record<string, string>;
/** Variable declarations (from role=declare blocks) */
declarations: VarDeclaration[];
/** Tag modifiers (from role=declare blocks) */
tagModifiers: TagModifier[];
}
/**
* Dispatch a command string. Works for both typed input and
* :cmd[] directive clicks.
*/
export async function dispatchCommand(
ctx: DispatchContext,
): Promise<DispatchResult> {
const raw = ctx.command.trim();
if (!raw) return { ok: false, error: "Empty command" };
// Observers can only send chat
if (ctx.role === "observer") {
const result = sendMessage("chat", { text: raw });
return finish(unwrap(result));
}
const prefixed = raw.startsWith("/") ? raw : "/" + raw;
const parsed = parseInput(prefixed);
if (parsed.error) return finish({ ok: false, error: parsed.error });
// Players: chat + set commands only
if (ctx.role === "player") {
if (parsed.type === "chat") {
const result = sendMessage("chat", { text: raw });
return finish(unwrap(result));
}
if (parsed.type === "set" || parsed.type === "rolltag") {
return finish(
dispatchSet(parsed.payload as Record<string, unknown>, ctx),
);
}
return finish({ ok: false, error: "玩家只能发送聊天消息或使用 /set 命令" });
}
// GM: all commands
if (parsed.type === "roll") {
const arg = (parsed.payload as { arg: string }).arg;
// Try spark table first: if arg matches a known spark table slug, roll it
const match = ctx.sparkTables.find((s) => s.slug === arg);
if (match) {
try {
const csvPath = match.csvPath ?? "";
const docPath = match.docPath;
const remix = match.remix ?? false;
const p = await resolveSparkPayload({
key: arg,
csvPath,
docPath,
remix,
});
const result = sendMessage("spark", p);
return finish(unwrap(result));
} catch (e) {
return finish({
ok: false,
error: e instanceof Error ? e.message : "种子表掷骰失败",
});
}
}
// Variable expression (e.g. "2d6 + $mod" or "$hp / 2")
if (arg.includes("$")) {
try {
const ev = evaluateExpression(arg, {
lookup: (name: string) => getCombined("$" + name, ctx.variables),
});
if (ev.value.kind !== "number") {
return finish({ ok: false, error: "Roll expression must evaluate to a number" });
}
const payload = {
notation: arg,
label: arg,
result: {
total: ev.value.value,
detail: "",
plainDetail: "",
pools: [] as { rolls: number[]; subtotal: number }[],
},
};
const result = sendMessage("roll", payload);
return finish(unwrap(result));
} catch (e) {
return finish({
ok: false,
error: e instanceof Error ? e.message : "表达式求值失败",
});
}
}
// Otherwise, treat as a dice expression
const p = resolveRollPayload({ notation: arg, label: arg });
const result = sendMessage("roll", p);
return finish(unwrap(result));
}
if (parsed.type === "set" || parsed.type === "rolltag") {
return finish(dispatchSet(parsed.payload as Record<string, unknown>, ctx));
}
const result = sendMessage(parsed.type, parsed.payload);
return finish(unwrap(result));
}
/** Update the shared error signal and return the result (pass-through). */
function finish(r: DispatchResult): DispatchResult {
setDispatchError(r.ok ? null : r.error);
return r;
}
// ---------------------------------------------------------------------------
// Set dispatch
// ---------------------------------------------------------------------------
function dispatchSet(
payload: Record<string, unknown>,
ctx: DispatchContext,
): DispatchResult {
const p = payload as { key?: string; expr?: string; tags?: string[] };
if (!p.key) {
return { ok: false, error: "缺少变量名" };
}
const key = p.key;
// Use getCombined with stream fallback for old value so tag transitions
// are detected correctly even when the variable has active mods.
const oldValue = getCombined(key, ctx.variables);
let newValue: string;
try {
if (p.tags && p.tags.length > 0) {
// Rolltag: pick random tag, format as tagmap entry
const idx = Math.floor(Math.random() * p.tags.length);
newValue = normalizeTagMap(p.tags[idx]);
} else if (p.expr) {
// Evaluate expression — handles both numeric and tagmap values
const result = evaluateExpression(p.expr, {
lookup: (name: string) => {
const k = "$" + name;
// Use getCombined with stream fallback for accurate values
return k === key ? undefined : getCombined(k, ctx.variables);
},
});
newValue = exprValueToString(result.value);
} else {
return { ok: false, error: "缺少表达式" };
}
} catch (e) {
return {
ok: false,
error: e instanceof Error ? e.message : "表达式求值失败",
};
}
// Update base value so getCombined returns the correct new value
// during cascade computation
setBase(key, newValue);
// Build working variable store for cascade (includes the new value)
const workingVars = { ...ctx.variables, [key]: newValue };
// Compute cascade (tag activation/deactivation + declaration re-eval)
try {
const cascade = computeCascade(key, oldValue, workingVars);
for (const change of cascade) {
sendMessage("var", {
action: "set",
key: change.key,
value: change.value,
});
}
} catch (e) {
// Cascade errors are non-fatal — the direct set already succeeded
console.warn("[dispatch] cascade error:", e);
}
// Send the direct set last so cascade messages arrive first.
// Use getCombined with stream fallback so the stream store receives the
// correct value (base + active mods) rather than just the raw base.
const combinedValue = getCombined(key, ctx.variables);
const r1 = sendMessage("var", { action: "set", key, value: combinedValue });
const u1 = unwrap(r1);
if (!u1.ok) return u1;
return { ok: true };
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Unwrap a sendMessage result into DispatchResult. */
function unwrap<R>(
r: { success: true; msg: R } | { success: false; error: string },
): DispatchResult {
return r.success ? { ok: true } : { ok: false, error: r.error };
}
+29 -41
View File
@@ -12,23 +12,17 @@ export interface CompletionItem {
}
export interface ParsedInput {
type: "chat" | "roll" | "spark" | "link" | "stat";
type: "chat" | "roll" | "link" | "set" | "rolltag";
payload: Record<string, unknown>;
error?: string;
}
export function parseInput(raw: string): ParsedInput {
if (raw.startsWith("/roll ")) {
const notation = raw.slice("/roll ".length).trim();
if (!notation)
return { type: "roll", payload: {}, error: "需要骰子表达式" };
return { type: "roll", payload: { notation, label: notation } };
}
if (raw.startsWith("/spark ")) {
const key = raw.slice("/spark ".length).trim();
if (!key) return { type: "spark", payload: {}, error: "需要种子表键名" };
return { type: "spark", payload: { key } };
const arg = raw.slice("/roll ".length).trim();
if (!arg)
return { type: "roll", payload: {}, error: "需要骰子表达式或种子表键名" };
return { type: "roll", payload: { arg } };
}
if (raw.startsWith("/link ")) {
@@ -41,41 +35,35 @@ export function parseInput(raw: string): ParsedInput {
return { type: "link", payload: { path, section } };
}
if (raw.startsWith("/stat ")) {
const arg = raw.slice("/stat ".length).trim();
if (!arg)
return { type: "stat", payload: {}, error: "需要 stat 命令 (set/del/roll)" };
if (raw.startsWith("/set ")) {
const rest = raw.slice("/set ".length).trim();
if (!rest)
return { type: "set", payload: {}, error: "格式: /set $key expression" };
if (arg.startsWith("set ")) {
const rest = arg.slice("set ".length).trim();
const eqIdx = rest.indexOf("=");
if (eqIdx === -1)
return { type: "stat", payload: {}, error: "格式: /stat set key=value" };
const key = rest.slice(0, eqIdx).trim();
const value = rest.slice(eqIdx + 1).trim();
if (!key || !value)
return { type: "stat", payload: {}, error: "key 和 value 不能为空" };
return { type: "stat", payload: { action: "set", key, value } };
// Split on first space to get key and expression
const spaceIdx = rest.indexOf(" ");
if (spaceIdx === -1)
return { type: "set", payload: {}, error: "格式: /set $key expression" };
const key = rest.slice(0, spaceIdx).trim();
const expr = rest.slice(spaceIdx + 1).trim();
if (!key || !expr)
return { type: "set", payload: {}, error: "key 和 expression 不能为空" };
if (!key.startsWith("$"))
return { type: "set", payload: {}, error: "key 必须以 $ 开头" };
// Check if expr is a rolltag (e.g. #w|#d|#s)
if (expr.includes("|") && expr.split("|").every((t) => t.trim().startsWith("#"))) {
const tags = expr.split("|").map((t) => t.trim());
return { type: "rolltag", payload: { key, tags } };
}
if (arg.startsWith("del ")) {
const key = arg.slice("del ".length).trim();
if (!key)
return { type: "stat", payload: {}, error: "格式: /stat del key" };
return { type: "stat", payload: { action: "del", key } };
return { type: "set", payload: { key, expr } };
}
if (arg.startsWith("roll ")) {
const key = arg.slice("roll ".length).trim();
if (!key)
return { type: "stat", payload: {}, error: "格式: /stat roll key" };
return { type: "stat", payload: { action: "roll", key } };
}
return { type: "stat", payload: {}, error: "未知 stat 子命令: set/del/roll" };
}
if (raw === "/roll" || raw === "/spark" || raw === "/link" || raw === "/stat") {
if (raw === "/roll" || raw === "/link" || raw === "/set") {
return { type: "chat", payload: {}, error: "请补全命令" };
}
+103 -113
View File
@@ -2,62 +2,44 @@
* Journal completions client-side loader for /__COMPLETIONS.json
*
* In CLI mode, fetches the pre-computed index. In dev/browser mode, falls
* back to scanning the in-memory file index for dice expressions and headings.
* back to scanning the in-memory file index for dice expressions, headings,
* and declare blocks.
*
* The fetch runs eagerly on module import. Call `useJournalCompletions()`
* from any Solid component to reactively read the state.
*/
import { createSignal } from "solid-js";
import Slugger from "github-slugger";
import { extractHeadings } from "../../data-loader/toc";
import {
getPathsByExtension,
getIndexedData,
setIndexedData,
setInlineResolver,
} from "../../data-loader/file-index";
import {
parseStatYaml,
parseStatCsv,
parseTemplateCsv,
} from "../../cli/completions/stat-parser";
import type { StatDef, StatTemplate } from "../../cli/completions/stat-parser";
import {
FENCED_BLOCK_RE,
parseBlockAttrs,
parseSparkTableCsv,
} from "../../cli/completions/block-scanner";
buildRegistryFromIndex,
deriveCompletions,
resolveInlineByPath,
type ContentRegistry,
} from "../../cli/content-registry";
import type {
CompletionsPayload,
DiceCompletion,
LinkCompletion,
SparkTableCompletion,
VarDeclaration,
TagModifier,
} from "../../cli/completions/types";
import { initReactivity, computeInitialValues } from "./var-reactivity";
import { sendMessage, journalStreamState } from "../stores/journalStream";
export type { StatDef, StatTemplate };
export type { VarDeclaration, TagModifier };
// ------------------- Types (mirrors CLI) -------------------
// Re-export CLI types so consumers don't need to know about the CLI path
export type { DiceCompletion, LinkCompletion, SparkTableCompletion };
export interface DiceCompletion {
label: string;
notation: string;
source: string;
}
export interface LinkCompletion {
path: string;
label: string;
section: string | null;
}
export interface SparkTableCompletion {
label: string;
notation: string;
slug: string;
filePath: string;
headers: string[];
}
export interface JournalCompletions {
dice: DiceCompletion[];
links: LinkCompletion[];
sparkTables: SparkTableCompletion[];
stats: StatDef[];
statTemplates: StatTemplate[];
}
/** Convenience alias — same shape as the CLI's CompletionsPayload. */
export type JournalCompletions = CompletionsPayload;
export type CompletionsState =
| { status: "loading" }
@@ -71,6 +53,23 @@ const [completionsState, setCompletionsState] = createSignal<CompletionsState>({
status: "loading",
});
// The registry backing the completions. Populated in both CLI and client
// modes so inline content ids can be resolved at runtime (e.g. spark rolls).
let activeRegistry: ContentRegistry = { pathIndex: {}, docContent: {} };
/**
* The registry backing the current completions.
* In CLI mode this is fetched from the server; in browser mode it is built
* client-side. Used to resolve inline content ids (e.g. spark table CSVs).
*/
export function getRegistry(): ContentRegistry {
return activeRegistry;
}
// Register the inline-content resolver so `getIndexedData` can resolve
// directive refs (e.g. `./csv_abc123`) that aren't real files.
setInlineResolver((path) => resolveInlineByPath(activeRegistry, path));
// ------------------- Fetch (CLI mode) -------------------
async function tryServer(): Promise<JournalCompletions | null> {
@@ -82,85 +81,54 @@ async function tryServer(): Promise<JournalCompletions | null> {
dice: Array.isArray(data.dice) ? data.dice : [],
links: Array.isArray(data.links) ? data.links : [],
sparkTables: Array.isArray(data.sparkTables) ? data.sparkTables : [],
stats: Array.isArray(data.stats) ? data.stats : [],
statTemplates: Array.isArray(data.statTemplates)
? data.statTemplates
: [],
declarations: Array.isArray(data.declarations) ? data.declarations : [],
tagModifiers: Array.isArray(data.tagModifiers) ? data.tagModifiers : [],
};
} catch {
return null;
}
}
/** Load the content registry from the server (CLI mode). */
async function tryServerRegistry(): Promise<void> {
try {
const resp = await fetch("/__CONTENT_REGISTRY.json");
if (!resp.ok) return;
const data = await resp.json();
activeRegistry = {
pathIndex: data.pathIndex ?? {},
docContent: data.docContent ?? {},
};
} catch {
// Registry unavailable — leave empty; client scan will populate it.
}
}
// ------------------- Client-side fallback scan -------------------
async function scanClientSide(): Promise<JournalCompletions> {
const paths = await getPathsByExtension("md");
const dice: DiceCompletion[] = [];
const links: LinkCompletion[] = [];
const sparkTables: SparkTableCompletion[] = [];
const stats: StatDef[] = [];
const statTemplates: StatTemplate[] = [];
const tagRegex = /:md-dice\[([^[]+)\]/gi;
// Load all .md content into a raw index, then build the registry through
// the same shared pipeline as the CLI (scanDoc).
const index: Record<string, string> = {};
for (const filePath of paths) {
const content = await getIndexedData(filePath);
if (!content) continue;
// Fresh slugger per file
const slugger = new Slugger();
// ---- Dice directives ----
let match: RegExpExecArray | null;
tagRegex.lastIndex = 0;
while ((match = tagRegex.exec(content)) !== null) {
const raw = match[1].trim();
if (!raw || raw.length > 80) continue;
if (!/^\d*d\d+/i.test(raw) && !/^[+-]/.test(raw)) continue;
dice.push({ label: raw, notation: raw, source: filePath });
index[filePath] = content;
}
// ---- Links (headings) ----
const basePath = filePath.replace(/\.md$/, "");
const fileName = basePath.split("/").filter(Boolean).pop() || basePath;
links.push({ path: basePath, label: fileName, section: null });
for (const heading of extractHeadings(content)) {
links.push({
path: basePath,
label: `${fileName} § ${heading.title}`,
section: heading.id ?? null,
});
const registry = buildRegistryFromIndex(index);
activeRegistry = registry;
// Write the processed (stripped) content back into the file index so
// Article/md-embed render the same content as CLI mode (attributed blocks
// processed by role).
for (const [path, content] of Object.entries(registry.pathIndex)) {
setIndexedData(path, content);
}
// ---- Unified block scanning ----
FENCED_BLOCK_RE.lastIndex = 0;
let blockMatch: RegExpExecArray | null;
while ((blockMatch = FENCED_BLOCK_RE.exec(content)) !== null) {
const [, lang, infoString, body] = blockMatch;
const attrs = parseBlockAttrs(infoString);
attrs.lang = attrs.lang || lang;
if (attrs.role === "stat") {
if (attrs.lang === "yaml" || attrs.lang === "yml") {
stats.push(...parseStatYaml(body, filePath));
} else if (attrs.lang === "csv") {
stats.push(...parseStatCsv(body, filePath));
}
}
if (attrs.role === "stat-template") {
const name = attrs.id || `_tpl_${filePath}_${stats.length}`;
statTemplates.push(parseTemplateCsv(body, filePath, name));
}
if (attrs.role === "spark-table") {
const parsed = parseSparkTableCsv(body, filePath, slugger);
if (parsed) sparkTables.push(parsed);
}
}
}
return { dice, links, sparkTables, stats, statTemplates };
return deriveCompletions(registry);
}
// ------------------- Init (runs eagerly at import time) -------------------
@@ -171,6 +139,16 @@ const _initPromise: Promise<void> = (async () => {
const serverData = await tryServer();
if (serverData) {
setCompletionsState({ status: "loaded", data: serverData });
await tryServerRegistry();
try {
initReactivity({
declarations: serverData.declarations,
tagModifiers: serverData.tagModifiers,
});
seedDeclaredVariables();
} catch (e) {
console.warn("[completions] reactivity init error:", e);
}
return;
}
@@ -179,6 +157,15 @@ const _initPromise: Promise<void> = (async () => {
const data = await scanClientSide();
if (data.dice.length > 0 || data.links.length > 0) {
setCompletionsState({ status: "loaded", data });
try {
initReactivity({
declarations: data.declarations,
tagModifiers: data.tagModifiers,
});
seedDeclaredVariables();
} catch (e) {
console.warn("[completions] reactivity init error:", e);
}
} else {
setCompletionsState({ status: "empty" });
}
@@ -200,14 +187,6 @@ export function ensureCompletions(): Promise<void> {
return _initPromise;
}
/** Force a re-fetch on the next page load. For runtime, call before invalidate triggers. */
let _invalidated = false;
export function invalidateCompletions(): void {
_invalidated = true;
// On next import (page reload), the module will re-init.
// For a runtime invalidation, you could call init again.
}
/**
* Reactive hooks for the journal input.
* Returns the current completions state + a convenience `data` extractor.
@@ -226,8 +205,19 @@ export function useJournalCompletions(): {
dice: [],
links: [],
sparkTables: [],
stats: [],
statTemplates: [],
declarations: [],
tagModifiers: [],
},
};
}
// ---------------------------------------------------------------------------
// Seed declared variables into the store on load
// ---------------------------------------------------------------------------
function seedDeclaredVariables(): void {
const initial = computeInitialValues(journalStreamState.variables);
for (const { key, value } of initial) {
sendMessage("var", { action: "set", key, value });
}
}
+10
View File
@@ -0,0 +1,10 @@
/**
* Declare parser re-export delegates to the shared parser in cli/completions.
*/
export {
parseDeclareCsv,
type VarDeclaration,
type TagModifier,
type DeclareResult,
} from "../../cli/completions/declare-parser";
+14 -1
View File
@@ -48,6 +48,19 @@ export { StreamMessageCard } from "./StreamMessage";
export { ComposePanel } from "./ComposePanel";
export { JournalInput } from "./JournalInput";
export { DynamicForm } from "./DynamicForm";
export { useJournalCompletions, invalidateCompletions } from "./completions";
export { useJournalCompletions } from "./completions";
export { dispatchCommand } from "./command-dispatcher";
export type { DispatchContext, DispatchResult } from "./command-dispatcher";
export { parseInput } from "./command-parser";
export type { ParsedInput, CompletionItem } from "./command-parser";
export { buildCompletions } from "./command-completions";
export type { CompletionsContext } from "./command-completions";
export { VariableView } from "./VariableView";
export { parseDeclareCsv } from "./declare-parser";
export type { VarDeclaration, TagModifier } from "./declare-parser";
export { evaluateExpression, expressionIsTag, exprValueToString } from "./variable-expression";
export type { EvalContext, EvalResult, ExprValue } from "./variable-expression";
export { initReactivity, computeCascade, getCombined, getMods, getDeclExpr, setBase, extractDependencies, computeInitialValues } from "./var-reactivity";
export type { VarReactivityState, VariableStore } from "./var-reactivity";
export { JournalContext, useJournalContext } from "./JournalContext";
export type { JournalContextValue } from "./JournalContext";
-216
View File
@@ -1,216 +0,0 @@
/**
* Minimal expression evaluator for derived stat formulas.
*
* Supports:
* - Stat references: any identifier resolves to a number from the lookup
* - Arithmetic: + - * /
* - Functions: floor(x), ceil(x), round(x)
* - Parentheses for grouping
*/
export type StatLookup = (key: string) => number;
/** Evaluate a formula string against a stat lookup function. */
export function evaluateFormula(formula: string, lookup: StatLookup): number {
const tokens = tokenize(formula);
const result = parseExpression(tokens, 0, lookup);
if (result.next < tokens.length) {
throw new Error(`Unexpected token at position ${result.next}: "${tokens[result.next]}"`);
}
return result.value;
}
// ---------------------------------------------------------------------------
// Tokenizer
// ---------------------------------------------------------------------------
type Token =
| { kind: "number"; value: number }
| { kind: "ident"; value: string }
| { kind: "op"; value: string }
| { kind: "lparen" }
| { kind: "rparen" }
| { kind: "comma" };
function tokenize(input: string): Token[] {
const tokens: Token[] = [];
let i = 0;
while (i < input.length) {
const ch = input[i];
// Whitespace
if (/\s/.test(ch)) {
i++;
continue;
}
// Number (integer or decimal)
if (/[0-9]/.test(ch)) {
let num = "";
while (i < input.length && /[0-9.]/.test(input[i])) {
num += input[i];
i++;
}
tokens.push({ kind: "number", value: parseFloat(num) });
continue;
}
// Identifier or function name
if (/[a-zA-Z_]/.test(ch)) {
let ident = "";
while (i < input.length && /[a-zA-Z0-9_]/.test(input[i])) {
ident += input[i];
i++;
}
tokens.push({ kind: "ident", value: ident });
continue;
}
// Operators and punctuation
switch (ch) {
case "+":
case "-":
case "*":
case "/":
tokens.push({ kind: "op", value: ch });
i++;
break;
case "(":
tokens.push({ kind: "lparen" });
i++;
break;
case ")":
tokens.push({ kind: "rparen" });
i++;
break;
case ",":
tokens.push({ kind: "comma" });
i++;
break;
default:
throw new Error(`Unexpected character: "${ch}"`);
}
}
return tokens;
}
// ---------------------------------------------------------------------------
// Recursive descent parser
// ---------------------------------------------------------------------------
interface ParseResult {
value: number;
next: number; // index of next unconsumed token
}
/** expression := term (("+" | "-") term)* */
function parseExpression(tokens: Token[], pos: number, lookup: StatLookup): ParseResult {
let result = parseTerm(tokens, pos, lookup);
pos = result.next;
while (pos < tokens.length) {
const tok = tokens[pos];
if (tok.kind === "op" && (tok.value === "+" || tok.value === "-")) {
const right = parseTerm(tokens, pos + 1, lookup);
if (tok.value === "+") {
result = { value: result.value + right.value, next: right.next };
} else {
result = { value: result.value - right.value, next: right.next };
}
pos = result.next;
} else {
break;
}
}
return result;
}
/** term := factor (("*" | "/") factor)* */
function parseTerm(tokens: Token[], pos: number, lookup: StatLookup): ParseResult {
let result = parseFactor(tokens, pos, lookup);
pos = result.next;
while (pos < tokens.length) {
const tok = tokens[pos];
if (tok.kind === "op" && (tok.value === "*" || tok.value === "/")) {
const right = parseFactor(tokens, pos + 1, lookup);
if (tok.value === "*") {
result = { value: result.value * right.value, next: right.next };
} else {
if (right.value === 0) throw new Error("Division by zero");
result = { value: result.value / right.value, next: right.next };
}
pos = result.next;
} else {
break;
}
}
return result;
}
/** factor := number | ident ["(" expression ")"] | "(" expression ")" | "-" factor */
function parseFactor(tokens: Token[], pos: number, lookup: StatLookup): ParseResult {
if (pos >= tokens.length) {
throw new Error("Unexpected end of formula");
}
const tok = tokens[pos];
// Unary minus
if (tok.kind === "op" && tok.value === "-") {
const inner = parseFactor(tokens, pos + 1, lookup);
return { value: -inner.value, next: inner.next };
}
// Number literal
if (tok.kind === "number") {
return { value: tok.value, next: pos + 1 };
}
// Parenthesized expression
if (tok.kind === "lparen") {
const inner = parseExpression(tokens, pos + 1, lookup);
if (inner.next >= tokens.length || tokens[inner.next].kind !== "rparen") {
throw new Error("Missing closing parenthesis");
}
return { value: inner.value, next: inner.next + 1 };
}
// Identifier (stat reference or function call)
if (tok.kind === "ident") {
const name = tok.value;
// Check for function call: ident "(" ...
if (pos + 1 < tokens.length && tokens[pos + 1].kind === "lparen") {
const arg = parseExpression(tokens, pos + 2, lookup);
if (arg.next >= tokens.length || tokens[arg.next].kind !== "rparen") {
throw new Error(`Missing closing parenthesis after ${name}(...)`);
}
const value = applyFunction(name, arg.value);
return { value, next: arg.next + 1 };
}
// Plain stat reference
const value = lookup(name);
return { value, next: pos + 1 };
}
throw new Error(`Unexpected token: "${JSON.stringify(tok)}"`);
}
function applyFunction(name: string, arg: number): number {
switch (name.toLowerCase()) {
case "floor":
return Math.floor(arg);
case "ceil":
return Math.ceil(arg);
case "round":
return Math.round(arg);
default:
throw new Error(`Unknown function: ${name}`);
}
}
-312
View File
@@ -1,312 +0,0 @@
/**
* Stat command helpers stat resolution, permission checking, and
* dice-formula stat-reference expansion.
*
* Extracted from JournalInput to keep that component focused on UI.
*/
import { rollFormula } from "../md-commander/hooks";
import { evaluateFormula } from "./stat-formula";
import type { StatDef, StatTemplate } from "./completions";
// ---------------------------------------------------------------------------
// Key resolution
// ---------------------------------------------------------------------------
/** Get the full runtime key for a stat def, given a player name. */
export function fullKey(def: StatDef, playerName: string): string {
return def.scope === "player" ? `${playerName}:${def.key}` : def.key;
}
/** Find a stat def by its full runtime key. */
export function findStatDef(
fk: string,
statDefs: StatDef[],
playerName: string,
): StatDef | undefined {
return statDefs.find((d) => fullKey(d, playerName) === fk);
}
// ---------------------------------------------------------------------------
// Formula stat reference resolution
// ---------------------------------------------------------------------------
/**
* Replace stat key references in a dice formula (e.g. "1d20 + attack")
* with their numeric values, so the result can be passed to rollFormula.
*
* Bare keys in formulas resolve relative to the caller's scope: if the
* caller def is `scope: player`, then `attack` resolves to `alice:attack`.
*/
export function resolveStatRefs(
formula: string,
lookup: (key: string) => number,
): string {
return formula.replace(/[a-zA-Z_]\w*/g, (match) => {
if (/^d\d/i.test(match)) return match;
if (/^[kdh]\d/i.test(match)) return match;
const val = lookup(match);
return String(val);
});
}
/**
* Build a stat lookup function that resolves bare keys to numbers.
* Bare keys are scoped by `playerName` if the originating def is player-scoped.
*/
export function makeStatLookup(
runtimeStats: Record<string, string>,
statDefs: StatDef[],
playerName: string,
/** The def that references are being resolved for (for scoping bare keys). */
callerDef?: StatDef,
): (bareKey: string) => number {
return (bareKey: string): number => {
// Try as a full key first, then try scoped
const candidates = [bareKey];
if (callerDef && callerDef.scope === "player") {
candidates.push(`${playerName}:${bareKey}`);
}
for (const k of candidates) {
const val = runtimeStats[k];
if (val !== undefined) {
const n = parseFloat(val);
if (!isNaN(n)) return n;
}
const sdef = statDefs.find((d) => fullKey(d, playerName) === k);
if (sdef?.default !== undefined) {
const n = parseFloat(sdef.default);
if (!isNaN(n)) return n;
}
}
return 0;
};
}
// ---------------------------------------------------------------------------
// Permission
// ---------------------------------------------------------------------------
/** Check whether a role can modify a given stat key. */
export function canModifyStat(
role: string,
myName: string,
fullKey: string,
statDefs: StatDef[],
): boolean {
if (role === "gm") return true;
if (role === "observer") return false;
const def = findStatDef(fullKey, statDefs, myName);
if (!def) return false;
if (def.scope === "player") {
return fullKey.startsWith(myName + ":");
}
// Global stats: player can't modify
return false;
}
// ---------------------------------------------------------------------------
// Roll resolution
// ---------------------------------------------------------------------------
export interface StatRollResult {
fullKey: string;
value: string;
error?: string;
/** For template rolls: additional modifier keys → values to apply */
modifiers?: Record<string, string>;
}
/**
* When a template-type stat is set explicitly (not rolled), look up the
* template entry by label and return the modifier overrides to apply.
* Returns null if the stat is not a template type or the label doesn't match.
*/
export function resolveTemplateSet(
def: StatDef,
value: string,
statDefs: StatDef[],
runtimeStats: Record<string, string>,
playerName: string,
templates?: StatTemplate[],
): Record<string, string> | null {
if (def.type !== "template" || !def.template) return null;
const tpl = templates?.find((t) => t.name === def.template);
if (!tpl) return null;
const entry = tpl.entries.find((e) => e.label === value);
if (!entry || Object.keys(entry.modifiers).length === 0) return null;
const lookup = makeStatLookup(runtimeStats, statDefs, playerName, def);
const resolved: Record<string, string> = {};
for (const [mk, mv] of Object.entries(entry.modifiers)) {
const modFullKey = def.scope === "player" ? `${playerName}:${mk}` : mk;
const currentVal = runtimeStats[modFullKey];
const currentNum =
currentVal !== undefined ? parseFloat(currentVal) : lookup(mk);
const delta = parseFloat(mv);
if (!isNaN(currentNum) && !isNaN(delta)) {
resolved[modFullKey] = String(currentNum + delta);
} else {
resolved[modFullKey] = mv;
}
}
return resolved;
}
/**
* Resolve a /stat roll command: look up the stat definition (by bare or full
* key), evaluate the appropriate resolution strategy, and return the string
* value to publish.
*/
export function resolveStatRoll(
inputKey: string,
statDefs: StatDef[],
runtimeStats: Record<string, string>,
playerName: string,
templates?: StatTemplate[],
): StatRollResult {
// Try exact match first, then resolve bare key → full key
let def = statDefs.find((d) => fullKey(d, playerName) === inputKey);
if (!def) {
// Try bare key: find a player-scoped def whose fullKey would match
def = statDefs.find(
(d) => d.scope === "player" && fullKey(d, playerName) === inputKey,
);
}
if (!def) {
// Try finding a bare key match (for global or player)
def = statDefs.find((d) => d.key === inputKey);
}
if (!def) {
return { fullKey: inputKey, value: "", error: `未知属性: ${inputKey}` };
}
const fk = fullKey(def, playerName);
const lookup = makeStatLookup(runtimeStats, statDefs, playerName, def);
if (def.type === "template" && def.template) {
const tpl = templates?.find((t) => t.name === def.template);
if (!tpl || tpl.entries.length === 0) {
return {
fullKey: fk,
value: "",
error: `未找到模板: ${def.template}`,
};
}
// Roll the dice to pick an entry (use template's notation)
const formula = tpl.notation || "1d" + String(tpl.entries.length);
const resolvedFormula = resolveStatRefs(formula, lookup);
const roll = rollFormula(resolvedFormula);
const rolled = roll.result.total;
// Find matching entry by range
const entry = matchTemplateRange(rolled, tpl.entries);
if (!entry) {
return {
fullKey: fk,
value: "",
error: `模板 "${def.template}" 中未找到匹配 ${rolled} 的条目`,
};
}
// Resolve modifier keys to full keys (same scope as def)
// Modifier values like "+20" / "-10" are applied relative to current value
const resolvedModifiers: Record<string, string> = {};
for (const [mk, mv] of Object.entries(entry.modifiers)) {
const modFullKey = def.scope === "player" ? `${playerName}:${mk}` : mk;
// Compute absolute value: current + delta
const currentVal = runtimeStats[modFullKey];
const currentNum =
currentVal !== undefined ? parseFloat(currentVal) : lookup(mk);
const delta = parseFloat(mv);
if (!isNaN(currentNum) && !isNaN(delta)) {
resolvedModifiers[modFullKey] = String(currentNum + delta);
} else {
// If we can't compute relative, fall back to raw value
resolvedModifiers[modFullKey] = mv;
}
}
return {
fullKey: fk,
value: entry.label,
modifiers: resolvedModifiers,
};
}
if (def.type === "enum" && def.options && def.options.length > 0) {
const idx = Math.floor(Math.random() * def.options.length);
return { fullKey: fk, value: def.options[idx] };
}
if (def.type === "derived" && def.formula) {
try {
const result = evaluateFormula(def.formula, lookup);
return { fullKey: fk, value: String(result) };
} catch (e) {
return {
fullKey: fk,
value: "",
error: e instanceof Error ? e.message : "公式计算失败",
};
}
}
if (def.roll) {
const resolvedFormula = resolveStatRefs(def.roll, lookup);
const roll = rollFormula(resolvedFormula);
return { fullKey: fk, value: String(roll.result.total) };
}
return { fullKey: fk, value: "", error: `属性 "${inputKey}" 不支持掷骰` };
}
// ---------------------------------------------------------------------------
// Template range matching
// ---------------------------------------------------------------------------
/**
* Match a rolled number against template entries with range strings.
*
* Range formats:
* "1-3" inclusive range
* "4" exact match
* "1-3,5" multiple ranges
*
* Returns the first matching entry, or undefined.
*/
function matchTemplateRange(
rolled: number,
entries: {
range: string;
label: string;
modifiers: Record<string, string>;
}[],
): (typeof entries)[number] | undefined {
for (const entry of entries) {
const parts = entry.range.split(",").map((s) => s.trim());
for (const part of parts) {
if (part.includes("-")) {
const [lo, hi] = part.split("-").map(Number);
if (!isNaN(lo) && !isNaN(hi) && rolled >= lo && rolled <= hi) {
return entry;
}
} else {
const n = Number(part);
if (!isNaN(n) && rolled === n) {
return entry;
}
}
}
}
return undefined;
}
+1 -1
View File
@@ -10,4 +10,4 @@ import "./roll";
import "./spark";
import "./link";
import "./intent";
import "./stat";
import "./var";
+26 -22
View File
@@ -16,12 +16,9 @@ import { z } from "zod";
import { For } from "solid-js";
import { registerMessageType } from "../registry";
import { rollFormula } from "../../md-commander/hooks";
import {
findSparkTableByCombinedSlug,
rollSparkTable,
scanSparkTables,
} from "../../utils/spark-table";
import { parseSparkTableCsv, rollSparkTable } from "../../utils/spark-table";
import { getIndexedData } from "../../../data-loader/file-index";
import { getRegistry } from "../../journal/completions";
// ---------------------------------------------------------------------------
// Schema
@@ -70,37 +67,44 @@ export type SparkPayload = z.infer<typeof schema>;
* Resolve a spark table roll.
*
* `key` is the combined slug (pageName-columnSlug).
* `filePath` is the .md file path (without .md extension) from completions.
* `csvPath` is the resolved .csv file path from completions.
* `remix` controls whether each column gets an independent roll.
*/
export async function resolveSparkPayload(raw: {
key: string;
filePath: string;
csvPath: string;
docPath?: string;
remix: boolean;
}): Promise<SparkPayload> {
const mdPath = `/${raw.filePath.replace(/^\//, "")}.md`;
const pageName =
raw.filePath.replace(/^\//, "").split("/").filter(Boolean).pop() ||
raw.filePath;
let csv: string | null;
let content: string;
// Inline content ids resolve through the registry (docPath + content id);
// real file paths fall back to the file index.
const registry = getRegistry();
const docStore = raw.docPath ? registry.docContent[raw.docPath] : undefined;
const inline = docStore?.[raw.csvPath];
if (inline) {
csv = inline.body;
} else {
try {
content = await getIndexedData(mdPath);
csv = await getIndexedData(raw.csvPath);
} catch {
throw new Error(`Failed to load file: "${mdPath}"`);
csv = null;
}
}
const meta = findSparkTableByCombinedSlug(content, raw.key, pageName);
if (csv === null) {
throw new Error(`Failed to load CSV: "${raw.csvPath}"`);
}
const meta = parseSparkTableCsv(csv);
if (!meta) {
const available = scanSparkTables(content);
const names =
available.length > 0
? available.map((t) => `${pageName}-${t.slug}`).join(", ")
: "(none)";
throw new Error(
`Spark table "${raw.key}" not found in "${mdPath}". Available: ${names}`,
`Spark table "${raw.key}" not found in CSV "${raw.csvPath}"`,
);
}
const sparkResult = rollSparkTable(meta);
const sparkResult = rollSparkTable(meta, { remix: raw.remix });
const firstRoll = rollFormula(meta.notation);
return {
@@ -1,12 +1,12 @@
/**
* Built-in message type: stat
* Built-in message type: var
*
* Emitters: gm, player
* Command: /stat set key=value | /stat del key | /stat roll key
* Command: /set $key expression
*
* Stat definitions are discovered from ```stat YAML blocks in markdown
* documents. The stream carries set/del mutations that build up the
* runtime stat store additively.
* Variable declarations and tag modifiers are authored in ```csv role=declare
* code blocks in markdown documents. The stream carries set/del mutations
* that build up the runtime variable store additively.
*/
import { z } from "zod";
@@ -20,11 +20,11 @@ const schema = z.object({
value: z.string().optional(),
});
export type StatPayload = z.infer<typeof schema>;
export type VarPayload = z.infer<typeof schema>;
registerMessageType<StatPayload>({
type: "stat",
label: "属性",
registerMessageType<VarPayload>({
type: "var",
label: "变量",
emitters: ["gm", "player"],
schema,
defaultPayload: () => ({ action: "set", key: "", value: "" }),
@@ -32,7 +32,7 @@ registerMessageType<StatPayload>({
if (p.action === "del") {
return (
<div class="flex items-center gap-1.5">
<span class="text-lg">📊</span>
<span class="text-lg">🔢</span>
<span class="font-mono text-sm text-gray-500 line-through">
{p.key}
</span>
@@ -41,9 +41,9 @@ registerMessageType<StatPayload>({
}
return (
<div class="flex items-center gap-1.5">
<span class="text-lg">📊</span>
<span class="text-lg">🔢</span>
<span class="font-mono text-sm">
{p.key} <span class="font-bold">{p.value}</span>
{p.key} = <span class="font-bold">{p.value}</span>
</span>
</div>
);
@@ -52,9 +52,9 @@ registerMessageType<StatPayload>({
journalSetState(
produce((s) => {
if (p.action === "del") {
delete s.stats[p.key];
delete s.variables[p.key];
} else if (p.value !== undefined) {
s.stats[p.key] = p.value;
s.variables[p.key] = p.value;
}
}),
);
+692
View File
@@ -0,0 +1,692 @@
/**
* Variable reactivity engine tracks variable declarations and tag
* modifiers, then cascades changes when variables are set.
*
* Runs client-side (in the sender's tab, via command-dispatcher).
* Uses a base/mod separation:
* - baseValues: what /set writes (or declaration evaluation produces)
* - numericMods: per-target list of {tag, value, source} from tag activations
* - tagMapMods: per-target list of {tag, value, source} for tagmap targets
* - sourceActivations: per-source list of {tag, target, value, threshold, kind}
* for deactivation
*
* Variables have one of two types:
* - numeric: base + sum(numericMods)
* - tagmap (#warrior:1;#druid:2): the tagmap is used for threshold-gated
* modifier activation; tagmap variables do not receive numeric mods but
* can receive tagmap mods (adding/subtracting to specific tag counts).
*
* All functions that resolve variable values accept an explicit `fallback`
* (the stream VariableStore) rather than relying on mutable module state.
* This ensures correctness regardless of call order.
*/
import type { VarDeclaration, TagModifier } from "./declare-parser";
import { evaluateExpression, exprValueToString } from "./variable-expression";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface VarReactivityState {
declarations: VarDeclaration[];
tagModifiers: TagModifier[];
}
export type VariableStore = Record<string, string>;
/** A single modifier applied to a target variable. */
export interface ActiveMod {
tag: string; // "#warrior"
value: number; // evaluated modifier expression result
source: string; // "$class" — which variable activated this tag
threshold: number; // the threshold that triggered this activation
kind: "numeric" | "tagmap";
}
// ---------------------------------------------------------------------------
// Tagmap helpers
// ---------------------------------------------------------------------------
/** Tagmap serialization format: "#warrior:1;#druid:2" */
const TAGMAP_RE = /^#([a-zA-Z_][a-zA-Z0-9_]*):(\d+)$/;
/** Parse a tagmap string. Returns null if the value is not a valid tagmap. */
function parseTagMap(value: string | undefined): Record<string, number> | null {
if (!value) return null;
const trimmed = value.trim();
if (!trimmed.startsWith("#")) return null;
const parts = trimmed.split(";");
const map: Record<string, number> = {};
for (const part of parts) {
const m = TAGMAP_RE.exec(part.trim());
if (!m) return null; // invalid entry
const tag = "#" + m[1];
const count = parseInt(m[2], 10);
if (count <= 0) continue; // skip zero/negative counts on parse
map[tag] = (map[tag] ?? 0) + count;
}
return Object.keys(map).length > 0 ? map : null;
}
/** Serialize a tagmap back to string. Returns empty string if map is empty. */
function tagMapToString(map: Record<string, number>): string {
const entries = Object.entries(map).filter(([, c]) => c > 0);
if (entries.length === 0) return "";
return entries.map(([tag, count]) => `${tag}:${count}`).join(";");
}
/** Check if a value is a tagmap string. */
function isTagMapValue(value: string | undefined): boolean {
if (!value) return false;
const trimmed = value.trim();
if (!trimmed.startsWith("#")) return false;
return parseTagMap(trimmed) !== null;
}
// ---------------------------------------------------------------------------
// Internal state
// ---------------------------------------------------------------------------
/** Dependency graph: $dep → Set<$declaredVar> */
let depGraph: Map<string, Set<string>> | null = null;
/** Reverse: $declaredVar → its expression */
let declExprs: Map<string, string> | null = null;
/** Tag modifiers from declare blocks: #tag → [{target, expression, threshold}] */
let tagModMap: Map<string, Array<{ target: string; expression: string; threshold: number }>> | null = null;
/** Base values set by /set or declaration evaluation */
const baseValues = new Map<string, string>();
/** Numeric mods per target: $target → [{tag, value, source, ...}] */
const numericMods = new Map<string, ActiveMod[]>();
/** Tagmap mods per target: $target → [{tag, value, source, ...}] */
const tagMapMods = new Map<string, ActiveMod[]>();
/** Activations per source: $source → [{tag, target, value, threshold, kind}] */
const sourceActivations = new Map<string, Array<{
tag: string;
target: string;
value: number;
threshold: number;
kind: "numeric" | "tagmap";
}>>();
/** Set of $vars currently being re-evaluated (cycle guard) */
const inFlight = new Set<string>();
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Initialize (or re-initialize) the reactivity engine from declarations
* and tag modifiers parsed from role=declare blocks.
*
* Throws if a circular dependency is detected.
*/
export function initReactivity(state: VarReactivityState): void {
depGraph = new Map();
declExprs = new Map();
tagModMap = new Map();
baseValues.clear();
numericMods.clear();
tagMapMods.clear();
sourceActivations.clear();
// Index tag modifiers
for (const tm of state.tagModifiers) {
let list = tagModMap.get(tm.tag);
if (!list) {
list = [];
tagModMap.set(tm.tag, list);
}
list.push({ target: tm.target, expression: tm.expression, threshold: tm.threshold });
}
// Index declarations and build dependency graph
for (const decl of state.declarations) {
declExprs.set(decl.key, decl.expression);
const deps = extractDependencies(decl.expression);
for (const dep of deps) {
let dependents = depGraph.get(dep);
if (!dependents) {
dependents = new Set();
depGraph.set(dep, dependents);
}
dependents.add(decl.key);
}
}
checkCircular();
}
/** Extract $var names from an expression string. */
export function extractDependencies(expr: string): string[] {
const vars: string[] = [];
const re = /\$([a-zA-Z_][a-zA-Z0-9_]*)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(expr)) !== null) {
const name = "$" + m[1];
if (!vars.includes(name)) vars.push(name);
}
return vars;
}
/**
* Get the combined value of a variable.
* - For tagmap variables: returns the serialized tagmap (base + tagmap mods).
* - For numeric variables: returns base + sum(numericMods).
* Falls back to the provided stream store for variables not tracked locally.
*/
export function getCombined(key: string, fallback?: VariableStore): string {
const base = baseValues.get(key);
if (base !== undefined && isTagMapValue(base)) {
// Tagmap variable: apply tagmap mods, then serialize
const baseMap = parseTagMap(base);
const mods = tagMapMods.get(key) ?? [];
if (baseMap && mods.length === 0) return base;
const resultMap: Record<string, number> = { ...(baseMap ?? {}) };
for (const m of mods) {
resultMap[m.tag] = (resultMap[m.tag] ?? 0) + m.value;
if (resultMap[m.tag] <= 0) delete resultMap[m.tag];
}
const serialized = tagMapToString(resultMap);
return serialized || "0";
}
const baseNum = base !== undefined ? parseFloat(base) : NaN;
const mods = numericMods.get(key) ?? [];
const modSum = mods.reduce((sum, m) => sum + m.value, 0);
if (!isNaN(baseNum)) {
return String(baseNum + modSum);
}
// Fall back to stream store. The stream value is authoritative for
// variables not tracked locally — it already includes any mods from
// the sender's engine, so we return it as-is without adding local mods.
const fb = fallback?.[key];
if (fb !== undefined) return fb;
// No base, but has numeric mods
if (mods.length > 0) return String(modSum);
return "0";
}
/**
* Get the effective tagmap for a variable (base + tagmap mods).
* Returns null if the variable is not a tagmap variable.
*/
export function getTagMap(key: string): Record<string, number> | null {
const base = baseValues.get(key);
if (base === undefined || !isTagMapValue(base)) return null;
const baseMap = parseTagMap(base);
if (!baseMap) return null;
const mods = tagMapMods.get(key) ?? [];
const result: Record<string, number> = { ...baseMap };
for (const m of mods) {
result[m.tag] = (result[m.tag] ?? 0) + m.value;
if (result[m.tag] <= 0) delete result[m.tag];
}
return Object.keys(result).length > 0 ? result : null;
}
/** Get the active mods for a variable (for UI hover display). */
export function getMods(key: string): ActiveMod[] {
const nums = numericMods.get(key) ?? [];
const tags = tagMapMods.get(key) ?? [];
return [...nums, ...tags];
}
/** Get the declaration expression for a variable, if any. */
export function getDeclExpr(key: string): string | undefined {
return declExprs?.get(key);
}
/**
* Set the base value for a variable. Called by dispatchSet before
* computing the cascade, so getCombined returns the correct new value.
*/
export function setBase(key: string, value: string): void {
baseValues.set(key, value);
}
/**
* Rebuild local reactivity state (baseValues, mods, sourceActivations)
* from the hydrated stream variable store. Must be called after replayReducers
* so that tag activations are correctly tracked for subsequent cascade
* computations.
*/
export function rebuildReactivityFromStore(variables: VariableStore): void {
if (!tagModMap) return;
for (const [key, value] of Object.entries(variables)) {
if (!baseValues.has(key)) {
baseValues.set(key, value);
}
const tagMap = isTagMapValue(value) ? parseTagMap(value) : null;
if (tagMap && !sourceActivations.has(key)) {
// Activate all tags in the tagmap
const added = applyTagMapActivations(key, {}, tagMap, variables);
// We don't return the added entries here — they'll be applied
// to the store separately
}
}
}
/**
* Compute cascade effects after a variable change.
*
* @param changedVar - the variable that was just set (e.g. "$con")
* @param oldValue - its previous combined value (for tag transition detection)
* @param currentVars - the full stream variable store (as fallback)
* @returns list of {key, value} pairs (combined values) to publish as var messages
*/
export function computeCascade(
changedVar: string,
oldValue: string | undefined,
currentVars: VariableStore,
): Array<{ key: string; value: string }> {
if (!depGraph || !declExprs || !tagModMap) {
return [];
}
const results: Array<{ key: string; value: string }> = [];
// ---- Tag activation/deactivation ----
const newValue = getCombined(changedVar, currentVars);
const oldTagMap = isTagMapValue(oldValue) ? parseTagMap(oldValue) : {};
const newTagMap = isTagMapValue(newValue) ? parseTagMap(newValue) : {};
// Diff tagmaps and apply changes
const tagResults = applyTagMapActivations(changedVar, oldTagMap ?? {}, newTagMap ?? {}, currentVars);
results.push(...tagResults);
// ---- Declaration re-evaluation ----
const reevaluated = reevaluateDependents(changedVar, currentVars);
for (const r of reevaluated) {
if (!results.some((x) => x.key === r.key)) {
results.push(r);
}
}
return results;
}
/**
* Compute initial values for all declared variables.
* Called once after initReactivity() to seed the store.
*/
export function computeInitialValues(
currentVars: VariableStore,
): Array<{ key: string; value: string }> {
if (!declExprs) return [];
const allKeys = [...declExprs.keys()];
const sorted = topoSortAffected(new Set(allKeys));
const results: Array<{ key: string; value: string }> = [];
for (const key of sorted) {
const expr = declExprs.get(key);
if (!expr) continue;
try {
const result = evaluateExpression(expr, {
lookup: (name: string) => {
const k = "$" + name;
return getCombined(k, currentVars);
},
});
const rawValue = exprValueToString(result.value);
baseValues.set(key, rawValue);
// Check if this is a tagmap value — if so, activate matching modifiers
const tagMap = isTagMapValue(rawValue) ? parseTagMap(rawValue) : null;
if (tagMap) {
const added = applyTagMapActivations(key, {}, tagMap, currentVars);
for (const r of added) {
if (!results.some((x) => x.key === r.key)) {
results.push(r);
}
}
}
// Always emit the combined value for this key
const combined = getCombined(key, currentVars);
if (!results.some((x) => x.key === key)) {
results.push({ key, value: combined });
}
} catch {
// skip failed evaluations at init time
}
}
return results;
}
// ---------------------------------------------------------------------------
// Tag activation / deactivation (threshold-based)
// ---------------------------------------------------------------------------
/**
* Apply tagmap changes for a source variable. Compares old and new tagmaps,
* activating/deactivating modifiers whose threshold crossing state changed.
*/
function applyTagMapActivations(
source: string,
oldTagMap: Record<string, number>,
newTagMap: Record<string, number>,
fallback: VariableStore,
): Array<{ key: string; value: string }> {
if (!tagModMap) return [];
const results: Array<{ key: string; value: string }> = [];
const allTags = new Set([...Object.keys(oldTagMap), ...Object.keys(newTagMap)]);
for (const tag of allTags) {
const mods = tagModMap.get(tag);
if (!mods || mods.length === 0) continue;
const oldCount = oldTagMap[tag] ?? 0;
const newCount = newTagMap[tag] ?? 0;
for (let modIdx = 0; modIdx < mods.length; modIdx++) {
const mod = mods[modIdx];
const wasActive = oldCount >= mod.threshold;
const isActive = newCount >= mod.threshold;
if (!wasActive && isActive) {
// Activate this modifier
try {
// Snapshot target's current value as base if needed
if (!baseValues.has(mod.target)) {
baseValues.set(mod.target, getCombined(mod.target, fallback));
}
const evalResult = evaluateExpression(mod.expression, {
lookup: (name: string) => {
const k = "$" + name;
return getCombined(k, fallback);
},
});
// Modifier expressions must evaluate to a number
if (evalResult.value.kind !== "number") {
throw new Error(
`Modifier expression "${mod.expression}" must evaluate to a number`,
);
}
const value = evalResult.value.value;
const targetIsTagMap = isTagMapValue(baseValues.get(mod.target));
if (targetIsTagMap) {
// Apply as tagmap mod to the target's tag count
const entry: ActiveMod = { tag, value, source, threshold: mod.threshold, kind: "tagmap" };
let targetMods = tagMapMods.get(mod.target);
if (!targetMods) {
targetMods = [];
tagMapMods.set(mod.target, targetMods);
}
targetMods.push(entry);
} else {
// Apply as numeric mod
const entry: ActiveMod = { tag, value, source, threshold: mod.threshold, kind: "numeric" };
let targetMods = numericMods.get(mod.target);
if (!targetMods) {
targetMods = [];
numericMods.set(mod.target, targetMods);
}
targetMods.push(entry);
}
// Track in source activations for deactivation
let sourceEntries = sourceActivations.get(source);
if (!sourceEntries) {
sourceEntries = [];
sourceActivations.set(source, sourceEntries);
}
sourceEntries.push({
tag,
target: mod.target,
value,
threshold: mod.threshold,
kind: targetIsTagMap ? "tagmap" : "numeric",
});
// Emit new combined value
const combined = getCombined(mod.target, fallback);
const existing = results.findIndex((r) => r.key === mod.target);
if (existing >= 0) {
results[existing] = { key: mod.target, value: combined };
} else {
results.push({ key: mod.target, value: combined });
}
} catch {
// skip failed modifier
}
} else if (wasActive && !isActive) {
// Deactivate this modifier
const sourceEntries = sourceActivations.get(source);
if (!sourceEntries) continue;
const idx = sourceEntries.findIndex(
(e) => e.tag === tag && e.target === mod.target && e.threshold === mod.threshold,
);
if (idx < 0) continue;
const entry = sourceEntries[idx];
sourceEntries.splice(idx, 1);
// Remove from the appropriate mod list
if (entry.kind === "tagmap") {
const targetMods = tagMapMods.get(entry.target);
if (targetMods) {
const modIdx = targetMods.findIndex(
(m) => m.tag === tag && m.source === source && m.threshold === mod.threshold,
);
if (modIdx >= 0) targetMods.splice(modIdx, 1);
if (targetMods.length === 0) tagMapMods.delete(entry.target);
}
} else {
const targetMods = numericMods.get(entry.target);
if (targetMods) {
const modIdx = targetMods.findIndex(
(m) => m.tag === tag && m.source === source && m.threshold === mod.threshold,
);
if (modIdx >= 0) targetMods.splice(modIdx, 1);
if (targetMods.length === 0) numericMods.delete(entry.target);
}
}
// Emit updated combined value
const combined = getCombined(entry.target, fallback);
const existing = results.findIndex((r) => r.key === entry.target);
if (existing >= 0) {
results[existing] = { key: entry.target, value: combined };
} else {
results.push({ key: entry.target, value: combined });
}
}
}
}
// Clean up empty sourceActivations
if (sourceActivations.get(source)?.length === 0) {
sourceActivations.delete(source);
}
return results;
}
// ---------------------------------------------------------------------------
// Declaration re-evaluation
// ---------------------------------------------------------------------------
function reevaluateDependents(
changedVar: string,
fallback: VariableStore,
): Array<{ key: string; value: string }> {
if (!depGraph || !declExprs) return [];
// Collect all dependents reachable from changedVar (BFS)
const affected = new Set<string>();
const queue = [changedVar];
while (queue.length > 0) {
const dep = queue.shift()!;
const dependents = depGraph.get(dep);
if (!dependents) continue;
for (const d of dependents) {
if (!affected.has(d)) {
affected.add(d);
queue.push(d);
}
}
}
const sorted = topoSortAffected(affected);
const results: Array<{ key: string; value: string }> = [];
for (const key of sorted) {
if (inFlight.has(key)) {
throw new Error(
`Circular dependency detected during evaluation of ${key}`,
);
}
inFlight.add(key);
try {
const expr = declExprs.get(key);
if (!expr) continue;
const result = evaluateExpression(expr, {
lookup: (name: string) => {
const k = "$" + name;
return getCombined(k, fallback);
},
});
const rawValue = exprValueToString(result.value);
// Check for tagmap transition on this declared variable
const oldCombined = getCombined(key, fallback);
const oldTagMap = isTagMapValue(oldCombined) ? parseTagMap(oldCombined) : {};
const newTagMap = isTagMapValue(rawValue) ? parseTagMap(rawValue) : {};
if (JSON.stringify(oldTagMap) !== JSON.stringify(newTagMap)) {
const tagResults = applyTagMapActivations(key, oldTagMap ?? {}, newTagMap ?? {}, fallback);
for (const r of tagResults) {
if (!results.some((x) => x.key === r.key)) {
results.push(r);
}
}
}
// Update base value
baseValues.set(key, rawValue);
// Emit combined value
const combined = getCombined(key, fallback);
if (!results.some((x) => x.key === key)) {
results.push({ key, value: combined });
}
} finally {
inFlight.delete(key);
}
}
return results;
}
// ---------------------------------------------------------------------------
// Topological sort & circular check
// ---------------------------------------------------------------------------
function topoSortAffected(affected: Set<string>): string[] {
if (!depGraph || !declExprs) return [...affected];
const result: string[] = [];
const visited = new Set<string>();
const temp = new Set<string>();
function visit(key: string): void {
if (visited.has(key)) return;
if (temp.has(key)) {
throw new Error(`Circular dependency involving ${key}`);
}
temp.add(key);
const expr = declExprs!.get(key);
if (expr) {
const deps = extractDependencies(expr);
for (const dep of deps) {
if (affected.has(dep) || declExprs!.has(dep)) {
visit(dep);
}
}
}
temp.delete(key);
visited.add(key);
result.push(key);
}
for (const key of affected) {
visit(key);
}
return result;
}
function checkCircular(): void {
if (!declExprs) return;
const allKeys = [...declExprs.keys()];
const state = new Map<string, "unvisited" | "visiting" | "visited">();
for (const k of allKeys) state.set(k, "unvisited");
const path: string[] = [];
function dfs(key: string): void {
const s = state.get(key);
if (s === "visited") return;
if (s === "visiting") {
const cycleStart = path.indexOf(key);
const cycle = path.slice(cycleStart).concat(key);
throw new Error(
`Circular dependency detected: ${cycle.join(" → ")}`,
);
}
state.set(key, "visiting");
path.push(key);
const expr = declExprs!.get(key);
if (expr) {
const deps = extractDependencies(expr);
for (const dep of deps) {
if (declExprs!.has(dep)) {
dfs(dep);
}
}
}
path.pop();
state.set(key, "visited");
}
for (const key of allKeys) {
dfs(key);
}
}
@@ -0,0 +1,505 @@
/**
* Variable expression evaluator parses and evaluates expressions used
* in `/set` commands and `role=declare` code blocks.
*
* Supports:
* - Number literals (integer or decimal)
* - Tagmap literals: #warrior:1;#druid:2 (bare #warrior #warrior:1)
* - $var references (resolved via lookup; auto-detects number vs tagmap)
* - Dice patterns: 3d6, 2d8kh1, etc. (delegates to rollFormula)
* - Arithmetic: + - * / (type-checked via registry)
* - Functions: floor(x), ceil(x), round(x) (numbers only)
* - Parentheses for grouping
*
* Throws on:
* - Type mismatch (e.g. number + tagmap, tagmap * number)
* - Circular variable references (detected by caller)
* - Division by zero
* - Unknown functions
* - Malformed expressions
*/
import { rollFormula } from "../md-commander/hooks";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** A value produced by the expression evaluator. */
export type ExprValue =
| { kind: "number"; value: number }
| { kind: "tagmap"; value: Record<string, number> };
export interface EvalContext {
/** Resolve $var string (numeric or tagmap serialized form).
* Return undefined if the variable doesn't exist. */
lookup: (varName: string) => string | undefined;
}
export interface EvalResult {
value: ExprValue;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Evaluate an expression string.
* Throws if the expression is malformed or contains a type mismatch.
*/
export function evaluateExpression(
expr: string,
ctx: EvalContext,
): EvalResult {
const tokens = tokenize(expr);
const result = parseExpression(tokens, 0, ctx);
if (result.next < tokens.length) {
throw new Error(
`Unexpected token at position ${result.next}: "${tokens[result.next].raw}"`,
);
}
return { value: result.value };
}
/** Quick check: does this expression produce a tag value? */
export function expressionIsTag(expr: string): boolean {
const trimmed = expr.trim();
return trimmed.startsWith("#");
}
/** Serialize an ExprValue back to a string (for var-reactivity integration). */
export function exprValueToString(v: ExprValue): string {
if (v.kind === "number") return String(v.value);
// tagmap
const entries = Object.entries(v.value).filter(([, c]) => c > 0);
if (entries.length === 0) return "0";
return entries.map(([tag, count]) => `${tag}:${count}`).join(";");
}
// ---------------------------------------------------------------------------
// Binary operation registry
// ---------------------------------------------------------------------------
type BinaryOp = (a: ExprValue, b: ExprValue) => ExprValue;
/* Helpers that narrow ExprValue to specific kinds for use in registry callbacks. */
const num = (a: ExprValue, b: ExprValue): [number, number] =>
[a.value as number, b.value as number];
const tmap = (a: ExprValue, b: ExprValue): [Record<string, number>, Record<string, number>] =>
[a.value as Record<string, number>, b.value as Record<string, number>];
/** Registry: binaryOps[leftKind][rightKind][operator] implementation.
* Any undefined combination throws a type-mismatch error. */
const binaryOps: Record<
string,
Record<string, Record<string, BinaryOp>>
> = {
number: {
number: {
"+": (a, b) => {
const [l, r] = num(a, b);
return { kind: "number", value: l + r };
},
"-": (a, b) => {
const [l, r] = num(a, b);
return { kind: "number", value: l - r };
},
"*": (a, b) => {
const [l, r] = num(a, b);
return { kind: "number", value: l * r };
},
"/": (a, b) => {
const [l, r] = num(a, b);
if (r === 0) throw new Error("Division by zero");
return { kind: "number", value: l / r };
},
},
},
tagmap: {
tagmap: {
"+": (a, b) => {
const [leftMap, rightMap] = tmap(a, b);
const result: Record<string, number> = { ...leftMap };
for (const [tag, count] of Object.entries(rightMap)) {
result[tag] = (result[tag] ?? 0) + count;
}
return { kind: "tagmap", value: result };
},
"-": (a, b) => {
const [leftMap, rightMap] = tmap(a, b);
const result: Record<string, number> = { ...leftMap };
for (const [tag, count] of Object.entries(rightMap)) {
result[tag] = (result[tag] ?? 0) - count;
if (result[tag] <= 0) delete result[tag];
}
return { kind: "tagmap", value: result };
},
},
},
};
function getBinaryOp(
left: ExprValue,
right: ExprValue,
op: string,
): BinaryOp {
const opFn = binaryOps[left.kind]?.[right.kind]?.[op];
if (!opFn) {
throw new Error(
`Type mismatch: cannot ${op} ${left.kind} with ${right.kind}`,
);
}
return opFn;
}
// ---------------------------------------------------------------------------
// Tokenizer
// ---------------------------------------------------------------------------
interface Token {
kind: "number" | "tagmap" | "var" | "ident" | "op" | "lparen" | "rparen" | "comma";
value: string;
raw: string;
/** Pre-parsed tagmap data (only set when kind === "tagmap") */
tagmap?: Record<string, number>;
}
/** Dice pattern: e.g. "3d6", "2d8kh1", "d20" */
const DICE_RE = /^\d*d\d+(?:[kdh]\d+)*$/i;
/** Single tagmap entry: "#warrior" or "#warrior:1" */
const TAGMAP_ENTRY_RE = /^#([a-zA-Z_][a-zA-Z0-9_]*)(?::(\d+))?/;
function tokenize(input: string): Token[] {
const tokens: Token[] = [];
let i = 0;
while (i < input.length) {
const ch = input[i];
// Whitespace
if (/\s/.test(ch)) {
i++;
continue;
}
// Number (integer or decimal, but NOT followed by 'd' which makes it a dice pattern)
if (/[0-9]/.test(ch)) {
let num = "";
while (i < input.length && /[0-9.]/.test(input[i])) {
num += input[i];
i++;
}
// Peek ahead: if next char is 'd' (case-insensitive), this is a dice pattern
if (i < input.length && /[dD]/.test(input[i])) {
// Dice pattern
let dice = num;
while (i < input.length && /[a-zA-Z0-9]/.test(input[i])) {
dice += input[i];
i++;
}
if (!DICE_RE.test(dice)) {
throw new Error(`Invalid dice notation: "${dice}"`);
}
tokens.push({ kind: "number", value: String(rollDice(dice)), raw: dice });
continue;
}
tokens.push({ kind: "number", value: num, raw: num });
continue;
}
// Tagmap literal: #warrior:1;#druid:2 or bare #warrior
if (ch === "#") {
let raw = "";
const map: Record<string, number> = {};
while (i < input.length && input[i] === "#") {
// Match one entry from the current position
const remaining = input.slice(i);
const m = TAGMAP_ENTRY_RE.exec(remaining);
if (!m) {
throw new Error(`Invalid tagmap entry at position ${i}: "${remaining.slice(0, 20)}..."`);
}
const matched = m[0];
raw += (raw ? ";" : "") + matched;
const tag = "#" + m[1];
const count = m[2] !== undefined ? parseInt(m[2], 10) : 1;
if (count > 0) {
map[tag] = (map[tag] ?? 0) + count;
}
i += matched.length;
// Skip whitespace after the entry
while (i < input.length && input[i] === " ") i++;
// Check for semicolon separator (continue to next entry)
if (i < input.length && input[i] === ";") {
raw += ";";
i++;
// Skip whitespace after semicolon
while (i < input.length && input[i] === " ") i++;
// If the next char is not '#', we're done with the tagmap
if (i >= input.length || input[i] !== "#") break;
} else {
break;
}
}
if (Object.keys(map).length === 0) {
throw new Error(`Empty tagmap: "${raw}"`);
}
tokens.push({ kind: "tagmap", value: raw, raw, tagmap: map });
continue;
}
// Variable reference: $var
if (ch === "$") {
let ident = "$";
i++;
if (i >= input.length || !/[a-zA-Z_]/.test(input[i])) {
throw new Error(`Invalid variable reference at position ${i - 1}: expected identifier after $`);
}
while (i < input.length && /[a-zA-Z0-9_]/.test(input[i])) {
ident += input[i];
i++;
}
tokens.push({ kind: "var", value: ident, raw: ident });
continue;
}
// Identifier or function name
if (/[a-zA-Z_]/.test(ch)) {
let ident = "";
while (i < input.length && /[a-zA-Z0-9_]/.test(input[i])) {
ident += input[i];
i++;
}
tokens.push({ kind: "ident", value: ident, raw: ident });
continue;
}
// Operators and punctuation
switch (ch) {
case "+":
case "-":
case "*":
case "/":
tokens.push({ kind: "op", value: ch, raw: ch });
i++;
break;
case "(":
tokens.push({ kind: "lparen", value: "(", raw: "(" });
i++;
break;
case ")":
tokens.push({ kind: "rparen", value: ")", raw: ")" });
i++;
break;
case ",":
tokens.push({ kind: "comma", value: ",", raw: "," });
i++;
break;
default:
throw new Error(`Unexpected character: "${ch}"`);
}
}
return tokens;
}
// ---------------------------------------------------------------------------
// Dice helper
// ---------------------------------------------------------------------------
function rollDice(notation: string): number {
const result = rollFormula(notation);
if (!result.success) {
throw new Error(`Dice roll failed: ${result.error ?? notation}`);
}
return result.result.total;
}
// ---------------------------------------------------------------------------
// Recursive descent parser
// ---------------------------------------------------------------------------
interface ParseResult {
value: ExprValue;
next: number; // index of next unconsumed token
}
/** expression := term (("+" | "-") term)* */
function parseExpression(
tokens: Token[],
pos: number,
ctx: EvalContext,
): ParseResult {
let result = parseTerm(tokens, pos, ctx);
pos = result.next;
while (pos < tokens.length) {
const tok = tokens[pos];
if (tok.kind === "op" && (tok.value === "+" || tok.value === "-")) {
const right = parseTerm(tokens, pos + 1, ctx);
const opFn = getBinaryOp(result.value, right.value, tok.value);
result = { value: opFn(result.value, right.value), next: right.next };
pos = result.next;
} else {
break;
}
}
return result;
}
/** term := factor (("*" | "/") factor)* */
function parseTerm(
tokens: Token[],
pos: number,
ctx: EvalContext,
): ParseResult {
let result = parseFactor(tokens, pos, ctx);
pos = result.next;
while (pos < tokens.length) {
const tok = tokens[pos];
if (tok.kind === "op" && (tok.value === "*" || tok.value === "/")) {
const right = parseFactor(tokens, pos + 1, ctx);
const opFn = getBinaryOp(result.value, right.value, tok.value);
result = { value: opFn(result.value, right.value), next: right.next };
pos = result.next;
} else {
break;
}
}
return result;
}
/** factor := number | tagmap | "$" ident | ident "(" expr ")" | "(" expr ")" | "-" factor */
function parseFactor(
tokens: Token[],
pos: number,
ctx: EvalContext,
): ParseResult {
if (pos >= tokens.length) {
throw new Error("Unexpected end of expression");
}
const tok = tokens[pos];
// Unary minus (numbers only)
if (tok.kind === "op" && tok.value === "-") {
const inner = parseFactor(tokens, pos + 1, ctx);
if (inner.value.kind !== "number") {
throw new Error(`Type mismatch: cannot negate ${inner.value.kind}`);
}
return { value: { kind: "number", value: -inner.value.value }, next: inner.next };
}
// Number literal (including already-rolled dice patterns)
if (tok.kind === "number") {
return { value: { kind: "number", value: parseFloat(tok.value) }, next: pos + 1 };
}
// Tagmap literal
if (tok.kind === "tagmap") {
return { value: { kind: "tagmap", value: { ...tok.tagmap! } }, next: pos + 1 };
}
// Variable reference: $var
if (tok.kind === "var") {
const varName = tok.value; // includes $ prefix
const resolved = ctx.lookup(varName.slice(1)); // strip $ for lookup
if (resolved === undefined) {
return { value: { kind: "number", value: 0 }, next: pos + 1 };
}
// Auto-detect: tagmap or numeric
if (resolved.startsWith("#")) {
const map = parseTagMapValue(resolved);
if (!map) {
throw new Error(
`Type mismatch: ${varName} is not a valid tagmap ("${resolved}")`,
);
}
return { value: { kind: "tagmap", value: map }, next: pos + 1 };
}
const num = parseFloat(resolved);
if (isNaN(num)) {
throw new Error(
`Type mismatch: ${varName} is not numeric ("${resolved}")`,
);
}
return { value: { kind: "number", value: num }, next: pos + 1 };
}
// Parenthesized expression
if (tok.kind === "lparen") {
const inner = parseExpression(tokens, pos + 1, ctx);
if (inner.next >= tokens.length || tokens[inner.next].kind !== "rparen") {
throw new Error("Missing closing parenthesis");
}
return { value: inner.value, next: inner.next + 1 };
}
// Function call: ident "(" expression ")"
if (tok.kind === "ident") {
const name = tok.value;
// Check for function call: ident "(" ...
if (pos + 1 < tokens.length && tokens[pos + 1].kind === "lparen") {
const arg = parseExpression(tokens, pos + 2, ctx);
if (arg.next >= tokens.length || tokens[arg.next].kind !== "rparen") {
throw new Error(`Missing closing parenthesis after ${name}(...)`);
}
const value = applyFunction(name, arg.value);
return { value, next: arg.next + 1 };
}
throw new Error(`Unknown identifier: "${name}" (use $ for variables)`);
}
throw new Error(`Unexpected token: "${tok.raw}"`);
}
function applyFunction(name: string, arg: ExprValue): ExprValue {
if (arg.kind !== "number") {
throw new Error(`Type mismatch: ${name}() requires a number, got ${arg.kind}`);
}
switch (name.toLowerCase()) {
case "floor":
return { kind: "number", value: Math.floor(arg.value) };
case "ceil":
return { kind: "number", value: Math.ceil(arg.value) };
case "round":
return { kind: "number", value: Math.round(arg.value) };
default:
throw new Error(`Unknown function: ${name}`);
}
}
// ---------------------------------------------------------------------------
// Tagmap parsing (shared with var-reactivity, duplicated to avoid circular deps)
// ---------------------------------------------------------------------------
const TAGMAP_RE = /^#([a-zA-Z_][a-zA-Z0-9_]*):(\d+)$/;
function parseTagMapValue(value: string): Record<string, number> | null {
const trimmed = value.trim();
if (!trimmed.startsWith("#")) return null;
const parts = trimmed.split(";");
const map: Record<string, number> = {};
for (const part of parts) {
const m = TAGMAP_RE.exec(part.trim());
if (!m) return null;
const tag = "#" + m[1];
const count = parseInt(m[2], 10);
if (count <= 0) continue;
map[tag] = (map[tag] ?? 0) + count;
}
return Object.keys(map).length > 0 ? map : null;
}
@@ -2,7 +2,7 @@ import { createStore } from "solid-js/store";
import type { MdCommanderCommand, MdCommanderCommandMap } from "../types";
import { setupHelpCommand, clearCommand, rollCommand, trackCommand, untrackCommand, listTrackCommand } from "../commands";
import {resolvePath} from "../../utils/path";
import {loadCSV} from "../../utils/csv-loader";
import {loadCSVFromPath} from "../../utils/csv-loader";
const defaultCommands: MdCommanderCommandMap = {
help: setupHelpCommand({}),
@@ -111,7 +111,7 @@ export async function loadCommandTemplatesFromCSV(
setCommandsError(undefined);
try {
const csv = await loadCSV<CommandTemplateRow>(resolvePath(articlePath, path));
const csv = await loadCSVFromPath<CommandTemplateRow>(resolvePath(articlePath, path));
// 按命令分组模板
const templatesByCommand = new Map<string, CommandTemplateRow[]>();
+28 -11
View File
@@ -1,7 +1,7 @@
import { createMemo, For, Show } from "solid-js";
import { createMemo, For, Show, type JSX } from "solid-js";
import { parseMarkdown } from "../../markdown";
import { getLayerStyle } from "./hooks/dimensions";
import type { CardData, CardSide, LayerConfig } from "./types";
import type { Align, CardData, CardSide, LayerConfig } from "./types";
import { DeckStore } from "./hooks/deckStore";
import { processVariables } from "../utils/csv-loader";
import type { LayerInteractionHandlers } from "./hooks/useLayerInteraction";
@@ -31,15 +31,29 @@ export function CardLayer(props: CardLayerProps) {
) as string;
}
const getAlignStyle = (align?: "l" | "c" | "r") => {
if (align === "l") return "left";
if (align === "r") return "right";
return "center";
const getAlignStyle = (align?: Align) => {
const horizontal = align?.includes("l")
? "left"
: align?.includes("r")
? "right"
: "center";
const vertical = align?.includes("t")
? "flex-start"
: align?.includes("b")
? "flex-end"
: "center";
return {
"text-align": horizontal,
"justify-content": vertical,
} as JSX.CSSProperties;
};
const isLayerSelected = (layerIndex: number) =>
selectedLayer() === layerIndex;
const isEditing = () =>
props.store.state.isEditing && !props.store.state.fixed;
const getFrameBounds = (layer: LayerConfig) => {
const dims = dimensions();
const left = (layer.x1 - 1) * dims.cellWidth;
@@ -75,20 +89,23 @@ export function CardLayer(props: CardLayerProps) {
return (
<>
<article
class="absolute flex flex-col items-stretch justify-center prose text-black prose-sm cursor-pointer"
class="absolute flex flex-col items-stretch justify-center prose text-black prose-sm"
classList={{
"cursor-pointer": isEditing(),
"ring-2 ring-blue-500 ring-offset-1":
isSelected() && !draggingState(),
isSelected() && !draggingState() && isEditing(),
}}
style={{
...getLayerStyle(layer, dimensions()),
"font-size": `${layer.fontSize || 3}mm`,
"text-align": getAlignStyle(layer.align),
...getAlignStyle(layer.align),
}}
innerHTML={renderLayerContent(props.cardData[layer.prop])}
innerHTML={renderLayerContent(
layer.template ?? props.cardData[layer.prop ?? ""],
)}
onClick={(e) => handleLayerClick(index(), e)}
/>
<Show when={isSelected()}>
<Show when={isSelected() && isEditing()}>
<div
class="absolute border-2 border-blue-500 pointer-events-none z-10"
style={{
+86
View File
@@ -0,0 +1,86 @@
import { For, Show, createEffect, createSignal, on } from "solid-js";
import type { DeckStore } from "./hooks/deckStore";
export interface CardListProps {
store: DeckStore;
}
const PAGE_SIZE = 10;
/**
*
*/
export function CardList(props: CardListProps) {
const { store } = props;
const [page, setPage] = createSignal(0);
const totalPages = () =>
Math.max(1, Math.ceil(store.state.cards.length / PAGE_SIZE));
const pageCards = () => {
const start = page() * PAGE_SIZE;
return store.state.cards.slice(start, start + PAGE_SIZE);
};
// 当活动卡牌变化时,自动跳转到其所在页(只响应 activeTab,避免与手动翻页互相干扰)
createEffect(
on(
() => store.state.activeTab,
(tab) => {
setPage(Math.floor(tab / PAGE_SIZE));
},
),
);
return (
<nav class="w-44 shrink-0 border-r border-gray-200 pr-3 flex flex-col">
<div class="flex flex-col gap-1 flex-1">
<For each={pageCards()}>
{(card, index) => {
const globalIndex = () => page() * PAGE_SIZE + index();
const active = () => store.state.activeTab === globalIndex();
return (
<button
onClick={() => store.actions.setActiveTab(globalIndex())}
class={`flex items-center gap-2 w-full text-left px-3 py-2 rounded text-sm font-medium transition-colors cursor-pointer ${
active()
? "bg-blue-100 text-blue-600"
: "text-gray-600 hover:bg-gray-100"
}`}
>
<span class="shrink-0 text-xs text-gray-400 tabular-nums w-5">
{globalIndex() + 1}
</span>
<span class="truncate">
{card.label || card.name || `Card ${globalIndex() + 1}`}
</span>
</button>
);
}}
</For>
</div>
<Show when={totalPages() > 1}>
<div class="flex items-center justify-between mt-2 pt-2 border-t border-gray-200">
<button
onClick={() => setPage(Math.max(0, page() - 1))}
disabled={page() === 0}
class="px-2 py-1 rounded text-sm text-gray-600 hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
>
</button>
<span class="text-xs text-gray-500 tabular-nums">
{page() + 1} / {totalPages()}
</span>
<button
onClick={() => setPage(Math.min(totalPages() - 1, page() + 1))}
disabled={page() >= totalPages() - 1}
class="px-2 py-1 rounded text-sm text-gray-600 hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
>
</button>
</div>
</Show>
</nav>
);
}
+62 -14
View File
@@ -1,4 +1,5 @@
import { For } from "solid-js";
import { Show, createSignal, onMount } from "solid-js";
import { Portal } from "solid-js/web";
import type { DeckStore } from "./hooks/deckStore";
export interface DeckHeaderProps {
@@ -10,8 +11,28 @@ export interface DeckHeaderProps {
*/
export function DeckHeader(props: DeckHeaderProps) {
const { store } = props;
const [showCopyFallback, setShowCopyFallback] = createSignal(false);
const [fallbackCode, setFallbackCode] = createSignal("");
let textareaRef: HTMLTextAreaElement | undefined;
onMount(() => {
if (showCopyFallback() && textareaRef) {
textareaRef.focus();
textareaRef.select();
}
});
const handleCopy = () => {
store.actions.copyCode((code) => {
setFallbackCode(code);
setShowCopyFallback(true);
});
};
const closeFallback = () => setShowCopyFallback(false);
return (
<>
<div class="flex items-center gap-2 border-b border-gray-200 pb-2 mb-4">
{/* 编辑按钮 */}
<button
@@ -33,23 +54,50 @@ export function DeckHeader(props: DeckHeaderProps) {
📥 PDF
</button>
{/* Tab 选择器 */}
<div class="flex gap-1 overflow-x-auto flex-1 min-w-0 flex-wrap">
<For each={store.state.cards}>
{(card, index) => (
{/* 复制代码按钮 */}
<button
onClick={() => store.actions.setActiveTab(index())}
class={`font-medium transition-colors shrink-0 min-w-[1.6em] cursor-pointer px-2 py-1 rounded ${
store.state.activeTab === index()
? "bg-blue-100 text-blue-600 border-b-2 border-blue-600"
: "text-gray-500 hover:text-gray-700 hover:bg-gray-100"
}`}
onClick={handleCopy}
class="px-2 py-1 rounded text-xs font-medium transition-colors cursor-pointer bg-purple-100 text-purple-600 hover:bg-purple-200"
>
{card.label || card.name || `Card ${index() + 1}`}
📋
</button>
</div>
{/* 剪贴板不可用时的手动复制弹窗 */}
<Show when={showCopyFallback()}>
<Portal>
<div
class="fixed inset-0 bg-black/50 z-60 flex items-center justify-center p-4"
onClick={closeFallback}
>
<div
class="bg-white rounded-lg shadow-xl w-full max-w-lg p-4"
onClick={(e) => e.stopPropagation()}
>
<h3 class="font-bold mb-2"></h3>
<p class="text-sm text-gray-600 mb-3">
HTTPS
</p>
<textarea
ref={textareaRef}
readonly
value={fallbackCode()}
class="w-full h-40 border border-gray-300 rounded px-3 py-2 text-xs font-mono bg-gray-50 resize-none"
onFocus={(e) => e.currentTarget.select()}
onClick={(e) => e.currentTarget.select()}
/>
<div class="flex justify-end gap-2 mt-3">
<button
onClick={closeFallback}
class="px-3 py-1.5 rounded text-sm font-medium cursor-pointer bg-gray-100 text-gray-700 hover:bg-gray-200"
>
</button>
)}
</For>
</div>
</div>
</div>
</Portal>
</Show>
</>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { createSignal } from "solid-js";
import type { DeckStore } from "./hooks/deckStore";
import { LayerEditorPanel, PropertiesEditorPanel } from "./editor-panel";
export interface EditorTabsProps {
store: DeckStore;
}
type TabKey = "sizing" | "front" | "back";
/**
* / /
*/
export function EditorTabs(props: EditorTabsProps) {
const { store } = props;
const [tab, setTab] = createSignal<TabKey>("sizing");
const tabs: { key: TabKey; label: string }[] = [
{ key: "sizing", label: "尺寸" },
{ key: "front", label: "正面图层" },
{ key: "back", label: "背面图层" },
];
const selectTab = (key: TabKey) => {
setTab(key);
// 切换正/背面标签时,同步预览的显示面
if (key === "front") store.actions.setActiveSide("front");
if (key === "back") store.actions.setActiveSide("back");
};
return (
<div class="w-64 shrink-0">
{/* 标签页切换 */}
<div class="flex gap-1 mb-3">
{tabs.map((t) => (
<button
onClick={() => selectTab(t.key)}
class={`flex-1 px-3 py-1.5 rounded text-sm font-medium cursor-pointer ${
tab() === t.key
? "bg-blue-600 text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`}
>
{t.label}
</button>
))}
</div>
{tab() === "sizing" ? (
<PropertiesEditorPanel store={store} />
) : (
<LayerEditorPanel
store={store}
side={tab() === "front" ? "front" : "back"}
/>
)}
</div>
);
}
+1 -1
View File
@@ -115,7 +115,7 @@ export function PrintPreview(props: PrintPreviewProps) {
<PltPreview pltCode={pltCode()} onClose={handleClosePltPreview} />
}
>
<div class="fixed inset-0 bg-black/50 z-[60] overflow-auto">
<div class="fixed inset-0 bg-black/50 z-60 overflow-auto">
<div class="min-h-screen py-20 px-4">
<PrintPreviewHeader
store={store}
@@ -179,6 +179,20 @@ export function PrintPreviewHeader(props: PrintPreviewHeaderProps) {
</label>
</div>
<div class="flex items-center gap-2">
<label class="flex items-center gap-1 cursor-pointer">
<input
type="checkbox"
checked={store.state.pltDoubleCut}
onChange={(e) =>
store.actions.setPltDoubleCut(e.target.checked)
}
class="cursor-pointer"
/>
<span class="text-sm text-gray-600">PLT </span>
</label>
</div>
<div class="flex items-center gap-2">
<label class="text-sm text-gray-600">:</label>
<div class="flex items-center gap-1">
+131
View File
@@ -0,0 +1,131 @@
import { layersToConfigs, normalizeDeckConfig } from "./config";
describe("layersToConfigs", () => {
test("parses compact string format (legacy)", () => {
const layers = layersToConfigs("title:1,1-5,1f8 body:1,5-8,8f3");
expect(layers).toHaveLength(2);
expect(layers[0]).toMatchObject({
prop: "title",
visible: true,
x1: 1,
y1: 1,
x2: 5,
y2: 1,
fontSize: 8,
});
expect(layers[1].prop).toBe("body");
});
test("parses structured list with prop layers", () => {
const layers = layersToConfigs([
{ prop: "name", pos: "1,1-5,2", font: 12 },
]);
expect(layers).toHaveLength(1);
expect(layers[0]).toMatchObject({
prop: "name",
template: undefined,
visible: true,
x1: 1,
y1: 1,
x2: 5,
y2: 2,
fontSize: 12,
});
});
test("parses structured list with template layers", () => {
const layers = layersToConfigs([
{ template: "**{{name}}**", pos: "1,3-5,8", align: "l" },
]);
expect(layers).toHaveLength(1);
expect(layers[0]).toMatchObject({
prop: undefined,
template: "**{{name}}**",
visible: true,
x1: 1,
y1: 3,
x2: 5,
y2: 8,
align: "l",
});
});
test("parses combined vertical+horizontal align", () => {
const layers = layersToConfigs([
{ prop: "a", pos: "1,1-2,2", align: "tl" },
{ prop: "b", pos: "1,1-2,2", align: "br" },
{ prop: "c", pos: "1,1-2,2", align: "tc" },
{ prop: "d", pos: "1,1-2,2", align: "bc" },
]);
expect(layers[0].align).toBe("tl");
expect(layers[1].align).toBe("br");
expect(layers[2].align).toBe("tc");
expect(layers[3].align).toBe("bc");
});
test("parses vertical+horizontal align in compact layers string", () => {
const layers = layersToConfigs("title:1,1-4,1tl body:1,2-5,8bc");
expect(layers[0].align).toBe("tl");
expect(layers[1].align).toBe("bc");
});
test("defaults placement when pos is missing or invalid", () => {
const layers = layersToConfigs([{ prop: "x" }, { prop: "y", pos: "bogus" }]);
expect(layers[0]).toMatchObject({ x1: 1, y1: 1, x2: 2, y2: 2 });
expect(layers[1]).toMatchObject({ x1: 1, y1: 1, x2: 2, y2: 2 });
});
test("returns [] for absent/empty input", () => {
expect(layersToConfigs()).toEqual([]);
expect(layersToConfigs("")).toEqual([]);
expect(layersToConfigs([])).toEqual([]);
});
});
describe("normalizeDeckConfig", () => {
test("parses string dimensions", () => {
const cfg = normalizeDeckConfig({ size: "54x86", grid: "5x8" });
expect(cfg.sizeW).toBe(54);
expect(cfg.sizeH).toBe(86);
expect(cfg.gridW).toBe(5);
expect(cfg.gridH).toBe(8);
});
test("parses array dimensions", () => {
const cfg = normalizeDeckConfig({ size: [63, 88] });
expect(cfg.sizeW).toBe(63);
expect(cfg.sizeH).toBe(88);
});
test("coerces numeric strings for bleed/padding", () => {
const cfg = normalizeDeckConfig({ bleed: "2", padding: "3" });
expect(cfg.bleed).toBe(2);
expect(cfg.padding).toBe(3);
});
test("normalizes layers and back_layers", () => {
const cfg = normalizeDeckConfig({
layers: [
{ prop: "name", pos: "1,1-5,1" },
{ template: "{{body}}", pos: "1,2-5,8" },
],
back_layers: "logo:1,1-2,2",
});
expect(cfg.frontLayers).toHaveLength(2);
expect(cfg.frontLayers[1].template).toBe("{{body}}");
expect(cfg.backLayers).toHaveLength(1);
expect(cfg.backLayers[0].prop).toBe("logo");
});
test("missing fields stay undefined", () => {
const cfg = normalizeDeckConfig({});
expect(cfg.sizeW).toBeUndefined();
expect(cfg.sizeH).toBeUndefined();
expect(cfg.gridW).toBeUndefined();
expect(cfg.gridH).toBeUndefined();
expect(cfg.bleed).toBeUndefined();
expect(cfg.padding).toBeUndefined();
expect(cfg.frontLayers).toEqual([]);
expect(cfg.backLayers).toEqual([]);
});
});
+148
View File
@@ -0,0 +1,148 @@
import type { Align, CardShape, LayerConfig } from "./types";
import { parseLayers } from "./hooks/layer-parser";
/**
* YAML/JSON shape of an `md-deck` `data-config`.
*
* Mirrors the frontmatter `deck:` block and the `:md-deck` directive attrs,
* but allows `layers`/`back_layers` as structured lists where each layer is
* either a CSV `prop` or a markdown `template`.
*/
export interface DeckConfigYaml {
size?: string | [number, number];
grid?: string | [number, number];
bleed?: number | string;
padding?: number | string;
shape?: CardShape;
fixed?: boolean;
layers?: string | DeckLayerYaml[];
back_layers?: string | DeckLayerYaml[];
}
export interface DeckLayerYaml {
prop?: string;
template?: string;
/** Grid placement "x1,y1-x2,y2" (1-based), same shape as the compact layers string. */
pos?: string;
font?: number;
fontSize?: number;
orientation?: "n" | "s" | "e" | "w";
align?: Align;
visible?: boolean;
}
export interface NormalizedDeckConfig {
sizeW?: number;
sizeH?: number;
gridW?: number;
gridH?: number;
bleed?: number;
padding?: number;
shape?: CardShape;
fixed?: boolean;
frontLayers: LayerConfig[];
backLayers: LayerConfig[];
}
/** Parse a "54x86" string or [54, 86] array into [w, h]. */
function parseDimension(
v?: string | [number, number],
): [number, number] | undefined {
if (!v) return undefined;
if (Array.isArray(v)) {
const [w, h] = v;
if (typeof w === "number" && typeof h === "number") return [w, h];
return undefined;
}
const parts = String(v)
.toLowerCase()
.split("x")
.map((n) => Number(n.trim()));
if (
parts.length === 2 &&
Number.isFinite(parts[0]) &&
Number.isFinite(parts[1])
) {
return [parts[0], parts[1]];
}
return undefined;
}
/** Parse a "x1,y1-x2,y2" placement string into grid coordinates. */
export function parsePos(
pos?: string,
): { x1: number; y1: number; x2: number; y2: number } | undefined {
if (!pos) return undefined;
const m = /^(\d+)\s*,\s*(\d+)\s*-\s*(\d+)\s*,\s*(\d+)$/.exec(pos.trim());
if (!m) return undefined;
return {
x1: Number(m[1]),
y1: Number(m[2]),
x2: Number(m[3]),
y2: Number(m[4]),
};
}
/** Map a structured YAML layer to a LayerConfig. */
function layerYamlToConfig(l: DeckLayerYaml): LayerConfig {
const pos = parsePos(l.pos);
return {
prop: l.prop,
template: l.template,
visible: l.visible ?? true,
x1: pos?.x1 ?? 1,
y1: pos?.y1 ?? 1,
x2: pos?.x2 ?? 2,
y2: pos?.y2 ?? 2,
orientation: l.orientation,
fontSize: l.fontSize ?? l.font,
align: l.align,
};
}
/**
* Normalize a `layers`/`back_layers` value (compact string or structured list)
* into LayerConfig[]. Empty/absent [].
*/
export function layersToConfigs(
layers?: string | DeckLayerYaml[],
): LayerConfig[] {
if (!layers) return [];
if (typeof layers === "string") {
return parseLayers(layers).map((l) => ({
prop: l.prop,
visible: true,
x1: l.x1,
y1: l.y1,
x2: l.x2,
y2: l.y2,
orientation: l.orientation,
fontSize: l.fontSize,
align: l.align,
}));
}
return layers.map(layerYamlToConfig);
}
/**
* Normalize a parsed `data-config` object into concrete numeric config +
* ready-to-use layer lists. Missing fields are left undefined so callers can
* apply defaults.
*/
export function normalizeDeckConfig(cfg: DeckConfigYaml): NormalizedDeckConfig {
const size = parseDimension(cfg.size);
const grid = parseDimension(cfg.grid);
return {
sizeW: size?.[0],
sizeH: size?.[1],
gridW: grid?.[0],
gridH: grid?.[1],
bleed: typeof cfg.bleed === "string" ? Number(cfg.bleed) : cfg.bleed,
padding:
typeof cfg.padding === "string" ? Number(cfg.padding) : cfg.padding,
shape: cfg.shape,
fixed: cfg.fixed,
frontLayers: layersToConfigs(cfg.layers),
backLayers: layersToConfigs(cfg.back_layers),
};
}
@@ -6,11 +6,13 @@ import {
closestCenter,
} from "@thisbeyond/solid-dnd";
import type { DeckStore } from "../hooks/deckStore";
import type { CardSide } from "../types";
import { toSortableId } from "../hooks/layer-crud";
import { LayerRow } from "./LayerRow";
export interface LayerEditorPanelProps {
store: DeckStore;
side?: CardSide;
}
function LayerEditorPanel(props: LayerEditorPanelProps) {
@@ -20,7 +22,7 @@ function LayerEditorPanel(props: LayerEditorPanelProps) {
const [addMenuOpen, setAddMenuOpen] = createSignal(false);
let addMenuRef: HTMLDivElement | undefined;
const side = () => store.state.activeSide;
const side = () => props.side || "front";
const layers = () =>
side() === "front"
? store.state.frontLayerConfigs
@@ -143,15 +145,6 @@ function LayerEditorPanel(props: LayerEditorPanelProps) {
</SortableProvider>
</DragDropSensors>
</DragDropProvider>
<hr class="my-4" />
<button
onClick={() => store.actions.copyCode()}
class="w-full bg-blue-600 hover:bg-blue-700 text-white px-3 py-2 rounded text-sm font-medium cursor-pointer flex items-center gap-2 justify-center"
>
<span>📋</span>
<span></span>
</button>
</div>
);
}
@@ -1,7 +1,7 @@
import { For, createSignal, onCleanup, onMount } from "solid-js";
import { createSortable, useDragDropContext } from "@thisbeyond/solid-dnd";
import type { DeckStore } from "../hooks/deckStore";
import type { LayerConfig } from "../types";
import type { Align, LayerConfig } from "../types";
import alignLeftIcon from "./icons/align-left.png";
import alignCenterIcon from "./icons/align-center.png";
import alignRightIcon from "./icons/align-right.png";
@@ -17,7 +17,7 @@ export interface LayerRowProps {
setOpenDropdown: (val: string | null) => void;
onUpdateOrientation: (o: "n" | "s" | "e" | "w") => void;
onUpdateFontSize: (fs?: number) => void;
onUpdateAlign: (a?: "l" | "c" | "r") => void;
onUpdateAlign: (a?: Align) => void;
onSelect: () => void;
onRemove: () => void;
}
@@ -29,11 +29,17 @@ const ORIENTATIONS = [
{ value: "w" as const, label: "← 西" },
];
const ALIGNS = [
{ value: "" as const, icon: alignCenterIcon },
{ value: "l" as const, icon: alignLeftIcon },
{ value: "c" as const, icon: alignCenterIcon },
{ value: "r" as const, icon: alignRightIcon },
const ALIGNS: { value: Align | ""; icon: string }[] = [
{ value: "", icon: alignCenterIcon },
{ value: "l", icon: alignLeftIcon },
{ value: "c", icon: alignCenterIcon },
{ value: "r", icon: alignRightIcon },
{ value: "tl", icon: "↖" },
{ value: "tc", icon: "↑" },
{ value: "tr", icon: "↗" },
{ value: "bl", icon: "↙" },
{ value: "bc", icon: "↓" },
{ value: "br", icon: "↘" },
];
const FONT_PRESETS = [3, 5, 8, 12] as const;
@@ -53,14 +59,28 @@ function orientChar(v: string) {
}
}
function alignSrc(v: string) {
function alignIcon(v: string): import("solid-js").JSX.Element {
switch (v) {
case "tl":
return "↖";
case "tc":
return "↑";
case "tr":
return "↗";
case "bl":
return "↙";
case "bc":
return "↓";
case "br":
return "↘";
case "l":
return alignLeftIcon;
return <img src={alignLeftIcon} alt="align" class="w-5 h-5 not-prose" />;
case "r":
return alignRightIcon;
return <img src={alignRightIcon} alt="align" class="w-5 h-5 not-prose" />;
default:
return alignCenterIcon;
return (
<img src={alignCenterIcon} alt="align" class="w-5 h-5 not-prose" />
);
}
}
@@ -120,7 +140,7 @@ export function LayerRow(props: LayerRowProps) {
class="text-sm flex-1 truncate cursor-pointer hover:text-blue-600 select-none"
onClick={props.onSelect}
>
{props.layer.prop}
{props.layer.prop || (props.layer.template ? "(模板)" : "")}
</span>
<DropdownButton
@@ -149,13 +169,7 @@ export function LayerRow(props: LayerRowProps) {
</DropdownButton>
<DropdownButton
icon={
<img
src={alignSrc(props.layer.align || "")}
alt="align"
class="w-5 h-5 not-prose"
/>
}
icon={alignIcon(props.layer.align || "")}
visible={props.layer.visible}
open={props.openDropdown === `align-${props.index}`}
onToggle={() =>
@@ -173,7 +187,13 @@ export function LayerRow(props: LayerRowProps) {
onClick={() => props.onUpdateAlign(o.value || undefined)}
class="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-gray-100 cursor-pointer whitespace-nowrap"
>
{o.icon.endsWith(".png") ? (
<img src={o.icon} alt="" class="w-4 h-4 not-prose max-w-none" />
) : (
<span class="w-4 h-4 flex items-center justify-center not-prose">
{o.icon}
</span>
)}
</button>
)}
</For>
@@ -1,49 +1,32 @@
import type { DeckStore } from '../hooks/deckStore';
import type { CardShape } from '../types';
import type { DeckStore } from "../hooks/deckStore";
import type { CardShape } from "../types";
export interface PropertiesEditorPanelProps {
store: DeckStore;
}
/**
*
*
*/
export function PropertiesEditorPanel(props: PropertiesEditorPanelProps) {
const { store } = props;
return (
<div class="w-64 flex-shrink-0">
<h3 class="font-bold mb-2 mt-0"></h3>
const shapeOptions: { value: CardShape; label: string }[] = [
{ value: "rectangle", label: "矩形" },
{ value: "circle", label: "圆形" },
{ value: "triangle", label: "三角形" },
{ value: "hexagon", label: "六边形" },
];
{/* 正面/背面切换标签页 */}
<div class="mb-4">
<div class="flex gap-1">
<button
onClick={() => store.actions.setActiveSide('front')}
class={`flex-1 px-3 py-1.5 rounded text-sm font-medium cursor-pointer border ${
store.state.activeSide === 'front'
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
}`}
>
</button>
<button
onClick={() => store.actions.setActiveSide('back')}
class={`flex-1 px-3 py-1.5 rounded text-sm font-medium cursor-pointer border ${
store.state.activeSide === 'back'
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
}`}
>
</button>
</div>
</div>
return (
<div class="w-64 shrink-0">
<h3 class="font-bold mb-2 mt-0"></h3>
<div class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-700"> (mm)</label>
<label class="block text-sm font-medium text-gray-700">
(mm)
</label>
<div class="flex gap-2">
<input
type="number"
@@ -83,7 +66,9 @@ export function PropertiesEditorPanel(props: PropertiesEditorPanelProps) {
</div>
<div>
<label class="block text-sm font-medium text-gray-700"> / (mm)</label>
<label class="block text-sm font-medium text-gray-700">
/ (mm)
</label>
<div class="flex gap-2">
<input
type="number"
@@ -103,24 +88,20 @@ export function PropertiesEditorPanel(props: PropertiesEditorPanelProps) {
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2"></label>
<div class="grid grid-cols-2 gap-2">
{(['rectangle', 'circle', 'triangle', 'hexagon'] as CardShape[]).map((shape) => (
<button
onClick={() => store.actions.setShape(shape)}
class={`px-3 py-1.5 rounded text-sm font-medium cursor-pointer border ${
store.state.shape === shape
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
}`}
<label class="block text-sm font-medium text-gray-700 mb-2">
</label>
<select
value={store.state.shape}
onChange={(e) =>
store.actions.setShape(e.target.value as CardShape)
}
class="w-full border border-gray-300 rounded px-2 py-1 text-sm bg-white"
>
{shape === 'rectangle' && '矩形'}
{shape === 'circle' && '圆形'}
{shape === 'triangle' && '三角形'}
{shape === 'hexagon' && '六边形'}
</button>
{shapeOptions.map((s) => (
<option value={s.value}>{s.label}</option>
))}
</div>
</select>
</div>
</div>
</div>
+80 -16
View File
@@ -1,7 +1,8 @@
import { createStore } from "solid-js/store";
import yaml from "js-yaml";
import { calculateDimensions } from "./dimensions";
import { loadCSV, CSV } from "../../utils/csv-loader";
import { formatLayers, initLayerConfigsForSide } from "./layer-parser";
import { loadCSVFromPath, CSV } from "../../utils/csv-loader";
import { formatLayers } from "./layer-parser";
import * as layerCrud from "./layer-crud";
import type {
CardData,
@@ -41,6 +42,8 @@ export interface DeckState {
cornerRadius: number;
shape: CardShape;
fixed: boolean;
/** True when the deck was configured via a yaml role=tag codeblock (data-config). */
isYamlBlock: boolean;
src: string;
rawSrc: string;
@@ -73,6 +76,7 @@ export interface DeckState {
printFrontOddPageOffsetX: number;
printFrontOddPageOffsetY: number;
printDoubleSided: boolean;
pltDoubleCut: boolean;
}
export interface DeckActions {
@@ -84,6 +88,7 @@ export interface DeckActions {
setPadding: (padding: number) => void;
setCornerRadius: (cornerRadius: number) => void;
setShape: (shape: CardShape) => void;
setIsYamlBlock: (isYamlBlock: boolean) => void;
setCards: (cards: CSV<CardData>) => void;
setActiveTab: (index: number) => void;
@@ -137,14 +142,14 @@ export interface DeckActions {
loadCardsFromPath: (
path: string,
rawSrc: string,
layersStr?: string,
backLayersStr?: string,
frontLayers?: LayerConfig[],
backLayers?: LayerConfig[],
) => Promise<void>;
setError: (error: string | null) => void;
clearError: () => void;
generateCode: (backLayersStr?: string) => string;
copyCode: (backLayersStr?: string) => Promise<void>;
copyCode: (fallback?: (code: string) => void) => Promise<void>;
setExporting: (exporting: boolean) => void;
exportDeck: () => void;
@@ -156,6 +161,7 @@ export interface DeckActions {
setPrintFrontOddPageOffsetX: (offset: number) => void;
setPrintFrontOddPageOffsetY: (offset: number) => void;
setPrintDoubleSided: (doubleSided: boolean) => void;
setPltDoubleCut: (doubleCut: boolean) => void;
}
export interface DeckStore {
@@ -174,6 +180,7 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
cornerRadius: DECK_DEFAULTS.CORNER_RADIUS,
shape: "rectangle",
fixed: false,
isYamlBlock: false,
src: initialSrc,
rawSrc: initialSrc,
dimensions: null,
@@ -197,6 +204,7 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
printFrontOddPageOffsetX: 0,
printFrontOddPageOffsetY: 0,
printDoubleSided: false,
pltDoubleCut: false,
});
const updateDimensions = () => {
@@ -241,6 +249,9 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
const setShape = (shape: CardShape) => {
setState({ shape });
};
const setIsYamlBlock = (isYamlBlock: boolean) => {
setState({ isYamlBlock });
};
const setCards = (cards: CSV<CardData>) => setState({ cards, activeTab: 0 });
const setActiveTab = (index: number) => setState({ activeTab: index });
@@ -439,8 +450,8 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
const loadCardsFromPath = async (
path: string,
rawSrc: string,
layersStr: string = "",
backLayersStr: string = "",
frontLayers: LayerConfig[] = [],
backLayers: LayerConfig[] = [],
) => {
if (!path) {
setState({ error: "未指定 CSV 文件路径" });
@@ -450,7 +461,7 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
setState({ isLoading: true, error: null, src: path, rawSrc: rawSrc });
try {
const data = await loadCSV(path);
const data = await loadCSVFromPath(path);
if (data.length === 0) {
setState({
@@ -463,12 +474,8 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
setState({
cards: data,
activeTab: 0,
frontLayerConfigs: layerCrud.withKeys(
initLayerConfigsForSide(data, layersStr),
),
backLayerConfigs: layerCrud.withKeys(
initLayerConfigsForSide(data, backLayersStr),
),
frontLayerConfigs: layerCrud.withKeys(frontLayers),
backLayerConfigs: layerCrud.withKeys(backLayers),
isLoading: false,
});
updateDimensions();
@@ -484,6 +491,9 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
const clearError = () => setState({ error: null });
const generateCode = (backLayersStr?: string) => {
if (state.isYamlBlock) {
return generateYamlCode();
}
const frontLayersStr = formatLayers(state.frontLayerConfigs);
const backLayersString =
backLayersStr || formatLayers(state.backLayerConfigs);
@@ -511,15 +521,63 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
return parts.join("");
};
const copyCode = async (backLayersStr?: string) => {
const code = generateCode(backLayersStr);
/** Serialize the deck back to a yaml codeblock (round-trips templates). */
const generateYamlCode = () => {
const toYamlLayer = (l: LayerConfig) => {
const out: Record<string, unknown> = {};
if (l.template) {
out.template = l.template;
} else {
out.prop = l.prop ?? "";
}
out.pos = `${l.x1},${l.y1}-${l.x2},${l.y2}`;
if (l.fontSize) out.font = l.fontSize;
if (l.orientation && l.orientation !== "n") out.orientation = l.orientation;
if (l.align) out.align = l.align;
if (!l.visible) out.visible = false;
return out;
};
const config: Record<string, unknown> = {
size: `${state.sizeW}x${state.sizeH}`,
grid: `${state.gridW}x${state.gridH}`,
layers: state.frontLayerConfigs.map(toYamlLayer),
};
if (state.bleed !== DECK_DEFAULTS.BLEED) config.bleed = state.bleed;
if (state.padding !== DECK_DEFAULTS.PADDING) config.padding = state.padding;
if (state.shape !== "rectangle") config.shape = state.shape;
if (state.backLayerConfigs.length > 0) {
config.back_layers = state.backLayerConfigs.map(toYamlLayer);
}
const doc = {
tag: "md-deck",
body: state.rawSrc || state.src,
"data-config": config,
};
const yamlStr = yaml.dump(doc, {
indent: 2,
lineWidth: -1,
noRefs: true,
});
const fence = "```yaml role=tag";
const backtick = "`";
return `${fence}\n${yamlStr}${backtick.repeat(3)}`;
};
const copyCode = async (fallback?: (code: string) => void) => {
const code = generateCode();
try {
await navigator.clipboard.writeText(code);
alert("已复制到剪贴板!");
} catch (err) {
console.error("复制失败:", err);
if (fallback) {
fallback(code);
} else {
alert("复制失败,请手动复制");
}
}
};
const setExporting = (exporting: boolean) =>
@@ -553,6 +611,10 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
setState({ printDoubleSided: doubleSided });
};
const setPltDoubleCut = (doubleCut: boolean) => {
setState({ pltDoubleCut: doubleCut });
};
const actions: DeckActions = {
setSizeW,
setSizeH,
@@ -562,6 +624,7 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
setPadding,
setCornerRadius,
setShape,
setIsYamlBlock,
setCards,
setActiveTab,
updateCardData,
@@ -599,6 +662,7 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
setPrintFrontOddPageOffsetX,
setPrintFrontOddPageOffsetY,
setPrintDoubleSided,
setPltDoubleCut,
};
return { state, actions };
+8 -5
View File
@@ -4,15 +4,16 @@ import { CSV } from "../../utils/csv-loader";
/**
* layers
* body:1,7-5,8 title:1,1-4,1f6.6sl
* f[fontSize] l/c/r
* f[fontSize]
* l/c/r t/b tl/tr/bl/br
*/
export function parseLayers(layersStr: string): Layer[] {
if (!layersStr) return [];
const layers: Layer[] = [];
// 匹配:prop:x1,y1-x2,y2[ffontSize][direction][align]
// 匹配:prop:x1,y1-x2,y2[ffontSize][direction][[t|b]align]
const regex =
/([^: ]+):(\d+),(\d+)-(\d+),(\d+)(?:f([\d.]+))?([nsew])?([lcr])?/g;
/([^: ]+):(\d+),(\d+)-(\d+),(\d+)(?:f([\d.]+))?([nsew])?([tb])?([lcr])?/g;
let match;
while ((match = regex.exec(layersStr)) !== null) {
@@ -24,7 +25,7 @@ export function parseLayers(layersStr: string): Layer[] {
y2: parseInt(match[5]),
fontSize: match[6] ? parseFloat(match[6]) : undefined,
orientation: match[7] as "n" | "s" | "e" | "w" | undefined,
align: match[8] as "l" | "c" | "r" | undefined,
align: [match[8], match[9]].filter(Boolean).join("") as Layer["align"],
});
}
@@ -35,8 +36,10 @@ export function parseLayers(layersStr: string): Layer[] {
* layers
*/
export function formatLayers(layers: LayerConfig[]): string {
// Template-only layers have no prop and can't be represented in the
// compact string format, so they are skipped here.
return layers
.filter((l) => l.visible)
.filter((l) => l.visible && l.prop)
.map((l) => {
let str = `${l.prop}:${l.x1},${l.y1}-${l.x2},${l.y2}`;
if (l.fontSize) {
@@ -87,6 +87,8 @@ export function useLayerInteraction(
const handleLayerClick = (index: number, e: MouseEvent) => {
e.stopPropagation();
if (!store.state.isEditing || store.state.fixed) return;
const currentlySelected = store.state.selectedLayer;
if (currentlySelected === index) {
@@ -100,6 +102,7 @@ export function useLayerInteraction(
const handleCardClick = (e: MouseEvent, cardEl: HTMLElement) => {
if (store.state.draggingState) return;
if (!store.state.isEditing || store.state.fixed) return;
const { gridX, gridY } = calculateGridCoords(e, cardEl);
const overlapping = getOverlappingLayers(gridX, gridY);
@@ -147,6 +150,7 @@ export function useLayerInteraction(
edge?: "n" | "s" | "e" | "w",
e?: MouseEvent,
) => {
if (!store.state.isEditing || store.state.fixed) return;
if (store.state.selectedLayer === null) return;
if (e) e.stopPropagation();
@@ -35,6 +35,7 @@ export function usePlotterExport(store: DeckStore): UsePlotterExportReturn {
const cardHeight = () => store.state.dimensions?.cardHeight || 88;
const shape = () => store.state.shape;
const orientation = () => store.state.printOrientation || 'landscape';
const doubleCut = () => store.state.pltDoubleCut;
/**
* PLT
@@ -60,7 +61,7 @@ export function usePlotterExport(store: DeckStore): UsePlotterExportReturn {
// 将卡片路径转换为相对于 frameBoundsWithMargin 的坐标
// 原点在 frameBoundsWithMargin 的左上角
const relativePaths = layout.cardPaths.map(cardPath => {
const relativePaths = layout.cardPaths.flatMap(cardPath => {
const relativePoints = cardPath.points.map(([x, y]) => {
// 转换为相对于 frameBounds 左上角的坐标
const relativeX = x - frameBounds.x;
@@ -71,7 +72,11 @@ export function usePlotterExport(store: DeckStore): UsePlotterExportReturn {
// 所以 plotterY = pltHeight - relativeY
return [relativeX, pltHeight - relativeY] as [number, number];
});
return relativePoints;
if (doubleCut()) {
// 重复一次:某些切割机需要每张卡牌切两次
return [relativePoints, relativePoints];
}
return [relativePoints];
});
// 起点和终点都在 frameBoundsWithMargin 的左上角 (0, pltHeight)
+60 -43
View File
@@ -2,17 +2,16 @@ import { customElement, noShadowDOM } from "solid-element";
import { Show, onCleanup } from "solid-js";
import { resolvePath } from "../utils/path";
import { v4 as uuidv4 } from "uuid";
import { createDeckStore } from "./hooks/deckStore";
import { createDeckStore, DECK_DEFAULTS } from "./hooks/deckStore";
import { registerDeck, unregisterDeck } from "./hooks/deck-registry";
import type { CardShape } from "./types";
import type { CardShape, LayerConfig } from "./types";
import { normalizeDeckConfig, layersToConfigs } from "./config";
import { DeckHeader } from "./DeckHeader";
import { CardList } from "./CardList";
import { DeckContent } from "./DeckContent";
import { EditorTabs } from "./EditorTabs";
import { PrintPreview } from "./PrintPreview";
import {
DataEditorPanel,
LayerEditorPanel,
PropertiesEditorPanel,
} from "./editor-panel";
import { DataEditorPanel } from "./editor-panel";
interface DeckProps {
size?: string;
@@ -70,49 +69,77 @@ customElement<DeckProps>(
const deckId = `deck-${uuidv4()}`;
registerDeck(deckId, store, resolvedSrc, csvPath);
// 读取 data-configyaml role=tag 代码块方式):结构化配置优先
let config:
| ReturnType<typeof normalizeDeckConfig>
| undefined;
const dataConfigAttr = element?.getAttribute("data-config");
if (dataConfigAttr) {
try {
config = normalizeDeckConfig(JSON.parse(dataConfigAttr));
// 记录来源,复制代码时输出 yaml role=tag 代码块
store.actions.setIsYamlBlock(true);
} catch (e) {
console.error("Invalid data-config on md-deck:", e);
}
}
// 解析 size 属性(支持旧格式 "54x86" 和新格式)
if (props.size && props.size.includes("x")) {
if (config?.sizeW !== undefined && config.sizeH !== undefined) {
store.actions.setSizeW(config.sizeW);
store.actions.setSizeH(config.sizeH);
} else if (props.size && props.size.includes("x")) {
const [w, h] = props.size.split("x").map(Number);
store.actions.setSizeW(w);
store.actions.setSizeH(h);
} else {
store.actions.setSizeW(props.sizeW ?? 54);
store.actions.setSizeH(props.sizeH ?? 86);
store.actions.setSizeW(props.sizeW ?? DECK_DEFAULTS.SIZE_W);
store.actions.setSizeH(props.sizeH ?? DECK_DEFAULTS.SIZE_H);
}
// 解析 grid 属性(支持旧格式 "5x8" 和新格式)
if (props.grid && props.grid.includes("x")) {
if (config?.gridW !== undefined && config.gridH !== undefined) {
store.actions.setGridW(config.gridW);
store.actions.setGridH(config.gridH);
} else if (props.grid && props.grid.includes("x")) {
const [w, h] = props.grid.split("x").map(Number);
store.actions.setGridW(w);
store.actions.setGridH(h);
} else {
store.actions.setGridW(props.gridW ?? 5);
store.actions.setGridH(props.gridH ?? 8);
store.actions.setGridW(props.gridW ?? DECK_DEFAULTS.GRID_W);
store.actions.setGridH(props.gridH ?? DECK_DEFAULTS.GRID_H);
}
// 解析 bleed 和 padding(支持旧字符串格式和新数字格式)
if (typeof props.bleed === "string") {
if (config?.bleed !== undefined) {
store.actions.setBleed(config.bleed);
} else if (typeof props.bleed === "string") {
store.actions.setBleed(Number(props.bleed));
} else {
store.actions.setBleed(props.bleed ?? 1);
store.actions.setBleed(props.bleed ?? DECK_DEFAULTS.BLEED);
}
if (typeof props.padding === "string") {
if (config?.padding !== undefined) {
store.actions.setPadding(config.padding);
} else if (typeof props.padding === "string") {
store.actions.setPadding(Number(props.padding));
} else {
store.actions.setPadding(props.padding ?? 2);
store.actions.setPadding(props.padding ?? DECK_DEFAULTS.PADDING);
}
// 设置形状
store.actions.setShape(props.shape ?? "rectangle");
store.actions.setShape(config?.shape ?? props.shape ?? "rectangle");
// 确定前后图层(data-config 优先,回退旧 layers 字符串)
const frontLayers: LayerConfig[] = config
? config.frontLayers
: layersToConfigs((props.layers as string) || "");
const backLayers: LayerConfig[] = config
? config.backLayers
: layersToConfigs((props.backLayers as string) || "");
// 加载 CSV 数据
store.actions.loadCardsFromPath(
resolvedSrc,
csvPath,
(props.layers as string) || "",
(props.backLayers as string) || "",
);
store.actions.loadCardsFromPath(resolvedSrc, csvPath, frontLayers, backLayers);
// 清理函数
onCleanup(() => {
@@ -139,29 +166,19 @@ customElement<DeckProps>(
</Show>
<div class="flex gap-4">
{/* 内容区域:错误/加载/卡牌预览/空状态 */}
{/* 左侧:CSV 数据编辑 */}
{/*<Show when={store.state.isEditing && !store.state.fixed}>*/}
{/* <DataEditorPanel*/}
{/* activeTab={store.state.activeTab}*/}
{/* cards={store.state.cards}*/}
{/* updateCardData={store.actions.updateCardData}*/}
{/* />*/}
{/*</Show>*/}
<Show when={store.state.isEditing && !store.state.fixed}>
<div class="flex-1">
<PropertiesEditorPanel store={store} />
</div>
{/* 左侧:卡牌列表导航 */}
<Show when={store.state.cards.length > 0 && !store.state.error}>
<CardList store={store} />
</Show>
{/* 中间:内容区域(错误/加载/卡牌预览/空状态) */}
<div class="flex-1 min-w-0">
<DeckContent store={store} isLoading={store.state.isLoading} />
{/* 右侧:属性/图层编辑面板 */}
<Show when={store.state.isEditing && !store.state.fixed}>
<div class="flex-1">
<LayerEditorPanel store={store} />
</div>
{/* 右侧:属性/图层编辑面板(标签页切换) */}
<Show when={store.state.isEditing && !store.state.fixed}>
<EditorTabs store={store} />
</Show>
</div>
</div>
+26 -4
View File
@@ -4,21 +4,43 @@ export interface CardData {
export type CardSide = "front" | "back";
/**
* Layer alignment: horizontal (l/c/r) optionally combined with vertical
* (t/b). "tl" = top-left, "br" = bottom-right, "tc" = top-center,
* "bc" = bottom-center, etc. Plain "l"/"c"/"r" means vertically centered.
*/
export type Align =
| "l"
| "c"
| "r"
| "tl"
| "tc"
| "tr"
| "bl"
| "bc"
| "br";
export type { CardShape } from "../../plotcutter/contour";
export interface Layer {
prop: string;
/** CSV column the layer reads, when it renders a prop value. */
prop?: string;
/** Markdown template (with {{var}} substitution) when the layer renders a template. */
template?: string;
x1: number;
y1: number;
x2: number;
y2: number;
orientation?: "n" | "s" | "e" | "w";
fontSize?: number;
align?: "l" | "c" | "r";
align?: Align;
}
export interface LayerConfig {
prop: string;
/** CSV column the layer reads, when it renders a prop value. */
prop?: string;
/** Markdown template (with {{var}} substitution) when the layer renders a template. */
template?: string;
visible: boolean;
x1: number;
y1: number;
@@ -26,7 +48,7 @@ export interface LayerConfig {
y2: number;
orientation?: "n" | "s" | "e" | "w";
fontSize?: number;
align?: "l" | "c" | "r";
align?: Align;
_key?: number;
}
+2 -2
View File
@@ -42,7 +42,7 @@ customElement("md-embed", { headingBase: 0 }, (props, { element }) => {
});
return (
<div class="md-embed not-prose">
<div class="md-embed">
<Show when={content.loading}>
<div class="text-gray-400 italic">...</div>
</Show>
@@ -54,7 +54,7 @@ customElement("md-embed", { headingBase: 0 }, (props, { element }) => {
<Show when={!content.loading && !content.error && content()}>
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: markdown render */}
<div
class="prose"
class="prose text-black prose-sm"
innerHTML={parseMarkdown(content()!, resolvedPath)}
/>
</Show>
+80 -40
View File
@@ -1,9 +1,20 @@
import { customElement, noShadowDOM } from 'solid-element';
import { createSignal, For, Show, createEffect, createMemo, createResource } from 'solid-js';
import { marked } from '../markdown';
import { loadCSV, CSV, processVariables, isCSV } from './utils/csv-loader';
import { resolvePath } from './utils/path';
import { areAllLabelsNumeric, weightedRandomIndex } from './utils/weighted-random';
import { customElement, noShadowDOM } from "solid-element";
import {
createSignal,
For,
Show,
createEffect,
createMemo,
createResource,
} from "solid-js";
import { parseMarkdown } from "../markdown";
import { parseCSVString, CSV, processVariables } from "./utils/csv-loader";
import { resolveContentRef } from "./utils/resolve-content";
import { parseSparkTableCsv } from "./utils/spark-table";
import {
areAllLabelsNumeric,
weightedRandomIndex,
} from "./utils/weighted-random";;
export interface TableProps {
roll?: boolean;
@@ -16,45 +27,63 @@ interface TableRow {
[key: string]: string;
}
customElement('md-table', { roll: false, remix: false }, (props, { element }) => {
customElement(
"md-table",
{ roll: false, remix: false },
(props, { element }) => {
noShadowDOM();
const [rows, setRows] = createSignal<CSV<TableRow>>([] as unknown as CSV<TableRow>);
const [rows, setRows] = createSignal<CSV<TableRow>>(
[] as unknown as CSV<TableRow>,
);
const [activeTab, setActiveTab] = createSignal(0);
const [activeGroup, setActiveGroup] = createSignal<string | null>(null);
const [bodyHtml, setBodyHtml] = createSignal('');
const [bodyHtml, setBodyHtml] = createSignal("");
let tabsContainer: HTMLDivElement | undefined;
// 从 element 的 textContent 获取 CSV 路径或 inline CSV 数据
const rawContent = element?.textContent?.trim() || '';
const rawContent = element?.textContent?.trim() || "";
// 隐藏原始文本内容
if (element) {
element.textContent = '';
element.textContent = "";
}
// 从父节点 article 的 data-src 获取当前 markdown 文件完整路径
const articleEl = element?.closest('article[data-src]');
const articlePath = articleEl?.getAttribute('data-src') || '';
const articleEl = element?.closest("article[data-src]");
const articlePath = articleEl?.getAttribute("data-src") || "";
// 如果是 inline CSV直接使用否则解析相对路径
const contentOrPath = isCSV(rawContent) ? rawContent : resolvePath(articlePath, rawContent);
// 解析引用:inline CSV 直接使用否则通过 registry 解析(含内联内容 id
const [csvData] = createResource(
() => ({ ref: rawContent, docPath: articlePath }),
async ({ ref, docPath }) => {
const content = await resolveContentRef(ref, docPath);
if (content === null) {
throw new Error(`Failed to resolve table content: "${ref}"`);
}
return content;
},
);
// 使用 createResource 加载 CSV,自动响应路径变化并避免重复加载
const [csvData] = createResource(() => contentOrPath, loadCSV);
// 当数据加载完成后更新 rows
// 当数据加载完成后更新 rows,并为火花表设置 data-spark(供 RevealManager
// 悬停触发 /roll)。data-spark 在渲染时派生,不在扫描时注入。
createEffect(() => {
const data = csvData();
if (data) {
// 将加载的数据赋值给 rowsCSV 类型已经包含 sourcePath 等属性
setRows(data as unknown as CSV<TableRow>);
const content = csvData();
if (!content) return;
setRows(parseCSVString(content) as unknown as CSV<TableRow>);
if (element) {
const meta = parseSparkTableCsv(content);
if (meta) {
element.setAttribute("data-spark", meta.slug);
} else {
element.removeAttribute("data-spark");
}
}
});
// 检测是否有 group 列
const hasGroup = createMemo(() => {
const allRows = rows();
return allRows.length > 0 && 'group' in allRows[0];
return allRows.length > 0 && "group" in allRows[0];
});
// 获取所有分组
@@ -75,13 +104,16 @@ customElement('md-table', { roll: false, remix: false }, (props, { element }) =>
const allRows = rows();
const group = activeGroup();
if (!group) return allRows;
return allRows.filter(row => row.group === group);
return allRows.filter((row) => row.group === group);
});
// 处理 body 内容中的 {{prop}} 语法并解析 markdown
const processBody = (body: string, currentRow: TableRow): string => {
// 使用 marked 解析 markdown
return marked.parse(processVariables(body, currentRow, rows(), filteredRows(), props.remix)) as string;
// 使用 parseMarkdown 统一入口(设置图标 base path 等)
return parseMarkdown(
processVariables(body, currentRow, rows(), filteredRows(), props.remix),
articlePath,
);
};
// 更新 body 内容
@@ -113,7 +145,7 @@ customElement('md-table', { roll: false, remix: false }, (props, { element }) =>
if (filtered.length === 0) return;
// 检查所有 label 是否都是整数/整数范围格式
const labels = filtered.map(row => row.label);
const labels = filtered.map((row) => row.label);
if (areAllLabelsNumeric(labels)) {
// 使用加权随机
const randomIndex = weightedRandomIndex(labels);
@@ -128,8 +160,12 @@ customElement('md-table', { roll: false, remix: false }, (props, { element }) =>
// 滚动到可视区域
setTimeout(() => {
const activeButton = tabsContainer?.querySelector('.border-blue-600');
activeButton?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
const activeButton = tabsContainer?.querySelector(".border-blue-600");
activeButton?.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "nearest",
});
}, 50);
};
@@ -144,8 +180,8 @@ customElement('md-table', { roll: false, remix: false }, (props, { element }) =>
onClick={() => handleGroupChange(null)}
class={`font-medium transition-colors ${
activeGroup() === null
? 'text-blue-600 border-b-2 border-blue-600'
: 'text-gray-500 hover:text-gray-700'
? "text-blue-600 border-b-2 border-blue-600"
: "text-gray-500 hover:text-gray-700"
}`}
>
@@ -156,8 +192,8 @@ customElement('md-table', { roll: false, remix: false }, (props, { element }) =>
onClick={() => handleGroupChange(group)}
class={`font-medium transition-colors ${
activeGroup() === group
? 'text-blue-600 border-b-2 border-blue-600'
: 'text-gray-500 hover:text-gray-700'
? "text-blue-600 border-b-2 border-blue-600"
: "text-gray-500 hover:text-gray-700"
}`}
>
{group}
@@ -171,21 +207,24 @@ customElement('md-table', { roll: false, remix: false }, (props, { element }) =>
<Show when={props.roll}>
<button
onClick={handleRoll}
class="text-gray-500 hover:text-gray-700 flex-shrink-0 cursor-pointer"
class="text-gray-500 hover:text-gray-700 shrink-0 cursor-pointer"
title="随机切换"
>
🎲
</button>
</Show>
<div ref={tabsContainer} class="flex gap-1 overflow-x-auto flex-1 min-w-0">
<div
ref={tabsContainer}
class="flex gap-1 overflow-x-auto flex-1 min-w-0"
>
<For each={filteredRows()}>
{(row, index) => (
<button
onClick={() => setActiveTab(index())}
class={`font-medium transition-colors flex-shrink-0 min-w-[1.6em] cursor-pointer ${
class={`font-medium transition-colors shrink-0 min-w-[1.6em] cursor-pointer ${
activeTab() === index()
? 'text-blue-600 border-b-2 border-blue-600'
: 'text-gray-500 hover:text-gray-700'
? "text-blue-600 border-b-2 border-blue-600"
: "text-gray-500 hover:text-gray-700"
}`}
>
{row.label}
@@ -203,4 +242,5 @@ customElement('md-table', { roll: false, remix: false }, (props, { element }) =>
</div>
</div>
);
});
},
);
+219 -389
View File
@@ -1,20 +1,22 @@
/**
* Journal Stream Client Store
* Journal Stream Client Store (Worker Proxy)
*
* Reactive state for a single session's message stream. Manages MQTT
* connection, local message log, per-sender sequence tracking, and the
* revealed-paths set (populated by the link reducer).
* This is the main-thread side of the journal stream. It spawns a Shared
* Worker that owns the MQTT connection. All state flows from the worker
* to this store via Comlink callbacks.
*
* Session lifecycle (create/list/delete) is handled via MQTT retained
* topics ttrpg/$SESSIONS for the manifest and ttrpg/{id}/meta per session.
*
* No persistence here that's the CLI server's job via JSONL append.
* The public API is unchanged components still call `useJournalStream()`,
* `sendMessage()`, etc. exactly as before.
*/
import { createStore, produce } from "solid-js/store";
import { createSignal } from "solid-js";
import * as Comlink from "comlink";
import type { StreamMessage } from "../journal/registry";
import { getMessageType, validatePayload } from "../journal/registry";
import { rebuildReactivityFromStore } from "../journal/var-reactivity";
import type { WorkerState, WorkerPatch, SessionManifest } from "../../workers/journal.worker";
import type { JournalWorkerAPI } from "../../workers/journal.worker";
import {
loadPersisted,
saveName,
@@ -32,39 +34,18 @@ import {
export interface JournalStreamState {
sessionId: string | null;
/** Human-readable name for the current session (from manifest) */
sessionName: string | null;
/** Full message log, oldest-first */
messages: StreamMessage[];
/** Last sequence number per sender */
senderSeq: Record<string, number>;
/**
* Paths and sections revealed by link messages.
* Key: normalized path (no .md). Value: set of revealed section slugs.
* An empty set means the whole article is revealed.
* Populated during hydration and live receipt via the type's reducer.
*/
revealedPaths: Record<string, Set<string>>;
/** MQTT connection status */
connected: boolean;
/** Granular connection state for UI indicators */
connectionStatus: "disconnected" | "connecting" | "connected" | "error";
/** Last connection error message, if any */
connectionError: string | null;
/** This client's identity */
myName: string;
/** Role: gm | player | observer. Immutable while connected. */
myRole: "gm" | "player" | "observer";
/** Broker URL, set after connect */
brokerUrl: string | null;
/** Active player list (keyed by player name) */
players: Record<string, { role: string }>;
/**
* Stat values set via /stat set/del/roll commands.
* Key: stat key (e.g. "strength", "alice:hp"). Value: string.
* Populated during hydration and live receipt via the stat type's reducer.
*/
stats: Record<string, string>;
variables: Record<string, string>;
}
export interface SessionMeta {
@@ -73,8 +54,24 @@ export interface SessionMeta {
players: string[];
}
export interface SessionManifest {
sessions: Record<string, SessionMeta>;
export type { SessionManifest } from "../../workers/journal.worker";
// ---------------------------------------------------------------------------
// Worker connection
// ---------------------------------------------------------------------------
let _workerAPI: Comlink.Remote<JournalWorkerAPI> | null = null;
function getWorkerAPI(): Comlink.Remote<JournalWorkerAPI> {
if (!_workerAPI) {
const worker = new SharedWorker(
new URL("../../workers/journal.worker.ts", import.meta.url),
);
_workerAPI = Comlink.wrap<JournalWorkerAPI>(worker.port);
worker.port.start();
}
return _workerAPI;
}
// ---------------------------------------------------------------------------
@@ -84,7 +81,6 @@ export interface SessionManifest {
const persisted = loadPersisted();
const urlParams = readUrlParams();
// URL params override localStorage if present
const initialName = urlParams.playerName ?? persisted.myName;
const initialSession = urlParams.sessionId ?? persisted.lastSessionId;
@@ -101,10 +97,9 @@ const [state, setState] = createStore<JournalStreamState>({
myRole: (persisted.myRole as "gm" | "player" | "observer") || "gm",
brokerUrl: persisted.brokerUrl,
players: {},
stats: {},
variables: {},
});
// Sync initial URL params if they came from localStorage (not URL)
if (initialName && !urlParams.playerName) syncUrlParam("player", initialName);
if (initialSession && !urlParams.sessionId)
syncUrlParam("session", initialSession);
@@ -117,56 +112,10 @@ const [sessionList, setSessionList] = createSignal<SessionManifest>({
export { sessionList as sessions };
/**
* Change the current player's name. Persisted to localStorage so it
* survives page reloads. Also syncs to URL search param.
*/
export function setMyName(name: string): void {
setState("myName", name);
saveName(name);
syncUrlParam("player", name);
}
/**
* Change the current player's role. Persisted to localStorage.
* Only callable when disconnected.
*/
export function setMyRole(role: "gm" | "player" | "observer"): void {
setState("myRole", role);
saveRole(role);
}
/**
* Set the active session ID and sync to URL. Also resolves the human-readable
* session name from the cached manifest.
*/
export function setSessionId(id: string | null): void {
setState("sessionId", id);
if (id) {
saveSessionId(id);
syncUrlParam("session", id);
// Resolve session name from current manifest
const manifest = sessionList();
const name = manifest.sessions[id]?.name ?? null;
setState("sessionName", name);
} else {
removeUrlParam("session");
setState("sessionName", null);
}
}
// Will hold the MQTT client instance after connect()
let _mqttClient: import("mqtt").MqttClient | null = null;
let _mqttConnected = false;
// ---------------------------------------------------------------------------
// Helpers
// Reducer runner — runs locally in each tab
// ---------------------------------------------------------------------------
function makeMessageId(sender: string, seq: number): string {
return `${sender}-${seq}`;
}
function runReducer(msg: StreamMessage): void {
const def = getMessageType(msg.type);
if (def?.reducer) {
@@ -174,258 +123,195 @@ function runReducer(msg: StreamMessage): void {
}
}
const $SESSIONS = "ttrpg/$SESSIONS";
// ---------------------------------------------------------------------------
// Hydration (initial load from server)
// ---------------------------------------------------------------------------
/**
* Load the full message history from the static server's JSONL file.
* The file is served from the same HTTP origin as the web app.
* Runs all reducers in order.
*/
export async function hydrateFromServer(sessionId: string): Promise<void> {
const response = await fetch(
`/.ttrpg/sessions/${encodeURIComponent(sessionId)}/stream.jsonl`,
);
if (!response.ok) {
if (response.status === 404) {
return; // fresh session, no file yet
}
throw new Error(`Failed to load session: ${response.statusText}`);
}
const text = await response.text();
const lines = text.split("\n").filter((l) => l.trim());
const messages: StreamMessage[] = [];
const senderSeq: Record<string, number> = {};
for (const line of lines) {
try {
const msg: StreamMessage = JSON.parse(line);
messages.push(msg);
senderSeq[msg.sender] = Math.max(senderSeq[msg.sender] ?? 0, msg.seq);
/** Replay all messages through reducers to rebuild derived state. */
function replayReducers(): void {
// Reset derived state
setState("revealedPaths", {});
setState("variables", {});
for (const msg of state.messages) {
runReducer(msg);
} catch {
/* skip corrupt */
}
}
// ---------------------------------------------------------------------------
// Worker patch handler
// ---------------------------------------------------------------------------
function handlePatch(patch: WorkerPatch): void {
switch (patch.type) {
case "fullState": {
setState(
produce((s) => {
s.messages = messages;
s.senderSeq = senderSeq;
s.sessionId = sessionId;
s.sessionId = patch.state.sessionId;
s.sessionName = patch.state.sessionName;
s.messages = patch.state.messages;
s.senderSeq = patch.state.senderSeq;
s.connected = patch.state.connected;
s.connectionStatus = patch.state.connectionStatus;
s.connectionError = patch.state.connectionError;
s.myName = patch.state.myName;
s.myRole = patch.state.myRole;
s.brokerUrl = patch.state.brokerUrl;
s.players = patch.state.players;
}),
);
// Persist name/role so URL and localStorage stay in sync
if (patch.state.myName) {
saveName(patch.state.myName);
syncUrlParam("player", patch.state.myName);
}
saveRole(patch.state.myRole);
// Rebuild derived state (revealedPaths, variables) by replaying reducers
replayReducers();
// Rebuild local reactivity engine state so tag activations from
// hydrated variables are tracked for subsequent cascade computations.
rebuildReactivityFromStore(state.variables);
break;
}
case "message": {
const msg = patch.message;
const existingIdx = state.messages.findIndex((m) => m.id === msg.id);
if (existingIdx !== -1) {
setState("messages", existingIdx, msg);
} else {
setState(
produce((s) => {
s.messages.push(msg);
s.senderSeq[msg.sender] = Math.max(
s.senderSeq[msg.sender] ?? 0,
msg.seq,
);
}),
);
}
runReducer(msg);
break;
}
case "connectionStatus": {
setState("connectionStatus", patch.status);
if (patch.error !== undefined) {
setState("connectionError", patch.error);
}
if (patch.status === "connected") {
setState("connected", true);
} else if (patch.status === "disconnected") {
setState("connected", false);
}
break;
}
case "players": {
setState("players", patch.players);
break;
}
case "sessionName": {
setState("sessionName", patch.name);
break;
}
case "sessionList": {
setSessionList(patch.sessions);
break;
}
}
}
// ---------------------------------------------------------------------------
// Subscribe to worker (called once per tab)
// ---------------------------------------------------------------------------
let _subscribed = false;
async function ensureSubscribed(): Promise<void> {
if (_subscribed) return;
_subscribed = true;
const api = getWorkerAPI();
await api.subscribe(
Comlink.proxy((patch: WorkerPatch) => {
handlePatch(patch);
}),
);
}
// Start subscription eagerly
ensureSubscribed();
// ---------------------------------------------------------------------------
// MQTT Connect
// Public API — same signatures as before
// ---------------------------------------------------------------------------
/**
* Connect to the MQTT broker, subscribe to the session stream, session
* list, and session meta. Must be called after `hydrateFromServer`.
*/
export function setMyName(name: string): void {
setState("myName", name);
saveName(name);
syncUrlParam("player", name);
}
export function setMyRole(role: "gm" | "player" | "observer"): void {
setState("myRole", role);
saveRole(role);
}
export function setSessionId(id: string | null): void {
setState("sessionId", id);
if (id) {
saveSessionId(id);
syncUrlParam("session", id);
} else {
removeUrlParam("session");
setState("sessionName", null);
}
}
export async function connectStream(
sessionId: string,
brokerUrl: string,
): Promise<void> {
const { default: mqtt } = await import("mqtt");
const api = getWorkerAPI();
setState("connectionStatus", "connecting");
setState("connectionError", null);
return new Promise<void>((resolve, reject) => {
const client = mqtt.connect(brokerUrl, {
clientId: `${state.myName}-${state.myRole}-${Date.now()}`,
protocol: brokerUrl.startsWith("wss") ? "wss" : "ws",
reconnectPeriod: 2000,
});
_mqttClient = client;
client.on("connect", () => {
_mqttConnected = true;
setState("connected", true);
setState("connectionStatus", "connected");
setState("connectionError", null);
setState("brokerUrl", brokerUrl);
// Persist connection info for next time
try {
await api.connect(sessionId, brokerUrl, state.myName, state.myRole);
saveBrokerUrl(brokerUrl);
saveSessionId(sessionId);
client.subscribe(`ttrpg/${sessionId}/stream`, { qos: 1 }, (err) => {
if (err) console.error("[stream] stream sub err:", err);
});
client.subscribe($SESSIONS, { qos: 1 }, (err) => {
if (err) console.error("[stream] sessions sub err:", err);
});
client.subscribe(`ttrpg/${sessionId}/meta`, { qos: 1 });
// Presence tracking
client.subscribe(`ttrpg/${sessionId}/presence/+`, { qos: 1 });
// Publish own presence (retained)
const presenceData = JSON.stringify({
name: state.myName,
role: state.myRole,
});
client.publish(
`ttrpg/${sessionId}/presence/${state.myName}`,
presenceData,
{ qos: 1, retain: true },
);
resolve();
});
client.on("error", (err) => {
console.error("[stream] mqtt error:", err);
} catch (err) {
setState("connectionStatus", "error");
setState("connectionError", err.message);
reject(err);
});
client.on("close", () => {
_mqttConnected = false;
setState("connected", false);
setState("connectionStatus", "disconnected");
});
client.on("message", (topic, payload) => {
const raw = payload.toString();
const parts = topic.split("/");
if (topic === $SESSIONS) {
// Session manifest update
try {
const manifest: SessionManifest = JSON.parse(raw);
setSessionList(manifest);
// Refresh the sessionName if we're in a session now
const currentId = state.sessionId;
if (currentId && manifest.sessions[currentId]) {
setState("sessionName", manifest.sessions[currentId].name);
}
} catch (e) {
console.error("[stream] manifest parse err:", e);
}
return;
}
if (parts.length >= 3 && parts[0] === "ttrpg" && parts[2] === "meta") {
// Session meta update — manifest will be republished by server,
// handled via $SESSIONS subscription above.
return;
}
// Presence: ttrpg/{sessionId}/presence/{playerName}
if (
parts.length >= 4 &&
parts[0] === "ttrpg" &&
parts[2] === "presence"
) {
const playerName = parts[3];
if (raw) {
try {
const presence = JSON.parse(raw);
setState("players", playerName, {
role: presence.role || "player",
});
} catch {
setState("players", playerName, { role: "player" });
}
} else {
// Tombstone — player disconnected
setState(
produce((s) => {
delete s.players[playerName];
}),
"connectionError",
err instanceof Error ? err.message : "Connection failed",
);
}
return;
}
if (parts.length >= 3 && parts[0] === "ttrpg" && parts[2] === "stream") {
try {
const msg: StreamMessage = JSON.parse(raw);
receiveMessage(msg);
} catch (e) {
console.error("[stream] malformed message:", e);
throw err;
}
}
});
});
}
// ---------------------------------------------------------------------------
// Session lifecycle
// ---------------------------------------------------------------------------
/**
* Create a new session by publishing retained metadata to its meta topic.
* The server picks it up and adds it to the $SESSIONS manifest.
*/
export function createSession(name: string, players: string[] = []): void {
if (!_mqttClient || !_mqttConnected) return;
const id =
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "") || "session";
const meta: SessionMeta = { name, created: Date.now(), players };
_mqttClient.publish(`ttrpg/${id}/meta`, JSON.stringify(meta), {
qos: 1,
retain: true,
});
}
/**
* Delete a session: publish tombstone (empty payload) to its meta topic.
*/
export function deleteSession(sessionId: string): void {
if (!_mqttClient || !_mqttConnected) return;
_mqttClient.publish(`ttrpg/${sessionId}/meta`, "", { qos: 1, retain: true });
}
// ---------------------------------------------------------------------------
// Send
// ---------------------------------------------------------------------------
/**
* Publish a message to the stream. Validates the payload against the
* registered Zod schema before sending. Auto-increments the sender's seq.
*/
export function sendMessage<T>(
type: string,
payload: T,
):
{ success: true; msg: StreamMessage<T> } | { success: false; error: string } {
if (!_mqttClient || !_mqttConnected) {
return { success: false, error: "Not connected to stream" };
}
const sessionId = state.sessionId;
if (!sessionId) {
return { success: false, error: "No active session" };
}
| { success: true; msg: StreamMessage<T> }
| { success: false; error: string } {
const api = getWorkerAPI();
// Validate locally first
const validation = validatePayload(type, payload);
if (!validation.success) {
return { success: false, error: validation.error };
}
// Send to worker (which publishes to MQTT)
// The worker will broadcast back via the message patch
const result = api.sendMessage(type, validation.data);
// We can't await the proxy result synchronously, so we optimistically
// return success. The actual message will arrive via the patch callback.
// For the sync API compatibility, we construct a placeholder.
const sender = state.myName;
const seq = (state.senderSeq[sender] ?? 0) + 1;
const id = makeMessageId(sender, seq);
const id = `${sender}-${seq}`;
const msg: StreamMessage<T> = {
id,
@@ -437,116 +323,52 @@ export function sendMessage<T>(
reverted: false,
};
const topic = `ttrpg/${sessionId}/stream`;
_mqttClient.publish(topic, JSON.stringify(msg), { qos: 1 }, (err) => {
if (err) console.error("[stream] publish error:", err);
});
// Optimistic local insert
receiveMessage(msg as StreamMessage);
return { success: true, msg };
}
// ---------------------------------------------------------------------------
// Receive
// ---------------------------------------------------------------------------
function receiveMessage(msg: StreamMessage): void {
const existing = state.messages.find((m) => m.id === msg.id);
if (existing) {
setState(
produce((s) => {
const idx = s.messages.findIndex((m) => m.id === msg.id);
if (idx !== -1) s.messages[idx] = msg;
}),
);
return;
}
setState(
produce((s) => {
s.messages.push(msg);
s.senderSeq[msg.sender] = Math.max(s.senderSeq[msg.sender] ?? 0, msg.seq);
}),
);
runReducer(msg);
}
// ---------------------------------------------------------------------------
// Revert
// ---------------------------------------------------------------------------
/**
* Revert the current sender's latest (highest seq) message.
* Only works if it's still the latest a subsequent message locks it.
*/
export function revertLatest():
{ success: true } | { success: false; error: string } {
if (!_mqttClient || !_mqttConnected) {
return { success: false, error: "Not connected to stream" };
| { success: true }
| { success: false; error: string } {
const api = getWorkerAPI();
return api.revertLatest() as unknown as
| { success: true }
| { success: false; error: string };
}
const sessionId = state.sessionId;
if (!sessionId) return { success: false, error: "No active session" };
const sender = state.myName;
const latestSeq = state.senderSeq[sender];
if (!latestSeq) return { success: false, error: "No messages to revert" };
const id = makeMessageId(sender, latestSeq);
const original = state.messages.find((m) => m.id === id);
if (!original) return { success: false, error: "Message not found" };
if (original.reverted) return { success: false, error: "Already reverted" };
const reverted: StreamMessage = { ...original, reverted: true };
const topic = `ttrpg/${sessionId}/stream`;
_mqttClient.publish(topic, JSON.stringify(reverted), { qos: 1 }, (err) => {
if (err) console.error("[stream] revert publish error:", err);
});
return { success: true };
}
// ---------------------------------------------------------------------------
// Disconnect
// ---------------------------------------------------------------------------
export function disconnectStream(): void {
if (_mqttClient) {
// Clear our presence before disconnecting (tombstone)
const sessionId = state.sessionId;
if (sessionId) {
_mqttClient.publish(`ttrpg/${sessionId}/presence/${state.myName}`, "", {
qos: 1,
retain: true,
});
}
// Force disconnect without reconnect
_mqttClient.end(true, void 0, () => {
// noop
});
_mqttClient = null;
_mqttConnected = false;
}
setState("connected", false);
setState("connectionStatus", "disconnected");
setState("players", {});
// Strip autojoin param so the dialog shows normally on next connect
const api = getWorkerAPI();
api.disconnect();
removeUrlParam("autojoin");
}
// ---------------------------------------------------------------------------
// Derived / helpers
// ---------------------------------------------------------------------------
export function createSession(
name: string,
players: string[] = [],
): string | null {
const api = getWorkerAPI();
// This is sync in the worker but async over Comlink.
// We generate the ID locally using the same algorithm.
const id =
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "") || "session";
api.createSession(name, players);
return id;
}
export function deleteSession(sessionId: string): void {
const api = getWorkerAPI();
api.deleteSession(sessionId);
}
export function canRevert(): boolean {
const sender = state.myName;
const seq = state.senderSeq[sender];
if (!seq) return false;
const msg = state.messages.find((m) => m.id === makeMessageId(sender, seq));
const msg = state.messages.find(
(m) => m.id === `${sender}-${seq}`,
);
return msg !== undefined && !msg.reverted;
}
@@ -559,3 +381,11 @@ export function useJournalStream() {
}
export { state as journalStreamState };
/**
* Hydrate from server now handled by the worker during connect().
* Kept for API compatibility; does nothing.
*/
export async function hydrateFromServer(_sessionId: string): Promise<void> {
// Hydration is now handled by the worker during connect()
}
+56
View File
@@ -0,0 +1,56 @@
/**
* useHeadingFlash watches location.hash and applies a fading highlight
* animation to the target heading element on every hash change.
*
* Handles page refresh / initial load by retrying up to ~500ms until the
* target element exists in the DOM.
*/
import { createEffect, onCleanup } from "solid-js";
import { useLocation } from "@solidjs/router";
const FLASH_CLASS = "heading-flash";
const MAX_RETRIES = 10;
const RETRY_INTERVAL = 50;
function flashElement(el: HTMLElement) {
// Re-trigger the animation: remove and re-add the class
el.classList.remove(FLASH_CLASS);
void el.offsetWidth; // force reflow
el.classList.add(FLASH_CLASS);
}
export function useHeadingFlash() {
const location = useLocation();
createEffect(() => {
const hash = location.hash;
if (!hash) return;
const id = decodeURIComponent(hash.startsWith("#") ? hash.slice(1) : hash);
if (!id) return;
let retries = 0;
let timer: ReturnType<typeof setTimeout>;
const tryFlash = () => {
const el = document.getElementById(id);
if (el) {
flashElement(el);
el.addEventListener("animationend", function onEnd() {
el.classList.remove(FLASH_CLASS);
el.removeEventListener("animationend", onEnd);
}, { once: true });
} else if (retries < MAX_RETRIES) {
retries++;
timer = setTimeout(tryFlash, RETRY_INTERVAL);
}
};
// Delay slightly so Solid has a chance to flush DOM updates from
// a concurrent SPA navigation before we look for the element.
timer = setTimeout(tryFlash, 0);
onCleanup(() => clearTimeout(timer));
});
}
+4 -46
View File
@@ -31,42 +31,6 @@ function parseFrontMatter(content: string): { frontmatter?: JSONObject; remainin
}
}
/**
* CSV
* @param str
* @returns CSV true
*/
export function isCSV(str: string): boolean {
const trimmed = str.trim();
// 检查是否以 YAML front matter 开头
if (trimmed.startsWith('---\n') || trimmed.startsWith('---\r\n')) {
return true;
}
// 检查是否包含 CSV 特征:多行且有分隔符
const lines = trimmed.split(/\r?\n/).filter(line => line.trim() !== '');
if (lines.length < 2) {
return false;
}
// 检测常见 CSV 分隔符
const separators = [',', '\t', ';', '|'];
const firstLine = lines[0];
for (const sep of separators) {
if (firstLine.includes(sep)) {
// 检查其他行是否也有相同的分隔符
const hasSeparatorInOtherLines = lines.slice(1).some(line => line.includes(sep));
if (hasSeparatorInOtherLines) {
return true;
}
}
}
return false;
}
/**
* CSV
* @template T Record<string, string>
@@ -102,18 +66,12 @@ export function parseCSVString<T = Record<string, string>>(csvString: string, so
/**
* CSV
* @template T Record<string, string>
* @param pathOrContent inline CSV
* @param path file-index
* @returns CSV
*/
export async function loadCSV<T = Record<string, string>>(pathOrContent: string): Promise<CSV<T>> {
// 检测是否是 inline CSV 数据
if (isCSV(pathOrContent)) {
return parseCSVString<T>(pathOrContent, 'inline');
}
// 从索引获取文件内容
const content = await getIndexedData(pathOrContent);
return parseCSVString<T>(content, pathOrContent);
export async function loadCSVFromPath<T = Record<string, string>>(path: string): Promise<CSV<T>> {
const content = await getIndexedData(path);
return parseCSVString<T>(content, path);
}
type JSONData = JSONArray | JSONObject | string | number | boolean | null;
+36
View File
@@ -0,0 +1,36 @@
import { resolveContent } from "../../cli/content-registry";
import { getRegistry } from "../journal/completions";
import { getIndexedData } from "../../data-loader/file-index";
import { resolvePath } from "./path";
/**
* Resolve a content reference (a directive body) to raw content.
*
* Prefers the shared content-registry resolution, which understands:
* - inline CSV bodies (returned as-is)
* - inline doc content refs like `./csv_abc123` (scoped to the current doc)
* - absolute paths and relative paths resolved against the current doc
*
* Falls back to path resolution + the file index when the registry isn't
* populated yet (e.g. before completions load) or the ref isn't indexed.
*
* Returns `null` when nothing matches.
*/
export async function resolveContentRef(
ref: string,
docPath: string,
): Promise<string | null> {
const trimmed = ref.trim();
if (!trimmed) return null;
const resolved = resolveContent(getRegistry(), docPath, trimmed);
if (resolved !== null) return resolved;
// Fallback: resolve relative path and fetch through the file index.
const path = resolvePath(docPath, trimmed);
try {
return await getIndexedData(path);
} catch {
return null;
}
}
+87 -147
View File
@@ -1,24 +1,26 @@
/**
* Spark Table markdown table where the first column header is a dice
* formula (d6, d20, d100, etc.). Rolling a spark table means:
* Spark Table runtime rolling and CSV parsing.
*
* 1. Roll the dice formula once for each data column (non-dice columns)
* 2. Look up the row whose dice-column value matches each roll
* 3. Return the rolled values keyed by column slug
* A spark table is a CSV whose first column header is a dice formula
* (d6, d20, d100, etc.). Rolling a spark table means:
*
* 1. Roll the dice formula once
* 2. Look up the row whose dice-column value matches the roll
* 3. Return all column values from that row
*
* When `remix` is true, each data column gets its own independent roll
* and may come from different rows.
*/
import Slugger from "github-slugger";
import { parse } from "csv-parse/browser/esm/sync";
import { parseCSVString } from "../utils/csv-loader";
import { rollFormula } from "../md-commander/hooks";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface MarkdownTable {
headers: string[];
rows: string[][];
}
export interface SparkTableColumn {
header: string;
slug: string;
@@ -39,154 +41,62 @@ export interface SparkTableMeta {
notation: string;
/** Concatenated slug of data columns */
slug: string;
/** The header name of the dice column (first column) */
diceHeader: string;
/** Data column headers (excluding dice column) */
dataHeaders: string[];
/** Full list of rows */
rows: string[][];
/** Full list of rows as objects keyed by header */
rows: Record<string, string>[];
}
// ---------------------------------------------------------------------------
// Markdown table parser
// CSV parsing
// ---------------------------------------------------------------------------
// Matches the scanner's `isSparkTableHeader` (content-registry): plain dice,
// counts, and modifiers all count as spark-table first columns.
const DICE_HEADER_RE = /^\d*d\d+(?:[+-]\d+)?$/i;
/**
* Parse all markdown tables from a markdown string.
* Handles both leading/trailing `|` styles and bare styles.
* Parse a CSV string into a SparkTableMeta.
* The CSV must have a dice formula as its first column header.
* Returns null if the CSV is not a valid spark table.
*/
export function parseMarkdownTables(markdown: string): MarkdownTable[] {
const tables: MarkdownTable[] = [];
const lines = markdown.split(/\r?\n/);
export function parseSparkTableCsv(csv: string): SparkTableMeta | null {
// Parse raw headers first — before parseCSVString injects frontmatter keys
// into rows. We use csv-parse directly to get the header order reliably.
const rawParsed = parse(csv, {
columns: false,
comment: "#",
trim: true,
skipEmptyLines: true,
bom: true,
}) as string[][];
for (let i = 0; i < lines.length; i++) {
const headerCells = splitTableRow(lines[i]);
if (!headerCells || headerCells.length < 2) continue;
if (rawParsed.length < 2) return null;
// Peek at the next line — must be a separator row
if (i + 1 >= lines.length) continue;
const sepCells = splitTableRow(lines[i + 1]);
if (!sepCells || sepCells.length < headerCells.length) continue;
if (!sepCells.every((c) => /^:?-{3,}:?$/.test(c))) continue;
const headers = rawParsed[0];
if (headers.length < 2) return null;
if (!DICE_HEADER_RE.test(headers[0])) return null;
// Valid table header + separator — collect body rows
const rows: string[][] = [];
let j = i + 2;
while (j < lines.length) {
const rowCells = splitTableRow(lines[j]);
if (!rowCells) break;
// Allow rows with fewer cells (unfilled trailing columns)
rows.push(rowCells);
j++;
}
// Now parse with csv-loader for full frontmatter + quoting support
const parsed = parseCSVString(csv);
// Only include tables with at least one data row
if (rows.length > 0) {
tables.push({ headers: headerCells, rows });
}
i = j - 1;
}
return tables;
}
/** Split a pipe-delimited table row, stripping optional leading/trailing `|` */
function splitTableRow(line: string): string[] | null {
const trimmed = line.trim();
if (!trimmed.includes("|")) return null;
// Strip optional leading and trailing `|`
let inner = trimmed;
if (inner.startsWith("|")) inner = inner.slice(1);
if (inner.endsWith("|")) inner = inner.slice(0, -1);
return inner.split("|").map((c) => c.trim());
}
// ---------------------------------------------------------------------------
// Spark table detection & metadata
// ---------------------------------------------------------------------------
const DICE_HEADER_RE = /^d\d+$/i;
/** Check whether a table is a spark table (first header is a dice formula) */
export function isSparkTable(table: MarkdownTable): boolean {
if (table.headers.length < 2) return false;
return DICE_HEADER_RE.test(table.headers[0]);
}
/**
* Generate the spark table slug by concatenating slugs of all data column
* headers (excluding the dice column).
*/
export function sparkTableSlug(table: MarkdownTable): string {
const slugger = new Slugger();
return table.headers
.slice(1)
const diceHeader = headers[0];
const dataHeaders = headers.slice(1);
const slug = dataHeaders
.map((h) => slugger.slug(h.toLowerCase()))
.join("-");
}
/**
* Find a spark table in the given markdown content matching `slug`.
* Returns null if not found.
*/
export function findSparkTable(
markdown: string,
slug: string,
): SparkTableMeta | null {
const tables = parseMarkdownTables(markdown);
for (const table of tables) {
if (!isSparkTable(table)) continue;
if (sparkTableSlug(table) === slug) {
return {
notation: table.headers[0],
notation: diceHeader,
slug,
dataHeaders: table.headers.slice(1),
rows: table.rows,
diceHeader,
dataHeaders,
rows: parsed as Record<string, string>[],
};
}
}
return null;
}
/**
* Find a spark table by its combined slug (`pageName-columnSlug`).
* Iterates every spark table in the content, constructs the combined slug,
* and returns the first match. Avoids the need to guess where the page
* name ends and the column slug begins (page names may contain `-`).
*/
export function findSparkTableByCombinedSlug(
markdown: string,
combinedSlug: string,
pageName: string,
): SparkTableMeta | null {
const tables = parseMarkdownTables(markdown);
for (const table of tables) {
if (!isSparkTable(table)) continue;
const colSlug = sparkTableSlug(table);
const candidate = `${pageName}-${colSlug}`;
if (candidate === combinedSlug) {
return {
notation: table.headers[0],
slug: colSlug,
dataHeaders: table.headers.slice(1),
rows: table.rows,
};
}
}
return null;
}
/** Scan all spark tables in a markdown file and return their metadata */
export function scanSparkTables(
markdown: string,
): Omit<SparkTableMeta, "rows">[] {
const tables = parseMarkdownTables(markdown);
return tables.filter(isSparkTable).map((table) => ({
notation: table.headers[0],
slug: sparkTableSlug(table),
dataHeaders: table.headers.slice(1),
}));
}
// ---------------------------------------------------------------------------
// Range parsing
@@ -229,33 +139,63 @@ function matchesCell(diceCell: string, rolledValue: number): boolean {
// Rolling
// ---------------------------------------------------------------------------
export interface RollSparkTableOptions {
/** When true, each data column gets its own independent roll. Default false. */
remix?: boolean;
}
/**
* Roll a spark table: for each data column, roll the dice formula and look
* up the corresponding row value.
* Roll a spark table.
*
* By default, rolls the dice once and reads all columns from the matched row.
* When `remix` is true, each data column gets its own independent roll and
* may come from different rows.
*/
export function rollSparkTable(meta: SparkTableMeta): SparkTableResult {
export function rollSparkTable(
meta: SparkTableMeta,
options: RollSparkTableOptions = {},
): SparkTableResult {
const slugger = new Slugger();
const columns: SparkTableColumn[] = [];
for (let colIdx = 0; colIdx < meta.dataHeaders.length; colIdx++) {
const header = meta.dataHeaders[colIdx];
if (options.remix) {
// Independent roll per column
for (const header of meta.dataHeaders) {
const slug = slugger.slug(header.toLowerCase());
const roll = rollFormula(meta.notation);
const rolledValue = roll.result.total;
// Find the row matching the rolled value
let value = `(no row for ${rolledValue})`;
for (const row of meta.rows) {
const diceCell = row[0] ?? "";
if (matchesCell(diceCell, rolledValue)) {
value = row[colIdx + 1] ?? "";
if (matchesCell(row[meta.diceHeader] ?? "", rolledValue)) {
value = row[header] ?? "";
break;
}
}
columns.push({ header, slug, value });
}
} else {
// Single roll — all columns from the same row
const roll = rollFormula(meta.notation);
const rolledValue = roll.result.total;
let matchedRow: Record<string, string> | null = null;
for (const row of meta.rows) {
if (matchesCell(row[meta.diceHeader] ?? "", rolledValue)) {
matchedRow = row;
break;
}
}
for (const header of meta.dataHeaders) {
const slug = slugger.slug(header.toLowerCase());
const value = matchedRow
? (matchedRow[header] ?? "")
: `(no row for ${rolledValue})`;
columns.push({ header, slug, value });
}
}
return {
notation: meta.notation,
+49 -35
View File
@@ -1,10 +1,14 @@
/**
*
*
* 1. CLI /__CONTENT_INDEX.json
* 2. Dev 使 webpackContext .md dev
* 3.
*
* 1. CLI / /__CONTENT_INDEX.json
* 2.
* - IndexedDB
*
* Dev CLI rsbuild
* > npm run tttk serve ./content
* > npm run dev
* rsbuild /__CONTENT_INDEX.json/__COMPLETIONS.json/content/ CLI
*/
import {
@@ -13,25 +17,40 @@ import {
removeHandle,
ensurePermission,
} from "./file-index-db";
import { normalizePathKey } from "../cli/content-registry";
type FileIndex = Record<string, string>;
let fileIndex: FileIndex | null = null;
let indexLoadPromise: Promise<void> | null = null;
let activeSource: "cli" | "webpack" | "folder" | null = null;
let activeSource: "cli" | "folder" | null = null;
/**
* Optional registry for resolving inline content ids (set by the journal
* completions module). When present, `getIndexedData` resolves ids that
* aren't real files through it.
*/
let inlineResolver: ((path: string) => string | null) | null = null;
/** Register a resolver for inline content ids (see journal/completions). */
export function setInlineResolver(
fn: ((path: string) => string | null) | null,
): void {
inlineResolver = fn;
}
/** Currently active directory handle (if folder source) */
let activeDirHandle: FileSystemDirectoryHandle | null = null;
/**
*
* CLI JSON webpack context (dev only)
* CLI JSON
*/
function ensureIndexLoaded(): Promise<void> {
if (indexLoadPromise) return indexLoadPromise;
indexLoadPromise = (async () => {
// 策略 1: CLI 环境 — 从 /__CONTENT_INDEX.json 加载
// 策略 1: CLI / 代理环境 — 从 /__CONTENT_INDEX.json 加载
try {
const response = await fetch("/__CONTENT_INDEX.json");
if (response.ok) {
@@ -44,31 +63,7 @@ function ensureIndexLoaded(): Promise<void> {
// CLI 索引不可用时尝试下一个策略
}
// 策略 2: Dev 环境 — webpackContext + raw loader(仅 dev 构建时生效)
// 生产构建时 process.env.NODE_ENV 被替换为 'production',整个分支会被 tree-shake 掉
if (process.env.NODE_ENV === "development") {
try {
const context = import.meta.webpackContext("../../content", {
recursive: true,
regExp: /\.md|\.yarn|\.csv$/i,
});
const keys = context.keys();
const index: FileIndex = {};
for (const key of keys) {
const module = context(key) as { default?: string } | string;
const content =
typeof module === "string" ? module : (module.default ?? "");
const normalizedPath = "/content" + key.slice(1);
index[normalizedPath] = content;
}
fileIndex = { ...fileIndex, ...index };
activeSource = "webpack";
} catch (e) {
// webpackContext 不可用时忽略
}
}
// 策略 3: 浏览器 — 尝试从 IndexedDB 恢复保存的目录句柄
// 策略 2: 浏览器 — 尝试从 IndexedDB 恢复保存的目录句柄
if (!activeSource) {
try {
const restored = await restoreSavedHandle();
@@ -94,7 +89,7 @@ async function scanDirectory(
prefix = "",
): Promise<FileIndex> {
const index: FileIndex = {};
const acceptedExt = /\.(md|yarn|csv)$/i;
const acceptedExt = /\.(md|yarn|csv|svg)$/i;
for await (const [name, entry] of (handle as any).entries()) {
if (entry.kind === "directory") {
@@ -105,7 +100,7 @@ async function scanDirectory(
Object.assign(index, sub);
} else if (entry.kind === "file" && acceptedExt.test(name)) {
const file = await (entry as FileSystemFileHandle).getFile();
const path = prefix + name;
const path = normalizePathKey(prefix + name);
index[path] = await file.text();
}
}
@@ -176,7 +171,7 @@ export async function loadFromUserFolder(): Promise<string[] | null> {
/**
*
* 退 CLI/webpack
* 退 CLI
*/
export async function switchToBuiltInSource(): Promise<void> {
await removeHandle();
@@ -201,6 +196,15 @@ export async function getIndexedData(path: string): Promise<string> {
if (fileIndex && fileIndex[path]) {
return fileIndex[path];
}
// Resolve inline content ids through the registry before fetching.
if (inlineResolver) {
const inline = inlineResolver(path);
if (inline !== null) {
fileIndex = fileIndex || {};
fileIndex[path] = inline;
return inline;
}
}
const res = await fetch(path);
const content = await res.text();
fileIndex = fileIndex || {};
@@ -208,6 +212,16 @@ export async function getIndexedData(path: string): Promise<string> {
return content;
}
/**
* /
* registry stripped markdown
* 使 CLI
*/
export function setIndexedData(path: string, content: string): void {
fileIndex = fileIndex || {};
fileIndex[normalizePathKey(path)] = content;
}
/**
*
*/
+12 -25
View File
@@ -2,20 +2,12 @@
tag: journal-gm
icon: 🎭
title: 主持人指南
description: 作为 GM 创建会话、管理玩家、使用动态表单和消息类型。
syntax: '点击导航栏 📋 图标打开 Journal 面板'
props:
- name: 会话管理
type: —
desc: 创建、切换、删除会话;通过邀请链接添加玩家
- name: 消息类型
type: —
desc: 发送叙述、掷骰、表单等多种消息类型
- name: 动态表单
type: —
desc: 创建自定义表单收集玩家输入
---
作为 GM 创建会话、管理玩家、使用命令与玩家互动。
点击顶部导航栏的 📋 按钮打开 Journal 面板。
## 快速开始
1. 点击顶部导航栏的 📋 按钮打开 Journal 面板
@@ -32,25 +24,20 @@ props:
玩家通过邀请链接加入后会自动以玩家身份连接。
## 消息类型
## 消息与命令
Journal 支持多种消息类型,通过输入框上方的标签切换
Journal 使用斜杠命令来发送不同类型的消息。在输入框中输入命令,按 Enter 发送
- **叙述**普通的文字描述,用于推进剧情
- **掷骰**:发送掷骰结果,可附带公式
- **表单**:创建动态表单,收集玩家选择或输入
- **聊天**直接输入文字,按 Enter 发送普通叙述消息
- **`/roll 表达式`**:掷骰或抽取火花表。传入骰子表达式(如 `/roll 3d6`)则掷骰;传入火花表键名(如 `/roll npc`)则抽取火花表
- **`/link 路径#章节`**:发送可点击的文档链接
- **`/set $key 值`**:设置变量值,支持数值、标签映射和随机标签
## 动态表单
表单消息允许你创建交互式问卷:
- 添加文本输入、选择框、复选框等字段
- 玩家提交后,GM 可以看到汇总结果
- 适用于投票、决策、角色创建等场景
输入 `/` 后按 Tab 可打开命令补全下拉菜单。
## 撤回消息
GM 可以撤回自己发送的最新消息,点击消息旁的撤回按钮即可。
GM 可以撤回自己发送的最新消息,点击消息卡片右上角的 × 按钮即可。
## 连接状态
+34
View File
@@ -0,0 +1,34 @@
---
tag: journal-link
icon: 🔗
title: 链接命令
---
在 Journal 中发送可点击的文档链接,可指向特定章节。
**语法:** `/link 路径#章节`
## 概述
`/link` 命令用于在 Journal 流中发送文档链接,点击后可在文章区域打开对应文档。
## 参数
| 参数 | 类型 | 说明 |
|---|---|---|
| 路径 | string | 文档路径(不含 `.md` 扩展名) |
| 章节 | string | 可选,文档中的章节标题 |
## 示例
```
/link rules/combat
/link rules/combat#伤害计算
/link npc/商人
```
## 权限
- **GM**:可使用
- **玩家**:不可使用
- **观察者**:不可使用
+11 -21
View File
@@ -2,20 +2,12 @@
tag: journal-player
icon: 🎲
title: 玩家指南
description: 作为玩家加入 GM 的会话,查看消息、提交表单、参与互动。
syntax: '通过 GM 发送的邀请链接加入'
props:
- name: 加入方式
type: —
desc: 点击邀请链接自动加入,或手动输入名字和角色
- name: 查看消息
type: —
desc: 实时查看 GM 和其他玩家的消息
- name: 提交表单
type: —
desc: 填写 GM 发送的动态表单并提交
---
作为玩家加入 GM 的会话,查看消息、使用 `/set` 命令设置变量、参与互动。
通过 GM 发送的邀请链接加入。
## 加入会话
有两种方式加入 GM 的会话:
@@ -38,20 +30,17 @@ GM 会发送一个包含 `?session=xxx&player=你的名字&autojoin=1` 的链接
- GM 发送的叙述消息
- 掷骰结果
- 火花表抽取结果
- 其他玩家的消息
- 动态表单
消息实时更新,无需刷新页面。
## 填写表单
## 使用命令
当 GM 发送动态表单时
玩家可在输入框中使用以下命令
1. 表单会显示在消息流中
2. 根据表单类型填写(文本、选择、复选框等)
3. 点击提交按钮发送你的回答
GM 可以看到所有玩家的提交结果。
- **聊天**:直接输入文字,按 Enter 发送普通消息
- **`/set $key 值`**:设置变量值,支持数值和标签映射
## 角色标识
@@ -63,4 +52,5 @@ GM 可以看到所有玩家的提交结果。
- 玩家无法创建或切换会话
- 玩家无法撤回消息
- 断开连接后重新加入会保留之前的消息记录
- 玩家无法使用 `/roll``/link` 命令
- 在 CLI 模式下,断开连接后重新加入会保留之前的消息记录
+58
View File
@@ -0,0 +1,58 @@
---
tag: journal-roll
icon: 🎲
title: 掷骰与火花表命令
---
在 Journal 中掷骰或随机抽取火花表内容。优先匹配火花表,否则作为骰子表达式处理。
**语法:** `/roll 骰子表达式 | 火花表键名`
## 概述
`/roll` 命令用于掷骰或随机抽取火花表,并将结果同步到所有客户端。
- 如果参数匹配已定义的**火花表** slug,则抽取并发布对应火花表的结果。
- 否则,作为**骰子表达式**求值并发布掷骰结果。
## 示例
### 掷骰
```
/roll d20
/roll 3d6
/roll 2d8kh1
/roll 1d20+5
/roll 4d6k3
```
### 火花表
```
/roll npc
/roll encounter
/roll loot
```
## 支持的骰子表达式
| 表达式 | 说明 |
|---|---|
| `d20` | 单个 20 面骰 |
| `3d6` | 3 个 6 面骰求和 |
| `2d8kh1` | 2 个 8 面骰保留最高 1 个 |
| `1d20+5` | 1 个 20 面骰加 5 |
| `4d6k3` | 4 个 6 面骰保留最高 3 个 |
## 火花表定义
火花表在 markdown 文档中通过 `:spark[CSV路径]` 标记声明,系统扫描文档时自动发现。CSV 文件的第一列表头为骰子公式(如 `d6``d20`),后续列为数据列。
抽取时根据骰子公式投掷,查找对应行并将各列数据作为火花表结果发布。
## 权限
- **GM**:可使用
- **玩家**:不可使用
- **观察者**:不可使用
+210
View File
@@ -0,0 +1,210 @@
---
tag: journal-set
icon: 🔢
title: 变量系统
description: 在文档中声明变量和标签,通过 /set 命令设置变量值,自动计算派生变量和标签效果。
syntax: '```csv role=declare'
props:
- name: 定义文件
type: —
desc: 在任意 .md 文档中使用 ```csv role=declare 代码块定义变量和标签
- name: 命令
type: —
desc: /set $key expression | /set $key #tag1:count1;#tag2:count2
- name: 权限
type: —
desc: GM 和玩家均可设置变量
---
## 概述
变量系统允许你定义数值变量和标签,通过命令设置值,系统自动计算派生变量和标签效果。
核心概念:
- **变量**`$name`):存储数值或标签映射,通过 `/set` 命令修改
- **标签映射**`#tag:count`):变量可以存储一个标签映射(如 `#warrior:2;#druid:1`),每个标签有一个计数值
- **标签修饰符**:当变量中的标签计数值达到阈值时,修饰符生效
- **声明**`declare`):在 `role=declare` 代码块中定义派生变量和标签修饰符
## 代码块语法
```csv role=declare
tag,threshold,key,expr
,,$hp,$con*5+$mod_hp
,,$ac,10+$dex
#warrior,1,$mod_hp,20
#warrior,2,$mod_str,1
```
| 列 | 说明 |
|---|---|
| `tag` | 标签名(空表示普通变量声明) |
| `threshold` | 激活阈值(可选,默认 `1`)。标签计数值 ≥ 阈值时激活 |
| `key` | 变量名,以 `$` 开头 |
| `expr` | 表达式,支持数字、`$var` 引用、骰子、算术、函数 |
### 声明类型
**普通声明**tag 为空):`$key` 是一个派生变量,其值由表达式计算。当依赖变量变化时自动重新计算。
```csv role=declare
tag,key,expr
,$hp,$con*5+$mod_hp
,$ac,10+$dex
```
**标签修饰符**(tag 非空):当源变量的标签映射中 `#tag` 的计数值 ≥ `threshold` 时,`$key` 加上表达式的计算结果。修饰符按阈值独立激活。
```csv role=declare
tag,threshold,key,expr
#warrior,1,$mod_hp,20
#warrior,2,$mod_str,1
```
上例中:
- `#warrior` 计数 ≥ 1 时,`$mod_hp` 获得 +20
- `#warrior` 计数 ≥ 2 时,`$mod_str` 获得 +1(需要更高等级)
## 表达式语法
支持以下表达式元素:
| 元素 | 示例 | 说明 |
|---|---|---|
| 数字 | `10`, `3.5` | 整数或小数 |
| 变量引用 | `$str`, `$mod_hp` | 引用其他变量的值 |
| 骰子 | `3d6`, `2d8kh1` | 每次求值时重新掷骰 |
| 算术 | `+ - * /` | 四则运算 |
| 函数 | `floor(x)`, `ceil(x)`, `round(x)` | 取整函数 |
| 括号 | `(2+3)*4` | 分组 |
变量引用必须解析为数值,不能是标签映射值(`#warrior:1`)。如果引用的变量是标签类型,求值会报错。
## 命令
所有命令在 Journal 输入框中输入:
| 命令 | 示例 | 说明 |
|---|---|---|
| `/set $key expr` | `/set $str 16` | 设置变量为数值 |
| `/set $key #tag` | `/set $class #warrior` | 设置单个标签(等价 `#warrior:1` |
| `/set $key #tag:count` | `/set $class #warrior:3` | 设置标签及计数值 |
| `/set $key #t1:c1;#t2:c2` | `/set $class #warrior:2;#druid:1` | 设置多个标签 |
| `/set $key #a\|#b\|#c` | `/set $class #w\|#d\|#s` | 随机选择一个标签 |
### 设置数值
```
/set $con 14
/set $dex 12
```
设置后,依赖 `$con``$dex` 的派生变量(如 `$hp``$ac`)会自动重新计算。
### 设置标签映射
```
/set $class #warrior
```
等价于 `/set $class #warrior:1`。当 `$class``#warrior` 计数 ≥ 1 时:
1. `#warrior` 修饰符中阈值 ≤ 1 的生效
2. 如 `$mod_hp += 20`,依赖 `$mod_hp` 的派生变量自动重新计算
**多标签示例:**
```
/set $class #warrior:2;#druid:1
```
变量 `$class` 同时拥有 `#warrior` 计数 2 和 `#druid` 计数 1,两者的修饰符会同时生效(各自按阈值判断)。
### 随机标签
```
/set $class #warrior|#druid|#sorcerer|#wizard
```
从给定标签中随机选择一个,计数值为 1。
## 标签激活
标签的激活状态由变量中标签映射的计数值决定:
- 如果变量的标签映射中 `#tag` 的计数值 = 0(或不存在),该标签停用
- 计数值 ≥ 修饰符的 `threshold` 时,该修饰符激活
- 计数值 < `threshold` 时,修饰符停用
- 标签激活时,其修饰符**加到**目标变量上
- 标签停用时,修饰符**从**目标变量中减去
### 阈值机制
阈值允许同一标签产生不同层级的效果:
```csv role=declare
tag,threshold,key,expr
#warrior,1,$mod_hp,10
#warrior,3,$mod_hp,20
```
- `#warrior` 计数 1-2`$mod_hp` +10
- `#warrior` 计数 ≥ 3`$mod_hp` +30(两个修饰符叠加)
一个变量可以存储多个标签(标签映射),支持多职业、混合属性等场景。
## 变量视图
在 Journal 面板顶部点击 **变量** 标签切换到变量视图,显示所有已定义的变量及其当前值。
- 每行显示变量名和当前值,标签类型变量以紫色高亮显示
- 鼠标悬停在变量行上,会弹出气泡显示:声明表达式(如有)和当前激活的标签修饰符(标签名、数值、来源变量)
## 文档模板
在 markdown 文章中使用 `{{$key}}` 语法显示变量的实时值:
```
当前生命值:{{$hp}}
护甲等级:{{$ac}}
```
变量值会随着 Journal 流中的 `/set` 命令实时更新。
## 完整示例
在文档中定义:
```csv role=declare
tag,threshold,key,expr
,,$con,10
,,$dex,12
,,$hp,$con*5+$mod_hp
,,$ac,10+$dex
#warrior,1,$mod_hp,20
#warrior,2,$mod_str,1
```
在 Journal 中输入:
```
/set $con 14
/set $class #warrior:2
```
结果:
- `$con = 14`
- `$dex = 12`
- `$hp = 14*5 + 20 = 90`
- `$ac = 10 + 12 = 22`
- `$mod_hp = 20`(来自 `#warrior` 修饰符,阈值 1,计数 2 ≥ 1
- `$mod_str = 1`(来自 `#warrior` 修饰符,阈值 2,计数 2 ≥ 2
## 循环依赖
系统在加载声明时检测循环依赖。如果声明之间存在循环引用(如 `$a = $b + 1`, `$b = $a + 1`),会抛出错误提示。
## 权限
- **GM**:可设置所有变量
- **玩家**:可设置变量
- **观察者**:不能设置变量

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