bookish-press 0.9.2 → 0.9.4

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.
package/README.md CHANGED
@@ -50,6 +50,22 @@ The content lives in `scripts/demo-book.js` and the images are drawn by `scripts
50
50
 
51
51
  It uses the offset emulator ports from `firebase.test.json`, so it doesn't collide with another project's emulators on the default ports, and it builds the app rather than running `vite dev` — the reader route's server render pulls the chapter model through a module cycle that Vite's dev SSR evaluator can't order, though the production build resolves it fine.
52
52
 
53
+ ## Storage CORS
54
+
55
+ Book images live in Firebase Storage and are fetched by the browser when
56
+ building an EPUB, which requires the bucket to send CORS headers. It does not
57
+ by default, and without them every image is silently dropped from the export.
58
+ `cors.json` holds the policy; apply it to a project's bucket with:
59
+
60
+ ```
61
+ gcloud storage buckets update gs://<project>.appspot.com --cors-file=cors.json --project <project>
62
+ ```
63
+
64
+ It is applied to both `bookish-prod` and `bookish-dev-21ac3`. The objects are
65
+ already world-readable — the thumbnail function calls `makePublic()` on upload —
66
+ so allowing `GET` from any origin grants nothing that a plain HTTP request
67
+ couldn't already do.
68
+
53
69
  ## Deployment notes
54
70
 
55
71
  There are two major components to Bookish: the authoring platform and the reading front end. Changes to the authoring platform that do not affect reading can be verified and deployed to Firebase without any other coordination. However, changes to the reading experience have downstream dependencies that need to be managed. Here's the general deployment workflow, which I'm currently using as reminders for myself:
@@ -409,3 +409,45 @@ test('a reference whose fields are not strings still exports', async () => {
409
409
  expect(references).toContain('-370');
410
410
  expect(references).toContain('The Republic');
411
411
  });
412
+ test("a chapter's header image is rendered, not just packaged", async () => {
413
+ // Edition.getEmbeds() collects chapter header images, so they were fetched
414
+ // and packaged, but chapterDocument never rendered one -- the reader saw
415
+ // the book cover and nothing else, and paid for the bytes anyway.
416
+ const edition = makeEdition({
417
+ chapters: [
418
+ chapter('one', 'One', 'Text with no images of its own.', {
419
+ image: '|header.jpg|A header image|A caption|A credit|',
420
+ }),
421
+ ],
422
+ });
423
+ const { entries } = await build(edition);
424
+ const one = textOf(entries, 'OEBPS/one.xhtml');
425
+ expect(one).toContain('<img');
426
+ expect(one).toContain('A header image');
427
+ // Above the title, as the web reader places it.
428
+ expect(one.indexOf('<img')).toBeLessThan(one.indexOf('<h1>'));
429
+ });
430
+ test('every packaged image is referenced by a document', async () => {
431
+ // The general form of the bug above: an image that is fetched, shrunk and
432
+ // written into the package but never pointed at is pure weight.
433
+ const edition = makeEdition({
434
+ images: { cover: '|cover.jpg|The cover|||' },
435
+ chapters: [
436
+ chapter('one', 'One', 'A body image: |body.jpg|Alt|||', {
437
+ image: '|chapterhead.jpg|A chapter header|||',
438
+ }),
439
+ chapter('two', 'Two', 'No images here.', {
440
+ image: '|another.jpg|Another header|||',
441
+ }),
442
+ ],
443
+ });
444
+ const { entries } = await build(edition);
445
+ const documents = [...entries.keys()].filter((path) => path.endsWith('.xhtml'));
446
+ const markup = documents.map((path) => textOf(entries, path)).join('\n');
447
+ const images = [...entries.keys()]
448
+ .filter((path) => path.startsWith('OEBPS/images/'))
449
+ .map((path) => path.replace('OEBPS/', ''));
450
+ expect(images.length).toBeGreaterThan(2);
451
+ const orphans = images.filter((image) => !markup.includes(`"${image}"`));
452
+ expect(orphans, `packaged but never referenced: ${orphans.join(', ')}`).toEqual([]);
453
+ });
@@ -7,7 +7,8 @@
7
7
  import Reference from '../models/book/Reference';
8
8
  import FormatNode from '../models/chapter/FormatNode';
9
9
  import Parser from '../models/chapter/Parser';
10
- import { chapterPath, document_, endnoteSection, escapeAttribute, escapeText, serializeChapter, serializeFormat, } from './xhtml';
10
+ import EmbedNode from '../models/chapter/EmbedNode';
11
+ import { chapterPath, document_, endnoteSection, escapeAttribute, escapeText, serializeChapter, serializeEmbed, serializeFormat, } from './xhtml';
11
12
  export const MIMETYPE = 'application/epub+zip';
12
13
  /** Where the package's documents live inside the archive. */
13
14
  export const CONTENT_DIRECTORY = 'OEBPS';
@@ -149,6 +150,18 @@ export function safeFilename(title) {
149
150
  .toLowerCase();
150
151
  return cleaned.length > 0 ? cleaned.slice(0, 80) : 'book';
151
152
  }
153
+ /**
154
+ * A chapter or back-matter page's header image, rendered the way the web
155
+ * reader does: above the title, with its caption and credit. The image is
156
+ * stored as Bookdown and already packaged by Edition.getEmbeds(), so this only
157
+ * has to render it.
158
+ */
159
+ function headerImage(edition, image, context) {
160
+ if (image === null || image.trim().length === 0)
161
+ return '';
162
+ const node = Parser.parseEmbed(edition, image);
163
+ return node instanceof EmbedNode ? serializeEmbed(node, context) : '';
164
+ }
152
165
  /** The plain-text form of a Bookdown string, for metadata. */
153
166
  function text(edition, bookdown) {
154
167
  return Parser.parseFormat(edition, bookdown).toText().trim();
@@ -203,6 +216,7 @@ export function chapterDocument(edition, chapter, context) {
203
216
  const section = chapter.getSection();
204
217
  const authors = chapter.getAuthors();
205
218
  const header = `<header>
