SPB Git
5.2 KB · 65 lines typescript
1/** Page → Markdown with YAML front-matter. Mentions become relative links when the resolver knows the target. */2import path from "node:path";3import { toMarkdown, type PMNode } from "@/lib/editor/text";4import { tagsOf, rowValuesByName, type ExportSet, type PageRow, type Placed } from "./data";56export interface Resolvers { mention?: (id: string, label: string) => string | null; file?: (src: string) => string | null }7const FILE_RE = /^\/api\/files\/([0-9a-f-]{36})/;8export const attachmentIdOf = (src: unknown) => (typeof src === "string" ? src.match(FILE_RE)?.[1] ?? null : null);910/** Deep-copies the doc, turning resolved mentions into linked text and rewriting /api/files/<id> sources. */11export function rewriteDoc(doc: PMNode, r: Resolvers): PMNode {12  const walk = (n: PMNode): PMNode => {13    if (n.type === "mention" && n.attrs?.kind !== "user") { const label = String(n.attrs?.label ?? n.attrs?.id ?? ""); const href = r.mention?.(String(n.attrs?.id ?? ""), label); if (href) return { type: "text", text: label, marks: [{ type: "link", attrs: { href } }] }; return n; }14    const out: PMNode = { ...n };15    if ((n.type === "image" || n.type === "fileBlock") && r.file && typeof n.attrs?.src === "string") { const nv = r.file(n.attrs.src); if (nv) out.attrs = { ...n.attrs, src: nv }; }16    if (n.marks?.some((m) => m.type === "link")) out.marks = n.marks.map((m) => { if (m.type !== "link" || typeof m.attrs?.href !== "string") return m; const href = m.attrs.href; const pm = href.match(/^\/p\/([0-9a-f-]{36})/); if (pm && r.mention) { const h = r.mention(pm[1], n.text ?? ""); if (h) return { ...m, attrs: { ...m.attrs, href: h } }; } if (r.file) { const f = r.file(href); if (f) return { ...m, attrs: { ...m.attrs, href: f } }; } return m; });17    if (n.content) out.content = n.content.map(walk);18    return out;19  };20  return walk(doc);21}2223const yamlScalar = (v: unknown): string => {24  if (v === null || v === undefined) return "null";25  if (typeof v === "number" || typeof v === "boolean") return String(v);26  if (v instanceof Date) return v.toISOString();27  const s = String(v);28  return /^[A-Za-z0-9 _.,;!?()/À-ÿ-]*$/.test(s) && !/^(true|false|null|yes|no|~|\d.*|-.*|\s.*|.*\s)$/i.test(s) && s !== "" ? s : JSON.stringify(s);29};30export function frontMatter(obj: Record<string, unknown>): string {31  const lines: string[] = ["---"];32  const emit = (k: string, v: unknown, indent: string) => {33    if (v === undefined) return;34    if (Array.isArray(v)) { if (!v.length) { lines.push(`${indent}${k}: []`); return; } if (v.every((x) => typeof x !== "object" || x === null)) { lines.push(`${indent}${k}: [${v.map(yamlScalar).join(", ")}]`); return; } lines.push(`${indent}${k}:`); for (const x of v) lines.push(`${indent}  - ${typeof x === "object" ? JSON.stringify(x) : yamlScalar(x)}`); return; }35    if (v && typeof v === "object" && !(v instanceof Date)) { const e = Object.entries(v as Record<string, unknown>); if (!e.length) { lines.push(`${indent}${k}: {}`); return; } lines.push(`${indent}${k}:`); for (const [kk, vv] of e) emit(kk, vv, indent + "  "); return; }36    lines.push(`${indent}${k}: ${yamlScalar(v)}`);37  };38  for (const [k, v] of Object.entries(obj)) emit(k, v, "");39  lines.push("---"); return lines.join("\n") + "\n";40}4142/** Relative link from one exported file to another (POSIX). */43export const relLink = (fromFile: string, toFile: string) => { const r = path.posix.relative(path.posix.dirname(fromFile), toFile); return r.startsWith(".") ? r : `./${r}`; };4445export function pageMarkdown(set: ExportSet, page: PageRow, opts: { resolvers?: Resolvers; placed?: Map<string, Placed>; withId?: boolean } = {}): string {46  const rowVals = rowValuesByName(set, page);47  const props = { ...(page.properties as Record<string, unknown>) }; delete props.tags;48  const fm: Record<string, unknown> = { title: page.title, ...(opts.withId === false ? {} : { id: page.id }), ...(page.icon ? { icon: page.icon } : {}), type: page.type, section: page.section, tags: tagsOf(set, page), created: page.createdAt.toISOString(), updated: page.updatedAt.toISOString() };49  if (rowVals && Object.keys(rowVals).length) fm.properties = rowVals; else if (Object.keys(props).length) fm.properties = props;50  if (page.databaseId) { const d = set.databases.find((x) => x.id === page.databaseId); if (d) fm.database = d.name; }51  const doc = rewriteDoc(page.content as PMNode, opts.resolvers ?? {});52  const body = toMarkdown(doc);53  return `${frontMatter(fm)}\n# ${page.title}\n\n${body}`;54}5556/** Resolvers for a ZIP layout: mentions → relative .md links, files → attachments/… */57export function zipResolvers(set: ExportSet, placed: Map<string, Placed>, fromFile: string, attFile: (id: string) => string | null): Resolvers {58  return {59    mention: (id) => { const t = placed.get(id); return t ? relLink(fromFile, t.file) : null; },60    file: (src) => { const id = attachmentIdOf(src); if (!id) return null; const f = attFile(id); return f ? relLink(fromFile, f) : null; },61  };62}63/** Resolvers for a single-file export: mentions → absolute app links, files kept as /api/files/<id>. */64export const standaloneResolvers = (appUrl: string): Resolvers => ({ mention: (id) => `${appUrl}/p/${id}`, file: (src) => (attachmentIdOf(src) ? `${appUrl}${src}` : null) });65
Files · main
Menu

Theme