Compare commits

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

- Adds `scope` property to StatDef
- Implements key resolution to handle bare keys vs full keys
- Updates `canModifyStat` to enforce player-specific permissions
- Enhances `makeStatLookup` to resolve scoped references in formulas
- Adds documentation for the new stat system
2026-07-09 09:41:16 +08:00
hypercross 44e4e15f3f feat: implement command autocomplete for journal input
Refactor journal input command parsing into a dedicated module and
add a `CompletionsDropdown` component to provide real-time suggestions
for commands (/roll, /spark, /link, /stat) and their arguments.
2026-07-09 09:31:19 +08:00
hypercross 9747409a1f feat: implement stat system and StatsView
Introduces a comprehensive stat management system including:
- `/stat` command support (set, del, roll)
- `StatsView` component for displaying grouped stat tables
- Formula evaluation for derived stats (supporting arithmetic and
  functions)
- Stat definition parsing from YAML blocks
- Permission checking for stat modification based on roles
2026-07-09 09:22:14 +08:00
hypercross 4faff9a391 feat(journal): allow players to emit roll messages 2026-07-09 09:06:25 +08:00
126 changed files with 8294 additions and 3535 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 ```bash
# 安装依赖 # 无需安装,直接使用
npm install npx tttk serve ./content
# 全局安装 CLI
npm link
# 预览内容
ttrpg serve ./content
``` ```
## 文档导航 ## 文档导航
@@ -20,6 +14,7 @@ ttrpg serve ./content
| 文档 | 说明 | | 文档 | 说明 |
|------|------| |------|------|
| [📖 CLI 使用说明](./docs/cli.md) | CLI 安装、命令和用法 | | [📖 CLI 使用说明](./docs/cli.md) | CLI 安装、命令和用法 |
| [📦 npm](https://www.npmjs.com/package/tttk) | `npx tttk serve ./content` |
| [🛠️ 开发指南](./docs/development.md) | 项目结构、开发规范和构建 | | [🛠️ 开发指南](./docs/development.md) | 项目结构、开发规范和构建 |
| [📝 Markdown 编写说明](./docs/markdown.md) | Markdown 语法和组件用法 | | [📝 Markdown 编写说明](./docs/markdown.md) | Markdown 语法和组件用法 |
| [📊 CSV 编写说明](./docs/csv.md) | CSV 文件格式、字段定义、变量语法 | | [📊 CSV 编写说明](./docs/csv.md) | CSV 文件格式、字段定义、变量语法 |
@@ -27,7 +22,7 @@ ttrpg serve ./content
## 功能概览 ## 功能概览
- **CLI 工具**: `serve` 预览模式 和 `compile` 编译模式 - **CLI 工具**: `tttk serve` 预览模式 和 `tttk compile` 编译模式
- **Markdown 解析**: 支持指令语法、YAML 标签、mermaid 图表 - **Markdown 解析**: 支持指令语法、YAML 标签、mermaid 图表
- **TTRPG 组件**: 骰子、表格、卡牌、标记、命令追踪器等 - **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" 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" } :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 ```yaml role=tag tag=md-deck
tag: md-deck
body: ./names.csv body: ./names.csv
size: 54x86 size: 54x86
grid: 5x8 grid: 5x8
@@ -15,3 +14,24 @@ bleed: 1
padding: 2 padding: 2
layers: name1:1,1-2,2f12 name2:4,7-5,8f12s num1:1,3-2,4f12 num2:4,5-5,6f12s layers: name1:1,1-2,2f12 name2:4,7-5,8f12s num1:1,3-2,4f12 num2:4,5-5,6f12s
``` ```
```yaml role=tag
tag: md-deck
body: ./names.csv
data-config:
size: 54x86
grid: 5x8
layers:
- prop: name1
pos: 1,1-2,2
font: 12
- prop: num2
pos: 4,5-5,6
font: 12
orientation: s
- template: |
**{{name1}}** / {{num1}}
pos: 1,3-5,4
font: 6
align: l
```
+16 -16
View File
@@ -1,18 +1,18 @@
# CLI 使用说明 # CLI 使用说明
TTRPG Tools 提供一个 CLI 工具,用于将目录内的各种 TTRPG 文档编译为 HTML。 TTTK 提供一个 CLI 工具,用于将目录内的各种 TTRPG 文档编译为 HTML。
## 安装 ## 安装
```bash ```bash
# 克隆仓库后安装依赖 # 无需安装,直接运行
npm install npx tttk serve ./content
# 全局链接 CLI 工具 # 全局安装
npm link npm install -g tttk
``` ```
安装完成后,可在任意目录使用 `ttrpg` 命令。 安装完成后,可在任意目录使用 `tttk` 命令。
## 命令 ## 命令
@@ -23,7 +23,7 @@ CLI 使用子命令组织:
运行一个 Web 服务器预览目录中的内容,并实时监听文件更新。 运行一个 Web 服务器预览目录中的内容,并实时监听文件更新。
```bash ```bash
ttrpg serve [dir] -p 3000 tttk serve [dir] -p 3000
``` ```
**参数:** **参数:**
@@ -50,13 +50,13 @@ ttrpg serve [dir] -p 3000
```bash ```bash
# 预览当前目录 # 预览当前目录
ttrpg serve tttk serve
# 预览指定目录 # 预览指定目录
ttrpg serve ./my-ttrpg-content tttk serve ./my-ttrpg-content
# 指定端口 # 指定端口
ttrpg serve ./docs -p 8080 tttk serve ./docs -p 8080
``` ```
### compile - 编译模式 ### compile - 编译模式
@@ -64,7 +64,7 @@ ttrpg serve ./docs -p 8080
将目录中的内容输出为带 hash 路由、单个 HTML 入口的 Web 应用。 将目录中的内容输出为带 hash 路由、单个 HTML 入口的 Web 应用。
```bash ```bash
ttrpg compile [dir] -o ./dist/output tttk compile [dir] -o ./dist/output
``` ```
**参数:** **参数:**
@@ -90,13 +90,13 @@ ttrpg compile [dir] -o ./dist/output
```bash ```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 ```bash
# 使用其他端口 # 使用其他端口
ttrpg serve -p 8080 tttk serve -p 8080
``` ```
### 编译输出为空 ### 编译输出为空
@@ -174,5 +174,5 @@ ttrpg serve -p 8080
ls -R ./content 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/ ├── src/
│ ├── cli/ # CLI 工具源码 │ ├── cli/ # CLI 工具源码
│ │ ├── commands/ # 命令实现 (serve, compile) │ │ ├── commands/ # 命令实现 (serve, compile)
+55 -16
View File
@@ -373,26 +373,30 @@ label,name,description
:md-table[./quests.csv]{roll=true remix=true} :md-table[./quests.csv]{roll=true remix=true}
``` ```
**自动表格转换** **内联表格(显式声明)**
标准 Markdown 表格会自动转换`md-table` 组件,当表头包含 `label``md-table-label` 列时 Markdown 表格会自动转换。如需内联表格,用代码块并声明 `role=spark-table`(首列需为骰子公式,如 `d6`
```markdown ````markdown
| label | name | description | ```markdown role=spark-table
|-------|------|-------------| | d6 | 结果 |
| 1 | 战士 | 近战专家 | |----|------|
| 2 | 法师 | 奥术施法者 | | 1 | 遭遇强盗 |
| 2 | 平安无事 |
``` ```
````
自动转换为 `:md-table` 组件。 扫描时转换为 CSV 并渲染为 `md-table` 组件。CSV 格式的内联表格用 `csv` 语言:
**特殊表头标识:** ````markdown
```csv role=spark-table
d6,结果
1,遭遇强盗
2,平安无事
```
````
| 表头 | 效果 | 普通 Markdown 表格(无 role 声明)始终按标准 GFM 表格渲染,不做任何转换。
|------|------|
| `label``md-table-label` | 转换为 md-table |
| `md-roll-label` 或骰子格式(如 `1d6` | 添加 `roll=true` |
| `md-remix-label` | 添加 `roll=true remix=true` |
### 🃏 卡牌组件 (md-deck) ### 🃏 卡牌组件 (md-deck)
@@ -423,6 +427,32 @@ layers="字段:起始行,起始列 - 结束列,字体大小"
示例:`title:1,1-5,1f8` 表示 title 字段从第 1 行开始,占据 1-5 列,8mm字体。 示例:`title:1,1-5,1f8` 表示 title 字段从第 1 行开始,占据 1-5 列,8mm字体。
**结构化配置(yaml 代码块):**
推荐使用 ```yaml role=tag 代码块(`yaml` 语言可被语法高亮),通过 `body` 指定 CSV 路径,`data-config` 提供结构化配置(图层可以是字段或模板):
````markdown
```yaml role=tag
tag: md-deck
body: ./cards.csv
data-config:
size: 63x88
grid: 5x5
layers:
- prop: title
pos: 1,1-5,1
font: 12
- template: |
**{{name}}** — {{type}}
{{description}}
pos: 1,3-5,8
font: 3
align: l
```
````
每个图层通过 `prop`(读取 CSV 字段)或 `template`(渲染带 `{{变量}}` 替换的 markdown 模板)定义,`pos` 为网格位置 `x1,y1-x2,y2`1-based,与紧凑 layers 字符串同格式),支持 `font`、`orientation`、`align`(水平 `l`/`c`/`r`,可加垂直前缀 `t`/`b` 组成 `tl`/`tc`/`tr`/`bl`/`bc`/`br`,如 `align: tl` 表示左上对齐)。`back_layers` 用于背面图层。
### 🧶 叙事线组件 (md-yarn-spinner) ### 🧶 叙事线组件 (md-yarn-spinner)
用于展示分支叙事结构,支持 Yarn Spinner 格式文件。 用于展示分支叙事结构,支持 Yarn Spinner 格式文件。
@@ -463,10 +493,10 @@ track npc#john.dwarf.warrior[hp=4/4 ac=15 name="John"]
## YAML 标签 ## YAML 标签
使用 ```yaml/tag 代码块创建自定义标签: 使用 ```yaml role=tag 代码块创建自定义标签`yaml` 语言可被语法高亮)
````markdown ````markdown
```yaml/tag ```yaml role=tag
tag: tag-name tag: tag-name
class: custom-class class: custom-class
id: my-id id: my-id
@@ -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 ```html
+2 -1
View File
@@ -535,7 +535,8 @@ src/cli/
│ │ └── write-frontmatter.ts │ │ └── write-frontmatter.ts
│ ├── card/ │ ├── card/
│ │ └── card-crud.ts │ │ └── card-crud.ts
│ ├── ensure-deck-preview.ts │ ├── deck-utils.ts
│ ├── preview-deck.ts
│ └── generate-card-deck.ts │ └── generate-card-deck.ts
└── index.ts └── index.ts
``` ```
+8
View File
@@ -4,6 +4,14 @@ export default {
testEnvironment: 'node', testEnvironment: 'node',
testMatch: ['**/*.test.ts'], testMatch: ['**/*.test.ts'],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
moduleNameMapper: {
// Resolve .js imports to .ts source files (ESM convention in TS source)
'^(.+)\\.js$': ['$1.ts', '$1.tsx', '$1.js'],
// github-slugger v2 is ESM-only; jest's CJS runtime cannot require it.
'^github-slugger$': '<rootDir>/__mocks__/github-slugger.js',
// Same for the browser ESM build of csv-parse — map to the CJS build.
'^csv-parse/browser/esm/sync$': 'csv-parse/sync',
},
transform: { transform: {
'^.+\\.tsx?$': [ '^.+\\.tsx?$': [
'ts-jest', 'ts-jest',
+13 -4
View File
@@ -1,11 +1,11 @@
{ {
"name": "ttrpg-tools", "name": "tttk",
"version": "0.0.1", "version": "0.0.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ttrpg-tools", "name": "tttk",
"version": "0.0.1", "version": "0.0.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -14,6 +14,7 @@
"@thisbeyond/solid-dnd": "^0.7.5", "@thisbeyond/solid-dnd": "^0.7.5",
"aedes": "^1.1.1", "aedes": "^1.1.1",
"chokidar": "^5.0.0", "chokidar": "^5.0.0",
"comlink": "^4.4.2",
"commander": "^14.0.3", "commander": "^14.0.3",
"csv-parse": "^6.1.0", "csv-parse": "^6.1.0",
"csv-stringify": "^6.7.0", "csv-stringify": "^6.7.0",
@@ -32,8 +33,7 @@
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"bin": { "bin": {
"ttrpg": "dist/cli/index.js", "tttk": "dist/cli/index.js"
"ttt": "dist/cli/index.js"
}, },
"devDependencies": { "devDependencies": {
"@rsbuild/core": "^1.1.8", "@rsbuild/core": "^1.1.8",
@@ -56,6 +56,9 @@
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"webpack-bundle-analyzer": "^5.3.0" "webpack-bundle-analyzer": "^5.3.0"
},
"engines": {
"node": ">=18"
} }
}, },
"node_modules/@alloc/quick-lru": { "node_modules/@alloc/quick-lru": {
@@ -4330,6 +4333,12 @@
"node": ">= 0.8" "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": { "node_modules/commander": {
"version": "14.0.3", "version": "14.0.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", "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", "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", "type": "module",
"bin": { "bin": {
"ttrpg": "./dist/cli/index.js", "tttk": "./dist/cli/index.js"
"ttt": "./dist/cli/index.js"
}, },
"main": "./dist/index.js", "files": [
"module": "./dist/index.js", "dist",
"exports": { "LICENSE",
".": "./dist/index.js", "README.md"
"./components": "./dist/components/index.js", ],
"./markdown": "./dist/markdown/index.js" "repository": {
"type": "git",
"url": "https://github.com/hypercross/ttrpg-tools"
},
"engines": {
"node": ">=18"
}, },
"scripts": { "scripts": {
"dev": "rsbuild dev", "dev": "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", "preview": "rsbuild preview",
"analyze": "cross-env ANALYZE=true rsbuild build", "analyze": "cross-env ANALYZE=true rsbuild build",
"cli:dev": "tsc -p tsconfig.cli.json --watch", "cli:dev": "tsc -p tsconfig.cli.json --watch",
"cli:build": "tsc -p tsconfig.cli.json", "cli:build": "npm run build:cli",
"ttrpg": "node --loader ts-node/esm ./src/cli/index.ts", "ttrpg": "node --loader ts-node/esm ./src/cli/index.ts",
"tttk": "node --loader ts-node/esm ./src/cli/index.ts",
"test": "jest", "test": "jest",
"prepare": "npx husky" "prepare": "npx husky",
"prepublishOnly": "npm run build"
}, },
"keywords": [ "keywords": [
"ttrpg", "ttrpg",
@@ -31,7 +39,7 @@
"markdown", "markdown",
"toolbox" "toolbox"
], ],
"author": "", "author": "hypercross",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^0.5.0", "@modelcontextprotocol/sdk": "^0.5.0",
@@ -39,6 +47,7 @@
"@thisbeyond/solid-dnd": "^0.7.5", "@thisbeyond/solid-dnd": "^0.7.5",
"aedes": "^1.1.1", "aedes": "^1.1.1",
"chokidar": "^5.0.0", "chokidar": "^5.0.0",
"comlink": "^4.4.2",
"commander": "^14.0.3", "commander": "^14.0.3",
"csv-parse": "^6.1.0", "csv-parse": "^6.1.0",
"csv-stringify": "^6.7.0", "csv-stringify": "^6.7.0",
+12 -6
View File
@@ -21,8 +21,8 @@ export default defineConfig({
module: { module: {
rules: [ rules: [
{ {
test: /\.md|\.yarn|\.csv$/, // Built-in doc entries are statically imported as strings
exclude: /\.schema\.csv$/, test: /[\\/]doc-entries[\\/].*\.md$/,
type: "asset/source", type: "asset/source",
}, },
], ],
@@ -40,13 +40,19 @@ export default defineConfig({
index: "./src/main.tsx", 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: { output: {
distPath: { distPath: {
root: "dist/web", root: "dist/web",
}, },
copy:
process.env.NODE_ENV === "development"
? [{ from: "./content", to: "content" }]
: [],
}, },
}); });
+9 -2
View File
@@ -18,9 +18,12 @@ import {
DocDialog, DocDialog,
DataSourceDialog, DataSourceDialog,
RevealManager, RevealManager,
ReactiveVariableManager,
CommandLinkManager,
} from "./components"; } from "./components";
import { generateToc, type FileNode, type TocNode } from "./data-loader"; import { generateToc, type FileNode, type TocNode } from "./data-loader";
import { JournalPanel } from "./components/journal"; import { JournalPanel } from "./components/journal";
import { useHeadingFlash } from "./components/useHeadingFlash";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Scroll container context lets child components (FileTree, RevealManager) // Scroll container context lets child components (FileTree, RevealManager)
@@ -68,6 +71,8 @@ const App: Component = () => {
void loadToc(); void loadToc();
}); });
useHeadingFlash();
const handleSourceChanged = () => { const handleSourceChanged = () => {
setTocKey((k) => k + 1); setTocKey((k) => k + 1);
}; };
@@ -96,7 +101,7 @@ const App: Component = () => {
> >
</button> </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" /> <div class="flex-1" />
<button <button
onClick={() => setIsJournalOpen((v) => !v)} onClick={() => setIsJournalOpen((v) => !v)}
@@ -152,10 +157,12 @@ const App: Component = () => {
<main ref={mainRef} class="flex-1 min-w-0 overflow-y-auto"> <main ref={mainRef} class="flex-1 min-w-0 overflow-y-auto">
<div class="max-w-4xl mx-auto px-4 py-8"> <div class="max-w-4xl mx-auto px-4 py-8">
<Article <Article
class="prose text-black prose-sm max-w-full" class="prose prose-sm max-w-full"
src={currentPath()} src={currentPath()}
> >
<RevealManager /> <RevealManager />
<ReactiveVariableManager />
<CommandLinkManager />
</Article> </Article>
</div> </div>
</main> </main>
+6 -6
View File
@@ -1,14 +1,14 @@
import type { CompileCommandHandler } from '../types.js'; import type { CompileCommandHandler } from "../types.js";
export const compileCommand: CompileCommandHandler = async (dir, options) => { export const compileCommand: CompileCommandHandler = async (dir, options) => {
console.log(`开始编译...`); console.log("开始编译...");
console.log(`目录:${dir}`); console.log(`目录:${dir}`);
console.log(`输出目录:${options.output}`); console.log(`输出目录:${options.output}`);
// TODO: 实现编译逻辑 // TODO: 实现编译逻辑
// 1. 扫描目录下的所有 .md 文件 // 1. 扫描目录下的所有 .md 文件
// 2. 解析 markdown 并生成路由 // 2. 解析 markdown 并生成路由
// 3. 打包为带 hash 路由的单个 HTML 入口 // 3. 打包为带 hash 路由的单个 HTML 入口
console.log('编译完成!'); console.log("编译完成!");
}; };
+73 -82
View File
@@ -2,30 +2,32 @@ import type { ServeCommandHandler } from "../types.js";
import { createServer, Server, IncomingMessage, ServerResponse } from "http"; import { createServer, Server, IncomingMessage, ServerResponse } from "http";
import { readdirSync, statSync, readFileSync, existsSync } from "fs"; import { readdirSync, statSync, readFileSync, existsSync } from "fs";
import { createReadStream } from "fs"; import { createReadStream } from "fs";
import { join, resolve, extname, sep, relative, dirname } from "path"; import { join, resolve, extname, relative, dirname } from "path";
import { watch } from "chokidar"; import { watch } from "chokidar";
import { networkInterfaces } from "os"; import { networkInterfaces } from "os";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import { createJournalServer } from "../journal.js"; import { createJournalServer } from "../journal.js";
import { import {
scanCompletions, buildRegistryFromIndex,
type CompletionsPayload, normalizePathKey,
} from "../completions/index.js"; deriveCompletions,
import { extractInlineBlocks } from "../inline-blocks.js"; type ContentRegistry,
} from "../content-registry.js";
import type { CompletionsPayload } from "../completions/types.js";
interface ContentIndex { interface ContentIndex {
[path: string]: string; [path: string]: string;
} }
/** /**
* 获取 CLI 脚本文件所在目录路径(用于定位 dist 文件夹 * 获取 CLI 脚本文件所在目录路径(用于定位 dist 目录
*/ */
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
const distDir = resolve(__dirname, "..", "..", "..", "dist", "web"); const distDir = resolve(__dirname, "..", "..", "..", "dist", "web");
/** /**
* MIME 类型映射 * MIME 类型映射
*/ */
const MIME_TYPES: Record<string, string> = { const MIME_TYPES: Record<string, string> = {
".html": "text/html", ".html": "text/html",
@@ -44,7 +46,7 @@ const MIME_TYPES: Record<string, string> = {
}; };
/** /**
* 获取文件扩展名对应的 MIME 类型 * 根据文件扩展名获取对应的 MIME 类型
*/ */
function getMimeType(filePath: string): string { function getMimeType(filePath: string): string {
const ext = extname(filePath).toLowerCase(); const ext = extname(filePath).toLowerCase();
@@ -52,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 { function getBestIP(): string {
const interfaces = networkInterfaces(); const interfaces = networkInterfaces();
@@ -81,10 +83,10 @@ function getBestIP(): string {
} }
/** /**
* 扫描目录内的 .md 文件,生成索引 * 扫描目录内的 .md 文件,构建内容注册表(路径索引 + 每文档内联内容)
*/ */
export function scanDirectory(dir: string): ContentIndex { export function buildRegistry(dir: string): ContentRegistry {
const index: ContentIndex = {}; const index: Record<string, string> = {};
function scan(currentPath: string, relativePath: string) { function scan(currentPath: string, relativePath: string) {
const entries = readdirSync(currentPath); const entries = readdirSync(currentPath);
@@ -94,7 +96,7 @@ export function scanDirectory(dir: string): ContentIndex {
const fullPath = join(currentPath, entry); const fullPath = join(currentPath, entry);
const relPath = relativePath ? join(relativePath, entry) : entry; const relPath = relativePath ? join(relativePath, entry) : entry;
const normalizedRelPath = "/" + relPath.split(sep).join("/"); const normalizedRelPath = normalizePathKey(relPath);
const stats = statSync(fullPath); const stats = statSync(fullPath);
if (stats.isDirectory()) { if (stats.isDirectory()) {
@@ -102,20 +104,11 @@ export function scanDirectory(dir: string): ContentIndex {
} else if ( } else if (
entry.endsWith(".md") || entry.endsWith(".md") ||
entry.endsWith(".csv") || entry.endsWith(".csv") ||
entry.endsWith(".yarn") entry.endsWith(".yarn") ||
entry.endsWith(".svg")
) { ) {
try { try {
const content = readFileSync(fullPath, "utf-8"); index[normalizedRelPath] = readFileSync(fullPath, "utf-8");
if (entry.endsWith(".md")) {
const stripped = extractInlineBlocks(
content,
normalizedRelPath,
index,
);
index[normalizedRelPath] = stripped;
} else {
index[normalizedRelPath] = content;
}
} catch (e) { } catch (e) {
console.error(`读取文件失败:${fullPath}`, e); console.error(`读取文件失败:${fullPath}`, e);
} }
@@ -124,11 +117,12 @@ export function scanDirectory(dir: string): ContentIndex {
} }
scan(dir, ""); scan(dir, "");
return index;
return buildRegistryFromIndex(index);
} }
/** /**
* 发送文件响应 * 发送文件响应(流式传输)
*/ */
function sendFile(res: ServerResponse, filePath: string) { function sendFile(res: ServerResponse, filePath: string) {
res.writeHead(200, { res.writeHead(200, {
@@ -139,7 +133,7 @@ function sendFile(res: ServerResponse, filePath: string) {
} }
/** /**
* 发送 404 响应 * 发送 404 未找到响应
*/ */
function send404(res: ServerResponse) { function send404(res: ServerResponse) {
res.writeHead(404, { "Content-Type": "text/plain" }); res.writeHead(404, { "Content-Type": "text/plain" });
@@ -147,7 +141,7 @@ function send404(res: ServerResponse) {
} }
/** /**
* 发送 JSON 响应 * 发送 JSON 格式的响应
*/ */
function sendJson(res: ServerResponse, data: unknown) { function sendJson(res: ServerResponse, data: unknown) {
res.writeHead(200, { res.writeHead(200, {
@@ -159,7 +153,7 @@ function sendJson(res: ServerResponse, data: unknown) {
/** /**
* 尝试提供静态文件 * 尝试提供静态文件
* @returns 如果文件存在并成功发送返回 true * @returns 文件存在并成功发送返回 true
*/ */
function tryServeStatic( function tryServeStatic(
res: ServerResponse, res: ServerResponse,
@@ -182,13 +176,14 @@ function tryServeStatic(
} }
/** /**
* 创建请求处理器 * 创建 HTTP 请求处理器
*/ */
function createRequestHandler( function createRequestHandler(
contentDir: string, contentDir: string,
distDir: string, distDir: string,
getIndex: () => ContentIndex, getIndex: () => ContentIndex,
getCompletions: () => CompletionsPayload, getCompletions: () => CompletionsPayload,
getRegistry: () => ContentRegistry,
) { ) {
return (req: IncomingMessage, res: ServerResponse) => { return (req: IncomingMessage, res: ServerResponse) => {
const url = req.url || "/"; const url = req.url || "/";
@@ -206,6 +201,12 @@ function createRequestHandler(
return; return;
} }
// 1c. 处理 /__CONTENT_REGISTRY.json(含每文档内联内容,供运行时解析)
if (filePath === "/__CONTENT_REGISTRY.json") {
sendJson(res, getRegistry());
return;
}
// 2. 处理 /static/ 目录(从 dist/web // 2. 处理 /static/ 目录(从 dist/web
if (filePath.startsWith("/static/")) { if (filePath.startsWith("/static/")) {
if (tryServeStatic(res, filePath, distDir)) { if (tryServeStatic(res, filePath, distDir)) {
@@ -232,7 +233,7 @@ function createRequestHandler(
} }
/** /**
* 内容服务器接口 * 内容服务器实例接口
*/ */
export interface ContentServer { export interface ContentServer {
/** /**
@@ -258,7 +259,7 @@ export interface ContentServer {
} }
/** /**
* 创建内容服务器 * 创建内容服务器HTTP + WebSocket + 文件监听)
*/ */
export function createContentServer( export function createContentServer(
contentDir: string, contentDir: string,
@@ -266,29 +267,31 @@ export function createContentServer(
distPath: string = distDir, distPath: string = distDir,
host: string = "0.0.0.0", host: string = "0.0.0.0",
): ContentServer { ): ContentServer {
let contentIndex: ContentIndex = {}; let registry: ContentRegistry = { pathIndex: {}, docContent: {} };
let completionsIndex: CompletionsPayload = { let completionsIndex: CompletionsPayload = {
dice: [], dice: [],
links: [], links: [],
sparkTables: [], sparkTables: [],
declarations: [],
tagModifiers: [],
}; };
/** Re-scan completions from current content index (cached) */ /** 从当前注册表重新派生补全数据 */
function recomputeCompletions(): void { function recomputeCompletions(): void {
completionsIndex = scanCompletions(contentIndex); completionsIndex = deriveCompletions(registry);
console.log( console.log(
`[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length}`, `[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length} declarations=${completionsIndex.declarations.length} tagModifiers=${completionsIndex.tagModifiers.length}`,
); );
} }
// 扫描内容目录生成索引 // 扫描内容目录生成注册表
console.log("扫描内容目录..."); console.log("正在扫描内容目录...");
contentIndex = scanDirectory(contentDir); registry = buildRegistry(contentDir);
console.log(`已索引 ${Object.keys(contentIndex).length} 个文件`); console.log(`已索引 ${Object.keys(registry.pathIndex).length} 个文件`);
recomputeCompletions(); recomputeCompletions();
// 监听文件变化 // 监听文件变化
console.log("监听文件变化..."); console.log("正在监听文件变化...");
const watcher = watch(contentDir, { const watcher = watch(contentDir, {
ignored: /(^|[\/\\])\./, ignored: /(^|[\/\\])\./,
persistent: true, persistent: true,
@@ -300,23 +303,14 @@ export function createContentServer(
if ( if (
path.endsWith(".md") || path.endsWith(".md") ||
path.endsWith(".csv") || path.endsWith(".csv") ||
path.endsWith(".yarn") path.endsWith(".yarn") ||
path.endsWith(".svg")
) { ) {
try { try {
const content = readFileSync(path, "utf-8"); // 全量重建注册表以刷新跨文件派生(spark 注入)
const relPath = "/" + relative(contentDir, path).split(sep).join("/"); registry = buildRegistry(contentDir);
if (relPath.endsWith(".md")) { recomputeCompletions();
const stripped = extractInlineBlocks( console.log(`[新增] ${path}`);
content,
relPath,
contentIndex,
);
contentIndex[relPath] = stripped;
recomputeCompletions();
} else {
contentIndex[relPath] = content;
}
console.log(`[新增] ${relPath}`);
} catch (e) { } catch (e) {
console.error(`读取新增文件失败:${path}`, e); console.error(`读取新增文件失败:${path}`, e);
} }
@@ -326,23 +320,13 @@ export function createContentServer(
if ( if (
path.endsWith(".md") || path.endsWith(".md") ||
path.endsWith(".csv") || path.endsWith(".csv") ||
path.endsWith(".yarn") path.endsWith(".yarn") ||
path.endsWith(".svg")
) { ) {
try { try {
const content = readFileSync(path, "utf-8"); registry = buildRegistry(contentDir);
const relPath = "/" + relative(contentDir, path).split(sep).join("/"); recomputeCompletions();
if (relPath.endsWith(".md")) { console.log(`[更新] ${path}`);
const stripped = extractInlineBlocks(
content,
relPath,
contentIndex,
);
contentIndex[relPath] = stripped;
recomputeCompletions();
} else {
contentIndex[relPath] = content;
}
console.log(`[更新] ${relPath}`);
} catch (e) { } catch (e) {
console.error(`读取更新文件失败:${path}`, e); console.error(`读取更新文件失败:${path}`, e);
} }
@@ -352,24 +336,25 @@ export function createContentServer(
if ( if (
path.endsWith(".md") || path.endsWith(".md") ||
path.endsWith(".csv") || path.endsWith(".csv") ||
path.endsWith(".yarn") path.endsWith(".yarn") ||
path.endsWith(".svg")
) { ) {
const relPath = "/" + relative(contentDir, path).split(sep).join("/"); registry = buildRegistry(contentDir);
delete contentIndex[relPath]; recomputeCompletions();
console.log(`[删除] ${relPath}`); console.log(`[删除] ${path}`);
if (relPath.endsWith(".md")) recomputeCompletions();
} }
}); });
// ---- Journal / MQTT broker ---- // ---- Journal / MQTT broker ----
let journal: Awaited<ReturnType<typeof createJournalServer>> | null = null; let journal: Awaited<ReturnType<typeof createJournalServer>> | null = null;
// 创建 HTTP 服务器 BEFORE journal so we can pass it in // 在 journal 之前创建 HTTP 服务器,以便传入给 journal
const handleRequest = createRequestHandler( const handleRequest = createRequestHandler(
contentDir, contentDir,
distPath, distPath,
() => contentIndex, () => registry.pathIndex,
() => completionsIndex, () => completionsIndex,
() => registry,
); );
const server = createServer(handleRequest); const server = createServer(handleRequest);
@@ -387,16 +372,16 @@ export function createContentServer(
} }
console.log(`内容目录:${contentDir}`); console.log(`内容目录:${contentDir}`);
console.log(`静态资源目录:${distPath}`); console.log(`静态资源目录:${distPath}`);
console.log(`Journal 连接:ws://${bestIP}:${port}\n`); console.log(`日志连接:ws://${bestIP}:${port}\n`);
}); });
return { return {
server, server,
watcher, watcher,
index: contentIndex, index: registry.pathIndex,
completions: completionsIndex, completions: completionsIndex,
close() { close() {
console.log("关闭内容服务器..."); console.log("正在关闭内容服务器...");
server.close(); server.close();
watcher.close(); watcher.close();
if (journal) journal.close(); if (journal) journal.close();
@@ -409,6 +394,12 @@ export function createContentServer(
*/ */
export const serveCommand: ServeCommandHandler = async (dir, options) => { export const serveCommand: ServeCommandHandler = async (dir, options) => {
const contentDir = resolve(dir); const contentDir = resolve(dir);
if (!existsSync(contentDir)) {
console.error(`错误:目录不存在:${contentDir}`);
process.exit(1);
}
const port = parseInt(options.port, 10); const port = parseInt(options.port, 10);
const host = options.host || "0.0.0.0"; const host = options.host || "0.0.0.0";
@@ -0,0 +1,7 @@
/**
* Jest mock for csv-parse/browser/esm/sync
*
* The real import path is csv-parse/browser/esm/sync which only works in
* browser bundlers. In Node (Jest), we redirect to csv-parse/sync.
*/
export { parse } from "csv-parse/sync";
+26
View File
@@ -0,0 +1,26 @@
/**
* Shared block scanning utilities — safe for both CLI and browser.
*
* Parses fenced code blocks with attributes:
* ```lang id=xxx role=xxx key=value
*
* The `role` fully determines block behavior (see `scanDoc` in
* `content-registry.ts`):
* - spark-table → stored as CSV, rendered as an `:md-table` directive
* - declare / file → stored, block stripped
* - tag → block kept intact for the yaml-tag render extension
* - unknown role → warned about and stripped
* - no role → kept as a visible code block
*
* Attribute parsing itself lives in `src/markdown/block-attrs.ts` so render
* extensions can share the same syntax.
*/
export { parseBlockAttrs, type BlockAttrs } from "../../markdown/block-attrs.js";
// ---------------------------------------------------------------------------
// Regex
// ---------------------------------------------------------------------------
/** Matches fenced code blocks with an info string (at least one attr). */
export const FENCED_BLOCK_RE = /^```(\w*)\s+(\S.*?)\s*\n([\s\S]*?)```\s*$/gm;
+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 };
}
-38
View File
@@ -1,38 +0,0 @@
/**
* Completion index — orchestrates all registered completion sources.
*/
import { diceSource } from "./sources/dice.js";
import { linksSource } from "./sources/links.js";
import { sparkTablesSource } from "./sources/spark-tables.js";
import type { CompletionSource, CompletionsPayload } from "./types.js";
export type {
CompletionsPayload,
DiceCompletion,
LinkCompletion,
SparkTableCompletion,
} from "./types.js";
/** Registered sources — open for extension */
const sources: CompletionSource[] = [
diceSource,
linksSource,
sparkTablesSource,
];
/**
* Scan the full content index and return structured completion data.
* Called at server startup and on any file change.
*/
export function scanCompletions(
index: Record<string, string>,
): CompletionsPayload {
const payload: Record<string, unknown[]> = {};
for (const source of sources) {
payload[source.key] = source.scan(index);
}
return payload as unknown as CompletionsPayload;
}
-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;
}
@@ -1,82 +0,0 @@
/**
* Spark table completion source — extracts spark tables (markdown tables
* whose first column header is a dice formula like d6, d20, etc.) from all
* .md files.
*/
import Slugger from "github-slugger";
import type { CompletionSource, SparkTableCompletion } from "../types.js";
/** Regex: matches a pipe-delimited markdown table row */
function splitTableRow(line: string): string[] | null {
const trimmed = line.trim();
if (!trimmed.includes("|")) return null;
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());
}
const SEP_RE = /^:?-{3,}:?$/;
const DICE_RE = /^d\d+$/i;
export const sparkTablesSource: CompletionSource = {
key: "sparkTables",
scan(index) {
const items: SparkTableCompletion[] = [];
for (const [filePath, content] of Object.entries(index)) {
if (!filePath.endsWith(".md")) continue;
// Fresh slugger per file so duplicate headers across files don't
// pollute each other. (github-slugger is stateful.)
const slugger = new Slugger();
const lines = content.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const headerCells = splitTableRow(lines[i]);
if (!headerCells || headerCells.length < 2) continue;
if (!DICE_RE.test(headerCells[0])) continue;
// Check separator row
if (i + 1 >= lines.length) continue;
const sepCells = splitTableRow(lines[i + 1]);
if (!sepCells || !sepCells.every((c) => SEP_RE.test(c))) continue;
// Collect body rows
let j = i + 2;
while (j < lines.length) {
const rowCells = splitTableRow(lines[j]);
if (!rowCells) break;
j++;
}
if (j <= i + 2) continue; // No body rows
// Build slug from data columns
const dataHeaders = headerCells.slice(1);
const slug = dataHeaders
.map((h: string) => slugger.slug(h.toLowerCase()))
.join("-");
const basePath = filePath.replace(/\.md$/, "");
const fileName = basePath.split("/").filter(Boolean).pop() || basePath;
// Combined key: pageName-columnSlug (what user types after /spark)
const combinedSlug = `${fileName}-${slug}`;
items.push({
label: `${fileName} § ${slug}`,
notation: headerCells[0],
slug: combinedSlug,
filePath: basePath,
headers: dataHeaders,
});
i = j - 1;
}
}
return items;
},
};
+13 -2
View File
@@ -2,6 +2,10 @@
* Completion source types — shared between CLI scanner and frontend consumer. * Completion source types — shared between CLI scanner and frontend consumer.
*/ */
import type { VarDeclaration, TagModifier } from "./declare-parser.js";
export type { VarDeclaration, TagModifier };
/** A single dice expression found in a markdown file */ /** A single dice expression found in a markdown file */
export interface DiceCompletion { export interface DiceCompletion {
/** Display text shown in the dropdown */ /** Display text shown in the dropdown */
@@ -30,17 +34,24 @@ export interface SparkTableCompletion {
notation: string; notation: string;
/** Concatenated slug of data column headers */ /** Concatenated slug of data column headers */
slug: string; slug: string;
/** File path of the containing .md file */ /** File path of the containing .md file (without extension) */
filePath: string; 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 */ /** Data column headers for display */
headers: string[]; headers: string[];
/** Whether to roll each column independently (remix mode) */
remix: boolean;
} }
/** Top-level payload served at /__COMPLETIONS.json */
export interface CompletionsPayload { export interface CompletionsPayload {
dice: DiceCompletion[]; dice: DiceCompletion[];
links: LinkCompletion[]; links: LinkCompletion[];
sparkTables: SparkTableCompletion[]; sparkTables: SparkTableCompletion[];
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(); const program = new Command();
program program
.name("ttrpg") .name("tttk")
.description("TTRPG 工具箱 - 用于编译和预览 TTRPG 文档") .description(
"TTRPG 内容预览服务器。不带参数运行时默认启动当前目录的预览服务。",
)
.version("0.0.1"); .version("0.0.1");
program program
.command("serve") .command("serve")
.description("运行一个 web 服务器预览目录中的内容,并实时监听更新") .description("启动 web 服务器预览目录中的内容,并实时监听文件更新")
.argument("[dir]", "要预览的目录", ".") .argument("[dir]", "要预览的目录", ".")
.option("-p, --port <port>", "HTTP 端口号", "3000") .option("-p, --port <port>", "HTTP 端口号", "3000")
.option("-h, --host <host>", "主机地址", "0.0.0.0") .option("--host <host>", "主机地址", "0.0.0.0")
.action(serveCommand); .action(serveCommand);
program program
.command("compile") .command("compile")
.description("将目录中的内容输出为带 hash 路由、单个 html 入口的 web 应用") .description("[实验性] 将目录内容编译为带 hash 路由的单页 Web 应用 (开发中)")
.argument("[dir]", "要编译的目录", ".") .argument("[dir]", "要编译的目录", ".")
.option("-o, --output <dir>", "输出目录", "./dist/output") .option("-o, --output <dir>", "输出目录", "./dist/output")
.action(compileCommand); .action(compileCommand);
program.addCommand(mcpCommand); 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]);
-105
View File
@@ -1,105 +0,0 @@
import { posix } from "path";
import { createHash } from "crypto";
/**
* Regex to match a fenced code block with attributes, e.g.:
*
* ```csv file="card-data.csv"
* name, cost, effect
* Fireball, 1, Blast em
* ```
*
* ```csv as="md-table" roll=true
* name, cost, effect
* Fireball, 1, Blast em
* ```
*
* Group 1: language identifier (e.g. "csv")
* Group 2: attribute string (e.g. `file="card-data.csv" as="md-table" roll=true`)
* Group 3: the code block body
*/
const INLINE_FILE_BLOCK_RE = /^```(\w*)\s+(.+?)\s*\n([\s\S]*?)```\s*$/gm;
/**
* Parse key="value" and key=value pairs from an attribute string.
*/
function parseAttrs(info: string): Record<string, string> {
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, "");
}
return attrs;
}
/**
* Short content hash for stable generated filenames.
*/
function contentHash(body: string): string {
return createHash("md5").update(body).digest("hex").slice(0, 8);
}
/**
* Process fenced code blocks that contain attributes.
*
* For each block like:
* ```lang file="card-data.csv" as="md-table" roll=true
* content
* ```
*
* 1. Strips the block from the returned text
* 2. Injects a synthetic entry into `index` at the resolved file path
* 3. If `as` is present, replaces the block with a directive like
* `:md-table[./file.csv]{roll=true}`
*
* @param content - Raw file content
* @param fileRelativePath - Normalized path of the containing file (e.g. "/rules/combat.md")
* @param index - The content index to inject synthetic entries into
* @returns Content with inline blocks processed
*/
export function extractInlineBlocks(
content: string,
fileRelativePath: string,
index: Record<string, string>,
): string {
const fileDir = posix.dirname(fileRelativePath);
return content.replace(
INLINE_FILE_BLOCK_RE,
(_match: string, lang: string, infoString: string, body: string) => {
const attrs = parseAttrs(infoString);
// Only process blocks that have `file` or `as`; leave others untouched
if (!attrs.file && !attrs.as) {
return _match;
}
// Determine filename: explicit `file` or content-hash generated
const filename =
attrs.file || `_inline_${contentHash(body)}.${lang || "txt"}`;
const resolvedPath = posix.join(fileDir, filename);
// Inject the body as a synthetic file entry
index[resolvedPath] = body;
console.log(
`[inline] Extracted ${resolvedPath} from ${fileRelativePath}`,
);
// If `as` is set, emit a directive instead of stripping
if (attrs.as) {
const extra = { ...attrs };
delete extra.file;
delete extra.as;
const extraStr = Object.keys(extra).length
? `{${Object.entries(extra)
.map(([k, v]) => `${k}=${v}`)
.join(" ")}}`
: "";
return `:${attrs.as}[./${filename}]${extraStr}`;
}
return ""; // has `file` but no `as` — strip the block (body is already injected into index)
},
);
}
+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 服务器上挂载 aedes MQTT 代理(WebSocket 传输),
* HTTP server. Uses ws v8's built-in createWebSocketStream. * 使用 ws v8 内置的 createWebSocketStream
* *
* Pattern from aedes docs: * 实现参考 aedes 文档:
* https://github.com/moscajs/aedes#mqtt-server-over-websocket * https://github.com/moscajs/aedes#mqtt-server-over-websocket
* *
* Persistence uses aedes's internal subscribe/publish API, so the * 持久化使用 aedes 内部 subscribe/publish API,无需服务器进程
* server process doesn't need to connect to itself as an MQTT client. * 以 MQTT 客户端身份连接自身。
*/ */
import { join, resolve } from "path"; import { join, resolve } from "path";
@@ -26,7 +26,7 @@ import type { AedesPublishPacket } from "aedes";
import { WebSocketServer, createWebSocketStream } from "ws"; import { WebSocketServer, createWebSocketStream } from "ws";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // 类型定义
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
interface SessionMeta { interface SessionMeta {
@@ -46,7 +46,7 @@ export interface JournalServer {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Path helpers // 路径工具函数
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function dataDir(contentDir: string): string { function dataDir(contentDir: string): string {
@@ -88,7 +88,7 @@ function appendStream(dataRoot: string, id: string, line: string): void {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Server factory // 服务器工厂
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const $SESSIONS = "ttrpg/$SESSIONS"; const $SESSIONS = "ttrpg/$SESSIONS";
@@ -98,15 +98,15 @@ export async function createJournalServer(
httpServer: HttpServer, httpServer: HttpServer,
): Promise<JournalServer> { ): Promise<JournalServer> {
const root = dataDir(contentDir); const root = dataDir(contentDir);
console.log(`[journal] data dir: ${root}`); console.log(`[日志] 数据目录:${root}`);
// ---- MQTT Broker ---- // ---- MQTT 代理 ----
const { Aedes: AedesFactory } = await import("aedes"); const { Aedes: AedesFactory } = await import("aedes");
// aedes requires listen() to initialize persistence before handling connections. // aedes 需要先 listen() 初始化持久化,再处理连接
const broker = await AedesFactory.createBroker(); const broker = await AedesFactory.createBroker();
// ---- WebSocket server (attached to the existing HTTP server) ---- // ---- WebSocket 服务器(挂载到现有 HTTP 服务器上) ----
// Pattern from aedes docs: pass { server } to WSS, use 'connection' event // 参考 aedes 文档:给 WSS 传入 { server },使用 'connection' 事件
const wss = new WebSocketServer({ server: httpServer }); const wss = new WebSocketServer({ server: httpServer });
wss.on("connection", (ws, req) => { wss.on("connection", (ws, req) => {
@@ -115,13 +115,13 @@ export async function createJournalServer(
}); });
broker.on("client", (client: import("aedes").Client) => { 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) => { 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( broker.subscribe(
"ttrpg/+/stream", "ttrpg/+/stream",
(packet, cb) => { (packet, cb) => {
@@ -130,7 +130,7 @@ export async function createJournalServer(
try { try {
appendStream(root, sessionId, packet.payload.toString()); appendStream(root, sessionId, packet.payload.toString());
} catch (e) { } catch (e) {
console.error(`[journal] stream append err for ${sessionId}:`, e); console.error(`[日志] 会话 ${sessionId} 流记录写入失败:`, e);
} }
} }
cb(); cb();
@@ -150,7 +150,7 @@ export async function createJournalServer(
() => {}, () => {},
); );
// Publish the initial manifest on startup so clients see existing sessions // 启动时发布初始清单,让客户端能看到已有会话
const initialManifest = loadManifest(root); const initialManifest = loadManifest(root);
broker.publish( broker.publish(
{ {
@@ -164,14 +164,14 @@ export async function createJournalServer(
() => {}, () => {},
); );
console.log("[journal] persistence listener active"); console.log("[日志] 持久化监听已启动");
console.log("[journal] broker available at ws://<host>:<port>"); console.log("[日志] MQTT 代理已就绪:ws://<host>:<port>");
return { return {
broker, broker,
wsServer: wss, wsServer: wss,
async close() { async close() {
console.log("[journal] shutting down..."); console.log("[日志] 正在关闭...");
await new Promise<void>((r) => wss.close(() => r())); await new Promise<void>((r) => wss.close(() => r()));
await new Promise<void>((r) => broker.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 { function extractSessionId(topic: string): string | null {
@@ -188,7 +188,7 @@ function extractSessionId(topic: string): string | null {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Session lifecycle // 会话生命周期管理
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function handleMetaChange( function handleMetaChange(
@@ -212,7 +212,7 @@ function handleMetaChange(
} catch { } catch {
/* */ /* */
} }
console.log(`[journal] session deleted: ${sessionId}`); console.log(`[日志] 会话已删除:${sessionId}`);
} }
} else { } else {
manifest.sessions[sessionId] = { manifest.sessions[sessionId] = {
@@ -220,7 +220,7 @@ function handleMetaChange(
created: meta.created || Date.now(), created: meta.created || Date.now(),
players: meta.players || [], players: meta.players || [],
}; };
console.log(`[journal] session updated: ${sessionId} (${meta.name})`); console.log(`[日志] 会话已更新:${sessionId} (${meta.name})`);
} }
saveManifest(root, manifest); saveManifest(root, manifest);
@@ -236,6 +236,6 @@ function handleMetaChange(
() => {}, () => {},
); );
} catch (e) { } 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 { readFileSync, writeFileSync, existsSync } from "fs";
import { parse } from 'csv-parse/browser/esm/sync'; import { parse } from "csv-parse/browser/esm/sync";
import { stringify } from 'csv-stringify/browser/esm/sync'; import { stringify } from "csv-stringify/browser/esm/sync";
import yaml from 'js-yaml'; import type { DeckFrontmatter } from "../frontmatter/read-frontmatter.js";
import type { DeckFrontmatter } from '../frontmatter/read-frontmatter.js'; import {
parseFrontMatter,
serializeFrontMatter,
} from "../frontmatter/shared.js";
/** /**
* 卡牌数据 * 卡牌数据
@@ -23,7 +26,7 @@ export interface CardCrudParams {
/** /**
* 操作类型 * 操作类型
*/ */
action: 'create' | 'read' | 'update' | 'delete'; action: "create" | "read" | "update" | "delete";
/** /**
* 卡牌数据(单张或数组) * 卡牌数据(单张或数组)
*/ */
@@ -44,41 +47,6 @@ export interface CardCrudResult {
count?: number; count?: number;
} }
/**
* 解析 CSV 文件的 frontmatter
*/
function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } {
const parts = content.trim().split(/(?:^|\n)---\s*\n/g);
if (parts.length !== 3 || parts[0] !== '') {
return { csvContent: content };
}
try {
const frontmatterStr = parts[1].trim();
const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined;
const csvContent = parts.slice(2).join('---\n').trimStart();
return { frontmatter, csvContent };
} catch (error) {
console.warn('Failed to parse front matter:', error);
return { csvContent: content };
}
}
/**
* 序列化 frontmatter 为 YAML 字符串
*/
function serializeFrontMatter(frontmatter: DeckFrontmatter): string {
const yamlStr = yaml.dump(frontmatter, {
indent: 2,
lineWidth: -1,
noRefs: true,
quotingType: '"',
forceQuotes: false
});
return `---\n${yamlStr}---\n`;
}
/** /**
* 加载 CSV 数据(包含 frontmatter * 加载 CSV 数据(包含 frontmatter
*/ */
@@ -87,19 +55,19 @@ function loadCSVWithFrontmatter(filePath: string): {
records: CardData[]; records: CardData[];
headers: string[]; headers: string[];
} { } {
const content = readFileSync(filePath, 'utf-8'); const content = readFileSync(filePath, "utf-8");
const { frontmatter, csvContent } = parseFrontMatter(content); const { frontmatter, csvContent } = parseFrontMatter(content);
const records = parse(csvContent, { const records = parse(csvContent, {
columns: true, columns: true,
comment: '#', comment: "#",
trim: true, trim: true,
skipEmptyLines: true skipEmptyLines: true,
}) as CardData[]; }) as CardData[];
// 获取表头 // 获取表头
const firstLine = csvContent.split('\n')[0]; const firstLine = csvContent.split("\n")[0];
const headers = firstLine.split(',').map(h => h.trim()); const headers = firstLine.split(",").map((h) => h.trim());
return { frontmatter, records, headers }; return { frontmatter, records, headers };
} }
@@ -111,30 +79,30 @@ function saveCSVWithFrontmatter(
filePath: string, filePath: string,
frontmatter: DeckFrontmatter | undefined, frontmatter: DeckFrontmatter | undefined,
records: CardData[], records: CardData[],
headers?: string[] headers?: string[],
): void { ): void {
// 序列化 frontmatter // 序列化 frontmatter
const frontmatterStr = frontmatter ? serializeFrontMatter(frontmatter) : ''; const frontmatterStr = frontmatter ? serializeFrontMatter(frontmatter) : "";
// 确定表头 // 确定表头
if (!headers || headers.length === 0) { if (!headers || headers.length === 0) {
// 从 records 和 frontmatter.fields 推断表头 // 从 records 和 frontmatter.fields 推断表头
headers = ['label']; headers = ["label"];
if (frontmatter?.fields && Array.isArray(frontmatter.fields)) { if (frontmatter?.fields && Array.isArray(frontmatter.fields)) {
for (const field of frontmatter.fields) { for (const field of frontmatter.fields) {
if (field.name && typeof field.name === 'string') { if (field.name && typeof field.name === "string") {
headers.push(field.name); headers.push(field.name);
} }
} }
} }
headers.push('body'); headers.push("body");
} }
// 确保所有 record 都有 headers 中的列 // 确保所有 record 都有 headers 中的列
for (const record of records) { for (const record of records) {
for (const header of headers) { for (const header of headers) {
if (!(header in record)) { if (!(header in record)) {
record[header] = ''; record[header] = "";
} }
} }
} }
@@ -142,11 +110,11 @@ function saveCSVWithFrontmatter(
// 序列化 CSV // 序列化 CSV
const csvContent = stringify(records, { const csvContent = stringify(records, {
header: true, header: true,
columns: headers columns: headers,
}); });
// 写入文件 // 写入文件
writeFileSync(filePath, frontmatterStr + csvContent, 'utf-8'); writeFileSync(filePath, frontmatterStr + csvContent, "utf-8");
} }
/** /**
@@ -167,10 +135,10 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
const { csv_file, action, cards, label } = params; const { csv_file, action, cards, label } = params;
// 检查文件是否存在(create 操作可以不存在) // 检查文件是否存在(create 操作可以不存在)
if (action !== 'create' && !existsSync(csv_file)) { if (action !== "create" && !existsSync(csv_file)) {
return { return {
success: false, success: false,
message: `文件不存在:${csv_file}` message: `文件不存在:${csv_file}`,
}; };
} }
@@ -189,8 +157,8 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
// 执行操作 // 执行操作
switch (action) { switch (action) {
case 'create': { case "create": {
const newCards = Array.isArray(cards) ? cards : (cards ? [cards] : []); const newCards = Array.isArray(cards) ? cards : cards ? [cards] : [];
for (const card of newCards) { for (const card of newCards) {
if (!card.label) { if (!card.label) {
card.label = generateNextLabel(records); card.label = generateNextLabel(records);
@@ -202,16 +170,22 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
success: true, success: true,
message: `成功创建 ${newCards.length} 张卡牌`, message: `成功创建 ${newCards.length} 张卡牌`,
cards: newCards, cards: newCards,
count: newCards.length count: newCards.length,
}; };
} }
case 'read': { case "read": {
const labelsToRead = Array.isArray(label) ? label : (label ? [label] : null); const labelsToRead = Array.isArray(label)
? label
: label
? [label]
: null;
let resultCards: CardData[]; let resultCards: CardData[];
if (labelsToRead && labelsToRead.length > 0) { if (labelsToRead && labelsToRead.length > 0) {
resultCards = records.filter(r => labelsToRead.includes(r.label || '')); resultCards = records.filter((r) =>
labelsToRead.includes(r.label || ""),
);
} else { } else {
resultCards = records; resultCards = records;
} }
@@ -220,20 +194,26 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
success: true, success: true,
message: `成功读取 ${resultCards.length} 张卡牌`, message: `成功读取 ${resultCards.length} 张卡牌`,
cards: resultCards, cards: resultCards,
count: resultCards.length count: resultCards.length,
}; };
} }
case 'update': { case "update": {
const labelsToUpdate = Array.isArray(label) ? label : (label ? [label] : null); const labelsToUpdate = Array.isArray(label)
const updateCards = Array.isArray(cards) ? cards : (cards ? [cards] : []); ? label
: label
? [label]
: null;
const updateCards = Array.isArray(cards) ? cards : cards ? [cards] : [];
let updatedCount = 0; let updatedCount = 0;
if (labelsToUpdate && labelsToUpdate.length > 0) { if (labelsToUpdate && labelsToUpdate.length > 0) {
// 按 label 更新 // 按 label 更新
for (const updateCard of updateCards) { for (const updateCard of updateCards) {
const targetLabel = updateCard.label || labelsToUpdate[updatedCount % labelsToUpdate.length]; const targetLabel =
const index = records.findIndex(r => r.label === targetLabel); updateCard.label ||
labelsToUpdate[updatedCount % labelsToUpdate.length];
const index = records.findIndex((r) => r.label === targetLabel);
if (index !== -1) { if (index !== -1) {
records[index] = { ...records[index], ...updateCard }; records[index] = { ...records[index], ...updateCard };
updatedCount++; updatedCount++;
@@ -243,7 +223,9 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
// 按 cards 中的 label 更新 // 按 cards 中的 label 更新
for (const updateCard of updateCards) { for (const updateCard of updateCards) {
if (updateCard.label) { if (updateCard.label) {
const index = records.findIndex(r => r.label === updateCard.label); const index = records.findIndex(
(r) => r.label === updateCard.label,
);
if (index !== -1) { if (index !== -1) {
records[index] = { ...records[index], ...updateCard }; records[index] = { ...records[index], ...updateCard };
updatedCount++; updatedCount++;
@@ -257,23 +239,29 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
success: true, success: true,
message: `成功更新 ${updatedCount} 张卡牌`, message: `成功更新 ${updatedCount} 张卡牌`,
cards: updateCards, cards: updateCards,
count: updatedCount count: updatedCount,
}; };
} }
case 'delete': { case "delete": {
const labelsToDelete = Array.isArray(label) ? label : (label ? [label] : null); const labelsToDelete = Array.isArray(label)
? label
: label
? [label]
: null;
let deletedCount = 0; let deletedCount = 0;
if (labelsToDelete && labelsToDelete.length > 0) { if (labelsToDelete && labelsToDelete.length > 0) {
const beforeCount = records.length; const beforeCount = records.length;
records = records.filter(r => !labelsToDelete.includes(r.label || '')); records = records.filter(
(r) => !labelsToDelete.includes(r.label || ""),
);
deletedCount = beforeCount - records.length; deletedCount = beforeCount - records.length;
} else if (cards) { } else if (cards) {
const cardsToDelete = Array.isArray(cards) ? cards : [cards]; const cardsToDelete = Array.isArray(cards) ? cards : [cards];
const beforeCount = records.length; const beforeCount = records.length;
records = records.filter(r => records = records.filter(
!cardsToDelete.some(c => c.label && r.label === c.label) (r) => !cardsToDelete.some((c) => c.label && r.label === c.label),
); );
deletedCount = beforeCount - records.length; deletedCount = beforeCount - records.length;
} }
@@ -282,20 +270,20 @@ export function cardCrud(params: CardCrudParams): CardCrudResult {
return { return {
success: true, success: true,
message: `成功删除 ${deletedCount} 张卡牌`, message: `成功删除 ${deletedCount} 张卡牌`,
count: deletedCount count: deletedCount,
}; };
} }
default: default:
return { return {
success: false, success: false,
message: `未知操作:${action}` message: `未知操作:${action}`,
}; };
} }
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
message: `操作失败:${error instanceof Error ? error.message : '未知错误'}` message: `操作失败:${error instanceof Error ? error.message : "未知错误"}`,
}; };
} }
} }
+134
View File
@@ -0,0 +1,134 @@
/**
* Shared deck utility functions — used by generate-card-deck and preview-deck.
*/
import { dirname, join, basename, extname, relative } from "path";
import type {
DeckConfig,
DeckFrontmatter,
} from "./frontmatter/read-frontmatter.js";
import { parseFrontMatter } from "./frontmatter/shared.js";
import { readFileSync } from "fs";
/**
* Build a `:md-deck[path]{attrs}` directive string from a CSV path and config.
*/
export function buildDeckComponent(
csvPath: string,
config?: DeckConfig,
): string {
const parts = [`:md-deck[${csvPath}]`];
const attrs: string[] = [];
if (config?.size) {
attrs.push(`size="${config.size}"`);
}
if (config?.grid) {
attrs.push(`grid="${config.grid}"`);
}
if (config?.bleed !== undefined && config.bleed !== 1) {
attrs.push(`bleed="${config.bleed}"`);
}
if (config?.padding !== undefined && config.padding !== 2) {
attrs.push(`padding="${config.padding}"`);
}
if (config?.shape && config.shape !== "rectangle") {
attrs.push(`shape="${config.shape}"`);
}
if (config?.layers) {
attrs.push(`layers="${config.layers}"`);
}
if (config?.back_layers) {
attrs.push(`back-layers="${config.back_layers}"`);
}
if (attrs.length > 0) {
parts.push(`{${attrs.join(" ")}}`);
}
return parts.join("");
}
/**
* Derive a human-readable title from a CSV filename.
* e.g. "my-deck.csv" → "My Deck"
*/
export function titleFromCsvPath(csvPath: string): string {
const name = basename(csvPath, extname(csvPath));
return name.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
/**
* Compute the relative path from a markdown file to its CSV.
* Handles the case where they are in the same or different directories.
*/
export function relativeCsvPath(
mdFilePath: string,
csvFilePath: string,
): string {
const mdDir = dirname(mdFilePath);
const rel = relative(mdDir, csvFilePath);
return rel.startsWith(".") ? rel : `./${rel}`;
}
/**
* Determine the default markdown file path for a CSV.
* e.g. "foo/bar.csv" → "foo/bar.md"
*/
export function defaultMdPath(csvFilePath: string): string {
const dir = dirname(csvFilePath);
const name = basename(csvFilePath, extname(csvFilePath));
return join(dir, `${name}.md`);
}
/**
* Read DeckConfig from a CSV file's frontmatter, if present.
*/
export function readDeckConfig(csvFilePath: string): DeckConfig | undefined {
try {
const content = readFileSync(csvFilePath, "utf-8");
const { frontmatter } = parseFrontMatter(content);
return frontmatter?.deck;
} catch {
return undefined;
}
}
/**
* Generate the standard markdown content for a deck preview file.
*/
export function generateDeckPreviewMd(
deckTitle: string,
deckComponent: string,
description?: string,
): string {
const lines: string[] = [];
lines.push(`# ${deckTitle}`);
lines.push("");
if (description) {
lines.push(description);
lines.push("");
}
lines.push("## 卡牌预览");
lines.push("");
lines.push(deckComponent);
lines.push("");
lines.push("## 使用说明");
lines.push("");
lines.push("- 点击卡牌可以查看详情");
lines.push("- 使用右上角的按钮可以随机抽取卡牌");
lines.push("- 可以通过编辑面板调整卡牌样式和布局");
lines.push("");
return lines.join("\n");
}
-217
View File
@@ -1,217 +0,0 @@
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { dirname, join, basename, extname } from 'path';
import yaml from 'js-yaml';
import type { DeckFrontmatter, DeckConfig } from './frontmatter/read-frontmatter.js';
/**
* 确保 Deck 预览文件的参数
*/
export interface EnsureDeckPreviewParams {
/**
* CSV 文件路径
*/
csv_file: string;
/**
* Markdown 文件路径(可选,默认与 CSV 同名)
*/
md_file?: string;
/**
* 标题(可选,默认从 CSV 文件名推断)
*/
title?: string;
/**
* 描述(可选)
*/
description?: string;
}
/**
* 确保 Deck 预览文件的结果
*/
export interface EnsureDeckPreviewResult {
success: boolean;
message: string;
md_file?: string;
created?: boolean;
}
/**
* 解析 CSV 文件的 frontmatter
*/
function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } {
const parts = content.trim().split(/(?:^|\n)---\s*\n/g);
if (parts.length !== 3 || parts[0] !== '') {
return { csvContent: content };
}
try {
const frontmatterStr = parts[1].trim();
const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined;
const csvContent = parts.slice(2).join('---\n').trimStart();
return { frontmatter, csvContent };
} catch (error) {
console.warn('Failed to parse front matter:', error);
return { csvContent: content };
}
}
/**
* 构建 md-deck 组件代码
*/
function buildDeckComponent(csvPath: string, config?: DeckConfig): string {
const parts = [`:md-deck[${csvPath}]`];
const attrs: string[] = [];
if (config?.size) {
attrs.push(`size="${config.size}"`);
}
if (config?.grid) {
attrs.push(`grid="${config.grid}"`);
}
if (config?.bleed !== undefined && config.bleed !== 1) {
attrs.push(`bleed="${config.bleed}"`);
}
if (config?.padding !== undefined && config.padding !== 2) {
attrs.push(`padding="${config.padding}"`);
}
if (config?.shape && config.shape !== 'rectangle') {
attrs.push(`shape="${config.shape}"`);
}
if (config?.layers) {
attrs.push(`layers="${config.layers}"`);
}
if (config?.back_layers) {
attrs.push(`back-layers="${config.back_layers}"`);
}
if (attrs.length > 0) {
parts.push(`{${attrs.join(' ')}}`);
}
return parts.join('');
}
/**
* 确保 Markdown 预览文件存在
*/
export function ensureDeckPreview(params: EnsureDeckPreviewParams): EnsureDeckPreviewResult {
const { csv_file, md_file, title, description } = params;
// 检查 CSV 文件是否存在
if (!existsSync(csv_file)) {
return {
success: false,
message: `CSV 文件不存在:${csv_file}`
};
}
// 确定 Markdown 文件路径
let mdFilePath = md_file;
if (!mdFilePath) {
// 使用与 CSV 相同的路径和文件名,只是扩展名不同
const dir = dirname(csv_file);
const name = basename(csv_file, extname(csv_file));
mdFilePath = join(dir, `${name}.md`);
}
const created = !existsSync(mdFilePath);
// 如果文件已存在,检查是否已有 md-deck 组件
if (!created) {
try {
const existingContent = readFileSync(mdFilePath, 'utf-8');
// 如果已有 md-deck 组件引用该 CSV,直接返回
if (existingContent.includes(`:md-deck[${csv_file}]`) ||
existingContent.includes(`:md-deck[./${basename(csv_file)}]`)) {
return {
success: true,
message: `预览文件 ${mdFilePath} 已存在`,
md_file: mdFilePath,
created: false
};
}
} catch (error) {
// 读取失败,继续创建
}
}
// 读取 CSV 的 frontmatter 获取配置
let deckConfig: DeckConfig | undefined;
try {
const csvContent = readFileSync(csv_file, 'utf-8');
const { frontmatter } = parseFrontMatter(csvContent);
deckConfig = frontmatter?.deck;
} catch (error) {
// 忽略错误,使用默认配置
}
// 确定标题
let deckTitle = title;
if (!deckTitle) {
// 从 CSV 文件名推断
const name = basename(csv_file, extname(csv_file));
deckTitle = name.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}
// 构建 md-deck 组件代码(使用相对路径)
const mdDir = dirname(mdFilePath);
const csvDir = dirname(csv_file);
let relativeCsvPath: string;
if (mdDir === csvDir) {
relativeCsvPath = `./${basename(csv_file)}`;
} else {
relativeCsvPath = `./${basename(csv_file)}`;
}
const deckComponent = buildDeckComponent(relativeCsvPath, deckConfig);
// 生成 Markdown 内容
const mdLines: string[] = [];
mdLines.push(`# ${deckTitle}`);
mdLines.push('');
if (description) {
mdLines.push(description);
mdLines.push('');
}
mdLines.push('## 卡牌预览');
mdLines.push('');
mdLines.push(deckComponent);
mdLines.push('');
mdLines.push('## 使用说明');
mdLines.push('');
mdLines.push('- 点击卡牌可以查看详情');
mdLines.push('- 使用右上角的按钮可以随机抽取卡牌');
mdLines.push('- 可以通过编辑面板调整卡牌样式和布局');
mdLines.push('');
// 写入文件
try {
writeFileSync(mdFilePath, mdLines.join('\n'), 'utf-8');
return {
success: true,
message: created
? `创建预览文件 ${mdFilePath}`
: `更新预览文件 ${mdFilePath}`,
md_file: mdFilePath,
created
};
} catch (error) {
return {
success: false,
message: `写入失败:${error instanceof Error ? error.message : '未知错误'}`
};
}
}
+12 -32
View File
@@ -1,5 +1,5 @@
import { readFileSync, existsSync } from 'fs'; import { readFileSync, existsSync } from "fs";
import yaml from 'js-yaml'; import { parseFrontMatter } from "./shared.js";
/** /**
* 读取 CSV frontmatter 的参数 * 读取 CSV frontmatter 的参数
@@ -52,7 +52,7 @@ export interface CardFieldStyle {
/** /**
* 朝向:上侧朝向 "n" | "w" | "s" | "e"(北/西/南/东) * 朝向:上侧朝向 "n" | "w" | "s" | "e"(北/西/南/东)
*/ */
up?: 'n' | 'w' | 's' | 'e'; up?: "n" | "w" | "s" | "e";
} }
/** /**
@@ -74,7 +74,7 @@ export interface DeckConfig {
grid?: string; grid?: string;
bleed?: number; bleed?: number;
padding?: number; padding?: number;
shape?: 'rectangle' | 'circle' | 'hex' | 'diamond'; shape?: "rectangle" | "circle" | "hex" | "diamond";
layers?: string; layers?: string;
back_layers?: string; back_layers?: string;
[key: string]: unknown; [key: string]: unknown;
@@ -89,45 +89,25 @@ export interface ReadFrontmatterResult {
message: string; message: string;
} }
/**
* 解析 CSV 文件的 frontmatter
*/
function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } {
const parts = content.trim().split(/(?:^|\n)---\s*\n/g);
// 至少需要三个部分:空字符串、front matter、CSV 内容
if (parts.length !== 3 || parts[0] !== '') {
return { csvContent: content };
}
try {
const frontmatterStr = parts[1].trim();
const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined;
const csvContent = parts.slice(2).join('---\n').trimStart();
return { frontmatter, csvContent };
} catch (error) {
console.warn('Failed to parse front matter:', error);
return { csvContent: content };
}
}
/** /**
* 读取 CSV 文件的 frontmatter * 读取 CSV 文件的 frontmatter
*/ */
export function readFrontmatter(params: ReadFrontmatterParams): ReadFrontmatterResult { export function readFrontmatter(
params: ReadFrontmatterParams,
): ReadFrontmatterResult {
const { csv_file } = params; const { csv_file } = params;
// 检查文件是否存在 // 检查文件是否存在
if (!existsSync(csv_file)) { if (!existsSync(csv_file)) {
return { return {
success: false, success: false,
message: `文件不存在:${csv_file}` message: `文件不存在:${csv_file}`,
}; };
} }
try { try {
// 读取文件内容 // 读取文件内容
const content = readFileSync(csv_file, 'utf-8'); const content = readFileSync(csv_file, "utf-8");
// 解析 frontmatter // 解析 frontmatter
const { frontmatter } = parseFrontMatter(content); const { frontmatter } = parseFrontMatter(content);
@@ -136,19 +116,19 @@ export function readFrontmatter(params: ReadFrontmatterParams): ReadFrontmatterR
return { return {
success: true, success: true,
frontmatter: {}, frontmatter: {},
message: `文件 ${csv_file} 没有 frontmatter,返回空对象` message: `文件 ${csv_file} 没有 frontmatter,返回空对象`,
}; };
} }
return { return {
success: true, success: true,
frontmatter, frontmatter,
message: `成功读取 ${csv_file} 的 frontmatter` message: `成功读取 ${csv_file} 的 frontmatter`,
}; };
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
message: `读取失败:${error instanceof Error ? error.message : '未知错误'}` message: `读取失败:${error instanceof Error ? error.message : "未知错误"}`,
}; };
} }
} }
+48
View File
@@ -0,0 +1,48 @@
/**
* Shared frontmatter utilities — used by read-frontmatter, write-frontmatter,
* card-crud, preview-deck, and generate-card-deck.
*/
import yaml from "js-yaml";
import type { DeckFrontmatter } from "./read-frontmatter.js";
/**
* Parse YAML frontmatter from a CSV file's raw content.
* Expects `---\n...\n---\n` delimited frontmatter at the top of the file.
*/
export function parseFrontMatter(content: string): {
frontmatter?: DeckFrontmatter;
csvContent: string;
} {
const parts = content.trim().split(/(?:^|\n)---\s*\n/g);
if (parts.length !== 3 || parts[0] !== "") {
return { csvContent: content };
}
try {
const frontmatterStr = parts[1].trim();
const frontmatter = yaml.load(frontmatterStr) as
| DeckFrontmatter
| undefined;
const csvContent = parts.slice(2).join("---\n").trimStart();
return { frontmatter, csvContent };
} catch (error) {
console.warn("Failed to parse front matter:", error);
return { csvContent: content };
}
}
/**
* Serialize a DeckFrontmatter object to YAML frontmatter string.
*/
export function serializeFrontMatter(frontmatter: DeckFrontmatter): string {
const yamlStr = yaml.dump(frontmatter, {
indent: 2,
lineWidth: -1,
noRefs: true,
quotingType: '"',
forceQuotes: false,
});
return `---\n${yamlStr}---\n`;
}
+20 -53
View File
@@ -1,6 +1,6 @@
import { readFileSync, writeFileSync, existsSync } from 'fs'; import { readFileSync, writeFileSync, existsSync } from "fs";
import yaml from 'js-yaml'; import type { DeckFrontmatter } from "./read-frontmatter.js";
import type { DeckFrontmatter } from './read-frontmatter.js'; import { parseFrontMatter, serializeFrontMatter } from "./shared.js";
/** /**
* 写入 CSV frontmatter 的参数 * 写入 CSV frontmatter 的参数
@@ -29,70 +29,37 @@ export interface WriteFrontmatterResult {
csv_file?: string; csv_file?: string;
} }
/**
* 解析 CSV 文件的 frontmatter
*/
function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } {
const parts = content.trim().split(/(?:^|\n)---\s*\n/g);
// 至少需要三个部分:空字符串、front matter、CSV 内容
if (parts.length !== 3 || parts[0] !== '') {
return { csvContent: content };
}
try {
const frontmatterStr = parts[1].trim();
const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined;
const csvContent = parts.slice(2).join('---\n').trimStart();
return { frontmatter, csvContent };
} catch (error) {
console.warn('Failed to parse front matter:', error);
return { csvContent: content };
}
}
/**
* 序列化 frontmatter 为 YAML 字符串
*/
function serializeFrontMatter(frontmatter: DeckFrontmatter): string {
const yamlStr = yaml.dump(frontmatter, {
indent: 2,
lineWidth: -1, // 不限制行宽
noRefs: true, // 不使用引用
quotingType: '"',
forceQuotes: false
});
return `---\n${yamlStr}---\n`;
}
/** /**
* 写入/更新 CSV 文件的 frontmatter * 写入/更新 CSV 文件的 frontmatter
*/ */
export function writeFrontmatter(params: WriteFrontmatterParams): WriteFrontmatterResult { export function writeFrontmatter(
params: WriteFrontmatterParams,
): WriteFrontmatterResult {
const { csv_file, frontmatter, merge = true } = params; const { csv_file, frontmatter, merge = true } = params;
let csvContent = ''; let csvContent = "";
let existingFrontmatter: DeckFrontmatter | undefined; let existingFrontmatter: DeckFrontmatter | undefined;
// 如果文件存在且需要合并,先读取现有内容 // 如果文件存在且需要合并,先读取现有内容
if (merge && existsSync(csv_file)) { if (merge && existsSync(csv_file)) {
try { try {
const content = readFileSync(csv_file, 'utf-8'); const content = readFileSync(csv_file, "utf-8");
const result = parseFrontMatter(content); const result = parseFrontMatter(content);
existingFrontmatter = result.frontmatter; existingFrontmatter = result.frontmatter;
csvContent = result.csvContent; csvContent = result.csvContent;
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
message: `读取现有文件失败:${error instanceof Error ? error.message : '未知错误'}` message: `读取现有文件失败:${error instanceof Error ? error.message : "未知错误"}`,
}; };
} }
} }
// 合并或替换 frontmatter // 合并或替换 frontmatter
const finalFrontmatter = merge && existingFrontmatter const finalFrontmatter =
? { ...existingFrontmatter, ...frontmatter } merge && existingFrontmatter
: frontmatter; ? { ...existingFrontmatter, ...frontmatter }
: frontmatter;
// 序列化 frontmatter // 序列化 frontmatter
const frontmatterStr = serializeFrontMatter(finalFrontmatter); const frontmatterStr = serializeFrontMatter(finalFrontmatter);
@@ -100,32 +67,32 @@ export function writeFrontmatter(params: WriteFrontmatterParams): WriteFrontmatt
// 如果文件不存在或没有 CSV 内容,创建一个空的 CSV 内容 // 如果文件不存在或没有 CSV 内容,创建一个空的 CSV 内容
if (!csvContent.trim()) { if (!csvContent.trim()) {
// 从 frontmatter 推断 CSV 表头 // 从 frontmatter 推断 CSV 表头
const headers = ['label']; const headers = ["label"];
if (finalFrontmatter.fields && Array.isArray(finalFrontmatter.fields)) { if (finalFrontmatter.fields && Array.isArray(finalFrontmatter.fields)) {
for (const field of finalFrontmatter.fields) { for (const field of finalFrontmatter.fields) {
if (field.name && typeof field.name === 'string') { if (field.name && typeof field.name === "string") {
headers.push(field.name); headers.push(field.name);
} }
} }
} }
headers.push('body'); headers.push("body");
csvContent = headers.join(',') + '\n'; csvContent = headers.join(",") + "\n";
} }
// 写入文件 // 写入文件
try { try {
const fullContent = frontmatterStr + csvContent; const fullContent = frontmatterStr + csvContent;
writeFileSync(csv_file, fullContent, 'utf-8'); writeFileSync(csv_file, fullContent, "utf-8");
return { return {
success: true, success: true,
message: `成功写入 frontmatter 到 ${csv_file}`, message: `成功写入 frontmatter 到 ${csv_file}`,
csv_file csv_file,
}; };
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
message: `写入失败:${error instanceof Error ? error.message : '未知错误'}` message: `写入失败:${error instanceof Error ? error.message : "未知错误"}`,
}; };
} }
} }
+74 -160
View File
@@ -1,5 +1,7 @@
import { writeFileSync, mkdirSync, existsSync } from 'fs'; import { writeFileSync, mkdirSync, existsSync } from "fs";
import { join } from 'path'; import { join } from "path";
import { buildDeckComponent, generateDeckPreviewMd } from "./deck-utils.js";
import type { DeckConfig } from "./frontmatter/read-frontmatter.js";
/** /**
* 字段样式配置 * 字段样式配置
@@ -24,7 +26,7 @@ export interface CardFieldStyle {
/** /**
* 朝向:上侧朝向 "n" | "w" | "s" | "e"(北/西/南/东) * 朝向:上侧朝向 "n" | "w" | "s" | "e"(北/西/南/东)
*/ */
up?: 'n' | 'w' | 's' | 'e'; up?: "n" | "w" | "s" | "e";
} }
/** /**
@@ -45,18 +47,7 @@ export interface CardTemplate {
examples?: Record<string, string>[]; examples?: Record<string, string>[];
} }
/** export type { DeckConfig };
* Deck 配置
*/
export interface DeckConfig {
size?: string;
grid?: string;
bleed?: number;
padding?: number;
shape?: 'rectangle' | 'circle' | 'hex' | 'diamond';
layers?: string;
backLayers?: string;
}
/** /**
* 生成卡牌组的参数 * 生成卡牌组的参数
@@ -73,31 +64,31 @@ export interface GenerateCardDeckParams {
/** /**
* 生成卡牌数据 CSV * 生成卡牌数据 CSV
*/ */
function generateCardCSV( function generateCardCSV(template: CardTemplate, cardCount: number): string {
template: CardTemplate,
cardCount: number
): string {
const fields = template.fields; const fields = template.fields;
// 构建 CSV 表头 // 构建 CSV 表头
const headers = ['label', ...fields.map(f => f.name), 'body']; const headers = ["label", ...fields.map((f) => f.name), "body"];
// 生成示例数据 // 生成示例数据
const rows: string[][] = []; const rows: string[][] = [];
const examples = template.examples || []; const examples = template.examples || [];
for (let i = 0; i < cardCount; i++) { for (let i = 0; i < cardCount; i++) {
const row: string[] = [(i + 1).toString()]; const row: string[] = [(i + 1).toString()];
// 为每个字段生成值 // 为每个字段生成值
for (const field of fields) { for (const field of fields) {
let value = ''; let value = "";
if (examples.length > 0) { if (examples.length > 0) {
// 从示例中循环取值 // 从示例中循环取值
const exampleIndex = i % examples.length; const exampleIndex = i % examples.length;
const example = examples[exampleIndex]; const example = examples[exampleIndex];
value = example[field.name] || field.examples?.[i % (field.examples?.length || 1)] || ''; value =
example[field.name] ||
field.examples?.[i % (field.examples?.length || 1)] ||
"";
} else if (field.examples && field.examples.length > 0) { } else if (field.examples && field.examples.length > 0) {
// 从字段的示例中取值 // 从字段的示例中取值
value = field.examples[i % field.examples.length]; value = field.examples[i % field.examples.length];
@@ -105,131 +96,49 @@ function generateCardCSV(
// 默认占位符 // 默认占位符
value = `{{${field.name}_${i + 1}}}`; value = `{{${field.name}_${i + 1}}}`;
} }
row.push(value); row.push(value);
} }
// body 列使用模板语法 // body 列使用模板语法
const bodyParts: string[] = []; const bodyParts: string[] = [];
for (const field of fields) { for (const field of fields) {
bodyParts.push(`**${field.name}:** {{${field.name}}}`); bodyParts.push(`**${field.name}:** {{${field.name}}}`);
} }
row.push(bodyParts.join('\n\n')); row.push(bodyParts.join("\n\n"));
rows.push(row); rows.push(row);
} }
// 组合 CSV 内容 // 组合 CSV 内容
const csvLines = [headers.join(',')]; const csvLines = [headers.join(",")];
for (const row of rows) { for (const row of rows) {
csvLines.push(row.map(cell => { csvLines.push(
// 处理包含逗号或换行的单元格 row
if (cell.includes(',') || cell.includes('\n') || cell.includes('"')) { .map((cell) => {
return `"${cell.replace(/"/g, '""')}"`; // 处理包含逗号或换行的单元格
} if (cell.includes(",") || cell.includes("\n") || cell.includes('"')) {
return cell; return `"${cell.replace(/"/g, '""')}"`;
}).join(',')); }
return cell;
})
.join(","),
);
} }
return csvLines.join('\n');
}
/** return csvLines.join("\n");
* 生成卡牌介绍的 Markdown 文件
*/
function generateDeckMarkdown(
deckName: string,
csvFileName: string,
deckConfig: DeckConfig,
description?: string
): string {
const mdLines: string[] = [];
// 标题
mdLines.push(`# ${deckName}`);
mdLines.push('');
// 描述
if (description) {
mdLines.push(description);
mdLines.push('');
}
// 卡牌预览组件
mdLines.push('## 卡牌预览');
mdLines.push('');
// 构建 :md-deck 组件代码
const deckComponent = buildDeckComponent(csvFileName, deckConfig);
mdLines.push(deckComponent);
mdLines.push('');
// 使用说明
mdLines.push('## 使用说明');
mdLines.push('');
mdLines.push('- 点击卡牌可以查看详情');
mdLines.push('- 使用右上角的按钮可以随机抽取卡牌');
mdLines.push('- 可以通过编辑面板调整卡牌样式和布局');
mdLines.push('');
return mdLines.join('\n');
}
/**
* 构建 :md-deck 组件代码
*/
function buildDeckComponent(
csvFileName: string,
config: DeckConfig
): string {
const parts = [`:md-deck[${csvFileName}]`];
const attrs: string[] = [];
if (config.size) {
attrs.push(`size="${config.size}"`);
}
if (config.grid) {
attrs.push(`grid="${config.grid}"`);
}
if (config.bleed !== undefined && config.bleed !== 1) {
attrs.push(`bleed="${config.bleed}"`);
}
if (config.padding !== undefined && config.padding !== 2) {
attrs.push(`padding="${config.padding}"`);
}
if (config.shape && config.shape !== 'rectangle') {
attrs.push(`shape="${config.shape}"`);
}
if (config.layers) {
attrs.push(`layers="${config.layers}"`);
}
if (config.backLayers) {
attrs.push(`back-layers="${config.backLayers}"`);
}
if (attrs.length > 0) {
parts.push(`{${attrs.join(' ')}}`);
}
return parts.join('');
} }
/** /**
* 自动生成图层配置 * 自动生成图层配置
*/ */
function autoGenerateLayers(fields: CardField[]): string { function autoGenerateLayers(fields: CardField[]): string {
if (fields.length === 0) return ''; if (fields.length === 0) return "";
const layers: string[] = []; const layers: string[] = [];
const totalHeight = 8; const totalHeight = 8;
const heightPerField = Math.floor((totalHeight - 2) / fields.length); const heightPerField = Math.floor((totalHeight - 2) / fields.length);
for (let i = 0; i < fields.length; i++) { for (let i = 0; i < fields.length; i++) {
const field = fields[i]; const field = fields[i];
const y1 = 2 + i * heightPerField; const y1 = 2 + i * heightPerField;
@@ -237,8 +146,8 @@ function autoGenerateLayers(fields: CardField[]): string {
const fontSize = Math.min(12, Math.floor(80 / fields.length)); const fontSize = Math.min(12, Math.floor(80 / fields.length));
layers.push(`${field.name}:1,${y1}-${y2},${fontSize}`); layers.push(`${field.name}:1,${y1}-${y2},${fontSize}`);
} }
return layers.join(' '); return layers.join(" ");
} }
/** /**
@@ -256,61 +165,66 @@ export function generateCardDeck(params: GenerateCardDeckParams): {
card_count = 10, card_count = 10,
card_template, card_template,
deck_config = {}, deck_config = {},
description description,
} = params; } = params;
// 确保输出目录存在 // 确保输出目录存在
if (!existsSync(output_dir)) { if (!existsSync(output_dir)) {
mkdirSync(output_dir, { recursive: true }); mkdirSync(output_dir, { recursive: true });
} }
// 生成文件名 // 生成文件名
const safeName = deck_name.toLowerCase().replace(/\s+/g, '-'); const safeName = deck_name.toLowerCase().replace(/\s+/g, "-");
const csvFileName = `${safeName}.csv`; const csvFileName = `${safeName}.csv`;
const mdFileName = `${safeName}.md`; const mdFileName = `${safeName}.md`;
// 创建默认模板(如果没有提供) // 创建默认模板(如果没有提供)
const template: CardTemplate = card_template || { const template: CardTemplate = card_template || {
fields: [ fields: [
{ name: 'name', description: '卡牌名称', examples: ['示例卡牌 1', '示例卡牌 2'] }, {
{ name: 'type', description: '卡牌类型', examples: ['物品', '法术'] }, name: "name",
{ name: 'cost', description: '费用', examples: ['1', '2'] }, description: "卡牌名称",
{ name: 'description', description: '效果描述', examples: ['这是一个效果描述', '这是另一个效果'] } examples: ["示例卡牌 1", "示例卡牌 2"],
] },
{ name: "type", description: "卡牌类型", examples: ["物品", "法术"] },
{ name: "cost", description: "费用", examples: ["1", "2"] },
{
name: "description",
description: "效果描述",
examples: ["这是一个效果描述", "这是另一个效果"],
},
],
}; };
// 创建默认配置(如果没有提供) // 创建默认配置(如果没有提供)
const config: DeckConfig = { const config: DeckConfig = {
size: deck_config.size || '54x86', size: deck_config.size || "54x86",
grid: deck_config.grid || '5x8', grid: deck_config.grid || "5x8",
bleed: deck_config.bleed ?? 1, bleed: deck_config.bleed ?? 1,
padding: deck_config.padding ?? 2, padding: deck_config.padding ?? 2,
shape: deck_config.shape || 'rectangle', shape: deck_config.shape || "rectangle",
layers: deck_config.layers || autoGenerateLayers(template.fields) layers: deck_config.layers || autoGenerateLayers(template.fields),
}; };
// 生成 CSV 内容 // 生成 CSV 内容
const csvContent = generateCardCSV(template, card_count); const csvContent = generateCardCSV(template, card_count);
const csvPath = join(output_dir, csvFileName); const csvPath = join(output_dir, csvFileName);
writeFileSync(csvPath, csvContent, 'utf-8'); writeFileSync(csvPath, csvContent, "utf-8");
// 生成 Markdown 内容 // 生成 Markdown 内容
const mdContent = generateDeckMarkdown( const deckComponent = buildDeckComponent(`./${csvFileName}`, config);
const mdContent = generateDeckPreviewMd(
deck_name, deck_name,
`./${csvFileName}`, deckComponent,
config, description,
description
); );
const mdPath = join(output_dir, mdFileName); const mdPath = join(output_dir, mdFileName);
writeFileSync(mdPath, mdContent, 'utf-8'); writeFileSync(mdPath, mdContent, "utf-8");
// 构建完整的 deck 组件代码
const deckComponent = buildDeckComponent(`./${csvFileName}`, config);
return { return {
mdFile: mdPath, mdFile: mdPath,
csvFile: csvPath, csvFile: csvPath,
deckComponent, deckComponent,
message: `已生成卡牌组 "${deck_name}":\n- Markdown 文件:${mdPath}\n- CSV 数据:${csvPath}\n- 组件代码:${deckComponent}` message: `已生成卡牌组 "${deck_name}":\n- Markdown 文件:${mdPath}\n- CSV 数据:${csvPath}\n- 组件代码:${deckComponent}`,
}; };
} }
+53 -148
View File
@@ -1,8 +1,13 @@
import { readFileSync, writeFileSync, existsSync } from 'fs'; import { readFileSync, writeFileSync, existsSync } from "fs";
import { dirname, join, basename, extname, relative } from 'path'; import { relative } from "path";
import { execSync } from 'child_process'; import { execSync } from "child_process";
import yaml from 'js-yaml'; import {
import type { DeckFrontmatter, DeckConfig } from './frontmatter/read-frontmatter.js'; buildDeckComponent,
titleFromCsvPath,
defaultMdPath,
readDeckConfig,
generateDeckPreviewMd,
} from "./deck-utils.js";
/** /**
* 预览 Deck 的参数 * 预览 Deck 的参数
@@ -37,88 +42,35 @@ export interface PreviewDeckResult {
preview_url?: string; preview_url?: string;
} }
/**
* 解析 CSV 文件的 frontmatter
*/
function parseFrontMatter(content: string): { frontmatter?: DeckFrontmatter; csvContent: string } {
const parts = content.trim().split(/(?:^|\n)---\s*\n/g);
if (parts.length !== 3 || parts[0] !== '') {
return { csvContent: content };
}
try {
const frontmatterStr = parts[1].trim();
const frontmatter = yaml.load(frontmatterStr) as DeckFrontmatter | undefined;
const csvContent = parts.slice(2).join('---\n').trimStart();
return { frontmatter, csvContent };
} catch (error) {
console.warn('Failed to parse front matter:', error);
return { csvContent: content };
}
}
/**
* 构建 md-deck 组件代码
*/
function buildDeckComponent(csvPath: string, config?: DeckConfig): string {
const parts = [`:md-deck[${csvPath}]`];
const attrs: string[] = [];
if (config?.size) {
attrs.push(`size="${config.size}"`);
}
if (config?.grid) {
attrs.push(`grid="${config.grid}"`);
}
if (config?.bleed !== undefined && config.bleed !== 1) {
attrs.push(`bleed="${config.bleed}"`);
}
if (config?.padding !== undefined && config.padding !== 2) {
attrs.push(`padding="${config.padding}"`);
}
if (config?.shape && config.shape !== 'rectangle') {
attrs.push(`shape="${config.shape}"`);
}
if (config?.layers) {
attrs.push(`layers="${config.layers}"`);
}
if (config?.back_layers) {
attrs.push(`back-layers="${config.back_layers}"`);
}
if (attrs.length > 0) {
parts.push(`{${attrs.join(' ')}}`);
}
return parts.join('');
}
/** /**
* 打开浏览器 * 打开浏览器
*/ */
function openBrowser(url: string) { function openBrowser(url: string) {
try { try {
// Windows 使用 start 命令 if (process.platform === "win32") {
if (process.platform === 'win32') { execSync(`start "" "${url}"`, { stdio: "ignore" });
execSync(`start "" "${url}"`, { stdio: 'ignore' }); } else if (process.platform === "darwin") {
} else if (process.platform === 'darwin') { execSync(`open "${url}"`, { stdio: "ignore" });
execSync(`open "${url}"`, { stdio: 'ignore' });
} else { } else {
execSync(`xdg-open "${url}"`, { stdio: 'ignore' }); execSync(`xdg-open "${url}"`, { stdio: "ignore" });
} }
console.error(`已打开浏览器:${url}`); console.error(`已打开浏览器:${url}`);
} catch (error) { } catch (error) {
console.error('打开浏览器失败:', error); console.error("打开浏览器失败:", error);
} }
} }
/**
* 计算预览 URL
*/
function computePreviewUrl(mdFilePath: string): string {
const relativeMdPath = relative(process.cwd(), mdFilePath)
.split("\\")
.join("/")
.replace(/\.md$/, "");
return `http://localhost:3000/${relativeMdPath}`;
}
/** /**
* 预览 Deck - 保存 Markdown 并打开浏览器 * 预览 Deck - 保存 Markdown 并打开浏览器
*/ */
@@ -129,108 +81,61 @@ export function previewDeck(params: PreviewDeckParams): PreviewDeckResult {
if (!existsSync(csv_file)) { if (!existsSync(csv_file)) {
return { return {
success: false, success: false,
message: `CSV 文件不存在:${csv_file}` message: `CSV 文件不存在:${csv_file}`,
}; };
} }
// 确定 Markdown 文件路径 // 确定 Markdown 文件路径
let mdFilePath = md_file; const mdFilePath = md_file || defaultMdPath(csv_file);
if (!mdFilePath) {
// 使用与 CSV 相同的路径和文件名,只是扩展名不同
const dir = dirname(csv_file);
const name = basename(csv_file, extname(csv_file));
mdFilePath = join(dir, `${name}.md`);
}
const created = !existsSync(mdFilePath); const created = !existsSync(mdFilePath);
// 如果文件已存在,检查是否已有 md-deck 组件 // 如果文件已存在,检查是否已有 md-deck 组件
if (!created) { if (!created) {
try { try {
const existingContent = readFileSync(mdFilePath, 'utf-8'); const existingContent = readFileSync(mdFilePath, "utf-8");
// 如果已有 md-deck 组件引用该 CSV,直接返回 if (
if (existingContent.includes(`:md-deck[${csv_file}]`) || existingContent.includes(`:md-deck[${csv_file}]`) ||
existingContent.includes(`:md-deck[./${basename(csv_file)}]`)) { existingContent.includes(`:md-deck[./${csv_file.split("/").pop()}]`)
// 仍然打开浏览器 ) {
const relativeMdPath = relative(process.cwd(), mdFilePath).split('\\').join('/'); const previewUrl = computePreviewUrl(mdFilePath);
const previewUrl = `http://localhost:3000/${relativeMdPath}`;
openBrowser(previewUrl); openBrowser(previewUrl);
return { return {
success: true, success: true,
message: `预览文件 ${mdFilePath} 已存在`, message: `预览文件 ${mdFilePath} 已存在`,
md_file: mdFilePath, md_file: mdFilePath,
created: false, created: false,
preview_url: previewUrl preview_url: previewUrl,
}; };
} }
} catch (error) { } catch {
// 读取失败,继续创建 // 读取失败,继续创建
} }
} }
// 读取 CSV 的 frontmatter 获取配置 // 读取 CSV 的 frontmatter 获取配置
let deckConfig: DeckConfig | undefined; const deckConfig = readDeckConfig(csv_file);
try {
const csvContent = readFileSync(csv_file, 'utf-8');
const { frontmatter } = parseFrontMatter(csvContent);
deckConfig = frontmatter?.deck;
} catch (error) {
// 忽略错误,使用默认配置
}
// 确定标题 // 确定标题
let deckTitle = title; const deckTitle = title || titleFromCsvPath(csv_file);
if (!deckTitle) {
// 从 CSV 文件名推断
const name = basename(csv_file, extname(csv_file));
deckTitle = name.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}
// 构建 md-deck 组件代码(使用相对路径) // 构建 md-deck 组件代码
const mdDir = dirname(mdFilePath); const deckComponent = buildDeckComponent(
const csvDir = dirname(csv_file); `./${csv_file.split("/").pop()}`,
let relativeCsvPath: string; deckConfig,
);
if (mdDir === csvDir) {
relativeCsvPath = `./${basename(csv_file)}`;
} else {
relativeCsvPath = `./${basename(csv_file)}`;
}
const deckComponent = buildDeckComponent(relativeCsvPath, deckConfig);
// 生成 Markdown 内容 // 生成 Markdown 内容
const mdLines: string[] = []; const mdContent = generateDeckPreviewMd(
deckTitle,
mdLines.push(`# ${deckTitle}`); deckComponent,
mdLines.push(''); description,
);
if (description) {
mdLines.push(description);
mdLines.push('');
}
mdLines.push('## 卡牌预览');
mdLines.push('');
mdLines.push(deckComponent);
mdLines.push('');
mdLines.push('## 使用说明');
mdLines.push('');
mdLines.push('- 点击卡牌可以查看详情');
mdLines.push('- 使用右上角的按钮可以随机抽取卡牌');
mdLines.push('- 可以通过编辑面板调整卡牌样式和布局');
mdLines.push('');
// 写入文件 // 写入文件
try { try {
writeFileSync(mdFilePath, mdLines.join('\n'), 'utf-8'); writeFileSync(mdFilePath, mdContent, "utf-8");
// 计算预览 URL const previewUrl = computePreviewUrl(mdFilePath);
const relativeMdPath = relative(process.cwd(), mdFilePath).split('\\').join('/');
const previewUrl = `http://localhost:3000/${relativeMdPath.slice(0, -3)}`;
// 打开浏览器
openBrowser(previewUrl); openBrowser(previewUrl);
return { return {
@@ -240,12 +145,12 @@ export function previewDeck(params: PreviewDeckParams): PreviewDeckResult {
: `更新预览文件 ${mdFilePath}`, : `更新预览文件 ${mdFilePath}`,
md_file: mdFilePath, md_file: mdFilePath,
created, created,
preview_url: previewUrl preview_url: previewUrl,
}; };
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
message: `写入失败:${error instanceof Error ? error.message : '未知错误'}` message: `写入失败:${error instanceof Error ? error.message : "未知错误"}`,
}; };
} }
} }
+10 -2
View File
@@ -108,7 +108,15 @@ export const Article: Component<ArticleProps & ParentProps> = (props) => {
if (href.startsWith("#")) return; if (href.startsWith("#")) return;
e.preventDefault(); 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); dom.addEventListener("click", onClick);
@@ -121,7 +129,7 @@ export const Article: Component<ArticleProps & ParentProps> = (props) => {
return ( return (
<article <article
class={`prose text-black ${props.class || ""}`} class={`prose ${props.class || ""}`}
data-src={props.src} data-src={props.src}
> >
<Show when={content.loading}> <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;
+3 -64
View File
@@ -162,73 +162,12 @@ const DocContent: Component<{ entry: AnyEntry }> = (props) => {
return ( return (
<div class="max-w-none"> <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} {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> </h2>
<p class="text-gray-600 mb-6">{e.description}</p> <div class="text-sm leading-relaxed whitespace-pre-wrap text-gray-800">
{e.body}
<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">
{e.body}
</div>
</div> </div>
</div> </div>
); );
+23 -9
View File
@@ -1,4 +1,5 @@
import { Component, createMemo, createSignal, Show } from "solid-js"; import { Component, createMemo, createSignal, Show } from "solid-js";
import { useLocation } from "@solidjs/router";
import { type FileNode, type TocNode } from "../data-loader"; import { type FileNode, type TocNode } from "../data-loader";
import { useNavigateWithParams } from "./useNavigateWithParams"; import { useNavigateWithParams } from "./useNavigateWithParams";
import { useScrollContainer } from "../App"; import { useScrollContainer } from "../App";
@@ -24,6 +25,7 @@ export const FileTreeNode: Component<{
isHidden?: (node: FileNode) => boolean; isHidden?: (node: FileNode) => boolean;
}> = (props) => { }> = (props) => {
const navigate = useNavigateWithParams(); const navigate = useNavigateWithParams();
const location = useLocation();
const isDir = !!props.node.children; const isDir = !!props.node.children;
const isActive = createMemo(() => props.currentPath === props.node.path); const isActive = createMemo(() => props.currentPath === props.node.path);
// 默认收起,除非当前文件在该文件夹内 // 默认收起,除非当前文件在该文件夹内
@@ -31,21 +33,28 @@ export const FileTreeNode: Component<{
isDir && isPathInDir(props.currentPath, props.node.path), isDir && isPathInDir(props.currentPath, props.node.path),
); );
const handleClick = () => { const href = () => props.node.path + location.search;
const handleClick = (e: MouseEvent) => {
if (isDir) { if (isDir) {
e.preventDefault();
setIsExpanded(!isExpanded()); setIsExpanded(!isExpanded());
} else { } else if (e.button === 0) {
// Left-click: use SPA navigation
e.preventDefault();
navigate(props.node.path); navigate(props.node.path);
props.onClose(); props.onClose();
} }
// Middle-click / right-click: let the browser handle the <a> natively
}; };
const indent = props.depth * 12; const indent = props.depth * 12;
return ( return (
<div class={props.isHidden?.(props.node) === true ? "hidden" : ""}> <div class={props.isHidden?.(props.node) === true ? "hidden" : ""}>
<div <a
class={`flex items-center py-1 px-2 cursor-pointer hover:bg-gray-100 rounded ${ href={href()}
class={`flex items-center py-1 px-2 cursor-pointer hover:bg-gray-100 rounded no-underline ${
isActive() ? "bg-blue-50 text-blue-700" : "text-gray-700" isActive() ? "bg-blue-50 text-blue-700" : "text-gray-700"
}`} }`}
style={{ "padding-left": `${indent + 8}px` }} style={{ "padding-left": `${indent + 8}px` }}
@@ -58,7 +67,7 @@ export const FileTreeNode: Component<{
<span class="mr-1 text-gray-400">📄</span> <span class="mr-1 text-gray-400">📄</span>
</Show> </Show>
<span class="text-sm truncate">{props.node.name}</span> <span class="text-sm truncate">{props.node.name}</span>
</div> </a>
<Show when={isDir && isExpanded() && props.node.children}> <Show when={isDir && isExpanded() && props.node.children}>
<div> <div>
{props.node.children!.map((child) => ( {props.node.children!.map((child) => (
@@ -87,8 +96,9 @@ export const HeadingNode: Component<{
isHidden?: (node: TocNode) => boolean; isHidden?: (node: TocNode) => boolean;
}> = (props) => { }> = (props) => {
const navigate = useNavigateWithParams(); const navigate = useNavigateWithParams();
const location = useLocation();
const anchor = props.node.id || ""; const anchor = props.node.id || "";
const href = `${props.basePath}#${anchor}`; const href = () => `${props.basePath}${location.search}#${anchor}`;
const hasChildren = !!props.node.children; const hasChildren = !!props.node.children;
// 默认收起,除非当前锚点在该节点内 // 默认收起,除非当前锚点在该节点内
const [isExpanded, setIsExpanded] = createSignal(props.depth <= 0); const [isExpanded, setIsExpanded] = createSignal(props.depth <= 0);
@@ -102,8 +112,12 @@ export const HeadingNode: Component<{
const scrollContainer = useScrollContainer(); const scrollContainer = useScrollContainer();
const handleClick = (e: MouseEvent) => { const handleClick = (e: MouseEvent) => {
e.preventDefault(); if (e.button === 0) {
navigate(href); // Left-click: use SPA navigation
e.preventDefault();
navigate(`${props.basePath}#${anchor}`);
}
// Middle-click / right-click: let the browser handle the <a> natively
// 滚动到目标元素,考虑导航栏高度偏移 // 滚动到目标元素,考虑导航栏高度偏移
requestAnimationFrame(() => { requestAnimationFrame(() => {
const element = document.getElementById(anchor); const element = document.getElementById(anchor);
@@ -141,7 +155,7 @@ export const HeadingNode: Component<{
</span> </span>
</span> </span>
<a <a
href={href} href={href()}
class="inline-flex items-center py-0.5 px-2 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-50 rounded truncate cursor-pointer" class="inline-flex items-center py-0.5 px-2 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-50 rounded truncate cursor-pointer"
onClick={handleClick} onClick={handleClick}
> >
+3 -13
View File
@@ -18,7 +18,6 @@ import {
import { Portal } from "solid-js/web"; import { Portal } from "solid-js/web";
import { useLocation } from "@solidjs/router"; import { useLocation } from "@solidjs/router";
import { useArticleDom } from "./Article"; import { useArticleDom } from "./Article";
import { useScrollContainer } from "../App";
import { journalStreamState } from "./stores/journalStream"; import { journalStreamState } from "./stores/journalStream";
import { useJournalCompletions } from "./journal/completions"; import { useJournalCompletions } from "./journal/completions";
import { import {
@@ -76,7 +75,6 @@ function hide() {
export const RevealManager: Component = () => { export const RevealManager: Component = () => {
const contentDom = useArticleDom(); const contentDom = useArticleDom();
const scrollContainer = useScrollContainer();
const location = useLocation(); const location = useLocation();
const comp = useJournalCompletions(); const comp = useJournalCompletions();
@@ -128,7 +126,7 @@ export const RevealManager: Component = () => {
rect: sparkTable.getBoundingClientRect(), rect: sparkTable.getBoundingClientRect(),
action: () => action: () =>
setActionPrefill({ setActionPrefill({
command: "/spark", command: "/roll",
text: combinedSlug, text: combinedSlug,
}), }),
title: "Roll spark table", title: "Roll spark table",
@@ -181,16 +179,8 @@ export const RevealManager: Component = () => {
<button <button
class={`fixed z-50 inline-flex items-center justify-center w-5 h-5 rounded cursor-pointer ${btn().color}`} class={`fixed z-50 inline-flex items-center justify-center w-5 h-5 rounded cursor-pointer ${btn().color}`}
style={{ style={{
top: `${ top: `${btn().rect.top - 6}px`,
btn().rect.top + left: `${btn().rect.left - 20}px`,
(scrollContainer()?.scrollTop ?? window.scrollY) -
6
}px`,
left: `${
btn().rect.left +
(scrollContainer()?.scrollLeft ?? window.scrollX) -
20
}px`,
}} }}
title={btn().title} title={btn().title}
innerHTML={btn().svg} innerHTML={btn().svg}
+2 -9
View File
@@ -4,16 +4,12 @@ export interface DocEntry {
tag: string; tag: string;
icon: string; icon: string;
title: string; title: string;
description: string; /** Full markdown body (everything after frontmatter ---) */
syntax: string;
props: { name: string; type: string; default?: string; desc: string }[];
/** Markdown body (everything after frontmatter ---) */
body: string; body: string;
} }
/** Splits frontmatter and markdown body from a raw .md string. */ /** Splits frontmatter and markdown body from a raw .md string. */
function parseFrontmatter(raw: string): Record<string, unknown> | null { 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 normalized = raw.replace(/\r\n/g, "\n");
const match = normalized.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/); const match = normalized.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
if (!match) return null; if (!match) return null;
@@ -27,7 +23,7 @@ function parseFrontmatter(raw: string): Record<string, unknown> | null {
function getBody(raw: string): string { function getBody(raw: string): string {
const normalized = raw.replace(/\r\n/g, "\n"); const normalized = raw.replace(/\r\n/g, "\n");
const match = normalized.match(/^---\n[\s\S]*?\n---\n?([\s\S]*)$/); 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 { function parseEntry(raw: string): DocEntry | null {
@@ -37,9 +33,6 @@ function parseEntry(raw: string): DocEntry | null {
tag: fm.tag as string, tag: fm.tag as string,
icon: fm.icon as string, icon: fm.icon as string,
title: fm.title 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), body: getBody(raw),
}; };
} }
+2
View File
@@ -15,6 +15,8 @@ import "./md-yarn-spinner";
export { Article } from "./Article"; export { Article } from "./Article";
export type { ArticleProps } from "./Article"; export type { ArticleProps } from "./Article";
export { RevealManager } from "./RevealManager"; export { RevealManager } from "./RevealManager";
export { CommandLinkManager } from "./CommandLinkManager";
export { ReactiveVariableManager } from "./journal/ReactiveVariableManager";
export { MobileSidebar, DesktopSidebar } from "./Sidebar"; export { MobileSidebar, DesktopSidebar } from "./Sidebar";
export type { SidebarProps } from "./Sidebar"; export type { SidebarProps } from "./Sidebar";
export { FileTreeNode, HeadingNode } from "./FileTree"; export { FileTreeNode, HeadingNode } from "./FileTree";
+12 -8
View File
@@ -4,9 +4,7 @@ export interface JournalDocEntry {
tag: string; tag: string;
icon: string; icon: string;
title: string; title: string;
description: string; /** Full markdown body (everything after frontmatter ---) */
syntax: string;
props: { name: string; type: string; default?: string; desc: string }[];
body: string; body: string;
} }
@@ -24,7 +22,7 @@ function parseFrontmatter(raw: string): Record<string, unknown> | null {
function getBody(raw: string): string { function getBody(raw: string): string {
const normalized = raw.replace(/\r\n/g, "\n"); const normalized = raw.replace(/\r\n/g, "\n");
const match = normalized.match(/^---\n[\s\S]*?\n---\n?([\s\S]*)$/); 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 { function parseEntry(raw: string): JournalDocEntry | null {
@@ -34,17 +32,23 @@ function parseEntry(raw: string): JournalDocEntry | null {
tag: fm.tag as string, tag: fm.tag as string,
icon: fm.icon as string, icon: fm.icon as string,
title: fm.title 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), body: getBody(raw),
}; };
} }
import journalGmRaw from "../doc-entries/journal-gm.md"; import journalGmRaw from "../doc-entries/journal-gm.md";
import journalPlayerRaw from "../doc-entries/journal-player.md"; import journalPlayerRaw from "../doc-entries/journal-player.md";
import journalSetRaw from "../doc-entries/journal-set.md";
import journalRollRaw from "../doc-entries/journal-roll.md";
import journalLinkRaw from "../doc-entries/journal-link.md";
const rawDocuments: string[] = [journalGmRaw, journalPlayerRaw]; const rawDocuments: string[] = [
journalGmRaw,
journalPlayerRaw,
journalSetRaw,
journalRollRaw,
journalLinkRaw,
];
let _entries: JournalDocEntry[] | null = null; let _entries: JournalDocEntry[] | null = null;
@@ -0,0 +1,114 @@
/**
* CompletionsDropdown — autocomplete popover for journal command input.
*/
import { Component, For, Show, createEffect, onMount, onCleanup } from "solid-js";
import type { CompletionItem } from "./command-parser";
import type { CompletionsState } from "./completions";
export interface CompletionsDropdownProps {
show: boolean;
items: CompletionItem[];
selectedIdx: number;
onSelect: (item: CompletionItem) => void;
onHover: (idx: number) => void;
onClose: () => void;
state: CompletionsState;
}
export const CompletionsDropdown: Component<CompletionsDropdownProps> = (
props,
) => {
let containerRef!: HTMLDivElement;
// Scroll selected into view
createEffect(() => {
const idx = props.selectedIdx;
if (!props.show || !containerRef) return;
const el = containerRef.querySelector(`[data-comp-idx="${idx}"]`);
if (el) el.scrollIntoView({ block: "nearest" });
});
// Click outside
onMount(() => {
const handler = (e: MouseEvent) => {
if (containerRef && !containerRef.contains(e.target as Node)) {
props.onClose();
}
};
document.addEventListener("mousedown", handler);
onCleanup(() => document.removeEventListener("mousedown", handler));
});
return (
<Show when={props.show}>
<Show
when={props.state.status === "loaded" || props.state.status === "empty"}
fallback={
<Show
when={props.state.status === "loading"}
fallback={
<div
ref={containerRef}
class="absolute bottom-full left-0 right-0 bg-white border border-red-200 rounded-t shadow-lg mb-0.5 z-50 px-3 py-2 text-xs text-red-500"
>
{(props.state as any).message || "无法加载补全数据"}
</div>
}
>
<div
ref={containerRef}
class="absolute bottom-full left-0 right-0 bg-white border border-gray-200 rounded-t shadow-lg mb-0.5 z-50 px-3 py-2 text-xs text-gray-400 italic"
>
</div>
</Show>
}
>
<Show when={props.items.length > 0}>
<div
ref={containerRef}
class="absolute bottom-full left-0 right-0 bg-white border border-gray-200 rounded-t shadow-lg mb-0.5 max-h-40 overflow-y-auto z-50"
>
<For each={props.items}>
{(item, idx) => (
<div
data-comp-idx={idx()}
class={`flex items-center gap-2 px-3 py-1.5 cursor-pointer text-sm ${
item.kind === "no-results"
? "text-gray-400 italic cursor-default"
: idx() === props.selectedIdx
? "bg-blue-50 text-blue-700"
: "hover:bg-gray-100 text-gray-700"
}`}
onClick={() => props.onSelect(item)}
onMouseEnter={() =>
item.kind !== "no-results" && props.onHover(idx())
}
>
<Show
when={item.kind !== "no-results"}
fallback={
<span class="text-xs text-gray-300 shrink-0"></span>
}
>
<span
class={`text-xs px-1 rounded shrink-0 ${
item.kind === "command"
? "bg-purple-100 text-purple-700"
: "bg-gray-100 text-gray-500"
}`}
>
{item.kind === "command" ? "cmd" : "val"}
</span>
</Show>
<span class="font-mono truncate flex-1">{item.label}</span>
</div>
)}
</For>
</div>
</Show>
</Show>
</Show>
);
};
-1
View File
@@ -5,7 +5,6 @@
import { Component, createSignal, For, Show, createMemo } from "solid-js"; import { Component, createSignal, For, Show, createMemo } from "solid-js";
import { registeredTypes, canEmit } from "./registry"; import { registeredTypes, canEmit } from "./registry";
import { sendMessage, useJournalStream } from "../stores/journalStream"; import { sendMessage, useJournalStream } from "../stores/journalStream";
import type { MessageTypeDef } from "./registry";
import { DynamicForm } from "./DynamicForm"; import { DynamicForm } from "./DynamicForm";
export const ComposePanel: Component = () => { export const ComposePanel: Component = () => {
+6 -1
View File
@@ -40,7 +40,12 @@ export const ConnectDialog: Component = () => {
const name = playerName().trim() || stream.myName; const name = playerName().trim() || stream.myName;
if (!name) return; 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); setConnecting(true);
setError(null); setError(null);
+5 -1
View File
@@ -25,7 +25,11 @@ export const ConnectionBar: Component = () => {
const handleCreateSession = () => { const handleCreateSession = () => {
const name = newSessionName().trim(); const name = newSessionName().trim();
if (!name) return; if (!name) return;
createSession(name); const id = createSession(name);
if (id) {
setSessionId(id);
hydrateFromServer(id).catch(() => {});
}
setNewSessionName(""); setNewSessionName("");
setShowNewSession(false); setShowNewSession(false);
}; };
+11 -3
View File
@@ -2,7 +2,7 @@
* CreateSessionDialog — modal for naming a new session * CreateSessionDialog — modal for naming a new session
*/ */
import { Component, createSignal, onMount } from "solid-js"; import { Component, createSignal, onMount } from "solid-js";
import { createSession } from "../stores/journalStream"; import { createSession, setSessionId, hydrateFromServer } from "../stores/journalStream";
interface CreateSessionDialogProps { interface CreateSessionDialogProps {
onClose: () => void; onClose: () => void;
@@ -12,13 +12,21 @@ export const CreateSessionDialog: Component<CreateSessionDialogProps> = (
props, props,
) => { ) => {
const [name, setName] = createSignal(""); const [name, setName] = createSignal("");
const [creating, setCreating] = createSignal(false);
let inputRef!: HTMLInputElement; let inputRef!: HTMLInputElement;
const handleCreate = () => { const handleCreate = async () => {
const trimmed = name().trim(); const trimmed = name().trim();
if (!trimmed) return; 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(""); setName("");
setCreating(false);
props.onClose(); props.onClose();
}; };
+1 -5
View File
@@ -9,7 +9,6 @@
import { Component, For, Show, createMemo } from "solid-js"; import { Component, For, Show, createMemo } from "solid-js";
import { z } from "zod"; import { z } from "zod";
import { getMessageType } from "./registry"; import { getMessageType } from "./registry";
import type { MessageTypeDef } from "./registry";
type FormData = Record<string, unknown>; type FormData = Record<string, unknown>;
@@ -22,9 +21,6 @@ interface DynamicFormProps {
export const DynamicForm: Component<DynamicFormProps> = (props) => { export const DynamicForm: Component<DynamicFormProps> = (props) => {
const def = createMemo(() => getMessageType(props.type)); const def = createMemo(() => getMessageType(props.type));
// Generate default on type change
const schema = createMemo(() => def()?.schema);
return ( return (
<div class="space-y-1.5"> <div class="space-y-1.5">
<Show when={def()}> <Show when={def()}>
@@ -136,7 +132,7 @@ const FieldInput: Component<{
return ( return (
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<label <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} title={props.label}
> >
{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>
);
};
+115 -332
View File
@@ -5,72 +5,22 @@
* - Plain text → "chat" type * - Plain text → "chat" type
* - `/roll 3d6kh1` → "roll" type (result resolved client-side by GM) * - `/roll 3d6kh1` → "roll" type (result resolved client-side by GM)
* - `/link path#section` → "link" type * - `/link path#section` → "link" type
* - `/` alone opens completions dropdown (populated from /__COMPLETIONS.json * - `/stat set key=value` → "stat" type
* in CLI mode, or client-side scan of the in-memory file index in dev mode) * - `/` alone opens completions dropdown
*
* Delegates command dispatch to the shared command-dispatcher module.
*/ */
import { import { Component, createSignal, createEffect, onMount, Show } from "solid-js";
Component,
createSignal,
createEffect,
onMount,
onCleanup,
Show,
For,
Switch,
Match,
} from "solid-js";
import { sendMessage, useJournalStream } from "../stores/journalStream"; import { sendMessage, useJournalStream } from "../stores/journalStream";
import { actionPrefill, setActionPrefill } from "../stores/reveal"; import { actionPrefill, setActionPrefill } from "../stores/reveal";
import { useJournalCompletions, ensureCompletions } from "./completions"; import { useJournalCompletions, ensureCompletions } from "./completions";
import { resolveRollPayload } from "./types/roll"; import { parseInput } from "./command-parser";
import { resolveSparkPayload } from "./types/spark"; import type { CompletionItem } from "./command-parser";
import { buildCompletions } from "./command-completions";
// ---- Helpers ---- import { CompletionsDropdown } from "./CompletionsDropdown";
import { ErrorPopup } from "./ErrorPopup";
interface CompletionItem { import { dispatchCommand, dispatchError, setDispatchError } from "./command-dispatcher";
label: string;
kind: "command" | "value" | "no-results";
insertText: string;
}
interface ParsedInput {
type: "chat" | "roll" | "spark" | "link";
payload: Record<string, unknown>;
error?: string;
}
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 } };
}
if (raw.startsWith("/link ")) {
const arg = raw.slice("/link ".length).trim();
if (!arg) return { type: "link", payload: {}, error: "需要路径" };
const hashIdx = arg.indexOf("#");
const path = hashIdx === -1 ? arg : arg.slice(0, hashIdx);
const section =
hashIdx === -1 ? undefined : arg.slice(hashIdx + 1) || undefined;
return { type: "link", payload: { path, section } };
}
// /roll, /spark, or /link with no space — need to complete, don't send
if (raw === "/roll" || raw === "/spark" || raw === "/link") {
return { type: "chat", payload: {}, error: "请补全命令" };
}
return { type: "chat", payload: { text: raw } };
}
// ---- Component ---- // ---- Component ----
@@ -83,20 +33,19 @@ export const JournalInput: Component = () => {
const isGm = () => stream.myRole === "gm"; const isGm = () => stream.myRole === "gm";
const [text, setText] = createSignal(""); const [text, setText] = createSignal("");
const [error, setError] = createSignal<string | null>(null); const [showErrorPopup, setShowErrorPopup] = createSignal(false);
const [sending, setSending] = createSignal(false); const [sending, setSending] = createSignal(false);
const [showCompletions, setShowCompletions] = createSignal(false); const [showCompletions, setShowCompletions] = createSignal(false);
const [selectedIdx, setSelectedIdx] = createSignal(0); const [selectedIdx, setSelectedIdx] = createSignal(0);
let textareaRef!: HTMLTextAreaElement; let textareaRef!: HTMLTextAreaElement;
let completionsRef!: HTMLDivElement;
// Ensure completions are loading on mount // Ensure completions are loading on mount
onMount(() => { onMount(() => {
void ensureCompletions(); void ensureCompletions();
}); });
// Listen for action prefill requests from article buttons (heading /link, spark table roll, etc.) // Listen for action prefill requests from article buttons
createEffect(() => { createEffect(() => {
const action = actionPrefill(); const action = actionPrefill();
if (action) { if (action) {
@@ -106,196 +55,96 @@ export const JournalInput: Component = () => {
} }
}); });
// ---- Derived ----
const completions = () =>
buildCompletions(text().trim(), {
data: comp.data,
isGm: isGm(),
});
// ---- Send ---- // ---- Send ----
async function handleSend() { async function handleSend() {
const raw = text().trim(); const raw = text().trim();
if (!raw) return; if (!raw) return;
// Players / observers: everything is plain chat // Observers: everything is plain chat
if (isPlayer() || isObserver()) { if (isObserver()) {
const result = sendMessage("chat", { text: raw }); const result = sendMessage("chat", { text: raw });
if (!result.success) { const r = unwrapSendResult(result);
setError(result.error); finish(r.ok, r.error);
} else {
setText("");
}
textareaRef?.focus();
return; return;
} }
const parsed = parseInput(raw); const parsed = parseInput(raw);
if (parsed.error) { if (parsed.error) {
setError(parsed.error); setDispatchError(parsed.error);
return; return;
} }
setError(null); setDispatchError(null);
// Players: chat + set commands only
if (isPlayer()) {
if (parsed.type === "chat") {
const result = sendMessage("chat", { text: raw });
const r = unwrapSendResult(result);
finish(r.ok, r.error);
return;
}
if (parsed.type !== "set" && parsed.type !== "rolltag") {
setDispatchError("玩家只能发送聊天消息或使用 /set 命令");
return;
}
}
// GM: all commands, Player: set commands
setSending(true); setSending(true);
// GM roll: resolve the dice result locally await dispatchCommand({
if (parsed.type === "roll") { role: stream.myRole as "gm" | "player" | "observer",
const p = resolveRollPayload( myName: stream.myName,
parsed.payload as { notation: string; label?: string }, command: raw,
); sparkTables: comp.data.sparkTables,
const result = sendMessage("roll", p); variables: stream.variables,
if (!result.success) { declarations: comp.data.declarations,
setError(result.error); tagModifiers: comp.data.tagModifiers,
} else { });
setText("");
}
setSending(false);
textareaRef?.focus();
return;
}
// GM spark: resolve the spark table roll locally // dispatchCommand already set the shared error if it failed.
if (parsed.type === "spark") { // Only clear text on success (no error is shown).
try { if (!dispatchError()) {
const key = (parsed.payload as { key: string }).key;
// Look up filePath from completions data
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);
if (!result.success) {
setError(result.error);
} else {
setText("");
}
setSending(false);
textareaRef?.focus();
return;
} catch (e) {
setError(e instanceof Error ? e.message : "种子表掷骰失败");
setSending(false);
return;
}
}
const result = sendMessage(parsed.type, parsed.payload);
if (!result.success) {
setError(result.error);
} else {
setText(""); setText("");
} }
setSending(false); setSending(false);
textareaRef?.focus(); textareaRef?.focus();
} }
// ---- Completions ---- /** Clear text or set shared dispatch error on failure. */
// ---- Scroll selected completion into view ---- function finish(success: boolean, err?: string) {
if (success) {
createEffect(() => { setText("");
const idx = selectedIdx(); setDispatchError(null);
if (!showCompletions() || !completionsRef) return; } else if (err) {
const el = completionsRef.querySelector(`[data-comp-idx="${idx}"]`); setDispatchError(err);
if (el) el.scrollIntoView({ block: "nearest" });
});
// ---- Completions ----
function buildCompletions(): CompletionItem[] {
const raw = text().trim();
// Only GM gets completions
if (!isGm()) return [];
const data = comp.data;
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 " },
];
// Show commands when user types / or starts typing a command name
if (raw.startsWith("/") && !raw.includes(" ")) {
const prefix = raw.toLowerCase();
const matches = commands.filter((c) =>
c.label.toLowerCase().startsWith(prefix),
);
return matches.length > 0
? matches
: [{ label: "未知命令", kind: "no-results", insertText: "" }];
} }
setSending(false);
// After /roll — show dice suggestions textareaRef?.focus();
if (raw.startsWith("/roll ")) {
const prefix = raw.slice("/roll ".length).toLowerCase();
const matches = 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,
}));
}
// After /spark — show spark table suggestions
if (raw.startsWith("/spark ")) {
const prefix = raw.slice("/spark ".length).toLowerCase();
const matches = 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: "",
},
];
}
return matches.map((s) => ({
label: `${s.slug} (${s.notation})`,
kind: "value" as const,
insertText: `/spark ${s.slug}`,
}));
}
// After /link — show article and heading suggestions
if (raw.startsWith("/link ")) {
const prefix = raw.slice("/link ".length).toLowerCase();
const matches = data.links
.filter(
(l) =>
l.path.toLowerCase().includes(prefix) ||
l.label.toLowerCase().includes(prefix),
)
.slice(0, 8);
if (matches.length === 0) {
return [{ label: "未找到链接", kind: "no-results", insertText: "" }];
}
return matches.map((l) => {
const insert = l.section
? `/link ${l.path}#${l.section}`
: `/link ${l.path}`;
return {
label: l.label,
kind: "value" as const,
insertText: insert,
};
});
}
return [];
} }
function currentCompletions(): CompletionItem[] { /** Unwrap a sendMessage result into (success, error?) for finish(). */
return buildCompletions(); function unwrapSendResult<R>(
r: { success: true; msg: R } | { success: false; error: string },
) {
return r.success
? ({ ok: true, error: undefined } as const)
: ({ ok: false, error: r.error } as const);
} }
// ---- Completions ----
function acceptCompletion(item: CompletionItem) { function acceptCompletion(item: CompletionItem) {
if (item.kind === "no-results") return; if (item.kind === "no-results") return;
setText(item.insertText); setText(item.insertText);
@@ -304,7 +153,7 @@ export const JournalInput: Component = () => {
} }
function selectCompletion(dir: "up" | "down") { function selectCompletion(dir: "up" | "down") {
const comps = currentCompletions(); const comps = completions();
if (comps.length === 0) return; if (comps.length === 0) return;
setSelectedIdx((prev) => { setSelectedIdx((prev) => {
if (dir === "down") return (prev + 1) % comps.length; if (dir === "down") return (prev + 1) % comps.length;
@@ -312,14 +161,17 @@ export const JournalInput: Component = () => {
}); });
} }
function openCompletions() {
setShowCompletions(true);
setSelectedIdx(0);
}
// ---- Keyboard ---- // ---- Keyboard ----
function handleKeyDown(e: KeyboardEvent) { function handleKeyDown(e: KeyboardEvent) {
const comps = currentCompletions(); const comps = completions();
const open = showCompletions(); const open = showCompletions();
// When opening completions with Tab, if the text matches a command prefix
// and there are options, accept the first. Otherwise just show.
if (e.key === "Tab") { if (e.key === "Tab") {
if (open) { if (open) {
e.preventDefault(); e.preventDefault();
@@ -328,12 +180,10 @@ export const JournalInput: Component = () => {
} }
return; return;
} }
// If not open and starts with /, open completions (GM only)
const raw = text(); const raw = text();
if (isGm() && raw.startsWith("/")) { if ((isGm() || raw.startsWith("/set")) && raw.startsWith("/")) {
e.preventDefault(); e.preventDefault();
setShowCompletions(true); openCompletions();
setSelectedIdx(0);
return; return;
} }
return; return;
@@ -350,9 +200,9 @@ export const JournalInput: Component = () => {
selectCompletion("up"); selectCompletion("up");
return; return;
} }
if (e.key === "Enter" && comps[selectedIdx()].kind !== "no-results") { if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
acceptCompletion(comps[selectedIdx()]); setShowCompletions(false);
return; return;
} }
if (e.key === "Escape") { if (e.key === "Escape") {
@@ -361,7 +211,6 @@ export const JournalInput: Component = () => {
} }
} }
// Send
if (e.key === "Enter" && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
handleSend(); handleSend();
@@ -373,8 +222,7 @@ export const JournalInput: Component = () => {
const raw = input.value; const raw = input.value;
setText(raw); setText(raw);
// Completions visibility — keep open while user types any / (GM only) if ((isGm() || raw.startsWith("/set")) && raw.startsWith("/")) {
if (isGm() && raw.startsWith("/")) {
setShowCompletions(true); setShowCompletions(true);
setSelectedIdx(0); setSelectedIdx(0);
} else { } else {
@@ -386,98 +234,20 @@ export const JournalInput: Component = () => {
textareaRef.style.height = Math.min(textareaRef.scrollHeight, 150) + "px"; textareaRef.style.height = Math.min(textareaRef.scrollHeight, 150) + "px";
} }
// ---- Click outside ---- // ---- Render ----
onMount(() => {
const handler = (e: MouseEvent) => {
if (completionsRef && !completionsRef.contains(e.target as Node)) {
setShowCompletions(false);
}
};
document.addEventListener("mousedown", handler);
onCleanup(() => document.removeEventListener("mousedown", handler));
});
// Completions placeholder — shown in the dropdown area when no matches
function renderCompletionsDropdown() {
const comps = currentCompletions();
if (comps.length === 0) return null;
return (
<div
ref={completionsRef}
class="absolute bottom-full left-0 right-0 bg-white border border-gray-200 rounded-t shadow-lg mb-0.5 max-h-40 overflow-y-auto z-50"
>
<For each={comps}>
{(item, idx) => (
<div
data-comp-idx={idx()}
class={`flex items-center gap-2 px-3 py-1.5 cursor-pointer text-sm ${
item.kind === "no-results"
? "text-gray-400 italic cursor-default"
: idx() === selectedIdx()
? "bg-blue-50 text-blue-700"
: "hover:bg-gray-100 text-gray-700"
}`}
onClick={() => acceptCompletion(item)}
onMouseEnter={() =>
item.kind !== "no-results" && setSelectedIdx(idx())
}
>
<Show
when={item.kind !== "no-results"}
fallback={<span class="text-xs text-gray-300 shrink-0"></span>}
>
<span
class={`text-xs px-1 rounded shrink-0 ${
item.kind === "command"
? "bg-purple-100 text-purple-700"
: "bg-gray-100 text-gray-500"
}`}
>
{item.kind === "command" ? "cmd" : "val"}
</span>
</Show>
<span class="font-mono truncate flex-1">{item.label}</span>
</div>
)}
</For>
</div>
);
}
return ( return (
<div class="border-t border-gray-200 bg-white relative"> <div class="border-t border-gray-200 bg-white relative">
{/* Completions loading / error / empty teaser */} <CompletionsDropdown
<Show when={showCompletions()}> show={showCompletions()}
<Switch> items={completions()}
<Match when={comp.state.status === "loading"}> selectedIdx={selectedIdx()}
<div onSelect={acceptCompletion}
ref={completionsRef} onHover={(idx) => setSelectedIdx(idx)}
class="absolute bottom-full left-0 right-0 bg-white border border-gray-200 rounded-t shadow-lg mb-0.5 z-50 px-3 py-2 text-xs text-gray-400 italic" onClose={() => setShowCompletions(false)}
> state={comp.state}
/>
</div>
</Match>
<Match when={comp.state.status === "error"}>
<div
ref={completionsRef}
class="absolute bottom-full left-0 right-0 bg-white border border-red-200 rounded-t shadow-lg mb-0.5 z-50 px-3 py-2 text-xs text-red-500"
>
{(comp.state as any).message || "无法加载补全数据"}
</div>
</Match>
<Match
when={
comp.state.status === "empty" || comp.state.status === "loaded"
}
>
{renderCompletionsDropdown()}
</Match>
</Switch>
</Show>
{/* Textarea + actions */}
<div class="flex flex-col"> <div class="flex flex-col">
<Show <Show
when={!isObserver()} when={!isObserver()}
@@ -489,24 +259,30 @@ export const JournalInput: Component = () => {
> >
<textarea <textarea
ref={textareaRef} ref={textareaRef}
id="journal-input-textarea"
value={text()} value={text()}
onInput={handleInput} onInput={handleInput}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={ placeholder={
isGm() isGm()
? "输入消息,或使用 /roll、/spark、/link 命令..." ? "输入消息,或使用 /roll、/link、/set 命令..."
: "输入消息..." : "输入消息,或使用 /set 命令..."
} }
rows={2} rows={2}
class="w-full resize-none border-0 px-3 pt-2.5 pb-1 text-sm 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 text-gray-800 placeholder-gray-400 focus:outline-none
bg-transparent min-h-[60px]" bg-transparent min-h-15"
/> />
{/* Bottom row: error + buttons */}
<div class="flex items-center justify-between px-2 pb-2"> <div class="flex items-center justify-between px-2 pb-2">
<Show when={error()}> <Show when={dispatchError()}>
<p class="text-red-500 text-xs truncate max-w-[60%]">{error()}</p> <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> </Show>
<div class="flex items-center gap-1 ml-auto"> <div class="flex items-center gap-1 ml-auto">
<button <button
@@ -522,6 +298,13 @@ export const JournalInput: Component = () => {
</div> </div>
</Show> </Show>
</div> </div>
{/* Error popup */}
<ErrorPopup
message={dispatchError()}
show={showErrorPopup()}
onClose={() => setShowErrorPopup(false)}
/>
</div> </div>
); );
}; };
+28 -1
View File
@@ -15,6 +15,7 @@ import { JournalHeader } from "./JournalHeader";
import { ConnectDialog } from "./ConnectDialog"; import { ConnectDialog } from "./ConnectDialog";
import { InviteDialog } from "./InviteDialog"; import { InviteDialog } from "./InviteDialog";
import { CreateSessionDialog } from "./CreateSessionDialog"; import { CreateSessionDialog } from "./CreateSessionDialog";
import { VariableView } from "./VariableView";
export interface JournalPanelProps { export interface JournalPanelProps {
open: boolean; open: boolean;
@@ -25,6 +26,7 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
const stream = useJournalStream(); const stream = useJournalStream();
const [showInvite, setShowInvite] = createSignal(false); const [showInvite, setShowInvite] = createSignal(false);
const [showCreateSession, setShowCreateSession] = createSignal(false); const [showCreateSession, setShowCreateSession] = createSignal(false);
const [viewMode, setViewMode] = createSignal<"stream" | "variables">("stream");
// Player list (exclude gm and observer) // Player list (exclude gm and observer)
const playerEntries = () => const playerEntries = () =>
@@ -84,8 +86,33 @@ export const JournalPanel: Component<JournalPanelProps> = (props) => {
</div> </div>
} }
> >
{/* View toggle */}
<div class="flex items-center border-b border-gray-200 bg-gray-50 shrink-0">
<button
onClick={() => setViewMode("stream")}
class={`flex-1 text-xs py-1.5 transition-colors ${
viewMode() === "stream"
? "bg-white text-blue-600 font-medium border-b-2 border-blue-600"
: "text-gray-500 hover:text-gray-700"
}`}
>
</button>
<button
onClick={() => setViewMode("variables")}
class={`flex-1 text-xs py-1.5 transition-colors ${
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"> <div class="flex-1 min-h-0">
<StreamView /> <Show when={viewMode() === "stream"} fallback={<VariableView />}>
<StreamView />
</Show>
</div> </div>
<JournalInput /> <JournalInput />
</Show> </Show>
@@ -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;
+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;
@@ -0,0 +1,149 @@
/**
* Command completions — build the dropdown items for journal input
* autocomplete based on the current raw text and completions data.
*
* Pure function with no component dependencies.
*/
import type { CompletionItem } from "./command-parser";
import type { JournalCompletions } from "./completions";
export interface CompletionsContext {
data: JournalCompletions;
isGm: boolean;
}
export function buildCompletions(
raw: string,
ctx: CompletionsContext,
): CompletionItem[] {
const { data, isGm } = ctx;
const commands = [
{ label: "/roll", kind: "command" as const, insertText: "/roll " },
{ label: "/link", kind: "command" as const, insertText: "/link " },
{ label: "/set", kind: "command" as const, insertText: "/set " },
];
// Show commands when user types / or starts typing a command name
if (raw.startsWith("/") && !raw.includes(" ")) {
const prefix = raw.toLowerCase();
let matches = commands.filter((c) =>
c.label.toLowerCase().startsWith(prefix),
);
if (!isGm) {
matches = matches.filter((c) => c.label === "/set");
}
return matches.length > 0
? matches
: [{ label: "未知命令", kind: "no-results", insertText: "" }];
}
// After /roll — show dice AND spark table suggestions
if (raw.startsWith("/roll ")) {
const prefix = raw.slice("/roll ".length).toLowerCase();
const diceMatches = data.dice
.filter(
(d) =>
d.notation.toLowerCase().includes(prefix) ||
d.label.toLowerCase().includes(prefix),
)
.slice(0, 4);
const sparkMatches = data.sparkTables
.filter(
(s) =>
s.slug.toLowerCase().includes(prefix) ||
s.label.toLowerCase().includes(prefix),
)
.slice(0, 4);
const items: CompletionItem[] = [];
for (const d of diceMatches) {
items.push({
label: `🎲 ${d.notation}`,
kind: "value",
insertText: "/roll " + d.notation,
});
}
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();
const matches = data.links
.filter(
(l) =>
l.path.toLowerCase().includes(prefix) ||
l.label.toLowerCase().includes(prefix),
)
.slice(0, 8);
if (matches.length === 0) {
return [{ label: "未找到链接", kind: "no-results", insertText: "" }];
}
return matches.map((l) => {
const insert = l.section
? `/link ${l.path}#${l.section}`
: `/link ${l.path}`;
return { label: l.label, kind: "value" as const, insertText: insert };
});
}
// After /set — show known variable and tag names
if (raw.startsWith("/set ")) {
const rest = raw.slice("/set ".length).trim();
// 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((t) => ({
label: t,
kind: "value" as const,
insertText: raw + t,
}));
}
// 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: "$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 };
}
+71
View File
@@ -0,0 +1,71 @@
/**
* Command parser — parse a raw input string into a typed ParsedInput.
*
* Pure function with no component dependencies, so it can live in its own
* module and keep JournalInput small.
*/
export interface CompletionItem {
label: string;
kind: "command" | "value" | "no-results";
insertText: string;
}
export interface ParsedInput {
type: "chat" | "roll" | "link" | "set" | "rolltag";
payload: Record<string, unknown>;
error?: string;
}
export function parseInput(raw: string): ParsedInput {
if (raw.startsWith("/roll ")) {
const arg = raw.slice("/roll ".length).trim();
if (!arg)
return { type: "roll", payload: {}, error: "需要骰子表达式或种子表键名" };
return { type: "roll", payload: { arg } };
}
if (raw.startsWith("/link ")) {
const arg = raw.slice("/link ".length).trim();
if (!arg) return { type: "link", payload: {}, error: "需要路径" };
const hashIdx = arg.indexOf("#");
const path = hashIdx === -1 ? arg : arg.slice(0, hashIdx);
const section =
hashIdx === -1 ? undefined : arg.slice(hashIdx + 1) || undefined;
return { type: "link", payload: { path, section } };
}
if (raw.startsWith("/set ")) {
const rest = raw.slice("/set ".length).trim();
if (!rest)
return { type: "set", payload: {}, error: "格式: /set $key expression" };
// 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 } };
}
return { type: "set", payload: { key, expr } };
}
if (raw === "/roll" || raw === "/link" || raw === "/set") {
return { type: "chat", payload: {}, error: "请补全命令" };
}
return { type: "chat", payload: { text: raw } };
}
+113 -110
View File
@@ -2,47 +2,44 @@
* Journal completions — client-side loader for /__COMPLETIONS.json * Journal completions — client-side loader for /__COMPLETIONS.json
* *
* In CLI mode, fetches the pre-computed index. In dev/browser mode, falls * 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()` * The fetch runs eagerly on module import. Call `useJournalCompletions()`
* from any Solid component to reactively read the state. * from any Solid component to reactively read the state.
*/ */
import { createSignal } from "solid-js"; import { createSignal } from "solid-js";
import Slugger from "github-slugger";
import { extractHeadings } from "../../data-loader/toc";
import { import {
getPathsByExtension, getPathsByExtension,
getIndexedData, getIndexedData,
setIndexedData,
setInlineResolver,
} from "../../data-loader/file-index"; } from "../../data-loader/file-index";
import {
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";
// ------------------- Types (mirrors CLI) ------------------- export type { VarDeclaration, TagModifier };
export interface DiceCompletion { // Re-export CLI types so consumers don't need to know about the CLI path
label: string; export type { DiceCompletion, LinkCompletion, SparkTableCompletion };
notation: string;
source: string;
}
export interface LinkCompletion { /** Convenience alias — same shape as the CLI's CompletionsPayload. */
path: string; export type JournalCompletions = CompletionsPayload;
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[];
}
export type CompletionsState = export type CompletionsState =
| { status: "loading" } | { status: "loading" }
@@ -56,6 +53,23 @@ const [completionsState, setCompletionsState] = createSignal<CompletionsState>({
status: "loading", 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) ------------------- // ------------------- Fetch (CLI mode) -------------------
async function tryServer(): Promise<JournalCompletions | null> { async function tryServer(): Promise<JournalCompletions | null> {
@@ -67,96 +81,54 @@ async function tryServer(): Promise<JournalCompletions | null> {
dice: Array.isArray(data.dice) ? data.dice : [], dice: Array.isArray(data.dice) ? data.dice : [],
links: Array.isArray(data.links) ? data.links : [], links: Array.isArray(data.links) ? data.links : [],
sparkTables: Array.isArray(data.sparkTables) ? data.sparkTables : [], sparkTables: Array.isArray(data.sparkTables) ? data.sparkTables : [],
declarations: Array.isArray(data.declarations) ? data.declarations : [],
tagModifiers: Array.isArray(data.tagModifiers) ? data.tagModifiers : [],
}; };
} catch { } catch {
return null; 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 ------------------- // ------------------- Client-side fallback scan -------------------
async function scanClientSide(): Promise<JournalCompletions> { async function scanClientSide(): Promise<JournalCompletions> {
const paths = await getPathsByExtension("md"); const paths = await getPathsByExtension("md");
const dice: DiceCompletion[] = [];
const links: LinkCompletion[] = [];
const sparkTables: SparkTableCompletion[] = [];
const tagRegex = /<md-dice[^>]*>\s*([\s\S]*?)\s*<\/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) { for (const filePath of paths) {
const content = await getIndexedData(filePath); const content = await getIndexedData(filePath);
if (!content) continue; if (!content) continue;
index[filePath] = content;
// Fresh slugger per file so duplicate headers across files don't
// pollute each other.
const slugger = new Slugger();
// Dice scan
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 });
}
// Link scan (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,
});
}
// Spark table scan
const sparkLines = content.split(/\r?\n/);
for (let i = 0; i < sparkLines.length; i++) {
const headerCells = splitTableRow(sparkLines[i]);
if (!headerCells || headerCells.length < 2) continue;
if (!/^d\d+$/i.test(headerCells[0])) continue;
if (i + 1 >= sparkLines.length) continue;
const sepCells = splitTableRow(sparkLines[i + 1]);
if (!sepCells || !sepCells.every((c) => /^:?-{3,}:?$/.test(c))) continue;
let j = i + 2;
while (j < sparkLines.length && splitTableRow(sparkLines[j])) j++;
if (j <= i + 2) continue;
const dataHeaders = headerCells.slice(1);
const stSlug = dataHeaders
.map((h) => slugger.slug(h.toLowerCase()))
.join("-");
// Combined key: pageName-columnSlug
const combinedSlug = `${fileName}-${stSlug}`;
sparkTables.push({
label: `${fileName} § ${stSlug}`,
notation: headerCells[0],
slug: combinedSlug,
filePath: basePath,
headers: dataHeaders,
});
i = j - 1;
}
} }
return { dice, links, sparkTables }; const registry = buildRegistryFromIndex(index);
} activeRegistry = registry;
function splitTableRow(line: string): string[] | null { // Write the processed (stripped) content back into the file index so
const trimmed = line.trim(); // Article/md-embed render the same content as CLI mode (attributed blocks
if (!trimmed.includes("|")) return null; // processed by role).
let inner = trimmed; for (const [path, content] of Object.entries(registry.pathIndex)) {
if (inner.startsWith("|")) inner = inner.slice(1); setIndexedData(path, content);
if (inner.endsWith("|")) inner = inner.slice(0, -1); }
return inner.split("|").map((c) => c.trim());
return deriveCompletions(registry);
} }
// ------------------- Init (runs eagerly at import time) ------------------- // ------------------- Init (runs eagerly at import time) -------------------
@@ -167,6 +139,16 @@ const _initPromise: Promise<void> = (async () => {
const serverData = await tryServer(); const serverData = await tryServer();
if (serverData) { if (serverData) {
setCompletionsState({ status: "loaded", data: 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; return;
} }
@@ -175,6 +157,15 @@ const _initPromise: Promise<void> = (async () => {
const data = await scanClientSide(); const data = await scanClientSide();
if (data.dice.length > 0 || data.links.length > 0) { if (data.dice.length > 0 || data.links.length > 0) {
setCompletionsState({ status: "loaded", data }); setCompletionsState({ status: "loaded", data });
try {
initReactivity({
declarations: data.declarations,
tagModifiers: data.tagModifiers,
});
seedDeclaredVariables();
} catch (e) {
console.warn("[completions] reactivity init error:", e);
}
} else { } else {
setCompletionsState({ status: "empty" }); setCompletionsState({ status: "empty" });
} }
@@ -196,14 +187,6 @@ export function ensureCompletions(): Promise<void> {
return _initPromise; 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. * Reactive hooks for the journal input.
* Returns the current completions state + a convenience `data` extractor. * Returns the current completions state + a convenience `data` extractor.
@@ -216,5 +199,25 @@ export function useJournalCompletions(): {
if (s.status === "loaded") { if (s.status === "loaded") {
return { state: s, data: s.data }; return { state: s, data: s.data };
} }
return { state: s, data: { dice: [], links: [], sparkTables: [] } }; return {
state: s,
data: {
dice: [],
links: [],
sparkTables: [],
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 { ComposePanel } from "./ComposePanel";
export { JournalInput } from "./JournalInput"; export { JournalInput } from "./JournalInput";
export { DynamicForm } from "./DynamicForm"; 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 { JournalContext, useJournalContext } from "./JournalContext";
export type { JournalContextValue } from "./JournalContext"; export type { JournalContextValue } from "./JournalContext";
+1
View File
@@ -10,3 +10,4 @@ import "./roll";
import "./spark"; import "./spark";
import "./link"; import "./link";
import "./intent"; import "./intent";
import "./var";
+3 -3
View File
@@ -1,9 +1,9 @@
/** /**
* Built-in message type: roll * Built-in message type: roll
* *
* Emitters: gm * Emitters: gm, player
* Command: /roll formula * Command: /roll formula
* The GM's browser rolls the formula and publishes the resolved result. * The sender's browser rolls the formula and publishes the resolved result.
*/ */
import { z } from "zod"; import { z } from "zod";
@@ -44,7 +44,7 @@ export function resolveRollPayload(raw: {
registerMessageType<RollPayload>({ registerMessageType<RollPayload>({
type: "roll", type: "roll",
label: "掷骰", label: "掷骰",
emitters: ["gm"], emitters: ["gm", "player"],
schema, schema,
defaultPayload: () => ({ defaultPayload: () => ({
notation: "1d20", notation: "1d20",
+28 -24
View File
@@ -16,12 +16,9 @@ import { z } from "zod";
import { For } from "solid-js"; import { For } from "solid-js";
import { registerMessageType } from "../registry"; import { registerMessageType } from "../registry";
import { rollFormula } from "../../md-commander/hooks"; import { rollFormula } from "../../md-commander/hooks";
import { import { parseSparkTableCsv, rollSparkTable } from "../../utils/spark-table";
findSparkTableByCombinedSlug,
rollSparkTable,
scanSparkTables,
} from "../../utils/spark-table";
import { getIndexedData } from "../../../data-loader/file-index"; import { getIndexedData } from "../../../data-loader/file-index";
import { getRegistry } from "../../journal/completions";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Schema // Schema
@@ -70,37 +67,44 @@ export type SparkPayload = z.infer<typeof schema>;
* Resolve a spark table roll. * Resolve a spark table roll.
* *
* `key` is the combined slug (pageName-columnSlug). * `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: { export async function resolveSparkPayload(raw: {
key: string; key: string;
filePath: string; csvPath: string;
docPath?: string;
remix: boolean;
}): Promise<SparkPayload> { }): Promise<SparkPayload> {
const mdPath = `/${raw.filePath.replace(/^\//, "")}.md`; let csv: string | null;
const pageName =
raw.filePath.replace(/^\//, "").split("/").filter(Boolean).pop() ||
raw.filePath;
let content: string; // Inline content ids resolve through the registry (docPath + content id);
try { // real file paths fall back to the file index.
content = await getIndexedData(mdPath); const registry = getRegistry();
} catch { const docStore = raw.docPath ? registry.docContent[raw.docPath] : undefined;
throw new Error(`Failed to load file: "${mdPath}"`); const inline = docStore?.[raw.csvPath];
if (inline) {
csv = inline.body;
} else {
try {
csv = await getIndexedData(raw.csvPath);
} catch {
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) { if (!meta) {
const available = scanSparkTables(content);
const names =
available.length > 0
? available.map((t) => `${pageName}-${t.slug}`).join(", ")
: "(none)";
throw new Error( 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); const firstRoll = rollFormula(meta.notation);
return { return {
+62
View File
@@ -0,0 +1,62 @@
/**
* Built-in message type: var
*
* Emitters: gm, player
* Command: /set $key expression
*
* 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";
import { registerMessageType } from "../registry";
import { journalSetState } from "../../stores/journalStream";
import { produce } from "solid-js/store";
const schema = z.object({
action: z.enum(["set", "del"]),
key: z.string().min(1),
value: z.string().optional(),
});
export type VarPayload = z.infer<typeof schema>;
registerMessageType<VarPayload>({
type: "var",
label: "变量",
emitters: ["gm", "player"],
schema,
defaultPayload: () => ({ action: "set", key: "", value: "" }),
render: (p) => {
if (p.action === "del") {
return (
<div class="flex items-center gap-1.5">
<span class="text-lg">🔢</span>
<span class="font-mono text-sm text-gray-500 line-through">
{p.key}
</span>
</div>
);
}
return (
<div class="flex items-center gap-1.5">
<span class="text-lg">🔢</span>
<span class="font-mono text-sm">
{p.key} = <span class="font-bold">{p.value}</span>
</span>
</div>
);
},
reducer: (p) => {
journalSetState(
produce((s) => {
if (p.action === "del") {
delete s.variables[p.key];
} else if (p.value !== undefined) {
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 type { MdCommanderCommand, MdCommanderCommandMap } from "../types";
import { setupHelpCommand, clearCommand, rollCommand, trackCommand, untrackCommand, listTrackCommand } from "../commands"; import { setupHelpCommand, clearCommand, rollCommand, trackCommand, untrackCommand, listTrackCommand } from "../commands";
import {resolvePath} from "../../utils/path"; import {resolvePath} from "../../utils/path";
import {loadCSV} from "../../utils/csv-loader"; import {loadCSVFromPath} from "../../utils/csv-loader";
const defaultCommands: MdCommanderCommandMap = { const defaultCommands: MdCommanderCommandMap = {
help: setupHelpCommand({}), help: setupHelpCommand({}),
@@ -111,7 +111,7 @@ export async function loadCommandTemplatesFromCSV(
setCommandsError(undefined); setCommandsError(undefined);
try { try {
const csv = await loadCSV<CommandTemplateRow>(resolvePath(articlePath, path)); const csv = await loadCSVFromPath<CommandTemplateRow>(resolvePath(articlePath, path));
// 按命令分组模板 // 按命令分组模板
const templatesByCommand = new Map<string, CommandTemplateRow[]>(); 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 { parseMarkdown } from "../../markdown";
import { getLayerStyle } from "./hooks/dimensions"; 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 { DeckStore } from "./hooks/deckStore";
import { processVariables } from "../utils/csv-loader"; import { processVariables } from "../utils/csv-loader";
import type { LayerInteractionHandlers } from "./hooks/useLayerInteraction"; import type { LayerInteractionHandlers } from "./hooks/useLayerInteraction";
@@ -31,15 +31,29 @@ export function CardLayer(props: CardLayerProps) {
) as string; ) as string;
} }
const getAlignStyle = (align?: "l" | "c" | "r") => { const getAlignStyle = (align?: Align) => {
if (align === "l") return "left"; const horizontal = align?.includes("l")
if (align === "r") return "right"; ? "left"
return "center"; : 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) => const isLayerSelected = (layerIndex: number) =>
selectedLayer() === layerIndex; selectedLayer() === layerIndex;
const isEditing = () =>
props.store.state.isEditing && !props.store.state.fixed;
const getFrameBounds = (layer: LayerConfig) => { const getFrameBounds = (layer: LayerConfig) => {
const dims = dimensions(); const dims = dimensions();
const left = (layer.x1 - 1) * dims.cellWidth; const left = (layer.x1 - 1) * dims.cellWidth;
@@ -75,20 +89,23 @@ export function CardLayer(props: CardLayerProps) {
return ( return (
<> <>
<article <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={{ classList={{
"cursor-pointer": isEditing(),
"ring-2 ring-blue-500 ring-offset-1": "ring-2 ring-blue-500 ring-offset-1":
isSelected() && !draggingState(), isSelected() && !draggingState() && isEditing(),
}} }}
style={{ style={{
...getLayerStyle(layer, dimensions()), ...getLayerStyle(layer, dimensions()),
"font-size": `${layer.fontSize || 3}mm`, "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)} onClick={(e) => handleLayerClick(index(), e)}
/> />
<Show when={isSelected()}> <Show when={isSelected() && isEditing()}>
<div <div
class="absolute border-2 border-blue-500 pointer-events-none z-10" class="absolute border-2 border-blue-500 pointer-events-none z-10"
style={{ 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>
);
}
+85 -37
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"; import type { DeckStore } from "./hooks/deckStore";
export interface DeckHeaderProps { export interface DeckHeaderProps {
@@ -10,46 +11,93 @@ export interface DeckHeaderProps {
*/ */
export function DeckHeader(props: DeckHeaderProps) { export function DeckHeader(props: DeckHeaderProps) {
const { store } = props; 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 ( return (
<div class="flex items-center gap-2 border-b border-gray-200 pb-2 mb-4"> <>
{/* 编辑按钮 */} <div class="flex items-center gap-2 border-b border-gray-200 pb-2 mb-4">
<button {/* 编辑按钮 */}
onClick={() => store.actions.setIsEditing(!store.state.isEditing)} <button
class={`px-3 py-1 rounded text-sm font-medium transition-colors cursor-pointer ${ onClick={() => store.actions.setIsEditing(!store.state.isEditing)}
store.state.isEditing && !store.state.fixed class={`px-3 py-1 rounded text-sm font-medium transition-colors cursor-pointer ${
? "bg-blue-100 text-blue-600" store.state.isEditing && !store.state.fixed
: "bg-gray-100 text-gray-600 hover:bg-gray-200" ? "bg-blue-100 text-blue-600"
}`} : "bg-gray-100 text-gray-600 hover:bg-gray-200"
> }`}
{store.state.isEditing ? "✓ 编辑中" : "✏️ 编辑"} >
</button> {store.state.isEditing ? "✓ 编辑中" : "✏️ 编辑"}
</button>
{/* 导出 PDF 按钮 */} {/* 导出 PDF 按钮 */}
<button <button
onClick={() => store.actions.exportDeck()} onClick={() => store.actions.exportDeck()}
class="px-2 py-1 rounded text-xs font-medium transition-colors cursor-pointer bg-green-100 text-green-600 hover:bg-green-200" class="px-2 py-1 rounded text-xs font-medium transition-colors cursor-pointer bg-green-100 text-green-600 hover:bg-green-200"
> >
📥 PDF 📥 PDF
</button> </button>
{/* Tab 选择器 */} {/* 复制代码按钮 */}
<div class="flex gap-1 overflow-x-auto flex-1 min-w-0 flex-wrap"> <button
<For each={store.state.cards}> onClick={handleCopy}
{(card, index) => ( class="px-2 py-1 rounded text-xs font-medium transition-colors cursor-pointer bg-purple-100 text-purple-600 hover:bg-purple-200"
<button >
onClick={() => store.actions.setActiveTab(index())} 📋
class={`font-medium transition-colors shrink-0 min-w-[1.6em] cursor-pointer px-2 py-1 rounded ${ </button>
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"
}`}
>
{card.label || card.name || `Card ${index() + 1}`}
</button>
)}
</For>
</div> </div>
</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>
</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} /> <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"> <div class="min-h-screen py-20 px-4">
<PrintPreviewHeader <PrintPreviewHeader
store={store} store={store}
@@ -179,6 +179,20 @@ export function PrintPreviewHeader(props: PrintPreviewHeaderProps) {
</label> </label>
</div> </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"> <div class="flex items-center gap-2">
<label class="text-sm text-gray-600">:</label> <label class="text-sm text-gray-600">:</label>
<div class="flex items-center gap-1"> <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, closestCenter,
} from "@thisbeyond/solid-dnd"; } from "@thisbeyond/solid-dnd";
import type { DeckStore } from "../hooks/deckStore"; import type { DeckStore } from "../hooks/deckStore";
import type { CardSide } from "../types";
import { toSortableId } from "../hooks/layer-crud"; import { toSortableId } from "../hooks/layer-crud";
import { LayerRow } from "./LayerRow"; import { LayerRow } from "./LayerRow";
export interface LayerEditorPanelProps { export interface LayerEditorPanelProps {
store: DeckStore; store: DeckStore;
side?: CardSide;
} }
function LayerEditorPanel(props: LayerEditorPanelProps) { function LayerEditorPanel(props: LayerEditorPanelProps) {
@@ -20,7 +22,7 @@ function LayerEditorPanel(props: LayerEditorPanelProps) {
const [addMenuOpen, setAddMenuOpen] = createSignal(false); const [addMenuOpen, setAddMenuOpen] = createSignal(false);
let addMenuRef: HTMLDivElement | undefined; let addMenuRef: HTMLDivElement | undefined;
const side = () => store.state.activeSide; const side = () => props.side || "front";
const layers = () => const layers = () =>
side() === "front" side() === "front"
? store.state.frontLayerConfigs ? store.state.frontLayerConfigs
@@ -143,15 +145,6 @@ function LayerEditorPanel(props: LayerEditorPanelProps) {
</SortableProvider> </SortableProvider>
</DragDropSensors> </DragDropSensors>
</DragDropProvider> </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> </div>
); );
} }
@@ -1,7 +1,7 @@
import { For, createSignal, onCleanup, onMount } from "solid-js"; import { For, createSignal, onCleanup, onMount } from "solid-js";
import { createSortable, useDragDropContext } from "@thisbeyond/solid-dnd"; import { createSortable, useDragDropContext } from "@thisbeyond/solid-dnd";
import type { DeckStore } from "../hooks/deckStore"; 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 alignLeftIcon from "./icons/align-left.png";
import alignCenterIcon from "./icons/align-center.png"; import alignCenterIcon from "./icons/align-center.png";
import alignRightIcon from "./icons/align-right.png"; import alignRightIcon from "./icons/align-right.png";
@@ -17,7 +17,7 @@ export interface LayerRowProps {
setOpenDropdown: (val: string | null) => void; setOpenDropdown: (val: string | null) => void;
onUpdateOrientation: (o: "n" | "s" | "e" | "w") => void; onUpdateOrientation: (o: "n" | "s" | "e" | "w") => void;
onUpdateFontSize: (fs?: number) => void; onUpdateFontSize: (fs?: number) => void;
onUpdateAlign: (a?: "l" | "c" | "r") => void; onUpdateAlign: (a?: Align) => void;
onSelect: () => void; onSelect: () => void;
onRemove: () => void; onRemove: () => void;
} }
@@ -29,11 +29,17 @@ const ORIENTATIONS = [
{ value: "w" as const, label: "← 西" }, { value: "w" as const, label: "← 西" },
]; ];
const ALIGNS = [ const ALIGNS: { value: Align | ""; icon: string }[] = [
{ value: "" as const, icon: alignCenterIcon }, { value: "", icon: alignCenterIcon },
{ value: "l" as const, icon: alignLeftIcon }, { value: "l", icon: alignLeftIcon },
{ value: "c" as const, icon: alignCenterIcon }, { value: "c", icon: alignCenterIcon },
{ value: "r" as const, icon: alignRightIcon }, { 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; 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) { switch (v) {
case "tl":
return "↖";
case "tc":
return "↑";
case "tr":
return "↗";
case "bl":
return "↙";
case "bc":
return "↓";
case "br":
return "↘";
case "l": case "l":
return alignLeftIcon; return <img src={alignLeftIcon} alt="align" class="w-5 h-5 not-prose" />;
case "r": case "r":
return alignRightIcon; return <img src={alignRightIcon} alt="align" class="w-5 h-5 not-prose" />;
default: 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" class="text-sm flex-1 truncate cursor-pointer hover:text-blue-600 select-none"
onClick={props.onSelect} onClick={props.onSelect}
> >
{props.layer.prop} {props.layer.prop || (props.layer.template ? "(模板)" : "")}
</span> </span>
<DropdownButton <DropdownButton
@@ -149,13 +169,7 @@ export function LayerRow(props: LayerRowProps) {
</DropdownButton> </DropdownButton>
<DropdownButton <DropdownButton
icon={ icon={alignIcon(props.layer.align || "")}
<img
src={alignSrc(props.layer.align || "")}
alt="align"
class="w-5 h-5 not-prose"
/>
}
visible={props.layer.visible} visible={props.layer.visible}
open={props.openDropdown === `align-${props.index}`} open={props.openDropdown === `align-${props.index}`}
onToggle={() => onToggle={() =>
@@ -173,7 +187,13 @@ export function LayerRow(props: LayerRowProps) {
onClick={() => props.onUpdateAlign(o.value || undefined)} 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" class="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-gray-100 cursor-pointer whitespace-nowrap"
> >
<img src={o.icon} alt="" class="w-4 h-4 not-prose max-w-none" /> {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> </button>
)} )}
</For> </For>
@@ -1,49 +1,32 @@
import type { DeckStore } from '../hooks/deckStore'; import type { DeckStore } from "../hooks/deckStore";
import type { CardShape } from '../types'; import type { CardShape } from "../types";
export interface PropertiesEditorPanelProps { export interface PropertiesEditorPanelProps {
store: DeckStore; store: DeckStore;
} }
/** /**
* *
*/ */
export function PropertiesEditorPanel(props: PropertiesEditorPanelProps) { export function PropertiesEditorPanel(props: PropertiesEditorPanelProps) {
const { store } = props; const { store } = props;
return ( const shapeOptions: { value: CardShape; label: string }[] = [
<div class="w-64 flex-shrink-0"> { value: "rectangle", label: "矩形" },
<h3 class="font-bold mb-2 mt-0"></h3> { value: "circle", label: "圆形" },
{ value: "triangle", label: "三角形" },
{ value: "hexagon", label: "六边形" },
];
{/* 正面/背面切换标签页 */} return (
<div class="mb-4"> <div class="w-64 shrink-0">
<div class="flex gap-1"> <h3 class="font-bold mb-2 mt-0"></h3>
<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>
<div class="space-y-3"> <div class="space-y-3">
<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"> <div class="flex gap-2">
<input <input
type="number" type="number"
@@ -83,7 +66,9 @@ export function PropertiesEditorPanel(props: PropertiesEditorPanelProps) {
</div> </div>
<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"> <div class="flex gap-2">
<input <input
type="number" type="number"
@@ -93,34 +78,30 @@ export function PropertiesEditorPanel(props: PropertiesEditorPanelProps) {
placeholder="出血" placeholder="出血"
/> />
<input <input
type="number" type="number"
class="w-full border border-gray-300 rounded px-2 py-1 text-sm" class="w-full border border-gray-300 rounded px-2 py-1 text-sm"
value={store.state.padding} value={store.state.padding}
onChange={(e) => store.actions.setPadding(Number(e.target.value))} onChange={(e) => store.actions.setPadding(Number(e.target.value))}
placeholder="内边距" placeholder="内边距"
/> />
</div> </div>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2"></label> <label class="block text-sm font-medium text-gray-700 mb-2">
<div class="grid grid-cols-2 gap-2">
{(['rectangle', 'circle', 'triangle', 'hexagon'] as CardShape[]).map((shape) => ( </label>
<button <select
onClick={() => store.actions.setShape(shape)} value={store.state.shape}
class={`px-3 py-1.5 rounded text-sm font-medium cursor-pointer border ${ onChange={(e) =>
store.state.shape === shape store.actions.setShape(e.target.value as CardShape)
? 'bg-blue-600 text-white border-blue-600' }
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50' class="w-full border border-gray-300 rounded px-2 py-1 text-sm bg-white"
}`} >
> {shapeOptions.map((s) => (
{shape === 'rectangle' && '矩形'} <option value={s.value}>{s.label}</option>
{shape === 'circle' && '圆形'}
{shape === 'triangle' && '三角形'}
{shape === 'hexagon' && '六边形'}
</button>
))} ))}
</div> </select>
</div> </div>
</div> </div>
</div> </div>
+81 -17
View File
@@ -1,7 +1,8 @@
import { createStore } from "solid-js/store"; import { createStore } from "solid-js/store";
import yaml from "js-yaml";
import { calculateDimensions } from "./dimensions"; import { calculateDimensions } from "./dimensions";
import { loadCSV, CSV } from "../../utils/csv-loader"; import { loadCSVFromPath, CSV } from "../../utils/csv-loader";
import { formatLayers, initLayerConfigsForSide } from "./layer-parser"; import { formatLayers } from "./layer-parser";
import * as layerCrud from "./layer-crud"; import * as layerCrud from "./layer-crud";
import type { import type {
CardData, CardData,
@@ -41,6 +42,8 @@ export interface DeckState {
cornerRadius: number; cornerRadius: number;
shape: CardShape; shape: CardShape;
fixed: boolean; fixed: boolean;
/** True when the deck was configured via a yaml role=tag codeblock (data-config). */
isYamlBlock: boolean;
src: string; src: string;
rawSrc: string; rawSrc: string;
@@ -73,6 +76,7 @@ export interface DeckState {
printFrontOddPageOffsetX: number; printFrontOddPageOffsetX: number;
printFrontOddPageOffsetY: number; printFrontOddPageOffsetY: number;
printDoubleSided: boolean; printDoubleSided: boolean;
pltDoubleCut: boolean;
} }
export interface DeckActions { export interface DeckActions {
@@ -84,6 +88,7 @@ export interface DeckActions {
setPadding: (padding: number) => void; setPadding: (padding: number) => void;
setCornerRadius: (cornerRadius: number) => void; setCornerRadius: (cornerRadius: number) => void;
setShape: (shape: CardShape) => void; setShape: (shape: CardShape) => void;
setIsYamlBlock: (isYamlBlock: boolean) => void;
setCards: (cards: CSV<CardData>) => void; setCards: (cards: CSV<CardData>) => void;
setActiveTab: (index: number) => void; setActiveTab: (index: number) => void;
@@ -137,14 +142,14 @@ export interface DeckActions {
loadCardsFromPath: ( loadCardsFromPath: (
path: string, path: string,
rawSrc: string, rawSrc: string,
layersStr?: string, frontLayers?: LayerConfig[],
backLayersStr?: string, backLayers?: LayerConfig[],
) => Promise<void>; ) => Promise<void>;
setError: (error: string | null) => void; setError: (error: string | null) => void;
clearError: () => void; clearError: () => void;
generateCode: (backLayersStr?: string) => string; generateCode: (backLayersStr?: string) => string;
copyCode: (backLayersStr?: string) => Promise<void>; copyCode: (fallback?: (code: string) => void) => Promise<void>;
setExporting: (exporting: boolean) => void; setExporting: (exporting: boolean) => void;
exportDeck: () => void; exportDeck: () => void;
@@ -156,6 +161,7 @@ export interface DeckActions {
setPrintFrontOddPageOffsetX: (offset: number) => void; setPrintFrontOddPageOffsetX: (offset: number) => void;
setPrintFrontOddPageOffsetY: (offset: number) => void; setPrintFrontOddPageOffsetY: (offset: number) => void;
setPrintDoubleSided: (doubleSided: boolean) => void; setPrintDoubleSided: (doubleSided: boolean) => void;
setPltDoubleCut: (doubleCut: boolean) => void;
} }
export interface DeckStore { export interface DeckStore {
@@ -174,6 +180,7 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
cornerRadius: DECK_DEFAULTS.CORNER_RADIUS, cornerRadius: DECK_DEFAULTS.CORNER_RADIUS,
shape: "rectangle", shape: "rectangle",
fixed: false, fixed: false,
isYamlBlock: false,
src: initialSrc, src: initialSrc,
rawSrc: initialSrc, rawSrc: initialSrc,
dimensions: null, dimensions: null,
@@ -197,6 +204,7 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
printFrontOddPageOffsetX: 0, printFrontOddPageOffsetX: 0,
printFrontOddPageOffsetY: 0, printFrontOddPageOffsetY: 0,
printDoubleSided: false, printDoubleSided: false,
pltDoubleCut: false,
}); });
const updateDimensions = () => { const updateDimensions = () => {
@@ -241,6 +249,9 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
const setShape = (shape: CardShape) => { const setShape = (shape: CardShape) => {
setState({ shape }); setState({ shape });
}; };
const setIsYamlBlock = (isYamlBlock: boolean) => {
setState({ isYamlBlock });
};
const setCards = (cards: CSV<CardData>) => setState({ cards, activeTab: 0 }); const setCards = (cards: CSV<CardData>) => setState({ cards, activeTab: 0 });
const setActiveTab = (index: number) => setState({ activeTab: index }); const setActiveTab = (index: number) => setState({ activeTab: index });
@@ -439,8 +450,8 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
const loadCardsFromPath = async ( const loadCardsFromPath = async (
path: string, path: string,
rawSrc: string, rawSrc: string,
layersStr: string = "", frontLayers: LayerConfig[] = [],
backLayersStr: string = "", backLayers: LayerConfig[] = [],
) => { ) => {
if (!path) { if (!path) {
setState({ error: "未指定 CSV 文件路径" }); setState({ error: "未指定 CSV 文件路径" });
@@ -450,7 +461,7 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
setState({ isLoading: true, error: null, src: path, rawSrc: rawSrc }); setState({ isLoading: true, error: null, src: path, rawSrc: rawSrc });
try { try {
const data = await loadCSV(path); const data = await loadCSVFromPath(path);
if (data.length === 0) { if (data.length === 0) {
setState({ setState({
@@ -463,12 +474,8 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
setState({ setState({
cards: data, cards: data,
activeTab: 0, activeTab: 0,
frontLayerConfigs: layerCrud.withKeys( frontLayerConfigs: layerCrud.withKeys(frontLayers),
initLayerConfigsForSide(data, layersStr), backLayerConfigs: layerCrud.withKeys(backLayers),
),
backLayerConfigs: layerCrud.withKeys(
initLayerConfigsForSide(data, backLayersStr),
),
isLoading: false, isLoading: false,
}); });
updateDimensions(); updateDimensions();
@@ -484,6 +491,9 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
const clearError = () => setState({ error: null }); const clearError = () => setState({ error: null });
const generateCode = (backLayersStr?: string) => { const generateCode = (backLayersStr?: string) => {
if (state.isYamlBlock) {
return generateYamlCode();
}
const frontLayersStr = formatLayers(state.frontLayerConfigs); const frontLayersStr = formatLayers(state.frontLayerConfigs);
const backLayersString = const backLayersString =
backLayersStr || formatLayers(state.backLayerConfigs); backLayersStr || formatLayers(state.backLayerConfigs);
@@ -511,14 +521,62 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
return parts.join(""); return parts.join("");
}; };
const copyCode = async (backLayersStr?: string) => { /** Serialize the deck back to a yaml codeblock (round-trips templates). */
const code = generateCode(backLayersStr); 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 { try {
await navigator.clipboard.writeText(code); await navigator.clipboard.writeText(code);
alert("已复制到剪贴板!"); alert("已复制到剪贴板!");
} catch (err) { } catch (err) {
console.error("复制失败:", err); console.error("复制失败:", err);
alert("复制失败,请手动复制"); if (fallback) {
fallback(code);
} else {
alert("复制失败,请手动复制");
}
} }
}; };
@@ -553,6 +611,10 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
setState({ printDoubleSided: doubleSided }); setState({ printDoubleSided: doubleSided });
}; };
const setPltDoubleCut = (doubleCut: boolean) => {
setState({ pltDoubleCut: doubleCut });
};
const actions: DeckActions = { const actions: DeckActions = {
setSizeW, setSizeW,
setSizeH, setSizeH,
@@ -562,6 +624,7 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
setPadding, setPadding,
setCornerRadius, setCornerRadius,
setShape, setShape,
setIsYamlBlock,
setCards, setCards,
setActiveTab, setActiveTab,
updateCardData, updateCardData,
@@ -599,6 +662,7 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
setPrintFrontOddPageOffsetX, setPrintFrontOddPageOffsetX,
setPrintFrontOddPageOffsetY, setPrintFrontOddPageOffsetY,
setPrintDoubleSided, setPrintDoubleSided,
setPltDoubleCut,
}; };
return { state, actions }; return { state, actions };
+8 -5
View File
@@ -4,15 +4,16 @@ import { CSV } from "../../utils/csv-loader";
/** /**
* layers * layers
* body:1,7-5,8 title:1,1-4,1f6.6sl * 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[] { export function parseLayers(layersStr: string): Layer[] {
if (!layersStr) return []; if (!layersStr) return [];
const layers: Layer[] = []; const layers: Layer[] = [];
// 匹配:prop:x1,y1-x2,y2[ffontSize][direction][align] // 匹配:prop:x1,y1-x2,y2[ffontSize][direction][[t|b]align]
const regex = const regex =
/([^: ]+):(\d+),(\d+)-(\d+),(\d+)(?:f([\d.]+))?([nsew])?([lcr])?/g; /([^: ]+):(\d+),(\d+)-(\d+),(\d+)(?:f([\d.]+))?([nsew])?([tb])?([lcr])?/g;
let match; let match;
while ((match = regex.exec(layersStr)) !== null) { while ((match = regex.exec(layersStr)) !== null) {
@@ -24,7 +25,7 @@ export function parseLayers(layersStr: string): Layer[] {
y2: parseInt(match[5]), y2: parseInt(match[5]),
fontSize: match[6] ? parseFloat(match[6]) : undefined, fontSize: match[6] ? parseFloat(match[6]) : undefined,
orientation: match[7] as "n" | "s" | "e" | "w" | 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 * layers
*/ */
export function formatLayers(layers: LayerConfig[]): string { 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 return layers
.filter((l) => l.visible) .filter((l) => l.visible && l.prop)
.map((l) => { .map((l) => {
let str = `${l.prop}:${l.x1},${l.y1}-${l.x2},${l.y2}`; let str = `${l.prop}:${l.x1},${l.y1}-${l.x2},${l.y2}`;
if (l.fontSize) { if (l.fontSize) {
@@ -87,6 +87,8 @@ export function useLayerInteraction(
const handleLayerClick = (index: number, e: MouseEvent) => { const handleLayerClick = (index: number, e: MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
if (!store.state.isEditing || store.state.fixed) return;
const currentlySelected = store.state.selectedLayer; const currentlySelected = store.state.selectedLayer;
if (currentlySelected === index) { if (currentlySelected === index) {
@@ -100,6 +102,7 @@ export function useLayerInteraction(
const handleCardClick = (e: MouseEvent, cardEl: HTMLElement) => { const handleCardClick = (e: MouseEvent, cardEl: HTMLElement) => {
if (store.state.draggingState) return; if (store.state.draggingState) return;
if (!store.state.isEditing || store.state.fixed) return;
const { gridX, gridY } = calculateGridCoords(e, cardEl); const { gridX, gridY } = calculateGridCoords(e, cardEl);
const overlapping = getOverlappingLayers(gridX, gridY); const overlapping = getOverlappingLayers(gridX, gridY);
@@ -147,6 +150,7 @@ export function useLayerInteraction(
edge?: "n" | "s" | "e" | "w", edge?: "n" | "s" | "e" | "w",
e?: MouseEvent, e?: MouseEvent,
) => { ) => {
if (!store.state.isEditing || store.state.fixed) return;
if (store.state.selectedLayer === null) return; if (store.state.selectedLayer === null) return;
if (e) e.stopPropagation(); if (e) e.stopPropagation();
@@ -35,6 +35,7 @@ export function usePlotterExport(store: DeckStore): UsePlotterExportReturn {
const cardHeight = () => store.state.dimensions?.cardHeight || 88; const cardHeight = () => store.state.dimensions?.cardHeight || 88;
const shape = () => store.state.shape; const shape = () => store.state.shape;
const orientation = () => store.state.printOrientation || 'landscape'; const orientation = () => store.state.printOrientation || 'landscape';
const doubleCut = () => store.state.pltDoubleCut;
/** /**
* PLT * PLT
@@ -60,7 +61,7 @@ export function usePlotterExport(store: DeckStore): UsePlotterExportReturn {
// 将卡片路径转换为相对于 frameBoundsWithMargin 的坐标 // 将卡片路径转换为相对于 frameBoundsWithMargin 的坐标
// 原点在 frameBoundsWithMargin 的左上角 // 原点在 frameBoundsWithMargin 的左上角
const relativePaths = layout.cardPaths.map(cardPath => { const relativePaths = layout.cardPaths.flatMap(cardPath => {
const relativePoints = cardPath.points.map(([x, y]) => { const relativePoints = cardPath.points.map(([x, y]) => {
// 转换为相对于 frameBounds 左上角的坐标 // 转换为相对于 frameBounds 左上角的坐标
const relativeX = x - frameBounds.x; const relativeX = x - frameBounds.x;
@@ -71,7 +72,11 @@ export function usePlotterExport(store: DeckStore): UsePlotterExportReturn {
// 所以 plotterY = pltHeight - relativeY // 所以 plotterY = pltHeight - relativeY
return [relativeX, pltHeight - relativeY] as [number, number]; return [relativeX, pltHeight - relativeY] as [number, number];
}); });
return relativePoints; if (doubleCut()) {
// 重复一次:某些切割机需要每张卡牌切两次
return [relativePoints, relativePoints];
}
return [relativePoints];
}); });
// 起点和终点都在 frameBoundsWithMargin 的左上角 (0, pltHeight) // 起点和终点都在 frameBoundsWithMargin 的左上角 (0, pltHeight)
+60 -43
View File
@@ -2,17 +2,16 @@ import { customElement, noShadowDOM } from "solid-element";
import { Show, onCleanup } from "solid-js"; import { Show, onCleanup } from "solid-js";
import { resolvePath } from "../utils/path"; import { resolvePath } from "../utils/path";
import { v4 as uuidv4 } from "uuid"; 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 { 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 { DeckHeader } from "./DeckHeader";
import { CardList } from "./CardList";
import { DeckContent } from "./DeckContent"; import { DeckContent } from "./DeckContent";
import { EditorTabs } from "./EditorTabs";
import { PrintPreview } from "./PrintPreview"; import { PrintPreview } from "./PrintPreview";
import { import { DataEditorPanel } from "./editor-panel";
DataEditorPanel,
LayerEditorPanel,
PropertiesEditorPanel,
} from "./editor-panel";
interface DeckProps { interface DeckProps {
size?: string; size?: string;
@@ -70,49 +69,77 @@ customElement<DeckProps>(
const deckId = `deck-${uuidv4()}`; const deckId = `deck-${uuidv4()}`;
registerDeck(deckId, store, resolvedSrc, csvPath); 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" 和新格式) // 解析 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); const [w, h] = props.size.split("x").map(Number);
store.actions.setSizeW(w); store.actions.setSizeW(w);
store.actions.setSizeH(h); store.actions.setSizeH(h);
} else { } else {
store.actions.setSizeW(props.sizeW ?? 54); store.actions.setSizeW(props.sizeW ?? DECK_DEFAULTS.SIZE_W);
store.actions.setSizeH(props.sizeH ?? 86); store.actions.setSizeH(props.sizeH ?? DECK_DEFAULTS.SIZE_H);
} }
// 解析 grid 属性(支持旧格式 "5x8" 和新格式) // 解析 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); const [w, h] = props.grid.split("x").map(Number);
store.actions.setGridW(w); store.actions.setGridW(w);
store.actions.setGridH(h); store.actions.setGridH(h);
} else { } else {
store.actions.setGridW(props.gridW ?? 5); store.actions.setGridW(props.gridW ?? DECK_DEFAULTS.GRID_W);
store.actions.setGridH(props.gridH ?? 8); store.actions.setGridH(props.gridH ?? DECK_DEFAULTS.GRID_H);
} }
// 解析 bleed 和 padding(支持旧字符串格式和新数字格式) // 解析 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)); store.actions.setBleed(Number(props.bleed));
} else { } 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)); store.actions.setPadding(Number(props.padding));
} else { } 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 数据 // 加载 CSV 数据
store.actions.loadCardsFromPath( store.actions.loadCardsFromPath(resolvedSrc, csvPath, frontLayers, backLayers);
resolvedSrc,
csvPath,
(props.layers as string) || "",
(props.backLayers as string) || "",
);
// 清理函数 // 清理函数
onCleanup(() => { onCleanup(() => {
@@ -139,29 +166,19 @@ customElement<DeckProps>(
</Show> </Show>
<div class="flex gap-4"> <div class="flex gap-4">
{/* 内容区域:错误/加载/卡牌预览/空状态 */} {/* 左侧:卡牌列表导航 */}
{/* 左侧:CSV 数据编辑 */} <Show when={store.state.cards.length > 0 && !store.state.error}>
{/*<Show when={store.state.isEditing && !store.state.fixed}>*/} <CardList store={store} />
{/* <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> </Show>
<DeckContent store={store} isLoading={store.state.isLoading} /> {/* 中间:内容区域(错误/加载/卡牌预览/空状态) */}
<div class="flex-1 min-w-0">
<DeckContent store={store} isLoading={store.state.isLoading} />
</div>
{/* 右侧:属性/图层编辑面板 */} {/* 右侧:属性/图层编辑面板(标签页切换) */}
<Show when={store.state.isEditing && !store.state.fixed}> <Show when={store.state.isEditing && !store.state.fixed}>
<div class="flex-1"> <EditorTabs store={store} />
<LayerEditorPanel store={store} />
</div>
</Show> </Show>
</div> </div>
</div> </div>
+26 -4
View File
@@ -4,21 +4,43 @@ export interface CardData {
export type CardSide = "front" | "back"; 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 type { CardShape } from "../../plotcutter/contour";
export interface Layer { 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; x1: number;
y1: number; y1: number;
x2: number; x2: number;
y2: number; y2: number;
orientation?: "n" | "s" | "e" | "w"; orientation?: "n" | "s" | "e" | "w";
fontSize?: number; fontSize?: number;
align?: "l" | "c" | "r"; align?: Align;
} }
export interface LayerConfig { 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; visible: boolean;
x1: number; x1: number;
y1: number; y1: number;
@@ -26,7 +48,7 @@ export interface LayerConfig {
y2: number; y2: number;
orientation?: "n" | "s" | "e" | "w"; orientation?: "n" | "s" | "e" | "w";
fontSize?: number; fontSize?: number;
align?: "l" | "c" | "r"; align?: Align;
_key?: number; _key?: number;
} }
+2 -2
View File
@@ -42,7 +42,7 @@ customElement("md-embed", { headingBase: 0 }, (props, { element }) => {
}); });
return ( return (
<div class="md-embed not-prose"> <div class="md-embed">
<Show when={content.loading}> <Show when={content.loading}>
<div class="text-gray-400 italic">...</div> <div class="text-gray-400 italic">...</div>
</Show> </Show>
@@ -54,7 +54,7 @@ customElement("md-embed", { headingBase: 0 }, (props, { element }) => {
<Show when={!content.loading && !content.error && content()}> <Show when={!content.loading && !content.error && content()}>
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: markdown render */} {/* biome-ignore lint/security/noDangerouslySetInnerHtml: markdown render */}
<div <div
class="prose" class="prose text-black prose-sm"
innerHTML={parseMarkdown(content()!, resolvedPath)} innerHTML={parseMarkdown(content()!, resolvedPath)}
/> />
</Show> </Show>
+214 -174
View File
@@ -1,9 +1,20 @@
import { customElement, noShadowDOM } from 'solid-element'; import { customElement, noShadowDOM } from "solid-element";
import { createSignal, For, Show, createEffect, createMemo, createResource } from 'solid-js'; import {
import { marked } from '../markdown'; createSignal,
import { loadCSV, CSV, processVariables, isCSV } from './utils/csv-loader'; For,
import { resolvePath } from './utils/path'; Show,
import { areAllLabelsNumeric, weightedRandomIndex } from './utils/weighted-random'; 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 { export interface TableProps {
roll?: boolean; roll?: boolean;
@@ -16,191 +27,220 @@ interface TableRow {
[key: string]: string; [key: string]: string;
} }
customElement('md-table', { roll: false, remix: false }, (props, { element }) => { customElement(
noShadowDOM(); "md-table",
const [rows, setRows] = createSignal<CSV<TableRow>>([] as unknown as CSV<TableRow>); { roll: false, remix: false },
const [activeTab, setActiveTab] = createSignal(0); (props, { element }) => {
const [activeGroup, setActiveGroup] = createSignal<string | null>(null); noShadowDOM();
const [bodyHtml, setBodyHtml] = createSignal(''); const [rows, setRows] = createSignal<CSV<TableRow>>(
let tabsContainer: HTMLDivElement | undefined; [] as unknown as CSV<TableRow>,
);
const [activeTab, setActiveTab] = createSignal(0);
const [activeGroup, setActiveGroup] = createSignal<string | null>(null);
const [bodyHtml, setBodyHtml] = createSignal("");
let tabsContainer: HTMLDivElement | undefined;
// 从 element 的 textContent 获取 CSV 路径或 inline CSV 数据 // 从 element 的 textContent 获取 CSV 路径或 inline CSV 数据
const rawContent = element?.textContent?.trim() || ''; const rawContent = element?.textContent?.trim() || "";
// 隐藏原始文本内容 // 隐藏原始文本内容
if (element) { if (element) {
element.textContent = ''; element.textContent = "";
}
// 从父节点 article 的 data-src 获取当前 markdown 文件完整路径
const articleEl = element?.closest('article[data-src]');
const articlePath = articleEl?.getAttribute('data-src') || '';
// 如果是 inline CSV,直接使用;否则解析相对路径
const contentOrPath = isCSV(rawContent) ? rawContent : resolvePath(articlePath, rawContent);
// 使用 createResource 加载 CSV,自动响应路径变化并避免重复加载
const [csvData] = createResource(() => contentOrPath, loadCSV);
// 当数据加载完成后更新 rows
createEffect(() => {
const data = csvData();
if (data) {
// 将加载的数据赋值给 rowsCSV 类型已经包含 sourcePath 等属性
setRows(data as unknown as CSV<TableRow>);
} }
});
// 检测是否有 group 列 // 从父节点 article 的 data-src 获取当前 markdown 文件完整路径
const hasGroup = createMemo(() => { const articleEl = element?.closest("article[data-src]");
const allRows = rows(); const articlePath = articleEl?.getAttribute("data-src") || "";
return allRows.length > 0 && 'group' in allRows[0];
});
// 获取所有分组 // 解析引用:inline CSV 直接使用,否则通过 registry 解析(含内联内容 id
const groups = createMemo(() => { const [csvData] = createResource(
if (!hasGroup()) return []; () => ({ ref: rawContent, docPath: articlePath }),
const allRows = rows(); async ({ ref, docPath }) => {
const groupSet = new Set<string>(); const content = await resolveContentRef(ref, docPath);
for (const row of allRows) { if (content === null) {
if (row.group) { throw new Error(`Failed to resolve table content: "${ref}"`);
groupSet.add(row.group); }
return content;
},
);
// 当数据加载完成后更新 rows,并为火花表设置 data-spark(供 RevealManager
// 悬停触发 /roll)。data-spark 在渲染时派生,不在扫描时注入。
createEffect(() => {
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");
}
} }
} });
return Array.from(groupSet).sort();
});
// 根据当前选中的分组过滤行 // 检测是否有 group 列
const filteredRows = createMemo(() => { const hasGroup = createMemo(() => {
const allRows = rows(); const allRows = rows();
const group = activeGroup(); return allRows.length > 0 && "group" in allRows[0];
if (!group) return allRows; });
return allRows.filter(row => row.group === group);
});
// 处理 body 内容中的 {{prop}} 语法并解析 markdown // 获取所有分组
const processBody = (body: string, currentRow: TableRow): string => { const groups = createMemo(() => {
// 使用 marked 解析 markdown if (!hasGroup()) return [];
return marked.parse(processVariables(body, currentRow, rows(), filteredRows(), props.remix)) as string; const allRows = rows();
}; const groupSet = new Set<string>();
for (const row of allRows) {
if (row.group) {
groupSet.add(row.group);
}
}
return Array.from(groupSet).sort();
});
// 更新 body 内容 // 根据当前选中的分组过滤行
const updateBodyContent = () => { const filteredRows = createMemo(() => {
const filtered = filteredRows(); const allRows = rows();
if (!csvData.loading && filtered.length > 0) { const group = activeGroup();
const index = Math.min(activeTab(), filtered.length - 1); if (!group) return allRows;
const currentRow = filtered[index]; return allRows.filter((row) => row.group === group);
setBodyHtml(processBody(currentRow.body, currentRow)); });
}
};
// 监听 activeTab 和 activeGroup 变化并更新内容 // 处理 body 内容中的 {{prop}} 语法并解析 markdown
createEffect(() => { const processBody = (body: string, currentRow: TableRow): string => {
activeTab(); // 使用 parseMarkdown 统一入口(设置图标 base path 等)
activeGroup(); return parseMarkdown(
updateBodyContent(); processVariables(body, currentRow, rows(), filteredRows(), props.remix),
}); articlePath,
);
};
// 切换分组时重置 tab 索引 // 更新 body 内容
const handleGroupChange = (group: string | null) => { const updateBodyContent = () => {
setActiveGroup(group); const filtered = filteredRows();
setActiveTab(0); if (!csvData.loading && filtered.length > 0) {
}; const index = Math.min(activeTab(), filtered.length - 1);
const currentRow = filtered[index];
setBodyHtml(processBody(currentRow.body, currentRow));
}
};
// 随机切换 tab // 监听 activeTab 和 activeGroup 变化并更新内容
const handleRoll = () => { createEffect(() => {
const filtered = filteredRows(); activeTab();
if (filtered.length === 0) return; activeGroup();
updateBodyContent();
// 检查所有 label 是否都是整数/整数范围格式 });
const labels = filtered.map(row => row.label);
if (areAllLabelsNumeric(labels)) { // 切换分组时重置 tab 索引
// 使用加权随机 const handleGroupChange = (group: string | null) => {
const randomIndex = weightedRandomIndex(labels); setActiveGroup(group);
if (randomIndex !== -1) { setActiveTab(0);
};
// 随机切换 tab
const handleRoll = () => {
const filtered = filteredRows();
if (filtered.length === 0) return;
// 检查所有 label 是否都是整数/整数范围格式
const labels = filtered.map((row) => row.label);
if (areAllLabelsNumeric(labels)) {
// 使用加权随机
const randomIndex = weightedRandomIndex(labels);
if (randomIndex !== -1) {
setActiveTab(randomIndex);
}
} else {
// 使用简单随机
const randomIndex = Math.floor(Math.random() * filtered.length);
setActiveTab(randomIndex); setActiveTab(randomIndex);
} }
} else {
// 使用简单随机
const randomIndex = Math.floor(Math.random() * filtered.length);
setActiveTab(randomIndex);
}
// 滚动到可视区域
setTimeout(() => {
const activeButton = tabsContainer?.querySelector('.border-blue-600');
activeButton?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
}, 50);
};
return ( // 滚动到可视区域
<div class="ttrpg-table"> setTimeout(() => {
<div class="flex items-center gap-2 border-b border-gray-200"> const activeButton = tabsContainer?.querySelector(".border-blue-600");
<div class="flex flex-col overflow-x-auto"> activeButton?.scrollIntoView({
{/* 分组 tabs */} behavior: "smooth",
<Show when={hasGroup()}> block: "nearest",
<div class="flex gap-2 border-b border-gray-100 pb-2"> inline: "nearest",
<button });
onClick={() => handleGroupChange(null)} }, 50);
class={`font-medium transition-colors ${ };
activeGroup() === null
? 'text-blue-600 border-b-2 border-blue-600' return (
: 'text-gray-500 hover:text-gray-700' <div class="ttrpg-table">
}`} <div class="flex items-center gap-2 border-b border-gray-200">
> <div class="flex flex-col overflow-x-auto">
{/* 分组 tabs */}
</button> <Show when={hasGroup()}>
<For each={groups()}> <div class="flex gap-2 border-b border-gray-100 pb-2">
{(group) => ( <button
<button onClick={() => handleGroupChange(null)}
onClick={() => handleGroupChange(group)} class={`font-medium transition-colors ${
class={`font-medium transition-colors ${ activeGroup() === null
activeGroup() === group ? "text-blue-600 border-b-2 border-blue-600"
? 'text-blue-600 border-b-2 border-blue-600' : "text-gray-500 hover:text-gray-700"
: 'text-gray-500 hover:text-gray-700' }`}
}`} >
>
{group} </button>
</button> <For each={groups()}>
)} {(group) => (
</For> <button
</div> onClick={() => handleGroupChange(group)}
</Show> class={`font-medium transition-colors ${
{/* 内容 tabs */} activeGroup() === group
<div class="flex items-center gap-2"> ? "text-blue-600 border-b-2 border-blue-600"
<Show when={props.roll}> : "text-gray-500 hover:text-gray-700"
<button }`}
onClick={handleRoll} >
class="text-gray-500 hover:text-gray-700 flex-shrink-0 cursor-pointer" {group}
title="随机切换" </button>
> )}
🎲 </For>
</button> </div>
</Show> </Show>
<div ref={tabsContainer} class="flex gap-1 overflow-x-auto flex-1 min-w-0"> {/* 内容 tabs */}
<For each={filteredRows()}> <div class="flex items-center gap-2">
{(row, index) => ( <Show when={props.roll}>
<button <button
onClick={() => setActiveTab(index())} onClick={handleRoll}
class={`font-medium transition-colors flex-shrink-0 min-w-[1.6em] cursor-pointer ${ class="text-gray-500 hover:text-gray-700 shrink-0 cursor-pointer"
activeTab() === index() title="随机切换"
? 'text-blue-600 border-b-2 border-blue-600' >
: 'text-gray-500 hover:text-gray-700' 🎲
}`} </button>
> </Show>
{row.label} <div
</button> ref={tabsContainer}
)} class="flex gap-1 overflow-x-auto flex-1 min-w-0"
</For> >
<For each={filteredRows()}>
{(row, index) => (
<button
onClick={() => setActiveTab(index())}
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"
}`}
>
{row.label}
</button>
)}
</For>
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="p-4 prose max-w-none">
<Show when={!csvData.loading && filteredRows().length > 0}>
<div innerHTML={bodyHtml()} />
</Show>
</div>
</div> </div>
<div class="p-4 prose max-w-none"> );
<Show when={!csvData.loading && filteredRows().length > 0}> },
<div innerHTML={bodyHtml()} /> );
</Show>
</div>
</div>
);
});
+223 -386
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 * This is the main-thread side of the journal stream. It spawns a Shared
* connection, local message log, per-sender sequence tracking, and the * Worker that owns the MQTT connection. All state flows from the worker
* revealed-paths set (populated by the link reducer). * to this store via Comlink callbacks.
* *
* Session lifecycle (create/list/delete) is handled via MQTT retained * The public API is unchanged components still call `useJournalStream()`,
* topics ttrpg/$SESSIONS for the manifest and ttrpg/{id}/meta per session. * `sendMessage()`, etc. exactly as before.
*
* No persistence here that's the CLI server's job via JSONL append.
*/ */
import { createStore, produce } from "solid-js/store"; import { createStore, produce } from "solid-js/store";
import { createSignal } from "solid-js"; import { createSignal } from "solid-js";
import * as Comlink from "comlink";
import type { StreamMessage } from "../journal/registry"; import type { StreamMessage } from "../journal/registry";
import { getMessageType, validatePayload } 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 { import {
loadPersisted, loadPersisted,
saveName, saveName,
@@ -32,33 +34,18 @@ import {
export interface JournalStreamState { export interface JournalStreamState {
sessionId: string | null; sessionId: string | null;
/** Human-readable name for the current session (from manifest) */
sessionName: string | null; sessionName: string | null;
/** Full message log, oldest-first */
messages: StreamMessage[]; messages: StreamMessage[];
/** Last sequence number per sender */
senderSeq: Record<string, number>; 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>>; revealedPaths: Record<string, Set<string>>;
/** MQTT connection status */
connected: boolean; connected: boolean;
/** Granular connection state for UI indicators */
connectionStatus: "disconnected" | "connecting" | "connected" | "error"; connectionStatus: "disconnected" | "connecting" | "connected" | "error";
/** Last connection error message, if any */
connectionError: string | null; connectionError: string | null;
/** This client's identity */
myName: string; myName: string;
/** Role: gm | player | observer. Immutable while connected. */
myRole: "gm" | "player" | "observer"; myRole: "gm" | "player" | "observer";
/** Broker URL, set after connect */
brokerUrl: string | null; brokerUrl: string | null;
/** Active player list (keyed by player name) */
players: Record<string, { role: string }>; players: Record<string, { role: string }>;
variables: Record<string, string>;
} }
export interface SessionMeta { export interface SessionMeta {
@@ -67,8 +54,24 @@ export interface SessionMeta {
players: string[]; players: string[];
} }
export interface SessionManifest { export type { SessionManifest } from "../../workers/journal.worker";
sessions: Record<string, SessionMeta>;
// ---------------------------------------------------------------------------
// 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;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -78,7 +81,6 @@ export interface SessionManifest {
const persisted = loadPersisted(); const persisted = loadPersisted();
const urlParams = readUrlParams(); const urlParams = readUrlParams();
// URL params override localStorage if present
const initialName = urlParams.playerName ?? persisted.myName; const initialName = urlParams.playerName ?? persisted.myName;
const initialSession = urlParams.sessionId ?? persisted.lastSessionId; const initialSession = urlParams.sessionId ?? persisted.lastSessionId;
@@ -95,9 +97,9 @@ const [state, setState] = createStore<JournalStreamState>({
myRole: (persisted.myRole as "gm" | "player" | "observer") || "gm", myRole: (persisted.myRole as "gm" | "player" | "observer") || "gm",
brokerUrl: persisted.brokerUrl, brokerUrl: persisted.brokerUrl,
players: {}, players: {},
variables: {},
}); });
// Sync initial URL params if they came from localStorage (not URL)
if (initialName && !urlParams.playerName) syncUrlParam("player", initialName); if (initialName && !urlParams.playerName) syncUrlParam("player", initialName);
if (initialSession && !urlParams.sessionId) if (initialSession && !urlParams.sessionId)
syncUrlParam("session", initialSession); syncUrlParam("session", initialSession);
@@ -110,56 +112,10 @@ const [sessionList, setSessionList] = createSignal<SessionManifest>({
export { sessionList as sessions }; 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 { function runReducer(msg: StreamMessage): void {
const def = getMessageType(msg.type); const def = getMessageType(msg.type);
if (def?.reducer) { if (def?.reducer) {
@@ -167,258 +123,195 @@ function runReducer(msg: StreamMessage): void {
} }
} }
const $SESSIONS = "ttrpg/$SESSIONS"; /** Replay all messages through reducers to rebuild derived state. */
function replayReducers(): void {
// --------------------------------------------------------------------------- // Reset derived state
// Hydration (initial load from server) setState("revealedPaths", {});
// --------------------------------------------------------------------------- setState("variables", {});
for (const msg of state.messages) {
/** runReducer(msg);
* 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()); // Worker patch handler
// ---------------------------------------------------------------------------
const messages: StreamMessage[] = []; function handlePatch(patch: WorkerPatch): void {
const senderSeq: Record<string, number> = {}; switch (patch.type) {
case "fullState": {
setState(
produce((s) => {
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;
}
for (const line of lines) { case "message": {
try { const msg = patch.message;
const msg: StreamMessage = JSON.parse(line); const existingIdx = state.messages.findIndex((m) => m.id === msg.id);
messages.push(msg); if (existingIdx !== -1) {
senderSeq[msg.sender] = Math.max(senderSeq[msg.sender] ?? 0, msg.seq); 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); runReducer(msg);
} catch { break;
/* skip corrupt */ }
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;
} }
} }
}
setState( // ---------------------------------------------------------------------------
produce((s) => { // Subscribe to worker (called once per tab)
s.messages = messages; // ---------------------------------------------------------------------------
s.senderSeq = senderSeq;
s.sessionId = sessionId; 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
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** export function setMyName(name: string): void {
* Connect to the MQTT broker, subscribe to the session stream, session setState("myName", name);
* list, and session meta. Must be called after `hydrateFromServer`. 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( export async function connectStream(
sessionId: string, sessionId: string,
brokerUrl: string, brokerUrl: string,
): Promise<void> { ): Promise<void> {
const { default: mqtt } = await import("mqtt"); const api = getWorkerAPI();
setState("connectionStatus", "connecting"); setState("connectionStatus", "connecting");
setState("connectionError", null); setState("connectionError", null);
return new Promise<void>((resolve, reject) => { try {
const client = mqtt.connect(brokerUrl, { await api.connect(sessionId, brokerUrl, state.myName, state.myRole);
clientId: `${state.myName}-${state.myRole}-${Date.now()}`, saveBrokerUrl(brokerUrl);
protocol: brokerUrl.startsWith("wss") ? "wss" : "ws", saveSessionId(sessionId);
reconnectPeriod: 2000, } catch (err) {
}); setState("connectionStatus", "error");
setState(
_mqttClient = client; "connectionError",
err instanceof Error ? err.message : "Connection failed",
client.on("connect", () => { );
_mqttConnected = true; throw err;
setState("connected", true); }
setState("connectionStatus", "connected");
setState("connectionError", null);
setState("brokerUrl", brokerUrl);
// Persist connection info for next time
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);
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];
}),
);
}
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);
}
}
});
});
} }
// ---------------------------------------------------------------------------
// 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>( export function sendMessage<T>(
type: string, type: string,
payload: T, payload: T,
): ):
{ success: true; msg: StreamMessage<T> } | { success: false; error: string } { | { success: true; msg: StreamMessage<T> }
if (!_mqttClient || !_mqttConnected) { | { success: false; error: string } {
return { success: false, error: "Not connected to stream" }; const api = getWorkerAPI();
}
const sessionId = state.sessionId;
if (!sessionId) {
return { success: false, error: "No active session" };
}
// Validate locally first
const validation = validatePayload(type, payload); const validation = validatePayload(type, payload);
if (!validation.success) { if (!validation.success) {
return { success: false, error: validation.error }; 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 sender = state.myName;
const seq = (state.senderSeq[sender] ?? 0) + 1; const seq = (state.senderSeq[sender] ?? 0) + 1;
const id = makeMessageId(sender, seq); const id = `${sender}-${seq}`;
const msg: StreamMessage<T> = { const msg: StreamMessage<T> = {
id, id,
@@ -430,116 +323,52 @@ export function sendMessage<T>(
reverted: false, 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 }; 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(): export function revertLatest():
{ success: true } | { success: false; error: string } { | { success: true }
if (!_mqttClient || !_mqttConnected) { | { success: false; error: string } {
return { success: false, error: "Not connected to stream" }; const api = getWorkerAPI();
} return api.revertLatest() as unknown as
| { success: true }
const sessionId = state.sessionId; | { success: false; error: string };
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 { export function disconnectStream(): void {
if (_mqttClient) { const api = getWorkerAPI();
// Clear our presence before disconnecting (tombstone) api.disconnect();
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
removeUrlParam("autojoin"); removeUrlParam("autojoin");
} }
// --------------------------------------------------------------------------- export function createSession(
// Derived / helpers 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 { export function canRevert(): boolean {
const sender = state.myName; const sender = state.myName;
const seq = state.senderSeq[sender]; const seq = state.senderSeq[sender];
if (!seq) return false; 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; return msg !== undefined && !msg.reverted;
} }
@@ -552,3 +381,11 @@ export function useJournalStream() {
} }
export { state as journalStreamState }; 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 * CSV
* @template T Record<string, string> * @template T Record<string, string>
@@ -102,18 +66,12 @@ export function parseCSVString<T = Record<string, string>>(csvString: string, so
/** /**
* CSV * CSV
* @template T Record<string, string> * @template T Record<string, string>
* @param pathOrContent inline CSV * @param path file-index
* @returns CSV * @returns CSV
*/ */
export async function loadCSV<T = Record<string, string>>(pathOrContent: string): Promise<CSV<T>> { export async function loadCSVFromPath<T = Record<string, string>>(path: string): Promise<CSV<T>> {
// 检测是否是 inline CSV 数据 const content = await getIndexedData(path);
if (isCSV(pathOrContent)) { return parseCSVString<T>(content, path);
return parseCSVString<T>(pathOrContent, 'inline');
}
// 从索引获取文件内容
const content = await getIndexedData(pathOrContent);
return parseCSVString<T>(content, pathOrContent);
} }
type JSONData = JSONArray | JSONObject | string | number | boolean | null; 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;
}
}
+92 -152
View File
@@ -1,24 +1,26 @@
/** /**
* Spark Table markdown table where the first column header is a dice * Spark Table runtime rolling and CSV parsing.
* formula (d6, d20, d100, etc.). Rolling a spark table means:
* *
* 1. Roll the dice formula once for each data column (non-dice columns) * A spark table is a CSV whose first column header is a dice formula
* 2. Look up the row whose dice-column value matches each roll * (d6, d20, d100, etc.). Rolling a spark table means:
* 3. Return the rolled values keyed by column slug *
* 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 Slugger from "github-slugger";
import { parse } from "csv-parse/browser/esm/sync";
import { parseCSVString } from "../utils/csv-loader";
import { rollFormula } from "../md-commander/hooks"; import { rollFormula } from "../md-commander/hooks";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export interface MarkdownTable {
headers: string[];
rows: string[][];
}
export interface SparkTableColumn { export interface SparkTableColumn {
header: string; header: string;
slug: string; slug: string;
@@ -39,153 +41,61 @@ export interface SparkTableMeta {
notation: string; notation: string;
/** Concatenated slug of data columns */ /** Concatenated slug of data columns */
slug: string; slug: string;
/** The header name of the dice column (first column) */
diceHeader: string;
/** Data column headers (excluding dice column) */ /** Data column headers (excluding dice column) */
dataHeaders: string[]; dataHeaders: string[];
/** Full list of rows */ /** Full list of rows as objects keyed by header */
rows: string[][]; 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. * Parse a CSV string into a SparkTableMeta.
* Handles both leading/trailing `|` styles and bare styles. * 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[] { export function parseSparkTableCsv(csv: string): SparkTableMeta | null {
const tables: MarkdownTable[] = []; // Parse raw headers first — before parseCSVString injects frontmatter keys
const lines = markdown.split(/\r?\n/); // 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++) { if (rawParsed.length < 2) return null;
const headerCells = splitTableRow(lines[i]);
if (!headerCells || headerCells.length < 2) continue;
// Peek at the next line — must be a separator row const headers = rawParsed[0];
if (i + 1 >= lines.length) continue; if (headers.length < 2) return null;
const sepCells = splitTableRow(lines[i + 1]); if (!DICE_HEADER_RE.test(headers[0])) return null;
if (!sepCells || sepCells.length < headerCells.length) continue;
if (!sepCells.every((c) => /^:?-{3,}:?$/.test(c))) continue;
// Valid table header + separator — collect body rows // Now parse with csv-loader for full frontmatter + quoting support
const rows: string[][] = []; const parsed = parseCSVString(csv);
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++;
}
// 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(); const slugger = new Slugger();
return table.headers const diceHeader = headers[0];
.slice(1) const dataHeaders = headers.slice(1);
const slug = dataHeaders
.map((h) => slugger.slug(h.toLowerCase())) .map((h) => slugger.slug(h.toLowerCase()))
.join("-"); .join("-");
}
/** return {
* Find a spark table in the given markdown content matching `slug`. notation: diceHeader,
* Returns null if not found. slug,
*/ diceHeader,
export function findSparkTable( dataHeaders,
markdown: string, rows: parsed as Record<string, 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],
slug,
dataHeaders: table.headers.slice(1),
rows: table.rows,
};
}
}
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),
}));
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -229,32 +139,62 @@ function matchesCell(diceCell: string, rolledValue: number): boolean {
// Rolling // 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 * Roll a spark table.
* up the corresponding row value. *
* 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 slugger = new Slugger();
const columns: SparkTableColumn[] = []; const columns: SparkTableColumn[] = [];
for (let colIdx = 0; colIdx < meta.dataHeaders.length; colIdx++) { if (options.remix) {
const header = meta.dataHeaders[colIdx]; // Independent roll per column
const slug = slugger.slug(header.toLowerCase()); for (const header of meta.dataHeaders) {
const slug = slugger.slug(header.toLowerCase());
const roll = rollFormula(meta.notation);
const rolledValue = roll.result.total;
let value = `(no row for ${rolledValue})`;
for (const row of meta.rows) {
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 roll = rollFormula(meta.notation);
const rolledValue = roll.result.total; const rolledValue = roll.result.total;
// Find the row matching the rolled value let matchedRow: Record<string, string> | null = null;
let value = `(no row for ${rolledValue})`;
for (const row of meta.rows) { for (const row of meta.rows) {
const diceCell = row[0] ?? ""; if (matchesCell(row[meta.diceHeader] ?? "", rolledValue)) {
if (matchesCell(diceCell, rolledValue)) { matchedRow = row;
value = row[colIdx + 1] ?? "";
break; break;
} }
} }
columns.push({ header, slug, value }); 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 { return {
+49 -35
View File
@@ -1,10 +1,14 @@
/** /**
* *
* *
* 1. CLI /__CONTENT_INDEX.json * 1. CLI / /__CONTENT_INDEX.json
* 2. Dev 使 webpackContext .md dev * 2.
* 3.
* - IndexedDB * - IndexedDB
*
* Dev CLI rsbuild
* > npm run tttk serve ./content
* > npm run dev
* rsbuild /__CONTENT_INDEX.json/__COMPLETIONS.json/content/ CLI
*/ */
import { import {
@@ -13,25 +17,40 @@ import {
removeHandle, removeHandle,
ensurePermission, ensurePermission,
} from "./file-index-db"; } from "./file-index-db";
import { normalizePathKey } from "../cli/content-registry";
type FileIndex = Record<string, string>; type FileIndex = Record<string, string>;
let fileIndex: FileIndex | null = null; let fileIndex: FileIndex | null = null;
let indexLoadPromise: Promise<void> | 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) */ /** Currently active directory handle (if folder source) */
let activeDirHandle: FileSystemDirectoryHandle | null = null; let activeDirHandle: FileSystemDirectoryHandle | null = null;
/** /**
* *
* CLI JSON webpack context (dev only) * CLI JSON
*/ */
function ensureIndexLoaded(): Promise<void> { function ensureIndexLoaded(): Promise<void> {
if (indexLoadPromise) return indexLoadPromise; if (indexLoadPromise) return indexLoadPromise;
indexLoadPromise = (async () => { indexLoadPromise = (async () => {
// 策略 1: CLI 环境 — 从 /__CONTENT_INDEX.json 加载 // 策略 1: CLI / 代理环境 — 从 /__CONTENT_INDEX.json 加载
try { try {
const response = await fetch("/__CONTENT_INDEX.json"); const response = await fetch("/__CONTENT_INDEX.json");
if (response.ok) { if (response.ok) {
@@ -44,31 +63,7 @@ function ensureIndexLoaded(): Promise<void> {
// CLI 索引不可用时尝试下一个策略 // CLI 索引不可用时尝试下一个策略
} }
// 策略 2: Dev 环境 — webpackContext + raw loader(仅 dev 构建时生效) // 策略 2: 浏览器 — 尝试从 IndexedDB 恢复保存的目录句柄
// 生产构建时 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 恢复保存的目录句柄
if (!activeSource) { if (!activeSource) {
try { try {
const restored = await restoreSavedHandle(); const restored = await restoreSavedHandle();
@@ -94,7 +89,7 @@ async function scanDirectory(
prefix = "", prefix = "",
): Promise<FileIndex> { ): Promise<FileIndex> {
const index: 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()) { for await (const [name, entry] of (handle as any).entries()) {
if (entry.kind === "directory") { if (entry.kind === "directory") {
@@ -105,7 +100,7 @@ async function scanDirectory(
Object.assign(index, sub); Object.assign(index, sub);
} else if (entry.kind === "file" && acceptedExt.test(name)) { } else if (entry.kind === "file" && acceptedExt.test(name)) {
const file = await (entry as FileSystemFileHandle).getFile(); const file = await (entry as FileSystemFileHandle).getFile();
const path = prefix + name; const path = normalizePathKey(prefix + name);
index[path] = await file.text(); 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> { export async function switchToBuiltInSource(): Promise<void> {
await removeHandle(); await removeHandle();
@@ -201,6 +196,15 @@ export async function getIndexedData(path: string): Promise<string> {
if (fileIndex && fileIndex[path]) { if (fileIndex && fileIndex[path]) {
return 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 res = await fetch(path);
const content = await res.text(); const content = await res.text();
fileIndex = fileIndex || {}; fileIndex = fileIndex || {};
@@ -208,6 +212,16 @@ export async function getIndexedData(path: string): Promise<string> {
return content; return content;
} }
/**
* /
* registry stripped markdown
* 使 CLI
*/
export function setIndexedData(path: string, content: string): void {
fileIndex = fileIndex || {};
fileIndex[normalizePathKey(path)] = content;
}
/** /**
* *
*/ */
+13 -26
View File
@@ -2,20 +2,12 @@
tag: journal-gm tag: journal-gm
icon: 🎭 icon: 🎭
title: 主持人指南 title: 主持人指南
description: 作为 GM 创建会话、管理玩家、使用动态表单和消息类型。
syntax: '点击导航栏 📋 图标打开 Journal 面板'
props:
- name: 会话管理
type: —
desc: 创建、切换、删除会话;通过邀请链接添加玩家
- name: 消息类型
type: —
desc: 发送叙述、掷骰、表单等多种消息类型
- name: 动态表单
type: —
desc: 创建自定义表单收集玩家输入
--- ---
作为 GM 创建会话、管理玩家、使用命令与玩家互动。
点击顶部导航栏的 📋 按钮打开 Journal 面板。
## 快速开始 ## 快速开始
1. 点击顶部导航栏的 📋 按钮打开 Journal 面板 1. 点击顶部导航栏的 📋 按钮打开 Journal 面板
@@ -32,25 +24,20 @@ props:
玩家通过邀请链接加入后会自动以玩家身份连接。 玩家通过邀请链接加入后会自动以玩家身份连接。
## 消息类型 ## 消息与命令
Journal 支持多种消息类型,通过输入框上方的标签切换 Journal 使用斜杠命令来发送不同类型的消息。在输入框中输入命令,按 Enter 发送
- **叙述**普通的文字描述,用于推进剧情 - **聊天**直接输入文字,按 Enter 发送普通叙述消息
- **掷骰**:发送掷骰结果,可附带公式 - **`/roll 表达式`**:掷骰或抽取火花表。传入骰子表达式(如 `/roll 3d6`)则掷骰;传入火花表键名(如 `/roll npc`)则抽取火花表
- **表单**:创建动态表单,收集玩家选择或输入 - **`/link 路径#章节`**:发送可点击的文档链接
- **`/set $key 值`**:设置变量值,支持数值、标签映射和随机标签
## 动态表单 输入 `/` 后按 Tab 可打开命令补全下拉菜单。
表单消息允许你创建交互式问卷:
- 添加文本输入、选择框、复选框等字段
- 玩家提交后,GM 可以看到汇总结果
- 适用于投票、决策、角色创建等场景
## 撤回消息 ## 撤回消息
GM 可以撤回自己发送的最新消息,点击消息旁的撤回按钮即可。 GM 可以撤回自己发送的最新消息,点击消息卡片右上角的 × 按钮即可。
## 连接状态 ## 连接状态
@@ -59,4 +46,4 @@ 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 tag: journal-player
icon: 🎲 icon: 🎲
title: 玩家指南 title: 玩家指南
description: 作为玩家加入 GM 的会话,查看消息、提交表单、参与互动。
syntax: '通过 GM 发送的邀请链接加入'
props:
- name: 加入方式
type: —
desc: 点击邀请链接自动加入,或手动输入名字和角色
- name: 查看消息
type: —
desc: 实时查看 GM 和其他玩家的消息
- name: 提交表单
type: —
desc: 填写 GM 发送的动态表单并提交
--- ---
作为玩家加入 GM 的会话,查看消息、使用 `/set` 命令设置变量、参与互动。
通过 GM 发送的邀请链接加入。
## 加入会话 ## 加入会话
有两种方式加入 GM 的会话: 有两种方式加入 GM 的会话:
@@ -38,20 +30,17 @@ GM 会发送一个包含 `?session=xxx&player=你的名字&autojoin=1` 的链接
- GM 发送的叙述消息 - GM 发送的叙述消息
- 掷骰结果 - 掷骰结果
- 火花表抽取结果
- 其他玩家的消息 - 其他玩家的消息
- 动态表单
消息实时更新,无需刷新页面。 消息实时更新,无需刷新页面。
## 填写表单 ## 使用命令
当 GM 发送动态表单时 玩家可在输入框中使用以下命令
1. 表单会显示在消息流中 - **聊天**:直接输入文字,按 Enter 发送普通消息
2. 根据表单类型填写(文本、选择、复选框等) - **`/set $key 值`**:设置变量值,支持数值和标签映射
3. 点击提交按钮发送你的回答
GM 可以看到所有玩家的提交结果。
## 角色标识 ## 角色标识
@@ -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**:可设置所有变量
- **玩家**:可设置变量
- **观察者**:不能设置变量
+39
View File
@@ -0,0 +1,39 @@
---
tag: journal-spark
icon: 🎰
title: 火花表
---
在 Journal 中通过 `/roll` 命令随机抽取火花表内容,用于生成随机遭遇、NPC、物品等。
**语法:** `/roll 火花表键名`
## 概述
火花表通过 `/roll` 命令触发:当参数匹配已定义的火花表 slug 时,自动抽取并发布结果,而非作为骰子表达式处理。
## 示例
```
/roll npc
/roll encounter
/roll loot
```
## 火花表定义
火花表在 markdown 文档中通过 `:spark[CSV路径]` 标记声明,CSV 文件的第一列表头为骰子公式(如 `d6``d20`),后续列为数据列。系统扫描文档时自动发现这些标记。
```
:spark[npc]
:spark[encounter]
:spark[loot]
```
抽取时根据骰子公式投掷,查找对应行并发布结果。
## 权限
- **GM**:可使用
- **玩家**:不可使用
- **观察者**:不可使用

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