feat(reader): render Markdown (.md) files at runtime (#774) (#4816)

Open standalone .md files in the reader without converting to EPUB. A new
makeMarkdownBook (src/utils/md.ts) parses Markdown to sanitized HTML with
marked + DOMPurify, splits the document into sections at H1 boundaries, and
builds an in-memory foliate book (modeled on fb2.js) with a nested heading
TOC. DocumentLoader routes .md/.markdown before the TXT path so a Markdown
file served as text/plain is not converted to EPUB. Layout, font and theme
settings apply the same as for any other format.

Relative-image resolution and Markdown bundle/folder packages are left as
follow-ups (a standalone file has no sibling-asset access on the web).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-27 11:36:32 +08:00
committed by GitHub
parent 580c5e5deb
commit 24370ca511
6 changed files with 398 additions and 1 deletions
@@ -142,6 +142,7 @@ describe('services/constants', () => {
expect(SUPPORTED_BOOK_EXTS).toContain('pdf');
expect(SUPPORTED_BOOK_EXTS).toContain('mobi');
expect(SUPPORTED_BOOK_EXTS).toContain('txt');
expect(SUPPORTED_BOOK_EXTS).toContain('md');
});
it('BOOK_ACCEPT_FORMATS is a comma-separated string of dotted extensions', () => {
@@ -0,0 +1,162 @@
import { describe, it, expect, vi } from 'vitest';
import { makeMarkdownBook } from '@/utils/md';
import type { BookDoc } from '@/libs/document';
// makeMarkdownBook returns a foliate book with a few methods (resolveHref,
// isExternal, destroy) and a per-section load() that the BookDoc type does not
// declare. Cast to this richer shape in tests to exercise them.
type MdBook = BookDoc & {
toc: NonNullable<BookDoc['toc']>;
sections: Array<BookDoc['sections'][number] & { load: () => string }>;
resolveHref: (
href: string,
) => { index: number; anchor: (doc: Document) => Element | null } | null;
isExternal: (uri: string) => boolean;
destroy: () => void;
};
const mdFile = (content: string, name = 'note.md', type = 'text/markdown') =>
new File([content], name, { type });
const make = async (content: string, name?: string, type?: string) =>
(await makeMarkdownBook(mdFile(content, name, type))) as unknown as MdBook;
const flattenToc = (items: BookDoc['toc'] = []): NonNullable<BookDoc['toc']> =>
items.flatMap((i) => [i, ...(i.subitems ? flattenToc(i.subitems) : [])]);
describe('makeMarkdownBook', () => {
it('renders headings, inline marks, lists, code and tables', async () => {
const book = await make(
'# Title\n\nHello **world**.\n\n- a\n- b\n\n```js\nconst x = 1;\n```\n\n| h |\n| - |\n| v |\n',
);
const doc = await book.sections[0]!.createDocument();
expect(doc.querySelector('parsererror')).toBeNull();
expect(doc.querySelector('h1')?.textContent).toBe('Title');
expect(doc.querySelector('strong')?.textContent).toBe('world');
expect(doc.querySelectorAll('li').length).toBe(2);
expect(doc.querySelector('table')).toBeTruthy();
expect(doc.querySelector('code')?.getAttribute('class')).toContain('language-js');
});
it('splits at H1 into one section per chapter, nests deeper headings in the TOC', async () => {
const book = await make('# One\n\na\n\n## Sub\n\nb\n\n# Two\n\nc\n');
expect(book.sections.map((s) => s.id)).toEqual(['0', '1']);
expect(book.toc.length).toBe(2);
expect(book.toc[0]!.label).toBe('One');
expect(book.toc[0]!.href).toBe('0#one');
expect(book.toc[0]!.subitems![0]!.label).toBe('Sub');
expect(book.toc[0]!.subitems![0]!.href).toBe('0#sub');
expect(book.toc[1]!.href).toBe('1#two');
});
it('treats content before the first H1 as a leading preamble section', async () => {
const book = await make('Intro text.\n\n# Only\n\nbody\n');
expect(book.sections.length).toBe(2);
expect(book.toc.length).toBe(1);
expect(book.toc[0]!.href).toBe('1#only');
});
it('yields a single section with a usable TOC when there is no H1', async () => {
const book = await make('## Sub only\n\ntext\n');
expect(book.sections.length).toBe(1);
expect(book.toc.length).toBe(1);
expect(book.toc[0]!.href).toBe('0#sub-only');
});
it('de-duplicates heading ids deterministically', async () => {
const book = await make('# Dup\n\n## Dup\n\n### Dup\n');
const ids = flattenToc(book.toc).map((i) => i.href.split('#')[1]);
expect(new Set(ids).size).toBe(ids.length);
expect(ids).toEqual(['dup', 'dup-1', 'dup-2']);
});
it('keeps section ids as strings that resolve in a string-keyed section map (nav contract)', async () => {
const book = await make('# A\n\n## A1\n\n# B\n');
const sectionMap = new Map(book.sections.map((s) => [s.id, s]));
for (const item of flattenToc(book.toc)) {
const [sid] = book.splitTOCHref(item.href);
expect(typeof sid).toBe('string');
expect(sectionMap.get(sid as string)).toBeDefined();
}
});
it('produces XHTML that parses without errors despite void tags', async () => {
const book = await make('# H\n\nline one \nline two\n\n---\n\n![alt](https://e.com/i.png)\n');
const doc = await book.sections[0]!.createDocument();
expect(doc.querySelector('parsererror')).toBeNull();
expect(doc.querySelector('br')).toBeTruthy();
expect(doc.querySelector('img')?.getAttribute('src')).toBe('https://e.com/i.png');
});
it('resolves TOC links, internal anchors, and rejects unknown/external', async () => {
const book = await make('# A\n\njump [x](#b-sec)\n\n# B Sec\n');
expect(book.resolveHref('1#b-sec')).toMatchObject({ index: 1 });
expect(book.resolveHref('#b-sec')).toMatchObject({ index: 1 });
expect(book.resolveHref('#missing')).toBeNull();
expect(book.resolveHref('9#x')).toBeNull();
expect(book.isExternal('https://example.com')).toBe(true);
expect(book.isExternal('mailto:a@b.com')).toBe(true);
expect(book.isExternal('#b-sec')).toBe(false);
});
it('strips scripts and event handlers but keeps code class and heading ids', async () => {
const book = await make(
'# H\n\n<script>alert(1)</script>\n\n<img src="x" onerror="alert(1)">\n\n```ts\nok\n```\n',
);
const doc = await book.sections[0]!.createDocument();
expect(doc.querySelector('script')).toBeNull();
expect(doc.querySelector('img')?.getAttribute('onerror')).toBeNull();
expect(doc.querySelector('code')?.getAttribute('class')).toContain('language-ts');
expect(doc.querySelector('h1')?.getAttribute('id')).toBeTruthy();
});
it('resolves the title from frontmatter, then first H1, then filename', async () => {
const fm = await make('---\ntitle: From Front\nauthor: Jane Doe\n---\n\n# Ignored\n');
expect(fm.metadata.title).toBe('From Front');
expect(fm.metadata.author).toBe('Jane Doe');
const h1 = await make('# The Heading\n\nbody\n');
expect(h1.metadata.title).toBe('The Heading');
const fn = await make('just text\n', 'My Notes.md');
expect(fn.metadata.title).toBe('My Notes');
});
it('creates object URLs lazily and revokes every one on destroy', async () => {
const url = URL as unknown as {
createObjectURL: (b: Blob) => string;
revokeObjectURL: (u: string) => void;
};
const origCreate = url.createObjectURL;
const origRevoke = url.revokeObjectURL;
const revoked: string[] = [];
let n = 0;
url.createObjectURL = vi.fn(() => `blob:md-${n++}`);
url.revokeObjectURL = vi.fn((u: string) => void revoked.push(u));
try {
const book = await make('# A\n\n# B\n');
expect(url.createObjectURL).toHaveBeenCalledTimes(0); // lazy
const u0 = book.sections[0]!.load();
expect(book.sections[0]!.load()).toBe(u0); // cached per section
expect(url.createObjectURL).toHaveBeenCalledTimes(1);
book.destroy();
expect(revoked).toContain(u0);
} finally {
url.createObjectURL = origCreate;
url.revokeObjectURL = origRevoke;
}
});
});
describe('DocumentLoader markdown routing', () => {
it('routes .md, .markdown and a markdown blob typed text/plain to MD format', async () => {
const { DocumentLoader } = await import('@/libs/document');
const cases = [
mdFile('# X\n', 'a.md', 'text/markdown'),
mdFile('# X\n', 'a.markdown', ''),
mdFile('# X\n', 'a.md', 'text/plain'),
];
for (const file of cases) {
const { format } = await new DocumentLoader(file).open();
expect(format).toBe('MD');
}
});
});
+17
View File
@@ -337,6 +337,16 @@ export class DocumentLoader {
);
}
private isMd(): boolean {
const name = this.file.name?.toLowerCase() ?? '';
return (
this.file.type === 'text/markdown' ||
this.file.type === 'text/x-markdown' ||
name.endsWith(`.${EXTS.MD}`) ||
name.endsWith('.markdown')
);
}
public async open(): Promise<{ book: BookDoc; format: BookFormat }> {
let book = null;
let format: BookFormat = 'EPUB';
@@ -349,6 +359,13 @@ export class DocumentLoader {
// conversion the import path runs) and parse that. The managed library
// stores the already-converted EPUB, but the Android "Open with" transient
// path points the book at the original .txt, so it reaches us unconverted.
// Markdown is rendered to HTML at runtime (no EPUB conversion). Check
// this BEFORE isTxt() — a .md served as text/plain would otherwise be
// grabbed by the TXT->EPUB path above.
if (this.isMd()) {
const { makeMarkdownBook } = await import('@/utils/md');
return { book: await makeMarkdownBook(this.file), format: 'MD' };
}
if (this.isTxt()) {
const { TxtToEpubConverter } = await import('@/utils/txt');
const { file: epubFile } = await new TxtToEpubConverter().convert({ file: this.file });
@@ -53,6 +53,7 @@ export const SUPPORTED_BOOK_EXTS = [
'cbz',
'pdf',
'txt',
'md',
];
export const BOOK_ACCEPT_FORMATS = SUPPORTED_BOOK_EXTS.map((ext) => `.${ext}`).join(', ');
export const BOOK_UNGROUPED_NAME = '';
+212
View File
@@ -0,0 +1,212 @@
import { marked } from 'marked';
import type { BookDoc, SectionItem } from '@/libs/document';
import { sanitizeHtml } from './sanitize';
// Render a standalone Markdown (.md) file into an in-memory foliate-js book at
// runtime (no EPUB conversion). The document is split into one section per
// top-level heading so reading progress, the cross-device location cursor and
// TTS section tracking all work per chapter — the same contract fb2.js gives a
// single-file format. Layout/font/theme styling applies for free because the
// reader styles whatever HTML the view renders.
const XHTML_NS = 'http://www.w3.org/1999/xhtml';
// Minimal defaults so code blocks wrap inside the paginated column (long lines
// would otherwise overflow and break pagination) and tables/images stay legible
// under every theme. `currentColor` keeps borders readable in dark / e-ink.
const MD_STYLE = `
img { max-width: 100%; height: auto; }
pre { white-space: pre-wrap; overflow-wrap: break-word; }
pre, code { font-family: monospace; }
table { border-collapse: collapse; }
th, td { border: 1px solid currentColor; padding: 0.2em 0.5em; }
blockquote { margin-inline: 1em; }
`;
const wrapXhtml = (inner: string): string =>
'<?xml version="1.0" encoding="utf-8"?>\n' +
`<html xmlns="${XHTML_NS}"><head><meta charset="utf-8"/>` +
`<style>${MD_STYLE}</style></head><body>${inner}</body></html>`;
interface Frontmatter {
title?: string;
author?: string;
}
// Strip a leading YAML frontmatter block so it does not render as a stray
// `<hr>` + text, and lift `title` / `author` from it.
const stripFrontmatter = (text: string): { body: string; meta: Frontmatter } => {
const match = text.match(/^?---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n?/);
if (!match) return { body: text, meta: {} };
const meta: Frontmatter = {};
for (const line of match[1]!.split(/\r?\n/)) {
const kv = line.match(/^([A-Za-z][\w-]*)\s*:\s*(.*)$/);
if (!kv) continue;
const key = kv[1]!.toLowerCase();
const value = kv[2]!.trim().replace(/^['"]|['"]$/g, '');
if (key === 'title') meta.title = value;
else if (key === 'author') meta.author = value;
}
return { body: text.slice(match[0]!.length), meta };
};
const slugify = (text: string): string =>
text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/^-+|-+$/g, '') || 'section';
const isExternalUri = (uri: string): boolean => /^(?:https?|mailto|tel):/i.test(uri);
interface TocNode {
label: string;
href: string;
subitems?: TocNode[];
}
type MarkdownSection = SectionItem & { load: () => string };
export async function makeMarkdownBook(file: File): Promise<BookDoc> {
const text = await file.text();
const { body, meta } = stripFrontmatter(text);
const rawHtml = await marked.parse(body, { gfm: true });
const safeHtml = sanitizeHtml(rawHtml);
const docBody = new DOMParser().parseFromString(safeHtml, 'text/html').body;
// Ensure every id is unique (including author-provided ids on raw HTML /
// footnotes), then give each heading a stable slug id for TOC anchors and
// internal-link resolution.
const usedIds = new Set<string>();
for (const el of Array.from(docBody.querySelectorAll('[id]'))) {
if (el.id) usedIds.add(el.id);
}
const uniqueId = (base: string): string => {
let id = base;
let n = 1;
while (usedIds.has(id)) id = `${base}-${n++}`;
usedIds.add(id);
return id;
};
const headingEls = Array.from(docBody.querySelectorAll('h1, h2, h3'));
for (const h of headingEls) {
if (!h.id) h.id = uniqueId(slugify(h.textContent ?? ''));
}
// Split the top-level nodes into sections at <h1> boundaries. Content before
// the first <h1> becomes a leading preamble section (only when it has real
// content). A document with no <h1> stays a single section.
const hasContent = (nodes: ChildNode[]): boolean =>
nodes.some(
(n) =>
n.nodeType === Node.ELEMENT_NODE ||
(n.nodeType === Node.TEXT_NODE && !!n.textContent?.trim()),
);
const groups: ChildNode[][] = [];
let current: ChildNode[] = [];
for (const node of Array.from(docBody.childNodes)) {
if (node.nodeType === Node.ELEMENT_NODE && (node as Element).tagName === 'H1') {
if (hasContent(current)) groups.push(current);
current = [node];
} else {
current.push(node);
}
}
if (hasContent(current)) groups.push(current);
if (groups.length === 0) groups.push([]);
// Serialize each section to well-formed XHTML. Marked emits HTML5 void tags
// (<br>, <hr>, <img>) that are parse errors under application/xhtml+xml, so
// XMLSerializer (not innerHTML) is required. Map every id to its section.
const serializer = new XMLSerializer();
const idMap = new Map<string, number>();
const xhtml = groups.map((nodes, index) => {
for (const node of nodes) {
if (node.nodeType !== Node.ELEMENT_NODE) continue;
const el = node as Element;
if (el.id) idMap.set(el.id, index);
for (const child of Array.from(el.querySelectorAll('[id]'))) {
if (child.id) idMap.set(child.id, index);
}
}
return wrapXhtml(nodes.map((n) => serializer.serializeToString(n)).join(''));
});
// Build a nested heading outline as the TOC, each entry linking to its
// section index plus heading anchor.
const root: TocNode[] = [];
const stack: { level: number; subitems: TocNode[] }[] = [{ level: 0, subitems: root }];
for (const h of headingEls) {
const label = (h.textContent ?? '').trim();
if (!label) continue;
const level = Number(h.tagName.slice(1));
const item: TocNode = { label, href: `${idMap.get(h.id) ?? 0}#${h.id}`, subitems: [] };
while (stack[stack.length - 1]!.level >= level) stack.pop();
stack[stack.length - 1]!.subitems.push(item);
stack.push({ level, subitems: item.subitems! });
}
const prune = (items: TocNode[]): TocNode[] =>
items.map(({ label, href, subitems }) => ({
label,
href,
subitems: subitems && subitems.length ? prune(subitems) : undefined,
}));
const toc = prune(root);
const urls: (string | undefined)[] = new Array(xhtml.length).fill(undefined);
const sections: MarkdownSection[] = xhtml.map((str, index) => ({
id: String(index),
cfi: '',
size: new TextEncoder().encode(str).length,
linear: 'yes',
load: () => {
if (urls[index] === undefined) {
urls[index] = URL.createObjectURL(new Blob([str], { type: 'application/xhtml+xml' }));
}
return urls[index]!;
},
loadText: async () => str,
createDocument: async () => new DOMParser().parseFromString(str, 'application/xhtml+xml'),
}));
const title =
meta.title?.trim() ||
(headingEls.find((h) => h.tagName === 'H1')?.textContent ?? '').trim() ||
file.name.replace(/\.(?:md|markdown)$/i, '');
const book = {
metadata: {
title,
author: meta.author?.trim() ?? '',
language: 'en',
identifier: file.name,
},
rendition: { layout: 'reflowable' as const },
dir: 'ltr',
toc,
sections,
splitTOCHref: (href: string): string[] => (href ? href.split('#') : []),
getTOCFragment: (doc: Document, id: string): Element | null => doc.getElementById(id),
resolveHref: (href: string) => {
const [a, b] = href.split('#');
if (a) {
const index = Number(a);
if (!Number.isInteger(index) || index < 0 || index >= sections.length) return null;
return { index, anchor: (doc: Document) => (b ? doc.getElementById(b) : null) };
}
if (!b) return null;
const index = idMap.get(b);
if (index === undefined) return null;
return { index, anchor: (doc: Document) => doc.getElementById(b) };
},
isExternal: (uri: string): boolean => isExternalUri(uri),
getCover: async (): Promise<Blob | null> => null,
destroy: () => {
for (const url of urls) if (url) URL.revokeObjectURL(url);
},
};
return book as unknown as BookDoc;
}
+5 -1
View File
@@ -35,6 +35,8 @@ export function sanitizeHtml(html: string): string {
'i',
'u',
's',
'del',
'ins',
'sup',
'sub',
'span',
@@ -58,7 +60,9 @@ export function sanitizeHtml(html: string): string {
// `id` is allowed so heading anchors survive — the EPUB's nested
// navMap uses `chapter1.xhtml#heading-id` to link the TOC sidebar to
// each section. Without it readers see one entry per chapter only.
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'colspan', 'rowspan', 'id'],
// `class` is allowed so Markdown code fences keep their `language-*`
// class for theming (see utils/md.ts).
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'colspan', 'rowspan', 'id', 'class'],
// Drop anything that would load or run remote code.
FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form', 'input'],
FORBID_ATTR: ['srcset'],