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",
|
||||
): 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}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/** 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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { sendMessage, useJournalStream } from "../stores/journalStream";
|
|||
import { linkPrefill, setLinkPrefill } 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" };
|
||||
}
|
||||
|
||||
|
|
@ -101,7 +109,7 @@ export const JournalInput: Component = () => {
|
|||
|
||||
// ---- 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();
|
||||
|
|
|
|||
|
|
@ -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,7 +79,9 @@ 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;
|
||||
const slugger = new Slugger();
|
||||
|
||||
for (const filePath of paths) {
|
||||
const content = await getIndexedData(filePath);
|
||||
|
|
@ -95,9 +108,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 +213,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: [] } };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,5 +7,6 @@
|
|||
|
||||
import "./chat";
|
||||
import "./roll";
|
||||
import "./spark";
|
||||
import "./link";
|
||||
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);
|
||||
setIsRolled(true);
|
||||
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