Compare commits

..

12 Commits

Author SHA1 Message Date
hypercross e6d74cc318 fix: remove command prefix from action prefill text 2026-07-07 23:32:33 +08:00
hypercross 779722fe85 refactor: preserve URL search params during navigation
Introduce `useNavigateWithParams` to ensure that existing URL search
parameters are maintained when navigating between pages. This is
applied to the Article component, FileTree, and journal links to
prevent losing state (like session or player info) during client-side
navigation.

Also intercepts markdown anchor links in the Article component to use
client-side navigation.
2026-07-07 23:26:44 +08:00
hypercross 48776424a7 fix(RevealManager): prevent GM listeners when disconnected
Only initialize GM hover listeners if the user is both a GM and
connected.
2026-07-07 23:17:57 +08:00
hypercross 4eaf6aab16 refactor: move GM action buttons to Solid components
Migrate GM action button injection from imperative DOM manipulation to
a reactive Solid component (`RevealManager`) using `Portal`. This
improves cleanup reliability and ensures buttons are proper Solid
components rather than raw HTML strings injected into the DOM.

Also adds URI encoding for link paths and simplifies the reveal
store logic.
2026-07-07 23:14:04 +08:00
hypercross aa326f38a0 refactor: introduce RevealManager and Article DOM context
Decouple the reveal logic from the `Article` component by introducing
a `RevealManager` component and an `ArticleDomCtx`. This allows the
reveal logic to reactively manage DOM injections and classes without
polluting the `Article` component's props or lifecycle.

Additionally, implements a robust `cleanupInjections` mechanism to
ensure that injected buttons and styling artifacts are properly removed
during navigation, role changes, or disconnections.
2026-07-07 22:19:04 +08:00
hypercross 1ef2560faa fix: normalize line endings in frontmatter parsing
Normalize CRLF line endings to LF before parsing frontmatter
to ensure regex matching works consistently across different platforms.
2026-07-07 21:47:25 +08:00
hypercross 6b3a915350 refactor: simplify spark button injection logic
Update the spark button injection to use `data-spark` slugs on
`md-table` elements instead of manual table parsing. This improves
reliability by leveraging the slug generated during markdown
rendering.
2026-07-07 21:44:00 +08:00
hypercross 56af7dea42 feat: add spark table roll buttons for GMs
Introduces the ability for GMs to inject spark roll buttons into
tables that match existing spark completions.

- Replaces `linkPrefill` with a more flexible `actionPrefill` system
  to support multiple command types (e.g., `/link`, `/spark`).
- Implements `injectSparkButtons` to identify spark tables in the
  DOM and insert action buttons.
- Updates `JournalInput` to handle structured prefill actions.
2026-07-07 21:38:43 +08:00
hypercross e801ac9b5f refactor: improve spark table lookup and error reporting
Replace the heuristic-based slug parsing with a more robust
combined-slug lookup to correctly handle page names containing
hyphens. Added `scanSparkTables` to provide better error messages
when a table is not found.
2026-07-07 19:13:11 +08:00
hypercross 9a312f76ad fix(completions): instantiate Slugger per file
Move Slugger instantiation inside the file loops to prevent
state leakage between files, as Slugger is stateful.
2026-07-07 19:07:37 +08:00
hypercross 97148aa010 refactor: improve column slug extraction logic 2026-07-07 19:05:47 +08:00
hypercross 2f29f8774d feat: add spark table completion and rolling support
Implement "spark tables" functionality, which allows users to roll
dice against markdown tables to retrieve specific values.

- Add `sparkTablesSource` to scan markdown files for tables starting
  with a dice notation (e.g., d6, d20).
- Implement `/spark` command in the journal to resolve and roll
  spark tables.
- Add a new message type `spark` with a dedicated UI component to
  render the results.
- Update completions API to include spark table metadata.
2026-07-07 19:02:17 +08:00
20 changed files with 1058 additions and 150 deletions

View File

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

View File

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

View File

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

View File

