Skip to content

Convert Storybooks#870

Open
TheNonPirate wants to merge 21 commits into
sillsdev:mainfrom
TheNonPirate:feature/convertStorybook
Open

Convert Storybooks#870
TheNonPirate wants to merge 21 commits into
sillsdev:mainfrom
TheNonPirate:feature/convertStorybook

Conversation

@TheNonPirate
Copy link
Copy Markdown
Contributor

@TheNonPirate TheNonPirate commented Jul 21, 2025

Per #596:
This PR is intended to modify storybook files into a form that the Proskomma USFM parser can parse. I took some code from #645.
I've gotten the basics of storybooks with text and images implemented.

Summary by CodeRabbit

  • New Features

    • Chapter-specific illustrations for story-type books shown above text with swipe navigation.
    • Support for styled inline spans, paragraph/character style milestones, and improved nested list rendering.
    • Book config now includes page illustrations metadata.
  • Bug Fixes

    • Better handling of multi-file story books with correct chapter assembly and illustration mapping.
  • Tests

    • Added Playwright E2E tests and unit tests for list transformations and storybook parsing.
  • Chores

    • Test scripts updated to run Playwright + Vitest; Playwright added as a dev dependency.
    • .gitignore updated to exclude test-results.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jul 21, 2025

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds per-page illustration metadata to BookConfig, implements USFM paragraph/character/list transformations, updates rendering to show chapter illustrations for story books, and introduces Playwright E2E tests plus a test-run orchestration script; related unit tests and config adjustments included.

Changes

Story illustrations and USFM processing

