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.
This commit is contained in:
parent
3690d13407
commit
2f29f8774d
|
|
@ -227,13 +227,17 @@ 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 = { dice: [], links: [] };
|
let completionsIndex: CompletionsPayload = {
|
||||||
|
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}`,
|
`[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,22 @@
|
||||||
|
|
||||||
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[] = [diceSource, linksSource];
|
const sources: CompletionSource[] = [
|
||||||
|
diceSource,
|
||||||
|
linksSource,
|
||||||
|
sparkTablesSource,
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scan the full content index and return structured completion data.
|
* Scan the full content index and return structured completion data.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
/**
|
||||||
|
* 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[] = [];
|
||||||
|
const slugger = new Slugger();
|
||||||
|
|
||||||
|
for (const [filePath, content] of Object.entries(index)) {
|
||||||
|
if (!filePath.endsWith(".md")) continue;
|
||||||
|
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -22,10 +22,25 @@ 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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import { sendMessage, useJournalStream } from "../stores/journalStream";
|
||||||
import { linkPrefill, setLinkPrefill } 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 ----
|
||||||
|
|
||||||
|
|
@ -34,7 +35,7 @@ interface CompletionItem {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ParsedInput {
|
interface ParsedInput {
|
||||||
type: "chat" | "roll" | "link";
|
type: "chat" | "roll" | "spark" | "link";
|
||||||
payload: Record<string, unknown>;
|
payload: Record<string, unknown>;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -47,6 +48,13 @@ 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" };
|
||||||
|
|
@ -57,8 +65,8 @@ function parseInput(raw: string): ParsedInput {
|
||||||
return { type: "link", payload: { path, section } };
|
return { type: "link", payload: { path, section } };
|
||||||
}
|
}
|
||||||
|
|
||||||
// /roll or /link with no space — need to complete, don't send
|
// /roll, /spark, or /link with no space — need to complete, don't send
|
||||||
if (raw === "/roll" || raw === "/link") {
|
if (raw === "/roll" || raw === "/spark" || raw === "/link") {
|
||||||
return { type: "chat", payload: {}, error: "Complete the command" };
|
return { type: "chat", payload: {}, error: "Complete the command" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -101,7 +109,7 @@ export const JournalInput: Component = () => {
|
||||||
|
|
||||||
// ---- Send ----
|
// ---- Send ----
|
||||||
|
|
||||||
function handleSend() {
|
async function handleSend() {
|
||||||
const raw = text().trim();
|
const raw = text().trim();
|
||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
|
|
||||||
|
|
@ -142,6 +150,30 @@ 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);
|
||||||
|
|
@ -172,6 +204,7 @@ 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 " },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -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
|
// 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();
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
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,
|
||||||
|
|
@ -29,9 +30,18 @@ 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 =
|
||||||
|
|
@ -56,6 +66,7 @@ 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;
|
||||||
|
|
@ -68,7 +79,9 @@ 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;
|
||||||
|
const slugger = new Slugger();
|
||||||
|
|
||||||
for (const filePath of paths) {
|
for (const filePath of paths) {
|
||||||
const content = await getIndexedData(filePath);
|
const content = await getIndexedData(filePath);
|
||||||
|
|
@ -95,9 +108,52 @@ 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 };
|
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) -------------------
|
// ------------------- Init (runs eagerly at import time) -------------------
|
||||||
|
|
@ -157,5 +213,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: [] } };
|
return { state: s, data: { dice: [], links: [], sparkTables: [] } };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,5 +7,6 @@
|
||||||
|
|
||||||
import "./chat";
|
import "./chat";
|
||||||
import "./roll";
|
import "./roll";
|
||||||
|
import "./spark";
|
||||||
import "./link";
|
import "./link";
|
||||||
import "./intent";
|
import "./intent";
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
/**
|
||||||
|
* 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 { Show, For } from "solid-js";
|
||||||
|
import { registerMessageType } from "../registry";
|
||||||
|
import { rollFormula } from "../../md-commander/hooks";
|
||||||
|
import {
|
||||||
|
findSparkTable,
|
||||||
|
rollSparkTable,
|
||||||
|
parseMarkdownTables,
|
||||||
|
isSparkTable,
|
||||||
|
sparkTableSlug,
|
||||||
|
} 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> {
|
||||||
|
// key is "pageName-columnSlug"; columnSlug is the part after the first "-"
|
||||||
|
const dashIdx = raw.key.indexOf("-");
|
||||||
|
const columnSlug = dashIdx === -1 ? raw.key : raw.key.slice(dashIdx + 1);
|
||||||
|
const filePath = `/${raw.filePath.replace(/^\//, "")}`;
|
||||||
|
|
||||||
|
let content: string;
|
||||||
|
try {
|
||||||
|
content = await getIndexedData(filePath);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Failed to load file: "${filePath}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = findSparkTable(content, columnSlug);
|
||||||
|
if (!meta) {
|
||||||
|
throw new Error(`Spark table "${columnSlug}" not found in "${filePath}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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.total);
|
setDiceResultToUrl(effectiveKey(), rollResult.result.total);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,204 @@
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 (1-based dice result)
|
||||||
|
let value = `(no row for ${rolledValue})`;
|
||||||
|
for (const row of meta.rows) {
|
||||||
|
const diceCell = row[0] ?? "";
|
||||||
|
if (String(rolledValue) === diceCell.trim()) {
|
||||||
|
value = row[colIdx + 1] ?? "";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
columns.push({ header, slug, value });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
notation: meta.notation,
|
||||||
|
columns,
|
||||||
|
source: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue