Skip to content

feat(drafting): store the text of section headings - #269

Merged
henrique221 merged 1 commit into
feat/263-verse-markers-book-fieldsfrom
feat/section-heading-text
Aug 18, 2026
Merged

feat(drafting): store the text of section headings#269
henrique221 merged 1 commit into
feat/263-verse-markers-book-fieldsfrom
feat/section-heading-text

Conversation

@henrique221

Copy link
Copy Markdown
Contributor

Follow-up to #264, and the last storage gap behind fluent-web#397 (chapter view).

Targets feat/263-verse-markers-book-fields, not main, so it does not disturb the review in flight on #264. It retargets to main when that merges, and the diff stays the same.

The gap

A paragraph record is { marker, offset } — a marker plus an offset into the verse's text. A section heading is a block of its own, carrying its own words, belonging to no verse, so there is nowhere to put them. Today the export renders a heading as a bare marker and the translator's words are gone:

\s1                              ← the heading's text has nowhere to live
\v 1 In the beginning God created…

fluent-web#408 can therefore apply a heading as a block type but cannot let anyone author its text, which is called out in that PR.

The shape

{ "headings": [ { "marker": "s1", "text": "The Creation" } ],
  "paragraphs": [ { "marker": "p", "offset": 0 } ] }

Headings are blocks emitted before the verse, in order, and before its paragraph marker — a heading is not a paragraph, so the verse that follows still needs one:

\c 1
\s1 The Creation
\p
\v 1 In the beginning God created…

Three constraints worth a look:

  • Markers are the heading subset of USFM_PARAGRAPH_MARKERS (\s1\s4, \ms, \mr, \sr, \d, …). Allowing \p here would let a caller store the verse's own prose as a heading and lose it from the row.
  • Text is guarded against backslashes and line breaks exactly as the book fields are, since it goes straight into the USFM stream.
  • paragraphs became optional, because a verse may carry only a heading — and the object must now hold one or the other, since null already means "no structure".

No migration

The column is jsonb, so the shape lives in Zod rather than in DDL. Confirmed rather than assumed: drizzle-kit generate reports "No schema changes, nothing to migrate", and the migration folder is untouched. That also means this could have landed before or after #264 at the same cost — I said earlier it was cheaper to fold into #264 while it was open, and that was wrong.

Verification

