feat: improve completions loading and UI handling
Refactor the completions system to support both CLI mode (via `/__COMPLETIONS.json`) and a client-side fallback scan for dev mode. - Implement client-side scanning of the file index for dice and headings - Add `ensureCompletions` to allow components to await data readiness - Update `JournalInput` to handle "no results" states in the dropdown - Improve keyboard navigation (Tab to accept, Enter to select) - Update dice regex to support `:md-dice[...]` syntax
This commit is contained in:
@@ -1,11 +1,19 @@
|
||||
/**
|
||||
* Journal completions — client-side loader for /__COMPLETIONS.json
|
||||
*
|
||||
* Fetches once on first call, caches the result. Call `invalidateCompletions()`
|
||||
* to force a re-fetch (e.g., when the content source changes).
|
||||
* In CLI mode, fetches the pre-computed index. In dev/browser mode, falls
|
||||
* back to scanning the in-memory file index for dice expressions and headings.
|
||||
*
|
||||
* The fetch runs eagerly on module import. Call `useJournalCompletions()`
|
||||
* from any Solid component to reactively read the state.
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js";
|
||||
import { extractHeadings } from "../../data-loader/toc";
|
||||
import {
|
||||
getPathsByExtension,
|
||||
getIndexedData,
|
||||
} from "../../data-loader/file-index";
|
||||
|
||||
// ------------------- Types (mirrors CLI) -------------------
|
||||
|
||||
@@ -26,57 +34,128 @@ export interface JournalCompletions {
|
||||
links: LinkCompletion[];
|
||||
}
|
||||
|
||||
// ------------------- Cache -------------------
|
||||
export type CompletionsState =
|
||||
| { status: "loading" }
|
||||
| { status: "loaded"; data: JournalCompletions }
|
||||
| { status: "empty" }
|
||||
| { status: "error"; message: string };
|
||||
|
||||
let _cache: JournalCompletions = { dice: [], links: [] };
|
||||
let _loaded = false;
|
||||
// ------------------- Reactive state (module-level signal) -------------------
|
||||
|
||||
/** Thawed value signal; invalidate resets it. */
|
||||
const [completions, setCompletions] = createSignal<JournalCompletions>(_cache);
|
||||
const [completionsState, setCompletionsState] = createSignal<CompletionsState>({
|
||||
status: "loading",
|
||||
});
|
||||
|
||||
// ------------------- Fetch -------------------
|
||||
// ------------------- Fetch (CLI mode) -------------------
|
||||
|
||||
async function load(): Promise<void> {
|
||||
async function tryServer(): Promise<JournalCompletions | null> {
|
||||
try {
|
||||
const resp = await fetch("/__COMPLETIONS.json");
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
_cache = {
|
||||
dice: Array.isArray(data.dice) ? data.dice : [],
|
||||
links: Array.isArray(data.links) ? data.links : [],
|
||||
};
|
||||
} else {
|
||||
_cache = { dice: [], links: [] };
|
||||
}
|
||||
if (!resp.ok) return null;
|
||||
const data = await resp.json();
|
||||
return {
|
||||
dice: Array.isArray(data.dice) ? data.dice : [],
|
||||
links: Array.isArray(data.links) ? data.links : [],
|
||||
};
|
||||
} catch {
|
||||
_cache = { dice: [], links: [] };
|
||||
return null;
|
||||
}
|
||||
_loaded = true;
|
||||
setCompletions(_cache);
|
||||
}
|
||||
|
||||
/** Ensure data is loaded (lazy, idempotent). */
|
||||
let _promise: Promise<void> | null = null;
|
||||
export function ensureCompletions(): Promise<void> {
|
||||
if (!_promise) _promise = load();
|
||||
return _promise;
|
||||
// ------------------- Client-side fallback scan -------------------
|
||||
|
||||
async function scanClientSide(): Promise<JournalCompletions> {
|
||||
const paths = await getPathsByExtension("md");
|
||||
const dice: DiceCompletion[] = [];
|
||||
const links: LinkCompletion[] = [];
|
||||
const tagRegex = /<md-dice[^>]*>\s*([\s\S]*?)\s*<\/md-dice>/gi;
|
||||
|
||||
for (const filePath of paths) {
|
||||
const content = await getIndexedData(filePath);
|
||||
if (!content) continue;
|
||||
|
||||
// Dice scan
|
||||
let match: RegExpExecArray | null;
|
||||
tagRegex.lastIndex = 0;
|
||||
while ((match = tagRegex.exec(content)) !== null) {
|
||||
const raw = match[1].trim();
|
||||
if (!raw || raw.length > 80) continue;
|
||||
if (!/^\d*d\d+/i.test(raw) && !/^[+-]/.test(raw)) continue;
|
||||
dice.push({ label: raw, notation: raw, source: filePath });
|
||||
}
|
||||
|
||||
// Link scan (headings)
|
||||
const basePath = filePath.replace(/\.md$/, "");
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { dice, links };
|
||||
}
|
||||
|
||||
/** Force a re-fetch on next use. */
|
||||
export function invalidateCompletions(): void {
|
||||
_loaded = false;
|
||||
_promise = null;
|
||||
}
|
||||
// ------------------- Init (runs eagerly at import time) -------------------
|
||||
|
||||
// ------------------- Hook -------------------
|
||||
// Using a top-level IIFE so the promise starts immediately
|
||||
const _initPromise: Promise<void> = (async () => {
|
||||
// 1. Try server first (CLI mode)
|
||||
const serverData = await tryServer();
|
||||
if (serverData) {
|
||||
setCompletionsState({ status: "loaded", data: serverData });
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Fall back to client-side scan (dev/browser mode)
|
||||
try {
|
||||
const data = await scanClientSide();
|
||||
if (data.dice.length > 0 || data.links.length > 0) {
|
||||
setCompletionsState({ status: "loaded", data });
|
||||
} else {
|
||||
setCompletionsState({ status: "empty" });
|
||||
}
|
||||
} catch (e) {
|
||||
setCompletionsState({
|
||||
status: "error",
|
||||
message: e instanceof Error ? e.message : "Failed to scan",
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
// ------------------- Public API -------------------
|
||||
|
||||
/**
|
||||
* Reactive completions data for the journal input.
|
||||
* Triggers a lazy fetch on first access; returns empty arrays while loading.
|
||||
* Returns a Promise that resolves once completions are loaded (or failed).
|
||||
* Useful for waiting before showing the completions dropdown.
|
||||
*/
|
||||
export function useJournalCompletions(): JournalCompletions {
|
||||
if (!_loaded) {
|
||||
void ensureCompletions();
|
||||
export function ensureCompletions(): Promise<void> {
|
||||
return _initPromise;
|
||||
}
|
||||
|
||||
/** Force a re-fetch on the next page load. For runtime, call before invalidate triggers. */
|
||||
let _invalidated = false;
|
||||
export function invalidateCompletions(): void {
|
||||
_invalidated = true;
|
||||
// On next import (page reload), the module will re-init.
|
||||
// For a runtime invalidation, you could call init again.
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive hooks for the journal input.
|
||||
* Returns the current completions state + a convenience `data` extractor.
|
||||
*/
|
||||
export function useJournalCompletions(): {
|
||||
state: CompletionsState;
|
||||
data: JournalCompletions;
|
||||
} {
|
||||
const s = completionsState();
|
||||
if (s.status === "loaded") {
|
||||
return { state: s, data: s.data };
|
||||
}
|
||||
return completions();
|
||||
return { state: s, data: { dice: [], links: [] } };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user