Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fuzzy-lions-attribute.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 3 additions & 3 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 8 additions & 8 deletions docs/passage-display-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down
25 changes: 25 additions & 0 deletions packages/core/src/__tests__/bible-passage-display.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
BibleClient,
MissingPassageAttributionError,
getBibleStylesheets,
getPassageAttribution,
getPassageDisplay,
} from '../index';
import { BiblePassageDisplaySchema } from '../schemas/passage-display';
Expand Down Expand Up @@ -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({
Expand Down
10 changes: 7 additions & 3 deletions packages/core/src/bible-passage-display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
};
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export {
export {
BIBLE_CONTAINER_ATTRIBUTES,
MissingPassageAttributionError,
getPassageAttribution,
getPassageDisplay,
} from './bible-passage-display';
export { LanguagesClient } from './languages';
Expand Down
58 changes: 55 additions & 3 deletions packages/ui/src/components/bible-card.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -66,6 +66,7 @@ function renderCard(
onVersionChange?: (id: number) => void;
maxWidth?: number | '100%';
hostWidth?: number;
version?: BibleVersion;
} = {},
) {
const {
Expand All @@ -76,11 +77,12 @@ function renderCard(
onVersionChange,
maxWidth,
hostWidth,
version = mockVersion,
} = extra;
const card = (
<HookOverrideProvider
overrides={{
useVersion: () => idleVersion(),
useVersion: () => idleVersion(version),
usePassage: () => passage,
}}
>
Expand Down Expand Up @@ -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: '<strong>Promotional attribution</strong>',
},
});

expect(
within(container).getByText('<strong>Promotional attribution</strong>'),
).toBeInTheDocument();
expect(container.querySelector('strong')).toBeNull();
Comment thread
cameronapak marked this conversation as resolved.
},
);

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();
Expand Down
9 changes: 6 additions & 3 deletions packages/ui/src/components/bible-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -143,11 +144,11 @@ function BibleCardVersionPicker({
);
}

function BibleCardFooter({ copyright }: { copyright?: string | null }): React.ReactNode {
function BibleCardFooter({ attribution }: { attribution?: string | null }): React.ReactNode {
return (
<div className="yv:grid yv:grid-cols-[1fr_auto] yv:gap-4 yv:items-center yv:mt-4">
<p className="yv:text-balance yv:text-muted-foreground yv:justify-self-start yv:font-bold yv:text-[0.5rem]">
<bdi dir="auto">{copyright || ''}</bdi>
<bdi dir="auto">{attribution || ''}</bdi>
</p>

<div className="yv:justify-self-end">
Expand Down Expand Up @@ -275,7 +276,9 @@ export function BibleCard({
/>
</AnimatedHeight>

<BibleCardFooter copyright={!passageError ? version?.copyright : null} />
<BibleCardFooter
attribution={!passageError && version ? getPassageAttribution(version)?.text : null}
/>
</div>
</section>
);
Expand Down
44 changes: 44 additions & 0 deletions packages/ui/src/components/bible-reader-controlled.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,50 @@ function renderReader(props: Partial<BibleReaderRootProps> = {}, 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: '<strong>Promotional attribution</strong>',
expected: '<strong>Promotional attribution</strong>',
},
])(
'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<HTMLElement>(`.yv-v[v="${verse}"]`);
const el = els[els.length - 1];
Expand Down
12 changes: 7 additions & 5 deletions packages/ui/src/components/bible-reader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1281,18 +1283,18 @@ function Content() {
</div>
)}

{version?.copyright && (
{attribution && (
<footer
className="yv:flex yv:flex-col yv:items-center yv:gap-2"
style={{ fontSize: currentFontSize }}
>
<p className="yv:text-balance yv:text-[0.75em] yv:text-center yv:text-muted-foreground">
<bdi dir="auto">{version.copyright}</bdi>
<bdi dir="auto">{attribution.text}</bdi>
</p>
{version.publisher_url ? (
{publisherUrl ? (
<a
className="yv:flex yv:items-center yv:gap-1 yv:text-xs yv:font-bold"
href={version.publisher_url}
href={publisherUrl}
target="_blank"
rel="noopener noreferrer"
>
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export {
getLanguages,
getLocalStorage,
getPassage,
getPassageAttribution,
getPassageDisplay,
getSessionStorage,
getSuggestedQueries,
Expand Down
Loading