SPB Git
10.4 KB · 138 lines typescript
1/**2 * Markdown → page. Front-matter → title/icon/tags/properties; `[[Wiki links]]` → mentions when the title (or archive path) is3 * known, otherwise plain text; `![img](relative)` and `[file](relative)` → attachments through `resolveFile`.4 */5import { markdownToDoc } from "@/lib/editor/markdown-client";6import type { PMNode } from "@/lib/editor/text";7import { createPage, updatePage, type ImportCtx, type Page } from "./pages";89export interface FrontMatter { data: Record<string, unknown>; body: string }10const yamlValue = (raw: string): unknown => {11  const s = raw.trim();12  if (s === "" ) return "";13  if (s === "null" || s === "~") return null; if (s === "true") return true; if (s === "false") return false;14  if (/^-?\d+(\.\d+)?$/.test(s)) return Number(s);15  if (/^\[.*\]$/.test(s)) { try { return JSON.parse(s); } catch { return s.slice(1, -1).split(",").map((x) => yamlValue(x)).filter((x) => x !== ""); } }16  if (/^\{.*\}$/.test(s)) { try { return JSON.parse(s); } catch { return s; } }17  if (/^".*"$/.test(s)) { try { return JSON.parse(s); } catch { return s.slice(1, -1); } }18  if (/^'.*'$/.test(s)) return s.slice(1, -1).replace(/''/g, "'");19  return s;20};21/** Minimal YAML subset: `key: scalar`, `key: [a, b]`, block lists (`- x`) and one level of nested maps. */22export function parseFrontMatter(src: string): FrontMatter {23  const m = src.replace(/^/, "").match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/);24  if (!m) return { data: {}, body: src };25  const data: Record<string, unknown> = {}; const lines = m[1].split(/\r?\n/);26  let i = 0;27  while (i < lines.length) {28    const line = lines[i]; i++;29    const kv = line.match(/^([A-Za-z0-9_ .-]+?):\s*(.*)$/); if (!kv || /^\s/.test(line)) continue;30    const key = kv[1].trim(); const rest = kv[2];31    if (rest !== "") { data[key] = yamlValue(rest); continue; }32    // block: list or nested map33    const list: unknown[] = []; const map: Record<string, unknown> = {}; let isList = false, isMap = false;34    while (i < lines.length && (/^\s+\S/.test(lines[i]) || lines[i].trim() === "")) {35      const l = lines[i]; i++; if (!l.trim()) continue;36      const li = l.match(/^\s+-\s*(.*)$/); if (li) { isList = true; list.push(yamlValue(li[1])); continue; }37      const mk = l.match(/^\s+([A-Za-z0-9_ .-]+?):\s*(.*)$/); if (mk) { isMap = true; map[mk[1].trim()] = yamlValue(mk[2]); }38    }39    data[key] = isList ? list : isMap ? map : null;40  }41  return { data, body: src.slice(m[0].length) };42}43/** Notion-style header: `# Title` then `Key: value` lines until the first blank line. */44export function parseNotionHeader(body: string): { title: string | null; props: Record<string, string>; body: string } {45  let rest = body.replace(/^\s*\n/, ""); let title: string | null = null;46  const h = rest.match(/^#\s+(.+?)\s*\r?\n/); if (h) { title = h[1].trim(); rest = rest.slice(h[0].length); }47  const props: Record<string, string> = {};48  const lines = rest.split(/\r?\n/); let j = 0;49  while (j < lines.length && /^[A-Za-z][A-Za-z0-9 _()/#&-]{0,60}:\s/.test(lines[j]) && !/^https?:/i.test(lines[j])) { const mm = lines[j].match(/^([^:]+):\s+(.*)$/); if (!mm) break; props[mm[1].trim()] = mm[2].trim(); j++; }50  if (j > 0 && (j >= lines.length || lines[j].trim() === "")) rest = lines.slice(j).join("\n"); else Object.keys(props).forEach((k) => delete props[k]);51  return { title, props, body: rest.replace(/^\s*\n/, "") };52}5354export type FileResolver = (ref: string) => Promise<{ id: string; filename: string; mime: string; size: number } | null>;55export interface MdOptions { resolveFile?: FileResolver; resolvePath?: (ref: string) => string | null }56const IMG_RE = /!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;57const WIKI_RE = /\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|([^\]]+))?\]\]/g;58const TOKEN_RE = /⦃(img|ref):(\d+)⦄/g;59const tok = (k: string, n: number) => `⦃${k}:${n}⦄`;6061/** Markdown → ProseMirror doc, resolving images/attachments and wiki links. */62export async function mdToDoc(ctx: ImportCtx, md: string, opts: MdOptions = {}): Promise<PMNode> {63  const imgs: { alt: string; src: string }[] = []; const refs: { target: string; alias: string | null }[] = [];64  // only transform outside fenced code blocks65  const parts = md.replace(/\r\n/g, "\n").split(/(^```[\s\S]*?^```$)/m);66  const pre = parts.map((seg, i) => (i % 2 ? seg : seg.replace(IMG_RE, (_m, alt, src) => { imgs.push({ alt, src }); return tok("img", imgs.length - 1); }).replace(WIKI_RE, (_m, t, a) => { refs.push({ target: t.trim(), alias: a?.trim() ?? null }); return tok("ref", refs.length - 1); }))).join("");67  const nodes = markdownToDoc(pre);68  const files = new Map<number, { src: string; alt: string } | null>(); const links = new Map<string, string>();69  const resolveSrc = async (src: string): Promise<string | null> => {70    if (/^(https?:)?\/\//i.test(src) || src.startsWith("data:") || src.startsWith("/api/files/")) return src;71    if (!opts.resolveFile) return null;72    const f = await opts.resolveFile(safeDecode(src)); return f ? `/api/files/${f.id}` : null;73  };74  for (let i = 0; i < imgs.length; i++) { const s = await resolveSrc(imgs[i].src); files.set(i, s ? { src: s, alt: imgs[i].alt } : null); }75  const resolveRef = (r: { target: string; alias: string | null }) => { const id = ctx.titles.get(r.target.toLowerCase()) ?? opts.resolvePath?.(r.target) ?? opts.resolvePath?.(r.target + ".md") ?? null; return { id, label: r.alias ?? r.target }; };76  const resolveHref = async (href: string): Promise<string> => {77    if (links.has(href)) return links.get(href)!;78    let out = href;79    if (!/^(https?:|mailto:|#|\/|data:)/i.test(href)) {80      const dec = safeDecode(href.replace(/#.*$/, ""));81      const pid = opts.resolvePath?.(dec); if (pid) out = `/p/${pid}`;82      else if (opts.resolveFile && !/\.md$/i.test(dec)) { const f = await opts.resolveFile(dec); if (f) out = `/api/files/${f.id}`; }83    }84    links.set(href, out); return out;85  };86  // collect hrefs first (async), then rewrite synchronously87  const hrefs = new Set<string>(); const collect = (n: PMNode) => { for (const m of n.marks ?? []) if (m.type === "link" && typeof m.attrs?.href === "string") hrefs.add(m.attrs.href); n.content?.forEach(collect); }; nodes.forEach(collect);88  for (const h of hrefs) await resolveHref(h);89  const splitText = (n: PMNode): PMNode[] => {90    const text = n.text ?? ""; if (!TOKEN_RE.test(text)) { TOKEN_RE.lastIndex = 0; return [n]; } TOKEN_RE.lastIndex = 0;91    const out: PMNode[] = []; let last = 0; let m: RegExpExecArray | null;92    while ((m = TOKEN_RE.exec(text))) {93      if (m.index > last) out.push({ ...n, text: text.slice(last, m.index) });94      const idx = Number(m[2]);95      if (m[1] === "img") { const f = files.get(idx); if (f) out.push({ type: "image", attrs: { src: f.src, alt: f.alt || null, title: null } }); else out.push({ ...n, text: imgs[idx]?.alt ? `[image: ${imgs[idx].alt}]` : `[image: ${imgs[idx]?.src ?? ""}]` }); }96      else { const { id, label } = resolveRef(refs[idx]); if (id) out.push({ type: "mention", attrs: { id, label } }); else out.push({ ...n, text: label }); }97      last = m.index + m[0].length;98    }99    if (last < text.length) out.push({ ...n, text: text.slice(last) });100    return out;101  };102  const walk = (n: PMNode): PMNode[] => {103    if (n.type === "text") { const nn = n.marks?.some((m) => m.type === "link") ? { ...n, marks: n.marks!.map((m) => (m.type === "link" ? { ...m, attrs: { ...m.attrs, href: links.get(String(m.attrs?.href)) ?? m.attrs?.href } } : m)) } : n; return splitText(nn); }104    if (!n.content) return [n];105    let children = n.content.flatMap(walk);106    // marked emits the task checkbox as a stray "[x] " paragraph before the item text — drop it107    if ((n.type === "taskItem" || n.type === "listItem") && children.length > 1 && children[0].type === "paragraph" && children[0].content?.length === 1 && /^\[( |x|X)\]\s*$/.test(children[0].content[0].text ?? "")) children = children.slice(1);108    if (n.type === "paragraph" || n.type === "heading") {109      // images are block nodes: split the paragraph around them110      const out: PMNode[] = []; let buf: PMNode[] = [];111      const flush = () => { if (buf.length) out.push({ ...n, content: buf }); buf = []; };112      for (const c of children) { if (c.type === "image") { flush(); out.push(c); } else buf.push(c); }113      flush(); return out.length ? out : [{ ...n, content: [] }];114    }115    return [{ ...n, content: children }];116  };117  const content = nodes.flatMap(walk);118  return { type: "doc", content: content.length ? content : [{ type: "paragraph" }] };119}120const safeDecode = (s: string) => { try { return decodeURIComponent(s); } catch { return s; } };121122/** Full Markdown file → page (front-matter + Notion header aware). `existingId` updates a page created in a first pass. */123export async function importMarkdown(ctx: ImportCtx, src: string, opts: MdOptions & { filename?: string; parentId?: string | null; existingId?: string; section?: string } = {}): Promise<Page> {124  const { data, body } = parseFrontMatter(src);125  const nh = parseNotionHeader(body);126  const title = String(data.title ?? nh.title ?? (opts.filename ?? "Untitled").replace(/\.(md|markdown|txt)$/i, "")).trim() || "Untitled";127  const props: Record<string, unknown> = { ...(typeof data.properties === "object" && data.properties ? (data.properties as Record<string, unknown>) : {}), ...nh.props };128  for (const [k, v] of Object.entries(data)) if (!["title", "id", "icon", "tags", "properties", "created", "updated", "type", "section", "database", "cover"].includes(k)) props[k] = v;129  const tags = Array.isArray(data.tags) ? data.tags.map(String) : typeof data.tags === "string" ? data.tags.split(/[,;]\s*/).filter(Boolean) : typeof nh.props.Tags === "string" ? nh.props.Tags.split(/,\s*/).filter(Boolean) : [];130  if (tags.length) props.tags = tags;131  const doc = await mdToDoc(ctx, nh.body, opts);132  const icon = typeof data.icon === "string" ? data.icon.slice(0, 16) : null;133  const dates = { createdAt: data.created ? new Date(String(data.created)) : undefined, updatedAt: data.updated ? new Date(String(data.updated)) : undefined };134  const valid = (d?: Date) => (d && !isNaN(d.getTime()) ? d : undefined);135  if (opts.existingId) return updatePage(ctx, opts.existingId, { title, content: doc, icon, properties: props, updatedAt: valid(dates.updatedAt) });136  return createPage(ctx, { title, parentId: opts.parentId ?? null, section: opts.section ?? (typeof data.section === "string" ? data.section : "notes"), content: doc, icon, properties: props, createdAt: valid(dates.createdAt), updatedAt: valid(dates.updatedAt) });137}138
Files · main
Menu

Theme