c647b29712
- md-to-tiptap.ts: remark-based converter (headings, lists, blockquotes, code blocks, bold/italic/strike, links, images, hr) - Obsidian ![[wikilink]] stripped, [[link|alias]] → plain text - POST /api/admin/import-md: parses frontmatter (gray-matter) + converts content - LessonEditor: "Импорт .md" button populates editor without auto-save - ROADMAP: marked Stages 2, 3, 5, 6, 7, 8 as complete, fixed numbering Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
189 lines
5.9 KiB
TypeScript
189 lines
5.9 KiB
TypeScript
import { unified } from "unified";
|
|
import remarkParse from "remark-parse";
|
|
|
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
|
|
type Mark = { type: string; attrs?: Record<string, unknown> };
|
|
|
|
type TipTapNode = {
|
|
type: string;
|
|
attrs?: Record<string, unknown>;
|
|
content?: TipTapNode[];
|
|
text?: string;
|
|
marks?: Mark[];
|
|
};
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
type MdastNode = Record<string, any>;
|
|
|
|
// ── Inline converter ──────────────────────────────────────────────────────────
|
|
|
|
function convertInline(nodes: MdastNode[], marks: Mark[] = []): TipTapNode[] {
|
|
const result: TipTapNode[] = [];
|
|
|
|
for (const node of nodes) {
|
|
switch (node.type) {
|
|
case "text": {
|
|
if (!node.value) break;
|
|
const n: TipTapNode = { type: "text", text: node.value as string };
|
|
if (marks.length) n.marks = marks;
|
|
result.push(n);
|
|
break;
|
|
}
|
|
|
|
case "strong":
|
|
result.push(...convertInline(node.children, [...marks, { type: "bold" }]));
|
|
break;
|
|
|
|
case "emphasis":
|
|
result.push(...convertInline(node.children, [...marks, { type: "italic" }]));
|
|
break;
|
|
|
|
case "delete":
|
|
result.push(...convertInline(node.children, [...marks, { type: "strike" }]));
|
|
break;
|
|
|
|
case "inlineCode":
|
|
result.push({
|
|
type: "text",
|
|
text: node.value as string,
|
|
marks: [...marks, { type: "code" }],
|
|
});
|
|
break;
|
|
|
|
case "link": {
|
|
const linkMark: Mark = {
|
|
type: "link",
|
|
attrs: {
|
|
href: node.url ?? "#",
|
|
target: "_blank",
|
|
rel: "noopener noreferrer",
|
|
class: null,
|
|
},
|
|
};
|
|
result.push(...convertInline(node.children, [...marks, linkMark]));
|
|
break;
|
|
}
|
|
|
|
case "image":
|
|
// Only HTTP/HTTPS images — local Obsidian paths are not resolvable here
|
|
if (typeof node.url === "string" && /^https?:\/\//.test(node.url)) {
|
|
result.push({
|
|
type: "image",
|
|
attrs: {
|
|
src: node.url,
|
|
alt: node.alt ?? null,
|
|
title: node.title ?? null,
|
|
},
|
|
});
|
|
}
|
|
break;
|
|
|
|
case "break":
|
|
result.push({ type: "hardBreak" });
|
|
break;
|
|
|
|
default:
|
|
// Try to extract content from unknown inline nodes
|
|
if (Array.isArray(node.children)) {
|
|
result.push(...convertInline(node.children, marks));
|
|
} else if (node.value) {
|
|
const n: TipTapNode = { type: "text", text: node.value as string };
|
|
if (marks.length) n.marks = marks;
|
|
result.push(n);
|
|
}
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// ── Block converter ───────────────────────────────────────────────────────────
|
|
|
|
function convertBlock(nodes: MdastNode[]): TipTapNode[] {
|
|
const result: TipTapNode[] = [];
|
|
|
|
for (const node of nodes) {
|
|
switch (node.type) {
|
|
case "paragraph": {
|
|
const content = convertInline(node.children ?? []);
|
|
result.push(content.length ? { type: "paragraph", content } : { type: "paragraph" });
|
|
break;
|
|
}
|
|
|
|
case "heading": {
|
|
const content = convertInline(node.children ?? []);
|
|
result.push({
|
|
type: "heading",
|
|
attrs: { level: node.depth ?? 2 },
|
|
content,
|
|
});
|
|
break;
|
|
}
|
|
|
|
case "blockquote": {
|
|
const content = convertBlock(node.children ?? []);
|
|
result.push({ type: "blockquote", content });
|
|
break;
|
|
}
|
|
|
|
case "code": {
|
|
result.push({
|
|
type: "codeBlock",
|
|
attrs: { language: node.lang ?? null },
|
|
content: [{ type: "text", text: node.value as string }],
|
|
});
|
|
break;
|
|
}
|
|
|
|
case "list": {
|
|
const listType = node.ordered ? "orderedList" : "bulletList";
|
|
const items = (node.children as MdastNode[]).map((item) => ({
|
|
type: "listItem",
|
|
content: convertBlock(item.children ?? []),
|
|
}));
|
|
const listNode: TipTapNode = { type: listType, content: items };
|
|
if (node.ordered) listNode.attrs = { start: (node.start as number) ?? 1 };
|
|
result.push(listNode);
|
|
break;
|
|
}
|
|
|
|
case "thematicBreak":
|
|
result.push({ type: "horizontalRule" });
|
|
break;
|
|
|
|
// Skip html, definitions, footnotes, etc.
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// ── Public API ────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Converts a Markdown string to a TipTap/ProseMirror JSON document.
|
|
* Handles: headings, paragraphs, bold, italic, strike, inline code,
|
|
* code blocks, blockquotes, lists (nested), links, images (HTTP only),
|
|
* horizontal rules, hard breaks.
|
|
*
|
|
* Obsidian-specific syntax (![[wikilink]], [[link]]) is silently ignored
|
|
* since local file paths are not available during server-side import.
|
|
*/
|
|
export function mdToTiptap(markdown: string): object {
|
|
// Strip Obsidian wikilinks: [[link]] → plain text, ![[image]] → removed
|
|
const cleaned = markdown
|
|
.replace(/!\[\[([^\]]+)\]\]/g, "") // remove ![[image]] embeds
|
|
.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_m, target, alias) => alias ?? target); // [[link|alias]] → alias or target
|
|
|
|
const tree = unified().use(remarkParse).parse(cleaned);
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const content = convertBlock((tree as any).children ?? []);
|
|
|
|
if (content.length === 0) {
|
|
content.push({ type: "paragraph" });
|
|
}
|
|
|
|
return { type: "doc", content };
|
|
}
|