md-pdf-studio 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,337 @@
1
+ const crypto = require('crypto');
2
+ const hljs = require('highlight.js/lib/common');
3
+ const { buildDocument, collectHeadings, isPortableLink, plainText } = require('./docmodel');
4
+ const { readLocalImage } = require('./images');
5
+ const { createZip } = require('./zip');
6
+
7
+ /**
8
+ * Markdown to EPUB 3, written from the document model. Every page is XHTML
9
+ * serialized here, so it is well formed whatever raw HTML the Markdown holds.
10
+ *
11
+ * The book is split into one file per top-level heading, with a navigation
12
+ * document (and an NCX for older readers) built from the headings. Local
13
+ * images are embedded; an image on the web is replaced by a link to it, since
14
+ * a reader may be offline. Themes, custom CSS, Mermaid and KaTeX are not
15
+ * carried over: a reading system applies its own typography.
16
+ */
17
+
18
+ function xml(text) {
19
+ return String(text)
20
+ .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F￾￿]/g, '')
21
+ .replace(/&/g, '&')
22
+ .replace(/</g, '&lt;')
23
+ .replace(/>/g, '&gt;')
24
+ .replace(/"/g, '&quot;');
25
+ }
26
+
27
+ const STYLESHEET = `body { font-family: serif; line-height: 1.5; margin: 0 5%; }
28
+ h1, h2, h3, h4, h5, h6 { font-family: sans-serif; line-height: 1.25; page-break-after: avoid; }
29
+ h1 { font-size: 1.8em; margin-top: 1.5em; }
30
+ h2 { font-size: 1.4em; }
31
+ h3 { font-size: 1.2em; }
32
+ p { margin: 0.6em 0; }
33
+ a { color: #0b57d0; }
34
+ img { max-width: 100%; height: auto; }
35
+ code { font-family: monospace; font-size: 0.9em; background: #f2f2f2; padding: 0.1em 0.25em; }
36
+ pre { background: #f5f5f5; padding: 0.8em; white-space: pre-wrap; word-wrap: break-word; font-size: 0.85em; }
37
+ pre code { background: none; padding: 0; font-size: 1em; }
38
+ blockquote { margin: 0.8em 0; padding-left: 1em; border-left: 0.25em solid #bfbfbf; color: #595959; }
39
+ table { border-collapse: collapse; margin: 0.8em 0; width: 100%; }
40
+ th, td { border: 1px solid #bfbfbf; padding: 0.3em 0.5em; text-align: left; vertical-align: top; }
41
+ th { background: #f2f2f2; }
42
+ hr { border: 0; border-top: 1px solid #bfbfbf; margin: 1.5em 0; }
43
+ ul.tasks { list-style: none; padding-left: 1.2em; }
44
+ .page-break { page-break-after: always; }
45
+ .cover { text-align: center; margin-top: 30%; }
46
+ .cover h1 { font-size: 2.2em; }
47
+ .cover .subtitle { font-size: 1.3em; color: #595959; }
48
+ .cover .line { color: #595959; }
49
+ nav ol { list-style: none; padding-left: 1.2em; }
50
+ .hljs-comment, .hljs-quote { color: #6a737d; }
51
+ .hljs-keyword, .hljs-selector-tag, .hljs-literal, .hljs-type { color: #d73a49; }
52
+ .hljs-string, .hljs-regexp, .hljs-attr, .hljs-addition { color: #22863a; }
53
+ .hljs-number, .hljs-built_in, .hljs-symbol, .hljs-meta { color: #005cc5; }
54
+ .hljs-title, .hljs-section, .hljs-name, .hljs-function { color: #6f42c1; }
55
+ .hljs-deletion { color: #b31d28; }
56
+ `;
57
+
58
+ class EpubWriter {
59
+ constructor(document, baseDir) {
60
+ this.document = document;
61
+ this.baseDir = baseDir;
62
+ this.images = [];
63
+ this.imagePaths = new Map();
64
+ // Where each heading ends up, so a link to `#slug` can name the right file.
65
+ this.targets = new Map();
66
+ }
67
+
68
+ image(run) {
69
+ const { src, alt } = run.image;
70
+ if (!this.imagePaths.has(src)) {
71
+ const image = readLocalImage(src, this.baseDir);
72
+ if (image) {
73
+ const name = `images/image-${this.images.length + 1}.${image.extension}`;
74
+ this.images.push({ name, data: image.data, mediaType: image.mediaType });
75
+ this.imagePaths.set(src, name);
76
+ } else {
77
+ this.imagePaths.set(src, null);
78
+ }
79
+ }
80
+ const path = this.imagePaths.get(src);
81
+ if (path) return `<img src="${xml(path)}" alt="${xml(alt)}"/>`;
82
+ // Not a local file: a link stands in for it when it has a web address.
83
+ const label = xml(alt || src);
84
+ return /^https?:\/\//i.test(src) ? `<a href="${xml(src)}">${label}</a>` : `<em>[${label}]</em>`;
85
+ }
86
+
87
+ /** The address a link is written with, or null when it would lead nowhere from inside the book. */
88
+ href(target) {
89
+ if (target[0] !== '#') return isPortableLink(target) ? target : null;
90
+ let id = target.slice(1);
91
+ try {
92
+ id = decodeURIComponent(id);
93
+ } catch (_) {
94
+ // Used as written.
95
+ }
96
+ const file = this.targets.get(id);
97
+ return file ? `${file}#${encodeURIComponent(id)}` : null;
98
+ }
99
+
100
+ runs(runs) {
101
+ return runs
102
+ .map(run => {
103
+ let html;
104
+ if (run.lineBreak) return '<br/>';
105
+ if (run.image) html = this.image(run);
106
+ else html = xml(run.text);
107
+ if (run.code) html = `<code>${html}</code>`;
108
+ if (run.sup) html = `<sup>${html}</sup>`;
109
+ else if (run.sub) html = `<sub>${html}</sub>`;
110
+ if (run.strike) html = `<del>${html}</del>`;
111
+ if (run.italic) html = `<em>${html}</em>`;
112
+ if (run.bold) html = `<strong>${html}</strong>`;
113
+ const href = run.href ? this.href(run.href) : null;
114
+ // An image that already became a link is not wrapped in a second one.
115
+ if (href && !html.startsWith('<a ')) html = `<a href="${xml(href)}">${html}</a>`;
116
+ return html;
117
+ })
118
+ .join('');
119
+ }
120
+
121
+ code(block) {
122
+ const highlight = this.document.settings.highlight && block.lang && hljs.getLanguage(block.lang);
123
+ // highlight.js escapes the code and adds only balanced spans, so its output is XHTML too.
124
+ const body = highlight ? hljs.highlight(block.text, { language: block.lang, ignoreIllegals: true }).value : xml(block.text);
125
+ return `<pre><code>${body}</code></pre>\n`;
126
+ }
127
+
128
+ blocks(blocks) {
129
+ let output = '';
130
+ for (const block of blocks) {
131
+ switch (block.type) {
132
+ case 'heading': {
133
+ const tag = `h${Math.min(block.depth, 6)}`;
134
+ output += `<${tag} id="${xml(block.id)}">${this.runs(block.runs)}</${tag}>\n`;
135
+ break;
136
+ }
137
+ case 'paragraph':
138
+ output += `<p>${this.runs(block.runs)}</p>\n`;
139
+ break;
140
+ case 'code':
141
+ output += this.code(block);
142
+ break;
143
+ case 'quote':
144
+ output += `<blockquote>\n${this.blocks(block.blocks) || '<p></p>\n'}</blockquote>\n`;
145
+ break;
146
+ case 'list': {
147
+ const tasks = block.items.some(item => item.checked !== null);
148
+ const tag = block.ordered ? 'ol' : 'ul';
149
+ const attributes = block.ordered && block.start !== 1 ? ` start="${block.start}"` : !block.ordered && tasks ? ' class="tasks"' : '';
150
+ const items = block.items.map(item => {
151
+ const box = item.checked === null ? '' : item.checked ? '☑ ' : '☐ ';
152
+ // A tight item is one line of text: no paragraph around it.
153
+ const single = item.blocks.length === 1 && item.blocks[0].type === 'paragraph';
154
+ const body = single ? box + this.runs(item.blocks[0].runs) : box + this.blocks(item.blocks);
155
+ return `<li>${body}</li>\n`;
156
+ });
157
+ output += `<${tag}${attributes}>\n${items.join('') || '<li></li>\n'}</${tag}>\n`;
158
+ break;
159
+ }
160
+ case 'table': {
161
+ const cell = (tag, source) => {
162
+ const align = source.align && source.align !== 'left' ? ` style="text-align: ${source.align}"` : '';
163
+ return `<${tag}${align}>${this.runs(source.runs)}</${tag}>`;
164
+ };
165
+ output +=
166
+ `<table>\n<thead>\n<tr>${block.header.map(source => cell('th', source)).join('')}</tr>\n</thead>\n` +
167
+ (block.rows.length
168
+ ? `<tbody>\n${block.rows.map(row => `<tr>${row.map(source => cell('td', source)).join('')}</tr>\n`).join('')}</tbody>\n`
169
+ : '') +
170
+ '</table>\n';
171
+ break;
172
+ }
173
+ case 'rule':
174
+ output += '<hr/>\n';
175
+ break;
176
+ case 'pageBreak':
177
+ output += '<div class="page-break"></div>\n';
178
+ break;
179
+ default:
180
+ break;
181
+ }
182
+ }
183
+ return output;
184
+ }
185
+
186
+ page(title, body, bodyAttributes = '') {
187
+ const language = xml(this.document.meta.language);
188
+ return (
189
+ '<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE html>\n' +
190
+ `<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="${language}" xml:lang="${language}">\n` +
191
+ `<head>\n<meta charset="UTF-8"/>\n<title>${xml(title)}</title>\n<link rel="stylesheet" type="text/css" href="style.css"/>\n</head>\n` +
192
+ `<body${bodyAttributes}>\n${body}</body>\n</html>\n`
193
+ );
194
+ }
195
+
196
+ /** The blocks cut into chapters: a new one at every top-level heading. */
197
+ chapters() {
198
+ const { blocks, meta } = this.document;
199
+ const chapters = [];
200
+ for (const block of blocks) {
201
+ const opens = block.type === 'heading' && block.depth === 1;
202
+ if (opens || chapters.length === 0) {
203
+ chapters.push({ title: opens ? plainText(block.runs) : meta.title, blocks: [] });
204
+ }
205
+ chapters[chapters.length - 1].blocks.push(block);
206
+ }
207
+ if (chapters.length === 0) chapters.push({ title: meta.title, blocks: [] });
208
+ chapters.forEach((chapter, index) => {
209
+ chapter.file = `chapter-${index + 1}.xhtml`;
210
+ for (const heading of collectHeadings(chapter.blocks)) this.targets.set(heading.id, chapter.file);
211
+ });
212
+ return chapters;
213
+ }
214
+
215
+ /** Headings up to the third level as a nested list of { title, href, children }. */
216
+ outline(chapters) {
217
+ const root = { depth: 0, children: [] };
218
+ const stack = [root];
219
+ for (const chapter of chapters) {
220
+ const headings = collectHeadings(chapter.blocks).filter(heading => heading.depth <= 3);
221
+ // A chapter that opens with no heading still has to be reachable.
222
+ if (!headings.length || chapter.blocks[0] !== headings[0]) {
223
+ stack.length = 1;
224
+ const entry = { depth: 1, title: chapter.title, href: chapter.file, children: [] };
225
+ root.children.push(entry);
226
+ stack.push(entry);
227
+ }
228
+ for (const heading of headings) {
229
+ while (stack[stack.length - 1].depth >= heading.depth) stack.pop();
230
+ const entry = { depth: heading.depth, title: plainText(heading.runs) || 'Untitled', href: `${chapter.file}#${encodeURIComponent(heading.id)}`, children: [] };
231
+ stack[stack.length - 1].children.push(entry);
232
+ stack.push(entry);
233
+ }
234
+ }
235
+ return root.children;
236
+ }
237
+
238
+ build(now = new Date()) {
239
+ const { meta, settings } = this.document;
240
+ const chapters = this.chapters();
241
+ const outline = this.outline(chapters);
242
+ const pages = chapters.map(chapter => ({ name: chapter.file, data: this.page(chapter.title, this.blocks(chapter.blocks)) }));
243
+
244
+ const navList = entries =>
245
+ `<ol>\n${entries
246
+ .map(entry => `<li><a href="${xml(entry.href)}">${xml(entry.title)}</a>${entry.children.length ? `\n${navList(entry.children)}` : ''}</li>\n`)
247
+ .join('')}</ol>\n`;
248
+ const nav = this.page(
249
+ 'Table of Contents',
250
+ `<nav epub:type="toc" id="toc">\n<h1>Table of Contents</h1>\n${navList(outline)}</nav>\n`
251
+ );
252
+
253
+ let playOrder = 0;
254
+ const navPoints = entries =>
255
+ entries
256
+ .map(entry => {
257
+ const order = ++playOrder;
258
+ return (
259
+ `<navPoint id="nav-${order}" playOrder="${order}"><navLabel><text>${xml(entry.title)}</text></navLabel>` +
260
+ `<content src="${xml(entry.href)}"/>${navPoints(entry.children)}</navPoint>\n`
261
+ );
262
+ })
263
+ .join('');
264
+ const identifier = `urn:uuid:${crypto.randomUUID()}`;
265
+ const ncx =
266
+ '<?xml version="1.0" encoding="UTF-8"?>\n<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">\n' +
267
+ `<head><meta name="dtb:uid" content="${identifier}"/><meta name="dtb:depth" content="3"/>` +
268
+ '<meta name="dtb:totalPageCount" content="0"/><meta name="dtb:maxPageNumber" content="0"/></head>\n' +
269
+ `<docTitle><text>${xml(meta.title)}</text></docTitle>\n<navMap>\n${navPoints(outline)}</navMap>\n</ncx>\n`;
270
+
271
+ let cover = null;
272
+ if (settings.cover) {
273
+ cover = this.page(
274
+ meta.title,
275
+ `<div class="cover">\n<h1>${xml(meta.title)}</h1>\n` +
276
+ (meta.subtitle ? `<p class="subtitle">${xml(meta.subtitle)}</p>\n` : '') +
277
+ [meta.author, meta.date].filter(Boolean).map(line => `<p class="line">${xml(line)}</p>\n`).join('') +
278
+ '</div>\n'
279
+ );
280
+ }
281
+
282
+ const manifest = [
283
+ '<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>',
284
+ '<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>',
285
+ '<item id="css" href="style.css" media-type="text/css"/>',
286
+ ...(cover ? ['<item id="cover" href="cover.xhtml" media-type="application/xhtml+xml"/>'] : []),
287
+ ...pages.map((page, index) => `<item id="chapter-${index + 1}" href="${page.name}" media-type="application/xhtml+xml"/>`),
288
+ ...this.images.map((image, index) => `<item id="image-${index + 1}" href="${xml(image.name)}" media-type="${image.mediaType}"/>`)
289
+ ];
290
+ const spine = [
291
+ ...(cover ? ['<itemref idref="cover"/>'] : []),
292
+ // The contents page is always in the book for the reader's own menu; it is a page to read only when asked for.
293
+ ...(settings.toc ? ['<itemref idref="nav"/>'] : []),
294
+ ...pages.map((_, index) => `<itemref idref="chapter-${index + 1}"/>`)
295
+ ];
296
+ const opf =
297
+ '<?xml version="1.0" encoding="UTF-8"?>\n' +
298
+ '<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="book-id">\n' +
299
+ '<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">\n' +
300
+ `<dc:identifier id="book-id">${identifier}</dc:identifier>\n<dc:title>${xml(meta.title)}</dc:title>\n` +
301
+ `<dc:language>${xml(meta.language)}</dc:language>\n` +
302
+ (meta.author ? `<dc:creator>${xml(meta.author)}</dc:creator>\n` : '') +
303
+ `<meta property="dcterms:modified">${now.toISOString().replace(/\.\d+Z$/, 'Z')}</meta>\n</metadata>\n` +
304
+ `<manifest>\n${manifest.join('\n')}\n</manifest>\n<spine toc="ncx">\n${spine.join('\n')}\n</spine>\n</package>\n`;
305
+ const container =
306
+ '<?xml version="1.0" encoding="UTF-8"?>\n' +
307
+ '<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">\n' +
308
+ '<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles>\n</container>\n';
309
+
310
+ return createZip([
311
+ // First and uncompressed: it is how a reader recognizes the file.
312
+ { name: 'mimetype', data: 'application/epub+zip', store: true },
313
+ { name: 'META-INF/container.xml', data: container },
314
+ { name: 'OEBPS/content.opf', data: opf },
315
+ { name: 'OEBPS/nav.xhtml', data: nav },
316
+ { name: 'OEBPS/toc.ncx', data: ncx },
317
+ { name: 'OEBPS/style.css', data: STYLESHEET },
318
+ ...(cover ? [{ name: 'OEBPS/cover.xhtml', data: cover }] : []),
319
+ ...pages.map(page => ({ name: `OEBPS/${page.name}`, data: page.data })),
320
+ ...this.images.map(image => ({ name: `OEBPS/${image.name}`, data: image.data, store: true }))
321
+ ]);
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Converts Markdown to the bytes of an .epub file.
327
+ * @param {string} rawMarkdown
328
+ * @param {Object} [options] The converter's options, plus `baseDir`: the folder images are relative to
329
+ * @returns {Buffer}
330
+ */
331
+ function markdownToEpub(rawMarkdown, options = {}) {
332
+ return new EpubWriter(buildDocument(rawMarkdown, options), options.baseDir).build();
333
+ }
334
+
335
+ module.exports = {
336
+ markdownToEpub
337
+ };
@@ -0,0 +1,69 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ /**
5
+ * Local images for the exporters that embed them (DOCX, EPUB).
6
+ */
7
+
8
+ const MEDIA_TYPES = {
9
+ png: 'image/png',
10
+ jpg: 'image/jpeg',
11
+ jpeg: 'image/jpeg',
12
+ gif: 'image/gif',
13
+ svg: 'image/svg+xml',
14
+ webp: 'image/webp'
15
+ };
16
+
17
+ /**
18
+ * Width and height in pixels of a PNG, JPEG or GIF, read from its header.
19
+ * @param {Buffer} data
20
+ * @returns {{ width: number, height: number } | null} null for anything else
21
+ */
22
+ function imageSize(data) {
23
+ if (data.length >= 24 && data.readUInt32BE(0) === 0x89504e47 && data.toString('ascii', 12, 16) === 'IHDR') {
24
+ return { width: data.readUInt32BE(16), height: data.readUInt32BE(20) };
25
+ }
26
+ if (data.length >= 10 && data.toString('ascii', 0, 3) === 'GIF') {
27
+ return { width: data.readUInt16LE(6), height: data.readUInt16LE(8) };
28
+ }
29
+ if (data.length >= 4 && data[0] === 0xff && data[1] === 0xd8) {
30
+ let offset = 2;
31
+ while (offset + 9 < data.length) {
32
+ if (data[offset] !== 0xff) return null;
33
+ const marker = data[offset + 1];
34
+ // Start-of-frame markers carry the size; C4, C8 and CC are other tables.
35
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
36
+ return { width: data.readUInt16BE(offset + 7), height: data.readUInt16BE(offset + 5) };
37
+ }
38
+ offset += 2 + data.readUInt16BE(offset + 2);
39
+ }
40
+ }
41
+ return null;
42
+ }
43
+
44
+ /**
45
+ * Reads the image a Markdown document refers to.
46
+ * @param {string} src As written in the document
47
+ * @param {string} baseDir Folder of the document
48
+ * @returns {{ data: Buffer, extension: string, mediaType: string, size: { width: number, height: number } | null } | null}
49
+ * null when it is not a readable local file of a known type (a web address, for one)
50
+ */
51
+ function readLocalImage(src, baseDir) {
52
+ if (!src || /^[a-z][a-z0-9+.-]*:/i.test(src) && !/^file:/i.test(src) && !/^[a-zA-Z]:[\\/]/.test(src)) return null;
53
+ let relative = src.replace(/^file:\/\/\/?/i, '').split(/[?#]/)[0];
54
+ try {
55
+ relative = decodeURIComponent(relative);
56
+ } catch (_) {
57
+ // A stray percent sign: the name is used as written.
58
+ }
59
+ const filePath = path.resolve(baseDir || process.cwd(), relative);
60
+ const extension = path.extname(filePath).slice(1).toLowerCase();
61
+ if (!MEDIA_TYPES[extension] || !fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) return null;
62
+ const data = fs.readFileSync(filePath);
63
+ return { data, extension: extension === 'jpeg' ? 'jpg' : extension, mediaType: MEDIA_TYPES[extension], size: imageSize(data) };
64
+ }
65
+
66
+ module.exports = {
67
+ imageSize,
68
+ readLocalImage
69
+ };
@@ -0,0 +1,104 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { parseFrontMatter } = require('./frontmatter');
4
+
5
+ /**
6
+ * File includes: a line that holds only `@import "path"` is replaced by the
7
+ * file it names, relative to the file the line is in.
8
+ *
9
+ * A Markdown file (.md, .markdown) is put in as Markdown, without its front
10
+ * matter, and may import further files itself. Any other file is put in as a
11
+ * fenced code block labelled with its extension, so `@import "src/app.js"`
12
+ * shows the source. Lines inside a fenced code block are left as they are.
13
+ */
14
+
15
+ const IMPORT_LINE = /^[ \t]*@import[ \t]+(["'])(.+?)\1[ \t]*$/;
16
+ const FENCE_LINE = /^ {0,3}(`{3,}|~{3,})/;
17
+ const MARKDOWN_EXTENSIONS = new Set(['.md', '.markdown']);
18
+ const MAX_DEPTH = 10;
19
+
20
+ /**
21
+ * A fence longer than any run of backticks in the code, so the code cannot
22
+ * close its own block.
23
+ * @param {string} code
24
+ * @returns {string}
25
+ */
26
+ function fenceFor(code) {
27
+ const longest = (code.match(/`+/g) || []).reduce((max, run) => Math.max(max, run.length), 0);
28
+ return '`'.repeat(Math.max(3, longest + 1));
29
+ }
30
+
31
+ function expand(markdown, filePath, chain, files) {
32
+ const baseDir = path.dirname(filePath);
33
+ const output = [];
34
+ let fence = null;
35
+
36
+ markdown.split(/\r?\n/).forEach((line, index) => {
37
+ const fenceMatch = line.match(FENCE_LINE);
38
+ if (fenceMatch) {
39
+ const marker = fenceMatch[1];
40
+ if (!fence) {
41
+ fence = marker;
42
+ } else if (marker[0] === fence[0] && marker.length >= fence.length && line.trim() === marker) {
43
+ fence = null;
44
+ }
45
+ output.push(line);
46
+ return;
47
+ }
48
+
49
+ const importMatch = fence ? null : line.match(IMPORT_LINE);
50
+ if (!importMatch) {
51
+ output.push(line);
52
+ return;
53
+ }
54
+
55
+ const where = `${filePath}:${index + 1}`;
56
+ const target = path.resolve(baseDir, importMatch[2]);
57
+ if (!fs.existsSync(target) || !fs.statSync(target).isFile()) {
58
+ throw new Error(`Cannot import "${importMatch[2]}" (${where}): file not found at ${target}`);
59
+ }
60
+ if (chain.includes(target)) {
61
+ const loop = [...chain.slice(chain.indexOf(target)), target]
62
+ .map(file => path.basename(file))
63
+ .join(' → ');
64
+ throw new Error(`Cannot import "${importMatch[2]}" (${where}): the files import each other (${loop})`);
65
+ }
66
+ if (chain.length > MAX_DEPTH) {
67
+ throw new Error(`Cannot import "${importMatch[2]}" (${where}): imports are nested more than ${MAX_DEPTH} deep`);
68
+ }
69
+
70
+ files.add(target);
71
+ const text = fs.readFileSync(target, 'utf8').replace(/^/, '');
72
+ const extension = path.extname(target).toLowerCase();
73
+
74
+ if (MARKDOWN_EXTENSIONS.has(extension)) {
75
+ const body = parseFrontMatter(text).content;
76
+ output.push(expand(body, target, [...chain, target], files).replace(/(\r?\n)+$/, ''));
77
+ } else {
78
+ const code = text.replace(/(\r?\n)+$/, '');
79
+ const marker = fenceFor(code);
80
+ output.push(`${marker}${extension.slice(1)}`, code, marker);
81
+ }
82
+ });
83
+
84
+ return output.join('\n');
85
+ }
86
+
87
+ /**
88
+ * Replaces every `@import "path"` line of a Markdown document with the file
89
+ * it names.
90
+ * @param {string} markdown Content of the document
91
+ * @param {string} filePath Path of the document; imports are relative to it
92
+ * @returns {{ content: string, files: string[] }} The expanded Markdown and
93
+ * the absolute path of every file that was imported, directly or not
94
+ */
95
+ function expandIncludes(markdown = '', filePath = '') {
96
+ const absolutePath = path.resolve(filePath || 'document.md');
97
+ const files = new Set();
98
+ const content = expand(markdown, absolutePath, [absolutePath], files);
99
+ return { content, files: Array.from(files) };
100
+ }
101
+
102
+ module.exports = {
103
+ expandIncludes
104
+ };
package/src/core/index.js CHANGED
@@ -3,13 +3,40 @@ const path = require('path');
3
3
  const { parseMarkdownToHtml } = require('./parser');
4
4
  const { renderHtmlToPdf } = require('../browser/renderer');
5
5
  const { resolvePath } = require('./utils');
6
+ const { expandIncludes } = require('./include');
7
+ const { markdownToDocx } = require('./docx');
8
+ const { markdownToEpub } = require('./epub');
9
+
10
+ const FORMATS = ['pdf', 'html', 'docx', 'epub'];
11
+
12
+ /**
13
+ * Where the page the browser prints is written: beside the Markdown file, so
14
+ * that its relative pictures and links resolve as they do in the source, also
15
+ * when the PDF goes to another folder. Falls back to the PDF's folder when the
16
+ * source's folder cannot be written to.
17
+ * @param {string} absInputPath
18
+ * @param {string} absOutputPath
19
+ * @returns {string}
20
+ */
21
+ function temporaryHtmlPath(absInputPath, absOutputPath) {
22
+ const name = `_temp_${Date.now()}_${Math.random().toString(36).substring(7)}.html`;
23
+ const sourceDir = path.dirname(absInputPath);
24
+ try {
25
+ fs.accessSync(sourceDir, fs.constants.W_OK);
26
+ return path.join(sourceDir, name);
27
+ } catch (_) {
28
+ return path.join(path.dirname(absOutputPath), name);
29
+ }
30
+ }
6
31
 
7
32
  /**
8
- * Main function to convert Markdown file to PDF
33
+ * Main function to convert a Markdown file: to PDF by default, or to the
34
+ * format named in `options.format` (`html`, `docx`, `epub`)
9
35
  * @param {string} inputPath Path to source .md file
10
- * @param {string} [outputPath] Path to target .pdf file
36
+ * @param {string} [outputPath] Path to the target file
11
37
  * @param {Object} [options] Conversion options
12
- * @returns {Promise<{ htmlPath: string, pdfPath: string }>}
38
+ * @returns {Promise<{ outputPath: string, htmlPath: string, pdfPath: string, includedFiles: string[] }>}
39
+ * `outputPath` is the file that was asked for, whatever its format
13
40
  */
14
41
  async function convertMarkdownToPdf(inputPath, outputPath, options = {}) {
15
42
  const absInputPath = resolvePath(inputPath);
@@ -19,6 +46,9 @@ async function convertMarkdownToPdf(inputPath, outputPath, options = {}) {
19
46
  }
20
47
 
21
48
  const format = options.format ? options.format.toLowerCase() : 'pdf';
49
+ if (!FORMATS.includes(format)) {
50
+ throw new Error(`Unknown output format "${options.format}". Use one of: ${FORMATS.join(', ')}`);
51
+ }
22
52
 
23
53
  // Derive output path if omitted
24
54
  let absOutputPath;
@@ -29,8 +59,24 @@ async function convertMarkdownToPdf(inputPath, outputPath, options = {}) {
29
59
  absOutputPath = path.join(parsed.dir, `${parsed.name}.${format}`);
30
60
  }
31
61
 
32
- // Read Markdown
33
- const markdownContent = fs.readFileSync(absInputPath, 'utf8');
62
+ // Read Markdown, pulling in the files it imports with `@import "path"`
63
+ let markdownContent = fs.readFileSync(absInputPath, 'utf8');
64
+ let includedFiles = [];
65
+ if (options.include !== false) {
66
+ ({ content: markdownContent, files: includedFiles } = expandIncludes(markdownContent, absInputPath));
67
+ }
68
+
69
+ // Word and EPUB are written from the Markdown itself, without a browser
70
+ if (format === 'docx' || format === 'epub') {
71
+ const build = format === 'docx' ? markdownToDocx : markdownToEpub;
72
+ fs.writeFileSync(absOutputPath, build(markdownContent, { ...options, baseDir: path.dirname(absInputPath) }));
73
+ return {
74
+ outputPath: absOutputPath,
75
+ htmlPath: null,
76
+ pdfPath: null,
77
+ includedFiles
78
+ };
79
+ }
34
80
 
35
81
  // Parse to HTML
36
82
  const htmlContent = parseMarkdownToHtml(markdownContent, options);
@@ -38,13 +84,15 @@ async function convertMarkdownToPdf(inputPath, outputPath, options = {}) {
38
84
  if (format === 'html') {
39
85
  fs.writeFileSync(absOutputPath, htmlContent, 'utf8');
40
86
  return {
87
+ outputPath: absOutputPath,
41
88
  htmlPath: absOutputPath,
42
- pdfPath: null
89
+ pdfPath: null,
90
+ includedFiles
43
91
  };
44
92
  }
45
93
 
46
94
  // Temporary HTML path
47
- const tempHtmlPath = path.join(path.parse(absOutputPath).dir, `_temp_${Date.now()}_${Math.random().toString(36).substring(7)}.html`);
95
+ const tempHtmlPath = temporaryHtmlPath(absInputPath, absOutputPath);
48
96
 
49
97
  try {
50
98
  // Save temporary HTML file
@@ -61,8 +109,10 @@ async function convertMarkdownToPdf(inputPath, outputPath, options = {}) {
61
109
  }
62
110
 
63
111
  return {
112
+ outputPath: absOutputPath,
64
113
  htmlPath: tempHtmlPath,
65
- pdfPath: absOutputPath
114
+ pdfPath: absOutputPath,
115
+ includedFiles
66
116
  };
67
117
  } catch (error) {
68
118
  // Clean up on failure
@@ -75,6 +125,10 @@ async function convertMarkdownToPdf(inputPath, outputPath, options = {}) {
75
125
 
76
126
  module.exports = {
77
127
  convertMarkdownToPdf,
128
+ expandIncludes,
129
+ markdownToDocx,
130
+ markdownToEpub,
78
131
  parseMarkdownToHtml,
79
- renderHtmlToPdf
132
+ renderHtmlToPdf,
133
+ temporaryHtmlPath
80
134
  };
package/src/core/utils.js CHANGED
@@ -18,11 +18,11 @@ function preprocessMarkdown(mdContent = '') {
18
18
 
19
19
  return mdContent
20
20
  // GitHub Callouts / Alerts
21
- .replace(/^>\s*\[!NOTE\]\s*\r?\n/gm, '> **ℹ️ NOTE:** ')
22
- .replace(/^>\s*\[!TIP\]\s*\r?\n/gm, '> **💡 TIP:** ')
23
- .replace(/^>\s*\[!WARNING\]\s*\r?\n/gm, '> **⚠️ WARNING:** ')
24
- .replace(/^>\s*\[!IMPORTANT\]\s*\r?\n/gm, '> **❗ IMPORTANT:** ')
25
- .replace(/^>\s*\[!CAUTION\]\s*\r?\n/gm, '> **🚫 CAUTION:** ')
21
+ .replace(/^>[ \t]*\[!NOTE\][ \t]*(?:\r?\n>[ \t]?)?/gm, '> **ℹ️ NOTE:** ')
22
+ .replace(/^>[ \t]*\[!TIP\][ \t]*(?:\r?\n>[ \t]?)?/gm, '> **💡 TIP:** ')
23
+ .replace(/^>[ \t]*\[!WARNING\][ \t]*(?:\r?\n>[ \t]?)?/gm, '> **⚠️ WARNING:** ')
24
+ .replace(/^>[ \t]*\[!IMPORTANT\][ \t]*(?:\r?\n>[ \t]?)?/gm, '> **❗ IMPORTANT:** ')
25
+ .replace(/^>[ \t]*\[!CAUTION\][ \t]*(?:\r?\n>[ \t]?)?/gm, '> **🚫 CAUTION:** ')
26
26
  // Pagebreaks
27
27
  .replace(/<!--\s*page-?break\s*-->/gi, '<div class="page-break"></div>')
28
28
  .replace(/\\pagebreak/gi, '<div class="page-break"></div>')