bookish-press 0.7.4 → 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.
Files changed (276) hide show
  1. package/README.md +56 -9
  2. package/dist/components/Auth.svelte +29 -39
  3. package/dist/components/Auth.svelte.d.ts +5 -17
  4. package/dist/components/BookPreview.svelte +14 -20
  5. package/dist/components/BookPreview.svelte.d.ts +6 -18
  6. package/dist/components/Link.svelte +19 -30
  7. package/dist/components/Link.svelte.d.ts +7 -20
  8. package/dist/components/app/BookList.svelte +2 -3
  9. package/dist/components/app/BookList.svelte.d.ts +6 -18
  10. package/dist/components/app/Button.svelte +12 -8
  11. package/dist/components/app/Button.svelte.d.ts +10 -23
  12. package/dist/components/app/Feedback.svelte +4 -4
  13. package/dist/components/app/Feedback.svelte.d.ts +6 -19
  14. package/dist/components/app/Head.svelte +2 -2
  15. package/dist/components/app/Head.svelte.d.ts +6 -19
  16. package/dist/components/app/Large.svelte +4 -1
  17. package/dist/components/app/Large.svelte.d.ts +5 -28
  18. package/dist/components/app/Lead.svelte +4 -1
  19. package/dist/components/app/Lead.svelte.d.ts +5 -28
  20. package/dist/components/app/Link.svelte +12 -9
  21. package/dist/components/app/Link.svelte.d.ts +9 -22
  22. package/dist/components/app/Options.svelte +8 -6
  23. package/dist/components/app/Options.svelte.d.ts +8 -20
  24. package/dist/components/app/Paragraph.svelte +2 -2
  25. package/dist/components/app/Paragraph.svelte.d.ts +6 -19
  26. package/dist/components/app/Section.svelte +4 -1
  27. package/dist/components/app/Section.svelte.d.ts +5 -28
  28. package/dist/components/app/Table.svelte +4 -1
  29. package/dist/components/app/Table.svelte.d.ts +5 -28
  30. package/dist/components/app/TextInput.svelte +10 -8
  31. package/dist/components/app/TextInput.svelte.d.ts +12 -23
  32. package/dist/components/chapter/Atom.svelte +4 -7
  33. package/dist/components/chapter/Atom.svelte.d.ts +6 -19
  34. package/dist/components/chapter/Block.svelte +21 -23
  35. package/dist/components/chapter/Block.svelte.d.ts +5 -17
  36. package/dist/components/chapter/Callout.svelte +3 -5
  37. package/dist/components/chapter/Callout.svelte.d.ts +5 -17
  38. package/dist/components/chapter/CaptionedCode.svelte +12 -14
  39. package/dist/components/chapter/CaptionedCode.svelte.d.ts +5 -17
  40. package/dist/components/chapter/ChapterBody.svelte +14 -17
  41. package/dist/components/chapter/ChapterBody.svelte.d.ts +7 -19
  42. package/dist/components/chapter/ChapterBody.test.d.ts +1 -0
  43. package/dist/components/chapter/ChapterBody.test.js +85 -0
  44. package/dist/components/chapter/Citations.svelte +39 -45
  45. package/dist/components/chapter/Citations.svelte.d.ts +5 -17
  46. package/dist/components/chapter/Code.svelte +158 -155
  47. package/dist/components/chapter/Code.svelte.d.ts +10 -23
  48. package/dist/components/chapter/Comment.svelte +30 -23
  49. package/dist/components/chapter/Comment.svelte.d.ts +5 -17
  50. package/dist/components/chapter/Definition.svelte +36 -34
  51. package/dist/components/chapter/Definition.svelte.d.ts +5 -17
  52. package/dist/components/chapter/Embed.svelte +78 -72
  53. package/dist/components/chapter/Embed.svelte.d.ts +8 -20
  54. package/dist/components/chapter/ErrorMessage.svelte +2 -4
  55. package/dist/components/chapter/ErrorMessage.svelte.d.ts +5 -17
  56. package/dist/components/chapter/Figure.svelte +22 -29
  57. package/dist/components/chapter/Figure.svelte.d.ts +9 -22
  58. package/dist/components/chapter/Footnote.svelte +40 -32
  59. package/dist/components/chapter/Footnote.svelte.d.ts +5 -17
  60. package/dist/components/chapter/Format.svelte +26 -41
  61. package/dist/components/chapter/Format.svelte.d.ts +7 -18
  62. package/dist/components/chapter/InlineCode.svelte +7 -8
  63. package/dist/components/chapter/InlineCode.svelte.d.ts +5 -17
  64. package/dist/components/chapter/Label.svelte +5 -11
  65. package/dist/components/chapter/Label.svelte.d.ts +5 -17
  66. package/dist/components/chapter/LineBreak.svelte +1 -3
  67. package/dist/components/chapter/LineBreak.svelte.d.ts +5 -17
  68. package/dist/components/chapter/Link.svelte +17 -23
  69. package/dist/components/chapter/Link.svelte.d.ts +5 -17
  70. package/dist/components/chapter/List.svelte +9 -9
  71. package/dist/components/chapter/List.svelte.d.ts +6 -17
  72. package/dist/components/chapter/Marginal.svelte +59 -60
  73. package/dist/components/chapter/Marginal.svelte.d.ts +10 -22
  74. package/dist/components/chapter/Paragraph.svelte +8 -23
  75. package/dist/components/chapter/Paragraph.svelte.d.ts +6 -18
  76. package/dist/components/chapter/Positioned.svelte +2 -4
  77. package/dist/components/chapter/Positioned.svelte.d.ts +6 -19
  78. package/dist/components/chapter/Problem.svelte +3 -2
  79. package/dist/components/chapter/Problem.svelte.d.ts +5 -28
  80. package/dist/components/chapter/Python.svelte +45 -58
  81. package/dist/components/chapter/Python.svelte.d.ts +6 -18
  82. package/dist/components/chapter/Quote.svelte +5 -7
  83. package/dist/components/chapter/Quote.svelte.d.ts +5 -17
  84. package/dist/components/chapter/Rule.svelte +1 -3
  85. package/dist/components/chapter/Rule.svelte.d.ts +5 -17
  86. package/dist/components/chapter/Table.svelte +6 -8
  87. package/dist/components/chapter/Table.svelte.d.ts +5 -17
  88. package/dist/components/chapter/Text.svelte +54 -69
  89. package/dist/components/chapter/Text.svelte.d.ts +6 -18
  90. package/dist/components/controls/DarkToggle.svelte +7 -8
  91. package/dist/components/controls/DarkToggle.svelte.d.ts +6 -18
  92. package/dist/components/editor/BookishEditor.svelte +583 -761
  93. package/dist/components/editor/BookishEditor.svelte.d.ts +14 -26
  94. package/dist/components/editor/CalloutEditor.svelte +3 -3
  95. package/dist/components/editor/CalloutEditor.svelte.d.ts +5 -17
  96. package/dist/components/editor/CaptionedCodeEditor.svelte +5 -5
  97. package/dist/components/editor/CaptionedCodeEditor.svelte.d.ts +5 -17
  98. package/dist/components/editor/CaretView.svelte +23 -25
  99. package/dist/components/editor/CaretView.svelte.d.ts +15 -27
  100. package/dist/components/editor/CitationsEditor.svelte +25 -28
  101. package/dist/components/editor/CitationsEditor.svelte.d.ts +5 -17
  102. package/dist/components/editor/ConfirmButton.svelte +9 -6
  103. package/dist/components/editor/ConfirmButton.svelte.d.ts +8 -21
  104. package/dist/components/editor/DefinitionEditor.svelte +11 -15
  105. package/dist/components/editor/DefinitionEditor.svelte.d.ts +5 -17
  106. package/dist/components/editor/EmbedEditor.svelte +44 -45
  107. package/dist/components/editor/EmbedEditor.svelte.d.ts +5 -17
  108. package/dist/components/editor/Icon.svelte +1 -1
  109. package/dist/components/editor/Icon.svelte.d.ts +5 -17
  110. package/dist/components/editor/ImageChooser.svelte +15 -18
  111. package/dist/components/editor/ImageChooser.svelte.d.ts +6 -18
  112. package/dist/components/editor/ImageThumbnail.svelte +29 -24
  113. package/dist/components/editor/ImageThumbnail.svelte.d.ts +7 -19
  114. package/dist/components/editor/InlineCodeEditor.svelte +3 -3
  115. package/dist/components/editor/InlineCodeEditor.svelte.d.ts +5 -17
  116. package/dist/components/editor/LabelEditor.svelte +5 -6
  117. package/dist/components/editor/LabelEditor.svelte.d.ts +5 -17
  118. package/dist/components/editor/LanguageEditor.svelte +4 -5
  119. package/dist/components/editor/LanguageEditor.svelte.d.ts +6 -18
  120. package/dist/components/editor/LinkEditor.svelte +52 -68
  121. package/dist/components/editor/LinkEditor.svelte.d.ts +5 -17
  122. package/dist/components/editor/Note.svelte +4 -1
  123. package/dist/components/editor/Note.svelte.d.ts +5 -28
  124. package/dist/components/editor/Permissions.svelte +36 -34
  125. package/dist/components/editor/Permissions.svelte.d.ts +9 -21
  126. package/dist/components/editor/PositionEditor.svelte +6 -7
  127. package/dist/components/editor/PositionEditor.svelte.d.ts +6 -18
  128. package/dist/components/editor/QuoteEditor.svelte +3 -3
  129. package/dist/components/editor/QuoteEditor.svelte.d.ts +5 -17
  130. package/dist/components/editor/Switch.svelte +16 -11
  131. package/dist/components/editor/Switch.svelte.d.ts +9 -21
  132. package/dist/components/editor/TableEditor.svelte +3 -3
  133. package/dist/components/editor/TableEditor.svelte.d.ts +5 -17
  134. package/dist/components/editor/TextEditor.svelte +74 -87
  135. package/dist/components/editor/TextEditor.svelte.d.ts +13 -25
  136. package/dist/components/editor/Toggle.svelte +18 -21
  137. package/dist/components/editor/Toggle.svelte.d.ts +7 -20
  138. package/dist/components/editor/Toolbar.svelte +140 -145
  139. package/dist/components/editor/Toolbar.svelte.d.ts +5 -17
  140. package/dist/components/editor/ToolbarGroup.svelte +4 -4
  141. package/dist/components/editor/ToolbarGroup.svelte.d.ts +6 -19
  142. package/dist/components/editor/ToolbarSpacer.svelte +1 -1
  143. package/dist/components/editor/ToolbarSpacer.svelte.d.ts +22 -21
  144. package/dist/components/editor/URLEditor.svelte +3 -5
  145. package/dist/components/editor/URLEditor.svelte.d.ts +7 -19
  146. package/dist/components/page/Acknowledgements.svelte +14 -8
  147. package/dist/components/page/Acknowledgements.svelte.d.ts +3 -16
  148. package/dist/components/page/Analytics.svelte +2 -3
  149. package/dist/components/page/Analytics.svelte.d.ts +6 -17
  150. package/dist/components/page/Authors.svelte +29 -29
  151. package/dist/components/page/Authors.svelte.d.ts +11 -23
  152. package/dist/components/page/BookLoader.svelte +47 -43
  153. package/dist/components/page/BookLoader.svelte.d.ts +5 -17
  154. package/dist/components/page/BulkReferenceEditor.svelte +43 -26
  155. package/dist/components/page/BulkReferenceEditor.svelte.d.ts +3 -16
  156. package/dist/components/page/Chapter.svelte +224 -249
  157. package/dist/components/page/Chapter.svelte.d.ts +6 -18
  158. package/dist/components/page/ChapterContext.d.ts +3 -1
  159. package/dist/components/page/ChapterNumber.svelte +4 -1
  160. package/dist/components/page/ChapterNumber.svelte.d.ts +5 -28
  161. package/dist/components/page/ChapterTitle.svelte +4 -4
  162. package/dist/components/page/ChapterTitle.svelte.d.ts +6 -19
  163. package/dist/components/page/Contexts.js +2 -2
  164. package/dist/components/page/DefinitionView.svelte +51 -44
  165. package/dist/components/page/DefinitionView.svelte.d.ts +6 -18
  166. package/dist/components/page/Description.svelte +10 -4
  167. package/dist/components/page/Description.svelte.d.ts +16 -14
  168. package/dist/components/page/DownloadEPUB.svelte +234 -0
  169. package/dist/components/page/DownloadEPUB.svelte.d.ts +3 -0
  170. package/dist/components/page/EditableHeader.svelte +4 -6
  171. package/dist/components/page/EditableHeader.svelte.d.ts +7 -18
  172. package/dist/components/page/Edition.svelte +57 -71
  173. package/dist/components/page/Edition.svelte.d.ts +7 -20
  174. package/dist/components/page/EditionLoader.svelte +119 -82
  175. package/dist/components/page/EditionLoader.svelte.d.ts +6 -19
  176. package/dist/components/page/Editions.svelte +26 -31
  177. package/dist/components/page/Editions.svelte.d.ts +5 -17
  178. package/dist/components/page/Glossary.svelte +31 -35
  179. package/dist/components/page/Glossary.svelte.d.ts +3 -16
  180. package/dist/components/page/Header.svelte +43 -49
  181. package/dist/components/page/Header.svelte.d.ts +19 -30
  182. package/dist/components/page/Index.svelte +46 -46
  183. package/dist/components/page/Index.svelte.d.ts +3 -16
  184. package/dist/components/page/Instructions.svelte +2 -2
  185. package/dist/components/page/Instructions.svelte.d.ts +6 -19
  186. package/dist/components/page/License.svelte +13 -7
  187. package/dist/components/page/License.svelte.d.ts +16 -14
  188. package/dist/components/page/Loading.svelte +9 -9
  189. package/dist/components/page/Loading.svelte.d.ts +22 -21
  190. package/dist/components/page/Media.svelte +36 -27
  191. package/dist/components/page/Media.svelte.d.ts +3 -16
  192. package/dist/components/page/MediaPreview.svelte +22 -19
  193. package/dist/components/page/MediaPreview.svelte.d.ts +8 -21
  194. package/dist/components/page/Muted.svelte +4 -1
  195. package/dist/components/page/Muted.svelte.d.ts +5 -28
  196. package/dist/components/page/Outline.svelte +75 -97
  197. package/dist/components/page/Outline.svelte.d.ts +8 -20
  198. package/dist/components/page/Page.svelte +23 -33
  199. package/dist/components/page/Page.svelte.d.ts +7 -20
  200. package/dist/components/page/PageHeader.svelte +2 -2
  201. package/dist/components/page/PageHeader.svelte.d.ts +6 -19
  202. package/dist/components/page/PageParagraph.svelte +4 -1
  203. package/dist/components/page/PageParagraph.svelte.d.ts +5 -28
  204. package/dist/components/page/PossibleChapter.svelte +7 -7
  205. package/dist/components/page/PossibleChapter.svelte.d.ts +3 -16
  206. package/dist/components/page/PossibleReference.svelte +4 -5
  207. package/dist/components/page/PossibleReference.svelte.d.ts +6 -18
  208. package/dist/components/page/Print.svelte +11 -12
  209. package/dist/components/page/Print.svelte.d.ts +3 -16
  210. package/dist/components/page/Publish.svelte +47 -47
  211. package/dist/components/page/Publish.svelte.d.ts +3 -16
  212. package/dist/components/page/Reference.svelte +24 -35
  213. package/dist/components/page/Reference.svelte.d.ts +6 -18
  214. package/dist/components/page/References.svelte +21 -22
  215. package/dist/components/page/References.svelte.d.ts +3 -16
  216. package/dist/components/page/Rows.svelte +5 -2
  217. package/dist/components/page/Rows.svelte.d.ts +5 -28
  218. package/dist/components/page/Search.svelte +59 -61
  219. package/dist/components/page/Search.svelte.d.ts +3 -16
  220. package/dist/components/page/Status.svelte +2 -2
  221. package/dist/components/page/Status.svelte.d.ts +16 -14
  222. package/dist/components/page/SubdomainEditor.svelte +11 -11
  223. package/dist/components/page/SubdomainEditor.svelte.d.ts +16 -14
  224. package/dist/components/page/TableOfContents.svelte +157 -124
  225. package/dist/components/page/TableOfContents.svelte.d.ts +3 -16
  226. package/dist/components/page/TableOfContentsImage.svelte +6 -7
  227. package/dist/components/page/TableOfContentsImage.svelte.d.ts +6 -18
  228. package/dist/components/page/TableOfContentsRow.svelte +32 -32
  229. package/dist/components/page/TableOfContentsRow.svelte.d.ts +10 -22
  230. package/dist/components/page/ThemeEditor.svelte +35 -28
  231. package/dist/components/page/ThemeEditor.svelte.d.ts +3 -16
  232. package/dist/components/page/ThemeEditorPreview.svelte +11 -13
  233. package/dist/components/page/ThemeEditorPreview.svelte.d.ts +5 -17
  234. package/dist/components/page/ThemeSetEditor.svelte +4 -6
  235. package/dist/components/page/ThemeSetEditor.svelte.d.ts +7 -19
  236. package/dist/components/page/ThemeVariableEditor.svelte +9 -11
  237. package/dist/components/page/ThemeVariableEditor.svelte.d.ts +7 -19
  238. package/dist/components/page/Title.svelte +4 -1
  239. package/dist/components/page/Title.svelte.d.ts +5 -28
  240. package/dist/components/page/Unknown.svelte +11 -8
  241. package/dist/components/page/Unknown.svelte.d.ts +5 -17
  242. package/dist/epub/build.d.ts +39 -0
  243. package/dist/epub/build.js +143 -0
  244. package/dist/epub/build.test.d.ts +1 -0
  245. package/dist/epub/build.test.js +411 -0
  246. package/dist/epub/images.d.ts +79 -0
  247. package/dist/epub/images.js +260 -0
  248. package/dist/epub/images.test.d.ts +1 -0
  249. package/dist/epub/images.test.js +129 -0
  250. package/dist/epub/package.d.ts +75 -0
  251. package/dist/epub/package.js +392 -0
  252. package/dist/epub/stylesheet.test.d.ts +1 -0
  253. package/dist/epub/stylesheet.test.js +243 -0
  254. package/dist/epub/xhtml.d.ts +69 -0
  255. package/dist/epub/xhtml.js +367 -0
  256. package/dist/epub/xhtml.test.d.ts +1 -0
  257. package/dist/epub/xhtml.test.js +283 -0
  258. package/dist/epub/zip.d.ts +35 -0
  259. package/dist/epub/zip.js +202 -0
  260. package/dist/epub/zip.test.d.ts +1 -0
  261. package/dist/epub/zip.test.js +171 -0
  262. package/dist/index.d.ts +1 -0
  263. package/dist/index.js +1 -0
  264. package/dist/models/Firebase.js +18 -4
  265. package/dist/models/book/Book.js +2 -1
  266. package/dist/models/book/BookMedia.js +1 -2
  267. package/dist/models/book/Chapter.js +3 -2
  268. package/dist/models/book/ChapterID.d.ts +1 -1
  269. package/dist/models/book/Edition.d.ts +1 -1
  270. package/dist/models/book/Edition.js +6 -1
  271. package/dist/models/chapter/BlocksNode.js +3 -1
  272. package/dist/models/chapter/BlocksNode.test.js +5 -1
  273. package/dist/models/chapter/Node.test.js +2 -2
  274. package/dist/models/chapter/TextNode.js +8 -5
  275. package/dist/models/chapter/TextNode.test.js +8 -0
  276. package/package.json +36 -22
