refactor(conversion): centralize format contracts in native-doc.ts; document lossiness matrix

This commit is contained in:
avi 2026-08-24 22:00:12 -05:00
commit 4ce0a5ebfb
6 changed files with 48 additions and 12 deletions

View file

@ -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);

View file

@ -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/<id>. 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<number, (doc: FolioChapterDoc) => FolioChapterDoc> = {};

View file

@ -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/<id> 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 `<span style="color:${attrs.color}">${text}</span>`;
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);

View file

@ -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),

View file

@ -6,6 +6,10 @@
// <section class="print-chapter"> blocks, delimited by the chapterBreak nodes
// the Full Book assembly inserts between chapters.
import type { FolioMark, FolioNode } from "../main/native-doc.js";
import {
CALLOUT_TITLE_CLASS,
CHAPTER_LINK_PREFIX,
} from "../main/native-doc.js";
// Native-document print rendering for the PDF-exported manuscript (#printBook).
//
@ -56,7 +60,7 @@ function applyMarksHtml(text: string, marks: FolioMark[] = []): string {
const link = marks.find((m) => m.type === "link");
if (link?.attrs?.href) {
const href = String(link.attrs.href);
const internal = href.startsWith("folio:chapter/");
const internal = href.startsWith(CHAPTER_LINK_PREFIX);
const cls = internal ? ' class="wikilink internal"' : "";
out = `<a href="${esc(href)}"${cls}>${out}</a>`;
}
@ -84,8 +88,8 @@ function blockToHtml(node: FolioNode): string {
return `<h${level}${ALIGN(node)}>${inlineToHtml(node.content)}</h${level}>`;
}
case "paragraph": {
const isTitle = String(node.attrs?.class ?? "") === "callout-title";
const cls = isTitle ? ' class="callout-title"' : "";
const isTitle = String(node.attrs?.class ?? "") === CALLOUT_TITLE_CLASS;
const cls = isTitle ? ` class="${CALLOUT_TITLE_CLASS}"` : "";
return `<p${cls}${ALIGN(node)}>${inlineToHtml(node.content)}</p>`;
}
case "bulletList":

View file

@ -1,4 +1,5 @@
import type { MarkdownIt, StateInline } from "markdown-it";
import { CHAPTER_LINK_PREFIX } from "../main/native-doc.js";
export interface WikilinkTarget {
id: string;
@ -65,7 +66,7 @@ export function wikilinkPlugin(md: MarkdownIt, opts: WikilinkOpts): void {
const open = state.push("link_open", "a", 1);
if (id !== null) {
open.attrs = [
["href", `folio:chapter/${encodeURIComponent(id)}`],
["href", `${CHAPTER_LINK_PREFIX}${encodeURIComponent(id)}`],
["class", "wikilink internal"],
];
} else {