forked from akai/readest
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:
@@ -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\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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user