@@ -0,0 +1,171 @@
1
+ import { expect, test } from 'vitest';
2
+ import Zip, { crc32 } from './zip';
3
+ const decoder = new TextDecoder();
4
+ /**
5
+ * A minimal ZIP reader, so the writer's output is verified by parsing it back
6
+ * rather than by asserting on bytes we'd have to keep in sync by hand.
7
+ */
8
+ function read(bytes) {
9
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
10
+ // Find the end of central directory record, which is last.
11
+ let end = bytes.length - 22;
12
+ while (end >= 0 && view.getUint32(end, true) !== 0x06054b50)
13
+ end--;
14
+ if (end < 0)
15
+ throw new Error('No end of central directory record');
16
+ const count = view.getUint16(end + 8, true);
17
+ let offset = view.getUint32(end + 16, true);
18
+ const entries = [];
19
+ for (let i = 0; i < count; i++) {
20
+ if (view.getUint32(offset, true) !== 0x02014b50)
21
+ throw new Error(`Bad central header at ${offset}`);
22
+ const nameLength = view.getUint16(offset + 28, true);
23
+ const extraLength = view.getUint16(offset + 30, true);
24
+ const commentLength = view.getUint16(offset + 32, true);
25
+ entries.push({
26
+ method: view.getUint16(offset + 10, true),
27
+ crc: view.getUint32(offset + 16, true),
28
+ size: view.getUint32(offset + 24, true),
29
+ offset: view.getUint32(offset + 42, true),
30
+ path: decoder.decode(bytes.subarray(offset + 46, offset + 46 + nameLength)),
31
+ });
32
+ offset += 46 + nameLength + extraLength + commentLength;
33
+ }
34
+ return { entries, view, bytes };
35
+ }
36
+ /** Read one entry's bytes, inflating if necessary. */
37
+ async function contentOf(archive, path) {
38
+ const entry = archive.entries.find((e) => e.path === path);
39
+ if (entry === undefined)
40
+ throw new Error(`No entry ${path}`);
41
+ const { view, bytes } = archive;
42
+ if (view.getUint32(entry.offset, true) !== 0x04034b50)
43
+ throw new Error(`Bad local header for ${path}`);
44
+ const nameLength = view.getUint16(entry.offset + 26, true);
45
+ const extraLength = view.getUint16(entry.offset + 28, true);
46
+ const compressedSize = view.getUint32(entry.offset + 18, true);
47
+ const start = entry.offset + 30 + nameLength + extraLength;
48
+ const data = bytes.subarray(start, start + compressedSize);
49
+ if (entry.method === 0)
50
+ return data;
51
+ return inflate(data);
52
+ }
53
+ /** Raw DEFLATE decompression, avoiding Blob.stream(), which jsdom lacks. */
54
+ async function inflate(data) {
55
+ const stream = new DecompressionStream('deflate-raw');
56
+ const writer = stream.writable.getWriter();
57
+ const written = writer.write(data).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 out = new Uint8Array(length);
70
+ let offset = 0;
71
+ for (const chunk of chunks) {
72
+ out.set(chunk, offset);
73
+ offset += chunk.length;
74
+ }
75
+ return out;
76
+ }
77
+ async function archiveOf(zip) {
78
+ return read(new Uint8Array(await (await zip.toBlob()).arrayBuffer()));
79
+ }
80
+ // The check value published in the zlib and PNG specifications.
81
+ test('crc32 matches the specification check value', () => {
82
+ expect(crc32(new TextEncoder().encode('123456789'))).toBe(0xcbf43926);
83
+ });
84
+ test('crc32 of nothing is zero', () => {
85
+ expect(crc32(new Uint8Array(0))).toBe(0);
86
+ });
87
+ test('round trips text and binary content', async () => {
88
+ const binary = new Uint8Array([0, 1, 2, 253, 254, 255]);
89
+ const zip = new Zip();
90
+ zip.add('hello.txt', 'Hello, world!');
91
+ zip.add('nested/deep/bytes.bin', binary);
92
+ const archive = await archiveOf(zip);
93
+ expect(archive.entries.map((e) => e.path)).toEqual([
94
+ 'hello.txt',
95
+ 'nested/deep/bytes.bin',
96
+ ]);
97
+ expect(decoder.decode(await contentOf(archive, 'hello.txt'))).toBe('Hello, world!');
98
+ expect(await contentOf(archive, 'nested/deep/bytes.bin')).toEqual(binary);
99
+ });
100
+ test('round trips content that compresses well', async () => {
101
+ // Long enough that deflate definitely beats storing it.
102
+ const text = 'the quick brown fox '.repeat(500);
103
+ const zip = new Zip();
104
+ zip.add('repetitive.txt', text);
105
+ const archive = await archiveOf(zip);
106
+ expect(archive.entries[0].method).toBe(8);
107
+ expect(decoder.decode(await contentOf(archive, 'repetitive.txt'))).toBe(text);
108
+ });
109
+ test('stores rather than deflates when compression would not help', async () => {
110
+ const zip = new Zip();
111
+ // Incompressible: a short string has more deflate overhead than savings.
112
+ zip.add('tiny.txt', 'x');
113
+ const archive = await archiveOf(zip);
114
+ expect(archive.entries[0].method).toBe(0);
115
+ });
116
+ test('honors an explicit request to store', async () => {
117
+ const text = 'the quick brown fox '.repeat(500);
118
+ const zip = new Zip();
119
+ zip.add('repetitive.txt', text, true);
120
+ const archive = await archiveOf(zip);
121
+ expect(archive.entries[0].method).toBe(0);
122
+ expect(archive.entries[0].size).toBe(text.length);
123
+ });
124
+ test('records the uncompressed size and CRC of the original bytes', async () => {
125
+ const text = 'the quick brown fox '.repeat(500);
126
+ const zip = new Zip();
127
+ zip.add('repetitive.txt', text);
128
+ const archive = await archiveOf(zip);
129
+ expect(archive.entries[0].size).toBe(text.length);
130
+ expect(archive.entries[0].crc).toBe(crc32(new TextEncoder().encode(text)));
131
+ });
132
+ test('encodes non-ASCII paths and content as UTF-8', async () => {
133
+ const path = 'café/章.txt';
134
+ const content = 'Hello, 世界!';
135
+ const zip = new Zip();
136
+ zip.add(path, content);
137
+ const archive = await archiveOf(zip);
138
+ expect(archive.entries[0].path).toBe(path);
139
+ expect(decoder.decode(await contentOf(archive, path))).toBe(content);
140
+ });
141
+ // This is the constraint that makes a hand-rolled writer worth having: EPUB
142
+ // requires `mimetype` to be the first entry and to be stored, so that readers
143
+ // can identify the file by reading a fixed byte range at its head.
144
+ test('an EPUB-shaped archive puts a stored mimetype first', async () => {
145
+ const zip = new Zip();
146
+ zip.add('mimetype', 'application/epub+zip', true);
147
+ zip.add('META-INF/container.xml', '<container/>');
148
+ const blob = await zip.toBlob('application/epub+zip');
149
+ const bytes = new Uint8Array(await blob.arrayBuffer());
150
+ const archive = read(bytes);
151
+ expect(archive.entries[0].path).toBe('mimetype');
152
+ expect(archive.entries[0].method).toBe(0);
153
+ expect(archive.entries[0].offset).toBe(0);
154
+ // Signature, then the name at 30, then the stored content right after it.
155
+ expect(decoder.decode(bytes.subarray(0, 2))).toBe('PK');
156
+ expect(decoder.decode(bytes.subarray(30, 38))).toBe('mimetype');
157
+ expect(decoder.decode(bytes.subarray(38, 58))).toBe('application/epub+zip');
158
+ });
159
+ test('an empty archive is still a valid one', async () => {
160
+ const archive = await archiveOf(new Zip());
161
+ expect(archive.entries).toEqual([]);
162
+ });
163
+ test('output is reproducible for a fixed timestamp', async () => {
164
+ const build = async () => {
165
+ const zip = new Zip(new Date(2026, 0, 1, 12, 0, 0));
166
+ zip.add('mimetype', 'application/epub+zip', true);
167
+ zip.add('a.txt', 'content');
168
+ return new Uint8Array(await (await zip.toBlob()).arrayBuffer());
169
+ };
170
+ expect(await build()).toEqual(await build());
171
+ });
package/dist/index.d.ts CHANGED
@@ -8,6 +8,7 @@ export { default as Index } from "./components/page/Index.svelte";
8
8
  export { default as References } from "./components/page/References.svelte";