@ -0,0 +1,82 @@
/**
* 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,10 +22,25 @@ export interface LinkCompletion {
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 */
export interface CompletionsPayload {
dice: DiceCompletion[];
links: LinkCompletion[];
sparkTables: SparkTableCompletion[];
}
/**

View File

@ -1,17 +1,21 @@
import {
Component,
ParentProps,
createContext,
useContext,
createEffect,
onCleanup,
Show,
createResource,
createMemo,
createSignal,
} from "solid-js";
import { parseMarkdown } from "../markdown";
import { extractSection } from "../data-loader";
import mermaid from "mermaid";
import { getIndexedData } from "../data-loader/file-index";
import { resolvePath } from "./utils/path";
import { useLocation } from "@solidjs/router";
import { useNavigateWithParams } from "./useNavigateWithParams";
export interface ArticleProps {
src: string;
@ -21,7 +25,19 @@ export interface ArticleProps {
onError?: (error: Error) => void;
class?: string; // 额外的 class 用于样式控制
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: {
@ -59,13 +75,13 @@ function scrollToHash(hash: string) {
* Article
* src md markdown
*/
export const Article: Component<ArticleProps> = (props) => {
const location = useLocation();
export const Article: Component<ArticleProps & ParentProps> = (props) => {
const navigate = useNavigateWithParams();
const [content, { refetch }] = createResource(
() => ({ src: props.src, section: props.section }),
fetchArticleContent,
);
let innerDiv!: HTMLDivElement;
const [contentDom, setContentDom] = createSignal<HTMLDivElement>();
// 解析 iconPath默认为 "./assets",空字符串表示禁用
const iconPrefix = createMemo(() => {
@ -81,12 +97,33 @@ export const Article: Component<ArticleProps> = (props) => {
void mermaid.run();
// 内容渲染后检查 hash 并滚动
scrollToHash(location.hash);
props.onDom?.(innerDiv);
scrollToHash(window.location.hash);
}
});
// 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(() => {
// 清理时清空内容,触发内部组件的销毁
});
@ -103,11 +140,14 @@ export const Article: Component<ArticleProps> = (props) => {
<div class="text-red-500">{content.error?.message}</div>
</Show>
<Show when={!content.loading && !content.error && content()}>
<div
class="relative"
ref={innerDiv}
innerHTML={parseMarkdown(content()!, iconPrefix())}
/>
<ArticleDomCtx.Provider value={contentDom}>
<div
class="relative"
ref={setContentDom}
innerHTML={parseMarkdown(content()!, iconPrefix())}
/>
{props.children}
</ArticleDomCtx.Provider>
</Show>
</article>
);

View File

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

View File

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

View File

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

View File

