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.
package/README.md CHANGED
@@ -12,6 +12,44 @@ The app is a SvelteKit app. Server-side rendering runs on a [Cloud Run](https://
12
12
 
13
13
  One-time setup on a new machine: install the [gcloud CLI](https://cloud.google.com/sdk/docs/install) and run `gcloud auth login`. (This replaced the experimental Firebase Hosting "web frameworks" integration, which required `firebase experiments:enable webframeworks` and capped the Vite version; the old auto-generated `ssrbookishprod` Cloud Function can be deleted once the Cloud Run deploy is verified.)
14
14
 
15
+ ## The demo book
16
+
17
+ `npm run demo` seeds a book that uses every feature Bookish has into a set of
18
+ dedicated emulators, then serves it at <http://localhost:5173/demo>. Nothing is
19
+ uploaded and no sign-in is needed to read it; sign in as `demo@example.com` at
20
+ `/login` to edit it at `/write/demo/1`.
21
+
22
+ It exists so a change to the reading experience can be checked against
23
+ something real without hunting for a book that happens to exercise it. The
24
+ chapters cover every Bookdown construct — formatting, links of each kind,
25
+ footnotes, citations, definitions, lists, tables, quotes, callouts, code,
26
+ symbols, labels, comments — and the images are chosen to stress a small screen:
27
+ a photograph with fine texture, a diagram of hairlines, a stepped grey wedge,
28
+ extreme aspect ratios, transparency, and an SVG. That makes it the fastest way
29
+ to see what an EPUB export actually looks like on an e-reader.
30
+
31
+ The content lives in `scripts/demo-book.js` and the images are drawn by
32
+ `scripts/demo-images.js` rather than committed. Chapters must not use the ids
33
+ the reader reserves for its built-in pages (`references`, `search`, `media`,
34
+ `index`, `glossary`, `unknown`, `cover`); the seed script refuses to run if one
35
+ does.
36
+
37
+ It uses the offset emulator ports from `firebase.test.json`, so it does not
38
+ collide with another project's emulators on the default ports, and it builds
39
+ the app rather than running `vite dev` — the reader route's server render pulls
40
+ the chapter model through a module cycle that Vite's dev SSR evaluator cannot
41
+ order, though the production build resolves it fine.
42
+
43
+ ## The demo book
44
+
45
+ `npm run demo` seeds a book that uses every feature Bookish has into a set of dedicated emulators, then serves it at <http://localhost:5173/demo>. Nothing is uploaded and no sign-in is needed to read it; sign in as `demo@example.com` at `/login` to edit it at `/write/demo/1`.
46
+
47
+ It exists so a change to the reading experience can be checked against something real without hunting for a book that happens to exercise it. The chapters cover every Bookdown construct — formatting, links of each kind, footnotes, citations, definitions, lists, tables, quotes, callouts, code, symbols, labels, comments — and the images are chosen to stress a small screen: a photograph with fine texture, a diagram of hairlines, a stepped grey wedge, extreme aspect ratios, transparency, and an SVG. That makes it the fastest way to see what an EPUB export actually looks like on an e-reader.
48
+
49
+ The content lives in `scripts/demo-book.js` and the images are drawn by `scripts/demo-images.js` rather than committed. Chapters must not use the ids the reader reserves for its built-in pages (`references`, `search`, `media`, `index`, `glossary`, `unknown`, `cover`); the seed script refuses to run if one does.
50
+
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
+
15
53
  ## Deployment notes
16
54
 
17
55
  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:
@@ -19,14 +57,15 @@ There are two major components to Bookish: the authoring platform and the readin
19
57
  0. Ensure you're working in the `dev` branch
20
58
  1. Fix bugs, add features
21
59
  2. Ensure there are no type errors (`npm run check`) or failed tests (`npm run test`).
22
- 2. Test with emulator (`npm run emu`), if possible. (Some features aren't possible to perfectly emulate, such as authentication, hosting configurations, permissions.)
23
- 3. Test on the [staging server](https://bookish-dev-21ac3.web.app/`) (`npm run stage`).
24
- 4. Bump the version in `package.json`
25
- 5. Describe the changes in `CHANGELOG.md`, linking to any issues closed
26
- 6. Commit changes to `dev`, referring to any issues addressed
27
- 7. Release to production (`npm run release`)
28
- 8. Verify the change in production on [bookish.press](https://bookish.press)
29
- 9. Merge dev to main (`npm run merge`)
60
+ 3. Test with emulator (`npm run emu`), if possible. (Some features aren't possible to perfectly emulate, such as authentication, hosting configurations, permissions.)
61
+ 4. Test on the [staging server](https://bookish-dev-21ac3.web.app/`) (`npm run stage`).
62
+ 5. Bump the version in `package.json`
63
+ 6. Describe the changes in `CHANGELOG.md`, linking to any issues closed
64
+ 7. Commit changes to `dev`, referring to any issues addressed
65
+ 8. Release to production (`npm run release`)
66
+ 9. Verify the change in production on [bookish.press](https://bookish.press)
67
+ 10. Merge dev to main (`npm run merge`)
68
+
30
69
  > **Svelte 5 note**: as of 0.8.0, `bookish-press` requires Svelte 5 (declared as a peer dependency), which is a breaking change for consumers. Until `bookish-reader` has been migrated to Svelte 5, publish with `npm publish --tag next` so `latest` keeps pointing at the last Svelte 4 compatible release; flip the tag once the reader has migrated. The `0.7.x` branch exists for emergency maintenance releases of the Svelte 4 line.
31
70
 
32
71
  10. If any of the changes affected the reading experience (essentially if any `.svelte` file or its dependencies in `/src/lib/components` changed), then we need to update the reader package for authors who pre-build their books manually:
@@ -41,4 +80,4 @@ There are two major components to Bookish: the authoring platform and the readin
41
80
  1. Cloning the reader repo: `git clone https://github.com/amyjko/bookish-reader`
42
81
  2. Entering the repo directory: `cd bookish-reader`
43
82
  3. Binding the book: `bind.sh`
44
- 4. Deploying the bound book in the newly created `build` folder to wherever it's hosted.
83
+ 4. Deploying the bound book in the newly created `build` folder to wherever it's hosted.
@@ -136,7 +136,7 @@ function handleChange() {
136
136
  .token.char,
137
137
  .token.builtin,
138
138
  .token.inserted {
139
- color: var(--bookish-pragraph-color);
139
+ color: var(--bookish-paragraph-color);
140
140
  }
141
141
 
142
142
  .token.operator,
@@ -123,7 +123,7 @@ function handleImageSelection(image) {
123
123
  }
124
124
 
125
125
  .file-upload {
126
- font-family: var(--bookish-app-font);
126
+ font-family: var(--app-font);
127
127
  font-size: var(--app-chrome-font-size);
128
128
  }
129
129
 
@@ -0,0 +1,234 @@
1
+ <script lang="ts">import { onDestroy } from "svelte";
2
+ import { getBase, getEdition } from "./Contexts";
3
+ import { buildEPUB } from "../../epub/build";
4
+ import { DEFAULT_SIZE, SIZES } from "../../epub/images";
5
+ let edition = getEdition();
6
+ let base = getBase();
7
+ let sizeID = $state(DEFAULT_SIZE.id);
8
+ let building = $state(false);
9
+ let progress = $state(void 0);
10
+ let error = $state(void 0);
11
+ let warnings = $state([]);
12
+ let url = $state(void 0);
13
+ let filename = $state("book.epub");
14
+ function release() {
15
+ if (url !== void 0) {
16
+ URL.revokeObjectURL(url);
17
+ url = void 0;
18
+ }
19
+ }
20
+ function chooseSize(id) {
21
+ sizeID = id;
22
+ release();
23
+ warnings = [];
24
+ error = void 0;
25
+ }
26
+ async function build() {
27
+ if ($edition === void 0 || building) return;
28
+ building = true;
29
+ release();
30
+ error = void 0;
31
+ warnings = [];
32
+ progress = { phase: "images", done: 0, total: 0 };
33
+ try {
34
+ const result = await buildEPUB($edition, {
35
+ base: $base ?? "",
36
+ size: SIZES[sizeID] ?? DEFAULT_SIZE,
37
+ onProgress: (update) => progress = update
38
+ });
39
+ url = URL.createObjectURL(result.blob);
40
+ filename = result.filename;
41
+ warnings = result.warnings;
42
+ } catch (problem) {
43
+ console.error(problem);
44
+ error = "Something went wrong building the e-book.";
45
+ } finally {
46
+ building = false;
47
+ progress = void 0;
48
+ }
49
+ }
50
+ let status = $derived.by(() => {
51
+ if (progress === void 0) return void 0;
52
+ if (progress.phase === "images")
53
+ return progress.total === 0 ? "Gathering images\u2026" : `Shrinking image ${progress.done} of ${progress.total}\u2026`;
54
+ if (progress.phase === "chapters")
55
+ return `Preparing chapter ${progress.done} of ${progress.total}\u2026`;
56
+ return "Packaging the book\u2026";
57
+ });
58
+ onDestroy(release);
59
+ </script>
60
+
61
+ {#if $edition}
62
+ <div class="epub">
63
+ <label>
64
+ Image size
65
+ <!-- The wrapper carries the disclosure arrow: turning off native
66
+ appearance to get the book's font also removes the browser's. -->
67
+ <span class="select">
68
+ <select
69
+ value={sizeID}
70
+ disabled={building}
71
+ onchange={(event) => chooseSize(event.currentTarget.value)}
72
+ >
73
+ {#each Object.values(SIZES) as size}
74
+ <option value={size.id}>{size.label}</option>
75
+ {/each}
76
+ </select>
77
+ </span>
78
+ </label>
79
+ <!-- A plain button rather than the app's, whose --app-* variables are
80
+ only defined in the (app) layout, so it renders unstyled here and
81
+ in books compiled by bookish-reader. -->
82
+ <button
83
+ type="button"
84
+ title="Build an EPUB of this book to read on an e-reader"
85
+ aria-label="Build an EPUB of this book to read on an e-reader"
86
+ disabled={building}
87
+ onclick={build}>↓ EPUB</button
88
+ >
89
+ </div>
90
+
91
+ {#if error}
92
+ <p class="message error" role="alert">{error}</p>
93
+ {:else if status}
94
+ <p class="message" role="status" aria-live="polite">{status}</p>
95
+ {:else if url}
96
+ <!-- Rendered only once the blob exists, so this link is never present
97
+ in prerendered HTML for SvelteKit's crawler to follow. -->
98
+ <p class="ready">
99
+ <a href={url} download={filename}>Download {filename}</a>
100
+ </p>
101
+ {#if warnings.length > 0}
102
+ <p class="warnings">
103
+ {warnings.length}
104
+ {warnings.length === 1 ? 'image' : 'images'} couldn't be included,
105
+ usually because they're hosted somewhere that doesn't allow downloads.
106
+ Their captions are still in the book.
107
+ </p>
108
+ {/if}
109
+ {/if}
110
+ {/if}
111
+
112
+ <style>
113
+ /* Everything here is drawn with the book's own --bookish-* variables, which
114
+ are defined on the .bookish ancestor in Edition.svelte and so are in
115
+ scope in both the app and in books compiled by bookish-reader. The
116
+ --app-* chrome variables are not: they exist only in the (app) layout. */
117
+
118
+ .epub {
119
+ display: flex;
120
+ flex-direction: row;
121
+ flex-wrap: wrap;
122
+ align-items: center;
123
+ gap: 0.5em;
124
+ margin-block: var(--bookish-paragraph-spacing);
125
+ }
126
+
127
+ label {
128
+ display: inline-flex;
129
+ align-items: center;
130
+ gap: 0.5em;
131
+ font-family: var(--bookish-paragraph-font-family);
132
+ font-size: var(--bookish-small-font-size);
133
+ color: var(--bookish-muted-color);
134
+ }
135
+
136
+ .select {
137
+ position: relative;
138
+ display: inline-block;
139
+ }
140
+
141
+ /* The disclosure arrow, in place of the native one. It inherits the text
142
+ color, so it follows the theme into dark mode. */
143
+ .select::after {
144
+ content: '▾';
145
+ position: absolute;
146
+ right: 0.6em;
147
+ top: 50%;
148
+ transform: translateY(-50%);
149
+ pointer-events: none;
150
+ font-size: 0.9em;
151
+ color: var(--bookish-paragraph-color);
152
+ }
153
+
154
+ /* A <select> is a native menulist, and WebKit renders its face in the
155
+ system font whatever the cascade says until appearance is turned off. */
156
+ select {
157
+ appearance: none;
158
+ -webkit-appearance: none;
159
+ font-family: var(--bookish-paragraph-font-family);
160
+ font-size: var(--bookish-small-font-size);
161
+ font-weight: var(--bookish-paragraph-font-weight);
162
+ color: var(--bookish-paragraph-color);
163
+ background: var(--bookish-block-background-color);
164
+ border: 1px solid var(--bookish-border-color-light);
165
+ border-radius: var(--bookish-roundedness);
166
+ padding: var(--bookish-inline-padding);
167
+ padding-right: 2em;
168
+ max-width: 18em;
169
+ }
170
+
171
+ button {
172
+ font-family: var(--bookish-paragraph-font-family);
173
+ font-size: var(--bookish-small-font-size);
174
+ font-weight: var(--bookish-bold-font-weight);
175
+ color: var(--bookish-link-color);
176
+ background: var(--bookish-block-background-color);
177
+ border: 1px solid var(--bookish-border-color-light);
178
+ border-radius: var(--bookish-roundedness);
179
+ padding: var(--bookish-inline-padding)
180
+ calc(var(--bookish-inline-padding) * 3);
181
+ cursor: pointer;
182
+ }
183
+
184
+ button:hover:not(:disabled) {
185
+ border-color: var(--bookish-highlight-color);
186
+ }
187
+
188
+ /* focus-visible, so a mouse click doesn't leave the ring behind while
189
+ keyboard users still get one. */
190
+ select:focus-visible,
191
+ button:focus-visible {
192
+ outline: none;
193
+ border-color: var(--bookish-highlight-color);
194
+ box-shadow: 0 0 0 2px var(--bookish-highlight-color);
195
+ }
196
+
197
+ select:disabled,
198
+ button:disabled {
199
+ opacity: 0.5;
200
+ cursor: auto;
201
+ }
202
+
203
+ .message,
204
+ .ready,
205
+ .warnings {
206
+ font-family: var(--bookish-paragraph-font-family);
207
+ line-height: var(--bookish-paragraph-line-height);
208
+ }
209
+
210
+ .message {
211
+ color: var(--bookish-muted-color);
212
+ }
213
+
214
+ .error {
215
+ color: var(--bookish-error-color);
216
+ }
217
+
218
+ /* Matches a book link, from components/Link.svelte. */
219
+ .ready a {
220
+ color: var(--bookish-link-color);
221
+ font-weight: var(--bookish-link-font-weight);
222
+ text-decoration: none;
223
+ }
224
+
225
+ .ready a:hover {
226
+ text-decoration: underline;
227
+ }
228
+
229
+ .warnings {
230
+ font-size: var(--bookish-small-font-size);
231
+ font-style: italic;
232
+ color: var(--bookish-muted-color);
233
+ }
234
+ </style>
@@ -0,0 +1,3 @@
1
+ declare const DownloadEPUB: import("svelte").Component<Record<string, never>, {}, "">;
2
+ type DownloadEPUB = ReturnType<typeof DownloadEPUB>;
3
+ export default DownloadEPUB;
@@ -28,7 +28,7 @@
28
28
  table :global(tr:first-child th) {
29
29
  font-weight: bold;
30
30
  border: 0;
31
- border-bottom: 1px solid var(--bookish-border-color);
31
+ border-bottom: 1px solid var(--bookish-border-color-light);
32
32
  }
33
33
 
34
34
  table :global(tr:nth-child(even)) {
@@ -139,7 +139,7 @@ let results = $derived.by(() => {
139
139
  <style>
140
140
  .query {
141
141
  font-family: var(--bookish-header-font-family);
142
- font-size: var(--bookish-header-font-size);
142
+ font-size: var(--bookish-header-1-font-size);
143
143
  border: none;
144
144
  border-bottom: 3px solid var(--bookish-border-color-light);
145
145
  padding: var(--bookish-inline-padding);
@@ -24,6 +24,7 @@ import {
24
24
  } from "./Contexts";
25
25
  import Muted from "./Muted.svelte";
26
26
  import Button from "../app/Button.svelte";
27
+ import DownloadEPUB from "./DownloadEPUB.svelte";
27
28
  import PageHeader from "./PageHeader.svelte";
28
29
  import Rows from "./Rows.svelte";
29
30
  import PageParagraph from "./PageParagraph.svelte";
@@ -84,21 +85,18 @@ onMount(() => {
84
85
  $edition ? edition.set($edition.withTitle(text)) : undefined}
85
86
  >
86
87
  {#snippet before()}
87
-
88
- {#if editable}
89
- <SubdomainEditor />
90
- {/if}
91
-
92
- {/snippet}
88
+ {#if editable}
89
+ <SubdomainEditor />
90
+ {/if}
91
+ {/snippet}
93
92
  {#snippet outline()}
94
- <Outline
95
-
93
+ <Outline
96
94
  previous={null}
97
95
  next={$edition.getNextChapterID('', editable)}
98
96
  />
99
- {/snippet}
97
+ {/snippet}
100
98
  {#snippet after()}
101
- <div >
99
+ <div>
102
100
  <Authors
103
101
  editable={isBookEditable()}
104
102
  authors={$edition.getAuthors()}
@@ -108,7 +106,9 @@ onMount(() => {
108
106
  : undefined}
109
107
  edit={(index, text) =>
110
108
  $edition
111
- ? edition.set($edition.withAuthorName(index, text))
109
+ ? edition.set(
110
+ $edition.withAuthorName(index, text),
111
+ )
112
112
  : undefined}
113
113
  remove={(index) =>
114
114
  $edition
@@ -118,7 +118,7 @@ onMount(() => {
118
118
  />
119
119
  <Note>{$edition.getEditionLabel()} edition</Note>
120
120
  </div>
121
- {/snippet}
121
+ {/snippet}
122
122
  </Header>
123
123
 
124
124
  <Instructions {editable}>
@@ -130,8 +130,8 @@ onMount(() => {
130
130
 
131
131
  <Instructions {editable}>
132
132
  This will appear on the <Link to="/read">book browsing</Link>
133
- page and in your table of contents. Write an informative description
134
- of what your book is about.
133
+ page and in your table of contents. Write an informative description of
134
+ what your book is about.
135
135
  </Instructions>
136
136
 
137
137
  <Instructions {editable}>
@@ -183,7 +183,7 @@ onMount(() => {
183
183
  forthcoming={chapter.isForthcoming()}
184
184
  >
185
185
  {#snippet annotation()}
186
- <span >
186
+ <span>
187
187
  {#if editable}
188
188
  <TextEditor
189
189
  label={'Chapter section editor'}
@@ -202,9 +202,9 @@ onMount(() => {
202
202
  You can edit this chapter.
203
203
  {/if}
204
204
  </span>
205
- {/snippet}
205
+ {/snippet}
206
206
  {#snippet etc()}
207
- <span class="etc" >
207
+ <span class="etc">
208
208
  <Muted>
209
209
  {#if editable}
210
210
  <Toggle
@@ -223,44 +223,44 @@ onMount(() => {
223
223
  {/if}
224
224
  </Muted>
225
225
  </span>
226
- {/snippet}
226
+ {/snippet}
227
227
  </TableOfContentsRow>
228
228
  {/each}
229
229
  {#if $edition.hasReferences() || editable}
230
230
  <TableOfContentsRow chapter={ChapterIDs.ReferencesID}>
231
231
  {#snippet annotation()}
232
- <span >Everything cited</span>
233
- {/snippet}
232
+ <span>Everything cited</span>
233
+ {/snippet}
234
234
  </TableOfContentsRow>
235
235
  {/if}
236
236
  {#if ($edition.getGlossary() && Object.keys($edition.getGlossary()).length > 0) || editable}
237
237
  <TableOfContentsRow chapter={ChapterIDs.GlossaryID}>
238
238
  {#snippet annotation()}
239
- <span >Definitions</span>
240
- {/snippet}
239
+ <span>Definitions</span>
240
+ {/snippet}
241
241
  </TableOfContentsRow>
242
242
  {/if}
243
243
  <TableOfContentsRow chapter={ChapterIDs.IndexID}>
244
244
  {#snippet annotation()}
245
- <span >Common words and where they are</span>
246
- {/snippet}
245
+ <span>Common words and where they are</span>
246
+ {/snippet}
247
247
  </TableOfContentsRow>
248
248
  <TableOfContentsRow chapter={ChapterIDs.SearchID}>
249
249
  {#snippet annotation()}
250
- <span >Find where words occur</span>
251
- {/snippet}
250
+ <span>Find where words occur</span>
251
+ {/snippet}
252
252
  </TableOfContentsRow>
253
253
 
254
254
  <TableOfContentsRow chapter={ChapterIDs.MediaID}>
255
255
  {#snippet annotation()}
256
- <span >Images and video in the book</span>
257
- {/snippet}
256
+ <span>Images and video in the book</span>
257
+ {/snippet}
258
258
  </TableOfContentsRow>
259
259
  {#if editable}
260
260
  <TableOfContentsRow chapter={ChapterIDs.UnknownID}>
261
261
  {#snippet annotation()}
262
- <span >Customize bad links</span>
263
- {/snippet}
262
+ <span>Customize bad links</span>
263
+ {/snippet}
264
264
  </TableOfContentsRow>
265
265
  {/if}
266
266
  </Rows>
@@ -281,6 +281,21 @@ onMount(() => {
281
281
  > and then print or export. Long books can take some time to render.
282
282
  </PageParagraph>
283
283
 
284
+ <EditableHeader id="epub" label="Edit e-book header" />
285
+
286
+ <Instructions {editable}>
287
+ This builds an EPUB in the reader's browser. Nothing is uploaded and
288
+ no server is involved.
289
+ </Instructions>
290
+
291
+ <PageParagraph>
292
+ Reading on an e-reader? Build an EPUB of this book. Images are
293
+ shrunk to fit the size you choose, so pick the one closest to your
294
+ device. Long books with many images can take a minute.
295
+ </PageParagraph>
296
+
297
+ <DownloadEPUB />
298
+
284
299
  <EditableHeader id="citation" label="Edit citation header" />
285
300
 
286
301
  <Instructions {editable}>
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Builds a complete EPUB from a loaded edition, entirely in the browser.
3
+ *
4
+ * No server is involved: the whole book's text is already in memory by the time
5
+ * the table of contents renders, and images are fetched directly (Firebase's
6
+ * download URLs allow cross-origin reads, and compiled books serve theirs from
7
+ * the same origin).
8
+ */
9
+ import type Edition from '../models/book/Edition';
10
+ import { type ImageSize } from './images';
11
+ import { chapterPath } from './xhtml';
12
+ export type BuildPhase = 'images' | 'chapters' | 'packaging';
13
+ export type BuildProgress = {
14
+ phase: BuildPhase;
15
+ done: number;
16
+ total: number;
17
+ };
18
+ export type BuildResult = {
19
+ blob: Blob;
20
+ filename: string;
21
+ /** Images that couldn't be included, so the reader knows what's missing. */
22
+ warnings: string[];
23
+ };
24
+ export type BuildOptions = {
25
+ /** The book's base path, for resolving local image URLs. */
26
+ base?: string;
27
+ size?: ImageSize;
28
+ onProgress?: (progress: BuildProgress) => void;
29
+ /** Fixed timestamp, so tests can compare output. */
30
+ modified?: Date;
31
+ };
32
+ /**
33
+ * A stable, unique identifier for this edition. EPUB requires one, and readers
34
+ * use it to recognize the same book across downloads.
35
+ */
36
+ export declare function identifierFor(edition: Edition): string;
37
+ /** Build an EPUB for the given edition. */
38
+ export declare function buildEPUB(edition: Edition, options?: BuildOptions): Promise<BuildResult>;
39
+ export { chapterPath };