From 4ce0a5ebfb1ed8e6d06deebef7c32c4d9e4f0f92 Mon Sep 17 00:00:00 2001 From: avi Date: Mon, 24 Aug 2026 22:00:12 -0500 Subject: [PATCH] refactor(conversion): centralize format contracts in native-doc.ts; document lossiness matrix --- src/main/markdown-to-native.ts | 3 ++- src/main/native-doc.ts | 15 +++++++++++++++ src/main/native-to-markdown.ts | 19 +++++++++++++++---- src/main/office-export.ts | 10 +++++++--- src/renderer/print-format.ts | 10 +++++++--- src/renderer/wikilinks.ts | 3 ++- 6 files changed, 48 insertions(+), 12 deletions(-) diff --git a/src/main/markdown-to-native.ts b/src/main/markdown-to-native.ts index adda769..c07c0f0 100644 --- a/src/main/markdown-to-native.ts +++ b/src/main/markdown-to-native.ts @@ -17,6 +17,7 @@ import type { } from "markdown-it"; import { wikilinkPlugin, type WikilinkTarget } from "../renderer/wikilinks.js"; import { + CALLOUT_TITLE_CLASS, CURRENT_DOC_SCHEMA_VERSION, type FolioChapterDoc, type FolioMark, @@ -151,7 +152,7 @@ function calloutPlugin(md: MarkdownItInstance) { if (title) { const titleOpen = new state.Token("paragraph_open", "p", 1); - titleOpen.attrs = [["class", "callout-title"]]; + titleOpen.attrs = [["class", CALLOUT_TITLE_CLASS]]; const titleInline = new state.Token("inline", "", 0); titleInline.content = title; const titleText = new state.Token("text", "", 0); diff --git a/src/main/native-doc.ts b/src/main/native-doc.ts index 95cfe42..5708ca3 100644 --- a/src/main/native-doc.ts +++ b/src/main/native-doc.ts @@ -41,6 +41,21 @@ export interface FolioChapterDoc { // Bump this whenever the native document shape needs a one-way migration. export const CURRENT_DOC_SCHEMA_VERSION = 1; +// ---- Cross-converter contracts ------------------------------------------- +// These string constants are part of the document format itself. They are +// consumed by every module that reads or writes native docs (markdown-to-native, +// native-to-markdown, renderer/print-format), so they live here — next to the +// schema they belong to — instead of being re-typed as literals in each +// converter. Changing any of them changes what is persisted on disk. +// +// Callout title paragraphs carry this class so serializers can tell the title +// apart from body paragraphs (set on parse, stripped on serialize/render). +export const CALLOUT_TITLE_CLASS = "callout-title"; + +// Internal chapter-link scheme: folio:chapter/. Serializers turn it back +// into [[wikilinks]]; the wikilink plugin turns [[text]] into it on parse. +export const CHAPTER_LINK_PREFIX = "folio:chapter/"; + // Each entry migrates the document FROM its key version TO key+1. // Entries are additive and must be non-destructive. const MIGRATIONS: Record FolioChapterDoc> = {}; diff --git a/src/main/native-to-markdown.ts b/src/main/native-to-markdown.ts index 2c6c11b..e0d4198 100644 --- a/src/main/native-to-markdown.ts +++ b/src/main/native-to-markdown.ts @@ -8,14 +8,25 @@ // // The output is intentionally "good enough" Markdown that round-trips through // markdown-to-native.ts; it is NOT a pixel-perfect reconstruction of the -// original source (e.g. raw control characters inside ==highlight== are kept -// literal, and folio:chapter/ links become [[text]] wikilinks). +// original source. Known, accepted losses on serialize: +// - hard breaks degrade to soft breaks (re-parse yields a plain space) +// - table cells holding multiple blocks are flattened: block boundaries +// vanish because cells render as one inline run +// - chapter links serialize as [[display text]]; re-parsing resolves that +// text against chapter titles, so a link whose label differs from its +// target's title may point elsewhere after a round-trip +// Everything else (marks incl. colored highlights/underline, code fences with +// language, task-list state, callout type+title, image alt/title) survives. import type { FolioChapterDoc, FolioMark, FolioNode, } from "./native-doc.js"; +import { + CALLOUT_TITLE_CLASS, + CHAPTER_LINK_PREFIX, +} from "./native-doc.js"; // Marks applied outermost-first so nested formatting renders as valid Markdown. const MARK_ORDER = [ @@ -52,7 +63,7 @@ function wrap(mark: FolioMark, text: string): string { return `${text}`; case "link": { const href = String(attrs.href ?? ""); - if (href.startsWith("folio:chapter/")) return `[[${text}]]`; + if (href.startsWith(CHAPTER_LINK_PREFIX)) return `[[${text}]]`; const title = attrs.title ? ` "${attrs.title}"` : ""; return `[${text}](${href}${title})`; } @@ -142,7 +153,7 @@ function blockToMd(node: FolioNode, depth = 0): string { const rest: FolioNode[] = []; let title = ""; for (const c of node.content ?? []) { - if (c.type === "paragraph" && c.attrs?.class === "callout-title") { + if (c.type === "paragraph" && c.attrs?.class === CALLOUT_TITLE_CLASS) { title = inlineToMd(c.content); } else { rest.push(c); diff --git a/src/main/office-export.ts b/src/main/office-export.ts index f701c50..f12d6c3 100644 --- a/src/main/office-export.ts +++ b/src/main/office-export.ts @@ -31,6 +31,10 @@ import { } from "docx"; import { loadBook, type ChapterEntry } from "./project.js"; import { listChaptersInOrder, loadChapterDoc } from "./chapters.js"; +import { + CALLOUT_TITLE_CLASS, + CHAPTER_LINK_PREFIX, +} from "./native-doc.js"; import type { FolioMark, FolioNode } from "./native-doc.js"; const MONO_FONT = "Consolas"; @@ -165,7 +169,7 @@ function textNodeRuns(node: FolioNode, ctx: InlineCtx): (TextRun | ExternalHyper const href = String(linkMark.attrs?.href ?? ""); // Wikilinks point back into the book; the export has no in-app chapter URLs, // so they are rendered as plain label text. - if (href.startsWith("folio:chapter/") || href === "#" || href.startsWith("folio:")) return [run]; + if (href.startsWith(CHAPTER_LINK_PREFIX) || href === "#" || href.startsWith("folio:")) return [run]; if (href) return [new ExternalHyperlink({ link: href, children: [run] })]; return [run]; } @@ -254,7 +258,7 @@ function renderNodes(nodes: FolioNode[], bookPath: string): BlockEl[] { break; } case "paragraph": { - const isTitle = String(n.attrs?.class ?? "") === "callout-title"; + const isTitle = String(n.attrs?.class ?? "") === CALLOUT_TITLE_CLASS; out.push( new Paragraph({ children: renderInline(n.content, isTitle ? { ...defaultCtx, bold: true } : defaultCtx), @@ -360,7 +364,7 @@ function renderQuoted(content: FolioNode[], isCallout: boolean, bookPath: string const out: BlockEl[] = []; for (const block of content) { if (block.type === "paragraph") { - const isTitle = String(block.attrs?.class ?? "") === "callout-title"; + const isTitle = String(block.attrs?.class ?? "") === CALLOUT_TITLE_CLASS; out.push( new Paragraph({ children: renderInline((block.content ?? []) as FolioNode[], isTitle ? { ...defaultCtx, bold: true } : defaultCtx), diff --git a/src/renderer/print-format.ts b/src/renderer/print-format.ts index 1b3ad2b..673a28b 100644 --- a/src/renderer/print-format.ts +++ b/src/renderer/print-format.ts @@ -6,6 +6,10 @@ //