Files
FabledGrimoire/frontend/src/utils/markdownSerializer.ts
T
bvandeusenandClaude Sonnet 4.6 ef141f07f8 UI polish pass: word count, slash commands, task lists, graph peek, bulk delete, export
- WordCount component (toggle words/chars vs read time, persisted mode)
- TipTap: TaskList/TaskItem extensions, slash command menu (H1-H3, lists, code, quote, task)
- Markdown serializer: task list → `- [ ]` / `- [x]` roundtrip
- GraphView: slide-in peek panel for note/task nodes (body, tags, linked nodes); tag nodes still navigate
- ChatView: bulk-select conversations with two-click confirm delete + chat retention policy (default 90d)
- NoteEditorView: pill tab bar with animated edit/preview toggle + WordCount in toolbar
- WorkspaceNoteEditor: inline search with tag matching, inline new-note creation, WordCount
- WorkspaceTaskPanel: task body rendered in slide-over + Edit link
- Settings: data export (Markdown ZIP / JSON) via GET /api/export
- Accessibility: skip-to-content link, aria-labels on all icon-only buttons
- Fix: export route used non-existent fabledassistant.database — corrected to async_session()
- Fix: ToolCallRecord.status type now includes "running" (was causing TS build error)
- Dockerfile: upgrade npm to latest before install to suppress major-version notice
- npm audit fix: patched minimatch (ReDoS) and rollup (path traversal) — 0 vulnerabilities

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 23:15:01 -05:00

204 lines
5.2 KiB
TypeScript

import type { JSONContent } from "@tiptap/vue-3";
export function serializeToMarkdown(doc: JSONContent): string {
if (doc.type !== "doc") return "";
return serializeNodes(doc.content || []).replace(/\n{3,}/g, "\n\n").trimEnd();
}
function serializeNodes(nodes: JSONContent[], listIndent = 0): string {
let out = "";
for (let i = 0; i < nodes.length; i++) {
out += serializeNode(nodes[i], listIndent);
}
return out;
}
function serializeNode(node: JSONContent, listIndent = 0): string {
switch (node.type) {
case "paragraph":
return serializeInline(node.content || []) + "\n\n";
case "heading": {
const level = node.attrs?.level ?? 1;
const prefix = "#".repeat(level) + " ";
return prefix + serializeInline(node.content || []) + "\n\n";
}
case "bulletList":
return (
serializeBulletList(node.content || [], listIndent) +
(listIndent === 0 ? "\n" : "")
);
case "orderedList":
return (
serializeOrderedList(node.content || [], listIndent, node.attrs?.start ?? 1) +
(listIndent === 0 ? "\n" : "")
);
case "taskList":
return (
serializeTaskList(node.content || [], listIndent) +
(listIndent === 0 ? "\n" : "")
);
case "listItem":
case "taskItem":
// Handled by their respective list serializers
return "";
case "codeBlock": {
const lang = node.attrs?.language || "";
const code = getTextContent(node);
return "```" + lang + "\n" + code + "\n```\n\n";
}
case "blockquote": {
const inner = serializeNodes(node.content || []);
return (
inner
.trimEnd()
.split("\n")
.map((line) => "> " + line)
.join("\n") + "\n\n"
);
}
case "horizontalRule":
return "---\n\n";
case "hardBreak":
return "\n";
case "text":
return applyMarks(node.text || "", node.marks || []);
default:
// Unknown node — try to serialize children
if (node.content) {
return serializeNodes(node.content);
}
return node.text || "";
}
}
function serializeBulletList(
items: JSONContent[],
indent: number
): string {
let out = "";
for (const item of items) {
if (item.type !== "listItem") continue;
const children = item.content || [];
const prefix = " ".repeat(indent) + "- ";
out += serializeListItem(children, prefix, indent);
}
return out;
}
function serializeOrderedList(
items: JSONContent[],
indent: number,
start: number
): string {
let out = "";
let num = start;
for (const item of items) {
if (item.type !== "listItem") continue;
const children = item.content || [];
const prefix = " ".repeat(indent) + num + ". ";
out += serializeListItem(children, prefix, indent);
num++;
}
return out;
}
function serializeTaskList(items: JSONContent[], indent: number): string {
let out = "";
for (const item of items) {
if (item.type !== "taskItem") continue;
const checked = item.attrs?.checked ? "x" : " ";
const prefix = " ".repeat(indent) + `- [${checked}] `;
out += serializeListItem(item.content || [], prefix, indent);
}
return out;
}
function serializeListItem(
children: JSONContent[],
prefix: string,
indent: number
): string {
let out = "";
for (let i = 0; i < children.length; i++) {
const child = children[i];
if (child.type === "paragraph") {
if (i === 0) {
out += prefix + serializeInline(child.content || []) + "\n";
} else {
out +=
" ".repeat(indent + 1) +
serializeInline(child.content || []) +
"\n";
}
} else if (child.type === "bulletList") {
out += serializeBulletList(child.content || [], indent + 1);
} else if (child.type === "orderedList") {
out += serializeOrderedList(
child.content || [],
indent + 1,
child.attrs?.start ?? 1
);
} else if (child.type === "taskList") {
out += serializeTaskList(child.content || [], indent + 1);
} else {
out += serializeNode(child, indent + 1);
}
}
return out;
}
function serializeInline(nodes: JSONContent[]): string {
let out = "";
for (const node of nodes) {
if (node.type === "text") {
out += applyMarks(node.text || "", node.marks || []);
} else if (node.type === "hardBreak") {
out += "\n";
} else if (node.content) {
out += serializeInline(node.content);
}
}
return out;
}
function applyMarks(text: string, marks: NonNullable<JSONContent["marks"]>): string {
let result = text;
for (const mark of marks) {
switch (mark.type) {
case "bold":
result = `**${result}**`;
break;
case "italic":
result = `_${result}_`;
break;
case "strike":
result = `~~${result}~~`;
break;
case "code":
result = "`" + result + "`";
break;
case "link":
result = `[${result}](${mark.attrs?.href || ""})`;
break;
}
}
return result;
}
function getTextContent(node: JSONContent): string {
if (node.text) return node.text;
if (!node.content) return "";
return node.content.map(getTextContent).join("");
}