Skip to content

Conversion and display of content items for the new data from SAB #936

Merged
chrisvire merged 12 commits intosillsdev:mainfrom
eomerdws:content_items_update
Feb 23, 2026
Merged

Conversion and display of content items for the new data from SAB #936
chrisvire merged 12 commits intosillsdev:mainfrom
eomerdws:content_items_update

Conversation

@eomerdws
Copy link
Contributor

@eomerdws eomerdws commented Feb 5, 2026

  • Carousel added
  • Grid added
  • contents.js now has children content items for the grid and carousel items
  • Divided the content items into their own components (headings, singles, carousels, and grids)

Summary by CodeRabbit

  • New Features

    • New content components: Grid, Carousel (draggable), Heading, and Single views for nested parent/child rendering; page payload exposes nestedItems.
  • Improvements

    • Enhanced multilingual title/subtitle/audio/link handling, richer per-item metadata (features, layout, image/audio), more flexible layouts, per-item font sizing, safer click/link behavior, improved reference loading and XML decoding.
  • Tests

    • Extensive content parsing and nested-item test suite added.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 5, 2026

Important

Review skipped

Review was skipped due to path filters

⛔ Files ignored due to path filters (1)
  • test_data/projects/sab/web_gospels_nested.zip is excluded by !**/*.zip

CodeRabbit blocks several paths by default. You can override this behavior by explicitly including those paths in the path filters. For example, including **/dist/** will override the default block on the dist directory, by removing the pattern from both the lists.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Added nested-item support and richer parsing to the contents converter (multilingual fields, link/layout metadata, children arrays); exported parsing helpers; updated route/page to surface nestedItems; added four Svelte UI components (Carousel, Grid, Heading, Single) and extensive tests for content parsing.

Changes

Cohort / File(s) Summary
Content conversion / parsing
convert/convertContents.ts
Large refactor: add LangContainer, LinkMeta, many parse helpers (id, type, heading, title, subtitle, image, audio, link, features, layout), nested-item detection/children extraction, decode improvements, add ContentsData.nestedItems?, change ContentsTaskOutput to include data: ContentsData, and expose parsing helpers and convertContents(..., verbose).
Content UI components
src/lib/components/ContentCarousel.svelte, src/lib/components/ContentGrid.svelte, src/lib/components/ContentHeading.svelte, src/lib/components/ContentSingle.svelte
Add four components with props (item, imageFolder, onClick, checkImageSize, loadReferenceText, contentsFontSize, features). Implement image/path resolution, language-aware title/subtitle rendering, layout-driven templates, click/drag handling, async reference loading, and fallback handlers.
Route & page
src/routes/contents/[id]/+page.js, src/routes/contents/[id]/+page.svelte
load now returns nestedItems; page uses nestedItems to select component-driven rendering path, wires features and titles through, adds guards for clicks/navigation, and adjusts checkImageSize handling.
Tests
convert/tests/sab/convertContentSAB.test.ts
New comprehensive Vitest suite exercising parse helpers, nested-item detection, layout/feature parsing, image/audio/link parsing, and many mocked XML cases; includes setup/cleanup and conditional skipping when contents.xml absent.
Test data / projects
test_data/projects/index.json
Add project entry for nested test archive (web_gospels_nested.zip) and newline fix.
Misc / logging & exports
*
Added verbose logging/warnings and updated public exports/payload shape to include nestedItems and data wrapper across converter and route.

Sequence Diagram(s)

sequenceDiagram
    participant Converter as Converter (convertContents)
    participant Server as Route Load (load)
    participant Page as Page (+page.svelte)
    participant UI as UI Component (Carousel/Grid/Heading/Single)

    Note over Converter,Server: Converter produces ContentsData (items, nestedItems, children)
    Converter->>Server: persist ContentsData artifact
    Server->>Page: load() returns { menu, items, features, title, nestedItems }
    Page->>UI: select component and pass item (may include children)
    UI-->>Page: emit onClick or request reference text
    Page->>Server: fetch reference text (if needed)
    Server-->>Page: return reference text
    Page-->>UI: deliver reference text to component
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Suggested reviewers

  • chrisvire

Poem

🐰 I hopped through tags and folders bright,
Found titles nested out of sight.
Carousels spin and grids align,
Headings hop into a line.
Little rabbit cheers — contents right!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.79% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main focus: adding conversion and display support for content items with new SAB data, matching the core changes across parsing logic and new Svelte components.

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

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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.

Copy link
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: 9

🤖 Fix all issues with AI agents
In `@convert/convertContents.ts`:
- Around line 139-147: The parseItemHeading function incorrectly Boolean-casts
the heading attribute string and calls getNamedItem('heading') twice; update
parseItemHeading to fetch the attribute once (e.g., const attr =
tag.attributes.getNamedItem('heading')) and return attr ? attr.value === 'true'
: false so that values like "false" are handled correctly and the attribute
lookup is not repeated.
- Around line 245-279: In parseItemLink, the callback passed to
scriptureConfig.bookCollections?.some currently returns true when a matching
book with book.type is found but implicitly returns undefined otherwise; change
the callback in the .some call (the function taking collection => { ... }) to
explicitly return false in the branch when no match is found (i.e., after the if
(book && book.type) block) so every code path returns a boolean and satisfies
Biome/CI rules.

In `@src/lib/components/ContentCarousel.svelte`:
- Around line 38-42: The function hasIdUnderMouseItem currently uses an OR chain
that always returns true; update hasIdUnderMouseItem so after verifying item is
defined and item.hasAttribute('id') you check that the id is non-empty (use an
AND chain or a single truthy/non-empty test) so empty string IDs return false
and traversal can continue; locate the function hasIdUnderMouseItem and replace
the final return condition with a non-empty check (e.g., ensure item.id is not
undefined/null/empty or use a trimmed truthy check).

In `@src/lib/components/ContentHeading.svelte`:
- Around line 30-35: The function renderLastTextBox currently calls
exclude.find(...).length which will throw when find returns undefined; change
the logic to safely check membership (e.g., use exclude.includes(layout) or
check the find result !== undefined) and return the negation accordingly, and
ensure the function uses the correct input (the existing guard should handle
undefined but still use a safe membership test). Also replace the incorrect use
of features['layout'] with item.features['layout'] where the item’s layout is
intended (matching the other occurrences at lines near 79, 84, and 94), so
rendering uses the item's layout not the component prop.
- Around line 68-71: The template assumes item.subtitle always exists but
ContentItem.subtitle is optional, so guard accesses to avoid runtime errors;
update the conditional rendering and the interpolation in ContentHeading.svelte
to use optional chaining or explicit checks on item.subtitle (e.g., in the {`#if`
features['show-subtitles'] === true} block and the expression that reads
item.subtitle[$language] or item.subtitle.default) so the displayed value
becomes item.subtitle?.[$language] ?? item.subtitle?.default ?? '' (or
equivalent null checks) to safely fallback to an empty string when subtitle is
undefined.

In `@src/lib/components/ContentSingle.svelte`:
- Around line 41-45: The click handler is wired directly to onClick which
ignores any resolved fallback handler; update the element's onclick to call the
resolved onClickCallback (or use onClickCallback in place of onClick) so the
component honors fallback behavior — locate the JSX/Svelte element with
id={item.id} and change its onclick binding to invoke onClickCallback(event,
item) (or the appropriate resolved handler variable) instead of onClick so the
fallback/derived callback is used.
- Around line 71-74: The subtitle access can throw if item.subtitle is
undefined; update the rendering guarded by features['show-subtitles'] to safely
handle absent subtitles by using optional chaining on item.subtitle and falling
back to an empty string or other default; specifically modify the expression
that reads item.subtitle[$language] and item.subtitle.default to use
item.subtitle?.[$language] and item.subtitle?.default (or equivalent nullish
checks) so ContentSingle.svelte and the features['show-subtitles'] branch won't
cause a runtime error when subtitle is missing.
- Around line 31-36: renderLastTextBox currently calls exclude.find(...).length
which throws when find returns undefined; change the logic to safely test
membership (e.g., use exclude.includes(layout) or check the find result !==
undefined) and return the negated membership result; also fix the inconsistent
call site that passes features['layout'] — replace it with
item.features['layout'] to match the other layout checks (ensure all callers use
item.features['layout']).

In `@src/routes/contents/`[id]/+page.js:
- Around line 6-21: The console.warn message is inverted: inside the loop that
iterates menu.items and sets found = contents.items.find(...), when found is
falsy you should log that the item was missing rather than "Found items"; update
the warning in the block that uses variables menu, item, found, and
contents.items (the for loop near items.push(found)) to a clear message such as
"Missing item for menu item: ${item.id}" or similar, including the item.id (and
optional context like menu id) so the log accurately reflects the missing-item
condition.
🧹 Nitpick comments (3)
convert/convertContents.ts (3)

149-170: Gate or remove the debug console.log in parseItemTitle.

This will spam logs during conversions.

🧹 Suggested cleanup
-            console.log(
-                `title is nested: ${isTagInnerNestedItem(titleTag)} ${titleTag.innerHTML} ${titleTag.parentElement?.parentElement?.tagName}`
-            );

329-330: Remove the empty block left in convertContents.

🧹 Suggested cleanup
-    {
-    }

364-502: Gate the child-item debug log behind verbose.

The console.log for each child will be noisy in normal runs.

🧹 Suggested cleanup
-                        console.log(`Child Tag: ${itemChild.tagName}`);
+                        if (verbose >= 3) console.log(`Child Tag: ${itemChild.tagName}`);

Copy link
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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
convert/convertContents.ts (1)

104-111: ⚠️ Potential issue | 🟡 Minor

decodeFromXml only replaces the first occurrence of each entity.

The replace() method without the g flag only replaces the first match. If XML content contains multiple instances of the same entity (e.g., &&), subsequent occurrences won't be decoded.

🐛 Proposed fix using replaceAll or regex with g flag
 function decodeFromXml(input: string): string {
     return input
-        .replace('"', '"')
-        .replace(''', "'")
-        .replace('&lt;', '<')
-        .replace('&gt;', '>')
-        .replace('&amp;', '&');
+        .replaceAll('&quot;', '"')
+        .replaceAll('&apos;', "'")
+        .replaceAll('&lt;', '<')
+        .replaceAll('&gt;', '>')
+        .replaceAll('&amp;', '&');
 }
🤖 Fix all issues with AI agents
In `@convert/convertContents.ts`:
- Around line 114-120: The parseItemId function currently uses a non-null
assertion on tag.attributes.getNamedItem('id') which will throw if the element
has no id attribute; update parseItemId to safely read the id (e.g., use
tag.getAttribute('id') or check getNamedItem('id') !== null), handle the null
case by logging a warning and returning a safe numeric fallback (e.g., 0) or
NaN, then convert the string to Number only when present; ensure you reference
the parseItemId function and the tag variable when making the guard.
- Around line 283-288: The loop over featureTags assumes each <feature> has name
and value attributes and uses non-null assertions which can throw on malformed
XML; update the loop in convertContents.ts (the for...of over featureTags / the
featureTag handling) to safely retrieve attributes via
featureTag.attributes.getNamedItem('name') and getNamedItem('value'), check that
both are non-null before using them, and skip (or log a warning) when either is
missing instead of dereferencing; if present, use the attribute .value and
continue to call parseFeatureValue(value) and assign features[name].

In `@src/lib/components/ContentGrid.svelte`:
- Around line 47-48: The template iterates over item.children but
ContentItem.children is optional, which will throw if undefined; update the
{`#each` item.children as child} usage in ContentGrid.svelte to guard by using a
conditional/fallback such as iterating over item.children ?? [] or wrapping the
block with an {`#if` item.children} check so the loop only runs when children is
present, referencing the ContentItem type and the item.children property and
keeping the existing child variable and block contents unchanged.

In `@src/lib/components/ContentHeading.svelte`:
- Around line 30-35: The console.log in renderLastTextBox uses
exclude.find(...).length which can throw when find returns undefined; remove or
replace that log with a safe check using exclude.includes(layout) or
Boolean(exclude.find(x => x === layout)) to avoid accessing .length on
undefined, and ensure the function still returns !exclude.includes(layout) as
implemented; update the console.log (or delete it) inside renderLastTextBox to
use the safe expression.

In `@src/routes/contents/`[id]/+page.js:
- Around line 5-11: The code assumes menu exists after const menu =
contents.screens.find((x) => x.id === id); which can be undefined and cause a
crash when reading menu.items; add a guard immediately after that line to handle
the missing screen (e.g., if (!menu) return an appropriate error/404 or throw a
descriptive Error mentioning id) so subsequent code that iterates over
menu.items or uses menu does not run; update any callers/returns in this load
function to match the chosen error/response behavior.
🧹 Nitpick comments (4)
src/routes/contents/[id]/+page.js (1)

12-13: Minor: Combine declaration and assignment.

The let found; declaration followed by immediate assignment can be combined for clarity.

✏️ Suggested simplification
-        let found;
-        found = contents.items.find((x) => x.id === item.id);
+        const found = contents.items.find((x) => x.id === item.id);
src/lib/components/ContentGrid.svelte (1)

50-53: Consider converting numeric id to string for HTML attribute.

The child.id is a number (per ContentItem type), but HTML id attributes should be strings. While browsers typically coerce numbers to strings, being explicit prevents potential issues.

✏️ Suggested fix
             <div
                 class="contents-grid-item-block contents-grid-item-block-base contents-link-ref"
-                id={child.id}
+                id={`grid-item-${child.id}`}
                 onclick={(event) => onClickCallback(event, child)}
             >
convert/convertContents.ts (2)

327-328: Remove empty block.

Lines 327-328 contain an empty block { } that appears to be leftover code with no effect.

🧹 Proposed fix
     if (hasContentsDir) {
         createOutputDir(destDir);
     } else {
         deleteOutputDir(destDir);
     }
-    {
-    }
 
     const contentsFile = path.join(dataDir, 'contents.xml');

156-158: Debug console.log statements should be gated by verbose level.

Lines 156-158 and 437 contain unconditional console.log statements that will output during every conversion. Other debug logs in this file are properly gated with if (verbose >= 3). Consider applying the same pattern for consistency.

✏️ Proposed fix

For line 156-158:

-            console.log(
-                `title is nested: ${isTagInnerNestedItem(titleTag)} ${titleTag.innerHTML} ${titleTag.parentElement?.parentElement?.tagName}`
-            );
+            if (verbose >= 3) console.log(
+                `title is nested: ${isTagInnerNestedItem(titleTag)} ${titleTag.innerHTML} ${titleTag.parentElement?.parentElement?.tagName}`
+            );

For line 437:

-                        console.log(`Child Tag: ${itemChild.tagName}`);
+                        if (verbose >= 3) console.log(`Child Tag: ${itemChild.tagName}`);

Note: The verbose parameter would need to be passed to parseItemTitle for this to work.

Also applies to: 437-437

Copy link
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

🤖 Fix all issues with AI agents
In `@src/lib/components/ContentHeading.svelte`:
- Around line 15-29: The template never uses loadReferenceTextCallback (resolved
from loadReferenceText), so remove the unused prop/variable and its fallback:
delete loadReferenceTextCallback, the loadReferenceText ??
loadReferenceTextFallback resolution on Line 17, and the
loadReferenceTextFallback function; also remove any prop or exported variable
named loadReferenceText from the component API; alternatively, if this callback
is intended to be invoked, add calls to loadReferenceTextCallback in the
template/logic where reference text should be loaded (e.g., on mount or on a
relevant click), referencing the existing function name
loadReferenceTextCallback and its fallback loadReferenceTextFallback so the
behavior is wired correctly.
- Around line 78-96: The branch that handles non-horizontal image layouts calls
{`@render` img(item)} but never renders {`@render` text(item)}, and it also
erroneously calls renderLastTextBox(features['layout']) without the item prefix;
update that conditional to use renderLastTextBox(item.features['layout']) and
add {`@render` text(item)} immediately after {`@render` img(item)} so headings with
an image and non-horizontal layouts (e.g., "image-above-text-below") render both
the image and the text via img(item) and text(item).

In `@src/lib/components/ContentSingle.svelte`:
- Around line 15-29: The resolved callback loadReferenceTextCallback and its
fallback loadReferenceTextFallback in ContentSingle.svelte are unused dead code;
remove the unused binding by deleting the loadReferenceText resolution (the
const loadReferenceTextCallback = loadReferenceText ??
loadReferenceTextFallback) and the loadReferenceTextFallback function, or if the
template should use it, wire loadReferenceTextCallback into the appropriate
template/event where reference text is needed; ensure you also update any
exports/props that supply loadReferenceText so there are no orphaned props.

Copy link
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

🤖 Fix all issues with AI agents
In `@src/routes/contents/`[id]/+page.svelte:
- Around line 52-61: The checkImageSize function can return undefined and leaves
a debug console.warn; update checkImageSize (in +page.svelte) to remove the
console.warn and always return a valid style string (e.g., return '' or 'width:
auto' when no image-width and layout conditions match) so interpolations never
produce "undefined" in style attributes; ensure the returned strings include
proper CSS termination (semicolon) if other style fragments are concatenated.
🧹 Nitpick comments (3)
src/routes/contents/[id]/+page.svelte (3)

63-66: The 'undefined' string check hints at an upstream data issue.

Checking for the string literal 'undefined' is a reasonable defensive measure, but it typically indicates that somewhere upstream, undefined is being coerced to a string (e.g., via template interpolation or String()). Consider tracking down and fixing the root cause so this guard isn't needed.


107-109: getContentsMenu is an unused stub with only a console.log.

This function is never called in the template. If it's planned for future use, consider removing it until needed to avoid dead code. If it's work-in-progress, a // TODO comment would clarify intent.


3-3: Consider migrating $app/stores to $app/state.

This import predates this PR, but since the project uses Svelte 5, $app/stores is deprecated in favor of $app/state. This could be addressed in a follow-up. Based on learnings, in SvelteKit 2.12+ and Svelte 5, imports should be changed from import { page } from '$app/stores' to import { page } from '$app/state'.

Copy link
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

🤖 Fix all issues with AI agents
In `@convert/convertContents.ts`:
- Around line 173-185: parseItemSubtitle, parseItemLink, and parseItemFeatures
are currently using tag.getElementsByTagName(...) and thus grab descendant tags
from nested child items; update these functions to mirror parseItemTitle's
behavior by filtering results with isTagInnerNestedItem(tag, element,
upperLayer) when upperLayer is true (or alternatively iterate only over direct
child nodes) so only immediate child tags are parsed for the parent item;
reference the functions parseItemSubtitle, parseItemLink, parseItemFeatures,
parseItemTitle and the helper isTagInnerNestedItem and replace unfiltered
getElementsByTagName usage with the same filtered iteration pattern
parseItemTitle uses.
🧹 Nitpick comments (3)
src/routes/contents/[id]/+page.svelte (1)

266-307: No fallback for unrecognized itemType values in the nested rendering path.

If an item's itemType is something other than 'grid', 'carousel', 'heading', or 'single', it will be silently dropped. Consider adding an {:else} branch with a warning or a default rendering to help surface unexpected data during development.

Proposed addition
                     {:else if item.itemType === 'single'}
                         <ContentSingle
                             {item}
                             {imageFolder}
                             {onClick}
                             {checkImageSize}
                             {contentsFontSize}
                             features={$page.data.features}
                         />
+                    {:else}
+                        <!-- Unknown item type: silently skip or log -->
                     {/if}
convert/convertContents.ts (2)

538-540: Duplicate ContentsTaskOutput interface — consider consolidating.

This interface is declared at Line 58 (with taskName) and again here (with data). TypeScript will merge them, so it works, but having a single declaration is clearer for maintainability.

Proposed consolidation

Merge both declarations into one (e.g., at line 58):

 export interface ContentsTaskOutput extends TaskOutput {
     taskName: 'ConvertContents';
+    data: ContentsData;
 }

Then remove the second declaration at lines 538–540.


122-137: Unused parameters contentItemContainer and prevItemType.

Neither parameter is referenced inside the function body. Similarly, the variables prevItemType (Line 371) and currentContentContainer (Line 372) in the main loop are tracked but never consumed. If these are scaffolding for future logic, consider adding a // TODO comment; otherwise, remove them to reduce noise.

Proposed cleanup
-function parseItemType(
-    tag: Element | HTMLElement | undefined,
-    contentItemContainer: boolean,
-    prevItemType: string | undefined = undefined
-): string | undefined {
+function parseItemType(tag: Element | HTMLElement | undefined): string | undefined {
     if (tag === undefined) return undefined;
     let itemType: string | undefined = undefined;
 
     if (tag.hasAttribute('type')) {
         itemType = String(tag.attributes.getNamedItem('type')!.value);
     } else {
         itemType = 'single'; // the only item type that is not specified in contents.xml
     }
 
     return itemType;
 }

And at the call site (Line 389):

-const itemType = parseItemType(itemTag, contentItemContainer);
+const itemType = parseItemType(itemTag);

Remove prevItemType (Line 371), currentContentContainer (Line 372), and the update at Line 494 if no longer needed.

@eomerdws eomerdws changed the title Content items update Conversion and display of content items for the new data for content items Feb 9, 2026
@eomerdws eomerdws changed the title Conversion and display of content items for the new data for content items Conversion and display of content items for the new data from SAB Feb 9, 2026
@chrisvire chrisvire self-requested a review February 11, 2026 14:33
Copy link
Member

@chrisvire chrisvire left a comment

Choose a reason for hiding this comment

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

Great work!

If you want to add tests, you can do that in this PR or open another.

Copy link
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: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
convert/convertContents.ts (2)

104-111: ⚠️ Potential issue | 🔴 Critical

decodeFromXml only replaces the first occurrence of each XML entity.

String.prototype.replace() with a string pattern (not a regex with /g) replaces only the first match. If input contains multiple instances of the same entity (e.g., &amp;foo&amp;bar), subsequent occurrences are left encoded.

🐛 Proposed fix — use `replaceAll`
 function decodeFromXml(input: string): string {
     return input
-        .replace('&quot;', '"')
-        .replace('&apos;', "'")
-        .replace('&lt;', '<')
-        .replace('&gt;', '>')
-        .replace('&amp;', '&');
+        .replaceAll('&quot;', '"')
+        .replaceAll('&apos;', "'")
+        .replaceAll('&lt;', '<')
+        .replaceAll('&gt;', '>')
+        .replaceAll('&amp;', '&');
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convert/convertContents.ts` around lines 104 - 111, The decodeFromXml
function only replaces the first occurrence of each XML entity because it uses
String.prototype.replace with string patterns; update decodeFromXml to perform
global replacements (e.g., use replaceAll or replace with global regexes like
/&quot;/g) for each entity (&quot;, &apos;, &lt;, &gt;, &amp;) and ensure the
replacement order avoids double-decoding (replace &amp; last or handle &amp;
carefully) so all instances in the input are properly decoded.

357-365: ⚠️ Potential issue | 🔴 Critical

Add null check for featuresTag to prevent runtime error.

Line 360 calls featuresTag.getElementsByTagName('feature') without checking if featuresTag is undefined. If the XML structure doesn't include a top-level <features> element directly under <contents>, this will crash with "Cannot read property 'getElementsByTagName' of undefined". Add a guard like:

const featuresTag = document.getElementsByTagName('features')[0];
if (featuresTag) {
    const featureTags = featuresTag.getElementsByTagName('feature');
    // ... rest of the loop
}

Additionally, getElementsByTagName('feature') is recursive and will incorrectly capture any <feature> tags nested inside <contents-item> children if those items contain <features> blocks. Use featureTags.children or a more specific selector to only capture direct children.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convert/convertContents.ts` around lines 357 - 365, Guard against a missing
<features> element and restrict to direct child <feature> nodes: check that
featuresTag (from document.getElementsByTagName('features')[0]) is non-null
before calling methods on it, then iterate only over its direct child feature
elements (e.g., use featuresTag.children or a scoped selector like
featuresTag.querySelectorAll(':scope > feature')) to fill data.features using
parseFeatureValue; update the block that assigns data.features in
convertContents.ts (symbols: featuresTag, featureTags, parseFeatureValue,
data.features) accordingly.
🧹 Nitpick comments (7)
convert/convertContents.ts (3)

56-60: Duplicate ContentsTaskOutput interface declarations rely on declaration merging.

The interface is declared at line 58 (with taskName) and again at line 542 (with data). TypeScript merges them, but this is unusual within a single file and easy to miss. Consolidating into one declaration improves clarity.

♻️ Consolidate into a single declaration
 export interface ContentsTaskOutput extends TaskOutput {
     taskName: 'ConvertContents';
+    data: ContentsData;
 }

Then remove the second declaration at line 542–544.

Also applies to: 542-544

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convert/convertContents.ts` around lines 56 - 60, There are two separate
declarations of the ContentsTaskOutput interface in this file (one with taskName
and another with data), relying on TypeScript declaration merging; consolidate
them into a single interface declaration that includes both the taskName:
'ConvertContents' and the data: ContentsData properties (extending TaskOutput as
before), and remove the duplicate declaration so only one ContentsTaskOutput
exists (update any references if needed).

375-376: Unused variables: currentContentContainer is declared but never read; prevItemType is only used in a verbose log.

currentContentContainer (line 376) is assigned nowhere after declaration. prevItemType (line 375) is updated at line 498 but only read at line 395 inside a verbose log. If these are placeholders for future logic, consider adding a TODO or removing them to reduce confusion.

Also applies to: 498-498

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convert/convertContents.ts` around lines 375 - 376, Remove or document the
two unused locals to eliminate confusion: drop the unused
currentContentContainer variable altogether from convertContents.ts (it's never
assigned/used), and either remove prevItemType or restrict it to the verbose
logging path (or add a clear TODO comment) in the function where prevItemType is
set and compared—ensure you update the assignment site in the code that
currently updates prevItemType so it no longer assigns to a dead variable if you
remove it; if you keep prevItemType for future use, add a TODO comment
explaining its planned purpose and limit assignments to when verbose logging
actually reads it.

122-137: contentItemContainer parameter is accepted but never used.

parseItemType takes contentItemContainer as a parameter but doesn't reference it in the function body. The caller at line 393 computes it and passes it, but the function ignores it. Either remove the parameter or use it.

♻️ Proposed simplification
 export function parseItemType(
     tag: Element | HTMLElement | undefined,
-    contentItemContainer: boolean,
     prevItemType: string | undefined = undefined
 ): string | undefined {

And update the call site at line 393:

-    const itemType = parseItemType(itemTag, contentItemContainer);
+    const itemType = parseItemType(itemTag);

And the test calls accordingly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convert/convertContents.ts` around lines 122 - 137, The function
parseItemType accepts contentItemContainer but never uses it, so remove the
unused parameter from parseItemType's signature and body, update all call sites
that pass contentItemContainer to call parseItemType with the remaining
parameters (e.g., replace parseItemType(tag, contentItemContainer, prevItemType)
with parseItemType(tag, prevItemType)), and adjust any tests to match the new
signature; alternatively, if the parameter is actually required, modify
parseItemType to use contentItemContainer in its logic (refer to the
parseItemType function) instead of leaving it unused.
convert/tests/sab/convertContentSAB.test.ts (4)

41-48: Empty beforeAll/afterAll hooks and cleanup function are dead code.

These are stubs with only TODO comments. They add noise without behavior. Consider removing them until they're needed, or tracking the TODOs in an issue.

Also applies to: 112-114

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convert/tests/sab/convertContentSAB.test.ts` around lines 41 - 48, The test
file contains empty lifecycle hooks and a no-op cleanup function (beforeAll,
afterAll, and cleanup) that are dead code; either remove these unused stubs from
convert/tests/sab/convertContentSAB.test.ts or implement them if setup/teardown
is required: delete the beforeAll and afterAll hooks and the cleanup function if
not used, or replace the TODOs with concrete checks (e.g., verify required files
exist in beforeAll and perform test data deletion in afterAll) and ensure any
calls reference the implemented cleanup; update or remove the TODO comments
accordingly so the test file has no no-op scaffolding.

260-265: Mocked tests unnecessarily depend on contentsFileExists guard.

Tests at lines 260, 293 (and the mocked isTagInnerNestedItem/tagHasInnerNestedItems tests starting at 608, 621, 652, 694, 725) call setup() and early-return when contents.xml is absent, even though they use inline mocked XML and never touch testSetup data. This means these tests silently skip in environments where the file is missing, despite being self-contained.

Similarly, "handles undefined input" tests (lines 148, 177, 214, 325, 359, 411, 496, 511, 543, 570) don't need the file at all — they just pass undefined to parsing functions.

Consider removing the file-existence guard from self-contained tests so they always execute.

♻️ Example fix for the single-type mocked test
 test('convertContents: Get Item Type send a MOCKED test of a single ...', () => {
-    const testSetup: TestSetup = setup(undefined, true);
-    if (!testSetup.contentsFileExists) {
-        console.warn('Contents file does not exist, so test cannot be run');
-        return;
-    }
-
     const xml = `
         <contents>
             ...
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convert/tests/sab/convertContentSAB.test.ts` around lines 260 - 265, Remove
the unnecessary contentsFileExists guard and its early-return from
self-contained mocked tests that use inline XML or pass undefined; instead
either (a) avoid calling setup() when its only purpose is checking the file, or
(b) call setup(undefined, true) but do not conditional-return on
testSetup.contentsFileExists. Specifically update the tests referencing setup()
and contentsFileExists in the convertContents test and the mocked
isTagInnerNestedItem / tagHasInnerNestedItems tests so they always run; ensure
tests still create their inline XML fixtures and call convertContents,
isTagInnerNestedItem, or tagHasInnerNestedItems directly (or pass undefined)
without depending on file presence.

1-6: Unused import and type-only imports used as value imports.

  • Script from vm (line 3) is imported but never used.
  • LangContainer and LinkMeta (lines 9–10) are types and should use import type to avoid emitting runtime references.
♻️ Proposed fix
 import { existsSync, readFileSync } from 'fs';
 import path from 'path';
-import { Script } from 'vm';
 import type { ScriptureConfig } from '$config';
 import jsdom from 'jsdom';
 import { afterAll, beforeAll, expect, test } from 'vitest';
 import {
     isTagInnerNestedItem,
-    LangContainer,
-    LinkMeta,
     parseItemAudio,
     parseItemFeatures,
     parseItemId,
     parseItemImage,
     parseItemLayoutCollection,
     parseItemLayoutMode,
     parseItemLink,
     parseItemSubtitle,
     parseItemTitle,
     parseItemType,
     tagHasInnerNestedItems
 } from '../../convertContents';
+import type { LangContainer, LinkMeta } from '../../convertContents';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convert/tests/sab/convertContentSAB.test.ts` around lines 1 - 6, Remove the
unused runtime import Script from 'vm' (it's never referenced) and change the
value imports for the type-only symbols LangContainer and LinkMeta to type-only
imports (use "import type" for LangContainer and LinkMeta) so they don't emit
runtime references; locate the imports that currently bring in Script,
LangContainer, and LinkMeta and update them accordingly.

170-174: Prefer idiomatic Vitest matchers over boolean expressions inside expect.

Throughout the file, patterns like expect(id === 0).toBeFalsy() and expect(Object.keys(x).length === 0).toBeTruthy() are used. These produce unhelpful failure messages (e.g., "expected true, received false"). Using direct matchers yields clearer diagnostics.

♻️ Examples
-    expect(id === 0).toBeFalsy();
+    expect(id).not.toBe(0);

-    expect(id === 0).toBeTruthy();
+    expect(id).toBe(0);

-    expect(Object.keys(titleUpperFalse).length === 0).toBeTruthy();
+    expect(Object.keys(titleUpperFalse)).toHaveLength(0);

-    expect(testResult === tagHasInnerNestedItems(item)).toSatisfy((r) => r);
+    expect(tagHasInnerNestedItems(item)).toBe(testResult);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convert/tests/sab/convertContentSAB.test.ts` around lines 170 - 174, Replace
boolean-expression assertions with idiomatic Vitest matchers: for the loop using
parseItemId and id, change expect(id === 0).toBeFalsy() to
expect(id).not.toBe(0) and keep expect(id).toSatisfy((r) => typeof r ===
'number') or prefer expect(typeof id).toBe('number'); likewise replace patterns
like expect(Object.keys(x).length === 0).toBeTruthy() with
expect(Object.keys(x)).toHaveLength(0) or directly expect(x).toEqual({})
depending on intent. Update assertions wherever similar boolean-expression
checks appear to use direct matchers for clearer failure messages.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@convert/tests/sab/convertContentSAB.test.ts`:
- Around line 488-492: The current test in convertContentSAB.test.ts calls
expect(link.linkTarget.match(/^[0-9]*$/i)) without a matcher, making it a no-op;
change it to assert properly—either use
expect(link.linkTarget).toMatch(/^[0-9]*$/) or assert the match result is not
null with expect(link.linkTarget.match(/^[0-9]*$/i)).not.toBeNull() (within the
same if block where link.linkType === 'screen' and link.linkTarget is defined).
- Around line 526-541: Rename the misspelled test descriptions that use
"assgined" to "assigned" in the test suite—specifically update the test titled
"convertContents: Layout Mode if assgined is not empty" (and the other
occurrence with the same typo) to read "convertContents: Layout Mode if assigned
is not empty" so the test names are spelled correctly; search for the string
"assgined" in convertContentSAB.test.ts and replace with "assigned" (no code
logic changes required).

---

Outside diff comments:
In `@convert/convertContents.ts`:
- Around line 104-111: The decodeFromXml function only replaces the first
occurrence of each XML entity because it uses String.prototype.replace with
string patterns; update decodeFromXml to perform global replacements (e.g., use
replaceAll or replace with global regexes like /&quot;/g) for each entity
(&quot;, &apos;, &lt;, &gt;, &amp;) and ensure the replacement order avoids
double-decoding (replace &amp; last or handle &amp; carefully) so all instances
in the input are properly decoded.
- Around line 357-365: Guard against a missing <features> element and restrict
to direct child <feature> nodes: check that featuresTag (from
document.getElementsByTagName('features')[0]) is non-null before calling methods
on it, then iterate only over its direct child feature elements (e.g., use
featuresTag.children or a scoped selector like
featuresTag.querySelectorAll(':scope > feature')) to fill data.features using
parseFeatureValue; update the block that assigns data.features in
convertContents.ts (symbols: featuresTag, featureTags, parseFeatureValue,
data.features) accordingly.

---

Nitpick comments:
In `@convert/convertContents.ts`:
- Around line 56-60: There are two separate declarations of the
ContentsTaskOutput interface in this file (one with taskName and another with
data), relying on TypeScript declaration merging; consolidate them into a single
interface declaration that includes both the taskName: 'ConvertContents' and the
data: ContentsData properties (extending TaskOutput as before), and remove the
duplicate declaration so only one ContentsTaskOutput exists (update any
references if needed).
- Around line 375-376: Remove or document the two unused locals to eliminate
confusion: drop the unused currentContentContainer variable altogether from
convertContents.ts (it's never assigned/used), and either remove prevItemType or
restrict it to the verbose logging path (or add a clear TODO comment) in the
function where prevItemType is set and compared—ensure you update the assignment
site in the code that currently updates prevItemType so it no longer assigns to
a dead variable if you remove it; if you keep prevItemType for future use, add a
TODO comment explaining its planned purpose and limit assignments to when
verbose logging actually reads it.
- Around line 122-137: The function parseItemType accepts contentItemContainer
but never uses it, so remove the unused parameter from parseItemType's signature
and body, update all call sites that pass contentItemContainer to call
parseItemType with the remaining parameters (e.g., replace parseItemType(tag,
contentItemContainer, prevItemType) with parseItemType(tag, prevItemType)), and
adjust any tests to match the new signature; alternatively, if the parameter is
actually required, modify parseItemType to use contentItemContainer in its logic
(refer to the parseItemType function) instead of leaving it unused.

In `@convert/tests/sab/convertContentSAB.test.ts`:
- Around line 41-48: The test file contains empty lifecycle hooks and a no-op
cleanup function (beforeAll, afterAll, and cleanup) that are dead code; either
remove these unused stubs from convert/tests/sab/convertContentSAB.test.ts or
implement them if setup/teardown is required: delete the beforeAll and afterAll
hooks and the cleanup function if not used, or replace the TODOs with concrete
checks (e.g., verify required files exist in beforeAll and perform test data
deletion in afterAll) and ensure any calls reference the implemented cleanup;
update or remove the TODO comments accordingly so the test file has no no-op
scaffolding.
- Around line 260-265: Remove the unnecessary contentsFileExists guard and its
early-return from self-contained mocked tests that use inline XML or pass
undefined; instead either (a) avoid calling setup() when its only purpose is
checking the file, or (b) call setup(undefined, true) but do not
conditional-return on testSetup.contentsFileExists. Specifically update the
tests referencing setup() and contentsFileExists in the convertContents test and
the mocked isTagInnerNestedItem / tagHasInnerNestedItems tests so they always
run; ensure tests still create their inline XML fixtures and call
convertContents, isTagInnerNestedItem, or tagHasInnerNestedItems directly (or
pass undefined) without depending on file presence.
- Around line 1-6: Remove the unused runtime import Script from 'vm' (it's never
referenced) and change the value imports for the type-only symbols LangContainer
and LinkMeta to type-only imports (use "import type" for LangContainer and
LinkMeta) so they don't emit runtime references; locate the imports that
currently bring in Script, LangContainer, and LinkMeta and update them
accordingly.
- Around line 170-174: Replace boolean-expression assertions with idiomatic
Vitest matchers: for the loop using parseItemId and id, change expect(id ===
0).toBeFalsy() to expect(id).not.toBe(0) and keep expect(id).toSatisfy((r) =>
typeof r === 'number') or prefer expect(typeof id).toBe('number'); likewise
replace patterns like expect(Object.keys(x).length === 0).toBeTruthy() with
expect(Object.keys(x)).toHaveLength(0) or directly expect(x).toEqual({})
depending on intent. Update assertions wherever similar boolean-expression
checks appear to use direct matchers for clearer failure messages.

Comment on lines +488 to +492
if (link.linkType === 'screen' && link.linkTarget !== undefined) {
console.log(link.linkTarget);
// NOTE: LinkTargets for screens are id numbers of the screen
expect(link.linkTarget.match(/^[0-9]*$/i));
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Missing assertion matcher — this expect is a no-op.

Line 491 calls expect(...) on the regex match result but never chains a matcher (e.g., .not.toBeNull()). The test silently passes regardless of the match outcome.

🐛 Proposed fix
         if (link.linkType === 'screen' && link.linkTarget !== undefined) {
             console.log(link.linkTarget);
             // NOTE: LinkTargets for screens are id numbers of the screen
-            expect(link.linkTarget.match(/^[0-9]*$/i));
+            expect(link.linkTarget.match(/^[0-9]*$/i)).not.toBeNull();
         }
📝 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 (link.linkType === 'screen' && link.linkTarget !== undefined) {
console.log(link.linkTarget);
// NOTE: LinkTargets for screens are id numbers of the screen
expect(link.linkTarget.match(/^[0-9]*$/i));
}
if (link.linkType === 'screen' && link.linkTarget !== undefined) {
console.log(link.linkTarget);
// NOTE: LinkTargets for screens are id numbers of the screen
expect(link.linkTarget.match(/^[0-9]*$/i)).not.toBeNull();
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convert/tests/sab/convertContentSAB.test.ts` around lines 488 - 492, The
current test in convertContentSAB.test.ts calls
expect(link.linkTarget.match(/^[0-9]*$/i)) without a matcher, making it a no-op;
change it to assert properly—either use
expect(link.linkTarget).toMatch(/^[0-9]*$/) or assert the match result is not
null with expect(link.linkTarget.match(/^[0-9]*$/i)).not.toBeNull() (within the
same if block where link.linkType === 'screen' and link.linkTarget is defined).

Comment on lines +526 to +541
test('convertContents: Layout Mode if assgined is not empty', () => {
const testSetup: TestSetup = setup(undefined, true, true);
if (!testSetup.contentsFileExists) {
console.warn('Contents file does not exist, so test cannot be run');
return;
}

for (const item of testSetup.itemTags ? testSetup.itemTags : []) {
const layoutMode = parseItemLayoutMode(item);
if (Object.keys(item).length > 0) {
expect(layoutMode).not.toSatisfy((r) => r === undefined || r === '');
} else {
expect(layoutMode).not.toSatisfy((r) => r === '');
}
}
});
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Typos in test names: "assgined" → "assigned".

Lines 526 and 554 have the misspelling "assgined" in test descriptions.

✏️ Proposed fix
-test('convertContents: Layout Mode if assgined is not empty', () => {
+test('convertContents: Layout Mode if assigned is not empty', () => {
-test('convertContents: Layout Collection if assgined is not empty', () => {
+test('convertContents: Layout Collection if assigned is not empty', () => {
📝 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
test('convertContents: Layout Mode if assgined is not empty', () => {
const testSetup: TestSetup = setup(undefined, true, true);
if (!testSetup.contentsFileExists) {
console.warn('Contents file does not exist, so test cannot be run');
return;
}
for (const item of testSetup.itemTags ? testSetup.itemTags : []) {
const layoutMode = parseItemLayoutMode(item);
if (Object.keys(item).length > 0) {
expect(layoutMode).not.toSatisfy((r) => r === undefined || r === '');
} else {
expect(layoutMode).not.toSatisfy((r) => r === '');
}
}
});
test('convertContents: Layout Mode if assigned is not empty', () => {
const testSetup: TestSetup = setup(undefined, true, true);
if (!testSetup.contentsFileExists) {
console.warn('Contents file does not exist, so test cannot be run');
return;
}
for (const item of testSetup.itemTags ? testSetup.itemTags : []) {
const layoutMode = parseItemLayoutMode(item);
if (Object.keys(item).length > 0) {
expect(layoutMode).not.toSatisfy((r) => r === undefined || r === '');
} else {
expect(layoutMode).not.toSatisfy((r) => r === '');
}
}
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@convert/tests/sab/convertContentSAB.test.ts` around lines 526 - 541, Rename
the misspelled test descriptions that use "assgined" to "assigned" in the test
suite—specifically update the test titled "convertContents: Layout Mode if
assgined is not empty" (and the other occurrence with the same typo) to read
"convertContents: Layout Mode if assigned is not empty" so the test names are
spelled correctly; search for the string "assgined" in convertContentSAB.test.ts
and replace with "assigned" (no code logic changes required).

@chrisvire chrisvire merged commit 27a4e9e into sillsdev:main Feb 23, 2026
4 checks passed
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.

2 participants