Compare commits

..

No commits in common. "e6d74cc3189b3f70035de049c968bf002fcd7cd6" and "3690d13407b46956bbd31450c5a3f2bc25406772" have entirely different histories.

20 changed files with 151 additions and 1059 deletions

View File

@ -1,6 +1,7 @@
import { Component, createEffect, createMemo, createSignal } from "solid-js"; import { Component, createEffect, createMemo, createSignal } from "solid-js";
import { useLocation } from "@solidjs/router"; import { useLocation } from "@solidjs/router";
import { useJournalStream } from "./components/stores/journalStream"; import { useJournalStream } from "./components/stores/journalStream";
import { addRevealedClasses } from "./components/stores/reveal";
// 导入组件以注册自定义元素 // 导入组件以注册自定义元素
import "./components"; import "./components";
@ -10,7 +11,6 @@ import {
DesktopSidebar, DesktopSidebar,
DocDialog, DocDialog,
DataSourceDialog, DataSourceDialog,
RevealManager,
} from "./components"; } from "./components";
import { generateToc, type FileNode, type TocNode } from "./data-loader"; import { generateToc, type FileNode, type TocNode } from "./data-loader";
import { JournalPanel } from "./components/journal"; import { JournalPanel } from "./components/journal";
@ -124,9 +124,8 @@ const App: Component = () => {
<Article <Article
class="prose text-black prose-sm max-w-full flex-1" class="prose text-black prose-sm max-w-full flex-1"
src={currentPath()} src={currentPath()}
> onDom={(dom) => addRevealedClasses(dom, location.pathname)}
<RevealManager /> />
</Article>
</main> </main>
</div> </div>
<DocDialog isOpen={isDocOpen()} onClose={() => setIsDocOpen(false)} /> <DocDialog isOpen={isDocOpen()} onClose={() => setIsDocOpen(false)} />

View File

@ -227,17 +227,13 @@ export function createContentServer(
host: string = "0.0.0.0", host: string = "0.0.0.0",
): ContentServer { ): ContentServer {
let contentIndex: ContentIndex = {}; let contentIndex: ContentIndex = {};
let completionsIndex: CompletionsPayload = { let completionsIndex: CompletionsPayload = { dice: [], links: [] };
dice: [],
links: [],
sparkTables: [],
};
/** Re-scan completions from current content index (cached) */ /** Re-scan completions from current content index (cached) */
function recomputeCompletions(): void { function recomputeCompletions(): void {
completionsIndex = scanCompletions(contentIndex); completionsIndex = scanCompletions(contentIndex);
console.log( console.log(
`[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length}`, `[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length}`,
); );
} }

View File

@ -4,22 +4,16 @@
import { diceSource } from "./sources/dice.js"; import { diceSource } from "./sources/dice.js";
import { linksSource } from "./sources/links.js"; import { linksSource } from "./sources/links.js";
import { sparkTablesSource } from "./sources/spark-tables.js";
import type { CompletionSource, CompletionsPayload } from "./types.js"; import type { CompletionSource, CompletionsPayload } from "./types.js";
export type { export type {
CompletionsPayload, CompletionsPayload,
DiceCompletion, DiceCompletion,
LinkCompletion, LinkCompletion,
SparkTableCompletion,
} from "./types.js"; } from "./types.js";
/** Registered sources — open for extension */ /** Registered sources — open for extension */
const sources: CompletionSource[] = [ const sources: CompletionSource[] = [diceSource, linksSource];
diceSource,
linksSource,
sparkTablesSource,
];
/** /**
* Scan the full content index and return structured completion data. * Scan the full content index and return structured completion data.

View File

@ -1,82 +0,0 @@
/**
* Spark table completion source extracts spark tables (markdown tables
* whose first column header is a dice formula like d6, d20, etc.) from all
* .md files.
*/
import Slugger from "github-slugger";
import type { CompletionSource, SparkTableCompletion } from "../types.js";
/** Regex: matches a pipe-delimited markdown table row */
function splitTableRow(line: string): string[] | null {
const trimmed = line.trim();
if (!trimmed.includes("|")) return null;
let inner = trimmed;
if (inner.startsWith("|")) inner = inner.slice(1);
if (inner.endsWith("|")) inner = inner.slice(0, -1);
return inner.split("|").map((c) => c.trim());
}
const SEP_RE = /^:?-{3,}:?$/;
const DICE_RE = /^d\d+$/i;
export const sparkTablesSource: CompletionSource = {
key: "sparkTables",
scan(index) {
const items: SparkTableCompletion[] = [];
for (const [filePath, content] of Object.entries(index)) {
if (!filePath.endsWith(".md")) continue;
// Fresh slugger per file so duplicate headers across files don't
// pollute each other. (github-slugger is stateful.)
const slugger = new Slugger();
const lines = content.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const headerCells = splitTableRow(lines[i]);
if (!headerCells || headerCells.length < 2) continue;
if (!DICE_RE.test(headerCells[0])) continue;
// Check separator row
if (i + 1 >= lines.length) continue;
const sepCells = splitTableRow(lines[i + 1]);
if (!sepCells || !sepCells.every((c) => SEP_RE.test(c))) continue;
// Collect body rows
let j = i + 2;
while (j < lines.length) {
const rowCells = splitTableRow(lines[j]);
if (!rowCells) break;
j++;
}
if (j <= i + 2) continue; // No body rows
// Build slug from data columns
const dataHeaders = headerCells.slice(1);
const slug = dataHeaders
.map((h: string) => slugger.slug(h.toLowerCase()))
.join("-");
const basePath = filePath.replace(/\.md$/, "");
const fileName = basePath.split("/").filter(Boolean).pop() || basePath;
// Combined key: pageName-columnSlug (what user types after /spark)
const combinedSlug = `${fileName}-${slug}`;
items.push({
label: `${fileName} § ${slug}`,
notation: headerCells[0],
slug: combinedSlug,
filePath: basePath,
headers: dataHeaders,
});
i = j - 1;
}
}
return items;
},
};

View File

@ -22,25 +22,10 @@ export interface LinkCompletion {
section: string | null; section: string | null;
} }
/** A spark table found in a markdown file */
export interface SparkTableCompletion {
/** Display label: "file § slug" */
label: string;
/** Dice notation (e.g. "d6", "d20") parsed from the first column header */
notation: string;
/** Concatenated slug of data column headers */
slug: string;
/** File path of the containing .md file */
filePath: string;
/** Data column headers for display */
headers: string[];
}
/** Top-level payload served at /__COMPLETIONS.json */ /** Top-level payload served at /__COMPLETIONS.json */
export interface CompletionsPayload { export interface CompletionsPayload {
dice: DiceCompletion[]; dice: DiceCompletion[];
links: LinkCompletion[]; links: LinkCompletion[];
sparkTables: SparkTableCompletion[];
} }
/** /**

View File

@ -1,21 +1,17 @@
import { import {
Component, Component,
ParentProps,
createContext,
useContext,
createEffect, createEffect,
onCleanup, onCleanup,
Show, Show,
createResource, createResource,
createMemo, createMemo,
createSignal,
} from "solid-js"; } from "solid-js";
import { parseMarkdown } from "../markdown"; import { parseMarkdown } from "../markdown";
import { extractSection } from "../data-loader"; import { extractSection } from "../data-loader";
import mermaid from "mermaid"; import mermaid from "mermaid";
import { getIndexedData } from "../data-loader/file-index"; import { getIndexedData } from "../data-loader/file-index";
import { resolvePath } from "./utils/path"; import { resolvePath } from "./utils/path";
import { useNavigateWithParams } from "./useNavigateWithParams"; import { useLocation } from "@solidjs/router";
export interface ArticleProps { export interface ArticleProps {
src: string; src: string;
@ -25,19 +21,7 @@ export interface ArticleProps {
onError?: (error: Error) => void; onError?: (error: Error) => void;
class?: string; // 额外的 class 用于样式控制 class?: string; // 额外的 class 用于样式控制
scrollToHash?: boolean; // 是否自动滚动到 hash scrollToHash?: boolean; // 是否自动滚动到 hash
} onDom?: (dom: HTMLDivElement) => void;
// ---------------------------------------------------------------------------
// Article DOM context lets child components react to the content container
// ---------------------------------------------------------------------------
const ArticleDomCtx = createContext<() => HTMLDivElement | undefined>();
/** Access the article's content DOM element from a child component. */
export function useArticleDom(): () => HTMLDivElement | undefined {
const ctx = useContext(ArticleDomCtx);
if (!ctx) throw new Error("useArticleDom must be used inside an <Article>");
return ctx;
} }
async function fetchArticleContent(params: { async function fetchArticleContent(params: {
@ -75,13 +59,13 @@ function scrollToHash(hash: string) {
* Article * Article
* src md markdown * src md markdown
*/ */
export const Article: Component<ArticleProps & ParentProps> = (props) => { export const Article: Component<ArticleProps> = (props) => {
const navigate = useNavigateWithParams(); const location = useLocation();
const [content, { refetch }] = createResource( const [content, { refetch }] = createResource(
() => ({ src: props.src, section: props.section }), () => ({ src: props.src, section: props.section }),
fetchArticleContent, fetchArticleContent,
); );
const [contentDom, setContentDom] = createSignal<HTMLDivElement>(); let innerDiv!: HTMLDivElement;
// 解析 iconPath默认为 "./assets",空字符串表示禁用 // 解析 iconPath默认为 "./assets",空字符串表示禁用
const iconPrefix = createMemo(() => { const iconPrefix = createMemo(() => {
@ -97,33 +81,12 @@ export const Article: Component<ArticleProps & ParentProps> = (props) => {
void mermaid.run(); void mermaid.run();
// 内容渲染后检查 hash 并滚动 // 内容渲染后检查 hash 并滚动
scrollToHash(window.location.hash); scrollToHash(location.hash);
props.onDom?.(innerDiv);
} }
}); });
// Intercept markdown <a> links to use client-side navigation with
// preserved URL search params.
createEffect(() => {
const dom = contentDom();
if (!dom) return;
const onClick = (e: MouseEvent) => {
const anchor = (e.target as HTMLElement).closest("a[href]");
if (!anchor) return;
const href = anchor.getAttribute("href");
if (!href) return;
// Only intercept same-origin navigation links (not external, not anchors)
if (href.startsWith("http") || href.startsWith("//")) return;
if (href.startsWith("#")) return;
e.preventDefault();
navigate(href);
};
dom.addEventListener("click", onClick);
onCleanup(() => dom.removeEventListener("click", onClick));
});
onCleanup(() => { onCleanup(() => {
// 清理时清空内容,触发内部组件的销毁 // 清理时清空内容,触发内部组件的销毁
}); });
@ -140,14 +103,11 @@ export const Article: Component<ArticleProps & ParentProps> = (props) => {
<div class="text-red-500">{content.error?.message}</div> <div class="text-red-500">{content.error?.message}</div>
</Show> </Show>
<Show when={!content.loading && !content.error && content()}> <Show when={!content.loading && !content.error && content()}>
<ArticleDomCtx.Provider value={contentDom}> <div
<div class="relative"
class="relative" ref={innerDiv}
ref={setContentDom} innerHTML={parseMarkdown(content()!, iconPrefix())}
innerHTML={parseMarkdown(content()!, iconPrefix())} />
/>
{props.children}
</ArticleDomCtx.Provider>
</Show> </Show>
</article> </article>
); );

View File

@ -1,6 +1,6 @@
import { Component, createMemo, createSignal, Show } from "solid-js"; import { Component, createMemo, createSignal, Show } from "solid-js";
import { type FileNode, type TocNode } from "../data-loader"; import { type FileNode, type TocNode } from "../data-loader";
import { useNavigateWithParams } from "./useNavigateWithParams"; import { useNavigate } from "@solidjs/router";
/** /**
* *
@ -22,7 +22,7 @@ export const FileTreeNode: Component<{
onClose: () => void; onClose: () => void;
isHidden?: (node: FileNode) => boolean; isHidden?: (node: FileNode) => boolean;
}> = (props) => { }> = (props) => {
const navigate = useNavigateWithParams(); const navigate = useNavigate();
const isDir = !!props.node.children; const isDir = !!props.node.children;
const isActive = createMemo(() => props.currentPath === props.node.path); const isActive = createMemo(() => props.currentPath === props.node.path);
// 默认收起,除非当前文件在该文件夹内 // 默认收起,除非当前文件在该文件夹内
@ -85,7 +85,7 @@ export const HeadingNode: Component<{
depth: number; depth: number;
isHidden?: (node: TocNode) => boolean; isHidden?: (node: TocNode) => boolean;
}> = (props) => { }> = (props) => {
const navigate = useNavigateWithParams(); const navigate = useNavigate();
const anchor = props.node.id || ""; const anchor = props.node.id || "";
const href = `${props.basePath}#${anchor}`; const href = `${props.basePath}#${anchor}`;
const hasChildren = !!props.node.children; const hasChildren = !!props.node.children;
@ -99,7 +99,6 @@ export const HeadingNode: Component<{
} }
}; };
const handleClick = (e: MouseEvent) => { const handleClick = (e: MouseEvent) => {
e.preventDefault();
navigate(href); navigate(href);
// 滚动到目标元素,考虑导航栏高度偏移 // 滚动到目标元素,考虑导航栏高度偏移
requestAnimationFrame(() => { requestAnimationFrame(() => {

View File

@ -1,200 +0,0 @@
/**
* RevealManager mounts as a child of <Article> and reactively applies
* reveal classes (non-GM) or injects action buttons (GM) whenever the
* content DOM, stream connection state, role, or completions change.
*
* All action buttons are proper Solid components rendered via Portal.
* Nothing is injected into the custom element DOM listeners are attached
* via addEventListener with proper cleanup.
*/
import {
Component,
createEffect,
createSignal,
onCleanup,
Show,
} from "solid-js";
import { Portal } from "solid-js/web";
import { useLocation } from "@solidjs/router";
import { useArticleDom } from "./Article";
import { journalStreamState } from "./stores/journalStream";
import { useJournalCompletions } from "./journal/completions";
import {
addRevealedClasses,
cleanupInjections,
setActionPrefill,
normalizePath,
} from "./stores/reveal";
// ---------------------------------------------------------------------------
// SVG icons
// ---------------------------------------------------------------------------
const LINK_SVG =
'<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" ' +
'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
'stroke-linecap="round" stroke-linejoin="round">' +
'<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>' +
'<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>' +
"</svg>";
const SPARK_SVG =
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" ' +
'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
'stroke-linecap="round" stroke-linejoin="round">' +
'<path d="M12 2l1.5 6.5L18 7l-4.5 4.5L16 16l-4-2.5L8 16l1.5-4.5L5 7l4.5-.5z"/>' +
"</svg>";
// ---------------------------------------------------------------------------
// Floating button state
// ---------------------------------------------------------------------------
interface FloatingButton {
rect: DOMRect;
action: () => void;
title: string;
svg: string;
color: string;
}
const [floating, setFloating] = createSignal<FloatingButton | null>(null);
let hideTimer: ReturnType<typeof setTimeout> | undefined;
function show(btn: FloatingButton) {
clearTimeout(hideTimer);
setFloating(btn);
}
function hide() {
hideTimer = setTimeout(() => setFloating(null), 200);
}
// ---------------------------------------------------------------------------
// RevealManager
// ---------------------------------------------------------------------------
export const RevealManager: Component = () => {
const contentDom = useArticleDom();
const location = useLocation();
const comp = useJournalCompletions();
// ---- Reveal / conceal classes ----
createEffect(() => {
const dom = contentDom();
if (!dom) return;
const state = journalStreamState;
void state.connected;
void state.myRole;
addRevealedClasses(dom, location.pathname);
});
onCleanup(() => {
const dom = contentDom();
if (dom) cleanupInjections(dom);
});
// ---- GM hover listeners (only when connected as GM) ----
createEffect(() => {
const dom = contentDom();
const state = journalStreamState;
if (!dom || state.myRole !== "gm" || !state.connected) return;
const normalized = normalizePath(location.pathname);
const data = comp.data;
// Spark tables on this page — matched by suffix on column slug.
// filePath in completions may have a leading slash; normalize both sides.
const pageSparkSlugs = data.sparkTables
.filter((st) => st.filePath.replace(/^\//, "") === normalized)
.map((st) => st.slug);
// Single delegated handler for both link and spark
const onMouseOver = (e: MouseEvent) => {
const target = e.target as HTMLElement;
// Spark table
const sparkTable = target.closest("md-table[data-spark]");
if (sparkTable) {
const colSlug = sparkTable.getAttribute("data-spark");
const combinedSlug = colSlug
? pageSparkSlugs.find((s) => s.endsWith(`-${colSlug}`))
: undefined;
if (combinedSlug) {
show({
rect: sparkTable.getBoundingClientRect(),
action: () =>
setActionPrefill({
command: "/spark",
text: combinedSlug,
}),
title: "Roll spark table",
svg: SPARK_SVG,
color: "text-purple-400 hover:text-purple-600 hover:bg-purple-50",
});
return;
}
}
// Heading
const heading = target.closest("h1, h2, h3, h4, h5, h6");
if (heading) {
const text = heading.id || heading.textContent?.trim() || "";
if (text) {
show({
rect: heading.getBoundingClientRect(),
action: () =>
setActionPrefill({
command: "/link",
text: `${normalized}#${text}`,
}),
title: "Send /link to stream",
svg: LINK_SVG,
color: "text-blue-500 hover:bg-blue-50",
});
}
}
};
const onMouseOut = (e: MouseEvent) => {
// Hide only if the mouse actually left the content area
const related = e.relatedTarget as HTMLElement | null;
if (!dom.contains(related)) hide();
};
dom.addEventListener("mouseover", onMouseOver);
dom.addEventListener("mouseout", onMouseOut);
onCleanup(() => {
dom.removeEventListener("mouseover", onMouseOver);
dom.removeEventListener("mouseout", onMouseOut);
});
});
return (
<Show when={floating()}>
{(btn) => (
<Portal>
<button
class={`fixed z-50 inline-flex items-center justify-center w-5 h-5 rounded cursor-pointer ${btn().color}`}
style={{
top: `${btn().rect.top + window.scrollY - 6}px`,
left: `${btn().rect.left + window.scrollX - 20}px`,
}}
title={btn().title}
innerHTML={btn().svg}
onClick={(e) => {
e.stopPropagation();
btn().action();
}}
onMouseEnter={() => clearTimeout(hideTimer)}
onMouseLeave={hide}
/>
</Portal>
)}
</Show>
);
};
export default RevealManager;

View File

@ -13,9 +13,7 @@ export interface DocEntry {
/** Splits frontmatter and markdown body from a raw .md string. */ /** Splits frontmatter and markdown body from a raw .md string. */
function parseFrontmatter(raw: string): Record<string, unknown> | null { function parseFrontmatter(raw: string): Record<string, unknown> | null {
// Handle CRLF line endings by normalizing to LF first const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
const normalized = raw.replace(/\r\n/g, "\n");
const match = normalized.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
if (!match) return null; if (!match) return null;
try { try {
return yaml.load(match[1]) as Record<string, unknown>; return yaml.load(match[1]) as Record<string, unknown>;
@ -25,8 +23,7 @@ function parseFrontmatter(raw: string): Record<string, unknown> | null {
} }
function getBody(raw: string): string { function getBody(raw: string): string {
const normalized = raw.replace(/\r\n/g, "\n"); const match = raw.match(/^---\n[\s\S]*?\n---\n?([\s\S]*)$/);
const match = normalized.match(/^---\n[\s\S]*?\n---\n?([\s\S]*)$/);
return match ? match[1] : raw; return match ? match[1] : raw;
} }

View File

@ -16,7 +16,6 @@ import "./md-token-viewer";
// 导出组件 // 导出组件
export { Article } from "./Article"; export { Article } from "./Article";
export type { ArticleProps } from "./Article"; export type { ArticleProps } from "./Article";
export { RevealManager } from "./RevealManager";
export { MobileSidebar, DesktopSidebar } from "./Sidebar"; export { MobileSidebar, DesktopSidebar } from "./Sidebar";
export type { SidebarProps } from "./Sidebar"; export type { SidebarProps } from "./Sidebar";
export { FileTreeNode, HeadingNode } from "./FileTree"; export { FileTreeNode, HeadingNode } from "./FileTree";

View File

@ -21,10 +21,9 @@ import {
Match, Match,
} from "solid-js"; } from "solid-js";
import { sendMessage, useJournalStream } from "../stores/journalStream"; import { sendMessage, useJournalStream } from "../stores/journalStream";
import { actionPrefill, setActionPrefill } from "../stores/reveal"; import { linkPrefill, setLinkPrefill } from "../stores/reveal";
import { useJournalCompletions, ensureCompletions } from "./completions"; import { useJournalCompletions, ensureCompletions } from "./completions";
import { resolveRollPayload } from "./types/roll"; import { resolveRollPayload } from "./types/roll";
import { resolveSparkPayload } from "./types/spark";
// ---- Helpers ---- // ---- Helpers ----
@ -35,7 +34,7 @@ interface CompletionItem {
} }
interface ParsedInput { interface ParsedInput {
type: "chat" | "roll" | "spark" | "link"; type: "chat" | "roll" | "link";
payload: Record<string, unknown>; payload: Record<string, unknown>;
error?: string; error?: string;
} }
@ -48,13 +47,6 @@ function parseInput(raw: string): ParsedInput {
return { type: "roll", payload: { notation, label: notation } }; return { type: "roll", payload: { notation, label: notation } };
} }
if (raw.startsWith("/spark ")) {
const key = raw.slice("/spark ".length).trim();
if (!key)
return { type: "spark", payload: {}, error: "Spark table key required" };
return { type: "spark", payload: { key } };
}
if (raw.startsWith("/link ")) { if (raw.startsWith("/link ")) {
const arg = raw.slice("/link ".length).trim(); const arg = raw.slice("/link ".length).trim();
if (!arg) return { type: "link", payload: {}, error: "Path required" }; if (!arg) return { type: "link", payload: {}, error: "Path required" };
@ -65,8 +57,8 @@ function parseInput(raw: string): ParsedInput {
return { type: "link", payload: { path, section } }; return { type: "link", payload: { path, section } };
} }
// /roll, /spark, or /link with no space — need to complete, don't send // /roll or /link with no space — need to complete, don't send
if (raw === "/roll" || raw === "/spark" || raw === "/link") { if (raw === "/roll" || raw === "/link") {
return { type: "chat", payload: {}, error: "Complete the command" }; return { type: "chat", payload: {}, error: "Complete the command" };
} }
@ -97,19 +89,19 @@ export const JournalInput: Component = () => {
void ensureCompletions(); void ensureCompletions();
}); });
// Listen for action prefill requests from article buttons (heading /link, spark table roll, etc.) // Listen for /link prefill requests from article headings
createEffect(() => { createEffect(() => {
const action = actionPrefill(); const prefilled = linkPrefill();
if (action) { if (prefilled) {
setText(`${action.command} ${action.text}`); setText(prefilled);
setActionPrefill(null); setLinkPrefill(null);
textareaRef?.focus(); textareaRef?.focus();
} }
}); });
// ---- Send ---- // ---- Send ----
async function handleSend() { function handleSend() {
const raw = text().trim(); const raw = text().trim();
if (!raw) return; if (!raw) return;
@ -150,30 +142,6 @@ export const JournalInput: Component = () => {
return; return;
} }
// GM spark: resolve the spark table roll locally
if (parsed.type === "spark") {
try {
const key = (parsed.payload as { key: string }).key;
// Look up filePath from completions data
const match = comp.data.sparkTables.find((s) => s.slug === key);
const filePath = match?.filePath ?? "";
const p = await resolveSparkPayload({ key, filePath });
const result = sendMessage("spark", p);
if (!result.success) {
setError(result.error);
} else {
setText("");
}
setSending(false);
textareaRef?.focus();
return;
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to roll spark table");
setSending(false);
return;
}
}
const result = sendMessage(parsed.type, parsed.payload); const result = sendMessage(parsed.type, parsed.payload);
if (!result.success) { if (!result.success) {
setError(result.error); setError(result.error);
@ -204,7 +172,6 @@ export const JournalInput: Component = () => {
const data = comp.data; const data = comp.data;
const commands = [ const commands = [
{ label: "/roll", kind: "command" as const, insertText: "/roll " }, { label: "/roll", kind: "command" as const, insertText: "/roll " },
{ label: "/spark", kind: "command" as const, insertText: "/spark " },
{ label: "/link", kind: "command" as const, insertText: "/link " }, { label: "/link", kind: "command" as const, insertText: "/link " },
]; ];
@ -239,32 +206,6 @@ export const JournalInput: Component = () => {
})); }));
} }
// After /spark — show spark table suggestions
if (raw.startsWith("/spark ")) {
const prefix = raw.slice("/spark ".length).toLowerCase();
const matches = data.sparkTables
.filter(
(s) =>
s.slug.toLowerCase().includes(prefix) ||
s.label.toLowerCase().includes(prefix),
)
.slice(0, 8);
if (matches.length === 0) {
return [
{
label: "No spark tables found",
kind: "no-results",
insertText: "",
},
];
}
return matches.map((s) => ({
label: `${s.filePath} § ${s.slug} (${s.notation})`,
kind: "value" as const,
insertText: `/spark ${s.slug}`,
}));
}
// After /link — show article and heading suggestions // After /link — show article and heading suggestions
if (raw.startsWith("/link ")) { if (raw.startsWith("/link ")) {
const prefix = raw.slice("/link ".length).toLowerCase(); const prefix = raw.slice("/link ".length).toLowerCase();
@ -498,7 +439,7 @@ export const JournalInput: Component = () => {
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={ placeholder={
isGm() isGm()
? "Type a message, or /roll, /spark, or /link..." ? "Type a message, or /roll or /link..."
: "Type a message..." : "Type a message..."
} }
rows={2} rows={2}

View File

@ -9,7 +9,6 @@
*/ */
import { createSignal } from "solid-js"; import { createSignal } from "solid-js";
import Slugger from "github-slugger";
import { extractHeadings } from "../../data-loader/toc"; import { extractHeadings } from "../../data-loader/toc";
import { import {
getPathsByExtension, getPathsByExtension,
@ -30,18 +29,9 @@ export interface LinkCompletion {
section: string | null; section: string | null;
} }
export interface SparkTableCompletion {
label: string;
notation: string;
slug: string;
filePath: string;
headers: string[];
}
export interface JournalCompletions { export interface JournalCompletions {
dice: DiceCompletion[]; dice: DiceCompletion[];
links: LinkCompletion[]; links: LinkCompletion[];
sparkTables: SparkTableCompletion[];
} }
export type CompletionsState = export type CompletionsState =
@ -66,7 +56,6 @@ async function tryServer(): Promise<JournalCompletions | null> {
return { return {
dice: Array.isArray(data.dice) ? data.dice : [], dice: Array.isArray(data.dice) ? data.dice : [],
links: Array.isArray(data.links) ? data.links : [], links: Array.isArray(data.links) ? data.links : [],
sparkTables: Array.isArray(data.sparkTables) ? data.sparkTables : [],
}; };
} catch { } catch {
return null; return null;
@ -79,17 +68,12 @@ async function scanClientSide(): Promise<JournalCompletions> {
const paths = await getPathsByExtension("md"); const paths = await getPathsByExtension("md");
const dice: DiceCompletion[] = []; const dice: DiceCompletion[] = [];
const links: LinkCompletion[] = []; const links: LinkCompletion[] = [];
const sparkTables: SparkTableCompletion[] = [];
const tagRegex = /<md-dice[^>]*>\s*([\s\S]*?)\s*<\/md-dice>/gi; const tagRegex = /<md-dice[^>]*>\s*([\s\S]*?)\s*<\/md-dice>/gi;
for (const filePath of paths) { for (const filePath of paths) {
const content = await getIndexedData(filePath); const content = await getIndexedData(filePath);
if (!content) continue; if (!content) continue;
// Fresh slugger per file so duplicate headers across files don't
// pollute each other.
const slugger = new Slugger();
// Dice scan // Dice scan
let match: RegExpExecArray | null; let match: RegExpExecArray | null;
tagRegex.lastIndex = 0; tagRegex.lastIndex = 0;
@ -111,52 +95,9 @@ async function scanClientSide(): Promise<JournalCompletions> {
section: heading.id ?? null, section: heading.id ?? null,
}); });
} }
// Spark table scan
const sparkLines = content.split(/\r?\n/);
for (let i = 0; i < sparkLines.length; i++) {
const headerCells = splitTableRow(sparkLines[i]);
if (!headerCells || headerCells.length < 2) continue;
if (!/^d\d+$/i.test(headerCells[0])) continue;
if (i + 1 >= sparkLines.length) continue;
const sepCells = splitTableRow(sparkLines[i + 1]);
if (!sepCells || !sepCells.every((c) => /^:?-{3,}:?$/.test(c))) continue;
let j = i + 2;
while (j < sparkLines.length && splitTableRow(sparkLines[j])) j++;
if (j <= i + 2) continue;
const dataHeaders = headerCells.slice(1);
const stSlug = dataHeaders
.map((h) => slugger.slug(h.toLowerCase()))
.join("-");
// Combined key: pageName-columnSlug
const combinedSlug = `${fileName}-${stSlug}`;
sparkTables.push({
label: `${fileName} § ${stSlug}`,
notation: headerCells[0],
slug: combinedSlug,
filePath: basePath,
headers: dataHeaders,
});
i = j - 1;
}
} }
return { dice, links, sparkTables }; return { dice, links };
}
function splitTableRow(line: string): string[] | null {
const trimmed = line.trim();
if (!trimmed.includes("|")) return null;
let inner = trimmed;
if (inner.startsWith("|")) inner = inner.slice(1);
if (inner.endsWith("|")) inner = inner.slice(0, -1);
return inner.split("|").map((c) => c.trim());
} }
// ------------------- Init (runs eagerly at import time) ------------------- // ------------------- Init (runs eagerly at import time) -------------------
@ -216,5 +157,5 @@ export function useJournalCompletions(): {
if (s.status === "loaded") { if (s.status === "loaded") {
return { state: s, data: s.data }; return { state: s, data: s.data };
} }
return { state: s, data: { dice: [], links: [], sparkTables: [] } }; return { state: s, data: { dice: [], links: [] } };
} }

View File

@ -7,6 +7,5 @@
import "./chat"; import "./chat";
import "./roll"; import "./roll";
import "./spark";
import "./link"; import "./link";
import "./intent"; import "./intent";

View File

@ -6,7 +6,7 @@
*/ */
import { Component } from "solid-js"; import { Component } from "solid-js";
import { useNavigateWithParams } from "../../useNavigateWithParams"; import { useNavigate } from "@solidjs/router";
import { z } from "zod"; import { z } from "zod";
import { registerMessageType } from "../registry"; import { registerMessageType } from "../registry";
import { journalSetState } from "../../stores/journalStream"; import { journalSetState } from "../../stores/journalStream";
@ -22,15 +22,7 @@ export type LinkPayload = z.infer<typeof schema>;
function fileNameFromPath(path: string): string { function fileNameFromPath(path: string): string {
const parts = path.split("/").filter(Boolean); const parts = path.split("/").filter(Boolean);
return decodeURIComponent(parts[parts.length - 1] || path); return parts[parts.length - 1] || path;
}
/** Encode a path, preserving / separators but encoding each segment */
function encodePath(path: string): string {
return path
.split("/")
.map((seg) => encodeURIComponent(seg))
.join("/");
} }
/** Section slug → human-readable title */ /** Section slug → human-readable title */
@ -42,11 +34,11 @@ function slugToTitle(slug: string): string {
} }
const RevealLink: Component<LinkPayload> = (p) => { const RevealLink: Component<LinkPayload> = (p) => {
const navigate = useNavigateWithParams(); const navigate = useNavigate();
const target = () => { const target = () => {
let t = encodePath(p.path); let t = p.path;
if (p.section) t += `#${encodeURIComponent(p.section)}`; if (p.section) t += `#${p.section}`;
return t; return t;
}; };

View File

@ -1,158 +0,0 @@
/**
* Built-in message type: spark
*
* Emitters: gm
* Command: /spark pageName-columnSlug
*
* "spark tables" are markdown tables whose first column header is a dice
* formula (d6, d20, d100, etc.). The command rolls the dice once per data
* column, looks up the row matching each roll, and publishes the results.
*
* The completion slug is "pageName-columnSlug" where columnSlug is the
* concatenated slugs of every data-column header.
*/
import { z } from "zod";
import { For } from "solid-js";
import { registerMessageType } from "../registry";
import { rollFormula } from "../../md-commander/hooks";
import {
findSparkTableByCombinedSlug,
rollSparkTable,
scanSparkTables,
} from "../../utils/spark-table";
import { getIndexedData } from "../../../data-loader/file-index";
// ---------------------------------------------------------------------------
// Schema
// ---------------------------------------------------------------------------
const sparkColumnSchema = z.object({
header: z.string(),
slug: z.string(),
value: z.string(),
});
const sparkResultSchema = z.object({
notation: z.string(),
columns: z.array(sparkColumnSchema),
source: z.string(),
});
const schema = z.object({
/** User-visible label, e.g. "adventure body-label" */
label: z.string(),
/** The dice notation from the spark table header */
notation: z.string().min(1),
/** Resolved spark table columns */
sparkTable: sparkResultSchema,
/** First column's raw dice roll — informational only */
rollResult: z.object({
total: z.number(),
detail: z.string(),
plainDetail: z.string(),
pools: z.array(
z.object({
rolls: z.array(z.number()),
subtotal: z.number(),
}),
),
}),
});
export type SparkPayload = z.infer<typeof schema>;
// ---------------------------------------------------------------------------
// Resolve
// ---------------------------------------------------------------------------
/**
* Resolve a spark table roll.
*
* `key` is the combined slug (pageName-columnSlug).
* `filePath` is the .md file path (without .md extension) from completions.
*/
export async function resolveSparkPayload(raw: {
key: string;
filePath: string;
}): Promise<SparkPayload> {
const mdPath = `/${raw.filePath.replace(/^\//, "")}.md`;
const pageName =
raw.filePath.replace(/^\//, "").split("/").filter(Boolean).pop() ||
raw.filePath;
let content: string;
try {
content = await getIndexedData(mdPath);
} catch {
throw new Error(`Failed to load file: "${mdPath}"`);
}
const meta = findSparkTableByCombinedSlug(content, raw.key, pageName);
if (!meta) {
const available = scanSparkTables(content);
const names =
available.length > 0
? available.map((t) => `${pageName}-${t.slug}`).join(", ")
: "(none)";
throw new Error(
`Spark table "${raw.key}" not found in "${mdPath}". Available: ${names}`,
);
}
const sparkResult = rollSparkTable(meta);
const firstRoll = rollFormula(meta.notation);
return {
label: raw.key,
notation: meta.notation,
sparkTable: sparkResult,
rollResult: firstRoll.result,
};
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
registerMessageType<SparkPayload>({
type: "spark",
label: "Spark Table",
emitters: ["gm"],
schema,
defaultPayload: () => ({
label: "",
notation: "d6",
sparkTable: { notation: "d6", columns: [], source: "" },
rollResult: { total: 0, detail: "", plainDetail: "", pools: [] },
}),
render: (p) => {
const st = p.sparkTable;
return (
<div class="space-y-1">
<div class="flex items-center gap-1.5">
<span class="text-lg"></span>
<span class="font-mono text-xs text-purple-600">
{st.notation} · spark
</span>
</div>
<div class="bg-purple-50 rounded border border-purple-200 overflow-hidden">
<table class="w-full text-xs">
<tbody>
<For each={st.columns}>
{(col) => (
<tr class="border-t border-purple-100 first:border-t-0">
<td class="px-2 py-1 text-purple-600 font-medium w-1/3">
{col.header}
</td>
<td class="px-2 py-1 text-gray-800">{col.value}</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</div>
);
},
});

View File

@ -62,7 +62,7 @@ customElement("md-dice", { key: "" }, (props, { element }) => {
setRollDetail(rollResult.result.plainDetail); setRollDetail(rollResult.result.plainDetail);
setIsRolled(true); setIsRolled(true);
if (effectiveKey()) { if (effectiveKey()) {
setDiceResultToUrl(effectiveKey(), rollResult.result.total); setDiceResultToUrl(effectiveKey(), rollResult.total);
} }
}; };

View File

@ -1,10 +1,8 @@
/** /**
* DOM reveal logic applies revealed/concealed classes to article headings * DOM reveal logic applies revealed/concealed classes to article headings
* for non-GM clients. * for non-GM clients, and injects "send to stream" link buttons for GM.
* *
* All state is read from the journal stream store via `journalStreamState`. * All state is read from the journal stream store via `journalStreamState`.
*
* Action buttons (link, spark) are handled by the RevealManager Solid component.
*/ */
import { createSignal } from "solid-js"; import { createSignal } from "solid-js";
@ -44,32 +42,14 @@ export function isPathRevealed(path: string, section?: string): boolean {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// actionPrefill signal — bridges article action buttons → JournalInput // linkPrefill signal — bridges article heading buttons → JournalInput
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** A prefill action: what command + text to place in the journal input. */ /** Shared signal: when set, JournalInput prefills the textarea with this value. */
export interface PrefillAction { export const [linkPrefill, setLinkPrefill] = createSignal<string | null>(null);
command: string;
text: string;
}
/** Shared signal: when set, JournalInput prefills the textarea. */
export const [actionPrefill, setActionPrefill] =
createSignal<PrefillAction | null>(null);
// Keep the old signal for backward compat; it's a convenience wrapper.
/** @deprecated Use actionPrefill instead. */
export const linkPrefill = () => {
const a = actionPrefill();
return a?.command === "/link" ? a.text : null;
};
/** @deprecated Use setActionPrefill instead. */
export function setLinkPrefill(text: string | null) {
setActionPrefill(text ? { command: "/link", text } : null);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// addRevealedClasses — non-GM only: applies revealed/concealed classes // addRevealedClasses — GM injects buttons; non-GM applies revealed/concealed
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
interface HeadingEntry { interface HeadingEntry {
@ -93,23 +73,18 @@ interface HeadingEntry {
*/ */
export function addRevealedClasses(root: HTMLDivElement, path: string) { export function addRevealedClasses(root: HTMLDivElement, path: string) {
const state = journalStreamState; const state = journalStreamState;
if (!state.connected) { if (!state.connected) return;
cleanupInjections(root);
return;
}
// GM sees everything — no classes needed
if (state.myRole === "gm") {
cleanupInjections(root);
return;
}
const normalized = normalizePath(path); const normalized = normalizePath(path);
// ---- GM mode: inject "send to stream" buttons on headings ----
if (state.myRole === "gm") {
injectSendButtons(root, normalized);
return;
}
// ---- Non-GM mode: apply revealed/concealed classes ----
const revealed = state.revealedPaths[normalized]; const revealed = state.revealedPaths[normalized];
// Always clean up previous classes before applying new ones
cleanupInjections(root);
if (!revealed) return; if (!revealed) return;
const headings = collectHeadings(root); const headings = collectHeadings(root);
@ -117,16 +92,47 @@ export function addRevealedClasses(root: HTMLDivElement, path: string) {
applyClasses(root, revealedSet); applyClasses(root, revealedSet);
} }
// --------------------------------------------------------------------------- // ---- GM helpers ----
// Cleanup — strip previously applied revealed/concealed classes
// ---------------------------------------------------------------------------
export function cleanupInjections(root: Element): void { function injectSendButtons(root: Element, normalizedPath: string) {
root const walk = (el: Element) => {
.querySelectorAll(".revealed, .concealed") const tag = el.tagName.toUpperCase();
.forEach((el) => el.classList.remove("revealed", "concealed")); if (HEADING_TAGS.has(tag)) {
const headingText = el.id || el.textContent?.trim() || "";
if (headingText) {
const btn = createSendButton(normalizedPath, headingText);
el.insertBefore(btn, el.firstChild);
(el as HTMLElement).classList.add("group", "flex", "items-center");
}
}
for (const child of el.children) walk(child);
};
for (const child of root.children) walk(child);
} }
function createSendButton(path: string, headingId: string): HTMLButtonElement {
const btn = document.createElement("button");
btn.className =
"inline-flex items-center justify-center w-5 h-5 mr-1 -ml-6 " +
"text-gray-300 hover:text-blue-500 hover:bg-blue-50 rounded " +
"transition-colors align-middle opacity-0 group-hover:opacity-100 focus:opacity-100";
btn.title = "Send /link to stream";
btn.innerHTML = LINK_SVG;
btn.addEventListener("click", (e) => {
e.stopPropagation();
setLinkPrefill(`/link ${path}#${headingId}`);
});
return btn;
}
const LINK_SVG =
'<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" ' +
'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
'stroke-linecap="round" stroke-linejoin="round">' +
'<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>' +
'<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>' +
"</svg>";
// ---- Non-GM helpers ---- // ---- Non-GM helpers ----
function collectHeadings(root: Element): HeadingEntry[] { function collectHeadings(root: Element): HeadingEntry[] {

View File

@ -1,21 +0,0 @@
/**
* useNavigateWithParams wraps navigate() to preserve the current URL
* search params (e.g. ?session=abc&player=alice) when navigating to a
* new path.
*/
import { useNavigate, useLocation } from "@solidjs/router";
export function useNavigateWithParams() {
const navigate = useNavigate();
const location = useLocation();
return (to: string) => {
// Split hash from the path so we can re-attach it after search params
const hashIdx = to.indexOf("#");
const path = hashIdx >= 0 ? to.slice(0, hashIdx) : to;
const hash = hashIdx >= 0 ? to.slice(hashIdx) : "";
navigate(path + location.search + hash);
};
}

View File

@ -1,234 +0,0 @@
/**
* Spark Table markdown table where the first column header is a dice
* formula (d6, d20, d100, etc.). Rolling a spark table means:
*
* 1. Roll the dice formula once for each data column (non-dice columns)
* 2. Look up the row whose dice-column value matches each roll
* 3. Return the rolled values keyed by column slug
*/
import Slugger from "github-slugger";
import { rollFormula } from "../md-commander/hooks";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface MarkdownTable {
headers: string[];
rows: string[][];
}
export interface SparkTableColumn {
header: string;
slug: string;
value: string;
}
export interface SparkTableResult {
/** The dice notation (e.g. "d6", "d20") */
notation: string;
/** The roll results keyed by column slug */
columns: SparkTableColumn[];
/** Source file path */
source: string;
}
export interface SparkTableMeta {
/** Dice notation from the first column header */
notation: string;
/** Concatenated slug of data columns */
slug: string;
/** Data column headers (excluding dice column) */
dataHeaders: string[];
/** Full list of rows */
rows: string[][];
}
// ---------------------------------------------------------------------------
// Markdown table parser
// ---------------------------------------------------------------------------
/**
* Parse all markdown tables from a markdown string.
* Handles both leading/trailing `|` styles and bare styles.
*/
export function parseMarkdownTables(markdown: string): MarkdownTable[] {
const tables: MarkdownTable[] = [];
const lines = markdown.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const headerCells = splitTableRow(lines[i]);
if (!headerCells || headerCells.length < 2) continue;
// Peek at the next line — must be a separator row
if (i + 1 >= lines.length) continue;
const sepCells = splitTableRow(lines[i + 1]);
if (!sepCells || sepCells.length < headerCells.length) continue;
if (!sepCells.every((c) => /^:?-{3,}:?$/.test(c))) continue;
// Valid table header + separator — collect body rows
const rows: string[][] = [];
let j = i + 2;
while (j < lines.length) {
const rowCells = splitTableRow(lines[j]);
if (!rowCells) break;
// Allow rows with fewer cells (unfilled trailing columns)
rows.push(rowCells);
j++;
}
// Only include tables with at least one data row
if (rows.length > 0) {
tables.push({ headers: headerCells, rows });
}
i = j - 1;
}
return tables;
}
/** Split a pipe-delimited table row, stripping optional leading/trailing `|` */
function splitTableRow(line: string): string[] | null {
const trimmed = line.trim();
if (!trimmed.includes("|")) return null;
// Strip optional leading and trailing `|`
let inner = trimmed;
if (inner.startsWith("|")) inner = inner.slice(1);
if (inner.endsWith("|")) inner = inner.slice(0, -1);
return inner.split("|").map((c) => c.trim());
}
// ---------------------------------------------------------------------------
// Spark table detection & metadata
// ---------------------------------------------------------------------------
const DICE_HEADER_RE = /^d\d+$/i;
/** Check whether a table is a spark table (first header is a dice formula) */
export function isSparkTable(table: MarkdownTable): boolean {
if (table.headers.length < 2) return false;
return DICE_HEADER_RE.test(table.headers[0]);
}
/**
* Generate the spark table slug by concatenating slugs of all data column
* headers (excluding the dice column).
*/
export function sparkTableSlug(table: MarkdownTable): string {
const slugger = new Slugger();
return table.headers
.slice(1)
.map((h) => slugger.slug(h.toLowerCase()))
.join("-");
}
/**
* Find a spark table in the given markdown content matching `slug`.
* Returns null if not found.
*/
export function findSparkTable(
markdown: string,
slug: string,
): SparkTableMeta | null {
const tables = parseMarkdownTables(markdown);
for (const table of tables) {
if (!isSparkTable(table)) continue;
if (sparkTableSlug(table) === slug) {
return {
notation: table.headers[0],
slug,
dataHeaders: table.headers.slice(1),
rows: table.rows,
};
}
}
return null;
}
/**
* Find a spark table by its combined slug (`pageName-columnSlug`).
* Iterates every spark table in the content, constructs the combined slug,
* and returns the first match. Avoids the need to guess where the page
* name ends and the column slug begins (page names may contain `-`).
*/
export function findSparkTableByCombinedSlug(
markdown: string,
combinedSlug: string,
pageName: string,
): SparkTableMeta | null {
const tables = parseMarkdownTables(markdown);
for (const table of tables) {
if (!isSparkTable(table)) continue;
const colSlug = sparkTableSlug(table);
const candidate = `${pageName}-${colSlug}`;
if (candidate === combinedSlug) {
return {
notation: table.headers[0],
slug: colSlug,
dataHeaders: table.headers.slice(1),
rows: table.rows,
};
}
}
return null;
}
/** Scan all spark tables in a markdown file and return their metadata */
export function scanSparkTables(
markdown: string,
): Omit<SparkTableMeta, "rows">[] {
const tables = parseMarkdownTables(markdown);
return tables.filter(isSparkTable).map((table) => ({
notation: table.headers[0],
slug: sparkTableSlug(table),
dataHeaders: table.headers.slice(1),
}));
}
// ---------------------------------------------------------------------------
// Rolling
// ---------------------------------------------------------------------------
/**
* Roll a spark table: for each data column, roll the dice formula and look
* up the corresponding row value.
*/
export function rollSparkTable(meta: SparkTableMeta): SparkTableResult {
const slugger = new Slugger();
const columns: SparkTableColumn[] = [];
for (let colIdx = 0; colIdx < meta.dataHeaders.length; colIdx++) {
const header = meta.dataHeaders[colIdx];
const slug = slugger.slug(header.toLowerCase());
const roll = rollFormula(meta.notation);
const rolledValue = roll.result.total;
// Find the row matching the rolled value — compare as integers,
// falling back to string comparison.
let value = `(no row for ${rolledValue})`;
for (const row of meta.rows) {
const diceCell = row[0] ?? "";
const cellNum = parseInt(diceCell.trim(), 10);
const match =
!isNaN(cellNum) && rolledValue === cellNum
? true
: String(rolledValue) === diceCell.trim();
if (match) {
value = row[colIdx + 1] ?? "";
break;
}
}
columns.push({ header, slug, value });
}
return {
notation: meta.notation,
columns,
source: "",
};
}

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
@ -8,87 +7,67 @@ import Slugger from "github-slugger";
* @returns CSV * @returns CSV
*/ */
function tableToCSV(headers: string[], rows: string[][]): string { function tableToCSV(headers: string[], rows: string[][]): string {
const escapeCell = (cell: string) => { const escapeCell = (cell: string) => {
// 如果单元格包含逗号、换行或引号,需要转义 // 如果单元格包含逗号、换行或引号,需要转义
if ( if (cell.includes(',') || cell.includes('\n') || cell.includes('"') || cell.includes("#")) {
cell.includes(",") || return `"${cell.replace(/"/g, '""')}"`;
cell.includes("\n") || }
cell.includes('"') || return cell;
cell.includes("#") };
) {
return `"${cell.replace(/"/g, '""')}"`;
}
return cell;
};
const headerLine = headers.map(escapeCell).join(","); const headerLine = headers.map(escapeCell).join(',');
const dataLines = rows.map((row) => row.map(escapeCell).join(",")); const dataLines = rows.map(row => row.map(escapeCell).join(','));
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+$/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 // 检查表头是否包含 md-table-label
const header = token.header; const header = token.header;
let roll = ""; let roll = '';
let spark = ""; 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"; return true;
// If this is a spark table (first column is a pure dice formula), }else if(cell.text === 'md-remix-label'){
// compute the data-column slug for DOM injection roll = ' roll=true remix=true';
if (SPARK_DICE_RE.test(cell.text)) { return true;
const slugger = new Slugger(); }
const dataHeaders = header return cell.text === 'md-table-label' || cell.text === 'label';
.filter((_, i) => i !== 0) });
.map((h) => h.text);
const slug = dataHeaders // 默认表格渲染 - 使用 marked 默认行为
.map((h) => slugger.slug(h.toLowerCase())) if(labelIndex === -1) return false;
.join("-");
spark = ` data-spark="${slug}"`; const headers = token.header.map(cell => cell.text);
headers[labelIndex] = 'label';
const rows = token.rows.map(row => row.map(cell => cell.text));
if(header.findIndex(header => header.text === 'body') < 0){
// 收集所有非 label 列的表头
const bodyColumns = headers
.filter(cell => cell !== 'label');
// 构建 body 列的模板:**列名**{{列名}}\n\n
const bodyTemplate = bodyColumns
.map(col => `**${col}**{{${col}}}`)
.join('\n\n');
headers.push('body');
rows.forEach(row => {
row.push(bodyTemplate);
});
}
// 生成 CSV 数据
const csvData = tableToCSV(headers, rows);
// 渲染为 md-table 组件,内联 CSV 数据
return `<md-table ${roll}>${csvData}</md-table>\n`;
} }
return true;
} else if (cell.text === "md-remix-label") {
roll = " roll=true remix=true";
return true;
}
return cell.text === "md-table-label" || cell.text === "label";
});
// 默认表格渲染 - 使用 marked 默认行为
if (labelIndex === -1) return false;
const headers = token.header.map((cell) => cell.text);
headers[labelIndex] = "label";
const rows = token.rows.map((row) => row.map((cell) => cell.text));
if (header.findIndex((header) => header.text === "body") < 0) {
// 收集所有非 label 列的表头
const bodyColumns = headers.filter((cell) => cell !== "label");
// 构建 body 列的模板:**列名**{{列名}}\n\n
const bodyTemplate = bodyColumns
.map((col) => `**${col}**{{${col}}}`)
.join("\n\n");
headers.push("body");
rows.forEach((row) => {
row.push(bodyTemplate);
});
} }
};
// 生成 CSV 数据
const csvData = tableToCSV(headers, rows);
// 渲染为 md-table 组件,内联 CSV 数据
return `<md-table ${roll}${spark}>${csvData}</md-table>\n`;
},
},
};
} }