// Host markdown→HTML pipeline for wiki/View.vue. The pure // `[[wiki-link]]` walker now lives in `@mulmoclaude/core/wiki` // (shared with MulmoTerminal); this file owns only the host-specific // wrapping (image-ref rewrite + marked + interactive task lists). import { marked } from "marked"; import { renderWikiLinks, WIKI_ACTION } from "@mulmoclaude/core/wiki"; import { rewriteMarkdownImageRefs } from "@mulmoclaude/markdown-utils/image/rewriteMarkdownImageRefs"; import { findTaskLines, makeTasksInteractive, toggleTaskAt } from "@mulmoclaude/markdown-utils/markdown/taskList"; import { splitFrontmatter } from "@mulmoclaude/markdown-utils/markdown/frontmatter"; // Re-export so existing host importers (and tests) keep a single // `./helpers` entry point for the renderer. export { renderWikiLinks } from "@mulmoclaude/core/wiki"; /** * Markdown→HTML pipeline shared between the standalone /wiki view * and the chat-inline preview (Stage 3a). Caller passes a body that * already has frontmatter stripped, plus the workspace-relative base * dir used to rewrite image refs (`data/wiki/pages` for a page, * `data/wiki` for log/lint). */ export function renderWikiPageHtml(body: string, baseDir: string): string { if (!body) return ""; const withImages = rewriteMarkdownImageRefs(body, baseDir); const rendered = marked.parse(renderWikiLinks(withImages)); // An async marked extension would hand back a Promise; this renderer is // synchronous, so it falls back to the same empty result as an empty body. return typeof rendered === "string" ? makeTasksInteractive(rendered) : ""; } /** String accessor that survives the `unknown` type from FAILSAFE * YAML — `meta` values are all strings under FAILSAFE schema, but * type-narrowing requires a runtime check. Empty strings collapse to * `null` so the metadata bar skips the field entirely. */ export function metaString(value: unknown): string | null { if (typeof value !== "string" || value.length === 0) return null; return value; } /** Array-of-strings accessor for `tags`. Drops non-string members so * a malformed `tags:` list can't leak a non-string into the chips. */ export function metaStringArray(value: unknown): string[] { if (!Array.isArray(value)) return []; return value.filter((item): item is string => typeof item === "string"); } /** Render an `updated` ISO timestamp as `YYYY-MM-DD HH:MM` in the * viewer's local timezone. On-disk values are UTC ISO * (`2026-04-27T14:32:56.789Z`) — showing the raw `14:32` would read * like local wall time on a non-UTC machine and mislead the reader. * Falls back to the raw value when it doesn't parse as a Date * (user-supplied frontmatter may hold any string here). `sv-SE` gives * the ISO-like space-separated shape; `hour12: false` defends against * AM/PM locales. */ export function formatUpdated(raw: string): string { const parsed = new Date(raw); if (Number.isNaN(parsed.getTime())) return raw; return new Intl.DateTimeFormat("sv-SE", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false, }).format(parsed); } /** Per-tag usage counts across page entries. Kept separate from * `computeTagChips` so the fallback chip (an active filter the * adaptive cutoff hides) can look up a real count instead of * understating a dropped non-singleton tag as `1`. */ export function computeTagCounts(entries: readonly { tags?: readonly string[] }[]): Map { const counts = new Map(); entries.forEach((entry) => (entry.tags ?? []).forEach((tag) => counts.set(tag, (counts.get(tag) ?? 0) + 1))); return counts; } /** Adaptive tag chips for the filter bar: sorted by count desc then * name asc, singletons dropped (a tag on one page adds no filtering * value, only noise — the per-row `#tag` chips still render it). Once * more than `target` tags qualify, the minimum count is raised to the * count at the target position, which keeps tied-popularity tags * grouped rather than sliced arbitrarily — so the row can exceed * `target` when a tie straddles the boundary. */ export function computeTagChips(entries: readonly { tags?: readonly string[] }[], target: number): [string, number][] { const meaningful = [...computeTagCounts(entries).entries()] .filter(([, count]) => count > 1) .sort(([tagA, countA], [tagB, countB]) => countB - countA || tagA.localeCompare(tagB)); const boundary = meaningful[target - 1]; if (meaningful.length <= target || !boundary) return meaningful; const [, cutoff] = boundary; return meaningful.filter(([, count]) => count >= cutoff); } /** Whether a page view should lazily fetch the global link graph to populate * its "Linked references" panel. The graph is global, so one fetch serves * every page — only fetch on a page that exists and only when the graph hasn't * already loaded. A wrong condition here fails silently (backlinks never * appear), so it is unit-tested. */ export function shouldLazyLoadGraph(action: string, pageExists: boolean, hasGraph: boolean): boolean { return action === WIKI_ACTION.page && pageExists && !hasGraph; } /** Outcome of a task-checkbox click, computed purely from the clicked * input, its rendered container, and the current markdown source: * - `toggled` — the new full document to persist. * - `mismatch` — source task count differs from the rendered * `input.md-task` count (source/DOM drift); the caller surfaces the * error and reverts. * - `skip` — the click can't be mapped (target not among the tasks, * or an out-of-range toggle); the caller reverts silently. */ export type TaskToggleResult = { readonly status: "toggled"; readonly content: string } | { readonly status: "mismatch" } | { readonly status: "skip" }; /** Compute the new document from a task-checkbox click. Pure: the DOM * is passed in (`root` is queried, never `document`), and the source * arrives as `content` rather than being read from a reactive ref, so * the toggle rule is testable in isolation. */ export function computeToggledContent(target: HTMLInputElement, root: HTMLElement, content: string): TaskToggleResult { const taskInputs = root.querySelectorAll("input.md-task"); const taskIndex = Array.from(taskInputs).indexOf(target); if (taskIndex < 0) return { status: "skip" }; const { prefix, body } = splitFrontmatter(content); if (findTaskLines(body).length !== taskInputs.length) return { status: "mismatch" }; const updatedBody = toggleTaskAt(body, taskIndex); if (updatedBody === null) return { status: "skip" }; return { status: "toggled", content: prefix + updatedBody }; }