@sonenta/astro 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/runtime.cjs CHANGED
@@ -20,13 +20,18 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/runtime.ts
21
21
  var runtime_exports = {};
22
22
  __export(runtime_exports, {
23
+ DEFAULT_SURFACE_BREAKPOINTS: () => DEFAULT_SURFACE_BREAKPOINTS,
23
24
  buildBundleUrl: () => buildBundleUrl,
25
+ buildOverlayUrl: () => buildOverlayUrl,
24
26
  createSonentaI18n: () => createSonentaI18n,
25
27
  createT: () => createT,
26
28
  fetchAll: () => fetchAll,
29
+ fetchAllOverlays: () => fetchAllOverlays,
27
30
  fetchBundle: () => fetchBundle,
28
31
  fetchNamespace: () => fetchNamespace,
29
- resolveCdnBase: () => resolveCdnBase
32
+ fetchOverlay: () => fetchOverlay,
33
+ resolveCdnBase: () => resolveCdnBase,
34
+ surfaceForWidth: () => surfaceForWidth
30
35
  });
31
36
  module.exports = __toCommonJS(runtime_exports);
32
37
 
@@ -34,6 +39,15 @@ module.exports = __toCommonJS(runtime_exports);
34
39
  var DEFAULT_CDN_BASE = "https://cdn.sonenta.com";
35
40
  var DEFAULT_VERSION = "main";
36
41
  var DEFAULT_NAMESPACE = "common";
42
+ var DEFAULT_SURFACE_BREAKPOINTS = {
43
+ mobile: 640,
44
+ tablet: 1024
45
+ };
46
+ function surfaceForWidth(width, breakpoints = DEFAULT_SURFACE_BREAKPOINTS) {
47
+ if (width < breakpoints.mobile) return "mobile";
48
+ if (width < breakpoints.tablet) return "tablet";
49
+ return "desktop";
50
+ }
37
51
 
38
52
  // src/cdn.ts
39
53
  function trimSlashes(s) {
@@ -48,6 +62,11 @@ function buildBundleUrl(opts, locale, namespace) {
48
62
  const version = opts.version ?? DEFAULT_VERSION;
49
63
  return `${base}/p/${opts.project}/${version}/latest/${locale}/${namespace}.json`;
50
64
  }
65
+ function buildOverlayUrl(opts, locale, namespace, surface) {
66
+ const base = resolveCdnBase(opts);
67
+ const version = opts.version ?? DEFAULT_VERSION;
68
+ return `${base}/p/${opts.project}/${version}/latest/${locale}/${namespace}.${surface}.json`;
69
+ }
51
70
  async function fetchBundle(opts, locale, namespace) {
52
71
  const url = buildBundleUrl(opts, locale, namespace);
53
72
  const doFetch = opts.fetchImpl ?? fetch;
@@ -85,6 +104,35 @@ async function fetchAll(opts, locales, namespaces) {
85
104
  );
86
105
  return out;
87
106
  }
107
+ async function fetchOverlay(opts, locale, namespace, surface) {
108
+ const url = buildOverlayUrl(opts, locale, namespace, surface);
109
+ const doFetch = opts.fetchImpl ?? fetch;
110
+ try {
111
+ const res = await doFetch(url, { method: "GET" });
112
+ if (!res.ok) return null;
113
+ const data = await res.json();
114
+ return data && typeof data === "object" ? data : null;
115
+ } catch {
116
+ return null;
117
+ }
118
+ }
119
+ async function fetchAllOverlays(opts, locales, namespaces, surfaces) {
120
+ const out = {};
121
+ for (const l of locales) {
122
+ out[l] = {};
123
+ for (const ns of namespaces) out[l][ns] = {};
124
+ }
125
+ await Promise.all(
126
+ locales.flatMap(
127
+ (l) => namespaces.flatMap(
128
+ (ns) => surfaces.map(async (s) => {
129
+ out[l][ns][s] = await fetchOverlay(opts, l, ns, s);
130
+ })
131
+ )
132
+ )
133
+ );
134
+ return out;
135
+ }
88
136
 
89
137
  // src/resolver.ts
90
138
  function normalizeFallback(fallbackLng, defaultLocale) {
@@ -108,6 +156,16 @@ function walk(bundle, path) {
108
156
  }
109
157
  return cursor;
110
158
  }
159
+ function unwrapValue(node) {
160
+ if (node && typeof node === "object" && Object.prototype.hasOwnProperty.call(node, "$value")) {
161
+ return node.$value;
162
+ }
163
+ return node;
164
+ }
165
+ function resolveLeaf(bundle, path) {
166
+ const v = unwrapValue(walk(bundle, path));
167
+ return typeof v === "string" ? v : void 0;
168
+ }
111
169
  function interpolate(template, vars) {
112
170
  if (!vars) return template;
113
171
  return template.replace(
@@ -115,16 +173,30 @@ function interpolate(template, vars) {
115
173
  (_, name) => name in vars ? String(vars[name]) : `{${name}}`
116
174
  );
117
175
  }
118
- function createT(data, locale, fallbackChain, defaultNamespace) {
119
- const order = [locale, ...fallbackChain.filter((l) => l !== locale)];
120
- return function t(key, vars) {
176
+ function resolutionOrder(locale, fallbackChain) {
177
+ return [locale, ...fallbackChain.filter((l) => l !== locale)];
178
+ }
179
+ function createT(data, overlays, locale, fallbackChain, defaultNamespace) {
180
+ const order = resolutionOrder(locale, fallbackChain);
181
+ const t = function t2(key, vars) {
121
182
  const [ns, rest] = splitKey(key, defaultNamespace);
122
183
  for (const l of order) {
123
- const hit = walk(data[l]?.[ns], rest);
124
- if (typeof hit === "string") return interpolate(hit, vars);
184
+ const hit = resolveLeaf(data[l]?.[ns], rest);
185
+ if (hit !== void 0) return interpolate(hit, vars);
125
186
  }
126
187
  return key;
127
188
  };
189
+ t.surface = function surface(key, surface, vars) {
190
+ const [ns, rest] = splitKey(key, defaultNamespace);
191
+ for (const l of order) {
192
+ const overlayHit = resolveLeaf(overlays[l]?.[ns]?.[surface], rest);
193
+ if (overlayHit !== void 0) return interpolate(overlayHit, vars);
194
+ const baseHit = resolveLeaf(data[l]?.[ns], rest);
195
+ if (baseHit !== void 0) return interpolate(baseHit, vars);
196
+ }
197
+ return key;
198
+ };
199
+ return t;
128
200
  }
129
201
  async function createSonentaI18n(options) {
130
202
  if (!options.project) {
@@ -138,12 +210,23 @@ async function createSonentaI18n(options) {
138
210
  const namespaces = options.namespaces?.length ? options.namespaces : [DEFAULT_NAMESPACE];
139
211
  const defaultNamespace = namespaces[0];
140
212
  const fallbackChain = normalizeFallback(options.fallbackLng, defaultLocale);
213
+ const surfaces = options.surfaces ?? [];
214
+ const surfaceBreakpoints = options.surfaceBreakpoints ?? DEFAULT_SURFACE_BREAKPOINTS;
141
215
  const data = await fetchAll(options, locales, namespaces);
216
+ const overlays = surfaces.length ? await fetchAllOverlays(options, locales, namespaces, surfaces) : {};
142
217
  return {
143
218
  locales,
144
219
  defaultLocale,
220
+ surfaces,
221
+ surfaceBreakpoints,
145
222
  getT(locale) {
146
- return createT(data, locale, fallbackChain, defaultNamespace);
223
+ return createT(data, overlays, locale, fallbackChain, defaultNamespace);
224
+ },
225
+ getSurfaces(locale, key, vars) {
226
+ const t = createT(data, overlays, locale, fallbackChain, defaultNamespace);
227
+ const out = {};
228
+ for (const s of surfaces) out[s] = t.surface(key, s, vars);
229
+ return out;
147
230
  },
148
231
  getCatalog(locale, namespace) {
149
232
  return data[locale]?.[namespace ?? defaultNamespace] ?? null;
@@ -152,12 +235,17 @@ async function createSonentaI18n(options) {
152
235
  }
153
236
  // Annotate the CommonJS export names for ESM import in node:
154
237
  0 && (module.exports = {
238
+ DEFAULT_SURFACE_BREAKPOINTS,
155
239
  buildBundleUrl,
240
+ buildOverlayUrl,
156
241
  createSonentaI18n,
157
242
  createT,
158
243
  fetchAll,
244
+ fetchAllOverlays,
159
245
  fetchBundle,
160
246
  fetchNamespace,
161
- resolveCdnBase
247
+ fetchOverlay,
248
+ resolveCdnBase,
249
+ surfaceForWidth
162
250
  });
163
251
  //# sourceMappingURL=runtime.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/runtime.ts","../src/types.ts","../src/cdn.ts","../src/resolver.ts"],"sourcesContent":["/**\n * `@sonenta/astro/runtime` — the framework-agnostic build-time resolver.\n *\n * Two ways to use it:\n * 1. Via the integration (default export of `@sonenta/astro`): the generated\n * `sonenta:i18n` virtual module imports `createSonentaI18n` from here.\n * 2. Directly, if you prefer the explicit website-style top-level `await`\n * without the integration:\n *\n * // src/i18n.ts\n * import { createSonentaI18n } from \"@sonenta/astro/runtime\";\n * export const i18n = await createSonentaI18n({ project, locales });\n * export const getT = i18n.getT;\n */\n\nexport {\n createSonentaI18n,\n createT,\n} from \"./resolver\";\n\nexport {\n buildBundleUrl,\n fetchBundle,\n fetchNamespace,\n fetchAll,\n resolveCdnBase,\n type Bundle,\n} from \"./cdn\";\n\nexport type {\n Locale,\n Namespace,\n Vars,\n TFn,\n SonentaI18n,\n SonentaI18nOptions,\n} from \"./types\";\n","/**\n * Public types for `@sonenta/astro` v0.1 (standalone, pre-core).\n *\n * FORWARD-COMPATIBILITY CONTRACT (frozen day-one — see CONTRACT.md): every\n * option key here is the SAME key the future `@sonenta/i18n-core`-backed\n * release will consume, so the later refactor (0.2.0) is a non-breaking,\n * additive internal swap. New capabilities (surfaces, a11y accessors, CLDR\n * plurals, variants) arrive ADDITIVELY; nothing here changes meaning.\n */\n\n/** A BCP-47 locale code, e.g. `\"fr\"`, `\"en\"`, `\"fr-CA\"`. */\nexport type Locale = string;\n\n/** A bundle namespace (the `{ns}.json` file on the CDN), e.g. `\"common\"`. */\nexport type Namespace = string;\n\n/** Interpolation variables for a `t()` call: `t(\"greeting\", { name: \"Ada\" })`. */\nexport type Vars = Record<string, string | number>;\n\n/** A resolved, per-locale translation function. */\nexport type TFn = (key: string, vars?: Vars) => string;\n\n/**\n * Configuration for the Sonenta Astro integration / runtime.\n *\n * Bundles are fetched at BUILD time from\n * `{cdnBase}/p/{project}/{version}/latest/{locale}/{namespace}.json`\n * (the canonical Sonenta CDN layout, identical to `@sonenta/react-i18next`).\n */\nexport interface SonentaI18nOptions {\n /** Project UUID from your Sonenta dashboard. Required. */\n project: string;\n\n /**\n * Released version slug or pinned content hash. Default `\"main\"`.\n * The CDN serves the latest release of this version under `/latest/`.\n */\n version?: string;\n\n /**\n * Locales to fetch and freeze into the static build. Required, non-empty.\n * A locale whose bundle 404s (e.g. a plan-limit-blocked language) is kept\n * as `null` and transparently falls back to the source language.\n */\n locales: Locale[];\n\n /**\n * Source / default locale. Default = `locales[0]`. Used as the implicit\n * final fallback target and as Astro's source language.\n */\n defaultLocale?: Locale;\n\n /**\n * Ordered fallback chain applied when a key is missing in the active\n * locale. Default = `[defaultLocale]`. v0.1 applies these locales in\n * order; BCP-47 variant→base inheritance (`fr-CA → fr`) is deliberately\n * left to the core-backed release.\n */\n fallbackLng?: Locale | Locale[];\n\n /**\n * Namespaces (bundle files) to fetch. Default `[\"common\"]`. The first\n * entry is the default namespace for un-prefixed keys; address others with\n * the i18next-style `\"ns:key\"` syntax.\n */\n namespaces?: Namespace[];\n\n /**\n * CDN host root for translation bundles, WITHOUT the `/p` segment.\n * Default `\"https://cdn.sonenta.com\"`. Overridable via the\n * `SONENTA_CDN_BASE` env var (also a bare host; for local dev point it at\n * your translation CDN). The `/p/{project}/{version}/latest/...` path is\n * appended by the loader.\n */\n cdnBase?: string;\n\n /**\n * API host root. Reserved for forward-compatibility (the core-backed\n * release uses it for the public language manifest and dev-mode runtime\n * fetch). Default `\"https://api.sonenta.dev\"`. Unused by v0.1's prod\n * build-time path.\n */\n apiBase?: string;\n\n /**\n * Injectable `fetch` implementation (testing / proxies / custom agents).\n * Defaults to the global `fetch`.\n */\n fetchImpl?: typeof fetch;\n}\n\n/**\n * The resolved Sonenta i18n handle returned by `createSonentaI18n` and\n * exposed through the `sonenta:i18n` virtual module.\n */\nexport interface SonentaI18n {\n /** Build a translation function bound to `locale`. */\n getT(locale: Locale): TFn;\n /** The locales that were fetched (the configured `locales`). */\n readonly locales: Locale[];\n /** The resolved source/default locale. */\n readonly defaultLocale: Locale;\n /**\n * Raw fetched dictionary for `locale` / `namespace` (default namespace when\n * omitted), or `null` when that bundle was absent (404 / plan-limit).\n */\n getCatalog(locale: Locale, namespace?: Namespace): Record<string, unknown> | null;\n}\n\n/** Default CDN host (no `/p`). */\nexport const DEFAULT_CDN_BASE = \"https://cdn.sonenta.com\";\n/** Default API host. */\nexport const DEFAULT_API_BASE = \"https://api.sonenta.dev\";\n/** Default version slug. */\nexport const DEFAULT_VERSION = \"main\";\n/** Default namespace. */\nexport const DEFAULT_NAMESPACE = \"common\";\n","/**\n * Build-time CDN loader for Sonenta translation bundles.\n *\n * Mirrors the canonical Sonenta CDN layout used by `@sonenta/react-i18next`:\n * `{cdnBase}/p/{project}/{version}/latest/{locale}/{namespace}.json`\n *\n * Pure data fetch — no DOM, no React, no runtime. Called during `astro build`\n * so the strings inline into the static HTML (zero client JS).\n */\n\nimport {\n DEFAULT_CDN_BASE,\n DEFAULT_VERSION,\n type Locale,\n type Namespace,\n type SonentaI18nOptions,\n} from \"./types\";\n\n/** A fetched bundle (a flat or nested dictionary of message strings). */\nexport type Bundle = Record<string, unknown>;\n\n/** Strip trailing slashes so URL joins never double up. */\nfunction trimSlashes(s: string): string {\n return s.replace(/\\/+$/, \"\");\n}\n\n/**\n * Resolve the effective CDN host (no `/p`): explicit option wins, then the\n * `SONENTA_CDN_BASE` env var, then the production default.\n */\nexport function resolveCdnBase(opts: Pick<SonentaI18nOptions, \"cdnBase\">): string {\n const env =\n typeof process !== \"undefined\" ? process.env?.SONENTA_CDN_BASE : undefined;\n return trimSlashes(opts.cdnBase ?? env ?? DEFAULT_CDN_BASE);\n}\n\n/**\n * Build the bundle URL for one `(locale, namespace)` pair.\n * `{cdnBase}/p/{project}/{version}/latest/{locale}/{namespace}.json`\n */\nexport function buildBundleUrl(\n opts: Pick<SonentaI18nOptions, \"project\" | \"version\" | \"cdnBase\">,\n locale: Locale,\n namespace: Namespace,\n): string {\n const base = resolveCdnBase(opts);\n const version = opts.version ?? DEFAULT_VERSION;\n return `${base}/p/${opts.project}/${version}/latest/${locale}/${namespace}.json`;\n}\n\n/**\n * Fetch a single bundle. Resolves to `null` on 404 (locale absent from the\n * project — e.g. a plan-limit-blocked language) so callers fall back to the\n * source language. Any other non-OK status or transport error throws so a\n * misconfigured build fails loudly rather than silently shipping empty pages.\n */\nexport async function fetchBundle(\n opts: Pick<SonentaI18nOptions, \"project\" | \"version\" | \"cdnBase\" | \"fetchImpl\">,\n locale: Locale,\n namespace: Namespace,\n): Promise<Bundle | null> {\n const url = buildBundleUrl(opts, locale, namespace);\n const doFetch = opts.fetchImpl ?? fetch;\n let res: Response;\n try {\n res = await doFetch(url, { method: \"GET\" });\n } catch (e) {\n throw new Error(\n `[@sonenta/astro] CDN fetch failed for ${locale}/${namespace}: ${\n (e as Error).message\n }`,\n );\n }\n if (!res.ok) {\n if (res.status === 404) return null;\n throw new Error(`[@sonenta/astro] CDN ${res.status} on ${url}`);\n }\n return (await res.json()) as Bundle;\n}\n\n/**\n * Fetch one namespace across every locale, in parallel. Absent locales map to\n * `null`. Returns `Record<Locale, Bundle | null>`.\n */\nexport async function fetchNamespace(\n opts: Pick<\n SonentaI18nOptions,\n \"project\" | \"version\" | \"cdnBase\" | \"fetchImpl\"\n >,\n locales: Locale[],\n namespace: Namespace,\n): Promise<Record<Locale, Bundle | null>> {\n const entries = await Promise.all(\n locales.map(\n async (l) => [l, await fetchBundle(opts, l, namespace)] as const,\n ),\n );\n return Object.fromEntries(entries) as Record<Locale, Bundle | null>;\n}\n\n/**\n * Fetch every `(locale, namespace)` pair. Returns a nested map keyed by\n * locale then namespace: `data[locale][namespace] = Bundle | null`.\n */\nexport async function fetchAll(\n opts: Pick<\n SonentaI18nOptions,\n \"project\" | \"version\" | \"cdnBase\" | \"fetchImpl\"\n >,\n locales: Locale[],\n namespaces: Namespace[],\n): Promise<Record<Locale, Record<Namespace, Bundle | null>>> {\n const out: Record<Locale, Record<Namespace, Bundle | null>> = {};\n for (const l of locales) out[l] = {};\n await Promise.all(\n locales.flatMap((l) =>\n namespaces.map(async (ns) => {\n out[l]![ns] = await fetchBundle(opts, l, ns);\n }),\n ),\n );\n return out;\n}\n","/**\n * Build-time translation resolver.\n *\n * v0.1 SCOPE (frozen — see CONTRACT.md): string resolution + `{var}`\n * interpolation + source-language fallback ONLY. Deliberately NO CLDR\n * plurals, NO surfaces, NO a11y accessors, NO BCP-47 variant→base\n * inheritance — those carry real i18n SEMANTICS owned by `@sonenta/i18n-core`\n * and arrive additively in 0.2.0. Keeping them out of v0.1 means there is no\n * naive semantics to break when the core swaps in underneath this same API.\n */\n\nimport { fetchAll, type Bundle } from \"./cdn\";\nimport {\n DEFAULT_NAMESPACE,\n type Locale,\n type Namespace,\n type SonentaI18n,\n type SonentaI18nOptions,\n type TFn,\n type Vars,\n} from \"./types\";\n\n/** Normalize `fallbackLng` (scalar | array | undefined) into a locale list. */\nfunction normalizeFallback(\n fallbackLng: SonentaI18nOptions[\"fallbackLng\"],\n defaultLocale: Locale,\n): Locale[] {\n if (fallbackLng == null) return [defaultLocale];\n return Array.isArray(fallbackLng) ? fallbackLng : [fallbackLng];\n}\n\n/** Split an i18next-style `\"ns:key\"` into `[namespace, key]`. */\nfunction splitKey(key: string, defaultNamespace: Namespace): [Namespace, string] {\n const i = key.indexOf(\":\");\n if (i === -1) return [defaultNamespace, key];\n return [key.slice(0, i), key.slice(i + 1)];\n}\n\n/** Walk a dotted path (`\"home.title\"`) through a bundle; `undefined` on miss. */\nfunction walk(bundle: Bundle | null | undefined, path: string): unknown {\n if (!bundle) return undefined;\n let cursor: unknown = bundle;\n for (const part of path.split(\".\")) {\n if (cursor && typeof cursor === \"object\" && part in cursor) {\n cursor = (cursor as Record<string, unknown>)[part];\n } else {\n return undefined;\n }\n }\n return cursor;\n}\n\n/** Substitute `{var}` placeholders; unknown placeholders are left intact. */\nfunction interpolate(template: string, vars?: Vars): string {\n if (!vars) return template;\n return template.replace(/\\{(\\w+)\\}/g, (_, name: string) =>\n name in vars ? String(vars[name]) : `{${name}}`,\n );\n}\n\n/**\n * Build a `t()` bound to `locale`, given the fetched data and resolution\n * order. Lookup: active locale's namespace dict → each fallback locale's same\n * namespace dict → the raw key (i18next-parity missing-key behaviour).\n */\nexport function createT(\n data: Record<Locale, Record<Namespace, Bundle | null>>,\n locale: Locale,\n fallbackChain: Locale[],\n defaultNamespace: Namespace,\n): TFn {\n // De-duped resolution order: active locale first, then configured\n // fallbacks (skipping the active one if it reappears).\n const order = [locale, ...fallbackChain.filter((l) => l !== locale)];\n return function t(key: string, vars?: Vars): string {\n const [ns, rest] = splitKey(key, defaultNamespace);\n for (const l of order) {\n const hit = walk(data[l]?.[ns], rest);\n if (typeof hit === \"string\") return interpolate(hit, vars);\n }\n // Missing everywhere → return the raw key (unchanged), i18next-style.\n return key;\n };\n}\n\n/**\n * Fetch every configured bundle at build time and return a resolved\n * {@link SonentaI18n} handle. Call once (top-level `await`) and reuse its\n * `getT(locale)` across pages.\n */\nexport async function createSonentaI18n(\n options: SonentaI18nOptions,\n): Promise<SonentaI18n> {\n if (!options.project) {\n throw new Error(\"[@sonenta/astro] `project` (UUID) is required.\");\n }\n if (!options.locales?.length) {\n throw new Error(\"[@sonenta/astro] `locales` must be a non-empty array.\");\n }\n const locales = options.locales;\n const defaultLocale = options.defaultLocale ?? locales[0]!;\n const namespaces =\n options.namespaces?.length ? options.namespaces : [DEFAULT_NAMESPACE];\n const defaultNamespace = namespaces[0]!;\n const fallbackChain = normalizeFallback(options.fallbackLng, defaultLocale);\n\n const data = await fetchAll(options, locales, namespaces);\n\n return {\n locales,\n defaultLocale,\n getT(locale: Locale): TFn {\n return createT(data, locale, fallbackChain, defaultNamespace);\n },\n getCatalog(locale: Locale, namespace?: Namespace): Bundle | null {\n return data[locale]?.[namespace ?? defaultNamespace] ?? null;\n },\n };\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/runtime.ts","../src/types.ts","../src/cdn.ts","../src/resolver.ts"],"sourcesContent":["/**\n * `@sonenta/astro/runtime` — the framework-agnostic build-time resolver.\n *\n * Two ways to use it:\n * 1. Via the integration (default export of `@sonenta/astro`): the generated\n * `sonenta:i18n` virtual module imports `createSonentaI18n` from here.\n * 2. Directly, if you prefer the explicit website-style top-level `await`\n * without the integration:\n *\n * // src/i18n.ts\n * import { createSonentaI18n } from \"@sonenta/astro/runtime\";\n * export const i18n = await createSonentaI18n({ project, locales });\n * export const getT = i18n.getT;\n */\n\nexport {\n createSonentaI18n,\n createT,\n} from \"./resolver\";\n\nexport {\n buildBundleUrl,\n buildOverlayUrl,\n fetchBundle,\n fetchNamespace,\n fetchAll,\n fetchOverlay,\n fetchAllOverlays,\n resolveCdnBase,\n type Bundle,\n} from \"./cdn\";\n\nexport {\n DEFAULT_SURFACE_BREAKPOINTS,\n surfaceForWidth,\n} from \"./types\";\n\nexport type {\n Locale,\n Namespace,\n Vars,\n TFn,\n Surface,\n SurfaceBreakpoints,\n SonentaI18n,\n SonentaI18nOptions,\n} from \"./types\";\n","/**\n * Public types for `@sonenta/astro` v0.1 (standalone, pre-core).\n *\n * FORWARD-COMPATIBILITY CONTRACT (frozen day-one — see CONTRACT.md): every\n * option key here is the SAME key the future `@sonenta/i18n-core`-backed\n * release will consume, so the later refactor (0.2.0) is a non-breaking,\n * additive internal swap. New capabilities (surfaces, a11y accessors, CLDR\n * plurals, variants) arrive ADDITIVELY; nothing here changes meaning.\n */\n\n/** A BCP-47 locale code, e.g. `\"fr\"`, `\"en\"`, `\"fr-CA\"`. */\nexport type Locale = string;\n\n/** A bundle namespace (the `{ns}.json` file on the CDN), e.g. `\"common\"`. */\nexport type Namespace = string;\n\n/** Interpolation variables for a `t()` call: `t(\"greeting\", { name: \"Ada\" })`. */\nexport type Vars = Record<string, string | number>;\n\n/**\n * A device surface — the second resolution dimension layered on top of the\n * locale chain (#911). The base bundle applies to every surface; a sparse\n * `{ns}.{surface}.json` overlay overrides individual keys for that surface.\n */\nexport type Surface = \"desktop\" | \"mobile\" | \"tablet\";\n\n/**\n * Min-width (px) thresholds mapping a viewport to a surface (mobile-first\n * ladder, mirrors `@sonenta/react-i18next` #913): width `< mobile` → mobile;\n * `< tablet` → tablet; otherwise desktop. Drives the `<SurfaceText>` media\n * queries (SSG renders every surface; CSS reveals the right one).\n */\nexport interface SurfaceBreakpoints {\n /** Upper bound (exclusive) of the `mobile` surface, px. Default 640. */\n mobile: number;\n /** Upper bound (exclusive) of the `tablet` surface, px. Default 1024. */\n tablet: number;\n}\n\n/**\n * A resolved, per-locale translation function. Calling `t(key, vars?)` returns\n * the surface-agnostic base value. `t.surface(key, surface, vars?)` returns the\n * value for a specific device surface (overlay ?? base).\n */\nexport interface TFn {\n (key: string, vars?: Vars): string;\n /** Resolve `key` for a specific device `surface` (overlay wins over base). */\n surface(key: string, surface: Surface, vars?: Vars): string;\n}\n\n/**\n * Configuration for the Sonenta Astro integration / runtime.\n *\n * Bundles are fetched at BUILD time from\n * `{cdnBase}/p/{project}/{version}/latest/{locale}/{namespace}.json`\n * (the canonical Sonenta CDN layout, identical to `@sonenta/react-i18next`).\n */\nexport interface SonentaI18nOptions {\n /** Project UUID from your Sonenta dashboard. Required. */\n project: string;\n\n /**\n * Released version slug or pinned content hash. Default `\"main\"`.\n * The CDN serves the latest release of this version under `/latest/`.\n */\n version?: string;\n\n /**\n * Locales to fetch and freeze into the static build. Required, non-empty.\n * A locale whose bundle 404s (e.g. a plan-limit-blocked language) is kept\n * as `null` and transparently falls back to the source language.\n */\n locales: Locale[];\n\n /**\n * Source / default locale. Default = `locales[0]`. Used as the implicit\n * final fallback target and as Astro's source language.\n */\n defaultLocale?: Locale;\n\n /**\n * Ordered fallback chain applied when a key is missing in the active\n * locale. Default = `[defaultLocale]`. v0.1 applies these locales in\n * order; BCP-47 variant→base inheritance (`fr-CA → fr`) is deliberately\n * left to the core-backed release.\n */\n fallbackLng?: Locale | Locale[];\n\n /**\n * Namespaces (bundle files) to fetch. Default `[\"common\"]`. The first\n * entry is the default namespace for un-prefixed keys; address others with\n * the i18next-style `\"ns:key\"` syntax.\n */\n namespaces?: Namespace[];\n\n /**\n * CDN host root for translation bundles, WITHOUT the `/p` segment.\n * Default `\"https://cdn.sonenta.com\"`. Overridable via the\n * `SONENTA_CDN_BASE` env var (also a bare host; for local dev point it at\n * your translation CDN). The `/p/{project}/{version}/latest/...` path is\n * appended by the loader.\n */\n cdnBase?: string;\n\n /**\n * API host root. Reserved for forward-compatibility (the core-backed\n * release uses it for the public language manifest and dev-mode runtime\n * fetch). Default `\"https://api.sonenta.dev\"`. Unused by v0.1's prod\n * build-time path.\n */\n apiBase?: string;\n\n /**\n * Device surfaces to fetch + expose (#911). When set (e.g.\n * `[\"desktop\", \"mobile\"]`), the build also pulls each sparse overlay\n * `{ns}.{surface}.json` and `getSurfaces` / `t.surface` / `<SurfaceText>`\n * resolve per-surface values. Omit to disable surfaces entirely (no overlay\n * fetch, identical to v0.1 behaviour).\n */\n surfaces?: Surface[];\n\n /**\n * Breakpoint ladder for `<SurfaceText>`'s responsive CSS. Default\n * `{ mobile: 640, tablet: 1024 }`. Resolution itself renders every surface;\n * these only decide which one CSS reveals at each viewport width.\n */\n surfaceBreakpoints?: SurfaceBreakpoints;\n\n /**\n * Injectable `fetch` implementation (testing / proxies / custom agents).\n * Defaults to the global `fetch`.\n */\n fetchImpl?: typeof fetch;\n}\n\n/**\n * The resolved Sonenta i18n handle returned by `createSonentaI18n` and\n * exposed through the `sonenta:i18n` virtual module.\n */\nexport interface SonentaI18n {\n /** Build a translation function bound to `locale`. */\n getT(locale: Locale): TFn;\n /** The locales that were fetched (the configured `locales`). */\n readonly locales: Locale[];\n /** The resolved source/default locale. */\n readonly defaultLocale: Locale;\n /** The configured device surfaces (empty when surfaces are disabled). */\n readonly surfaces: Surface[];\n /** The resolved surface breakpoint ladder. */\n readonly surfaceBreakpoints: SurfaceBreakpoints;\n /**\n * Resolve `key` for every configured surface at `locale`, e.g.\n * `{ desktop: \"Commencer gratuitement\", mobile: \"Commencer\" }`. A surface\n * without an overlay for the key resolves to the base value. Empty when\n * surfaces are disabled.\n */\n getSurfaces(locale: Locale, key: string, vars?: Vars): Record<Surface, string>;\n /**\n * Raw fetched dictionary for `locale` / `namespace` (default namespace when\n * omitted), or `null` when that bundle was absent (404 / plan-limit).\n */\n getCatalog(locale: Locale, namespace?: Namespace): Record<string, unknown> | null;\n}\n\n/** Default CDN host (no `/p`). */\nexport const DEFAULT_CDN_BASE = \"https://cdn.sonenta.com\";\n/** Default API host. */\nexport const DEFAULT_API_BASE = \"https://api.sonenta.dev\";\n/** Default version slug. */\nexport const DEFAULT_VERSION = \"main\";\n/** Default namespace. */\nexport const DEFAULT_NAMESPACE = \"common\";\n/** Default surface breakpoint ladder (mirrors `@sonenta/react-i18next` #913). */\nexport const DEFAULT_SURFACE_BREAKPOINTS: SurfaceBreakpoints = {\n mobile: 640,\n tablet: 1024,\n};\n\n/**\n * Map a viewport width (px) to a {@link Surface} using `breakpoints`. Pure —\n * shared with the `<SurfaceText>` CSS generation so the runtime mapping and\n * the media queries agree. `< mobile` → mobile, `< tablet` → tablet, else\n * desktop.\n */\nexport function surfaceForWidth(\n width: number,\n breakpoints: SurfaceBreakpoints = DEFAULT_SURFACE_BREAKPOINTS,\n): Surface {\n if (width < breakpoints.mobile) return \"mobile\";\n if (width < breakpoints.tablet) return \"tablet\";\n return \"desktop\";\n}\n","/**\n * Build-time CDN loader for Sonenta translation bundles.\n *\n * Mirrors the canonical Sonenta CDN layout used by `@sonenta/react-i18next`:\n * `{cdnBase}/p/{project}/{version}/latest/{locale}/{namespace}.json`\n *\n * Pure data fetch — no DOM, no React, no runtime. Called during `astro build`\n * so the strings inline into the static HTML (zero client JS).\n */\n\nimport {\n DEFAULT_CDN_BASE,\n DEFAULT_VERSION,\n type Locale,\n type Namespace,\n type SonentaI18nOptions,\n type Surface,\n} from \"./types\";\n\n/** A fetched bundle (a flat or nested dictionary of message strings). */\nexport type Bundle = Record<string, unknown>;\n\n/** Strip trailing slashes so URL joins never double up. */\nfunction trimSlashes(s: string): string {\n return s.replace(/\\/+$/, \"\");\n}\n\n/**\n * Resolve the effective CDN host (no `/p`): explicit option wins, then the\n * `SONENTA_CDN_BASE` env var, then the production default.\n */\nexport function resolveCdnBase(opts: Pick<SonentaI18nOptions, \"cdnBase\">): string {\n const env =\n typeof process !== \"undefined\" ? process.env?.SONENTA_CDN_BASE : undefined;\n return trimSlashes(opts.cdnBase ?? env ?? DEFAULT_CDN_BASE);\n}\n\n/**\n * Build the bundle URL for one `(locale, namespace)` pair.\n * `{cdnBase}/p/{project}/{version}/latest/{locale}/{namespace}.json`\n */\nexport function buildBundleUrl(\n opts: Pick<SonentaI18nOptions, \"project\" | \"version\" | \"cdnBase\">,\n locale: Locale,\n namespace: Namespace,\n): string {\n const base = resolveCdnBase(opts);\n const version = opts.version ?? DEFAULT_VERSION;\n return `${base}/p/${opts.project}/${version}/latest/${locale}/${namespace}.json`;\n}\n\n/**\n * Build the sparse surface-overlay URL for one `(locale, namespace, surface)`,\n * mirroring `@sonenta/react-i18next` #913:\n * `{cdnBase}/p/{project}/{version}/latest/{locale}/{namespace}.{surface}.json`\n */\nexport function buildOverlayUrl(\n opts: Pick<SonentaI18nOptions, \"project\" | \"version\" | \"cdnBase\">,\n locale: Locale,\n namespace: Namespace,\n surface: Surface,\n): string {\n const base = resolveCdnBase(opts);\n const version = opts.version ?? DEFAULT_VERSION;\n return `${base}/p/${opts.project}/${version}/latest/${locale}/${namespace}.${surface}.json`;\n}\n\n/**\n * Fetch a single bundle. Resolves to `null` on 404 (locale absent from the\n * project — e.g. a plan-limit-blocked language) so callers fall back to the\n * source language. Any other non-OK status or transport error throws so a\n * misconfigured build fails loudly rather than silently shipping empty pages.\n */\nexport async function fetchBundle(\n opts: Pick<SonentaI18nOptions, \"project\" | \"version\" | \"cdnBase\" | \"fetchImpl\">,\n locale: Locale,\n namespace: Namespace,\n): Promise<Bundle | null> {\n const url = buildBundleUrl(opts, locale, namespace);\n const doFetch = opts.fetchImpl ?? fetch;\n let res: Response;\n try {\n res = await doFetch(url, { method: \"GET\" });\n } catch (e) {\n throw new Error(\n `[@sonenta/astro] CDN fetch failed for ${locale}/${namespace}: ${\n (e as Error).message\n }`,\n );\n }\n if (!res.ok) {\n if (res.status === 404) return null;\n throw new Error(`[@sonenta/astro] CDN ${res.status} on ${url}`);\n }\n return (await res.json()) as Bundle;\n}\n\n/**\n * Fetch one namespace across every locale, in parallel. Absent locales map to\n * `null`. Returns `Record<Locale, Bundle | null>`.\n */\nexport async function fetchNamespace(\n opts: Pick<\n SonentaI18nOptions,\n \"project\" | \"version\" | \"cdnBase\" | \"fetchImpl\"\n >,\n locales: Locale[],\n namespace: Namespace,\n): Promise<Record<Locale, Bundle | null>> {\n const entries = await Promise.all(\n locales.map(\n async (l) => [l, await fetchBundle(opts, l, namespace)] as const,\n ),\n );\n return Object.fromEntries(entries) as Record<Locale, Bundle | null>;\n}\n\n/**\n * Fetch every `(locale, namespace)` pair. Returns a nested map keyed by\n * locale then namespace: `data[locale][namespace] = Bundle | null`.\n */\nexport async function fetchAll(\n opts: Pick<\n SonentaI18nOptions,\n \"project\" | \"version\" | \"cdnBase\" | \"fetchImpl\"\n >,\n locales: Locale[],\n namespaces: Namespace[],\n): Promise<Record<Locale, Record<Namespace, Bundle | null>>> {\n const out: Record<Locale, Record<Namespace, Bundle | null>> = {};\n for (const l of locales) out[l] = {};\n await Promise.all(\n locales.flatMap((l) =>\n namespaces.map(async (ns) => {\n out[l]![ns] = await fetchBundle(opts, l, ns);\n }),\n ),\n );\n return out;\n}\n\n/**\n * Fetch a single sparse surface overlay. Unlike {@link fetchBundle}, an\n * overlay is OPTIONAL: any miss (404), non-OK status, or transport error\n * resolves to `null` (base-only) and NEVER throws — a missing/erroring overlay\n * must never break the build or downgrade the base render. Mirrors\n * `@sonenta/react-i18next` #913's `{}`-on-miss behaviour.\n */\nexport async function fetchOverlay(\n opts: Pick<SonentaI18nOptions, \"project\" | \"version\" | \"cdnBase\" | \"fetchImpl\">,\n locale: Locale,\n namespace: Namespace,\n surface: Surface,\n): Promise<Bundle | null> {\n const url = buildOverlayUrl(opts, locale, namespace, surface);\n const doFetch = opts.fetchImpl ?? fetch;\n try {\n const res = await doFetch(url, { method: \"GET\" });\n if (!res.ok) return null;\n const data = (await res.json()) as Bundle;\n return data && typeof data === \"object\" ? data : null;\n } catch {\n return null;\n }\n}\n\n/**\n * Fetch every `(locale, namespace, surface)` overlay. Returns a nested map:\n * `overlays[locale][namespace][surface] = Bundle | null`. Empty when\n * `surfaces` is empty.\n */\nexport async function fetchAllOverlays(\n opts: Pick<\n SonentaI18nOptions,\n \"project\" | \"version\" | \"cdnBase\" | \"fetchImpl\"\n >,\n locales: Locale[],\n namespaces: Namespace[],\n surfaces: Surface[],\n): Promise<Record<Locale, Record<Namespace, Record<Surface, Bundle | null>>>> {\n const out: Record<Locale, Record<Namespace, Record<Surface, Bundle | null>>> =\n {};\n for (const l of locales) {\n out[l] = {};\n for (const ns of namespaces) out[l]![ns] = {} as Record<Surface, Bundle | null>;\n }\n await Promise.all(\n locales.flatMap((l) =>\n namespaces.flatMap((ns) =>\n surfaces.map(async (s) => {\n out[l]![ns]![s] = await fetchOverlay(opts, l, ns, s);\n }),\n ),\n ),\n );\n return out;\n}\n","/**\n * Build-time translation resolver.\n *\n * v0.2 SCOPE: string resolution + `{var}` interpolation + source-language\n * fallback (v0.1) PLUS device-surface variants (#911) — a sparse\n * `{ns}.{surface}.json` overlay over the base bundle, overlay-wins-per-key,\n * resolved per locale. Still NO CLDR plurals and NO a11y surfaces: a `$value`\n * that is a plural dict (object) is treated as \"no string here\" (deferred to\n * the `@sonenta/i18n-core`-backed 0.3.0). The `$asset` half of an envelope is\n * ignored for text resolution. This keeps the surface semantics a faithful,\n * regression-locked subset of react-i18next #913 so the later core swap is\n * behaviour-preserving.\n */\n\nimport { fetchAll, fetchAllOverlays, type Bundle } from \"./cdn\";\nimport {\n DEFAULT_NAMESPACE,\n DEFAULT_SURFACE_BREAKPOINTS,\n type Locale,\n type Namespace,\n type SonentaI18n,\n type SonentaI18nOptions,\n type Surface,\n type SurfaceBreakpoints,\n type TFn,\n type Vars,\n} from \"./types\";\n\n/** Nested base data: `data[locale][namespace] = Bundle | null`. */\ntype BaseData = Record<Locale, Record<Namespace, Bundle | null>>;\n/** Nested overlays: `overlays[locale][namespace][surface] = Bundle | null`. */\ntype OverlayData = Record<\n Locale,\n Record<Namespace, Record<Surface, Bundle | null>>\n>;\n\n/** Normalize `fallbackLng` (scalar | array | undefined) into a locale list. */\nfunction normalizeFallback(\n fallbackLng: SonentaI18nOptions[\"fallbackLng\"],\n defaultLocale: Locale,\n): Locale[] {\n if (fallbackLng == null) return [defaultLocale];\n return Array.isArray(fallbackLng) ? fallbackLng : [fallbackLng];\n}\n\n/** Split an i18next-style `\"ns:key\"` into `[namespace, key]`. */\nfunction splitKey(key: string, defaultNamespace: Namespace): [Namespace, string] {\n const i = key.indexOf(\":\");\n if (i === -1) return [defaultNamespace, key];\n return [key.slice(0, i), key.slice(i + 1)];\n}\n\n/** Walk a dotted path (`\"home.title\"`) through a bundle; `undefined` on miss. */\nfunction walk(bundle: Bundle | null | undefined, path: string): unknown {\n if (!bundle) return undefined;\n let cursor: unknown = bundle;\n for (const part of path.split(\".\")) {\n if (cursor && typeof cursor === \"object\" && part in cursor) {\n cursor = (cursor as Record<string, unknown>)[part];\n } else {\n return undefined;\n }\n }\n return cursor;\n}\n\n/**\n * Strip the `{ \"$value\", \"$asset\" }` envelope (#911): a node owning `$value`\n * resolves to that `$value` (the `$asset` half is ignored for text). Plain\n * nodes pass through unchanged.\n */\nfunction unwrapValue(node: unknown): unknown {\n if (\n node &&\n typeof node === \"object\" &&\n Object.prototype.hasOwnProperty.call(node, \"$value\")\n ) {\n return (node as Record<string, unknown>).$value;\n }\n return node;\n}\n\n/** Walk + unwrap to a leaf string, or `undefined` if absent / not a string. */\nfunction resolveLeaf(bundle: Bundle | null | undefined, path: string): string | undefined {\n const v = unwrapValue(walk(bundle, path));\n return typeof v === \"string\" ? v : undefined;\n}\n\n/** Substitute `{var}` placeholders; unknown placeholders are left intact. */\nfunction interpolate(template: string, vars?: Vars): string {\n if (!vars) return template;\n return template.replace(/\\{(\\w+)\\}/g, (_, name: string) =>\n name in vars ? String(vars[name]) : `{${name}}`,\n );\n}\n\n/** De-duped resolution order: active locale first, then configured fallbacks. */\nfunction resolutionOrder(locale: Locale, fallbackChain: Locale[]): Locale[] {\n return [locale, ...fallbackChain.filter((l) => l !== locale)];\n}\n\n/**\n * Build a `t()` (with `.surface`) bound to `locale`. Base lookup: active\n * locale's namespace dict → each fallback locale's same dict → raw key.\n * Surface lookup: per locale, the overlay wins over base, then the same\n * locale-fallback walk (mirrors react-i18next #913 compose-then-fallback).\n */\nexport function createT(\n data: BaseData,\n overlays: OverlayData,\n locale: Locale,\n fallbackChain: Locale[],\n defaultNamespace: Namespace,\n): TFn {\n const order = resolutionOrder(locale, fallbackChain);\n\n const t = function t(key: string, vars?: Vars): string {\n const [ns, rest] = splitKey(key, defaultNamespace);\n for (const l of order) {\n const hit = resolveLeaf(data[l]?.[ns], rest);\n if (hit !== undefined) return interpolate(hit, vars);\n }\n return key;\n } as TFn;\n\n t.surface = function surface(key: string, surface: Surface, vars?: Vars): string {\n const [ns, rest] = splitKey(key, defaultNamespace);\n for (const l of order) {\n // Overlay wins over base WITHIN a locale; then fall to the next locale.\n const overlayHit = resolveLeaf(overlays[l]?.[ns]?.[surface], rest);\n if (overlayHit !== undefined) return interpolate(overlayHit, vars);\n const baseHit = resolveLeaf(data[l]?.[ns], rest);\n if (baseHit !== undefined) return interpolate(baseHit, vars);\n }\n return key;\n };\n\n return t;\n}\n\n/**\n * Fetch every configured bundle (and surface overlay) at build time and return\n * a resolved {@link SonentaI18n} handle. Call once (top-level `await`) and\n * reuse its `getT(locale)` / `getSurfaces(locale, key)` across pages.\n */\nexport async function createSonentaI18n(\n options: SonentaI18nOptions,\n): Promise<SonentaI18n> {\n if (!options.project) {\n throw new Error(\"[@sonenta/astro] `project` (UUID) is required.\");\n }\n if (!options.locales?.length) {\n throw new Error(\"[@sonenta/astro] `locales` must be a non-empty array.\");\n }\n const locales = options.locales;\n const defaultLocale = options.defaultLocale ?? locales[0]!;\n const namespaces =\n options.namespaces?.length ? options.namespaces : [DEFAULT_NAMESPACE];\n const defaultNamespace = namespaces[0]!;\n const fallbackChain = normalizeFallback(options.fallbackLng, defaultLocale);\n const surfaces = options.surfaces ?? [];\n const surfaceBreakpoints: SurfaceBreakpoints =\n options.surfaceBreakpoints ?? DEFAULT_SURFACE_BREAKPOINTS;\n\n const data = await fetchAll(options, locales, namespaces);\n const overlays: OverlayData = surfaces.length\n ? await fetchAllOverlays(options, locales, namespaces, surfaces)\n : ({} as OverlayData);\n\n return {\n locales,\n defaultLocale,\n surfaces,\n surfaceBreakpoints,\n getT(locale: Locale): TFn {\n return createT(data, overlays, locale, fallbackChain, defaultNamespace);\n },\n getSurfaces(locale: Locale, key: string, vars?: Vars): Record<Surface, string> {\n const t = createT(data, overlays, locale, fallbackChain, defaultNamespace);\n const out = {} as Record<Surface, string>;\n for (const s of surfaces) out[s] = t.surface(key, s, vars);\n return out;\n },\n getCatalog(locale: Locale, namespace?: Namespace): Bundle | null {\n return data[locale]?.[namespace ?? defaultNamespace] ?? null;\n },\n };\n}\n"],"mappings":"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","names":["t"]}
@@ -13,8 +13,34 @@ type Locale = string;
13
13
  type Namespace = string;
14
14
  /** Interpolation variables for a `t()` call: `t("greeting", { name: "Ada" })`. */
15
15
  type Vars = Record<string, string | number>;
16
- /** A resolved, per-locale translation function. */
17
- type TFn = (key: string, vars?: Vars) => string;
16
+ /**
17
+ * A device surface the second resolution dimension layered on top of the
18
+ * locale chain (#911). The base bundle applies to every surface; a sparse
19
+ * `{ns}.{surface}.json` overlay overrides individual keys for that surface.
20
+ */
21
+ type Surface = "desktop" | "mobile" | "tablet";
22
+ /**
23
+ * Min-width (px) thresholds mapping a viewport to a surface (mobile-first
24
+ * ladder, mirrors `@sonenta/react-i18next` #913): width `< mobile` → mobile;
25
+ * `< tablet` → tablet; otherwise desktop. Drives the `<SurfaceText>` media
26
+ * queries (SSG renders every surface; CSS reveals the right one).
27
+ */
28
+ interface SurfaceBreakpoints {
29
+ /** Upper bound (exclusive) of the `mobile` surface, px. Default 640. */
30
+ mobile: number;
31
+ /** Upper bound (exclusive) of the `tablet` surface, px. Default 1024. */
32
+ tablet: number;
33
+ }
34
+ /**
35
+ * A resolved, per-locale translation function. Calling `t(key, vars?)` returns
36
+ * the surface-agnostic base value. `t.surface(key, surface, vars?)` returns the
37
+ * value for a specific device surface (overlay ?? base).
38
+ */
39
+ interface TFn {
40
+ (key: string, vars?: Vars): string;
41
+ /** Resolve `key` for a specific device `surface` (overlay wins over base). */
42
+ surface(key: string, surface: Surface, vars?: Vars): string;
43
+ }
18
44
  /**
19
45
  * Configuration for the Sonenta Astro integration / runtime.
20
46
  *
@@ -69,6 +95,20 @@ interface SonentaI18nOptions {
69
95
  * build-time path.
70
96
  */
71
97
  apiBase?: string;
98
+ /**
99
+ * Device surfaces to fetch + expose (#911). When set (e.g.
100
+ * `["desktop", "mobile"]`), the build also pulls each sparse overlay
101
+ * `{ns}.{surface}.json` and `getSurfaces` / `t.surface` / `<SurfaceText>`
102
+ * resolve per-surface values. Omit to disable surfaces entirely (no overlay
103
+ * fetch, identical to v0.1 behaviour).
104
+ */
105
+ surfaces?: Surface[];
106
+ /**
107
+ * Breakpoint ladder for `<SurfaceText>`'s responsive CSS. Default
108
+ * `{ mobile: 640, tablet: 1024 }`. Resolution itself renders every surface;
109
+ * these only decide which one CSS reveals at each viewport width.
110
+ */
111
+ surfaceBreakpoints?: SurfaceBreakpoints;
72
112
  /**
73
113
  * Injectable `fetch` implementation (testing / proxies / custom agents).
74
114
  * Defaults to the global `fetch`.
@@ -86,12 +126,32 @@ interface SonentaI18n {
86
126
  readonly locales: Locale[];
87
127
  /** The resolved source/default locale. */
88
128
  readonly defaultLocale: Locale;
129
+ /** The configured device surfaces (empty when surfaces are disabled). */
130
+ readonly surfaces: Surface[];
131
+ /** The resolved surface breakpoint ladder. */
132
+ readonly surfaceBreakpoints: SurfaceBreakpoints;
133
+ /**
134
+ * Resolve `key` for every configured surface at `locale`, e.g.
135
+ * `{ desktop: "Commencer gratuitement", mobile: "Commencer" }`. A surface
136
+ * without an overlay for the key resolves to the base value. Empty when
137
+ * surfaces are disabled.
138
+ */
139
+ getSurfaces(locale: Locale, key: string, vars?: Vars): Record<Surface, string>;
89
140
  /**
90
141
  * Raw fetched dictionary for `locale` / `namespace` (default namespace when
91
142
  * omitted), or `null` when that bundle was absent (404 / plan-limit).
92
143
  */
93
144
  getCatalog(locale: Locale, namespace?: Namespace): Record<string, unknown> | null;
94
145
  }
146
+ /** Default surface breakpoint ladder (mirrors `@sonenta/react-i18next` #913). */
147
+ declare const DEFAULT_SURFACE_BREAKPOINTS: SurfaceBreakpoints;
148
+ /**
149
+ * Map a viewport width (px) to a {@link Surface} using `breakpoints`. Pure —
150
+ * shared with the `<SurfaceText>` CSS generation so the runtime mapping and
151
+ * the media queries agree. `< mobile` → mobile, `< tablet` → tablet, else
152
+ * desktop.
153
+ */
154
+ declare function surfaceForWidth(width: number, breakpoints?: SurfaceBreakpoints): Surface;
95
155
 
96
156
  /**
97
157
  * Build-time CDN loader for Sonenta translation bundles.
@@ -115,6 +175,12 @@ declare function resolveCdnBase(opts: Pick<SonentaI18nOptions, "cdnBase">): stri
115
175
  * `{cdnBase}/p/{project}/{version}/latest/{locale}/{namespace}.json`
116
176
  */
117
177
  declare function buildBundleUrl(opts: Pick<SonentaI18nOptions, "project" | "version" | "cdnBase">, locale: Locale, namespace: Namespace): string;
178
+ /**
179
+ * Build the sparse surface-overlay URL for one `(locale, namespace, surface)`,
180
+ * mirroring `@sonenta/react-i18next` #913:
181
+ * `{cdnBase}/p/{project}/{version}/latest/{locale}/{namespace}.{surface}.json`
182
+ */
183
+ declare function buildOverlayUrl(opts: Pick<SonentaI18nOptions, "project" | "version" | "cdnBase">, locale: Locale, namespace: Namespace, surface: Surface): string;
118
184
  /**
119
185
  * Fetch a single bundle. Resolves to `null` on 404 (locale absent from the
120
186
  * project — e.g. a plan-limit-blocked language) so callers fall back to the
@@ -132,29 +198,51 @@ declare function fetchNamespace(opts: Pick<SonentaI18nOptions, "project" | "vers
132
198
  * locale then namespace: `data[locale][namespace] = Bundle | null`.
133
199
  */
134
200
  declare function fetchAll(opts: Pick<SonentaI18nOptions, "project" | "version" | "cdnBase" | "fetchImpl">, locales: Locale[], namespaces: Namespace[]): Promise<Record<Locale, Record<Namespace, Bundle | null>>>;
201
+ /**
202
+ * Fetch a single sparse surface overlay. Unlike {@link fetchBundle}, an
203
+ * overlay is OPTIONAL: any miss (404), non-OK status, or transport error
204
+ * resolves to `null` (base-only) and NEVER throws — a missing/erroring overlay
205
+ * must never break the build or downgrade the base render. Mirrors
206
+ * `@sonenta/react-i18next` #913's `{}`-on-miss behaviour.
207
+ */
208
+ declare function fetchOverlay(opts: Pick<SonentaI18nOptions, "project" | "version" | "cdnBase" | "fetchImpl">, locale: Locale, namespace: Namespace, surface: Surface): Promise<Bundle | null>;
209
+ /**
210
+ * Fetch every `(locale, namespace, surface)` overlay. Returns a nested map:
211
+ * `overlays[locale][namespace][surface] = Bundle | null`. Empty when
212
+ * `surfaces` is empty.
213
+ */
214
+ declare function fetchAllOverlays(opts: Pick<SonentaI18nOptions, "project" | "version" | "cdnBase" | "fetchImpl">, locales: Locale[], namespaces: Namespace[], surfaces: Surface[]): Promise<Record<Locale, Record<Namespace, Record<Surface, Bundle | null>>>>;
135
215
 
136
216
  /**
137
217
  * Build-time translation resolver.
138
218
  *
139
- * v0.1 SCOPE (frozen — see CONTRACT.md): string resolution + `{var}`
140
- * interpolation + source-language fallback ONLY. Deliberately NO CLDR
141
- * plurals, NO surfaces, NO a11y accessors, NO BCP-47 variant→base
142
- * inheritance those carry real i18n SEMANTICS owned by `@sonenta/i18n-core`
143
- * and arrive additively in 0.2.0. Keeping them out of v0.1 means there is no
144
- * naive semantics to break when the core swaps in underneath this same API.
219
+ * v0.2 SCOPE: string resolution + `{var}` interpolation + source-language
220
+ * fallback (v0.1) PLUS device-surface variants (#911) a sparse
221
+ * `{ns}.{surface}.json` overlay over the base bundle, overlay-wins-per-key,
222
+ * resolved per locale. Still NO CLDR plurals and NO a11y surfaces: a `$value`
223
+ * that is a plural dict (object) is treated as "no string here" (deferred to
224
+ * the `@sonenta/i18n-core`-backed 0.3.0). The `$asset` half of an envelope is
225
+ * ignored for text resolution. This keeps the surface semantics a faithful,
226
+ * regression-locked subset of react-i18next #913 so the later core swap is
227
+ * behaviour-preserving.
145
228
  */
146
229
 
230
+ /** Nested base data: `data[locale][namespace] = Bundle | null`. */
231
+ type BaseData = Record<Locale, Record<Namespace, Bundle | null>>;
232
+ /** Nested overlays: `overlays[locale][namespace][surface] = Bundle | null`. */
233
+ type OverlayData = Record<Locale, Record<Namespace, Record<Surface, Bundle | null>>>;
147
234
  /**
148
- * Build a `t()` bound to `locale`, given the fetched data and resolution
149
- * order. Lookup: active locale's namespace dict → each fallback locale's same
150
- * namespace dict the raw key (i18next-parity missing-key behaviour).
235
+ * Build a `t()` (with `.surface`) bound to `locale`. Base lookup: active
236
+ * locale's namespace dict → each fallback locale's same dict → raw key.
237
+ * Surface lookup: per locale, the overlay wins over base, then the same
238
+ * locale-fallback walk (mirrors react-i18next #913 compose-then-fallback).
151
239
  */
152
- declare function createT(data: Record<Locale, Record<Namespace, Bundle | null>>, locale: Locale, fallbackChain: Locale[], defaultNamespace: Namespace): TFn;
240
+ declare function createT(data: BaseData, overlays: OverlayData, locale: Locale, fallbackChain: Locale[], defaultNamespace: Namespace): TFn;
153
241
  /**
154
- * Fetch every configured bundle at build time and return a resolved
155
- * {@link SonentaI18n} handle. Call once (top-level `await`) and reuse its
156
- * `getT(locale)` across pages.
242
+ * Fetch every configured bundle (and surface overlay) at build time and return
243
+ * a resolved {@link SonentaI18n} handle. Call once (top-level `await`) and
244
+ * reuse its `getT(locale)` / `getSurfaces(locale, key)` across pages.
157
245
  */
158
246
  declare function createSonentaI18n(options: SonentaI18nOptions): Promise<SonentaI18n>;
159
247
 
160
- export { type Bundle, type Locale, type Namespace, type SonentaI18n, type SonentaI18nOptions, type TFn, type Vars, buildBundleUrl, createSonentaI18n, createT, fetchAll, fetchBundle, fetchNamespace, resolveCdnBase };
248
+ export { type Bundle, DEFAULT_SURFACE_BREAKPOINTS, type Locale, type Namespace, type SonentaI18n, type SonentaI18nOptions, type Surface, type SurfaceBreakpoints, type TFn, type Vars, buildBundleUrl, buildOverlayUrl, createSonentaI18n, createT, fetchAll, fetchAllOverlays, fetchBundle, fetchNamespace, fetchOverlay, resolveCdnBase, surfaceForWidth };
package/dist/runtime.d.ts CHANGED
@@ -13,8 +13,34 @@ type Locale = string;
13
13
  type Namespace = string;
14
14
  /** Interpolation variables for a `t()` call: `t("greeting", { name: "Ada" })`. */
15
15
  type Vars = Record<string, string | number>;
16
- /** A resolved, per-locale translation function. */
17
- type TFn = (key: string, vars?: Vars) => string;
16
+ /**
17
+ * A device surface the second resolution dimension layered on top of the
18
+ * locale chain (#911). The base bundle applies to every surface; a sparse
19
+ * `{ns}.{surface}.json` overlay overrides individual keys for that surface.
20
+ */
21
+ type Surface = "desktop" | "mobile" | "tablet";
22
+ /**
23
+ * Min-width (px) thresholds mapping a viewport to a surface (mobile-first
24
+ * ladder, mirrors `@sonenta/react-i18next` #913): width `< mobile` → mobile;
25
+ * `< tablet` → tablet; otherwise desktop. Drives the `<SurfaceText>` media
26
+ * queries (SSG renders every surface; CSS reveals the right one).
27
+ */
28
+ interface SurfaceBreakpoints {
29
+ /** Upper bound (exclusive) of the `mobile` surface, px. Default 640. */
30
+ mobile: number;
31
+ /** Upper bound (exclusive) of the `tablet` surface, px. Default 1024. */
32
+ tablet: number;
33
+ }
34
+ /**
35
+ * A resolved, per-locale translation function. Calling `t(key, vars?)` returns
36
+ * the surface-agnostic base value. `t.surface(key, surface, vars?)` returns the
37
+ * value for a specific device surface (overlay ?? base).
38
+ */
39
+ interface TFn {
40
+ (key: string, vars?: Vars): string;
41
+ /** Resolve `key` for a specific device `surface` (overlay wins over base). */
42
+ surface(key: string, surface: Surface, vars?: Vars): string;
43
+ }
18
44
  /**
19
45
  * Configuration for the Sonenta Astro integration / runtime.
20
46
  *
@@ -69,6 +95,20 @@ interface SonentaI18nOptions {
69
95
  * build-time path.
70
96
  */
71
97
  apiBase?: string;
98
+ /**
99
+ * Device surfaces to fetch + expose (#911). When set (e.g.
100
+ * `["desktop", "mobile"]`), the build also pulls each sparse overlay
101
+ * `{ns}.{surface}.json` and `getSurfaces` / `t.surface` / `<SurfaceText>`
102
+ * resolve per-surface values. Omit to disable surfaces entirely (no overlay
103
+ * fetch, identical to v0.1 behaviour).
104
+ */
105
+ surfaces?: Surface[];
106
+ /**
107
+ * Breakpoint ladder for `<SurfaceText>`'s responsive CSS. Default
108
+ * `{ mobile: 640, tablet: 1024 }`. Resolution itself renders every surface;
109
+ * these only decide which one CSS reveals at each viewport width.
110
+ */
111
+ surfaceBreakpoints?: SurfaceBreakpoints;
72
112
  /**
73
113
  * Injectable `fetch` implementation (testing / proxies / custom agents).
74
114
  * Defaults to the global `fetch`.
@@ -86,12 +126,32 @@ interface SonentaI18n {
86
126
  readonly locales: Locale[];
87
127
  /** The resolved source/default locale. */
88
128
  readonly defaultLocale: Locale;
129
+ /** The configured device surfaces (empty when surfaces are disabled). */
130
+ readonly surfaces: Surface[];
131
+ /** The resolved surface breakpoint ladder. */
132
+ readonly surfaceBreakpoints: SurfaceBreakpoints;
133
+ /**
134
+ * Resolve `key` for every configured surface at `locale`, e.g.
135
+ * `{ desktop: "Commencer gratuitement", mobile: "Commencer" }`. A surface
136
+ * without an overlay for the key resolves to the base value. Empty when
137
+ * surfaces are disabled.
138
+ */
139
+ getSurfaces(locale: Locale, key: string, vars?: Vars): Record<Surface, string>;
89
140
  /**
90
141
  * Raw fetched dictionary for `locale` / `namespace` (default namespace when
91
142
  * omitted), or `null` when that bundle was absent (404 / plan-limit).
92
143
  */
93
144
  getCatalog(locale: Locale, namespace?: Namespace): Record<string, unknown> | null;
94
145
  }
146
+ /** Default surface breakpoint ladder (mirrors `@sonenta/react-i18next` #913). */
147
+ declare const DEFAULT_SURFACE_BREAKPOINTS: SurfaceBreakpoints;
148
+ /**
149
+ * Map a viewport width (px) to a {@link Surface} using `breakpoints`. Pure —
150
+ * shared with the `<SurfaceText>` CSS generation so the runtime mapping and
151
+ * the media queries agree. `< mobile` → mobile, `< tablet` → tablet, else
152
+ * desktop.
153
+ */
154
+ declare function surfaceForWidth(width: number, breakpoints?: SurfaceBreakpoints): Surface;
95
155
 
96
156
  /**
97
157
  * Build-time CDN loader for Sonenta translation bundles.
@@ -115,6 +175,12 @@ declare function resolveCdnBase(opts: Pick<SonentaI18nOptions, "cdnBase">): stri
115
175
  * `{cdnBase}/p/{project}/{version}/latest/{locale}/{namespace}.json`
116
176
  */
117
177
  declare function buildBundleUrl(opts: Pick<SonentaI18nOptions, "project" | "version" | "cdnBase">, locale: Locale, namespace: Namespace): string;
178
+ /**
179
+ * Build the sparse surface-overlay URL for one `(locale, namespace, surface)`,
180
+ * mirroring `@sonenta/react-i18next` #913:
181
+ * `{cdnBase}/p/{project}/{version}/latest/{locale}/{namespace}.{surface}.json`
182
+ */
183
+ declare function buildOverlayUrl(opts: Pick<SonentaI18nOptions, "project" | "version" | "cdnBase">, locale: Locale, namespace: Namespace, surface: Surface): string;
118
184
  /**
119
185
  * Fetch a single bundle. Resolves to `null` on 404 (locale absent from the
120
186
  * project — e.g. a plan-limit-blocked language) so callers fall back to the
@@ -132,29 +198,51 @@ declare function fetchNamespace(opts: Pick<SonentaI18nOptions, "project" | "vers
132
198
  * locale then namespace: `data[locale][namespace] = Bundle | null`.
133
199
  */
134
200
  declare function fetchAll(opts: Pick<SonentaI18nOptions, "project" | "version" | "cdnBase" | "fetchImpl">, locales: Locale[], namespaces: Namespace[]): Promise<Record<Locale, Record<Namespace, Bundle | null>>>;
201
+ /**
202
+ * Fetch a single sparse surface overlay. Unlike {@link fetchBundle}, an
203
+ * overlay is OPTIONAL: any miss (404), non-OK status, or transport error
204
+ * resolves to `null` (base-only) and NEVER throws — a missing/erroring overlay
205
+ * must never break the build or downgrade the base render. Mirrors
206
+ * `@sonenta/react-i18next` #913's `{}`-on-miss behaviour.
207
+ */
208
+ declare function fetchOverlay(opts: Pick<SonentaI18nOptions, "project" | "version" | "cdnBase" | "fetchImpl">, locale: Locale, namespace: Namespace, surface: Surface): Promise<Bundle | null>;
209
+ /**
210
+ * Fetch every `(locale, namespace, surface)` overlay. Returns a nested map:
211
+ * `overlays[locale][namespace][surface] = Bundle | null`. Empty when
212
+ * `surfaces` is empty.
213
+ */
214
+ declare function fetchAllOverlays(opts: Pick<SonentaI18nOptions, "project" | "version" | "cdnBase" | "fetchImpl">, locales: Locale[], namespaces: Namespace[], surfaces: Surface[]): Promise<Record<Locale, Record<Namespace, Record<Surface, Bundle | null>>>>;
135
215
 
136
216
  /**
137
217
  * Build-time translation resolver.
138
218
  *
139
- * v0.1 SCOPE (frozen — see CONTRACT.md): string resolution + `{var}`
140
- * interpolation + source-language fallback ONLY. Deliberately NO CLDR
141
- * plurals, NO surfaces, NO a11y accessors, NO BCP-47 variant→base
142
- * inheritance those carry real i18n SEMANTICS owned by `@sonenta/i18n-core`
143
- * and arrive additively in 0.2.0. Keeping them out of v0.1 means there is no
144
- * naive semantics to break when the core swaps in underneath this same API.
219
+ * v0.2 SCOPE: string resolution + `{var}` interpolation + source-language
220
+ * fallback (v0.1) PLUS device-surface variants (#911) a sparse
221
+ * `{ns}.{surface}.json` overlay over the base bundle, overlay-wins-per-key,
222
+ * resolved per locale. Still NO CLDR plurals and NO a11y surfaces: a `$value`
223
+ * that is a plural dict (object) is treated as "no string here" (deferred to
224
+ * the `@sonenta/i18n-core`-backed 0.3.0). The `$asset` half of an envelope is
225
+ * ignored for text resolution. This keeps the surface semantics a faithful,
226
+ * regression-locked subset of react-i18next #913 so the later core swap is
227
+ * behaviour-preserving.
145
228
  */
146
229
 
230
+ /** Nested base data: `data[locale][namespace] = Bundle | null`. */
231
+ type BaseData = Record<Locale, Record<Namespace, Bundle | null>>;
232
+ /** Nested overlays: `overlays[locale][namespace][surface] = Bundle | null`. */
233
+ type OverlayData = Record<Locale, Record<Namespace, Record<Surface, Bundle | null>>>;
147
234
  /**
148
- * Build a `t()` bound to `locale`, given the fetched data and resolution
149
- * order. Lookup: active locale's namespace dict → each fallback locale's same
150
- * namespace dict the raw key (i18next-parity missing-key behaviour).
235
+ * Build a `t()` (with `.surface`) bound to `locale`. Base lookup: active
236
+ * locale's namespace dict → each fallback locale's same dict → raw key.
237
+ * Surface lookup: per locale, the overlay wins over base, then the same
238
+ * locale-fallback walk (mirrors react-i18next #913 compose-then-fallback).
151
239
  */
152
- declare function createT(data: Record<Locale, Record<Namespace, Bundle | null>>, locale: Locale, fallbackChain: Locale[], defaultNamespace: Namespace): TFn;
240
+ declare function createT(data: BaseData, overlays: OverlayData, locale: Locale, fallbackChain: Locale[], defaultNamespace: Namespace): TFn;
153
241
  /**
154
- * Fetch every configured bundle at build time and return a resolved
155
- * {@link SonentaI18n} handle. Call once (top-level `await`) and reuse its
156
- * `getT(locale)` across pages.
242
+ * Fetch every configured bundle (and surface overlay) at build time and return
243
+ * a resolved {@link SonentaI18n} handle. Call once (top-level `await`) and
244
+ * reuse its `getT(locale)` / `getSurfaces(locale, key)` across pages.
157
245
  */
158
246
  declare function createSonentaI18n(options: SonentaI18nOptions): Promise<SonentaI18n>;
159
247
 
160
- export { type Bundle, type Locale, type Namespace, type SonentaI18n, type SonentaI18nOptions, type TFn, type Vars, buildBundleUrl, createSonentaI18n, createT, fetchAll, fetchBundle, fetchNamespace, resolveCdnBase };
248
+ export { type Bundle, DEFAULT_SURFACE_BREAKPOINTS, type Locale, type Namespace, type SonentaI18n, type SonentaI18nOptions, type Surface, type SurfaceBreakpoints, type TFn, type Vars, buildBundleUrl, buildOverlayUrl, createSonentaI18n, createT, fetchAll, fetchAllOverlays, fetchBundle, fetchNamespace, fetchOverlay, resolveCdnBase, surfaceForWidth };