@ -21,9 +21,10 @@ import {
Match,
} from "solid-js";
import { sendMessage, useJournalStream } from "../stores/journalStream";
import { linkPrefill, setLinkPrefill } from "../stores/reveal";
import { actionPrefill, setActionPrefill } from "../stores/reveal";
import { useJournalCompletions, ensureCompletions } from "./completions";
import { resolveRollPayload } from "./types/roll";
import { resolveSparkPayload } from "./types/spark";
// ---- Helpers ----
@ -34,7 +35,7 @@ interface CompletionItem {
}
interface ParsedInput {
type: "chat" | "roll" | "link";
type: "chat" | "roll" | "spark" | "link";
payload: Record<string, unknown>;
error?: string;
}
@ -47,6 +48,13 @@ function parseInput(raw: string): ParsedInput {
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 ")) {
const arg = raw.slice("/link ".length).trim();
if (!arg) return { type: "link", payload: {}, error: "Path required" };
@ -57,8 +65,8 @@ function parseInput(raw: string): ParsedInput {
return { type: "link", payload: { path, section } };
}
// /roll or /link with no space — need to complete, don't send
if (raw === "/roll" || raw === "/link") {
// /roll, /spark, or /link with no space — need to complete, don't send
if (raw === "/roll" || raw === "/spark" || raw === "/link") {
return { type: "chat", payload: {}, error: "Complete the command" };
}
@ -89,19 +97,19 @@ export const JournalInput: Component = () => {
void ensureCompletions();
});
// Listen for /link prefill requests from article headings
// Listen for action prefill requests from article buttons (heading /link, spark table roll, etc.)
createEffect(() => {
const prefilled = linkPrefill();
if (prefilled) {
setText(prefilled);
setLinkPrefill(null);
const action = actionPrefill();
if (action) {
setText(`${action.command} ${action.text}`);
setActionPrefill(null);
textareaRef?.focus();
}
});
// ---- Send ----
function handleSend() {
async function handleSend() {
const raw = text().trim();
if (!raw) return;
@ -142,6 +150,30 @@ export const JournalInput: Component = () => {
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);
if (!result.success) {
setError(result.error);
@ -172,6 +204,7 @@ export const JournalInput: Component = () => {
const data = comp.data;
const commands = [
{ label: "/roll", kind: "command" as const, insertText: "/roll " },
{ label: "/spark", kind: "command" as const, insertText: "/spark " },
{ label: "/link", kind: "command" as const, insertText: "/link " },
];
@ -206,6 +239,32 @@ 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
if (raw.startsWith("/link ")) {
const prefix = raw.slice("/link ".length).toLowerCase();
@ -439,7 +498,7 @@ export const JournalInput: Component = () => {
onKeyDown={handleKeyDown}
placeholder={
isGm()
? "Type a message, or /roll or /link..."
? "Type a message, or /roll, /spark, or /link..."
: "Type a message..."
}
rows={2}

View File

@ -9,6 +9,7 @@
*/
import { createSignal } from "solid-js";
import Slugger from "github-slugger";
import { extractHeadings } from "../../data-loader/toc";
import {
getPathsByExtension,
@ -29,9 +30,18 @@ export interface LinkCompletion {
section: string | null;
}
export interface SparkTableCompletion {
label: string;
notation: string;
slug: string;
filePath: string;
headers: string[];
}
export interface JournalCompletions {
dice: DiceCompletion[];
links: LinkCompletion[];
sparkTables: SparkTableCompletion[];
}
export type CompletionsState =
@ -56,6 +66,7 @@ async function tryServer(): Promise<JournalCompletions | null> {
return {
dice: Array.isArray(data.dice) ? data.dice : [],
links: Array.isArray(data.links) ? data.links : [],
sparkTables: Array.isArray(data.sparkTables) ? data.sparkTables : [],
};
} catch {
return null;
@ -68,12 +79,17 @@ async function scanClientSide(): Promise<JournalCompletions> {
const paths = await getPathsByExtension("md");
const dice: DiceCompletion[] = [];
const links: LinkCompletion[] = [];
const sparkTables: SparkTableCompletion[] = [];
const tagRegex = /<md-dice[^>]*>\s*([\s\S]*?)\s*<\/md-dice>/gi;
for (const filePath of paths) {
const content = await getIndexedData(filePath);
if (!content) continue;
// Fresh slugger per file so duplicate headers across files don't
// pollute each other.
const slugger = new Slugger();
// Dice scan
let match: RegExpExecArray | null;
tagRegex.lastIndex = 0;
@ -95,9 +111,52 @@ async function scanClientSide(): Promise<JournalCompletions> {
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 };
return { dice, links, sparkTables };
}
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) -------------------
@ -157,5 +216,5 @@ export function useJournalCompletions(): {
if (s.status === "loaded") {
return { state: s, data: s.data };
}
return { state: s, data: { dice: [], links: [] } };
return { state: s, data: { dice: [], links: [], sparkTables: [] } };
}

View File

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

View File

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

View File

@ -0,0 +1,158 @@
/**
* 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);
setIsRolled(true);
if (effectiveKey()) {
setDiceResultToUrl(effectiveKey(), rollResult.total);
setDiceResultToUrl(effectiveKey(), rollResult.result.total);
}
};

View File

@ -1,8 +1,10 @@
/**
* DOM reveal logic applies revealed/concealed classes to article headings
* for non-GM clients, and injects "send to stream" link buttons for GM.
* for non-GM clients.
*
* 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";
@ -42,14 +44,32 @@ export function isPathRevealed(path: string, section?: string): boolean {
}
// ---------------------------------------------------------------------------
// linkPrefill signal — bridges article heading buttons → JournalInput
// actionPrefill signal — bridges article action buttons → JournalInput
// ---------------------------------------------------------------------------
/** Shared signal: when set, JournalInput prefills the textarea with this value. */
export const [linkPrefill, setLinkPrefill] = createSignal<string | null>(null);
/** A prefill action: what command + text to place in the journal input. */
export interface PrefillAction {
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 — GM injects buttons; non-GM applies revealed/concealed
// addRevealedClasses — non-GM only: applies revealed/concealed classes
// ---------------------------------------------------------------------------
interface HeadingEntry {
@ -73,18 +93,23 @@ interface HeadingEntry {
*/
export function addRevealedClasses(root: HTMLDivElement, path: string) {
const state = journalStreamState;
if (!state.connected) return;
const normalized = normalizePath(path);
// ---- GM mode: inject "send to stream" buttons on headings ----
if (state.myRole === "gm") {
injectSendButtons(root, normalized);
if (!state.connected) {
cleanupInjections(root);
return;
}
// ---- Non-GM mode: apply revealed/concealed classes ----
// GM sees everything — no classes needed
if (state.myRole === "gm") {
cleanupInjections(root);
return;
}
const normalized = normalizePath(path);
const revealed = state.revealedPaths[normalized];
// Always clean up previous classes before applying new ones
cleanupInjections(root);
if (!revealed) return;
const headings = collectHeadings(root);
@ -92,47 +117,16 @@ export function addRevealedClasses(root: HTMLDivElement, path: string) {
applyClasses(root, revealedSet);
}
// ---- GM helpers ----
// ---------------------------------------------------------------------------
// Cleanup — strip previously applied revealed/concealed classes
// ---------------------------------------------------------------------------
function injectSendButtons(root: Element, normalizedPath: string) {
const walk = (el: Element) => {
const tag = el.tagName.toUpperCase();
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);
export function cleanupInjections(root: Element): void {
root
.querySelectorAll(".revealed, .concealed")
.forEach((el) => el.classList.remove("revealed", "concealed"));
}
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 ----
function collectHeadings(root: Element): HeadingEntry[] {

View File

@ -0,0 +1,21 @@
/**
* 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

@ -0,0 +1,234 @@
/**
* 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,4 +1,5 @@
import type { MarkedExtension, Tokens } from "marked";
import Slugger from "github-slugger";
/**
* CSV
@ -7,67 +8,87 @@ import type { MarkedExtension, Tokens } from "marked";
* @returns CSV
*/
function tableToCSV(headers: string[], rows: string[][]): string {
const escapeCell = (cell: string) => {
// 如果单元格包含逗号、换行或引号,需要转义
if (cell.includes(',') || cell.includes('\n') || cell.includes('"') || cell.includes("#")) {
return `"${cell.replace(/"/g, '""')}"`;
}
return cell;
};
const escapeCell = (cell: string) => {
// 如果单元格包含逗号、换行或引号,需要转义
if (
cell.includes(",") ||
cell.includes("\n") ||
cell.includes('"') ||
cell.includes("#")
) {
return `"${cell.replace(/"/g, '""')}"`;
}
return cell;
};
const headerLine = headers.map(escapeCell).join(',');
const dataLines = rows.map(row => row.map(escapeCell).join(','));
const headerLine = headers.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 {
return {
renderer: {
table(token: Tokens.Table) {
// 检查表头是否包含 md-table-label
const header = token.header;
let roll = '';
const labelIndex = header.findIndex(cell => {
if(cell.text === 'md-roll-label' || cell.text.match(/(\d+)?d\d+/)){
roll = ' roll=true';
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}>${csvData}</md-table>\n`;
return {
renderer: {
table(token: Tokens.Table) {
// 检查表头是否包含 md-table-label
const header = token.header;
let roll = "";
let spark = "";
const labelIndex = header.findIndex((cell) => {
if (cell.text === "md-roll-label" || cell.text.match(/(\d+)?d\d+/)) {
roll = " roll=true";
// If this is a spark table (first column is a pure dice formula),
// compute the data-column slug for DOM injection
if (SPARK_DICE_RE.test(cell.text)) {
const slugger = new Slugger();
const dataHeaders = header
.filter((_, i) => i !== 0)
.map((h) => h.text);
const slug = dataHeaders
.map((h) => slugger.slug(h.toLowerCase()))
.join("-");
spark = ` data-spark="${slug}"`;
}
return true;
} else if (cell.text === "md-remix-label") {
roll = " roll=true remix=true";
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`;
},
},
};
}