From 7f4a053dffb1a550205f7760a754b39c89c5a95b Mon Sep 17 00:00:00 2001 From: abi Date: Tue, 8 Sep 2026 07:24:59 +0100 Subject: [PATCH 1/3] Launch reviewed Italian pilot and improve native translations --- docs/PRODUCT-EXPERIMENTS.md | 15 + docs/SEO-EXPERIMENTS.md | 17 + docs/indexing-queue.md | 40 +- docs/native-i18n/INVENTORY.md | 16 +- docs/runbooks/tools-and-data-sources.md | 10 + next.config.js | 35 +- package.json | 2 +- public/llms.txt | 13 +- .../bespoke/compile-breathe-index-content.mjs | 63 + scripts/i18n/bespoke/compile-home-content.mjs | 66 +- .../compile-privacy-support-content.mjs | 44 + .../i18n/bespoke/compile-timer-content.mjs | 61 + .../compile-remaining-page-gaps.mjs | 7 + .../compile-breathe-content.mjs | 104 +- scripts/tests/breathing-regression.test.mjs | 8 +- .../tests/conversion-loss-aversion.test.mjs | 4 +- scripts/tests/languages-page-ssr.test.mjs | 5 +- .../native-i18n-breathe-content.test.mjs | 23 +- ...native-i18n-breathe-index-content.test.mjs | 26 +- .../native-i18n-content-quality.test.mjs | 2 +- .../tests/native-i18n-for-content.test.mjs | 4 +- scripts/tests/native-i18n-foundation.test.mjs | 20 +- .../tests/native-i18n-home-content.test.mjs | 33 +- .../tests/native-i18n-route-manifest.test.mjs | 88 +- .../tests/native-i18n-route-shell.test.mjs | 2 +- scripts/tests/native-i18n-rw03-embed.test.mjs | 2 +- .../native-i18n-rw04-privacy-support.test.mjs | 12 +- scripts/tests/native-i18n-rw04-stats.test.mjs | 2 +- .../tests/native-i18n-timer-content.test.mjs | 32 +- .../next-config-locale-redirects.test.js | 17 + .../4-7-8-breathing-timer/timer-page.tsx | 4 +- .../(site-en)/breathe/breathe-index-page.tsx | 8 +- src/app/(site-en)/embed/embed-page.tsx | 10 +- src/app/(site-en)/home-page.tsx | 12 +- src/app/(site-en)/languages/page.tsx | 114 +- .../[locale]/4-7-8-breathing-timer/page.tsx | 4 +- .../[locale]/[[...segments]]/page.tsx | 7 +- .../(site-localized)/[locale]/about/page.tsx | 4 +- .../[locale]/embed/[slug]/page.tsx | 4 +- .../(site-localized)/[locale]/stats/page.tsx | 4 +- src/app/sitemap.xml/route.ts | 28 +- .../auth/loss-aversion-sign-in-sheet.tsx | 75 +- .../auth/non-blocking-sign-in-banner.tsx | 98 +- .../auth/session-complete-prompt.tsx | 4 + src/components/language-switcher.tsx | 197 +-- src/components/resonance/Resonance.tsx | 34 +- src/components/resonance/runtime-phrases.ts | 465 ++++- .../bespoke/breathe-index/messages/it-it.json | 82 + .../bespoke/breathe-index/provenance.json | 212 +++ .../bespoke/breathe-index/publication.json | 12 + .../server/load-breathe-index-content.ts | 3 +- .../content/bespoke/home/messages/de-de.json | 4 +- .../content/bespoke/home/messages/it-it.json | 473 +++++ .../content/bespoke/home/messages/ja-jp.json | 8 +- src/i18n/content/bespoke/home/provenance.json | 827 ++++++++- .../content/bespoke/home/publication.json | 33 +- .../bespoke/home/reviewed-replacements.json | 54 + .../bespoke/home/server/load-home-content.ts | 5 +- .../messages/privacy/it-it.json | 56 + .../messages/support/it-it.json | 60 + .../bespoke/privacy-support/provenance.json | 344 ++++ .../bespoke/privacy-support/publication.json | 12 + .../server/load-privacy-support-content.ts | 2 + .../content/bespoke/privacy-support/types.ts | 3 +- .../messages/es-es/box-breathing-app.json | 12 +- .../messages/fr-fr/box-breathing-app.json | 6 +- .../messages/pt-br/box-breathing-app.json | 6 +- .../bespoke/rw03-app-pages/publication.json | 6 +- .../rw03-app-pages/reviewed-replacements.json | 36 +- .../bespoke/timer-4-7-8/messages/de-de.json | 4 +- .../bespoke/timer-4-7-8/messages/it-it.json | 178 ++ .../bespoke/timer-4-7-8/provenance.json | 900 +++++++++- .../bespoke/timer-4-7-8/publication.json | 19 +- .../timer-4-7-8/reviewed-replacements.json | 18 + .../timer-4-7-8/server/load-timer-content.ts | 3 +- .../content/breathe/chrome/it-it/belly.json | 61 + .../content/breathe/chrome/it-it/box.json | 65 + .../breathe/chrome/it-it/coherent.json | 61 + src/i18n/content/breathe/manual/tummo.json | 7 +- .../content/breathe/provenance/4-7-8.json | 8 +- .../content/breathe/provenance/belly.json | 831 +++++++++ src/i18n/content/breathe/provenance/box.json | 898 +++++++++- .../content/breathe/provenance/coherent.json | 815 ++++++++- .../provenance/physiological-sigh.json | 20 +- .../content/breathe/provenance/tummo.json | 4 +- src/i18n/content/breathe/publication.json | 24 +- .../breathe/reviewed-replacements/4-7-8.json | 36 + .../breathe/reviewed-replacements/box.json | 53 +- .../reviewed-replacements/coherent.json | 63 + .../physiological-sigh.json | 94 +- .../breathe/reviewed-replacements/tummo.json | 18 + .../content/breathe/routes/de-de/4-7-8.json | 2 +- .../content/breathe/routes/de-de/box.json | 8 +- .../routes/de-de/physiological-sigh.json | 2 +- .../content/breathe/routes/de-de/tummo.json | 2 +- .../content/breathe/routes/es-es/box.json | 6 +- .../breathe/routes/es-es/coherent.json | 6 +- .../breathe/routes/fr-fr/coherent.json | 8 +- .../content/breathe/routes/it-it/belly.json | 265 +++ .../content/breathe/routes/it-it/box.json | 289 +++ .../breathe/routes/it-it/coherent.json | 244 +++ .../content/breathe/routes/ja-jp/4-7-8.json | 6 +- .../routes/ja-jp/physiological-sigh.json | 18 +- .../content/breathe/routes/ja-jp/tummo.json | 6 +- .../breathe/server/load-breathe-content.ts | 22 +- src/i18n/content/breathe/types.ts | 2 +- src/i18n/content/italian-pilot/.keep | 1 + .../content/italian-pilot/breathe-index.json | 301 ++++ .../content/italian-pilot/breathe/belly.json | 1423 +++++++++++++++ .../content/italian-pilot/breathe/box.json | 1559 +++++++++++++++++ .../italian-pilot/breathe/coherent.json | 1383 +++++++++++++++ src/i18n/content/italian-pilot/home.json | 1120 ++++++++++++ src/i18n/content/italian-pilot/privacy.json | 231 +++ src/i18n/content/italian-pilot/support.json | 259 +++ src/i18n/content/italian-pilot/timer.json | 1239 +++++++++++++ .../box-breathing-before-presentation.json | 17 +- .../physiological-sigh-panic-attack.json | 6 +- .../de-de.json | 6 +- .../es-es.json | 2 +- .../fr-fr.json | 2 +- .../ja-jp.json | 4 +- .../pt-br.json | 2 +- .../de-de.json | 4 +- .../ja-jp.json | 2 +- .../rw02-route-client/publication.json | 14 +- .../content/use-cases/manual/anxiety.json | 81 +- .../content/use-cases/provenance/anxiety.json | 16 +- .../use-cases/routes/es-es/anxiety.json | 12 +- src/i18n/index.ts | 22 + src/i18n/route-manifest.ts | 88 +- src/lib/seo/sitemap-routes.mjs | 7 +- src/lib/seo/sitemap-routes.ts | 1 + 132 files changed, 16332 insertions(+), 637 deletions(-) create mode 100644 src/i18n/content/bespoke/breathe-index/messages/it-it.json create mode 100644 src/i18n/content/bespoke/home/messages/it-it.json create mode 100644 src/i18n/content/bespoke/privacy-support/messages/privacy/it-it.json create mode 100644 src/i18n/content/bespoke/privacy-support/messages/support/it-it.json create mode 100644 src/i18n/content/bespoke/timer-4-7-8/messages/it-it.json create mode 100644 src/i18n/content/breathe/chrome/it-it/belly.json create mode 100644 src/i18n/content/breathe/chrome/it-it/box.json create mode 100644 src/i18n/content/breathe/chrome/it-it/coherent.json create mode 100644 src/i18n/content/breathe/routes/it-it/belly.json create mode 100644 src/i18n/content/breathe/routes/it-it/box.json create mode 100644 src/i18n/content/breathe/routes/it-it/coherent.json create mode 100644 src/i18n/content/italian-pilot/.keep create mode 100644 src/i18n/content/italian-pilot/breathe-index.json create mode 100644 src/i18n/content/italian-pilot/breathe/belly.json create mode 100644 src/i18n/content/italian-pilot/breathe/box.json create mode 100644 src/i18n/content/italian-pilot/breathe/coherent.json create mode 100644 src/i18n/content/italian-pilot/home.json create mode 100644 src/i18n/content/italian-pilot/privacy.json create mode 100644 src/i18n/content/italian-pilot/support.json create mode 100644 src/i18n/content/italian-pilot/timer.json diff --git a/docs/PRODUCT-EXPERIMENTS.md b/docs/PRODUCT-EXPERIMENTS.md index 5ce9aa3d..23e98aa1 100644 --- a/docs/PRODUCT-EXPERIMENTS.md +++ b/docs/PRODUCT-EXPERIMENTS.md @@ -23,6 +23,7 @@ Reverse chronological. Legend: ✅ Success · ❌ Failed · ⚪ Inconclusive · | Date | Entry | Status | |------|-------|--------| +| 2026-09-08 | [Italian interaction and translation clarity](#2026-09-08-italian-interaction-and-translation-clarity) | Implemented; production verification pending | | 2026-09-05 | [App acquisition funnel and shared website campaign](#2026-09-05-app-acquisition-funnel-and-shared-website-campaign) | 🔄 Implemented locally — deployment pending | | 2026-08-30 | [Guest completion provider-parity hotfix](#2026-08-30-guest-completion-provider-parity-hotfix) | 🔄 Implemented locally, approval pending | | 2026-08-30 | [Assistant recommendation handoff instrumentation](#2026-08-30-assistant-recommendation-handoff-instrumentation) | 🔄 Implemented in PR [#72](https://github.com/Darkmatter-AI/deepbreathing/pull/72) — live receipt pending | @@ -58,6 +59,20 @@ See also: [docs/FUNNEL-DASHBOARD.md](FUNNEL-DASHBOARD.md) for the current state, ## Active Experiments +### 2026-09-08: Italian interaction and translation clarity + +**Observed baseline.** Italian has no published web experience. Existing French settings show missing accents and the English label “Pink Noise (Rain)”. The speed control displays a multiplier as seconds per phase even though the actual phase durations are listed separately. These are directly observed copy and meaning defects, not measured conversion failures. Research for August 9–September 5 found 8 Italian-browser-language web users and 6 users firing a session-start event; that cohort is too small to infer an engagement rate change. + +**Hypothesis.** Complete Italian controls and account prompts, clear language destinations, and accurate speed units will make the published Italian exercises usable without changing session timing or authentication behavior. + +**Pre-committed gates.** The Italian session can start and pause, settings and guest account prompts are in Italian, template variables remain intact, unavailable translated destinations are not linked, and the speed display identifies a multiplier rather than seconds. Existing-language controls retain their actions. Verify desktop and mobile rendering with screenshots. Do not send authentication emails or modify real accounts for this check. + +**Outcome checkpoint.** On 2026-11-03, use the Italian-page session-start threshold from the matching SEO experiment (10 users) as an initial adoption signal. Below that threshold the product outcome is inconclusive. Retain objectively correct labels regardless of immature conversion data. Transactional emails and provider-hosted authentication screens are outside this on-site translation change. + +**Implementation.** Italian home/library 4-7-8 links open the Italian timer, whose main timer link returns to its own player. The Italian mobile home introduction flows below the player to avoid overlap between its longer copy and duration buttons. Desktop layout remains unchanged. + +**Status.** Implemented and locally verified on `codex/languages-italian-20260908`; production receipt pending. + ### 2026-08-30: Guest completion provider-parity hotfix **Observed baseline:** The live guest completion bucket is `loss_aversion` at 100%. A rendered one-minute signed-out completion shows Google and email only. The approved `keep_practice` Save-your-progress sheet contains both Apple and Google, but it is reachable only through its preview query or a stale persisted bucket. The earlier `keep_practice` conversion experiment failed at 8.37% intent (17/203 users); this hotfix does not relitigate that result. diff --git a/docs/SEO-EXPERIMENTS.md b/docs/SEO-EXPERIMENTS.md index 41453eb1..dfdd9b21 100644 --- a/docs/SEO-EXPERIMENTS.md +++ b/docs/SEO-EXPERIMENTS.md @@ -18,6 +18,7 @@ Reverse chronological. Legend: ✅ Success · ❌ Failed · ⚪ Inconclusive · | Date | Entry | Status | |------|-------|--------| +| 2026-09-08 | [Italian pilot and translation corrections](#2026-09-08-italian-pilot-and-translation-corrections) | Implemented; production verification pending | | 2026-08-30 | [Agent-Handoff Discoverability + Attribution](#2026-08-30-agent-handoff-discoverability--attribution) | 🔄 Implemented in PR [#72](https://github.com/Darkmatter-AI/deepbreathing/pull/72) — production verification pending | | 2026-07-27 | [Index Coverage Hygiene + Canonical-Hijack Recovery](#2026-07-27-index-coverage-hygiene--canonical-hijack-recovery) | 🔄 Implemented | | 2026-07-22 | [First-Load-JS Webpack Experiment (PR #31) — Production Outage Post-Mortem + Removal](#2026-07-22-first-load-js-webpack-experiment-pr-31--production-outage-post-mortem--removal) | ❌ Failed | @@ -94,6 +95,22 @@ See also: [Key Learnings (Jan 2026)](#key-learnings-jan-2026) — synthesis of w ## Active Experiments +### 2026-09-08: Italian Pilot and Translation Corrections + +**Hypothesis.** A complete Italian experience on a small, relevant route cohort can earn useful nonbrand search visits. Correcting objective errors in existing translations improves clarity without changing established URL identities or successful keyword targeting. One route-and-language publication policy will keep unfinished translations out of routes, alternate links and sitemaps. + +**Baseline.** Research captured September 7–8 using Ahrefs country Google estimates, finalized GSC and hostname/platform-filtered GA4. Production has 331 sitemap URLs across English and five translated languages, with no Italian URLs. For August 9–September 5, translated URL prefixes received 283 GSC clicks and 5,205 impressions. Japanese Tummo accounted for 176 clicks and 1,787 impressions and is a preservation priority. Italy received 3 country-level GSC clicks and 136 impressions; Italian browser-language users numbered 8 in GA4 with 6 users firing a session-start event. Browser language and country are different cohorts. Ahrefs Italy estimates are 6,800 monthly searches for “respirazione diaframmatica”, 1,700 for “esercizi di respirazione”, 700 for “respirazione quadrata”, 600 for “respirazione 4 7 8”, and 400 for “coerenza cardiaca”. Overlapping keyword volumes are not added and are not traffic forecasts. + +**Release scope.** Italian home, technique library, belly breathing, box breathing, 4-7-8 timer and coherent breathing, with translated controls, account prompts, privacy and support. Existing-language changes are source-faithful corrections, including clipped Spanish descriptions, French accents and VFC terminology, unclear German instructions, and Japanese count/typo corrections. Preserve the French coherent title and Japanese Tummo search positioning. No native mobile release is included. + +**Immediate gates.** Existing canonical URLs remain available. Each published Italian page returns 200 with Italian server-rendered content, self-canonical metadata, reciprocal alternates and no English fallback in the primary interaction. Unpublished Italian paths remain unavailable and are absent from language destinations, alternate links and sitemap entries. Internal links point to an available destination. Existing compiler checks and relevant tests pass. Desktop and mobile browser checks exercise the session controls and language navigation. Production is built from reviewed GitHub main. + +**Pre-committed outcome criteria.** At day 28, inspect all Italian pilot URLs for discovery, crawl and canonical status. At day 56, the learning threshold is at least 30 nonbrand GSC clicks to Italian pilot URLs and 10 users starting sessions on Italian pages. These are provisional decision thresholds, not a forecast. Below either threshold, classify as inconclusive and inspect query intent, indexation and session use before expanding; a persistent canonical or route defect is a release failure to fix immediately. Do not treat the breathing_session_end event as validated session completion. Measure the corrected existing-language cohort against the preceding finalized 28-day window, but retain correctness fixes independently of noisy CTR changes. + +**Measure after.** 2026-10-06 for indexing; 2026-11-03 for the initial growth decision, assuming September 8 deployment. Shift dates if deployment changes. + +**Status.** Implemented and locally verified on `codex/languages-italian-20260908`, based on `main@12ae231`. Production verification and supported recrawl receipts will be recorded after release. Agent translation review is not native human certification. + ### 2026-08-30: Agent-Handoff Discoverability + Attribution **Context and baseline.** An older analytics snapshot recorded approximately 32 AI-assistant sessions per week, but that baseline is stale and the broad channel cannot isolate explicit handoffs. The March 2026 `llms.txt` experiment failed its organic-search goal, while its value for assistant discovery was not measured. diff --git a/docs/indexing-queue.md b/docs/indexing-queue.md index 3d71d434..b990c78d 100644 --- a/docs/indexing-queue.md +++ b/docs/indexing-queue.md @@ -3,12 +3,20 @@ Tracks the index status of every URL. The **Indexed** column is refreshed from the GSC URL Inspection API by `scripts/gsc-index-status.mjs` (see the `daily-indexing` skill). -The **GSC** and **Bing** date columns are historical. We no longer submit URLs anywhere: -Google has no supported submission API for pages like ours, and Bing is covered -automatically by IndexNow on every production deploy. Do not write new dates into them. -See the 2026-07-09 entry in `SEO-EXPERIMENTS.md`. +The **GSC** and **Bing** date columns below are historical; preserve them. Record +new release receipts in the dated release notes instead. Google supports sitemap +submission and the Search Console browser's Request indexing action. Its general +Indexing API is not eligible for these pages. Bing receives explicit canonical URL +batches through IndexNow; shared or localized diffs can correctly produce no automatic +submission and need a reviewed URL batch. -## Current state (as of 2026-07-27) +## Italian release preparation (2026-09-08) + +The local native sitemap contains 339 URLs: the existing 331 plus eight Italian +pages. Production rollout and submission receipts are pending. Italian rows below +have unknown index status; inclusion in a sitemap is not evidence of indexing. + +## Historical state (as of 2026-07-27) - **Rows tracked**: **331**, exactly matching the cleaned canonical sitemap. - **GSC Page Indexing report baseline**: **270 indexed / 61 not indexed** in the @@ -59,14 +67,14 @@ Re-submitted **148 URLs** to GSC + Bing via the mass-translate Indexing API (`re - **GSC** — ISO date submitted via GSC URL Inspection → Request Indexing or the `mcp__mass-translate-backend__request_indexing` API. - **Bing** — ISO date submitted via Bing Webmaster → URL Submission or the `mcp__mass-translate-backend__submit_urls_bing` API. -## Submission channels (preferred order) +## Current submission channels -1. **mass-translate MCP API** — fast, batch-friendly, no UI automation. - - `mcp__mass-translate-backend__request_indexing` — Google Indexing API (one URL per call). - - `mcp__mass-translate-backend__submit_urls_bing` — up to 10 URLs per call. **Blocker**: needs Bing Content API key from BWT Settings → API Access → connect via backend. -2. **agent-browser UI** — fallback, slower but works without MCP auth. - - GSC URL Inspection — one URL at a time, ~10-20/day silent cap - - Bing Webmaster URL Submission — paste batch into dialog, 10K/day quota +1. Submit the canonical sitemap through the Search Console Sitemaps API after the + production sitemap is verified. Use browser URL Inspection → Request indexing for + a small priority cohort, subject to the account's available quota. +2. Submit the exact changed/new canonical URLs through IndexNow after checking each + against the live sitemap and its response. An accepted request does not confirm + crawling or indexing. Do not use the retired sitemap ping endpoints. ## Priority rationale @@ -81,6 +89,14 @@ Re-submitted **148 URLs** to GSC + Bing via the mass-translate Indexing API (`re | P | URL | Indexed | GSC | Bing | |---|---|---|---|---| +| 1 | https://deepbreathingexercises.com/it | | | | +| 3 | https://deepbreathingexercises.com/it/breathe | | | | +| 1 | https://deepbreathingexercises.com/it/breathe/belly | | | | +| 1 | https://deepbreathingexercises.com/it/breathe/box | | | | +| 3 | https://deepbreathingexercises.com/it/breathe/coherent | | | | +| 3 | https://deepbreathingexercises.com/it/4-7-8-breathing-timer | | | | +| 3 | https://deepbreathingexercises.com/it/privacy | | | | +| 3 | https://deepbreathingexercises.com/it/support | | | | | 1 | https://deepbreathingexercises.com/breathe/9d-breathwork | ✓ | 2026-06-13 | 2026-06-13 | | 1 | https://deepbreathingexercises.com/breathe/hope-cartel-9d-breathwork | ✓ | 2026-06-13 | 2026-06-13 | | 2 | https://deepbreathingexercises.com/about/abi | ✓ | 2026-06-13 | 2026-06-13 | diff --git a/docs/native-i18n/INVENTORY.md b/docs/native-i18n/INVENTORY.md index 52ee0eae..829a2fb8 100644 --- a/docs/native-i18n/INVENTORY.md +++ b/docs/native-i18n/INVENTORY.md @@ -235,21 +235,21 @@ The groups below separate live coupling from migration provenance and historical | Evidence | Dependency / cutover implication | |---|---| -| `next.config.js:189` | Keeps locale-prefix stripping only in the legacy proxy build mode; native serving modes release those paths to the App Router. | +| `next.config.js:210` | Keeps locale-prefix stripping only in the legacy proxy build mode; native serving modes release those paths to the App Router. | | `src/lib/seo/sitemap-routes.mjs:14` | Owns the five proxy prefixes, manufactures locale URLs/hreflang, and keeps `/languages` English-only. | | `src/lib/seo/sitemap-routes.ts:6` | Typed adapter exposes proxy-specific sitemap constants and URL classification. | -| `src/app/sitemap.xml/route.ts:5` | Publishes locale-prefixed URLs even though the current Next.js app has no native locale route tree. | +| `src/app/sitemap.xml/route.ts:13` | Publishes locale-prefixed URLs even though the current Next.js app has no native locale route tree. | | `scripts/ping-sitemap.mjs:11` | Builds the canonical URL allowlist used to fail closed when deriving changed-route IndexNow submissions; the legacy-named constant now represents the five published native locale prefixes. | | `src/app/robots.ts:8` | Carries `/api/proxy/` crawl cleanup and query-parameter rules created for proxy canonical behavior. | | `src/app/(site-en)/layout.tsx:72` | The English root explicitly selects the shared document language; the proxy currently changes locale-facing HTML outside the app. | -| `src/app/(site-en)/languages/page.tsx:348` | Hardcodes the locale discovery hub; proxy anchor rewriting is why this route is published only in English. | +| `src/app/(site-en)/languages/page.tsx:374` | Hardcodes the locale discovery hub; proxy anchor rewriting is why this route is published only in English. | ### Browser locale and translated-DOM contracts (active) | Evidence | Dependency / cutover implication | |---|---| -| `src/components/language-switcher.tsx:16` | Reads the injected proxy global, duplicates locale/path logic, and delays links until hydration to avoid rewritten double-prefix URLs. | -| `src/components/resonance/runtime-phrases.ts:914` | Uses the injected language as the first locale signal for the existing interactive phrase catalog. | +| `src/components/language-switcher.tsx:23` | Reads the injected proxy global, duplicates locale/path logic, and delays links until hydration to avoid rewritten double-prefix URLs. | +| `src/components/resonance/runtime-phrases.ts:1227` | Uses the injected language as the first locale signal for the existing interactive phrase catalog. | | `apps/mobile/src/components/breathing-web/runtime-phrases.ts:498` | Mobile web-content copy mirrors the same injected-global locale detection. | | `src/components/resonance/Resonance.tsx:13` | Consumes the runtime phrase resolver and reports fallback misses; native routing must provide its locale explicitly. | | `src/components/auth/sign-in-sheet.tsx:9` | Detects the proxy-backed runtime locale before resolving sign-in copy. | @@ -328,9 +328,9 @@ The groups below separate live coupling from migration provenance and historical | `CLAUDE.md:100` | Canonical project guidance still documents proxy URL semantics and old OAuth context. | | `AGENTS.md:21` | Project environment declares the origin application endpoint. | | `.claude/skills/daily-indexing/SKILL.md:19` | Correctly states that MassTranslate submission/OAuth paths are retired for indexing; keep this negative dependency true. | -| `docs/runbooks/tools-and-data-sources.md:58` | Primary live runbook for proxy cache, signed-webhook bypass, URL semantics, and remaining GSC/Bing fallbacks. | +| `docs/runbooks/tools-and-data-sources.md:68` | Primary live runbook for proxy cache, signed-webhook bypass, URL semantics, and remaining GSC/Bing fallbacks. | | `docs/runbooks/weekly-funnel-refresh.md:78` | Still contains MassTranslate GSC/Bing synchronization commands and OAuth recovery steps. | -| `docs/indexing-queue.md:44` | Contains operational and historical references to MassTranslate URL-submission tools. | +| `docs/indexing-queue.md:52` | Contains operational and historical references to MassTranslate URL-submission tools. | | `docs/FUNNEL-DASHBOARD.md:154` | A dated dashboard snapshot names MassTranslate as a prior GSC data source. | ### App Store submission notes (active) @@ -343,7 +343,7 @@ The groups below separate live coupling from migration provenance and historical | Evidence | Dependency / cutover implication | |---|---| -| `docs/SEO-EXPERIMENTS.md:236` | Permanent experiment history for proxy defects, mitigations, and indexing outcomes. | +| `docs/SEO-EXPERIMENTS.md:252` | Permanent experiment history for proxy defects, mitigations, and indexing outcomes. | | `docs/UX-BACKLOG.md:102` | Historical ownership and translation-coverage findings. | | `docs/qa-reports/traction-pages-2026-06-06.md:9` | Production evidence for delayed translation, partial coverage, and hydration failures. | | `docs/research/eeat-citations-2026-05.md:16` | A dated content-research decision record. | diff --git a/docs/runbooks/tools-and-data-sources.md b/docs/runbooks/tools-and-data-sources.md index d00cb53f..0db0037f 100644 --- a/docs/runbooks/tools-and-data-sources.md +++ b/docs/runbooks/tools-and-data-sources.md @@ -32,6 +32,16 @@ If any of these change, update this file FIRST, then update FUNNEL-DASHBOARD.md --- +## Current native-language release workflow (2026-09-08) + +The July native-language cutover supersedes the proxy-specific notes retained below. Current translated content is repository-owned and server-rendered. Do not deploy new translations through MassTranslate, infer English fallback from an old proxy observation, or warm the retired proxy cache. Verify the current route response and browser state. + +Production web releases must follow the `CLAUDE.md` provenance guard: review a branch, merge to GitHub `main`, then verify the Git integration's production deployment SHA and live aliases. Never promote a feature-branch deployment to production. + +For a partial language rollout, maintain one publication policy for routable pages, internal destinations, language choices, sitemap membership and alternate links. Registering a language does not make every translated page ready. Translate and review the complete interaction and safety copy for each published route. + +After verifying production, submit the canonical sitemap using the [Search Console Sitemaps API](https://developers.google.com/webmaster-tools/v1/sitemaps/submit). Google's [URL Inspection request-indexing flow](https://developers.google.com/search/docs/crawling-indexing/ask-google-to-recrawl) supports a small priority cohort. The API for general URL inspection is read-only; the separate Google Indexing API does not support these pages. For Bing, use [IndexNow](https://www.indexnow.org/documentation) with the exact verified changed/new URLs. Submission acceptance is not proof of crawling or indexing. Existing postbuild automation fails closed on ambiguous shared or locale content changes; inspect its receipt before deciding whether an explicitly authorized exact-URL submission is needed. + ## Per-task lookup table ### Pull GA4 funnel data diff --git a/next.config.js b/next.config.js index be1007f9..3f3d3181 100644 --- a/next.config.js +++ b/next.config.js @@ -1,5 +1,7 @@ const nativeI18nMode = process.env.NATIVE_I18N_MODE || 'proxy'; const supportedNativeI18nModes = new Set(['proxy', 'native-preview', 'native']); +const proxyLocalePattern = 'es|pt|fr|de|ja'; +const nativeLocalePattern = 'es|pt|fr|de|ja|it'; if (!supportedNativeI18nModes.has(nativeI18nMode)) { throw new Error(`Unsupported NATIVE_I18N_MODE: ${nativeI18nMode}`); @@ -8,48 +10,66 @@ if (!supportedNativeI18nModes.has(nativeI18nMode)) { const proxyLocalePrefixRedirects = nativeI18nMode === 'proxy' ? [ { - source: '/:locale(es|pt|fr|de|ja)/:rest+', + source: `/:locale(${proxyLocalePattern})/:rest+`, destination: '/:rest+', permanent: true, }, { - source: '/:locale(es|pt|fr|de|ja)', + source: `/:locale(${proxyLocalePattern})`, destination: '/', permanent: true, }, ] : []; +const italianNativeFallbacks = nativeI18nMode === 'proxy' + ? [] + : [ + // Italian has no published trust-page translation yet. Keep legacy + // links on the existing English destination until that content is + // explicitly admitted to the publication manifest. + { + source: '/it/about/methodology', + destination: '/about/editorial-policy', + permanent: true, + }, + { + source: '/it/about/methodology/:path*', + destination: '/about/editorial-policy', + permanent: true, + }, + ]; + // Native and native-preview modes: locale-aware redirects for legacy URLs and double-locale paths const nativeLocaleRedirects = nativeI18nMode === 'proxy' ? [] : [ // Legacy /about/methodology redirects for all locales { - source: '/:locale(es|pt|fr|de|ja)/about/methodology', + source: `/:locale(${nativeLocalePattern})/about/methodology`, destination: '/:locale/about/editorial-policy', permanent: true, }, { - source: '/:locale(es|pt|fr|de|ja)/about/methodology/:path*', + source: `/:locale(${nativeLocalePattern})/about/methodology/:path*`, destination: '/:locale/about/editorial-policy', permanent: true, }, // /languages is EN-only route; localized paths redirect to root { - source: '/:locale(es|pt|fr|de|ja)/languages', + source: `/:locale(${nativeLocalePattern})/languages`, destination: '/languages', permanent: true, }, // Double-locale bare paths: /:outer/:inner -> /:outer/ { - source: '/:outer(es|pt|fr|de|ja)/:inner(es|pt|fr|de|ja)', + source: `/:outer(${nativeLocalePattern})/:inner(${nativeLocalePattern})`, destination: '/:outer/', permanent: true, }, // Double-locale nested paths: /:outer/:inner/:rest* -> /:outer/:rest* { - source: '/:outer(es|pt|fr|de|ja)/:inner(es|pt|fr|de|ja)/:rest*', + source: `/:outer(${nativeLocalePattern})/:inner(${nativeLocalePattern})/:rest*`, destination: '/:outer/:rest*', permanent: true, }, @@ -105,6 +125,7 @@ const nextConfig = { destination: 'https://deepbreathingexercises.com/:path*', permanent: true, }, + ...italianNativeFallbacks, // Content redirects { source: '/about/methodology', diff --git a/package.json b/package.json index ae1196b1..7a14a9c0 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "prebuild": "pnpm run check:production-source && pnpm run check:native-i18n-breathe-index && pnpm run check:native-i18n-for-index && pnpm run check:native-i18n-breathe-pages && pnpm run check:native-i18n-for-pages && pnpm run check:native-i18n-duration-pages && pnpm run check:native-i18n-insomnia && pnpm run check:native-i18n-resonance-guides && pnpm run check:native-i18n-rw02-route-client && pnpm run check:native-i18n-holiday && pnpm run check:native-i18n-rw03-app-pages && pnpm run check:native-i18n-breathing-visualizer && pnpm run check:native-i18n-embed && pnpm run check:native-i18n-trust-pages && pnpm run check:native-i18n-privacy-support && pnpm run check:native-i18n-stats && pnpm test", + "prebuild": "pnpm run check:production-source && pnpm run check:native-i18n-home && pnpm run check:native-i18n-timer && pnpm run check:native-i18n-breathe-index && pnpm run check:native-i18n-for-index && pnpm run check:native-i18n-breathe-pages && pnpm run check:native-i18n-for-pages && pnpm run check:native-i18n-duration-pages && pnpm run check:native-i18n-insomnia && pnpm run check:native-i18n-resonance-guides && pnpm run check:native-i18n-rw02-route-client && pnpm run check:native-i18n-holiday && pnpm run check:native-i18n-rw03-app-pages && pnpm run check:native-i18n-breathing-visualizer && pnpm run check:native-i18n-embed && pnpm run check:native-i18n-trust-pages && pnpm run check:native-i18n-privacy-support && pnpm run check:native-i18n-stats && pnpm test", "dev": "next dev -H 0.0.0.0", "build": "next build", "start": "next start", diff --git a/public/llms.txt b/public/llms.txt index e4c78348..31ad503d 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -60,4 +60,15 @@ Canonical language prefixes: - [German](https://deepbreathingexercises.com/de) - [Japanese](https://deepbreathingexercises.com/ja) -Append the same language prefix to a canonical exercise or guide path, for example `/es/breathe/box` or `/pt/for/sleep`. +For the five languages above, append the language prefix to a canonical exercise or guide path, for example `/es/breathe/box` or `/pt/for/sleep`. + +Italian is available on a smaller set of pages. Use these explicit destinations: + +- [Italian home](https://deepbreathingexercises.com/it) +- [Italian breathing techniques](https://deepbreathingexercises.com/it/breathe) +- [Respirazione diaframmatica](https://deepbreathingexercises.com/it/breathe/belly) +- [Respirazione quadrata](https://deepbreathingexercises.com/it/breathe/box) +- [Timer respirazione 4-7-8](https://deepbreathingexercises.com/it/4-7-8-breathing-timer) +- [Coerenza cardiaca](https://deepbreathingexercises.com/it/breathe/coherent) + +Do not infer Italian versions of other exercise or guide URLs. The [language directory](https://deepbreathingexercises.com/languages) links to available translations. diff --git a/scripts/i18n/bespoke/compile-breathe-index-content.mjs b/scripts/i18n/bespoke/compile-breathe-index-content.mjs index f22e41bb..277bef85 100644 --- a/scripts/i18n/bespoke/compile-breathe-index-content.mjs +++ b/scripts/i18n/bespoke/compile-breathe-index-content.mjs @@ -15,6 +15,7 @@ const bindingsPath = join(contentRoot, "occurrence-bindings.json"); const overridesPath = join(contentRoot, "overrides.json"); const replacementsPath = join(contentRoot, "reviewed-replacements.json"); const outputRoot = join(contentRoot, "messages"); +const italianPilotPath = join(repoRoot, "src/i18n/content/italian-pilot/breathe-index.json"); export const BREATHE_INDEX_LOCALES = ["de-de", "es-es", "fr-fr", "ja-jp", "pt-br"]; const SOURCE_ROUTE = "/breathe"; @@ -70,6 +71,39 @@ function validateTranslation(sourceText, translation, label) { assert(translation.length <= Math.max(sourceText.length * 8, 320), `${label} is unexpectedly long`); } +function validateItalianTranslation(sourceText, translation, label) { + validateTranslation(sourceText, translation, label); + const numbers = (value) => (value.normalize("NFKC").match(/\d+(?:[.,]\d+)?/g) ?? []) + .map((token) => token.replace(",", ".")) + .sort(); + const placeholders = (value) => (value.match(/\{\{?[A-Za-z_][A-Za-z0-9_.-]*\}?\}|%(?:\([A-Za-z_][A-Za-z0-9_.-]*\))?[sdif]/g) ?? []).sort(); + const symbols = (value) => (value.match(/[→←↔%]/g) ?? []).sort(); + assert(JSON.stringify(numbers(translation)) === JSON.stringify(numbers(sourceText)), `${label} changed numeric values`); + assert(JSON.stringify(placeholders(translation)) === JSON.stringify(placeholders(sourceText)), `${label} changed placeholders`); + assert(JSON.stringify(symbols(translation)) === JSON.stringify(symbols(sourceText)), `${label} changed protected symbols`); +} + +async function readItalianPilot(bindings, bindingsByPath) { + const file = await readJson(italianPilotPath); + assert(file.schemaVersion === 1, "Unsupported Italian breathe-index schema"); + assert(file.sourceRoute === SOURCE_ROUTE, "Italian breathe-index source route changed"); + assert(Array.isArray(file.entries), "Italian breathe-index entries changed"); + assert(file.entries.length === bindings.length, "Italian breathe-index entry count changed"); + const entries = new Map(); + for (const entry of file.entries) { + const binding = bindingsByPath.get(entry.messagePath); + assert(binding, `Unknown Italian breathe-index message ${entry.messagePath}`); + assert(!entries.has(entry.messagePath), `Duplicate Italian breathe-index message ${entry.messagePath}`); + assert(entry.sourceText === binding.sourceText, `${entry.messagePath} Italian source changed`); + assert(entry.reviewedSourceHash === sha256(entry.sourceText), `${entry.messagePath} Italian source hash changed`); + assert(typeof entry.reason === "string" && entry.reason.trim(), `${entry.messagePath} Italian lacks a reason`); + validateItalianTranslation(entry.sourceText, entry.translation, `${entry.messagePath}:it-it`); + entries.set(entry.messagePath, entry); + } + assert(entries.size === bindings.length, "Italian breathe-index does not cover every binding"); + return entries; +} + function validateManualFile(file, kind, bindingsByPath) { assert(file.schemaVersion === 1, `Unsupported breathe-index ${kind} schema`); const records = file[kind]; @@ -139,6 +173,8 @@ export async function buildBreatheIndexContentArtifacts() { assert(binding.sourceText === sourceText, `${messagePath} source drift`); } + const italianEntries = await readItalianPilot(bindings, bindingsByPath); + validateManualFile(overrides, "overrides", bindingsByPath); validateManualFile(replacements, "replacements", bindingsByPath); const overrideByPath = new Map(overrides.overrides.map((record) => [record.messagePath, record])); @@ -234,6 +270,33 @@ export async function buildBreatheIndexContentArtifacts() { }; } + const italianMessages = structuredClone(source); + const italianProvenance = {}; + for (const binding of bindings) { + const entry = italianEntries.get(binding.messagePath); + setPath(italianMessages, binding.messagePath, entry.translation); + italianProvenance[binding.messagePath] = { + reason: entry.reason, + sourceHash: entry.reviewedSourceHash, + status: "italian-pilot-reviewed", + }; + } + const italianSerialized = stableJson(italianMessages); + outputs.set("messages/it-it.json", italianSerialized); + provenance.locales["it-it"] = italianProvenance; + publication.locales["it-it"] = { + catalogExact: 0, + catalogNormalized: 0, + override: 0, + replacement: 0, + unresolved: 0, + bytes: Buffer.byteLength(italianSerialized), + path: "messages/it-it.json", + publishable: true, + resolvedMessages: bindings.length, + sha256: sha256(italianSerialized), + }; + unresolved.unresolved.sort((left, right) => `${left.messagePath}:${left.locale}`.localeCompare(`${right.messagePath}:${right.locale}`, "en") ); diff --git a/scripts/i18n/bespoke/compile-home-content.mjs b/scripts/i18n/bespoke/compile-home-content.mjs index 5556c66d..f9786e6e 100644 --- a/scripts/i18n/bespoke/compile-home-content.mjs +++ b/scripts/i18n/bespoke/compile-home-content.mjs @@ -15,6 +15,7 @@ const bindingsPath = join(contentRoot, "occurrence-bindings.json"); const overridesPath = join(contentRoot, "overrides.json"); const replacementsPath = join(contentRoot, "reviewed-replacements.json"); const outputRoot = join(contentRoot, "messages"); +const italianPilotPath = join(repoRoot, "src/i18n/content/italian-pilot/home.json"); export const HOME_LOCALES = ["de-de", "es-es", "fr-fr", "ja-jp", "pt-br"]; const SOURCE_ROUTE = "/"; @@ -62,6 +63,27 @@ function deepClone(value) { return structuredClone(value); } +async function readItalianPilot(bindings, bindingsByPath) { + const file = await readJson(italianPilotPath); + assert(file.schemaVersion === 1, "Unsupported Italian home schema"); + assert(file.sourceRoute === SOURCE_ROUTE, "Italian home source route changed"); + assert(Array.isArray(file.entries), "Italian home entries changed"); + assert(file.entries.length === bindings.length, "Italian home entry count changed"); + const entries = new Map(); + for (const entry of file.entries) { + const binding = bindingsByPath.get(entry.messagePath); + assert(binding, `Unknown Italian home message ${entry.messagePath}`); + assert(!entries.has(entry.messagePath), `Duplicate Italian home message ${entry.messagePath}`); + assert(entry.sourceText === binding.sourceText, `${entry.messagePath} Italian source changed`); + assert(entry.reviewedSourceHash === sha256(entry.sourceText), `${entry.messagePath} Italian source hash changed`); + assert(typeof entry.reason === "string" && entry.reason.trim(), `${entry.messagePath} Italian lacks a reason`); + validateItalianTranslation(entry.sourceText, entry.translation, `${entry.messagePath}:it-it`); + entries.set(entry.messagePath, entry); + } + assert(entries.size === bindings.length, "Italian home does not cover every binding"); + return entries; +} + function flattenStringLeaves(value, prefix = "", output = new Map()) { for (const [key, child] of Object.entries(value)) { const childPath = prefix ? `${prefix}.${key}` : key; @@ -79,6 +101,18 @@ function validateTranslation(sourceText, translation, label) { assert(translation.length <= Math.max(sourceText.length * 8, 320), `${label} is unexpectedly long`); } +function validateItalianTranslation(sourceText, translation, label) { + validateTranslation(sourceText, translation, label); + const numbers = (value) => (value.normalize("NFKC").match(/\d+(?:[.,]\d+)?/g) ?? []) + .map((token) => token.replace(",", ".")) + .sort(); + const placeholders = (value) => (value.match(/\{\{?[A-Za-z_][A-Za-z0-9_.-]*\}?\}|%(?:\([A-Za-z_][A-Za-z0-9_.-]*\))?[sdif]/g) ?? []).sort(); + const symbols = (value) => (value.match(/[→←↔%]/g) ?? []).sort(); + assert(JSON.stringify(numbers(translation)) === JSON.stringify(numbers(sourceText)), `${label} changed numeric values`); + assert(JSON.stringify(placeholders(translation)) === JSON.stringify(placeholders(sourceText)), `${label} changed placeholders`); + assert(JSON.stringify(symbols(translation)) === JSON.stringify(symbols(sourceText)), `${label} changed protected symbols`); +} + function validateManualFile(file, kind, bindingsByPath) { assert(file.schemaVersion === 1, `Unsupported home ${kind} schema`); const records = file[kind]; @@ -141,6 +175,8 @@ export async function buildHomeContentArtifacts() { const bindings = bindingFile.bindings; const bindingsByPath = new Map(bindings.map((binding) => [binding.messagePath, binding])); + const italianEntries = await readItalianPilot(bindings, bindingsByPath); + for (const binding of bindings) { assert(getPath(source, binding.messagePath) === binding.sourceText, `${binding.messagePath} source drift`); } @@ -253,6 +289,34 @@ export async function buildHomeContentArtifacts() { }; } + const italianMessages = deepClone(source); + const italianProvenance = {}; + for (const binding of bindings) { + const entry = italianEntries.get(binding.messagePath); + setPath(italianMessages, binding.messagePath, entry.translation); + italianProvenance[binding.messagePath] = { + reason: entry.reason, + sourceHash: entry.reviewedSourceHash, + status: "italian-pilot-reviewed", + }; + } + const italianSerialized = stableJson(italianMessages); + outputs.set("messages/it-it.json", italianSerialized); + provenance.locales["it-it"] = italianProvenance; + publication.locales["it-it"] = { + bytes: Buffer.byteLength(italianSerialized), + catalogExact: 0, + catalogNormalized: 0, + overrideMessages: 0, + path: "messages/it-it.json", + publishable: true, + replacementMessages: 0, + resolvedMessages: bindings.length, + reviewedReplacementMessages: 0, + sha256: sha256(italianSerialized), + unresolved: 0, + }; + outputs.set("publication.json", stableJson(publication)); outputs.set("provenance.json", stableJson(provenance)); outputs.set("unresolved.json", stableJson(unresolved)); @@ -296,4 +360,4 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur const result = await writeHomeContentArtifacts(); console.log(JSON.stringify({ ...result, mode: "write" })); } -} \ No newline at end of file +} diff --git a/scripts/i18n/bespoke/compile-privacy-support-content.mjs b/scripts/i18n/bespoke/compile-privacy-support-content.mjs index 187ff44c..c4540950 100644 --- a/scripts/i18n/bespoke/compile-privacy-support-content.mjs +++ b/scripts/i18n/bespoke/compile-privacy-support-content.mjs @@ -11,6 +11,7 @@ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); const contentRoot = join(repoRoot, "src/i18n/content/bespoke/privacy-support"); const catalogRoot = join(repoRoot, "src/i18n/catalog"); const manualRoot = join(repoRoot, "src/i18n/content/remaining-pages/manual"); +const italianPilotRoot = join(repoRoot, "src/i18n/content/italian-pilot"); export const PRIVACY_SUPPORT_LOCALES = [ "de-de", @@ -146,6 +147,27 @@ function validateTranslation( }); } +async function readItalianPilot(routeName, sourceLeaves, sourceRoute) { + const file = await readJson(join(italianPilotRoot, `${routeName}.json`)); + assert(file.schemaVersion === 1, `Unsupported Italian ${routeName} schema`); + assert(file.sourceRoute === sourceRoute, `Italian ${routeName} source route changed`); + assert(Array.isArray(file.entries), `Italian ${routeName} entries changed`); + assert(file.entries.length === sourceLeaves.size, `Italian ${routeName} entry count changed`); + const entries = new Map(); + for (const entry of file.entries) { + const sourceText = sourceLeaves.get(entry.messagePath); + assert(typeof sourceText === "string", `Unknown Italian ${routeName} message ${entry.messagePath}`); + assert(!entries.has(entry.messagePath), `Duplicate Italian ${routeName} message ${entry.messagePath}`); + assert(entry.sourceText === sourceText, `${routeName}:${entry.messagePath} Italian source changed`); + assert(entry.reviewedSourceHash === sha256(entry.sourceText), `${routeName}:${entry.messagePath} Italian source hash changed`); + assert(typeof entry.reason === "string" && entry.reason.trim(), `${routeName}:${entry.messagePath} Italian lacks a reason`); + validateTranslation(sourceText, entry.translation, `${routeName}:it-it:${entry.messagePath}`); + entries.set(entry.messagePath, entry); + } + assert(entries.size === sourceLeaves.size, `Italian ${routeName} does not cover every source leaf`); + return entries; +} + function catalogFileName(route) { return route === "/" ? "_root.json" : `${route.slice(1)}.json`; } @@ -355,6 +377,7 @@ export async function buildPrivacySupportArtifacts() { for (const [routeName, config] of Object.entries(ROUTES)) { const { source, sourceLeaves, bindings, manualEntries } = routeInputs[routeName]; + const italianEntries = await readItalianPilot(routeName, sourceLeaves, config.sourceRoute); const claimedPaths = validateRouteBindings( routeName, config, @@ -553,6 +576,27 @@ export async function buildPrivacySupportArtifacts() { sha256: sha256(raw), }; } + + const italianLocalized = structuredClone(source); + const italianProvenance = {}; + for (const [messagePath, entry] of italianEntries) { + setPath(italianLocalized, messagePath, entry.translation); + italianProvenance[messagePath] = { + status: "italian-pilot-reviewed", + reviewedSourceHash: entry.reviewedSourceHash, + reason: entry.reason, + }; + } + const italianRaw = stableJson(italianLocalized); + const italianOutputPath = `messages/${routeName}/it-it.json`; + outputs.set(italianOutputPath, italianRaw); + provenance.routes[routeName].locales["it-it"] = italianProvenance; + routePublication.locales["it-it"] = { + path: italianOutputPath, + resolvedMessages: italianEntries.size, + publishable: true, + sha256: sha256(italianRaw), + }; publication.routes[routeName] = routePublication; } diff --git a/scripts/i18n/bespoke/compile-timer-content.mjs b/scripts/i18n/bespoke/compile-timer-content.mjs index 52bd1998..cd8b0567 100644 --- a/scripts/i18n/bespoke/compile-timer-content.mjs +++ b/scripts/i18n/bespoke/compile-timer-content.mjs @@ -15,6 +15,7 @@ const outputRoot = join(contentRoot, "messages"); const publicationPath = join(contentRoot, "publication.json"); const provenancePath = join(contentRoot, "provenance.json"); const unresolvedPath = join(contentRoot, "unresolved.json"); +const italianPilotPath = join(repoRoot, "src/i18n/content/italian-pilot/timer.json"); export const TIMER_LOCALES = ["de-de", "es-es", "fr-fr", "ja-jp", "pt-br"]; const SOURCE_ROUTE = "/4-7-8-breathing-timer"; @@ -149,6 +150,39 @@ function validateManualFile(file, kind, source) { } } +function validateItalianTranslation(sourceText, translation, label) { + assert(typeof translation === "string" && translation.trim(), `${label} is empty`); + assert(!/<\/?(?:script|style|iframe|object|embed)\b/i.test(translation), `${label} contains unsafe markup`); + const numbers = (value) => (value.normalize("NFKC").match(/\d+(?:[.,]\d+)?/g) ?? []) + .map((token) => token.replace(",", ".")) + .sort(); + const placeholders = (value) => (value.match(/\{\{?[A-Za-z_][A-Za-z0-9_.-]*\}?\}|%(?:\([A-Za-z_][A-Za-z0-9_.-]*\))?[sdif]/g) ?? []).sort(); + const symbols = (value) => (value.match(/[→←↔%]/g) ?? []).sort(); + assert(JSON.stringify(numbers(translation)) === JSON.stringify(numbers(sourceText)), `${label} changed numeric values`); + assert(JSON.stringify(placeholders(translation)) === JSON.stringify(placeholders(sourceText)), `${label} changed placeholders`); + assert(JSON.stringify(symbols(translation)) === JSON.stringify(symbols(sourceText)), `${label} changed protected symbols`); +} + +async function readItalianPilot(source) { + const file = await readJson(italianPilotPath); + assert(file.schemaVersion === 1, "Unsupported Italian timer schema"); + assert(file.sourceRoute === SOURCE_ROUTE, "Italian timer source route changed"); + assert(Array.isArray(file.entries), "Italian timer entries changed"); + assert(file.entries.length === Object.keys(source).length, "Italian timer entry count changed"); + const entries = new Map(); + for (const entry of file.entries) { + assert(typeof source[entry.messageId] === "string", `Unknown Italian timer message ${entry.messageId}`); + assert(!entries.has(entry.messageId), `Duplicate Italian timer message ${entry.messageId}`); + assert(entry.sourceText === source[entry.messageId], `${entry.messageId} Italian source changed`); + assert(entry.reviewedSourceHash === sha256(entry.sourceText), `${entry.messageId} Italian source hash changed`); + assert(typeof entry.reason === "string" && entry.reason.trim(), `${entry.messageId} Italian lacks a reason`); + validateItalianTranslation(entry.sourceText, entry.translation, `${entry.messageId}:it-it`); + entries.set(entry.messageId, entry); + } + assert(entries.size === Object.keys(source).length, "Italian timer does not cover every message"); + return entries; +} + export async function buildTimerContentArtifacts() { const [source, overrides, replacements] = await Promise.all([ readJson(sourcePath), @@ -157,6 +191,7 @@ export async function buildTimerContentArtifacts() { ]); validateManualFile(overrides, "overrides", source); validateManualFile(replacements, "replacements", source); + const italianEntries = await readItalianPilot(source); const overrideIndex = new Map( overrides.overrides.flatMap((record) => Object.entries(record.translations).map(([locale, translation]) => [ `${record.messageId}:${locale}`, @@ -253,6 +288,32 @@ export async function buildTimerContentArtifacts() { provenance.locales[locale] = localeProvenance; } + const italianMessages = {}; + const italianProvenance = {}; + for (const [messageId, sourceText] of Object.entries(source)) { + const entry = italianEntries.get(messageId); + italianMessages[messageId] = entry.translation; + italianProvenance[messageId] = { + reason: entry.reason, + sourceHash: entry.reviewedSourceHash, + status: "italian-pilot-reviewed", + }; + } + const italianSerialized = stableJson(italianMessages); + outputs.set("messages/it-it.json", italianSerialized); + provenance.locales["it-it"] = italianProvenance; + publication.locales["it-it"] = { + catalogExact: 0, + catalogNormalized: 0, + override: 0, + replacement: 0, + unresolved: 0, + path: "messages/it-it.json", + publishable: true, + resolvedMessages: Object.keys(source).length, + sha256: sha256(italianSerialized), + }; + unresolved.unresolved.sort((left, right) => compareText(`${left.messageId}:${left.locale}`, `${right.messageId}:${right.locale}`) ); diff --git a/scripts/i18n/remaining-pages/compile-remaining-page-gaps.mjs b/scripts/i18n/remaining-pages/compile-remaining-page-gaps.mjs index 49c48225..04be81c8 100644 --- a/scripts/i18n/remaining-pages/compile-remaining-page-gaps.mjs +++ b/scripts/i18n/remaining-pages/compile-remaining-page-gaps.mjs @@ -235,6 +235,13 @@ export function preserveReviewedGapValues(generated, existing, locales) { `${generated.sourceRoute}:${target.messageId}: existing ${field} changed`, ); } + if (prior.reviewReason !== undefined) { + assert( + typeof prior.reviewReason === "string" && prior.reviewReason.trim(), + `${generated.sourceRoute}:${target.messageId}: existing reviewReason is invalid`, + ); + target.reviewReason = prior.reviewReason; + } assert( JSON.stringify(Object.keys(prior.translations ?? {})) === JSON.stringify(locales), diff --git a/scripts/i18n/structured-breathe/compile-breathe-content.mjs b/scripts/i18n/structured-breathe/compile-breathe-content.mjs index 7cd1f6be..810e97a5 100644 --- a/scripts/i18n/structured-breathe/compile-breathe-content.mjs +++ b/scripts/i18n/structured-breathe/compile-breathe-content.mjs @@ -14,12 +14,15 @@ const outputRoot = join(repoRoot, "src/i18n/content/breathe"); const catalogRoot = join(repoRoot, "src/i18n/catalog"); const manualRoot = join(outputRoot, "manual"); const replacementRoot = join(outputRoot, "reviewed-replacements"); +const italianPilotRoot = join(repoRoot, "src/i18n/content/italian-pilot/breathe"); const proposedReplacementsPath = join(repoRoot, "docs/native-i18n/work/breathe-reviewed-replacements.proposed.json"); const sourceFilePath = join(repoRoot, "src/data/breathing-pages.ts"); const proofRoot = join(repoRoot, "src/i18n/content/proof"); const routeManifestPath = join(repoRoot, "src/i18n/route-manifest.ts"); export const BREATHE_CONTENT_LOCALES = ["de-de", "es-es", "fr-fr", "ja-jp", "pt-br"]; +export const BREATHE_CONTENT_RUNTIME_LOCALES = [...BREATHE_CONTENT_LOCALES, "it-it"]; +export const ITALIAN_PILOT_SLUGS = ["belly", "box", "coherent"]; const MODE_NAMES = { Belly: "Belly Breathing", @@ -336,6 +339,15 @@ async function extractSourcePages() { return Object.fromEntries(pages.map((page) => [page.slug, page])); } +function walkStringLeaves(value, prefix = "", output = []) { + if (typeof value === "string") output.push([prefix, value]); + else if (Array.isArray(value)) value.forEach((child, index) => walkStringLeaves(child, `${prefix}[${index}]`, output)); + else if (value && typeof value === "object") for (const [key, child] of Object.entries(value)) { + walkStringLeaves(child, prefix ? `${prefix}.${key}` : key, output); + } + return output; +} + function parsePath(path) { const parts = []; const pattern = /([^.\[\]]+)|\[(\d+)\]/g; @@ -351,6 +363,10 @@ function setPath(target, path, value) { parent[field] = value; } +function getPath(target, path) { + return parsePath(path).reduce((current, part) => current?.[part], target); +} + function approvedSegments(catalog) { return catalog.segments.filter((segment) => segment.translation?.isApproved === true && @@ -458,6 +474,38 @@ async function readManual(slug) { } } +async function readItalianPilot(slug, sourcePage, chrome, audited) { + const file = await readJson(join(italianPilotRoot, `${slug}.json`)); + assert(file.schemaVersion === 1, `${slug} Italian schema is unsupported`); + assert(file.sourceRoute === `/breathe/${slug}`, `${slug} Italian route changed`); + assert(Array.isArray(file.entries), `${slug} Italian entries must be an array`); + const sourceContent = new Map( + audited.leaves.filter((leaf) => leaf.category === "content").map((leaf) => [leaf.path, leaf.sourceText]), + ); + const sourceContentAll = new Map(walkStringLeaves(sourcePage)); + const expectedChrome = new Map(Object.entries(chrome)); + const entries = new Map(); + for (const entry of file.entries) { + assert(entry.scope === "content" || entry.scope === "chrome", `${slug}:${entry.messageId} Italian scope changed`); + const expectedSource = entry.scope === "content" + ? sourceContentAll.get(entry.messageId) + : expectedChrome.get(entry.messageId); + assert(typeof expectedSource === "string", `${slug}:${entry.scope}:${entry.messageId} Italian message is unknown`); + assert(!entries.has(`${entry.scope}:${entry.messageId}`), `${slug} duplicate Italian ${entry.scope}:${entry.messageId}`); + assert(entry.sourceText === expectedSource, `${slug}:${entry.messageId} Italian source changed`); + assert(entry.reviewedSourceHash === sha256(entry.sourceText), `${slug}:${entry.messageId} Italian source hash changed`); + assert(typeof entry.reason === "string" && entry.reason.trim(), `${slug}:${entry.messageId} Italian lacks a review reason`); + assert(typeof entry.translation === "string" && entry.translation.trim(), `${slug}:${entry.messageId} Italian translation is empty`); + validateBreatheTranslationSafety(entry.sourceText, entry.translation, `${slug}:${entry.messageId}:it-it`, { + numericReviewReason: entry.numericReviewReason, + }); + entries.set(`${entry.scope}:${entry.messageId}`, entry); + } + for (const messageId of sourceContent.keys()) assert(entries.has(`content:${messageId}`), `${slug}:content:${messageId} Italian message is missing`); + for (const messageId of expectedChrome.keys()) assert(entries.has(`chrome:${messageId}`), `${slug}:chrome:${messageId} Italian message is missing`); + return entries; +} + async function readReplacements(slug) { try { const file = await readJson(join(replacementRoot, `${slug}.json`)); @@ -536,7 +584,7 @@ function unresolvedEntry(scope, messageId, sourceText, locales, reason) { function renderTypes(slugs) { return `import type { BreathingPageContent } from "@/data/breathing-pages";\n\n` + - `export const BREATHE_CONTENT_LOCALES = ${JSON.stringify(BREATHE_CONTENT_LOCALES)} as const;\n` + + `export const BREATHE_CONTENT_LOCALES = ${JSON.stringify(BREATHE_CONTENT_RUNTIME_LOCALES)} as const;\n` + `export const BREATHE_CONTENT_SLUGS = ${JSON.stringify(slugs)} as const;\n\n` + `export type BreatheContentLocale = (typeof BREATHE_CONTENT_LOCALES)[number];\n` + `export type BreatheContentSlug = (typeof BREATHE_CONTENT_SLUGS)[number];\n` + @@ -549,6 +597,7 @@ function renderLoader(slugs) { for (const locale of BREATHE_CONTENT_LOCALES) { for (const slug of slugs) entries.push({ locale, slug }); } + for (const slug of ITALIAN_PILOT_SLUGS) entries.push({ locale: "it-it", slug }); const contentLoaders = entries.map(({ locale, slug }) => ` "${locale}:${slug}": () => import("../routes/${locale}/${slug}.json"),` ).join("\n"); @@ -559,8 +608,8 @@ function renderLoader(slugs) { `import type { BreathingPageContent } from "@/data/breathing-pages";\n` + `import publication from "../publication.json";\n` + `import type { BreatheChromeMessages, BreatheContentLocale, BreatheContentSlug, BreatheRouteBundle } from "../types";\n\n` + - `const contentLoaders = {\n${contentLoaders}\n} as const;\n\n` + - `const chromeLoaders = {\n${chromeLoaders}\n} as const;\n\n` + + `const contentLoaders: Record Promise<{ default: unknown }>> = {\n${contentLoaders}\n};\n\n` + + `const chromeLoaders: Record Promise<{ default: unknown }>> = {\n${chromeLoaders}\n};\n\n` + `function assertPublishable(slug: BreatheContentSlug, locale: BreatheContentLocale) {\n` + ` const route = publication.routes[\`/breathe/\${slug}\` as keyof typeof publication.routes];\n` + ` const localeState = route?.locales[locale as keyof typeof route.locales];\n` + @@ -568,12 +617,16 @@ function renderLoader(slugs) { `}\n\n` + `export async function loadBreatheContent(slug: BreatheContentSlug, locale: BreatheContentLocale): Promise {\n` + ` assertPublishable(slug, locale);\n` + - ` const contentModule = await contentLoaders[\`\${locale}:\${slug}\`]();\n` + + ` const contentLoader = contentLoaders[\`\${locale}:\${slug}\`];\n` + + ` if (!contentLoader) throw new Error(\`Breathe content loader is missing: \${locale}:\${slug}\`);\n` + + ` const contentModule = await contentLoader();\n` + ` return contentModule.default as BreathingPageContent;\n` + `}\n\n` + `export async function loadBreatheChrome(slug: BreatheContentSlug, locale: BreatheContentLocale): Promise {\n` + ` assertPublishable(slug, locale);\n` + - ` const chromeModule = await chromeLoaders[\`\${locale}:\${slug}\`]();\n` + + ` const chromeLoader = chromeLoaders[\`\${locale}:\${slug}\`];\n` + + ` if (!chromeLoader) throw new Error(\`Breathe chrome loader is missing: \${locale}:\${slug}\`);\n` + + ` const chromeModule = await chromeLoader();\n` + ` return chromeModule.default as BreatheChromeMessages;\n` + `}\n\n` + `export async function loadBreatheRoute(slug: BreatheContentSlug, locale: BreatheContentLocale): Promise {\n` + @@ -653,7 +706,7 @@ export async function buildBreatheArtifacts() { const auditedPages = audit.pages.filter((page) => page.route.startsWith("/breathe/")); assertBreatheManifestAlignment(routeManifestSource, auditedPages.map((page) => page.slug)); const outputs = new Map(); - const publication = { locales: BREATHE_CONTENT_LOCALES, routes: {}, schemaVersion: 1 }; + const publication = { locales: BREATHE_CONTENT_RUNTIME_LOCALES, routes: {}, schemaVersion: 1 }; const scaffolds = {}; const sharedManual = await readManual("_shared"); const sharedManualValues = manualIndex(sharedManual); @@ -673,6 +726,9 @@ export async function buildBreatheArtifacts() { ]))); const chrome = chromeSources(sourcePage); for (const related of sourcePage.related ?? []) chrome[`chrome.pattern.related-${related.slug}-title`] = sourcePages[related.slug]?.hero.title ?? related.slug; + const italianEntries = ITALIAN_PILOT_SLUGS.includes(slug) + ? await readItalianPilot(slug, sourcePage, chrome, audited) + : null; const routePublication = { contentMessages: audited.leaves.filter((leaf) => leaf.category === "content").length, locales: {}, routeId }; const routeProvenance = { locales: {}, schemaVersion: 1, sourceRoute: audited.route }; @@ -774,6 +830,42 @@ export async function buildBreatheArtifacts() { }; } + if (italianEntries) { + const localized = structuredClone(sourcePage); + const localizedChrome = {}; + const localeProvenance = { chrome: {}, content: {} }; + for (const leaf of audited.leaves.filter((entry) => entry.category === "content")) { + const entry = italianEntries.get(`content:${leaf.path}`); + setPath(localized, leaf.path, entry.translation); + localeProvenance.content[leaf.path] = { + sourceHash: entry.reviewedSourceHash, + sourceText: entry.sourceText, + status: "italian-pilot-reviewed", + }; + } + for (const [messageId, sourceText] of Object.entries(chrome)) { + const entry = italianEntries.get(`chrome:${messageId}`); + localizedChrome[messageId] = entry.translation; + localeProvenance.chrome[messageId] = { + sourceHash: entry.reviewedSourceHash, + sourceText: entry.sourceText, + status: "italian-pilot-reviewed", + }; + } + const routePath = `routes/it-it/${slug}.json`; + const chromePath = `chrome/it-it/${slug}.json`; + outputs.set(routePath, stableJson(localized)); + outputs.set(chromePath, stableJson(localizedChrome)); + routeProvenance.locales["it-it"] = localeProvenance; + routePublication.locales["it-it"] = { + chromeMessages: Object.keys(localizedChrome).length, + chromePath, + publishable: true, + routePath, + unresolved: 0, + }; + } + scaffolds[slug] = { entries: [...scaffoldByKey.values()].sort((left, right) => compareText(`${left.scope}:${left.messageId}`, `${right.scope}:${right.messageId}`)), schemaVersion: 1, diff --git a/scripts/tests/breathing-regression.test.mjs b/scripts/tests/breathing-regression.test.mjs index 3e46c0cf..298225cc 100644 --- a/scripts/tests/breathing-regression.test.mjs +++ b/scripts/tests/breathing-regression.test.mjs @@ -37,7 +37,7 @@ test("mobile: speed slider — LEFT = slower, RIGHT = faster, default centered", assert.match(sharedPacing, /SLIDER_MID = 1\.25/, "centered default position value"); // left end -> slowest (2x multiplier), right end -> fastest (0.5x) assert.match(sharedPacing, /return m >= 1 \? 2 - 0\.75 \* m : 2\.75 - 1\.5 \* m;/, "left=slow, right=fast map"); - assert.match(sharedPacing, /speedOf = \(multiplier: number\): number => 1 \/ multiplier/, "label reads as speed"); + assert.match(sharedPacing, /speedOf = \(multiplier: number\): number => 1 \/ multiplier/, "display speed is the inverse of the duration multiplier"); // Mobile pacing.ts is a thin re-export from the shared source. assert.match(pacing, /from '@resonance\/domain'/, "mobile pacing re-exports from domain"); // the slider input uses the mapping and a 0.05 step so the default centers @@ -282,13 +282,15 @@ test("desktop: single speed slider — LEFT = slower, RIGHT = faster, default ce assert.match(resonance, /const \[speedMultiplier, setSpeedMultiplier\] = useState/); assert.match(resonance, /type="range"/); // Desktop imports the shared mapping from @resonance/domain — no inline definitions. - assert.match(resonance, /import \{ multiplierToSlider, sliderToMultiplier \} from '@resonance\/domain'/, - "desktop imports slider fns from shared domain package"); + assert.match(resonance, /import \{ multiplierToSlider, sliderToMultiplier, speedOf \} from '@resonance\/domain'/, + "desktop imports slider and display-speed fns from shared domain package"); // Still uses them at the call site (the contract pins the wiring, not the // definition location). assert.match(resonance, /value=\{multiplierToSlider\(speedMultiplier\)\}/); assert.match(resonance, /onChange=\{\(e\) => setSpeedMultiplier\(sliderToMultiplier\(parseFloat\(e\.target\.value\)\)\)/); assert.match(resonance, /step="0\.05"/, "0.05 step keeps the centered default on-grid"); + assert.match(resonance, /ui\.speed_multiplier.*speedOf\(speedMultiplier\)\.toFixed\(1\)/, + "desktop displays speedOf so the label preserves slow-left/fast-right semantics"); // Verify the mapping lives in the shared source. assert.match(sharedPacing, /multiplierToSlider = /, "shared source defines multiplierToSlider"); assert.match(sharedPacing, /sliderToMultiplier = /, "shared source defines sliderToMultiplier"); diff --git a/scripts/tests/conversion-loss-aversion.test.mjs b/scripts/tests/conversion-loss-aversion.test.mjs index 9a8aa832..00c9b459 100644 --- a/scripts/tests/conversion-loss-aversion.test.mjs +++ b/scripts/tests/conversion-loss-aversion.test.mjs @@ -98,7 +98,7 @@ test("loss-aversion sheet fires signin_* events tagged variant: loss_aversion", test("loss-aversion sheet renders the real session card + reference copy", () => { const src = read(SHEET); assert.match(src, /SESSION COMPLETE/, "shows the SESSION COMPLETE eyebrow"); - assert.match(src, /\{sessionMode\}/, "renders the real mode label from props"); + assert.match(src, /localizedSessionMode \?\? sessionMode/, "renders the localized mode label with the source mode fallback"); assert.match(src, /just now/, "shows 'just now'"); assert.match(src, /formatDuration\(sessionSeconds\)/, "renders M:SS from sessionSeconds"); assert.match(src, /Keep tonight('|')s calm\./, "literal headline 'Keep tonight's calm.'"); @@ -106,7 +106,7 @@ test("loss-aversion sheet renders the real session card + reference copy", () => assert.match(src, /Continue with Google/, "Google primary CTA"); assert.match(src, /One tap\. No password\./, "Google subtitle"); assert.match(src, /or save with email/, "email expand label"); - assert.match(src, />\s*Not now\s*]*href=["']${SITE_URL}${prefix}["']`); assert.match( html, @@ -95,6 +95,7 @@ test("/languages SSR HTML preserves every locale and key-page crawl link", NEEDS test("/languages SSR HTML renders native labels for translated destinations", NEEDS_BUILD, () => { const html = fs.readFileSync(LANGUAGES_HTML, "utf8"); const expectedLinks = [ + ["/it/breathe/belly", "Respirazione addominale"], ["/es/breathe/belly", "Respiración abdominal"], ["/pt/breathe/pursed-lip", "Respiração com lábios franzidos"], ["/fr/breathe/coherent", "Cohérence cardiaque"], @@ -115,7 +116,7 @@ test("/languages SSR HTML renders native labels for translated destinations", NE ); } - for (const locale of ["es", "pt", "fr", "de", "ja"]) { + for (const locale of ["es-ES", "pt-BR", "fr-FR", "de-DE", "ja-JP", "it-IT"]) { assert.match(html, new RegExp(`]*lang=["']${locale}["']`)); } }); diff --git a/scripts/tests/native-i18n-breathe-content.test.mjs b/scripts/tests/native-i18n-breathe-content.test.mjs index e27677be..362b60ee 100644 --- a/scripts/tests/native-i18n-breathe-content.test.mjs +++ b/scripts/tests/native-i18n-breathe-content.test.mjs @@ -15,7 +15,9 @@ import { containsUsCrisisNumber } from "../i18n/verify-native-preview-build.mjs" const repoRoot = process.cwd(); const root = path.join(repoRoot, "src/i18n/content/breathe"); -const locales = ["de-de", "es-es", "fr-fr", "ja-jp", "pt-br"]; +const catalogLocales = ["de-de", "es-es", "fr-fr", "ja-jp", "pt-br"]; +const locales = [...catalogLocales, "it-it"]; +const italianPilotSlugs = new Set(["belly", "box", "coherent"]); const slugs = [ "4-7-8", "9d-breathwork", "belly", "box", "breath-of-fire", "buteyko", "coherent", "hope-cartel-9d-breathwork", "nadi-shodhana", "physiological-sigh", "pursed-lip", "tummo", @@ -161,7 +163,10 @@ test("publication covers every structured breathe route with manifest route ids" const route = publication.routes[`/breathe/${slug}`]; assert.ok(route); assert.equal(route.routeId, slug === "buteyko" ? "breathe.buteyko" : `breathe-${slug}`); - assert.deepEqual(Object.keys(route.locales).sort(), [...locales].sort()); + const expectedLocales = italianPilotSlugs.has(slug) + ? locales + : catalogLocales; + assert.deepEqual(Object.keys(route.locales).sort(), [...expectedLocales].sort()); } }); @@ -171,8 +176,8 @@ test("generated loader is server-only and contains literal active-locale imports assert.match(loader, /export async function loadBreatheContent/); assert.match(loader, /export async function loadBreatheChrome/); assert.match(loader, /export async function loadBreatheRoute/); - assert.equal((loader.match(/import\("\.\.\/routes\//g) ?? []).length, 70); - assert.equal((loader.match(/import\("\.\.\/chrome\//g) ?? []).length, 70); + assert.equal((loader.match(/import\("\.\.\/routes\//g) ?? []).length, 73); + assert.equal((loader.match(/import\("\.\.\/chrome\//g) ?? []).length, 73); }); test("the normal build fails closed when breathe artifacts are stale", () => { @@ -197,7 +202,7 @@ test("metadata uses catalog head occurrences unless explicitly replaced", () => ? json(replacementFile).replacements : []; const manual = json(path.join(root, "manual", `${slug}.json`)).entries; - for (const locale of locales) { + for (const locale of catalogLocales) { const content = json(path.join(root, "routes", locale, `${slug}.json`)); const catalog = json(path.join(repoRoot, "src/i18n/catalog", locale, "pages/breathe", `${slug}.json`)); for (const [field, occurrenceKey] of Object.entries(headOccurrences)) { @@ -239,7 +244,7 @@ test("manual inputs are deduplicated and contain only unresolved locale slots", test("publication unresolved counts equal emitted null values", () => { const publication = json(path.join(root, "publication.json")); for (const slug of slugs) { - for (const locale of locales) { + for (const locale of catalogLocales) { const content = json(path.join(root, "routes", locale, `${slug}.json`)); const chrome = json(path.join(root, "chrome", locale, `${slug}.json`)); assert.equal( @@ -252,7 +257,7 @@ test("publication unresolved counts equal emitted null values", () => { }); test("Buteyko preserves proof content outside head metadata and reviewed replacements", () => { - for (const locale of locales) { + for (const locale of catalogLocales) { const content = json(path.join(root, "routes", locale, "buteyko.json")); const proof = json(path.join(root, "../proof/routes", locale, "breathe-buteyko.json")); assert.equal(content.hero.intro, proof.hero.intro); @@ -267,7 +272,7 @@ test("normalized, global, and reviewed replacement provenance remain explicit", let replacements = 0; for (const slug of slugs) { const provenance = json(path.join(root, "provenance", `${slug}.json`)); - for (const locale of locales) { + for (const locale of catalogLocales) { for (const record of Object.values(provenance.locales[locale].content)) { if (record.status.includes("normalized")) normalized += 1; if (record.status.startsWith("global-")) global += 1; @@ -277,5 +282,5 @@ test("normalized, global, and reviewed replacement provenance remain explicit", } assert.ok(normalized > 0); assert.ok(global > 0); - assert.equal(replacements, 50); + assert.equal(replacements, 78); }); diff --git a/scripts/tests/native-i18n-breathe-index-content.test.mjs b/scripts/tests/native-i18n-breathe-index-content.test.mjs index f1173416..5febe78b 100644 --- a/scripts/tests/native-i18n-breathe-index-content.test.mjs +++ b/scripts/tests/native-i18n-breathe-index-content.test.mjs @@ -13,6 +13,7 @@ const contentRoot = new URL( "../../src/i18n/content/bespoke/breathe-index/", import.meta.url, ); +const expectedOutputLocales = [...BREATHE_INDEX_LOCALES, "it-it"]; const EXPECTED_SLUGS = [ "box", @@ -52,9 +53,9 @@ test("compiles the 42-field breathe index deterministically", async () => { assert.deepEqual(second.publication, first.publication); assert.deepEqual([...second.outputs], [...first.outputs]); - assert.equal(first.outputs.size, 8); + assert.equal(first.outputs.size, 9); assert.equal(first.publication.expectedMessages, 42); - assert.deepEqual(Object.keys(first.publication.locales), BREATHE_INDEX_LOCALES); + assert.deepEqual(Object.keys(first.publication.locales), expectedOutputLocales); assert.deepEqual(first.unresolved.unresolved, []); for (const locale of BREATHE_INDEX_LOCALES) { @@ -68,6 +69,16 @@ test("compiles the 42-field breathe index deterministically", async () => { assert.equal(coverage.unresolved, 0); assert.match(coverage.sha256, /^[a-f0-9]{64}$/); } + + const italian = first.publication.locales["it-it"]; + assert.equal(italian.catalogExact, 0); + assert.equal(italian.catalogNormalized, 0); + assert.equal(italian.override, 0); + assert.equal(italian.replacement, 0); + assert.equal(italian.resolvedMessages, 42); + assert.equal(italian.unresolved, 0); + assert.equal(italian.publishable, true); + assert.match(italian.sha256, /^[a-f0-9]{64}$/); }); test("keeps five values-only bundles aligned with the English source shape", async () => { @@ -82,6 +93,11 @@ test("keeps five values-only bundles aligned with the English source shape", asy assert.equal(stringLeaves(localized).length, 42); assert.ok(stringLeaves(localized).every((value) => value.trim())); } + + const italian = JSON.parse(build.outputs.get("messages/it-it.json")); + assert.deepEqual(shapeOf(italian), shapeOf(source)); + assert.equal(stringLeaves(italian).length, 42); + assert.ok(stringLeaves(italian).every((value) => value.trim())); }); test("pins hub cards to the current English structured page titles and subtitles", async () => { @@ -121,10 +137,10 @@ test("uses exact route occurrences for metadata and preserved card placements", }); test("keeps generated artifacts current and runtime bundles provenance-free", async () => { - assert.deepEqual(await checkBreatheIndexContentArtifacts(), { checked: 8, stale: [] }); + assert.deepEqual(await checkBreatheIndexContentArtifacts(), { checked: 9, stale: [] }); const publication = JSON.parse(await readFile(new URL("publication.json", contentRoot), "utf8")); - for (const locale of BREATHE_INDEX_LOCALES) { + for (const locale of expectedOutputLocales) { const raw = await readFile(new URL(publication.locales[locale].path, contentRoot), "utf8"); assert.doesNotMatch( raw, @@ -140,7 +156,7 @@ test("breathe index loader is literal, server-only, and fail-closed", async () = ); assert.equal(loader.startsWith('import "server-only";'), true); - assert.equal((loader.match(/import\("\.\.\/messages\//g) ?? []).length, 5); + assert.equal((loader.match(/import\("\.\.\/messages\//g) ?? []).length, 6); assert.match(loader, /publication\.json/); assert.match(loader, /!localeCoverage\.publishable/); assert.match(loader, /refusing English fallback/); diff --git a/scripts/tests/native-i18n-content-quality.test.mjs b/scripts/tests/native-i18n-content-quality.test.mjs index 15a34f41..03122323 100644 --- a/scripts/tests/native-i18n-content-quality.test.mjs +++ b/scripts/tests/native-i18n-content-quality.test.mjs @@ -75,7 +75,7 @@ test("confirmed migration defects stay corrected in generated bundles", async () ["src/i18n/content/bespoke/insomnia-4-7-8/messages/de-de.json", "metadata.title", "4-7-8 Atemtechnik bei Schlaflosigkeit: Einschlafen (Gratis-Timer)"], ["src/i18n/content/breathe/routes/fr-fr/9d-breathwork.json", "meta.title", "9D Breathwork expliqué : respiration, audio, coût (pacer gratuit)"], ["src/i18n/content/bespoke/privacy-support/messages/privacy/es-es.json", "metadata.socialDescription", "Política de privacidad de Deep Breathing Exercises."], - ["src/i18n/content/use-cases/routes/es-es/anxiety.json", "meta.ogDescription", "La técnica respiratoria que usan los Navy SEALs para la ansiedad. Detén los pensamientos acelerados y la opresión en el pecho en 60 segundos. Visualizador gratuito."], + ["src/i18n/content/use-cases/routes/es-es/anxiety.json", "meta.ogDescription", "Técnica de respiración cuadrada de los Navy SEAL para la ansiedad. Explora un visualizador gratuito para los pensamientos acelerados y la opresión en el pecho."], ]; for (const [file, path, expected] of checks) { diff --git a/scripts/tests/native-i18n-for-content.test.mjs b/scripts/tests/native-i18n-for-content.test.mjs index 145f1039..aabae616 100644 --- a/scripts/tests/native-i18n-for-content.test.mjs +++ b/scripts/tests/native-i18n-for-content.test.mjs @@ -260,6 +260,8 @@ test("metadata uses catalog head occurrences unless explicitly replaced", () => const replacements = existsSync(replacementFile) ? json(replacementFile).replacements : []; + const manualFile = path.join(root, "manual", `${slug}.json`); + const manual = existsSync(manualFile) ? json(manualFile).entries : []; for (const locale of locales) { const content = json(path.join(root, "routes", locale, `${slug}.json`)); const catalog = json( @@ -296,7 +298,7 @@ test("metadata uses catalog head occurrences unless explicitly replaced", () => : undefined; assert.equal( content.meta[field], - replacement?.replacement ?? proofCorrection ?? head.translation.text, + manual.find((entry) => entry.scope === "content" && entry.messageId === `meta.${field}`)?.translations?.[locale] ?? replacement?.replacement ?? proofCorrection ?? head.translation.text, `${locale}:${slug}:${field}`, ); } diff --git a/scripts/tests/native-i18n-foundation.test.mjs b/scripts/tests/native-i18n-foundation.test.mjs index d7df6b9a..77d68528 100644 --- a/scripts/tests/native-i18n-foundation.test.mjs +++ b/scripts/tests/native-i18n-foundation.test.mjs @@ -20,7 +20,7 @@ import { const SITE_URL = "https://deepbreathingexercises.com"; -test("locale registry defines the six native locales in display order", () => { +test("locale registry defines the seven native locales in display order", () => { assert.deepEqual(SUPPORTED_LOCALES, [ "en-US", "es-ES", @@ -28,13 +28,14 @@ test("locale registry defines the six native locales in display order", () => { "fr-FR", "de-DE", "ja-JP", + "it-IT", ]); assert.equal(DEFAULT_LOCALE, "en-US"); - assert.equal(LOCALES.length, 6); - assert.equal(TRANSLATED_LOCALES.length, 5); + assert.equal(LOCALES.length, 7); + assert.equal(TRANSLATED_LOCALES.length, 6); assert.deepEqual( LOCALES.map(({ routePrefix }) => routePrefix), - ["", "es", "pt", "fr", "de", "ja"] + ["", "es", "pt", "fr", "de", "ja", "it"] ); }); @@ -49,9 +50,12 @@ test("locale definitions own labels, direction, HTML language, and hreflang", () hreflang: "pt-BR", htmlLang: "pt-BR", direction: "ltr", + nativePublicationDefault: true, }); assert.equal(getLocale("ja-JP").nativeLabel, "日本語"); assert.ok(LOCALES.every(({ direction }) => direction === "ltr")); + assert.equal(getLocale("it-IT").direction, "ltr"); + assert.equal(getLocale("it-IT").nativePublicationDefault, false); }); test("locale resolution accepts tags, legacy casing, primary codes, and prefixes", () => { @@ -59,13 +63,13 @@ test("locale resolution accepts tags, legacy casing, primary codes, and prefixes assert.equal(resolveLocaleCode("/ES/"), "es-ES"); assert.equal(resolveLocaleCode("ja-jp"), "ja-JP"); assert.equal(resolveLocaleCode("fr"), "fr-FR"); - assert.equal(resolveLocaleCode("it"), null); + assert.equal(resolveLocaleCode("it"), "it-IT"); assert.equal(isLocaleCode("de-DE"), true); assert.equal(isLocaleCode("de-de"), false); assert.equal(getLocaleByPrefix(""), getLocale("en-US")); assert.equal(getLocaleByPrefix("/ja/")?.code, "ja-JP"); - assert.equal(getLocaleByPrefix("it"), null); - assert.throws(() => getLocale("it-IT"), /Unsupported locale/); + assert.equal(getLocaleByPrefix("it")?.code, "it-IT"); + assert.equal(getLocale("it-IT").code, "it-IT"); }); test("URL prefixes keep English unprefixed and translations short", () => { @@ -111,11 +115,13 @@ test("hreflang alternates share one canonical path and include x-default", () => "fr-FR": `${SITE_URL}/fr/breathe/box`, "de-DE": `${SITE_URL}/de/breathe/box`, "ja-JP": `${SITE_URL}/ja/breathe/box`, + "it-IT": `${SITE_URL}/it/breathe/box`, }); assert.deepEqual(buildHreflangAlternates(SITE_URL, "/", ["it-IT", "ja-JP"].filter(isLocaleCode)), { "en-US": `${SITE_URL}/`, "x-default": `${SITE_URL}/`, + "it-IT": `${SITE_URL}/it`, "ja-JP": `${SITE_URL}/ja`, }); }); diff --git a/scripts/tests/native-i18n-home-content.test.mjs b/scripts/tests/native-i18n-home-content.test.mjs index 82a2f14d..efda66e6 100644 --- a/scripts/tests/native-i18n-home-content.test.mjs +++ b/scripts/tests/native-i18n-home-content.test.mjs @@ -10,6 +10,7 @@ import { } from "../i18n/bespoke/compile-home-content.mjs"; const contentRoot = new URL("../../src/i18n/content/bespoke/home/", import.meta.url); +const expectedOutputLocales = [...HOME_LOCALES, "it-it"]; function shapeOf(value) { if (value && typeof value === "object" && !Array.isArray(value)) { @@ -31,14 +32,14 @@ test("compiles home content deterministically with catalog-backed coverage", asy assert.deepEqual(second.publication, first.publication); assert.deepEqual([...second.outputs], [...first.outputs]); - assert.equal(first.outputs.size, 8); + assert.equal(first.outputs.size, 9); assert.equal(first.publication.expectedMessages, 159); - assert.deepEqual(Object.keys(first.publication.locales), HOME_LOCALES); + assert.deepEqual(Object.keys(first.publication.locales), expectedOutputLocales); const expectedReplacementCounts = { - "de-de": 5, + "de-de": 7, "es-es": 13, "fr-fr": 4, - "ja-jp": 7, + "ja-jp": 11, "pt-br": 6, }; @@ -53,6 +54,16 @@ test("compiles home content deterministically with catalog-backed coverage", asy assert.equal(coverage.reviewedReplacementMessages, expectedReplacementCounts[locale]); assert.match(coverage.sha256, /^[a-f0-9]{64}$/); } + + const italian = first.publication.locales["it-it"]; + assert.equal(italian.catalogExact, 0); + assert.equal(italian.catalogNormalized, 0); + assert.equal(italian.overrideMessages, 0); + assert.equal(italian.replacementMessages, 0); + assert.equal(italian.resolvedMessages, 159); + assert.equal(italian.unresolved, 0); + assert.equal(italian.publishable, true); + assert.match(italian.sha256, /^[a-f0-9]{64}$/); }); test("keeps localized bundles aligned with the canonical English shape", async () => { @@ -63,7 +74,7 @@ test("keeps localized bundles aligned with the canonical English shape", async ( ]); assert.equal(translatablePaths(bindings).length, 159); - for (const locale of HOME_LOCALES) { + for (const locale of expectedOutputLocales) { const localized = JSON.parse(build.outputs.get(`messages/${locale}.json`)); assert.deepEqual(shapeOf(localized), shapeOf(source)); assert.equal(localized.sections.modePicker.featured.box.displaySlug, "/box"); @@ -131,6 +142,12 @@ test("records provenance separately from unresolved catalog gaps", async () => { de.sections.modePicker.featured.fourSevenEight.cardTitle, ); } + const italianProvenance = build.provenance.locales["it-it"]; + assert.equal(Object.keys(italianProvenance).length, 159); + assert.equal( + italianProvenance["sections.modePicker.featured.fourSevenEight.cardTitle"].status, + "italian-pilot-reviewed", + ); }); test("applies documented typography normalization for hero start session", () => { @@ -141,10 +158,10 @@ test("applies documented typography normalization for hero start session", () => }); test("checked-in home artifacts are current and runtime bundles contain no catalog identifiers", async () => { - assert.deepEqual(await checkHomeContentArtifacts(), { checked: 8, stale: [] }); + assert.deepEqual(await checkHomeContentArtifacts(), { checked: 9, stale: [] }); const publication = JSON.parse(await readFile(new URL("publication.json", contentRoot), "utf8")); - for (const locale of HOME_LOCALES) { + for (const locale of expectedOutputLocales) { const raw = await readFile(new URL(publication.locales[locale].path, contentRoot), "utf8"); assert.doesNotMatch( raw, @@ -160,7 +177,7 @@ test("home loader is server-only, literal, and fail-closed", async () => { ); assert.equal(loader.startsWith('import "server-only";'), true); - assert.equal((loader.match(/import\("\.\.\/messages\//g) ?? []).length, 5); + assert.equal((loader.match(/import\("\.\.\/messages\//g) ?? []).length, 6); assert.match(loader, /publication\.json/); assert.match(loader, /!localeCoverage\.publishable/); assert.match(loader, /refusing English fallback/); diff --git a/scripts/tests/native-i18n-route-manifest.test.mjs b/scripts/tests/native-i18n-route-manifest.test.mjs index 327fa168..c9422ec5 100644 --- a/scripts/tests/native-i18n-route-manifest.test.mjs +++ b/scripts/tests/native-i18n-route-manifest.test.mjs @@ -24,6 +24,7 @@ const { getPreviewLocalizedStaticParams, getNativeRouteById, getNativeRouteByPath, + getNativeAvailableLocales, getNativeLocalizedRoutePaths, isLocalePublicationIntended, isLocaleSemanticReady, @@ -41,6 +42,19 @@ import { collectInventory } from "../i18n/build-native-i18n-inventory.mjs"; const STATIC_ROUTES = NATIVE_ROUTE_MANIFEST.filter((route) => !route.dynamic); const DYNAMIC_ROUTES = NATIVE_ROUTE_MANIFEST.filter((route) => route.dynamic); +const COMPLETE_TRANSLATED_LOCALES = TRANSLATED_LOCALES.filter( + ({ nativePublicationDefault }) => nativePublicationDefault, +); +const ITALIAN_PUBLISHED_PATHS = new Set([ + "/", + "/4-7-8-breathing-timer", + "/breathe", + "/breathe/belly", + "/breathe/box", + "/breathe/coherent", + "/privacy", + "/support", +]); test("manifest pins the 61 static routes and dynamic embed exception", () => { assert.equal(NATIVE_ROUTE_MANIFEST.length, 62); @@ -84,7 +98,7 @@ test("publication intent preserves the complete public route matrix", () => { assert.equal( STATIC_ROUTES.filter((route) => route.publicationIntent[locale.code]) .length, - 56, + locale.code === "it-IT" ? ITALIAN_PUBLISHED_PATHS.size : 56, `${locale.code} publication intent`, ); } @@ -96,7 +110,7 @@ test("publication intent preserves the complete public route matrix", () => { .length, 0, ); - assert.equal(intendedUrlCount, 338); + assert.equal(intendedUrlCount, 346); assert.deepEqual( STATIC_ROUTES.filter((route) => @@ -136,14 +150,17 @@ test("manifest records indexability independently of public route availability", 58, ); assert.ok( - SUPPORTED_LOCALES.every((locale) => stats.publicationIntent[locale]), + COMPLETE_TRANSLATED_LOCALES.every( + ({ code }) => stats.publicationIntent[code], + ), ); + assert.equal(stats.publicationIntent["it-IT"], false); }); test("catalog facts cover 59 static routes and every translated sitemap route", () => { const cataloged = STATIC_ROUTES.filter((route) => route.catalogAvailable); assert.equal(cataloged.length, 59); - assert.equal(cataloged.length * TRANSLATED_LOCALES.length, 295); + assert.equal(cataloged.length * COMPLETE_TRANSLATED_LOCALES.length, 295); assert.deepEqual( STATIC_ROUTES.filter((route) => !route.catalogAvailable).map( @@ -195,12 +212,16 @@ test("translated migration state admits the complete cutover candidate", () => { "/stats", "/support", ]); + const semanticReadyPathsForLocale = (locale) => + locale === "it-IT" ? ITALIAN_PUBLISHED_PATHS : semanticReadyPaths; for (const route of NATIVE_ROUTE_MANIFEST) { assert.equal(route.nativeStatus["en-US"], "semantic-ready"); for (const locale of TRANSLATED_LOCALES) { - const expectedSemanticReady = semanticReadyPaths.has(route.path); + const expectedSemanticReady = semanticReadyPathsForLocale( + locale.code, + ).has(route.path); assert.equal( isLocaleSemanticReady(route, locale.code), expectedSemanticReady, @@ -234,9 +255,9 @@ test("translated migration state admits the complete cutover candidate", () => { assert.ok( TRANSLATED_LOCALES.every(({ code }) => isNativeLocalePublished(code)), ); - assert.equal(getLocalizedStaticParams().length, 265); + assert.equal(getLocalizedStaticParams().length, 272); // Explicit bespoke routes own their params outside the shared catch-all. - assert.equal(getPreviewLocalizedStaticParams().length, 265); + assert.equal(getPreviewLocalizedStaticParams().length, 272); assert.equal(isNativeLocalePublished(DEFAULT_LOCALE), false); }); @@ -256,7 +277,7 @@ test("the breathe and use-case families and their hubs are cutover-ready", () => 18, ); const semanticReady = structured.filter((route) => - TRANSLATED_LOCALES.every( + COMPLETE_TRANSLATED_LOCALES.every( ({ code }) => route.nativeStatus[code] === "cutover-ready", ), ); @@ -278,12 +299,12 @@ test("the breathe and use-case families and their hubs are cutover-ready", () => assert.ok(home); assert.ok(forIndex); assert.ok( - TRANSLATED_LOCALES.every( + COMPLETE_TRANSLATED_LOCALES.every( ({ code }) => home.nativeStatus[code] === "cutover-ready", ), ); assert.ok( - TRANSLATED_LOCALES.every( + COMPLETE_TRANSLATED_LOCALES.every( ({ code }) => forIndex.nativeStatus[code] === "cutover-ready", ), ); @@ -302,7 +323,7 @@ test("the R-W01 duration and insomnia routes are cutover-ready", () => { assert.ok(route, path); assert.equal(route.localizedHandler, "catch-all"); assert.ok( - TRANSLATED_LOCALES.every( + COMPLETE_TRANSLATED_LOCALES.every( ({ code }) => route.nativeStatus[code] === "cutover-ready", ), path, @@ -324,7 +345,7 @@ test("the R-W02 Resonance guides and holiday route are cutover-ready", () => { assert.ok(route, path); assert.equal(route.localizedHandler, "catch-all"); assert.ok( - TRANSLATED_LOCALES.every( + COMPLETE_TRANSLATED_LOCALES.every( ({ code }) => route.nativeStatus[code] === "cutover-ready", ), path, @@ -346,7 +367,7 @@ test("the R-W03 application, visualizer, and embed routes are cutover-ready", () assert.ok(route, path); assert.equal(route.localizedHandler, "catch-all"); assert.ok( - TRANSLATED_LOCALES.every( + COMPLETE_TRANSLATED_LOCALES.every( ({ code }) => route.nativeStatus[code] === "cutover-ready", ), path, @@ -373,7 +394,7 @@ test("the R-W04 trust and information routes are cutover-ready", () => { assert.ok(route, path); assert.equal(route.localizedHandler, "catch-all"); assert.ok( - TRANSLATED_LOCALES.every( + COMPLETE_TRANSLATED_LOCALES.every( ({ code }) => route.nativeStatus[code] === "cutover-ready", ), path, @@ -385,7 +406,7 @@ test("the R-W04 trust and information routes are cutover-ready", () => { assert.equal(stats.localizedHandler, "explicit"); assert.equal(stats.indexable, false); assert.ok( - TRANSLATED_LOCALES.every( + COMPLETE_TRANSLATED_LOCALES.every( ({ code }) => stats.nativeStatus[code] === "cutover-ready", ), ); @@ -414,6 +435,7 @@ test("native publication requires cutover-ready, intent, and a static route", () "fr-FR": "mapping-required", "de-DE": "mapping-required", "ja-JP": "mapping-required", + "it-IT": "mapping-required", }; const preview = { @@ -553,6 +575,40 @@ test("partial native links keep preview targets localized and fall back elsewher ); }); +test("route availability keeps Italian alternates and links within publication", () => { + assert.deepEqual(getNativeAvailableLocales("/breathe/box", "native"), [ + "en-US", + "es-ES", + "pt-BR", + "fr-FR", + "de-DE", + "ja-JP", + "it-IT", + ]); + assert.deepEqual(getNativeAvailableLocales("/support", "native"), [ + "en-US", + "es-ES", + "pt-BR", + "fr-FR", + "de-DE", + "ja-JP", + "it-IT", + ]); + assert.deepEqual(getNativeAvailableLocales("/unknown", "native"), []); + assert.equal( + resolveNativeInternalHref("/support", "it-IT", "native"), + "/it/support", + ); + assert.equal( + resolveNativeInternalHref("/about/abi", "it-IT", "native"), + "/about/abi", + ); + assert.equal( + resolveNativeInternalHref("/breathe/box", "it-IT", "native"), + "/it/breathe/box", + ); +}); + test("semantic proof route IDs are owned by the route manifest", async () => { const mapping = JSON.parse( await readFile( @@ -574,7 +630,7 @@ test("semantic proof route IDs are owned by the route manifest", async () => { const manifestRoute = getNativeRouteByPath(proofRoute.sourceRoute); assert.equal(manifestRoute?.id, proofRoute.routeId, proofRoute.sourceRoute); assert.ok( - TRANSLATED_LOCALES.every( + COMPLETE_TRANSLATED_LOCALES.every( ({ code }) => manifestRoute.nativeStatus[code] === "cutover-ready", ), ); diff --git a/scripts/tests/native-i18n-route-shell.test.mjs b/scripts/tests/native-i18n-route-shell.test.mjs index 3c8b9808..cd4d02da 100644 --- a/scripts/tests/native-i18n-route-shell.test.mjs +++ b/scripts/tests/native-i18n-route-shell.test.mjs @@ -249,7 +249,7 @@ test("the route groups keep English URLs stable and give localized pages their o assert.match(localizedStatsPage, /loadStatsContent/); assert.match(localizedStatsPage, /createStatsMetadataFromContent/); assert.match(localizedStatsPage, /buildHreflangAlternates/); - assert.match(localizedStatsPage, /SUPPORTED_LOCALES/); + assert.match(localizedStatsPage, /getNativeAvailableLocales/); assert.doesNotMatch(localizedStatsPage, /generateStaticParams|dynamicParams/); assert.match(statsServerPage, /robots:\s*\{\s*index:\s*false/); }); diff --git a/scripts/tests/native-i18n-rw03-embed.test.mjs b/scripts/tests/native-i18n-rw03-embed.test.mjs index eae540d5..bc80585c 100644 --- a/scripts/tests/native-i18n-rw03-embed.test.mjs +++ b/scripts/tests/native-i18n-rw03-embed.test.mjs @@ -205,7 +205,7 @@ test("R-W03 localized embed players gate on the parent route and preserve dynami assert.match(route, /loadBreatheContent/); assert.match(route, /getNativeLocalizedRoutePaths/); assert.match(route, /buildHreflangAlternates/); - assert.match(route, /SUPPORTED_LOCALES/); + assert.match(route, /getNativeAvailableLocales/); assert.match(route, /alternates:\s*\{/); assert.match(route, /canonical:\s*new URL\(request\.canonicalPath, siteUrl\)/); assert.match(route, /languages:\s*buildHreflangAlternates\(/); diff --git a/scripts/tests/native-i18n-rw04-privacy-support.test.mjs b/scripts/tests/native-i18n-rw04-privacy-support.test.mjs index d1737661..3c25c593 100644 --- a/scripts/tests/native-i18n-rw04-privacy-support.test.mjs +++ b/scripts/tests/native-i18n-rw04-privacy-support.test.mjs @@ -14,6 +14,7 @@ const contentRoot = new URL( import.meta.url, ); const expectedLocales = ["de-de", "es-es", "fr-fr", "ja-jp", "pt-br"]; +const expectedOutputLocales = [...expectedLocales, "it-it"]; const forbiddenRuntimeFields = /catalog|occurrence|placement|messageId|reason|reviewedSourceHash|sourceHash|sourceText/i; @@ -44,6 +45,7 @@ test("R-W04 privacy/support compiler emits complete semantic value-only bundles" const first = await buildPrivacySupportArtifacts(); const second = await buildPrivacySupportArtifacts(); assert.deepEqual([...second], [...first]); + assert.equal(first.size, 16); const privacySource = JSON.parse( await readFile(new URL("source/privacy.json", contentRoot), "utf8"), @@ -71,7 +73,11 @@ test("R-W04 privacy/support compiler emits complete semantic value-only bundles" assert.equal(stale.reduce((total, item) => total + item.cells, 0), 15); for (const route of ["privacy", "support"]) { - for (const locale of expectedLocales) { + assert.deepEqual( + Object.keys(publication.routes[route].locales).sort(), + [...expectedOutputLocales].sort(), + ); + for (const locale of expectedOutputLocales) { const raw = first.get(`messages/${route}/${locale}.json`); assert.ok(raw, `missing ${route}:${locale}`); assert.doesNotMatch(raw, forbiddenRuntimeFields); @@ -113,8 +119,8 @@ test("R-W04 privacy/support loader is literal and fail closed", async () => { "src/i18n/content/bespoke/privacy-support/server/load-privacy-support-content.ts", ); assert.equal(loader.startsWith('import "server-only";'), true); - assert.equal((loader.match(/import\("\.\.\/messages\/privacy\//g) ?? []).length, 5); - assert.equal((loader.match(/import\("\.\.\/messages\/support\//g) ?? []).length, 5); + assert.equal((loader.match(/import\("\.\.\/messages\/privacy\//g) ?? []).length, 6); + assert.equal((loader.match(/import\("\.\.\/messages\/support\//g) ?? []).length, 6); assert.match(loader, /loadPrivacyContent/); assert.match(loader, /loadSupportContent/); assert.match(loader, /refusing English fallback/); diff --git a/scripts/tests/native-i18n-rw04-stats.test.mjs b/scripts/tests/native-i18n-rw04-stats.test.mjs index c346ec9d..207075b5 100644 --- a/scripts/tests/native-i18n-rw04-stats.test.mjs +++ b/scripts/tests/native-i18n-rw04-stats.test.mjs @@ -130,7 +130,7 @@ test("stats loaders and explicit localized route fail closed", async () => { /createStatsMetadataFromContent\(\s*content,\s*request\.canonicalPath/, ); assert.match(localizedPage, /buildHreflangAlternates/); - assert.match(localizedPage, /SUPPORTED_LOCALES/); + assert.match(localizedPage, /getNativeAvailableLocales/); assert.match(localizedPage, /canonical: new URL\(request\.canonicalPath/); assert.match(localizedPage, /notFound\(\)/); assert.doesNotMatch(localizedPage, /generateStaticParams/); diff --git a/scripts/tests/native-i18n-timer-content.test.mjs b/scripts/tests/native-i18n-timer-content.test.mjs index e55e5228..e68753d3 100644 --- a/scripts/tests/native-i18n-timer-content.test.mjs +++ b/scripts/tests/native-i18n-timer-content.test.mjs @@ -12,6 +12,7 @@ const contentRoot = new URL( "../../src/i18n/content/bespoke/timer-4-7-8/", import.meta.url, ); +const expectedOutputLocales = [...TIMER_LOCALES, "it-it"]; test("compiles complete timer content deterministically", async () => { const first = await buildTimerContentArtifacts(); @@ -19,9 +20,9 @@ test("compiles complete timer content deterministically", async () => { assert.deepEqual(second.publication, first.publication); assert.deepEqual([...second.outputs], [...first.outputs]); - assert.equal(first.outputs.size, 8); + assert.equal(first.outputs.size, 9); assert.equal(first.publication.expectedMessages, 176); - assert.deepEqual(Object.keys(first.publication.locales), TIMER_LOCALES); + assert.deepEqual(Object.keys(first.publication.locales), expectedOutputLocales); assert.deepEqual(first.unresolved.unresolved, []); for (const locale of TIMER_LOCALES) { @@ -38,6 +39,16 @@ test("compiles complete timer content deterministically", async () => { ); assert.match(coverage.sha256, /^[0-9a-f]{64}$/); } + + const italian = first.publication.locales["it-it"]; + assert.equal(italian.catalogExact, 0); + assert.equal(italian.catalogNormalized, 0); + assert.equal(italian.override, 0); + assert.equal(italian.replacement, 0); + assert.equal(italian.resolvedMessages, 176); + assert.equal(italian.unresolved, 0); + assert.equal(italian.publishable, true); + assert.match(italian.sha256, /^[0-9a-f]{64}$/); }); test("keeps every locale aligned with the canonical English message IDs", async () => { @@ -53,6 +64,12 @@ test("keeps every locale aligned with the canonical English message IDs", async (value) => typeof value === "string" && value.trim(), )); } + + const italian = JSON.parse(build.outputs.get("messages/it-it.json")); + assert.deepEqual(Object.keys(italian).sort(), sourceKeys); + assert.ok(Object.values(italian).every( + (value) => typeof value === "string" && value.trim(), + )); }); test("records catalog, gap, and fidelity-replacement provenance separately", async () => { @@ -77,13 +94,18 @@ test("records catalog, gap, and fidelity-replacement provenance separately", asy assert.ok(statuses.some((status) => status.startsWith("route-catalog"))); assert.ok(statuses.includes("repo-reviewed-override")); } + assert.ok( + Object.values(build.provenance.locales["it-it"]).every( + ({ status }) => status === "italian-pilot-reviewed", + ), + ); }); test("checked-in timer artifacts are current and contain no catalog identifiers", async () => { - assert.deepEqual(await checkTimerContentArtifacts(), { checked: 8, stale: [] }); + assert.deepEqual(await checkTimerContentArtifacts(), { checked: 9, stale: [] }); const publication = JSON.parse(await readFile(new URL("publication.json", contentRoot), "utf8")); - for (const locale of TIMER_LOCALES) { + for (const locale of expectedOutputLocales) { const raw = await readFile(new URL(publication.locales[locale].path, contentRoot), "utf8"); assert.doesNotMatch( raw, @@ -99,7 +121,7 @@ test("timer loader is server-only, literal, and fails closed", async () => { ); assert.equal(loader.startsWith('import "server-only";'), true); - assert.equal((loader.match(/import\("\.\.\/messages\//g) ?? []).length, 5); + assert.equal((loader.match(/import\("\.\.\/messages\//g) ?? []).length, 6); assert.match(loader, /publication\.json/); assert.match(loader, /!localeCoverage\.publishable/); assert.match(loader, /refusing English fallback/); diff --git a/scripts/tests/next-config-locale-redirects.test.js b/scripts/tests/next-config-locale-redirects.test.js index aa973782..8a5a2ee5 100755 --- a/scripts/tests/next-config-locale-redirects.test.js +++ b/scripts/tests/next-config-locale-redirects.test.js @@ -203,6 +203,23 @@ async function runTests() { ); } + const italianExactRule = nativePreviewRedirects.find( + (rule) => rule.source === '/it/about/methodology', + ); + assertEquals( + italianExactRule ? italianExactRule.destination : null, + '/about/editorial-policy', + '/it/about/methodology should fall back to the English editorial policy until Italian trust content is published', + ); + for (const testPath of ['/it/about/methodology/legacy']) { + const result = findRedirectDestination(nativePreviewRedirects, testPath); + assertEquals( + result ? result.destination : null, + '/about/editorial-policy', + `${testPath} should fall back to the English editorial policy until Italian trust content is published`, + ); + } + // Test 6: Test nested methodology redirects console.log('\nTest Suite: Nested methodology redirects'); for (const locale of locales) { diff --git a/src/app/(site-en)/4-7-8-breathing-timer/timer-page.tsx b/src/app/(site-en)/4-7-8-breathing-timer/timer-page.tsx index 3e8c2460..f7f21b45 100644 --- a/src/app/(site-en)/4-7-8-breathing-timer/timer-page.tsx +++ b/src/app/(site-en)/4-7-8-breathing-timer/timer-page.tsx @@ -173,7 +173,7 @@ export function TimerPage({
-
+
{copy("start.title")}

{copy("start.body")}

- {copy("start.openTimer")} + {copy("start.openTimer")} {copy("start.fullApp")}
diff --git a/src/app/(site-en)/breathe/breathe-index-page.tsx b/src/app/(site-en)/breathe/breathe-index-page.tsx index 82ff9888..c94e194c 100644 --- a/src/app/(site-en)/breathe/breathe-index-page.tsx +++ b/src/app/(site-en)/breathe/breathe-index-page.tsx @@ -68,7 +68,13 @@ export function BreatheIndexPage({ renderContext?: NativeRouteRenderContext; }) { const href = (path: string) => renderContext - ? resolveNativeInternalHref(path, renderContext.locale, renderContext.linkMode) + ? resolveNativeInternalHref( + renderContext.locale === "it-IT" && path === "/breathe/4-7-8" + ? "/4-7-8-breathing-timer" + : path, + renderContext.locale, + renderContext.linkMode, + ) : path; const canonicalPath = renderContext?.canonicalPath ?? sourceRoute; const canonicalUrl = new URL(canonicalPath, siteUrl).toString(); diff --git a/src/app/(site-en)/embed/embed-page.tsx b/src/app/(site-en)/embed/embed-page.tsx index f00517a6..454e56ad 100644 --- a/src/app/(site-en)/embed/embed-page.tsx +++ b/src/app/(site-en)/embed/embed-page.tsx @@ -13,7 +13,10 @@ import { toEmbedGeneratorLocale, } from "@/i18n/content/bespoke/embed/types"; import type { NativeRouteRenderContext } from "@/i18n/render-context"; -import { resolveNativeInternalHref } from "@/i18n/route-manifest"; +import { + isNativeRoutePublished, + resolveNativeInternalHref, +} from "@/i18n/route-manifest"; import { createOgImagePath } from "@/lib/seo/og-image"; import { EmbedGenerator } from "./embed-generator"; @@ -27,7 +30,10 @@ const ogImage = createOgImagePath("Free Breathing Widget", { const localeOptionConfig: readonly { code: EmbedGeneratorLocaleCode; prefix: string; -}[] = LOCALES.map((locale) => ({ +}[] = LOCALES.filter( + (locale) => + locale.code === "en-US" || isNativeRoutePublished(sourceRoute, locale.code), +).map((locale) => ({ code: toEmbedGeneratorLocale(locale.code), prefix: locale.routePrefix ? `/${locale.routePrefix}` : "", })); diff --git a/src/app/(site-en)/home-page.tsx b/src/app/(site-en)/home-page.tsx index 6d95ea37..16b874ae 100644 --- a/src/app/(site-en)/home-page.tsx +++ b/src/app/(site-en)/home-page.tsx @@ -212,7 +212,12 @@ export function HomePage({ resonance: ReactNode; renderContext?: NativeRouteRenderContext; }) { - const href = (path: string) => resolveHref(path, renderContext); + const href = (path: string) => resolveHref( + renderContext?.locale === "it-IT" && path === "/breathe/4-7-8" + ? "/4-7-8-breathing-timer" + : path, + renderContext, + ); const faqSchema = { ...content.schema.faq, mainEntity: FAQ_ITEM_KEYS.map((key) => { @@ -269,7 +274,10 @@ export function HomePage({ {resonance}
-
+
{heroHeader}
diff --git a/src/app/(site-en)/languages/page.tsx b/src/app/(site-en)/languages/page.tsx index 64fc223e..5067687a 100644 --- a/src/app/(site-en)/languages/page.tsx +++ b/src/app/(site-en)/languages/page.tsx @@ -1,32 +1,52 @@ import type { Metadata } from "next"; import Link from "next/link"; +import { + DEFAULT_LOCALE, + LOCALES, + type LocaleCode, +} from "@/i18n"; import { createOgImagePath } from "@/lib/seo/og-image"; +import { + getNativeAvailableLocales, + resolveNativeInternalHref, +} from "@/i18n/route-manifest"; +import { resolveNativeI18nMode } from "@/i18n/serving-mode"; const SITE_URL = "https://deepbreathingexercises.com"; -const LOCALES = [ - { prefix: "", native: "English", name: "English", code: "en" }, - { prefix: "/es", native: "Español", name: "Spanish", code: "es" }, - { - prefix: "/pt", - native: "Português", - name: "Portuguese (Brazil)", - code: "pt", - }, - { prefix: "/fr", native: "Français", name: "French", code: "fr" }, - { prefix: "/de", native: "Deutsch", name: "German", code: "de" }, - { prefix: "/ja", native: "日本語", name: "Japanese", code: "ja" }, -] as const; +function getDisplayedLocales() { + const mode = resolveNativeI18nMode(); + const availableCodes = new Set( + mode === "proxy" + ? LOCALES.filter( + (locale) => + locale.code === DEFAULT_LOCALE || locale.nativePublicationDefault, + ).map((locale) => locale.code) + : getNativeAvailableLocales("/", mode), + ); + + return LOCALES.filter((locale) => availableCodes.has(locale.code)); +} -type LocaleCode = (typeof LOCALES)[number]["code"]; +const DISPLAYED_LOCALES = getDisplayedLocales(); + +function joinLanguageNames(names: readonly string[]): string { + if (names.length < 2) return names[0] ?? "English"; + if (names.length === 2) return names.join(" and "); + return `${names.slice(0, -1).join(", ")}, and ${names.at(-1)}`; +} + +const LANGUAGE_NAMES = joinLanguageNames( + DISPLAYED_LOCALES.map((locale) => locale.nativeLabel), +); type LocalizedKeyPage = { path: string; - labels: Record; + labels: Readonly>; }; -const KEY_PAGES = [ +const KEY_PAGES: readonly LocalizedKeyPage[] = [ { path: "/", labels: { @@ -36,6 +56,7 @@ const KEY_PAGES = [ fr: "Accueil", de: "Startseite", ja: "ホーム", + it: "Home", }, }, { @@ -47,6 +68,7 @@ const KEY_PAGES = [ fr: "Techniques de respiration", de: "Atemtechniken", ja: "呼吸法", + it: "Tecniche", }, }, { @@ -91,6 +113,7 @@ const KEY_PAGES = [ fr: "Minuteur de respiration 4-7-8", de: "4-7-8-Atemtimer", ja: "4-7-8呼吸タイマー", + it: "Timer di respirazione 4-7-8", }, }, { @@ -135,6 +158,7 @@ const KEY_PAGES = [ fr: "Respiration carrée", de: "Box-Atmung", ja: "ボックス呼吸法", + it: "Respirazione quadrata", }, }, { @@ -157,6 +181,7 @@ const KEY_PAGES = [ fr: "Cohérence cardiaque", de: "Kohärente Atmung", ja: "コヒーレント呼吸法", + it: "Respirazione coerente", }, }, { @@ -212,6 +237,7 @@ const KEY_PAGES = [ fr: "Respiration abdominale", de: "Bauchatmung", ja: "腹式呼吸", + it: "Respirazione addominale", }, }, { @@ -302,35 +328,36 @@ const KEY_PAGES = [ ja: "ヒューバーマンの呼吸プロトコル", }, }, -] as const satisfies readonly LocalizedKeyPage[]; +]; -function resolveHref(prefix: string, path: string): string { - if (path === "/") return `${SITE_URL}${prefix || "/"}`; - return `${SITE_URL}${prefix}${path}`; +function resolveHref(locale: LocaleCode, path: string): string { + return new URL( + resolveNativeInternalHref(path, locale, "native"), + SITE_URL, + ).toString(); } -const OG_IMAGE_ALT = "Deep Breathing Exercises in 6 languages"; +const OG_IMAGE_ALT = `Deep Breathing Exercises in ${DISPLAYED_LOCALES.length} languages`; const ogImageUrl = createOgImagePath(OG_IMAGE_ALT); export const metadata: Metadata = { - title: "Languages — Deep Breathing Exercises in 6 languages", + title: `Languages — Deep Breathing Exercises in ${DISPLAYED_LOCALES.length} languages`, description: - "Deep Breathing Exercises is available in English, Español, Português, Français, Deutsch, and 日本語. Jump straight to techniques and guides in your language.", + `Deep Breathing Exercises is available in ${LANGUAGE_NAMES}. Jump straight to techniques and guides in your language.`, alternates: { canonical: `${SITE_URL}/languages` }, robots: { index: true, follow: true }, openGraph: { - title: "Deep Breathing Exercises in 6 languages", + title: `Deep Breathing Exercises in ${DISPLAYED_LOCALES.length} languages`, description: - "Breathing techniques, timers, and guides in English, Español, Português, Français, Deutsch, and 日本語.", + `Breathing techniques, timers, and guides in ${LANGUAGE_NAMES}.`, url: `${SITE_URL}/languages`, type: "website", images: [{ url: ogImageUrl, width: 1200, height: 630, alt: OG_IMAGE_ALT }], }, twitter: { card: "summary_large_image", - title: "Deep Breathing Exercises in 6 languages", - description: - "Breathing techniques, timers, and guides in 6 languages. Pick yours.", + title: `Deep Breathing Exercises in ${DISPLAYED_LOCALES.length} languages`, + description: `Breathing techniques, timers, and guides in ${DISPLAYED_LOCALES.length} languages. Pick yours.`, images: [ogImageUrl], }, }; @@ -339,46 +366,45 @@ export default function LanguagesPage() { return (

- Available in 6 languages + Available in {DISPLAYED_LOCALES.length} languages

- Deep Breathing Exercises is translated into Spanish, Portuguese, French, - German, and Japanese. Each language has its own set of breathing - techniques, timers, and guides. Pick a language below to browse - translated pages. + Deep Breathing Exercises is translated into {LANGUAGE_NAMES}. Each + language has its own set of breathing techniques, timers, and guides. + Pick a language below to browse translated pages.

- {LOCALES.map(({ prefix, native, name, code }) => ( + {DISPLAYED_LOCALES.map((locale) => (

- {native} + {locale.nativeLabel} - ({name}) + ({locale.label})

    {KEY_PAGES.map(({ path, labels }) => (
  • - {labels[code]} + {labels[locale.language] ?? labels.en}
  • ))} diff --git a/src/app/(site-localized)/[locale]/4-7-8-breathing-timer/page.tsx b/src/app/(site-localized)/[locale]/4-7-8-breathing-timer/page.tsx index b9aa4296..0a7ee1f9 100644 --- a/src/app/(site-localized)/[locale]/4-7-8-breathing-timer/page.tsx +++ b/src/app/(site-localized)/[locale]/4-7-8-breathing-timer/page.tsx @@ -6,7 +6,6 @@ import { createTimerMetadataFromContent, } from "@/app/(site-en)/4-7-8-breathing-timer/timer-page"; import { - SUPPORTED_LOCALES, TRANSLATED_LOCALES, buildHreflangAlternates, getLocaleByPrefix, @@ -18,6 +17,7 @@ import { } from "@/i18n/content/bespoke/timer-4-7-8/server/load-timer-content"; import type { NativeRouteRenderContext } from "@/i18n/render-context"; import { + getNativeAvailableLocales, getNativeLocalizedRoutePaths, isNativeRoutePreviewable, isNativeRoutePublished, @@ -80,7 +80,7 @@ export async function generateMetadata({ languages: buildHreflangAlternates( siteUrl, request.canonicalPath, - SUPPORTED_LOCALES, + getNativeAvailableLocales(sourceRoute, request.linkMode), ), }, }; diff --git a/src/app/(site-localized)/[locale]/[[...segments]]/page.tsx b/src/app/(site-localized)/[locale]/[[...segments]]/page.tsx index 3eb456e7..0ac7153c 100644 --- a/src/app/(site-localized)/[locale]/[[...segments]]/page.tsx +++ b/src/app/(site-localized)/[locale]/[[...segments]]/page.tsx @@ -142,13 +142,13 @@ import { type ForContentSlug, } from "@/i18n/content/use-cases/types"; import { - SUPPORTED_LOCALES, buildHreflangAlternates, getLocaleByPrefix, localizePathname, } from "@/i18n"; import { getLocalizedStaticParams, + getNativeAvailableLocales, getNativeRouteByPath, getNativeLocalizedRoutePaths, getPreviewLocalizedStaticParams, @@ -330,6 +330,9 @@ function withLocalizedAlternates( metadata: Metadata, canonicalPath: string, ): Metadata { + const mode = resolveNativeI18nMode(); + const linkMode = mode === "native-preview" ? mode : "native"; + return { ...metadata, alternates: { @@ -337,7 +340,7 @@ function withLocalizedAlternates( languages: buildHreflangAlternates( siteUrl, canonicalPath, - SUPPORTED_LOCALES, + getNativeAvailableLocales(canonicalPath, linkMode), ), }, }; diff --git a/src/app/(site-localized)/[locale]/about/page.tsx b/src/app/(site-localized)/[locale]/about/page.tsx index a9c430a0..9e9fd820 100644 --- a/src/app/(site-localized)/[locale]/about/page.tsx +++ b/src/app/(site-localized)/[locale]/about/page.tsx @@ -6,7 +6,6 @@ import { createAboutMetadataFromContent, } from "@/app/(site-en)/about/about-page"; import { - SUPPORTED_LOCALES, TRANSLATED_LOCALES, buildHreflangAlternates, getLocaleByPrefix, @@ -18,6 +17,7 @@ import { } from "@/i18n/content/bespoke/about/server/load-about-content"; import type { NativeRouteRenderContext } from "@/i18n/render-context"; import { + getNativeAvailableLocales, getNativeLocalizedRoutePaths, isNativeRoutePreviewable, isNativeRoutePublished, @@ -80,7 +80,7 @@ export async function generateMetadata({ languages: buildHreflangAlternates( siteUrl, request.canonicalPath, - SUPPORTED_LOCALES, + getNativeAvailableLocales(sourceRoute, request.linkMode), ), }, }; diff --git a/src/app/(site-localized)/[locale]/embed/[slug]/page.tsx b/src/app/(site-localized)/[locale]/embed/[slug]/page.tsx index 3146c7a5..ad1b2d3e 100644 --- a/src/app/(site-localized)/[locale]/embed/[slug]/page.tsx +++ b/src/app/(site-localized)/[locale]/embed/[slug]/page.tsx @@ -7,7 +7,6 @@ import { type EmbedPlayerSearchParams, } from "@/app/(site-en)/embed/[slug]/embed-player"; import { - SUPPORTED_LOCALES, TRANSLATED_LOCALES, buildHreflangAlternates, getLocaleByPrefix, @@ -26,6 +25,7 @@ import { } from "@/i18n/content/bespoke/embed/types"; import type { NativeRouteRenderContext } from "@/i18n/render-context"; import { + getNativeAvailableLocales, getNativeLocalizedRoutePaths, isNativeRoutePreviewable, isNativeRoutePublished, @@ -106,7 +106,7 @@ export async function generateMetadata({ languages: buildHreflangAlternates( siteUrl, request.canonicalPath, - SUPPORTED_LOCALES, + getNativeAvailableLocales(sourceRoute, request.linkMode), ), }, }; diff --git a/src/app/(site-localized)/[locale]/stats/page.tsx b/src/app/(site-localized)/[locale]/stats/page.tsx index 8cc058ad..da916de2 100644 --- a/src/app/(site-localized)/[locale]/stats/page.tsx +++ b/src/app/(site-localized)/[locale]/stats/page.tsx @@ -6,7 +6,6 @@ import { createStatsMetadataFromContent, } from "@/app/(site-en)/stats/stats-page"; import { - SUPPORTED_LOCALES, buildHreflangAlternates, getLocaleByPrefix, localizePathname, @@ -17,6 +16,7 @@ import { } from "@/i18n/content/bespoke/stats/server/load-stats-content"; import type { NativeRouteRenderContext } from "@/i18n/render-context"; import { + getNativeAvailableLocales, getNativeLocalizedRoutePaths, isNativeRoutePreviewable, isNativeRoutePublished, @@ -68,7 +68,7 @@ export async function generateMetadata({ languages: buildHreflangAlternates( siteUrl, request.canonicalPath, - SUPPORTED_LOCALES, + getNativeAvailableLocales(sourceRoute, request.linkMode), ), }, }; diff --git a/src/app/sitemap.xml/route.ts b/src/app/sitemap.xml/route.ts index 3d952e93..7943802d 100644 --- a/src/app/sitemap.xml/route.ts +++ b/src/app/sitemap.xml/route.ts @@ -2,7 +2,16 @@ import path from "node:path"; import { breathingPages } from "@/data/breathing-pages"; import { useCasePages } from "@/data/use-case-pages"; -import { buildSitemapEntries, EDGE_PROXY_LOCALE_PREFIXES } from "@/lib/seo/sitemap-routes"; +import { getLocaleByPrefix, TRANSLATED_LOCALES } from "@/i18n"; +import { + isNativeRoutePreviewable, + isNativeRoutePublished, +} from "@/i18n/route-manifest"; +import { resolveNativeI18nMode } from "@/i18n/serving-mode"; +import { + buildSitemapEntries, + EDGE_PROXY_LOCALE_PREFIXES, +} from "@/lib/seo/sitemap-routes"; const siteUrl = "https://deepbreathingexercises.com"; @@ -12,11 +21,26 @@ function escapeXml(str: string): string { export function GET() { const appDir = path.join(process.cwd(), "src", "app"); + const mode = resolveNativeI18nMode(); + const isNativeMode = mode !== "proxy"; + const localePrefixes = isNativeMode + ? TRANSLATED_LOCALES.map((locale) => locale.routePrefix) + : EDGE_PROXY_LOCALE_PREFIXES; + const localeAvailability = isNativeMode + ? (route: string, prefix: string) => { + const locale = getLocaleByPrefix(prefix); + if (!locale || !locale.routePrefix) return false; + return mode === "native-preview" + ? isNativeRoutePreviewable(route, locale.code) + : isNativeRoutePublished(route, locale.code); + } + : undefined; const entries = buildSitemapEntries({ appDir, siteUrl, - localePrefixes: EDGE_PROXY_LOCALE_PREFIXES, + localePrefixes, + localeAvailability, breathingPageMeta: breathingPages.map((page) => ({ slug: page.slug, dateModified: page.meta.dateModified, diff --git a/src/components/auth/loss-aversion-sign-in-sheet.tsx b/src/components/auth/loss-aversion-sign-in-sheet.tsx index 61ab7a26..c1fd3ad3 100644 --- a/src/components/auth/loss-aversion-sign-in-sheet.tsx +++ b/src/components/auth/loss-aversion-sign-in-sheet.tsx @@ -8,10 +8,14 @@ * proof, no fake streak. One rollback line: CHALLENGER_SHARE=0 in variant.ts. */ -import React, { useEffect, useRef, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { X, Check, Loader2, Activity } from "lucide-react"; import { signIn } from "@/lib/auth-client"; import { Sheet, SheetContent } from "@/components/ui/sheet"; +import { + createRuntimePhraseResolver, + type RuntimePhraseKey, +} from "@/components/resonance/runtime-phrases"; function trackEvent(name: string, params?: Record) { if (typeof window !== "undefined" && typeof (window as any).gtag === "function") { @@ -24,8 +28,10 @@ interface LossAversionSignInSheetProps { onOpenChange: (open: boolean) => void; onSuccess?: () => void; sessionMode: string; + localizedSessionMode?: string; sessionSeconds: number; accentColor?: string; + locale?: string; } const PREFIX = "cpl"; // conversion-prompt loss-aversion @@ -41,13 +47,41 @@ export function LossAversionSignInSheet({ onOpenChange, onSuccess, sessionMode, + localizedSessionMode, sessionSeconds, accentColor = "#f6743b", + locale = "en", }: LossAversionSignInSheetProps) { const [email, setEmail] = useState(""); const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">("idle"); const [emailOpen, setEmailOpen] = useState(false); const swapTimer = useRef | null>(null); + const phrases = useMemo(() => createRuntimePhraseResolver(locale), [locale]); + const t = (key: RuntimePhraseKey, vars?: Record) => + phrases.resolve(key, vars).text; + // Preserve the reviewed English Prompt C copy while giving every other + // locale a typed runtime fallback. + const sourceDefaults = { + eyebrow: "✓ SESSION COMPLETE", + justNow: "just now", + headline: "Keep tonight's calm.", + body: "Right now this session lives on this device only. A free account keeps it, and carries every minute after it onto any screen you pick up.", + google: "Continue with Google", + googleSub: "One tap. No password.", + emailAddress: "Email address", + emailPlaceholder: "Enter your email", + sendLink: "Send link", + error: "Something went wrong. Please try again.", + saveWithEmail: "or save with email", + notNow: "Not now", + checkEmail: "Check your email", + sentLinkTo: "We sent a link to", + you: "you", + }; + const localized = (sourceValue: string, key: RuntimePhraseKey) => + phrases.locale === "en" ? sourceValue : t(key); + const completionEyebrow = + phrases.locale === "en" ? sourceDefaults.eyebrow : `✓ ${t("session.complete")}`; useEffect(() => { if (open) trackEvent("signin_prompt_view", { variant: "loss_aversion" }); @@ -114,8 +148,8 @@ export function LossAversionSignInSheet({ className="inset-0 flex items-center justify-center border-0 bg-transparent p-4 shadow-none outline-none" > -
    - @@ -124,9 +158,9 @@ export function LossAversionSignInSheet({
    -

    Check your email

    +

    {localized(sourceDefaults.checkEmail, "auth.check_email")}

    - We sent a link to {email.trim() || "you"} + {localized(sourceDefaults.sentLinkTo, "auth.sent_link_to")} {email.trim() || localized(sourceDefaults.you, "auth.you")}

    ) : ( @@ -163,23 +197,22 @@ export function LossAversionSignInSheet({ />
-
✓ SESSION COMPLETE
-
{sessionMode}
+
{completionEyebrow}
+
+ {localizedSessionMode ?? sessionMode} +
{duration} - just now + {localized(sourceDefaults.justNow, "auth.just_now")}
-

Keep tonight's calm.

-

- Right now this session lives on this device only. A free account keeps it, and - carries every minute after it onto any screen you pick up. -

+

{localized(sourceDefaults.headline, "auth.save_progress_question")}

+

{localized(sourceDefaults.body, "auth.local_only")}

@@ -213,8 +246,8 @@ export function LossAversionSignInSheet({ type="email" value={email} onChange={(e) => setEmail(e.target.value)} - placeholder="Enter your email" - aria-label="Email address" + placeholder={localized(sourceDefaults.emailPlaceholder, "auth.enter_email")} + aria-label={localized(sourceDefaults.emailAddress, "auth.email_address")} required /> {status === "error" && ( -

Something went wrong. Please try again.

+

{localized(sourceDefaults.error, "auth.something_went_wrong")}

)}
@@ -239,10 +272,10 @@ export function LossAversionSignInSheet({ onClick={() => setEmailOpen((v) => !v)} aria-expanded={emailOpen} > - or save with email + {localized(sourceDefaults.saveWithEmail, "auth.save_with_email")} )} diff --git a/src/components/auth/non-blocking-sign-in-banner.tsx b/src/components/auth/non-blocking-sign-in-banner.tsx index 24c90e50..18169854 100644 --- a/src/components/auth/non-blocking-sign-in-banner.tsx +++ b/src/components/auth/non-blocking-sign-in-banner.tsx @@ -17,10 +17,14 @@ * is untouched. */ -import React, { useEffect, useRef, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { X, Check, Loader2 } from "lucide-react"; import { signIn } from "@/lib/auth-client"; import type { ResonanceRouteClientMessages } from "@/i18n/content/remaining-pages/rw02-route-client/types"; +import { + createRuntimePhraseResolver, + type RuntimePhraseKey, +} from "@/components/resonance/runtime-phrases"; function trackEvent(name: string, params?: Record) { if (typeof window !== "undefined" && typeof (window as any).gtag === "function") { @@ -35,10 +39,12 @@ interface NonBlockingSignInBannerProps { onOpenChange: (open: boolean) => void; onSuccess?: () => void; sessionMode: string; + localizedSessionMode?: string; sessionSeconds: number; accentColor?: string; layout?: BannerLayout; messages?: ResonanceRouteClientMessages; + locale?: string; } const PREFIX = "nbb"; // non-blocking banner @@ -54,10 +60,12 @@ export function NonBlockingSignInBanner({ onOpenChange, onSuccess, sessionMode, + localizedSessionMode, sessionSeconds, accentColor = "#f6743b", layout = "card", messages, + locale = "en", }: NonBlockingSignInBannerProps) { const [email, setEmail] = useState(""); const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">("idle"); @@ -67,6 +75,56 @@ export function NonBlockingSignInBanner({ // reject it, they kept breathing), so it does NOT fire the dismiss event. const [hiddenByPlay, setHiddenByPlay] = useState(false); const swapTimer = useRef | null>(null); + const phrases = useMemo(() => createRuntimePhraseResolver(locale), [locale]); + const t = (key: RuntimePhraseKey, vars?: Record) => + phrases.resolve(key, vars).text; + // Keep the reviewed R-W02 source defaults intact for route-client audits, + // while using runtime phrases when an Italian or other locale has no route + // specific override. + const sourceDefaults = { + saveSessionAria: messages?.saveSessionAria ?? "Save your session", + closeAria: messages?.closeAria ?? "Close", + title: messages?.title ?? "Save your breathing practice journey", + sessionComplete: messages?.sessionComplete ?? "✓ SESSION COMPLETE", + justNow: messages?.justNow ?? "just now", + continueWithGoogle: messages?.continueWithGoogle ?? "Continue with Google", + oneTapNoPassword: messages?.oneTapNoPassword ?? "One tap. No password.", + emailAddressAria: messages?.emailAddressAria ?? "Email address", + emailPlaceholder: messages?.emailPlaceholder ?? "Enter your email", + sendLink: messages?.sendLink ?? "Send link", + saveWithEmail: messages?.saveWithEmail ?? "or save with email", + pillHead: "Keep tonight's calm", + pillSub: "It only lives on this device", + saveIt: "Save it", + error: "Something went wrong. Please try again.", + checkEmail: "Check your email", + sentLinkTo: "We sent a link to", + you: "you", + }; + const localized = (sourceValue: string, key: RuntimePhraseKey, provided?: string) => + provided ?? (phrases.locale === "en" ? sourceValue : t(key)); + const bannerCopy = { + saveSessionAria: localized(sourceDefaults.saveSessionAria, "auth.save_progress", messages?.saveSessionAria), + closeAria: localized(sourceDefaults.closeAria, "ui.close", messages?.closeAria), + title: localized(sourceDefaults.title, "auth.save_progress", messages?.title), + sessionComplete: + messages?.sessionComplete ?? + (phrases.locale === "en" ? sourceDefaults.sessionComplete : `✓ ${t("session.complete")}`), + justNow: localized(sourceDefaults.justNow, "auth.just_now", messages?.justNow), + continueWithGoogle: localized(sourceDefaults.continueWithGoogle, "auth.continue_google", messages?.continueWithGoogle), + oneTapNoPassword: localized(sourceDefaults.oneTapNoPassword, "auth.one_tap_no_password", messages?.oneTapNoPassword), + emailAddressAria: localized(sourceDefaults.emailAddressAria, "auth.email_address", messages?.emailAddressAria), + emailPlaceholder: localized(sourceDefaults.emailPlaceholder, "auth.enter_email", messages?.emailPlaceholder), + sendLink: localized(sourceDefaults.sendLink, "auth.send_link", messages?.sendLink), + saveWithEmail: localized(sourceDefaults.saveWithEmail, "auth.save_with_email", messages?.saveWithEmail), + checkEmail: localized(sourceDefaults.checkEmail, "auth.check_email"), + sentLinkTo: localized(sourceDefaults.sentLinkTo, "auth.sent_link_to"), + you: localized(sourceDefaults.you, "auth.you"), + }; + const sourceMode = messages?.modeName ?? sessionMode; + const displayMode = messages?.modeName + ? sourceMode + : localizedSessionMode ?? sourceMode; useEffect(() => { if (open) { @@ -150,15 +208,15 @@ export function NonBlockingSignInBanner({
- {messages?.sessionComplete ?? "✓ SESSION COMPLETE"} + {bannerCopy.sessionComplete}
- {messages?.modeName ?? sessionMode} + {displayMode}
{duration} - {messages?.justNow ?? "just now"} + {bannerCopy.justNow}
@@ -170,20 +228,20 @@ export function NonBlockingSignInBanner({
{layout === "pill" ? ( <> {SessionCard}
-
Keep tonight's calm
-
It only lives on this device
+
{localized(sourceDefaults.pillHead, "auth.save_progress_question")}
+
{localized(sourceDefaults.pillSub, "auth.local_only")}
- @@ -192,9 +250,9 @@ export function NonBlockingSignInBanner({
-

Check your email

+

{bannerCopy.checkEmail}

- We sent a link to {email.trim() || "you"} + {bannerCopy.sentLinkTo} {email.trim() || bannerCopy.you}

) : ( @@ -202,22 +260,22 @@ export function NonBlockingSignInBanner({

- {messages?.title ?? "Save your breathing practice journey"} + {bannerCopy.title}

{SessionCard} @@ -229,24 +287,24 @@ export function NonBlockingSignInBanner({ type="email" value={email} onChange={(e) => setEmail(e.target.value)} - placeholder={messages?.emailPlaceholder ?? "Enter your email"} - aria-label={messages?.emailAddressAria ?? "Email address"} + placeholder={bannerCopy.emailPlaceholder} + aria-label={bannerCopy.emailAddressAria} required /> - {status === "error" &&

Something went wrong. Please try again.

} + {status === "error" &&

{localized(sourceDefaults.error, "auth.something_went_wrong")}

}
diff --git a/src/components/auth/session-complete-prompt.tsx b/src/components/auth/session-complete-prompt.tsx index 2acb901c..ea9587ca 100644 --- a/src/components/auth/session-complete-prompt.tsx +++ b/src/components/auth/session-complete-prompt.tsx @@ -125,10 +125,12 @@ export function SessionCompletePrompt({ onOpenChange={handleOpenChange} onSuccess={onSuccess} sessionMode={pattern.name} + localizedSessionMode={modeDisplayName} accentColor={pattern.color} sessionSeconds={sessionSeconds || 90} layout={bannerUi} messages={routeClientMessages} + locale={locale} /> ); } @@ -141,8 +143,10 @@ export function SessionCompletePrompt({ onOpenChange={handleOpenChange} onSuccess={onSuccess} sessionMode={pattern.name} + localizedSessionMode={modeDisplayName} accentColor={pattern.color} sessionSeconds={sessionSeconds} + locale={locale} /> ); } diff --git a/src/components/language-switcher.tsx b/src/components/language-switcher.tsx index 36a36d91..e5deea19 100644 --- a/src/components/language-switcher.tsx +++ b/src/components/language-switcher.tsx @@ -2,66 +2,40 @@ import { useEffect, useState, useRef } from "react"; import { createRuntimePhraseResolver } from "@/components/resonance/runtime-phrases"; - -interface MTConfig { - supportedLocales: string[]; - defaultLang: string; - urlMode: string; - lang: string; - path: string; -} +import { + DEFAULT_LOCALE, + LOCALES, + getLocale, + getLocaleFromPathname, + localizePathname, + resolveLocaleCode, + stripLocalePrefix, + type LocaleCode, +} from "@/i18n"; +import { + getNativeRouteByPath, + isNativeRoutePublished, + resolveNativeInternalHref, +} from "@/i18n/route-manifest"; declare global { interface Window { - __MT_CONFIG__?: MTConfig; + __MT_CONFIG__?: { + lang?: string; + supportedLocales?: readonly string[]; + }; } } -const SUPPORTED_LOCALES = ["es-es", "pt-br", "fr-fr", "de-de", "ja-jp"]; - -const LOCALE_SHORT: Record = { - en: "EN", - "es-es": "ES", - "pt-br": "PT", - "fr-fr": "FR", - "de-de": "DE", - "ja-jp": "JA", -}; - -const LOCALE_FULL: Record = { - en: "English", - "es-es": "Español", - "pt-br": "Português", - "fr-fr": "Français", - "de-de": "Deutsch", - "ja-jp": "日本語", -}; - -function getPrefix(locale: string): string { - return locale.split("-")[0]; -} +const TRANSLATED_LOCALES = LOCALES.filter( + (locale) => locale.code !== DEFAULT_LOCALE, +); -/** Strip all known locale prefixes from a path to get the English base path. */ -function stripLocalePrefix(pathname: string): string { - for (const loc of SUPPORTED_LOCALES) { - const prefix = `/${getPrefix(loc)}`; - if (pathname.startsWith(prefix + "/")) { - return stripLocalePrefix(pathname.slice(prefix.length)); - } - if (pathname === prefix) { - return "/"; - } - } - return pathname; -} +type LocaleInfo = { currentLocale: LocaleCode; basePath: string }; /** - * Routes that exist in English only. These are in the mass-translate proxy's - * `exclude_paths`, so a localized variant (e.g. /es/languages) is NOT served — - * the proxy 301s it back to the EN canonical. Linking a locale switcher straight - * to /{loc}/languages would advertise a URL that just bounces, so for these - * routes we point each locale at its localized home instead. - * Keep in sync with the tenant's exclude_paths / EN_ONLY_ROUTES in sitemap-routes. + * Routes that exist in English only. Pointing these options at each locale's + * published home avoids a localized URL that cannot be served. */ const EN_ONLY_ROUTES = new Set(["/languages"]); @@ -69,46 +43,64 @@ function isEnOnlyRoute(basePath: string): boolean { return EN_ONLY_ROUTES.has(basePath) || [...EN_ONLY_ROUTES].some((r) => basePath.startsWith(r + "/")); } -/** Build the href for a locale option. EN-only routes route to the localized home. */ -function localeHref(prefix: string, basePath: string): string { - if (isEnOnlyRoute(basePath)) return `/${prefix}`; - return `/${prefix}${basePath}`; +function getDisplayedLocales(basePath: string) { + const configuredLocales = + typeof window === "undefined" + ? undefined + : window.__MT_CONFIG__?.supportedLocales; + const configuredCodes = configuredLocales + ? new Set( + configuredLocales + .map((value) => resolveLocaleCode(value)) + .filter((code): code is LocaleCode => code !== null), + ) + : null; + const candidates = configuredCodes + ? TRANSLATED_LOCALES.filter((locale) => configuredCodes.has(locale.code)) + : TRANSLATED_LOCALES; + + const route = getNativeRouteByPath( + isEnOnlyRoute(basePath) ? "/" : basePath, + ); + if (!route) { + // MassTranslate can serve dynamic proxy routes that are deliberately not + // admitted to the static native manifest. Preserve those old options only + // when the proxy has explicitly configured them. + return configuredCodes ? candidates : []; + } + + if (route.dynamic && configuredCodes) return candidates; + return candidates.filter((locale) => + isNativeRoutePublished(route, locale.code), + ); } -function getCurrentLocaleAndPath(): { currentLocale: string; basePath: string } { - if (typeof window === "undefined") return { currentLocale: "en", basePath: "/" }; +/** Build a locale option without linking to an unavailable native route. */ +function localeHref(locale: LocaleCode, basePath: string): string { + if (isEnOnlyRoute(basePath)) return localizePathname("/", locale); + return resolveNativeInternalHref(basePath, locale, "native"); +} - const config = window.__MT_CONFIG__; - if (config) { - // Ensure config.path has no locale prefix (defensive) - return { currentLocale: config.lang, basePath: stripLocalePrefix(config.path) }; +function getCurrentLocaleAndPath(): LocaleInfo { + if (typeof window === "undefined") { + return { currentLocale: DEFAULT_LOCALE, basePath: "/" }; } - // Detect from URL const pathname = window.location.pathname; - const basePath = stripLocalePrefix(pathname); - - // If basePath differs from pathname, we had a locale prefix - if (basePath !== pathname) { - // Find which locale it was - for (const loc of SUPPORTED_LOCALES) { - const prefix = `/${getPrefix(loc)}`; - if (pathname.startsWith(prefix + "/") || pathname === prefix) { - return { currentLocale: loc, basePath }; - } - } - } - - return { currentLocale: "en", basePath }; + return { + currentLocale: getLocaleFromPathname(pathname).code, + basePath: stripLocalePrefix(pathname), + }; } function explicitLocaleInfo( locale?: string, basePath?: string, -): { currentLocale: string; basePath: string } | null { +): LocaleInfo | null { if (!locale || !basePath) return null; - const normalized = locale.toLowerCase() === "en-us" ? "en" : locale.toLowerCase(); - return { currentLocale: normalized, basePath: stripLocalePrefix(basePath) }; + const code = resolveLocaleCode(locale); + if (!code) return null; + return { currentLocale: code, basePath: stripLocalePrefix(basePath) }; } interface LanguageSwitcherProps { @@ -144,7 +136,7 @@ function GlobeIcon({ className }: { className?: string }) { */ export function LanguageSwitcherInline({ basePath, locale }: LanguageSwitcherProps = {}) { const [open, setOpen] = useState(false); - const [info, setInfo] = useState<{ currentLocale: string; basePath: string } | null>( + const [info, setInfo] = useState( () => explicitLocaleInfo(locale, basePath), ); const ref = useRef(null); @@ -164,7 +156,7 @@ export function LanguageSwitcherInline({ basePath, locale }: LanguageSwitcherPro if (!info) return null; - const currentShort = LOCALE_SHORT[info.currentLocale] || "EN"; + const currentShort = getLocale(info.currentLocale).shortLabel; const changeLanguageLabel = createRuntimePhraseResolver(locale ?? info.currentLocale) .resolve("ui.change_language").text; @@ -183,27 +175,26 @@ export function LanguageSwitcherInline({ basePath, locale }: LanguageSwitcherPro English - {SUPPORTED_LOCALES.map((loc) => { - const prefix = getPrefix(loc); - const isActive = info.currentLocale === loc || getPrefix(info.currentLocale) === prefix; + {getDisplayedLocales(info.basePath).map((loc) => { + const isActive = info.currentLocale === loc.code; return ( - {LOCALE_FULL[loc]} + {loc.nativeLabel} ); })} @@ -214,21 +205,16 @@ export function LanguageSwitcherInline({ basePath, locale }: LanguageSwitcherPro } /** - * Footer-style language switcher — inline links. + * Footer-style language switcher, inline links. * Minimized: globe that expands to show all locales. * - * Note: the mass-translate reverse proxy rewrites anchor hrefs (both - * relative and absolute on the canonical host) to prefix the current - * locale. Server-rendering anchors here leaks broken paths like - * /de/es into translated pages, which would generate crawler 404s. - * We therefore gate the whole switcher on client-side hydration; for - * crawl-discovery of translated pages, rely on hreflang metadata and - * any server-rendered anchors added in page-level components where - * the build pathway is proxy-safe. + * This stays client-side because the switcher is an interactive control. It + * only offers locales whose current route is published, while proxy pages use + * the proxy's configured locale list for dynamic legacy routes. */ export function LanguageSwitcherFooter({ basePath, locale }: LanguageSwitcherProps = {}) { const [open, setOpen] = useState(false); - const [info, setInfo] = useState<{ currentLocale: string; basePath: string } | null>( + const [info, setInfo] = useState( () => explicitLocaleInfo(locale, basePath), ); @@ -238,8 +224,8 @@ export function LanguageSwitcherFooter({ basePath, locale }: LanguageSwitcherPro if (!info) return null; - const currentLabel = LOCALE_FULL[info.currentLocale] || "English"; - const locales = SUPPORTED_LOCALES; + const currentLabel = getLocale(info.currentLocale).nativeLabel; + const locales = getDisplayedLocales(info.basePath); const changeLanguageLabel = createRuntimePhraseResolver(locale ?? info.currentLocale) .resolve("ui.change_language").text; @@ -262,7 +248,7 @@ export function LanguageSwitcherFooter({ basePath, locale }: LanguageSwitcherPro {locales.map((loc) => { - const prefix = getPrefix(loc); - const isActive = info.currentLocale === loc || getPrefix(info.currentLocale) === prefix; + const isActive = info.currentLocale === loc.code; return ( - {LOCALE_FULL[loc]} + {loc.nativeLabel} ); })} diff --git a/src/components/resonance/Resonance.tsx b/src/components/resonance/Resonance.tsx index 1dddc78c..210dbc46 100644 --- a/src/components/resonance/Resonance.tsx +++ b/src/components/resonance/Resonance.tsx @@ -8,9 +8,9 @@ import { Volume2, VolumeX, Eye, EyeOff, Activity, Waves, Wind, Sun, Moon, Turtle import { BreathingPhase, ModeName, AIRecommendation, ProtocolPhase, ProtocolState } from './types'; import { BREATHING_PATTERNS, DEFAULT_SPEED_MULTIPLIER, WIM_HOF_PROTOCOL, modeToSlug } from './constants'; import { AudioService } from '@resonance/audio'; -import { multiplierToSlider, sliderToMultiplier } from '@resonance/domain'; +import { multiplierToSlider, sliderToMultiplier, speedOf } from '@resonance/domain'; import Visualizer from './components/Visualizer'; -import { createRuntimePhraseResolver, detectRuntimeLocale, RuntimePhraseKey } from './runtime-phrases'; +import { createRuntimePhraseResolver, detectRuntimeLocale, resolveRuntimeModeLabel, RuntimePhraseKey } from './runtime-phrases'; import { LanguageSwitcherInline } from '@/components/language-switcher'; import { localizePathname, stripLocalePrefix } from '@/i18n'; import type { ResonanceRouteClientMessages } from '@/i18n/content/remaining-pages/rw02-route-client/types'; @@ -1497,22 +1497,22 @@ const Resonance: React.FC = ({ apiKey, className = '', defaultMo
{WIM_HOF_PROTOCOL.rounds} - Rounds + {getSafePhrase('ui.rounds')}
{WIM_HOF_PROTOCOL.powerBreathCount} - Breaths + {getSafePhrase('ui.breaths')}
- ~15min - Duration + {getSafePhrase('ui.duration_min', { n: '~15' })} + {getSafePhrase('ui.duration')}
- Beta Waves (15Hz) - Alertness + {getSafePhrase('ui.beta_waves', { n: 15 })} + {getSafePhrase('ui.alertness')}
@@ -1524,9 +1524,10 @@ const Resonance: React.FC = ({ apiKey, className = '', defaultMo ); } - const waveType = isRelax ? "Delta Waves (2Hz)" : getSafePhrase('ui.alpha_waves'); - const waveDesc = isRelax ? "Deep Sleep" : getSafePhrase('ui.flow_state'); - const ambienceType = (activeMode === ModeName.Relax || activeMode === ModeName.Coherent) ? "Pink Noise (Rain)" : getSafePhrase('ui.drone_synth'); + const waveType = isRelax ? getSafePhrase('ui.delta_waves', { n: 2 }) : getSafePhrase('ui.alpha_waves'); + const waveDesc = isRelax ? getSafePhrase('ui.deep_sleep') : getSafePhrase('ui.flow_state'); + const isRainBed = activeMode === ModeName.Relax || activeMode === ModeName.Coherent; + const ambienceType = isRainBed ? getSafePhrase('ui.pink_noise_rain') : getSafePhrase('ui.drone_synth'); return (
@@ -1560,7 +1561,7 @@ const Resonance: React.FC = ({ apiKey, className = '', defaultMo {waveDesc}
- {ambienceType.includes("Rain") ? : } + {isRainBed ? : } {ambienceType} {getSafePhrase('ui.audio_8d')}
@@ -1952,12 +1953,7 @@ const Resonance: React.FC = ({ apiKey, className = '', defaultMo return true; }) .map((m) => { - // Short labels for mode buttons - let label = m.name.split(' ')[0]; - if (m.name === ModeName.Sigh) label = 'Sigh'; - if (m.name === ModeName.WimHof) label = 'Wim Hof'; - if (m.name === ModeName.Tummo) label = 'Tummo'; - if (m.name === ModeName.BreathOfFire) label = 'Fire'; + const label = resolveRuntimeModeLabel(runtimePhrases.locale, m.name); return (