diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4f5087d..71b73f2d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,9 +23,10 @@ jobs: run: pnpm lint:native-i18n - name: Run no-raw-color oxlint regression tests run: pnpm lint:no-raw-color - - name: Run iOS development tooling tests - run: pnpm test:ios-dev - - name: Run CI script regression tests + # `test:ci-scripts` runs the bash signoff suite plus every scripts/*.test.mjs, + # which includes the iOS tooling suite, so a separate step for it would run + # that suite twice. `pnpm test:ios-dev` stays in package.json for local use. + - name: Run repo script regression tests run: pnpm test:ci-scripts typecheck: diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..34601046 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,357 @@ +# Changelog + +All notable changes to the YouVersion Platform React Native (Expo) SDK. + +`@youversion/platform-react-native-expo-core` and +`@youversion/platform-react-native-expo-ui` are a `fixed` group in `.changeset/config.json`, +so they share a version number and release together. Each entry below notes which packages +it affected. + +Generated from the per-package changelogs by `scripts/build-root-changelog.mjs`. Edit those, +or the changeset, rather than this file. + +## 2.0.0 + +### Major Changes + +- _(all packages)_ 8bb6a02: Require Expo SDK 57 (`expo >=57.0.0 <58.0.0`, `react-native >=0.86.0`). Expo 56 is no longer supported. Consumers must upgrade Expo and align native peers (`react-native-reanimated >=4.4.0`, `react-native-worklets >=0.9.1`) before installing this release. + +### Minor Changes + +- _(all packages)_ 7e7fcec: VerseOfTheDay is now hybrid: native chrome (share, attribution, Card surface) wrapping the DOM BibleTextView for scripture (YPE-5440 / RNV2-2). Public props are unchanged. Light/dark resolve on native before they cross the bridge. Share uses the native Share API. Native share and the header reference honor the provider version filter lists, so a refused version cannot leak through Share. + +- _(@youversion/platform-react-native-expo-core)_ 8bb6a02: feat: wrap platform-core SearchClient (YPE-5746). Public `useSearch` and `biblePassageAnchorFromPassageId`. A verse hit `id` is a passage id. + +- _(all packages)_ dffc732: feat: draw a native Reader toolbar on iOS/Android instead of the in-WebView Web SDK toolbar (YPE-5712 / RNV2-9a). Avatar, chapter (with chevrons), version, and settings open the existing sheets. + +- _(all packages)_ 8bb6a02: BibleCard is now hybrid: native Card chrome (reference, version control, copyright, logo) wrapping the DOM BibleTextView for scripture (YPE-5830 / RNV2-3). Public props are unchanged. maxWidth defaults to 700 on the native Card and no longer crosses the bridge. + +- _(@youversion/platform-react-native-expo-ui)_ 8bb6a02: feat: add BibleReaderNavigation for chapter jumps (YPE-5745). Host can request a version/book/chapter before the Reader mounts. Scroll-to-verse and focus are not in this release. + +- _(@youversion/platform-react-native-expo-ui)_ 8bb6a02: feat: focus a verse from Reader Search + + Search taps and `focusReference` scroll to the verse through the Web SDK. A chapter `request` still opens the chapter without focusing. + +- _(@youversion/platform-react-native-expo-ui)_ 8bb6a02: feat: add native Reader Search (YPE-5748). Search sheet and a Search icon in the native toolbar. The header has the field and Cancel, the snippet sits above the title, and suggestions use the version language. A result tap loads the chapter; scroll-to-verse stays with YPE-5747. + +- _(@youversion/platform-react-native-expo-ui)_ 8bb6a02: Replace the version picker's Expo DOM content with a native React Native picker that keeps both the versions and language panels mounted on device (YPE-5834). + + `BibleVersionPickerSheet` no longer accepts a `dom` prop. The picker is native, so there is no Expo DOM surface to configure. This ships in the same major release as the Expo SDK 57 peer requirement. + +- _(@youversion/platform-react-native-expo-ui)_ 8bb6a02: Replace the chapter picker's Expo DOM content with a native React Native picker and export `BibleChapterPicker` for custom presentation (YPE-5836). + + `BibleChapterPickerSheet` no longer accepts a `dom` prop. The picker is native, so there is no Expo DOM surface to configure. This ships in the same major release as the Expo SDK 57 peer requirement. + +- _(@youversion/platform-react-native-expo-ui)_ 8bb6a02: feat: match native Reader chrome to Swift PR 268 chapter and version capsules (YPE-5953). Previous and next sit in the chapter capsule. The avatar is gone. Fonts and settings, sign-in, and sign-out are in the More menu. `onSettingsPress`, `onSignInPress`, and `onSignOutPress` are unchanged. Open `reader-toolbar-menu`, then `reader-toolbar-settings`, `reader-toolbar-sign-in`, or `reader-toolbar-sign-out`. `reader-toolbar-avatar` and `reader-toolbar-user` are removed. Search is a separate change. + +### Patch Changes + +- _(@youversion/platform-react-native-expo-ui)_ 8bb6a02: Search results scroll inside the sheet. A drag on the list no longer moves the sheet. The handle, the backdrop, and Cancel still close it. + +- _(@youversion/platform-react-native-expo-ui)_ 8bb6a02: Untitled Serif from the Fonts API registers on iOS and Android. The bundled Source Serif 4 fallback registers under its own names instead of the Untitled Serif names, because native Expo Font cannot replace a loaded face. When the Fonts API request or one of its font files fails, native serif text uses Source Serif 4, and a later request that succeeds after an `appKey` or `apiHost` change still registers Untitled Serif. + +- _(@youversion/platform-react-native-expo-ui)_ 8bb6a02: Search suggestions and trending wait for the Bible version language. A missing language no longer sends `language_ranges[]=*`, which the Search API rejects. + +- _(@youversion/platform-react-native-expo-ui)_ b583fc6: Sync localization from platform-localization (1fe6b5d): update 3 keys in en. + +- _(@youversion/platform-react-native-expo-ui)_ ea09740: Sync localization from platform-localization (21c52c1): update 7 keys in en. + +- _(@youversion/platform-react-native-expo-ui)_ 9b95630: Sync localization from platform-localization (413e2e7): update 1 keys in en. + +- _(@youversion/platform-react-native-expo-ui)_ a9ebfe4: Sync localization from platform-localization (b767ef1): update 10 keys in en, es. + +- _(@youversion/platform-react-native-expo-ui)_ 5586004: Sync localization from platform-localization (c99c472): update 1 keys in en. + +- _(@youversion/platform-react-native-expo-ui)_ 7b5bf5c: Sync localization from platform-localization (d8ad211): update 5 keys in en. + +- _(@youversion/platform-react-native-expo-ui)_ 6fd7a23: Sync localization from platform-localization (e4c7700): update 3 keys in en. + +- _(@youversion/platform-react-native-expo-ui)_ 4ad729d: Sync localization from platform-localization (ee03e27): update 49 keys in es. + +- _(@youversion/platform-react-native-expo-ui)_ a4184af: Sync localization from platform-localization (f7a8ff2): update 5 keys in en. + +- _(@youversion/platform-react-native-expo-ui)_ 8bb6a02: YouVersionAuthButton now composes the design-system Button `outline` look for press, radius, type, 1px border, and label color. Fill stays the forced scheme `background` so the Bible App logo stays readable in dark. Padding, logo gap, and logo size match the Swift sign-in button (20 / 12, 8px gap, 24px logo). The pill hugs its content. The host places it. The label can wrap to two lines. `outline`, `radius`, and `size` are no longer public props (YPE-5833 / RNV2-6). + +- _(@youversion/platform-react-native-expo-ui)_ 8bb6a02: Render Bible reader font settings as native controls inside the settings sheet (YPE-5835). + +## 1.6.0 + +### Minor Changes + +- _(@youversion/platform-react-native-expo-ui)_ 040d231: feat: name `tokens.radius` by role — `surface` (16, web `rounded-2xl`) and `full` (pill) — and drop the `sm`/`md`/`lg`/`xl` steps ported from web's shadcn calc ramp, which only Button read and which rendered as a pill anyway. Adds the internal `Card` compound primitive on `radius.surface`. + + ## Migration + + `tokens.radius.md` (and `sm` / `lg` / `xl`) maps to `tokens.radius.full`. Surfaces use `tokens.radius.surface`. + + ## Released as minor, not major + + `radius` reaches consumers through the public `getTokens` / `useTokens` / `Tokens` + surface, so dropping the size keys is technically a breaking type change. It ships + as `minor` deliberately: the ramp went public one release ago in 1.5.0, the design + system is still being built out, and no consumer reads `tokens.radius` yet. + +### Patch Changes + +- _(@youversion/platform-react-native-expo-ui)_ 233dc86: fix: YouVersionProvider holds children until bundled Inter registers, then native text always draws Inter. First paint waits on that local load. Theme toggles must not ellipsize button labels or drop a line from multi-line text. + +- _(@youversion/platform-react-native-expo-ui)_ a32975d: Sync localization from platform-localization (0ae8cca): update 29 keys in en. + +- _(@youversion/platform-react-native-expo-ui)_ cd9b2ff: Sync localization from platform-localization (5225dbc): update 1 keys in en. + +- _(@youversion/platform-react-native-expo-ui)_ 2dc28cc: fix: derive native sheet chrome from design tokens (YPE-5271). Handle, muted labels, stroke, and shadows now resolve from palette / semantic tokens instead of copied hex. A small shift on the handle and supporting labels is expected where the old hex sat off-palette. + +- _(@youversion/platform-react-native-expo-ui)_ b57174d: Replace auth-button hex with design tokens (YPE-5272). Border, fill, and label colors resolve from border/background/foreground for the background prop's scheme. Borders and white surfaces stay byte-identical; pure-black values move to #121212. + +- _(@youversion/platform-react-native-expo-ui)_ 52b742e: feat: add internal Tabs, Accordion, and Popover primitives (YPE-5439 / RNV2-1). Token-styled wrappers around `@rn-primitives` 1.4.0 (same line as the existing portal). Picker/chrome use only; not public API. + +- _(@youversion/platform-react-native-expo-ui)_ 3247134: fix: resolve BibleTextView light/dark on native and pass that scheme into the in-WebView provider (YPE-5442 / RNV2-4). Font size and family stay consumer props; `fontFamily` still crosses as an ADR 0009 token, not a `getTokens` value. + + Standalone `BibleTextView` now uses the same content-sized embed defaults as `BibleCard` / `VerseOfTheDay` (`matchContents`, `flex: 0`, scroll off). Pass `dom={{ matchContents: false }}` to opt out and size with flex styles. + +## 1.5.0 + +### Minor Changes + +- _(@youversion/platform-react-native-expo-core)_ 80d3718: Bible content is cached on device (YPE-5262). The Bible Content Client reads a per-version MMKV store before fetching and writes each 2xx body back with the lifetime the response's `Cache-Control` declares — `max-age` less `Age`, seven days when no usable `max-age` is present, and no write at all for `no-cache`, `no-store`, or a lifetime of zero — so previously read chapters, pickers, and BibleCard content render without a network, including offline. Content is scoped to the app key and survives sign-out. + +- _(all packages)_ 553757e: Native owns Bible content requests (YPE-5510). Core exposes a Bible Content Client and a required `fetchBibleContent` action on the provider context; the UI package weaves it under each DOM component's `fetch`, so eligible `/v1/bibles/*` requests cross the bridge and run natively with `X-YVP-Sdk: ReactNativeSDK=` headers. The SDK version stamp moves from ui to core (`@youversion/platform-react-native-expo-core/sdk-version`). + +- _(all packages)_ 5805e95: feat: replace the native highlight apply palette with the six YPE-5058 hexes, mix verse-action dots against `SHEET_SURFACE` via `mixSrgb`, and pin `@youversion/platform-react-ui` to 2.12.0 so reader fill and Words of Christ match that release (YPE-5059). Apply stays palette-only. Leftover `fffe00` still paints and clears. WOC stays unmixed `#94000C` / `#e4bfc2`. + +- _(all packages)_ 3dfe296: feat: load Inter, Untitled Serif, and Source Serif 4 from `YouVersionProvider` (YPE-5266) + + `YouVersionProvider` registers brand fonts in the background with `expo-font`. Untitled Serif is fetched from the Fonts API (`GET /v1/fonts/1` with `X-YVP-App-Key`). Inter and Source Serif 4 come from Google Font packages. There is no opt-out and no public fonts-ready hook. Children still render while fonts load. If Untitled Serif cannot load, native serif falls back to Source Serif 4. + + ## Action required + + Install the new `expo-font` peer and rebuild the dev client. A JS-only reload shows `Cannot find native module`. + + ```bash + npx expo install expo-font + ``` + +- _(@youversion/platform-react-native-expo-ui)_ a1f5751: feat: export `getTokens` with locally owned light/dark design tokens ported from the web theme (YPE-5264). Palette and semantic maps stay internal. Does not change live sheet or auth-button colors. + +- _(@youversion/platform-react-native-expo-ui)_ 3758182: feat: export `useTokens` from YouVersionProvider theme context (YPE-5265). + +- _(@youversion/platform-react-native-expo-ui)_ 9c8003b: feat: expose `typography` size scale on `Tokens` via `useTokens` (YPE-5268). + +## 1.4.0 + +### Minor Changes + +- _(all packages)_ dd11c3f: Export `hookOverrides` on `YouVersionProvider` as a test seam, plus the `HookOverrides` and `AuthContextValue` types. Production apps leave `hookOverrides` unset. + +- _(@youversion/platform-react-native-expo-ui)_ de27302: feat: expose BibleCard `maxWidth` (`number | '100%'`) and forward it to the web card (YPE-5197). Pin `@youversion/platform-react-ui` to 2.10.0 so the WebView runs the published card (platform-sdk-react#354). Native only forwards the prop; scripture fill (`--yv-reader-max-width: none` on the painted section) stays web-owned. + +## 1.3.1 + +### Patch Changes + +- _(@youversion/platform-react-native-expo-ui)_ f5501f8: fix: omit verse-action highlight colors when `auth` is not configured, so a kids app without sign-in still gets Copy and Share instead of dead swatches. + +- _(@youversion/platform-react-native-expo-ui)_ 9d90e75: fix: forward resolved provider locale into DOM WebViews so in-WebView copy matches native SDK language. + +## 1.3.0 + +### Minor Changes + +- _(all packages)_ 7388cbe: feat: paint host highlights on BibleTextView, BibleCard, and VerseOfTheDay + + Subscribe those surfaces at chapter scope and always pass Highlight[] into the DOM so paint uses the native cache. + +- _(all packages)_ f88fd12: Add optional version filter lists to `YouVersionProvider`: `permittedVersionIds`, `excludedVersionIds`, and `permittedLanguageTags`. The UI provider forwards them through native wrappers into each DOM web `YouVersionProvider`. Pin `@youversion/platform-react-ui` and `@youversion/platform-core` to 2.8.0 so the web SDK enforces those lists in Expo DOM WebViews (YPE-4657/YPE-4658). + +## 1.2.0 + +### Minor Changes + +- _(all packages)_ 624d008: Bible highlights on native. The reader paints the highlights of the signed-in user. Verse actions are a native bottom sheet. Highlights made offline survive a relaunch and land on their own. A user who taps a color before sign-in or grant still gets that highlight. + + ## Action required + + Install three new peer modules and rebuild the dev client. A JS-only reload shows `Cannot find native module`. + + ```bash + npx expo install expo-network expo-clipboard expo-application + ``` + + - `expo-network` is a core peer. It wakes parked writes when connectivity returns. + - `expo-clipboard` is a UI peer. It is the Copy fallback in the verse action sheet. + - `expo-application` is a UI peer. It supplies the app name on the sign-in sheet. Core no longer depends on it. + + CAUTION: The default serif font of the reader changes from Source Serif 4 to Untitled Serif. The WebView fetches a stylesheet from `api.youversion.com` and font files from `cdn.youversion.com`. There is no opt-out. If those hosts are blocked, serif text falls back to Source Serif 4. Readers who chose Source Serif are migrated. Any other `fontFamily` you pass is left untouched. + + ## BibleReader + + `BibleReader` owns highlights on native. It reads `useHighlights` for the current passage and passes the result as a controlled prop. The WebView does not fetch highlights, store them, or hold a token. + + A verse selection opens a native sheet with the reference, color swatches, Copy, and Share. No new prop turns this on. + - Swatches: a remove circle for each color on the selection, then an apply circle for the palette colors not already covering the selection. + - Sign-in and consent: the sheet asks for whatever is missing, then applies the chosen color. This needs `auth.permissions` to include `highlights`. + - Copy and Share fall back to `expo-clipboard` and React Native `Share`. Optional `onCopy` and `onShare` take either over. Both receive `BibleReaderShareData`. + + The sheet has no backdrop. A backdrop blocks the next verse tap. The user dismisses the sheet with a swipe down, a deselect, or an action on the sheet. Themed sheets draw an upward drop shadow so the sheet still separates from the page. + + Selection across the bridge: + - `onVerseSelect(selection)` fires on every change, including a clear (`verses: []`). + - `clearSelectionSignal` dismisses the selection from native. The host increments the value. The value at mount is the baseline. + + `BibleReaderVerseSelection` and `BibleReaderShareData` are re-exported from this package. + + `ref.refreshHighlights()` re-fetches the current passage. A screen that regains focus can call it. + + `onHighlightError` reports `{ status: 'queued' }` and `{ status: 'error', reason: 'transient' }` only. Other outcomes stay silent. The `HighlightWriteError` type is exported. + + Sign-out from the reader menu and from `YouVersionAuthButton` asks first. If parked writes are still waiting, the alert is "Save your highlights?". The Confirm action calls `signOut()`. `useYVAuth().signOut()` still signs out at once. `useSignOutGuard` is exported for a host sign-out UI. `hasQueuedHighlightWrites(userId)` chooses the alert variant and never throws. + + On web, the Web SDK popover is the verse-action UI. Sign-out is unprompted because `Alert.alert` is a no-op on React Native Web. + + ## useHighlights + + `useHighlights({ versionId, book, chapter })` is the public surface for highlight data. It paints from an MMKV cache on the first render. `apply` and `remove` are optimistic. If the server refuses a write, the paint reverts. + + `apply` and `remove` resolve a `HighlightWriteOutcome`: `ok`, `noop`, `queued`, or `error`. `queued` is new. It is a `minor` because no existing status changed meaning. An exhaustive `switch` with no `default` is the only consumer branch that breaks. An `error` carries `reason` (`not-signed-in` / `auth` / `invalid` / `transient`) plus `failedVerses` and `succeededVerses`. The hook `error` is fetch-only. + + Also exported: `deriveServerColors`, `HIGHLIGHT_COLORS`, `isHighlightColor`, `refresh()`, and the `Highlight` / `HighlightColor` / `HighlightScope` / `ServerColors` types. + + `apply` accepts only the five palette colors. A valid non-palette hex already on the account paints and clears by exact value. An unparseable hex is dropped. + + `isRefreshing` means a GET is in flight. `highlights` is always safe to render. Mounted subscriptions also refresh when the app becomes active. + + The GET runs only for an app that requested the `highlights` permission on `YouVersionProvider`. The gate reads the requested list, not a grant. + + ## Offline writes + + A tap with no service keeps its paint and parks the write. The write is stored per user and chapter. It survives a relaunch. When service returns, the write lands. + - Unreachable or 5xx: paint stands. Outcome is `{ status: 'queued', verses }`. + - 401, 403, or any other 4xx: paint reverts. Outcome reports the refusal. + + `queued` repeats on every tap of a verse that is still parked. If you show "saved offline" once, hold that copy in your own state. + + Sign-out drops every parked write with the highlights cache and the grant cache. A write parked on one account cannot land on the next account. + + A 401 or 403 on the drain earns one forced refresh and one retry. Only a second auth refusal under a minted token drops the entry. A failed force drops nothing. + + ## Highlighting before sign-in + + `useHighlightPermissionFlow` wraps `useHighlights` and guards `apply`. It holds the pending highlight, runs sign-in and/or consent, then applies. `remove` passes through. + + It returns the `useHighlights` result plus `isConfirming`, `confirm()`, `decline()`, and `flowError`. A cancel or decline resolves `noop`. `BibleReader` already wires the prompts. This hook is for a custom highlight UI. + + This needs `auth` on `YouVersionProvider` and the `highlights` permission. With no `auth`, the flow behaves as signed out. + + ## Permissions and tokens + + `useYVAuth()` now reports `grantedPermissions`, `hasPermission()`, `invalidatePermissions()`, and `requestedPermissions`. `grantedPermissions` is `null` (unknown), `[]` (denied), or a list (granted). The grant is read from the OAuth app redirect and cached per user. + + `requestPermissions(permissions)` asks a signed-in user for a grant without sign-out. It resolves a `DataExchangeOutcome` and never throws: `granted`, `cancel`, or `failure` (`not-signed-in` / `not-permitted` / `user-changed` / `in-progress` / `transient`). The grant merges. The consent page returns to your `redirectUri`. If that URI does not match the registered callback, the outcome is `cancel`. + + The cached grant is a hint. A privileged action gates on the pre-flight, not on a cached `true`. + + `getAccessToken(options?)` resolves `{ status: 'ok', token, userId }` or `{ status: 'unavailable', reason: 'signed-out' | 'refresh-failed' }`. It refreshes only near expiry unless you pass `{ force: true }`. It never rejects. `refresh-failed` keeps the session. Highlights writes and `requestPermissions` treat `refresh-failed` as `transient` and do not send the request. + + ## Dependencies + + `@youversion/platform-core` and `@youversion/platform-react-ui` move to 2.6.2. That release supplies controlled highlights, data-exchange primitives, and a fix that reads an empty-body 2xx DELETE as success. + +### Patch Changes + +- _(@youversion/platform-react-native-expo-core)_ 2902934: Installation IDs are now a random UUID persisted in MMKV, not the device identifier (iOS IDFV / Android `ANDROID_ID`). Kids' apps that ship this SDK must not transmit persistent device IDs under COPPA; this matches the Swift, Kotlin, and React web SDKs. Existing stored installation IDs are left unchanged. `expo-application` is no longer a peer dependency of core. `YouVersionProvider` resolves the installation ID synchronously, so the `fallback` prop is unused. + +## 1.1.1 + +### Patch Changes + +- _(@youversion/platform-react-native-expo-ui)_ 0726f7a: Sync localization from platform-localization (15b2da1): add French (fr); update 15 keys in en. + +## 1.1.0 + +### Minor Changes + +- _(@youversion/platform-react-native-expo-core)_ 89a4e50: Partners can now request YouVersion Platform permissions at sign-in. `AuthConfig` gains an optional `permissions` field typed by the new exported `AuthPermission` union (`'bibles' | 'highlights' | 'votd' | 'demographics' | 'bible_activity'`), and the PKCE flow appends each requested value to `/auth/authorize` as a repeated `requested_permissions[]` param — deduped and sorted, and omitted entirely when no permissions are configured. Permissions are deliberately kept separate from `scopes`: they are not OIDC scopes, and the auth server silently drops unknown values from `scope`, so requesting one there would grant nothing. This ships the request side only — reading back which permissions the user actually granted arrives in a later release. + +### Patch Changes + +- _(@youversion/platform-react-native-expo-ui)_ f0057ca: Syncs additional native UI locales from platform-localization (Czech, Finnish, Hungarian, Italian, Dutch, Norwegian, Serbian, Ukrainian) and registers them in the SDK locale catalog. Norwegian device tags (`nb` / `nn`) now resolve to the bundled `no` resource. + +## 1.0.0 + +### Major Changes + +- _(all packages)_ ce283a0: Release 1.0.0 — the first stable release of the YouVersion Platform React Native Expo SDK. + + This is a milestone version bump marking the SDK's official 1.0 launch. There are no breaking API changes from 0.9.1; the major bump signifies the transition to a stable, publicly supported release line. + +## 0.9.1 + +_(@youversion/platform-react-native-expo-core)_ Initial release. Installation id, optional PKCE authentication, and storage adapters for the YouVersion Platform React Native (Expo) SDK. + +_(@youversion/platform-react-native-expo-ui)_ Initial release. Drop YouVersion Bible content into an Expo app on iOS and Android, with native bottom sheets, theming, and optional sign-in. Built on the [React Web SDK](https://github.com/youversion/platform-sdk-react) wrapped as [Expo DOM Components](https://docs.expo.dev/guides/dom-components/), with native affordances layered on top. + +### Added + +- _(@youversion/platform-react-native-expo-core)_ `YouVersionProvider` — installation id plus optional `auth` config (forwarded by the UI provider), and the `useYouVersion` hook + +- _(@youversion/platform-react-native-expo-core)_ PKCE OAuth via `useYVAuth`, with auth types `AuthConfig`, `AuthScope`, and `YVUserInfo` + +- _(@youversion/platform-react-native-expo-core)_ Token storage in `expo-secure-store`; token expiry and cached user info in MMKV via `mmkvStorage` + +**Auth hardening** + +- _(@youversion/platform-react-native-expo-core)_ User info drops placeholder and non-`https` avatar URLs (blocked by iOS ATS and Android cleartext defaults anyway), so consumers never receive a broken picture URL + +- _(@youversion/platform-react-native-expo-core)_ Canceling sign-in (`access_denied` callback) is treated as a clean cancel rather than an error + +- _(@youversion/platform-react-native-expo-core)_ Cached user info is validated with a zod schema on read, so a corrupt or legacy cache entry can't surface wrong-typed fields + +**Scripture display** + +- _(@youversion/platform-react-native-expo-ui)_ `BibleTextView` — render a verse or verse range from a USFM reference + +- _(@youversion/platform-react-native-expo-ui)_ `BibleCard` — a verse with built-in reader controls + +- _(@youversion/platform-react-native-expo-ui)_ `VerseOfTheDay` — the daily verse, ready to drop in + +**Bible reader** + +- _(@youversion/platform-react-native-expo-ui)_ `BibleReader` — a full reading experience with built-in chapter and version pickers; bring your own picker UI via `onChapterPickerPress` / `onVersionPickerPress` + +- _(@youversion/platform-react-native-expo-ui)_ Standalone sheets for advanced flows: `BibleChapterPickerSheet`, `BibleVersionPickerSheet`, `BibleReaderSettingsSheet` + +Every prop and option for these components is documented at [developers.youversion.com/sdks/react-native](https://developers.youversion.com/sdks/react-native). + +**Provider & theming** + +- _(@youversion/platform-react-native-expo-ui)_ `YouVersionProvider` — single root provider supplying your `appKey`, resolved theme, and native sheet support + +- _(@youversion/platform-react-native-expo-ui)_ `light` / `dark` / `system` themes, with per-component overrides + +**Authentication (optional)** + +- _(@youversion/platform-react-native-expo-ui)_ `YouVersionAuthButton` and the `auth` prop on `YouVersionProvider` for PKCE OAuth (auth primitives and storage live in `@youversion/platform-react-native-expo-core`) + +**Native presentation** + +- _(@youversion/platform-react-native-expo-ui)_ Footnotes, chapter, and version pickers open in native bottom sheets via `@gorhom/bottom-sheet` + +- _(@youversion/platform-react-native-expo-ui)_ WebView pre-warming so sheets open without a cold-start flash + +- _(@youversion/platform-react-native-expo-ui)_ Sheets cap at 640 wide and center on large screens like iPad; full-width below that breakpoint + +**Types** + +- _(@youversion/platform-react-native-expo-ui)_ Prop types are exported for each component, e.g. `BibleCardProps`, `BibleReaderProps`, `BibleTextViewProps` + +**Attribution** + +- _(@youversion/platform-react-native-expo-ui)_ SDK traffic is identified to YouVersion in the `x-yvp-sdk` header as `ReactNativeSDK={version}`; builds running from source report `{version}-dev`, matching the Web SDK's format + +### Package surface + +- _(@youversion/platform-react-native-expo-core)_ Imports are restricted to the package root via an `exports` map — import everything from `@youversion/platform-react-native-expo-core`. Deep imports (e.g. into `build/`) are not part of the public API. + +- _(@youversion/platform-react-native-expo-ui)_ Only the package root is importable — import everything from `@youversion/platform-react-native-expo-ui`. If you want to see how it all works, read the source on [GitHub](https://github.com/youversion/platform-sdk-reactnative-expo). + +- _(@youversion/platform-react-native-expo-ui)_ Runtime dependencies ship pinned to exact versions, so an install resolves exactly what was published and tested rather than silently picking up a newer release. Peer dependency ranges are unchanged. diff --git a/package.json b/package.json index d3b98e3d..8cc29e33 100644 --- a/package.json +++ b/package.json @@ -32,9 +32,10 @@ "generate:locale-index": "node scripts/generate-locale-index.mjs", "check:locale-index": "node scripts/generate-locale-index.mjs --check", "changeset": "changeset", - "version-packages": "changeset version", + "version-packages": "changeset version && node scripts/build-root-changelog.mjs", "release": "turbo build && pnpm exec changeset publish", - "test:ci-scripts": "bash .github/scripts/major-release-signoff.test.sh && node --test scripts/preview-release.test.mjs" + "build:root-changelog": "node scripts/build-root-changelog.mjs", + "test:ci-scripts": "bash .github/scripts/major-release-signoff.test.sh && node --test scripts/*.test.mjs" }, "devDependencies": { "@changesets/cli": "2.29.7", diff --git a/scripts/build-root-changelog.mjs b/scripts/build-root-changelog.mjs new file mode 100644 index 00000000..f0eab1f0 --- /dev/null +++ b/scripts/build-root-changelog.mjs @@ -0,0 +1,283 @@ +#!/usr/bin/env node +// Build the repo-root CHANGELOG.md by merging the per-package changelogs Changesets writes. +// +// Swift and Kotlin each ship a root CHANGELOG.md; this repo had only per-package files, so +// there was no single place to see what shipped in a release (YPE-4190). +// +// The two packages are a `fixed` group in .changeset/config.json, so they always share a +// version number and a changeset touching several of them writes the *same* entry into each +// of their changelogs. Copying all three verbatim would therefore triple most entries. This +// merges by version, shows each entry once, and notes which packages it affected. +// +// `Updated dependencies` blocks are dropped: they are the fixed group's internal bookkeeping, +// not something a consumer reading release notes needs. +// +// Run via `pnpm build:root-changelog`; wired into `version-packages` so the Version Packages +// PR carries an up-to-date root changelog. +import { readdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const PACKAGES = join(ROOT, 'packages') + +// Read before the filters below, which need the group's package names: a bump line for a +// package outside the group is a note about a real dependency, not our own bookkeeping. +const fixedGroups = JSON.parse(readFileSync(join(ROOT, '.changeset', 'config.json'), 'utf8')).fixed +if (fixedGroups?.length !== 1) { + throw new Error( + `Expected exactly one fixed group in .changeset/config.json, found ${fixedGroups?.length ?? 0}.`, + ) +} + +/** `## 2.12.1` ... up to the next `## ` */ +function versionSections(markdown) { + const out = [] + const lines = markdown.split('\n') + let current = null + for (const line of lines) { + const m = line.match(/^## (\d+\.\d+\.\d+.*)$/) + if (m) { + if (current) out.push(current) + current = { version: m[1].trim(), body: [] } + continue + } + if (current) current.body.push(line) + } + if (current) out.push(current) + return out +} + +/** + * Split a version body into `{ kind, entries }`, where kind is Major/Minor/Patch and each + * entry keeps its continuation lines (Changesets indents them by two spaces). + */ +export function parseEntries(bodyLines) { + const groups = [] + let kind = null + let entry = null + const push = () => { + if (entry && kind) groups.push({ kind, text: entry.join('\n').trimEnd() }) + entry = null + } + for (const line of bodyLines) { + const heading = line.match(/^### (.+?)\s*$/) + if (heading) { + push() + // Keep the heading verbatim unless it is one Changesets writes. React's changelogs are + // entirely Changesets output so only ever carry " Changes", but this repo has a + // hand-written 0.9.1 using `### Added` and `### Package surface`. Matching only the + // Changesets form left `kind` unset and silently dropped those entries. + const level = heading[1].match(/^(Major|Minor|Patch) Changes$/) + kind = level ? level[1] : heading[1] + continue + } + if (/^- /.test(line)) { + push() + entry = [line] + continue + } + if (entry) { + entry.push(line) + continue + } + // A subheading introducing the entries below it, with no entry open to + // attach to. Every other one in these changelogs follows a bullet, so it + // rides along as that entry's trailing lines and lands in the right place + // by accident. The first one under a `###` heading has nothing to ride, and + // was silently dropped. Emit it as its own group instead: the renderer only + // prefixes text starting with `- `, so it comes out verbatim. + if (line.trim() !== '') { + entry = [line] + push() + } + } + push() + return groups + .map((g) => ({ ...g, text: stripBookkeeping(g.text) })) + .filter((g) => g.text !== null) +} + +const escapeForRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + +/** + * ` - @youversion/platform-react-native-expo-core@1.6.0`, at any indent. Restricted to the + * fixed group's own packages: the same shape naming anything else is a real dependency the + * consumer is being told about, and dropping it loses information. + */ +const DEPENDENCY_BUMP = new RegExp( + `^\\s*-\\s+(?:${fixedGroups[0].map(escapeForRegExp).join('|')})@\\d[\\w.-]*\\s*$`, +) + +/** + * Changesets' own header for that block, alone on its line and carrying the originating + * commit: `- Updated dependencies [80d3718]`. Anchored at both ends, because a prefix match + * also swallows a hand-written note that merely opens the same way, such as + * `- Updated dependencies [deadbee] to address CVE-1234.` + */ +const UPDATED_DEPENDENCIES = /^-\s+Updated dependencies \[[0-9a-f]+\]\s*$/ + +/** + * Remove the fixed group's own version bookkeeping, returning null when an entry is nothing else. + * + * Changesets records it two ways and both reach a consumer-facing changelog as noise: + * an `Updated dependencies` block, and bare `- @pkg@version` lines, either standing alone as + * their own entry or trailing a real note as continuation lines. + */ +export function stripBookkeeping(text) { + // Drop the bookkeeping lines and judge the entry by what survives, rather than discarding + // a whole entry because its first line looks like bookkeeping. An entry can open with the + // generated header and still carry something a consumer needs, such as a bump for a + // package outside the fixed group. + const kept = text + .split('\n') + .filter((line) => !UPDATED_DEPENDENCIES.test(line) && !DEPENDENCY_BUMP.test(line)) + const collapsed = kept + .join('\n') + .replace(/\n{3,}/g, '\n\n') + .trimEnd() + // Nothing but a bullet marker left, so the entry was only version bookkeeping. + if (/^-\s*$/.test(collapsed) || collapsed.trim() === '') return null + // Removing the header can leave a continuation line first. Promote it to a top-level + // bullet so the renderer can still tag it with its package scope. + return collapsed.replace(/^\s+- /, '- ') +} + +/** + * Same note, written slightly differently in two packages' changelogs. Compared on collapsed + * whitespace so a stray blank line does not split one entry into two. + */ +function dedupeKey(text) { + return text.replace(/\s+/g, ' ').trim() +} + +// Take the package list from the `fixed` group rather than whatever directories happen to +// have a changelog. That group is the reason this merge is valid at all: fixed packages share +// a version, so the same entry appears in each of their changelogs. Reading the filesystem +// instead would silently drop a package whose changelog went missing, and would silently fold +// a future unrelated package into `(all packages)`. +const expected = new Map( + readdirSync(PACKAGES) + .filter((d) => existsSync(join(PACKAGES, d, 'package.json'))) + .map((d) => [JSON.parse(readFileSync(join(PACKAGES, d, 'package.json'), 'utf8')).name, d]), +) +const packages = fixedGroups[0].map((name) => { + const dir = expected.get(name) + if (!dir || !existsSync(join(PACKAGES, dir, 'CHANGELOG.md'))) { + throw new Error( + `Fixed-group package ${name} has no changelog; refusing to write a partial root changelog.`, + ) + } + return dir +}) + +/** Section order in the output. */ +const KIND_ORDER = ['Major', 'Minor', 'Patch'] + +/** + * Prose written directly under a `## version`, above any `###` heading. Changesets never + * emits this, but a hand-written release can: both packages summarise 0.9.1 that way. + */ +function preamble(bodyLines) { + const out = [] + for (const line of bodyLines) { + if (/^### /.test(line) || /^- /.test(line)) break + out.push(line) + } + return out.join('\n').trim() +} + +/** version -> dedupe key -> { kind, packages, text } */ +const byVersion = new Map() +/** version -> dedupe key -> { packages, text } for the prose above the first heading */ +const byVersionPreamble = new Map() +const order = [] + +for (const dir of packages) { + const pkgName = JSON.parse(readFileSync(join(PACKAGES, dir, 'package.json'), 'utf8')).name + const md = readFileSync(join(PACKAGES, dir, 'CHANGELOG.md'), 'utf8') + for (const { version, body } of versionSections(md)) { + if (!byVersion.has(version)) { + byVersion.set(version, new Map()) + order.push(version) + } + if (!byVersionPreamble.has(version)) byVersionPreamble.set(version, new Map()) + const lead = preamble(body) + if (lead) { + const leadMap = byVersionPreamble.get(version) + const leadKey = dedupeKey(lead) + if (leadMap.has(leadKey)) leadMap.get(leadKey).packages.add(pkgName) + else leadMap.set(leadKey, { packages: new Set([pkgName]), text: lead }) + } + const entries = byVersion.get(version) + for (const { kind, text } of parseEntries(body)) { + const key = dedupeKey(text) + const existing = entries.get(key) + if (!existing) { + entries.set(key, { kind, packages: new Set([pkgName]), text }) + continue + } + existing.packages.add(pkgName) + // A fixed-group changeset can land under different headings per package: 71e4c1a is + // Patch for core but Minor for hooks and ui. Keep the most significant kind rather + // than whichever package readdir happened to return first, so the merged entry is + // filed where a reader looking for that change would go. + if ( + KIND_ORDER.includes(kind) && + KIND_ORDER.includes(existing.kind) && + KIND_ORDER.indexOf(kind) < KIND_ORDER.indexOf(existing.kind) + ) { + existing.kind = kind + } + } + } +} + +// Newest first. Changesets already writes each file newest-first, and the fixed group means +// every package sees the same versions, so first-seen order is release order. +const out = [ + '# Changelog', + '', + 'All notable changes to the YouVersion Platform React Native (Expo) SDK.', + '', + '`@youversion/platform-react-native-expo-core` and', + '`@youversion/platform-react-native-expo-ui` are a `fixed` group in `.changeset/config.json`,', + 'so they share a version number and release together. Each entry below notes which packages', + 'it affected.', + '', + 'Generated from the per-package changelogs by `scripts/build-root-changelog.mjs`. Edit those,', + 'or the changeset, rather than this file.', + '', +] + +for (const version of order) { + out.push(`## ${version}`, '') + for (const { text, packages: pkgs } of (byVersionPreamble.get(version) ?? new Map()).values()) { + const scope = pkgs.size === packages.length ? 'all packages' : [...pkgs].sort().join(', ') + out.push(`_(${scope})_ ${text}`, '') + } + const entries = [...byVersion.get(version).values()] + const extraKinds = [...new Set([...entries.values()].map((e) => e.kind))].filter( + (k) => !KIND_ORDER.includes(k), + ) + for (const kind of [...KIND_ORDER, ...extraKinds]) { + const forKind = entries.filter((e) => e.kind === kind) + if (forKind.length === 0) continue + out.push(`### ${KIND_ORDER.includes(kind) ? `${kind} Changes` : kind}`, '') + for (const { text, packages: pkgs } of forKind) { + const scope = pkgs.size === packages.length ? 'all packages' : [...pkgs].sort().join(', ') + out.push(text.replace(/^- /, `- _(${scope})_ `), '') + } + } +} + +if (process.argv[1] !== undefined && fileURLToPath(import.meta.url) === process.argv[1]) { + writeFileSync( + join(ROOT, 'CHANGELOG.md'), + out + .join('\n') + .replace(/\n{3,}/g, '\n\n') + .trimEnd() + '\n', + ) + console.log(`Wrote CHANGELOG.md: ${order.length} versions from ${packages.length} packages.`) +} diff --git a/scripts/build-root-changelog.test.mjs b/scripts/build-root-changelog.test.mjs new file mode 100644 index 00000000..e4970c38 --- /dev/null +++ b/scripts/build-root-changelog.test.mjs @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' + +import { parseEntries, stripBookkeeping } from './build-root-changelog.mjs' + +const CORE = '@youversion/platform-react-native-expo-core' + +test('drops the fixed group version bookkeeping Changesets writes', () => { + assert.equal(stripBookkeeping('- Updated dependencies [80d3718]'), null) + assert.equal(stripBookkeeping(`- Updated dependencies [3dfe296]\n - ${CORE}@1.5.0`), null) + assert.equal(stripBookkeeping(`- ${CORE}@1.6.0`), null) +}) + +test('keeps a release note that only reads like bookkeeping', () => { + // The filter used to match the bare `- Updated dependencies` prefix, so this whole entry + // disappeared from the root changelog. + const note = '- Updated dependencies to address CVE-1234.' + assert.equal(stripBookkeeping(note), note) +}) + +test('keeps a note that opens with the generated header but says more', () => { + // The header regex used to match a prefix, so anything starting this way was discarded + // whole, including a hand-written note. + const note = '- Updated dependencies [deadbee] to address CVE-1234.' + assert.equal(stripBookkeeping(note), note) +}) + +test('keeps a third-party bump listed under the generated header', () => { + // The header is bookkeeping, the bump under it is not: that package is outside the fixed + // group, so its version is news. Dropping the entry wholesale lost it. + assert.equal( + stripBookkeeping(`- Updated dependencies [deadbee]\n - @vendor/client@2.0.0`), + '- @vendor/client@2.0.0', + ) +}) + +test('keeps a bump for a package outside the fixed group', () => { + // Only the group's own packages share a version, so only their bumps are duplication. A + // third-party bump is something the consumer is being told about. + const note = '- Bumped the HTTP client.\n - @vendor/client@2.0.0' + assert.equal(stripBookkeeping(note), note) + assert.equal(stripBookkeeping('- @vendor/client@2.0.0'), '- @vendor/client@2.0.0') +}) + +test('strips a trailing group bump without eating the note above it', () => { + assert.equal( + stripBookkeeping(`- Added a native chapter picker.\n - ${CORE}@1.6.0`), + '- Added a native chapter picker.', + ) +}) + +test('keeps a subheading that opens a section before any bullet', () => { + // Every other subheading in these changelogs follows a bullet and rides along + // as that entry's trailing lines. The first one under a `###` has nothing to + // ride, and used to be dropped outright. + const groups = parseEntries([ + '### Added', + '', + '**Scripture display**', + '', + '- `BibleTextView` renders a verse', + ]) + + assert.deepEqual( + groups.map((g) => g.text), + ['**Scripture display**', '- `BibleTextView` renders a verse'], + ) + assert.ok(groups.every((g) => g.kind === 'Added')) +}) + +test('a subheading after a bullet stays with the entry it trails', () => { + const groups = parseEntries([ + '### Added', + '', + '- first thing', + '', + '**Bible reader**', + '', + '- second thing', + ]) + + assert.deepEqual( + groups.map((g) => g.text), + ['- first thing\n\n**Bible reader**', '- second thing'], + ) +})