Layer / File(s) Summary
BookConfig type and config parsing
config/index.d.ts, convert/convertConfig.ts
Adds optional pageIllustrations to BookConfig and populates { num, filename } entries when parsing pages.
USFM transformations and conversion pipeline
convert/convertBooks.ts
Adds regex-based transforms for paragraph (replacePStyleTags) and character (replaceCStyleTags) styles, exports transformLists, updates applied filters, removes 'story' from unsupported types, and changes split-file assembly for story books.
Rendering: Svelte component milestone & span handling
src/lib/components/ScriptureViewSofria.svelte
Wraps phrase text in an enclosable span when zcstyle is used; handles zonN, zoliN, zuliN milestones and zstyle/zcstyle subtypes to set list counters, classes, and span-enclosure.
Page route: illustrations, layout, and swipe UI
src/routes/text/+page.svelte
Adds eager illustration asset glob, derives bookType, implements getCurrentIllustrationFile(), conditionally renders top illustration for story books with swipe navigation, and refactors layout into a flex column.
Test infra: run script, package scripts, Playwright config & E2E tests
convert/runTests.ts, package.json, playwright.config.ts, playwright/* , .gitignore
Adds convert/runTests.ts to run Playwright then Vitest, updates package.json test scripts and devDeps, adds Playwright config and Storybook E2E tests, and ignores test-results.
Unit tests for list transformations and adjusted config tests
convert/tests/sab/storybook.test.ts, convert/tests/sab/convertConfigSAB.test.ts
Adds comprehensive Vitest tests for transformLists across list types and nesting; relaxes some config assertions for books of type story.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant Page as src/routes/text/+page.svelte
  participant Config as convert/convertConfig.ts
  participant Converter as convert/convertBooks.ts
  participant View as ScriptureViewSofria.svelte
  User->>Page: Navigate to book page
  Page->>Config: Load BookConfig (includes pageIllustrations)
  Page->>Page: derive bookType, call getCurrentIllustrationFile()
  Page->>View: render content (pass USFM)
  View->>Converter: request USFM transforms (p-style, c-style, lists)
  Converter-->>View: transformed USFM tokens
  Page->>User: display illustration (if story) and rendered content
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • chrisvire
  • davidmoore1

Poem

A rabbit hops through code so bright,
With milestones marching into sight,
Story pages find their picture frames,
Lists transform and tests proclaim,
🐇✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Convert Storybooks' directly reflects the main objective of the PR—adding storybook support with USFM transformations, configuration parsing, and UI rendering for story-type books with illustrations.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Comment thread convert/convertBooks.ts
for (const book of collection.books) {
let bookConverted = false;
switch (book.type) {
case 'story':
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know that it will fall to the default case, but I think it would be better to include case 'story': above default:

@chrisvire chrisvire linked an issue Jul 23, 2025 that may be closed by this pull request
@TheNonPirate TheNonPirate marked this pull request as ready for review August 1, 2025 20:47
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

♻️ Duplicate comments (1)
convert/convertBooks.ts (1)

532-533: Add explicit 'story' case above default

As suggested in the previous review, it would be clearer to include case 'story': explicitly above the default: case for better code readability and maintenance.

🧹 Nitpick comments (5)
convert/convertConfig.ts (1)

533-545: Good implementation with minor suggestion for robustness.

The logic correctly extracts image filenames and constructs appropriate paths with collection/book prefixes. The conditional prefix logic based on the presence of an <images> tag is well thought out.

Consider adding error handling for cases where required attributes might be missing:

const imageFileTag = page.getElementsByTagName('image-filename')[0];
if (imageFileTag) {
+   const pageNum = page.attributes.getNamedItem('num')?.value;
+   if (!pageNum) continue; // Skip if page number is missing
    pageIllustrations.push({
-       num: Number(page.attributes.getNamedItem('num')?.value),
+       num: Number(pageNum),
        filename: book.getElementsByTagName('images')[0]
            ? tag.id +
              '-' +
              book.attributes.getNamedItem('id')!.value +
              '-' +
              imageFileTag.innerHTML
            : imageFileTag.innerHTML
    });
}
src/routes/text/+page.svelte (1)

489-494: Consider consolidating swipe gesture logic.

The swipe gesture on the illustration image duplicates the existing doSwipe function logic. This is functional but could lead to maintenance issues if the swipe behavior needs to be modified.

Consider extracting the swipe configuration to a shared constant:

+const swipeConfig = {
+    timeframe: 300,
+    minSwipeDistance: 60,
+    touchAction: 'pan-y'
+};

// Then use it in both places:
use:swipe={swipeConfig}
src/lib/components/ScriptureViewSofria.svelte (2)

2336-2365: Enhance list milestone handling for better maintainability

The list handling logic is functional but has a few areas for improvement:

  1. The padding calculation 2 * match[1] - 1 + 'rem' could result in excessive indentation for deeply nested lists
  2. Consider extracting magic numbers into constants for better maintainability

Consider these improvements:

+                           const LIST_INDENT_BASE = 1.5; // rem
+                           const LIST_INDENT_MULTIPLIER = 1.5; // rem per level
                            let match;
                            if ((match = element.subType.match(/^usfm:zon(\d+)$/))) {
                                workspace['level' + match[1] + 'ListNum'] =
                                    element.atts['start'][0];
                            } else if ((match = element.subType.match(/^usfm:zoli(\d+)$/))) {
                                workspace.paragraphDiv.classList.add('list-item');
                                workspace.paragraphDiv.classList.add('list-decimal');
                                workspace.paragraphDiv.classList.add('list-inside');
                                workspace.paragraphDiv.style.counterSet =
                                    'list-item ' + workspace['level' + match[1] + 'ListNum'];
                                workspace['level' + match[1] + 'ListNum']++;

                                workspace.paragraphDiv.style.paddingInlineStart =
-                                   2 * match[1] - 1 + 'rem';
+                                   LIST_INDENT_BASE + (match[1] - 1) * LIST_INDENT_MULTIPLIER + 'rem';
                            } else if ((match = element.subType.match(/^usfm:zuli(\d+)$/))) {
                                workspace.paragraphDiv.classList.add('list-item');
                                workspace.paragraphDiv.classList.add('list-inside');

                                workspace.paragraphDiv.style.paddingInlineStart =
-                                   2 * match[1] - 1 + 'rem';
+                                   LIST_INDENT_BASE + (match[1] - 1) * LIST_INDENT_MULTIPLIER + 'rem';
                                if (match[1] === 2) {
                                    workspace.paragraphDiv.classList.add(
                                        '[list-style-type:circle]'
                                    );
                                } else if (match[1] >= 3) {
                                    workspace.paragraphDiv.classList.add(
                                        '[list-style-type:square]'
                                    );
                                }
                            }

2402-2403: Remove debug console.log statements

Debug logging should be conditional or removed for production code.

                                case 'usfm:zcstyle': {
                                    const style = element.atts['id'][0];
-                                   console.log('Element: ');
-                                   console.log(element);
+                                   if (scriptureLogs?.milestone) {
+                                       console.log('Character style milestone:', element);
+                                   }
                                    workspace.encloseInSpanTag = document.createElement('span');
                                    workspace.encloseInSpanTag.classList.add(style);
                                    break;
                                }
convert/convertBooks.ts (1)

78-150: Well-implemented ordered list transformation

The ordered list transformation correctly handles nested lists, start numbers, and inline formatting. The recursive approach is appropriate for handling arbitrary nesting.

Consider adding JSDoc comments to document the complex regex patterns and transformation logic for future maintainability.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8002e5f and 373c260.

📒 Files selected for processing (5)
  • config/index.d.ts (1 hunks)
  • convert/convertBooks.ts (6 hunks)
  • convert/convertConfig.ts (3 hunks)
  • src/lib/components/ScriptureViewSofria.svelte (3 hunks)
  • src/routes/text/+page.svelte (3 hunks)
🧰 Additional context used
🧠 Learnings (6)
📓 Common learnings
Learnt from: chrisvire
PR: sillsdev/appbuilder-pwa#808
File: convert/convertMarkdown.ts:216-222
Timestamp: 2025-05-07T13:16:04.481Z
Learning: The USFM attribute encoding workflow in appbuilder-pwa has two distinct steps: (1) Markdown to USFM conversion in convertMarkdown.ts should NOT encode attributes like title or href, (2) The encoding of these attributes should happen only in the convertBooks.ts process via the encodeJmpLinks function to prevent double-encoding.
Learnt from: chrisvire
PR: sillsdev/appbuilder-pwa#808
File: convert/convertMarkdown.ts:216-222
Timestamp: 2025-05-07T13:16:04.481Z
Learning: All USFM attribute encoding (href, title, etc.) should happen in the convertBooks process via the encodeJmpLinks function, not during the markdown to USFM conversion in convertMarkdown.ts.
📚 Learning: in svelte 5, the `page` store is imported from `$app/state` instead of `$app/stores`. this is part o...
Learnt from: chrisvire
PR: sillsdev/appbuilder-pwa#845
File: src/routes/text/+page.svelte:3-3
Timestamp: 2025-07-04T18:51:27.821Z
Learning: In Svelte 5, the `page` store is imported from `$app/state` instead of `$app/stores`. This is part of the migration from Svelte 4 to Svelte 5 where `$app/stores` is being deprecated in favor of `$app/state`.

Applied to files:

  • src/routes/text/+page.svelte
📚 Learning: in sveltekit 2.12 and svelte 5, `$app/stores` was deprecated in favor of `$app/state`. when migratin...
Learnt from: chrisvire
PR: sillsdev/appbuilder-pwa#845
File: src/routes/text/+page.svelte:3-3
Timestamp: 2025-07-04T18:51:27.821Z
Learning: In SvelteKit 2.12 and Svelte 5, `$app/stores` was deprecated in favor of `$app/state`. When migrating from Svelte 4 to Svelte 5, imports should be changed from `import { page } from '$app/stores';` to `import { page } from '$app/state';`. This change aligns with Svelte 5's reactivity model and provides more granular control over state updates.

Applied to files:

  • src/routes/text/+page.svelte
📚 Learning: in svelte 5, `$state` creates a reactive rune, not a store. stores are created with functions like `...
Learnt from: chrisvire
PR: sillsdev/appbuilder-pwa#845
File: src/routes/text/+page.svelte:201-209
Timestamp: 2025-07-04T18:53:08.369Z
Learning: In Svelte 5, `$state` creates a reactive rune, not a store. Stores are created with functions like `writable()`, `readable()`, and `derived()`. When using `bind:this` to bind DOM elements, use plain variables rather than reactive runes to ensure direct DOM property access works correctly.

Applied to files:

  • src/routes/text/+page.svelte
📚 Learning: the usfm attribute encoding workflow in appbuilder-pwa has two distinct steps: (1) markdown to usfm ...
Learnt from: chrisvire
PR: sillsdev/appbuilder-pwa#808
File: convert/convertMarkdown.ts:216-222
Timestamp: 2025-05-07T13:16:04.481Z
Learning: The USFM attribute encoding workflow in appbuilder-pwa has two distinct steps: (1) Markdown to USFM conversion in convertMarkdown.ts should NOT encode attributes like title or href, (2) The encoding of these attributes should happen only in the convertBooks.ts process via the encodeJmpLinks function to prevent double-encoding.

Applied to files:

  • src/lib/components/ScriptureViewSofria.svelte
  • convert/convertBooks.ts
📚 Learning: all usfm attribute encoding (href, title, etc.) should happen in the convertbooks process via the en...
Learnt from: chrisvire
PR: sillsdev/appbuilder-pwa#808
File: convert/convertMarkdown.ts:216-222
Timestamp: 2025-05-07T13:16:04.481Z
Learning: All USFM attribute encoding (href, title, etc.) should happen in the convertBooks process via the encodeJmpLinks function, not during the markdown to USFM conversion in convertMarkdown.ts.

Applied to files:

  • src/lib/components/ScriptureViewSofria.svelte
  • convert/convertBooks.ts
🔇 Additional comments (10)
config/index.d.ts (1)

75-79: LGTM! Well-structured type definition for page illustrations.

The addition of the optional pageIllustrations property to BookConfig is well-designed. The structure with num and filename fields provides a clear mapping between chapter numbers and illustration files, which aligns perfectly with the story book enhancement objectives.

convert/convertConfig.ts (2)

521-521: LGTM! Proper initialization of pageIllustrations array.

The initialization follows the established pattern in the codebase for collecting page-specific data.


728-729: LGTM! Proper inclusion of pageIllustrations in book object.

The pageIllustrations property is correctly added to the book object, maintaining consistency with the TypeScript definition.

src/routes/text/+page.svelte (2)

105-109: LGTM! Clean derivation of book type.

The bookType derived value correctly extracts the book type from the configuration, following Svelte's reactivity patterns.


497-560: LGTM! Well-structured layout changes.

The restructuring to use flexbox with a conditional illustration above the scrollable content is well-implemented. The grow class ensures the scrollable area takes up remaining space, and existing functionality like borders, scroll position saving, and navigation buttons are preserved.

convert/convertBooks.ts (5)

43-45: LGTM!

The paragraph style tag transformation correctly converts \p_style tags to milestone markers with appropriate id attributes.


52-56: LGTM!

Clean orchestration function that properly delegates list transformations.


61-76: LGTM!

The unordered list transformation correctly handles nested lists with dynamic level processing and appropriate milestone markers.


898-913: LGTM! Story book handling is well-implemented

The special handling for story books correctly treats the first file as metadata and subsequent files as chapter pages, with appropriate chapter marker insertion.


386-388: Filter functions properly integrated

The new transformation functions are correctly added to the USFM filter pipeline in the appropriate order.

Comment thread convert/convertBooks.ts
Comment thread convert/convertBooks.ts Outdated
Comment on lines +459 to +468
if (workspace.encloseInSpanTag) {
const textNode = workspace.phraseDiv.firstChild;
workspace.encloseInSpanTag.appendChild(textNode);
workspace.phraseDiv.innerHTML = workspace.encloseInSpanTag;
workspace.phraseDiv.replaceChild(
workspace.encloseInSpanTag,
workspace.phraseDiv.firstChild
);
workspace.encloseInSpanTag = undefined;
} //Enclose the text in a previously-created span tag
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Potential null reference issue in span wrapping logic

The code attempts to wrap text in a span tag but has several issues:

  1. Line 462 incorrectly assigns the span element as innerHTML instead of appending it as a child element
  2. The subsequent replaceChild call (lines 463-466) attempts to replace the first child that was just set as innerHTML
  3. No null checks for workspace.phraseDiv.firstChild before manipulation

Apply this fix to properly wrap the text node:

                if (workspace.encloseInSpanTag) {
                    const textNode = workspace.phraseDiv.firstChild;
-                   workspace.encloseInSpanTag.appendChild(textNode);
-                   workspace.phraseDiv.innerHTML = workspace.encloseInSpanTag;
-                   workspace.phraseDiv.replaceChild(
-                       workspace.encloseInSpanTag,
-                       workspace.phraseDiv.firstChild
-                   );
+                   if (textNode) {
+                       workspace.encloseInSpanTag.appendChild(textNode);
+                       workspace.phraseDiv.replaceChild(
+                           workspace.encloseInSpanTag,
+                           textNode
+                       );
+                   }
                    workspace.encloseInSpanTag = undefined;
                } //Enclose the text in a previously-created span tag
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (workspace.encloseInSpanTag) {
const textNode = workspace.phraseDiv.firstChild;
workspace.encloseInSpanTag.appendChild(textNode);
workspace.phraseDiv.innerHTML = workspace.encloseInSpanTag;
workspace.phraseDiv.replaceChild(
workspace.encloseInSpanTag,
workspace.phraseDiv.firstChild
);
workspace.encloseInSpanTag = undefined;
} //Enclose the text in a previously-created span tag
if (workspace.encloseInSpanTag) {
const textNode = workspace.phraseDiv.firstChild;
if (textNode) {
workspace.encloseInSpanTag.appendChild(textNode);
workspace.phraseDiv.replaceChild(
workspace.encloseInSpanTag,
textNode
);
}
workspace.encloseInSpanTag = undefined;
} //Enclose the text in a previously-created span tag
🤖 Prompt for AI Agents
In src/lib/components/ScriptureViewSofria.svelte lines 459 to 468, the code
incorrectly assigns a span element to innerHTML and attempts to replace a child
node without null checks, risking runtime errors. To fix this, remove the
innerHTML assignment, ensure workspace.phraseDiv.firstChild is not null before
manipulation, and properly append or replace child nodes by directly
manipulating DOM elements rather than using innerHTML.

Comment on lines +483 to +496
<div class="flex flex-col overflow-y-auto">
{#if bookType === 'story'}
<!-- svelte-ignore a11y_missing_attribute -->
<img
src={getCurrentIllustrationFile()}
class="w-screen object-cover"
use:swipe={{
timeframe: 300,
minSwipeDistance: 60,
touchAction: 'pan-y'
}}
onswipe={doSwipe}
/>
{/if}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Verify image rendering with undefined src.

The image element should handle cases where getCurrentIllustrationFile() returns null or undefined to prevent broken image displays.

Consider adding a conditional check:

{#if bookType === 'story'}
+   {@const illustrationFile = getCurrentIllustrationFile()}
+   {#if illustrationFile}
        <!-- svelte-ignore a11y_missing_attribute -->
        <img
-           src={getCurrentIllustrationFile()}
+           src={illustrationFile}
            class="w-screen object-cover"
            use:swipe={{
                timeframe: 300,
                minSwipeDistance: 60,
                touchAction: 'pan-y'
            }}
            onswipe={doSwipe}
        />
+   {/if}
{/if}

🏁 Script executed:

#!/bin/bash
# Search for similar image handling patterns in the codebase
ast-grep --pattern $'<img
  $$$
  src={$_}
  $$$
/>'

Length of output: 53


🏁 Script executed:

#!/bin/bash
# Locate the definition and all usages of getCurrentIllustrationFile to verify its return values
rg -n "getCurrentIllustrationFile" -C3 .

Length of output: 1096


🏁 Script executed:

#!/bin/bash
# Display the full body of getCurrentIllustrationFile to determine its return behavior
sed -n '369,400p' src/routes/text/+page.svelte

Length of output: 1493


Guard image rendering when illustrationFile is undefined

To avoid broken images and potential runtime errors, assign the result of getCurrentIllustrationFile() to a local variable and only render the <img> when it’s truthy.

• File: src/routes/text/+page.svelte (around line 483)
• Steps:

  1. Extract illustrationFile: {@const illustrationFile = getCurrentIllustrationFile()}
  2. Wrap the <img> in {#if illustrationFile}{/if}

Suggested patch:

{#if bookType === 'story'}
+   {@const illustrationFile = getCurrentIllustrationFile()}
+   {#if illustrationFile}
        <!-- svelte-ignore a11y_missing_attribute -->
        <img
-           src={getCurrentIllustrationFile()}
+           src={illustrationFile}
            class="w-screen object-cover"
            use:swipe={{
                timeframe: 300,
                minSwipeDistance: 60,
                touchAction: 'pan-y'
            }}
            onswipe={doSwipe}
        />
+   {/if}
{/if}

You may also harden getCurrentIllustrationFile() against missing data:

 function getCurrentIllustrationFile() {
-    let illustrations = config?.bookCollections
+    const illustrations = config?.bookCollections
         .find(x => x.id === $refs.collection)
         .books.find(x => x.id === $refs.book)
         ?.pageIllustrations;
+   if (!illustrations) return;
     for (let i = 0; i < illustrations.length; i++) {
       if (illustrations[i].num === Number($refs.chapter)) {
         return base + '/illustrations/' + illustrations[i].filename;
       }
     }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div class="flex flex-col overflow-y-auto">
{#if bookType === 'story'}
<!-- svelte-ignore a11y_missing_attribute -->
<img
src={getCurrentIllustrationFile()}
class="w-screen object-cover"
use:swipe={{
timeframe: 300,
minSwipeDistance: 60,
touchAction: 'pan-y'
}}
onswipe={doSwipe}
/>
{/if}
<div class="flex flex-col overflow-y-auto">
{#if bookType === 'story'}
{@const illustrationFile = getCurrentIllustrationFile()}
{#if illustrationFile}
<!-- svelte-ignore a11y_missing_attribute -->
<img
src={illustrationFile}
class="w-screen object-cover"
use:swipe={{
timeframe: 300,
minSwipeDistance: 60,
touchAction: 'pan-y'
}}
onswipe={doSwipe}
/>
{/if}
{/if}
</div>
🤖 Prompt for AI Agents
In src/routes/text/+page.svelte around lines 483 to 496, the image rendering
uses getCurrentIllustrationFile() directly, which can cause broken images if it
returns undefined. To fix this, assign the result of
getCurrentIllustrationFile() to a local variable named illustrationFile using
{@const illustrationFile = getCurrentIllustrationFile()} before the conditional
block, then wrap the <img> element inside an {#if illustrationFile}...{/if}
block so the image only renders when illustrationFile is truthy.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (4)
convert/tests/sab/convertConfigSAB.test.ts (1)

112-116: Consider also asserting the expected emptiness for story books

You now skip the assertions for section, testament, and abbreviation when bkk.type === 'story'.
For extra safety (and clearer documentation of intent) add a short else branch that explicitly checks these fields are undefined/empty for story books.
This prevents silent drift if the parser later starts populating them.

package.json (1)

23-26: Document the combined test runner

"test": "ts-node convert/runTests.ts" now does both e2e and unit tests.
Consider a short comment in the script section (or README) so contributors realise that Playwright runs first and can use --grep etc. through the wrapper.

convert/tests/sab/storybook.test.ts (1)

4-6: Tokenisation hides whitespace-sensitive regressions

tokensOf splits on \\s+, so ordering is preserved but all whitespace is discarded.
If the transformer later introduces unintended line-breaks/indentation you will not detect it.
Consider comparing the raw strings after normalising only leading indentation, or use expect(result.trim()).toBe(expected.trim()).

playwright/storybookTest.e2e.ts (1)

32-36: Prefer toHaveCSS over manual evaluate

Instead of

expect(
    container.evaluate((el) => el.style.getPropertyValue('padding-inline-start'))
).resolves.toBe('1rem');

simply use

await expect(container.first()).toHaveCSS('padding-inline-start', '1rem');

This keeps assertions consistent and avoids mixing locator evaluation with test-runner expectations.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a45467e and a25055d.

⛔ Files ignored due to path filters (2)
  • package-lock.json is excluded by !**/package-lock.json
  • test_data/projects/sab/web_gospels.zip is excluded by !**/*.zip
📒 Files selected for processing (7)
  • .gitignore (1 hunks)
  • convert/runTests.ts (1 hunks)
  • convert/tests/sab/convertConfigSAB.test.ts (1 hunks)
  • convert/tests/sab/storybook.test.ts (1 hunks)
  • package.json (1 hunks)
  • playwright.config.ts (1 hunks)
  • playwright/storybookTest.e2e.ts (1 hunks)
✅ Files skipped from review due to trivial changes (2)
  • .gitignore
  • playwright.config.ts
🧰 Additional context used
🧠 Learnings (2)
📓 Common learnings
Learnt from: chrisvire
PR: sillsdev/appbuilder-pwa#808
File: convert/convertMarkdown.ts:216-222
Timestamp: 2025-05-07T13:16:04.481Z
Learning: The USFM attribute encoding workflow in appbuilder-pwa has two distinct steps: (1) Markdown to USFM conversion in convertMarkdown.ts should NOT encode attributes like title or href, (2) The encoding of these attributes should happen only in the convertBooks.ts process via the encodeJmpLinks function to prevent double-encoding.
Learnt from: chrisvire
PR: sillsdev/appbuilder-pwa#808
File: convert/convertMarkdown.ts:216-222
Timestamp: 2025-05-07T13:16:04.481Z
Learning: All USFM attribute encoding (href, title, etc.) should happen in the convertBooks process via the encodeJmpLinks function, not during the markdown to USFM conversion in convertMarkdown.ts.
📚 Learning: 2025-05-07T13:16:04.481Z
Learnt from: chrisvire
PR: sillsdev/appbuilder-pwa#808
File: convert/convertMarkdown.ts:216-222
Timestamp: 2025-05-07T13:16:04.481Z
Learning: The USFM attribute encoding workflow in appbuilder-pwa has two distinct steps: (1) Markdown to USFM conversion in convertMarkdown.ts should NOT encode attributes like title or href, (2) The encoding of these attributes should happen only in the convertBooks.ts process via the encodeJmpLinks function to prevent double-encoding.

Applied to files:

  • convert/tests/sab/convertConfigSAB.test.ts
🧬 Code Graph Analysis (1)
convert/tests/sab/storybook.test.ts (1)
convert/convertBooks.ts (1)
  • transformLists (52-57)

Comment thread convert/runTests.ts Outdated
Comment on lines +3 to +17
let args = '';
for (let i = 2; i < process.argv.length; i++) {
args += process.argv[i];
}
console.log('playwright test ' + args);
spawn('playwright test ' + args, {
shell: true,
stdio: 'inherit'
}).on('close', (code) => {
console.log('vitest ' + args);
spawn('vitest ' + args, {
shell: true,
stdio: 'inherit'
});
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Fix CLI-arg handling and propagate failures

Current runner concatenates args without spaces (--run--reporter) and always runs Vitest even if Playwright fails, masking CI errors.

-let args = '';
-for (let i = 2; i < process.argv.length; i++) {
-    args += process.argv[i];
-}
+const args = process.argv.slice(2).join(' ');
-spawn('playwright test ' + args, { … }).on('close', (code) => {
-    console.log('vitest ' + args);
-    spawn('vitest ' + args, { … });
-});
+const pw = spawn(`playwright test ${args}`, { shell: true, stdio: 'inherit' });
+pw.on('close', (code) => {
+    if (code !== 0) process.exit(code);
+    console.log(`vitest ${args}`);
+    const vt = spawn(`vitest ${args}`, { shell: true, stdio: 'inherit' });
+    vt.on('close', (vc) => process.exit(vc));
+});

This preserves spacing, halts on the first failing suite, and forwards the exit code to the parent process.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let args = '';
for (let i = 2; i < process.argv.length; i++) {
args += process.argv[i];
}
console.log('playwright test ' + args);
spawn('playwright test ' + args, {
shell: true,
stdio: 'inherit'
}).on('close', (code) => {
console.log('vitest ' + args);
spawn('vitest ' + args, {
shell: true,
stdio: 'inherit'
});
});
// replace the old args loop with a single join
const args = process.argv.slice(2).join(' ');
console.log('playwright test ' + args);
const pw = spawn(`playwright test ${args}`, {
shell: true,
stdio: 'inherit'
});
pw.on('close', (code) => {
if (code !== 0) process.exit(code);
console.log(`vitest ${args}`);
const vt = spawn(`vitest ${args}`, {
shell: true,
stdio: 'inherit'
});
vt.on('close', (vc) => process.exit(vc));
});
🤖 Prompt for AI Agents
In convert/runTests.ts around lines 3 to 17, the code concatenates CLI arguments
without spaces, causing merged arguments, and it runs Vitest regardless of
Playwright's success, hiding failures. Fix this by joining process.argv elements
with spaces to preserve argument separation, modify the Playwright spawn to
check its exit code, and only run Vitest if Playwright succeeds. Also, ensure
the script exits with the same code as the first failing process to properly
propagate errors.

@TheNonPirate TheNonPirate force-pushed the feature/convertStorybook branch from 7763c09 to b84d2fe Compare May 18, 2026 18:23
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (1)
convert/runTests.ts (1)

8-13: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Still not propagating failing test-suite exit codes.

Line 8 ignores Playwright's exit status, and the Vitest child is never awaited, so this wrapper can still exit successfully after a failed suite. This was already raised earlier and is still unresolved.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@convert/runTests.ts` around lines 8 - 13, The current 'close' handler ignores
the Playwright exit code and never waits for the spawned Vitest process; change
the handler so it captures the ChildProcess returned by spawn (e.g., const
vitest = spawn('vitest ' + args, {...})), attach a 'close' (or 'exit') listener
to that child, and when the vitest child closes call process.exit() with the
proper non-zero code (prefer vitest's exit code if non-zero, otherwise fall back
to the Playwright 'code' from the outer 'close' handler) so failing test-suite
exit codes are propagated and the wrapper exits with the correct status.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@convert/convertConfig.ts`:
- Around line 592-603: The code silently creates pageIllustrations entries with
NaN for num; instead, validate the page 'num' attribute on page
(page.attributes.getNamedItem('num')) before pushing: parse it to an integer,
check !isNaN and that it is a positive integer (or meets expected constraints),
and if invalid or missing throw an Error with a clear message including
identifying context (e.g., page.tagName/id or surrounding book id) so conversion
fails fast; update the block that constructs pageIllustrations (the code
referencing page, imageFileTag, book and page.attributes.getNamedItem('num')) to
perform this validation and throw rather than pushing entries with NaN.

In `@convert/runTests.ts`:
- Around line 3-7: The current code builds a single shell string using args and
calls spawn with shell:true which breaks quoted/space-containing args; change
the spawn call to forward arguments as an array by using process.argv.slice(2)
directly (e.g. spawn('playwright', ['test', ...process.argv.slice(2)], { shell:
false, stdio: 'inherit' })) so argument boundaries created by
process.argv.slice(2) are preserved; update any references to the earlier args
variable accordingly and remove the shell:true usage.

In `@src/routes/text/`+page.svelte:
- Around line 399-410: In getCurrentIllustrationFile(), the code assumes
illustrationURLs['/src/gen-assets/illustrations/'+illustrations[i].filename]
exists and immediately reads .default, which throws if the key is missing; guard
the glob lookup by first obtaining the entry (e.g., const entry =
illustrationURLs['/src/gen-assets/illustrations/'+illustrations[i].filename])
and only return entry.default when entry is defined, otherwise return a safe
fallback (undefined or a placeholder) or continue searching; update
getCurrentIllustrationFile to perform this existence check before accessing
.default so missing keys don't cause runtime errors.

---

Duplicate comments:
In `@convert/runTests.ts`:
- Around line 8-13: The current 'close' handler ignores the Playwright exit code
and never waits for the spawned Vitest process; change the handler so it
captures the ChildProcess returned by spawn (e.g., const vitest = spawn('vitest
' + args, {...})), attach a 'close' (or 'exit') listener to that child, and when
the vitest child closes call process.exit() with the proper non-zero code
(prefer vitest's exit code if non-zero, otherwise fall back to the Playwright
'code' from the outer 'close' handler) so failing test-suite exit codes are
propagated and the wrapper exits with the correct status.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5f1d92fa-f4bc-4684-859b-a03e73faa06f

📥 Commits

Reviewing files that changed from the base of the PR and between 7763c09 and b84d2fe.

⛔ Files ignored due to path filters (2)
  • package-lock.json is excluded by !**/package-lock.json
  • test_data/projects/sab/web_gospels.zip is excluded by !**/*.zip
📒 Files selected for processing (12)
  • .gitignore
  • config/index.d.ts
  • convert/convertBooks.ts
  • convert/convertConfig.ts
  • convert/runTests.ts
  • convert/tests/sab/convertConfigSAB.test.ts
  • convert/tests/sab/storybook.test.ts
  • package.json
  • playwright.config.ts
  • playwright/storybookTest.e2e.ts
  • src/lib/components/ScriptureViewSofria.svelte
  • src/routes/text/+page.svelte
🚧 Files skipped from review as they are similar to previous changes (8)
  • playwright.config.ts
  • convert/tests/sab/convertConfigSAB.test.ts
  • config/index.d.ts
  • package.json
  • playwright/storybookTest.e2e.ts
  • convert/tests/sab/storybook.test.ts
  • src/lib/components/ScriptureViewSofria.svelte
  • convert/convertBooks.ts

Comment thread convert/convertConfig.ts
Comment on lines +592 to +603
const imageFileTag = page.getElementsByTagName('image-filename')[0];
if (imageFileTag) {
pageIllustrations.push({
num: Number(page.attributes.getNamedItem('num')?.value),
filename: book.getElementsByTagName('images')[0]
? tag.id +
'-' +
book.attributes.getNamedItem('id')!.value +
'-' +
imageFileTag.innerHTML
: imageFileTag.innerHTML
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fail fast when page num is missing or invalid.

At Line 595, Number(page.attributes.getNamedItem('num')?.value) can silently yield NaN, which creates unusable pageIllustrations entries instead of surfacing bad XML immediately.

Suggested fix
                 const imageFileTag = page.getElementsByTagName('image-filename')[0];
                 if (imageFileTag) {
+                    const pageNum = parseInt(page.attributes.getNamedItem('num')!.value, 10);
+                    if (Number.isNaN(pageNum)) {
+                        throw new Error(`Invalid page num for book ${book.id}`);
+                    }
                     pageIllustrations.push({
-                        num: Number(page.attributes.getNamedItem('num')?.value),
+                        num: pageNum,
                         filename: book.getElementsByTagName('images')[0]
                             ? tag.id +
                               '-' +

Based on learnings: For conversion modules under convert/, adopt fail-fast behavior and surface invalid/missing XML attributes rather than silently recovering.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@convert/convertConfig.ts` around lines 592 - 603, The code silently creates
pageIllustrations entries with NaN for num; instead, validate the page 'num'
attribute on page (page.attributes.getNamedItem('num')) before pushing: parse it
to an integer, check !isNaN and that it is a positive integer (or meets expected
constraints), and if invalid or missing throw an Error with a clear message
including identifying context (e.g., page.tagName/id or surrounding book id) so
conversion fails fast; update the block that constructs pageIllustrations (the
code referencing page, imageFileTag, book and
page.attributes.getNamedItem('num')) to perform this validation and throw rather
than pushing entries with NaN.

Comment thread convert/runTests.ts
Comment on lines +3 to +7
const args = process.argv.slice(2).join(' ');
console.log('playwright test ' + args);
spawn('playwright test ' + args, {
shell: true,
stdio: 'inherit'
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

# First, let's verify the file exists and check its content
cat -n convert/runTests.ts

Repository: sillsdev/appbuilder-pwa

Length of output: 519


🏁 Script executed:

# Let's check the Node.js spawn documentation and common patterns
# First, let's see if there are any other spawn calls in the repository that use best practices
rg "spawn\(" --type ts -A 3 | head -50

Repository: sillsdev/appbuilder-pwa

Length of output: 414


🏁 Script executed:

# Let's verify Node.js spawn API behavior with a quick test
# Check if there's any documentation or test files that show the correct pattern
fd -e md -e txt -e js -e ts | xargs grep -l "spawn.*args" | head -5

Repository: sillsdev/appbuilder-pwa

Length of output: 87


🏁 Script executed:

# Let me verify the exit code propagation issue mentioned in the internal scratchpad
# Check if the code properly handles exit codes from both spawn calls
grep -A 10 "spawn('vitest" convert/runTests.ts

Repository: sillsdev/appbuilder-pwa

Length of output: 155


Forward argv as an argument array instead of a shell string.

process.argv.slice(2) preserves argument boundaries correctly. Joining it into a single string and passing to spawn('playwright test ' + args, { shell: true, ... }) causes the shell to re-parse the string, breaking arguments with spaces or quotes. For example, -t "story book" will be split into separate tokens and fail to match reliably. Use spawn('playwright', ['test', ...process.argv.slice(2)], { shell: false }) instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@convert/runTests.ts` around lines 3 - 7, The current code builds a single
shell string using args and calls spawn with shell:true which breaks
quoted/space-containing args; change the spawn call to forward arguments as an
array by using process.argv.slice(2) directly (e.g. spawn('playwright', ['test',
...process.argv.slice(2)], { shell: false, stdio: 'inherit' })) so argument
boundaries created by process.argv.slice(2) are preserved; update any references
to the earlier args variable accordingly and remove the shell:true usage.

Comment on lines +399 to +410
function getCurrentIllustrationFile() {
let illustrations = config?.bookCollections
.find((x) => x.id === $refs.collection)
.books.find((x) => x.id === $refs.book)?.pageIllustrations;
for (let i = 0; i < illustrations.length; i++) {
if (illustrations[i].num === Number($refs.chapter)) {
return illustrationURLs[
'/src/gen-assets/illustrations/' + illustrations[i].filename
].default; //This works for both npm run dev and npm run build/preview, but it does have the hardcoded '/src/gen-assets/illustrations/'. Is there a better way to do this?
}
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard glob lookup before accessing .default.

At Lines 405-407, a missing key in illustrationURLs will throw (Cannot read properties of undefined (reading 'default')).

Suggested fix
 function getCurrentIllustrationFile() {
-    let illustrations = config?.bookCollections
-        .find((x) => x.id === $refs.collection)
-        .books.find((x) => x.id === $refs.book)?.pageIllustrations;
+    const illustrations =
+        config?.bookCollections
+            .find((x) => x.id === $refs.collection)
+            ?.books.find((x) => x.id === $refs.book)?.pageIllustrations ?? [];
     for (let i = 0; i < illustrations.length; i++) {
         if (illustrations[i].num === Number($refs.chapter)) {
-            return illustrationURLs[
-                '/src/gen-assets/illustrations/' + illustrations[i].filename
-            ].default; //This works for both npm run dev and npm run build/preview, but it does have the hardcoded '/src/gen-assets/illustrations/'. Is there a better way to do this?
+            const key = '/src/gen-assets/illustrations/' + illustrations[i].filename;
+            return illustrationURLs[key]?.default;
         }
     }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function getCurrentIllustrationFile() {
let illustrations = config?.bookCollections
.find((x) => x.id === $refs.collection)
.books.find((x) => x.id === $refs.book)?.pageIllustrations;
for (let i = 0; i < illustrations.length; i++) {
if (illustrations[i].num === Number($refs.chapter)) {
return illustrationURLs[
'/src/gen-assets/illustrations/' + illustrations[i].filename
].default; //This works for both npm run dev and npm run build/preview, but it does have the hardcoded '/src/gen-assets/illustrations/'. Is there a better way to do this?
}
}
}
function getCurrentIllustrationFile() {
const illustrations =
config?.bookCollections
.find((x) => x.id === $refs.collection)
?.books.find((x) => x.id === $refs.book)?.pageIllustrations ?? [];
for (let i = 0; i < illustrations.length; i++) {
if (illustrations[i].num === Number($refs.chapter)) {
const key = '/src/gen-assets/illustrations/' + illustrations[i].filename;
return illustrationURLs[key]?.default;
}
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/routes/text/`+page.svelte around lines 399 - 410, In
getCurrentIllustrationFile(), the code assumes
illustrationURLs['/src/gen-assets/illustrations/'+illustrations[i].filename]
exists and immediately reads .default, which throws if the key is missing; guard
the glob lookup by first obtaining the entry (e.g., const entry =
illustrationURLs['/src/gen-assets/illustrations/'+illustrations[i].filename])
and only return entry.default when entry is defined, otherwise return a safe
fallback (undefined or a placeholder) or continue searching; update
getCurrentIllustrationFile to perform this existence check before accessing
.default so missing keys don't cause runtime errors.

Copy link
Copy Markdown
Collaborator

@FyreByrd FyreByrd left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like a good start. Unfortunately, the pictures in the provided storybook can get quite large, which really messes with the text visibility.

Image Image

On this largest one, I can't even scroll on the area where the text is supposed to be.
Image

@TheNonPirate
Copy link
Copy Markdown
Contributor Author

According to #596, it is not supposed to display the text when in landscape mode; it is only supposed to display the picture as a full screen image.

@FyreByrd
Copy link
Copy Markdown
Collaborator

According to #596, it is not supposed to display the text when in landscape mode; it is only supposed to display the picture as a full screen image.

Noted. I'll need to revisit this with Chris or David. I don't think this is the behavior we would want on laptop.

@FyreByrd
Copy link
Copy Markdown
Collaborator

Per conversation with @davidmoore1

  1. In "portrait mode", the image should take up no more than half the screen's height.
  2. The "portrait mode" vs. "landscape mode" feature was designed for mobile phones.
  3. Users generally aren't going to be flipping a laptop on it's side.
  4. Users may be using the PWA on a phone, so we still want to support the expected phone behavior.
  5. Laptop users could have a button to switch between landscape and portrait mode. This could also be part of the double tap instead...

It is also worth noting that what you have right now is not actually a proper implementation of "landscape mode"

@TheNonPirate
Copy link
Copy Markdown
Contributor Author

What would a proper implementation of landscape mode be?

@FyreByrd
Copy link
Copy Markdown
Collaborator

What would a proper implementation of landscape mode be?

See the second image in #596 (comment)

I think what's missing here is that in landscape mode the picture is supposed to be deliberately fullscreen.

There is still some active discussion between me, Chris, and David about how we want to handle landscape vs. portrait mode for the PWA, so this can wait for a bit until we've reached an agreement in our discussion.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Basic storybook - No audio

3 participants