diff --git a/.changeset/fuzzy-lions-attribute.md b/.changeset/fuzzy-lions-attribute.md new file mode 100644 index 00000000..38cf3da9 --- /dev/null +++ b/.changeset/fuzzy-lions-attribute.md @@ -0,0 +1,6 @@ +--- +'@youversion/platform-core': minor +'@youversion/platform-react-ui': patch +--- + +Export shared Bible version attribution selection and fall back to promotional content in BibleCard and BibleReader when copyright is blank. diff --git a/CONTEXT.md b/CONTEXT.md index 8b70b161..b46c7d0c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -49,9 +49,9 @@ 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. +short copyright text is preferred; promotional content is its fallback. Core +and React UI use the same attribution selection. `getPassageDisplay` fails +closed when neither value exists; React UI components omit the attribution. _Avoid_: copyright HTML. ## Bible version diff --git a/docs/passage-display-api.md b/docs/passage-display-api.md index b8195f29..658a490b 100644 --- a/docs/passage-display-api.md +++ b/docs/passage-display-api.md @@ -51,8 +51,9 @@ const display = await bibleClient.getPassageDisplay({ 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. +that install global resources once, `getPassageAttribution(version)` for shared +attribution selection, and stable constants for the Bible CSS URL, Untitled +Serif font ID, and container attributes. ## Behavior @@ -64,13 +65,12 @@ Untitled Serif font ID, and container attributes. 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. + and is never cached by this API. - 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. + fallback. `getPassageAttribution` returns `null` if neither exists. React UI + components use this shared selection and omit missing attribution. + `getPassageDisplay` instead rejects with `MissingPassageAttributionError` 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. diff --git a/packages/core/src/__tests__/bible-passage-display.test.ts b/packages/core/src/__tests__/bible-passage-display.test.ts index 63973dc6..6f85cdc6 100644 --- a/packages/core/src/__tests__/bible-passage-display.test.ts +++ b/packages/core/src/__tests__/bible-passage-display.test.ts @@ -7,6 +7,7 @@ import { BibleClient, MissingPassageAttributionError, getBibleStylesheets, + getPassageAttribution, getPassageDisplay, } from '../index'; import { BiblePassageDisplaySchema } from '../schemas/passage-display'; @@ -44,6 +45,30 @@ function setupDisplayTest(): void { } describe.skipIf(Boolean(process.env.INTEGRATION_TESTS))('passage display model', () => { + it('selects copyright, then promotional content, without failing on missing attribution', () => { + expect( + getPassageAttribution({ + ...mockDisplayVersion, + copyright: 'Preferred copyright', + promotional_content: 'Fallback promotional content', + }), + ).toEqual({ text: 'Preferred copyright', source: 'copyright' }); + expect( + getPassageAttribution({ + ...mockDisplayVersion, + copyright: ' ', + promotional_content: 'Fallback promotional content', + }), + ).toEqual({ text: 'Fallback promotional content', source: 'promotionalContent' }); + expect( + getPassageAttribution({ + ...mockDisplayVersion, + copyright: null, + promotional_content: null, + }), + ).toBeNull(); + }); + it('returns transformed HTML, current attribution, stylesheets, and container attributes', async () => { setupDisplayTest(); const display = await createBibleClient().getPassageDisplay({ diff --git a/packages/core/src/bible-passage-display.ts b/packages/core/src/bible-passage-display.ts index 4bc8c3bf..4b7fcf72 100644 --- a/packages/core/src/bible-passage-display.ts +++ b/packages/core/src/bible-passage-display.ts @@ -33,14 +33,15 @@ export class MissingPassageAttributionError extends Error { } } -function getPassageAttribution(version: BibleVersion): PassageAttribution { +/** Selects the preferred non-empty attribution for a Bible version. */ +export function getPassageAttribution(version: BibleVersion): PassageAttribution | null { 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); + return null; } async function fetchDisplayResources(client: ApiClient, options: GetPassageDisplayOptions) { @@ -85,10 +86,13 @@ export async function getPassageDisplay( const resources = await fetchDisplayResources(client, options); const version = BibleVersionSchema.parse(resources.version); + const attribution = getPassageAttribution(version); + if (!attribution) throw new MissingPassageAttributionError(version.id); + return { version, html: resources.passage.content, - attribution: getPassageAttribution(version), + attribution, stylesheets, containerAttributes: BIBLE_CONTAINER_ATTRIBUTES, }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9f1ffa90..1ddb439f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -20,6 +20,7 @@ export { export { BIBLE_CONTAINER_ATTRIBUTES, MissingPassageAttributionError, + getPassageAttribution, getPassageDisplay, } from './bible-passage-display'; export { LanguagesClient } from './languages'; diff --git a/packages/ui/src/components/bible-card.test.tsx b/packages/ui/src/components/bible-card.test.tsx index bf3a76e4..5c62f219 100644 --- a/packages/ui/src/components/bible-card.test.tsx +++ b/packages/ui/src/components/bible-card.test.tsx @@ -37,9 +37,9 @@ const mockVersion: BibleVersion = { youversion_deep_link: 'https://bible.com/versions/3034', }; -function idleVersion(): UseVersionResult { +function idleVersion(version: BibleVersion = mockVersion): UseVersionResult { return { - version: mockVersion, + version, loading: false, error: null, refetch: () => undefined, @@ -66,6 +66,7 @@ function renderCard( onVersionChange?: (id: number) => void; maxWidth?: number | '100%'; hostWidth?: number; + version?: BibleVersion; } = {}, ) { const { @@ -76,11 +77,12 @@ function renderCard( onVersionChange, maxWidth, hostWidth, + version = mockVersion, } = extra; const card = ( idleVersion(), + useVersion: () => idleVersion(version), usePassage: () => passage, }} > @@ -112,6 +114,56 @@ const multiVersePassage: BiblePassage = { }; const highlights: Highlight[] = [{ version_id: 111, passage_id: 'JHN.1.2', color: YELLOW }]; +const loadedPassage = passageResult({ passage: mockPassage, loading: false }); + +it('prefers non-whitespace copyright over promotional content', () => { + const { container } = renderCard(loadedPassage, { + version: { + ...mockVersion, + copyright: 'Copyright attribution', + promotional_content: 'Promotional attribution', + }, + }); + + expect(within(container).getByText('Copyright attribution')).toBeInTheDocument(); + expect(within(container).queryByText('Promotional attribution')).toBeNull(); +}); + +it.each([null, undefined, '', ' \n '])( + 'falls back to promotional content when copyright is %s', + (copyright) => { + const { container } = renderCard(loadedPassage, { + version: { + ...mockVersion, + copyright, + promotional_content: 'Promotional attribution', + }, + }); + + expect( + within(container).getByText('Promotional attribution'), + ).toBeInTheDocument(); + expect(container.querySelector('strong')).toBeNull(); + }, +); + +it.each([ + { state: 'absent', version: mockVersion }, + { + state: 'blank', + version: { ...mockVersion, copyright: ' ', promotional_content: '\n' }, + }, +])('renders no attribution when both fields are $state', ({ version }) => { + const { container } = renderCard(loadedPassage, { + version, + }); + + const attribution = requireHtmlElement( + container.querySelector('section > div > div:last-child p bdi'), + ); + expect(attribution).toHaveTextContent(/^\s*$/); +}); + describe('BibleCard - Delayed spinner', () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/packages/ui/src/components/bible-card.tsx b/packages/ui/src/components/bible-card.tsx index 858db154..82dcf756 100644 --- a/packages/ui/src/components/bible-card.tsx +++ b/packages/ui/src/components/bible-card.tsx @@ -4,6 +4,7 @@ import type { CSSProperties } from 'react'; import { usePassage, useVersion, useTheme } from '@youversion/platform-react-hooks'; import { DEFAULT_LICENSE_FREE_BIBLE_VERSION, + getPassageAttribution, type Highlight, type TextDirection, } from '@youversion/platform-core'; @@ -143,11 +144,11 @@ function BibleCardVersionPicker({ ); } -function BibleCardFooter({ copyright }: { copyright?: string | null }): React.ReactNode { +function BibleCardFooter({ attribution }: { attribution?: string | null }): React.ReactNode { return (

