Compare commits

...

8 Commits

Author SHA1 Message Date
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
19 changed files with 476 additions and 178 deletions

View File

@ -21,8 +21,8 @@ export default defineConfig({
module: {
rules: [
{
test: /\.md|\.yarn|\.csv$/,
exclude: /\.schema\.csv$/,
// Built-in doc entries are statically imported as strings
test: /[\\/]doc-entries[\\/].*\.md$/,
type: "asset/source",
},
],
@ -40,13 +40,18 @@ export default defineConfig({
index: "./src/main.tsx",
},
},
server: {
proxy: {
// Proxy content API requests to the CLI dev server (npm run tttk)
"/__CONTENT_INDEX.json": "http://localhost:3000",
"/__COMPLETIONS.json": "http://localhost:3000",
"/content": "http://localhost:3000",
"/.ttrpg": "http://localhost:3000",
},
},
output: {
distPath: {
root: "dist/web",
},
copy:
process.env.NODE_ENV === "development"
? [{ from: "./content", to: "content" }]
: [],
},
});

View File

@ -152,7 +152,7 @@ const App: Component = () => {
<main ref={mainRef} class="flex-1 min-w-0 overflow-y-auto">
<div class="max-w-4xl mx-auto px-4 py-8">
<Article
class="prose text-black prose-sm max-w-full"
class="prose prose-sm max-w-full"
src={currentPath()}
>
<RevealManager />

View File

@ -15,6 +15,10 @@ import {
processBlocks,
type ProcessedBlocks,
} from "../completions/block-processor.js";
import {
scanDirectives,
type DirectiveScanResult,
} from "../completions/directive-scanner.js";
import type { StatSheet } from "../completions/types.js";
interface ContentIndex {
@ -106,14 +110,16 @@ function labelFromId(id: string): string {
export function scanDirectory(dir: string): {
index: ContentIndex;
blocks: ProcessedBlocks;
directiveResults: DirectiveScanResult[];
} {
const index: ContentIndex = {};
const blocks: ProcessedBlocks = {
stats: [],
statTemplates: [],
sparkTables: [],
statSheets: [],
};
const directiveResults: DirectiveScanResult[] = [];
const mdFiles: { content: string; relPath: string }[] = [];
function scan(currentPath: string, relativePath: string) {
const entries = readdirSync(currentPath);
@ -141,7 +147,7 @@ export function scanDirectory(dir: string): {
index[normalizedRelPath] = result.stripped;
blocks.stats.push(...result.blocks.stats);
blocks.statTemplates.push(...result.blocks.statTemplates);
blocks.sparkTables.push(...result.blocks.sparkTables);
mdFiles.push({ content: result.stripped, relPath: normalizedRelPath });
} else if (entry.endsWith(".sheet.svg")) {
index[normalizedRelPath] = content;
const id = entry.replace(/\.sheet\.svg$/i, "");
@ -164,7 +170,32 @@ export function scanDirectory(dir: string): {
}
scan(dir, "");
return { index, blocks };
// ---- Directive scanning pass (after all blocks processed) ----
const posixDir = dir.split(sep).join("/");
for (const { content, relPath } of mdFiles) {
const fileDir = posixRelDir(relPath);
const result = scanDirectives(content, relPath, index, fileDir);
// Apply rewritten content back to index
index[relPath] = result.rewritten;
// Inject new index entries (inline CSV bodies)
for (const [key, value] of Object.entries(result.newIndexEntries)) {
index[key] = value;
}
directiveResults.push(result);
}
return { index, blocks, directiveResults };
}
/** Get the POSIX directory of a file path */
function posixRelDir(filePath: string): string {
const parts = filePath.split("/");
parts.pop();
return parts.join("/") || ".";
}
/**
@ -310,9 +341,9 @@ export function createContentServer(
let collectedBlocks: ProcessedBlocks = {
stats: [],
statTemplates: [],
sparkTables: [],
statSheets: [],
};
let directiveResults: DirectiveScanResult[] = [];
let completionsIndex: CompletionsPayload = {
dice: [],
links: [],
@ -324,7 +355,7 @@ export function createContentServer(
/** 从当前内容索引和已收集的块重新扫描补全数据 */
function recomputeCompletions(): void {
completionsIndex = scanCompletions(contentIndex, collectedBlocks);
completionsIndex = scanCompletions(contentIndex, collectedBlocks, directiveResults);
console.log(
`[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length} stats=${completionsIndex.stats.length} templates=${completionsIndex.statTemplates.length} sheets=${completionsIndex.statSheets.length}`,
);
@ -335,6 +366,7 @@ export function createContentServer(
const scanResult = scanDirectory(contentDir);
contentIndex = scanResult.index;
collectedBlocks = scanResult.blocks;
directiveResults = scanResult.directiveResults;
console.log(`已索引 ${Object.keys(contentIndex).length} 个文件`);
recomputeCompletions();
@ -363,12 +395,14 @@ export function createContentServer(
// Re-scan to get fresh blocks (simpler than per-file merge)
const rescan = scanDirectory(contentDir);
collectedBlocks = rescan.blocks;
directiveResults = rescan.directiveResults;
recomputeCompletions();
} else {
contentIndex[relPath] = content;
if (relPath.endsWith(".sheet.svg")) {
const rescan = scanDirectory(contentDir);
collectedBlocks = rescan.blocks;
directiveResults = rescan.directiveResults;
recomputeCompletions();
}
}
@ -393,12 +427,14 @@ export function createContentServer(
contentIndex[relPath] = result.stripped;
const rescan = scanDirectory(contentDir);
collectedBlocks = rescan.blocks;
directiveResults = rescan.directiveResults;
recomputeCompletions();
} else {
contentIndex[relPath] = content;
if (relPath.endsWith(".sheet.svg")) {
const rescan = scanDirectory(contentDir);
collectedBlocks = rescan.blocks;
directiveResults = rescan.directiveResults;
recomputeCompletions();
}
}
@ -421,6 +457,7 @@ export function createContentServer(
if (relPath.endsWith(".md") || relPath.endsWith(".sheet.svg")) {
const rescan = scanDirectory(contentDir);
collectedBlocks = rescan.blocks;
directiveResults = rescan.directiveResults;
recomputeCompletions();
}
}

View File

@ -7,7 +7,6 @@
import { posix } from "path";
import { createHash } from "crypto";
import Slugger from "github-slugger";
import {
parseStatYaml,
parseStatCsv,
@ -20,16 +19,14 @@ import {
FENCED_BLOCK_RE,
parseBlockAttrs,
resolveBlockAs,
parseSparkTableCsv,
} from "./block-scanner.js";
import type { SparkTableCompletion, StatSheet } from "./types.js";
import type { StatSheet } from "./types.js";
// Re-export shared pieces for convenience
export {
FENCED_BLOCK_RE,
parseBlockAttrs,
resolveBlockAs,
parseSparkTableCsv,
type BlockAttrs,
} from "./block-scanner.js";
@ -40,7 +37,6 @@ export {
export interface ProcessedBlocks {
stats: StatDef[];
statTemplates: StatTemplate[];
sparkTables: SparkTableCompletion[];
statSheets: StatSheet[];
}
@ -68,7 +64,9 @@ function contentHash(body: string): string {
*
* - Strips/replaces blocks based on `as`
* - Injects `role=file` bodies into the content index
* - Collects stat/template/spark-table blocks for completions
* - Collects stat/template blocks for completions
* - role=spark-table blocks are converted to :md-table directives
* (spark table completions are collected later by the directive scanner)
*/
export function processBlocks(
content: string,
@ -76,12 +74,10 @@ export function processBlocks(
index: Record<string, string>,
): BlockResult {
const fileDir = posix.dirname(fileRelativePath);
const slugger = new Slugger();
const blocks: ProcessedBlocks = {
stats: [],
statTemplates: [],
sparkTables: [],
statSheets: [],
};
@ -122,11 +118,6 @@ export function processBlocks(
blocks.statTemplates.push(result.template);
}
if (attrs.role === "spark-table") {
const parsed = parseSparkTableCsv(body, fileRelativePath, slugger);
if (parsed) blocks.sparkTables.push(parsed);
}
if (attrs.role === "file") {
const filename = attrs.id
? `${attrs.id}.${attrs.lang || "txt"}`

View File

@ -9,9 +9,6 @@
* - `id` is used for cross-references (template names, file paths)
*/
import Slugger from "github-slugger";
import type { SparkTableCompletion } from "./types.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
@ -58,46 +55,13 @@ export function parseBlockAttrs(info: string): BlockAttrs {
*
* Defaults:
* - role is set, no explicit as "none" (strip it's metadata)
* - role=spark-table "md-table" (render as md-table directive)
* - no role, no as "codeblock" (keep as visible code block)
*/
export function resolveBlockAs(role: string | undefined, as: string | undefined): string {
if (as) return as;
// spark-table blocks render as md-table by default
if (role === "spark-table") return "md-table";
if (role) return "none";
return "codeblock";
}
// ---------------------------------------------------------------------------
// Spark table parsing
// ---------------------------------------------------------------------------
const DICE_RE = /^d\d+$/i;
export function parseSparkTableCsv(
body: string,
filePath: string,
slugger: Slugger,
): SparkTableCompletion | null {
const lines = body.trim().split(/\r?\n/);
if (lines.length < 2) return null;
const headers = lines[0].split(",").map((h) => h.trim());
if (headers.length < 2) return null;
if (!DICE_RE.test(headers[0])) return null;
const dataHeaders = headers.slice(1);
const slug = dataHeaders
.map((h) => slugger.slug(h.toLowerCase()))
.join("-");
const basePath = filePath.replace(/\.md$/, "");
const fileName = basePath.split("/").filter(Boolean).pop() || basePath;
const combinedSlug = `${fileName}-${slug}`;
return {
label: `${fileName} § ${slug}`,
notation: headers[0],
slug: combinedSlug,
filePath: basePath,
headers: dataHeaders,
};
}

View File

@ -0,0 +1,329 @@
/**
* Unified directive scanner shared between CLI and browser.
*
* One pass over stripped markdown content that:
* 1. Detects markdown tables that look like spark tables coerces to
* :md-table[./_inline_{hash}.csv] directives
* 2. Scans :md-dice[...] directives collects DiceCompletion
* 3. Scans :md-table[...] directives resolves CSV, checks if spark table
* collects SparkTableCompletion
* 4. Scans :md-card[...] directives same as md-table
*
* Safe for both Node and browser. No Node-specific imports.
*/
import Slugger from "github-slugger";
import type { DiceCompletion, SparkTableCompletion } from "./types.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface DirectiveScanResult {
/** Rewritten content with markdown tables coerced to directives */
rewritten: string;
/** Dice completions discovered */
dice: DiceCompletion[];
/** Spark table completions discovered */
sparkTables: SparkTableCompletion[];
/** New index entries for inline CSV bodies (key → CSV content) */
newIndexEntries: Record<string, string>;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const DICE_HEADER_RE = /^\d*d\d+$/i;
function contentHash(body: string): string {
// Simple hash suitable for both Node and browser
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);
}
function looksLikeDice(raw: string): boolean {
if (raw.length > 80) return false;
return /^\d*d\d+/i.test(raw) || /^[+-]/.test(raw);
}
// ---------------------------------------------------------------------------
// Markdown table → CSV conversion
// ---------------------------------------------------------------------------
/**
* Split a markdown table row into cells.
* Handles leading/trailing pipes and trims whitespace.
*/
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;
// Validate separator row (must contain dashes)
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);
// Pad to match header length
while (cells.length < headers.length) cells.push("");
return cells.slice(0, headers.length).map(escapeCsvCell).join(",");
});
return [csvHeader, ...csvRows].join("\n");
}
// ---------------------------------------------------------------------------
// Spark table CSV inspection
// ---------------------------------------------------------------------------
/**
* Check if a CSV body represents a spark table.
* Returns the data column headers (excluding the dice column) if so, or null.
*/
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 (!DICE_HEADER_RE.test(headers[0])) return null;
return headers.slice(1);
}
/**
* Build a SparkTableCompletion from CSV data and file path.
*/
export function buildSparkTableCompletion(
csv: string,
filePath: string,
slugger: Slugger,
): SparkTableCompletion | null {
const dataHeaders = inspectSparkTableCsv(csv);
if (!dataHeaders) return null;
const lines = csv.trim().split(/\r?\n/);
const headers = lines[0].split(",").map((h) => h.trim());
const slug = dataHeaders
.map((h) => slugger.slug(h.toLowerCase()))
.join("-");
const basePath = filePath.replace(/\.md$/, "");
const fileName = basePath.split("/").filter(Boolean).pop() || basePath;
const combinedSlug = `${fileName}-${slug}`;
return {
label: `${fileName} § ${slug}`,
notation: headers[0],
slug: combinedSlug,
filePath: basePath,
headers: dataHeaders,
};
}
// ---------------------------------------------------------------------------
// Main scanner
// ---------------------------------------------------------------------------
/**
* Scan a single markdown file's stripped content for directives and
* spark-shaped markdown tables.
*
* @param content - Stripped markdown content (after block processing)
* @param filePath - The file's path (e.g. "/rules/combat.md")
* @param index - The content index for resolving CSV paths
* @param fileDir - Directory of the file (for resolving relative paths)
*/
export function scanDirectives(
content: string,
filePath: string,
index: Record<string, string>,
fileDir: string,
): DirectiveScanResult {
const slugger = new Slugger();
const dice: DiceCompletion[] = [];
const sparkTables: SparkTableCompletion[] = [];
const newIndexEntries: Record<string, string> = {};
// ------------------------------------------------------------------
// Pass 1: Coerce spark-shaped markdown tables to :md-table directives
// ------------------------------------------------------------------
const mdTableRegex =
/^(\|.+\|)\n(\|[-: |]+\|)\n((?:\|.+\|\n?)+)/gm;
let rewritten = content;
let mdMatch: RegExpExecArray | null;
// Collect matches first (rewriting while iterating is tricky with regex)
interface TableMatch {
fullMatch: string;
headerRow: string;
separatorRow: string;
bodyRowsText: string;
index: number;
}
const tableMatches: TableMatch[] = [];
while ((mdMatch = mdTableRegex.exec(content)) !== null) {
const [, headerRow, separatorRow, bodyRowsText] = mdMatch;
const headers = splitTableRow(headerRow);
// Check if this is a spark-shaped table: first column is a dice formula
// and one of the headers is a label marker
const isSpark =
DICE_HEADER_RE.test(headers[0]) &&
headers.some(
(h) =>
h === "label" ||
h === "md-table-label" ||
h === "md-roll-label",
);
if (!isSpark) continue;
const bodyRows = bodyRowsText
.trim()
.split(/\n/)
.filter((r) => r.trim().startsWith("|"));
const csv = markdownTableToCsv(headerRow, separatorRow, bodyRows);
if (!csv) continue;
tableMatches.push({
fullMatch: mdMatch[0],
headerRow,
separatorRow,
bodyRowsText,
index: mdMatch.index,
});
}
// Replace matches from end to start to preserve indices
for (let i = tableMatches.length - 1; i >= 0; i--) {
const m = tableMatches[i];
const bodyRows = m.bodyRowsText
.trim()
.split(/\n/)
.filter((r) => r.trim().startsWith("|"));
const csv = markdownTableToCsv(m.headerRow, m.separatorRow, bodyRows)!;
const hash = contentHash(csv);
const filename = `_spark_md_${hash}.csv`;
const resolvedPath = `${fileDir}/${filename}`;
newIndexEntries[resolvedPath] = csv;
// Collect spark table completion
const st = buildSparkTableCompletion(csv, filePath, slugger);
if (st) {
sparkTables.push(st);
}
// Replace markdown table with :md-table directive
const directive = `:md-table[./${filename}]{data-spark="${st?.slug ?? ""}"}`;
rewritten =
rewritten.slice(0, m.index) +
directive +
rewritten.slice(m.index + m.fullMatch.length);
}
// ------------------------------------------------------------------
// Pass 2: Scan :md-dice[...] directives
// ------------------------------------------------------------------
const diceRegex = /:md-dice\[([^[\]]+)\]/gi;
let diceMatch: RegExpExecArray | null;
while ((diceMatch = diceRegex.exec(rewritten)) !== null) {
const raw = diceMatch[1].trim();
if (!raw || !looksLikeDice(raw)) continue;
dice.push({ label: raw, notation: raw, source: filePath });
}
// ------------------------------------------------------------------
// Pass 3: Scan :md-table[...] and :md-card[...] directives
// ------------------------------------------------------------------
const tableDirectiveRegex = /:md-(table|card)\[([^[\]]+)\](?:\{([^}]*)\})?/gi;
let tableMatch: RegExpExecArray | null;
while ((tableMatch = tableDirectiveRegex.exec(rewritten)) !== null) {
const [, /* type */ , path, extraStr] = tableMatch;
// Resolve the CSV path
const csvPath = path.startsWith("./")
? `${fileDir}/${path.slice(2)}`
: path;
let csv = index[csvPath] ?? newIndexEntries[csvPath];
if (!csv) continue;
const st = buildSparkTableCompletion(csv, filePath, slugger);
if (!st) continue;
// Check if data-spark is already set in extra attrs
if (!extraStr || !extraStr.includes("data-spark=")) {
// Inject data-spark attribute into the directive
const fullMatch = tableMatch[0];
const insertPos = fullMatch.indexOf("]") + 1;
const before = fullMatch.slice(0, insertPos);
const after = fullMatch.slice(insertPos);
const sparkAttr = `{data-spark="${st.slug}"}`;
let replacement: string;
if (after.startsWith("{")) {
// Merge into existing attrs
replacement = before + after.replace(/^\{/, `{data-spark="${st.slug}" `);
} else {
replacement = before + sparkAttr + after;
}
rewritten =
rewritten.slice(0, tableMatch.index) +
replacement +
rewritten.slice(tableMatch.index + fullMatch.length);
}
sparkTables.push(st);
}
return { rewritten, dice, sparkTables, newIndexEntries };
}

View File

@ -2,10 +2,10 @@
* Completion index orchestrates all registered completion sources.
*/
import { diceSource } from "./sources/dice.js";
import { linksSource } from "./sources/links.js";
import type { ProcessedBlocks } from "./block-processor.js";
import type { CompletionsPayload } from "./types.js";
import type { DirectiveScanResult } from "./directive-scanner.js";
export type {
CompletionsPayload,
@ -18,20 +18,29 @@ export type {
} from "./types.js";
/**
* Build completions from the content index and pre-collected blocks.
* Build completions from the content index, pre-collected blocks,
* and directive scan results.
* Called at server startup and on any file change.
*/
export function scanCompletions(
index: Record<string, string>,
blocks: ProcessedBlocks,
directiveResults: DirectiveScanResult[],
): CompletionsPayload {
const dice = diceSource.scan(index) as CompletionsPayload["dice"];
const links = linksSource.scan(index) as CompletionsPayload["links"];
// Merge all directive scan results
const dice: CompletionsPayload["dice"] = [];
const sparkTables: CompletionsPayload["sparkTables"] = [];
for (const dr of directiveResults) {
dice.push(...dr.dice);
sparkTables.push(...dr.sparkTables);
}
return {
dice,
links,
sparkTables: blocks.sparkTables,
sparkTables,
stats: blocks.stats,
statTemplates: blocks.statTemplates,
statSheets: blocks.statSheets,

View File

@ -121,7 +121,7 @@ export const Article: Component<ArticleProps & ParentProps> = (props) => {
return (
<article
class={`prose text-black ${props.class || ""}`}
class={`prose ${props.class || ""}`}
data-src={props.src}
>
<Show when={content.loading}>

View File

@ -18,7 +18,6 @@ import {
import { Portal } from "solid-js/web";
import { useLocation } from "@solidjs/router";
import { useArticleDom } from "./Article";
import { useScrollContainer } from "../App";
import { journalStreamState } from "./stores/journalStream";
import { useJournalCompletions } from "./journal/completions";
import {
@ -76,7 +75,6 @@ function hide() {
export const RevealManager: Component = () => {
const contentDom = useArticleDom();
const scrollContainer = useScrollContainer();
const location = useLocation();
const comp = useJournalCompletions();
@ -181,16 +179,8 @@ export const RevealManager: Component = () => {
<button
class={`fixed z-50 inline-flex items-center justify-center w-5 h-5 rounded cursor-pointer ${btn().color}`}
style={{
top: `${
btn().rect.top +
(scrollContainer()?.scrollTop ?? window.scrollY) -
6
}px`,
left: `${
btn().rect.left +
(scrollContainer()?.scrollLeft ?? window.scrollX) -
20
}px`,
top: `${btn().rect.top - 6}px`,
left: `${btn().rect.left - 20}px`,
}}
title={btn().title}
innerHTML={btn().svg}

View File

@ -40,7 +40,12 @@ export const ConnectDialog: Component = () => {
const name = playerName().trim() || stream.myName;
if (!name) return;
const brokerUrl = `ws://${window.location.host}`;
// In dev, the MQTT broker lives on the CLI server (port 3000), not the
// rsbuild dev server. In production, the CLI serves everything itself.
const brokerUrl =
process.env.NODE_ENV === "development"
? `ws://localhost:3000`
: `ws://${window.location.host}`;
setConnecting(true);
setError(null);

View File

@ -25,7 +25,11 @@ export const ConnectionBar: Component = () => {
const handleCreateSession = () => {
const name = newSessionName().trim();
if (!name) return;
createSession(name);
const id = createSession(name);
if (id) {
setSessionId(id);
hydrateFromServer(id).catch(() => {});
}
setNewSessionName("");
setShowNewSession(false);
};

View File

@ -2,7 +2,7 @@
* CreateSessionDialog modal for naming a new session
*/
import { Component, createSignal, onMount } from "solid-js";
import { createSession } from "../stores/journalStream";
import { createSession, setSessionId, hydrateFromServer } from "../stores/journalStream";
interface CreateSessionDialogProps {
onClose: () => void;
@ -12,13 +12,21 @@ export const CreateSessionDialog: Component<CreateSessionDialogProps> = (
props,
) => {
const [name, setName] = createSignal("");
const [creating, setCreating] = createSignal(false);
let inputRef!: HTMLInputElement;
const handleCreate = () => {
const handleCreate = async () => {
const trimmed = name().trim();
if (!trimmed) return;
createSession(trimmed);
setCreating(true);
const id = createSession(trimmed);
if (id) {
setSessionId(id);
// Hydrate the new (empty) session so the UI reflects it immediately
await hydrateFromServer(id).catch(() => {});
}
setName("");
setCreating(false);
props.onClose();
};

View File

@ -330,9 +330,9 @@ export const JournalInput: Component = () => {
selectCompletion("up");
return;
}
if (e.key === "Enter" && comps[selectedIdx()].kind !== "no-results") {
if (e.key === "Enter") {
e.preventDefault();
acceptCompletion(comps[selectedIdx()]);
setShowCompletions(false);
return;
}
if (e.key === "Escape") {

View File

@ -254,7 +254,7 @@ export const StatsView: Component = () => {
{/* Sheet picker — only show if sheets are available */}
<Show when={sheets().length > 0}>
<div class="absolute bottom-3 right-3">
<div class="sticky bottom-3 flex justify-end pr-3">
{/* Dropdown trigger */}
<button
class="bg-white border border-gray-300 rounded-full w-8 h-8 flex items-center justify-center shadow-sm hover:bg-gray-50 transition-colors text-gray-500"

View File

@ -26,8 +26,12 @@ import type { StatSheet } from "../../cli/completions/types";
import {
FENCED_BLOCK_RE,
parseBlockAttrs,
parseSparkTableCsv,
} from "../../cli/completions/block-scanner";
import {
scanDirectives,
buildSparkTableCompletion,
inspectSparkTableCsv,
} from "../../cli/completions/directive-scanner";
export type { StatDef, StatTemplate, StatSheet };
@ -105,26 +109,24 @@ async function scanClientSide(): Promise<JournalCompletions> {
const sparkTables: SparkTableCompletion[] = [];
const stats: StatDef[] = [];
const statTemplates: StatTemplate[] = [];
const tagRegex = /:md-dice\[([^[]+)\]/gi;
// Build a temporary index for resolving CSV paths
const tempIndex: Record<string, string> = {};
// First pass: load all .md content into temp index
for (const filePath of paths) {
const content = await getIndexedData(filePath);
if (content) tempIndex[filePath] = content;
}
for (const filePath of paths) {
const content = tempIndex[filePath];
if (!content) continue;
// Fresh slugger per file
const slugger = new Slugger();
// ---- Dice directives ----
let match: RegExpExecArray | null;
tagRegex.lastIndex = 0;
while ((match = tagRegex.exec(content)) !== null) {
const raw = match[1].trim();
if (!raw || raw.length > 80) continue;
if (!/^\d*d\d+/i.test(raw) && !/^[+-]/.test(raw)) continue;
dice.push({ label: raw, notation: raw, source: filePath });
}
// ---- Links (headings) ----
// ---- Links (headings) - from original content ----
const basePath = filePath.replace(/\.md$/, "");
const fileName = basePath.split("/").filter(Boolean).pop() || basePath;
links.push({ path: basePath, label: fileName, section: null });
@ -136,7 +138,7 @@ async function scanClientSide(): Promise<JournalCompletions> {
});
}
// ---- Unified block scanning ----
// ---- Unified block scanning (stats) ----
FENCED_BLOCK_RE.lastIndex = 0;
let blockMatch: RegExpExecArray | null;
while ((blockMatch = FENCED_BLOCK_RE.exec(content)) !== null) {
@ -164,12 +166,13 @@ async function scanClientSide(): Promise<JournalCompletions> {
stats.push(...result.modifierDefs);
statTemplates.push(result.template);
}
if (attrs.role === "spark-table") {
const parsed = parseSparkTableCsv(body, filePath, slugger);
if (parsed) sparkTables.push(parsed);
}
}
// ---- Directive scanning (dice + spark tables) ----
const fileDir = filePath.split("/").slice(0, -1).join("/") || ".";
const directiveResult = scanDirectives(content, filePath, tempIndex, fileDir);
dice.push(...directiveResult.dice);
sparkTables.push(...directiveResult.sparkTables);
}
return { dice, links, sparkTables, stats, statTemplates, statSheets: [] };

View File

@ -370,8 +370,8 @@ export async function connectStream(
* 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;
export function createSession(name: string, players: string[] = []): string | null {
if (!_mqttClient || !_mqttConnected) return null;
const id =
name
@ -385,6 +385,8 @@ export function createSession(name: string, players: string[] = []): void {
qos: 1,
retain: true,
});
return id;
}
/**

View File

@ -1,10 +1,14 @@
/**
*
*
* 1. CLI /__CONTENT_INDEX.json
* 2. Dev 使 webpackContext .md dev
* 3.
*
* 1. CLI / /__CONTENT_INDEX.json
* 2.
* - IndexedDB
*
* Dev CLI rsbuild
* > npm run tttk serve ./content
* > npm run dev
* rsbuild /__CONTENT_INDEX.json/__COMPLETIONS.json/content/ CLI
*/
import {
@ -18,20 +22,20 @@ type FileIndex = Record<string, string>;
let fileIndex: FileIndex | null = null;
let indexLoadPromise: Promise<void> | null = null;
let activeSource: "cli" | "webpack" | "folder" | null = null;
let activeSource: "cli" | "folder" | null = null;
/** Currently active directory handle (if folder source) */
let activeDirHandle: FileSystemDirectoryHandle | null = null;
/**
*
* CLI JSON webpack context (dev only)
* CLI JSON
*/
function ensureIndexLoaded(): Promise<void> {
if (indexLoadPromise) return indexLoadPromise;
indexLoadPromise = (async () => {
// 策略 1: CLI 环境 — 从 /__CONTENT_INDEX.json 加载
// 策略 1: CLI / 代理环境 — 从 /__CONTENT_INDEX.json 加载
try {
const response = await fetch("/__CONTENT_INDEX.json");
if (response.ok) {
@ -44,31 +48,7 @@ function ensureIndexLoaded(): Promise<void> {
// CLI 索引不可用时尝试下一个策略
}
// 策略 2: Dev 环境 — webpackContext + raw loader仅 dev 构建时生效)
// 生产构建时 process.env.NODE_ENV 被替换为 'production',整个分支会被 tree-shake 掉
if (process.env.NODE_ENV === "development") {
try {
const context = import.meta.webpackContext("../../content", {
recursive: true,
regExp: /\.md|\.yarn|\.csv|\.svg$/i,
});
const keys = context.keys();
const index: FileIndex = {};
for (const key of keys) {
const module = context(key) as { default?: string } | string;
const content =
typeof module === "string" ? module : (module.default ?? "");
const normalizedPath = "/content" + key.slice(1);
index[normalizedPath] = content;
}
fileIndex = { ...fileIndex, ...index };
activeSource = "webpack";
} catch (e) {
// webpackContext 不可用时忽略
}
}
// 策略 3: 浏览器 — 尝试从 IndexedDB 恢复保存的目录句柄
// 策略 2: 浏览器 — 尝试从 IndexedDB 恢复保存的目录句柄
if (!activeSource) {
try {
const restored = await restoreSavedHandle();
@ -176,7 +156,7 @@ export async function loadFromUserFolder(): Promise<string[] | null> {
/**
*
* 退 CLI/webpack
* 退 CLI
*/
export async function switchToBuiltInSource(): Promise<void> {
await removeHandle();

15
src/global.d.ts vendored
View File

@ -35,21 +35,6 @@ interface Window {
): Promise<FileSystemDirectoryHandle>;
}
interface WebpackContext {
(path: string): { default?: string } | string;
keys(): string[];
}
interface ImportMeta {
webpackContext(
directory: string,
options: {
recursive?: boolean;
regExp?: RegExp;
},
): WebpackContext;
}
declare module "*.md" {
const content: string;
export default content;

View File

@ -1,5 +1,4 @@
import type { MarkedExtension, Tokens } from "marked";
import Slugger from "github-slugger";
/**
* CSV
@ -27,32 +26,17 @@ function tableToCSV(headers: string[], rows: string[][]): string {
return [headerLine, ...dataLines].join("\n");
}
/** Spark table: first column header is a pure dice formula (d6, d20, etc.) */
const SPARK_DICE_RE = /^\d*d\d+$/i;
export default function markedTable(): MarkedExtension {
return {
renderer: {
table(token: Tokens.Table) {
// 检查表头是否包含 md-table-label
const header = token.header;
let roll = "";
let spark = "";
let remix = "";
const labelIndex = header.findIndex((cell) => {
if (cell.text === "md-roll-label" || cell.text.match(/(\d+)?d\d+/)) {
roll = " roll=true";
// If this is a spark table (first column is a pure dice formula),
// compute the data-column slug for DOM injection
if (SPARK_DICE_RE.test(cell.text)) {
const slugger = new Slugger();
const dataHeaders = header
.filter((_, i) => i !== 0)
.map((h) => h.text);
const slug = dataHeaders
.map((h) => slugger.slug(h.toLowerCase()))
.join("-");
spark = ` data-spark="${slug}"`;
}
return true;
} else if (cell.text === "md-remix-label") {
roll = " roll=true remix=true";
@ -87,7 +71,9 @@ export default function markedTable(): MarkedExtension {
const csvData = tableToCSV(headers, rows);
// 渲染为 md-table 组件,内联 CSV 数据
return `<md-table ${roll}${spark}>${csvData}</md-table>\n`;
// data-spark attribute is injected by the CLI directive scanner,
// not computed here.
return `<md-table ${roll}${remix}>${csvData}</md-table>\n`;
},
},
};