296 tests pass. Twelve are new: eight over the schema (accepting one and several headings, rejecting body-text markers, rejecting injection attempts and empty text, and old rows still parsing) and four over the export (a heading before its verse, several in order, the chapter's default paragraph still emitted after a heading, and a mid-chapter heading landing before its own verse rather than at the chapter top). Lint and typecheck clean.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d1a270db-b8c8-4bda-b3f1-6fae4046b7d9

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

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.

Follow-up to #264, and the last storage gap behind fluent-web#397.

A paragraph record is a marker plus an offset into the *verse's* text, so it
cannot hold a section heading: a heading is a block of its own, with its own
words, belonging to no verse. Exported today it renders as a bare "\s1" and the
translator's words are gone.

markers.headings carries them, in order, as blocks emitted before the verse:

  { "headings": [{ "marker": "s1", "text": "The Creation" }] }

Markers are restricted to the heading subset of USFM_PARAGRAPH_MARKERS, so body
text can never be stored as a heading and lost from the row, and the text is
guarded against backslashes and line breaks exactly as the book fields are,
since it is written straight into the USFM stream.

paragraphs became optional, because a verse may carry only a heading, and the
object now has to hold one or the other — null already means "no structure".

No migration: the column is jsonb, so the shape lives in Zod. Confirmed with
drizzle-kit generate ("No schema changes, nothing to migrate").

Claude-Session: https://claude.ai/code/session_01R6Xec2CgkML6mYyHmqiGK5
@henrique221
henrique221 force-pushed the feat/section-heading-text branch from 14b4fc0 to d9fd279 Compare August 13, 2026 18:28
@henrique221
henrique221 merged commit f63d228 into feat/263-verse-markers-book-fields Aug 18, 2026
1 check passed
@github-actions
github-actions Bot deleted the feat/section-heading-text branch August 18, 2026 02:17
henrique221 added a commit that referenced this pull request Aug 21, 2026
…lds (#264)

* feat(drafting): store verse paragraph markers and book-level USFM fields

Closes #263. Unblocks fluent-web#314 (paragraph authoring in the RTE) and
fluent-web#398 (Book Details panel). All additive: legacy rows and existing
exports behave exactly as before.

- translated_verses.markers (nullable jsonb): paragraph starts per verse.
  Offset 0 means the verse opens a paragraph; a mid-text offset splits the
  verse across paragraphs. Zod-validated: marker pattern (no USFM injection),
  strictly increasing offsets, offsets bounded by the content on insert.
  Upsert writes it, responses return it.
- project_unit_bible_books.running_header / book_title: authored \h and \mt1
  per unit and book, validated against marker injection, empty clears to null.
- USFM export honors both: stored paragraph markers replace the hardcoded
  single \p per chapter (which remains the fallback for rows without markers),
  and \h/\mt prefer the authored fields with the display-name fallback.
- GET /project-units/{id}/book-details and PATCH .../book-details/{bookId},
  gated by authenticateUser + requirePermission + requireProjectUnitAccess.

21 new tests: the export generator (golden legacy output, opening markers,
mid-verse splits, out-of-range offsets, book-field fallbacks) and the two
validation schemas. Migration 0019 verified against a scratch Postgres.

Claude-Session: https://claude.ai/code/session_01R6Xec2CgkML6mYyHmqiGK5

* fix(drafting): allowlist the paragraph markers a verse may open with

The stored marker is emitted verbatim as `\<marker>` by the USFM export, so
the previous `^[a-z][a-z0-9]{0,9}$` pattern let a client store `v` or `c` and
put a bare `\v` / `\c` — a structural marker with no number — ahead of the
real one. Replace the pattern with `z.enum` over the USFM 3.x body-text
paragraph set, which also documents the accepted values in the OpenAPI spec
for the editor. Introduction markers stay out: they precede \c 1 and cannot
open inside a verse.

Refs: #264

* fix(book-details): reject control and line-separator characters in book fields

The guard covered `\`, LF and CR, but NUL, ESC, U+2028 and U+2029 passed
straight into the `\h` / `\mt` line of the export, where a consumer treating
the separators as line breaks sees a different file than the validator did.
Reject the Cc, Zl and Zp ranges, spelled as literal ranges rather than
`\p{...}` so the pattern published in the OpenAPI document carries the same
meaning without the unicode flag.

Also corrects the endpoint descriptions: the export emits `\mt`, not `\mt1`.

Refs: #264

* test(usfm): assert a non-default marker on the untranslated-verse export

The case stored `p`, which is exactly what the chapter falls back to, so it
passed whether or not the exporter read `markers` at all. Use `q1` and assert
no `\p` is emitted, so the test fails if the stored marker is ignored.

Refs: #264

* style: prettier over the markers select and drizzle-generated migration meta

Formatting only, and the cause of the red validate job: the markers select was
inserted with off-by-two indentation, and drizzle-kit writes its meta JSON in
its own style, which the repo's format check does not accept. Both meta files
parse identically before and after.

Claude-Session: https://claude.ai/code/session_01R6Xec2CgkML6mYyHmqiGK5

* refactor(book-details): own the project-unit auth check

Review feedback: book-details imported requireProjectUnitAccess from usfm's
middleware, coupling book metadata authoring to export generation because the
two checks happen to look alike.

The domain now carries its own. usfm and translated-verses each already do the
same, so this follows the existing convention rather than inventing one;
consolidating the three into shared infrastructure stays a separate, deliberate
decision.

A boundary test pins it: book-details imports nothing from a sibling feature
domain, and its route uses its own middleware. Both fail against the code as
reviewed.

Claude-Session: https://claude.ai/code/session_01R6Xec2CgkML6mYyHmqiGK5

* feat(drafting): store the text of section headings (#269)

Follow-up to #264, and the last storage gap behind fluent-web#397.

A paragraph record is a marker plus an offset into the *verse's* text, so it
cannot hold a section heading: a heading is a block of its own, with its own
words, belonging to no verse. Exported today it renders as a bare "\s1" and the
translator's words are gone.

markers.headings carries them, in order, as blocks emitted before the verse:

  { "headings": [{ "marker": "s1", "text": "The Creation" }] }

Markers are restricted to the heading subset of USFM_PARAGRAPH_MARKERS, so body
text can never be stored as a heading and lost from the row, and the text is
guarded against backslashes and line breaks exactly as the book fields are,
since it is written straight into the USFM stream.

paragraphs became optional, because a verse may carry only a heading, and the
object now has to hold one or the other — null already means "no structure".

No migration: the column is jsonb, so the shape lives in Zod. Confirmed with
drizzle-kit generate ("No schema changes, nothing to migrate").

Claude-Session: https://claude.ai/code/session_01R6Xec2CgkML6mYyHmqiGK5

* style: prettier over the heading schema and its test

Pre-merge validation caught the test file. The schema was unformatted too, and
fixing only the reported file would have left the check red: prettier expands
the USFM_HEADING_MARKERS array to one entry per line.

Reflow only, no content change.

Claude-Session: https://claude.ai/code/session_01R6Xec2CgkML6mYyHmqiGK5
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