bookish-press 0.8.2 → 0.9.2

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,283 @@
1
+ import { expect, test } from 'vitest';
2
+ import Parser from '../models/chapter/Parser';
3
+ import Edition from '../models/book/Edition';
4
+ import { document_, endnoteSection, escapeAttribute, escapeText, serializeChapter, } from './xhtml';
5
+ /** An edition with just enough in it to resolve references and definitions. */
6
+ function makeEdition(options = {}) {
7
+ return Edition.fromJSON(undefined, {
8
+ title: 'Test',
9
+ authors: [],
10
+ number: 1,
11
+ summary: '',
12
+ published: null,
13
+ images: {},
14
+ description: '',
15
+ chapters: [],
16
+ license: '',
17
+ acknowledgements: '',
18
+ tags: [],
19
+ sources: {},
20
+ references: options.references ?? {},
21
+ symbols: {},
22
+ glossary: options.glossary ?? {},
23
+ theme: null,
24
+ uids: [],
25
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
26
+ });
27
+ }
28
+ function context(overrides = {}) {
29
+ return {
30
+ edition: makeEdition(),
31
+ images: new Map(),
32
+ chapters: new Set(),
33
+ ...overrides,
34
+ };
35
+ }
36
+ /** Serialize Bookdown to chapter body markup. */
37
+ function html(bookdown, ctx = context()) {
38
+ return serializeChapter(Parser.parseChapter(ctx.edition, bookdown), ctx)
39
+ .body;
40
+ }
41
+ /** Parse as XML and return the parse error, if any. This is the property that
42
+ * actually matters: EPUB readers reject documents that are not well-formed. */
43
+ function xmlErrorIn(xhtml) {
44
+ const parsed = new DOMParser().parseFromString(xhtml, 'application/xhtml+xml');
45
+ const error = parsed.getElementsByTagName('parsererror')[0];
46
+ return error ? (error.textContent ?? 'parse error') : undefined;
47
+ }
48
+ test.each([
49
+ ['Hello.', '<p>Hello.</p>'],
50
+ ['# A header', '<h2 id="header-0">A header</h2>'],
51
+ ['## Deeper', '<h3 id="header-0">Deeper</h3>'],
52
+ ['*bold*', '<p><strong>bold</strong></p>'],
53
+ ['_italic_', '<p><em>italic</em></p>'],
54
+ ['^super^', '<p><sup>super</sup></p>'],
55
+ ['^vsub^', '<p><sub>sub</sub></p>'],
56
+ ['-', '<hr/>'],
57
+ ['* One\n\n* Two', '<ul><li>One</li><li>Two</li></ul>'],
58
+ ['1. One\n\n2. Two', '<ol><li>One</li><li>Two</li></ol>'],
59
+ ['`code`python', '<p><code>code</code></p>'],
60
+ [
61
+ '[label|https://example.com]',
62
+ '<p><a href="https://example.com">label</a></p>',
63
+ ],
64
+ ])('%s serializes to %s', (bookdown, expected) => {
65
+ expect(html(bookdown)).toBe(expected);
66
+ });
67
+ test('escapes text content, every occurrence', () => {
68
+ expect(escapeText('a < b & c > d & e')).toBe('a &lt; b &amp; c &gt; d &amp; e');
69
+ });
70
+ test('escapes ampersands before the escapes it introduces', () => {
71
+ // The bug in TextNode.toHTML(): escaping < first turns &lt; into &amp;lt;.
72
+ expect(escapeText('<')).toBe('&lt;');
73
+ expect(escapeText('&lt;')).toBe('&amp;lt;');
74
+ });
75
+ test('escapes quotes in attributes but not in text', () => {
76
+ expect(escapeAttribute('say "hi" & \'bye\'')).toBe('say &quot;hi&quot; &amp; &#39;bye&#39;');
77
+ expect(escapeText('say "hi"')).toBe('say "hi"');
78
+ });
79
+ test('a comment is omitted', () => {
80
+ // A comment opens on a '%' preceded by a space.
81
+ const out = html('Before %an editorial note% after');
82
+ expect(out).not.toContain('editorial');
83
+ expect(out).toContain('Before');
84
+ expect(out).toContain('after');
85
+ });
86
+ test('a label becomes an anchor rather than vanishing', () => {
87
+ expect(html(':spot Text')).toContain('<span id="spot"></span>');
88
+ });
89
+ test('a code block keeps its code, not its language', () => {
90
+ // The bug in InlineCodeNode.toHTML() was emitting the language instead.
91
+ // The language goes on the opening line, the caption after the closing tick.
92
+ const out = html('`python\nprint(1)\n`A caption');
93
+ expect(out).toContain('<code>print(1)</code>');
94
+ expect(out).toContain('language-python');
95
+ // CodeNode.toHTML() emitted a second opening <pre> instead of closing it.
96
+ expect(out).toContain('</pre>');
97
+ expect(out).toContain('<figcaption>A caption</figcaption>');
98
+ });
99
+ test('a code block escapes markup in the code', () => {
100
+ expect(html('`\n<script>&\n`')).toContain('&lt;script&gt;&amp;');
101
+ });
102
+ test('a quote with a credit becomes a figure with a citation', () => {
103
+ const out = html('"\nQuoted.\n"Someone');
104
+ expect(out).toContain('<blockquote><p>Quoted.</p></blockquote>');
105
+ expect(out).toContain('<cite>Someone</cite>');
106
+ });
107
+ test('a callout becomes an aside', () => {
108
+ expect(html('=\nAttention.\n=')).toBe('<aside class="callout"><p>Attention.</p></aside>');
109
+ });
110
+ test('a table has a header row, a body, and closed rows', () => {
111
+ // TableNode.toHTML() never closed its <tr> elements.
112
+ const out = html(',a|b\n,c|d\nA caption');
113
+ expect(out).toContain('<thead><tr><th>a</th><th>b</th></tr></thead>');
114
+ expect(out).toContain('<tbody><tr><td>c</td><td>d</td></tr></tbody>');
115
+ expect(out).toContain('<caption><p>A caption</p></caption>');
116
+ });
117
+ test('a footnote becomes a linked reference plus an endnote', () => {
118
+ const ctx = context();
119
+ const chapter = Parser.parseChapter(ctx.edition, 'Text{the note}.');
120
+ const { body, endnotes } = serializeChapter(chapter, ctx);
121
+ expect(body).toContain('epub:type="noteref"');
122
+ expect(body).toContain('href="#fn-0"');
123
+ expect(body).toContain('id="fnref-0"');
124
+ expect(body).toContain('<sup>a</sup>');
125
+ expect(endnotes).toHaveLength(1);
126
+ expect(endnotes[0].symbol).toBe('a');
127
+ expect(endnotes[0].content).toBe('the note');
128
+ const section = endnoteSection(endnotes);
129
+ expect(section).toContain('id="fn-0"');
130
+ // The endnote links back to where it was referenced.
131
+ expect(section).toContain('href="#fnref-0"');
132
+ });
133
+ test('footnotes are lettered in document order', () => {
134
+ const ctx = context();
135
+ const { endnotes } = serializeChapter(Parser.parseChapter(ctx.edition, 'A{first} B{second}'), ctx);
136
+ expect(endnotes.map((n) => n.symbol)).toEqual(['a', 'b']);
137
+ });
138
+ test('a citation links to its reference', () => {
139
+ const ctx = context({
140
+ edition: makeEdition({ references: { ko2020: 'Ko, A. (2020).' } }),
141
+ });
142
+ expect(html('Text<ko2020>.', ctx)).toContain('<a href="references.xhtml#ref-ko2020">ko2020</a>');
143
+ });
144
+ test('an unknown citation degrades to plain text', () => {
145
+ const out = html('Text<nope>.');
146
+ expect(out).toContain('<sup class="citation">nope</sup>');
147
+ expect(out).not.toContain('<a ');
148
+ });
149
+ test('a definition links to the glossary', () => {
150
+ const ctx = context({
151
+ edition: makeEdition({
152
+ glossary: {
153
+ bug: { phrase: 'bug', definition: 'A defect', synonyms: [] },
154
+ },
155
+ }),
156
+ });
157
+ expect(html('A ~bug~bug here.', ctx)).toContain('<a class="definition" href="glossary.xhtml#gloss-bug">bug</a>');
158
+ });
159
+ test('an unknown definition degrades to plain text', () => {
160
+ expect(html('A ~bug~nope here.')).toBe('<p>A bug here.</p>');
161
+ });
162
+ test('a chapter link points at the chapter document', () => {
163
+ const ctx = context({ chapters: new Set(['intro']) });
164
+ expect(html('[go|intro]', ctx)).toBe('<p><a href="intro.xhtml">go</a></p>');
165
+ });
166
+ test('a chapter link with a label points at the label', () => {
167
+ const ctx = context({ chapters: new Set(['intro']) });
168
+ expect(html('[go|intro:spot]', ctx)).toBe('<p><a href="intro.xhtml#spot">go</a></p>');
169
+ });
170
+ test('a label-only link stays within the document', () => {
171
+ expect(html('[go|:spot]')).toBe('<p><a href="#spot">go</a></p>');
172
+ });
173
+ test('a link to a chapter not in the package keeps the words but drops the link', () => {
174
+ expect(html('[go|missing]')).toBe('<p>go</p>');
175
+ });
176
+ test('an image points at its packaged path', () => {
177
+ const ctx = context({
178
+ images: new Map([['photo.jpg', 'images/0.jpg']]),
179
+ });
180
+ const out = html('|photo.jpg|A photo|A caption|A credit|', ctx);
181
+ expect(out).toContain('<img src="images/0.jpg" alt="A photo"/>');
182
+ // Caption and credit are paragraphs, so they can't run together on a
183
+ // reader that drops the tags it doesn't know but keeps their contents.
184
+ expect(out).toContain('<p class="caption">A caption</p>');
185
+ expect(out).toContain('<p class="credit">A credit</p>');
186
+ });
187
+ test('an image that could not be packaged keeps its caption', () => {
188
+ const out = html('|photo.jpg|A photo|A caption|A credit|');
189
+ expect(out).not.toContain('<img');
190
+ expect(out).toContain('A caption');
191
+ });
192
+ test('a video embed becomes a link rather than disappearing', () => {
193
+ const out = html('|https://www.youtube.com/embed/abc|A video|Watch this||');
194
+ expect(out).toContain('<a href="https://www.youtube.com/embed/abc">');
195
+ expect(out).not.toContain('<iframe');
196
+ });
197
+ test('an image alt attribute cannot break out of its quotes', () => {
198
+ const ctx = context({ images: new Map([['p.jpg', 'images/0.jpg']]) });
199
+ // The parser curls straight quotes, so markup characters are what matter
200
+ // here; escapeAttribute covers quoting directly.
201
+ const out = html('|p.jpg|A & <angled> alt|||', ctx);
202
+ expect(out).toContain('alt="A &amp; &lt;angled&gt; alt"');
203
+ expect(xmlErrorIn(document_('T', out))).toBeUndefined();
204
+ });
205
+ // The property that decides whether an e-reader will open the file at all.
206
+ test.each([
207
+ ['a paragraph', 'Hello.'],
208
+ ['a header', '# Header'],
209
+ ['formatting', '*bold* and _italic_ and ^sup^'],
210
+ ['a list', '* One\n\n* Two'],
211
+ ['a nested list', '* One\n\n** Nested\n\n* Two'],
212
+ ['a numbered list', '1. One\n\n2. Two'],
213
+ ['a rule', '-'],
214
+ ['a line break', 'One\\\ntwo'],
215
+ ['a table', ',a|b\n,c|d\nCaption'],
216
+ ['a code block', '`html\n<div>&amp;</div>\n`'],
217
+ ['inline code', 'Try `x < y`js here'],
218
+ ['a quote', '"\nQuoted.\n"Someone'],
219
+ ['a callout', '=\nAttention.\n='],
220
+ ['an image', '|p.jpg|Alt|Caption|Credit|'],
221
+ ['a footnote', 'Text{a note} more'],
222
+ ['a citation', 'Text<ref> more'],
223
+ ['a definition', 'A ~term~id here'],
224
+ ['a label', ':spot Text'],
225
+ ['a link', '[text|https://example.com/?a=1&b=2]'],
226
+ ['a comment', 'Before %a note% after'],
227
+ ['unescaped characters', 'Less < greater > amp & quote " apostrophe \''],
228
+ ])('%s produces well-formed XML', (_name, bookdown) => {
229
+ const ctx = context({ images: new Map([['p.jpg', 'images/0.jpg']]) });
230
+ const chapter = Parser.parseChapter(ctx.edition, bookdown);
231
+ const { body, endnotes } = serializeChapter(chapter, ctx);
232
+ const xhtml = document_('Title', body + endnoteSection(endnotes));
233
+ expect(xmlErrorIn(xhtml)).toBeUndefined();
234
+ });
235
+ test('a document declares the epub namespace its footnotes use', () => {
236
+ expect(document_('T', '')).toContain('xmlns:epub="http://www.idpf.org/2007/ops"');
237
+ });
238
+ test('a document escapes its title', () => {
239
+ expect(document_('A & B', '')).toContain('<title>A &amp; B</title>');
240
+ });
241
+ test('a video link is labelled distinctly from its caption', () => {
242
+ // The caption was previously used both as the link text and as the
243
+ // figcaption, so it appeared twice in a row on the page.
244
+ const out = html('|https://www.youtube.com/embed/abc|A description|A caption||');
245
+ expect(out).toContain('>A description</a>');
246
+ expect(out).toContain('<p class="caption">A caption</p>');
247
+ expect(out.match(/A caption/g)).toHaveLength(1);
248
+ });
249
+ test('a video link falls back to its URL when it has no description', () => {
250
+ const out = html('|https://www.youtube.com/embed/abc||A caption||');
251
+ expect(out).toContain('>https://www.youtube.com/embed/abc</a>');
252
+ });
253
+ test('a code block carries its line breaks as markup', () => {
254
+ // A reader that ignores `white-space` reflows a <pre> into a paragraph, so
255
+ // the breaks are carried by <br/> instead of literal newlines. The
256
+ // newlines are dropped rather than kept alongside, which would double-space
257
+ // the block on a reader that honours both.
258
+ const out = html('`\nfirst\nsecond\n`');
259
+ expect(out).toContain('first<br/>second');
260
+ expect(out).not.toContain('first\nsecond');
261
+ });
262
+ test('a code block keeps its indentation without relying on white-space', () => {
263
+ const out = html('`python\nif x:\n return 1\n`');
264
+ // Leading spaces become non-breaking, so they survive whitespace collapsing.
265
+ expect(out).toContain('if x:<br/>\u00a0\u00a0\u00a0\u00a0return 1');
266
+ });
267
+ test('a code block still escapes markup after the line-break rewrite', () => {
268
+ expect(html('`\n<script>&\n`')).toContain('&lt;script&gt;&amp;');
269
+ });
270
+ test('a link URL carrying Bookdown escapes is unescaped', () => {
271
+ // Authors write escaped punctuation inside URLs; the parser keeps a link's
272
+ // address verbatim, and a backslash is never valid in one. epubcheck
273
+ // rejects it as a bad path segment delimiter.
274
+ expect(html('[text|https://medium\\.com/a-post]')).toContain('href="https://medium.com/a-post"');
275
+ });
276
+ test('an unclosed link cannot make the whole book invalid', () => {
277
+ // A missing ']' swallows what follows into the address. The link is wrong
278
+ // either way, but the characters are encoded so the document stays valid
279
+ // rather than the typo making the book unopenable.
280
+ const out = html('[text|https://example.com/a b, [other|https://x.com]');
281
+ expect(out).not.toMatch(/href="[^"]* [^"]*"/);
282
+ expect(xmlErrorIn(document_('T', out))).toBeUndefined();
283
+ });
@@ -0,0 +1,35 @@
1
+ /**
2
+ * A minimal ZIP writer, sufficient for EPUB and dependency-free.
3
+ *
4
+ * EPUB requires that the first entry in the archive be an uncompressed
5
+ * `mimetype` file, which most convenience wrappers don't expose, so we write
6
+ * the format directly. Only the pieces EPUB needs are implemented: no Zip64
7
+ * (books are nowhere near 4GB), no encryption, no directory entries.
8
+ *
9
+ * Everything here is portable between the browser and Node, so it can be unit
10
+ * tested and is safe to import during server rendering. `CompressionStream` is
11
+ * used when available and entries fall back to stored otherwise.
12
+ */
13
+ /** The CRC-32 of the given bytes, as the ZIP spec defines it. */
14
+ export declare function crc32(bytes: Uint8Array): number;
15
+ export default class Zip {
16
+ #private;
17
+ /**
18
+ * @param modified the timestamp recorded for every entry. Defaults to now;
19
+ * tests pass a fixed date so output is byte-for-byte reproducible.
20
+ */
21
+ constructor(modified?: Date);
22
+ /**
23
+ * Queue a file. Entries are written in the order added, which is how the
24
+ * EPUB `mimetype` requirement is met: add it first, stored.
25
+ *
26
+ * @param store true to skip compression. Pass it for `mimetype` (required)
27
+ * and for already-compressed payloads like JPEGs, where deflating costs
28
+ * time and saves nothing.
29
+ */
30
+ add(path: string, bytes: Uint8Array | string, store?: boolean): void;
31
+ /** The number of files queued. */
32
+ get size(): number;
33
+ /** Assemble the archive. */
34
+ toBlob(type?: string): Promise<Blob>;
35
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * A minimal ZIP writer, sufficient for EPUB and dependency-free.
3
+ *
4
+ * EPUB requires that the first entry in the archive be an uncompressed
5
+ * `mimetype` file, which most convenience wrappers don't expose, so we write
6
+ * the format directly. Only the pieces EPUB needs are implemented: no Zip64
7
+ * (books are nowhere near 4GB), no encryption, no directory entries.
8
+ *
9
+ * Everything here is portable between the browser and Node, so it can be unit
10
+ * tested and is safe to import during server rendering. `CompressionStream` is
11
+ * used when available and entries fall back to stored otherwise.
12
+ */
13
+ const LOCAL_HEADER = 0x04034b50;
14
+ const CENTRAL_HEADER = 0x02014b50;
15
+ const END_OF_CENTRAL_DIRECTORY = 0x06054b50;
16
+ /** Deflated, per the ZIP spec's compression method field. */
17
+ const DEFLATED = 8;
18
+ /** Stored (no compression). */
19
+ const STORED = 0;
20
+ /** Bit 11 of the general purpose flags declares the filename to be UTF-8. */
21
+ const UTF8_NAMES = 0x0800;
22
+ const CRC_TABLE = makeCRCTable();
23
+ function makeCRCTable() {
24
+ const table = new Uint32Array(256);
25
+ for (let i = 0; i < 256; i++) {
26
+ let c = i;
27
+ for (let bit = 0; bit < 8; bit++)
28
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
29
+ table[i] = c >>> 0;
30
+ }
31
+ return table;
32
+ }
33
+ /** The CRC-32 of the given bytes, as the ZIP spec defines it. */
34
+ export function crc32(bytes) {
35
+ let crc = 0xffffffff;
36
+ for (let i = 0; i < bytes.length; i++)
37
+ crc = CRC_TABLE[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8);
38
+ return (crc ^ 0xffffffff) >>> 0;
39
+ }
40
+ /**
41
+ * Compress with raw DEFLATE, or return undefined if the platform can't.
42
+ *
43
+ * The bytes are pushed through the stream directly rather than via
44
+ * `Blob.stream()`, which keeps this working everywhere web streams exist --
45
+ * including jsdom, whose Blob has no `stream()`.
46
+ */
47
+ async function deflate(bytes) {
48
+ if (typeof CompressionStream === 'undefined')
49
+ return undefined;
50
+ try {
51
+ const stream = new CompressionStream('deflate-raw');
52
+ // Typed explicitly: a bare Uint8Array is generic over its buffer, and
53
+ // the DOM types require one that is not shared.
54
+ const writer = stream.writable.getWriter();
55
+ // Deliberately not awaited before reading: a large chunk can exceed the
56
+ // stream's queue, and the write only settles once the reader drains it.
57
+ const written = writer.write(bytes).then(() => writer.close());
58
+ const reader = stream.readable.getReader();
59
+ const chunks = [];
60
+ let length = 0;
61
+ for (;;) {
62
+ const { done, value } = await reader.read();
63
+ if (done)
64
+ break;
65
+ chunks.push(value);
66
+ length += value.length;
67
+ }
68
+ await written;
69
+ const deflated = new Uint8Array(length);
70
+ let offset = 0;
71
+ for (const chunk of chunks) {
72
+ deflated.set(chunk, offset);
73
+ offset += chunk.length;
74
+ }
75
+ return deflated;
76
+ }
77
+ catch {
78
+ // A platform with the constructor but without 'deflate-raw'.
79
+ return undefined;
80
+ }
81
+ }
82
+ /**
83
+ * DOS date and time, which is what ZIP stores. Seconds have two-second
84
+ * resolution and years are relative to 1980; both are the format's, not ours.
85
+ */
86
+ function dosDateTime(date) {
87
+ return {
88
+ date: (Math.max(0, date.getFullYear() - 1980) << 9) |
89
+ ((date.getMonth() + 1) << 5) |
90
+ date.getDate(),
91
+ time: (date.getHours() << 11) |
92
+ (date.getMinutes() << 5) |
93
+ (date.getSeconds() >> 1),
94
+ };
95
+ }
96
+ export default class Zip {
97
+ #encoder = new TextEncoder();
98
+ #pending = [];
99
+ #modified;
100
+ /**
101
+ * @param modified the timestamp recorded for every entry. Defaults to now;
102
+ * tests pass a fixed date so output is byte-for-byte reproducible.
103
+ */
104
+ constructor(modified = new Date()) {
105
+ this.#modified = modified;
106
+ }
107
+ /**
108
+ * Queue a file. Entries are written in the order added, which is how the
109
+ * EPUB `mimetype` requirement is met: add it first, stored.
110
+ *
111
+ * @param store true to skip compression. Pass it for `mimetype` (required)
112
+ * and for already-compressed payloads like JPEGs, where deflating costs
113
+ * time and saves nothing.
114
+ */
115
+ add(path, bytes, store = false) {
116
+ this.#pending.push({
117
+ path,
118
+ bytes: typeof bytes === 'string' ? this.#encoder.encode(bytes) : bytes,
119
+ store,
120
+ });
121
+ }
122
+ /** The number of files queued. */
123
+ get size() {
124
+ return this.#pending.length;
125
+ }
126
+ /** Assemble the archive. */
127
+ async toBlob(type = 'application/zip') {
128
+ const { date, time } = dosDateTime(this.#modified);
129
+ const entries = [];
130
+ const parts = [];
131
+ let offset = 0;
132
+ for (const { path, bytes, store } of this.#pending) {
133
+ const name = this.#encoder.encode(path);
134
+ const compressed = store ? undefined : await deflate(bytes);
135
+ // Only take the compressed form if it actually helped.
136
+ const useDeflate = compressed !== undefined && compressed.length < bytes.length;
137
+ const data = useDeflate ? compressed : bytes;
138
+ const entry = {
139
+ name,
140
+ data,
141
+ crc: crc32(bytes),
142
+ size: bytes.length,
143
+ method: useDeflate ? DEFLATED : STORED,
144
+ offset,
145
+ };
146
+ entries.push(entry);
147
+ const header = new Uint8Array(30 + name.length);
148
+ const view = new DataView(header.buffer);
149
+ view.setUint32(0, LOCAL_HEADER, true);
150
+ view.setUint16(4, 20, true); // version needed
151
+ view.setUint16(6, UTF8_NAMES, true);
152
+ view.setUint16(8, entry.method, true);
153
+ view.setUint16(10, time, true);
154
+ view.setUint16(12, date, true);
155
+ view.setUint32(14, entry.crc, true);
156
+ view.setUint32(18, data.length, true); // compressed size
157
+ view.setUint32(22, entry.size, true); // uncompressed size
158
+ view.setUint16(26, name.length, true);
159
+ view.setUint16(28, 0, true); // extra field length
160
+ header.set(name, 30);
161
+ parts.push(header, data);
162
+ offset += header.length + data.length;
163
+ }
164
+ const directoryOffset = offset;
165
+ for (const entry of entries) {
166
+ const header = new Uint8Array(46 + entry.name.length);
167
+ const view = new DataView(header.buffer);
168
+ view.setUint32(0, CENTRAL_HEADER, true);
169
+ view.setUint16(4, 20, true); // version made by
170
+ view.setUint16(6, 20, true); // version needed
171
+ view.setUint16(8, UTF8_NAMES, true);
172
+ view.setUint16(10, entry.method, true);
173
+ view.setUint16(12, time, true);
174
+ view.setUint16(14, date, true);
175
+ view.setUint32(16, entry.crc, true);
176
+ view.setUint32(20, entry.data.length, true);
177
+ view.setUint32(24, entry.size, true);
178
+ view.setUint16(28, entry.name.length, true);
179
+ view.setUint16(30, 0, true); // extra field length
180
+ view.setUint16(32, 0, true); // comment length
181
+ view.setUint16(34, 0, true); // disk number
182
+ view.setUint16(36, 0, true); // internal attributes
183
+ view.setUint32(38, 0, true); // external attributes
184
+ view.setUint32(42, entry.offset, true);
185
+ header.set(entry.name, 46);
186
+ parts.push(header);
187
+ offset += header.length;
188
+ }
189
+ const end = new Uint8Array(22);
190
+ const endView = new DataView(end.buffer);
191
+ endView.setUint32(0, END_OF_CENTRAL_DIRECTORY, true);
192
+ endView.setUint16(4, 0, true); // disk number
193
+ endView.setUint16(6, 0, true); // disk with directory
194
+ endView.setUint16(8, entries.length, true);
195
+ endView.setUint16(10, entries.length, true);
196
+ endView.setUint32(12, offset - directoryOffset, true);
197
+ endView.setUint32(16, directoryOffset, true);
198
+ endView.setUint16(20, 0, true); // comment length
199
+ parts.push(end);
200
+ return new Blob(parts, { type });
201
+ }
202
+ }
@@ -0,0 +1 @@
1
+ export {};