Skip to content

Commit 5b693ee

Browse files
kckernclaude
andcommitted
refactor(web): review cleanups for compound lines, segment markers and meta blocks
- segmentFormats drops a leading ▲ before reading the marker, as formattedData did - isCompoundLine is shared by foldCompounds and chapterRuns - the short book title is set for every block, with or without headings; foldCompounds no longer falls back to the full title - blockTexts skips grouping above 50 pieces, documents the '..' separator and handles a piece the package cannot read - dead variables removed from section.ejs; a test pins 'Genesis 1; Genesis 3' → 'Genesis 1; 3' Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 099dedc commit 5b693ee

9 files changed

Lines changed: 76 additions & 14 deletions

File tree

‎apps/web/src/domain/rules/legacyText.js‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,14 +104,21 @@ export function renderHeadingFields(record) {
104104
return out;
105105
}
106106

107+
// scriptures::formattedData: a line of exactly "*" (2HRBOM, CTBOM, EARLIEST ...) is a verse merged
108+
// into the line before it.
109+
export function isCompoundLine(text) {
110+
return String(text ?? '').trim() === '*';
111+
}
112+
107113
// scriptures::formattedData: the format and paragraph class of each segment of a verse, from the
108114
// verse's own format and the marker before each segment. A marker's format holds for the rest of
109115
// the verse: ¶▼ poetry, ¶↪ indent, ¶► prose; ¶◄ starts a "flush" paragraph, ¶◤ a "linebreak"
110116
// one and ‾¶ a "nogap" one (both prose). A line starting with § (a subtitle) is prose.
111117
// segments: [{ meta, text }] from VerseSerializer.splitSegments.
112118
export function segmentFormats(verseFormat, segments) {
113119
let format = verseFormat || 'prose';
114-
return segments.map(({ meta, text }) => {
120+
return segments.map(({ meta: rawMeta, text }) => {
121+
const meta = String(rawMeta ?? '').replace(/^▲/, ''); // formattedData drops a verse head's ▲ first
115122
let paraClass = '';
116123
if (meta === '¶◄' || meta === '§¶◄') { paraClass = 'flush'; }
117124
else if (meta === '¶▼') format = 'poetry';

‎apps/web/src/presentation/legacy/meta.js‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,23 @@ const trimChars = (s, chars) => s.replace(new RegExp(`^[${chars}]+|[${chars}]+$`
2828
// The package splits verses after a comma into their own part ("Matthew 13:24-30, 36-43" →
2929
// "Matthew 13:24-30; 13:36-43"); cleanReference kept them in one block. Group the package's parts
3030
// by the ";"-separated pieces of the input, and join a group's parts back with ", ".
31+
// Above this many pieces the grouping (one package parse per piece) is skipped: one block per part.
32+
const MAX_GROUPED_PIECES = 50;
33+
3134
function blockTexts(ref, normalized) {
3235
const parts = normalized.split(';');
36+
// cleanReference turned ".." into ";", so both separate blocks
3337
const pieces = String(ref).split(/;|\.\./);
38+
if (pieces.length > MAX_GROUPED_PIECES) return parts;
3439
const groups = [];
3540
let used = 0;
3641
pieces.forEach((piece, i) => {
37-
const upTo = i === pieces.length - 1 ? parts.length : normalize(pieces.slice(0, i + 1).join(';')).split(';').length;
42+
let upTo = parts.length;
43+
if (i < pieces.length - 1) {
44+
const prefix = normalize(pieces.slice(0, i + 1).join(';'));
45+
// A prefix the package cannot read adds no parts; its text joins the next piece's group
46+
upTo = prefix ? prefix.split(';').length : used;
47+
}
3848
if (upTo > used) groups.push(parts.slice(used, upTo));
3949
used = Math.max(used, upTo);
4050
});

‎apps/web/src/presentation/presenters/HtmlPresenter.js‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import VerseSerializer from '../serializers/VerseSerializer.js';
22
import { displayReference, blankHeading } from '../../domain/rules/referenceParsing.js';
33

44
import { versionImg, formatReference as referenceFromVerseIds } from '../../domain/rules/legacyReference.js';
5-
import { renderHeadingHtml, renderHeadingFields, RENDERED_HEADING_FIELDS, segmentFormats, escapeHtml } from '../../domain/rules/legacyText.js';
5+
import { renderHeadingHtml, renderHeadingFields, RENDERED_HEADING_FIELDS, segmentFormats, escapeHtml, isCompoundLine } from '../../domain/rules/legacyText.js';
66

77
// versionImg takes lds_scriptures_volumes.volume_id; Reference.volume is a volume code.
88
// Verses outside the five volumes (null) have no LDS cover: 0 gives "lds".
@@ -18,7 +18,7 @@ export function foldCompounds(lines, bookShort) {
1818
let merged = null; // verse_id → { book, bookId, chapter, verse } of the host and its merged verses
1919
const at = line => ({ book: bookShort, bookId: 0, chapter: line.chapter, verse: line.num });
2020
for (const line of lines) {
21-
if (String(line.text).trim() !== '*' || !host) {
21+
if (!isCompoundLine(line.text) || !host) {
2222
out.push(line);
2323
host = line;
2424
merged = new Map([[line.id, at(line)]]);
@@ -456,7 +456,7 @@ export class HtmlPresenter {
456456
lbIndex: lbIdx
457457
}));
458458
});
459-
}), result.config._bookShort || result.reference._bookShort || result.reference.book),
459+
}), result.config._bookShort ?? ''),
460460
h3ByVerse, // full h3 headings indexed by verse_id
461461
inlineByVerse, // non-heading inline elements indexed by verse_id
462462
prePassageH3, // chapter heading from prior section (renders before verses with ellipsis)

‎apps/web/src/presentation/views/partials/section.ejs‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,11 @@ function buildH3(h, version) {
2626
const faxAttr = h.fax ? ` fax="1"` : '';
2727
// A heading without text shows its reference (PHP "Reference Headings")
2828
const text = (h.headless || !h.text) ? (h.ref || h.text || '') : (h.textHtml ?? h.text);
29-
let html = `<h3 class="heading" version="${h.headVersion || version}" heading_verse_id="${h.displayVerseId || h.verse_id}"${vc}${lv}${headless}${ref}${slug}${ver}${img}${parallelAttr}${faxAttr}${audioAttr}>${text}</h3>`;
30-
return html;
29+
return `<h3 class="heading" version="${h.headVersion || version}" heading_verse_id="${h.displayVerseId || h.verse_id}"${vc}${lv}${headless}${ref}${slug}${ver}${img}${parallelAttr}${faxAttr}${audioAttr}>${text}</h3>`;
3130
}
3231
33-
// Build passage HTML — group verses into paragraphs based on paraBreak flag
32+
// Group verses into paragraphs based on the paraBreak flag.
3433
// Mirrors PHP: multiple verse spans share one <p> unless separated by a ¶ marker
35-
let passageHtml = '';
36-
const lastVerseId = data.verses.length ? data.verses[data.verses.length - 1].id : null;
3734
3835
// Attach summary/headnote from chapterHeading to the chapter heading h3
3936
const chapterH3Data = ch && data.h3ByVerse[ch.verse_id] ? data.h3ByVerse[ch.verse_id] : null;

‎apps/web/src/services/ScriptureLookupService.js‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import VerseSerializer from '../presentation/serializers/VerseSerializer.js';
88
import { defaultVersions, versionOrder } from '../domain/rules/defaultVersions.js';
99
import { applyFormatOverride } from '../domain/rules/formatOverride.js';
1010
import { getBook } from '../domain/rules/legacyReference.js';
11+
import { isCompoundLine } from '../domain/rules/legacyText.js';
1112
import { wantsChapterNav, chapterNavLinks, sectionNavLinks, sectionRef } from '../domain/rules/chapterNav.js';
1213

1314
// Verse rows grouped by chapter, in order, for one synthetic heading per chapter. A chapter whose
@@ -22,7 +23,7 @@ export function chapterRuns(rows) {
2223
}
2324
const runs = [];
2425
for (const c of chapters) {
25-
if (runs.length && c.rows.every(r => String(r.text).trim() === '*')) runs[runs.length - 1].rows.push(...c.rows);
26+
if (runs.length && c.rows.every(r => isCompoundLine(r.text))) runs[runs.length - 1].rows.push(...c.rows);
2627
else runs.push(c);
2728
}
2829
return runs;
@@ -168,12 +169,16 @@ export class ScriptureLookupService {
168169
let chapterHeading = null;
169170
let chapterNav = null;
170171

172+
// Book of the first verse: its short title labels compound verses ("1 Ne. 1:1–3"), with or
173+
// without headings
174+
const bookInfo = verseIds.length ? await this.headingRepository.findBookId(verseIds[0]) : null;
175+
if (bookInfo) config._bookShort = bookInfo.book_title_short;
176+
171177
if (config.includeHeadings !== false && verseIds.length > 0) {
172178
headings = await this.headingRepository.findByVerseIds(verseIds, headingVersion);
173179

174180
const firstVerseId = verseIds[0];
175181
const lastVerseId = verseIds[verseIds.length - 1];
176-
const bookInfo = await this.headingRepository.findBookId(firstVerseId);
177182

178183
if (bookInfo) {
179184
const { book_id: bookId, lds_org: bookSlug, book_title_short: bookTitleShort, book_title: bookTitle } = bookInfo;
@@ -202,7 +207,6 @@ export class ScriptureLookupService {
202207
const audioFlag = await this.headingRepository.hasAudio(verseIds, version);
203208
// Always store bookTitle on config so HtmlPresenter can build slugs even when chapterHeading is null
204209
config._bookTitle = bookTitle;
205-
config._bookShort = bookTitleShort;
206210
if (chapterHeading) {
207211
chapterHeading.bookSlug = bookSlug;
208212
chapterHeading.bookTitle = bookTitle;

‎apps/web/test/unit/legacyMeta.test.js‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,17 @@ describe('metaFromRef (scriptures::loadMetaFromRef)', () => {
4242
expect(metaFromRef('john.3.16,18;gen.1.1').map(b => b.orig)).toEqual(['John 3:16, 18', 'Genesis Genesis 1:1']);
4343
});
4444

45+
test('a piece the package cannot read adds no block', () => {
46+
expect(metaFromRef('xyzzy;john.3.16').map(b => b.orig)).toEqual(['John 3:16']);
47+
});
48+
49+
test('many pieces skip the per-piece grouping', () => {
50+
const ref = Array.from({ length: 60 }, (_, i) => `ps.${i + 1}.1`).join(';');
51+
const t0 = performance.now();
52+
expect(metaFromRef(ref)).toHaveLength(60);
53+
expect(performance.now() - t0).toBeLessThan(250);
54+
});
55+
4556
test('unknown reference gives no blocks', () => {
4657
expect(metaFromRef('nothing')).toEqual([]);
4758
});

‎apps/web/test/unit/legacyText.test.js‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, it, expect } from 'vitest';
22
import {
33
flattenText, splitVerseLines, arrayText, plainText, plainPassage,
4-
stemText, transposeRef, baseVersion, highlightSearchTerm, escapeHtml, renderHeadingHtml, segmentFormats,
4+
stemText, transposeRef, baseVersion, highlightSearchTerm, escapeHtml, renderHeadingHtml, segmentFormats, isCompoundLine,
55
} from '../../src/domain/rules/legacyText.js';
66

77
// Fixtures are KJV (public domain) or hand-written.
@@ -136,7 +136,21 @@ describe('segmentFormats (scriptures::formattedData paragraph markers)', () => {
136136
expect(segmentFormats('poetry', [seg('‾¶')])).toEqual([{ format: 'prose', paraClass: 'nogap' }]);
137137
expect(segmentFormats('prose', [seg('¶▼◄')])).toEqual([{ format: 'prose', paraClass: 'flush' }]);
138138
});
139+
it('a leading ▲ (verse head) is dropped before the marker is read', () => {
140+
expect(segmentFormats('prose', [seg('▲¶▼')]).map(f => f.format)).toEqual(['poetry']);
141+
expect(segmentFormats('poetry', [seg('▲¶◄')])).toEqual([{ format: 'poetry', paraClass: 'flush' }]);
142+
});
139143
it('a line that starts with § (a subtitle) is prose', () => {
140144
expect(segmentFormats('poetry', [seg('¶', '§A Psalm of David.'), seg('/')]).map(f => f.format)).toEqual(['prose', 'poetry']);
141145
});
142146
});
147+
148+
describe('isCompoundLine', () => {
149+
it('is a line of exactly "*", spaces aside', () => {
150+
expect(isCompoundLine('*')).toBe(true);
151+
expect(isCompoundLine(' * ')).toBe(true);
152+
expect(isCompoundLine('**')).toBe(false);
153+
expect(isCompoundLine('And *')).toBe(false);
154+
expect(isCompoundLine(null)).toBe(false);
155+
});
156+
});

‎apps/web/test/unit/referenceParsing.test.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ describe('displayReference', () => {
7474
expect(displayReference('2 Nephi 3:19-20; 2 Nephi 27:13; 2 Nephi 29:2')).toBe('2 Nephi 3:19–20; 27:13; 29:2');
7575
expect(displayReference('Abraham 2:11; Genesis 17:7; Genesis 18:18')).toBe('Abraham 2:11; Genesis 17:7; 18:18');
7676
expect(displayReference('1 Nephi 1-5; 1 Nephi 7-19')).toBe('1 Nephi 1–5; 7–19');
77+
expect(displayReference('Genesis 1; Genesis 3')).toBe('Genesis 1; 3');
7778
expect(displayReference('Isaiah 9:12; Isaiah 9:17; 2 Nephi 15:25')).toBe('Isaiah 9:12,17; 2 Nephi 15:25');
7879
});
7980
test('a range from verse 1 into the next chapter drops the first verse', () => {

‎apps/web/test/unit/scriptureLookup.test.js‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,21 @@ describe('splitBlocks', () => {
2828
expect(svc.splitBlocks('1; 5-6')).toEqual(['1', '5-6']);
2929
});
3030
});
31+
32+
describe('lookup: the short book title for compound refs', () => {
33+
const rows = { 1: { verse_id: 1, verse: 1, chapter: 1, text: 'In the beginning.' }, 57: { verse_id: 57, verse: 1, chapter: 3, text: '*' } };
34+
const refs = { parse: t => ({ isEmpty: false, book: 'Genesis', normalized: t, verseIds: t.includes('3') ? [57] : [1], chapter: 1 }) };
35+
const verses = {
36+
findByIds: async ids => ids.map(id => ({ ...rows[id], version: 'KJV' })),
37+
findChaptersByVerseIds: async ids => ids.map(id => ({ verse_id: id, chapter: rows[id].chapter })),
38+
};
39+
const headings = {
40+
findBookId: async () => ({ book_id: 1, lds_org: 'gen', book_title: 'Genesis', book_title_short: 'Gen.' }),
41+
findFaxVerseIds: async () => [],
42+
};
43+
const svc = new ScriptureLookupService(refs, verses, headings);
44+
test('is set on every block, with or without headings', async () => {
45+
const r = await svc.lookup('gen.1.1; gen.3.1', { version: 'KJV', includeHeadings: false });
46+
expect(r.sections.map(s => s.config._bookShort)).toEqual(['Gen.', 'Gen.']);
47+
});
48+
});

0 commit comments

Comments
 (0)