md-pdf-studio 2.1.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,122 @@
1
+ /**
2
+ * Markdown footnotes: `text[^id]` in the body, `[^id]: note` anywhere on a line of its own.
3
+ *
4
+ * Works on the Markdown text, before it is parsed, and needs nothing from Node: the same file
5
+ * is shipped in the extension as extension/lib/footnotes.js (a test keeps the two identical).
6
+ */
7
+ (function (scope) {
8
+ const DEFINITION = /^ {0,3}\[\^([^\]\s]+)\]:[ \t]*(.*)$/;
9
+ const REFERENCE = /\[\^([^\]\s]+)\]/g;
10
+ const FENCE = /^ {0,3}(`{3,}|~{3,})/;
11
+ const CONTINUATION = /^(?: {2,}|\t)\S/;
12
+
13
+ /**
14
+ * Splits the text into lines tagged as code or prose. Footnote syntax inside a fenced
15
+ * block is an example of the syntax, not a footnote.
16
+ * @param {string} markdown
17
+ * @returns {{ text: string, code: boolean }[]}
18
+ */
19
+ function tagLines(markdown) {
20
+ const lines = [];
21
+ let fence = null;
22
+ for (const text of markdown.split(/\r?\n/)) {
23
+ const match = FENCE.exec(text);
24
+ if (fence) {
25
+ lines.push({ text, code: true });
26
+ if (match && match[1][0] === fence[0] && match[1].length >= fence.length) fence = null;
27
+ } else if (match) {
28
+ fence = match[1];
29
+ lines.push({ text, code: true });
30
+ } else {
31
+ lines.push({ text, code: false });
32
+ }
33
+ }
34
+ return lines;
35
+ }
36
+
37
+ /**
38
+ * Applies `replace` to the parts of a line that are not inline code.
39
+ * @param {string} line
40
+ * @param {(prose: string) => string} replace
41
+ * @returns {string}
42
+ */
43
+ function outsideInlineCode(line, replace) {
44
+ return line
45
+ .split(/(`+[^`]*`+)/)
46
+ .map((part, index) => (index % 2 === 1 ? part : replace(part)))
47
+ .join('');
48
+ }
49
+
50
+ /**
51
+ * Numbers the footnotes of a Markdown document in the order they are first referenced,
52
+ * replaces each reference with a superscript link, and appends the notes as a list.
53
+ *
54
+ * A reference to a note that is never defined is left as the author typed it; a note that
55
+ * is never referenced is left out, as GitHub does.
56
+ * @param {string} markdown
57
+ * @returns {string} Markdown, with HTML for the references and the notes section
58
+ */
59
+ function applyFootnotes(markdown = '') {
60
+ if (!markdown || markdown.indexOf('[^') === -1) return markdown;
61
+
62
+ const lines = tagLines(markdown);
63
+ const definitions = new Map();
64
+ const body = [];
65
+
66
+ for (let index = 0; index < lines.length; index++) {
67
+ const line = lines[index];
68
+ const match = line.code ? null : DEFINITION.exec(line.text);
69
+ if (!match) {
70
+ body.push(line);
71
+ continue;
72
+ }
73
+ const parts = [match[2].trim()];
74
+ // Indented lines that follow belong to the note.
75
+ while (
76
+ index + 1 < lines.length &&
77
+ !lines[index + 1].code &&
78
+ CONTINUATION.test(lines[index + 1].text)
79
+ ) {
80
+ index += 1;
81
+ parts.push(lines[index].text.trim());
82
+ }
83
+ // The first definition of an id wins, as with link reference definitions.
84
+ if (!definitions.has(match[1])) definitions.set(match[1], parts.join(' ').trim());
85
+ }
86
+
87
+ if (definitions.size === 0) return markdown;
88
+
89
+ const numbers = new Map();
90
+ const uses = new Map();
91
+ const text = body
92
+ .map((line) => {
93
+ if (line.code) return line.text;
94
+ return outsideInlineCode(line.text, (prose) =>
95
+ prose.replace(REFERENCE, (whole, id) => {
96
+ if (!definitions.has(id)) return whole;
97
+ if (!numbers.has(id)) numbers.set(id, numbers.size + 1);
98
+ const number = numbers.get(id);
99
+ const use = (uses.get(id) || 0) + 1;
100
+ uses.set(id, use);
101
+ const anchor = use === 1 ? `fnref-${number}` : `fnref-${number}-${use}`;
102
+ return `<sup class="footnote-ref"><a href="#fn-${number}" id="${anchor}">${number}</a></sup>`;
103
+ })
104
+ );
105
+ })
106
+ .join('\n');
107
+
108
+ if (numbers.size === 0) return text;
109
+
110
+ const notes = Array.from(numbers.entries()).map(([id, number]) => {
111
+ const note = definitions.get(id);
112
+ return `${number}. <span id="fn-${number}"></span>${note} <a href="#fnref-${number}" class="footnote-backref" aria-label="Back to the text">↩</a>`;
113
+ });
114
+
115
+ // The blank lines let Markdown inside the section be parsed as Markdown.
116
+ return `${text.replace(/\s+$/, '')}\n\n<section class="footnotes">\n\n${notes.join('\n')}\n\n</section>\n`;
117
+ }
118
+
119
+ const api = { applyFootnotes };
120
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
121
+ else scope.MdPdfFootnotes = api;
122
+ })(typeof globalThis !== 'undefined' ? globalThis : this);
@@ -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
+ };