bookish-press 0.5.62 → 0.5.71

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
@@ -4,4 +4,33 @@ Bookish is a framework and platform for writing and publishing web-based books.
4
4
 
5
5
  ## Status
6
6
 
7
- I'm working on a beta launch of the platform at [bookish.press](https://bookish.press) and expect to maintain the platform for the foreseeable future. Let me know if you'd like to help!
7
+ I'm working on a 1.0 of the platform at [bookish.press](https://bookish.press) and expect to maintain the platform for the foreseeable future. Let me know if you'd like to help!
8
+
9
+ ## Deployment notes
10
+
11
+ 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:
12
+
13
+ 0. Ensure you're working in the `dev` branch
14
+ 1. Fix bugs, add features
15
+ 2. Ensure there are no type errors (`npm run check`) or failed tests (`npm run test`).
16
+ 2. Test with emulator (`npm run emu`), if possible. (Some features aren't possible to perfectly emulate, such as authentication, hosting configurations, permissions.)
17
+ 3. Test on the [staging server](https://bookish-dev-21ac3.web.app/`) (`npm run stage`).
18
+ 4. Bump the version in `package.json`
19
+ 5. Describe the changes in `CHANGELOG.md`, linking to any issues closed
20
+ 6. Commit changes to `dev`, referring to any issues addressed
21
+ 7. Release to production (`npm run release`)
22
+ 8. Verify the change in production on [bookish.press](https://bookish.press)
23
+ 9. Merge dev to main (`npm run merge`)
24
+ 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:
25
+ 1. Update the bookish package with the Sveltekit package tooling (`npm run package`)
26
+ 2. Publish the package to npm (`npm publish`)
27
+ 3. Open the `bookish-reader` repository
28
+ 4. Pull the revisions (`npm update`), ensuring you update the repository's `package.json` dependencies, in case there was a major version change
29
+ 5. Update reader's `CHANGELOG.md` to describe any changes.
30
+ 6. Bump the reader's version in `package.json`
31
+ 7. Commit the changes directly to `main`, and sync them
32
+ 8. For any of these changes to make it to pre-built books, authors need ot use the updated `bookish-reader` repo to rebuild their books. That generally involves:
33
+ 1. Cloning the reader repo: `git clone https://github.com/amyjko/bookish-reader`
34
+ 2. Entering the repo directory: `cd bookish-reader`
35
+ 3. Binding the book: `bind.sh`
36
+ 4. Deploying the bound book in the newly created `build` folder to wherever it's hosted.
@@ -20,5 +20,6 @@ export let label;
20
20
  padding: var(--app-chrome-padding);
21
21
  font-family: var(--app-font);
22
22
  font-size: var(--app-chrome-font-size);
23
+ max-width: 10em;
23
24
  }
24
25
  </style>
@@ -9,7 +9,6 @@
9
9
  width: 100%;
10
10
  overflow-x: auto;
11
11
  margin-top: var(--app-text-spacing);
12
- margin-bottom: var(--app-text-spacing);
13
12
  border-top: var(--app-chrome-border-size) solid var(--app-border-color);
14
13
  border-bottom: var(--app-chrome-border-size) solid
15
14
  var(--app-border-color);
@@ -5,35 +5,22 @@ import Python from './Python.svelte';
5
5
  import Text from './Text.svelte';
6
6
  import { isChapterEditable } from '../page/Contexts';
7
7
  import Figure from './Figure.svelte';
8
- import { getCaret } from '../page/Contexts';
8
+ import Button from '../app/Button.svelte';
9
9
  export let node;
10
10
  $: language = node.getLanguage();
11
11
  let editable = isChapterEditable();
12
12
  // const languages = [ "C", "C++", "CSS", "Go", "HTML", "Java", "JavaScript", "Markdown", "Plaintext", "Python", "TypeScript" ];
13
- let caret = getCaret();
14
- $: inside =
15
- $caret?.range?.start.node === node.getCodeNode() ||
16
- $caret?.range?.end.node === node.getCodeNode();
13
+ let editing = true;
17
14
  </script>
18
15
 
19
- <Figure {node} caption={node.getCaption()}>
20
- {#if editable}
21
- <div class="container">
16
+ <Figure {node} caption={node.getCaption()} focusable={false}>
17
+ {#if editable && editing}
18
+ <div class="container" class:editing>
22
19
  <code
23
- class={`bookish-code bookish-code-block language-${language} ${
24
- !inside ? 'hidden' : ''
25
- }`}
20
+ class={`bookish-code bookish-code-block language-${language}`}
26
21
  >
27
22
  <Text node={node.getCodeNode()} />
28
23
  </code>
29
- <div class="code" class:inside>
30
- <Code
31
- editable={false}
32
- inline={false}
33
- language={node.getLanguage()}
34
- nodeID={node.getCodeNode().nodeID}>{node.getCode()}</Code
35
- >
36
- </div>
37
24
  </div>
38
25
  {:else if node.getLanguage() === 'python' && node.isExecutable()}
39
26
  <Python {node} startCode={node.getCode()} />
@@ -45,6 +32,15 @@ $: inside =
45
32
  nodeID={node.getCodeNode().nodeID}>{node.getCode()}</Code
46
33
  >
47
34
  {/if}
35
+ {#if editable}
36
+ <Button
37
+ tooltip={editing ? 'stop editing' : 'start editing'}
38
+ command={() => {
39
+ editing = !editing;
40
+ }}
41
+ >{#if editing}done{:else}edit{/if}</Button
42
+ >
43
+ {/if}
48
44
  {#if node.getLanguage() !== 'plaintext'}<aside class="language"
49
45
  >{node.getLanguage()}</aside
50
46
  >{/if}
@@ -66,6 +62,12 @@ $: inside =
66
62
  .container {
67
63
  position: relative;
68
64
  }
65
+
66
+ .container.editing {
67
+ border: 1px solid var(--app-interactive-color);
68
+ border-radius: var(--app-chrome-roundedness);
69
+ }
70
+
69
71
  .code {
70
72
  position: absolute;
71
73
  top: 0;
@@ -6,6 +6,7 @@ import { getCaret, isChapterEditable } from '../page/Contexts';
6
6
  export let node;
7
7
  export let caption;
8
8
  export let credit = undefined;
9
+ export let focusable = true;
9
10
  let editable = isChapterEditable();
10
11
  let caret = getCaret();
11
12
  $: selected = $caret?.range?.start.node === node;
@@ -30,11 +31,12 @@ function focusFigure() {
30
31
 
31
32
  <Positioned position={node.getPosition()}>
32
33
  <figure class="bookish-figure" data-nodeid={node.nodeID}>
34
+ <!-- svelte-ignore a11y-no-noninteractive-tabindex -->
33
35
  <div
34
36
  class:selected
35
- role="button"
36
- tabindex="0"
37
- on:mousedown={focusFigure}
37
+ role={focusable ? 'button' : undefined}
38
+ tabindex={focusable ? 0 : undefined}
39
+ on:mousedown={focusable ? focusFigure : null}
38
40
  on:keydown={(event) =>
39
41
  event.key === ' ' || event.key === 'Enter'
40
42
  ? focusFigure()
@@ -8,6 +8,7 @@ declare const __propDef: {
8
8
  node: TableNode | EmbedNode | CodeNode;
9
9
  caption: FormatNode | undefined;
10
10
  credit?: FormatNode | undefined;
11
+ focusable?: boolean | undefined;
11
12
  };
12
13
  events: {
13
14
  [evt: string]: CustomEvent<any>;
@@ -18,6 +18,7 @@ import commands from './Commands';
18
18
  import { afterUpdate, onMount, setContext } from 'svelte';
19
19
  import { getCaret } from '../page/Contexts';
20
20
  import CaretView from './CaretView.svelte';
21
+ import Parser from '../../models/chapter/Parser';
21
22
  const IDLE_TIME = 500;
22
23
  export let text;
23
24
  export let parser;
@@ -698,14 +699,16 @@ function handleCopy(node) {
698
699
  window.alert("Your browser doesn't support copying to the clipboard :(");
699
700
  }
700
701
  function handlePaste(context, text, save) {
702
+ // If we're pasting a node, see what will accept it.
701
703
  if (text instanceof BookishNode)
702
704
  return handlePasteNode(context, text, save);
703
- const plain = text.plain === undefined ? undefined : new TextNode(text.plain);
704
- if (plain) {
705
- const edit = handlePasteNode(context, plain, save);
706
- if (edit)
707
- return edit;
708
- }
705
+ if (text.plain === undefined)
706
+ return undefined;
707
+ // If we're pasting text, then we parse it as a series of paragraphs.
708
+ const chapter = Parser.parseChapter(undefined, text.plain);
709
+ const edit = handlePasteNode(context, chapter, save);
710
+ if (edit)
711
+ return edit;
709
712
  return undefined;
710
713
  }
711
714
  function handlePasteNode(context, node, save) {
@@ -1,4 +1,4 @@
1
- <script>import { afterUpdate } from 'svelte';
1
+ <script>import { afterUpdate, tick } from 'svelte';
2
2
  export let left;
3
3
  export let top;
4
4
  export let height;
@@ -11,9 +11,14 @@ export let blink;
11
11
  export let locked;
12
12
  let element;
13
13
  let headerHeight = 0;
14
- // When the caret updates, make sure it's in view.
14
+ // When the caret updates position, scroll the element into view after the render is complete.
15
+ $: {
16
+ left;
17
+ top;
18
+ tick().then(() => element ? element.scrollIntoView({ block: 'nearest' }) : undefined);
19
+ }
20
+ /** Keep track of the header height, so we can snap to the caret position. */
15
21
  afterUpdate(() => {
16
- element?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
17
22
  headerHeight =
18
23
  document.querySelector('.bookish-app > .header')
19
24
  ?.offsetHeight ?? 0;
@@ -1471,7 +1471,7 @@ const commands = [
1471
1471
  const text = await blob.text();
1472
1472
  contents.plain = text;
1473
1473
  }
1474
- context.handlePaste(context, contents, true);
1474
+ return context.handlePaste(context, contents, true);
1475
1475
  }
1476
1476
  catch (err) { }
1477
1477
  }
@@ -39,7 +39,7 @@ function handleEmptyAdd() {
39
39
  command={handleBulkAdd}>+ bulk references</Button
40
40
  >
41
41
  <Button tooltip="add empty reference" command={handleEmptyAdd}
42
- >+ reference</Button
42
+ >+ empty reference</Button
43
43
  >
44
44
 
45
45
  <style>
@@ -213,8 +213,8 @@ let editable = isBookEditable() ||
213
213
  <Outline
214
214
  slot="outline"
215
215
  collapse={$marginal !== undefined}
216
- previous={$edition.getPreviousChapterID(chapterID)}
217
- next={$edition.getNextChapterID(chapterID)}
216
+ previous={$edition.getPreviousChapterID(chapterID, editable)}
217
+ next={$edition.getNextChapterID(chapterID, editable)}
218
218
  listener={(expanded) => {
219
219
  // If the outline is being expanded, hide the marginal, otherwise leave it alone.
220
220
  if (expanded) marginal.set(undefined);
@@ -331,9 +331,8 @@ let editable = isBookEditable() ||
331
331
  {:else}
332
332
  <ChapterBody node={chapterAST} />
333
333
  {/if}
334
- {:else}
335
- <span>Loading...</span>
336
- {/if}
334
+ {:else if $edition === undefined}Loading...{:else}Unable to load
335
+ chapter text{/if}
337
336
  </section>
338
337
 
339
338
  {#if citations && citations.size > 0}
@@ -4,12 +4,13 @@ import Feedback from '../app/Feedback.svelte';
4
4
  import { getBook, getEdition, getUser, getChapterText, } from './Contexts';
5
5
  import { onDestroy } from 'svelte';
6
6
  import { page } from '$app/stores';
7
- import { deleteChapterText, listenToChapters, listenToEdition, } from '../../models/CRUD';
7
+ import { listenToChapters, listenToEdition } from '../../models/CRUD';
8
8
  export let write;
9
9
  let auth = getUser();
10
10
  let book = getBook();
11
11
  let edition = getEdition();
12
- let chapterText = getChapterText();
12
+ /** Mapping from Firestore docuemnt IDs to chapter text */
13
+ let chapterText = new Map();
13
14
  let editionUnsub = undefined;
14
15
  let chaptersUnsub = undefined;
15
16
  let error = undefined;
@@ -54,9 +55,7 @@ function listen() {
54
55
  editionUnsub = listenToEdition($book.getID(), editionID, (ed) => {
55
56
  if (ed) {
56
57
  // Before setting the new edition, augmented it with the chapter text.
57
- edition.set($chapterText
58
- ? ed.withChapterText($chapterText)
59
- : ed);
58
+ edition.set(ed.withChapterText(chapterText));
60
59
  }
61
60
  else {
62
61
  edition.set(undefined);
@@ -64,20 +63,12 @@ function listen() {
64
63
  }
65
64
  });
66
65
  chaptersUnsub = listenToChapters($book.getID(), editionID, (chapters) => {
67
- // Update the chapter text store.
68
- chapterText.set(chapters);
66
+ // Update the text of the chapters we received.
67
+ for (const [ref, text] of chapters)
68
+ chapterText.set(ref.id, text);
69
69
  // Update the chapter text in the current edition.
70
- if ($edition) {
71
- const newEdition = $edition.withChapterText(chapters);
72
- if (newEdition !== $edition)
73
- edition.set(newEdition);
74
- // Get all the chapters in the updated chapter list and
75
- // delete the ones not referenced by the edition.
76
- chapters
77
- .filter(([ref]) => $edition &&
78
- !$edition.hasChapterRefID(ref.id))
79
- .forEach(([ref]) => deleteChapterText(ref));
80
- }
70
+ if ($edition)
71
+ edition.set($edition.withChapterText(chapterText));
81
72
  });
82
73
  }
83
74
  }
@@ -1,7 +1,7 @@
1
1
  <script>import { createNewEdition } from '../../models/CRUD';
2
2
  import Instructions from './Instructions.svelte';
3
3
  import Note from '../editor/Note.svelte';
4
- import { getBook, isBookEditable } from './Contexts';
4
+ import { getBook, getUser, isBookEditable } from './Contexts';
5
5
  import Button from '../app/Button.svelte';
6
6
  import Link from '../app/Link.svelte';
7
7
  import Format from '../chapter/Format.svelte';
@@ -10,6 +10,7 @@ import Permissions from '../editor/Permissions.svelte';
10
10
  export let editors = true;
11
11
  let book = getBook();
12
12
  let editable = isBookEditable();
13
+ let user = getUser();
13
14
  $: editions = $book?.getEditions();
14
15
  $: latestPublishedID = $book?.getLatestPublishedEditionID();
15
16
  async function handleDraftEdition() {
@@ -48,7 +49,7 @@ async function handleDraftEdition() {
48
49
  ? 'rd'
49
50
  : 'th')}
50
51
 
51
- {#if $book && (editable || edition.published)}
52
+ {#if $book && (editable || edition.published || ($user?.user?.uid && edition.editionuids.includes($user.user.uid)))}
52
53
  <section class="edition">
53
54
  <h3
54
55
  >{editionLabel}
@@ -68,7 +69,7 @@ async function handleDraftEdition() {
68
69
  }`}
69
70
  external>read</Link
70
71
  >{/if}
71
- {#if editable}
72
+ {#if editable || ($user?.user?.uid && edition.editionuids.includes($user?.user.uid))}
72
73
  &nbsp;
73
74
  <Link
74
75
  to={`/write/${$book.getID()}/${
@@ -50,8 +50,14 @@ function addEmptyDefinition() {
50
50
  >
51
51
  <Outline
52
52
  slot="outline"
53
- previous={$edition.getPreviousChapterID(ChapterIDs.GlossaryID)}
54
- next={$edition.getNextChapterID(ChapterIDs.GlossaryID)}
53
+ previous={$edition.getPreviousChapterID(
54
+ ChapterIDs.GlossaryID,
55
+ editable
56
+ )}
57
+ next={$edition.getNextChapterID(
58
+ ChapterIDs.GlossaryID,
59
+ editable
60
+ )}
55
61
  />
56
62
  </Header>
57
63
  {#if editable}
@@ -57,8 +57,11 @@ $: {
57
57
  >
58
58
  <Outline
59
59
  slot="outline"
60
- previous={$edition.getPreviousChapterID(ChapterIDs.IndexID)}
61
- next={$edition.getNextChapterID(ChapterIDs.IndexID)}
60
+ previous={$edition.getPreviousChapterID(
61
+ ChapterIDs.IndexID,
62
+ editable
63
+ )}
64
+ next={$edition.getNextChapterID(ChapterIDs.IndexID, editable)}
62
65
  />
63
66
  </Header>
64
67
 
@@ -43,8 +43,11 @@ $: unused = images?.filter((image) => !embeds.some((embed) => image.url.includes
43
43
  >
44
44
  <Outline
45
45
  slot="outline"
46
- previous={$edition.getPreviousChapterID(ChapterIDs.MediaID)}
47
- next={$edition.getNextChapterID(ChapterIDs.MediaID)}
46
+ previous={$edition.getPreviousChapterID(
47
+ ChapterIDs.MediaID,
48
+ editable
49
+ )}
50
+ next={$edition.getNextChapterID(ChapterIDs.MediaID, editable)}
48
51
  />
49
52
  </Header>
50
53
  <Instructions {editable}>
@@ -231,7 +231,7 @@ function move(field, direction) {
231
231
  </tr>
232
232
  </Table>
233
233
  {:else}
234
- <!-- If not editable, just render the reference. -->
234
+ <!-- Otherwise, just render the reference. -->
235
235
  <p class="reference">
236
236
  {#if reference.authors}{reference.authors}{:else}<em>Authors</em>{/if}
237
237
  {#if reference.year}({reference.year}){:else}<em>Year</em>{/if}. {#if reference.url === null || reference.url.length === 0}{#if reference.title}{reference.title}{:else}<em
@@ -247,26 +247,31 @@ function move(field, direction) {
247
247
  </p>
248
248
  {/if}
249
249
  {#if edit && editable && !reference.short}
250
- <ConfirmButton
251
- tooltip="delete reference {reference.title}"
252
- confirm="delete reference"
253
- command={() =>
254
- $edition
255
- ? edition.set($edition.withoutReference(reference.citationID))
256
- : undefined}>⨉</ConfirmButton
257
- >
258
- <Button
259
- tooltip="stop editing reference"
260
- command={() => (editing ? stopEditing() : startEditing())}
261
- >{editing ? 'done' : 'edit'}</Button
262
- >
250
+ <div class="controls">
251
+ {#if editable}<Note>{reference.citationID}</Note>{/if}
252
+ <ConfirmButton
253
+ tooltip="delete reference {reference.title}"
254
+ confirm="delete reference"
255
+ command={() =>
256
+ $edition
257
+ ? edition.set(
258
+ $edition.withoutReference(reference.citationID)
259
+ )
260
+ : undefined}>⨉</ConfirmButton
261
+ >
262
+ <Button
263
+ tooltip="stop editing reference"
264
+ command={() => (editing ? stopEditing() : startEditing())}
265
+ >{editing ? 'done' : 'edit'}</Button
266
+ >
267
+ </div>
263
268
  {/if}
264
269
 
265
270
  <style>
266
271
  .reference {
267
272
  line-height: var(--bookish-paragraph-line-height-tight);
268
273
  margin-top: 0;
269
- margin-bottom: var(--bookish-block-padding);
274
+ margin-top: var(--bookish-block-padding);
270
275
  }
271
276
 
272
277
  p {
@@ -289,4 +294,8 @@ function move(field, direction) {
289
294
  td {
290
295
  line-height: 1;
291
296
  }
297
+
298
+ .controls {
299
+ margin-top: calc(var(--bookish-block-padding) / 2);
300
+ }
292
301
  </style>
@@ -36,9 +36,13 @@ if (references !== null) {
36
36
  <Outline
37
37
  slot="outline"
38
38
  previous={$edition.getPreviousChapterID(
39
- ChapterIDs.ReferencesID
39
+ ChapterIDs.ReferencesID,
40
+ editable
41
+ )}
42
+ next={$edition.getNextChapterID(
43
+ ChapterIDs.ReferencesID,
44
+ editable
40
45
  )}
41
- next={$edition.getNextChapterID(ChapterIDs.ReferencesID)}
42
46
  />
43
47
  </Header>
44
48
  {#if editable}<BulkReferenceEditor />{/if}
@@ -5,7 +5,6 @@ import Link from '../Link.svelte';
5
5
  import Chapter from '../../models/book/Chapter';
6
6
  import ChapterIDs from '../../models/book/ChapterID';
7
7
  import { getBase, getEdition, isEditionEditable } from './Contexts';
8
- import TextInput from '../app/TextInput.svelte';
9
8
  import ChapterTitle from './ChapterTitle.svelte';
10
9
  import ChapterNumber from './ChapterNumber.svelte';
11
10
  import PageHeader from './PageHeader.svelte';
@@ -76,8 +75,11 @@ $: {
76
75
  >
77
76
  <Outline
78
77
  slot="outline"
79
- previous={$edition.getPreviousChapterID(ChapterIDs.SearchID)}
80
- next={$edition.getNextChapterID(ChapterIDs.SearchID)}
78
+ previous={$edition.getPreviousChapterID(
79
+ ChapterIDs.SearchID,
80
+ false
81
+ )}
82
+ next={$edition.getNextChapterID(ChapterIDs.SearchID, false)}
81
83
  />
82
84
  </Header>
83
85
 
@@ -1,6 +1,6 @@
1
1
  <script>import TextEditor from '../editor/TextEditor.svelte';
2
2
  import { isSubdomainAvailable } from '../../models/CRUD';
3
- import Note from '../editor/Note.svelte';
3
+ import { getSubdomain } from '../../util/getSubdomain';
4
4
  import { getBook } from './Contexts';
5
5
  import Muted from './Muted.svelte';
6
6
  let book = getBook();
@@ -22,9 +22,10 @@ const invalidNames = [
22
22
  save={// Save the new domain
23
23
  async (domain) => {
24
24
  if ($book) {
25
- const available = await isSubdomainAvailable(domain, $book);
25
+ const available = await isSubdomainAvailable(domain);
26
26
  if (available) book.set($book.withSubdomain(domain));
27
- else throw Error("Domain isn't available");
27
+ else if ($book.getSubdomain() !== domain)
28
+ throw Error("Domain isn't available");
28
29
  }
29
30
  }}
30
31
  placeholder="book name"
@@ -95,7 +95,7 @@ onMount(() => {
95
95
  <Outline
96
96
  slot="outline"
97
97
  previous={null}
98
- next={$edition.getNextChapterID('')}
98
+ next={$edition.getNextChapterID('', editable)}
99
99
  />
100
100
  <div slot="after">
101
101
  <Authors
@@ -126,7 +126,7 @@ onMount(() => {
126
126
  <Description />
127
127
 
128
128
  <Instructions {editable}>
129
- This will appear on the <Link to="{$base}/read">book browsing</Link>
129
+ This will appear on the <Link to="/read">book browsing</Link>
130
130
  page and in your table of contents. Write an informative description
131
131
  of what your book is about.
132
132
  </Instructions>
@@ -192,7 +192,7 @@ onMount(() => {
192
192
  You can edit this chapter.
193
193
  {/if}
194
194
  </span>
195
- <span slot="etc">
195
+ <span class="etc" slot="etc">
196
196
  <Muted>
197
197
  {#if editable}
198
198
  <Toggle
@@ -310,3 +310,9 @@ onMount(() => {
310
310
  {/if}
311
311
  </Page>
312
312
  {/if}
313
+
314
+ <style>
315
+ .etc {
316
+ white-space: nowrap;
317
+ }
318
+ </style>
@@ -82,7 +82,7 @@ $: chapterURL =
82
82
  ? edition.set(
83
83
  $edition.withoutChapter(chapter.getID())
84
84
  )
85
- : undefined}>- chapter</ConfirmButton
85
+ : undefined}>⨉</ConfirmButton
86
86
  >
87
87
  {/if}
88
88
  </td>
@@ -102,6 +102,8 @@ $: chapterURL =
102
102
  display: flex;
103
103
  flex-direction: row;
104
104
  flex-wrap: nowrap;
105
+ justify-content: end;
106
+ margin-inline-end: var(--app-chrome-padding);
105
107
  gap: var(--app-chrome-padding);
106
108
  }
107
109
 
@@ -20,9 +20,8 @@ export declare function updateLock(edition: Edition, uid: string, content: strin
20
20
  * returns a map of DocumentReferences for new chapters so they can be persisted client side.
21
21
  */
22
22
  export declare function updateEdition(book: Book, previousEdition: Edition | undefined, newEdition: Edition): Promise<Map<string, DocumentReference>>;
23
- export declare function deleteChapterText(ref: DocumentReference): Promise<void>;
24
23
  export declare function updateBook(book: Book): Promise<void>;
25
- export declare function isSubdomainAvailable(subdomain: string, book: Book): Promise<boolean>;
24
+ export declare function isSubdomainAvailable(subdomain: string): Promise<boolean>;
26
25
  export declare function getBookIDWithSubdomain(subdomain: string): Promise<string | null>;
27
26
  export declare function publish(edition: Edition): Promise<string | {
28
27
  url: string;
@@ -62,7 +62,8 @@ export function listenToChapters(bookID, editionID, react) {
62
62
  const chapters = [];
63
63
  // For each chapter in the book, if the doc exists and it's not a pending local write, send updated chapters to the front end.
64
64
  docs.forEach((doc) => {
65
- if (doc.exists() && !doc.metadata.hasPendingWrites)
65
+ // We propagate pending writes to cycle back local edits to the client.
66
+ if (doc.exists())
66
67
  // If the chapter text is somehow null, set it to an empty string.
67
68
  // This accounts for previous defects that could lead to null.
68
69
  chapters.push([doc.ref, doc.data().text ?? '']);
@@ -235,15 +236,12 @@ export async function updateEdition(book, previousEdition, newEdition) {
235
236
  await setDoc(doc(db, editionPath(newEdition.bookRef.id, editionRef.id)), newEdition.toObject());
236
237
  return newChapterRefs;
237
238
  }
238
- export async function deleteChapterText(ref) {
239
- deleteDoc(ref);
240
- }
241
239
  export async function updateBook(book) {
242
240
  if (!db)
243
241
  throw Error("Can't update book, not connected to Firebase.");
244
242
  await setDoc(doc(db, 'books', book.getID()), book.toJSON());
245
243
  }
246
- export async function isSubdomainAvailable(subdomain, book) {
244
+ export async function isSubdomainAvailable(subdomain) {
247
245
  if (!db)
248
246
  throw Error('Not connected to Firebase.');
249
247
  return (await getBookIDWithSubdomain(subdomain)) === null;
@@ -85,17 +85,22 @@ export default class BookMedia {
85
85
  // The canonical path format for Bookish images in the store is image/{bookid}/{imageid}
86
86
  // where {bookid} is the Firestore document ID of the book being edited and {imageid} is just a random id.
87
87
  const imageRef = ref(storage, `${this.getImagePath()}/${imageName}`);
88
- const thumbnailRef = ref(storage, `${this.getImagePath()}/thumbnails/${imageName}`);
89
88
  uploadBytes(imageRef, file)
90
89
  .then((snapshot) => {
91
90
  getDownloadURL(snapshot.ref)
92
91
  // After we get the download URL for the uploaded image,
93
92
  // try to get the thumbnail URL for the resized image on the server.
94
93
  .then((url) => finishedHandler(this.stripTokenFromURL(url), this.makeThumbnailURL(this.stripTokenFromURL(url))))
95
- .catch(() => errorHandler('Unable to get image URL.'));
94
+ .catch((error) => {
95
+ console.error(error);
96
+ errorHandler('Unable to get image URL.');
97
+ });
96
98
  })
97
99
  .then(() => this.cacheImages())
98
- .catch(() => errorHandler('Unable to upload image.'));
100
+ .catch((error) => {
101
+ console.error(error);
102
+ errorHandler('Unable to upload image.');
103
+ });
99
104
  // This seems to be broken in Firebase right now. Switched above to a no-feedback approach.
100
105
  // const uploadTask = uploadBytesResumable(imageRef, file);
101
106
  // uploadTask.on("state_changed",
@@ -156,9 +156,8 @@ export default class Chapter {
156
156
  }
157
157
  getWordCount(edition) {
158
158
  if (this._wordCount === undefined)
159
- this._wordCount = this.getAST(edition)
160
- ?.toText()
161
- .split(/\s+/).length;
159
+ this._wordCount = this.getAST(edition)?.toText().split(/\s+/)
160
+ .length;
162
161
  return this._wordCount;
163
162
  }
164
163
  // Utility function
@@ -27,7 +27,9 @@ export type EditionSpecification = {
27
27
  glossary: Record<string, Definition>;
28
28
  theme: Theme | null;
29
29
  base: string | null;
30
+ /** People who have edit access to this edition */
30
31
  uids: string[];
32
+ /** All uids who have edit access to at least one chapter. Used in firestore rules to give permission to update book. */
31
33
  chapteruids: string[];
32
34
  active: Record<string, string>;
33
35
  };
@@ -78,7 +80,7 @@ export default class Edition {
78
80
  getChapterPosition(chapterID: string): number | undefined;
79
81
  getChapterCount(): number;
80
82
  withChapters(chapters: Chapter[]): Edition;
81
- withChapterText(text: [DocumentReference, string][]): Edition;
83
+ withChapterText(textByID: Map<string, string>): Edition;
82
84
  withRevisedChapter(previous: Chapter, edited: Chapter): Edition;
83
85
  withoutRefs(): Edition;
84
86
  withIncrementedEditionNumber(): Edition;
@@ -130,8 +132,8 @@ export default class Edition {
130
132
  getChapterSection(chapterID: string): string | undefined;
131
133
  getFootnoteSymbol(number: number): string;
132
134
  getBookIndex(): Record<string, Set<string>>;
133
- getNextChapterID(chapterID: string): string | null;
134
- getPreviousChapterID(chapterID: string): string | null;
135
+ getNextChapterID(chapterID: string, editable: boolean): string | null;
136
+ getPreviousChapterID(chapterID: string, editable: boolean): string | null;
135
137
  getEmbeds(): {
136
138
  embed: EmbedNode;
137
139
  chapterID: string | undefined;
@@ -174,20 +174,15 @@ export default class Edition {
174
174
  withChapters(chapters) {
175
175
  return new Edition(this.bookRef, this.editionRef, this.uids, this.title, this.authors, this.number, this.summary, this.published, this.images, this.description, chapters, this.license, this.acknowledgements, this.tags, this.sources, this.references, this.symbols, this.glossary, this.theme, this.base, this.active);
176
176
  }
177
- withChapterText(text) {
178
- // No need to make a new edition if no chapter was changed.
179
- let modified = false;
177
+ withChapterText(textByID) {
180
178
  const newChapters = this.chapters.map((chapter) => {
181
- if (chapter.ref === undefined)
179
+ if (chapter.ref === undefined) {
182
180
  return chapter;
183
- const match = text.find(([ref, text]) => chapter.ref !== undefined &&
184
- chapter.ref.id === ref.id &&
185
- chapter.text !== text);
186
- if (match)
187
- modified = true;
188
- return match ? chapter.withText(match[1]) : chapter;
181
+ }
182
+ const text = textByID.get(chapter.ref.id);
183
+ return text === undefined ? chapter : chapter.withText(text);
189
184
  });
190
- return modified ? this.withChapters(newChapters) : this;
185
+ return this.withChapters(newChapters);
191
186
  }
192
187
  withRevisedChapter(previous, edited) {
193
188
  const index = this.chapters.indexOf(previous);
@@ -482,7 +477,7 @@ export default class Edition {
482
477
  return bookIndex;
483
478
  }
484
479
  // Given the current chapter, find the available chapter after it.
485
- getNextChapterID(chapterID) {
480
+ getNextChapterID(chapterID, editable) {
486
481
  // Handle back matter chapters.
487
482
  switch (chapterID) {
488
483
  case ChapterIDs.ReferencesID:
@@ -508,7 +503,7 @@ export default class Edition {
508
503
  if (chapter.getID() === chapterID)
509
504
  after = true;
510
505
  // If we're after the given chapter and it's not forthcoming.
511
- else if (after && !chapter.isForthcoming())
506
+ else if (after && (!chapter.isForthcoming() || editable))
512
507
  return chapter.getID();
513
508
  }
514
509
  // If the given ID was the last chapter, go to the next back matter chapter.
@@ -524,7 +519,7 @@ export default class Edition {
524
519
  }
525
520
  }
526
521
  // Given a chapter id, find the available chapter before it.
527
- getPreviousChapterID(chapterID) {
522
+ getPreviousChapterID(chapterID, editable) {
528
523
  switch (chapterID) {
529
524
  // Handle back matter chapters.
530
525
  case ChapterIDs.ReferencesID:
@@ -556,7 +551,7 @@ export default class Edition {
556
551
  if (chapter.getID() === chapterID)
557
552
  before = true;
558
553
  // If we're before the given chapter and it's not forthcoming.
559
- else if (before && !chapter.isForthcoming())
554
+ else if (before && (!chapter.isForthcoming() || editable))
560
555
  return chapter.getID();
561
556
  }
562
557
  // If the given ID was the last chapter, go to the next back matter chapter.
@@ -1,12 +1,16 @@
1
1
  import Reference from '../models/book/Reference';
2
2
  export function mineReference(ids, text) {
3
3
  // First find the year, since it's the most easily detected. Optional parens.
4
- let yearMatches = text.match(/(\(?[0-9]{4}\)?)/);
4
+ let yearMatches = text.match(/(\(?[0-9]{4}(,[a-zA-Z ]+)?\)?)/);
5
5
  let year = yearMatches !== null && yearMatches.length > 0
6
6
  ? yearMatches[1].replace('(', '').replace(')', '')
7
7
  : undefined;
8
- if (year && yearMatches)
8
+ if (year && yearMatches) {
9
+ const digits = year.match(/([0-9]{4})/);
10
+ if (digits !== null)
11
+ year = digits[1];
9
12
  text = text.replace(yearMatches[1], '');
13
+ }
10
14
  // Next, find the URL
11
15
  let url = null;
12
16
  let urlMatches = text.match(/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/);
@@ -38,9 +42,7 @@ export function mineReference(ids, text) {
38
42
  source = source.trim();
39
43
  }
40
44
  // If there's anything left, fall back to just a string.
41
- return chunks.length > 0
42
- ? new Reference(getUniqueReferenceID(ids), text)
43
- : new Reference(getUniqueReferenceID(ids, authors, year), authors || '', year || '', title || '', source || '', url || '', '', false);
45
+ return new Reference(getUniqueReferenceID(ids, authors, year), authors || '', year || '', title || '', source || '', url || '', '', false);
44
46
  }
45
47
  export function splitIntoSentences(text) {
46
48
  // Split the remaining text by period-space sequences, assuming chunks of information are segmented by sentences
@@ -92,7 +94,7 @@ export function getUniqueReferenceID(ids, authors, year) {
92
94
  return revisedID;
93
95
  }
94
96
  else {
95
- let id = 'ref';
97
+ let id = '00';
96
98
  for (let i = 0; i < 4; i++)
97
99
  id = id + Math.round(Math.random() * 9);
98
100
  return id;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "bookish-press",
3
3
  "private": false,
4
- "version": "0.5.62",
4
+ "version": "0.5.71",
5
5
  "type": "module",
6
6
  "description": "A platform for reading and writing readable, accessible, beautiful online books.",
7
7
  "license": "MIT",
@@ -27,38 +27,41 @@
27
27
  "merge": "git checkout main && git merge dev && git push origin main && git checkout dev"
28
28
  },
29
29
  "devDependencies": {
30
- "@sveltejs/adapter-static": "^2",
31
- "@sveltejs/kit": "^1.22",
32
- "@sveltejs/package": "^2",
30
+ "@sveltejs/adapter-static": "^2.0",
31
+ "@sveltejs/adapter-auto": "^2.1.1",
32
+ "@sveltejs/kit": "^1.27",
33
+ "@sveltejs/package": "^2.2",
33
34
  "@types/prismjs": "^1.26",
34
35
  "@types/smoothscroll-polyfill": "^0",
35
36
  "@types/uuid": "^9.0",
36
37
  "prettier": "^3",
37
38
  "prettier-plugin-svelte": "^3",
38
39
  "svelte-adapter-firebase": "^0.15.0",
39
- "svelte-check": "^3",
40
- "svelte-preprocess": "^5",
40
+ "svelte-check": "^3.6",
41
+ "svelte-preprocess": "^5.1",
41
42
  "tslib": "^2",
42
43
  "typescript": "^5",
43
44
  "vite": "^4",
44
45
  "vitest": "^0"
45
46
  },
46
47
  "dependencies": {
48
+ "@sveltejs/kit": "^1.27",
47
49
  "ajv": "^8",
48
50
  "ajv-formats": "^2",
49
51
  "firebase": "latest",
50
52
  "firebase-functions": "latest",
51
53
  "prismjs": "^1.29",
52
54
  "smoothscroll-polyfill": "^0.4",
53
- "svelte": "^4.0",
54
- "@sveltejs/kit": "^1.22",
55
+ "svelte": "^4.2",
55
56
  "uuid": "^9.0"
56
57
  },
57
58
  "types": "./dist/index.d.ts",
59
+ "main": "./dist/index.js",
58
60
  "exports": {
59
61
  "./package.json": "./package.json",
60
62
  ".": {
61
63
  "types": "./dist/index.d.ts",
64
+ "main": "./dist/index.js",
62
65
  "svelte": "./dist/index.js",
63
66
  "default": "./dist/index.js"
64
67
  },