Compare commits
8 Commits
89b32ef15e
...
6d0bd75cb6
| Author | SHA1 | Date |
|---|---|---|
|
|
6d0bd75cb6 | |
|
|
e437d20d73 | |
|
|
f71af6a06d | |
|
|
75e862c310 | |
|
|
c661d2a1d2 | |
|
|
52563fd3ef | |
|
|
9790205468 | |
|
|
d4ebca5fd0 |
|
|
@ -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" }]
|
|
||||||
: [],
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"}`
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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") {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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: [] };
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue