diff --git a/.changeset/fresh-papayas-display.md b/.changeset/fresh-papayas-display.md new file mode 100644 index 00000000..712d291e --- /dev/null +++ b/.changeset/fresh-papayas-display.md @@ -0,0 +1,7 @@ +--- +'@youversion/platform-core': minor +--- + +Add a declarative passage display API that returns transformed Bible HTML, +current attribution, required stylesheet descriptors, and container attributes +without rendering or mutating the DOM. diff --git a/CONTEXT.md b/CONTEXT.md index f13c3b03..a63f913c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -20,6 +20,25 @@ A verse or contiguous verse range in one chapter of one Bible version, identified by a USFM string (`JHN.3.16`, `JHN.3.16-18`). A chapter USFM (`JHN.3`) is a passage *scope* used for querying, not a highlightable unit. +## Passage ID + +The canonical string identifier for a passage, expressed in USFM format, such +as `JHN.3.16` or `JHN.3.16-18`. _Avoid_: USFM, reference string. + +## Passage display model + +A declarative representation of a passage and everything a web application +needs to display it with YouVersion's rendering contract. _Avoid_: rendered +passage, passage component, display bundle. + +## Passage display model attribution + +The current, non-empty legal text returned by the passage display model. The +short copyright text is preferred; promotional content is its fallback. This +fail-closed contract applies to `getPassageDisplay`; existing React UI +components retain their own attribution behavior. +_Avoid_: copyright HTML. + ## Bible version A translation/edition of the Bible, identified by a numeric id. The SDK diff --git a/PUBLISHING.md b/PUBLISHING.md index 7a89888d..a3a16a44 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -75,6 +75,7 @@ The `` path segment is defined in **one place**: the `packages/ui/CDN_CSS - **Do not bump it for routine releases.** The file at `/platform//bible.css` is overwritten in place with each UI package release. - **Bump it only when the CSS changes in a breaking way** (selectors/variables/class names that existing consumers depend on are removed or behave differently). Bumping starts publishing to a new `/platform//bible.css` URL and leaves the old file untouched for existing consumers. +- **Keep core synchronized.** `pnpm check:cdn-css-major` verifies that the URL returned by `getBibleStylesheets` uses the same major. Root lint and CI run this guard. ### Feature flag: `feature.platform.sdkCssCdn` diff --git a/docs/adding-a-core-endpoint.md b/docs/adding-a-core-endpoint.md index cfb4806c..7be170dc 100644 --- a/docs/adding-a-core-endpoint.md +++ b/docs/adding-a-core-endpoint.md @@ -48,7 +48,7 @@ export runtime-agnostic while `linkedom` stays out of browser bundles: |---|---| | `@youversion/platform-core` | Runtime-agnostic; requires DOM adapters | | `@youversion/platform-core/browser` | Convenience wrapper using native `DOMParser` | -| `@youversion/platform-core/server` | Convenience wrapper using `linkedom` | +| `@youversion/platform-core/server` | Convenience wrapper using `jsdom` | If a new client needs DOM access, follow the same pattern rather than importing a DOM library into the main entry point. diff --git a/docs/adr/0008-return-declarative-passage-display-models.md b/docs/adr/0008-return-declarative-passage-display-models.md new file mode 100644 index 00000000..be0819c6 --- /dev/null +++ b/docs/adr/0008-return-declarative-passage-display-models.md @@ -0,0 +1,8 @@ +# Return declarative passage display models from core + +`@youversion/platform-core` will provide a high-level passage display operation +that returns transformed HTML, current attribution, stylesheet descriptors, and +container attributes as data. Core will fetch but will not render, inject +resources, mutate the DOM, or cache attribution, preserving its +framework-agnostic boundary while making the correct rendering path difficult +to misuse. diff --git a/docs/passage-display-api.md b/docs/passage-display-api.md new file mode 100644 index 00000000..b8195f29 --- /dev/null +++ b/docs/passage-display-api.md @@ -0,0 +1,102 @@ +# Passage display API + +## Purpose + +`getPassageDisplay` gives non-React web applications one supported operation +for retrieving transformed Bible HTML, current display attribution, and the +resources required to apply YouVersion's Bible presentation. The result is +declarative and works with browser frameworks, server-rendered templates, and +plain JavaScript. + +The operation complements the granular `getPassage` and `getVersion` methods; +it does not replace them. + +## Public API + +```ts +type GetPassageDisplayOptions = Readonly<{ + versionId: number; + passageId: string; + includeHeadings?: boolean; + includeNotes?: boolean; +}>; + +type PassageStylesheet = Readonly<{ + kind: "bible" | "font"; + rel: "stylesheet"; + href: string; +}>; + +type BiblePassageDisplay = Readonly<{ + version: BibleVersion; + html: string; + attribution: { + text: string; + source: "copyright" | "promotionalContent"; + }; + stylesheets: readonly PassageStylesheet[]; + containerAttributes: { + "data-yv-sdk": ""; + "data-slot": "yv-bible-renderer"; + }; +}>; + +const display = await bibleClient.getPassageDisplay({ + versionId: 3034, + passageId: "JHN.3.16", + includeHeadings: true, + includeNotes: true, +}); +``` + +The module also exports `getPassageDisplay(client, options)` for the +tree-shakable functional API, `getBibleStylesheets(config)` for applications +that install global resources once, and stable constants for the Bible CSS URL, +Untitled Serif font ID, and container attributes. + +## Behavior + +- The operation always requests HTML and always transforms it. Callers that + need text or raw API HTML use `getPassage`. +- Passage content and Bible version metadata are fetched concurrently when the + active version filter can decide from the numeric id alone. +- A language filter requires version metadata. In that case, the version is + validated before Scripture is fetched, and that same response supplies the + display model. No duplicate metadata request is made. +- Passage display model attribution is freshly requested for every operation + and is never cached by this API. This contract is scoped to + `getPassageDisplay`; it does not redefine existing React UI component + behavior. +- Non-empty `copyright` is preferred. Non-empty `promotional_content` is the + fallback. If neither exists, `MissingPassageAttributionError` rejects the + operation so a caller cannot receive a display-ready passage without legal + text. +- The font stylesheet URL uses font ID `1`, respects the configured API host, + and URL-encodes the app key. Untitled Serif is the intended first-choice font; + Source Serif 4 remains the CSS fallback. +- The operation does not create elements, inject stylesheets, mutate global + state, or cache data. + +## Environment behavior + +Browser transformation uses the platform `DOMParser`. Server transformation +uses the existing dynamic `jsdom` path and therefore requires the documented +optional peer dependency. Zero-configuration server dependency design is a +separate concern and does not expand this API. + +## Failure behavior + +HTTP, timeout, input-validation, version-filter, and transformation failures +flow through their existing paths. Missing attribution throws +`MissingPassageAttributionError`, which exposes: + +```ts +readonly code = "missing_passage_attribution"; +readonly versionId: number; +``` + +## Non-goals + +The first version does not expose text format, raw HTML, transformation opt-out, +theme selection, CSS overrides, DOM targets, resource injection, cache policy, +or transformer dependency injection. diff --git a/package.json b/package.json index dee7d94f..4af87dca 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "test": "turbo test", "test:coverage": "pnpm --filter @youversion/platform-core test:coverage && pnpm --filter @youversion/platform-react-hooks test:coverage && pnpm --filter @youversion/platform-react-ui test:coverage", "test:watch": "turbo test:watch", - "lint": "turbo build --filter=@youversion/platform-react-hooks && oxlint", + "lint": "turbo build --filter=@youversion/platform-react-hooks && pnpm check:cdn-css-major && oxlint", "typecheck": "turbo typecheck", "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", "changeset": "changeset", @@ -36,6 +36,7 @@ "size:why": "node scripts/bundle-visualize.mjs", "size:visualize": "node scripts/bundle-visualize.mjs", "check:tree-shaking": "node scripts/check-tree-shaking.mjs", + "check:cdn-css-major": "node scripts/check-cdn-css-major.mjs", "analyze": "node scripts/analyze.mjs", "analyze:select": "node scripts/analyze-select.mjs", "generate:i18n": "pnpm --filter @youversion/platform-react-ui generate:i18n", diff --git a/packages/core/AGENTS.md b/packages/core/AGENTS.md index aa856793..52e98000 100644 --- a/packages/core/AGENTS.md +++ b/packages/core/AGENTS.md @@ -22,6 +22,8 @@ bible-chapter.ts # getVersion/getChapter + shared id/book/chapter pa bible-reads.ts # Book/chapter/verse/VOTD reads (tree-shakable module) bible-versions.ts # Version listing (tree-shakable module) bible-passage.ts # Passage fetch (tree-shakable module) +bible-display-resources.ts # Dependency-free Bible CSS/font resource descriptors +bible-passage-display.ts # Declarative styled-passage model and resources languages.ts # LanguagesClient - language data (facade over languages-* modules) languages-language.ts # Single-language fetch (tree-shakable module) languages-list.ts # Language listing (tree-shakable module) @@ -51,6 +53,9 @@ index.ts # Main entry point (runtime-agnostic) - `setStorageItem()`, `removeStorageItem()`, `clearStorage()`: Throw-safe mutations for a resolved store (`setStorageItem` returns whether the write landed) - `transformBibleHtml`: Runtime-agnostic Bible HTML transformer (requires DOM adapters) - `TransformBibleHtmlOptions`: Options for DOM parsing and serialization +- `getPassageDisplay`: Fetch transformed HTML, current attribution, and declarative rendering resources +- `getBibleStylesheets`: Build the ordered Bible CSS and Fonts API stylesheet descriptors +- `MissingPassageAttributionError`: Fail-closed error when a version has no display attribution ### Browser CSS (`@youversion/platform-core/browser/styles/*`) - `index.css`: All-in-one import (fonts + theme + bible-reader) diff --git a/packages/core/README.md b/packages/core/README.md index 2fea9b66..b9cc3eb9 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -52,6 +52,29 @@ const passage = await bibleClient.getPassage(versions.data[0].id, 'JHN.3.16'); console.log(passage.content); ``` +### Display Bible HTML + +Use `getPassageDisplay` when you need transformed HTML together with current +attribution and the resources required to apply YouVersion's Bible styles: + +```ts +const display = await bibleClient.getPassageDisplay({ + versionId: 3034, + passageId: 'JHN.3.16', +}); + +for (const stylesheet of display.stylesheets) { + console.log(stylesheet.href); +} + +console.log(display.html); +console.log(display.attribution.text); +``` + +The result is declarative. The SDK does not insert the stylesheets or HTML into +your page. On a server, HTML transformation requires the optional `jsdom` peer +dependency. + ## Documentation and API Reference * [developers.youversion.com/sdks/typescript](https://developers.youversion.com/sdks/typescript) diff --git a/packages/core/package.json b/packages/core/package.json index 898b523a..339c4222 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -25,6 +25,10 @@ "!dist/bible-versions.cjs", "!dist/bible-passage.js", "!dist/bible-passage.cjs", + "!dist/bible-display-resources.js", + "!dist/bible-display-resources.cjs", + "!dist/bible-passage-display.js", + "!dist/bible-passage-display.cjs", "!dist/client.js", "!dist/client.cjs", "!dist/languages.js", diff --git a/packages/core/src/__tests__/bible-passage-display.test.ts b/packages/core/src/__tests__/bible-passage-display.test.ts new file mode 100644 index 00000000..e349f8f6 --- /dev/null +++ b/packages/core/src/__tests__/bible-passage-display.test.ts @@ -0,0 +1,299 @@ +import { http, HttpResponse } from 'msw'; +import { describe, expect, it } from 'vitest'; +import { + ApiClient, + BIBLE_CONTAINER_ATTRIBUTES, + BIBLE_CSS_STYLESHEET_URL, + BibleClient, + MissingPassageAttributionError, + getBibleStylesheets, + getPassageDisplay, +} from '../index'; +import { BiblePassageDisplaySchema } from '../schemas/passage-display'; +import { YouVersionPlatformConfiguration } from '../YouVersionPlatformConfiguration'; +import { mockNIVGen1Verse1PassageHTML } from './MockPassages'; +import { mockVersionKJV } from './MockVersions'; +import { server } from './setup'; + +const apiHost = process.env.YVP_API_HOST || 'api.youversion.com'; +const mockDisplayVersion = { ...mockVersionKJV, id: 111 }; + +function createApiClient(appKey = 'test app/key'): ApiClient { + return new ApiClient({ + apiHost, + appKey, + installationId: 'test-installation', + }); +} + +function createBibleClient(appKey = 'test app/key'): BibleClient { + return new BibleClient(createApiClient(appKey)); +} + +function clearVersionFilters(): void { + YouVersionPlatformConfiguration.permittedVersionIds = undefined; + YouVersionPlatformConfiguration.excludedVersionIds = undefined; + YouVersionPlatformConfiguration.permittedLanguageTags = undefined; +} + +function setupDisplayTest(): void { + clearVersionFilters(); + server.use( + http.get(`https://${apiHost}/v1/bibles/:id`, () => HttpResponse.json(mockDisplayVersion)), + ); +} + +describe.skipIf(Boolean(process.env.INTEGRATION_TESTS))('passage display model', () => { + it('returns transformed HTML, current attribution, stylesheets, and container attributes', async () => { + setupDisplayTest(); + const display = await createBibleClient().getPassageDisplay({ + versionId: 111, + passageId: 'GEN.1.1', + }); + + expect(BiblePassageDisplaySchema.safeParse(display).success).toBe(true); + expect(display.html).toContain('data-yv-transformed'); + expect(display.version).toEqual(mockDisplayVersion); + expect(display.attribution).toEqual({ + text: mockDisplayVersion.copyright, + source: 'copyright', + }); + expect(display.stylesheets).toEqual([ + { + kind: 'bible', + rel: 'stylesheet', + href: BIBLE_CSS_STYLESHEET_URL, + }, + { + kind: 'font', + rel: 'stylesheet', + href: `https://${apiHost}/v1/fonts/1/stylesheet?app_key=test%20app%2Fkey`, + }, + ]); + expect(display.containerAttributes).toEqual(BIBLE_CONTAINER_ATTRIBUTES); + }); + + it('forwards heading and note options to the passage request', async () => { + setupDisplayTest(); + const display = await createBibleClient().getPassageDisplay({ + versionId: 111, + passageId: 'ROM.1', + includeHeadings: true, + includeNotes: true, + }); + + expect(display.html).toContain('yv-h'); + expect(display.html).toContain('data-verse-footnote'); + }); + + it('supports the public tree-shakable function', async () => { + setupDisplayTest(); + const display = await getPassageDisplay(createApiClient(), { + versionId: 111, + passageId: 'GEN.1.1', + }); + + expect(display.html).toContain('data-yv-transformed'); + expect(display.attribution.source).toBe('copyright'); + }); + + it('requests fresh attribution for repeated display operations', async () => { + setupDisplayTest(); + let versionRequests = 0; + server.use( + http.get(`https://${apiHost}/v1/bibles/:id`, () => { + versionRequests += 1; + return HttpResponse.json({ + ...mockDisplayVersion, + copyright: versionRequests === 1 ? 'First attribution' : 'Second attribution', + }); + }), + ); + const client = createBibleClient(); + const options = { versionId: 111, passageId: 'GEN.1.1' }; + + const first = await client.getPassageDisplay(options); + const second = await client.getPassageDisplay(options); + + expect(first.attribution.text).toBe('First attribution'); + expect(second.attribution.text).toBe('Second attribution'); + expect(versionRequests).toBe(2); + }); + + it('falls back to promotional content when copyright is empty', async () => { + setupDisplayTest(); + server.use( + http.get(`https://${apiHost}/v1/bibles/:id`, () => + HttpResponse.json({ + ...mockDisplayVersion, + copyright: ' ', + promotional_content: 'Required long-form attribution', + }), + ), + ); + + const display = await createBibleClient().getPassageDisplay({ + versionId: 111, + passageId: 'GEN.1.1', + }); + + expect(display.attribution).toEqual({ + text: 'Required long-form attribution', + source: 'promotionalContent', + }); + }); + + it('fails closed when the version has no display attribution', async () => { + setupDisplayTest(); + server.use( + http.get(`https://${apiHost}/v1/bibles/:id`, () => + HttpResponse.json({ + ...mockDisplayVersion, + copyright: null, + promotional_content: null, + }), + ), + ); + + const result = createBibleClient().getPassageDisplay({ + versionId: 111, + passageId: 'GEN.1.1', + }); + + await expect(result).rejects.toBeInstanceOf(MissingPassageAttributionError); + await expect(result).rejects.toMatchObject({ + code: 'missing_passage_attribution', + versionId: 111, + }); + }); + + it('rejects malformed passage content before transforming it', async () => { + setupDisplayTest(); + server.use( + http.get(`https://${apiHost}/v1/bibles/:id/passages/:passageId`, () => + HttpResponse.json({ + ...mockNIVGen1Verse1PassageHTML, + content: null, + }), + ), + ); + + await expect( + createBibleClient().getPassageDisplay({ + versionId: 111, + passageId: 'GEN.1.1', + }), + ).rejects.toThrow('"expected": "string"'); + }); + + it('starts passage and version requests concurrently when no language filter is active', async () => { + setupDisplayTest(); + let requestCount = 0; + let releaseRequests: (() => void) | undefined; + const bothRequestsStarted = new Promise((resolve) => { + releaseRequests = resolve; + }); + const waitAtBarrier = async () => { + requestCount += 1; + if (requestCount === 2) releaseRequests?.(); + await bothRequestsStarted; + }; + + server.use( + http.get(`https://${apiHost}/v1/bibles/:id`, async () => { + await waitAtBarrier(); + return HttpResponse.json(mockDisplayVersion); + }), + http.get(`https://${apiHost}/v1/bibles/:id/passages/GEN.1.1`, async () => { + await waitAtBarrier(); + return HttpResponse.json(mockNIVGen1Verse1PassageHTML); + }), + ); + + await createBibleClient().getPassageDisplay({ + versionId: 111, + passageId: 'GEN.1.1', + }); + + expect(requestCount).toBe(2); + }); + + it('reuses the version request when a language filter requires metadata validation', async () => { + setupDisplayTest(); + YouVersionPlatformConfiguration.permittedLanguageTags = ['en']; + let versionRequests = 0; + let passageRequests = 0; + server.use( + http.get(`https://${apiHost}/v1/bibles/:id`, () => { + versionRequests += 1; + return HttpResponse.json(mockDisplayVersion); + }), + http.get(`https://${apiHost}/v1/bibles/:id/passages/GEN.1.1`, () => { + passageRequests += 1; + return HttpResponse.json(mockNIVGen1Verse1PassageHTML); + }), + ); + + await createBibleClient().getPassageDisplay({ + versionId: 111, + passageId: 'GEN.1.1', + }); + + expect(versionRequests).toBe(1); + expect(passageRequests).toBe(1); + }); + + it('refuses an excluded version before requesting passage content', async () => { + clearVersionFilters(); + YouVersionPlatformConfiguration.excludedVersionIds = [111]; + let passageRequests = 0; + server.use( + http.get(`https://${apiHost}/v1/bibles/:id/passages/:passageId`, () => { + passageRequests += 1; + return HttpResponse.json(mockNIVGen1Verse1PassageHTML); + }), + ); + + await expect( + createBibleClient().getPassageDisplay({ + versionId: 111, + passageId: 'GEN.1.1', + }), + ).rejects.toMatchObject({ status: 403 }); + expect(passageRequests).toBe(0); + }); + + it('validates display inputs', async () => { + clearVersionFilters(); + await expect( + createBibleClient().getPassageDisplay({ versionId: 0, passageId: 'GEN.1.1' }), + ).rejects.toThrow('Version ID must be a positive integer'); + await expect( + createBibleClient().getPassageDisplay({ versionId: 111, passageId: ' ' }), + ).rejects.toThrow('Passage ID must be a non-empty string'); + }); +}); + +describe('getBibleStylesheets', () => { + it('returns stable assets and respects a custom API host', () => { + expect( + getBibleStylesheets({ appKey: 'key +/reserved', apiHost: 'api-staging.youversion.com' }), + ).toEqual([ + { + kind: 'bible', + rel: 'stylesheet', + href: BIBLE_CSS_STYLESHEET_URL, + }, + { + kind: 'font', + rel: 'stylesheet', + href: 'https://api-staging.youversion.com/v1/fonts/1/stylesheet?app_key=key%20%2B%2Freserved', + }, + ]); + }); + + it('rejects missing app keys instead of returning an unusable font resource', () => { + expect(() => getBibleStylesheets({ appKey: '' })).toThrow('A non-empty app key is required'); + expect(() => getBibleStylesheets({ appKey: ' ' })).toThrow('A non-empty app key is required'); + }); +}); diff --git a/packages/core/src/__tests__/bible-version-filters.test.ts b/packages/core/src/__tests__/bible-version-filters.test.ts index 167599a1..7354d0cb 100644 --- a/packages/core/src/__tests__/bible-version-filters.test.ts +++ b/packages/core/src/__tests__/bible-version-filters.test.ts @@ -26,6 +26,30 @@ function clearFilters(): void { } describe('BibleClient version filter', () => { + it('validates passage booleans before filter-related requests', async () => { + clearFilters(); + YouVersionPlatformConfiguration.permittedLanguageTags = ['en']; + let requestCount = 0; + server.use( + http.get(`https://${apiHost}/v1/bibles/:id`, () => { + requestCount += 1; + return HttpResponse.json({ id: 111, language_tag: 'en' }); + }), + http.get(`https://${apiHost}/v1/bibles/:id/passages/:passageId`, () => { + requestCount += 1; + return HttpResponse.json({ id: 'GEN.1.1', content: '', reference: 'Genesis 1:1' }); + }), + ); + + await expect( + // @ts-expect-error - verifies runtime validation for unsafe JavaScript callers + bibleClient().getPassage(111, 'GEN.1.1', 'html', 'true'), + ).rejects.toThrow('"expected": "boolean"'); + expect(requestCount).toBe(0); + + clearFilters(); + }); + it('refuses an excluded version before fetching and walks pages for usable rows', async () => { YouVersionPlatformConfiguration.excludedVersionIds = [111]; YouVersionPlatformConfiguration.permittedVersionIds = [111, 206]; diff --git a/packages/core/src/bible-display-resources.ts b/packages/core/src/bible-display-resources.ts new file mode 100644 index 00000000..f338d3b3 --- /dev/null +++ b/packages/core/src/bible-display-resources.ts @@ -0,0 +1,34 @@ +import type { PassageStylesheet } from './schemas/passage-display'; +import type { ApiConfig } from './types'; + +/** + * The YouVersion stylesheet for Bible HTML. The path is a CSS compatibility + * major, not the version of this package; routine UI releases overwrite it. + */ +export const BIBLE_CSS_STYLESHEET_URL = 'https://cdn.youversion.com/platform/1/bible.css'; + +/** The permanent Fonts API identifier for Untitled Serif. */ +export const UNTITLED_SERIF_FONT_ID = 1; + +/** Returns the ordered stylesheet resources for displaying Bible HTML. */ +export function getBibleStylesheets( + config: Pick, +): readonly PassageStylesheet[] { + if (!config.appKey.trim()) { + throw new Error('A non-empty app key is required to build Bible stylesheet resources.'); + } + + const apiHost = config.apiHost || 'api.youversion.com'; + return [ + { + kind: 'bible', + rel: 'stylesheet', + href: BIBLE_CSS_STYLESHEET_URL, + }, + { + kind: 'font', + rel: 'stylesheet', + href: `https://${apiHost}/v1/fonts/${UNTITLED_SERIF_FONT_ID}/stylesheet?app_key=${encodeURIComponent(config.appKey)}`, + }, + ]; +} diff --git a/packages/core/src/bible-passage-display.ts b/packages/core/src/bible-passage-display.ts new file mode 100644 index 00000000..4bc8c3bf --- /dev/null +++ b/packages/core/src/bible-passage-display.ts @@ -0,0 +1,95 @@ +import type { ApiClient } from './client'; +import { getVersion } from './bible-chapter'; +import { getBibleStylesheets } from './bible-display-resources'; +import { getPassageForValidatedVersion } from './bible-passage'; +import { + GetPassageDisplayOptionsSchema, + type BiblePassageDisplay, + type GetPassageDisplayOptions, + type PassageAttribution, +} from './schemas/passage-display'; +import { BibleVersionSchema } from './schemas/version'; +import type { BibleVersion } from './types'; +import { + isLanguageFilterActive, + isVersionIdDecidablyUnusable, + throwUnusableBibleVersion, +} from './version-filters'; + +/** Attributes that scope Bible CSS to a passage container. */ +export const BIBLE_CONTAINER_ATTRIBUTES = Object.freeze({ + 'data-yv-sdk': '', + 'data-slot': 'yv-bible-renderer' as const, +}); + +export class MissingPassageAttributionError extends Error { + readonly code = 'missing_passage_attribution' as const; + readonly versionId: number; + + constructor(versionId: number) { + super(`Bible version ${versionId} has no display attribution.`); + this.name = 'MissingPassageAttributionError'; + this.versionId = versionId; + } +} + +function getPassageAttribution(version: BibleVersion): PassageAttribution { + if (version.copyright?.trim()) { + return { text: version.copyright, source: 'copyright' }; + } + if (version.promotional_content?.trim()) { + return { text: version.promotional_content, source: 'promotionalContent' }; + } + throw new MissingPassageAttributionError(version.id); +} + +async function fetchDisplayResources(client: ApiClient, options: GetPassageDisplayOptions) { + const fetchPassage = () => + getPassageForValidatedVersion( + client, + options.versionId, + options.passageId, + 'html', + options.includeHeadings, + options.includeNotes, + true, + ); + + if (isLanguageFilterActive()) { + const version = await getVersion(client, options.versionId); + const passage = await fetchPassage(); + return { passage, version }; + } + + if (isVersionIdDecidablyUnusable(options.versionId)) { + throwUnusableBibleVersion(); + } + + const [passage, version] = await Promise.all([ + fetchPassage(), + getVersion(client, options.versionId), + ]); + return { passage, version }; +} + +/** + * Fetches transformed passage HTML, current attribution, and declarative + * rendering resources without modifying the DOM or caching attribution. + */ +export async function getPassageDisplay( + client: ApiClient, + input: GetPassageDisplayOptions, +): Promise { + const options = GetPassageDisplayOptionsSchema.parse(input); + const stylesheets = getBibleStylesheets(client.config); + + const resources = await fetchDisplayResources(client, options); + const version = BibleVersionSchema.parse(resources.version); + return { + version, + html: resources.passage.content, + attribution: getPassageAttribution(version), + stylesheets, + containerAttributes: BIBLE_CONTAINER_ATTRIBUTES, + }; +} diff --git a/packages/core/src/bible-passage.ts b/packages/core/src/bible-passage.ts index 17d34ff6..30e07e9e 100644 --- a/packages/core/src/bible-passage.ts +++ b/packages/core/src/bible-passage.ts @@ -2,6 +2,7 @@ import * as z from 'zod/mini'; import type { ApiClient } from './client'; import { transformBibleHtml, type TransformBibleHtmlOptions } from './bible-html-transformer'; import { assertUsableVersion, parseBibleVersionId } from './bible-chapter'; +import { BiblePassageSchema } from './schemas/passage'; import type { BiblePassage } from './types'; type PassageQuery = { @@ -12,6 +13,27 @@ type PassageQuery = { const booleanSchema = z.boolean(); +function buildPassageQuery( + format: 'html' | 'text', + includeHeadings?: boolean, + includeNotes?: boolean, +): PassageQuery { + if (includeHeadings !== undefined) { + booleanSchema.parse(includeHeadings); + } + if (includeNotes !== undefined) { + booleanSchema.parse(includeNotes); + } + const params: PassageQuery = { format }; + if (includeHeadings !== undefined) { + params.include_headings = includeHeadings; + } + if (includeNotes !== undefined) { + params.include_notes = includeNotes; + } + return params; +} + async function getHtmlAdapters(): Promise { if (globalThis.DOMParser) { return { @@ -50,28 +72,37 @@ export async function getPassage( transform?: boolean, ): Promise { parseBibleVersionId(versionId); - if (include_headings !== undefined) { - booleanSchema.parse(include_headings); - } - if (include_notes !== undefined) { - booleanSchema.parse(include_notes); - } - const params: PassageQuery = { - format, - }; - if (include_headings !== undefined) { - params.include_headings = include_headings; - } - if (include_notes !== undefined) { - params.include_notes = include_notes; - } + const params = buildPassageQuery(format, include_headings, include_notes); await assertUsableVersion(client, versionId); - const passage = await client.get( - `/v1/bibles/${versionId}/passages/${usfm}`, - params, - ); + return fetchPassage(client, versionId, usfm, params, transform); +} + +/** @internal Fetches a passage after the caller has enforced the version filter. */ +export async function getPassageForValidatedVersion( + client: ApiClient, + versionId: number, + usfm: string, + format: 'html' | 'text' = 'html', + include_headings?: boolean, + include_notes?: boolean, + transform?: boolean, +): Promise { + parseBibleVersionId(versionId); + const params = buildPassageQuery(format, include_headings, include_notes); + return fetchPassage(client, versionId, usfm, params, transform); +} + +async function fetchPassage( + client: ApiClient, + versionId: number, + usfm: string, + params: PassageQuery, + transform?: boolean, +): Promise { + const response = await client.get(`/v1/bibles/${versionId}/passages/${usfm}`, params); + const passage = BiblePassageSchema.parse(response); - if (format === 'html' && transform !== false) { + if (params.format === 'html' && transform !== false) { const adapters = await getHtmlAdapters(); const { html } = transformBibleHtml(passage.content, adapters); return { ...passage, content: html }; diff --git a/packages/core/src/bible.ts b/packages/core/src/bible.ts index 38de5739..d68cd542 100644 --- a/packages/core/src/bible.ts +++ b/packages/core/src/bible.ts @@ -1,6 +1,8 @@ import type { ApiClient } from './client'; import { assertUsableVersion, getChapter, getVersion, parseBibleVersionId } from './bible-chapter'; import { getPassage } from './bible-passage'; +import { getPassageDisplay } from './bible-passage-display'; +import type { BiblePassageDisplay, GetPassageDisplayOptions } from './schemas/passage-display'; import { getAllVOTDs, getBook, @@ -195,6 +197,17 @@ export class BibleClient { ); } + /** + * Fetches transformed passage HTML, current attribution, and the resources + * required to display it with YouVersion's Bible styles. + * + * The returned model is declarative. This method does not inject styles, + * modify the DOM, or cache Bible version attribution. + */ + async getPassageDisplay(options: GetPassageDisplayOptions): Promise { + return getPassageDisplay(this.client, options); + } + /** * Fetches the indexing structure for a Bible version. * @param versionId The version ID. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 368d4a81..bbf8beae 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,6 +12,16 @@ export { } from './bible-reads'; export { getVersions, type GetVersionsOptions } from './bible-versions'; export { getPassage } from './bible-passage'; +export { + BIBLE_CSS_STYLESHEET_URL, + UNTITLED_SERIF_FONT_ID, + getBibleStylesheets, +} from './bible-display-resources'; +export { + BIBLE_CONTAINER_ATTRIBUTES, + MissingPassageAttributionError, + getPassageDisplay, +} from './bible-passage-display'; export { LanguagesClient } from './languages'; export { getLanguage } from './languages-language'; export { getLanguages, type GetLanguagesOptions } from './languages-list'; diff --git a/packages/core/src/schemas/index.ts b/packages/core/src/schemas/index.ts index 1dac7c2e..6e993b8e 100644 --- a/packages/core/src/schemas/index.ts +++ b/packages/core/src/schemas/index.ts @@ -11,6 +11,7 @@ export * from './language'; export * from './license'; export * from './organization'; export * from './passage'; +export * from './passage-display'; export * from './version'; export * from './verse'; export * from './video'; diff --git a/packages/core/src/schemas/passage-display.ts b/packages/core/src/schemas/passage-display.ts new file mode 100644 index 00000000..7074bf0e --- /dev/null +++ b/packages/core/src/schemas/passage-display.ts @@ -0,0 +1,48 @@ +import * as z from 'zod/mini'; +import { BibleVersionIdSchema, BibleVersionSchema, type BibleVersion } from './version'; + +export const GetPassageDisplayOptionsSchema = z.object({ + versionId: BibleVersionIdSchema, + passageId: z.string().check(z.trim(), z.minLength(1, 'Passage ID must be a non-empty string')), + includeHeadings: z.optional(z.boolean()), + includeNotes: z.optional(z.boolean()), +}); + +export const PassageAttributionSchema = z.object({ + text: z.string().check(z.minLength(1)), + source: z.enum(['copyright', 'promotionalContent']), +}); + +export const PassageStylesheetSchema = z.object({ + kind: z.enum(['bible', 'font']), + rel: z.literal('stylesheet'), + href: z.url(), +}); + +const BiblePassageContainerAttributesSchema = z.object({ + 'data-yv-sdk': z.literal(''), + 'data-slot': z.literal('yv-bible-renderer'), +}); + +export const BiblePassageDisplaySchema = z.object({ + version: BibleVersionSchema, + html: z.string(), + attribution: PassageAttributionSchema, + stylesheets: z.array(PassageStylesheetSchema), + containerAttributes: BiblePassageContainerAttributesSchema, +}); + +export type GetPassageDisplayOptions = Readonly>; +export type PassageAttribution = Readonly>; +export type PassageStylesheet = Readonly>; +export type BiblePassageDisplay = Readonly< + Omit< + z.infer, + 'version' | 'attribution' | 'stylesheets' | 'containerAttributes' + > & { + version: BibleVersion; + attribution: PassageAttribution; + stylesheets: readonly PassageStylesheet[]; + containerAttributes: Readonly>; + } +>; diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index d0997f83..c12b72e5 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -9,6 +9,12 @@ export type { BibleBook, BibleBookIntro, CANON } from '../schemas/book'; export type { BibleChapter } from '../schemas/chapter'; export type { BibleVerse } from '../schemas/verse'; export type { BiblePassage } from '../schemas/passage'; +export type { + BiblePassageDisplay, + GetPassageDisplayOptions, + PassageAttribution, + PassageStylesheet, +} from '../schemas/passage-display'; export type { VOTD } from '../schemas/votd'; export type { BibleIndex, diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index 79bc7385..eef4e14a 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -30,6 +30,8 @@ export default defineConfig({ 'src/bible-reads.ts', 'src/bible-versions.ts', 'src/bible-passage.ts', + 'src/bible-display-resources.ts', + 'src/bible-passage-display.ts', 'src/languages.ts', 'src/languages-language.ts', 'src/languages-list.ts', diff --git a/scripts/check-cdn-css-major.mjs b/scripts/check-cdn-css-major.mjs new file mode 100644 index 00000000..74cfcba7 --- /dev/null +++ b/scripts/check-cdn-css-major.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node + +import { readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const resourceSourcePath = join(repoRoot, 'packages/core/src/bible-display-resources.ts'); +const cssMajorPath = join(repoRoot, 'packages/ui/CDN_CSS_MAJOR_VERSION'); + +const cssMajor = readFileSync(cssMajorPath, 'utf8').trim(); +if (!/^\d+$/.test(cssMajor)) { + console.error( + `check-cdn-css-major: ${cssMajorPath} must contain one non-negative integer; got ${JSON.stringify(cssMajor)}.`, + ); + process.exit(1); +} + +const resourceSource = readFileSync(resourceSourcePath, 'utf8'); +const urlMatch = resourceSource.match( + /BIBLE_CSS_STYLESHEET_URL\s*=\s*['"]https:\/\/cdn\.youversion\.com\/platform\/(\d+)\/bible\.css['"]/, +); +if (!urlMatch) { + console.error( + `check-cdn-css-major: could not parse BIBLE_CSS_STYLESHEET_URL in ${resourceSourcePath}.`, + ); + process.exit(1); +} + +const urlMajor = urlMatch[1]; +if (urlMajor !== cssMajor) { + console.error( + `check-cdn-css-major: core returns CSS major ${urlMajor}, but packages/ui/CDN_CSS_MAJOR_VERSION is ${cssMajor}. Update both in the same change.`, + ); + process.exit(1); +} + +console.log(`check-cdn-css-major: core and CDN publisher use CSS major ${cssMajor}.`); diff --git a/scripts/check-tree-shaking.mjs b/scripts/check-tree-shaking.mjs index e4381bbd..4034d70b 100644 --- a/scripts/check-tree-shaking.mjs +++ b/scripts/check-tree-shaking.mjs @@ -84,7 +84,7 @@ function assertPackageSideEffects() { return errors; } -/** @type {Array<{ package: string; external: string[]; narrow: object; controls: object[]; fullBarrel: object }>} */ +/** @type {Array<{ package: string; external: string[]; narrow: object; controls: object[]; focused?: object[]; fullBarrel: object }>} */ const CHECKS = [ { package: '@youversion/platform-core', @@ -115,6 +115,26 @@ const CHECKS = [ present: ['Invalid state parameter - possible CSRF attack'], }, ], + focused: [ + { + label: 'getBibleStylesheets only', + source: `import { getBibleStylesheets } from '@youversion/platform-core'; +export const stylesheets = getBibleStylesheets({ appKey: 'fixture' }); +`, + absent: [ + 'missing_passage_attribution', + 'Passage ID must be a non-empty string', + 'Server-side HTML transformation requires "jsdom".', + ], + maxBytes: 5_000, + control: { + label: 'getPassageDisplay', + source: `import { getPassageDisplay } from '@youversion/platform-core'; +export { getPassageDisplay }; +`, + }, + }, + ], fullBarrel: { label: 'multi-export barrel', source: `import { @@ -242,6 +262,48 @@ async function runPackageCheck(check) { } } + for (const focused of check.focused ?? []) { + const bundle = await bundleConsumer(focused.source, check.external); + const leaked = focused.absent.filter((sentinel) => bundle.text.includes(sentinel)); + const sizePass = bundle.bytes <= focused.maxBytes; + const pass = leaked.length === 0 && sizePass; + const details = []; + if (leaked.length > 0) { + details.push(`leaked: ${leaked.map((s) => JSON.stringify(s)).join(', ')}`); + } + if (!sizePass) { + details.push(`${bundle.bytes.toLocaleString()} B > ${focused.maxBytes.toLocaleString()} B`); + } + rows.push({ + kind: 'focused', + label: focused.label, + bytes: bundle.bytes, + pass, + detail: pass ? 'isolated from display orchestration' : details.join('; '), + }); + if (!pass) { + errors.push(`${check.package} focused import "${focused.label}" is not isolated`); + } + + const control = await bundleConsumer(focused.control.source, check.external); + const missing = focused.absent.filter((sentinel) => !control.text.includes(sentinel)); + const controlPass = missing.length === 0; + rows.push({ + kind: 'control', + label: `${focused.control.label} sentinel control`, + bytes: control.bytes, + pass: controlPass, + detail: controlPass + ? 'all excluded sentinels present' + : `missing: ${missing.map((s) => JSON.stringify(s)).join(', ')}`, + }); + if (!controlPass) { + errors.push( + `${check.package} focused control "${focused.control.label}" has stale sentinels`, + ); + } + } + const full = await bundleConsumer(check.fullBarrel.source, check.external); const sizePass = narrow.bytes < full.bytes; rows.push({