9
9
  export { default as Media } from "./components/page/Media.svelte";
10
10
  export { default as Print } from "./components/page/Print.svelte";
11
+ export { default as DownloadEPUB } from "./components/page/DownloadEPUB.svelte";
11
12
  export { default as Unknown } from "./components/page/Unknown.svelte";
12
13
  export { default as Glossary } from "./components/page/Glossary.svelte";
13
14
  export { default as Edition } from "./components/page/Edition.svelte";
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ export { default as Index } from './components/page/Index.svelte';
9
9
  export { default as References } from './components/page/References.svelte';
10
10
  export { default as Media } from './components/page/Media.svelte';
11
11
  export { default as Print } from './components/page/Print.svelte';
12
+ export { default as DownloadEPUB } from './components/page/DownloadEPUB.svelte';
12
13
  export { default as Unknown } from './components/page/Unknown.svelte';
13
14
  export { default as Glossary } from './components/page/Glossary.svelte';
14
15
  export { default as Edition } from './components/page/Edition.svelte';
@@ -4,6 +4,7 @@ import { connectFirestoreEmulator, getFirestore } from 'firebase/firestore';
4
4
  import { connectFunctionsEmulator, getFunctions } from 'firebase/functions';
5
5
  import { connectStorageEmulator, getStorage } from 'firebase/storage';
