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.
This commit is contained in:
hypercross 2026-07-07 19:13:11 +08:00
parent 9a312f76ad
commit e801ac9b5f
2 changed files with 62 additions and 28 deletions

View File

@ -16,7 +16,11 @@ import { z } from "zod";
import { For } from "solid-js"; import { For } from "solid-js";
import { registerMessageType } from "../registry"; import { registerMessageType } from "../registry";
import { rollFormula } from "../../md-commander/hooks"; import { rollFormula } from "../../md-commander/hooks";
import { findSparkTable, rollSparkTable } from "../../utils/spark-table"; import {
findSparkTableByCombinedSlug,
rollSparkTable,
scanSparkTables,
} from "../../utils/spark-table";
import { getIndexedData } from "../../../data-loader/file-index"; import { getIndexedData } from "../../../data-loader/file-index";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -72,28 +76,28 @@ export async function resolveSparkPayload(raw: {
key: string; key: string;
filePath: string; filePath: string;
}): Promise<SparkPayload> { }): Promise<SparkPayload> {
// key is "pageName-columnSlug". The page name is the last segment of const mdPath = `/${raw.filePath.replace(/^\//, "")}.md`;
// filePath (e.g. "01-dry-dock" from "/ayi-games/.../01-dry-dock"). const pageName =
// Strip it from the key to recover the column slug. raw.filePath.replace(/^\//, "").split("/").filter(Boolean).pop() ||
const pathParts = raw.filePath.replace(/^\//, "").split("/"); raw.filePath;
const pageName = pathParts[pathParts.length - 1] || raw.filePath;
const columnSlug =
raw.key.length > pageName.length + 1 && raw.key.startsWith(pageName + "-")
? raw.key.slice(pageName.length + 1)
: raw.key;
const filePath = `/${raw.filePath.replace(/^\//, "")}`;
let content: string; let content: string;
try { try {
content = await getIndexedData(filePath); content = await getIndexedData(mdPath);
} catch { } catch {
throw new Error(`Failed to load file: "${filePath}"`); throw new Error(`Failed to load file: "${mdPath}"`);
} }
const meta = findSparkTable(content, columnSlug); const meta = findSparkTableByCombinedSlug(content, raw.key, pageName);
if (!meta) { if (!meta) {
throw new Error(`Spark table "${columnSlug}" not found in "${filePath}"`); 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 sparkResult = rollSparkTable(meta);

View File

@ -148,14 +148,40 @@ export function findSparkTable(
return null; 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 */ /** Scan all spark tables in a markdown file and return their metadata */
export function scanSparkTables( export function scanSparkTables(
markdown: string, markdown: string,
): Omit<SparkTableMeta, "rows">[] { ): Omit<SparkTableMeta, "rows">[] {
const tables = parseMarkdownTables(markdown); const tables = parseMarkdownTables(markdown);
return tables return tables.filter(isSparkTable).map((table) => ({
.filter(isSparkTable)
.map((table) => ({
notation: table.headers[0], notation: table.headers[0],
slug: sparkTableSlug(table), slug: sparkTableSlug(table),
dataHeaders: table.headers.slice(1), dataHeaders: table.headers.slice(1),
@ -170,9 +196,7 @@ export function scanSparkTables(
* Roll a spark table: for each data column, roll the dice formula and look * Roll a spark table: for each data column, roll the dice formula and look
* up the corresponding row value. * up the corresponding row value.
*/ */
export function rollSparkTable( export function rollSparkTable(meta: SparkTableMeta): SparkTableResult {
meta: SparkTableMeta,
): SparkTableResult {
const slugger = new Slugger(); const slugger = new Slugger();
const columns: SparkTableColumn[] = []; const columns: SparkTableColumn[] = [];
@ -183,11 +207,17 @@ export function rollSparkTable(
const roll = rollFormula(meta.notation); const roll = rollFormula(meta.notation);
const rolledValue = roll.result.total; const rolledValue = roll.result.total;
// Find the row matching the rolled value (1-based dice result) // Find the row matching the rolled value — compare as integers,
// falling back to string comparison.
let value = `(no row for ${rolledValue})`; let value = `(no row for ${rolledValue})`;
for (const row of meta.rows) { for (const row of meta.rows) {
const diceCell = row[0] ?? ""; const diceCell = row[0] ?? "";
if (String(rolledValue) === diceCell.trim()) { const cellNum = parseInt(diceCell.trim(), 10);
const match =
!isNaN(cellNum) && rolledValue === cellNum
? true
: String(rolledValue) === diceCell.trim();
if (match) {
value = row[colIdx + 1] ?? ""; value = row[colIdx + 1] ?? "";
break; break;
} }