From 24370ca51177ab757aaaf4fcf402c43c83352807 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Sat, 27 Jun 2026 11:36:32 +0800 Subject: [PATCH] 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) --- .../src/__tests__/services/constants.test.ts | 1 + .../src/__tests__/utils/md.test.ts | 162 +++++++++++++ apps/readest-app/src/libs/document.ts | 17 ++ apps/readest-app/src/services/constants.ts | 1 + apps/readest-app/src/utils/md.ts | 212 ++++++++++++++++++ apps/readest-app/src/utils/sanitize.ts | 6 +- 6 files changed, 398 insertions(+), 1 deletion(-) create mode 100644 apps/readest-app/src/__tests__/utils/md.test.ts create mode 100644 apps/readest-app/src/utils/md.ts diff --git a/apps/readest-app/src/__tests__/services/constants.test.ts b/apps/readest-app/src/__tests__/services/constants.test.ts index 50fcc7ff..fe37f0fb 100644 --- a/apps/readest-app/src/__tests__/services/constants.test.ts +++ b/apps/readest-app/src/__tests__/services/constants.test.ts @@ -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', () => { diff --git a/apps/readest-app/src/__tests__/utils/md.test.ts b/apps/readest-app/src/__tests__/utils/md.test.ts new file mode 100644 index 00000000..ebf92cf8 --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/md.test.ts @@ -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; + sections: Array 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 => + 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\n\n\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'); + } + }); +}); diff --git a/apps/readest-app/src/libs/document.ts b/apps/readest-app/src/libs/document.ts index f25d2a8f..c09c8b11 100644 --- a/apps/readest-app/src/libs/document.ts +++ b/apps/readest-app/src/libs/document.ts @@ -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 }); diff --git a/apps/readest-app/src/services/constants.ts b/apps/readest-app/src/services/constants.ts index ba82102c..bb64847f 100644 --- a/apps/readest-app/src/services/constants.ts +++ b/apps/readest-app/src/services/constants.ts @@ -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 = ''; diff --git a/apps/readest-app/src/utils/md.ts b/apps/readest-app/src/utils/md.ts new file mode 100644 index 00000000..5b31315b --- /dev/null +++ b/apps/readest-app/src/utils/md.ts @@ -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 => + '\n' + + `` + + `${inner}`; + +interface Frontmatter { + title?: string; + author?: string; +} + +// Strip a leading YAML frontmatter block so it does not render as a stray +// `
` + 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 { + 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(); + 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

boundaries. Content before + // the first

becomes a leading preamble section (only when it has real + // content). A document with no

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 + // (
,
, ) 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(); + 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 => null, + destroy: () => { + for (const url of urls) if (url) URL.revokeObjectURL(url); + }, + }; + + return book as unknown as BookDoc; +} diff --git a/apps/readest-app/src/utils/sanitize.ts b/apps/readest-app/src/utils/sanitize.ts index e3bf42b6..f0aa1eaa 100644 --- a/apps/readest-app/src/utils/sanitize.ts +++ b/apps/readest-app/src/utils/sanitize.ts @@ -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'],