6
6
  import { PUBLIC_READER, PUBLIC_CONTEXT, PUBLIC_FIREBASE_API_KEY, PUBLIC_FIREBASE_AUTH_DOMAIN, PUBLIC_FIREBASE_PROJECT_ID, PUBLIC_FIREBASE_STORAGE_BUCKET, PUBLIC_FIREBASE_MESSAGING_SENDER_ID, PUBLIC_FIREBASE_APP_ID, } from '$env/static/public';
7
+ import { env } from '$env/dynamic/public';
7
8
  const isReader = PUBLIC_READER === 'true';
8
9
  // Some feedback about context
9
10
  if (!isReader && PUBLIC_CONTEXT !== 'prod')
@@ -24,13 +25,26 @@ export const auth = isReader ? undefined : getAuth(app);
24
25
  export const db = isReader ? undefined : getFirestore();
25
26
  export const storage = isReader ? undefined : getStorage(app);
26
27
  export const functions = isReader ? undefined : getFunctions(app);
28
+ /** Emulator ports default to the firebase.json values but can be overridden
29
+ * by env vars so test runs (see firebase.test.json) can use dedicated ports
30
+ * that don't collide with other running emulator suites. Dynamic env isn't
31
+ * available in every context (e.g. unit tests), hence the guard. */
32
+ function emulatorPort(name, fallback) {
33
+ try {
34
+ return (env?.[name] ??
35
+ fallback);
36
+ }
37
+ catch {
38
+ return fallback;
39
+ }
40
+ }
27
41
  if (!isReader && PUBLIC_CONTEXT === 'local') {
28
42
  if (db)
29
- connectFirestoreEmulator(db, 'localhost', 8080);
43
+ connectFirestoreEmulator(db, 'localhost', parseInt(emulatorPort('PUBLIC_EMULATOR_FIRESTORE', '8080')));
30
44
  if (auth)
31
- connectAuthEmulator(auth, 'http://localhost:9099');
45
+ connectAuthEmulator(auth, `http://localhost:${emulatorPort('PUBLIC_EMULATOR_AUTH', '9099')}`);
32
46
  if (storage)
33
- connectStorageEmulator(storage, 'localhost', 9199);
47
+ connectStorageEmulator(storage, 'localhost', parseInt(emulatorPort('PUBLIC_EMULATOR_STORAGE', '9199')));
34
48
  if (functions)
35
- connectFunctionsEmulator(functions, 'localhost', 5001);
49
+ connectFunctionsEmulator(functions, 'localhost', parseInt(emulatorPort('PUBLIC_EMULATOR_FUNCTIONS', '5001')));
36
50
  }