219
+ ${headerImage(edition, chapter.getImage(), context)}
206
220
  ${number !== undefined
207
221
  ? `<span class="chapter-number">Chapter ${number}</span>`
208
222
  : ''}
@@ -242,6 +256,7 @@ export function referencesDocument(edition, context) {
242
256
  return undefined;
243
257
  const header = edition.getHeader('references');
244
258
  const body = `<section epub:type="bibliography">
259
+ ${headerImage(edition, edition.getImage('references'), context)}
245
260
  <h1>${escapeText(header)}</h1>
246
261
  ${ids
247
262
  .map((id) => `<p class="reference" id="ref-${escapeAttribute(id)}">${referenceContent(references[id], context)}</p>`)
@@ -267,6 +282,7 @@ export function glossaryDocument(edition, context) {
267
282
  // content, so there is no way to put a block inside it that would survive.
268
283
  // The semantics are carried by the classes instead.
269
284
  const body = `<section epub:type="glossary">
285
+ ${headerImage(edition, edition.getImage('glossary'), context)}
270
286
  <h1>${escapeText(header)}</h1>
271
287
  ${ids
272
288
  .map((id) => {
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import type Edition from '../models/book/Edition';
15
15
  import type ChapterNode from '../models/chapter/ChapterNode';
16
+ import EmbedNode from '../models/chapter/EmbedNode';
16
17
  import FormatNode from '../models/chapter/FormatNode';
17
18
  /**
18
19
  * Escape text content. Order matters: ampersands first, or we escape our own
@@ -56,6 +57,11 @@ export declare function serializeChapter(chapter: ChapterNode, context: Serializ
56
57
  };
57
58
  /** The document filename for a chapter. */
58
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;
59
65
  /** Serialize a standalone format node, for titles, captions and definitions. */
60
66
  export declare function serializeFormat(node: FormatNode, context: SerializationContext): string;
61
67
  /** Render gathered footnotes as an endnote section with links back to the text. */
@@ -326,6 +326,18 @@ function definition(node, local) {
326
326
  ? phrase
327
327
  : `<a class="definition" href="glossary.xhtml#gloss-${escapeAttribute(id)}">${phrase}</a>`;
328
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
+ }
329
341
  /** Serialize a standalone format node, for titles, captions and definitions. */
330
342
  export function serializeFormat(node, context) {
331
343
  return inline(node, {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "bookish-press",
3
3
  "private": false,
4
- "version": "0.9.2",
4
+ "version": "0.9.4",
5
5
  "type": "module",
6
6
  "description": "A platform for reading and writing readable, accessible, beautiful online books.",
7
7
  "license": "MIT",