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: { 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,18 @@ 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",
},
},
output: { output: {
distPath: { distPath: {
root: "dist/web", 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"> <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 />

View File

@ -15,6 +15,10 @@ import {
processBlocks, processBlocks,
type ProcessedBlocks, type ProcessedBlocks,
} from "../completions/block-processor.js"; } from "../completions/block-processor.js";
import {
scanDirectives,
type DirectiveScanResult,
} from "../completions/directive-scanner.js";
import type { StatSheet } from "../completions/types.js"; import type { StatSheet } from "../completions/types.js";
interface ContentIndex { interface ContentIndex {
@ -106,14 +110,16 @@ function labelFromId(id: string): string {
export function scanDirectory(dir: string): { export function scanDirectory(dir: string): {
index: ContentIndex; index: ContentIndex;
blocks: ProcessedBlocks; blocks: ProcessedBlocks;
directiveResults: DirectiveScanResult[];
} { } {
const index: ContentIndex = {}; const index: ContentIndex = {};
const blocks: ProcessedBlocks = { const blocks: ProcessedBlocks = {
stats: [], stats: [],
statTemplates: [], statTemplates: [],
sparkTables: [],
statSheets: [], statSheets: [],
}; };
const directiveResults: DirectiveScanResult[] = [];
const mdFiles: { content: string; relPath: string }[] = [];
function scan(currentPath: string, relativePath: string) { function scan(currentPath: string, relativePath: string) {
const entries = readdirSync(currentPath); const entries = readdirSync(currentPath);
@ -141,7 +147,7 @@ export function scanDirectory(dir: string): {
index[normalizedRelPath] = result.stripped; index[normalizedRelPath] = result.stripped;
blocks.stats.push(...result.blocks.stats); blocks.stats.push(...result.blocks.stats);
blocks.statTemplates.push(...result.blocks.statTemplates); 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")) { } else if (entry.endsWith(".sheet.svg")) {
index[normalizedRelPath] = content; index[normalizedRelPath] = content;
const id = entry.replace(/\.sheet\.svg$/i, ""); const id = entry.replace(/\.sheet\.svg$/i, "");
@ -164,7 +170,32 @@ export function scanDirectory(dir: string): {
} }
scan(dir, ""); 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 = { let collectedBlocks: ProcessedBlocks = {
stats: [], stats: [],
statTemplates: [], statTemplates: [],
sparkTables: [],
statSheets: [], statSheets: [],
}; };
let directiveResults: DirectiveScanResult[] = [];
let completionsIndex: CompletionsPayload = { let completionsIndex: CompletionsPayload = {
dice: [], dice: [],
links: [], links: [],
@ -324,7 +355,7 @@ export function createContentServer(
/** 从当前内容索引和已收集的块重新扫描补全数据 */ /** 从当前内容索引和已收集的块重新扫描补全数据 */
function recomputeCompletions(): void { function recomputeCompletions(): void {
completionsIndex = scanCompletions(contentIndex, collectedBlocks); completionsIndex = scanCompletions(contentIndex, collectedBlocks, directiveResults);
console.log( 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}`, `[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); const scanResult = scanDirectory(contentDir);
contentIndex = scanResult.index; contentIndex = scanResult.index;
collectedBlocks = scanResult.blocks; collectedBlocks = scanResult.blocks;
directiveResults = scanResult.directiveResults;
console.log(`已索引 ${Object.keys(contentIndex).length} 个文件`); console.log(`已索引 ${Object.keys(contentIndex).length} 个文件`);
recomputeCompletions(); recomputeCompletions();
@ -363,12 +395,14 @@ export function createContentServer(
// Re-scan to get fresh blocks (simpler than per-file merge) // Re-scan to get fresh blocks (simpler than per-file merge)
const rescan = scanDirectory(contentDir); const rescan = scanDirectory(contentDir);
collectedBlocks = rescan.blocks; collectedBlocks = rescan.blocks;
directiveResults = rescan.directiveResults;
recomputeCompletions(); recomputeCompletions();
} else { } else {
contentIndex[relPath] = content; contentIndex[relPath] = content;
if (relPath.endsWith(".sheet.svg")) { if (relPath.endsWith(".sheet.svg")) {
const rescan = scanDirectory(contentDir); const rescan = scanDirectory(contentDir);
collectedBlocks = rescan.blocks; collectedBlocks = rescan.blocks;
directiveResults = rescan.directiveResults;
recomputeCompletions(); recomputeCompletions();
} }
} }
@ -393,12 +427,14 @@ export function createContentServer(
contentIndex[relPath] = result.stripped; contentIndex[relPath] = result.stripped;
const rescan = scanDirectory(contentDir); const rescan = scanDirectory(contentDir);
collectedBlocks = rescan.blocks; collectedBlocks = rescan.blocks;
directiveResults = rescan.directiveResults;
recomputeCompletions(); recomputeCompletions();
} else { } else {
contentIndex[relPath] = content; contentIndex[relPath] = content;
if (relPath.endsWith(".sheet.svg")) { if (relPath.endsWith(".sheet.svg")) {
const rescan = scanDirectory(contentDir); const rescan = scanDirectory(contentDir);
collectedBlocks = rescan.blocks; collectedBlocks = rescan.blocks;
directiveResults = rescan.directiveResults;
recomputeCompletions(); recomputeCompletions();
} }
} }
@ -421,6 +457,7 @@ export function createContentServer(
if (relPath.endsWith(".md") || relPath.endsWith(".sheet.svg")) { if (relPath.endsWith(".md") || relPath.endsWith(".sheet.svg")) {
const rescan = scanDirectory(contentDir); const rescan = scanDirectory(contentDir);
collectedBlocks = rescan.blocks; collectedBlocks = rescan.blocks;
directiveResults = rescan.directiveResults;
recomputeCompletions(); recomputeCompletions();
} }
} }

View File

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

View File

@ -9,9 +9,6 @@
* - `id` is used for cross-references (template names, file paths) * - `id` is used for cross-references (template names, file paths)
*/ */
import Slugger from "github-slugger";
import type { SparkTableCompletion } from "./types.js";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -58,46 +55,13 @@ export function parseBlockAttrs(info: string): BlockAttrs {
* *
* Defaults: * Defaults:
* - role is set, no explicit as "none" (strip it's metadata) * - 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) * - no role, no as "codeblock" (keep as visible code block)
*/ */
export function resolveBlockAs(role: string | undefined, as: string | undefined): string { export function resolveBlockAs(role: string | undefined, as: string | undefined): string {
if (as) return as; if (as) return as;
// spark-table blocks render as md-table by default
if (role === "spark-table") return "md-table";
if (role) return "none"; if (role) return "none";
return "codeblock"; 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. * Completion index orchestrates all registered completion sources.
*/ */
import { diceSource } from "./sources/dice.js";
import { linksSource } from "./sources/links.js"; import { linksSource } from "./sources/links.js";
import type { ProcessedBlocks } from "./block-processor.js"; import type { ProcessedBlocks } from "./block-processor.js";
import type { CompletionsPayload } from "./types.js"; import type { CompletionsPayload } from "./types.js";
import type { DirectiveScanResult } from "./directive-scanner.js";
export type { export type {
CompletionsPayload, CompletionsPayload,
@ -18,20 +18,29 @@ export type {
} from "./types.js"; } 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. * Called at server startup and on any file change.
*/ */
export function scanCompletions( export function scanCompletions(
index: Record<string, string>, index: Record<string, string>,
blocks: ProcessedBlocks, blocks: ProcessedBlocks,
directiveResults: DirectiveScanResult[],
): CompletionsPayload { ): CompletionsPayload {
const dice = diceSource.scan(index) as CompletionsPayload["dice"];
const links = linksSource.scan(index) as CompletionsPayload["links"]; 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 { return {
dice, dice,
links, links,
sparkTables: blocks.sparkTables, sparkTables,
stats: blocks.stats, stats: blocks.stats,
statTemplates: blocks.statTemplates, statTemplates: blocks.statTemplates,
statSheets: blocks.statSheets, statSheets: blocks.statSheets,

View File

@ -121,7 +121,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}>

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();
@ -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}

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);

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);
}; };

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();
}; };

View File

@ -330,9 +330,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") {

View File

@ -254,7 +254,7 @@ export const StatsView: Component = () => {
{/* Sheet picker — only show if sheets are available */} {/* Sheet picker — only show if sheets are available */}
<Show when={sheets().length > 0}> <Show when={sheets().length > 0}>
<div class="absolute bottom-3 right-3"> <div class="sticky bottom-3 flex justify-end pr-3">
{/* Dropdown trigger */} {/* Dropdown trigger */}
<button <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" 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 { import {
FENCED_BLOCK_RE, FENCED_BLOCK_RE,
parseBlockAttrs, parseBlockAttrs,
parseSparkTableCsv,
} from "../../cli/completions/block-scanner"; } from "../../cli/completions/block-scanner";
import {
scanDirectives,
buildSparkTableCompletion,
inspectSparkTableCsv,
} from "../../cli/completions/directive-scanner";
export type { StatDef, StatTemplate, StatSheet }; export type { StatDef, StatTemplate, StatSheet };
@ -105,26 +109,24 @@ async function scanClientSide(): Promise<JournalCompletions> {
const sparkTables: SparkTableCompletion[] = []; const sparkTables: SparkTableCompletion[] = [];
const stats: StatDef[] = []; const stats: StatDef[] = [];
const statTemplates: StatTemplate[] = []; 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) { for (const filePath of paths) {
const content = await getIndexedData(filePath); const content = await getIndexedData(filePath);
if (content) tempIndex[filePath] = content;
}
for (const filePath of paths) {
const content = tempIndex[filePath];
if (!content) continue; if (!content) continue;
// Fresh slugger per file // Fresh slugger per file
const slugger = new Slugger(); const slugger = new Slugger();
// ---- Dice directives ---- // ---- Links (headings) - from original content ----
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) ----
const basePath = filePath.replace(/\.md$/, ""); const basePath = filePath.replace(/\.md$/, "");
const fileName = basePath.split("/").filter(Boolean).pop() || basePath; const fileName = basePath.split("/").filter(Boolean).pop() || basePath;
links.push({ path: basePath, label: fileName, section: null }); 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; FENCED_BLOCK_RE.lastIndex = 0;
let blockMatch: RegExpExecArray | null; let blockMatch: RegExpExecArray | null;
while ((blockMatch = FENCED_BLOCK_RE.exec(content)) !== null) { while ((blockMatch = FENCED_BLOCK_RE.exec(content)) !== null) {
@ -164,12 +166,13 @@ async function scanClientSide(): Promise<JournalCompletions> {
stats.push(...result.modifierDefs); stats.push(...result.modifierDefs);
statTemplates.push(result.template); statTemplates.push(result.template);
} }
}
if (attrs.role === "spark-table") { // ---- Directive scanning (dice + spark tables) ----
const parsed = parseSparkTableCsv(body, filePath, slugger); const fileDir = filePath.split("/").slice(0, -1).join("/") || ".";
if (parsed) sparkTables.push(parsed); const directiveResult = scanDirectives(content, filePath, tempIndex, fileDir);
} dice.push(...directiveResult.dice);
} sparkTables.push(...directiveResult.sparkTables);
} }
return { dice, links, sparkTables, stats, statTemplates, statSheets: [] }; 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. * Create a new session by publishing retained metadata to its meta topic.
* The server picks it up and adds it to the $SESSIONS manifest. * The server picks it up and adds it to the $SESSIONS manifest.
*/ */
export function createSession(name: string, players: string[] = []): void { export function createSession(name: string, players: string[] = []): string | null {
if (!_mqttClient || !_mqttConnected) return; if (!_mqttClient || !_mqttConnected) return null;
const id = const id =
name name
@ -385,6 +385,8 @@ export function createSession(name: string, players: string[] = []): void {
qos: 1, qos: 1,
retain: true, retain: true,
}); });
return id;
} }
/** /**

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 {
@ -18,20 +22,20 @@ 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;
/** 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 +48,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|\.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 恢复保存的目录句柄
if (!activeSource) { if (!activeSource) {
try { try {
const restored = await restoreSavedHandle(); const restored = await restoreSavedHandle();
@ -176,7 +156,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();

15
src/global.d.ts vendored
View File

@ -35,21 +35,6 @@ interface Window {
): Promise<FileSystemDirectoryHandle>; ): 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" { declare module "*.md" {
const content: string; const content: string;
export default content; export default content;

View File

@ -1,5 +1,4 @@
import type { MarkedExtension, Tokens } from "marked"; import type { MarkedExtension, Tokens } from "marked";
import Slugger from "github-slugger";
/** /**
* CSV * CSV
@ -27,32 +26,17 @@ function tableToCSV(headers: string[], rows: string[][]): string {
return [headerLine, ...dataLines].join("\n"); 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 { export default function markedTable(): MarkedExtension {
return { return {
renderer: { renderer: {
table(token: Tokens.Table) { table(token: Tokens.Table) {
// 检查表头是否包含 md-table-label
const header = token.header; const header = token.header;
let roll = ""; let roll = "";
let spark = ""; let remix = "";
const labelIndex = header.findIndex((cell) => { const labelIndex = header.findIndex((cell) => {
if (cell.text === "md-roll-label" || cell.text.match(/(\d+)?d\d+/)) { if (cell.text === "md-roll-label" || cell.text.match(/(\d+)?d\d+/)) {
roll = " roll=true"; 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; return true;
} else if (cell.text === "md-remix-label") { } else if (cell.text === "md-remix-label") {
roll = " roll=true remix=true"; roll = " roll=true remix=true";
@ -87,7 +71,9 @@ export default function markedTable(): MarkedExtension {
const csvData = tableToCSV(headers, rows); const csvData = tableToCSV(headers, rows);
// 渲染为 md-table 组件,内联 CSV 数据 // 渲染为 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`;
}, },
}, },
}; };