@@ -91,7 +91,8 @@ export default class Book {
91
91
  return edition ? edition.ref.id : undefined;
92
92
  }
93
93
  getLatestPublishedEditionID() {
94
- return this.editions.filter((ed) => ed.published !== null).at(-1)?.ref.id;
94
+ return this.editions.filter((ed) => ed.published !== null).at(-1)?.ref
95
+ .id;
95
96
  }
96
97
  getLatestEditionID() {
97
98
  return this.editions.toSorted((a, b) => b.number - a.number)[0]?.ref.id;
@@ -1,6 +1,5 @@
1
1
  import { deleteObject, getDownloadURL, listAll, ref, uploadBytes, } from 'firebase/storage';
2
2
  import { storage } from '../Firebase';
3
- import { v4 as uuidv4 } from 'uuid';
4
3
  export default class BookMedia {
5
4
  book;
6
5
  _images;
@@ -84,7 +83,7 @@ export default class BookMedia {
84
83
  // Get the extension if there is one.
85
84
  const [name, extension] = file.name.split('.');
86
85
  // We use the file name given, but append a unique string to avoid collisions.
87
- const imageName = `${name}-${uuidv4()}.${extension ?? ''}`;
86
+ const imageName = `${name}-${crypto.randomUUID()}.${extension ?? ''}`;
88
87
  // The canonical path format for Bookish images in the store is image/{bookid}/{imageid}
89
88
  // where {bookid} is the Firestore document ID of the book being edited and {imageid} is just a random id.
90
89
  const imageRef = ref(storage, `${this.getImagePath()}/${imageName}`);
@@ -161,8 +161,9 @@ export default class Chapter {
161
161
  }
162
162
  getWordCount(edition) {
163
163
  if (this._wordCount === undefined)
164
- this._wordCount = this.getAST(edition)?.toText().split(/\s+/)
165
- .length;
164
+ this._wordCount = this.getAST(edition)
165
+ ?.toText()
166
+ .split(/\s+/).length;
166
167
  return this._wordCount;
167
168
  }
168
169
  /** Either a number of minutes, or undefined if there's no text yet, or null if forthcoming. */
@@ -1,4 +1,4 @@
1
1
  export type ChapterIDKey = 'references' | 'search' | 'media' | 'index' | 'glossary' | 'unknown' | 'cover';
2
- export type TOCHeaderKey = 'chapters' | 'acknowledgements' | 'license' | 'print' | 'citation';
2
+ export type TOCHeaderKey = 'chapters' | 'acknowledgements' | 'license' | 'print' | 'epub' | 'citation';
3
3
  declare const ChapterIDs: Record<string, ChapterIDKey>;
4
4
  export default ChapterIDs;
@@ -7,7 +7,7 @@ import type Definition from './Definition.js';
7
7
  import { type ChapterIDKey, type TOCHeaderKey } from './ChapterID.js';
8
8
  import type { DocumentReference } from 'firebase/firestore';
9
9
  import type Book from './Book';
10
- import type FormatNode from '../chapter/FormatNode';
10
+ import FormatNode from '../chapter/FormatNode';
11
11
  import type { EditionInfo } from './Book';
12
12
  export type EditionSpecification = {
13
13
  bookRef: DocumentReference;
@@ -3,6 +3,7 @@ import Parser from '../chapter/Parser';
3
3
  import EmbedNode from '../chapter/EmbedNode';
4
4
  import Reference from './Reference';
5
5
  import ChapterIDs, {} from './ChapterID.js';
6
+ import FormatNode from '../chapter/FormatNode';
6
7
  export default class Edition {
7
8
  bookRef;
8
9
  editionRef;
@@ -48,8 +49,11 @@ export default class Edition {
48
49
  this.sources = Object.assign({}, sources);
49
50
  this.references = {};
50
51
  for (const [citationID, ref] of Object.entries(references)) {
52
+ // Pass through already-parsed references: strings parse to
53
+ // FormatNodes, and every copy-on-write constructor call re-runs
54
+ // this normalization, so FormatNodes must survive it too.
51
55
  this.references[citationID] =
52
- ref instanceof Reference
56
+ ref instanceof Reference || ref instanceof FormatNode
53
57
  ? ref
54
58
  : Edition.parseReference(citationID, ref, this);
55
59
  }
@@ -654,6 +658,7 @@ export default class Edition {
654
658
  acknowledgements: 'Acknowledgements',
655
659
  license: 'License',
656
660
  print: 'Print',
661
+ epub: 'E-book',
657
662
  citation: 'Citation',
658
663
  chapters: 'Chapters',
659
664
  cover: 'Cover',
@@ -474,7 +474,9 @@ export default class BlocksNode extends BlockNode {
474
474
  if (first instanceof ParagraphNode &&
475
475
  last instanceof ParagraphNode) {
476
476
  // Replace the first paragraph with the second merged.
477
- newBlocks = newBlocks.withNodeReplaced(first, first.withContent(first.getFormat().withSegmentsAppended(last.getFormat())));
477
+ newBlocks = newBlocks.withNodeReplaced(first, first.withContent(first
478
+ .getFormat()
479
+ .withSegmentsAppended(last.getFormat())));
478
480
  if (newBlocks === undefined)
479
481
  return;
480
482
  // Remove the last paragraph.
@@ -29,7 +29,11 @@ const beforeBold = new TextNode('I am a ');
29
29
  const bold = new TextNode('bold');
30
30
  const afterBold = new TextNode(' word.');
31
31
  const boldChapter = new ChapterNode([
32
- new ParagraphNode(0, new FormatNode('', [beforeBold, new FormatNode('*', [bold]), afterBold])),
32
+ new ParagraphNode(0, new FormatNode('', [
33
+ beforeBold,
34
+ new FormatNode('*', [bold]),
35
+ afterBold,
36
+ ])),
33
37
  ]);
34
38
  const itemOneTextNode = new TextNode('one');
35
39
  const itemOneFormat = new FormatNode('', [itemOneTextNode]);
@@ -13,11 +13,11 @@ const format = new FormatNode('', [textOne, textTwo, bold, textFive]);
13
13
 
14
14
  test('Replace descendants', () => {
15
15
  expect(
16
- format.withNodeReplaced(textThree, new TextNode('hi'))?.toBookdown()
16
+ format.withNodeReplaced(textThree, new TextNode('hi'))?.toBookdown(),
17
17
  ).toBe('onetwo*hifour*five');
18
18
 
19
19
  expect(
20
- format.withNodeReplaced(textFive, new TextNode('yo'))?.toBookdown()
20
+ format.withNodeReplaced(textFive, new TextNode('yo'))?.toBookdown(),
21
21
  ).toBe('onetwo*threefour*yo');
22
22
  });
23
23
 
@@ -49,12 +49,15 @@ export default class TextNode extends Node {
49
49
  .replace(/(:)([a-z])/g, '\\:$2');
50
50
  }
51
51
  toHTML() {
52
+ // Ampersands are escaped first, or the entities introduced below get
53
+ // escaped in turn ('<' would become '&amp;lt;'). The flags matter too:
54
+ // without them only the first occurrence of each character is escaped.
52
55
  return this.#text
53
- .replace('<', '&lt;')
54
- .replace('>', '&gt;')
55
- .replace('&', '&amp;')
56
- .replace('"', '&quot;')
57
- .replace("'", '&#39;');
56
+ .replace(/&/g, '&amp;')
57
+ .replace(/</g, '&lt;')
58
+ .replace(/>/g, '&gt;')
59
+ .replace(/"/g, '&quot;')
60
+ .replace(/'/g, '&#39;');
58
61
  }
59
62
  getChildren() {
60
63
  return [];
@@ -4,3 +4,11 @@ test('Insert character', () => {
4
4
  const text = new TextNode('Hello');
5
5
  expect(text.withCharacterAt('!', 5)?.toBookdown()).toBe('Hello!');
6
6
  });
7
+ test('escapes every occurrence of a markup character, not just the first', () => {
8
+ expect(new TextNode('a < b < c').toHTML()).toBe('a &lt; b &lt; c');
9
+ });
10
+ test('escapes ampersands without escaping the entities it introduces', () => {
11
+ expect(new TextNode('<').toHTML()).toBe('&lt;');
12
+ expect(new TextNode('a & b').toHTML()).toBe('a &amp; b');
13
+ expect(new TextNode('&lt;').toHTML()).toBe('&amp;lt;');
14
+ });
package/package.json CHANGED
@@ -1,61 +1,75 @@
1
1
  {
2
2
  "name": "bookish-press",
3
3
  "private": false,
4
- "version": "0.7.4",
4
+ "version": "0.9.2",
5
5
  "type": "module",
6
6
  "description": "A platform for reading and writing readable, accessible, beautiful online books.",
7
7
  "license": "MIT",
8
- "repository": "https://github.com/amyjko/bookish.git",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/amyjko/bookish.git"
11
+ },
9
12
  "scripts": {
10
13
  "prepare": "svelte-kit sync",
11
14
  "//dev": "echo 'Run the Svelte/Vite local server to test locally. Use in combination with run emu to run locally.'",
12
15
  "dev": "vite dev",
13
16
  "preview": "npm run build && vite preview",
14
17
  "build": "npm run env && vite build",
18
+ "//demo": "echo 'Seeds a demo book that uses every feature into dedicated emulators and serves it at /demo. Read-only; no sign-in needed.'",
19
+ "demo": "bash scripts/demo.sh",
15
20
  "//emu": "echo 'This copies the local .env file and starts the emulator'",
16
21
  "emu": "firebase use dev && npm run env && firebase emulators:start",
17
22
  "package": "svelte-package --watch",
23
+ "package:build": "svelte-package",
18
24
  "test": "vitest",
25
+ "test:ci": "vitest run",
26
+ "//test:e2e": "echo 'Build the app and run the Playwright smoke tests against a preview server.'",
27
+ "test:e2e": "npm run build && playwright test",
28
+ "//test:e2e:emu": "echo 'Build the app and run the Playwright suite that signs in and edits against dedicated Firebase emulators (see firebase.test.json).'",
29
+ "test:e2e:emu": "PUBLIC_FIREBASE_PROJECT_ID=demo-bookish PUBLIC_FIREBASE_STORAGE_BUCKET=demo-bookish.appspot.com npm run build && bash scripts/test-emulators.sh 'npx playwright test --config playwright.emulator.config.ts'",
19
30
  "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
31
+ "check:ci": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
20
32
  "//env": "echo 'This copies the .env file corresponding to the current Firebase project specified with firebase use, defaulting to local'",
21
33
  "env": "cp .env.${GCLOUD_PROJECT:-example} .env",
22
- "//stage": "echo 'switch to the dev project, deploy, relying on Firebase's predeploy hook to run the build script above before release'",
23
- "stage": "firebase use dev && cp .env.bookish-dev-21ac3 .env && firebase deploy",
24
- "//release": "echo 'switch to the prod project, deploy, relying on Firebase's predeploy hook to run the build script above before release'",
25
- "release": "firebase use prod && cp .env.bookish-prod .env && firebase deploy",
34
+ "//deploy:ssr": "echo 'Build the app with the current .env, then deploy the SSR server in build/ to Cloud Run for the given project. Used by stage and release.'",
35
+ "deploy:ssr": "vite build && gcloud run deploy bookish-ssr --source . --region us-central1 --allow-unauthenticated --quiet --project",
36
+ "//stage": "echo 'Switch to the dev project, build with the dev .env, deploy the SSR server to Cloud Run, then deploy hosting, rules, and functions.'",
37
+ "stage": "firebase use dev && cp .env.bookish-dev-21ac3 .env && npm run deploy:ssr -- bookish-dev-21ac3 && firebase deploy",
38
+ "//release": "echo 'Switch to the prod project, build with the prod .env, deploy the SSR server to Cloud Run, then deploy hosting, rules, and functions.'",
39
+ "release": "firebase use prod && cp .env.bookish-prod .env && npm run deploy:ssr -- bookish-prod && firebase deploy",
26
40
  "//merge": "merge dev into main, push to remote",
27
41
  "merge": "git checkout main && git merge dev && git push origin main && git checkout dev"
28
42
  },
29
43
  "devDependencies": {
44
+ "@playwright/test": "^1",
30
45
  "@sveltejs/adapter-node": "^5",
31
- "@sveltejs/kit": "^2",
32
46
  "@sveltejs/package": "^2",
33
- "@sveltejs/vite-plugin-svelte": "^3",
47
+ "@sveltejs/vite-plugin-svelte": "^6",
48
+ "@testing-library/svelte": "^5",
34
49
  "@types/prismjs": "^1.26",
35
50
  "@types/smoothscroll-polyfill": "^0",
36
- "chalk": "^5.3.0",
37
- "globals": "^16",
51
+ "firebase-tools": "^15.28.2",
52
+ "jsdom": "^29",
38
53
  "prettier": "^3",
39
- "prettier-plugin-svelte": "^3",
40
- "svelte-check": "^3",
41
- "svelte-preprocess": "^5",
54
+ "prettier-plugin-svelte": "^4",
55
+ "svelte": "^5.57",
56
+ "svelte-check": "^4.7",
42
57
  "tslib": "^2",
43
58
  "typescript": "^5",
44
- "uuid": "^11.1.0",
45
- "vite": "^5",
46
- "vitest": "^3"
59
+ "vite": "^7",
60
+ "vitest": "^4"
47
61
  },
48
62
  "dependencies": {
49
63
  "@sveltejs/kit": "^2",
50
64
  "ajv": "^8",
51
65
  "ajv-formats": "^3",
52
- "firebase": "^11",
53
- "firebase-admin": "^12.7.0",
54
- "firebase-functions": "^6",
66
+ "firebase": "^12",
67
+ "firebase-admin": "^14",
55
68
  "prismjs": "^1.29",
56
- "smoothscroll-polyfill": "^0.4",
57
- "svelte": "^4",
58
- "uuid": "^11"
69
+ "smoothscroll-polyfill": "^0.4"
70
+ },
71
+ "peerDependencies": {
72
+ "svelte": "^5.0.0"
59
73
  },
60
74
  "engines": {
61
75
  "node": "22"