bookish-press 0.8.2 → 0.9.3

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,75 @@
1
+ /**
2
+ * Serializes chapter ASTs to the XHTML that EPUB requires.
3
+ *
4
+ * This deliberately does not reuse `Node.toHTML()`. That serializer is lossy by
5
+ * design -- footnotes, citations and definitions collapse to parenthesised text
6
+ * and labels vanish -- and it has no access to the edition, so it cannot number
7
+ * a footnote or resolve a citation to its reference. It also emits HTML, not
8
+ * XHTML, and EPUB readers reject documents that are not well-formed XML.
9
+ *
10
+ * The three things the web reader renders as marginal pop-ups (footnotes,
11
+ * citations, definitions) all become ordinary links here, because an e-reader
12
+ * has no margin to put them in.
13
+ */
14
+ import type Edition from '../models/book/Edition';
15
+ import type ChapterNode from '../models/chapter/ChapterNode';
16
+ import EmbedNode from '../models/chapter/EmbedNode';
17
+ import FormatNode from '../models/chapter/FormatNode';
18
+ /**
19
+ * Escape text content. Order matters: ampersands first, or we escape our own
20
+ * escapes.
21
+ *
22
+ * The argument is deliberately `unknown`. Book data is JSON that predates the
23
+ * current types and is only loosely validated, so fields the model declares as
24
+ * strings arrive as numbers or null in real books -- reference years in
25
+ * particular. Coercing here keeps one stale field from throwing partway through
26
+ * an export and losing the whole book.
27
+ */
28
+ export declare function escapeText(text: unknown): string;
29
+ /** Escape an attribute value, which additionally must not close its own quotes. */
30
+ export declare function escapeAttribute(value: unknown): string;
31
+ /**
32
+ * The context a chapter needs to resolve its cross-references: which edition it
33
+ * belongs to, which images made it into the package, and which chapters exist
34
+ * (so a link to a missing one degrades to plain text rather than a dead link).
35
+ */
36
+ export type SerializationContext = {
37
+ edition: Edition;
38
+ /** Maps an embed's URL to its path inside the package, e.g. `images/3.jpg`. */
39
+ images: Map<string, string>;
40
+ /** Chapter IDs that have their own document in the package. */
41
+ chapters: Set<string>;
42
+ };
43
+ /** A footnote gathered while serializing, to be emitted as an endnote. */
44
+ type Endnote = {
45
+ id: string;
46
+ symbol: string;
47
+ content: string;
48
+ };
49
+ /**
50
+ * Serialize one chapter's blocks, returning the body markup and the endnotes
51
+ * that its footnotes produced. Callers place the endnotes wherever they like;
52
+ * `chapterDocument` puts them at the end of the chapter.
53
+ */
54
+ export declare function serializeChapter(chapter: ChapterNode, context: SerializationContext): {
55
+ body: string;
56
+ endnotes: Endnote[];
57
+ };
58
+ /** The document filename for a chapter. */
59
+ export declare function chapterPath(chapterID: string): string;
60
+ /**
61
+ * Serialize a standalone embed, for the header images that a chapter and the
62
+ * back matter pages carry. Those are stored as Bookdown, parsed by the caller.
63
+ */
64
+ export declare function serializeEmbed(node: EmbedNode, context: SerializationContext): string;
65
+ /** Serialize a standalone format node, for titles, captions and definitions. */
66
+ export declare function serializeFormat(node: FormatNode, context: SerializationContext): string;
67
+ /** Render gathered footnotes as an endnote section with links back to the text. */
68
+ export declare function endnoteSection(endnotes: Endnote[]): string;
69
+ /**
70
+ * Wrap body markup in a complete XHTML document. The epub namespace is declared
71
+ * on every document because footnote markup uses `epub:type`, and an undeclared
72
+ * prefix makes the XML malformed.
73
+ */
74
+ export declare function document_(title: string, body: string): string;
75
+ export {};
@@ -0,0 +1,379 @@
1
+ /**
2
+ * Serializes chapter ASTs to the XHTML that EPUB requires.
3
+ *
4
+ * This deliberately does not reuse `Node.toHTML()`. That serializer is lossy by
5
+ * design -- footnotes, citations and definitions collapse to parenthesised text
6
+ * and labels vanish -- and it has no access to the edition, so it cannot number
7
+ * a footnote or resolve a citation to its reference. It also emits HTML, not
8
+ * XHTML, and EPUB readers reject documents that are not well-formed XML.
9
+ *
10
+ * The three things the web reader renders as marginal pop-ups (footnotes,
11
+ * citations, definitions) all become ordinary links here, because an e-reader
12
+ * has no margin to put them in.
13
+ */
14
+ import BlockNode from '../models/chapter/BlockNode';
15
+ import CalloutNode from '../models/chapter/CalloutNode';
16
+ import CitationsNode from '../models/chapter/CitationsNode';
17
+ import CodeNode from '../models/chapter/CodeNode';
18
+ import CommentNode from '../models/chapter/CommentNode';
19
+ import DefinitionNode from '../models/chapter/DefinitionNode';
20
+ import EmbedNode from '../models/chapter/EmbedNode';
21
+ import ErrorNode from '../models/chapter/ErrorNode';
22
+ import FootnoteNode from '../models/chapter/FootnoteNode';
23
+ import FormatNode from '../models/chapter/FormatNode';
24
+ import InlineCodeNode from '../models/chapter/InlineCodeNode';
25
+ import LabelNode from '../models/chapter/LabelNode';
26
+ import LineBreakNode from '../models/chapter/LineBreakNode';
27
+ import LinkNode from '../models/chapter/LinkNode';
28
+ import ListNode from '../models/chapter/ListNode';
29
+ import ParagraphNode from '../models/chapter/ParagraphNode';
30
+ import QuoteNode from '../models/chapter/QuoteNode';
31
+ import RuleNode from '../models/chapter/RuleNode';
32
+ import TableNode from '../models/chapter/TableNode';
33
+ import TextNode from '../models/chapter/TextNode';
34
+ /**
35
+ * Escape text content. Order matters: ampersands first, or we escape our own
36
+ * escapes.
37
+ *
38
+ * The argument is deliberately `unknown`. Book data is JSON that predates the
39
+ * current types and is only loosely validated, so fields the model declares as
40
+ * strings arrive as numbers or null in real books -- reference years in
41
+ * particular. Coercing here keeps one stale field from throwing partway through
42
+ * an export and losing the whole book.
43
+ */
44
+ export function escapeText(text) {
45
+ return String(text ?? '')
46
+ .replace(/&/g, '&amp;')
47
+ .replace(/</g, '&lt;')
48
+ .replace(/>/g, '&gt;');
49
+ }
50
+ /** Escape an attribute value, which additionally must not close its own quotes. */
51
+ export function escapeAttribute(value) {
52
+ return escapeText(value).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
53
+ }
54
+ /**
55
+ * Serialize one chapter's blocks, returning the body markup and the endnotes
56
+ * that its footnotes produced. Callers place the endnotes wherever they like;
57
+ * `chapterDocument` puts them at the end of the chapter.
58
+ */
59
+ export function serializeChapter(chapter, context) {
60
+ const endnotes = [];
61
+ const footnotes = chapter.getFootnotes();
62
+ const headers = chapter.getHeaders();
63
+ const body = chapter
64
+ .getBlocks()
65
+ .map((block) => block_(block, { ...context, footnotes, headers, endnotes }))
66
+ .join('\n');
67
+ return { body, endnotes };
68
+ }
69
+ /** Serialize a block-level node. */
70
+ function block_(node, local) {
71
+ if (node instanceof ParagraphNode) {
72
+ const level = node.getLevel();
73
+ const content = inline(node.getFormat(), local);
74
+ if (level === 0)
75
+ return `<p>${content}</p>`;
76
+ // The chapter title is the document's h1, so headers start at h2. The
77
+ // id mirrors Paragraph.svelte so in-book links to headers still land.
78
+ const index = local.headers.indexOf(node);
79
+ const id = index >= 0 ? ` id="header-${index}"` : '';
80
+ const tag = `h${Math.min(level + 1, 6)}`;
81
+ return `<${tag}${id}>${content}</${tag}>`;
82
+ }
83
+ if (node instanceof ListNode)
84
+ return list(node, local);
85
+ if (node instanceof EmbedNode)
86
+ return embed(node, local);
87
+ if (node instanceof CodeNode) {
88
+ const language = node.getLanguage();
89
+ const caption = inline(node.getCaption(), local);
90
+ // No syntax highlighting: e-ink is greyscale, and the spans would only
91
+ // add weight. The language is kept as a class for readers that style it.
92
+ // Newlines become <br/> and leading spaces become non-breaking, so
93
+ // the code keeps its shape on a reader that collapses whitespace
94
+ // rather than honouring `white-space`. The literal newlines are
95
+ // dropped rather than kept alongside the <br/>, which would otherwise
96
+ // double-space the block on a reader that honours both.
97
+ const code = `<pre class="code${language && language !== 'plaintext'
98
+ ? ` language-${escapeAttribute(language)}`
99
+ : ''}"><code>${preformatted(node.getCode())}</code></pre>`;
100
+ return caption.trim().length > 0
101
+ ? `<figure class="code-figure">${code}<figcaption>${caption}</figcaption></figure>`
102
+ : code;
103
+ }
104
+ if (node instanceof QuoteNode) {
105
+ const credit = inline(node.getCredit(), local);
106
+ const blocks = node
107
+ .getBlocks()
108
+ .map((b) => block_(b, local))
109
+ .join('\n');
110
+ return credit.trim().length > 0
111
+ ? `<figure class="quote"><blockquote>${blocks}</blockquote><figcaption><cite>${credit}</cite></figcaption></figure>`
112
+ : `<blockquote>${blocks}</blockquote>`;
113
+ }
114
+ if (node instanceof CalloutNode)
115
+ return `<aside class="callout">${node
116
+ .getBlocks()
117
+ .map((b) => block_(b, local))
118
+ .join('\n')}</aside>`;
119
+ if (node instanceof TableNode)
120
+ return table(node, local);
121
+ if (node instanceof RuleNode)
122
+ return '<hr/>';
123
+ // Editorial comments and parse errors are for authors, not readers.
124
+ if (node instanceof CommentNode || node instanceof ErrorNode)
125
+ return '';
126
+ return '';
127
+ }
128
+ /**
129
+ * Escape code for a <pre>, preserving its shape without relying on the reader
130
+ * honouring `white-space`: line breaks become <br/> and each line's leading
131
+ * indentation becomes non-breaking spaces.
132
+ */
133
+ function preformatted(code) {
134
+ return code
135
+ .split('\n')
136
+ .map((line) => {
137
+ const indent = line.length - line.trimStart().length;
138
+ return '\u00a0'.repeat(indent) + escapeText(line.slice(indent));
139
+ })
140
+ .join('<br/>');
141
+ }
142
+ function list(node, local) {
143
+ const tag = node.isNumbered() ? 'ol' : 'ul';
144
+ const items = node
145
+ .getItems()
146
+ .map((item) => item instanceof ListNode
147
+ ? // A nested list belongs inside a list item, not between them.
148
+ `<li>${list(item, local)}</li>`
149
+ : `<li>${inline(item, local)}</li>`)
150
+ .join('');
151
+ return `<${tag}>${items}</${tag}>`;
152
+ }
153
+ function table(node, local) {
154
+ const rows = node.getRows();
155
+ const caption = inline(node.getCaption(), local);
156
+ // Row 0 is the header row, matching how Table.svelte renders it.
157
+ const head = rows.length > 0
158
+ ? `<thead><tr>${rows[0]
159
+ .map((cell) => `<th>${inline(cell, local)}</th>`)
160
+ .join('')}</tr></thead>`
161
+ : '';
162
+ const body = rows
163
+ .slice(1)
164
+ .map((row) => `<tr>${row
165
+ .map((cell) => `<td>${inline(cell, local)}</td>`)
166
+ .join('')}</tr>`)
167
+ .join('');
168
+ return `<table>${caption.trim().length > 0 ? `<caption><p>${caption}</p></caption>` : ''}${head}<tbody>${body}</tbody></table>`;
169
+ }
170
+ function embed(node, local) {
171
+ const caption = inline(node.getCaption(), local);
172
+ const credit = inline(node.getCredit(), local);
173
+ const description = escapeAttribute(node.getDescription());
174
+ // The caption and credit are paragraphs rather than a bare string and a
175
+ // span. Simple readers drop tags they don't know but keep their contents,
176
+ // so text placed directly inside a <figcaption> runs into whatever follows
177
+ // it; a <p> is understood almost everywhere and breaks the line.
178
+ const figcaption = caption.trim().length > 0 || credit.trim().length > 0
179
+ ? `<figcaption>${caption.trim().length > 0
180
+ ? `<p class="caption">${caption}</p>`
181
+ : ''}${credit.trim().length > 0
182
+ ? `<p class="credit">${credit}</p>`
183
+ : ''}</figcaption>`
184
+ : '';
185
+ // Video embeds are iframes on the web, which EPUB has no equivalent for, so
186
+ // offer the link instead of silently dropping the content.
187
+ if (node.isVideo()) {
188
+ const url = escapeAttribute(safeURL(node.getURL()));
189
+ // The caption belongs to the figure, so the link is labelled with the
190
+ // description instead; using the caption for both printed it twice.
191
+ const label = node.getDescription().trim().length > 0
192
+ ? escapeText(node.getDescription())
193
+ : url;
194
+ return `<figure class="video"><p><a href="${url}">${label}</a></p>${figcaption}</figure>`;
195
+ }
196
+ const path = local.images.get(node.getURL());
197
+ // An image that could not be fetched or decoded keeps its caption and
198
+ // description, so the reader still knows what was meant to be here.
199
+ if (path === undefined)
200
+ return figcaption.length > 0
201
+ ? `<figure class="missing-image">${figcaption}</figure>`
202
+ : description.length > 0
203
+ ? `<figure class="missing-image"><figcaption>${escapeText(node.getDescription())}</figcaption></figure>`
204
+ : '';
205
+ return `<figure><img src="${escapeAttribute(path)}" alt="${description}"/>${figcaption}</figure>`;
206
+ }
207
+ /** Serialize an inline node and its descendants. */
208
+ function inline(node, local) {
209
+ if (node instanceof TextNode)
210
+ return escapeText(node.getText());
211
+ if (node instanceof FormatNode) {
212
+ const content = node
213
+ .getSegments()
214
+ .map((segment) => inline(segment, local))
215
+ .join('');
216
+ const format = node.getFormat();
217
+ return format === '*'
218
+ ? `<strong>${content}</strong>`
219
+ : format === '_'
220
+ ? `<em>${content}</em>`
221
+ : format === '^'
222
+ ? `<sup>${content}</sup>`
223
+ : format === 'v'
224
+ ? `<sub>${content}</sub>`
225
+ : content;
226
+ }
227
+ if (node instanceof LineBreakNode)
228
+ return '<br/>';
229
+ if (node instanceof InlineCodeNode)
230
+ return `<code>${escapeText(node.getText().getText())}</code>`;
231
+ if (node instanceof LinkNode)
232
+ return link(node, local);
233
+ if (node instanceof FootnoteNode)
234
+ return footnote(node, local);
235
+ if (node instanceof CitationsNode)
236
+ return citations(node, local);
237
+ if (node instanceof DefinitionNode)
238
+ return definition(node, local);
239
+ if (node instanceof LabelNode)
240
+ // An empty anchor, so links to this label resolve. toHTML() drops these.
241
+ return `<span id="${escapeAttribute(node.getMeta())}"></span>`;
242
+ // Editorial comments never reach readers.
243
+ if (node instanceof CommentNode)
244
+ return '';
245
+ if (node instanceof ErrorNode)
246
+ return '';
247
+ // A block that turned up inline (a nested list item, say).
248
+ if (node instanceof BlockNode)
249
+ return block_(node, local);
250
+ return '';
251
+ }
252
+ /** The document filename for a chapter. */
253
+ export function chapterPath(chapterID) {
254
+ return `${chapterID}.xhtml`;
255
+ }
256
+ /**
257
+ * Clean up a URL taken from book text.
258
+ *
259
+ * Two things come out of real books. Bookdown escapes punctuation with a
260
+ * backslash and authors write those inside URLs ("medium\\.com"), which the
261
+ * parser keeps verbatim. And an unclosed link swallows whatever follows it,
262
+ * so the address ends up containing spaces and brackets. Neither is a valid
263
+ * URL, and epubcheck rejects both -- which would make the whole book invalid
264
+ * over one typo, so the characters are encoded rather than passed through.
265
+ */
266
+ function safeURL(url) {
267
+ return url
268
+ .replace(/\\(.)/g, '$1')
269
+ .replace(/[ "<>`{}|\\^\[\]]/g, (character) => encodeURIComponent(character));
270
+ }
271
+ function link(node, local) {
272
+ const url = safeURL(node.getMeta());
273
+ const content = escapeText(node.getText().getText());
274
+ if (url.startsWith('http'))
275
+ return `<a href="${escapeAttribute(url)}">${content}</a>`;
276
+ // Bookdown addresses another chapter as `chapter`, `chapter:label`, or a
277
+ // label in this chapter as `:label`.
278
+ const separator = url.includes(':') ? ':' : url.includes('#') ? '#' : ':';
279
+ const [chapter, label] = url.split(separator);
280
+ if (chapter.length === 0)
281
+ return label === undefined
282
+ ? content
283
+ : `<a href="#${escapeAttribute(label)}">${content}</a>`;
284
+ // A link to a chapter that isn't in the package (forthcoming, or one of the
285
+ // interactive pages we omit) would be dead, so keep the words and drop the link.
286
+ if (!local.chapters.has(chapter))
287
+ return content;
288
+ const href = label === undefined || label.length === 0
289
+ ? chapterPath(chapter)
290
+ : `${chapterPath(chapter)}#${escapeAttribute(label)}`;
291
+ return `<a href="${escapeAttribute(href)}">${content}</a>`;
292
+ }
293
+ function footnote(node, local) {
294
+ const index = local.footnotes.indexOf(node);
295
+ if (index < 0)
296
+ return '';
297
+ const symbol = local.edition.getFootnoteSymbol(index);
298
+ const id = `fn-${index}`;
299
+ local.endnotes.push({
300
+ id,
301
+ symbol,
302
+ content: inline(node.getMeta(), local),
303
+ });
304
+ return `<a class="noteref" epub:type="noteref" href="#${id}" id="fnref-${index}"><sup>${escapeText(symbol)}</sup></a>`;
305
+ }
306
+ function citations(node, local) {
307
+ const ids = node.getMeta();
308
+ if (ids.length === 0)
309
+ return '';
310
+ const links = ids
311
+ .map((id) => {
312
+ const reference = local.edition.getReference(id);
313
+ const label = escapeText(id);
314
+ // An unknown citation has no reference entry to link to.
315
+ return reference === undefined
316
+ ? label
317
+ : `<a href="references.xhtml#ref-${escapeAttribute(id)}">${label}</a>`;
318
+ })
319
+ .join(', ');
320
+ return `<sup class="citation">${links}</sup>`;
321
+ }
322
+ function definition(node, local) {
323
+ const id = node.getMeta();
324
+ const phrase = escapeText(node.getText().getText());
325
+ return local.edition.getGlossary()[id] === undefined
326
+ ? phrase
327
+ : `<a class="definition" href="glossary.xhtml#gloss-${escapeAttribute(id)}">${phrase}</a>`;
328
+ }
329
+ /**
330
+ * Serialize a standalone embed, for the header images that a chapter and the
331
+ * back matter pages carry. Those are stored as Bookdown, parsed by the caller.
332
+ */
333
+ export function serializeEmbed(node, context) {
334
+ return embed(node, {
335
+ ...context,
336
+ footnotes: [],
337
+ headers: [],
338
+ endnotes: [],
339
+ });
340
+ }
341
+ /** Serialize a standalone format node, for titles, captions and definitions. */
342
+ export function serializeFormat(node, context) {
343
+ return inline(node, {
344
+ ...context,
345
+ footnotes: [],
346
+ headers: [],
347
+ endnotes: [],
348
+ });
349
+ }
350
+ /** Render gathered footnotes as an endnote section with links back to the text. */
351
+ export function endnoteSection(endnotes) {
352
+ if (endnotes.length === 0)
353
+ return '';
354
+ return `<section class="notes" epub:type="footnotes">
355
+ <h2>Notes</h2>
356
+ ${endnotes
357
+ .map((note, index) => `<aside class="note" epub:type="footnote" id="${note.id}"><p><a href="#fnref-${index}"><sup>${escapeText(note.symbol)}</sup></a> ${note.content}</p></aside>`)
358
+ .join('\n')}
359
+ </section>`;
360
+ }
361
+ /**
362
+ * Wrap body markup in a complete XHTML document. The epub namespace is declared
363
+ * on every document because footnote markup uses `epub:type`, and an undeclared
364
+ * prefix makes the XML malformed.
365
+ */
366
+ export function document_(title, body) {
367
+ return `<?xml version="1.0" encoding="utf-8"?>
368
+ <!DOCTYPE html>
369
+ <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="en" xml:lang="en">
370
+ <head>
371
+ <meta charset="utf-8"/>
372
+ <title>${escapeText(title)}</title>
373
+ <link rel="stylesheet" type="text/css" href="style.css"/>
374
+ </head>
375
+ <body>
376
+ ${body}
377
+ </body>
378
+ </html>`;
379
+ }
@@ -0,0 +1 @@
1
+ export {};