refactor(completions): centralize content scanning
Add a browser-safe ContentRegistry owning path and inline content stores, then derive completions from it. Remove the old block-processor, directive-scanner, and link-source modules in favor of scanDoc, deriveCompletions, and injectSparkDirectives, and register an inline resolver for the frontend file index.
This commit is contained in:
+48
-105
@@ -8,17 +8,12 @@ import { networkInterfaces } from "os";
|
|||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import { createJournalServer } from "../journal.js";
|
import { createJournalServer } from "../journal.js";
|
||||||
import {
|
import {
|
||||||
scanCompletions,
|
scanDoc,
|
||||||
type CompletionsPayload,
|
deriveCompletions,
|
||||||
} from "../completions/index.js";
|
injectSparkDirectives,
|
||||||
import {
|
type ContentRegistry,
|
||||||
processBlocks,
|
} from "../content-registry.js";
|
||||||
type ProcessedBlocks,
|
import type { CompletionsPayload } from "../completions/types.js";
|
||||||
} from "../completions/block-processor.js";
|
|
||||||
import {
|
|
||||||
scanDirectives,
|
|
||||||
type DirectiveScanResult,
|
|
||||||
} from "../completions/directive-scanner.js";
|
|
||||||
|
|
||||||
interface ContentIndex {
|
interface ContentIndex {
|
||||||
[path: string]: string;
|
[path: string]: string;
|
||||||
@@ -88,20 +83,10 @@ function getBestIP(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 扫描目录内的 .md 等文件,生成内容索引与块数据
|
* 扫描目录内的 .md 等文件,构建内容注册表(路径索引 + 每文档内联内容)
|
||||||
*/
|
*/
|
||||||
export function scanDirectory(dir: string): {
|
export function buildRegistry(dir: string): ContentRegistry {
|
||||||
index: ContentIndex;
|
const registry: ContentRegistry = { pathIndex: {}, docContent: {} };
|
||||||
blocks: ProcessedBlocks;
|
|
||||||
directiveResults: DirectiveScanResult[];
|
|
||||||
} {
|
|
||||||
const index: ContentIndex = {};
|
|
||||||
const blocks: ProcessedBlocks = {
|
|
||||||
declarations: [],
|
|
||||||
tagModifiers: [],
|
|
||||||
};
|
|
||||||
const directiveResults: DirectiveScanResult[] = [];
|
|
||||||
const mdFiles: { content: string; relPath: string }[] = [];
|
|
||||||
|
|
||||||
function scan(currentPath: string, relativePath: string) {
|
function scan(currentPath: string, relativePath: string) {
|
||||||
const entries = readdirSync(currentPath);
|
const entries = readdirSync(currentPath);
|
||||||
@@ -125,13 +110,11 @@ export function scanDirectory(dir: string): {
|
|||||||
try {
|
try {
|
||||||
const content = readFileSync(fullPath, "utf-8");
|
const content = readFileSync(fullPath, "utf-8");
|
||||||
if (entry.endsWith(".md")) {
|
if (entry.endsWith(".md")) {
|
||||||
const result = processBlocks(content, normalizedRelPath, index);
|
const result = scanDoc(content, normalizedRelPath);
|
||||||
index[normalizedRelPath] = result.stripped;
|
registry.pathIndex[normalizedRelPath] = result.stripped;
|
||||||
blocks.declarations.push(...result.blocks.declarations);
|
registry.docContent[normalizedRelPath] = result.content;
|
||||||
blocks.tagModifiers.push(...result.blocks.tagModifiers);
|
|
||||||
mdFiles.push({ content: result.stripped, relPath: normalizedRelPath });
|
|
||||||
} else {
|
} else {
|
||||||
index[normalizedRelPath] = content;
|
registry.pathIndex[normalizedRelPath] = content;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`读取文件失败:${fullPath}`, e);
|
console.error(`读取文件失败:${fullPath}`, e);
|
||||||
@@ -142,31 +125,17 @@ export function scanDirectory(dir: string): {
|
|||||||
|
|
||||||
scan(dir, "");
|
scan(dir, "");
|
||||||
|
|
||||||
// ---- Directive scanning pass (after all blocks processed) ----
|
// ---- Inject data-spark into real-file spark table directives ----
|
||||||
const posixDir = dir.split(sep).join("/");
|
for (const [relPath, content] of Object.entries(registry.pathIndex)) {
|
||||||
for (const { content, relPath } of mdFiles) {
|
if (!relPath.endsWith(".md")) continue;
|
||||||
const fileDir = posixRelDir(relPath);
|
registry.pathIndex[relPath] = injectSparkDirectives(
|
||||||
const result = scanDirectives(content, relPath, index, fileDir);
|
content,
|
||||||
|
relPath,
|
||||||
// Apply rewritten content back to index
|
registry,
|
||||||
index[relPath] = result.rewritten;
|
);
|
||||||
|
|
||||||
// Inject new index entries (inline CSV bodies)
|
|
||||||
for (const [key, value] of Object.entries(result.newIndexEntries)) {
|
|
||||||
index[key] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
directiveResults.push(result);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { index, blocks, directiveResults };
|
return registry;
|
||||||
}
|
|
||||||
|
|
||||||
/** Get the POSIX directory of a file path */
|
|
||||||
function posixRelDir(filePath: string): string {
|
|
||||||
const parts = filePath.split("/");
|
|
||||||
parts.pop();
|
|
||||||
return parts.join("/") || ".";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -231,6 +200,7 @@ function createRequestHandler(
|
|||||||
distDir: string,
|
distDir: string,
|
||||||
getIndex: () => ContentIndex,
|
getIndex: () => ContentIndex,
|
||||||
getCompletions: () => CompletionsPayload,
|
getCompletions: () => CompletionsPayload,
|
||||||
|
getRegistry: () => ContentRegistry,
|
||||||
) {
|
) {
|
||||||
return (req: IncomingMessage, res: ServerResponse) => {
|
return (req: IncomingMessage, res: ServerResponse) => {
|
||||||
const url = req.url || "/";
|
const url = req.url || "/";
|
||||||
@@ -248,6 +218,12 @@ function createRequestHandler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1c. 处理 /__CONTENT_REGISTRY.json(含每文档内联内容,供运行时解析)
|
||||||
|
if (filePath === "/__CONTENT_REGISTRY.json") {
|
||||||
|
sendJson(res, getRegistry());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 2. 处理 /static/ 目录(从 dist/web)
|
// 2. 处理 /static/ 目录(从 dist/web)
|
||||||
if (filePath.startsWith("/static/")) {
|
if (filePath.startsWith("/static/")) {
|
||||||
if (tryServeStatic(res, filePath, distDir)) {
|
if (tryServeStatic(res, filePath, distDir)) {
|
||||||
@@ -308,12 +284,7 @@ export function createContentServer(
|
|||||||
distPath: string = distDir,
|
distPath: string = distDir,
|
||||||
host: string = "0.0.0.0",
|
host: string = "0.0.0.0",
|
||||||
): ContentServer {
|
): ContentServer {
|
||||||
let contentIndex: ContentIndex = {};
|
let registry: ContentRegistry = { pathIndex: {}, docContent: {} };
|
||||||
let collectedBlocks: ProcessedBlocks = {
|
|
||||||
declarations: [],
|
|
||||||
tagModifiers: [],
|
|
||||||
};
|
|
||||||
let directiveResults: DirectiveScanResult[] = [];
|
|
||||||
let completionsIndex: CompletionsPayload = {
|
let completionsIndex: CompletionsPayload = {
|
||||||
dice: [],
|
dice: [],
|
||||||
links: [],
|
links: [],
|
||||||
@@ -322,21 +293,18 @@ export function createContentServer(
|
|||||||
tagModifiers: [],
|
tagModifiers: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 从当前内容索引和已收集的块重新扫描补全数据 */
|
/** 从当前注册表重新派生补全数据 */
|
||||||
function recomputeCompletions(): void {
|
function recomputeCompletions(): void {
|
||||||
completionsIndex = scanCompletions(contentIndex, collectedBlocks, directiveResults);
|
completionsIndex = deriveCompletions(registry);
|
||||||
console.log(
|
console.log(
|
||||||
`[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length} declarations=${completionsIndex.declarations.length} tagModifiers=${completionsIndex.tagModifiers.length}`,
|
`[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length} declarations=${completionsIndex.declarations.length} tagModifiers=${completionsIndex.tagModifiers.length}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 扫描内容目录生成索引
|
// 扫描内容目录生成注册表
|
||||||
console.log("正在扫描内容目录...");
|
console.log("正在扫描内容目录...");
|
||||||
const scanResult = scanDirectory(contentDir);
|
registry = buildRegistry(contentDir);
|
||||||
contentIndex = scanResult.index;
|
console.log(`已索引 ${Object.keys(registry.pathIndex).length} 个文件`);
|
||||||
collectedBlocks = scanResult.blocks;
|
|
||||||
directiveResults = scanResult.directiveResults;
|
|
||||||
console.log(`已索引 ${Object.keys(contentIndex).length} 个文件`);
|
|
||||||
recomputeCompletions();
|
recomputeCompletions();
|
||||||
|
|
||||||
// 监听文件变化
|
// 监听文件变化
|
||||||
@@ -356,20 +324,10 @@ export function createContentServer(
|
|||||||
path.endsWith(".svg")
|
path.endsWith(".svg")
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const content = readFileSync(path, "utf-8");
|
// 全量重建注册表以刷新跨文件派生(spark 注入)
|
||||||
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
|
registry = buildRegistry(contentDir);
|
||||||
if (relPath.endsWith(".md")) {
|
recomputeCompletions();
|
||||||
const result = processBlocks(content, relPath, contentIndex);
|
console.log(`[新增] ${path}`);
|
||||||
contentIndex[relPath] = result.stripped;
|
|
||||||
// Re-scan to get fresh blocks (simpler than per-file merge)
|
|
||||||
const rescan = scanDirectory(contentDir);
|
|
||||||
collectedBlocks = rescan.blocks;
|
|
||||||
directiveResults = rescan.directiveResults;
|
|
||||||
recomputeCompletions();
|
|
||||||
} else {
|
|
||||||
contentIndex[relPath] = content;
|
|
||||||
}
|
|
||||||
console.log(`[新增] ${relPath}`);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`读取新增文件失败:${path}`, e);
|
console.error(`读取新增文件失败:${path}`, e);
|
||||||
}
|
}
|
||||||
@@ -383,19 +341,9 @@ export function createContentServer(
|
|||||||
path.endsWith(".svg")
|
path.endsWith(".svg")
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const content = readFileSync(path, "utf-8");
|
registry = buildRegistry(contentDir);
|
||||||
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
|
recomputeCompletions();
|
||||||
if (relPath.endsWith(".md")) {
|
console.log(`[更新] ${path}`);
|
||||||
const result = processBlocks(content, relPath, contentIndex);
|
|
||||||
contentIndex[relPath] = result.stripped;
|
|
||||||
const rescan = scanDirectory(contentDir);
|
|
||||||
collectedBlocks = rescan.blocks;
|
|
||||||
directiveResults = rescan.directiveResults;
|
|
||||||
recomputeCompletions();
|
|
||||||
} else {
|
|
||||||
contentIndex[relPath] = content;
|
|
||||||
}
|
|
||||||
console.log(`[更新] ${relPath}`);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`读取更新文件失败:${path}`, e);
|
console.error(`读取更新文件失败:${path}`, e);
|
||||||
}
|
}
|
||||||
@@ -408,15 +356,9 @@ export function createContentServer(
|
|||||||
path.endsWith(".yarn") ||
|
path.endsWith(".yarn") ||
|
||||||
path.endsWith(".svg")
|
path.endsWith(".svg")
|
||||||
) {
|
) {
|
||||||
const relPath = "/" + relative(contentDir, path).split(sep).join("/");
|
registry = buildRegistry(contentDir);
|
||||||
delete contentIndex[relPath];
|
recomputeCompletions();
|
||||||
console.log(`[删除] ${relPath}`);
|
console.log(`[删除] ${path}`);
|
||||||
if (relPath.endsWith(".md")) {
|
|
||||||
const rescan = scanDirectory(contentDir);
|
|
||||||
collectedBlocks = rescan.blocks;
|
|
||||||
directiveResults = rescan.directiveResults;
|
|
||||||
recomputeCompletions();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -427,8 +369,9 @@ export function createContentServer(
|
|||||||
const handleRequest = createRequestHandler(
|
const handleRequest = createRequestHandler(
|
||||||
contentDir,
|
contentDir,
|
||||||
distPath,
|
distPath,
|
||||||
() => contentIndex,
|
() => registry.pathIndex,
|
||||||
() => completionsIndex,
|
() => completionsIndex,
|
||||||
|
() => registry,
|
||||||
);
|
);
|
||||||
const server = createServer(handleRequest);
|
const server = createServer(handleRequest);
|
||||||
|
|
||||||
@@ -452,7 +395,7 @@ export function createContentServer(
|
|||||||
return {
|
return {
|
||||||
server,
|
server,
|
||||||
watcher,
|
watcher,
|
||||||
index: contentIndex,
|
index: registry.pathIndex,
|
||||||
completions: completionsIndex,
|
completions: completionsIndex,
|
||||||
close() {
|
close() {
|
||||||
console.log("正在关闭内容服务器...");
|
console.log("正在关闭内容服务器...");
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
/**
|
|
||||||
* CLI block processor — wraps block-scanner with Node-specific index injection
|
|
||||||
* and content stripping.
|
|
||||||
*
|
|
||||||
* Uses Node `crypto` and `path` — not safe for browser import.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { posix } from "path";
|
|
||||||
import { createHash } from "crypto";
|
|
||||||
import { parseDeclareCsv, type VarDeclaration, type TagModifier } from "./declare-parser.js";
|
|
||||||
import {
|
|
||||||
FENCED_BLOCK_RE,
|
|
||||||
parseBlockAttrs,
|
|
||||||
resolveBlockAs,
|
|
||||||
} from "./block-scanner.js";
|
|
||||||
|
|
||||||
// Re-export shared pieces for convenience
|
|
||||||
export {
|
|
||||||
FENCED_BLOCK_RE,
|
|
||||||
parseBlockAttrs,
|
|
||||||
resolveBlockAs,
|
|
||||||
type BlockAttrs,
|
|
||||||
} from "./block-scanner.js";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface ProcessedBlocks {
|
|
||||||
declarations: VarDeclaration[];
|
|
||||||
tagModifiers: TagModifier[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BlockResult {
|
|
||||||
/** Content with blocks processed (stripped or replaced with directives) */
|
|
||||||
stripped: string;
|
|
||||||
/** Parsed blocks for completions */
|
|
||||||
blocks: ProcessedBlocks;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Content hash
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function contentHash(body: string): string {
|
|
||||||
return createHash("md5").update(body).digest("hex").slice(0, 8);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Main processor
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process all attributed fenced code blocks in a markdown file.
|
|
||||||
*
|
|
||||||
* - Strips/replaces blocks based on `as`
|
|
||||||
* - Injects `role=file` bodies into the content index
|
|
||||||
* - Collects declare blocks for completions
|
|
||||||
* - role=spark-table blocks are converted to :md-table directives
|
|
||||||
* (spark table completions are collected later by the directive scanner)
|
|
||||||
*/
|
|
||||||
export function processBlocks(
|
|
||||||
content: string,
|
|
||||||
fileRelativePath: string,
|
|
||||||
index: Record<string, string>,
|
|
||||||
): BlockResult {
|
|
||||||
const fileDir = posix.dirname(fileRelativePath);
|
|
||||||
|
|
||||||
const blocks: ProcessedBlocks = {
|
|
||||||
declarations: [],
|
|
||||||
tagModifiers: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const stripped = content.replace(
|
|
||||||
FENCED_BLOCK_RE,
|
|
||||||
(
|
|
||||||
_match: string,
|
|
||||||
lang: string,
|
|
||||||
infoString: string,
|
|
||||||
body: string,
|
|
||||||
): string => {
|
|
||||||
const attrs = parseBlockAttrs(infoString);
|
|
||||||
attrs.lang = attrs.lang || lang;
|
|
||||||
const effectiveAs = resolveBlockAs(attrs.role, attrs.as);
|
|
||||||
|
|
||||||
// ---- Dispatch by role ----
|
|
||||||
|
|
||||||
if (attrs.role === "declare") {
|
|
||||||
try {
|
|
||||||
const result = parseDeclareCsv(body, fileRelativePath);
|
|
||||||
blocks.declarations.push(...result.variables);
|
|
||||||
blocks.tagModifiers.push(...result.tagModifiers);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`[block-processor] ${fileRelativePath}: ${e}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attrs.role === "file") {
|
|
||||||
const filename = attrs.id
|
|
||||||
? `${attrs.id}.${attrs.lang || "txt"}`
|
|
||||||
: `_inline_${contentHash(body)}.${attrs.lang || "txt"}`;
|
|
||||||
const resolvedPath = posix.join(fileDir, filename);
|
|
||||||
index[resolvedPath] = body;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Render by as ----
|
|
||||||
|
|
||||||
if (effectiveAs === "codeblock") {
|
|
||||||
return _match; // keep as-is
|
|
||||||
}
|
|
||||||
|
|
||||||
if (effectiveAs === "none") {
|
|
||||||
return ""; // strip
|
|
||||||
}
|
|
||||||
|
|
||||||
// Directive: :md-table[./file.csv], :md-card[./file.csv], :md-dice[./file.csv]
|
|
||||||
if (effectiveAs.startsWith("md-")) {
|
|
||||||
const filename = attrs.id
|
|
||||||
? `${attrs.id}.${attrs.lang || "txt"}`
|
|
||||||
: `_inline_${contentHash(body)}.${attrs.lang || "txt"}`;
|
|
||||||
const resolvedPath = posix.join(fileDir, filename);
|
|
||||||
|
|
||||||
// Ensure body is in the index for directive rendering
|
|
||||||
if (!index[resolvedPath]) {
|
|
||||||
index[resolvedPath] = body;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect extra attrs for the directive
|
|
||||||
const extra = { ...attrs.extra };
|
|
||||||
const extraStr = Object.keys(extra).length
|
|
||||||
? `{${Object.entries(extra)
|
|
||||||
.map(([k, v]) => `${k}=${v}`)
|
|
||||||
.join(" ")}}`
|
|
||||||
: "";
|
|
||||||
return `:${effectiveAs}[./${filename}]${extraStr}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unknown as → strip
|
|
||||||
return "";
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return { stripped, blocks };
|
|
||||||
}
|
|
||||||
@@ -16,49 +16,21 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { parse } from "csv-parse/browser/esm/sync";
|
import { parse } from "csv-parse/browser/esm/sync";
|
||||||
import { FENCED_BLOCK_RE, parseBlockAttrs } from "./block-scanner.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scan markdown content for ```csv role=declare blocks and return
|
|
||||||
* parsed declarations and tag modifiers. Shared between CLI and client.
|
|
||||||
*/
|
|
||||||
export function scanDeclareBlocks(content: string, filePath: string): DeclareResult {
|
|
||||||
const variables: VarDeclaration[] = [];
|
|
||||||
const tagModifiers: TagModifier[] = [];
|
|
||||||
|
|
||||||
FENCED_BLOCK_RE.lastIndex = 0;
|
|
||||||
let m: RegExpExecArray | null;
|
|
||||||
while ((m = FENCED_BLOCK_RE.exec(content)) !== null) {
|
|
||||||
const [, , infoString, body] = m;
|
|
||||||
const attrs = parseBlockAttrs(infoString);
|
|
||||||
if (attrs.role !== "declare") continue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = parseDeclareCsv(body, filePath);
|
|
||||||
variables.push(...result.variables);
|
|
||||||
tagModifiers.push(...result.tagModifiers);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`[declare-parser] ${filePath}: ${e}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { variables, tagModifiers };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface VarDeclaration {
|
export interface VarDeclaration {
|
||||||
key: string; // "$hp" (always starts with $)
|
key: string; // "$hp" (always starts with $)
|
||||||
expression: string; // "$con*5+$mod_hp"
|
expression: string; // "$con*5+$mod_hp"
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TagModifier {
|
export interface TagModifier {
|
||||||
tag: string; // "#warrior"
|
tag: string; // "#warrior"
|
||||||
target: string; // "$mod_hp"
|
target: string; // "$mod_hp"
|
||||||
expression: string; // "20"
|
expression: string; // "20"
|
||||||
threshold: number; // minimum tagmap count to activate (default 1)
|
threshold: number; // minimum tagmap count to activate (default 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeclareResult {
|
export interface DeclareResult {
|
||||||
@@ -104,14 +76,10 @@ export function parseDeclareCsv(csv: string, source: string): DeclareResult {
|
|||||||
if (tag) {
|
if (tag) {
|
||||||
// Tag modifier
|
// Tag modifier
|
||||||
if (!tag.startsWith("#")) {
|
if (!tag.startsWith("#")) {
|
||||||
throw new Error(
|
throw new Error(`${source}: tag must start with #, got "${tag}"`);
|
||||||
`${source}: tag must start with #, got "${tag}"`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (!key.startsWith("$")) {
|
if (!key.startsWith("$")) {
|
||||||
throw new Error(
|
throw new Error(`${source}: key must start with $, got "${key}"`);
|
||||||
`${source}: key must start with $, got "${key}"`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const thresholdRaw = row.threshold?.trim() ?? "";
|
const thresholdRaw = row.threshold?.trim() ?? "";
|
||||||
const threshold = thresholdRaw ? parseInt(thresholdRaw, 10) : 1;
|
const threshold = thresholdRaw ? parseInt(thresholdRaw, 10) : 1;
|
||||||
@@ -124,13 +92,11 @@ export function parseDeclareCsv(csv: string, source: string): DeclareResult {
|
|||||||
} else {
|
} else {
|
||||||
// Variable declaration
|
// Variable declaration
|
||||||
if (!key.startsWith("$")) {
|
if (!key.startsWith("$")) {
|
||||||
throw new Error(
|
throw new Error(`${source}: key must start with $, got "${key}"`);
|
||||||
`${source}: key must start with $, got "${key}"`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
variables.push({ key, expression: expr });
|
variables.push({ key, expression: expr });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { variables, tagModifiers };
|
return { variables, tagModifiers };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,341 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unified directive scanner — shared between CLI and browser.
|
|
||||||
*
|
|
||||||
* One pass over stripped markdown content that:
|
|
||||||
* 1. Detects markdown tables that look like spark tables → coerces to
|
|
||||||
* :md-table[./_inline_{hash}.csv] directives
|
|
||||||
* 2. Scans :md-dice[...] directives → collects DiceCompletion
|
|
||||||
* 3. Scans :md-table[...] directives → resolves CSV, checks if spark table
|
|
||||||
* → collects SparkTableCompletion
|
|
||||||
* 4. Scans :md-card[...] directives → same as md-table
|
|
||||||
*
|
|
||||||
* Safe for both Node and browser. No Node-specific imports.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Slugger from "github-slugger";
|
|
||||||
import type { DiceCompletion, SparkTableCompletion } from "./types.js";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface DirectiveScanResult {
|
|
||||||
/** Rewritten content with markdown tables coerced to directives */
|
|
||||||
rewritten: string;
|
|
||||||
/** Dice completions discovered */
|
|
||||||
dice: DiceCompletion[];
|
|
||||||
/** Spark table completions discovered */
|
|
||||||
sparkTables: SparkTableCompletion[];
|
|
||||||
/** New index entries for inline CSV bodies (key → CSV content) */
|
|
||||||
newIndexEntries: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const DICE_HEADER_RE = /^\d*d\d+$/i;
|
|
||||||
|
|
||||||
function contentHash(body: string): string {
|
|
||||||
// Simple hash suitable for both Node and browser
|
|
||||||
let hash = 0;
|
|
||||||
for (let i = 0; i < body.length; i++) {
|
|
||||||
const ch = body.charCodeAt(i);
|
|
||||||
hash = ((hash << 5) - hash + ch) | 0;
|
|
||||||
}
|
|
||||||
return Math.abs(hash).toString(16).slice(0, 8);
|
|
||||||
}
|
|
||||||
|
|
||||||
function looksLikeDice(raw: string): boolean {
|
|
||||||
if (raw.length > 80) return false;
|
|
||||||
return /^\d*d\d+/i.test(raw) || /^[+-]/.test(raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Parse key=value pairs from directive extra attrs string */
|
|
||||||
function parseDirectiveAttrs(extraStr: string | undefined): Record<string, string> {
|
|
||||||
if (!extraStr) return {};
|
|
||||||
const attrs: Record<string, string> = {};
|
|
||||||
const re = /(\w+)\s*=\s*("[^"]*"|\S+)/g;
|
|
||||||
let m: RegExpExecArray | null;
|
|
||||||
while ((m = re.exec(extraStr)) !== null) {
|
|
||||||
attrs[m[1]] = m[2].replace(/^"|"$/g, "");
|
|
||||||
}
|
|
||||||
return attrs;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Markdown table → CSV conversion
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Split a markdown table row into cells.
|
|
||||||
* Handles leading/trailing pipes and trims whitespace.
|
|
||||||
*/
|
|
||||||
function splitTableRow(row: string): string[] {
|
|
||||||
return row
|
|
||||||
.replace(/^\|/, "")
|
|
||||||
.replace(/\|$/, "")
|
|
||||||
.split("|")
|
|
||||||
.map((c) => c.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Escape a cell value for CSV output.
|
|
||||||
*/
|
|
||||||
function escapeCsvCell(cell: string): string {
|
|
||||||
if (
|
|
||||||
cell.includes(",") ||
|
|
||||||
cell.includes("\n") ||
|
|
||||||
cell.includes('"') ||
|
|
||||||
cell.includes("#")
|
|
||||||
) {
|
|
||||||
return `"${cell.replace(/"/g, '""')}"`;
|
|
||||||
}
|
|
||||||
return cell;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a markdown table (header + separator + rows) to a CSV string.
|
|
||||||
*/
|
|
||||||
function markdownTableToCsv(
|
|
||||||
headerRow: string,
|
|
||||||
separatorRow: string,
|
|
||||||
bodyRows: string[],
|
|
||||||
): string | null {
|
|
||||||
const headers = splitTableRow(headerRow);
|
|
||||||
if (headers.length === 0) return null;
|
|
||||||
|
|
||||||
// Validate separator row (must contain dashes)
|
|
||||||
const sepCells = splitTableRow(separatorRow);
|
|
||||||
if (!sepCells.every((c) => /^:?-{3,}:?$/.test(c))) return null;
|
|
||||||
if (sepCells.length !== headers.length) return null;
|
|
||||||
|
|
||||||
const csvHeader = headers.map(escapeCsvCell).join(",");
|
|
||||||
|
|
||||||
const csvRows = bodyRows.map((row) => {
|
|
||||||
const cells = splitTableRow(row);
|
|
||||||
// Pad to match header length
|
|
||||||
while (cells.length < headers.length) cells.push("");
|
|
||||||
return cells.slice(0, headers.length).map(escapeCsvCell).join(",");
|
|
||||||
});
|
|
||||||
|
|
||||||
return [csvHeader, ...csvRows].join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Spark table CSV inspection
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a CSV body represents a spark table.
|
|
||||||
* Returns the data column headers (excluding the dice column) if so, or null.
|
|
||||||
*/
|
|
||||||
export function inspectSparkTableCsv(csv: string): string[] | null {
|
|
||||||
const lines = csv.trim().split(/\r?\n/);
|
|
||||||
if (lines.length < 2) return null;
|
|
||||||
|
|
||||||
const headers = lines[0].split(",").map((h) => h.trim());
|
|
||||||
if (headers.length < 2) return null;
|
|
||||||
if (!DICE_HEADER_RE.test(headers[0])) return null;
|
|
||||||
|
|
||||||
return headers.slice(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a SparkTableCompletion from CSV data and file path.
|
|
||||||
*/
|
|
||||||
export function buildSparkTableCompletion(
|
|
||||||
csv: string,
|
|
||||||
filePath: string,
|
|
||||||
csvPath: string,
|
|
||||||
remix: boolean,
|
|
||||||
slugger: Slugger,
|
|
||||||
): SparkTableCompletion | null {
|
|
||||||
const dataHeaders = inspectSparkTableCsv(csv);
|
|
||||||
if (!dataHeaders) return null;
|
|
||||||
|
|
||||||
const lines = csv.trim().split(/\r?\n/);
|
|
||||||
const headers = lines[0].split(",").map((h) => h.trim());
|
|
||||||
|
|
||||||
const slug = dataHeaders
|
|
||||||
.map((h) => slugger.slug(h.toLowerCase()))
|
|
||||||
.join("-");
|
|
||||||
|
|
||||||
const basePath = filePath.replace(/\.md$/, "");
|
|
||||||
const fileName = basePath.split("/").filter(Boolean).pop() || basePath;
|
|
||||||
const combinedSlug = `${fileName}-${slug}`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
label: `${fileName} § ${slug}`,
|
|
||||||
notation: headers[0],
|
|
||||||
slug: combinedSlug,
|
|
||||||
filePath: basePath,
|
|
||||||
csvPath,
|
|
||||||
headers: dataHeaders,
|
|
||||||
remix,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Main scanner
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scan a single markdown file's stripped content for directives and
|
|
||||||
* spark-shaped markdown tables.
|
|
||||||
*
|
|
||||||
* @param content - Stripped markdown content (after block processing)
|
|
||||||
* @param filePath - The file's path (e.g. "/rules/combat.md")
|
|
||||||
* @param index - The content index for resolving CSV paths
|
|
||||||
* @param fileDir - Directory of the file (for resolving relative paths)
|
|
||||||
*/
|
|
||||||
export function scanDirectives(
|
|
||||||
content: string,
|
|
||||||
filePath: string,
|
|
||||||
index: Record<string, string>,
|
|
||||||
fileDir: string,
|
|
||||||
): DirectiveScanResult {
|
|
||||||
const slugger = new Slugger();
|
|
||||||
const dice: DiceCompletion[] = [];
|
|
||||||
const sparkTables: SparkTableCompletion[] = [];
|
|
||||||
const newIndexEntries: Record<string, string> = {};
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// Pass 1: Coerce spark-shaped markdown tables to :md-table directives
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
|
|
||||||
const mdTableRegex =
|
|
||||||
/^(\|.+\|)\n(\|[-: |]+\|)\n((?:\|.+\|\n?)+)/gm;
|
|
||||||
|
|
||||||
let rewritten = content;
|
|
||||||
let mdMatch: RegExpExecArray | null;
|
|
||||||
|
|
||||||
// Collect matches first (rewriting while iterating is tricky with regex)
|
|
||||||
interface TableMatch {
|
|
||||||
fullMatch: string;
|
|
||||||
headerRow: string;
|
|
||||||
separatorRow: string;
|
|
||||||
bodyRowsText: string;
|
|
||||||
index: number;
|
|
||||||
}
|
|
||||||
const tableMatches: TableMatch[] = [];
|
|
||||||
|
|
||||||
while ((mdMatch = mdTableRegex.exec(content)) !== null) {
|
|
||||||
const [, headerRow, separatorRow, bodyRowsText] = mdMatch;
|
|
||||||
const headers = splitTableRow(headerRow);
|
|
||||||
|
|
||||||
// Check if this looks like a spark table: first column is a dice formula
|
|
||||||
const isSpark = DICE_HEADER_RE.test(headers[0]);
|
|
||||||
|
|
||||||
if (!isSpark) continue;
|
|
||||||
|
|
||||||
const bodyRows = bodyRowsText
|
|
||||||
.trim()
|
|
||||||
.split(/\n/)
|
|
||||||
.filter((r) => r.trim().startsWith("|"));
|
|
||||||
|
|
||||||
const csv = markdownTableToCsv(headerRow, separatorRow, bodyRows);
|
|
||||||
if (!csv) continue;
|
|
||||||
|
|
||||||
tableMatches.push({
|
|
||||||
fullMatch: mdMatch[0],
|
|
||||||
headerRow,
|
|
||||||
separatorRow,
|
|
||||||
bodyRowsText,
|
|
||||||
index: mdMatch.index,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replace matches from end to start to preserve indices
|
|
||||||
for (let i = tableMatches.length - 1; i >= 0; i--) {
|
|
||||||
const m = tableMatches[i];
|
|
||||||
const bodyRows = m.bodyRowsText
|
|
||||||
.trim()
|
|
||||||
.split(/\n/)
|
|
||||||
.filter((r) => r.trim().startsWith("|"));
|
|
||||||
const csv = markdownTableToCsv(m.headerRow, m.separatorRow, bodyRows)!;
|
|
||||||
|
|
||||||
const hash = contentHash(csv);
|
|
||||||
const filename = `_spark_md_${hash}.csv`;
|
|
||||||
const resolvedPath = `${fileDir}/${filename}`;
|
|
||||||
|
|
||||||
newIndexEntries[resolvedPath] = csv;
|
|
||||||
|
|
||||||
// Collect spark table completion
|
|
||||||
const st = buildSparkTableCompletion(csv, filePath, resolvedPath, false, slugger);
|
|
||||||
if (st) {
|
|
||||||
sparkTables.push(st);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replace markdown table with :md-table directive
|
|
||||||
const directive = `:md-table[./${filename}]{data-spark="${st?.slug ?? ""}"}`;
|
|
||||||
rewritten =
|
|
||||||
rewritten.slice(0, m.index) +
|
|
||||||
directive +
|
|
||||||
rewritten.slice(m.index + m.fullMatch.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// Pass 2: Scan :md-dice[...] directives
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
|
|
||||||
const diceRegex = /:md-dice\[([^[\]]+)\]/gi;
|
|
||||||
let diceMatch: RegExpExecArray | null;
|
|
||||||
while ((diceMatch = diceRegex.exec(rewritten)) !== null) {
|
|
||||||
const raw = diceMatch[1].trim();
|
|
||||||
if (!raw || !looksLikeDice(raw)) continue;
|
|
||||||
dice.push({ label: raw, notation: raw, source: filePath });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// Pass 3: Scan :md-table[...] and :md-card[...] directives
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
|
|
||||||
const tableDirectiveRegex = /:md-(table|card)\[([^[\]]+)\](?:\{([^}]*)\})?/gi;
|
|
||||||
let tableMatch: RegExpExecArray | null;
|
|
||||||
while ((tableMatch = tableDirectiveRegex.exec(rewritten)) !== null) {
|
|
||||||
const [, /* type */ , path, extraStr] = tableMatch;
|
|
||||||
|
|
||||||
// Resolve the CSV path
|
|
||||||
const csvPath = path.startsWith("./")
|
|
||||||
? `${fileDir}/${path.slice(2)}`
|
|
||||||
: path;
|
|
||||||
|
|
||||||
let csv = index[csvPath] ?? newIndexEntries[csvPath];
|
|
||||||
if (!csv) continue;
|
|
||||||
|
|
||||||
// Parse extra attrs for remix flag
|
|
||||||
const attrs = parseDirectiveAttrs(extraStr);
|
|
||||||
const isRemix = attrs["remix"] === "true";
|
|
||||||
|
|
||||||
const st = buildSparkTableCompletion(csv, filePath, csvPath, isRemix, slugger);
|
|
||||||
if (!st) continue;
|
|
||||||
|
|
||||||
// Check if data-spark is already set in extra attrs
|
|
||||||
if (!extraStr || !extraStr.includes("data-spark=")) {
|
|
||||||
// Inject data-spark attribute into the directive
|
|
||||||
const fullMatch = tableMatch[0];
|
|
||||||
const insertPos = fullMatch.indexOf("]") + 1;
|
|
||||||
const before = fullMatch.slice(0, insertPos);
|
|
||||||
const after = fullMatch.slice(insertPos);
|
|
||||||
|
|
||||||
const sparkAttr = `{data-spark="${st.slug}"}`;
|
|
||||||
let replacement: string;
|
|
||||||
if (after.startsWith("{")) {
|
|
||||||
// Merge into existing attrs
|
|
||||||
replacement = before + after.replace(/^\{/, `{data-spark="${st.slug}" `);
|
|
||||||
} else {
|
|
||||||
replacement = before + sparkAttr + after;
|
|
||||||
}
|
|
||||||
|
|
||||||
rewritten =
|
|
||||||
rewritten.slice(0, tableMatch.index) +
|
|
||||||
replacement +
|
|
||||||
rewritten.slice(tableMatch.index + fullMatch.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
sparkTables.push(st);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { rewritten, dice, sparkTables, newIndexEntries };
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
/**
|
|
||||||
* Completion index — orchestrates all registered completion sources.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { linksSource } from "./sources/links.js";
|
|
||||||
import type { ProcessedBlocks } from "./block-processor.js";
|
|
||||||
import type { CompletionsPayload } from "./types.js";
|
|
||||||
import type { DirectiveScanResult } from "./directive-scanner.js";
|
|
||||||
|
|
||||||
export type {
|
|
||||||
CompletionsPayload,
|
|
||||||
DiceCompletion,
|
|
||||||
LinkCompletion,
|
|
||||||
SparkTableCompletion,
|
|
||||||
VarDeclaration,
|
|
||||||
TagModifier,
|
|
||||||
} from "./types.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build completions from the content index, pre-collected blocks,
|
|
||||||
* and directive scan results.
|
|
||||||
* Called at server startup and on any file change.
|
|
||||||
*/
|
|
||||||
export function scanCompletions(
|
|
||||||
index: Record<string, string>,
|
|
||||||
blocks: ProcessedBlocks,
|
|
||||||
directiveResults: DirectiveScanResult[],
|
|
||||||
): CompletionsPayload {
|
|
||||||
const links = linksSource.scan(index) as CompletionsPayload["links"];
|
|
||||||
|
|
||||||
// Merge all directive scan results
|
|
||||||
const dice: CompletionsPayload["dice"] = [];
|
|
||||||
const sparkTables: CompletionsPayload["sparkTables"] = [];
|
|
||||||
for (const dr of directiveResults) {
|
|
||||||
dice.push(...dr.dice);
|
|
||||||
sparkTables.push(...dr.sparkTables);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
dice,
|
|
||||||
links,
|
|
||||||
sparkTables,
|
|
||||||
declarations: blocks.declarations,
|
|
||||||
tagModifiers: blocks.tagModifiers,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
/**
|
|
||||||
* Link completion source — extracts markdown headings from all .md files.
|
|
||||||
*
|
|
||||||
* Produces two entries per heading section, plus one for the file itself.
|
|
||||||
* Uses github-slugger to match marked-gfm-heading-id's generated IDs.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Slugger from "github-slugger";
|
|
||||||
import type { CompletionSource, LinkCompletion } from "../types.js";
|
|
||||||
|
|
||||||
export const linksSource: CompletionSource = {
|
|
||||||
key: "links",
|
|
||||||
|
|
||||||
scan(index) {
|
|
||||||
const items: LinkCompletion[] = [];
|
|
||||||
|
|
||||||
for (const [filePath, content] of Object.entries(index)) {
|
|
||||||
if (!filePath.endsWith(".md")) continue;
|
|
||||||
|
|
||||||
// Strip .md extension for the router-friendly path
|
|
||||||
const basePath = filePath.replace(/\.md$/, "");
|
|
||||||
const fileName = fileNameFromPath(basePath);
|
|
||||||
const slugger = new Slugger();
|
|
||||||
|
|
||||||
// Add the file itself as a link (whole article)
|
|
||||||
items.push({
|
|
||||||
path: basePath,
|
|
||||||
label: fileName,
|
|
||||||
section: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Parse headings for section-scoped links
|
|
||||||
const headingRegex = /^(#{1,6})\s+(.+)$/gm;
|
|
||||||
let match: RegExpExecArray | null;
|
|
||||||
|
|
||||||
while ((match = headingRegex.exec(content)) !== null) {
|
|
||||||
const title = match[2].trim();
|
|
||||||
const id = slugger.slug(title.toLowerCase());
|
|
||||||
|
|
||||||
items.push({
|
|
||||||
path: basePath,
|
|
||||||
label: `${fileName} § ${title}`,
|
|
||||||
section: id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return items;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function fileNameFromPath(path: string): string {
|
|
||||||
const parts = path.split("/").filter(Boolean);
|
|
||||||
return parts[parts.length - 1] || path;
|
|
||||||
}
|
|
||||||
@@ -36,6 +36,8 @@ export interface SparkTableCompletion {
|
|||||||
slug: string;
|
slug: string;
|
||||||
/** File path of the containing .md file (without extension) */
|
/** File path of the containing .md file (without extension) */
|
||||||
filePath: string;
|
filePath: string;
|
||||||
|
/** Path of the containing .md file (with extension) — for inline lookup */
|
||||||
|
docPath: string;
|
||||||
/** Resolved path to the .csv file backing this spark table */
|
/** Resolved path to the .csv file backing this spark table */
|
||||||
csvPath: string;
|
csvPath: string;
|
||||||
/** Data column headers for display */
|
/** Data column headers for display */
|
||||||
@@ -61,4 +63,4 @@ export interface CompletionSource {
|
|||||||
key: string;
|
key: string;
|
||||||
/** Scan the content index and return structured completion items */
|
/** Scan the content index and return structured completion items */
|
||||||
scan(index: Record<string, string>): unknown[];
|
scan(index: Record<string, string>): unknown[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* - variable-expression (expression evaluation)
|
* - variable-expression (expression evaluation)
|
||||||
* - var-reactivity (dependency graph, cascade, tag activation)
|
* - var-reactivity (dependency graph, cascade, tag activation)
|
||||||
* - command-parser (input parsing)
|
* - command-parser (input parsing)
|
||||||
* - directive-scanner (spark table detection)
|
* - content-registry (spark table detection)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -47,8 +47,7 @@ import { parseInput } from "../../components/journal/command-parser";
|
|||||||
import {
|
import {
|
||||||
inspectSparkTableCsv,
|
inspectSparkTableCsv,
|
||||||
buildSparkTableCompletion,
|
buildSparkTableCompletion,
|
||||||
} from "./directive-scanner";
|
} from "../content-registry";
|
||||||
import Slugger from "github-slugger";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
@@ -70,7 +69,10 @@ describe("parseDeclareCsv", () => {
|
|||||||
,$ac,10+$dex`;
|
,$ac,10+$dex`;
|
||||||
const result = parseDeclareCsv(csv, "test.md");
|
const result = parseDeclareCsv(csv, "test.md");
|
||||||
expect(result.variables).toHaveLength(2);
|
expect(result.variables).toHaveLength(2);
|
||||||
expect(result.variables[0]).toEqual({ key: "$hp", expression: "$con*5+$mod_hp" });
|
expect(result.variables[0]).toEqual({
|
||||||
|
key: "$hp",
|
||||||
|
expression: "$con*5+$mod_hp",
|
||||||
|
});
|
||||||
expect(result.variables[1]).toEqual({ key: "$ac", expression: "10+$dex" });
|
expect(result.variables[1]).toEqual({ key: "$ac", expression: "10+$dex" });
|
||||||
expect(result.tagModifiers).toHaveLength(0);
|
expect(result.tagModifiers).toHaveLength(0);
|
||||||
});
|
});
|
||||||
@@ -154,7 +156,7 @@ describe("parseDeclareCsv", () => {
|
|||||||
const csv = `tag,key,expr
|
const csv = `tag,key,expr
|
||||||
warrior,$mod_hp,20`;
|
warrior,$mod_hp,20`;
|
||||||
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
|
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
|
||||||
'tag must start with #',
|
"tag must start with #",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -162,7 +164,7 @@ warrior,$mod_hp,20`;
|
|||||||
const csv = `tag,key,expr
|
const csv = `tag,key,expr
|
||||||
,hp,$con*5`;
|
,hp,$con*5`;
|
||||||
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
|
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
|
||||||
'key must start with $',
|
"key must start with $",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -170,7 +172,7 @@ warrior,$mod_hp,20`;
|
|||||||
const csv = `tag,key,expr
|
const csv = `tag,key,expr
|
||||||
#warrior,mod_hp,20`;
|
#warrior,mod_hp,20`;
|
||||||
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
|
expect(() => parseDeclareCsv(csv, "test.md")).toThrow(
|
||||||
'key must start with $',
|
"key must start with $",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -219,8 +221,8 @@ describe("parseBlockAttrs", () => {
|
|||||||
test("parses standard attributes", () => {
|
test("parses standard attributes", () => {
|
||||||
// parseBlockAttrs receives the info string AFTER the lang.
|
// parseBlockAttrs receives the info string AFTER the lang.
|
||||||
// The lang is extracted from the fenced block regex capture group
|
// The lang is extracted from the fenced block regex capture group
|
||||||
// and applied separately in block-processor.
|
// and applied separately in content-registry.
|
||||||
const attrs = parseBlockAttrs('id=stats role=declare as=none');
|
const attrs = parseBlockAttrs("id=stats role=declare as=none");
|
||||||
expect(attrs.id).toBe("stats");
|
expect(attrs.id).toBe("stats");
|
||||||
expect(attrs.role).toBe("declare");
|
expect(attrs.role).toBe("declare");
|
||||||
expect(attrs.as).toBe("none");
|
expect(attrs.as).toBe("none");
|
||||||
@@ -233,7 +235,7 @@ describe("parseBlockAttrs", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("collects unknown attributes in extra", () => {
|
test("collects unknown attributes in extra", () => {
|
||||||
const attrs = parseBlockAttrs('csv role=declare foo=bar baz=42');
|
const attrs = parseBlockAttrs("csv role=declare foo=bar baz=42");
|
||||||
expect(attrs.extra).toEqual({ foo: "bar", baz: "42" });
|
expect(attrs.extra).toEqual({ foo: "bar", baz: "42" });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -282,7 +284,9 @@ describe("evaluateExpression", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("evaluates with parentheses", () => {
|
test("evaluates with parentheses", () => {
|
||||||
const result = evaluateExpression("(2 + 3) * 4", { lookup: () => undefined });
|
const result = evaluateExpression("(2 + 3) * 4", {
|
||||||
|
lookup: () => undefined,
|
||||||
|
});
|
||||||
expect(result.value).toBe(20);
|
expect(result.value).toBe(20);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -309,11 +313,13 @@ describe("evaluateExpression", () => {
|
|||||||
evaluateExpression("$class + 5", {
|
evaluateExpression("$class + 5", {
|
||||||
lookup: (name) => (name === "class" ? "#warrior" : undefined),
|
lookup: (name) => (name === "class" ? "#warrior" : undefined),
|
||||||
}),
|
}),
|
||||||
).toThrow('$class is a tag');
|
).toThrow("$class is a tag");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("evaluates floor function", () => {
|
test("evaluates floor function", () => {
|
||||||
const result = evaluateExpression("floor(3.7)", { lookup: () => undefined });
|
const result = evaluateExpression("floor(3.7)", {
|
||||||
|
lookup: () => undefined,
|
||||||
|
});
|
||||||
expect(result.value).toBe(3);
|
expect(result.value).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -323,7 +329,9 @@ describe("evaluateExpression", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("evaluates round function", () => {
|
test("evaluates round function", () => {
|
||||||
const result = evaluateExpression("round(3.5)", { lookup: () => undefined });
|
const result = evaluateExpression("round(3.5)", {
|
||||||
|
lookup: () => undefined,
|
||||||
|
});
|
||||||
expect(result.value).toBe(4);
|
expect(result.value).toBe(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -424,9 +432,7 @@ describe("var-reactivity", () => {
|
|||||||
test("detects self-referencing circular dependency", () => {
|
test("detects self-referencing circular dependency", () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
initReactivity({
|
initReactivity({
|
||||||
declarations: [
|
declarations: [{ key: "$a", expression: "$a + 1" }],
|
||||||
{ key: "$a", expression: "$a + 1" },
|
|
||||||
],
|
|
||||||
tagModifiers: [],
|
tagModifiers: [],
|
||||||
}),
|
}),
|
||||||
).toThrow("Circular dependency");
|
).toThrow("Circular dependency");
|
||||||
@@ -527,14 +533,28 @@ describe("var-reactivity", () => {
|
|||||||
initReactivity({
|
initReactivity({
|
||||||
declarations: [],
|
declarations: [],
|
||||||
tagModifiers: [
|
tagModifiers: [
|
||||||
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
|
{
|
||||||
{ tag: "#warrior", target: "$mod_str", expression: "5", threshold: 1 },
|
tag: "#warrior",
|
||||||
|
target: "$mod_hp",
|
||||||
|
expression: "20",
|
||||||
|
threshold: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: "#warrior",
|
||||||
|
target: "$mod_str",
|
||||||
|
expression: "5",
|
||||||
|
threshold: 1,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set $class to #warrior:1 — should activate both modifiers
|
// Set $class to #warrior:1 — should activate both modifiers
|
||||||
setBase("$class", "#warrior:1");
|
setBase("$class", "#warrior:1");
|
||||||
const cascade = computeCascade("$class", undefined, store({ $class: "#warrior:1" }));
|
const cascade = computeCascade(
|
||||||
|
"$class",
|
||||||
|
undefined,
|
||||||
|
store({ $class: "#warrior:1" }),
|
||||||
|
);
|
||||||
|
|
||||||
// Should produce combined values for both targets
|
// Should produce combined values for both targets
|
||||||
const modHp = cascade.find((r) => r.key === "$mod_hp");
|
const modHp = cascade.find((r) => r.key === "$mod_hp");
|
||||||
@@ -547,7 +567,12 @@ describe("var-reactivity", () => {
|
|||||||
initReactivity({
|
initReactivity({
|
||||||
declarations: [],
|
declarations: [],
|
||||||
tagModifiers: [
|
tagModifiers: [
|
||||||
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
|
{
|
||||||
|
tag: "#warrior",
|
||||||
|
target: "$mod_hp",
|
||||||
|
expression: "20",
|
||||||
|
threshold: 1,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -572,7 +597,12 @@ describe("var-reactivity", () => {
|
|||||||
initReactivity({
|
initReactivity({
|
||||||
declarations: [],
|
declarations: [],
|
||||||
tagModifiers: [
|
tagModifiers: [
|
||||||
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
|
{
|
||||||
|
tag: "#warrior",
|
||||||
|
target: "$mod_hp",
|
||||||
|
expression: "20",
|
||||||
|
threshold: 1,
|
||||||
|
},
|
||||||
{ tag: "#mage", target: "$mod_hp", expression: "10", threshold: 1 },
|
{ tag: "#mage", target: "$mod_hp", expression: "10", threshold: 1 },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -600,19 +630,32 @@ describe("var-reactivity", () => {
|
|||||||
initReactivity({
|
initReactivity({
|
||||||
declarations: [],
|
declarations: [],
|
||||||
tagModifiers: [
|
tagModifiers: [
|
||||||
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 2 },
|
{
|
||||||
|
tag: "#warrior",
|
||||||
|
target: "$mod_hp",
|
||||||
|
expression: "20",
|
||||||
|
threshold: 2,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Count 1 < threshold 2 — should NOT activate
|
// Count 1 < threshold 2 — should NOT activate
|
||||||
setBase("$class", "#warrior:1");
|
setBase("$class", "#warrior:1");
|
||||||
const cascade1 = computeCascade("$class", undefined, store({ $class: "#warrior:1" }));
|
const cascade1 = computeCascade(
|
||||||
|
"$class",
|
||||||
|
undefined,
|
||||||
|
store({ $class: "#warrior:1" }),
|
||||||
|
);
|
||||||
const modHp1 = cascade1.find((r) => r.key === "$mod_hp");
|
const modHp1 = cascade1.find((r) => r.key === "$mod_hp");
|
||||||
expect(modHp1).toBeUndefined();
|
expect(modHp1).toBeUndefined();
|
||||||
|
|
||||||
// Increase to count 2 >= threshold 2 — should activate
|
// Increase to count 2 >= threshold 2 — should activate
|
||||||
setBase("$class", "#warrior:2");
|
setBase("$class", "#warrior:2");
|
||||||
const cascade2 = computeCascade("$class", "#warrior:1", store({ $class: "#warrior:2" }));
|
const cascade2 = computeCascade(
|
||||||
|
"$class",
|
||||||
|
"#warrior:1",
|
||||||
|
store({ $class: "#warrior:2" }),
|
||||||
|
);
|
||||||
const modHp2 = cascade2.find((r) => r.key === "$mod_hp");
|
const modHp2 = cascade2.find((r) => r.key === "$mod_hp");
|
||||||
expect(modHp2?.value).toBe("20");
|
expect(modHp2?.value).toBe("20");
|
||||||
});
|
});
|
||||||
@@ -621,7 +664,12 @@ describe("var-reactivity", () => {
|
|||||||
initReactivity({
|
initReactivity({
|
||||||
declarations: [],
|
declarations: [],
|
||||||
tagModifiers: [
|
tagModifiers: [
|
||||||
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 2 },
|
{
|
||||||
|
tag: "#warrior",
|
||||||
|
target: "$mod_hp",
|
||||||
|
expression: "20",
|
||||||
|
threshold: 2,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -645,13 +693,22 @@ describe("var-reactivity", () => {
|
|||||||
initReactivity({
|
initReactivity({
|
||||||
declarations: [],
|
declarations: [],
|
||||||
tagModifiers: [
|
tagModifiers: [
|
||||||
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
|
{
|
||||||
|
tag: "#warrior",
|
||||||
|
target: "$mod_hp",
|
||||||
|
expression: "20",
|
||||||
|
threshold: 1,
|
||||||
|
},
|
||||||
{ tag: "#druid", target: "$mod_mp", expression: "15", threshold: 1 },
|
{ tag: "#druid", target: "$mod_mp", expression: "15", threshold: 1 },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
setBase("$class", "#warrior:2;#druid:1");
|
setBase("$class", "#warrior:2;#druid:1");
|
||||||
const cascade = computeCascade("$class", undefined, store({ $class: "#warrior:2;#druid:1" }));
|
const cascade = computeCascade(
|
||||||
|
"$class",
|
||||||
|
undefined,
|
||||||
|
store({ $class: "#warrior:2;#druid:1" }),
|
||||||
|
);
|
||||||
|
|
||||||
const modHp = cascade.find((r) => r.key === "$mod_hp");
|
const modHp = cascade.find((r) => r.key === "$mod_hp");
|
||||||
const modMp = cascade.find((r) => r.key === "$mod_mp");
|
const modMp = cascade.find((r) => r.key === "$mod_mp");
|
||||||
@@ -663,9 +720,7 @@ describe("var-reactivity", () => {
|
|||||||
describe("computeCascade — declaration re-evaluation", () => {
|
describe("computeCascade — declaration re-evaluation", () => {
|
||||||
test("re-evaluates dependents when a dependency changes", () => {
|
test("re-evaluates dependents when a dependency changes", () => {
|
||||||
initReactivity({
|
initReactivity({
|
||||||
declarations: [
|
declarations: [{ key: "$hp", expression: "$con * 5" }],
|
||||||
{ key: "$hp", expression: "$con * 5" },
|
|
||||||
],
|
|
||||||
tagModifiers: [],
|
tagModifiers: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -701,11 +756,14 @@ describe("var-reactivity", () => {
|
|||||||
|
|
||||||
test("handles tagmap transition during re-evaluation", () => {
|
test("handles tagmap transition during re-evaluation", () => {
|
||||||
initReactivity({
|
initReactivity({
|
||||||
declarations: [
|
declarations: [{ key: "$class", expression: "0" }],
|
||||||
{ key: "$class", expression: "0" },
|
|
||||||
],
|
|
||||||
tagModifiers: [
|
tagModifiers: [
|
||||||
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
|
{
|
||||||
|
tag: "#warrior",
|
||||||
|
target: "$mod_hp",
|
||||||
|
expression: "20",
|
||||||
|
threshold: 1,
|
||||||
|
},
|
||||||
{ tag: "#novice", target: "$mod_hp", expression: "5", threshold: 1 },
|
{ tag: "#novice", target: "$mod_hp", expression: "5", threshold: 1 },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -744,7 +802,12 @@ describe("var-reactivity", () => {
|
|||||||
initReactivity({
|
initReactivity({
|
||||||
declarations: [],
|
declarations: [],
|
||||||
tagModifiers: [
|
tagModifiers: [
|
||||||
{ tag: "#warrior", target: "$mod_hp", expression: "20", threshold: 1 },
|
{
|
||||||
|
tag: "#warrior",
|
||||||
|
target: "$mod_hp",
|
||||||
|
expression: "20",
|
||||||
|
threshold: 1,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -861,7 +924,7 @@ describe("parseInput", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// directive-scanner
|
// content-registry (spark tables)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe("inspectSparkTableCsv", () => {
|
describe("inspectSparkTableCsv", () => {
|
||||||
@@ -915,13 +978,11 @@ describe("buildSparkTableCompletion", () => {
|
|||||||
test("builds completion from CSV data", () => {
|
test("builds completion from CSV data", () => {
|
||||||
const csv = `d6,Name,Description
|
const csv = `d6,Name,Description
|
||||||
1,Alice,The brave`;
|
1,Alice,The brave`;
|
||||||
const slugger = new Slugger();
|
|
||||||
const result = buildSparkTableCompletion(
|
const result = buildSparkTableCompletion(
|
||||||
csv,
|
csv,
|
||||||
"/rules/combat.md",
|
"/rules/combat.md",
|
||||||
"/rules/combat/test.csv",
|
"/rules/combat/test.csv",
|
||||||
false,
|
false,
|
||||||
slugger,
|
|
||||||
);
|
);
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
expect(result!.notation).toBe("d6");
|
expect(result!.notation).toBe("d6");
|
||||||
@@ -933,13 +994,11 @@ describe("buildSparkTableCompletion", () => {
|
|||||||
test("returns null for non-spark CSV", () => {
|
test("returns null for non-spark CSV", () => {
|
||||||
const csv = `Name,Value
|
const csv = `Name,Value
|
||||||
Alice,10`;
|
Alice,10`;
|
||||||
const slugger = new Slugger();
|
|
||||||
const result = buildSparkTableCompletion(
|
const result = buildSparkTableCompletion(
|
||||||
csv,
|
csv,
|
||||||
"/test.md",
|
"/test.md",
|
||||||
"/test.csv",
|
"/test.csv",
|
||||||
false,
|
false,
|
||||||
slugger,
|
|
||||||
);
|
);
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -947,13 +1006,11 @@ Alice,10`;
|
|||||||
test("sets remix flag", () => {
|
test("sets remix flag", () => {
|
||||||
const csv = `d6,Result
|
const csv = `d6,Result
|
||||||
1,Yes`;
|
1,Yes`;
|
||||||
const slugger = new Slugger();
|
|
||||||
const result = buildSparkTableCompletion(
|
const result = buildSparkTableCompletion(
|
||||||
csv,
|
csv,
|
||||||
"/test.md",
|
"/test.md",
|
||||||
"/test.csv",
|
"/test.csv",
|
||||||
true,
|
true,
|
||||||
slugger,
|
|
||||||
);
|
);
|
||||||
expect(result!.remix).toBe(true);
|
expect(result!.remix).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,689 @@
|
|||||||
|
/**
|
||||||
|
* Content registry — the single source of truth for all content in a
|
||||||
|
* TTRPG Tools project.
|
||||||
|
*
|
||||||
|
* Two stores:
|
||||||
|
* - `pathIndex`: real files on disk, keyed by path (`.md`, `.csv`, `.yarn`, `.svg`)
|
||||||
|
* - `docContent`: inline content *defined inside* a markdown doc, keyed by
|
||||||
|
* a stable id and owned by that doc.
|
||||||
|
*
|
||||||
|
* Everything structured (completions, declarations, tag modifiers) is a
|
||||||
|
* *derived* view over this registry — see `deriveCompletions`.
|
||||||
|
*
|
||||||
|
* This module is browser-safe (no Node-only imports) so the CLI and the
|
||||||
|
* frontend share one implementation. The filesystem walk lives in the CLI
|
||||||
|
* (`buildRegistry` in `commands/serve.ts`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Slugger from "github-slugger";
|
||||||
|
import {
|
||||||
|
parseDeclareCsv,
|
||||||
|
type VarDeclaration,
|
||||||
|
type TagModifier,
|
||||||
|
} from "./completions/declare-parser.js";
|
||||||
|
import {
|
||||||
|
FENCED_BLOCK_RE,
|
||||||
|
parseBlockAttrs,
|
||||||
|
resolveBlockAs,
|
||||||
|
} from "./completions/block-scanner.js";
|
||||||
|
import type {
|
||||||
|
CompletionsPayload,
|
||||||
|
DiceCompletion,
|
||||||
|
LinkCompletion,
|
||||||
|
SparkTableCompletion,
|
||||||
|
} from "./completions/types.js";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type ContentKind = "csv" | "text" | "declare";
|
||||||
|
|
||||||
|
/** A single piece of inline content defined inside a doc. */
|
||||||
|
export interface DocContent {
|
||||||
|
/** Stable id — author-supplied or derived from the body. */
|
||||||
|
id: string;
|
||||||
|
kind: ContentKind;
|
||||||
|
body: string;
|
||||||
|
/** Origin role that produced this content, for debugging. */
|
||||||
|
role?: string;
|
||||||
|
/** Origin `as` value, for debugging. */
|
||||||
|
as?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContentRegistry {
|
||||||
|
/** Real files on disk, keyed by path. */
|
||||||
|
pathIndex: Record<string, string>;
|
||||||
|
/** Inline content defined inside each doc, keyed by id. */
|
||||||
|
docContent: Record<string, Record<string, DocContent>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_REGISTRY: ContentRegistry = {
|
||||||
|
pathIndex: {},
|
||||||
|
docContent: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Id / hash derivation (single source of truth)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Browser-safe content hash — stable across CLI and frontend. */
|
||||||
|
export function contentHash(body: string): string {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < body.length; i++) {
|
||||||
|
const ch = body.charCodeAt(i);
|
||||||
|
hash = ((hash << 5) - hash + ch) | 0;
|
||||||
|
}
|
||||||
|
return Math.abs(hash).toString(16).slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a stable content id.
|
||||||
|
* Author-supplied `id` wins; otherwise `{kind}_{hash}`.
|
||||||
|
*/
|
||||||
|
export function deriveContentId(
|
||||||
|
kind: ContentKind,
|
||||||
|
body: string,
|
||||||
|
id?: string,
|
||||||
|
): string {
|
||||||
|
if (id) return id;
|
||||||
|
return `${kind}_${contentHash(body)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Per-doc scanning
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface DocScanResult {
|
||||||
|
/** Content with blocks processed (stripped or replaced with directives). */
|
||||||
|
stripped: string;
|
||||||
|
/** Inline content defined in this doc, keyed by id. */
|
||||||
|
content: Record<string, DocContent>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a single markdown doc:
|
||||||
|
* - strips/replaces attributed fenced code blocks based on `as`
|
||||||
|
* - coerces spark-shaped markdown tables to `:md-table` directives
|
||||||
|
* - collects inline content (role=file, md-* bodies, spark tables, declare)
|
||||||
|
* into the doc's content store
|
||||||
|
*
|
||||||
|
* Does NOT touch the path index — the caller assembles the registry.
|
||||||
|
*/
|
||||||
|
export function scanDoc(content: string, docPath: string): DocScanResult {
|
||||||
|
const contentStore: Record<string, DocContent> = {};
|
||||||
|
|
||||||
|
// ---- Pass 1: attributed fenced code blocks ----
|
||||||
|
const stripped = content.replace(
|
||||||
|
FENCED_BLOCK_RE,
|
||||||
|
(
|
||||||
|
_match: string,
|
||||||
|
lang: string,
|
||||||
|
infoString: string,
|
||||||
|
body: string,
|
||||||
|
): string => {
|
||||||
|
const attrs = parseBlockAttrs(infoString);
|
||||||
|
attrs.lang = attrs.lang || lang;
|
||||||
|
const effectiveAs = resolveBlockAs(attrs.role, attrs.as);
|
||||||
|
|
||||||
|
if (attrs.role === "declare") {
|
||||||
|
const id = deriveContentId("declare", body, attrs.id);
|
||||||
|
contentStore[id] = {
|
||||||
|
id,
|
||||||
|
kind: "declare",
|
||||||
|
body,
|
||||||
|
role: attrs.role,
|
||||||
|
as: effectiveAs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attrs.role === "file") {
|
||||||
|
const id = deriveContentId("text", body, attrs.id);
|
||||||
|
contentStore[id] = {
|
||||||
|
id,
|
||||||
|
kind: "text",
|
||||||
|
body,
|
||||||
|
role: attrs.role,
|
||||||
|
as: effectiveAs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effectiveAs === "codeblock") {
|
||||||
|
return _match;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effectiveAs === "none") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effectiveAs.startsWith("md-")) {
|
||||||
|
const id = deriveContentId("csv", body, attrs.id);
|
||||||
|
contentStore[id] = {
|
||||||
|
id,
|
||||||
|
kind: "csv",
|
||||||
|
body,
|
||||||
|
role: attrs.role,
|
||||||
|
as: effectiveAs,
|
||||||
|
};
|
||||||
|
|
||||||
|
const extra = { ...attrs.extra };
|
||||||
|
const extraStr = Object.keys(extra).length
|
||||||
|
? `{${Object.entries(extra)
|
||||||
|
.map(([k, v]) => `${k}=${v}`)
|
||||||
|
.join(" ")}}`
|
||||||
|
: "";
|
||||||
|
return `:${effectiveAs}[./${id}]${extraStr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- Pass 2: coerce spark-shaped markdown tables to :md-table ----
|
||||||
|
const rewritten = coerceSparkTables(stripped, contentStore);
|
||||||
|
|
||||||
|
return { stripped: rewritten, content: contentStore };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Spark table coercion
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const DICE_HEADER_RE = /^\d*d\d+$/i;
|
||||||
|
|
||||||
|
/** Split a markdown table row into cells. */
|
||||||
|
function splitTableRow(row: string): string[] {
|
||||||
|
return row
|
||||||
|
.replace(/^\|/, "")
|
||||||
|
.replace(/\|$/, "")
|
||||||
|
.split("|")
|
||||||
|
.map((c) => c.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Escape a cell value for CSV output. */
|
||||||
|
function escapeCsvCell(cell: string): string {
|
||||||
|
if (
|
||||||
|
cell.includes(",") ||
|
||||||
|
cell.includes("\n") ||
|
||||||
|
cell.includes('"') ||
|
||||||
|
cell.includes("#")
|
||||||
|
) {
|
||||||
|
return `"${cell.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return cell;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert a markdown table (header + separator + rows) to a CSV string. */
|
||||||
|
function markdownTableToCsv(
|
||||||
|
headerRow: string,
|
||||||
|
separatorRow: string,
|
||||||
|
bodyRows: string[],
|
||||||
|
): string | null {
|
||||||
|
const headers = splitTableRow(headerRow);
|
||||||
|
if (headers.length === 0) return null;
|
||||||
|
|
||||||
|
const sepCells = splitTableRow(separatorRow);
|
||||||
|
if (!sepCells.every((c) => /^:?-{3,}:?$/.test(c))) return null;
|
||||||
|
if (sepCells.length !== headers.length) return null;
|
||||||
|
|
||||||
|
const csvHeader = headers.map(escapeCsvCell).join(",");
|
||||||
|
const csvRows = bodyRows.map((row) => {
|
||||||
|
const cells = splitTableRow(row);
|
||||||
|
while (cells.length < headers.length) cells.push("");
|
||||||
|
return cells.slice(0, headers.length).map(escapeCsvCell).join(",");
|
||||||
|
});
|
||||||
|
|
||||||
|
return [csvHeader, ...csvRows].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coerce spark-shaped markdown tables (first column header is a dice formula)
|
||||||
|
* into `:md-table` directives, storing the CSV in the doc's content store and
|
||||||
|
* injecting `data-spark` for the reveal feature.
|
||||||
|
*/
|
||||||
|
function coerceSparkTables(
|
||||||
|
content: string,
|
||||||
|
contentStore: Record<string, DocContent>,
|
||||||
|
): string {
|
||||||
|
const mdTableRegex = /^(\|.+\|)\n(\|[-: |]+\|)\n((?:\|.+\|\n?)+)/gm;
|
||||||
|
|
||||||
|
interface TableMatch {
|
||||||
|
fullMatch: string;
|
||||||
|
headerRow: string;
|
||||||
|
separatorRow: string;
|
||||||
|
bodyRowsText: string;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
const tableMatches: TableMatch[] = [];
|
||||||
|
|
||||||
|
let mdMatch: RegExpExecArray | null;
|
||||||
|
while ((mdMatch = mdTableRegex.exec(content)) !== null) {
|
||||||
|
const [, headerRow, separatorRow, bodyRowsText] = mdMatch;
|
||||||
|
const headers = splitTableRow(headerRow);
|
||||||
|
if (!DICE_HEADER_RE.test(headers[0])) continue;
|
||||||
|
|
||||||
|
const bodyRows = bodyRowsText
|
||||||
|
.trim()
|
||||||
|
.split(/\n/)
|
||||||
|
.filter((r) => r.trim().startsWith("|"));
|
||||||
|
const csv = markdownTableToCsv(headerRow, separatorRow, bodyRows);
|
||||||
|
if (!csv) continue;
|
||||||
|
|
||||||
|
tableMatches.push({
|
||||||
|
fullMatch: mdMatch[0],
|
||||||
|
headerRow,
|
||||||
|
separatorRow,
|
||||||
|
bodyRowsText,
|
||||||
|
index: mdMatch.index,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let rewritten = content;
|
||||||
|
for (let i = tableMatches.length - 1; i >= 0; i--) {
|
||||||
|
const m = tableMatches[i];
|
||||||
|
const bodyRows = m.bodyRowsText
|
||||||
|
.trim()
|
||||||
|
.split(/\n/)
|
||||||
|
.filter((r) => r.trim().startsWith("|"));
|
||||||
|
const csv = markdownTableToCsv(m.headerRow, m.separatorRow, bodyRows)!;
|
||||||
|
|
||||||
|
const id = deriveContentId("csv", csv);
|
||||||
|
contentStore[id] = {
|
||||||
|
id,
|
||||||
|
kind: "csv",
|
||||||
|
body: csv,
|
||||||
|
role: "spark-table",
|
||||||
|
as: "md-table",
|
||||||
|
};
|
||||||
|
|
||||||
|
const slug = sparkSlug(csv);
|
||||||
|
const directive = `:md-table[./${id}]{data-spark="${slug}"}`;
|
||||||
|
rewritten =
|
||||||
|
rewritten.slice(0, m.index) +
|
||||||
|
directive +
|
||||||
|
rewritten.slice(m.index + m.fullMatch.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rewritten;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Directive spark injection (real-file `:md-table` / `:md-card` references)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan a doc's stripped content for `:md-table[...]` / `:md-card[...]`
|
||||||
|
* directives that resolve to a spark table, and inject `data-spark` so the
|
||||||
|
* reveal feature can match them. Idempotent — only injects when missing.
|
||||||
|
*
|
||||||
|
* Returns the possibly-rewritten content.
|
||||||
|
*/
|
||||||
|
export function injectSparkDirectives(
|
||||||
|
content: string,
|
||||||
|
docPath: string,
|
||||||
|
registry: ContentRegistry,
|
||||||
|
): string {
|
||||||
|
const tableDirectiveRegex = /:md-(table|card)\[([^[\]]+)\](?:\{([^}]*)\})?/gi;
|
||||||
|
let rewritten = content;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((m = tableDirectiveRegex.exec(content)) !== null) {
|
||||||
|
const [, , ref, extraStr] = m;
|
||||||
|
if (extraStr && extraStr.includes("data-spark=")) continue;
|
||||||
|
|
||||||
|
const csv = resolveContent(registry, docPath, ref);
|
||||||
|
if (!csv) continue;
|
||||||
|
|
||||||
|
const slug = sparkSlug(csv);
|
||||||
|
if (!slug) continue;
|
||||||
|
|
||||||
|
const fullMatch = m[0];
|
||||||
|
const insertPos = fullMatch.indexOf("]") + 1;
|
||||||
|
const before = fullMatch.slice(0, insertPos);
|
||||||
|
const after = fullMatch.slice(insertPos);
|
||||||
|
|
||||||
|
let replacement: string;
|
||||||
|
if (after.startsWith("{")) {
|
||||||
|
replacement = before + after.replace(/^\{/, `{data-spark="${slug}" `);
|
||||||
|
} else {
|
||||||
|
replacement = before + `{data-spark="${slug}"}` + after;
|
||||||
|
}
|
||||||
|
|
||||||
|
rewritten =
|
||||||
|
rewritten.slice(0, m.index) +
|
||||||
|
replacement +
|
||||||
|
rewritten.slice(m.index + fullMatch.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rewritten;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Resolution
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a content reference from within a doc.
|
||||||
|
*
|
||||||
|
* - `ref` is inline CSV → returned as-is.
|
||||||
|
* - `ref` is an absolute path → path index.
|
||||||
|
* - `ref` is a relative path → resolved against the doc directory; checks
|
||||||
|
* the path index first, then the doc's inline content store.
|
||||||
|
*
|
||||||
|
* Returns `null` when nothing matches.
|
||||||
|
*/
|
||||||
|
export function resolveContent(
|
||||||
|
registry: ContentRegistry,
|
||||||
|
docPath: string,
|
||||||
|
ref: string,
|
||||||
|
): string | null {
|
||||||
|
const trimmed = ref.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
// Inline CSV body.
|
||||||
|
if (looksLikeCsv(trimmed)) return trimmed;
|
||||||
|
|
||||||
|
if (trimmed.startsWith("/")) {
|
||||||
|
return registry.pathIndex[trimmed] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline content in the same doc (e.g. ./{id}).
|
||||||
|
const docStore = registry.docContent[docPath];
|
||||||
|
if (docStore) {
|
||||||
|
const id = trimmed.startsWith("./") ? trimmed.slice(2) : trimmed;
|
||||||
|
const entry = docStore[id];
|
||||||
|
if (entry) return entry.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relative path resolved against the doc directory.
|
||||||
|
const resolved = posixJoin(posixDir(docPath), trimmed);
|
||||||
|
return registry.pathIndex[resolved] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a *resolved* path (e.g. `/content/csv_abc123`) to inline content
|
||||||
|
* by searching every doc's content store for a matching id. Used by the
|
||||||
|
* frontend when a directive ref has already been resolved to a path.
|
||||||
|
*
|
||||||
|
* Returns `null` when no inline content matches.
|
||||||
|
*/
|
||||||
|
export function resolveInlineByPath(
|
||||||
|
registry: ContentRegistry,
|
||||||
|
resolvedPath: string,
|
||||||
|
): string | null {
|
||||||
|
const id = resolvedPath.split("/").filter(Boolean).pop() || "";
|
||||||
|
if (!id) return null;
|
||||||
|
for (const store of Object.values(registry.docContent)) {
|
||||||
|
const entry = store[id];
|
||||||
|
if (entry) return entry.body;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Naive CSV sniff — matches the frontend `isCSV` heuristic. */
|
||||||
|
function looksLikeCsv(str: string): boolean {
|
||||||
|
const trimmed = str.trim();
|
||||||
|
if (trimmed.startsWith("---\n") || trimmed.startsWith("---\r\n")) return true;
|
||||||
|
|
||||||
|
const lines = trimmed.split(/\r?\n/).filter((line) => line.trim() !== "");
|
||||||
|
if (lines.length < 2) return false;
|
||||||
|
|
||||||
|
const separators = [",", "\t", ";", "|"];
|
||||||
|
const firstLine = lines[0];
|
||||||
|
for (const sep of separators) {
|
||||||
|
if (firstLine.includes(sep)) {
|
||||||
|
const hasInOthers = lines.slice(1).some((line) => line.includes(sep));
|
||||||
|
if (hasInOthers) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Derived completions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the completions payload from the registry.
|
||||||
|
* Pure function — recompute on any file change.
|
||||||
|
*
|
||||||
|
* Dice + spark completions come from scanning each doc's stripped content
|
||||||
|
* for directives (resolving CSV refs through the registry), so both inline
|
||||||
|
* and real-file spark tables are covered. Declarations come from the doc
|
||||||
|
* content store.
|
||||||
|
*/
|
||||||
|
export function deriveCompletions(
|
||||||
|
registry: ContentRegistry,
|
||||||
|
): CompletionsPayload {
|
||||||
|
const links = deriveLinks(registry.pathIndex);
|
||||||
|
const dice: DiceCompletion[] = [];
|
||||||
|
const sparkTables: SparkTableCompletion[] = [];
|
||||||
|
const declarations: VarDeclaration[] = [];
|
||||||
|
const tagModifiers: TagModifier[] = [];
|
||||||
|
|
||||||
|
for (const [docPath, content] of Object.entries(registry.pathIndex)) {
|
||||||
|
if (!docPath.endsWith(".md")) continue;
|
||||||
|
const found = scanDocDirectives(content, docPath, registry);
|
||||||
|
dice.push(...found.dice);
|
||||||
|
sparkTables.push(...found.sparkTables);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocks = deriveBlocks(registry);
|
||||||
|
declarations.push(...blocks.declarations);
|
||||||
|
tagModifiers.push(...blocks.tagModifiers);
|
||||||
|
|
||||||
|
return { dice, links, sparkTables, declarations, tagModifiers };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan a doc's stripped content for `:md-dice` and `:md-table`/`:md-card`
|
||||||
|
* directives, resolving CSV refs through the registry.
|
||||||
|
*/
|
||||||
|
function scanDocDirectives(
|
||||||
|
content: string,
|
||||||
|
docPath: string,
|
||||||
|
registry: ContentRegistry,
|
||||||
|
): { dice: DiceCompletion[]; sparkTables: SparkTableCompletion[] } {
|
||||||
|
const dice = scanDice(content, docPath);
|
||||||
|
const sparkTables: SparkTableCompletion[] = [];
|
||||||
|
|
||||||
|
const tableDirectiveRegex = /:md-(table|card)\[([^[\]]+)\](?:\{([^}]*)\})?/gi;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = tableDirectiveRegex.exec(content)) !== null) {
|
||||||
|
const [, , ref, extraStr] = m;
|
||||||
|
const csv = resolveContent(registry, docPath, ref);
|
||||||
|
if (!csv) continue;
|
||||||
|
|
||||||
|
// csvPath: content id for inline content, resolved path for real files.
|
||||||
|
const docStore = registry.docContent[docPath];
|
||||||
|
const id = ref.startsWith("./") ? ref.slice(2) : ref;
|
||||||
|
const csvPath =
|
||||||
|
docStore && docStore[id]
|
||||||
|
? id
|
||||||
|
: resolveContentPath(registry, docPath, ref);
|
||||||
|
|
||||||
|
const attrs = parseDirectiveAttrs(extraStr);
|
||||||
|
const st = buildSparkTableCompletion(
|
||||||
|
csv,
|
||||||
|
docPath,
|
||||||
|
csvPath,
|
||||||
|
attrs["remix"] === "true",
|
||||||
|
);
|
||||||
|
if (st) sparkTables.push(st);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { dice, sparkTables };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a directive ref to a path-index key (for real files). */
|
||||||
|
function resolveContentPath(
|
||||||
|
registry: ContentRegistry,
|
||||||
|
docPath: string,
|
||||||
|
ref: string,
|
||||||
|
): string {
|
||||||
|
const trimmed = ref.trim();
|
||||||
|
if (trimmed.startsWith("/")) return trimmed;
|
||||||
|
return posixJoin(posixDir(docPath), trimmed.replace(/^\.\//, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse key=value pairs from a directive extra-attrs string. */
|
||||||
|
function parseDirectiveAttrs(
|
||||||
|
extraStr: string | undefined,
|
||||||
|
): Record<string, string> {
|
||||||
|
if (!extraStr) return {};
|
||||||
|
const attrs: Record<string, string> = {};
|
||||||
|
const re = /(\w+)\s*=\s*("[^"]*"|\S+)/g;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = re.exec(extraStr)) !== null) {
|
||||||
|
attrs[m[1]] = m[2].replace(/^"|"$/g, "");
|
||||||
|
}
|
||||||
|
return attrs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Derive variable declarations + tag modifiers from the registry. */
|
||||||
|
export function deriveBlocks(registry: ContentRegistry): {
|
||||||
|
declarations: VarDeclaration[];
|
||||||
|
tagModifiers: TagModifier[];
|
||||||
|
} {
|
||||||
|
const declarations: VarDeclaration[] = [];
|
||||||
|
const tagModifiers: TagModifier[] = [];
|
||||||
|
|
||||||
|
for (const [docPath, store] of Object.entries(registry.docContent)) {
|
||||||
|
for (const entry of Object.values(store)) {
|
||||||
|
if (entry.kind !== "declare") continue;
|
||||||
|
try {
|
||||||
|
const result = parseDeclareCsv(entry.body, docPath);
|
||||||
|
declarations.push(...result.variables);
|
||||||
|
tagModifiers.push(...result.tagModifiers);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[content-registry] ${docPath}: ${e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { declarations, tagModifiers };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Derivation helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Extract headings from all `.md` files as link completions. */
|
||||||
|
function deriveLinks(pathIndex: Record<string, string>): LinkCompletion[] {
|
||||||
|
const items: LinkCompletion[] = [];
|
||||||
|
for (const [filePath, content] of Object.entries(pathIndex)) {
|
||||||
|
if (!filePath.endsWith(".md")) continue;
|
||||||
|
|
||||||
|
const basePath = filePath.replace(/\.md$/, "");
|
||||||
|
const fileName = fileNameFromPath(basePath);
|
||||||
|
const slugger = new Slugger();
|
||||||
|
|
||||||
|
items.push({ path: basePath, label: fileName, section: null });
|
||||||
|
|
||||||
|
const headingRegex = /^(#{1,6})\s+(.+)$/gm;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = headingRegex.exec(content)) !== null) {
|
||||||
|
const title = match[2].trim();
|
||||||
|
const id = slugger.slug(title.toLowerCase());
|
||||||
|
items.push({
|
||||||
|
path: basePath,
|
||||||
|
label: `${fileName} § ${title}`,
|
||||||
|
section: id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DICE_DIRECTIVE_RE = /:md-dice\[([^[\]]+)\]/gi;
|
||||||
|
|
||||||
|
function looksLikeDice(raw: string): boolean {
|
||||||
|
if (raw.length > 80) return false;
|
||||||
|
return /^\d*d\d+/i.test(raw) || /^[+-]/.test(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scan a text body for `:md-dice[...]` directives. */
|
||||||
|
function scanDice(body: string, source: string): DiceCompletion[] {
|
||||||
|
const dice: DiceCompletion[] = [];
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = DICE_DIRECTIVE_RE.exec(body)) !== null) {
|
||||||
|
const raw = m[1].trim();
|
||||||
|
if (!raw || !looksLikeDice(raw)) continue;
|
||||||
|
dice.push({ label: raw, notation: raw, source });
|
||||||
|
}
|
||||||
|
return dice;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inspect a CSV body and return its data-column headers if it's a spark
|
||||||
|
* table (first column header is a dice formula), or null otherwise.
|
||||||
|
*/
|
||||||
|
export function inspectSparkTableCsv(csv: string): string[] | null {
|
||||||
|
const lines = csv.trim().split(/\r?\n/);
|
||||||
|
if (lines.length < 2) return null;
|
||||||
|
|
||||||
|
const headers = lines[0].split(",").map((h) => h.trim());
|
||||||
|
if (headers.length < 2) return null;
|
||||||
|
if (!DICE_HEADER_RE.test(headers[0])) return null;
|
||||||
|
|
||||||
|
return headers.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compute the spark slug for a CSV body, or null if it's not a spark table. */
|
||||||
|
function sparkSlug(csv: string): string | null {
|
||||||
|
const dataHeaders = inspectSparkTableCsv(csv);
|
||||||
|
if (!dataHeaders) return null;
|
||||||
|
const slugger = new Slugger();
|
||||||
|
return dataHeaders.map((h) => slugger.slug(h.toLowerCase())).join("-");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a SparkTableCompletion from a CSV body. */
|
||||||
|
export function buildSparkTableCompletion(
|
||||||
|
csv: string,
|
||||||
|
docPath: string,
|
||||||
|
contentId: string,
|
||||||
|
remix: boolean,
|
||||||
|
): SparkTableCompletion | null {
|
||||||
|
const dataHeaders = inspectSparkTableCsv(csv);
|
||||||
|
if (!dataHeaders) return null;
|
||||||
|
|
||||||
|
const notation = csv.trim().split(/\r?\n/)[0].split(",")[0].trim();
|
||||||
|
const slugger = new Slugger();
|
||||||
|
const slug = dataHeaders.map((h) => slugger.slug(h.toLowerCase())).join("-");
|
||||||
|
|
||||||
|
const basePath = docPath.replace(/\.md$/, "");
|
||||||
|
const fileName = basePath.split("/").filter(Boolean).pop() || basePath;
|
||||||
|
const combinedSlug = `${fileName}-${slug}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: `${fileName} § ${slug}`,
|
||||||
|
notation,
|
||||||
|
slug: combinedSlug,
|
||||||
|
filePath: basePath,
|
||||||
|
docPath,
|
||||||
|
csvPath: contentId,
|
||||||
|
headers: dataHeaders,
|
||||||
|
remix,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Path helpers (browser-safe posix)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function posixDir(path: string): string {
|
||||||
|
const idx = path.lastIndexOf("/");
|
||||||
|
return idx >= 0 ? path.slice(0, idx) : ".";
|
||||||
|
}
|
||||||
|
|
||||||
|
function posixJoin(dir: string, rel: string): string {
|
||||||
|
if (dir === ".") return rel.startsWith("/") ? rel : `/${rel}`;
|
||||||
|
const base = dir.replace(/\/+$/, "");
|
||||||
|
const r = rel.replace(/^\/+/, "");
|
||||||
|
return `${base}/${r}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileNameFromPath(path: string): string {
|
||||||
|
const parts = path.split("/").filter(Boolean);
|
||||||
|
return parts[parts.length - 1] || path;
|
||||||
|
}
|
||||||
@@ -16,7 +16,8 @@ import type { VarDeclaration, TagModifier } from "./declare-parser";
|
|||||||
|
|
||||||
// Tagmap pattern: "#warrior:1;#druid:2". Also accepts bare "#tag" as shorthand.
|
// Tagmap pattern: "#warrior:1;#druid:2". Also accepts bare "#tag" as shorthand.
|
||||||
const BARE_TAG_PATTERN = /^#[a-zA-Z_][a-zA-Z0-9_]*$/;
|
const BARE_TAG_PATTERN = /^#[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||||
const TAGMAP_PATTERN = /^#[a-zA-Z_][a-zA-Z0-9_]*:\d+(?:;#[a-zA-Z_][a-zA-Z0-9_]*:\d+)*$/;
|
const TAGMAP_PATTERN =
|
||||||
|
/^#[a-zA-Z_][a-zA-Z0-9_]*:\d+(?:;#[a-zA-Z_][a-zA-Z0-9_]*:\d+)*$/;
|
||||||
|
|
||||||
function isTagMapExpr(expr: string): boolean {
|
function isTagMapExpr(expr: string): boolean {
|
||||||
const t = expr.trim();
|
const t = expr.trim();
|
||||||
@@ -33,17 +34,16 @@ function normalizeTagMap(expr: string): string {
|
|||||||
// Result
|
// Result
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export type DispatchResult =
|
export type DispatchResult = { ok: true } | { ok: false; error: string };
|
||||||
| { ok: true }
|
|
||||||
| { ok: false; error: string };
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared signal for dispatch errors from any source (typed or cmd-link clicks).
|
* Shared signal for dispatch errors from any source (typed or cmd-link clicks).
|
||||||
* Components that show errors (JournalInput) read from here; callers that
|
* Components that show errors (JournalInput) read from here; callers that
|
||||||
* want errors surfaced (CommandLinkManager) write to it.
|
* want errors surfaced (CommandLinkManager) write to it.
|
||||||
*/
|
*/
|
||||||
export const [dispatchError, setDispatchError] =
|
export const [dispatchError, setDispatchError] = createSignal<string | null>(
|
||||||
createSignal<string | null>(null);
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Main dispatch
|
// Main dispatch
|
||||||
@@ -57,7 +57,12 @@ export interface DispatchContext {
|
|||||||
/** The raw text to dispatch (with or without leading `/`) */
|
/** The raw text to dispatch (with or without leading `/`) */
|
||||||
command: string;
|
command: string;
|
||||||
/** Spark table lookup data (from completions) */
|
/** Spark table lookup data (from completions) */
|
||||||
sparkTables: { slug: string; csvPath?: string; remix?: boolean }[];
|
sparkTables: {
|
||||||
|
slug: string;
|
||||||
|
csvPath?: string;
|
||||||
|
docPath?: string;
|
||||||
|
remix?: boolean;
|
||||||
|
}[];
|
||||||
/** Current runtime variable values */
|
/** Current runtime variable values */
|
||||||
variables: Record<string, string>;
|
variables: Record<string, string>;
|
||||||
/** Variable declarations (from role=declare blocks) */
|
/** Variable declarations (from role=declare blocks) */
|
||||||
@@ -94,7 +99,9 @@ export async function dispatchCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.type === "set" || parsed.type === "rolltag") {
|
if (parsed.type === "set" || parsed.type === "rolltag") {
|
||||||
return finish(dispatchSet(parsed.payload as Record<string, unknown>, ctx));
|
return finish(
|
||||||
|
dispatchSet(parsed.payload as Record<string, unknown>, ctx),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return finish({ ok: false, error: "玩家只能发送聊天消息或使用 /set 命令" });
|
return finish({ ok: false, error: "玩家只能发送聊天消息或使用 /set 命令" });
|
||||||
@@ -109,8 +116,14 @@ export async function dispatchCommand(
|
|||||||
if (match) {
|
if (match) {
|
||||||
try {
|
try {
|
||||||
const csvPath = match.csvPath ?? "";
|
const csvPath = match.csvPath ?? "";
|
||||||
|
const docPath = match.docPath;
|
||||||
const remix = match.remix ?? false;
|
const remix = match.remix ?? false;
|
||||||
const p = await resolveSparkPayload({ key: arg, csvPath, remix });
|
const p = await resolveSparkPayload({
|
||||||
|
key: arg,
|
||||||
|
csvPath,
|
||||||
|
docPath,
|
||||||
|
remix,
|
||||||
|
});
|
||||||
const result = sendMessage("spark", p);
|
const result = sendMessage("spark", p);
|
||||||
return finish(unwrap(result));
|
return finish(unwrap(result));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -226,7 +239,11 @@ function dispatchSet(
|
|||||||
try {
|
try {
|
||||||
const cascade = computeCascade(key, oldValue, workingVars);
|
const cascade = computeCascade(key, oldValue, workingVars);
|
||||||
for (const change of cascade) {
|
for (const change of cascade) {
|
||||||
sendMessage("var", { action: "set", key: change.key, value: change.value });
|
sendMessage("var", {
|
||||||
|
action: "set",
|
||||||
|
key: change.key,
|
||||||
|
value: change.value,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Cascade errors are non-fatal — the direct set already succeeded
|
// Cascade errors are non-fatal — the direct set already succeeded
|
||||||
@@ -253,4 +270,4 @@ function unwrap<R>(
|
|||||||
r: { success: true; msg: R } | { success: false; error: string },
|
r: { success: true; msg: R } | { success: false; error: string },
|
||||||
): DispatchResult {
|
): DispatchResult {
|
||||||
return r.success ? { ok: true } : { ok: false, error: r.error };
|
return r.success ? { ok: true } : { ok: false, error: r.error };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,15 +10,17 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal } from "solid-js";
|
import { createSignal } from "solid-js";
|
||||||
import { extractHeadings } from "../../data-loader/toc";
|
|
||||||
import {
|
import {
|
||||||
getPathsByExtension,
|
getPathsByExtension,
|
||||||
getIndexedData,
|
getIndexedData,
|
||||||
|
setInlineResolver,
|
||||||
} from "../../data-loader/file-index";
|
} from "../../data-loader/file-index";
|
||||||
import {
|
import {
|
||||||
scanDirectives,
|
scanDoc,
|
||||||
} from "../../cli/completions/directive-scanner";
|
deriveCompletions,
|
||||||
import { scanDeclareBlocks } from "../../cli/completions/declare-parser";
|
resolveInlineByPath,
|
||||||
|
type ContentRegistry,
|
||||||
|
} from "../../cli/content-registry";
|
||||||
import type {
|
import type {
|
||||||
CompletionsPayload,
|
CompletionsPayload,
|
||||||
DiceCompletion,
|
DiceCompletion,
|
||||||
@@ -50,6 +52,23 @@ const [completionsState, setCompletionsState] = createSignal<CompletionsState>({
|
|||||||
status: "loading",
|
status: "loading",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The registry backing the completions. Populated in both CLI and client
|
||||||
|
// modes so inline content ids can be resolved at runtime (e.g. spark rolls).
|
||||||
|
let activeRegistry: ContentRegistry = { pathIndex: {}, docContent: {} };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The registry backing the current completions.
|
||||||
|
* In CLI mode this is fetched from the server; in browser mode it is built
|
||||||
|
* client-side. Used to resolve inline content ids (e.g. spark table CSVs).
|
||||||
|
*/
|
||||||
|
export function getRegistry(): ContentRegistry {
|
||||||
|
return activeRegistry;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the inline-content resolver so `getIndexedData` can resolve
|
||||||
|
// directive refs (e.g. `./csv_abc123`) that aren't real files.
|
||||||
|
setInlineResolver((path) => resolveInlineByPath(activeRegistry, path));
|
||||||
|
|
||||||
// ------------------- Fetch (CLI mode) -------------------
|
// ------------------- Fetch (CLI mode) -------------------
|
||||||
|
|
||||||
async function tryServer(): Promise<JournalCompletions | null> {
|
async function tryServer(): Promise<JournalCompletions | null> {
|
||||||
@@ -61,66 +80,49 @@ async function tryServer(): Promise<JournalCompletions | null> {
|
|||||||
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 : [],
|
sparkTables: Array.isArray(data.sparkTables) ? data.sparkTables : [],
|
||||||
declarations: Array.isArray(data.declarations)
|
declarations: Array.isArray(data.declarations) ? data.declarations : [],
|
||||||
? data.declarations
|
tagModifiers: Array.isArray(data.tagModifiers) ? data.tagModifiers : [],
|
||||||
: [],
|
|
||||||
tagModifiers: Array.isArray(data.tagModifiers)
|
|
||||||
? data.tagModifiers
|
|
||||||
: [],
|
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Load the content registry from the server (CLI mode). */
|
||||||
|
async function tryServerRegistry(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/__CONTENT_REGISTRY.json");
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const data = await resp.json();
|
||||||
|
activeRegistry = {
|
||||||
|
pathIndex: data.pathIndex ?? {},
|
||||||
|
docContent: data.docContent ?? {},
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// Registry unavailable — leave empty; client scan will populate it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------- Client-side fallback scan -------------------
|
// ------------------- Client-side fallback scan -------------------
|
||||||
|
|
||||||
async function scanClientSide(): Promise<JournalCompletions> {
|
async function scanClientSide(): Promise<JournalCompletions> {
|
||||||
const paths = await getPathsByExtension("md");
|
const paths = await getPathsByExtension("md");
|
||||||
const dice: DiceCompletion[] = [];
|
|
||||||
const links: LinkCompletion[] = [];
|
|
||||||
const sparkTables: SparkTableCompletion[] = [];
|
|
||||||
const declarations: VarDeclaration[] = [];
|
|
||||||
const tagModifiers: TagModifier[] = [];
|
|
||||||
|
|
||||||
// Build a temporary index for resolving CSV paths
|
// Build a registry from the in-memory file index, then derive completions
|
||||||
const tempIndex: Record<string, string> = {};
|
// through the same shared pipeline as the CLI.
|
||||||
|
const registry: ContentRegistry = { pathIndex: {}, docContent: {} };
|
||||||
|
|
||||||
// First pass: load all .md content into temp index
|
// First pass: load all .md content into the registry.
|
||||||
for (const filePath of paths) {
|
for (const filePath of paths) {
|
||||||
const content = await getIndexedData(filePath);
|
const content = await getIndexedData(filePath);
|
||||||
if (content) tempIndex[filePath] = content;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const filePath of paths) {
|
|
||||||
const content = tempIndex[filePath];
|
|
||||||
if (!content) continue;
|
if (!content) continue;
|
||||||
|
const result = scanDoc(content, filePath);
|
||||||
// ---- Links (headings) - from original content ----
|
registry.pathIndex[filePath] = result.stripped;
|
||||||
const basePath = filePath.replace(/\.md$/, "");
|
registry.docContent[filePath] = result.content;
|
||||||
const fileName = basePath.split("/").filter(Boolean).pop() || basePath;
|
|
||||||
links.push({ path: basePath, label: fileName, section: null });
|
|
||||||
for (const heading of extractHeadings(content)) {
|
|
||||||
links.push({
|
|
||||||
path: basePath,
|
|
||||||
label: `${fileName} § ${heading.title}`,
|
|
||||||
section: heading.id ?? null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Declare block scanning (shared with CLI) ----
|
|
||||||
const declareResult = scanDeclareBlocks(content, filePath);
|
|
||||||
declarations.push(...declareResult.variables);
|
|
||||||
tagModifiers.push(...declareResult.tagModifiers);
|
|
||||||
|
|
||||||
// ---- Directive scanning (dice + spark tables) ----
|
|
||||||
const fileDir = filePath.split("/").slice(0, -1).join("/") || ".";
|
|
||||||
const directiveResult = scanDirectives(content, filePath, tempIndex, fileDir);
|
|
||||||
dice.push(...directiveResult.dice);
|
|
||||||
sparkTables.push(...directiveResult.sparkTables);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { dice, links, sparkTables, declarations, tagModifiers };
|
activeRegistry = registry;
|
||||||
|
return deriveCompletions(registry);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------- Init (runs eagerly at import time) -------------------
|
// ------------------- Init (runs eagerly at import time) -------------------
|
||||||
@@ -131,10 +133,16 @@ const _initPromise: Promise<void> = (async () => {
|
|||||||
const serverData = await tryServer();
|
const serverData = await tryServer();
|
||||||
if (serverData) {
|
if (serverData) {
|
||||||
setCompletionsState({ status: "loaded", data: serverData });
|
setCompletionsState({ status: "loaded", data: serverData });
|
||||||
|
await tryServerRegistry();
|
||||||
try {
|
try {
|
||||||
initReactivity({ declarations: serverData.declarations, tagModifiers: serverData.tagModifiers });
|
initReactivity({
|
||||||
|
declarations: serverData.declarations,
|
||||||
|
tagModifiers: serverData.tagModifiers,
|
||||||
|
});
|
||||||
seedDeclaredVariables();
|
seedDeclaredVariables();
|
||||||
} catch (e) { console.warn("[completions] reactivity init error:", e); }
|
} catch (e) {
|
||||||
|
console.warn("[completions] reactivity init error:", e);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,9 +152,14 @@ const _initPromise: Promise<void> = (async () => {
|
|||||||
if (data.dice.length > 0 || data.links.length > 0) {
|
if (data.dice.length > 0 || data.links.length > 0) {
|
||||||
setCompletionsState({ status: "loaded", data });
|
setCompletionsState({ status: "loaded", data });
|
||||||
try {
|
try {
|
||||||
initReactivity({ declarations: data.declarations, tagModifiers: data.tagModifiers });
|
initReactivity({
|
||||||
|
declarations: data.declarations,
|
||||||
|
tagModifiers: data.tagModifiers,
|
||||||
|
});
|
||||||
seedDeclaredVariables();
|
seedDeclaredVariables();
|
||||||
} catch (e) { console.warn("[completions] reactivity init error:", e); }
|
} catch (e) {
|
||||||
|
console.warn("[completions] reactivity init error:", e);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setCompletionsState({ status: "empty" });
|
setCompletionsState({ status: "empty" });
|
||||||
}
|
}
|
||||||
@@ -201,4 +214,4 @@ function seedDeclaredVariables(): void {
|
|||||||
for (const { key, value } of initial) {
|
for (const { key, value } of initial) {
|
||||||
sendMessage("var", { action: "set", key, value });
|
sendMessage("var", { action: "set", key, value });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,11 +16,9 @@ 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 {
|
import { parseSparkTableCsv, rollSparkTable } from "../../utils/spark-table";
|
||||||
parseSparkTableCsv,
|
|
||||||
rollSparkTable,
|
|
||||||
} from "../../utils/spark-table";
|
|
||||||
import { getIndexedData } from "../../../data-loader/file-index";
|
import { getIndexedData } from "../../../data-loader/file-index";
|
||||||
|
import { getRegistry } from "../../journal/completions";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Schema
|
// Schema
|
||||||
@@ -75,12 +73,27 @@ export type SparkPayload = z.infer<typeof schema>;
|
|||||||
export async function resolveSparkPayload(raw: {
|
export async function resolveSparkPayload(raw: {
|
||||||
key: string;
|
key: string;
|
||||||
csvPath: string;
|
csvPath: string;
|
||||||
|
docPath?: string;
|
||||||
remix: boolean;
|
remix: boolean;
|
||||||
}): Promise<SparkPayload> {
|
}): Promise<SparkPayload> {
|
||||||
let csv: string;
|
let csv: string | null;
|
||||||
try {
|
|
||||||
csv = await getIndexedData(raw.csvPath);
|
// Inline content ids resolve through the registry (docPath + content id);
|
||||||
} catch {
|
// real file paths fall back to the file index.
|
||||||
|
const registry = getRegistry();
|
||||||
|
const docStore = raw.docPath ? registry.docContent[raw.docPath] : undefined;
|
||||||
|
const inline = docStore?.[raw.csvPath];
|
||||||
|
if (inline) {
|
||||||
|
csv = inline.body;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
csv = await getIndexedData(raw.csvPath);
|
||||||
|
} catch {
|
||||||
|
csv = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (csv === null) {
|
||||||
throw new Error(`Failed to load CSV: "${raw.csvPath}"`);
|
throw new Error(`Failed to load CSV: "${raw.csvPath}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,20 @@ let fileIndex: FileIndex | null = null;
|
|||||||
let indexLoadPromise: Promise<void> | null = null;
|
let indexLoadPromise: Promise<void> | null = null;
|
||||||
let activeSource: "cli" | "folder" | null = null;
|
let activeSource: "cli" | "folder" | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional registry for resolving inline content ids (set by the journal
|
||||||
|
* completions module). When present, `getIndexedData` resolves ids that
|
||||||
|
* aren't real files through it.
|
||||||
|
*/
|
||||||
|
let inlineResolver: ((path: string) => string | null) | null = null;
|
||||||
|
|
||||||
|
/** Register a resolver for inline content ids (see journal/completions). */
|
||||||
|
export function setInlineResolver(
|
||||||
|
fn: ((path: string) => string | null) | null,
|
||||||
|
): void {
|
||||||
|
inlineResolver = fn;
|
||||||
|
}
|
||||||
|
|
||||||
/** Currently active directory handle (if folder source) */
|
/** Currently active directory handle (if folder source) */
|
||||||
let activeDirHandle: FileSystemDirectoryHandle | null = null;
|
let activeDirHandle: FileSystemDirectoryHandle | null = null;
|
||||||
|
|
||||||
@@ -181,6 +195,15 @@ export async function getIndexedData(path: string): Promise<string> {
|
|||||||
if (fileIndex && fileIndex[path]) {
|
if (fileIndex && fileIndex[path]) {
|
||||||
return fileIndex[path];
|
return fileIndex[path];
|
||||||
}
|
}
|
||||||
|
// Resolve inline content ids through the registry before fetching.
|
||||||
|
if (inlineResolver) {
|
||||||
|
const inline = inlineResolver(path);
|
||||||
|
if (inline !== null) {
|
||||||
|
fileIndex = fileIndex || {};
|
||||||
|
fileIndex[path] = inline;
|
||||||
|
return inline;
|
||||||
|
}
|
||||||
|
}
|
||||||
const res = await fetch(path);
|
const res = await fetch(path);
|
||||||
const content = await res.text();
|
const content = await res.text();
|
||||||
fileIndex = fileIndex || {};
|
fileIndex = fileIndex || {};
|
||||||
|
|||||||
Reference in New Issue
Block a user