- {copyright || ''} + {attribution || ''}

@@ -275,7 +276,9 @@ export function BibleCard({ /> - +
); diff --git a/packages/ui/src/components/bible-reader-controlled.test.tsx b/packages/ui/src/components/bible-reader-controlled.test.tsx index 8111d4e4..27cbfdca 100644 --- a/packages/ui/src/components/bible-reader-controlled.test.tsx +++ b/packages/ui/src/components/bible-reader-controlled.test.tsx @@ -193,6 +193,50 @@ function renderReader(props: Partial = {}, overrides?: Hoo return render(wrapReader(props, overrides)); } +it.each([ + { + state: 'copyright and promotional content are populated', + copyright: 'Copyright attribution', + promotionalContent: 'Promotional attribution', + expected: 'Copyright attribution', + }, + { + state: 'copyright is blank', + copyright: ' \n ', + promotionalContent: 'Promotional attribution', + expected: 'Promotional attribution', + }, +])( + 'renders the selected version attribution when $state', + ({ copyright, promotionalContent, expected }) => { + const { container } = renderReader( + {}, + { + ...defaultOverrides(), + useVersion: () => ({ + version: { + ...mockVersion, + copyright, + promotional_content: promotionalContent, + }, + loading: false, + error: null, + refetch: () => undefined, + }), + }, + ); + + expect(screen.getByText(expected)).toBeInTheDocument(); + expect(container.querySelector('footer strong')).toBeNull(); + }, +); + +it('renders no attribution footer when both fields are absent', () => { + const { container } = renderReader(); + + expect(container.querySelector('footer')).toBeNull(); +}); + function getVerseEl(container: HTMLElement, verse: number): HTMLElement { const els = container.querySelectorAll(`.yv-v[v="${verse}"]`); const el = els[els.length - 1]; diff --git a/packages/ui/src/components/bible-reader.tsx b/packages/ui/src/components/bible-reader.tsx index 65dcb95f..c074b518 100644 --- a/packages/ui/src/components/bible-reader.tsx +++ b/packages/ui/src/components/bible-reader.tsx @@ -59,7 +59,7 @@ import { BibleTextView, getCleanVerseText, type FootnoteData } from './verse'; import { buildVerseReference, buildVerseShareText, joinVerseTexts } from '@/lib/verse-share'; import { isHighlightsLive } from '@/lib/feature-flags'; import { YvComponentStyles } from '@/lib/yv-styles-components'; -import { YouVersionPlatformConfiguration } from '@youversion/platform-core'; +import { YouVersionPlatformConfiguration, getPassageAttribution } from '@youversion/platform-core'; import { BibleReaderSearch, type BibleReaderSearchPressData } from './bible-reader-search'; import { useTransientVerseFocus, type VerseFocusRequest } from '@/lib/use-transient-verse-focus'; import { BibleReaderNavigation } from './bible-reader-navigation'; @@ -801,6 +801,8 @@ function Content() { scriptureDirection, } = useBibleReaderContext(); const { version } = useVersion(versionId); + const attribution = version ? getPassageAttribution(version) : null; + const publisherUrl = version?.publisher_url; const bookData = useMemo(() => { return booksData.find((b) => b.id === book); @@ -1281,18 +1283,18 @@ function Content() {
)} - {version?.copyright && ( + {attribution && (