feat: Unify spark table detection and content resolution
Export isSparkTableHeader from content-registry and use it in both CLI and frontend table parsing. Add resolveContentRef to resolve content references consistently. Write processed registry content back to the file index so browser mode renders identically to CLI mode.
This commit is contained in:
@@ -47,6 +47,7 @@ import { parseInput } from "../../components/journal/command-parser";
|
|||||||
import {
|
import {
|
||||||
inspectSparkTableCsv,
|
inspectSparkTableCsv,
|
||||||
buildSparkTableCompletion,
|
buildSparkTableCompletion,
|
||||||
|
isSparkTableHeader,
|
||||||
} from "../content-registry";
|
} from "../content-registry";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -927,6 +928,32 @@ describe("parseInput", () => {
|
|||||||
// content-registry (spark tables)
|
// content-registry (spark tables)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("isSparkTableHeader", () => {
|
||||||
|
test("detects plain dice formula", () => {
|
||||||
|
expect(isSparkTableHeader("d6")).toBe(true);
|
||||||
|
expect(isSparkTableHeader("d20")).toBe(true);
|
||||||
|
expect(isSparkTableHeader("d100")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detects dice count and modifiers", () => {
|
||||||
|
expect(isSparkTableHeader("2d6")).toBe(true);
|
||||||
|
expect(isSparkTableHeader("3d6+5")).toBe(true);
|
||||||
|
expect(isSparkTableHeader("1d8-2")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects non-dice headers", () => {
|
||||||
|
expect(isSparkTableHeader("Name")).toBe(false);
|
||||||
|
expect(isSparkTableHeader("d")).toBe(false);
|
||||||
|
expect(isSparkTableHeader("6")).toBe(false);
|
||||||
|
expect(isSparkTableHeader("d6x")).toBe(false);
|
||||||
|
expect(isSparkTableHeader("roll")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("trims surrounding whitespace", () => {
|
||||||
|
expect(isSparkTableHeader(" d20 ")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("inspectSparkTableCsv", () => {
|
describe("inspectSparkTableCsv", () => {
|
||||||
test("detects spark table from CSV headers", () => {
|
test("detects spark table from CSV headers", () => {
|
||||||
const csv = `d6,Name,Description
|
const csv = `d6,Name,Description
|
||||||
|
|||||||
@@ -239,7 +239,14 @@ export function buildRegistryFromIndex(
|
|||||||
// Spark table coercion
|
// Spark table coercion
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const DICE_HEADER_RE = /^\d*d\d+$/i;
|
/**
|
||||||
|
* Whether a table header cell is a dice formula (a "spark table" first
|
||||||
|
* column). Single source of truth shared by the CLI scanner and the frontend
|
||||||
|
* `markedTable` renderer so both agree on what counts as a spark table.
|
||||||
|
*/
|
||||||
|
export function isSparkTableHeader(header: string): boolean {
|
||||||
|
return /^\d*d\d+(?:[+-]\d+)?$/i.test(header.trim());
|
||||||
|
}
|
||||||
|
|
||||||
/** Split a markdown table row into cells. */
|
/** Split a markdown table row into cells. */
|
||||||
function splitTableRow(row: string): string[] {
|
function splitTableRow(row: string): string[] {
|
||||||
@@ -310,7 +317,7 @@ function coerceSparkTables(
|
|||||||
while ((mdMatch = mdTableRegex.exec(content)) !== null) {
|
while ((mdMatch = mdTableRegex.exec(content)) !== null) {
|
||||||
const [, headerRow, separatorRow, bodyRowsText] = mdMatch;
|
const [, headerRow, separatorRow, bodyRowsText] = mdMatch;
|
||||||
const headers = splitTableRow(headerRow);
|
const headers = splitTableRow(headerRow);
|
||||||
if (!DICE_HEADER_RE.test(headers[0])) continue;
|
if (!isSparkTableHeader(headers[0])) continue;
|
||||||
|
|
||||||
const bodyRows = bodyRowsText
|
const bodyRows = bodyRowsText
|
||||||
.trim()
|
.trim()
|
||||||
@@ -674,7 +681,7 @@ export function inspectSparkTableCsv(csv: string): string[] | null {
|
|||||||
|
|
||||||
const headers = lines[0].split(",").map((h) => h.trim());
|
const headers = lines[0].split(",").map((h) => h.trim());
|
||||||
if (headers.length < 2) return null;
|
if (headers.length < 2) return null;
|
||||||
if (!DICE_HEADER_RE.test(headers[0])) return null;
|
if (!isSparkTableHeader(headers[0])) return null;
|
||||||
|
|
||||||
return headers.slice(1);
|
return headers.slice(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { createSignal } from "solid-js";
|
|||||||
import {
|
import {
|
||||||
getPathsByExtension,
|
getPathsByExtension,
|
||||||
getIndexedData,
|
getIndexedData,
|
||||||
|
setIndexedData,
|
||||||
setInlineResolver,
|
setInlineResolver,
|
||||||
} from "../../data-loader/file-index";
|
} from "../../data-loader/file-index";
|
||||||
import {
|
import {
|
||||||
@@ -119,6 +120,14 @@ async function scanClientSide(): Promise<JournalCompletions> {
|
|||||||
|
|
||||||
const registry = buildRegistryFromIndex(index);
|
const registry = buildRegistryFromIndex(index);
|
||||||
activeRegistry = registry;
|
activeRegistry = registry;
|
||||||
|
|
||||||
|
// Write the processed (stripped) content back into the file index so
|
||||||
|
// Article/md-embed render the same content as CLI mode (spark tables
|
||||||
|
// coerced, attributed blocks processed, data-spark injected).
|
||||||
|
for (const [path, content] of Object.entries(registry.pathIndex)) {
|
||||||
|
setIndexedData(path, content);
|
||||||
|
}
|
||||||
|
|
||||||
return deriveCompletions(registry);
|
return deriveCompletions(registry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+18
-13
@@ -7,9 +7,9 @@ import {
|
|||||||
createMemo,
|
createMemo,
|
||||||
createResource,
|
createResource,
|
||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import { marked } from "../markdown";
|
import { parseMarkdown } from "../markdown";
|
||||||
import { loadCSV, CSV, processVariables, isCSV } from "./utils/csv-loader";
|
import { loadCSV, CSV, processVariables } from "./utils/csv-loader";
|
||||||
import { resolvePath } from "./utils/path";
|
import { resolveContentRef } from "./utils/resolve-content";
|
||||||
import {
|
import {
|
||||||
areAllLabelsNumeric,
|
areAllLabelsNumeric,
|
||||||
weightedRandomIndex,
|
weightedRandomIndex,
|
||||||
@@ -51,13 +51,17 @@ customElement(
|
|||||||
const articleEl = element?.closest("article[data-src]");
|
const articleEl = element?.closest("article[data-src]");
|
||||||
const articlePath = articleEl?.getAttribute("data-src") || "";
|
const articlePath = articleEl?.getAttribute("data-src") || "";
|
||||||
|
|
||||||
// 如果是 inline CSV,直接使用;否则解析相对路径
|
// 解析引用:inline CSV 直接使用,否则通过 registry 解析(含内联内容 id)
|
||||||
const contentOrPath = isCSV(rawContent)
|
const [csvData] = createResource(
|
||||||
? rawContent
|
() => ({ ref: rawContent, docPath: articlePath }),
|
||||||
: resolvePath(articlePath, rawContent);
|
async ({ ref, docPath }) => {
|
||||||
|
const content = await resolveContentRef(ref, docPath);
|
||||||
// 使用 createResource 加载 CSV,自动响应路径变化并避免重复加载
|
if (content === null) {
|
||||||
const [csvData] = createResource(() => contentOrPath, loadCSV);
|
throw new Error(`Failed to resolve table content: "${ref}"`);
|
||||||
|
}
|
||||||
|
return loadCSV(content);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// 当数据加载完成后更新 rows
|
// 当数据加载完成后更新 rows
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -97,10 +101,11 @@ customElement(
|
|||||||
|
|
||||||
// 处理 body 内容中的 {{prop}} 语法并解析 markdown
|
// 处理 body 内容中的 {{prop}} 语法并解析 markdown
|
||||||
const processBody = (body: string, currentRow: TableRow): string => {
|
const processBody = (body: string, currentRow: TableRow): string => {
|
||||||
// 使用 marked 解析 markdown
|
// 使用 parseMarkdown 统一入口(设置图标 base path 等)
|
||||||
return marked.parse(
|
return parseMarkdown(
|
||||||
processVariables(body, currentRow, rows(), filteredRows(), props.remix),
|
processVariables(body, currentRow, rows(), filteredRows(), props.remix),
|
||||||
) as string;
|
articlePath,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 更新 body 内容
|
// 更新 body 内容
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { resolveContent } from "../../cli/content-registry";
|
||||||
|
import { getRegistry } from "../journal/completions";
|
||||||
|
import { getIndexedData } from "../../data-loader/file-index";
|
||||||
|
import { resolvePath } from "./path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a content reference (a directive body) to raw content.
|
||||||
|
*
|
||||||
|
* Prefers the shared content-registry resolution, which understands:
|
||||||
|
* - inline CSV bodies (returned as-is)
|
||||||
|
* - inline doc content refs like `./csv_abc123` (scoped to the current doc)
|
||||||
|
* - absolute paths and relative paths resolved against the current doc
|
||||||
|
*
|
||||||
|
* Falls back to path resolution + the file index when the registry isn't
|
||||||
|
* populated yet (e.g. before completions load) or the ref isn't indexed.
|
||||||
|
*
|
||||||
|
* Returns `null` when nothing matches.
|
||||||
|
*/
|
||||||
|
export async function resolveContentRef(
|
||||||
|
ref: string,
|
||||||
|
docPath: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const trimmed = ref.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
const resolved = resolveContent(getRegistry(), docPath, trimmed);
|
||||||
|
if (resolved !== null) return resolved;
|
||||||
|
|
||||||
|
// Fallback: resolve relative path and fetch through the file index.
|
||||||
|
const path = resolvePath(docPath, trimmed);
|
||||||
|
try {
|
||||||
|
return await getIndexedData(path);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
removeHandle,
|
removeHandle,
|
||||||
ensurePermission,
|
ensurePermission,
|
||||||
} from "./file-index-db";
|
} from "./file-index-db";
|
||||||
|
import { normalizePathKey } from "../cli/content-registry";
|
||||||
|
|
||||||
type FileIndex = Record<string, string>;
|
type FileIndex = Record<string, string>;
|
||||||
|
|
||||||
@@ -99,7 +100,7 @@ async function scanDirectory(
|
|||||||
Object.assign(index, sub);
|
Object.assign(index, sub);
|
||||||
} else if (entry.kind === "file" && acceptedExt.test(name)) {
|
} else if (entry.kind === "file" && acceptedExt.test(name)) {
|
||||||
const file = await (entry as FileSystemFileHandle).getFile();
|
const file = await (entry as FileSystemFileHandle).getFile();
|
||||||
const path = prefix + name;
|
const path = normalizePathKey(prefix + name);
|
||||||
index[path] = await file.text();
|
index[path] = await file.text();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,6 +212,16 @@ export async function getIndexedData(path: string): Promise<string> {
|
|||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入/覆盖索引中的文件内容。
|
||||||
|
* 用于将处理后的内容(如 registry 的 stripped markdown)写回索引,
|
||||||
|
* 使浏览器模式与 CLI 模式渲染一致。
|
||||||
|
*/
|
||||||
|
export function setIndexedData(path: string, content: string): void {
|
||||||
|
fileIndex = fileIndex || {};
|
||||||
|
fileIndex[normalizePathKey(path)] = content;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取指定扩展名的文件路径
|
* 获取指定扩展名的文件路径
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { MarkedExtension, Tokens } from "marked";
|
import type { MarkedExtension, Tokens } from "marked";
|
||||||
|
import { isSparkTableHeader } from "../cli/content-registry";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将表格数据转换为 CSV 格式字符串
|
* 将表格数据转换为 CSV 格式字符串
|
||||||
@@ -35,7 +36,7 @@ export default function markedTable(): MarkedExtension {
|
|||||||
let remix = "";
|
let remix = "";
|
||||||
|
|
||||||
const labelIndex = header.findIndex((cell) => {
|
const labelIndex = header.findIndex((cell) => {
|
||||||
if (cell.text === "md-roll-label" || cell.text.match(/(\d+)?d\d+/)) {
|
if (cell.text === "md-roll-label" || isSparkTableHeader(cell.text)) {
|
||||||
roll = " roll=true";
|
roll = " roll=true";
|
||||||
return true;
|
return true;
|
||||||
} else if (cell.text === "md-remix-label") {
|
} else if (cell.text === "md-remix-label") {
|
||||||
|
|||||||
Reference in New Issue
Block a user