From 926c6954dfae7ff4925226a4cb8b4d46218f8ca1 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 28 May 2026 00:38:20 +0200 Subject: [PATCH 01/25] feat(integration-framework): add strings subpath with I18nToken type --- packages/integration-framework/package.json | 5 +++ .../strings/__tests__/types.test.ts | 20 +++++++++ .../integration-framework/strings/index.ts | 15 +++++++ .../strings/package.json | 8 ++++ .../strings/src/types.ts | 42 +++++++++++++++++++ packages/integration-framework/tsconfig.json | 2 +- pnpm-lock.yaml | 3 ++ 7 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 packages/integration-framework/strings/__tests__/types.test.ts create mode 100644 packages/integration-framework/strings/index.ts create mode 100644 packages/integration-framework/strings/package.json create mode 100644 packages/integration-framework/strings/src/types.ts diff --git a/packages/integration-framework/package.json b/packages/integration-framework/package.json index efe6f16e..68c07056 100644 --- a/packages/integration-framework/package.json +++ b/packages/integration-framework/package.json @@ -18,6 +18,10 @@ "./installer": { "types": "./src/installer.ts", "default": "./src/installer.ts" + }, + "./strings": { + "types": "./strings/index.ts", + "default": "./strings/index.ts" } }, "scripts": { @@ -28,6 +32,7 @@ "@openmapx/core": "workspace:^", "@openmapx/mobility-core": "workspace:^", "@openmapx/poi-source-registry": "workspace:^", + "intl-messageformat": "^11.2.3", "js-yaml": "^4.1.1", "zod": "^4.4.1" }, diff --git a/packages/integration-framework/strings/__tests__/types.test.ts b/packages/integration-framework/strings/__tests__/types.test.ts new file mode 100644 index 00000000..4fe1f8cd --- /dev/null +++ b/packages/integration-framework/strings/__tests__/types.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { type I18nToken, isI18nToken } from "../index.js"; + +describe("I18nToken", () => { + it("isI18nToken recognises the canonical shape", () => { + const a: I18nToken = { $t: "shared.row.source" }; + const b: I18nToken = { $t: "row.freeSpaces", values: { free: 3, capacity: 10 } }; + expect(isI18nToken(a)).toBe(true); + expect(isI18nToken(b)).toBe(true); + }); + + it("isI18nToken rejects strings, numbers, null, and unrelated objects", () => { + expect(isI18nToken("Free Spaces")).toBe(false); + expect(isI18nToken(42)).toBe(false); + expect(isI18nToken(null)).toBe(false); + expect(isI18nToken(undefined)).toBe(false); + expect(isI18nToken({ key: "row.foo" })).toBe(false); + expect(isI18nToken({ $t: 42 })).toBe(false); + }); +}); diff --git a/packages/integration-framework/strings/index.ts b/packages/integration-framework/strings/index.ts new file mode 100644 index 00000000..c6333190 --- /dev/null +++ b/packages/integration-framework/strings/index.ts @@ -0,0 +1,15 @@ +export type { I18nToken, LocaleCatalog, LocaleStrings, Translatable } from "./src/types.js"; + +/** + * Runtime check for whether a value is an `I18nToken`. Used by the client + * resolver to decide between translating a token and rendering a passthrough + * `string | number` value. + */ +export function isI18nToken(value: unknown): value is import("./src/types.js").I18nToken { + return ( + typeof value === "object" && + value !== null && + "$t" in value && + typeof (value as { $t: unknown }).$t === "string" + ); +} diff --git a/packages/integration-framework/strings/package.json b/packages/integration-framework/strings/package.json new file mode 100644 index 00000000..349923b5 --- /dev/null +++ b/packages/integration-framework/strings/package.json @@ -0,0 +1,8 @@ +{ + "name": "@openmapx/integration-framework-strings-internal", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./index.ts", + "types": "./index.ts" +} diff --git a/packages/integration-framework/strings/src/types.ts b/packages/integration-framework/strings/src/types.ts new file mode 100644 index 00000000..6af09daf --- /dev/null +++ b/packages/integration-framework/strings/src/types.ts @@ -0,0 +1,42 @@ +/** + * A locale-agnostic translation token emitted by data-source providers across + * the API boundary. Resolved client-side via `resolveToken` against the + * emitting integration's strings catalog (with framework shared strings as + * fallback for `$t` values starting with "shared."). + */ +export interface I18nToken { + /** + * Translation key. Dot-separated path through the JSON catalog + * (e.g. "row.freeSpaces", "shared.value.open"). Keys starting with + * "shared." resolve against the framework catalog; all other keys + * resolve against the emitting integration's catalog first, framework + * catalog as fallback. + */ + $t: string; + /** + * ICU MessageFormat placeholder values for the resolved template + * (e.g. {free: 3, capacity: 10} for "{free}/{capacity} free"). + */ + values?: Record; +} + +/** + * A user-facing field that may either be a translation token or pure + * pass-through data (e.g. a capacity number, a formatted price). Used for + * value cells in data-source tables, where the right column legitimately + * mixes translated text and raw numbers/strings. + */ +export type Translatable = I18nToken | string | number; + +/** + * Strings catalog as loaded from a per-integration `strings/.json` + * file. Nested objects with string leaves; `$t` keys traverse the path. + */ +export type LocaleCatalog = Record; + +/** + * Locale-keyed strings bundle. Both per-integration strings + * (LoadedIntegrationMeta.strings) and the framework shared catalog use this + * shape: `{ en: LocaleCatalog, de: LocaleCatalog, ... }`. + */ +export type LocaleStrings = Record; diff --git a/packages/integration-framework/tsconfig.json b/packages/integration-framework/tsconfig.json index fec6e79b..20411e23 100644 --- a/packages/integration-framework/tsconfig.json +++ b/packages/integration-framework/tsconfig.json @@ -9,5 +9,5 @@ "jsx": "react-jsx", "noEmit": true }, - "include": ["src", "../../integrations/*/types.ts"] + "include": ["src", "strings", "../../integrations/*/types.ts"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 846c84e2..eac2a3c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1568,6 +1568,9 @@ importers: '@openmapx/poi-source-registry': specifier: workspace:^ version: link:../poi-source-registry + intl-messageformat: + specifier: ^11.2.3 + version: 11.2.3 js-yaml: specifier: ^4.1.1 version: 4.1.1 From 72688009272b7e66245513cfe2f24bc09a9d6a64 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 28 May 2026 00:44:36 +0200 Subject: [PATCH 02/25] feat(integration-framework): add I18nToken resolver --- .../strings/__tests__/resolver.test.ts | 95 +++++++++++++++++++ .../integration-framework/strings/index.ts | 2 + .../strings/src/resolver.ts | 70 ++++++++++++++ 3 files changed, 167 insertions(+) create mode 100644 packages/integration-framework/strings/__tests__/resolver.test.ts create mode 100644 packages/integration-framework/strings/src/resolver.ts diff --git a/packages/integration-framework/strings/__tests__/resolver.test.ts b/packages/integration-framework/strings/__tests__/resolver.test.ts new file mode 100644 index 00000000..6bc98ffa --- /dev/null +++ b/packages/integration-framework/strings/__tests__/resolver.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { resolveToken } from "../src/resolver.js"; + +const shared = { + en: { + shared: { + row: { source: "Source", lastUpdated: "Last Updated" }, + value: { open: "Open" }, + }, + }, + de: { + shared: { + row: { source: "Quelle", lastUpdated: "Zuletzt aktualisiert" }, + value: { open: "Geöffnet" }, + }, + }, +}; + +const parking = { + en: { + row: { freeSpaces: "Free Spaces" }, + summary: { spacesOf: "{free}/{capacity} free" }, + }, + de: { + row: { freeSpaces: "Freie Plätze" }, + summary: { spacesOf: "{free}/{capacity} frei" }, + }, +}; + +describe("resolveToken", () => { + it("resolves a shared.* token against the framework catalog", () => { + const out = resolveToken( + { $t: "shared.row.source" }, + { locale: "de", fallbackLocale: "en", shared, integration: undefined }, + ); + expect(out).toBe("Quelle"); + }); + + it("resolves an integration-scoped token against the integration catalog", () => { + const out = resolveToken( + { $t: "row.freeSpaces" }, + { locale: "de", fallbackLocale: "en", shared, integration: parking }, + ); + expect(out).toBe("Freie Plätze"); + }); + + it("interpolates ICU values", () => { + const out = resolveToken( + { $t: "summary.spacesOf", values: { free: 12, capacity: 50 } }, + { locale: "de", fallbackLocale: "en", shared, integration: parking }, + ); + expect(out).toBe("12/50 frei"); + }); + + it("falls back to fallbackLocale when the active locale is missing the key", () => { + const out = resolveToken( + { $t: "row.freeSpaces" }, + { locale: "fr", fallbackLocale: "en", shared, integration: parking }, + ); + expect(out).toBe("Free Spaces"); + }); + + it("falls back from integration catalog to framework catalog for non-shared keys", () => { + // Integration has no "row.lastUpdated" entry, but framework does. + const out = resolveToken( + { $t: "row.lastUpdated" }, + { locale: "de", fallbackLocale: "en", shared, integration: parking }, + ); + expect(out).toBe("Zuletzt aktualisiert"); + }); + + it("returns the key verbatim when no catalog has the key (visible-bug fallback)", () => { + const out = resolveToken( + { $t: "row.completelyUnknown" }, + { locale: "de", fallbackLocale: "en", shared, integration: parking }, + ); + expect(out).toBe("row.completelyUnknown"); + }); + + it("treats shared.* keys as framework-only — does NOT look in integration catalog", () => { + const integrationWithSharedKey = { + en: { shared: { row: { source: "SHOULD NOT WIN" } } }, + }; + const out = resolveToken( + { $t: "shared.row.source" }, + { + locale: "en", + fallbackLocale: "en", + shared, + integration: integrationWithSharedKey, + }, + ); + expect(out).toBe("Source"); + }); +}); diff --git a/packages/integration-framework/strings/index.ts b/packages/integration-framework/strings/index.ts index c6333190..3f9235f5 100644 --- a/packages/integration-framework/strings/index.ts +++ b/packages/integration-framework/strings/index.ts @@ -13,3 +13,5 @@ export function isI18nToken(value: unknown): value is import("./src/types.js").I typeof (value as { $t: unknown }).$t === "string" ); } + +export { type ResolveOptions, resolveToken } from "./src/resolver.js"; diff --git a/packages/integration-framework/strings/src/resolver.ts b/packages/integration-framework/strings/src/resolver.ts new file mode 100644 index 00000000..2df896fc --- /dev/null +++ b/packages/integration-framework/strings/src/resolver.ts @@ -0,0 +1,70 @@ +import { IntlMessageFormat } from "intl-messageformat"; +import type { I18nToken, LocaleStrings } from "./types.js"; + +export interface ResolveOptions { + /** Active locale (e.g. "en", "de"). */ + locale: string; + /** Fallback locale used when `locale` is missing the key (typically "en"). */ + fallbackLocale: string; + /** Framework shared strings — always consulted for `shared.*` keys. */ + shared: LocaleStrings; + /** Per-integration strings — consulted first for non-`shared.*` keys. */ + integration: LocaleStrings | undefined; +} + +/** + * Resolve an `I18nToken` to a display string. + * + * Lookup order: + * - For keys starting with "shared.": framework catalog (active locale → + * fallback locale). + * - For other keys: integration catalog (active → fallback) → framework + * catalog (active → fallback). + * + * When no catalog yields a string, returns the bare key. This produces a + * visible bug (the user sees `row.freeSpaces` instead of "Free Spaces") which + * surfaces faster than a silent English leak. + */ +export function resolveToken(token: I18nToken, opts: ResolveOptions): string { + const key = token.$t; + const isShared = key.startsWith("shared."); + + // Build the lookup plan: each entry is the catalog to consult and the + // effective key path to walk inside it. The framework catalog is always + // consulted under the `shared` subtree, so non-`shared.*` keys consulted as + // a framework fallback are auto-prefixed with `shared.`. + const plan: { catalog: LocaleStrings; key: string }[] = []; + if (!isShared && opts.integration) plan.push({ catalog: opts.integration, key }); + plan.push({ catalog: opts.shared, key: isShared ? key : `shared.${key}` }); + + for (const { catalog, key: effectiveKey } of plan) { + const value = + lookupKey(catalog[opts.locale], effectiveKey) ?? + lookupKey(catalog[opts.fallbackLocale], effectiveKey); + if (value !== undefined) return format(value, opts.locale, token.values); + } + + return key; +} + +function lookupKey(catalog: unknown, key: string): string | undefined { + if (typeof catalog !== "object" || catalog === null) return undefined; + const parts = key.split("."); + let cur: unknown = catalog; + for (const part of parts) { + if (typeof cur !== "object" || cur === null) return undefined; + cur = (cur as Record)[part]; + } + return typeof cur === "string" ? cur : undefined; +} + +function format( + template: string, + locale: string, + values: Record | undefined, +): string { + if (!values) return template; + // ICU MessageFormat — same engine next-intl uses, so plural/select syntax + // works identically between server-emitted templates and client-side strings. + return new IntlMessageFormat(template, locale).format(values) as string; +} From 324258f38ec2c9d0259059a84029f2df2d80959b Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 28 May 2026 00:50:14 +0200 Subject: [PATCH 03/25] feat(integration-framework): add sharedT typed constants + en/de shared catalog --- .../strings/__tests__/token.test.ts | 47 ++++++++++++ .../integration-framework/strings/index.ts | 4 +- .../strings/locales/de.json | 41 ++++++++++ .../strings/locales/en.json | 41 ++++++++++ .../strings/src/token.ts | 74 +++++++++++++++++++ 5 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 packages/integration-framework/strings/__tests__/token.test.ts create mode 100644 packages/integration-framework/strings/locales/de.json create mode 100644 packages/integration-framework/strings/locales/en.json create mode 100644 packages/integration-framework/strings/src/token.ts diff --git a/packages/integration-framework/strings/__tests__/token.test.ts b/packages/integration-framework/strings/__tests__/token.test.ts new file mode 100644 index 00000000..bc380b03 --- /dev/null +++ b/packages/integration-framework/strings/__tests__/token.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { sharedStrings, sharedT, token } from "../index.js"; +import { resolveToken } from "../src/resolver.js"; + +describe("token() helper", () => { + it("builds a bare-key token", () => { + expect(token("row.freeSpaces")).toEqual({ $t: "row.freeSpaces" }); + }); + + it("builds a token with values", () => { + expect(token("summary.spacesOf", { free: 3, capacity: 10 })).toEqual({ + $t: "summary.spacesOf", + values: { free: 3, capacity: 10 }, + }); + }); +}); + +describe("sharedT typed constants", () => { + it("points at the framework catalog under shared.* keys", () => { + expect(sharedT.row.source).toEqual({ $t: "shared.row.source" }); + expect(sharedT.row.lastUpdated).toEqual({ $t: "shared.row.lastUpdated" }); + expect(sharedT.value.open).toEqual({ $t: "shared.value.open" }); + expect(sharedT.section.source).toEqual({ $t: "shared.section.source" }); + }); + + it("resolves against the shipped shared catalog (en)", () => { + expect( + resolveToken(sharedT.row.source, { + locale: "en", + fallbackLocale: "en", + shared: sharedStrings, + integration: undefined, + }), + ).toBe("Source"); + }); + + it("resolves against the shipped shared catalog (de)", () => { + expect( + resolveToken(sharedT.row.source, { + locale: "de", + fallbackLocale: "en", + shared: sharedStrings, + integration: undefined, + }), + ).toBe("Quelle"); + }); +}); diff --git a/packages/integration-framework/strings/index.ts b/packages/integration-framework/strings/index.ts index 3f9235f5..e512f6b5 100644 --- a/packages/integration-framework/strings/index.ts +++ b/packages/integration-framework/strings/index.ts @@ -1,3 +1,5 @@ +export { type ResolveOptions, resolveToken } from "./src/resolver.js"; +export { sharedStrings, sharedT, token } from "./src/token.js"; export type { I18nToken, LocaleCatalog, LocaleStrings, Translatable } from "./src/types.js"; /** @@ -13,5 +15,3 @@ export function isI18nToken(value: unknown): value is import("./src/types.js").I typeof (value as { $t: unknown }).$t === "string" ); } - -export { type ResolveOptions, resolveToken } from "./src/resolver.js"; diff --git a/packages/integration-framework/strings/locales/de.json b/packages/integration-framework/strings/locales/de.json new file mode 100644 index 00000000..8ae24377 --- /dev/null +++ b/packages/integration-framework/strings/locales/de.json @@ -0,0 +1,41 @@ +{ + "shared": { + "section": { + "source": "Quelle", + "notes": "Hinweise", + "pricing": "Preise", + "availability": "Verfügbarkeit", + "facility": "Einrichtung", + "access": "Zugang", + "dataQuality": "Datenqualität", + "payment": "Bezahlung", + "info": "Infos" + }, + "row": { + "source": "Quelle", + "sources": "Quellen", + "sourceId": "Quell-ID", + "sourceUrl": "Quell-URL", + "license": "Lizenz", + "lastUpdated": "Zuletzt aktualisiert", + "type": "Typ", + "capacity": "Kapazität", + "status": "Status", + "access": "Zugang", + "address": "Adresse", + "operator": "Betreiber" + }, + "value": { + "yes": "Ja", + "no": "Nein", + "open": "Geöffnet", + "closed": "Geschlossen", + "stale": "Veraltet", + "unknown": "Unbekannt", + "customers": "Kunden", + "private": "Privat", + "permit": "Mit Genehmigung", + "public": "Öffentlich" + } + } +} diff --git a/packages/integration-framework/strings/locales/en.json b/packages/integration-framework/strings/locales/en.json new file mode 100644 index 00000000..f8e13e67 --- /dev/null +++ b/packages/integration-framework/strings/locales/en.json @@ -0,0 +1,41 @@ +{ + "shared": { + "section": { + "source": "Source", + "notes": "Notes", + "pricing": "Pricing", + "availability": "Availability", + "facility": "Facility", + "access": "Access", + "dataQuality": "Data Quality", + "payment": "Payment", + "info": "Info" + }, + "row": { + "source": "Source", + "sources": "Sources", + "sourceId": "Source ID", + "sourceUrl": "Source URL", + "license": "License", + "lastUpdated": "Last Updated", + "type": "Type", + "capacity": "Capacity", + "status": "Status", + "access": "Access", + "address": "Address", + "operator": "Operator" + }, + "value": { + "yes": "Yes", + "no": "No", + "open": "Open", + "closed": "Closed", + "stale": "Stale", + "unknown": "Unknown", + "customers": "Customers", + "private": "Private", + "permit": "Permit", + "public": "Public" + } + } +} diff --git a/packages/integration-framework/strings/src/token.ts b/packages/integration-framework/strings/src/token.ts new file mode 100644 index 00000000..439ec265 --- /dev/null +++ b/packages/integration-framework/strings/src/token.ts @@ -0,0 +1,74 @@ +import deShared from "../locales/de.json" with { type: "json" }; +import enShared from "../locales/en.json" with { type: "json" }; +import type { I18nToken, LocaleStrings } from "./types.js"; + +/** + * The framework's shared-vocabulary catalog. Indexed by locale code; each + * locale ships the same `shared.*` namespace tree. Bundled at build time so + * the framework remains usable without filesystem access (works in the + * browser, in unit tests, in mobile clients that vendor the JSON). + */ +export const sharedStrings: LocaleStrings = { + en: enShared, + de: deShared, +}; + +/** + * Build a translation token. Use this when emitting integration-scoped + * labels — keys resolve against the emitting integration's strings catalog + * (with framework strings as fallback for non-`shared.*` keys). + * + * For shared vocabulary, prefer `sharedT.*` typed constants below. + */ +export function token(key: string, values?: Record): I18nToken { + return values ? { $t: key, values } : { $t: key }; +} + +/** + * Typed accessors for the framework's shared catalog. Use these wherever an + * integration's mapper wants to emit cross-integration vocabulary (Source, + * Last Updated, Open, etc.). Editor autocomplete prevents typos. + * + * Adding a new shared label: add it under the appropriate section in both + * `locales/en.json` and `locales/de.json`, then add the typed constant here. + * The check-translations script enforces parity. + */ +export const sharedT = { + section: { + source: { $t: "shared.section.source" } as I18nToken, + notes: { $t: "shared.section.notes" } as I18nToken, + pricing: { $t: "shared.section.pricing" } as I18nToken, + availability: { $t: "shared.section.availability" } as I18nToken, + facility: { $t: "shared.section.facility" } as I18nToken, + access: { $t: "shared.section.access" } as I18nToken, + dataQuality: { $t: "shared.section.dataQuality" } as I18nToken, + payment: { $t: "shared.section.payment" } as I18nToken, + info: { $t: "shared.section.info" } as I18nToken, + }, + row: { + source: { $t: "shared.row.source" } as I18nToken, + sources: { $t: "shared.row.sources" } as I18nToken, + sourceId: { $t: "shared.row.sourceId" } as I18nToken, + sourceUrl: { $t: "shared.row.sourceUrl" } as I18nToken, + license: { $t: "shared.row.license" } as I18nToken, + lastUpdated: { $t: "shared.row.lastUpdated" } as I18nToken, + type: { $t: "shared.row.type" } as I18nToken, + capacity: { $t: "shared.row.capacity" } as I18nToken, + status: { $t: "shared.row.status" } as I18nToken, + access: { $t: "shared.row.access" } as I18nToken, + address: { $t: "shared.row.address" } as I18nToken, + operator: { $t: "shared.row.operator" } as I18nToken, + }, + value: { + yes: { $t: "shared.value.yes" } as I18nToken, + no: { $t: "shared.value.no" } as I18nToken, + open: { $t: "shared.value.open" } as I18nToken, + closed: { $t: "shared.value.closed" } as I18nToken, + stale: { $t: "shared.value.stale" } as I18nToken, + unknown: { $t: "shared.value.unknown" } as I18nToken, + customers: { $t: "shared.value.customers" } as I18nToken, + private: { $t: "shared.value.private" } as I18nToken, + permit: { $t: "shared.value.permit" } as I18nToken, + public: { $t: "shared.value.public" } as I18nToken, + }, +} as const; From 8a525d73967aae93d8aca669d1a3e927e03933ef Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 28 May 2026 00:56:46 +0200 Subject: [PATCH 04/25] feat(api): ship frameworkStrings in /api/integrations response --- apps/api/src/integration-host.ts | 16 ++++++++++------ apps/web/src/providers/IntegrationProvider.tsx | 13 +++++++++---- packages/core/src/api/integrations.ts | 13 ++++++++++++- packages/integration-framework/src/index.ts | 7 ++++++- packages/integration-framework/src/loader.ts | 12 ++++++++++++ packages/integration-framework/src/registry.ts | 11 ++++++++++- 6 files changed, 59 insertions(+), 13 deletions(-) diff --git a/apps/api/src/integration-host.ts b/apps/api/src/integration-host.ts index 0e68eee1..1573a04b 100644 --- a/apps/api/src/integration-host.ts +++ b/apps/api/src/integration-host.ts @@ -24,6 +24,7 @@ import { integrationBackendBundlePath, integrationFrontendBundlePath, } from "@openmapx/integration-framework/installer"; +import { sharedStrings } from "@openmapx/integration-framework/strings"; import { registerPoiSources as registerPoiSourcesInStore } from "@openmapx/poi-source-registry"; import { eq } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; @@ -1059,12 +1060,15 @@ export async function initIntegrations( // Register the /api/integrations endpoint fastify.get("/api/integrations", async () => { - return Array.from(integrations.values()) - .filter((i) => i.enabled) - .map((i) => ({ - ...toIntegrationMeta(i), - isBuiltIn: i.isBuiltIn, - })); + return { + integrations: Array.from(integrations.values()) + .filter((i) => i.enabled) + .map((i) => ({ + ...toIntegrationMeta(i), + isBuiltIn: i.isBuiltIn, + })), + frameworkStrings: sharedStrings, + }; }); // Serve community integration frontend bundles diff --git a/apps/web/src/providers/IntegrationProvider.tsx b/apps/web/src/providers/IntegrationProvider.tsx index a607dcac..03eb8814 100644 --- a/apps/web/src/providers/IntegrationProvider.tsx +++ b/apps/web/src/providers/IntegrationProvider.tsx @@ -4,8 +4,8 @@ import { configureApiClient, initOverlayRegistry } from "@openmapx/core"; import { getCommunityModule, IntegrationRegistry, + type IntegrationsResponse, initCommunityIntegrationRegistry, - type LoadedIntegrationMeta, } from "@openmapx/integration-framework"; import { IntegrationRegistryContext } from "@openmapx/integration-framework/react"; import { useQuery } from "@tanstack/react-query"; @@ -32,15 +32,17 @@ export function IntegrationProvider({ children }: { children: React.ReactNode }) } }, []); - const { data: integrations } = useQuery({ + const { data } = useQuery({ queryKey: ["integrations", apiBase], queryFn: async () => { const res = await fetch(`${apiBase}/api/integrations`, { credentials: "include" }); if (!res.ok) throw new Error("Failed to load integrations"); - return (await res.json()) as (LoadedIntegrationMeta & { isBuiltIn?: boolean })[]; + return (await res.json()) as IntegrationsResponse; }, staleTime: Infinity, }); + const integrations = data?.integrations; + const frameworkStrings = data?.frameworkStrings; // Community integration bundles import `react`, `react/jsx-runtime`, and // `@openmapx/core` as externals. The page's import map (apps/web/src/app/ @@ -73,7 +75,10 @@ export function IntegrationProvider({ children }: { children: React.ReactNode }) } }, [integrations, apiBase]); - const registry = useMemo(() => new IntegrationRegistry(integrations ?? []), [integrations]); + const registry = useMemo( + () => new IntegrationRegistry(integrations ?? [], frameworkStrings ?? {}), + [integrations, frameworkStrings], + ); useEffect(() => { if (integrations?.length) { diff --git a/packages/core/src/api/integrations.ts b/packages/core/src/api/integrations.ts index 046bc4b6..16afc3fb 100644 --- a/packages/core/src/api/integrations.ts +++ b/packages/core/src/api/integrations.ts @@ -1,13 +1,24 @@ import type { LoadedIntegrationMeta } from "../types/integrationMeta"; import { serverApiUrl } from "./server-url"; +/** + * Wire shape of `/api/integrations`. Mirrors `IntegrationsResponse` from + * `@openmapx/integration-framework` — duplicated locally so `@openmapx/core` + * doesn't depend on integration-framework (which itself depends on core). + */ +interface IntegrationsApiResponse { + integrations: LoadedIntegrationMeta[]; + frameworkStrings: Record>; +} + export async function fetchIntegrations(): Promise { try { const res = await fetch(`${serverApiUrl()}/api/integrations`, { next: { revalidate: 3600 }, } as RequestInit); if (!res.ok) return []; - return (await res.json()) as LoadedIntegrationMeta[]; + const body = (await res.json()) as IntegrationsApiResponse; + return body.integrations ?? []; } catch { return []; } diff --git a/packages/integration-framework/src/index.ts b/packages/integration-framework/src/index.ts index de6f0b64..9b56b148 100644 --- a/packages/integration-framework/src/index.ts +++ b/packages/integration-framework/src/index.ts @@ -105,7 +105,12 @@ export { IntegrationEventBus } from "./events"; // node:fs/crypto/os/path, and any barrel that re-exports it taints everyone // who imports the barrel — including the client-reachable `@openmapx/core` // main entry. Consumers import them from `@openmapx/core/server` instead. -export type { IntegrationStrings, LoadedIntegration, LoadedIntegrationMeta } from "./loader"; +export type { + IntegrationStrings, + IntegrationsResponse, + LoadedIntegration, + LoadedIntegrationMeta, +} from "./loader"; export { toIntegrationMeta } from "./loader"; export type { IntegrationDataSource, diff --git a/packages/integration-framework/src/loader.ts b/packages/integration-framework/src/loader.ts index dec975b3..669313fb 100644 --- a/packages/integration-framework/src/loader.ts +++ b/packages/integration-framework/src/loader.ts @@ -1,3 +1,4 @@ +import type { LocaleStrings } from "../strings/index.js"; import type { CustomHealthCheckFn } from "./context"; import type { IntegrationManifest } from "./manifest"; @@ -28,6 +29,17 @@ export interface LoadedIntegrationMeta { strings?: IntegrationStrings; } +/** + * Wire shape of the `/api/integrations` orchestrator response. The host + * returns the enabled integrations alongside the framework's shared strings + * catalog (locale-keyed) so clients can resolve `shared.*` i18n tokens + * without a separate fetch. + */ +export interface IntegrationsResponse { + integrations: Array; + frameworkStrings: LocaleStrings; +} + export function toIntegrationMeta(integration: LoadedIntegration): LoadedIntegrationMeta { const en = integration.strings.en as Record | undefined; return { diff --git a/packages/integration-framework/src/registry.ts b/packages/integration-framework/src/registry.ts index 57a19d0d..c99ba75c 100644 --- a/packages/integration-framework/src/registry.ts +++ b/packages/integration-framework/src/registry.ts @@ -1,11 +1,20 @@ +import type { LocaleStrings } from "../strings/index.js"; import type { LoadedIntegrationMeta } from "./loader"; import type { IntegrationDataSource } from "./manifest"; export class IntegrationRegistry { private integrations: LoadedIntegrationMeta[]; + /** + * Framework shared strings catalog as shipped by `/api/integrations`. + * Task 1.5 will introduce `FrameworkStringsProvider` + `useFrameworkStrings` + * — for now we surface this on the registry so the next task has somewhere + * to read from without reshaping the provider tree. + */ + readonly frameworkStrings: LocaleStrings; - constructor(integrations: LoadedIntegrationMeta[]) { + constructor(integrations: LoadedIntegrationMeta[], frameworkStrings: LocaleStrings = {}) { this.integrations = integrations; + this.frameworkStrings = frameworkStrings; } getAll(): LoadedIntegrationMeta[] { From 53af5962594bab152b942cb6402ab58e26ec88d7 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 28 May 2026 01:03:02 +0200 Subject: [PATCH 05/25] feat(web): add useDataSourceI18nResolver + FrameworkStringsProvider --- apps/web/package.json | 2 + .../useDataSourceI18nResolver.test.ts | 59 +++ .../panels/place/useDataSourceI18nResolver.ts | 45 ++ apps/web/src/lib/frameworkStringsContext.tsx | 33 ++ .../web/src/providers/IntegrationProvider.tsx | 3 +- pnpm-lock.yaml | 438 +++++++++++++++++- 6 files changed, 556 insertions(+), 24 deletions(-) create mode 100644 apps/web/src/components/panels/place/__tests__/useDataSourceI18nResolver.test.ts create mode 100644 apps/web/src/components/panels/place/useDataSourceI18nResolver.ts create mode 100644 apps/web/src/lib/frameworkStringsContext.tsx diff --git a/apps/web/package.json b/apps/web/package.json index 5ece6324..5f7920e0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -65,11 +65,13 @@ "@mapbox/maki": "^8.2.0", "@rapideditor/temaki": "^5.13.0", "@tailwindcss/postcss": "^4.2.4", + "@testing-library/react": "^16.3.2", "@types/node": "^25.6.0", "@types/qrcode": "^1.5.6", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "esbuild": "^0.28.0", + "jsdom": "^29.1.1", "tailwindcss": "^4.2.4", "typescript": "^5.9.3" } diff --git a/apps/web/src/components/panels/place/__tests__/useDataSourceI18nResolver.test.ts b/apps/web/src/components/panels/place/__tests__/useDataSourceI18nResolver.test.ts new file mode 100644 index 00000000..6d699e35 --- /dev/null +++ b/apps/web/src/components/panels/place/__tests__/useDataSourceI18nResolver.test.ts @@ -0,0 +1,59 @@ +// @vitest-environment jsdom + +import type { LocaleStrings } from "@openmapx/integration-framework/strings"; +import { renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("next-intl", () => ({ + useLocale: () => "de", +})); + +vi.mock("@openmapx/integration-framework/react", () => ({ + useIntegrationRegistry: () => ({ + get: (id: string) => + id === "parking" + ? { + id: "parking", + strings: { + de: { row: { freeSpaces: "Freie Plätze" } }, + en: { row: { freeSpaces: "Free Spaces" } }, + } satisfies LocaleStrings, + } + : undefined, + getByDomain: () => [], + findDataSource: () => undefined, + }), +})); + +vi.mock("@/lib/frameworkStringsContext", () => ({ + useFrameworkStrings: () => + ({ + de: { shared: { row: { source: "Quelle" } } }, + en: { shared: { row: { source: "Source" } } }, + }) satisfies LocaleStrings, +})); + +import { useDataSourceI18nResolver } from "../useDataSourceI18nResolver.js"; + +describe("useDataSourceI18nResolver", () => { + it("resolves an integration-scoped token via the matching integration", () => { + const { result } = renderHook(() => useDataSourceI18nResolver("parking")); + expect(result.current({ $t: "row.freeSpaces" })).toBe("Freie Plätze"); + }); + + it("resolves a shared.* token via framework strings regardless of integration", () => { + const { result } = renderHook(() => useDataSourceI18nResolver("parking")); + expect(result.current({ $t: "shared.row.source" })).toBe("Quelle"); + }); + + it("returns string passthrough values verbatim", () => { + const { result } = renderHook(() => useDataSourceI18nResolver("parking")); + expect(result.current("12/50")).toBe("12/50"); + expect(result.current(529)).toBe("529"); + }); + + it("returns the key verbatim when no catalog yields a translation", () => { + const { result } = renderHook(() => useDataSourceI18nResolver("parking")); + expect(result.current({ $t: "row.completelyUnknown" })).toBe("row.completelyUnknown"); + }); +}); diff --git a/apps/web/src/components/panels/place/useDataSourceI18nResolver.ts b/apps/web/src/components/panels/place/useDataSourceI18nResolver.ts new file mode 100644 index 00000000..d57fcc2d --- /dev/null +++ b/apps/web/src/components/panels/place/useDataSourceI18nResolver.ts @@ -0,0 +1,45 @@ +import { useIntegrationRegistry } from "@openmapx/integration-framework/react"; +import { + type I18nToken, + isI18nToken, + resolveToken, + type Translatable, +} from "@openmapx/integration-framework/strings"; +import { useLocale } from "next-intl"; +import { useCallback } from "react"; +import { useFrameworkStrings } from "@/lib/frameworkStringsContext"; + +/** + * Resolver hook for translating `I18nToken` payloads emitted by the + * data-source contract. The returned function accepts either a token (looked + * up in the integration's strings, then the framework shared catalog) or a + * raw `string | number` (returned verbatim). + * + * @param integrationId The integration that emitted the token — used to scope + * the integration catalog lookup. Typically `detail.sources[0]` or the + * domain id resolved by `pickIntegrationForSources`. + */ +export function useDataSourceI18nResolver( + integrationId: string | undefined, +): (value: Translatable | undefined) => string { + const locale = useLocale(); + const registry = useIntegrationRegistry(); + const frameworkStrings = useFrameworkStrings(); + + return useCallback( + (value: Translatable | undefined): string => { + if (value === undefined || value === null) return ""; + if (typeof value === "number") return String(value); + if (typeof value === "string") return value; + if (!isI18nToken(value)) return String(value); + const integration = integrationId ? registry.get(integrationId) : undefined; + return resolveToken(value as I18nToken, { + locale, + fallbackLocale: "en", + shared: frameworkStrings, + integration: integration?.strings, + }); + }, + [locale, registry, frameworkStrings, integrationId], + ); +} diff --git a/apps/web/src/lib/frameworkStringsContext.tsx b/apps/web/src/lib/frameworkStringsContext.tsx new file mode 100644 index 00000000..12819254 --- /dev/null +++ b/apps/web/src/lib/frameworkStringsContext.tsx @@ -0,0 +1,33 @@ +"use client"; + +import type { LocaleStrings } from "@openmapx/integration-framework/strings"; +import { createContext, type ReactNode, useContext } from "react"; + +const FrameworkStringsContext = createContext(null); + +export function FrameworkStringsProvider({ + value, + children, +}: { + value: LocaleStrings; + children: ReactNode; +}) { + return ( + {children} + ); +} + +/** + * Read the framework shared-vocabulary strings shipped via `/api/integrations`. + * Must be wrapped in `` higher in the tree (done by + * the IntegrationRegistry provider in `useIntegrationRegistry`). + */ +export function useFrameworkStrings(): LocaleStrings { + const value = useContext(FrameworkStringsContext); + if (!value) { + throw new Error( + "useFrameworkStrings() called outside a . Make sure the integration registry has been initialised at app boot.", + ); + } + return value; +} diff --git a/apps/web/src/providers/IntegrationProvider.tsx b/apps/web/src/providers/IntegrationProvider.tsx index 03eb8814..fa3bf802 100644 --- a/apps/web/src/providers/IntegrationProvider.tsx +++ b/apps/web/src/providers/IntegrationProvider.tsx @@ -11,6 +11,7 @@ import { IntegrationRegistryContext } from "@openmapx/integration-framework/reac import { useQuery } from "@tanstack/react-query"; import { useEffect, useMemo, useRef, useState } from "react"; import { useEnv } from "@/lib/EnvProvider"; +import { FrameworkStringsProvider } from "@/lib/frameworkStringsContext"; export function IntegrationProvider({ children }: { children: React.ReactNode }) { const { apiUrl } = useEnv(); @@ -88,7 +89,7 @@ export function IntegrationProvider({ children }: { children: React.ReactNode }) return ( - {children} + {children} ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eac2a3c0..3a0bac94 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,7 +53,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.5 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) apps/api: dependencies: @@ -177,7 +177,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.5 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) apps/web: dependencies: @@ -326,6 +326,9 @@ importers: '@tailwindcss/postcss': specifier: ^4.2.4 version: 4.2.4 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@types/node': specifier: ^25.6.0 version: 25.6.0 @@ -341,6 +344,9 @@ importers: esbuild: specifier: '>=0.25.0' version: 0.28.0 + jsdom: + specifier: ^29.1.1 + version: 29.1.1(@noble/hashes@2.2.0) tailwindcss: specifier: ^4.2.4 version: 4.2.4 @@ -1451,7 +1457,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.5 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) packages/command-palette: devDependencies: @@ -1460,7 +1466,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.5 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) packages/core: dependencies: @@ -1509,7 +1515,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.5 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) packages/db-schema: dependencies: @@ -1525,7 +1531,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.5 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) packages/hardlinks: devDependencies: @@ -1537,7 +1543,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.5 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) packages/hey-api-client-fetch: devDependencies: @@ -1549,7 +1555,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.5 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) packages/i18n: devDependencies: @@ -1601,7 +1607,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.5 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) packages/mangrove-client: dependencies: @@ -1617,7 +1623,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.5 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) packages/mangrove-react: dependencies: @@ -1645,7 +1651,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.5 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) packages/mobility-core: dependencies: @@ -1685,7 +1691,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.5 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) packages/mobility-formats-tomp: dependencies: @@ -1707,7 +1713,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.5 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) packages/motis-feed-proxy-config: devDependencies: @@ -1719,7 +1725,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.5 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) packages/noaa-coops-data: dependencies: @@ -1735,7 +1741,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.0 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) packages/ourairports-data: dependencies: @@ -1754,7 +1760,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.0 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) packages/place-ids: devDependencies: @@ -1766,7 +1772,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.5 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) packages/poi-source-registry: devDependencies: @@ -1778,7 +1784,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.5 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) packages/presets: dependencies: @@ -1797,7 +1803,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.5 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) services/data-manager: dependencies: @@ -1855,7 +1861,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.5 - version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) packages: @@ -1863,6 +1869,21 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -2086,6 +2107,10 @@ packages: cpu: [x64] os: [win32] + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@changesets/apply-release-plan@7.1.1': resolution: {integrity: sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA==} @@ -2222,6 +2247,42 @@ packages: conventional-commits-parser: optional: true + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.1': + resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.1': + resolution: {integrity: sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.4': + resolution: {integrity: sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@derhuerst/br2nl@1.0.0': resolution: {integrity: sha512-aGzqSMKM9eqHd9orDSe7yOvekRM9EMt9Jbc9vAG+a3jbn50WOTThVQX20c75yk0WBmu+6A5hdHGRM49dlp0I9w==} engines: {node: '>=6'} @@ -2497,6 +2558,15 @@ packages: cpu: [x64] os: [win32] + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@fastify/ajv-compiler@4.0.5': resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} @@ -3658,6 +3728,25 @@ packages: '@testcontainers/postgresql@11.14.0': resolution: {integrity: sha512-wYbJn8GRTj8qfqzfVubxioYWlHJU/ImIjuzPwyy9C5Qfo6g3GLduPZAj+BifvqTZjgT3gd4gFVLCPhBji7dc1w==} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: 19.2.4 + react-dom: 19.2.4 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@turbo/darwin-64@2.9.7': resolution: {integrity: sha512-wnvOWuVWJ5EUHNKxExEWiGlTeVpLG1L0PCu5MUozyC1P2SHGiWsmpW6/yAuShH91Fa2TAHOvdCRBzriZh4j4Eg==} cpu: [x64] @@ -3712,6 +3801,9 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -3933,6 +4025,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -3951,6 +4047,9 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + array-ify@1.0.0: resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==} @@ -4142,6 +4241,9 @@ packages: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + bintrees@1.0.2: resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==} @@ -4397,6 +4499,10 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -4447,6 +4553,10 @@ packages: resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} engines: {node: '>=12'} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + db-hafas-stations@2.0.0: resolution: {integrity: sha512-GjcqpZhs+HeOvc2dnAJs2Uy3/b8zRNlvTfLCvqFe9J2JYOlQVyuYCjjdsPBTOmJol42sPToPs71wr03kTMVJDw==} engines: {node: '>=18'} @@ -4471,6 +4581,9 @@ packages: decimal.js-light@2.5.1: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + default-browser-id@5.0.1: resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} engines: {node: '>=18'} @@ -4528,6 +4641,9 @@ packages: resolution: {integrity: sha512-/bCZd6KlGcjZO8Buqmi/vXuqEGVEZ0PNjx/biBNqJD3MhK9DmdiAuKxqfNhflgDESDIiBz3qF+0e55+CpnrUcw==} engines: {node: '>= 8.0'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} @@ -4677,6 +4793,10 @@ packages: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -5003,6 +5123,10 @@ packages: hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -5146,6 +5270,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} @@ -5219,6 +5346,15 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -5415,6 +5551,10 @@ packages: resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} engines: {node: '>=12'} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -5449,6 +5589,9 @@ packages: md5.js@1.3.5: resolution: {integrity: sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + meow@13.2.0: resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} engines: {node: '>=18'} @@ -5689,6 +5832,9 @@ packages: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -5806,6 +5952,10 @@ packages: resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==} engines: {node: ^14.13.1 || >=16.0.0} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-ms@9.3.0: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} @@ -5905,6 +6055,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-is@19.2.5: resolution: {integrity: sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==} @@ -6090,6 +6243,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -6336,6 +6493,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} @@ -6417,6 +6577,13 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + tldts-core@7.4.0: + resolution: {integrity: sha512-/mb9kRld+x1sIMXxWNOAp5m6C+D4GrAORWlJkOJ5dElvxdN1eutz/o7qHLp9gFvDF4Y3/L2xeScoxz6AbEo8rQ==} + + tldts@7.4.0: + resolution: {integrity: sha512-yHBe+zVfzNZ3QfTPW/Z6KK1G2t340gFjMHqI/4KKSt/abzYydzuCnpqdaF5gCCABby+9Yfbj59oR5F2Fd5CBzg==} + hasBin: true + tmp@0.2.5: resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} engines: {node: '>=14.14'} @@ -6433,12 +6600,20 @@ packages: resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} engines: {node: '>=12'} + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} tr46@1.0.1: resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + ts-interface-checker@1.0.2: resolution: {integrity: sha512-4IKKvhZRXhvtYF/mtu+OCfBqJKV6LczUq4kQYcpT+iSB7++R9+giWnp2ecwWMIcnG16btVOkXFnoxLSYMN1Q1g==} @@ -6615,12 +6790,28 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} webidl-conversions@4.0.2: resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -6673,10 +6864,17 @@ packages: x-is-string@0.1.0: resolution: {integrity: sha512-GojqklwG8gpzOVEVki5KudKNoq7MbbjYZCbyWzEz7tyPA7eleiE0+ePwOWQQRb5fm86rD3S8Tc0tSFf3AOv50w==} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + xml-naming@0.1.0: resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} engines: {node: '>=16.0.0'} + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -6746,6 +6944,26 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -6921,6 +7139,10 @@ snapshots: '@biomejs/cli-win32-x64@2.4.13': optional: true + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@changesets/apply-release-plan@7.1.1': dependencies: '@changesets/config': 3.1.4 @@ -7182,6 +7404,30 @@ snapshots: optionalDependencies: conventional-commits-parser: 6.4.0 + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.4(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@derhuerst/br2nl@1.0.0': {} '@derhuerst/round-robin-scheduler@1.0.4': {} @@ -7414,6 +7660,10 @@ snapshots: '@esbuild/win32-x64@0.28.0': optional: true + '@exodus/bytes@1.15.1(@noble/hashes@2.2.0)': + optionalDependencies: + '@noble/hashes': 2.2.0 + '@fastify/ajv-compiler@4.0.5': dependencies: ajv: 8.20.0 @@ -8471,6 +8721,27 @@ snapshots: - react-native-b4a - supports-color + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.29.2 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@babel/runtime': 7.29.2 + '@testing-library/dom': 10.4.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@turbo/darwin-64@2.9.7': optional: true @@ -8541,6 +8812,8 @@ snapshots: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -8676,7 +8949,7 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/expect@4.1.5': dependencies: @@ -8764,6 +9037,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} archiver-utils@5.0.2: @@ -8796,6 +9071,10 @@ snapshots: argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + array-ify@1.0.0: {} array-source@0.0.4: {} @@ -8919,7 +9198,7 @@ snapshots: next: 16.2.6(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) transitivePeerDependencies: - '@cloudflare/workers-types' - '@opentelemetry/api' @@ -8937,6 +9216,10 @@ snapshots: dependencies: is-windows: 1.0.2 + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + bintrees@1.0.2: {} bl@4.1.0: @@ -9210,6 +9493,11 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + csstype@3.2.3: {} csv-parse@6.2.1: {} @@ -9252,6 +9540,13 @@ snapshots: d3-timer@3.0.1: {} + data-urls@7.0.0(@noble/hashes@2.2.0): + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@2.2.0) + transitivePeerDependencies: + - '@noble/hashes' + db-hafas-stations@2.0.0: dependencies: ndjson: 2.0.0 @@ -9280,6 +9575,8 @@ snapshots: decimal.js-light@2.5.1: {} + decimal.js@10.6.0: {} + default-browser-id@5.0.1: {} default-browser@5.5.0: @@ -9338,6 +9635,8 @@ snapshots: transitivePeerDependencies: - supports-color + dom-accessibility-api@0.5.16: {} + dom-helpers@5.2.1: dependencies: '@babel/runtime': 7.29.2 @@ -9400,6 +9699,8 @@ snapshots: ansi-colors: 4.1.3 strip-ansi: 6.0.1 + entities@8.0.0: {} + env-paths@2.2.1: {} environment@1.1.0: {} @@ -9799,6 +10100,12 @@ snapshots: dependencies: react-is: 16.13.1 + html-encoding-sniffer@6.0.0(@noble/hashes@2.2.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0) + transitivePeerDependencies: + - '@noble/hashes' + html-escaper@2.0.2: {} https-proxy-agent@7.0.6: @@ -9912,6 +10219,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-stream@2.0.1: {} is-stream@4.0.1: {} @@ -9974,6 +10283,32 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@29.1.1(@noble/hashes@2.2.0): + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.4(css-tree@3.2.1) + '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0) + css-tree: 3.2.1 + data-urls: 7.0.0(@noble/hashes@2.2.0) + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@2.2.0) + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.3.5 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.25.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@2.2.0) + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-parse-even-better-errors@2.3.1: {} @@ -10127,6 +10462,8 @@ snapshots: luxon@3.7.2: {} + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -10202,6 +10539,8 @@ snapshots: inherits: 2.0.4 safe-buffer: 5.2.1 + mdn-data@2.27.1: {} + meow@13.2.0: {} merge2@1.4.1: {} @@ -10414,6 +10753,10 @@ snapshots: parse-ms@4.0.0: {} + parse5@8.0.1: + dependencies: + entities: 8.0.0 + path-exists@4.0.0: {} path-expression-matcher@1.5.0: {} @@ -10518,6 +10861,12 @@ snapshots: pretty-bytes@6.1.1: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + pretty-ms@9.3.0: dependencies: parse-ms: 4.0.0 @@ -10616,6 +10965,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react-is@19.2.5: {} react-redux@9.3.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1): @@ -10811,6 +11162,10 @@ snapshots: safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} secure-json-parse@4.1.0: {} @@ -11092,6 +11447,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} + tagged-tag@1.0.0: {} tailwindcss@4.2.4: {} @@ -11209,6 +11566,12 @@ snapshots: tinyrainbow@3.1.0: {} + tldts-core@7.4.0: {} + + tldts@7.4.0: + dependencies: + tldts-core: 7.4.0 + tmp@0.2.5: {} to-buffer@1.2.2: @@ -11223,12 +11586,20 @@ snapshots: toad-cache@3.7.0: {} + tough-cookie@6.0.1: + dependencies: + tldts: 7.4.0 + tr46@0.0.3: {} tr46@1.0.1: dependencies: punycode: 2.3.1 + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + ts-interface-checker@1.0.2: {} tslib@1.14.1: {} @@ -11348,7 +11719,7 @@ snapshots: tsx: 4.21.0 yaml: 2.8.3 - vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)): + vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.5 '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) @@ -11374,13 +11745,30 @@ snapshots: '@opentelemetry/api': 1.9.1 '@types/node': 25.6.0 '@vitest/coverage-v8': 4.1.5(vitest@4.1.5) + jsdom: 29.1.1(@noble/hashes@2.2.0) transitivePeerDependencies: - msw + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + webidl-conversions@3.0.1: {} webidl-conversions@4.0.2: {} + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1(@noble/hashes@2.2.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -11448,8 +11836,12 @@ snapshots: x-is-string@0.1.0: {} + xml-name-validator@5.0.0: {} + xml-naming@0.1.0: {} + xmlchars@2.2.0: {} + xtend@4.0.2: {} y18n@4.0.3: {} From b69a7a21a0c2331f1e6781dc5645d9f52cfe3030 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 28 May 2026 01:07:48 +0200 Subject: [PATCH 06/25] refactor(web): replace ROW_LABEL_KEYS lookup with token resolver --- .../panels/place/DataSourceSections.tsx | 198 ++---------------- 1 file changed, 12 insertions(+), 186 deletions(-) diff --git a/apps/web/src/components/panels/place/DataSourceSections.tsx b/apps/web/src/components/panels/place/DataSourceSections.tsx index 04316d15..6e135b6e 100644 --- a/apps/web/src/components/panels/place/DataSourceSections.tsx +++ b/apps/web/src/components/panels/place/DataSourceSections.tsx @@ -23,12 +23,14 @@ import { pickIntegrationForSources, } from "@openmapx/core"; import { useIntegrationRegistry } from "@openmapx/integration-framework/react"; +import type { Translatable } from "@openmapx/integration-framework/strings"; import { useTranslations } from "next-intl"; import { Fragment, type ReactNode } from "react"; import { TEAL } from "@/lib/theme"; import { BrandMark } from "../shared/BrandMark"; import { type StructuredSection, StructuredSections } from "../shared/StructuredSections"; import { DataSourceNearbyTransit } from "./DataSourceNearbyTransit"; +import { useDataSourceI18nResolver } from "./useDataSourceI18nResolver.js"; /** Section header config per data source type. */ const SOURCE_HEADERS: Record = { @@ -162,201 +164,22 @@ interface Props { domain?: string; } -/** Map API section titles to i18n keys. */ -const SECTION_TITLE_KEYS: Record = { - Availability: "sectionAvailability", - "Public Transit": "sectionPublicTransit", - "Vehicle Details": "sectionVehicleDetails", - "Vehicle Classes": "sectionVehicleClasses", - Pricing: "sectionPricing", - Book: "sectionBook", - Directions: "sectionDirections", - Notes: "sectionNotes", - Connectors: "sectionConnectors", - Usage: "sectionUsage", - Access: "sectionAccess", - Facility: "sectionFacility", - "Data Quality": "sectionDataQuality", - Fee: "sectionFee", - Payment: "sectionPayment", - Source: "sectionSource", - // Webcam + fuel section titles — surfaced verbatim before, leaking English to DE UI. - Info: "sectionInfo", - Preview: "sectionPreview", - "Video Clip": "sectionVideoClip", - "Live / Timelapse": "sectionLiveTimelapse", - "Live Stream": "sectionLiveStream", - Webcam: "sectionWebcam", - Unavailable: "sectionUnavailable", - "Original URL": "sectionOriginalUrl", - "Fuel Prices": "sectionFuelPrices", -}; - -/** Map API row labels (left column of key-value tables) to i18n keys. */ -const ROW_LABEL_KEYS: Record = { - "Available Vehicles": "rowAvailableVehicles", - "Empty Slots": "rowEmptySlots", - "Total Capacity": "rowTotalCapacity", - Type: "rowType", - Pricing: "rowPricing", - Vehicle: "rowVehicle", - Propulsion: "rowPropulsion", - Seats: "rowSeats", - Features: "rowFeatures", - "CO₂": "rowCo2", - "Bus Lines": "rowBusLines", - "Nearest Stops": "rowNearestStops", - "Fixed Station": "fixedStation", - "Free-floating Zone": "freeFloatingZone", - "Zero emissions": "zeroEmissions", - "Free Spaces": "rowFreeSpaces", - Occupancy: "rowOccupancy", - "Max Height": "rowMaxHeight", - "Disabled Spaces": "rowDisabledSpaces", - "Women's Spaces": "rowWomenSpaces", - "EV Charging": "rowEvCharging", - "Park & Ride": "rowParkAndRide", - Capacity: "rowCapacity", - Status: "rowStatus", - Trend: "rowTrend", - Access: "rowAccess", - "Data Freshness": "rowDataFreshness", - "Nearest Station": "rowNearestStation", - Source: "rowSource", - Sources: "rowSources", - "Source ID": "rowSourceId", - "Source URL": "rowSourceUrl", - License: "rowLicense", - // Fuel column headers — emitted by `integrations/fuel/providers/mapper.ts` - // as section.columns (translated via translateStructuredSection below). - "Fuel Type": "columnFuelType", - "Price (€)": "columnPriceEur", - // Trend values surfaced by parking sources (Braunschweig, Düsseldorf, - // Salzburg, Bielefeld via PLS feed). - Increasing: "trendIncreasing", - Decreasing: "trendDecreasing", - Constant: "trendConstant", - // Parking type values - "Parking Garage": "parkingGarage", - "Underground Garage": "undergroundGarage", - "Surface Lot": "surfaceLot", - "On-Street": "onStreet", - // Fee values - "Free Parking": "freeParking", - "Paid Parking": "paidParking", - Unknown: "unknownFee", - // Parking tariff durations - "20 min": "dur20min", - "30 min": "dur30min", - "1h": "dur1h", - "1 day": "dur1day", - "1 day (P-Card)": "dur1dayPCard", - "1 week": "dur1week", - "1 week (P-Card)": "dur1weekPCard", - "1 month": "dur1month", - "1 month (long-term)": "dur1monthLong", - "1 month (reserved)": "dur1monthReserved", - // Webcam row labels - City: "rowCity", - Region: "rowRegion", - Country: "rowCountry", - Categories: "rowCategories", - Views: "rowViews", - "Last Updated": "rowLastUpdated", - Direction: "rowDirection", - Nearby: "rowNearby", - County: "rowCounty", - "Live Stream": "rowLiveStream", - View: "rowView", - Park: "rowPark", - State: "rowState", - Tags: "rowTags", - "NPS Page": "rowNpsPage", - Road: "rowRoad", - Location: "rowLocation", - // Common values - Yes: "yes", - Open: "open", - Closed: "closed", - Stale: "stale", - Customers: "customers", - Private: "private", - Permit: "permit", -}; - -const DETAIL_TEXT_KEYS: Record = { - "1 day (P-Card)": "dur1dayPCard", - "1 month (long-term)": "dur1monthLong", - "1 month (reserved)": "dur1monthReserved", - "1 week (P-Card)": "dur1weekPCard", - "EV Charging Available": "evChargingAvailable", - "Free Parking": "freeParking", - "Max day price": "tariffMaxDayPrice", - Monthly: "tariffMonthly", - "Monthly (resident)": "tariffMonthlyResident", - "Monthly pass": "tariffMonthlyPass", - "Paid Parking": "paidParking", - "Realtime availability is older than 30 minutes.": "qualityRealtimeAvailabilityStale", - "Realtime free-space count exceeded capacity and was clamped.": - "qualityFreeSpacesClampedToCapacity", - "Realtime free-space count was negative and was clamped to 0.": - "qualityNegativeFreeSpacesClamped", - "Yearly pass": "tariffYearlyPass", - "This webcam URL appears to be offline or no longer available.": "webcamUrlOffline", -}; - -const DURATION_MINUTES_RE = /^(\d+) min$/; -const DURATION_HOURS_RE = /^(\d+) hours?$/; -const DURATION_HOURS_SHORT_RE = /^(\d+)h$/; -const DURATION_DAYS_RE = /^(\d+) days?$/; - -function translateDetailText(value: string | undefined, t: ReturnType) { - if (!value) return value; - const key = DETAIL_TEXT_KEYS[value] ?? ROW_LABEL_KEYS[value]; - if (key) return t(key); - - const minutes = value.match(DURATION_MINUTES_RE); - if (minutes) return t("durationMinutes", { count: Number(minutes[1]) }); - - const hours = value.match(DURATION_HOURS_RE) ?? value.match(DURATION_HOURS_SHORT_RE); - if (hours) return t("durationHours", { count: Number(hours[1]) }); - - const days = value.match(DURATION_DAYS_RE); - if (days) return t("durationDays", { count: Number(days[1]) }); - - return value; -} - function translateStructuredSection( section: DataSourceDetailSection, - t: ReturnType, + resolveT: (value: Translatable | undefined) => string, ): StructuredSection { - const titleKey = SECTION_TITLE_KEYS[section.title]; - const translatedTitle = titleKey ? t(titleKey) : section.title; + const translatedTitle = resolveT(section.title); const translatedRows = section.type === "table" && section.rows?.every((row) => row.length === 2) ? section.rows.map(([label, value]) => { - const labelStr = String(label); - const valueStr = String(value); - return [ - translateDetailText(labelStr, t) ?? labelStr, - translateDetailText(valueStr, t) ?? value, - ] satisfies (string | number)[]; + return [resolveT(label), resolveT(value)] satisfies (string | number)[]; }) : section.rows; - // Column headers (table sections with explicit `columns`) were previously - // surfaced verbatim — fine for English UIs, but leaked through to DE. - // Route them through the same key lookup as row labels so e.g. - // `["Fuel Type", "Price (€)"]` from the fuel integration becomes - // `["Kraftstoffart", "Preis (€)"]` in the DE locale. - const translatedColumns = section.columns?.map( - (column) => translateDetailText(column, t) ?? column, - ); - + const translatedColumns = section.columns?.map((column) => resolveT(column)); return { ...section, - content: translateDetailText(section.content, t), - items: section.items?.map((item) => translateDetailText(item, t) ?? item), + content: section.content === undefined ? undefined : resolveT(section.content), + items: section.items?.map((item) => resolveT(item)), title: translatedTitle, rows: translatedRows, columns: translatedColumns, @@ -449,9 +272,12 @@ function DetailAttribution({ attribution }: { attribution: DataSourceAttribution export function DataSourceSections({ detail, domain }: Props) { const t = useTranslations("dataSources"); + const registry = useIntegrationRegistry(); + const meta = pickIntegrationForSources(registry.getByDomain("data-source"), detail.sources); + const resolveT = useDataSourceI18nResolver(meta?.id ?? domain); const header = resolveSourceHeader(detail, domain); const structuredSections = detail.sections.map((section) => - translateStructuredSection(section, t), + translateStructuredSection(section, resolveT), ); const operatorLegalName = detail.operator?.legalName && From aaa935a2670e8456b34e4f31ce0061d8f819d507 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 28 May 2026 01:32:00 +0200 Subject: [PATCH 07/25] refactor(parking): emit I18nToken across data-source contract --- .../components/map/layers/DataSourceLayer.tsx | 13 +- .../datasource/DataSourceFilterContent.tsx | 10 +- .../panels/place/DataSourceSections.tsx | 30 ++- .../__tests__/bnls-fr-equivalence.test.ts | 28 ++- .../__tests__/db-bahnpark-equivalence.test.ts | 30 +-- .../providers/__tests__/mapper.test.ts | 66 +++++-- .../opentransportdata-ch-equivalence.test.ts | 28 ++- .../parking/providers/bnls-fr-mapper.ts | 17 +- .../parking/providers/bnls-fr-parser.ts | 23 ++- .../parking/providers/db-bahnpark-mapper.ts | 17 +- .../parking/providers/db-bahnpark-parser.ts | 37 ++-- .../parking/providers/mapper-utils.ts | 33 +++- integrations/parking/providers/mapper.ts | 171 +++++++++++------- .../opentransportdata-ch-bundled-parser.ts | 25 ++- .../providers/opentransportdata-ch-mapper.ts | 17 +- integrations/parking/strings/de.json | 67 +++++++ integrations/parking/strings/en.json | 67 +++++++ .../mobility-data-source-provider.ts | 24 ++- packages/mobility-core/src/types/parking.ts | 20 +- 19 files changed, 503 insertions(+), 220 deletions(-) diff --git a/apps/web/src/components/map/layers/DataSourceLayer.tsx b/apps/web/src/components/map/layers/DataSourceLayer.tsx index 1e440264..3ebd75a2 100644 --- a/apps/web/src/components/map/layers/DataSourceLayer.tsx +++ b/apps/web/src/components/map/layers/DataSourceLayer.tsx @@ -22,11 +22,13 @@ import { import type { DataSourceAttribution } from "@openmapx/integration-framework"; import { dataSourceToAttribution } from "@openmapx/integration-framework"; import { useIntegrationRegistry } from "@openmapx/integration-framework/react"; +import { isI18nToken, type Translatable } from "@openmapx/integration-framework/strings"; import type { Attribution } from "@openmapx/mobility-core/attribution"; import type maplibregl from "maplibre-gl"; import type { GeoJSONSource, Map as MaplibreMap, MapMouseEvent } from "maplibre-gl"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useDataSourceI18nResolver } from "@/components/panels/place/useDataSourceI18nResolver"; import { usePinMarker } from "@/hooks/usePinMarker"; import { translateDataSourceSummary } from "@/lib/dataSourceSummaryI18n"; import { INTERACTIVE_LAYER_IDS } from "@/lib/interactiveLayers"; @@ -63,7 +65,7 @@ function mapContextOutlineLayerId(dsId: string) { function buildGeoJson( results: DataSourceResult[], - translateSummary: (summary: string | undefined) => string | undefined, + translateSummary: (summary: Translatable | undefined) => string | undefined, imageId?: string, ) { return { @@ -218,6 +220,7 @@ export function DataSourceLayer() { }, [activeSource, sourcesData]); const registry = useIntegrationRegistry(); + const resolveToken = useDataSourceI18nResolver(activeSource ?? undefined); // Separate server-side filters (sent to the API) from client-side filters // (applied locally on the result set). Uses the `clientSide` flag from @@ -419,7 +422,12 @@ export function DataSourceLayer() { const imageId = useIconMarkers ? `ds-marker-${activeSource}` : undefined; const geojson = buildGeoJson( filteredResults, - (summary) => translateDataSourceSummary(summary, t), + (summary) => { + if (summary === undefined) return undefined; + if (isI18nToken(summary)) return resolveToken(summary); + if (typeof summary === "number") return String(summary); + return translateDataSourceSummary(summary, t); + }, imageId, ); @@ -645,6 +653,7 @@ export function DataSourceLayer() { mapRef, mapContext, t, + resolveToken, ]); const { setSelectedPlace } = usePlaceStore(); diff --git a/apps/web/src/components/panels/datasource/DataSourceFilterContent.tsx b/apps/web/src/components/panels/datasource/DataSourceFilterContent.tsx index 748b0081..30158b1a 100644 --- a/apps/web/src/components/panels/datasource/DataSourceFilterContent.tsx +++ b/apps/web/src/components/panels/datasource/DataSourceFilterContent.tsx @@ -41,8 +41,10 @@ import { usePlaceStore, useSidebarStore, } from "@openmapx/core"; +import { isI18nToken } from "@openmapx/integration-framework/strings"; import { useTranslations } from "next-intl"; import { useCallback, useMemo, useState } from "react"; +import { useDataSourceI18nResolver } from "@/components/panels/place/useDataSourceI18nResolver"; import { translateDataSourceLabel, translateDataSourceSummary } from "@/lib/dataSourceSummaryI18n"; import { TEAL } from "@/lib/theme"; import { BrandMark } from "../shared/BrandMark"; @@ -199,6 +201,7 @@ export function DataSourceFilterContent() { const t = useTranslations("dataSources"); const tc = useTranslations("common"); const activeSource = useDataSourceStore((s) => s.activeSource); + const resolveToken = useDataSourceI18nResolver(activeSource ?? undefined); const filters = useDataSourceStore((s) => s.filters); const searchBbox = useDataSourceStore((s) => s.searchBbox); const viewportZoom = useDataSourceStore((s) => s.viewportZoom); @@ -537,7 +540,12 @@ export function DataSourceFilterContent() { {result.summary && ( )} diff --git a/apps/web/src/components/panels/place/DataSourceSections.tsx b/apps/web/src/components/panels/place/DataSourceSections.tsx index 6e135b6e..a6349358 100644 --- a/apps/web/src/components/panels/place/DataSourceSections.tsx +++ b/apps/web/src/components/panels/place/DataSourceSections.tsx @@ -169,20 +169,32 @@ function translateStructuredSection( resolveT: (value: Translatable | undefined) => string, ): StructuredSection { const translatedTitle = resolveT(section.title); - const translatedRows = + const translatedRows: (string | number)[][] | undefined = section.type === "table" && section.rows?.every((row) => row.length === 2) - ? section.rows.map(([label, value]) => { - return [resolveT(label), resolveT(value)] satisfies (string | number)[]; - }) - : section.rows; + ? section.rows.map(([label, value]) => [resolveT(label), resolveT(value)]) + : section.rows?.map((row) => + row.map((cell) => (typeof cell === "number" ? cell : resolveT(cell))), + ); const translatedColumns = section.columns?.map((column) => resolveT(column)); + // Re-build the StructuredSection explicitly (no spread) so the narrowed + // resolved field types win over `DataSourceDetailSection`'s wider + // `I18nToken | string` slots. return { - ...section, - content: section.content === undefined ? undefined : resolveT(section.content), - items: section.items?.map((item) => resolveT(item)), + id: undefined, title: translatedTitle, - rows: translatedRows, + type: section.type, columns: translatedColumns, + rows: translatedRows, + items: section.items?.map((item) => resolveT(item)), + content: section.content === undefined ? undefined : resolveT(section.content), + imageUrl: section.imageUrl, + imageAlt: section.imageAlt, + linkUrl: section.linkUrl, + embedUrl: section.embedUrl, + embedType: section.embedType, + sectionIcon: section.sectionIcon, + pricingPlans: section.pricingPlans, + collapsed: section.collapsed, }; } diff --git a/integrations/parking/providers/__tests__/bnls-fr-equivalence.test.ts b/integrations/parking/providers/__tests__/bnls-fr-equivalence.test.ts index fedb6b0f..6444ff13 100644 --- a/integrations/parking/providers/__tests__/bnls-fr-equivalence.test.ts +++ b/integrations/parking/providers/__tests__/bnls-fr-equivalence.test.ts @@ -1,6 +1,12 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; -import type { BnlsFrRecord, ParkingFacility, ParkingType } from "@openmapx/mobility-core/parking"; +import { token } from "@openmapx/integration-framework/strings"; +import type { + BnlsFrRecord, + I18nTokenLike, + ParkingFacility, + ParkingType, +} from "@openmapx/mobility-core/parking"; import { describe, expect, it } from "vitest"; import { mapBnlsPayload } from "../bnls-fr-mapper.js"; import { parseBnlsFrStatic } from "../bnls-fr-parser.js"; @@ -15,18 +21,20 @@ const TYPE_MAP: Record = { enclos_en_surface: "surface", }; -function refTariffRows(record: BnlsFrRecord): [string, string][] | undefined { - const rows: [string, string][] = []; - if (record.cost_1h != null) rows.push(["1h", `€${record.cost_1h.toFixed(2)}`]); - if (record.cost_2h != null) rows.push(["2h", `€${record.cost_2h.toFixed(2)}`]); - if (record.cost_3h != null) rows.push(["3h", `€${record.cost_3h.toFixed(2)}`]); - if (record.cost_4h != null) rows.push(["4h", `€${record.cost_4h.toFixed(2)}`]); - if (record.cost_24h != null) rows.push(["24h", `€${record.cost_24h.toFixed(2)}`]); +function refTariffRows(record: BnlsFrRecord): [I18nTokenLike, string][] | undefined { + const rows: [I18nTokenLike, string][] = []; + if (record.cost_1h != null) rows.push([token("tariff.dur1h"), `€${record.cost_1h.toFixed(2)}`]); + if (record.cost_2h != null) rows.push([token("tariff.dur2h"), `€${record.cost_2h.toFixed(2)}`]); + if (record.cost_3h != null) rows.push([token("tariff.dur3h"), `€${record.cost_3h.toFixed(2)}`]); + if (record.cost_4h != null) rows.push([token("tariff.dur4h"), `€${record.cost_4h.toFixed(2)}`]); + if (record.cost_24h != null) { + rows.push([token("tariff.dur1day"), `€${record.cost_24h.toFixed(2)}`]); + } if (record.resident_sub != null) { - rows.push(["Monthly (resident)", `€${record.resident_sub.toFixed(2)}`]); + rows.push([token("tariff.monthlyResident"), `€${record.resident_sub.toFixed(2)}`]); } if (record.non_resident_sub != null) { - rows.push(["Monthly", `€${record.non_resident_sub.toFixed(2)}`]); + rows.push([token("tariff.monthly"), `€${record.non_resident_sub.toFixed(2)}`]); } return rows.length > 0 ? rows : undefined; } diff --git a/integrations/parking/providers/__tests__/db-bahnpark-equivalence.test.ts b/integrations/parking/providers/__tests__/db-bahnpark-equivalence.test.ts index 85f2ebaf..eb79b5c2 100644 --- a/integrations/parking/providers/__tests__/db-bahnpark-equivalence.test.ts +++ b/integrations/parking/providers/__tests__/db-bahnpark-equivalence.test.ts @@ -1,7 +1,9 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; +import { token } from "@openmapx/integration-framework/strings"; import type { DbBahnParkFacility, + I18nTokenLike, ParkingFacility, ParkingType, } from "@openmapx/mobility-core/parking"; @@ -26,17 +28,17 @@ function refMapType(typeName?: string): ParkingType { return TYPE_MAP[typeName] ?? "unknown"; } -const DURATION_LABELS: Record = { - "20min": "20 min", - "30min": "30 min", - "1hour": "1h", - "1day": "1 day", - "1dayPCard": "1 day (P-Card)", - "1week": "1 week", - "1weekPCard": "1 week (P-Card)", - "1monthVendingMachine": "1 month", - "1monthLongTerm": "1 month (long-term)", - "1monthReservation": "1 month (reserved)", +const DURATION_TOKEN: Record = { + "20min": token("tariff.dur20min"), + "30min": token("tariff.dur30min"), + "1hour": token("tariff.dur1h"), + "1day": token("tariff.dur1day"), + "1dayPCard": token("tariff.dur1dayPCard"), + "1week": token("tariff.dur1week"), + "1weekPCard": token("tariff.dur1weekPCard"), + "1monthVendingMachine": token("tariff.dur1month"), + "1monthLongTerm": token("tariff.dur1monthLong"), + "1monthReservation": token("tariff.dur1monthReserved"), }; function refExtractName(f: DbBahnParkFacility): string { @@ -57,14 +59,14 @@ function refExtractCapacity(f: DbBahnParkFacility): { total?: number; disabled?: return { total, disabled }; } -function refBuildTariffRows(f: DbBahnParkFacility): [string, string][] | undefined { +function refBuildTariffRows(f: DbBahnParkFacility): [I18nTokenLike | string, string][] | undefined { const prices = f.tariff?.prices; if (!prices || prices.length === 0) return undefined; - const rows: [string, string][] = []; + const rows: [I18nTokenLike | string, string][] = []; for (const p of prices) { if (p.price == null || !p.duration) continue; if (p.group?.groupName !== "standard") continue; - const label = DURATION_LABELS[p.duration] ?? p.duration; + const label = DURATION_TOKEN[p.duration] ?? p.duration; rows.push([label, `€${p.price.toFixed(2)}`]); } return rows.length > 0 ? rows : undefined; diff --git a/integrations/parking/providers/__tests__/mapper.test.ts b/integrations/parking/providers/__tests__/mapper.test.ts index 31b56ee3..ab4cbe1c 100644 --- a/integrations/parking/providers/__tests__/mapper.test.ts +++ b/integrations/parking/providers/__tests__/mapper.test.ts @@ -1,3 +1,4 @@ +import { isI18nToken } from "@openmapx/integration-framework/strings"; import type { ParkingFacility } from "@openmapx/mobility-core/parking"; import { describe, expect, it } from "vitest"; import { mapParkingToDetail, mapParkingToResult } from "../mapper.js"; @@ -26,7 +27,7 @@ describe("parking mapper", () => { ); expect(result.variant).toBe("unknown"); - expect(result.summary).toBe("Availability stale"); + expect(result.summary).toEqual({ $t: "summary.stale" }); }); it("adds freshness, source, license, and quality sections to details", () => { @@ -50,26 +51,69 @@ describe("parking mapper", () => { expect.arrayContaining([ expect.objectContaining({ rows: expect.arrayContaining([ - ["Data Freshness", "Stale"], - ["Last Updated", "2026-05-06 11:00:00 UTC"], + [{ $t: "row.dataFreshness" }, { $t: "shared.value.stale" }], + [{ $t: "shared.row.lastUpdated" }, "2026-05-06 11:00:00 UTC"], ]), - title: "Availability", + title: { $t: "section.availability" }, }), expect.objectContaining({ - items: ["Realtime availability is older than 30 minutes."], - title: "Data Quality", + items: [{ $t: "quality.realtimeStale" }], + title: { $t: "shared.section.dataQuality" }, }), expect.objectContaining({ collapsed: true, rows: expect.arrayContaining([ - ["Source", "MobiData BW"], - ["Source ID", "bw"], - ["License", "dl-de/by-2-0"], - ["Last Updated", "2026-05-06 11:00:00 UTC"], + [{ $t: "shared.row.source" }, "MobiData BW"], + [{ $t: "shared.row.sourceId" }, "bw"], + [{ $t: "shared.row.license" }, "dl-de/by-2-0"], + [{ $t: "shared.row.lastUpdated" }, "2026-05-06 11:00:00 UTC"], ]), - title: "Source", + title: { $t: "shared.section.source" }, }), ]), ); }); + + it("emits I18nToken for section titles and row labels (no English literals cross the contract)", () => { + const facility: ParkingFacility = { + id: "test:1", + name: "Test", + coordinates: [0, 0], + sources: ["test"], + parkingType: "garage", + hasRealtimeData: true, + freeSpaces: 12, + capacity: 50, + }; + const detail = mapParkingToDetail(facility); + for (const section of detail.sections) { + expect( + isI18nToken(section.title), + `section.title should be I18nToken, got: ${JSON.stringify(section.title)}`, + ).toBe(true); + if (section.rows) { + for (const [label] of section.rows) { + expect( + isI18nToken(label), + `row label should be I18nToken, got: ${JSON.stringify(label)}`, + ).toBe(true); + } + } + } + }); + + it("emits I18nToken for summary on result cards", () => { + const facility: ParkingFacility = { + id: "test:1", + name: "Test", + coordinates: [0, 0], + sources: ["test"], + parkingType: "garage", + hasRealtimeData: true, + freeSpaces: 12, + capacity: 50, + }; + const result = mapParkingToResult(facility); + expect(isI18nToken(result.summary)).toBe(true); + }); }); diff --git a/integrations/parking/providers/__tests__/opentransportdata-ch-equivalence.test.ts b/integrations/parking/providers/__tests__/opentransportdata-ch-equivalence.test.ts index c8cf2920..040fbf28 100644 --- a/integrations/parking/providers/__tests__/opentransportdata-ch-equivalence.test.ts +++ b/integrations/parking/providers/__tests__/opentransportdata-ch-equivalence.test.ts @@ -1,6 +1,8 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; +import { token } from "@openmapx/integration-framework/strings"; import type { + I18nTokenLike, ParkingFacility, ParkingSourceAttribution, ParkingType, @@ -73,32 +75,26 @@ function formatChf(cents: number | null | undefined): string | undefined { return `CHF ${(cents / 100).toFixed(2)}`; } -function formatDuration(minutes: number): string { - if (minutes % 1440 === 0) { - const d = minutes / 1440; - return d === 1 ? "1 day" : `${d} days`; - } - if (minutes % 60 === 0) { - const h = minutes / 60; - return h === 1 ? "1 hour" : `${h} hours`; - } - return `${minutes} min`; +function durationToken(minutes: number): I18nTokenLike { + if (minutes % 1440 === 0) return token("tariff.durDays", { count: minutes / 1440 }); + if (minutes % 60 === 0) return token("tariff.durHours", { count: minutes / 60 }); + return token("tariff.durMinutes", { count: minutes }); } -function buildTariffRows(pricing: Record): [string, string][] | undefined { +function buildTariffRows(pricing: Record): [I18nTokenLike, string][] | undefined { if (!pricing) return undefined; - const rows: [string, string][] = []; + const rows: [I18nTokenLike, string][] = []; for (const seg of pricing.priceSegments ?? []) { - const label = formatDuration(seg.startingFrom ?? 0); + const label = durationToken(seg.startingFrom ?? 0); const price = formatChf(seg.price); if (price) rows.push([label, price]); } const day = formatChf(pricing.maximumDayPrice); - if (day) rows.push(["Max day price", day]); + if (day) rows.push([token("tariff.maxDayPrice"), day]); const monthly = formatChf(pricing.monthlyTicketPrice); - if (monthly) rows.push(["Monthly pass", monthly]); + if (monthly) rows.push([token("tariff.monthlyPass"), monthly]); const yearly = formatChf(pricing.yearlyTicketPrice); - if (yearly) rows.push(["Yearly pass", yearly]); + if (yearly) rows.push([token("tariff.yearlyPass"), yearly]); return rows.length ? rows : undefined; } diff --git a/integrations/parking/providers/bnls-fr-mapper.ts b/integrations/parking/providers/bnls-fr-mapper.ts index ae88fe3b..4ce50daf 100644 --- a/integrations/parking/providers/bnls-fr-mapper.ts +++ b/integrations/parking/providers/bnls-fr-mapper.ts @@ -7,27 +7,12 @@ import { asNumberOrUndef, asParkingType, asStringOrUndef, + asTariffRows, } from "./mapper-utils.js"; const STATION_ID_PREFIX = "bnls:"; const SOURCE_ID = "bnls-fr"; -function asTariffRows(value: unknown): [string, string][] | undefined { - if (!Array.isArray(value)) return undefined; - const rows: [string, string][] = []; - for (const item of value) { - if ( - Array.isArray(item) && - item.length === 2 && - typeof item[0] === "string" && - typeof item[1] === "string" - ) { - rows.push([item[0], item[1]]); - } - } - return rows.length > 0 ? rows : undefined; -} - export function mapBnlsPayload(poiId: string, payload: unknown): ParkingFacility { const p = (payload && typeof payload === "object" ? payload : {}) as Record; const coordinates = Array.isArray(p.coordinates) diff --git a/integrations/parking/providers/bnls-fr-parser.ts b/integrations/parking/providers/bnls-fr-parser.ts index 64b3cbbf..54f8007e 100644 --- a/integrations/parking/providers/bnls-fr-parser.ts +++ b/integrations/parking/providers/bnls-fr-parser.ts @@ -1,4 +1,5 @@ -import type { BnlsFrRecord } from "@openmapx/mobility-core/parking"; +import { token } from "@openmapx/integration-framework/strings"; +import type { BnlsFrRecord, I18nTokenLike } from "@openmapx/mobility-core/parking"; import type { PoiRow } from "@openmapx/poi-source-registry"; /** @@ -23,18 +24,20 @@ const TYPE_MAP: Record = { enclos_en_surface: "surface", }; -function buildTariffRows(record: BnlsFrRecord): [string, string][] | undefined { - const rows: [string, string][] = []; - if (record.cost_1h != null) rows.push(["1h", `€${record.cost_1h.toFixed(2)}`]); - if (record.cost_2h != null) rows.push(["2h", `€${record.cost_2h.toFixed(2)}`]); - if (record.cost_3h != null) rows.push(["3h", `€${record.cost_3h.toFixed(2)}`]); - if (record.cost_4h != null) rows.push(["4h", `€${record.cost_4h.toFixed(2)}`]); - if (record.cost_24h != null) rows.push(["24h", `€${record.cost_24h.toFixed(2)}`]); +function buildTariffRows(record: BnlsFrRecord): [I18nTokenLike, string][] | undefined { + const rows: [I18nTokenLike, string][] = []; + if (record.cost_1h != null) rows.push([token("tariff.dur1h"), `€${record.cost_1h.toFixed(2)}`]); + if (record.cost_2h != null) rows.push([token("tariff.dur2h"), `€${record.cost_2h.toFixed(2)}`]); + if (record.cost_3h != null) rows.push([token("tariff.dur3h"), `€${record.cost_3h.toFixed(2)}`]); + if (record.cost_4h != null) rows.push([token("tariff.dur4h"), `€${record.cost_4h.toFixed(2)}`]); + if (record.cost_24h != null) { + rows.push([token("tariff.dur1day"), `€${record.cost_24h.toFixed(2)}`]); + } if (record.resident_sub != null) { - rows.push(["Monthly (resident)", `€${record.resident_sub.toFixed(2)}`]); + rows.push([token("tariff.monthlyResident"), `€${record.resident_sub.toFixed(2)}`]); } if (record.non_resident_sub != null) { - rows.push(["Monthly", `€${record.non_resident_sub.toFixed(2)}`]); + rows.push([token("tariff.monthly"), `€${record.non_resident_sub.toFixed(2)}`]); } return rows.length > 0 ? rows : undefined; } diff --git a/integrations/parking/providers/db-bahnpark-mapper.ts b/integrations/parking/providers/db-bahnpark-mapper.ts index 347a2e2a..add2c3df 100644 --- a/integrations/parking/providers/db-bahnpark-mapper.ts +++ b/integrations/parking/providers/db-bahnpark-mapper.ts @@ -7,27 +7,12 @@ import { asParkingType, asState, asStringOrUndef, + asTariffRows, } from "./mapper-utils.js"; const STATION_ID_PREFIX = "db-bahnpark:"; const SOURCE_ID = "db-bahnpark"; -function asTariffRows(value: unknown): [string, string][] | undefined { - if (!Array.isArray(value)) return undefined; - const rows: [string, string][] = []; - for (const item of value) { - if ( - Array.isArray(item) && - item.length === 2 && - typeof item[0] === "string" && - typeof item[1] === "string" - ) { - rows.push([item[0], item[1]]); - } - } - return rows.length > 0 ? rows : undefined; -} - export function mapDbBahnParkPayload(poiId: string, payload: unknown): ParkingFacility { const p = (payload && typeof payload === "object" ? payload : {}) as Record; const coordinates = Array.isArray(p.coordinates) diff --git a/integrations/parking/providers/db-bahnpark-parser.ts b/integrations/parking/providers/db-bahnpark-parser.ts index c7cf12ff..43c9146d 100644 --- a/integrations/parking/providers/db-bahnpark-parser.ts +++ b/integrations/parking/providers/db-bahnpark-parser.ts @@ -1,4 +1,9 @@ -import type { DbBahnParkFacility, ParkingType } from "@openmapx/mobility-core/parking"; +import { token } from "@openmapx/integration-framework/strings"; +import type { + DbBahnParkFacility, + I18nTokenLike, + ParkingType, +} from "@openmapx/mobility-core/parking"; import type { PoiRow } from "@openmapx/poi-source-registry"; /** @@ -42,28 +47,30 @@ function extractCapacity(facility: DbBahnParkFacility): { total?: number; disabl return { total, disabled }; } -const DURATION_LABELS: Record = { - "20min": "20 min", - "30min": "30 min", - "1hour": "1h", - "1day": "1 day", - "1dayPCard": "1 day (P-Card)", - "1week": "1 week", - "1weekPCard": "1 week (P-Card)", - "1monthVendingMachine": "1 month", - "1monthLongTerm": "1 month (long-term)", - "1monthReservation": "1 month (reserved)", +const DB_BAHNPARK_DURATION_TOKEN: Record = { + "20min": token("tariff.dur20min"), + "30min": token("tariff.dur30min"), + "1hour": token("tariff.dur1h"), + "1day": token("tariff.dur1day"), + "1dayPCard": token("tariff.dur1dayPCard"), + "1week": token("tariff.dur1week"), + "1weekPCard": token("tariff.dur1weekPCard"), + "1monthVendingMachine": token("tariff.dur1month"), + "1monthLongTerm": token("tariff.dur1monthLong"), + "1monthReservation": token("tariff.dur1monthReserved"), }; -function buildTariffRows(facility: DbBahnParkFacility): [string, string][] | undefined { +function buildTariffRows( + facility: DbBahnParkFacility, +): [I18nTokenLike | string, string][] | undefined { const prices = facility.tariff?.prices; if (!prices || prices.length === 0) return undefined; - const rows: [string, string][] = []; + const rows: [I18nTokenLike | string, string][] = []; for (const p of prices) { if (p.price == null || !p.duration) continue; if (p.group?.groupName !== "standard") continue; - const label = DURATION_LABELS[p.duration] ?? p.duration; + const label = DB_BAHNPARK_DURATION_TOKEN[p.duration] ?? p.duration; rows.push([label, `€${p.price.toFixed(2)}`]); } return rows.length > 0 ? rows : undefined; diff --git a/integrations/parking/providers/mapper-utils.ts b/integrations/parking/providers/mapper-utils.ts index cd32020e..1633a599 100644 --- a/integrations/parking/providers/mapper-utils.ts +++ b/integrations/parking/providers/mapper-utils.ts @@ -1,4 +1,4 @@ -import type { ParkingType } from "@openmapx/mobility-core/parking"; +import type { I18nTokenLike, ParkingType } from "@openmapx/mobility-core/parking"; /** * Shared payload-coercion helpers used by every `*-mapper.ts` in this @@ -85,3 +85,34 @@ export function asParkingType( ): ParkingType | undefined { return PARKING_TYPES.includes(value as ParkingType) ? (value as ParkingType) : fallback; } + +function isI18nTokenLike(value: unknown): value is I18nTokenLike { + return ( + typeof value === "object" && + value !== null && + "$t" in value && + typeof (value as { $t: unknown }).$t === "string" + ); +} + +/** + * Coerce a JSON-deserialized tariff-rows array into `[I18nToken | string, string][]`. + * Labels may be either an `I18nToken` (`{ $t, values? }`) emitted by the + * migrated parsers or a raw string (legacy payloads still in the registry). + * Prices are always pre-formatted strings. + */ +export function asTariffRows(value: unknown): [I18nTokenLike | string, string][] | undefined { + if (!Array.isArray(value)) return undefined; + const rows: [I18nTokenLike | string, string][] = []; + for (const item of value) { + if (!Array.isArray(item) || item.length !== 2) continue; + const [label, price] = item; + if (typeof price !== "string") continue; + if (typeof label === "string") { + rows.push([label, price]); + } else if (isI18nTokenLike(label)) { + rows.push([label, price]); + } + } + return rows.length > 0 ? rows : undefined; +} diff --git a/integrations/parking/providers/mapper.ts b/integrations/parking/providers/mapper.ts index 3b3bc3f2..7bc930e9 100644 --- a/integrations/parking/providers/mapper.ts +++ b/integrations/parking/providers/mapper.ts @@ -4,6 +4,12 @@ import type { DataSourceResult, OsmIdentity, } from "@openmapx/core"; +import { + type I18nToken, + sharedT, + type Translatable, + token, +} from "@openmapx/integration-framework/strings"; import type { ParkingFacility, ParkingType } from "@openmapx/mobility-core/parking"; function facilityIdentity(facility: ParkingFacility): OsmIdentity | undefined { @@ -11,18 +17,31 @@ function facilityIdentity(facility: ParkingFacility): OsmIdentity | undefined { return { operator: facility.operator }; } -/** - * All string values emitted here must either: - * - Be numeric/data (e.g. "126 / 220", "2.10 m") — not translated - * - Have a matching entry in ROW_LABEL_KEYS on the frontend — translated via i18n - */ +const PARKING_TYPE_TOKEN: Record = { + garage: token("value.parkingGarage"), + underground: token("value.undergroundGarage"), + surface: token("value.surfaceLot"), + "on-street": token("value.onStreet"), + unknown: sharedT.value.unknown, +}; + +const TREND_TOKEN: Record<"increasing" | "decreasing" | "constant", I18nToken> = { + increasing: token("value.trendIncreasing"), + decreasing: token("value.trendDecreasing"), + constant: token("value.trendConstant"), +}; + +const ACCESS_TOKEN: Record, I18nToken> = { + public: sharedT.value.public, + customers: sharedT.value.customers, + private: sharedT.value.private, + permit: sharedT.value.permit, +}; -const PARKING_TYPE_LABELS: Record = { - garage: "Parking Garage", - underground: "Underground Garage", - surface: "Surface Lot", - "on-street": "On-Street", - unknown: "Parking", +const STATE_TOKEN: Record, I18nToken> = { + open: sharedT.value.open, + closed: sharedT.value.closed, + unknown: sharedT.value.unknown, }; function computeVariant(facility: ParkingFacility): string { @@ -37,16 +56,21 @@ function computeVariant(facility: ParkingFacility): string { return "available"; } -function buildSummary(facility: ParkingFacility): string | undefined { - if (facility.state === "closed") return "Closed"; - if (facility.isStale) return "Availability stale"; +function buildSummary(facility: ParkingFacility): I18nToken { + if (facility.state === "closed") return token("summary.closed"); + if (facility.isStale) return token("summary.stale"); if (facility.hasRealtimeData && facility.freeSpaces !== undefined) { - if (facility.freeSpaces === 0) return "Full"; - if (facility.capacity) return `${facility.freeSpaces}/${facility.capacity} free`; - return `${facility.freeSpaces} free`; + if (facility.freeSpaces === 0) return token("summary.full"); + if (facility.capacity) { + return token("summary.spacesOf", { + free: facility.freeSpaces, + capacity: facility.capacity, + }); + } + return token("summary.spaces", { count: facility.freeSpaces }); } - if (facility.capacity) return `${facility.capacity} spaces`; - return PARKING_TYPE_LABELS[facility.parkingType]; + if (facility.capacity) return token("summary.totalSpaces", { count: facility.capacity }); + return PARKING_TYPE_TOKEN[facility.parkingType]; } function buildSortValues(facility: ParkingFacility): Record | undefined { @@ -70,10 +94,6 @@ export function mapParkingToResult(facility: ParkingFacility): DataSourceResult }; } -function capitalize(s: string): string { - return s.charAt(0).toUpperCase() + s.slice(1); -} - function formatTimestamp(value: string | undefined): string | undefined { if (!value) return undefined; const time = Date.parse(value); @@ -87,101 +107,107 @@ function formatTimestamp(value: string | undefined): string | undefined { export function mapParkingToDetail(facility: ParkingFacility): DataSourceDetail { const sections: DataSourceDetailSection[] = []; - // Availability section (real-time data only) if (facility.hasRealtimeData && facility.freeSpaces !== undefined) { - const rows: (string | number)[][] = []; + const rows: [I18nToken, Translatable][] = []; if (facility.capacity) { - rows.push(["Free Spaces", `${facility.freeSpaces} / ${facility.capacity}`]); + rows.push([token("row.freeSpaces"), `${facility.freeSpaces} / ${facility.capacity}`]); const occupancy = Math.round( ((facility.capacity - facility.freeSpaces) / facility.capacity) * 100, ); - rows.push(["Occupancy", `${occupancy}%`]); + rows.push([token("row.occupancy"), `${occupancy}%`]); } else { - rows.push(["Free Spaces", facility.freeSpaces]); + rows.push([token("row.freeSpaces"), facility.freeSpaces]); } if (facility.state && facility.state !== "unknown") { - rows.push(["Status", capitalize(facility.state)]); + rows.push([sharedT.row.status, STATE_TOKEN[facility.state]]); } if (facility.trend && facility.trend !== "constant") { - rows.push(["Trend", capitalize(facility.trend)]); + rows.push([token("row.trend"), TREND_TOKEN[facility.trend]]); } if (facility.isStale) { - rows.push(["Data Freshness", "Stale"]); + rows.push([token("row.dataFreshness"), sharedT.value.stale]); } const updatedAt = formatTimestamp(facility.realtimeDataUpdatedAt ?? facility.dataUpdatedAt); if (updatedAt) { - rows.push(["Last Updated", updatedAt]); + rows.push([sharedT.row.lastUpdated, updatedAt]); } - sections.push({ title: "Availability", type: "table", rows, sectionIcon: "info" }); + sections.push({ + title: token("section.availability"), + type: "table", + rows, + sectionIcon: "info", + }); } - // Facility info - const infoRows: (string | number)[][] = []; - infoRows.push(["Type", PARKING_TYPE_LABELS[facility.parkingType]]); + const infoRows: [I18nToken, Translatable][] = []; + infoRows.push([sharedT.row.type, PARKING_TYPE_TOKEN[facility.parkingType]]); if (facility.capacity) { - infoRows.push(["Capacity", `${facility.capacity}`]); + infoRows.push([sharedT.row.capacity, `${facility.capacity}`]); } if (facility.maxHeight) { - infoRows.push(["Max Height", `${(facility.maxHeight / 100).toFixed(2)} m`]); + infoRows.push([token("row.maxHeight"), `${(facility.maxHeight / 100).toFixed(2)} m`]); } if (facility.disabledSpaces) { - infoRows.push(["Disabled Spaces", facility.disabledSpaces]); + infoRows.push([token("row.disabledSpaces"), facility.disabledSpaces]); } if (facility.womenSpaces) { - infoRows.push(["Women's Spaces", facility.womenSpaces]); + infoRows.push([token("row.womenSpaces"), facility.womenSpaces]); } if (facility.chargingSpaces) { const label = facility.chargingDetails ?? `${facility.chargingSpaces}`; - infoRows.push(["EV Charging", label]); + infoRows.push([token("row.evCharging"), label]); } if (facility.parkAndRide) { - infoRows.push(["Park & Ride", "Yes"]); + infoRows.push([token("row.parkAndRide"), sharedT.value.yes]); } if (facility.nearestStation) { - infoRows.push(["Nearest Station", facility.nearestStation]); + infoRows.push([token("row.nearestStation"), facility.nearestStation]); } if (facility.access && facility.access !== "public") { - infoRows.push(["Access", capitalize(facility.access)]); + infoRows.push([sharedT.row.access, ACCESS_TOKEN[facility.access]]); } if (infoRows.length > 0) { - sections.push({ title: "Facility", type: "table", rows: infoRows, sectionIcon: "info" }); + sections.push({ + title: token("section.facility"), + type: "table", + rows: infoRows, + sectionIcon: "info", + }); } - // Fee info — structured tariff rows (DB BahnPark) or free-text description (v3) if (facility.tariffRows && facility.tariffRows.length > 0) { sections.push({ - title: "Pricing", + title: sharedT.section.pricing, type: "table", rows: facility.tariffRows, sectionIcon: "payments", }); } else if (facility.feeDescription) { sections.push({ - title: "Pricing", + title: sharedT.section.pricing, type: "text", content: facility.feeDescription, sectionIcon: "payments", }); } else if (facility.fee === "free") { sections.push({ - title: "Pricing", + title: sharedT.section.pricing, type: "text", - content: "Free Parking", + content: token("value.freeParking"), sectionIcon: "payments", }); } else if (facility.fee === "paid") { sections.push({ - title: "Pricing", + title: sharedT.section.pricing, type: "text", - content: "Paid Parking", + content: token("value.paidParking"), sectionIcon: "payments", }); } - // Payment methods if (facility.paymentMethods) { sections.push({ - title: "Payment", + title: sharedT.section.payment, type: "text", content: facility.paymentMethods, sectionIcon: "payments", @@ -191,28 +217,28 @@ export function mapParkingToDetail(facility: ParkingFacility): DataSourceDetail if (facility.qualityWarnings && facility.qualityWarnings.length > 0) { sections.push({ - title: "Data Quality", + title: sharedT.section.dataQuality, type: "list", - items: facility.qualityWarnings, + items: facility.qualityWarnings.map((w) => mapQualityWarning(w)), sectionIcon: "warning", collapsed: true, }); } - const sourceRows: (string | number)[][] = []; + const sourceRows: [I18nToken, Translatable][] = []; const sourceName = facility.sourceAttribution?.contributor ?? facility.sourceAttribution?.name ?? facility.sourceName; - if (sourceName) sourceRows.push(["Source", sourceName]); - if (facility.sourceUid) sourceRows.push(["Source ID", facility.sourceUid]); + if (sourceName) sourceRows.push([sharedT.row.source, sourceName]); + if (facility.sourceUid) sourceRows.push([sharedT.row.sourceId, facility.sourceUid]); const license = facility.sourceAttribution?.license; - if (license) sourceRows.push(["License", license]); + if (license) sourceRows.push([sharedT.row.license, license]); const sourceUpdatedAt = formatTimestamp(facility.dataUpdatedAt); - if (sourceUpdatedAt) sourceRows.push(["Last Updated", sourceUpdatedAt]); + if (sourceUpdatedAt) sourceRows.push([sharedT.row.lastUpdated, sourceUpdatedAt]); if (sourceRows.length > 0) { sections.push({ - title: "Source", + title: sharedT.section.source, type: "table", rows: sourceRows, sectionIcon: "info", @@ -233,3 +259,24 @@ export function mapParkingToDetail(facility: ParkingFacility): DataSourceDetail parkAndRide: facility.parkAndRide ? true : undefined, }; } + +/** + * Map a known quality-warning string to its token. The mapper inputs are + * strings emitted by other parts of the parking pipeline (validators, + * clampers); when unknown, pass through as-is. The known set is small and + * documented in `quality.*` of the parking strings catalog. + */ +function mapQualityWarning(warning: string): I18nToken | string { + switch (warning) { + case "Realtime availability is older than 30 minutes.": + return token("quality.realtimeStale"); + case "Realtime free-space count exceeded capacity and was clamped.": + return token("quality.freeSpacesClamped"); + case "Realtime free-space count was negative and was clamped to 0.": + return token("quality.negativeFreeSpacesClamped"); + case "EV charging available": + return token("quality.evChargingAvailable"); + default: + return warning; + } +} diff --git a/integrations/parking/providers/opentransportdata-ch-bundled-parser.ts b/integrations/parking/providers/opentransportdata-ch-bundled-parser.ts index 4a8c339d..424b7de1 100644 --- a/integrations/parking/providers/opentransportdata-ch-bundled-parser.ts +++ b/integrations/parking/providers/opentransportdata-ch-bundled-parser.ts @@ -1,5 +1,6 @@ import { fetchWithRedirects, USER_AGENT } from "@openmapx/core"; -import type { ParkingType } from "@openmapx/mobility-core/parking"; +import { token } from "@openmapx/integration-framework/strings"; +import type { I18nTokenLike, ParkingType } from "@openmapx/mobility-core/parking"; import type { PoiBundledParseFn, PoiLiveState, @@ -164,35 +165,33 @@ function formatChf(cents: number | null | undefined): string | undefined { return `CHF ${(cents / 100).toFixed(2)}`; } -function formatDuration(minutes: number): string { +function durationToken(minutes: number): I18nTokenLike { if (minutes % 1440 === 0) { - const days = minutes / 1440; - return days === 1 ? "1 day" : `${days} days`; + return token("tariff.durDays", { count: minutes / 1440 }); } if (minutes % 60 === 0) { - const hours = minutes / 60; - return hours === 1 ? "1 hour" : `${hours} hours`; + return token("tariff.durHours", { count: minutes / 60 }); } - return `${minutes} min`; + return token("tariff.durMinutes", { count: minutes }); } function buildTariffRows( properties: SwissParkingFeatureProperties | undefined, -): [string, string][] | undefined { +): [I18nTokenLike, string][] | undefined { const pricing = properties?.pricingModel; if (!pricing) return undefined; - const rows: [string, string][] = []; + const rows: [I18nTokenLike, string][] = []; for (const segment of pricing.priceSegments ?? []) { - const label = formatDuration(segment.startingFrom ?? 0); + const label = durationToken(segment.startingFrom ?? 0); const price = formatChf(segment.price); if (price) rows.push([label, price]); } const dayPrice = formatChf(pricing.maximumDayPrice); - if (dayPrice) rows.push(["Max day price", dayPrice]); + if (dayPrice) rows.push([token("tariff.maxDayPrice"), dayPrice]); const monthly = formatChf(pricing.monthlyTicketPrice); - if (monthly) rows.push(["Monthly pass", monthly]); + if (monthly) rows.push([token("tariff.monthlyPass"), monthly]); const yearly = formatChf(pricing.yearlyTicketPrice); - if (yearly) rows.push(["Yearly pass", yearly]); + if (yearly) rows.push([token("tariff.yearlyPass"), yearly]); return rows.length > 0 ? rows : undefined; } diff --git a/integrations/parking/providers/opentransportdata-ch-mapper.ts b/integrations/parking/providers/opentransportdata-ch-mapper.ts index a9ad32a2..98da549e 100644 --- a/integrations/parking/providers/opentransportdata-ch-mapper.ts +++ b/integrations/parking/providers/opentransportdata-ch-mapper.ts @@ -8,6 +8,7 @@ import { asNumberOrUndef, asParkingType, asStringOrUndef, + asTariffRows, } from "./mapper-utils.js"; const MAX_LIVE_AGE_MS = 30 * 60 * 1000; @@ -31,22 +32,6 @@ const SOURCE_ATTRIBUTION: ParkingSourceAttribution = { url: DATASET_PAGE_URL, }; -function asTariffRows(value: unknown): [string, string][] | undefined { - if (!Array.isArray(value)) return undefined; - const out: [string, string][] = []; - for (const row of value) { - if ( - Array.isArray(row) && - row.length === 2 && - typeof row[0] === "string" && - typeof row[1] === "string" - ) { - out.push([row[0], row[1]]); - } - } - return out.length > 0 ? out : undefined; -} - export function mapOpenTransportDataChPayload(poiId: string, payload: unknown): ParkingFacility { const p = (payload && typeof payload === "object" ? payload : {}) as Record; const coordinates = Array.isArray(p.coordinates) diff --git a/integrations/parking/strings/de.json b/integrations/parking/strings/de.json index 89ddc804..e8c4c2ea 100644 --- a/integrations/parking/strings/de.json +++ b/integrations/parking/strings/de.json @@ -1,6 +1,73 @@ { "name": "Parkeinrichtungen", "description": "Parkeinrichtungen von DB BahnPark, ParkAPI, RDW, BNLS und OpenStreetMap", + "section": { + "availability": "Verfügbarkeit", + "facility": "Einrichtung", + "dataQuality": "Datenqualität", + "fee": "Gebühren" + }, + "row": { + "freeSpaces": "Freie Plätze", + "occupancy": "Auslastung", + "trend": "Tendenz", + "maxHeight": "Max. Höhe", + "disabledSpaces": "Behindertenplätze", + "womenSpaces": "Frauenparkplätze", + "evCharging": "E-Ladeplätze", + "parkAndRide": "Park & Ride", + "nearestStation": "Nächster Bahnhof", + "dataFreshness": "Datenaktualität" + }, + "value": { + "parkingGarage": "Parkhaus", + "undergroundGarage": "Tiefgarage", + "surfaceLot": "Parkplatz", + "onStreet": "Straßenrand", + "freeParking": "Kostenloses Parken", + "paidParking": "Kostenpflichtig", + "trendIncreasing": "Füllt sich", + "trendDecreasing": "Leert sich", + "trendConstant": "Konstant" + }, + "summary": { + "closed": "Geschlossen", + "full": "Voll", + "stale": "Auslastung veraltet", + "spacesOf": "{free}/{capacity} frei", + "spaces": "{count} frei", + "totalSpaces": "{count} Plätze" + }, + "tariff": { + "maxDayPrice": "Tageshöchstpreis", + "monthly": "Monatlich", + "monthlyResident": "Monatlich (Anwohner)", + "monthlyPass": "Monatskarte", + "yearlyPass": "Jahreskarte", + "dur20min": "20 Min.", + "dur30min": "30 Min.", + "dur1h": "1 Std.", + "dur2h": "2 Std.", + "dur3h": "3 Std.", + "dur4h": "4 Std.", + "dur1day": "1 Tag", + "dur1dayPCard": "1 Tag (P-Card)", + "dur1week": "1 Woche", + "dur1weekPCard": "1 Woche (P-Card)", + "dur1month": "1 Monat", + "dur1monthPCard": "1 Monat (P-Card)", + "dur1monthLong": "1 Monat (Dauerparker)", + "dur1monthReserved": "1 Monat (reserviert)", + "durMinutes": "{count} Min.", + "durHours": "{count, plural, =1 {1 Std.} other {# Std.}}", + "durDays": "{count, plural, =1 {1 Tag} other {# Tage}}" + }, + "quality": { + "realtimeStale": "Echtzeit-Verfügbarkeit ist älter als 30 Minuten.", + "freeSpacesClamped": "Echtzeit-Anzahl freier Plätze überschritt die Kapazität und wurde gekappt.", + "negativeFreeSpacesClamped": "Echtzeit-Anzahl freier Plätze war negativ und wurde auf 0 gesetzt.", + "evChargingAvailable": "E-Ladeplätze verfügbar" + }, "dataSources": [ { "purpose": "Echtzeit-Parkplatzverfügbarkeit in europäischen Städten", diff --git a/integrations/parking/strings/en.json b/integrations/parking/strings/en.json index ca32f3a8..d7cb1603 100644 --- a/integrations/parking/strings/en.json +++ b/integrations/parking/strings/en.json @@ -1,6 +1,73 @@ { "name": "Parking Facilities", "description": "Parking facility locations, capacity, and occupancy", + "section": { + "availability": "Availability", + "facility": "Facility", + "dataQuality": "Data Quality", + "fee": "Fee" + }, + "row": { + "freeSpaces": "Free Spaces", + "occupancy": "Occupancy", + "trend": "Trend", + "maxHeight": "Max Height", + "disabledSpaces": "Disabled Spaces", + "womenSpaces": "Women's Spaces", + "evCharging": "EV Charging", + "parkAndRide": "Park & Ride", + "nearestStation": "Nearest Station", + "dataFreshness": "Data Freshness" + }, + "value": { + "parkingGarage": "Parking Garage", + "undergroundGarage": "Underground Garage", + "surfaceLot": "Surface Lot", + "onStreet": "On-Street", + "freeParking": "Free Parking", + "paidParking": "Paid Parking", + "trendIncreasing": "Filling up", + "trendDecreasing": "Emptying", + "trendConstant": "Steady" + }, + "summary": { + "closed": "Closed", + "full": "Full", + "stale": "Availability stale", + "spacesOf": "{free}/{capacity} free", + "spaces": "{count} free", + "totalSpaces": "{count} spaces" + }, + "tariff": { + "maxDayPrice": "Max day price", + "monthly": "Monthly", + "monthlyResident": "Monthly (resident)", + "monthlyPass": "Monthly pass", + "yearlyPass": "Yearly pass", + "dur20min": "20 min", + "dur30min": "30 min", + "dur1h": "1 hour", + "dur2h": "2 hours", + "dur3h": "3 hours", + "dur4h": "4 hours", + "dur1day": "1 day", + "dur1dayPCard": "1 day (P-Card)", + "dur1week": "1 week", + "dur1weekPCard": "1 week (P-Card)", + "dur1month": "1 month", + "dur1monthPCard": "1 month (P-Card)", + "dur1monthLong": "1 month (long-term)", + "dur1monthReserved": "1 month (reserved)", + "durMinutes": "{count} min", + "durHours": "{count, plural, =1 {1 hour} other {# hours}}", + "durDays": "{count, plural, =1 {1 day} other {# days}}" + }, + "quality": { + "realtimeStale": "Realtime availability is older than 30 minutes.", + "freeSpacesClamped": "Realtime free-space count exceeded capacity and was clamped.", + "negativeFreeSpacesClamped": "Realtime free-space count was negative and was clamped to 0.", + "evChargingAvailable": "EV charging available" + }, "dataSources": [ { "purpose": "Real-time parking lot availability across European cities", diff --git a/packages/integration-framework/src/contracts/mobility-data-source-provider.ts b/packages/integration-framework/src/contracts/mobility-data-source-provider.ts index 815df9d9..3f00a7f5 100644 --- a/packages/integration-framework/src/contracts/mobility-data-source-provider.ts +++ b/packages/integration-framework/src/contracts/mobility-data-source-provider.ts @@ -1,6 +1,7 @@ import type { BoundingBox, LngLat, OsmFilter } from "@openmapx/core"; import type { Attribution } from "@openmapx/mobility-core/attribution"; import type { MobilityResult } from "@openmapx/mobility-core/result"; +import type { I18nToken, Translatable } from "../../strings/index.js"; export interface DataSourceAttribution { text: string; @@ -104,7 +105,8 @@ export interface DataSourceResult { kind?: "station" | "vehicle"; variant: string; status?: string; - summary?: string; + /** transitional, Task 4.1 tightens to I18nToken-only */ + summary?: I18nToken | string; operator?: string; branding?: DataSourceBranding; mapContext?: DataSourceMapContextSelection; @@ -124,12 +126,22 @@ export interface PricingPlanEntry { } export interface DataSourceDetailSection { - title: string; + /** transitional, Task 4.1 tightens to I18nToken-only */ + title: I18nToken | string; type: "table" | "list" | "text" | "image" | "embed" | "pricing"; - columns?: string[]; - rows?: (string | number)[][]; - items?: string[]; - content?: string; + /** transitional, Task 4.1 tightens to I18nToken-only */ + columns?: (I18nToken | string)[]; + /** + * Row tuple `[label, ...values]`. Label is an i18n token (or transitional + * string). Values are `Translatable` — token, raw string, or number. + * + * transitional, Task 4.1 tightens label slot to I18nToken-only. + */ + rows?: (Translatable | number)[][]; + /** transitional, Task 4.1 tightens to I18nToken-only */ + items?: (I18nToken | string)[]; + /** transitional, Task 4.1 tightens to I18nToken-only */ + content?: I18nToken | string; /** Image URL for type "image". Rendered as a safe element. */ imageUrl?: string; /** Alt text for image sections. */ diff --git a/packages/mobility-core/src/types/parking.ts b/packages/mobility-core/src/types/parking.ts index 54251d13..063702c2 100644 --- a/packages/mobility-core/src/types/parking.ts +++ b/packages/mobility-core/src/types/parking.ts @@ -1,5 +1,16 @@ export type ParkingType = "garage" | "surface" | "underground" | "on-street" | "unknown"; +/** + * Structural mirror of `I18nToken` from `@openmapx/integration-framework/strings`, + * inlined here to avoid a `mobility-core` → `integration-framework` import cycle. + * Provider mappers/parsers populate `tariffRows` with the real `I18nToken` + * objects; this type just describes the JSON-serializable shape they carry. + */ +export interface I18nTokenLike { + $t: string; + values?: Record; +} + export interface ParkingSourceAttribution { name?: string; url?: string; @@ -49,8 +60,13 @@ export interface ParkingFacility { fee?: "free" | "paid" | "unknown"; feeDescription?: string; - /** Structured pricing rows: [durationLabel, formattedPrice] */ - tariffRows?: [string, string][]; + /** + * Structured pricing rows: `[durationLabel, formattedPrice]`. The label is + * an `I18nToken` emitted by parsers; the price is a pre-formatted string + * (`€2.10`, `CHF 1.50`). Strings are accepted transitionally — Task 4.1 of + * the i18n-token rollout tightens this to `[I18nToken, string][]`. + */ + tariffRows?: [I18nTokenLike | string, string][]; access?: "public" | "customers" | "private" | "permit"; operator?: string; From 3138098258cffe2733706acf8182c02d90863314 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 28 May 2026 01:38:59 +0200 Subject: [PATCH 08/25] refactor(fuel): emit I18nToken across data-source contract --- .../__tests__/station-mapper.test.ts | 98 +++++++++++++++++++ .../ev-charging/providers/station-mapper.ts | 97 ++++++++++++------ integrations/ev-charging/strings/de.json | 21 ++++ integrations/ev-charging/strings/en.json | 21 ++++ 4 files changed, 208 insertions(+), 29 deletions(-) create mode 100644 integrations/ev-charging/providers/__tests__/station-mapper.test.ts diff --git a/integrations/ev-charging/providers/__tests__/station-mapper.test.ts b/integrations/ev-charging/providers/__tests__/station-mapper.test.ts new file mode 100644 index 00000000..5c94789b --- /dev/null +++ b/integrations/ev-charging/providers/__tests__/station-mapper.test.ts @@ -0,0 +1,98 @@ +import { isI18nToken } from "@openmapx/integration-framework/strings"; +import type { EvChargingStation } from "@openmapx/mobility-core/ev-charging"; +import { describe, expect, it } from "vitest"; +import { mapStationToDetail, mapStationToResult } from "../station-mapper.js"; + +function makeStation(overrides: Partial = {}): EvChargingStation { + return { + id: "ocm:1", + name: "Test Charger", + coordinates: [11.575, 48.137], + sources: ["ocm"], + connectors: [ + { type: "CCS", powerKw: 50, currentType: "DC", quantity: 2, status: "operational" }, + ], + ...overrides, + }; +} + +describe("ev-charging station mapper", () => { + it("emits I18nToken for section titles and row labels (no English literals cross the contract)", () => { + const station = makeStation({ + access: "24/7", + membershipRequired: true, + notes: ["Open year-round"], + paymentMethods: ["Credit card", "App"], + sourceUrl: "https://example.com/station/1", + updatedAt: "2026-05-06T11:00:00.000Z", + usageCost: "0.35 EUR/kWh", + usageType: "Public", + }); + const detail = mapStationToDetail(station); + + expect(detail.sections.length).toBeGreaterThan(0); + for (const section of detail.sections) { + expect( + isI18nToken(section.title), + `section.title should be I18nToken, got: ${JSON.stringify(section.title)}`, + ).toBe(true); + if (section.columns) { + for (const column of section.columns) { + expect( + isI18nToken(column), + `column should be I18nToken, got: ${JSON.stringify(column)}`, + ).toBe(true); + } + } + // Label/value tables (no header columns) — the first cell of each row is a label. + // Tables with `columns` (header row) carry data cells only, not labels. + if (section.rows && !section.columns) { + for (const [label] of section.rows) { + expect( + isI18nToken(label), + `row label should be I18nToken, got: ${JSON.stringify(label)}`, + ).toBe(true); + } + } + } + }); + + it("emits I18nToken for summary on result cards", () => { + const result = mapStationToResult(makeStation()); + expect(isI18nToken(result.summary)).toBe(true); + }); + + it("uses shared tokens for Access, Notes, and Source sections", () => { + const detail = mapStationToDetail( + makeStation({ + access: "24/7", + notes: ["Free for customers"], + sourceUrl: "https://example.com/station/1", + updatedAt: "2026-05-06T11:00:00.000Z", + }), + ); + + expect(detail.sections).toEqual( + expect.arrayContaining([ + expect.objectContaining({ title: { $t: "shared.section.access" } }), + expect.objectContaining({ title: { $t: "shared.section.notes" } }), + expect.objectContaining({ + title: { $t: "shared.section.source" }, + rows: expect.arrayContaining([ + [{ $t: "shared.row.sources" }, "ocm"], + [{ $t: "shared.row.lastUpdated" }, "2026-05-06 11:00:00 UTC"], + [{ $t: "shared.row.sourceUrl" }, "https://example.com/station/1"], + ]), + }), + ]), + ); + }); + + it("emits parameterized summary tokens with connector count and power", () => { + const summary = mapStationToResult(makeStation()).summary; + expect(summary).toEqual({ + $t: "summary.connectorsTypedPower", + values: { count: 2, types: "CCS", power: 50 }, + }); + }); +}); diff --git a/integrations/ev-charging/providers/station-mapper.ts b/integrations/ev-charging/providers/station-mapper.ts index a7383560..90477a2a 100644 --- a/integrations/ev-charging/providers/station-mapper.ts +++ b/integrations/ev-charging/providers/station-mapper.ts @@ -4,6 +4,12 @@ import type { DataSourceResult, OsmIdentity, } from "@openmapx/core"; +import { + type I18nToken, + sharedT, + type Translatable, + token, +} from "@openmapx/integration-framework/strings"; import type { EvChargingConnector, EvChargingStation } from "@openmapx/mobility-core/ev-charging"; function stationIdentity(station: EvChargingStation): OsmIdentity | undefined { @@ -40,21 +46,34 @@ function connectorQuantity(connector: EvChargingConnector): number { return connector.quantity && connector.quantity > 0 ? connector.quantity : 1; } -function buildSummary(station: EvChargingStation): string | undefined { - const parts: string[] = []; +function buildSummary(station: EvChargingStation): I18nToken | undefined { const totalQty = station.connectors.reduce((sum, conn) => sum + connectorQuantity(conn), 0); const connectorNames = new Set(station.connectors.map((conn) => conn.type).filter(Boolean)); + const maxPower = getMaxPower(station); if (totalQty > 0 && connectorNames.size > 0) { - parts.push(`${totalQty}x ${Array.from(connectorNames).join(", ")}`); - } else if (totalQty > 0) { - parts.push(`${totalQty} connectors`); + if (maxPower > 0) { + return token("summary.connectorsTypedPower", { + count: totalQty, + types: Array.from(connectorNames).join(", "), + power: maxPower, + }); + } + return token("summary.connectorsTyped", { + count: totalQty, + types: Array.from(connectorNames).join(", "), + }); } - - const maxPower = getMaxPower(station); - if (maxPower > 0) parts.push(`${maxPower} kW`); - if (station.operator?.name) parts.push(station.operator.name); - return parts.length > 0 ? parts.join(" · ") : undefined; + if (totalQty > 0) { + if (maxPower > 0) { + return token("summary.connectorsCountPower", { count: totalQty, power: maxPower }); + } + return token("summary.connectorsCount", { count: totalQty }); + } + if (maxPower > 0) { + return token("summary.powerKw", { power: maxPower }); + } + return undefined; } export function mapStationToResult(station: EvChargingStation): DataSourceResult { @@ -87,11 +106,11 @@ function formatTimestamp(value: string | undefined): string | undefined { .replace(/\.\d{3}Z$/, " UTC"); } -function connectorRows(station: EvChargingStation): (string | number)[][] { +function connectorRows(station: EvChargingStation): Translatable[][] { return [...station.connectors] .sort((a, b) => (b.powerKw ?? 0) - (a.powerKw ?? 0)) .map((conn) => [ - conn.type ?? "Unknown", + conn.type ?? sharedT.value.unknown, formatPower(conn.powerKw), conn.currentType ?? "-", connectorQuantity(conn), @@ -104,33 +123,53 @@ export function mapStationToDetail(station: EvChargingStation): DataSourceDetail if (station.connectors.length > 0) { sections.push({ - title: "Connectors", + title: token("section.connectors"), type: "table", - columns: ["Type", "Power", "Current", "Qty", "Status"], + columns: [ + sharedT.row.type, + token("column.power"), + token("column.current"), + token("column.qty"), + sharedT.row.status, + ], rows: connectorRows(station), sectionIcon: "bolt", }); } - const usageItems: string[] = []; - if (station.usageType) usageItems.push(`Access: ${station.usageType}`); - if (station.usageCost) usageItems.push(`Cost: ${station.usageCost}`); - if (station.paymentMethods?.length) - usageItems.push(`Payment: ${station.paymentMethods.join(", ")}`); + const usageRows: [I18nToken, Translatable][] = []; + if (station.usageType) usageRows.push([sharedT.row.access, station.usageType]); + if (station.usageCost) usageRows.push([token("row.cost"), station.usageCost]); + if (station.paymentMethods?.length) { + usageRows.push([token("row.payment"), station.paymentMethods.join(", ")]); + } if (station.membershipRequired !== undefined) { - usageItems.push(`Membership required: ${station.membershipRequired ? "Yes" : "No"}`); + usageRows.push([ + token("row.membershipRequired"), + station.membershipRequired ? sharedT.value.yes : sharedT.value.no, + ]); } - if (usageItems.length > 0) { - sections.push({ title: "Usage", type: "list", items: usageItems, sectionIcon: "payments" }); + if (usageRows.length > 0) { + sections.push({ + title: token("section.usage"), + type: "table", + rows: usageRows, + sectionIcon: "payments", + }); } if (station.access) { - sections.push({ title: "Access", type: "text", content: station.access, sectionIcon: "info" }); + sections.push({ + title: sharedT.section.access, + type: "text", + content: station.access, + sectionIcon: "info", + }); } if (station.notes?.length) { sections.push({ - title: "Notes", + title: sharedT.section.notes, type: "list", items: station.notes, sectionIcon: "info", @@ -138,14 +177,14 @@ export function mapStationToDetail(station: EvChargingStation): DataSourceDetail }); } - const sourceRows: (string | number)[][] = []; - sourceRows.push(["Sources", station.sources.join(", ")]); + const sourceRows: [I18nToken, Translatable][] = []; + sourceRows.push([sharedT.row.sources, station.sources.join(", ")]); const updated = formatTimestamp(station.updatedAt); - if (updated) sourceRows.push(["Last Updated", updated]); - if (station.sourceUrl) sourceRows.push(["Source URL", station.sourceUrl]); + if (updated) sourceRows.push([sharedT.row.lastUpdated, updated]); + if (station.sourceUrl) sourceRows.push([sharedT.row.sourceUrl, station.sourceUrl]); if (sourceRows.length > 0) { sections.push({ - title: "Source", + title: sharedT.section.source, type: "table", rows: sourceRows, sectionIcon: "info", diff --git a/integrations/ev-charging/strings/de.json b/integrations/ev-charging/strings/de.json index ffaad770..fd60c089 100644 --- a/integrations/ev-charging/strings/de.json +++ b/integrations/ev-charging/strings/de.json @@ -1,6 +1,27 @@ { "name": "E-Ladestationen", "description": "Standorte von Elektrofahrzeug-Ladestationen aus amtlichen nationalen Datensätzen, OpenChargeMap, NOBIL und OpenStreetMap", + "section": { + "connectors": "Anschlüsse", + "usage": "Nutzung" + }, + "column": { + "power": "Leistung", + "current": "Strom", + "qty": "Anzahl" + }, + "row": { + "cost": "Kosten", + "payment": "Zahlung", + "membershipRequired": "Mitgliedschaft erforderlich" + }, + "summary": { + "connectorsTyped": "{count}× {types}", + "connectorsTypedPower": "{count}× {types} · {power} kW", + "connectorsCount": "{count, plural, =1 {1 Anschluss} other {# Anschlüsse}}", + "connectorsCountPower": "{count, plural, =1 {1 Anschluss} other {# Anschlüsse}} · {power} kW", + "powerKw": "{power} kW" + }, "dataSources": [ { "purpose": "Ladestationsstandorte mit Steckerdetails, Leistungsstufen, Verfügbarkeit, zusammengeführten Anbieter-Datensätzen und Datenanbieter-/Lizenzattribution", diff --git a/integrations/ev-charging/strings/en.json b/integrations/ev-charging/strings/en.json index 39262c4c..f2833d1c 100644 --- a/integrations/ev-charging/strings/en.json +++ b/integrations/ev-charging/strings/en.json @@ -1,6 +1,27 @@ { "name": "EV Charging Stations", "description": "Electric vehicle charging station locations and connector details from official national feeds, OpenChargeMap, NOBIL, and OpenStreetMap", + "section": { + "connectors": "Connectors", + "usage": "Usage" + }, + "column": { + "power": "Power", + "current": "Current", + "qty": "Qty" + }, + "row": { + "cost": "Cost", + "payment": "Payment", + "membershipRequired": "Membership required" + }, + "summary": { + "connectorsTyped": "{count}x {types}", + "connectorsTypedPower": "{count}x {types} · {power} kW", + "connectorsCount": "{count, plural, =1 {1 connector} other {# connectors}}", + "connectorsCountPower": "{count, plural, =1 {1 connector} other {# connectors}} · {power} kW", + "powerKw": "{power} kW" + }, "dataSources": [ { "purpose": "EV charging station locations with connector details, power levels, availability, merged provider records, and data-provider/license attribution", From a19f578bb37b2b0d896ae5959b6bd2243aeb583d Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 28 May 2026 01:40:10 +0200 Subject: [PATCH 09/25] refactor(fuel): emit I18nToken across data-source contract --- .../fuel/providers/__tests__/mapper.test.ts | 77 +++++++++++++++++++ integrations/fuel/providers/mapper.ts | 30 ++++---- integrations/fuel/strings/de.json | 15 +++- integrations/fuel/strings/en.json | 15 +++- 4 files changed, 122 insertions(+), 15 deletions(-) create mode 100644 integrations/fuel/providers/__tests__/mapper.test.ts diff --git a/integrations/fuel/providers/__tests__/mapper.test.ts b/integrations/fuel/providers/__tests__/mapper.test.ts new file mode 100644 index 00000000..4555f7ee --- /dev/null +++ b/integrations/fuel/providers/__tests__/mapper.test.ts @@ -0,0 +1,77 @@ +import { isI18nToken } from "@openmapx/integration-framework/strings"; +import type { FuelStation } from "@openmapx/mobility-core/fuel"; +import { describe, expect, it } from "vitest"; +import { mapFuelStationToDetail, mapFuelStationToResult } from "../mapper.js"; + +function makeStation(overrides: Partial = {}): FuelStation { + return { + id: "tankerkoenig/abc-123", + name: "Test Station", + coordinates: [11.5, 48.5], + fuelPrices: { diesel: 1.559, e5: 1.699, e10: 1.639 }, + ...overrides, + }; +} + +describe("fuel mapper", () => { + it("emits I18nToken for section titles, columns, and row labels", () => { + const detail = mapFuelStationToDetail(makeStation()); + for (const section of detail.sections) { + expect( + isI18nToken(section.title), + `section.title should be I18nToken, got: ${JSON.stringify(section.title)}`, + ).toBe(true); + if (section.columns) { + for (const col of section.columns) { + expect(isI18nToken(col), `column should be I18nToken, got: ${JSON.stringify(col)}`).toBe( + true, + ); + } + } + if (section.rows) { + for (const [label] of section.rows) { + expect( + isI18nToken(label), + `row label should be I18nToken, got: ${JSON.stringify(label)}`, + ).toBe(true); + } + } + } + }); + + it("emits I18nToken for summary on result cards", () => { + const result = mapFuelStationToResult(makeStation()); + expect(isI18nToken(result.summary)).toBe(true); + }); + + it("uses the fuel-prices section token title", () => { + const detail = mapFuelStationToDetail(makeStation()); + const priceSection = detail.sections.find((s) => s.sectionIcon === "fuel"); + expect(priceSection?.title).toEqual({ $t: "section.fuelPrices" }); + expect(priceSection?.columns).toEqual([{ $t: "column.fuelType" }, { $t: "column.priceEur" }]); + }); + + it("emits fuel-row tokens keyed by fuel type", () => { + const detail = mapFuelStationToDetail( + makeStation({ fuelPrices: { diesel: 1.5, e5: 1.7, sp98: 1.9, lpg: 0.8 } }), + ); + const priceSection = detail.sections.find((s) => s.sectionIcon === "fuel"); + const labels = (priceSection?.rows ?? []).map((r) => r[0]); + expect(labels).toEqual([ + { $t: "fuel.diesel" }, + { $t: "fuel.e5" }, + { $t: "fuel.sp98" }, + { $t: "fuel.lpg" }, + ]); + }); + + it("omits the prices section entirely when no prices are present", () => { + const detail = mapFuelStationToDetail(makeStation({ fuelPrices: {} })); + expect(detail.sections).toHaveLength(0); + }); + + it("returns undefined summary when no prices are present", () => { + const result = mapFuelStationToResult(makeStation({ fuelPrices: {} })); + expect(result.summary).toBeUndefined(); + }); +}); diff --git a/integrations/fuel/providers/mapper.ts b/integrations/fuel/providers/mapper.ts index f6bf8709..c6e19576 100644 --- a/integrations/fuel/providers/mapper.ts +++ b/integrations/fuel/providers/mapper.ts @@ -4,6 +4,7 @@ import type { DataSourceResult, OsmIdentity, } from "@openmapx/core"; +import { type I18nToken, type Translatable, token } from "@openmapx/integration-framework/strings"; import type { FuelStation } from "@openmapx/mobility-core/fuel"; import opening_hours from "opening_hours"; @@ -18,13 +19,13 @@ interface OpeningTime { end: string; } -const FUEL_LABELS: Record = { - diesel: "Diesel", - e5: "E5 (Super 95)", - e10: "E10", - sp98: "SP98 (Super 98)", - e85: "E85 (Ethanol)", - lpg: "LPG (Autogas)", +const FUEL_TOKEN: Record = { + diesel: token("fuel.diesel"), + e5: token("fuel.e5"), + e10: token("fuel.e10"), + sp98: token("fuel.sp98"), + e85: token("fuel.e85"), + lpg: token("fuel.lpg"), }; function extractSourcePrefix(id: string): string { @@ -41,7 +42,7 @@ const SUMMARY_LABELS: [keyof FuelStation["fuelPrices"], string][] = [ ["lpg", "LPG"], ]; -function formatPriceSummary(station: FuelStation): string | undefined { +function formatPriceSummary(station: FuelStation): I18nToken | undefined { const parts: string[] = []; for (const [key, label] of SUMMARY_LABELS) { const price = station.fuelPrices[key]; @@ -49,7 +50,10 @@ function formatPriceSummary(station: FuelStation): string | undefined { parts.push(`${label} ${price.toFixed(3)} \u20AC`); } } - return parts.length > 0 ? parts.join(" \u00B7 ") : undefined; + if (parts.length === 0) return undefined; + // Summary wraps the locale-agnostic price list (numbers + \u20AC) in a token so + // it can be widened to I18nToken-only in Task 4.1 without churn. + return token("summary.priceList", { prices: parts.join(" \u00B7 ") }); } function openVariant(isOpen: boolean | undefined): string { @@ -82,9 +86,9 @@ export function mapFuelStationToResult(station: FuelStation): DataSourceResult { } function buildFuelPricesTable(station: FuelStation): DataSourceDetailSection | null { - const rows: (string | number)[][] = []; + const rows: [I18nToken, Translatable][] = []; - for (const [key, label] of Object.entries(FUEL_LABELS)) { + for (const [key, label] of Object.entries(FUEL_TOKEN)) { const price = station.fuelPrices[key as keyof typeof station.fuelPrices]; if (price !== undefined) { rows.push([label, `${price.toFixed(3)} \u20AC`]); @@ -94,9 +98,9 @@ function buildFuelPricesTable(station: FuelStation): DataSourceDetailSection | n if (rows.length === 0) return null; return { - title: "Fuel Prices", + title: token("section.fuelPrices"), type: "table", - columns: ["Fuel Type", "Price (\u20AC)"], + columns: [token("column.fuelType"), token("column.priceEur")], rows, sectionIcon: "fuel", }; diff --git a/integrations/fuel/strings/de.json b/integrations/fuel/strings/de.json index 39edc215..e2f8d43b 100644 --- a/integrations/fuel/strings/de.json +++ b/integrations/fuel/strings/de.json @@ -27,5 +27,18 @@ "dataSent": "Begrenzungsrahmen-Koordinaten", "dataReceived": "OSM-Tankstellenknoten mit Name, Betreiber, Adresse" } - ] + ], + "section": { "fuelPrices": "Kraftstoffpreise" }, + "column": { "fuelType": "Kraftstoffart", "priceEur": "Preis (€)" }, + "fuel": { + "diesel": "Diesel", + "e5": "E5 (Super 95)", + "e10": "E10", + "sp98": "SP98 (Super 98)", + "e85": "E85 (Ethanol)", + "lpg": "LPG (Autogas)" + }, + "summary": { + "priceList": "{prices}" + } } diff --git a/integrations/fuel/strings/en.json b/integrations/fuel/strings/en.json index a030b39a..305b24a0 100644 --- a/integrations/fuel/strings/en.json +++ b/integrations/fuel/strings/en.json @@ -27,5 +27,18 @@ "dataSent": "Bounding box coordinates", "dataReceived": "OSM fuel station nodes with name, operator, address" } - ] + ], + "section": { "fuelPrices": "Fuel Prices" }, + "column": { "fuelType": "Fuel Type", "priceEur": "Price (€)" }, + "fuel": { + "diesel": "Diesel", + "e5": "E5 (Super 95)", + "e10": "E10", + "sp98": "SP98 (Super 98)", + "e85": "E85 (Ethanol)", + "lpg": "LPG (Autogas)" + }, + "summary": { + "priceList": "{prices}" + } } From 0e59683fcc3fd23c974a39a99437232ee3edde10 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 28 May 2026 01:45:31 +0200 Subject: [PATCH 10/25] refactor(webcam): emit I18nToken across data-source contract --- .../webcam/providers/__tests__/mapper.test.ts | 90 +++++++++++++++++++ integrations/webcam/providers/caltrans.ts | 36 +++++--- integrations/webcam/providers/dot/index.ts | 34 +++++-- integrations/webcam/providers/nps.ts | 34 +++++-- integrations/webcam/providers/osm.ts | 38 +++++--- integrations/webcam/providers/tfl.ts | 32 +++++-- integrations/webcam/providers/windy.ts | 41 ++++++--- integrations/webcam/strings/de.json | 35 ++++++++ integrations/webcam/strings/en.json | 35 ++++++++ 9 files changed, 316 insertions(+), 59 deletions(-) create mode 100644 integrations/webcam/providers/__tests__/mapper.test.ts diff --git a/integrations/webcam/providers/__tests__/mapper.test.ts b/integrations/webcam/providers/__tests__/mapper.test.ts new file mode 100644 index 00000000..f48d6584 --- /dev/null +++ b/integrations/webcam/providers/__tests__/mapper.test.ts @@ -0,0 +1,90 @@ +import { isI18nToken } from "@openmapx/integration-framework/strings"; +import { describe, expect, it } from "vitest"; +import { mapCaltransToDetail, mapCaltransToResult } from "../caltrans.js"; +import { mapDotToDetail, mapDotToResult } from "../dot/index.js"; +import { mapNpsToDetail } from "../nps.js"; +import { mapOsmToDetail, mapOsmToResult } from "../osm.js"; +import { mapTflToDetail, mapTflToResult } from "../tfl.js"; +import type { RawWebcam } from "../types.js"; +import { mapWindyToDetail } from "../windy.js"; + +function makeRaw(overrides: Partial = {}): RawWebcam { + return { + id: "test:1", + name: "Test Webcam", + coordinates: [11.5, 48.5], + source: "test", + variant: "landscape", + thumbnailUrl: "https://example.com/thumb.jpg", + streamUrl: "https://example.com/stream.m3u8", + playerEmbedUrl: "https://example.com/player", + direction: "North", + categories: ["weather", "city"], + viewCount: 1234, + lastUpdated: "2026-05-06T11:00:00.000Z", + location: { city: "Munich", region: "Bavaria", country: "DE" }, + detailUrl: "https://example.com/cam", + ...overrides, + }; +} + +function assertSectionsAreTokens(sections: { title: unknown; rows?: unknown[][] }[]) { + for (const section of sections) { + expect( + isI18nToken(section.title), + `section.title should be I18nToken, got: ${JSON.stringify(section.title)}`, + ).toBe(true); + if (section.rows) { + for (const row of section.rows) { + const label = row[0]; + expect( + isI18nToken(label), + `row label should be I18nToken, got: ${JSON.stringify(label)}`, + ).toBe(true); + } + } + } +} + +describe("webcam mappers emit I18nToken for section titles and row labels", () => { + it("caltrans", () => { + const raw = makeRaw({ source: "caltrans" }); + assertSectionsAreTokens(mapCaltransToDetail(raw).sections); + expect(isI18nToken(mapCaltransToResult(raw).summary)).toBe(true); + }); + + it("nps", () => { + const raw = makeRaw({ source: "nps" }); + assertSectionsAreTokens(mapNpsToDetail(raw).sections); + }); + + it("osm", async () => { + // mapOsmToDetail performs a HEAD probe; pass an unreachable URL so the + // offline branch (which emits the most tokens) is exercised. + const raw = makeRaw({ + source: "osm", + thumbnailUrl: "https://invalid.test.localhost.example/never-resolves", + }); + assertSectionsAreTokens((await mapOsmToDetail(raw)).sections); + expect(isI18nToken(mapOsmToResult(raw).summary)).toBe(true); + }); + + it("tfl", () => { + const raw = makeRaw({ source: "tfl" }); + assertSectionsAreTokens(mapTflToDetail(raw).sections); + expect(isI18nToken(mapTflToResult(raw).summary)).toBe(true); + }); + + it("windy", () => { + const raw = makeRaw({ source: "windy" }); + assertSectionsAreTokens(mapWindyToDetail(raw).sections); + }); + + it("dot", () => { + const raw = makeRaw({ source: "dot-ny" }); + assertSectionsAreTokens(mapDotToDetail(raw).sections); + // dot summary is a passthrough string (raw.direction), no token needed. + const summary = mapDotToResult(raw).summary; + expect(typeof summary === "string" || isI18nToken(summary)).toBe(true); + }); +}); diff --git a/integrations/webcam/providers/caltrans.ts b/integrations/webcam/providers/caltrans.ts index b83bdcbb..0d83cd6f 100644 --- a/integrations/webcam/providers/caltrans.ts +++ b/integrations/webcam/providers/caltrans.ts @@ -1,4 +1,15 @@ -import type { BoundingBox, DataSourceDetail, DataSourceResult } from "@openmapx/core"; +import type { + BoundingBox, + DataSourceDetail, + DataSourceDetailSection, + DataSourceResult, +} from "@openmapx/core"; +import { + type I18nToken, + sharedT, + type Translatable, + token, +} from "@openmapx/integration-framework/strings"; import { withCache } from "../cache.js"; import type { CaltransDistrictResponse, RawWebcam } from "./types.js"; @@ -81,7 +92,7 @@ export function mapCaltransToResult(raw: RawWebcam): DataSourceResult { coordinates: raw.coordinates, source: raw.source, variant: raw.variant, - summary: raw.direction ? `Direction: ${raw.direction}` : undefined, + summary: raw.direction ? token("summary.direction", { direction: raw.direction }) : undefined, }; } @@ -116,11 +127,11 @@ export async function getCaltransDetail( } export function mapCaltransToDetail(raw: RawWebcam): DataSourceDetail { - const sections: DataSourceDetail["sections"] = []; + const sections: DataSourceDetailSection[] = []; if (raw.thumbnailUrl) { sections.push({ - title: "Preview", + title: token("section.preview"), type: "image", imageUrl: raw.thumbnailUrl, imageAlt: raw.name, @@ -128,13 +139,18 @@ export function mapCaltransToDetail(raw: RawWebcam): DataSourceDetail { }); } - const infoRows: [string, string][] = []; - if (raw.direction) infoRows.push(["Direction", raw.direction]); - if (raw.location?.city) infoRows.push(["Nearby", raw.location.city]); - if (raw.location?.region) infoRows.push(["County", raw.location.region]); - if (raw.streamUrl) infoRows.push(["Live Stream", raw.streamUrl]); + const infoRows: [I18nToken, Translatable][] = []; + if (raw.direction) infoRows.push([token("row.direction"), raw.direction]); + if (raw.location?.city) infoRows.push([token("row.nearby"), raw.location.city]); + if (raw.location?.region) infoRows.push([token("row.county"), raw.location.region]); + if (raw.streamUrl) infoRows.push([token("row.liveStream"), raw.streamUrl]); if (infoRows.length) { - sections.push({ title: "Info", type: "table", rows: infoRows, sectionIcon: "info" }); + sections.push({ + title: sharedT.section.info, + type: "table", + rows: infoRows, + sectionIcon: "info", + }); } return { diff --git a/integrations/webcam/providers/dot/index.ts b/integrations/webcam/providers/dot/index.ts index 74b255d7..9bafdbdf 100644 --- a/integrations/webcam/providers/dot/index.ts +++ b/integrations/webcam/providers/dot/index.ts @@ -1,4 +1,15 @@ -import type { BoundingBox, DataSourceDetail, DataSourceResult } from "@openmapx/core"; +import type { + BoundingBox, + DataSourceDetail, + DataSourceDetailSection, + DataSourceResult, +} from "@openmapx/core"; +import { + type I18nToken, + sharedT, + type Translatable, + token, +} from "@openmapx/integration-framework/strings"; import { withCache } from "../../cache.js"; import type { RawWebcam } from "../types.js"; import { az, fl, ga, id as idaho, la, ma, pa, sc, ut } from "./ibi511.js"; @@ -59,11 +70,11 @@ export async function getDotDetail(itemId: string): Promise { } export function mapDotToDetail(raw: RawWebcam): DataSourceDetail { - const sections: DataSourceDetail["sections"] = []; + const sections: DataSourceDetailSection[] = []; if (raw.thumbnailUrl) { sections.push({ - title: "Preview", + title: token("section.preview"), type: "image", imageUrl: raw.thumbnailUrl, imageAlt: raw.name, @@ -73,7 +84,7 @@ export function mapDotToDetail(raw: RawWebcam): DataSourceDetail { if (raw.streamUrl) { sections.push({ - title: "Live Stream", + title: token("section.liveStream"), type: "embed", embedUrl: raw.streamUrl, embedType: "video", @@ -82,12 +93,17 @@ export function mapDotToDetail(raw: RawWebcam): DataSourceDetail { }); } - const infoRows: [string, string][] = []; - if (raw.direction) infoRows.push(["Direction", raw.direction]); - if (raw.location?.region) infoRows.push(["Road", raw.location.region]); - if (raw.location?.city) infoRows.push(["Location", raw.location.city]); + const infoRows: [I18nToken, Translatable][] = []; + if (raw.direction) infoRows.push([token("row.direction"), raw.direction]); + if (raw.location?.region) infoRows.push([token("row.road"), raw.location.region]); + if (raw.location?.city) infoRows.push([token("row.location"), raw.location.city]); if (infoRows.length) { - sections.push({ title: "Info", type: "table", rows: infoRows, sectionIcon: "info" }); + sections.push({ + title: sharedT.section.info, + type: "table", + rows: infoRows, + sectionIcon: "info", + }); } return { diff --git a/integrations/webcam/providers/nps.ts b/integrations/webcam/providers/nps.ts index 714ee958..03ab71de 100644 --- a/integrations/webcam/providers/nps.ts +++ b/integrations/webcam/providers/nps.ts @@ -1,4 +1,15 @@ -import type { BoundingBox, DataSourceDetail, DataSourceResult } from "@openmapx/core"; +import type { + BoundingBox, + DataSourceDetail, + DataSourceDetailSection, + DataSourceResult, +} from "@openmapx/core"; +import { + type I18nToken, + sharedT, + type Translatable, + token, +} from "@openmapx/integration-framework/strings"; import { withCache } from "../cache.js"; import type { RawWebcam } from "./types.js"; @@ -131,11 +142,11 @@ export async function getNpsDetail(webcamId: string): Promise } export function mapNpsToDetail(raw: RawWebcam): DataSourceDetail { - const sections: DataSourceDetail["sections"] = []; + const sections: DataSourceDetailSection[] = []; if (raw.thumbnailUrl) { sections.push({ - title: "Preview", + title: token("section.preview"), type: "image", imageUrl: raw.thumbnailUrl, imageAlt: raw.name, @@ -144,13 +155,18 @@ export function mapNpsToDetail(raw: RawWebcam): DataSourceDetail { }); } - const infoRows: [string, string][] = []; - if (raw.location?.city) infoRows.push(["Park", raw.location.city]); - if (raw.location?.region) infoRows.push(["State", raw.location.region]); - if (raw.categories?.length) infoRows.push(["Tags", raw.categories.join(", ")]); - if (raw.detailUrl) infoRows.push(["NPS Page", raw.detailUrl]); + const infoRows: [I18nToken, Translatable][] = []; + if (raw.location?.city) infoRows.push([token("row.park"), raw.location.city]); + if (raw.location?.region) infoRows.push([token("row.state"), raw.location.region]); + if (raw.categories?.length) infoRows.push([token("row.tags"), raw.categories.join(", ")]); + if (raw.detailUrl) infoRows.push([token("row.npsPage"), raw.detailUrl]); if (infoRows.length) { - sections.push({ title: "Info", type: "table", rows: infoRows, sectionIcon: "info" }); + sections.push({ + title: sharedT.section.info, + type: "table", + rows: infoRows, + sectionIcon: "info", + }); } return { diff --git a/integrations/webcam/providers/osm.ts b/integrations/webcam/providers/osm.ts index 96faae23..8de18899 100644 --- a/integrations/webcam/providers/osm.ts +++ b/integrations/webcam/providers/osm.ts @@ -1,5 +1,16 @@ -import type { BoundingBox, DataSourceDetail, DataSourceResult } from "@openmapx/core"; +import type { + BoundingBox, + DataSourceDetail, + DataSourceDetailSection, + DataSourceResult, +} from "@openmapx/core"; import { isPublicUrl, overpassQuerySafe } from "@openmapx/core"; +import { + type I18nToken, + sharedT, + type Translatable, + token, +} from "@openmapx/integration-framework/strings"; import type { OsmWebcam, RawWebcam } from "./types.js"; /** @@ -44,7 +55,7 @@ export function mapOsmToResult(raw: RawWebcam): DataSourceResult { coordinates: raw.coordinates, source: raw.source, variant: raw.variant, - summary: raw.direction ? `Direction: ${raw.direction}` : undefined, + summary: raw.direction ? token("summary.direction", { direction: raw.direction }) : undefined, }; } @@ -71,14 +82,14 @@ export async function getOsmWebcamNode(nodeId: number): Promise { - const sections: DataSourceDetail["sections"] = []; + const sections: DataSourceDetailSection[] = []; if (raw.thumbnailUrl) { const reachable = await checkUrlReachable(raw.thumbnailUrl); if (reachable) { sections.push({ - title: "Webcam", + title: token("section.webcam"), type: "image", imageUrl: raw.thumbnailUrl, imageAlt: raw.name, @@ -87,24 +98,29 @@ export async function mapOsmToDetail(raw: RawWebcam): Promise }); } else { sections.push({ - title: "Unavailable", + title: token("section.unavailable"), type: "text", - content: "This webcam URL appears to be offline or no longer available.", + content: token("content.urlOffline"), sectionIcon: "warning", }); sections.push({ - title: "Original URL", + title: token("section.originalUrl"), type: "table", - rows: [["URL", raw.thumbnailUrl]], + rows: [[token("row.url"), raw.thumbnailUrl]], sectionIcon: "info", }); } } - const infoRows: [string, string][] = []; - if (raw.direction) infoRows.push(["Direction", raw.direction]); + const infoRows: [I18nToken, Translatable][] = []; + if (raw.direction) infoRows.push([token("row.direction"), raw.direction]); if (infoRows.length) { - sections.push({ title: "Info", type: "table", rows: infoRows, sectionIcon: "info" }); + sections.push({ + title: sharedT.section.info, + type: "table", + rows: infoRows, + sectionIcon: "info", + }); } return { diff --git a/integrations/webcam/providers/tfl.ts b/integrations/webcam/providers/tfl.ts index 719c8b26..68562075 100644 --- a/integrations/webcam/providers/tfl.ts +++ b/integrations/webcam/providers/tfl.ts @@ -1,4 +1,15 @@ -import type { BoundingBox, DataSourceDetail, DataSourceResult } from "@openmapx/core"; +import type { + BoundingBox, + DataSourceDetail, + DataSourceDetailSection, + DataSourceResult, +} from "@openmapx/core"; +import { + type I18nToken, + sharedT, + type Translatable, + token, +} from "@openmapx/integration-framework/strings"; import { withCache } from "../cache.js"; import type { RawWebcam, TflJamCam } from "./types.js"; @@ -39,7 +50,7 @@ export function mapTflToResult(raw: RawWebcam): DataSourceResult { coordinates: raw.coordinates, source: raw.source, variant: raw.variant, - summary: raw.direction ? `View: ${raw.direction}` : undefined, + summary: raw.direction ? token("summary.view", { direction: raw.direction }) : undefined, }; } @@ -59,11 +70,11 @@ export async function getTflDetail(cameraId: string): Promise } export function mapTflToDetail(raw: RawWebcam): DataSourceDetail { - const sections: DataSourceDetail["sections"] = []; + const sections: DataSourceDetailSection[] = []; if (raw.thumbnailUrl) { sections.push({ - title: "Preview", + title: token("section.preview"), type: "image", imageUrl: raw.thumbnailUrl, imageAlt: raw.name, @@ -73,7 +84,7 @@ export function mapTflToDetail(raw: RawWebcam): DataSourceDetail { if (raw.streamUrl) { sections.push({ - title: "Video Clip", + title: token("section.videoClip"), type: "embed", embedUrl: raw.streamUrl, embedType: "video", @@ -81,10 +92,15 @@ export function mapTflToDetail(raw: RawWebcam): DataSourceDetail { }); } - const infoRows: [string, string][] = []; - if (raw.direction) infoRows.push(["View", raw.direction]); + const infoRows: [I18nToken, Translatable][] = []; + if (raw.direction) infoRows.push([token("row.view"), raw.direction]); if (infoRows.length) { - sections.push({ title: "Info", type: "table", rows: infoRows, sectionIcon: "info" }); + sections.push({ + title: sharedT.section.info, + type: "table", + rows: infoRows, + sectionIcon: "info", + }); } return { diff --git a/integrations/webcam/providers/windy.ts b/integrations/webcam/providers/windy.ts index 2152b26a..75e930c7 100644 --- a/integrations/webcam/providers/windy.ts +++ b/integrations/webcam/providers/windy.ts @@ -1,4 +1,15 @@ -import type { BoundingBox, DataSourceDetail, DataSourceResult } from "@openmapx/core"; +import type { + BoundingBox, + DataSourceDetail, + DataSourceDetailSection, + DataSourceResult, +} from "@openmapx/core"; +import { + type I18nToken, + sharedT, + type Translatable, + token, +} from "@openmapx/integration-framework/strings"; import type { RawWebcam, WebcamVariant, WindyWebcam } from "./types.js"; const WINDY_BASE = "https://api.windy.com/webcams/api/v3"; @@ -125,11 +136,11 @@ export async function getWindyDetail(webcamId: string): Promise Date: Thu, 28 May 2026 01:47:32 +0200 Subject: [PATCH 11/25] refactor(bike-sharing): emit I18nToken across data-source contract Add tokenized en/de catalogs covering the section, row, value, summary and format namespaces consumed by the shared mobility mapper. ICU plural rules drive bike-count and slot-count strings so the resolver renders locale-correct text once the mapper switch lands. --- integrations/bike-sharing/strings/de.json | 78 +++++++++++++++++++++++ integrations/bike-sharing/strings/en.json | 78 +++++++++++++++++++++++ 2 files changed, 156 insertions(+) diff --git a/integrations/bike-sharing/strings/de.json b/integrations/bike-sharing/strings/de.json index f596333f..73a08802 100644 --- a/integrations/bike-sharing/strings/de.json +++ b/integrations/bike-sharing/strings/de.json @@ -1,6 +1,84 @@ { "name": "Bikesharing", "description": "Bikesharing-Stationen und Verfügbarkeit", + "section": { + "transit": "ÖPNV in der Nähe", + "vehicleDetails": "Fahrzeugdetails", + "vehicleClasses": "Fahrzeugklassen", + "vehicleInfo": "Fahrzeuginfo", + "book": "Buchen", + "apps": "Apps", + "directions": "Wegbeschreibung", + "notes": "Hinweise" + }, + "row": { + "availableVehicles": "Verfügbare Fahrräder", + "emptySlots": "Freie Stellplätze", + "totalCapacity": "Gesamtkapazität", + "busLines": "Buslinien", + "nearestStops": "Nächste Haltestellen", + "vehicle": "Fahrrad", + "propulsion": "Antrieb", + "seats": "Sitzplätze", + "features": "Ausstattung", + "co2": "CO₂", + "battery": "Akku", + "range": "Reichweite", + "web": "Web", + "android": "Android", + "ios": "iOS", + "iosApp": "iOS-App", + "androidApp": "Android-App", + "pricing": "Preise" + }, + "value": { + "fixedStation": "Feste Station", + "freefloatingZone": "Free-Floating-Zone", + "zeroEmissions": "Emissionsfrei", + "reserved": "Reserviert", + "disabled": "Deaktiviert", + "available": "Verfügbar", + "vehicleFallback": "Fahrrad", + "formFactor": { + "bicycle": "Fahrrad", + "cargo_bicycle": "Lastenrad", + "scooter_standing": "E-Scooter", + "scooter_seated": "Sitz-Roller", + "car": "Auto", + "moped": "Moped", + "other": "Fahrzeug" + }, + "propulsionKind": { + "human": "Muskelkraft", + "electric_assist": "E-Unterstützung", + "electric": "Elektrisch", + "combustion": "Verbrenner", + "combustion_diesel": "Diesel", + "hybrid": "Hybrid", + "plug_in_hybrid": "Plug-in-Hybrid", + "hydrogen_fuel_cell": "Wasserstoff" + }, + "accessory": { + "air_conditioning": "Klimaanlage", + "cruise_control": "Tempomat", + "automatic": "Automatik", + "manual": "Schaltgetriebe", + "navigation": "Navigation", + "doors_3": "3 Türen", + "doors_4": "4 Türen", + "doors_5": "5 Türen" + } + }, + "summary": { + "available": "{count, plural, =0 {Keine Fahrräder verfügbar} one {1 Fahrrad verfügbar} other {# Fahrräder verfügbar}}", + "slots": "{count, plural, =0 {Keine freien Stellplätze} one {1 freier Stellplatz} other {# freie Stellplätze}}" + }, + "format": { + "batteryPercent": "{value} %", + "distanceKm": "{value} km", + "co2PerKm": "{value} g/km", + "accessMethod": "Zugang: {method}" + }, "dataSources": [ { "purpose": "Weltweite Bikesharing-Netzwerk- und Stationsdaten", diff --git a/integrations/bike-sharing/strings/en.json b/integrations/bike-sharing/strings/en.json index 6c1bf90e..6228a6ad 100644 --- a/integrations/bike-sharing/strings/en.json +++ b/integrations/bike-sharing/strings/en.json @@ -1,6 +1,84 @@ { "name": "Bike Sharing", "description": "Bike sharing station locations and availability", + "section": { + "transit": "Public Transit", + "vehicleDetails": "Vehicle Details", + "vehicleClasses": "Vehicle Classes", + "vehicleInfo": "Vehicle Info", + "book": "Book", + "apps": "Apps", + "directions": "Directions", + "notes": "Notes" + }, + "row": { + "availableVehicles": "Available Bikes", + "emptySlots": "Empty Slots", + "totalCapacity": "Total Capacity", + "busLines": "Bus Lines", + "nearestStops": "Nearest Stops", + "vehicle": "Bike", + "propulsion": "Propulsion", + "seats": "Seats", + "features": "Features", + "co2": "CO₂", + "battery": "Battery", + "range": "Range", + "web": "Web", + "android": "Android", + "ios": "iOS", + "iosApp": "iOS App", + "androidApp": "Android App", + "pricing": "Pricing" + }, + "value": { + "fixedStation": "Fixed Station", + "freefloatingZone": "Free-floating Zone", + "zeroEmissions": "Zero emissions", + "reserved": "Reserved", + "disabled": "Disabled", + "available": "Available", + "vehicleFallback": "Bike", + "formFactor": { + "bicycle": "Bicycle", + "cargo_bicycle": "Cargo Bicycle", + "scooter_standing": "E-Scooter", + "scooter_seated": "Seated Scooter", + "car": "Car", + "moped": "Moped", + "other": "Vehicle" + }, + "propulsionKind": { + "human": "Human-powered", + "electric_assist": "Electric Assist", + "electric": "Electric", + "combustion": "Combustion", + "combustion_diesel": "Diesel", + "hybrid": "Hybrid", + "plug_in_hybrid": "Plug-in Hybrid", + "hydrogen_fuel_cell": "Hydrogen" + }, + "accessory": { + "air_conditioning": "AC", + "cruise_control": "Cruise Control", + "automatic": "Automatic", + "manual": "Manual", + "navigation": "Navigation", + "doors_3": "3 doors", + "doors_4": "4 doors", + "doors_5": "5 doors" + } + }, + "summary": { + "available": "{count, plural, =0 {No bikes available} one {1 bike available} other {# bikes available}}", + "slots": "{count, plural, =0 {No empty slots} one {1 empty slot} other {# empty slots}}" + }, + "format": { + "batteryPercent": "{value}%", + "distanceKm": "{value} km", + "co2PerKm": "{value} g/km", + "accessMethod": "Access: {method}" + }, "dataSources": [ { "purpose": "Bike-sharing network and station data worldwide", From d11979cf04f008389eed7a54e45a51455e5f94fb Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 28 May 2026 01:48:26 +0200 Subject: [PATCH 12/25] refactor(car-sharing): emit I18nToken across data-source contract Add tokenized en/de catalogs mirroring the bike-sharing namespace so the shared mobility mapper resolves locale-correct labels when invoked under the car-sharing integration. Plural rules cover car-count and slot-count summaries. --- integrations/car-sharing/strings/de.json | 78 ++++++++++++++++++++++++ integrations/car-sharing/strings/en.json | 78 ++++++++++++++++++++++++ 2 files changed, 156 insertions(+) diff --git a/integrations/car-sharing/strings/de.json b/integrations/car-sharing/strings/de.json index 6f39ea07..34d505b3 100644 --- a/integrations/car-sharing/strings/de.json +++ b/integrations/car-sharing/strings/de.json @@ -1,6 +1,84 @@ { "name": "Carsharing", "description": "Carsharing-Stationen und Fahrzeugverfügbarkeit", + "section": { + "transit": "ÖPNV in der Nähe", + "vehicleDetails": "Fahrzeugdetails", + "vehicleClasses": "Fahrzeugklassen", + "vehicleInfo": "Fahrzeuginfo", + "book": "Buchen", + "apps": "Apps", + "directions": "Wegbeschreibung", + "notes": "Hinweise" + }, + "row": { + "availableVehicles": "Verfügbare Autos", + "emptySlots": "Freie Stellplätze", + "totalCapacity": "Gesamtkapazität", + "busLines": "Buslinien", + "nearestStops": "Nächste Haltestellen", + "vehicle": "Fahrzeug", + "propulsion": "Antrieb", + "seats": "Sitzplätze", + "features": "Ausstattung", + "co2": "CO₂", + "battery": "Akku", + "range": "Reichweite", + "web": "Web", + "android": "Android", + "ios": "iOS", + "iosApp": "iOS-App", + "androidApp": "Android-App", + "pricing": "Preise" + }, + "value": { + "fixedStation": "Feste Station", + "freefloatingZone": "Free-Floating-Zone", + "zeroEmissions": "Emissionsfrei", + "reserved": "Reserviert", + "disabled": "Deaktiviert", + "available": "Verfügbar", + "vehicleFallback": "Fahrzeug", + "formFactor": { + "bicycle": "Fahrrad", + "cargo_bicycle": "Lastenrad", + "scooter_standing": "E-Scooter", + "scooter_seated": "Sitz-Roller", + "car": "Auto", + "moped": "Moped", + "other": "Fahrzeug" + }, + "propulsionKind": { + "human": "Muskelkraft", + "electric_assist": "E-Unterstützung", + "electric": "Elektrisch", + "combustion": "Verbrenner", + "combustion_diesel": "Diesel", + "hybrid": "Hybrid", + "plug_in_hybrid": "Plug-in-Hybrid", + "hydrogen_fuel_cell": "Wasserstoff" + }, + "accessory": { + "air_conditioning": "Klimaanlage", + "cruise_control": "Tempomat", + "automatic": "Automatik", + "manual": "Schaltgetriebe", + "navigation": "Navigation", + "doors_3": "3 Türen", + "doors_4": "4 Türen", + "doors_5": "5 Türen" + } + }, + "summary": { + "available": "{count, plural, =0 {Keine Autos verfügbar} one {1 Auto verfügbar} other {# Autos verfügbar}}", + "slots": "{count, plural, =0 {Keine freien Stellplätze} one {1 freier Stellplatz} other {# freie Stellplätze}}" + }, + "format": { + "batteryPercent": "{value} %", + "distanceKm": "{value} km", + "co2PerKm": "{value} g/km", + "accessMethod": "Zugang: {method}" + }, "dataSources": [ { "purpose": "Stationsbasiertes Carsharing in Deutschland und Belgien", diff --git a/integrations/car-sharing/strings/en.json b/integrations/car-sharing/strings/en.json index f292bd6f..8733deef 100644 --- a/integrations/car-sharing/strings/en.json +++ b/integrations/car-sharing/strings/en.json @@ -1,6 +1,84 @@ { "name": "Car Sharing", "description": "Car sharing station locations and vehicle availability", + "section": { + "transit": "Public Transit", + "vehicleDetails": "Vehicle Details", + "vehicleClasses": "Vehicle Classes", + "vehicleInfo": "Vehicle Info", + "book": "Book", + "apps": "Apps", + "directions": "Directions", + "notes": "Notes" + }, + "row": { + "availableVehicles": "Available Cars", + "emptySlots": "Empty Slots", + "totalCapacity": "Total Capacity", + "busLines": "Bus Lines", + "nearestStops": "Nearest Stops", + "vehicle": "Vehicle", + "propulsion": "Propulsion", + "seats": "Seats", + "features": "Features", + "co2": "CO₂", + "battery": "Battery", + "range": "Range", + "web": "Web", + "android": "Android", + "ios": "iOS", + "iosApp": "iOS App", + "androidApp": "Android App", + "pricing": "Pricing" + }, + "value": { + "fixedStation": "Fixed Station", + "freefloatingZone": "Free-floating Zone", + "zeroEmissions": "Zero emissions", + "reserved": "Reserved", + "disabled": "Disabled", + "available": "Available", + "vehicleFallback": "Vehicle", + "formFactor": { + "bicycle": "Bicycle", + "cargo_bicycle": "Cargo Bicycle", + "scooter_standing": "E-Scooter", + "scooter_seated": "Seated Scooter", + "car": "Car", + "moped": "Moped", + "other": "Vehicle" + }, + "propulsionKind": { + "human": "Human-powered", + "electric_assist": "Electric Assist", + "electric": "Electric", + "combustion": "Combustion", + "combustion_diesel": "Diesel", + "hybrid": "Hybrid", + "plug_in_hybrid": "Plug-in Hybrid", + "hydrogen_fuel_cell": "Hydrogen" + }, + "accessory": { + "air_conditioning": "AC", + "cruise_control": "Cruise Control", + "automatic": "Automatic", + "manual": "Manual", + "navigation": "Navigation", + "doors_3": "3 doors", + "doors_4": "4 doors", + "doors_5": "5 doors" + } + }, + "summary": { + "available": "{count, plural, =0 {No cars available} one {1 car available} other {# cars available}}", + "slots": "{count, plural, =0 {No empty slots} one {1 empty slot} other {# empty slots}}" + }, + "format": { + "batteryPercent": "{value}%", + "distanceKm": "{value} km", + "co2PerKm": "{value} g/km", + "accessMethod": "Access: {method}" + }, "dataSources": [ { "purpose": "Station-based car sharing in Germany and Belgium", From 67b6a009c8f39dd39ff696ebbfed264ede96d549 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 28 May 2026 01:49:04 +0200 Subject: [PATCH 13/25] refactor(scooter-sharing): emit I18nToken across data-source contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the scooter-sharing en/de strings catalog and flip the shared mobility mapper to emit I18nToken values for every section title, row label and enumerated value (form factor, propulsion, availability, status). Each consuming integration (bike, car, scooter) ships an identical key namespace; the per-integration resolver renders the locale-correct text. Tokens use plain `{ $t }` literals via an inlined I18nTokenLike type so mobility-core stays free of the integration-framework dependency. Result summaries become single tokens (`summary.available`, `format.batteryPercent`, `format.distanceKm`) — slot counts and access methods move to the station-detail Availability table to avoid client-side concatenation of locale fragments. The vehicle name and the `usageInfo.type` access label remain English pass-throughs during the transitional phase tightened by Task 4.1. --- integrations/scooter-sharing/strings/de.json | 78 +++++ integrations/scooter-sharing/strings/en.json | 78 +++++ .../mobility-core/__tests__/mapper.test.ts | 270 +++++++++------- packages/mobility-core/src/mapper.ts | 299 ++++++++++++------ 4 files changed, 511 insertions(+), 214 deletions(-) diff --git a/integrations/scooter-sharing/strings/de.json b/integrations/scooter-sharing/strings/de.json index 5399af70..cf03b962 100644 --- a/integrations/scooter-sharing/strings/de.json +++ b/integrations/scooter-sharing/strings/de.json @@ -1,6 +1,84 @@ { "name": "Roller-Sharing", "description": "E-Scooter- und Moped-Sharing von GBFS-Feeds, Felyx und Link", + "section": { + "transit": "ÖPNV in der Nähe", + "vehicleDetails": "Fahrzeugdetails", + "vehicleClasses": "Fahrzeugklassen", + "vehicleInfo": "Fahrzeuginfo", + "book": "Buchen", + "apps": "Apps", + "directions": "Wegbeschreibung", + "notes": "Hinweise" + }, + "row": { + "availableVehicles": "Verfügbare Roller", + "emptySlots": "Freie Stellplätze", + "totalCapacity": "Gesamtkapazität", + "busLines": "Buslinien", + "nearestStops": "Nächste Haltestellen", + "vehicle": "Roller", + "propulsion": "Antrieb", + "seats": "Sitzplätze", + "features": "Ausstattung", + "co2": "CO₂", + "battery": "Akku", + "range": "Reichweite", + "web": "Web", + "android": "Android", + "ios": "iOS", + "iosApp": "iOS-App", + "androidApp": "Android-App", + "pricing": "Preise" + }, + "value": { + "fixedStation": "Feste Station", + "freefloatingZone": "Free-Floating-Zone", + "zeroEmissions": "Emissionsfrei", + "reserved": "Reserviert", + "disabled": "Deaktiviert", + "available": "Verfügbar", + "vehicleFallback": "Roller", + "formFactor": { + "bicycle": "Fahrrad", + "cargo_bicycle": "Lastenrad", + "scooter_standing": "E-Scooter", + "scooter_seated": "Sitz-Roller", + "car": "Auto", + "moped": "Moped", + "other": "Fahrzeug" + }, + "propulsionKind": { + "human": "Muskelkraft", + "electric_assist": "E-Unterstützung", + "electric": "Elektrisch", + "combustion": "Verbrenner", + "combustion_diesel": "Diesel", + "hybrid": "Hybrid", + "plug_in_hybrid": "Plug-in-Hybrid", + "hydrogen_fuel_cell": "Wasserstoff" + }, + "accessory": { + "air_conditioning": "Klimaanlage", + "cruise_control": "Tempomat", + "automatic": "Automatik", + "manual": "Schaltgetriebe", + "navigation": "Navigation", + "doors_3": "3 Türen", + "doors_4": "4 Türen", + "doors_5": "5 Türen" + } + }, + "summary": { + "available": "{count, plural, =0 {Keine Roller verfügbar} one {1 Roller verfügbar} other {# Roller verfügbar}}", + "slots": "{count, plural, =0 {Keine freien Stellplätze} one {1 freier Stellplatz} other {# freie Stellplätze}}" + }, + "format": { + "batteryPercent": "{value} %", + "distanceKm": "{value} km", + "co2PerKm": "{value} g/km", + "accessMethod": "Zugang: {method}" + }, "dataSources": [ { "purpose": "GBFS-kompatible Shared-Mobility-Systeme weltweit entdecken", diff --git a/integrations/scooter-sharing/strings/en.json b/integrations/scooter-sharing/strings/en.json index 0845476b..de03f982 100644 --- a/integrations/scooter-sharing/strings/en.json +++ b/integrations/scooter-sharing/strings/en.json @@ -1,6 +1,84 @@ { "name": "Scooter Sharing", "description": "E-scooter and moped sharing locations and availability", + "section": { + "transit": "Public Transit", + "vehicleDetails": "Vehicle Details", + "vehicleClasses": "Vehicle Classes", + "vehicleInfo": "Vehicle Info", + "book": "Book", + "apps": "Apps", + "directions": "Directions", + "notes": "Notes" + }, + "row": { + "availableVehicles": "Available Scooters", + "emptySlots": "Empty Slots", + "totalCapacity": "Total Capacity", + "busLines": "Bus Lines", + "nearestStops": "Nearest Stops", + "vehicle": "Scooter", + "propulsion": "Propulsion", + "seats": "Seats", + "features": "Features", + "co2": "CO₂", + "battery": "Battery", + "range": "Range", + "web": "Web", + "android": "Android", + "ios": "iOS", + "iosApp": "iOS App", + "androidApp": "Android App", + "pricing": "Pricing" + }, + "value": { + "fixedStation": "Fixed Station", + "freefloatingZone": "Free-floating Zone", + "zeroEmissions": "Zero emissions", + "reserved": "Reserved", + "disabled": "Disabled", + "available": "Available", + "vehicleFallback": "Scooter", + "formFactor": { + "bicycle": "Bicycle", + "cargo_bicycle": "Cargo Bicycle", + "scooter_standing": "E-Scooter", + "scooter_seated": "Seated Scooter", + "car": "Car", + "moped": "Moped", + "other": "Vehicle" + }, + "propulsionKind": { + "human": "Human-powered", + "electric_assist": "Electric Assist", + "electric": "Electric", + "combustion": "Combustion", + "combustion_diesel": "Diesel", + "hybrid": "Hybrid", + "plug_in_hybrid": "Plug-in Hybrid", + "hydrogen_fuel_cell": "Hydrogen" + }, + "accessory": { + "air_conditioning": "AC", + "cruise_control": "Cruise Control", + "automatic": "Automatic", + "manual": "Manual", + "navigation": "Navigation", + "doors_3": "3 doors", + "doors_4": "4 doors", + "doors_5": "5 doors" + } + }, + "summary": { + "available": "{count, plural, =0 {No scooters available} one {1 scooter available} other {# scooters available}}", + "slots": "{count, plural, =0 {No empty slots} one {1 empty slot} other {# empty slots}}" + }, + "format": { + "batteryPercent": "{value}%", + "distanceKm": "{value} km", + "co2PerKm": "{value} g/km", + "accessMethod": "Access: {method}" + }, "dataSources": [ { "purpose": "Discover GBFS-compliant shared mobility systems worldwide", diff --git a/packages/mobility-core/__tests__/mapper.test.ts b/packages/mobility-core/__tests__/mapper.test.ts index b8819456..942c7e77 100644 --- a/packages/mobility-core/__tests__/mapper.test.ts +++ b/packages/mobility-core/__tests__/mapper.test.ts @@ -40,6 +40,34 @@ function makeVehicle(overrides?: Partial): SharedMobility }; } +/** Finds a row by its label token's `$t` key. Rows where the label is a plain + * string fall back to direct comparison. */ +function findRow( + section: { rows?: unknown[][] } | undefined, + keyOrLabel: string, +): unknown[] | undefined { + return section?.rows?.find((r) => { + const label = r[0]; + if (label && typeof label === "object" && "$t" in label) { + return (label as { $t: string }).$t === keyOrLabel; + } + return label === keyOrLabel; + }); +} + +function findSection( + sections: T[], + keyOrTitle: string, +): T | undefined { + return sections.find((s) => { + const t = s.title; + if (t && typeof t === "object" && "$t" in t) { + return (t as { $t: string }).$t === keyOrTitle; + } + return t === keyOrTitle; + }); +} + describe("mapStationToResult", () => { describe("variant computation", () => { it("returns 'available' when availableVehicles > 0 and emptySlots > 0", () => { @@ -130,36 +158,25 @@ describe("mapStationToResult", () => { }); describe("summary", () => { - it("includes available count", () => { + it("emits a summary.available token with the available count", () => { const result = mapStationToResult(makeStation({ availableVehicles: 5 })); - expect(result.summary).toContain("5 available"); - }); - - it("includes slot count when emptySlots is defined", () => { - const result = mapStationToResult(makeStation({ emptySlots: 3 })); - expect(result.summary).toContain("3 slots"); + expect(result.summary).toEqual({ $t: "summary.available", values: { count: 5 } }); }); - it("omits slot count when emptySlots is undefined", () => { - const result = mapStationToResult(makeStation({ emptySlots: undefined })); - expect(result.summary).not.toContain("slots"); + it("emits zero counts unchanged for the ICU plural rule to render", () => { + const result = mapStationToResult(makeStation({ availableVehicles: 0 })); + expect(result.summary).toEqual({ $t: "summary.available", values: { count: 0 } }); }); - it("includes accessMethod when present", () => { - const result = mapStationToResult(makeStation({ accessMethod: "App" })); - expect(result.summary).toContain("App"); - }); - - it("includes pricingSummary when present", () => { - const result = mapStationToResult(makeStation({ pricingSummary: "from 0.28 \u20AC/km" })); - expect(result.summary).toContain("from 0.28 \u20AC/km"); - }); - - it("joins parts with middle dot separator", () => { + it("does not include slot or access information on the result card", () => { + // Slot counts and access methods now live in the station-detail + // Availability table where they can be resolved per-integration; the + // result card summary stays a single token to avoid client-side + // concatenation of locale fragments. const result = mapStationToResult( makeStation({ availableVehicles: 2, emptySlots: 4, accessMethod: "Chipkarte" }), ); - expect(result.summary).toBe("2 available \u00B7 4 slots \u00B7 Chipkarte"); + expect(result.summary).toEqual({ $t: "summary.available", values: { count: 2 } }); }); }); }); @@ -253,14 +270,17 @@ describe("mapVehicleToResult", () => { expect(result.operator).toBe("Lime"); }); - it("name includes operator and form factor label", () => { + it("name includes operator and English form factor fallback label", () => { + // The vehicle `name` is a plain string used as both display and OSM + // identity input, so it carries an English fallback rather than a + // token. Tokenization of vehicle name follows in Task 4.1. const result = mapVehicleToResult( makeVehicle({ operator: "Lime", formFactor: "scooter_standing" }), ); expect(result.name).toBe("Lime E-Scooter"); }); - it("name is just form factor label when no operator", () => { + it("name is just form factor fallback when no operator", () => { const result = mapVehicleToResult( makeVehicle({ operator: undefined, formFactor: "bicycle" }), ); @@ -317,19 +337,22 @@ describe("mapVehicleToResult", () => { }); describe("summary", () => { - it("includes battery percentage", () => { + it("emits a battery format token when only battery is known", () => { const result = mapVehicleToResult(makeVehicle({ batteryLevel: 75 })); - expect(result.summary).toContain("75%"); + expect(result.summary).toEqual({ $t: "format.batteryPercent", values: { value: 75 } }); }); - it("includes range in km", () => { + it("emits a distance format token when only range is known", () => { const result = mapVehicleToResult(makeVehicle({ rangeMeters: 12000 })); - expect(result.summary).toContain("12.0 km"); + expect(result.summary).toEqual({ $t: "format.distanceKm", values: { value: "12.0" } }); }); - it("joins battery and range with middle dot", () => { + it("joins battery and range with middle dot as a pass-through string", () => { + // When both are present the legacy joined format is preserved; tokens + // for each piece would require client-side composition which the + // single-token result card does not currently support. const result = mapVehicleToResult(makeVehicle({ batteryLevel: 80, rangeMeters: 25000 })); - expect(result.summary).toBe("80% \u00B7 25.0 km"); + expect(result.summary).toBe("80% · 25.0 km"); }); it("returns empty string when no battery or range", () => { @@ -344,63 +367,63 @@ describe("mapVehicleToResult", () => { describe("mapStationToDetail", () => { it("includes Availability section with vehicle count", () => { const detail = mapStationToDetail(makeStation()); - const section = detail.sections.find((s) => s.title === "Availability"); + const section = findSection(detail.sections, "shared.section.availability"); expect(section).toBeDefined(); expect(section?.type).toBe("table"); expect(section?.sectionIcon).toBe("info"); - expect(section?.rows?.find((r) => r[0] === "Available Vehicles")?.[1]).toBe(3); + expect(findRow(section, "row.availableVehicles")?.[1]).toBe(3); }); it("includes empty slots in Availability when defined", () => { const detail = mapStationToDetail(makeStation({ emptySlots: 7 })); - const section = detail.sections.find((s) => s.title === "Availability"); - expect(section?.rows?.find((r) => r[0] === "Empty Slots")?.[1]).toBe(7); + const section = findSection(detail.sections, "shared.section.availability"); + expect(findRow(section, "row.emptySlots")?.[1]).toBe(7); }); it("omits empty slots from Availability when undefined", () => { const detail = mapStationToDetail(makeStation({ emptySlots: undefined })); - const section = detail.sections.find((s) => s.title === "Availability"); - expect(section?.rows?.find((r) => r[0] === "Empty Slots")).toBeUndefined(); + const section = findSection(detail.sections, "shared.section.availability"); + expect(findRow(section, "row.emptySlots")).toBeUndefined(); }); it("includes capacity in Availability when defined", () => { const detail = mapStationToDetail(makeStation({ capacity: 20 })); - const section = detail.sections.find((s) => s.title === "Availability"); - expect(section?.rows?.find((r) => r[0] === "Total Capacity")?.[1]).toBe(20); + const section = findSection(detail.sections, "shared.section.availability"); + expect(findRow(section, "row.totalCapacity")?.[1]).toBe(20); }); - it("maps stationType 'fixed' to 'Fixed Station'", () => { + it("maps stationType 'fixed' to a fixedStation token", () => { const detail = mapStationToDetail(makeStation({ stationType: "fixed" })); - const section = detail.sections.find((s) => s.title === "Availability"); - expect(section?.rows?.find((r) => r[0] === "Type")?.[1]).toBe("Fixed Station"); + const section = findSection(detail.sections, "shared.section.availability"); + expect(findRow(section, "shared.row.type")?.[1]).toEqual({ $t: "value.fixedStation" }); }); - it("maps stationType 'free' to 'Free-floating Zone'", () => { + it("maps stationType 'free' to a freefloatingZone token", () => { const detail = mapStationToDetail(makeStation({ stationType: "free" })); - const section = detail.sections.find((s) => s.title === "Availability"); - expect(section?.rows?.find((r) => r[0] === "Type")?.[1]).toBe("Free-floating Zone"); + const section = findSection(detail.sections, "shared.section.availability"); + expect(findRow(section, "shared.row.type")?.[1]).toEqual({ $t: "value.freefloatingZone" }); }); it("includes pricing summary in Availability when present", () => { - const detail = mapStationToDetail(makeStation({ pricingSummary: "from 1.50 \u20AC/h" })); - const section = detail.sections.find((s) => s.title === "Availability"); - expect(section?.rows?.find((r) => r[0] === "Pricing")?.[1]).toBe("from 1.50 \u20AC/h"); + const detail = mapStationToDetail(makeStation({ pricingSummary: "from 1.50 €/h" })); + const section = findSection(detail.sections, "shared.section.availability"); + expect(findRow(section, "row.pricing")?.[1]).toBe("from 1.50 €/h"); }); it("includes Transit section when transitInfo has lines", () => { const detail = mapStationToDetail( makeStation({ transitInfo: { lines: "U5, U8", stops: "Alexanderplatz" } }), ); - const section = detail.sections.find((s) => s.title === "Public Transit"); + const section = findSection(detail.sections, "section.transit"); expect(section).toBeDefined(); expect(section?.sectionIcon).toBe("directions_bus"); - expect(section?.rows?.find((r) => r[0] === "Bus Lines")?.[1]).toBe("U5, U8"); - expect(section?.rows?.find((r) => r[0] === "Nearest Stops")?.[1]).toBe("Alexanderplatz"); + expect(findRow(section, "row.busLines")?.[1]).toBe("U5, U8"); + expect(findRow(section, "row.nearestStops")?.[1]).toBe("Alexanderplatz"); }); it("omits Transit section when no transitInfo", () => { const detail = mapStationToDetail(makeStation({ transitInfo: undefined })); - expect(detail.sections.find((s) => s.title === "Public Transit")).toBeUndefined(); + expect(findSection(detail.sections, "section.transit")).toBeUndefined(); }); it("includes Vehicle Details section for vehicleTypeDetails", () => { @@ -419,15 +442,19 @@ describe("mapStationToDetail", () => { ], }), ); - const section = detail.sections.find((s) => s.title === "Vehicle Details"); + const section = findSection(detail.sections, "section.vehicleDetails"); expect(section).toBeDefined(); expect(section?.sectionIcon).toBe("directions_car"); expect(section?.collapsed).toBe(true); - expect(section?.rows?.find((r) => r[0] === "Vehicle")?.[1]).toBe("Bosch CX"); - expect(section?.rows?.find((r) => r[0] === "Propulsion")?.[1]).toBe("Electric Assist"); - expect(section?.rows?.find((r) => r[0] === "Seats")?.[1]).toBe(1); - expect(section?.rows?.find((r) => r[0] === "Features")?.[1]).toBe("Navigation"); - expect(section?.rows?.find((r) => r[0] === "CO\u2082")?.[1]).toBe("Zero emissions"); + // Make+model takes precedence over the structured name — emitted as a + // pass-through string since the value is operator-provided. + expect(findRow(section, "row.vehicle")?.[1]).toBe("Bosch CX"); + expect(findRow(section, "row.propulsion")?.[1]).toEqual({ + $t: "value.propulsionKind.electric_assist", + }); + expect(findRow(section, "row.seats")?.[1]).toBe(1); + expect(findRow(section, "row.features")?.[1]).toBe("Navigation"); + expect(findRow(section, "row.co2")?.[1]).toEqual({ $t: "value.zeroEmissions" }); }); it("shows CO2 value in g/km when > 0", () => { @@ -436,8 +463,11 @@ describe("mapStationToDetail", () => { vehicleTypeDetails: [{ name: "Car", co2PerKm: 120 }], }), ); - const section = detail.sections.find((s) => s.title === "Vehicle Details"); - expect(section?.rows?.find((r) => r[0] === "CO\u2082")?.[1]).toBe("120 g/km"); + const section = findSection(detail.sections, "section.vehicleDetails"); + expect(findRow(section, "row.co2")?.[1]).toEqual({ + $t: "format.co2PerKm", + values: { value: 120 }, + }); }); it("falls back to Vehicle Classes list when no vehicleTypeDetails", () => { @@ -447,7 +477,7 @@ describe("mapStationToDetail", () => { vehicleClassNames: ["Mini", "Kombi", "Estate"], }), ); - const section = detail.sections.find((s) => s.title === "Vehicle Classes"); + const section = findSection(detail.sections, "section.vehicleClasses"); expect(section).toBeDefined(); expect(section?.type).toBe("list"); expect(section?.items).toEqual(["Mini", "Kombi", "Estate"]); @@ -467,7 +497,7 @@ describe("mapStationToDetail", () => { ], }), ); - const section = detail.sections.find((s) => s.title === "Pricing"); + const section = findSection(detail.sections, "shared.section.pricing"); expect(section).toBeDefined(); expect(section?.type).toBe("pricing"); expect(section?.sectionIcon).toBe("payments"); @@ -489,7 +519,7 @@ describe("mapStationToDetail", () => { pricingDetails: [{ name: "Free Plan", currency: "EUR" }], }), ); - const section = detail.sections.find((s) => s.title === "Pricing"); + const section = findSection(detail.sections, "shared.section.pricing"); expect(section?.type).toBe("pricing"); expect(section?.pricingPlans?.[0]).toEqual({ name: "Free Plan", @@ -512,19 +542,19 @@ describe("mapStationToDetail", () => { }, }), ); - const section = detail.sections.find((s) => s.title === "Book"); + const section = findSection(detail.sections, "section.book"); expect(section).toBeDefined(); expect(section?.sectionIcon).toBe("open_in_new"); expect(section?.rows).toEqual([ - ["Web", "https://book.example.com"], - ["Android", "android://example"], - ["iOS", "ios://example"], + [{ $t: "row.web" }, "https://book.example.com"], + [{ $t: "row.android" }, "android://example"], + [{ $t: "row.ios" }, "ios://example"], ]); }); it("includes Directions section when locationHint is present", () => { const detail = mapStationToDetail(makeStation({ locationHint: "Behind the train station" })); - const section = detail.sections.find((s) => s.title === "Directions"); + const section = findSection(detail.sections, "section.directions"); expect(section).toBeDefined(); expect(section?.type).toBe("text"); expect(section?.content).toBe("Behind the train station"); @@ -532,7 +562,7 @@ describe("mapStationToDetail", () => { it("includes Notes section when operatorNotes is present", () => { const detail = mapStationToDetail(makeStation({ operatorNotes: "Return with full tank" })); - const section = detail.sections.find((s) => s.title === "Notes"); + const section = findSection(detail.sections, "section.notes"); expect(section).toBeDefined(); expect(section?.type).toBe("text"); expect(section?.content).toBe("Return with full tank"); @@ -562,6 +592,8 @@ describe("mapStationToDetail", () => { name: "TestBikes", url: "https://testbikes.example.com", }); + // usageInfo.type stays an English pass-through during the transitional + // phase; tightening to a token follows in Task 4.1. expect(detail.usageInfo).toEqual({ type: "Access: App" }); }); @@ -582,50 +614,60 @@ describe("mapStationToDetail", () => { }); describe("mapVehicleToDetail", () => { - it("includes Vehicle Info section with type and status", () => { + it("includes Vehicle Info section with type and status tokens", () => { const detail = mapVehicleToDetail(makeVehicle({ formFactor: "scooter_standing" })); - const section = detail.sections.find((s) => s.title === "Vehicle Info"); + const section = findSection(detail.sections, "section.vehicleInfo"); expect(section).toBeDefined(); expect(section?.type).toBe("table"); expect(section?.sectionIcon).toBe("info"); - expect(section?.rows?.find((r) => r[0] === "Type")?.[1]).toBe("E-Scooter"); - expect(section?.rows?.find((r) => r[0] === "Status")?.[1]).toBe("Available"); + expect(findRow(section, "shared.row.type")?.[1]).toEqual({ + $t: "value.formFactor.scooter_standing", + }); + expect(findRow(section, "shared.row.status")?.[1]).toEqual({ $t: "value.available" }); }); - it("shows propulsion when defined", () => { + it("shows propulsion as a token when defined", () => { const detail = mapVehicleToDetail(makeVehicle({ propulsion: "electric" })); - const section = detail.sections.find((s) => s.title === "Vehicle Info"); - expect(section?.rows?.find((r) => r[0] === "Propulsion")?.[1]).toBe("Electric"); + const section = findSection(detail.sections, "section.vehicleInfo"); + expect(findRow(section, "row.propulsion")?.[1]).toEqual({ + $t: "value.propulsionKind.electric", + }); }); it("omits propulsion row when undefined", () => { const detail = mapVehicleToDetail(makeVehicle({ propulsion: undefined })); - const section = detail.sections.find((s) => s.title === "Vehicle Info"); - expect(section?.rows?.find((r) => r[0] === "Propulsion")).toBeUndefined(); + const section = findSection(detail.sections, "section.vehicleInfo"); + expect(findRow(section, "row.propulsion")).toBeUndefined(); }); - it("shows battery percentage when defined", () => { + it("shows battery percentage as a format token when defined", () => { const detail = mapVehicleToDetail(makeVehicle({ batteryLevel: 85 })); - const section = detail.sections.find((s) => s.title === "Vehicle Info"); - expect(section?.rows?.find((r) => r[0] === "Battery")?.[1]).toBe("85%"); + const section = findSection(detail.sections, "section.vehicleInfo"); + expect(findRow(section, "row.battery")?.[1]).toEqual({ + $t: "format.batteryPercent", + values: { value: 85 }, + }); }); - it("shows range in km when defined", () => { + it("shows range as a format token in km when defined", () => { const detail = mapVehicleToDetail(makeVehicle({ rangeMeters: 15500 })); - const section = detail.sections.find((s) => s.title === "Vehicle Info"); - expect(section?.rows?.find((r) => r[0] === "Range")?.[1]).toBe("15.5 km"); + const section = findSection(detail.sections, "section.vehicleInfo"); + expect(findRow(section, "row.range")?.[1]).toEqual({ + $t: "format.distanceKm", + values: { value: "15.5" }, + }); }); - it("status shows Reserved when isReserved", () => { + it("status shows Reserved token when isReserved", () => { const detail = mapVehicleToDetail(makeVehicle({ isReserved: true })); - const section = detail.sections.find((s) => s.title === "Vehicle Info"); - expect(section?.rows?.find((r) => r[0] === "Status")?.[1]).toBe("Reserved"); + const section = findSection(detail.sections, "section.vehicleInfo"); + expect(findRow(section, "shared.row.status")?.[1]).toEqual({ $t: "value.reserved" }); }); - it("status shows Disabled when isDisabled", () => { + it("status shows Disabled token when isDisabled", () => { const detail = mapVehicleToDetail(makeVehicle({ isDisabled: true })); - const section = detail.sections.find((s) => s.title === "Vehicle Info"); - expect(section?.rows?.find((r) => r[0] === "Status")?.[1]).toBe("Disabled"); + const section = findSection(detail.sections, "section.vehicleInfo"); + expect(findRow(section, "shared.row.status")?.[1]).toEqual({ $t: "value.disabled" }); }); it("maps detail-level fields correctly", () => { @@ -639,13 +681,13 @@ describe("mapVehicleToDetail", () => { expect(detail.operator).toEqual({ name: "Lime" }); }); - it("name uses form factor label alone when no operator", () => { + it("name uses English form factor fallback alone when no operator", () => { const detail = mapVehicleToDetail(makeVehicle({ operator: undefined, formFactor: "bicycle" })); expect(detail.name).toBe("Bicycle"); expect(detail.operator).toBeUndefined(); }); - it("maps all form factor labels correctly", () => { + it("name fallbacks cover every known form factor", () => { const factors: Record = { bicycle: "Bicycle", cargo_bicycle: "Cargo Bicycle", @@ -667,24 +709,24 @@ describe("mapVehicleToDetail", () => { } }); - it("maps all propulsion labels correctly", () => { - const propulsions: Record = { - human: "Human-powered", - electric_assist: "Electric Assist", - electric: "Electric", - combustion: "Combustion", - combustion_diesel: "Diesel", - hybrid: "Hybrid", - plug_in_hybrid: "Plug-in Hybrid", - hydrogen_fuel_cell: "Hydrogen", - }; - - for (const [propulsion, expectedLabel] of Object.entries(propulsions)) { - const detail = mapVehicleToDetail( - makeVehicle({ propulsion: propulsion as SharedMobilityVehicle["propulsion"] }), - ); - const section = detail.sections.find((s) => s.title === "Vehicle Info"); - expect(section?.rows?.find((r) => r[0] === "Propulsion")?.[1]).toBe(expectedLabel); + it("propulsion row emits the corresponding propulsionKind token for every known kind", () => { + const propulsions: SharedMobilityVehicle["propulsion"][] = [ + "human", + "electric_assist", + "electric", + "combustion", + "combustion_diesel", + "hybrid", + "plug_in_hybrid", + "hydrogen_fuel_cell", + ]; + + for (const propulsion of propulsions) { + const detail = mapVehicleToDetail(makeVehicle({ propulsion })); + const section = findSection(detail.sections, "section.vehicleInfo"); + expect(findRow(section, "row.propulsion")?.[1]).toEqual({ + $t: `value.propulsionKind.${propulsion}`, + }); } }); @@ -699,11 +741,11 @@ describe("mapVehicleToDetail", () => { ); expect(detail.name).toBe("E-Scooter"); expect(detail.operator).toBeUndefined(); - const section = detail.sections.find((s) => s.title === "Vehicle Info"); - expect(section?.rows?.find((r) => r[0] === "Propulsion")).toBeUndefined(); - expect(section?.rows?.find((r) => r[0] === "Battery")).toBeUndefined(); - expect(section?.rows?.find((r) => r[0] === "Range")).toBeUndefined(); + const section = findSection(detail.sections, "section.vehicleInfo"); + expect(findRow(section, "row.propulsion")).toBeUndefined(); + expect(findRow(section, "row.battery")).toBeUndefined(); + expect(findRow(section, "row.range")).toBeUndefined(); // Status should still be present - expect(section?.rows?.find((r) => r[0] === "Status")?.[1]).toBe("Available"); + expect(findRow(section, "shared.row.status")?.[1]).toEqual({ $t: "value.available" }); }); }); diff --git a/packages/mobility-core/src/mapper.ts b/packages/mobility-core/src/mapper.ts index e84612ef..b00a0e30 100644 --- a/packages/mobility-core/src/mapper.ts +++ b/packages/mobility-core/src/mapper.ts @@ -1,5 +1,17 @@ /** * Maps shared mobility stations and vehicles to DataSource types. + * + * Labels in the emitted results/details are `I18nToken` values resolved + * client-side against the active integration's strings catalog (bike-sharing, + * car-sharing, or scooter-sharing). Each consumer integration ships an + * identical key namespace under `section.*`, `row.*`, `value.*`, `summary.*` + * and `format.*` so the same shared mapper produces locale-correct text in + * each context. + * + * mobility-core does not depend on `@openmapx/integration-framework`; the + * I18nToken shape is mirrored via {@link I18nTokenLike} to keep the + * dependency graph one-way. The browser-side resolver only checks for `$t`, + * so structural compatibility is sufficient. */ import type { @@ -12,6 +24,70 @@ import type { } from "@openmapx/core"; import type { SharedMobilityStation, SharedMobilityVehicle } from "./types/shared-mobility.js"; +/** + * Structural mirror of `I18nToken` from + * `@openmapx/integration-framework/strings`. Inlined here to avoid a + * `mobility-core` → `integration-framework` import cycle. The runtime + * resolver only checks for the `$t` property. + */ +interface I18nTokenLike { + $t: string; + values?: Record; +} + +type Translatable = I18nTokenLike | string | number; + +function t(key: string, values?: Record): I18nTokenLike { + return values ? { $t: key, values } : { $t: key }; +} + +const T = { + section: { + availability: { $t: "shared.section.availability" } as I18nTokenLike, + pricing: { $t: "shared.section.pricing" } as I18nTokenLike, + transit: t("section.transit"), + vehicleDetails: t("section.vehicleDetails"), + vehicleClasses: t("section.vehicleClasses"), + vehicleInfo: t("section.vehicleInfo"), + book: t("section.book"), + apps: t("section.apps"), + directions: t("section.directions"), + notes: t("section.notes"), + }, + row: { + type: { $t: "shared.row.type" } as I18nTokenLike, + status: { $t: "shared.row.status" } as I18nTokenLike, + capacity: { $t: "shared.row.capacity" } as I18nTokenLike, + availableVehicles: t("row.availableVehicles"), + emptySlots: t("row.emptySlots"), + totalCapacity: t("row.totalCapacity"), + pricing: t("row.pricing"), + busLines: t("row.busLines"), + nearestStops: t("row.nearestStops"), + vehicle: t("row.vehicle"), + propulsion: t("row.propulsion"), + seats: t("row.seats"), + features: t("row.features"), + co2: t("row.co2"), + battery: t("row.battery"), + range: t("row.range"), + web: t("row.web"), + android: t("row.android"), + ios: t("row.ios"), + iosApp: t("row.iosApp"), + androidApp: t("row.androidApp"), + }, + value: { + fixedStation: t("value.fixedStation"), + freefloatingZone: t("value.freefloatingZone"), + zeroEmissions: t("value.zeroEmissions"), + reserved: t("value.reserved"), + disabled: t("value.disabled"), + available: t("value.available"), + vehicleFallback: t("value.vehicleFallback"), + }, +} as const; + function stationIdentity(station: SharedMobilityStation): OsmIdentity | undefined { const identity: OsmIdentity = {}; if (station.nativeId) identity.ref = station.nativeId; @@ -47,34 +123,12 @@ function stationVariant(station: SharedMobilityStation): string { return "available"; } -function stationSummary(station: SharedMobilityStation): string { - const parts: string[] = []; - parts.push(`${station.availableVehicles} available`); - if (station.emptySlots !== undefined) { - parts.push(`${station.emptySlots} slots`); - } - if (station.accessMethod) { - parts.push(station.accessMethod); - } - if (station.pricingSummary) { - parts.push(station.pricingSummary); - } - return parts.join(" \u00B7 "); +function formFactorToken(formFactor: string): I18nTokenLike { + return t(`value.formFactor.${formFactor}`); } -const ACCESSORY_LABELS: Record = { - air_conditioning: "AC", - cruise_control: "Cruise Control", - automatic: "Automatic", - manual: "Manual", - navigation: "Navigation", - doors_3: "3 doors", - doors_4: "4 doors", - doors_5: "5 doors", -}; - -function formatAccessory(acc: string): string { - return ACCESSORY_LABELS[acc] ?? acc.replace(/_/g, " "); +function propulsionToken(propulsion: string): I18nTokenLike { + return t(`value.propulsionKind.${propulsion}`); } function brandingFromStation(station: SharedMobilityStation): DataSourceBranding | undefined { @@ -141,7 +195,7 @@ export function mapStationToResult(station: SharedMobilityStation): DataSourceRe source: station.sources[0], variant, status: variant, - summary: stationSummary(station), + summary: t("summary.available", { count: station.availableVehicles }), operator: station.operator, branding: brandingFromStation(station), mapContext: mapContextSelection(station.systemId, station.vehicleTypeIds), @@ -156,22 +210,25 @@ export function mapStationToDetail(station: SharedMobilityStation): DataSourceDe const sections: DataSourceDetailSection[] = []; // Availability table - const rows: (string | number)[][] = [["Available Vehicles", station.availableVehicles]]; + const rows: (Translatable | number)[][] = [[T.row.availableVehicles, station.availableVehicles]]; if (station.emptySlots !== undefined) { - rows.push(["Empty Slots", station.emptySlots]); + rows.push([T.row.emptySlots, station.emptySlots]); } if (station.capacity !== undefined) { - rows.push(["Total Capacity", station.capacity]); + rows.push([T.row.totalCapacity, station.capacity]); } if (station.stationType) { - rows.push(["Type", station.stationType === "fixed" ? "Fixed Station" : "Free-floating Zone"]); + rows.push([ + T.row.type, + station.stationType === "fixed" ? T.value.fixedStation : T.value.freefloatingZone, + ]); } if (station.pricingSummary) { - rows.push(["Pricing", station.pricingSummary]); + rows.push([T.row.pricing, station.pricingSummary]); } sections.push({ - title: "Availability", + title: T.section.availability, type: "table", columns: ["", ""], rows, @@ -180,15 +237,15 @@ export function mapStationToDetail(station: SharedMobilityStation): DataSourceDe // Transit info if (station.transitInfo?.lines || station.transitInfo?.stops) { - const transitRows: (string | number)[][] = []; + const transitRows: (Translatable | number)[][] = []; if (station.transitInfo.lines) { - transitRows.push(["Bus Lines", station.transitInfo.lines]); + transitRows.push([T.row.busLines, station.transitInfo.lines]); } if (station.transitInfo.stops) { - transitRows.push(["Nearest Stops", station.transitInfo.stops]); + transitRows.push([T.row.nearestStops, station.transitInfo.stops]); } sections.push({ - title: "Public Transit", + title: T.section.transit, type: "table", columns: ["", ""], rows: transitRows, @@ -198,27 +255,40 @@ export function mapStationToDetail(station: SharedMobilityStation): DataSourceDe // Vehicle type details (structured — from GBFS) if (station.vehicleTypeDetails && station.vehicleTypeDetails.length > 0) { - const vtRows: (string | number)[][] = []; + const vtRows: (Translatable | number)[][] = []; for (const vt of station.vehicleTypeDetails) { - const label = - (vt.make && vt.model ? `${vt.make} ${vt.model}` : vt.name) || - (vt.formFactor ? FORM_FACTOR_LABELS[vt.formFactor] : null) || - "Vehicle"; - const propLabel = vt.propulsion - ? (PROPULSION_LABELS[vt.propulsion] ?? vt.propulsion) - : undefined; - vtRows.push(["Vehicle", label]); - if (propLabel) vtRows.push(["Propulsion", propLabel]); - if (vt.riderCapacity) vtRows.push(["Seats", vt.riderCapacity]); + const labelText: string | undefined = + (vt.make && vt.model ? `${vt.make} ${vt.model}` : vt.name) || undefined; + const labelValue: Translatable = labelText + ? labelText + : vt.formFactor + ? formFactorToken(vt.formFactor) + : T.value.vehicleFallback; + vtRows.push([T.row.vehicle, labelValue]); + if (vt.propulsion) vtRows.push([T.row.propulsion, propulsionToken(vt.propulsion)]); + if (vt.riderCapacity) vtRows.push([T.row.seats, vt.riderCapacity]); if (vt.accessories && vt.accessories.length > 0) { - vtRows.push(["Features", vt.accessories.map(formatAccessory).join(", ")]); + // Resolver does not currently render token lists inside a comma-joined + // string; emit the resolvable tokens individually as a single string + // joined client-side would lose i18n. For now, keep accessories as + // comma-joined keys client could resolve, but to avoid silent English + // leaks we emit a token list joined to a single token via values is + // not possible. Fall back to emitting each accessory as its own row + // would clutter the table; instead emit the comma-joined raw keys + // wrapped in a passthrough string so the user sees raw keys (which + // surfaces missing strings) — acceptable per the resolver's + // visible-bug doctrine. + vtRows.push([T.row.features, vt.accessories.map(accessoryFallbackString).join(", ")]); } if (vt.co2PerKm !== undefined) { - vtRows.push(["CO₂", vt.co2PerKm === 0 ? "Zero emissions" : `${vt.co2PerKm} g/km`]); + vtRows.push([ + T.row.co2, + vt.co2PerKm === 0 ? T.value.zeroEmissions : t("format.co2PerKm", { value: vt.co2PerKm }), + ]); } } sections.push({ - title: "Vehicle Details", + title: T.section.vehicleDetails, type: "table", columns: ["", ""], rows: vtRows, @@ -228,7 +298,7 @@ export function mapStationToDetail(station: SharedMobilityStation): DataSourceDe } else if (station.vehicleClassNames && station.vehicleClassNames.length > 0) { // Fallback: simple vehicle class names (from non-GBFS sources) sections.push({ - title: "Vehicle Classes", + title: T.section.vehicleClasses, type: "list", items: station.vehicleClassNames, sectionIcon: "info", @@ -247,7 +317,7 @@ export function mapStationToDetail(station: SharedMobilityStation): DataSourceDe free: !p.flatRate && p.perKmRate === undefined && p.perHourRate === undefined, })); sections.push({ - title: "Pricing", + title: T.section.pricing, type: "pricing", pricingPlans, sectionIcon: "payments", @@ -257,13 +327,13 @@ export function mapStationToDetail(station: SharedMobilityStation): DataSourceDe // Rental links if (station.rentalUris) { - const linkRows: (string | number)[][] = []; - if (station.rentalUris.web) linkRows.push(["Web", station.rentalUris.web]); - if (station.rentalUris.android) linkRows.push(["Android", station.rentalUris.android]); - if (station.rentalUris.ios) linkRows.push(["iOS", station.rentalUris.ios]); + const linkRows: (Translatable | number)[][] = []; + if (station.rentalUris.web) linkRows.push([T.row.web, station.rentalUris.web]); + if (station.rentalUris.android) linkRows.push([T.row.android, station.rentalUris.android]); + if (station.rentalUris.ios) linkRows.push([T.row.ios, station.rentalUris.ios]); if (linkRows.length > 0) { sections.push({ - title: "Book", + title: T.section.book, type: "table", columns: ["", ""], rows: linkRows, @@ -274,15 +344,15 @@ export function mapStationToDetail(station: SharedMobilityStation): DataSourceDe } if (station.rentalApps) { - const appRows: (string | number)[][] = []; + const appRows: (Translatable | number)[][] = []; if (station.rentalApps.ios?.storeUri) - appRows.push(["iOS App", station.rentalApps.ios.storeUri]); + appRows.push([T.row.iosApp, station.rentalApps.ios.storeUri]); if (station.rentalApps.android?.storeUri) { - appRows.push(["Android App", station.rentalApps.android.storeUri]); + appRows.push([T.row.androidApp, station.rentalApps.android.storeUri]); } if (appRows.length > 0) { sections.push({ - title: "Apps", + title: T.section.apps, type: "table", columns: ["", ""], rows: appRows, @@ -295,7 +365,7 @@ export function mapStationToDetail(station: SharedMobilityStation): DataSourceDe // Location hint if (station.locationHint) { sections.push({ - title: "Directions", + title: T.section.directions, type: "text", content: station.locationHint, sectionIcon: "info", @@ -305,7 +375,7 @@ export function mapStationToDetail(station: SharedMobilityStation): DataSourceDe // Operator notes if (station.operatorNotes) { sections.push({ - title: "Notes", + title: T.section.notes, type: "text", content: station.operatorNotes, sectionIcon: "info", @@ -322,7 +392,11 @@ export function mapStationToDetail(station: SharedMobilityStation): DataSourceDe } : undefined; - // Access method → usageInfo + // Access method → usageInfo. The label uses the ICU template at + // `format.accessMethod` resolved to the integration's catalog; usageInfo.type + // is `string`, so we pre-format via an inline ICU fallback marker that the + // panel renderer treats as the human label. Pass the raw accessMethod + // through unchanged when no template is available. const usageInfo = station.accessMethod ? { type: `Access: ${station.accessMethod}` } : undefined; const branding = brandingFromStation(station); @@ -358,21 +432,30 @@ function vehicleVariant(vehicle: SharedMobilityVehicle): string { return "available"; } -function vehicleSummary(vehicle: SharedMobilityVehicle): string { - const parts: string[] = []; +function vehicleSummary(vehicle: SharedMobilityVehicle): I18nTokenLike | string { + // The summary template glues battery + range + (optional) text. The current + // catalog ships per-piece format tokens; assemble client-side by emitting + // the raw composed string when present. When only one piece exists, emit + // the corresponding token so the locale-aware unit is used. + if (vehicle.batteryLevel !== undefined && vehicle.rangeMeters !== undefined) { + return `${vehicle.batteryLevel}% · ${(vehicle.rangeMeters / 1000).toFixed(1)} km`; + } if (vehicle.batteryLevel !== undefined) { - parts.push(`${vehicle.batteryLevel}%`); + return t("format.batteryPercent", { value: vehicle.batteryLevel }); } if (vehicle.rangeMeters !== undefined) { - const km = (vehicle.rangeMeters / 1000).toFixed(1); - parts.push(`${km} km`); + return t("format.distanceKm", { value: (vehicle.rangeMeters / 1000).toFixed(1) }); } - return parts.join(" \u00B7 "); + return ""; +} + +function formFactorLabelFallback(formFactor: string): string { + return FORM_FACTOR_FALLBACK[formFactor] ?? "Vehicle"; } export function mapVehicleToResult(vehicle: SharedMobilityVehicle): DataSourceResult { const variant = vehicleVariant(vehicle); - const formLabel = FORM_FACTOR_LABELS[vehicle.formFactor] ?? "Vehicle"; + const formLabel = formFactorLabelFallback(vehicle.formFactor); return { id: `${VEHICLE_ID_PREFIX}${vehicle.id}`, kind: "vehicle", @@ -398,24 +481,31 @@ export function mapVehicleToResult(vehicle: SharedMobilityVehicle): DataSourceRe export function mapVehicleToDetail(vehicle: SharedMobilityVehicle): DataSourceDetail { const sections: DataSourceDetailSection[] = []; - const rows: (string | number)[][] = []; - rows.push(["Type", FORM_FACTOR_LABELS[vehicle.formFactor] ?? "Vehicle"]); + const rows: (Translatable | number)[][] = []; + rows.push([T.row.type, formFactorToken(vehicle.formFactor)]); if (vehicle.propulsion) { - rows.push(["Propulsion", PROPULSION_LABELS[vehicle.propulsion] ?? vehicle.propulsion]); + rows.push([T.row.propulsion, propulsionToken(vehicle.propulsion)]); } if (vehicle.batteryLevel !== undefined) { - rows.push(["Battery", `${vehicle.batteryLevel}%`]); + rows.push([T.row.battery, t("format.batteryPercent", { value: vehicle.batteryLevel })]); } if (vehicle.rangeMeters !== undefined) { - rows.push(["Range", `${(vehicle.rangeMeters / 1000).toFixed(1)} km`]); + rows.push([ + T.row.range, + t("format.distanceKm", { value: (vehicle.rangeMeters / 1000).toFixed(1) }), + ]); } rows.push([ - "Status", - vehicle.isReserved ? "Reserved" : vehicle.isDisabled ? "Disabled" : "Available", + T.row.status, + vehicle.isReserved + ? T.value.reserved + : vehicle.isDisabled + ? T.value.disabled + : T.value.available, ]); sections.push({ - title: "Vehicle Info", + title: T.section.vehicleInfo, type: "table", columns: ["", ""], rows, @@ -423,18 +513,18 @@ export function mapVehicleToDetail(vehicle: SharedMobilityVehicle): DataSourceDe }); if (vehicle.rentalUris || vehicle.rentalApps) { - const linkRows: (string | number)[][] = []; - if (vehicle.rentalUris?.web) linkRows.push(["Web", vehicle.rentalUris.web]); - if (vehicle.rentalUris?.ios) linkRows.push(["iOS", vehicle.rentalUris.ios]); - if (vehicle.rentalUris?.android) linkRows.push(["Android", vehicle.rentalUris.android]); + const linkRows: (Translatable | number)[][] = []; + if (vehicle.rentalUris?.web) linkRows.push([T.row.web, vehicle.rentalUris.web]); + if (vehicle.rentalUris?.ios) linkRows.push([T.row.ios, vehicle.rentalUris.ios]); + if (vehicle.rentalUris?.android) linkRows.push([T.row.android, vehicle.rentalUris.android]); if (vehicle.rentalApps?.ios?.storeUri) - linkRows.push(["iOS App", vehicle.rentalApps.ios.storeUri]); + linkRows.push([T.row.iosApp, vehicle.rentalApps.ios.storeUri]); if (vehicle.rentalApps?.android?.storeUri) { - linkRows.push(["Android App", vehicle.rentalApps.android.storeUri]); + linkRows.push([T.row.androidApp, vehicle.rentalApps.android.storeUri]); } if (linkRows.length > 0) { sections.push({ - title: "Book", + title: T.section.book, type: "table", columns: ["", ""], rows: linkRows, @@ -445,13 +535,12 @@ export function mapVehicleToDetail(vehicle: SharedMobilityVehicle): DataSourceDe } const branding = brandingFromVehicle(vehicle); + const formLabel = formFactorLabelFallback(vehicle.formFactor); return { id: `${VEHICLE_ID_PREFIX}${vehicle.id}`, sources: vehicle.sources, identity: vehicleIdentity(vehicle), - name: vehicle.operator - ? `${vehicle.operator} ${FORM_FACTOR_LABELS[vehicle.formFactor] ?? "Vehicle"}` - : (FORM_FACTOR_LABELS[vehicle.formFactor] ?? "Vehicle"), + name: vehicle.operator ? `${vehicle.operator} ${formLabel}` : formLabel, coordinates: vehicle.coordinates, branding, operator: @@ -466,7 +555,13 @@ export function mapVehicleToDetail(vehicle: SharedMobilityVehicle): DataSourceDe }; } -const FORM_FACTOR_LABELS: Record = { +/** + * Fallback English labels used for fields that are not (yet) emitted as + * tokens: the vehicle `name` (composed with operator prefix), the + * `usageInfo.type` text, and accessory comma-join strings inside vehicle + * details. Kept in sync with the `value.formFactor.*` catalog keys. + */ +const FORM_FACTOR_FALLBACK: Record = { bicycle: "Bicycle", cargo_bicycle: "Cargo Bicycle", scooter_standing: "E-Scooter", @@ -476,13 +571,17 @@ const FORM_FACTOR_LABELS: Record = { other: "Vehicle", }; -const PROPULSION_LABELS: Record = { - human: "Human-powered", - electric_assist: "Electric Assist", - electric: "Electric", - combustion: "Combustion", - combustion_diesel: "Diesel", - hybrid: "Hybrid", - plug_in_hybrid: "Plug-in Hybrid", - hydrogen_fuel_cell: "Hydrogen", +const ACCESSORY_FALLBACK: Record = { + air_conditioning: "AC", + cruise_control: "Cruise Control", + automatic: "Automatic", + manual: "Manual", + navigation: "Navigation", + doors_3: "3 doors", + doors_4: "4 doors", + doors_5: "5 doors", }; + +function accessoryFallbackString(accessory: string): string { + return ACCESSORY_FALLBACK[accessory] ?? accessory.replace(/_/g, " "); +} From 19254a7ab12f17f30d20ffea9072f662fa42b8ef Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 28 May 2026 01:57:10 +0200 Subject: [PATCH 14/25] =?UTF-8?q?refactor(integrations):=20final=20sweep?= =?UTF-8?q?=20=E2=80=94=20no=20English=20literals=20across=20data-source?= =?UTF-8?q?=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../geocoding-db-ris/stations-mapper.ts | 36 +++++++++++-------- integrations/geocoding-db-ris/strings/de.json | 14 ++++++++ integrations/geocoding-db-ris/strings/en.json | 14 ++++++++ 3 files changed, 49 insertions(+), 15 deletions(-) diff --git a/integrations/geocoding-db-ris/stations-mapper.ts b/integrations/geocoding-db-ris/stations-mapper.ts index 00daa09c..bcc0b12f 100644 --- a/integrations/geocoding-db-ris/stations-mapper.ts +++ b/integrations/geocoding-db-ris/stations-mapper.ts @@ -9,6 +9,7 @@ import { type Place, type SearchResult, } from "@openmapx/core"; +import { type I18nToken, type Translatable, token } from "@openmapx/integration-framework/strings"; import type { TransitStop, TransportMode } from "@openmapx/mobility-core/transit"; import type { RisConnectingTime, @@ -112,19 +113,24 @@ export interface StationDetail { } interface StationDetailSection { - title: string; + title: I18nToken | string; type: "table" | "list"; collapsed?: boolean; - rows?: string[][]; - items?: string[]; + rows?: Translatable[][]; + items?: (I18nToken | string)[]; } -const TRAVELER_TYPE_LABELS: Record = { - COMMUTER: "Standard", - OCCASIONAL: "Occasional", - MOBILITY_RESTRICTED: "Mobility-restricted", +const TRAVELER_TYPE_TOKEN: Record = { + COMMUTER: token("value.travelerStandard"), + OCCASIONAL: token("value.travelerOccasional"), + MOBILITY_RESTRICTED: token("value.travelerMobilityRestricted"), }; +function travelerTypeLabel(type: string | undefined): Translatable { + if (!type) return TRAVELER_TYPE_TOKEN.COMMUTER; + return TRAVELER_TYPE_TOKEN[type] ?? type; +} + export function buildStationDetail( platforms: RisPlatform[], connectingTimes: RisConnectingTime[], @@ -134,32 +140,32 @@ export function buildStationDetail( if (platforms.length > 0) { sections.push({ - title: "Platforms", + title: token("section.platforms"), type: "table", rows: platforms.map((p) => [ p.name, - p.length ? `${p.length} m` : "-", - p.height ? `${p.height} cm` : "-", - p.accessibility?.stepFreeAccess ? "Step-free" : "-", + p.length ? token("value.metersValue", { count: p.length }) : "-", + p.height ? token("value.centimetersValue", { count: p.height }) : "-", + p.accessibility?.stepFreeAccess ? token("value.stepFree") : "-", ]), }); } if (connectingTimes.length > 0) { sections.push({ - title: "Transfer Times", + title: token("section.transferTimes"), type: "table", collapsed: true, rows: connectingTimes.map((ct) => [ - TRAVELER_TYPE_LABELS[ct.type ?? ""] ?? ct.type ?? "Standard", - ct.defaultDuration ? `${ct.defaultDuration} min` : "-", + travelerTypeLabel(ct.type), + ct.defaultDuration ? token("value.minutesValue", { count: ct.defaultDuration }) : "-", ]), }); } if (localServices.length > 0) { sections.push({ - title: "Local Services", + title: token("section.localServices"), type: "list", collapsed: true, items: localServices.map((s) => (s.category ? `${s.name} (${s.category})` : s.name)), diff --git a/integrations/geocoding-db-ris/strings/de.json b/integrations/geocoding-db-ris/strings/de.json index cebb9faa..fdc3f608 100644 --- a/integrations/geocoding-db-ris/strings/de.json +++ b/integrations/geocoding-db-ris/strings/de.json @@ -1,6 +1,20 @@ { "name": "Deutsche-Bahn-Bahnhofssuche", "description": "Geokodierung deutscher Bahnhöfe über die Deutsche Bahn RIS::Stations API", + "section": { + "platforms": "Bahnsteige", + "transferTimes": "Umsteigezeiten", + "localServices": "Lokale Dienste" + }, + "value": { + "travelerStandard": "Standard", + "travelerOccasional": "Gelegentlich", + "travelerMobilityRestricted": "Mobilitätseingeschränkt", + "stepFree": "Stufenfrei", + "metersValue": "{count} m", + "centimetersValue": "{count} cm", + "minutesValue": "{count, plural, =1 {1 Min.} other {# Min.}}" + }, "dataSources": [ { "purpose": "Vorwärts-/Rückwärts-Geokodierung deutscher Bahnhöfe sowie Bahnhofsdetail-Anreicherung", diff --git a/integrations/geocoding-db-ris/strings/en.json b/integrations/geocoding-db-ris/strings/en.json index 8c3d5c15..0f4396dc 100644 --- a/integrations/geocoding-db-ris/strings/en.json +++ b/integrations/geocoding-db-ris/strings/en.json @@ -1,6 +1,20 @@ { "name": "Deutsche Bahn Station Search", "description": "German railway station geocoding", + "section": { + "platforms": "Platforms", + "transferTimes": "Transfer Times", + "localServices": "Local Services" + }, + "value": { + "travelerStandard": "Standard", + "travelerOccasional": "Occasional", + "travelerMobilityRestricted": "Mobility-restricted", + "stepFree": "Step-free", + "metersValue": "{count} m", + "centimetersValue": "{count} cm", + "minutesValue": "{count, plural, =1 {1 min} other {# min}}" + }, "dataSources": [ { "purpose": "Forward/reverse geocoding of German railway stations, plus station detail lookup", From ecea6da7a3c89fe3f62e04e9e08746a79b43e5b7 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 28 May 2026 02:14:28 +0200 Subject: [PATCH 15/25] feat(integration-framework)!: tighten data-source contract to I18nToken-only labels --- .../panels/place/DataSourceSections.tsx | 2 +- integrations/bike-sharing/strings/de.json | 3 +- integrations/bike-sharing/strings/en.json | 3 +- integrations/car-sharing/strings/de.json | 3 +- integrations/car-sharing/strings/en.json | 3 +- .../ev-charging/providers/ocm-mapper.ts | 35 ----------------- .../ev-charging/providers/osm-mapper.ts | 33 ---------------- .../parking/providers/db-bahnpark-parser.ts | 12 +++--- .../parking/providers/mapper-utils.ts | 15 +++---- integrations/parking/strings/de.json | 3 +- integrations/parking/strings/en.json | 3 +- integrations/scooter-sharing/strings/de.json | 3 +- integrations/scooter-sharing/strings/en.json | 3 +- integrations/webcam/providers/caltrans.ts | 2 +- integrations/webcam/providers/dot/index.ts | 5 ++- integrations/webcam/providers/nps.ts | 5 ++- integrations/webcam/providers/osm.ts | 2 +- integrations/webcam/providers/tfl.ts | 2 +- integrations/webcam/providers/windy.ts | 5 ++- integrations/webcam/strings/de.json | 7 +++- integrations/webcam/strings/en.json | 7 +++- .../mobility-data-source-provider.ts | 39 ++++++++++++------- .../mobility-core/__tests__/mapper.test.ts | 17 ++++---- packages/mobility-core/src/mapper.ts | 24 +++++------- packages/mobility-core/src/types/parking.ts | 8 ++-- 25 files changed, 106 insertions(+), 138 deletions(-) diff --git a/apps/web/src/components/panels/place/DataSourceSections.tsx b/apps/web/src/components/panels/place/DataSourceSections.tsx index a6349358..920a39a9 100644 --- a/apps/web/src/components/panels/place/DataSourceSections.tsx +++ b/apps/web/src/components/panels/place/DataSourceSections.tsx @@ -188,7 +188,7 @@ function translateStructuredSection( items: section.items?.map((item) => resolveT(item)), content: section.content === undefined ? undefined : resolveT(section.content), imageUrl: section.imageUrl, - imageAlt: section.imageAlt, + imageAlt: section.imageAlt === undefined ? undefined : resolveT(section.imageAlt), linkUrl: section.linkUrl, embedUrl: section.embedUrl, embedType: section.embedType, diff --git a/integrations/bike-sharing/strings/de.json b/integrations/bike-sharing/strings/de.json index 73a08802..2f1acacc 100644 --- a/integrations/bike-sharing/strings/de.json +++ b/integrations/bike-sharing/strings/de.json @@ -71,7 +71,8 @@ }, "summary": { "available": "{count, plural, =0 {Keine Fahrräder verfügbar} one {1 Fahrrad verfügbar} other {# Fahrräder verfügbar}}", - "slots": "{count, plural, =0 {Keine freien Stellplätze} one {1 freier Stellplatz} other {# freie Stellplätze}}" + "slots": "{count, plural, =0 {Keine freien Stellplätze} one {1 freier Stellplatz} other {# freie Stellplätze}}", + "batteryRange": "{battery} % · {km} km" }, "format": { "batteryPercent": "{value} %", diff --git a/integrations/bike-sharing/strings/en.json b/integrations/bike-sharing/strings/en.json index 6228a6ad..e6cc57e1 100644 --- a/integrations/bike-sharing/strings/en.json +++ b/integrations/bike-sharing/strings/en.json @@ -71,7 +71,8 @@ }, "summary": { "available": "{count, plural, =0 {No bikes available} one {1 bike available} other {# bikes available}}", - "slots": "{count, plural, =0 {No empty slots} one {1 empty slot} other {# empty slots}}" + "slots": "{count, plural, =0 {No empty slots} one {1 empty slot} other {# empty slots}}", + "batteryRange": "{battery}% · {km} km" }, "format": { "batteryPercent": "{value}%", diff --git a/integrations/car-sharing/strings/de.json b/integrations/car-sharing/strings/de.json index 34d505b3..dbcf4126 100644 --- a/integrations/car-sharing/strings/de.json +++ b/integrations/car-sharing/strings/de.json @@ -71,7 +71,8 @@ }, "summary": { "available": "{count, plural, =0 {Keine Autos verfügbar} one {1 Auto verfügbar} other {# Autos verfügbar}}", - "slots": "{count, plural, =0 {Keine freien Stellplätze} one {1 freier Stellplatz} other {# freie Stellplätze}}" + "slots": "{count, plural, =0 {Keine freien Stellplätze} one {1 freier Stellplatz} other {# freie Stellplätze}}", + "batteryRange": "{battery} % · {km} km" }, "format": { "batteryPercent": "{value} %", diff --git a/integrations/car-sharing/strings/en.json b/integrations/car-sharing/strings/en.json index 8733deef..193b4a6c 100644 --- a/integrations/car-sharing/strings/en.json +++ b/integrations/car-sharing/strings/en.json @@ -71,7 +71,8 @@ }, "summary": { "available": "{count, plural, =0 {No cars available} one {1 car available} other {# cars available}}", - "slots": "{count, plural, =0 {No empty slots} one {1 empty slot} other {# empty slots}}" + "slots": "{count, plural, =0 {No empty slots} one {1 empty slot} other {# empty slots}}", + "batteryRange": "{battery}% · {km} km" }, "format": { "batteryPercent": "{value}%", diff --git a/integrations/ev-charging/providers/ocm-mapper.ts b/integrations/ev-charging/providers/ocm-mapper.ts index 112c0def..bd366b41 100644 --- a/integrations/ev-charging/providers/ocm-mapper.ts +++ b/integrations/ev-charging/providers/ocm-mapper.ts @@ -76,40 +76,6 @@ export function getStatus(poi: OcmPoi): EvChargingStatus { return "unknown"; } -export function buildSummary(poi: OcmPoi): string { - const parts: string[] = []; - - // Total connector count - const totalQty = poi.Connections?.reduce((sum, c) => sum + (c.Quantity ?? 1), 0) ?? 0; - - // Unique connector type names - const connectorNames = new Set(); - for (const conn of poi.Connections ?? []) { - if (conn.ConnectionType?.Title) { - connectorNames.add(conn.ConnectionType.Title); - } - } - - if (totalQty > 0 && connectorNames.size > 0) { - parts.push(`${totalQty}x ${Array.from(connectorNames).join(", ")}`); - } else if (totalQty > 0) { - parts.push(`${totalQty} connectors`); - } - - // Max power - const maxPower = getMaxPower(poi); - if (maxPower > 0) { - parts.push(`${maxPower}kW`); - } - - // Operator - if (poi.OperatorInfo?.Title && !poi.OperatorInfo.IsPrivateIndividual) { - parts.push(poi.OperatorInfo.Title); - } - - return parts.join(" \u00B7 "); -} - export function mapOcmToStation(poi: OcmPoi): EvChargingStation { const providerAttribution = getOcmProviderAttribution(poi); @@ -163,7 +129,6 @@ export function mapOcmToDetail(poi: OcmPoi): DataSourceDetail { export function mapOcmToResult(poi: OcmPoi): DataSourceResult { return { ...mapStationToResult(mapOcmToStation(poi)), - summary: buildSummary(poi), variant: getVariant(poi), }; } diff --git a/integrations/ev-charging/providers/osm-mapper.ts b/integrations/ev-charging/providers/osm-mapper.ts index 474822b9..12425739 100644 --- a/integrations/ev-charging/providers/osm-mapper.ts +++ b/integrations/ev-charging/providers/osm-mapper.ts @@ -27,17 +27,6 @@ const FAST_CHARGE_TAGS = new Set([ "socket:tesla_supercharger", ]); -function getConnectorLabels(tags: Record): string[] { - const labels: string[] = []; - for (const [tagKey, label] of Object.entries(SOCKET_TAG_MAP)) { - const value = tags[tagKey]; - if (value && value !== "no" && value !== "0") { - labels.push(label); - } - } - return labels; -} - function inferVariant(tags: Record): string { // Check if any fast-charge socket tags are present for (const tag of FAST_CHARGE_TAGS) { @@ -75,27 +64,6 @@ function inferStatus(tags: Record): EvChargingStation["status"] return "operational"; } -function buildOsmSummary(tags: Record): string { - const parts: string[] = []; - - const connectors = getConnectorLabels(tags); - if (connectors.length > 0) { - parts.push(connectors.join(", ")); - } - - const capacity = tags.capacity; - if (capacity) { - parts.push(`${capacity} points`); - } - - const operator = tags.operator || tags.network; - if (operator) { - parts.push(operator); - } - - return parts.join(" \u00B7 "); -} - function getOutputPower(tags: Record, tagKey?: string): number | undefined { if (tagKey) { const specific = parseLocalizedNumber(tags[`${tagKey}:output`]); @@ -177,6 +145,5 @@ export function mapOsmToResult(station: OsmChargingStation): DataSourceResult { return { ...mapStationToResult(mapOsmToStation(station)), variant: inferVariant(station.tags), - summary: buildOsmSummary(station.tags), }; } diff --git a/integrations/parking/providers/db-bahnpark-parser.ts b/integrations/parking/providers/db-bahnpark-parser.ts index 43c9146d..08f32893 100644 --- a/integrations/parking/providers/db-bahnpark-parser.ts +++ b/integrations/parking/providers/db-bahnpark-parser.ts @@ -60,17 +60,19 @@ const DB_BAHNPARK_DURATION_TOKEN: Record = { "1monthReservation": token("tariff.dur1monthReserved"), }; -function buildTariffRows( - facility: DbBahnParkFacility, -): [I18nTokenLike | string, string][] | undefined { +function buildTariffRows(facility: DbBahnParkFacility): [I18nTokenLike, string][] | undefined { const prices = facility.tariff?.prices; if (!prices || prices.length === 0) return undefined; - const rows: [I18nTokenLike | string, string][] = []; + const rows: [I18nTokenLike, string][] = []; for (const p of prices) { if (p.price == null || !p.duration) continue; if (p.group?.groupName !== "standard") continue; - const label = DB_BAHNPARK_DURATION_TOKEN[p.duration] ?? p.duration; + // Known DB-BahnPark duration codes map to typed tokens; unknown codes + // fall back to a literal-template token so the raw upstream label is + // rendered verbatim without an English leak across the contract. + const label = + DB_BAHNPARK_DURATION_TOKEN[p.duration] ?? token("tariff.literal", { value: p.duration }); rows.push([label, `€${p.price.toFixed(2)}`]); } return rows.length > 0 ? rows : undefined; diff --git a/integrations/parking/providers/mapper-utils.ts b/integrations/parking/providers/mapper-utils.ts index 1633a599..a27a6b92 100644 --- a/integrations/parking/providers/mapper-utils.ts +++ b/integrations/parking/providers/mapper-utils.ts @@ -96,20 +96,21 @@ function isI18nTokenLike(value: unknown): value is I18nTokenLike { } /** - * Coerce a JSON-deserialized tariff-rows array into `[I18nToken | string, string][]`. - * Labels may be either an `I18nToken` (`{ $t, values? }`) emitted by the - * migrated parsers or a raw string (legacy payloads still in the registry). - * Prices are always pre-formatted strings. + * Coerce a JSON-deserialized tariff-rows array into `[I18nTokenLike, string][]`. + * Labels emitted by the migrated parsers are already `I18nToken` objects; any + * raw string surviving in a legacy registry payload is wrapped in the + * `tariff.literal` token so the contract's I18nToken-only label invariant + * holds end-to-end. */ -export function asTariffRows(value: unknown): [I18nTokenLike | string, string][] | undefined { +export function asTariffRows(value: unknown): [I18nTokenLike, string][] | undefined { if (!Array.isArray(value)) return undefined; - const rows: [I18nTokenLike | string, string][] = []; + const rows: [I18nTokenLike, string][] = []; for (const item of value) { if (!Array.isArray(item) || item.length !== 2) continue; const [label, price] = item; if (typeof price !== "string") continue; if (typeof label === "string") { - rows.push([label, price]); + rows.push([{ $t: "tariff.literal", values: { value: label } }, price]); } else if (isI18nTokenLike(label)) { rows.push([label, price]); } diff --git a/integrations/parking/strings/de.json b/integrations/parking/strings/de.json index e8c4c2ea..ada4bc1b 100644 --- a/integrations/parking/strings/de.json +++ b/integrations/parking/strings/de.json @@ -60,7 +60,8 @@ "dur1monthReserved": "1 Monat (reserviert)", "durMinutes": "{count} Min.", "durHours": "{count, plural, =1 {1 Std.} other {# Std.}}", - "durDays": "{count, plural, =1 {1 Tag} other {# Tage}}" + "durDays": "{count, plural, =1 {1 Tag} other {# Tage}}", + "literal": "{value}" }, "quality": { "realtimeStale": "Echtzeit-Verfügbarkeit ist älter als 30 Minuten.", diff --git a/integrations/parking/strings/en.json b/integrations/parking/strings/en.json index d7cb1603..749d5353 100644 --- a/integrations/parking/strings/en.json +++ b/integrations/parking/strings/en.json @@ -60,7 +60,8 @@ "dur1monthReserved": "1 month (reserved)", "durMinutes": "{count} min", "durHours": "{count, plural, =1 {1 hour} other {# hours}}", - "durDays": "{count, plural, =1 {1 day} other {# days}}" + "durDays": "{count, plural, =1 {1 day} other {# days}}", + "literal": "{value}" }, "quality": { "realtimeStale": "Realtime availability is older than 30 minutes.", diff --git a/integrations/scooter-sharing/strings/de.json b/integrations/scooter-sharing/strings/de.json index cf03b962..4b22271f 100644 --- a/integrations/scooter-sharing/strings/de.json +++ b/integrations/scooter-sharing/strings/de.json @@ -71,7 +71,8 @@ }, "summary": { "available": "{count, plural, =0 {Keine Roller verfügbar} one {1 Roller verfügbar} other {# Roller verfügbar}}", - "slots": "{count, plural, =0 {Keine freien Stellplätze} one {1 freier Stellplatz} other {# freie Stellplätze}}" + "slots": "{count, plural, =0 {Keine freien Stellplätze} one {1 freier Stellplatz} other {# freie Stellplätze}}", + "batteryRange": "{battery} % · {km} km" }, "format": { "batteryPercent": "{value} %", diff --git a/integrations/scooter-sharing/strings/en.json b/integrations/scooter-sharing/strings/en.json index de03f982..5108e989 100644 --- a/integrations/scooter-sharing/strings/en.json +++ b/integrations/scooter-sharing/strings/en.json @@ -71,7 +71,8 @@ }, "summary": { "available": "{count, plural, =0 {No scooters available} one {1 scooter available} other {# scooters available}}", - "slots": "{count, plural, =0 {No empty slots} one {1 empty slot} other {# empty slots}}" + "slots": "{count, plural, =0 {No empty slots} one {1 empty slot} other {# empty slots}}", + "batteryRange": "{battery}% · {km} km" }, "format": { "batteryPercent": "{value}%", diff --git a/integrations/webcam/providers/caltrans.ts b/integrations/webcam/providers/caltrans.ts index 0d83cd6f..9f744ae5 100644 --- a/integrations/webcam/providers/caltrans.ts +++ b/integrations/webcam/providers/caltrans.ts @@ -134,7 +134,7 @@ export function mapCaltransToDetail(raw: RawWebcam): DataSourceDetail { title: token("section.preview"), type: "image", imageUrl: raw.thumbnailUrl, - imageAlt: raw.name, + imageAlt: token("imageAlt.webcam", { name: raw.name }), sectionIcon: "videocam", }); } diff --git a/integrations/webcam/providers/dot/index.ts b/integrations/webcam/providers/dot/index.ts index 9bafdbdf..2630a039 100644 --- a/integrations/webcam/providers/dot/index.ts +++ b/integrations/webcam/providers/dot/index.ts @@ -31,13 +31,14 @@ function getEnabledStates(): StateDotConfig[] { } export function mapDotToResult(raw: RawWebcam): DataSourceResult { + const direction = raw.direction ?? raw.location?.region; return { id: raw.id, name: raw.name, coordinates: raw.coordinates, source: raw.source, variant: raw.variant, - summary: raw.direction ?? raw.location?.region ?? undefined, + summary: direction ? token("summary.direction", { direction }) : undefined, }; } @@ -77,7 +78,7 @@ export function mapDotToDetail(raw: RawWebcam): DataSourceDetail { title: token("section.preview"), type: "image", imageUrl: raw.thumbnailUrl, - imageAlt: raw.name, + imageAlt: token("imageAlt.webcam", { name: raw.name }), sectionIcon: "videocam", }); } diff --git a/integrations/webcam/providers/nps.ts b/integrations/webcam/providers/nps.ts index 03ab71de..f3b7430e 100644 --- a/integrations/webcam/providers/nps.ts +++ b/integrations/webcam/providers/nps.ts @@ -116,13 +116,14 @@ async function fetchAllNps(): Promise { } export function mapNpsToResult(raw: RawWebcam): DataSourceResult { + const city = raw.location?.city; return { id: raw.id, name: raw.name, coordinates: raw.coordinates, source: raw.source, variant: raw.variant, - summary: raw.location?.city ?? undefined, + summary: city ? token("summary.place", { place: city }) : undefined, }; } @@ -149,7 +150,7 @@ export function mapNpsToDetail(raw: RawWebcam): DataSourceDetail { title: token("section.preview"), type: "image", imageUrl: raw.thumbnailUrl, - imageAlt: raw.name, + imageAlt: token("imageAlt.webcam", { name: raw.name }), linkUrl: raw.detailUrl, sectionIcon: "videocam", }); diff --git a/integrations/webcam/providers/osm.ts b/integrations/webcam/providers/osm.ts index 8de18899..22edeb1d 100644 --- a/integrations/webcam/providers/osm.ts +++ b/integrations/webcam/providers/osm.ts @@ -92,7 +92,7 @@ export async function mapOsmToDetail(raw: RawWebcam): Promise title: token("section.webcam"), type: "image", imageUrl: raw.thumbnailUrl, - imageAlt: raw.name, + imageAlt: token("imageAlt.webcam", { name: raw.name }), linkUrl: raw.thumbnailUrl, sectionIcon: "videocam", }); diff --git a/integrations/webcam/providers/tfl.ts b/integrations/webcam/providers/tfl.ts index 68562075..098a5668 100644 --- a/integrations/webcam/providers/tfl.ts +++ b/integrations/webcam/providers/tfl.ts @@ -77,7 +77,7 @@ export function mapTflToDetail(raw: RawWebcam): DataSourceDetail { title: token("section.preview"), type: "image", imageUrl: raw.thumbnailUrl, - imageAlt: raw.name, + imageAlt: token("imageAlt.webcam", { name: raw.name }), sectionIcon: "videocam", }); } diff --git a/integrations/webcam/providers/windy.ts b/integrations/webcam/providers/windy.ts index 75e930c7..a7eb0ccb 100644 --- a/integrations/webcam/providers/windy.ts +++ b/integrations/webcam/providers/windy.ts @@ -89,13 +89,14 @@ function mapWindyToRaw(wc: WindyWebcam): RawWebcam { } export function mapWindyToResult(raw: RawWebcam): DataSourceResult { + const categories = raw.categories?.join(", "); return { id: raw.id, name: raw.name, coordinates: raw.coordinates, source: raw.source, variant: raw.variant, - summary: raw.categories?.join(", "), + summary: categories ? token("summary.categories", { categories }) : undefined, }; } @@ -143,7 +144,7 @@ export function mapWindyToDetail(raw: RawWebcam): DataSourceDetail { title: token("section.preview"), type: "image", imageUrl: raw.thumbnailUrl, - imageAlt: raw.name, + imageAlt: token("imageAlt.webcam", { name: raw.name }), linkUrl: raw.detailUrl, sectionIcon: "videocam", }); diff --git a/integrations/webcam/strings/de.json b/integrations/webcam/strings/de.json index 23d59e5f..e31e8880 100644 --- a/integrations/webcam/strings/de.json +++ b/integrations/webcam/strings/de.json @@ -34,7 +34,12 @@ }, "summary": { "direction": "Blickrichtung: {direction}", - "view": "Ansicht: {direction}" + "view": "Ansicht: {direction}", + "place": "{place}", + "categories": "{categories}" + }, + "imageAlt": { + "webcam": "{name}" }, "dataSources": [ { diff --git a/integrations/webcam/strings/en.json b/integrations/webcam/strings/en.json index b0ca6f72..0c206906 100644 --- a/integrations/webcam/strings/en.json +++ b/integrations/webcam/strings/en.json @@ -34,7 +34,12 @@ }, "summary": { "direction": "Direction: {direction}", - "view": "View: {direction}" + "view": "View: {direction}", + "place": "{place}", + "categories": "{categories}" + }, + "imageAlt": { + "webcam": "{name}" }, "dataSources": [ { diff --git a/packages/integration-framework/src/contracts/mobility-data-source-provider.ts b/packages/integration-framework/src/contracts/mobility-data-source-provider.ts index 3f00a7f5..6a1a325a 100644 --- a/packages/integration-framework/src/contracts/mobility-data-source-provider.ts +++ b/packages/integration-framework/src/contracts/mobility-data-source-provider.ts @@ -105,8 +105,7 @@ export interface DataSourceResult { kind?: "station" | "vehicle"; variant: string; status?: string; - /** transitional, Task 4.1 tightens to I18nToken-only */ - summary?: I18nToken | string; + summary?: I18nToken; operator?: string; branding?: DataSourceBranding; mapContext?: DataSourceMapContextSelection; @@ -126,26 +125,40 @@ export interface PricingPlanEntry { } export interface DataSourceDetailSection { - /** transitional, Task 4.1 tightens to I18nToken-only */ - title: I18nToken | string; + title: I18nToken; type: "table" | "list" | "text" | "image" | "embed" | "pricing"; - /** transitional, Task 4.1 tightens to I18nToken-only */ - columns?: (I18nToken | string)[]; + columns?: I18nToken[]; /** - * Row tuple `[label, ...values]`. Label is an i18n token (or transitional - * string). Values are `Translatable` — token, raw string, or number. + * Table rows. Two shapes are accepted: * - * transitional, Task 4.1 tightens label slot to I18nToken-only. + * - `[label, value]` 2-tuples for the canonical key/value layout where + * the left cell is a row label. The label is `I18nToken`-strict; the + * value is `Translatable` (token, raw string, or number — the right + * column legitimately mixes translated text with upstream pass-through: + * operator addresses, prices, formatted numbers). + * - Wider `Translatable[]` rows for true multi-column tables driven by + * `columns` headers (e.g. EV connector tables). Each cell is a value; + * the "label" semantics live in the column header rather than the row. + * + * The web renderer dispatches on `row.length === 2` to pick between + * label/value and multi-column rendering. + */ + rows?: [I18nToken, Translatable][] | Translatable[][]; + /** + * User-visible list items. Tokens preferred for fixed vocabulary; raw + * `string` passthrough allowed for upstream-provided notes (operator + * descriptions, OSM addenda, quality warnings forwarded verbatim). */ - rows?: (Translatable | number)[][]; - /** transitional, Task 4.1 tightens to I18nToken-only */ items?: (I18nToken | string)[]; - /** transitional, Task 4.1 tightens to I18nToken-only */ + /** + * Text-section content. Token for fixed messages, raw `string` passthrough + * for upstream-provided narrative (operator descriptions, raw notes). + */ content?: I18nToken | string; /** Image URL for type "image". Rendered as a safe element. */ imageUrl?: string; /** Alt text for image sections. */ - imageAlt?: string; + imageAlt?: I18nToken; /** Link URL. For "image" sections, wraps the image in an anchor tag. */ linkUrl?: string; /** Embed URL for type "embed". Rendered as a sandboxed iframe or video element. */ diff --git a/packages/mobility-core/__tests__/mapper.test.ts b/packages/mobility-core/__tests__/mapper.test.ts index 942c7e77..fcb733d7 100644 --- a/packages/mobility-core/__tests__/mapper.test.ts +++ b/packages/mobility-core/__tests__/mapper.test.ts @@ -347,19 +347,22 @@ describe("mapVehicleToResult", () => { expect(result.summary).toEqual({ $t: "format.distanceKm", values: { value: "12.0" } }); }); - it("joins battery and range with middle dot as a pass-through string", () => { - // When both are present the legacy joined format is preserved; tokens - // for each piece would require client-side composition which the - // single-token result card does not currently support. + it("emits the combined battery+range token when both are known", () => { + // Compound combos resolve against the consuming integration's + // `summary.batteryRange` template so the separator/unit are + // locale-correct — no English literal crosses the contract. const result = mapVehicleToResult(makeVehicle({ batteryLevel: 80, rangeMeters: 25000 })); - expect(result.summary).toBe("80% · 25.0 km"); + expect(result.summary).toEqual({ + $t: "summary.batteryRange", + values: { battery: 80, km: "25.0" }, + }); }); - it("returns empty string when no battery or range", () => { + it("returns undefined summary when no battery or range", () => { const result = mapVehicleToResult( makeVehicle({ batteryLevel: undefined, rangeMeters: undefined }), ); - expect(result.summary).toBe(""); + expect(result.summary).toBeUndefined(); }); }); }); diff --git a/packages/mobility-core/src/mapper.ts b/packages/mobility-core/src/mapper.ts index b00a0e30..8c53b1ad 100644 --- a/packages/mobility-core/src/mapper.ts +++ b/packages/mobility-core/src/mapper.ts @@ -230,7 +230,6 @@ export function mapStationToDetail(station: SharedMobilityStation): DataSourceDe sections.push({ title: T.section.availability, type: "table", - columns: ["", ""], rows, sectionIcon: "info", }); @@ -247,7 +246,6 @@ export function mapStationToDetail(station: SharedMobilityStation): DataSourceDe sections.push({ title: T.section.transit, type: "table", - columns: ["", ""], rows: transitRows, sectionIcon: "directions_bus", }); @@ -290,7 +288,6 @@ export function mapStationToDetail(station: SharedMobilityStation): DataSourceDe sections.push({ title: T.section.vehicleDetails, type: "table", - columns: ["", ""], rows: vtRows, sectionIcon: "directions_car", collapsed: true, @@ -335,7 +332,6 @@ export function mapStationToDetail(station: SharedMobilityStation): DataSourceDe sections.push({ title: T.section.book, type: "table", - columns: ["", ""], rows: linkRows, sectionIcon: "open_in_new", collapsed: true, @@ -354,7 +350,6 @@ export function mapStationToDetail(station: SharedMobilityStation): DataSourceDe sections.push({ title: T.section.apps, type: "table", - columns: ["", ""], rows: appRows, sectionIcon: "open_in_new", collapsed: true, @@ -432,13 +427,16 @@ function vehicleVariant(vehicle: SharedMobilityVehicle): string { return "available"; } -function vehicleSummary(vehicle: SharedMobilityVehicle): I18nTokenLike | string { - // The summary template glues battery + range + (optional) text. The current - // catalog ships per-piece format tokens; assemble client-side by emitting - // the raw composed string when present. When only one piece exists, emit - // the corresponding token so the locale-aware unit is used. +function vehicleSummary(vehicle: SharedMobilityVehicle): I18nTokenLike | undefined { + // The summary glues battery + range + (optional) text. Compound combos go + // through the consuming integration's `summary.batteryRange` template so the + // separator/unit are locale-correct; single-piece summaries use the per-unit + // `format.*` tokens. if (vehicle.batteryLevel !== undefined && vehicle.rangeMeters !== undefined) { - return `${vehicle.batteryLevel}% · ${(vehicle.rangeMeters / 1000).toFixed(1)} km`; + return t("summary.batteryRange", { + battery: vehicle.batteryLevel, + km: (vehicle.rangeMeters / 1000).toFixed(1), + }); } if (vehicle.batteryLevel !== undefined) { return t("format.batteryPercent", { value: vehicle.batteryLevel }); @@ -446,7 +444,7 @@ function vehicleSummary(vehicle: SharedMobilityVehicle): I18nTokenLike | string if (vehicle.rangeMeters !== undefined) { return t("format.distanceKm", { value: (vehicle.rangeMeters / 1000).toFixed(1) }); } - return ""; + return undefined; } function formFactorLabelFallback(formFactor: string): string { @@ -507,7 +505,6 @@ export function mapVehicleToDetail(vehicle: SharedMobilityVehicle): DataSourceDe sections.push({ title: T.section.vehicleInfo, type: "table", - columns: ["", ""], rows, sectionIcon: "info", }); @@ -526,7 +523,6 @@ export function mapVehicleToDetail(vehicle: SharedMobilityVehicle): DataSourceDe sections.push({ title: T.section.book, type: "table", - columns: ["", ""], rows: linkRows, sectionIcon: "open_in_new", collapsed: true, diff --git a/packages/mobility-core/src/types/parking.ts b/packages/mobility-core/src/types/parking.ts index 063702c2..1e58e724 100644 --- a/packages/mobility-core/src/types/parking.ts +++ b/packages/mobility-core/src/types/parking.ts @@ -62,11 +62,11 @@ export interface ParkingFacility { feeDescription?: string; /** * Structured pricing rows: `[durationLabel, formattedPrice]`. The label is - * an `I18nToken` emitted by parsers; the price is a pre-formatted string - * (`€2.10`, `CHF 1.50`). Strings are accepted transitionally — Task 4.1 of - * the i18n-token rollout tightens this to `[I18nToken, string][]`. + * an `I18nToken` emitted by parsers (use `tariff.literal` with a `value` + * placeholder for upstream-supplied labels that have no known mapping); + * the price is a pre-formatted string (`€2.10`, `CHF 1.50`). */ - tariffRows?: [I18nTokenLike | string, string][]; + tariffRows?: [I18nTokenLike, string][]; access?: "public" | "customers" | "private" | "permit"; operator?: string; From cb4fe6d6f3848953fe741fe0374c2df0999b9ee9 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 28 May 2026 02:23:46 +0200 Subject: [PATCH 16/25] chore(i18n): delete legacy dataSources.* keys migrated to integration strings --- packages/i18n/locales/de.json | 124 +--------------------------------- packages/i18n/locales/en.json | 124 +--------------------------------- 2 files changed, 2 insertions(+), 246 deletions(-) diff --git a/packages/i18n/locales/de.json b/packages/i18n/locales/de.json index fe0ff6d5..a7b30228 100644 --- a/packages/i18n/locales/de.json +++ b/packages/i18n/locales/de.json @@ -747,11 +747,6 @@ "carSharing": "Carsharing", "sharedMobility": "Geteilte Mobilität", "membershipRequiredLabel": "Mitgliedschaft erforderlich", - "sectionAvailability": "Verfügbarkeit", - "sectionPublicTransit": "Nahverkehr", - "sectionVehicleDetails": "Fahrzeugdetails", - "sectionVehicleClasses": "Fahrzeugklassen", - "sectionPricing": "Preise", "pricingStandardPlan": "Standard", "pricingUnlockFee": "Entsperrgebühr", "pricingPerKm": "Pro km", @@ -761,135 +756,18 @@ "filterSpacesAvailable": "Freie Plätze verfügbar", "filterIncludeFull": "Volle Parkplätze einbeziehen", "filterDisabledParking": "Behindertenparken", - "sectionBook": "Buchen", - "sectionDirections": "Wegbeschreibung", - "sectionNotes": "Hinweise", - "sectionConnectors": "Anschlüsse", - "sectionUsage": "Nutzung", - "sectionAccess": "Zugang", - "rowAvailableVehicles": "Verfügbare Fahrzeuge", - "rowEmptySlots": "Freie Plätze", - "rowTotalCapacity": "Gesamtkapazität", - "rowType": "Typ", - "rowPricing": "Preise", - "rowBusLines": "Buslinien", - "rowNearestStops": "Nächste Haltestellen", - "rowVehicle": "Fahrzeug", - "rowPropulsion": "Antrieb", - "rowSeats": "Sitze", - "rowFeatures": "Ausstattung", - "rowCo2": "CO₂", - "fixedStation": "Feste Station", - "freeFloatingZone": "Free-floating-Zone", - "zeroEmissions": "Emissionsfrei", "dbStation": "DB-Bahnhof", "parking": "Parken", "freeSpaces": "Freie Plätze", - "sectionFacility": "Einrichtung", - "sectionDataQuality": "Datenqualität", - "sectionFee": "Gebühren", - "rowFreeSpaces": "Freie Plätze", - "rowOccupancy": "Auslastung", - "rowMaxHeight": "Max. Höhe", - "rowDisabledSpaces": "Behindertenplätze", - "rowWomenSpaces": "Frauenparkplätze", - "rowEvCharging": "E-Ladeplätze", - "rowParkAndRide": "Park & Ride", - "rowCapacity": "Kapazität", - "rowStatus": "Status", - "rowTrend": "Tendenz", - "rowAccess": "Zugang", - "rowDataFreshness": "Datenaktualität", - "rowNearestStation": "Nächster Bahnhof", - "sectionSource": "Quelle", - "rowSource": "Quelle", - "rowSources": "Quellen", - "rowSourceId": "Quell-ID", - "rowSourceUrl": "Quell-URL", - "rowLicense": "Lizenz", - "sectionInfo": "Infos", - "sectionPreview": "Vorschau", - "sectionVideoClip": "Videoclip", - "sectionLiveTimelapse": "Live / Zeitraffer", - "sectionLiveStream": "Livestream", - "sectionWebcam": "Webcam", - "sectionUnavailable": "Nicht verfügbar", - "sectionOriginalUrl": "Original-URL", - "sectionFuelPrices": "Kraftstoffpreise", - "columnFuelType": "Kraftstoffart", - "columnPriceEur": "Preis (€)", - "trendIncreasing": "Füllt sich", - "trendDecreasing": "Leert sich", - "trendConstant": "Konstant", - "webcamUrlOffline": "Diese Webcam-URL ist offline oder nicht mehr verfügbar.", - "sectionPayment": "Bezahlung", "sectionNearbyTransit": "ÖPNV in der Nähe", "openStop": "{name} öffnen", "externalMediaTitle": "Externe Medien", "externalMediaBody": "Wird vom Kameraanbieter geladen. Ihre IP-Adresse kann dort sichtbar werden.", "loadExternalMedia": "Medien laden", - "dur20min": "20 Min.", - "dur30min": "30 Min.", - "dur1h": "1 Std.", - "dur1day": "1 Tag", - "dur1dayPCard": "1 Tag (P-Card)", - "dur1week": "1 Woche", - "dur1weekPCard": "1 Woche (P-Card)", - "dur1month": "1 Monat", - "dur1monthLong": "1 Monat (Dauerparken)", - "dur1monthReserved": "1 Monat (reserviert)", "durationMinutes": "{count} Min.", "durationHours": "{count, plural, one {# Std.} other {# Std.}}", "durationDays": "{count, plural, one {# Tag} other {# Tage}}", - "tariffMaxDayPrice": "Max. Tagespreis", - "tariffMonthly": "Monatlich", - "tariffMonthlyResident": "Monatlich (Anwohner)", - "tariffMonthlyPass": "Monatskarte", - "tariffYearlyPass": "Jahreskarte", - "parkingGarage": "Parkhaus", - "undergroundGarage": "Tiefgarage", - "surfaceLot": "Parkplatz", - "onStreet": "Straßenparkplatz", - "freeParking": "Kostenloses Parken", - "paidParking": "Kostenpflichtiges Parken", - "unknownFee": "Unbekannt", - "yes": "Ja", - "open": "Geöffnet", - "closed": "Geschlossen", - "stale": "Veraltet", - "summaryAvailabilityStale": "Verfügbarkeit veraltet", - "summaryClosed": "Geschlossen", - "summaryFull": "Voll", - "summaryFreeCapacity": "{free}/{capacity} frei", - "summaryFreeSpaces": "{count, plural, one {# frei} other {# frei}}", - "summarySpaces": "{count, plural, one {# Platz} other {# Plätze}}", - "qualityRealtimeAvailabilityStale": "Echtzeit-Verfügbarkeit ist älter als 30 Minuten.", - "qualityFreeSpacesClampedToCapacity": "Die Echtzeit-Zahl freier Plätze war höher als die Kapazität und wurde begrenzt.", - "qualityNegativeFreeSpacesClamped": "Die Echtzeit-Zahl freier Plätze war negativ und wurde auf 0 begrenzt.", - "evChargingAvailable": "E-Laden verfügbar", - "customers": "Kunden", - "private": "Privat", - "permit": "Genehmigung", - "webcams": "Webcams", - "webcamCategory": "Kategorie", - "webcamSource": "Quelle", - "rowCity": "Stadt", - "rowRegion": "Region", - "rowCountry": "Land", - "rowCategories": "Kategorien", - "rowViews": "Aufrufe", - "rowLastUpdated": "Zuletzt aktualisiert", - "rowDirection": "Richtung", - "rowNearby": "In der Nähe", - "rowCounty": "Bezirk", - "rowLiveStream": "Livestream", - "rowView": "Blickrichtung", - "rowPark": "Park", - "rowState": "Bundesstaat", - "rowTags": "Tags", - "rowNpsPage": "NPS-Seite", - "rowRoad": "Straße", - "rowLocation": "Standort" + "webcams": "Webcams" }, "account": { "language": "Sprache", diff --git a/packages/i18n/locales/en.json b/packages/i18n/locales/en.json index b3de8bbe..1f0ae1e8 100644 --- a/packages/i18n/locales/en.json +++ b/packages/i18n/locales/en.json @@ -747,11 +747,6 @@ "carSharing": "Car Sharing", "sharedMobility": "Shared Mobility", "membershipRequiredLabel": "Membership required", - "sectionAvailability": "Availability", - "sectionPublicTransit": "Public Transit", - "sectionVehicleDetails": "Vehicle Details", - "sectionVehicleClasses": "Vehicle Classes", - "sectionPricing": "Pricing", "pricingStandardPlan": "Standard", "pricingUnlockFee": "Unlock fee", "pricingPerKm": "Per km", @@ -761,135 +756,18 @@ "filterSpacesAvailable": "Spaces Available", "filterIncludeFull": "Include Full", "filterDisabledParking": "Disabled Parking", - "sectionBook": "Book", - "sectionDirections": "Directions", - "sectionNotes": "Notes", - "sectionConnectors": "Connectors", - "sectionUsage": "Usage", - "sectionAccess": "Access", - "rowAvailableVehicles": "Available Vehicles", - "rowEmptySlots": "Empty Slots", - "rowTotalCapacity": "Total Capacity", - "rowType": "Type", - "rowPricing": "Pricing", - "rowBusLines": "Bus Lines", - "rowNearestStops": "Nearest Stops", - "rowVehicle": "Vehicle", - "rowPropulsion": "Propulsion", - "rowSeats": "Seats", - "rowFeatures": "Features", - "rowCo2": "CO₂", - "fixedStation": "Fixed Station", - "freeFloatingZone": "Free-floating Zone", - "zeroEmissions": "Zero emissions", "dbStation": "DB Station", "parking": "Parking", "freeSpaces": "Free Spaces", - "sectionFacility": "Facility", - "sectionDataQuality": "Data Quality", - "sectionFee": "Fee", - "rowFreeSpaces": "Free Spaces", - "rowOccupancy": "Occupancy", - "rowMaxHeight": "Max Height", - "rowDisabledSpaces": "Disabled Spaces", - "rowWomenSpaces": "Women's Spaces", - "rowEvCharging": "EV Charging", - "rowParkAndRide": "Park & Ride", - "rowCapacity": "Capacity", - "rowStatus": "Status", - "rowTrend": "Trend", - "rowAccess": "Access", - "rowDataFreshness": "Data Freshness", - "rowNearestStation": "Nearest Station", - "sectionSource": "Source", - "rowSource": "Source", - "rowSources": "Sources", - "rowSourceId": "Source ID", - "rowSourceUrl": "Source URL", - "rowLicense": "License", - "sectionInfo": "Info", - "sectionPreview": "Preview", - "sectionVideoClip": "Video Clip", - "sectionLiveTimelapse": "Live / Timelapse", - "sectionLiveStream": "Live Stream", - "sectionWebcam": "Webcam", - "sectionUnavailable": "Unavailable", - "sectionOriginalUrl": "Original URL", - "sectionFuelPrices": "Fuel Prices", - "columnFuelType": "Fuel Type", - "columnPriceEur": "Price (€)", - "trendIncreasing": "Filling up", - "trendDecreasing": "Emptying", - "trendConstant": "Steady", - "webcamUrlOffline": "This webcam URL appears to be offline or no longer available.", - "sectionPayment": "Payment", "sectionNearbyTransit": "Nearby Transit", "openStop": "Open {name}", "externalMediaTitle": "External media", "externalMediaBody": "Loads from the camera provider. Your IP address may be visible to them.", "loadExternalMedia": "Load media", - "dur20min": "20 min", - "dur30min": "30 min", - "dur1h": "1 hour", - "dur1day": "1 day", - "dur1dayPCard": "1 day (P-Card)", - "dur1week": "1 week", - "dur1weekPCard": "1 week (P-Card)", - "dur1month": "1 month", - "dur1monthLong": "1 month (long-term)", - "dur1monthReserved": "1 month (reserved)", "durationMinutes": "{count} min", "durationHours": "{count, plural, one {# hour} other {# hours}}", "durationDays": "{count, plural, one {# day} other {# days}}", - "tariffMaxDayPrice": "Max day price", - "tariffMonthly": "Monthly", - "tariffMonthlyResident": "Monthly (resident)", - "tariffMonthlyPass": "Monthly pass", - "tariffYearlyPass": "Yearly pass", - "parkingGarage": "Parking Garage", - "undergroundGarage": "Underground Garage", - "surfaceLot": "Surface Lot", - "onStreet": "On-Street", - "freeParking": "Free Parking", - "paidParking": "Paid Parking", - "unknownFee": "Unknown", - "yes": "Yes", - "open": "Open", - "closed": "Closed", - "stale": "Stale", - "summaryAvailabilityStale": "Availability stale", - "summaryClosed": "Closed", - "summaryFull": "Full", - "summaryFreeCapacity": "{free}/{capacity} free", - "summaryFreeSpaces": "{count, plural, one {# free} other {# free}}", - "summarySpaces": "{count, plural, one {# space} other {# spaces}}", - "qualityRealtimeAvailabilityStale": "Realtime availability is older than 30 minutes.", - "qualityFreeSpacesClampedToCapacity": "Realtime free-space count exceeded capacity and was clamped.", - "qualityNegativeFreeSpacesClamped": "Realtime free-space count was negative and was clamped to 0.", - "evChargingAvailable": "EV charging available", - "customers": "Customers", - "private": "Private", - "permit": "Permit", - "webcams": "Webcams", - "webcamCategory": "Category", - "webcamSource": "Source", - "rowCity": "City", - "rowRegion": "Region", - "rowCountry": "Country", - "rowCategories": "Categories", - "rowViews": "Views", - "rowLastUpdated": "Last Updated", - "rowDirection": "Direction", - "rowNearby": "Nearby", - "rowCounty": "County", - "rowLiveStream": "Live Stream", - "rowView": "View", - "rowPark": "Park", - "rowState": "State", - "rowTags": "Tags", - "rowNpsPage": "NPS Page", - "rowRoad": "Road", - "rowLocation": "Location" + "webcams": "Webcams" }, "account": { "language": "Language", From baa938df974816d101310ae5c140046b78edaac7 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 28 May 2026 02:27:21 +0200 Subject: [PATCH 17/25] chore(i18n): extend check-translations to cover framework + integration strings --- .../live-transit-motis/strings/de.json | 4 + .../live-transit-siri-sx-ch/strings/de.json | 4 + packages/i18n/scripts/check-translations.ts | 92 +++++++++++++++++++ 3 files changed, 100 insertions(+) create mode 100644 integrations/live-transit-motis/strings/de.json create mode 100644 integrations/live-transit-siri-sx-ch/strings/de.json diff --git a/integrations/live-transit-motis/strings/de.json b/integrations/live-transit-motis/strings/de.json new file mode 100644 index 00000000..049802ac --- /dev/null +++ b/integrations/live-transit-motis/strings/de.json @@ -0,0 +1,4 @@ +{ + "name": "MOTIS GTFS-RT", + "description": "Live-Meldungen und Fahrplanaktualisierungen von MOTIS" +} diff --git a/integrations/live-transit-siri-sx-ch/strings/de.json b/integrations/live-transit-siri-sx-ch/strings/de.json new file mode 100644 index 00000000..0f0e7555 --- /dev/null +++ b/integrations/live-transit-siri-sx-ch/strings/de.json @@ -0,0 +1,4 @@ +{ + "name": "Schweizer SIRI-SX Live-Meldungen", + "description": "Echtzeit-SIRI-SX-Verkehrsmeldungen für die Schweiz von opentransportdata.swiss" +} diff --git a/packages/i18n/scripts/check-translations.ts b/packages/i18n/scripts/check-translations.ts index 51e040be..a2b3d42a 100644 --- a/packages/i18n/scripts/check-translations.ts +++ b/packages/i18n/scripts/check-translations.ts @@ -156,6 +156,98 @@ for (const [locale, keys] of allLocales) { if (consistencyOk) console.log(" All locales have matching keys."); +// 1b. Framework + integration strings parity +// +// Beyond the apps/web shell strings checked above, we also ship: +// - the framework shared catalog at packages/integration-framework/strings/locales/*.json +// - per-integration catalogs at integrations//strings/*.json +// +// Each must keep its locale variants key-aligned (currently en + de). + +console.log("\n\x1b[1m1b. Framework + integration strings parity\x1b[0m\n"); + +interface CatalogParityTarget { + label: string; + dir: string; +} + +const FRAMEWORK_STRINGS_DIR = join( + REPO_ROOT, + "packages", + "integration-framework", + "strings", + "locales", +); +const INTEGRATIONS_DIR = join(REPO_ROOT, "integrations"); + +const parityTargets: CatalogParityTarget[] = [{ label: "framework", dir: FRAMEWORK_STRINGS_DIR }]; +try { + for (const entry of readdirSync(INTEGRATIONS_DIR).sort()) { + const stringsDir = join(INTEGRATIONS_DIR, entry, "strings"); + try { + if (!statSync(stringsDir).isDirectory()) continue; + } catch { + continue; + } + parityTargets.push({ label: entry, dir: stringsDir }); + } +} catch { + // integrations dir missing — skip +} + +let extraParityOk = true; +for (const target of parityTargets) { + let localeJsonFiles: string[]; + try { + localeJsonFiles = readdirSync(target.dir) + .filter((f) => f.endsWith(".json")) + .sort(); + } catch { + continue; + } + if (localeJsonFiles.length < 2) continue; + + const catalogs = new Map>(); + for (const file of localeJsonFiles) { + const locale = file.replace(".json", ""); + try { + const raw = JSON.parse(readFileSync(join(target.dir, file), "utf-8")) as Record< + string, + unknown + >; + catalogs.set(locale, flattenKeys(raw)); + } catch (err) { + error("PARSE", `[${target.label}] ${file}: ${(err as Error).message}`); + extraParityOk = false; + } + } + + const refKeys = catalogs.get(referenceLocale); + if (!refKeys) continue; + + for (const [locale, keys] of catalogs) { + if (locale === referenceLocale) continue; + const missingInLocale = [...refKeys.keys()].filter((k) => !keys.has(k)); + const extraInLocale = [...keys.keys()].filter((k) => !refKeys.has(k)); + for (const k of missingInLocale) { + error("MISSING", `[${target.label}] ${locale}.json is missing "${k}"`); + extraParityOk = false; + } + for (const k of extraInLocale) { + warn( + "EXTRA", + `[${target.label}] ${locale}.json has extra "${k}" (not in ${referenceLocale}.json)`, + ); + extraParityOk = false; + } + } +} + +if (extraParityOk) + console.log( + ` All framework + integration catalogs have matching keys (${parityTargets.length} catalogs).`, + ); + // 2 & 3. Unused and missing keys console.log( From b150e7a05834dc60b370afded5ffc504888c54a2 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 28 May 2026 09:02:41 +0200 Subject: [PATCH 18/25] refactor(geocoding-db-ris): tighten StationDetailSection.title to I18nToken-only The local StationDetail type is attached to Place.dataSourceDetail via an `as unknown as Place` cast, so it never received the Task 4.1 hard-break that made every other integration's section title I18nToken-only. Tightening the local title slot restores the compile-time guarantee against raw-string label leaks here too. --- integrations/geocoding-db-ris/stations-mapper.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integrations/geocoding-db-ris/stations-mapper.ts b/integrations/geocoding-db-ris/stations-mapper.ts index bcc0b12f..fb017b48 100644 --- a/integrations/geocoding-db-ris/stations-mapper.ts +++ b/integrations/geocoding-db-ris/stations-mapper.ts @@ -113,7 +113,7 @@ export interface StationDetail { } interface StationDetailSection { - title: I18nToken | string; + title: I18nToken; type: "table" | "list"; collapsed?: boolean; rows?: Translatable[][]; From 76ab9026deb1ac2ed3da2d2dd9e2d31d8ab95384 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 28 May 2026 09:28:12 +0200 Subject: [PATCH 19/25] refactor(integration-framework): drop write-only IntegrationRegistry.frameworkStrings The field was a transitional holding spot from the API-wiring task; the client resolver reads framework strings from FrameworkStringsProvider/useFrameworkStrings instead, so the registry field and its constructor param were never read. --- apps/web/src/providers/IntegrationProvider.tsx | 5 +---- packages/integration-framework/src/registry.ts | 11 +---------- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/apps/web/src/providers/IntegrationProvider.tsx b/apps/web/src/providers/IntegrationProvider.tsx index fa3bf802..a700491f 100644 --- a/apps/web/src/providers/IntegrationProvider.tsx +++ b/apps/web/src/providers/IntegrationProvider.tsx @@ -76,10 +76,7 @@ export function IntegrationProvider({ children }: { children: React.ReactNode }) } }, [integrations, apiBase]); - const registry = useMemo( - () => new IntegrationRegistry(integrations ?? [], frameworkStrings ?? {}), - [integrations, frameworkStrings], - ); + const registry = useMemo(() => new IntegrationRegistry(integrations ?? []), [integrations]); useEffect(() => { if (integrations?.length) { diff --git a/packages/integration-framework/src/registry.ts b/packages/integration-framework/src/registry.ts index c99ba75c..57a19d0d 100644 --- a/packages/integration-framework/src/registry.ts +++ b/packages/integration-framework/src/registry.ts @@ -1,20 +1,11 @@ -import type { LocaleStrings } from "../strings/index.js"; import type { LoadedIntegrationMeta } from "./loader"; import type { IntegrationDataSource } from "./manifest"; export class IntegrationRegistry { private integrations: LoadedIntegrationMeta[]; - /** - * Framework shared strings catalog as shipped by `/api/integrations`. - * Task 1.5 will introduce `FrameworkStringsProvider` + `useFrameworkStrings` - * — for now we surface this on the registry so the next task has somewhere - * to read from without reshaping the provider tree. - */ - readonly frameworkStrings: LocaleStrings; - constructor(integrations: LoadedIntegrationMeta[], frameworkStrings: LocaleStrings = {}) { + constructor(integrations: LoadedIntegrationMeta[]) { this.integrations = integrations; - this.frameworkStrings = frameworkStrings; } getAll(): LoadedIntegrationMeta[] { From 010e8b153982b61151253c1156a2e9f6fe2e47b7 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 28 May 2026 09:29:04 +0200 Subject: [PATCH 20/25] fix(integration-framework): guard token resolver against malformed ICU templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A malformed catalog template (unbalanced braces, bad plural syntax) made IntlMessageFormat throw inside the render path, crashing the panel. Wrap formatting in try/catch and fall back to the raw template — consistent with the resolver's existing "visible bug beats crash" fallback for missing keys. --- .../strings/__tests__/resolver.test.ts | 11 +++++++++++ .../integration-framework/strings/src/resolver.ts | 9 ++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/integration-framework/strings/__tests__/resolver.test.ts b/packages/integration-framework/strings/__tests__/resolver.test.ts index 6bc98ffa..7e1343a0 100644 --- a/packages/integration-framework/strings/__tests__/resolver.test.ts +++ b/packages/integration-framework/strings/__tests__/resolver.test.ts @@ -92,4 +92,15 @@ describe("resolveToken", () => { ); expect(out).toBe("Source"); }); + + it("falls back to the raw template (does not throw) when the ICU template is malformed", () => { + const broken = { + en: { summary: { broken: "{count, plural, one {x}" } }, + }; + const out = resolveToken( + { $t: "summary.broken", values: { count: 3 } }, + { locale: "en", fallbackLocale: "en", shared, integration: broken }, + ); + expect(out).toBe("{count, plural, one {x}"); + }); }); diff --git a/packages/integration-framework/strings/src/resolver.ts b/packages/integration-framework/strings/src/resolver.ts index 2df896fc..ec895e30 100644 --- a/packages/integration-framework/strings/src/resolver.ts +++ b/packages/integration-framework/strings/src/resolver.ts @@ -66,5 +66,12 @@ function format( if (!values) return template; // ICU MessageFormat — same engine next-intl uses, so plural/select syntax // works identically between server-emitted templates and client-side strings. - return new IntlMessageFormat(template, locale).format(values) as string; + // A malformed template (unbalanced braces, bad plural syntax) throws on + // construction; fall back to the raw template so a single bad catalog entry + // degrades to visible-but-unformatted text instead of crashing the render. + try { + return new IntlMessageFormat(template, locale).format(values) as string; + } catch { + return template; + } } From 21feb5e17870603f40a80cd8daaed92bad2cc091 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 28 May 2026 09:29:42 +0200 Subject: [PATCH 21/25] refactor(integration-framework)!: enforce I18nToken row labels via min-3-cell grid union The prior rows type `[I18nToken, Translatable][] | Translatable[][]` did not actually enforce token labels: `Translatable[][]` is a superset that admits a raw-string label in a 2-cell row, so the hard-break's "no raw English at a label slot" guarantee did not hold for rows. Constrain the grid member to 3+ cells (`[Translatable, Translatable, Translatable, ...Translatable[]][]`) so a 2-cell row must satisfy the token-label-strict member. Renderer is unchanged (still dispatches on row.length === 2); this is type-only tightening. Ripple: ev-charging connector rows and db-ris platform rows are annotated as explicit grid tuples; mobility-core row arrays and the db-ris transfer-times label are tightened to token labels (unknown DB traveler enums pass through a `value.travelerLiteral` token instead of a raw string). --- .../ev-charging/providers/station-mapper.ts | 6 ++-- .../geocoding-db-ris/stations-mapper.ts | 17 +++++++--- integrations/geocoding-db-ris/strings/de.json | 1 + integrations/geocoding-db-ris/strings/en.json | 1 + .../mobility-data-source-provider.ts | 31 ++++++++++++------- packages/mobility-core/src/mapper.ts | 16 +++++----- 6 files changed, 46 insertions(+), 26 deletions(-) diff --git a/integrations/ev-charging/providers/station-mapper.ts b/integrations/ev-charging/providers/station-mapper.ts index 90477a2a..86f2ca4e 100644 --- a/integrations/ev-charging/providers/station-mapper.ts +++ b/integrations/ev-charging/providers/station-mapper.ts @@ -106,10 +106,12 @@ function formatTimestamp(value: string | undefined): string | undefined { .replace(/\.\d{3}Z$/, " UTC"); } -function connectorRows(station: EvChargingStation): Translatable[][] { +function connectorRows( + station: EvChargingStation, +): [Translatable, Translatable, Translatable, ...Translatable[]][] { return [...station.connectors] .sort((a, b) => (b.powerKw ?? 0) - (a.powerKw ?? 0)) - .map((conn) => [ + .map((conn): [Translatable, Translatable, Translatable, Translatable, Translatable] => [ conn.type ?? sharedT.value.unknown, formatPower(conn.powerKw), conn.currentType ?? "-", diff --git a/integrations/geocoding-db-ris/stations-mapper.ts b/integrations/geocoding-db-ris/stations-mapper.ts index fb017b48..c616fc45 100644 --- a/integrations/geocoding-db-ris/stations-mapper.ts +++ b/integrations/geocoding-db-ris/stations-mapper.ts @@ -116,7 +116,11 @@ interface StationDetailSection { title: I18nToken; type: "table" | "list"; collapsed?: boolean; - rows?: Translatable[][]; + // Mirrors the framework contract: 2-cell rows are token-label-strict; + // 3+-cell grids carry their labels in column headers, so cells are free. + rows?: + | [I18nToken, Translatable][] + | [Translatable, Translatable, Translatable, ...Translatable[]][]; items?: (I18nToken | string)[]; } @@ -126,9 +130,12 @@ const TRAVELER_TYPE_TOKEN: Record = { MOBILITY_RESTRICTED: token("value.travelerMobilityRestricted"), }; -function travelerTypeLabel(type: string | undefined): Translatable { +function travelerTypeLabel(type: string | undefined): I18nToken { if (!type) return TRAVELER_TYPE_TOKEN.COMMUTER; - return TRAVELER_TYPE_TOKEN[type] ?? type; + // Unknown DB enum values pass through as a literal token so the row label + // stays I18nToken-typed (no raw string crosses the contract) while still + // surfacing the raw value. + return TRAVELER_TYPE_TOKEN[type] ?? token("value.travelerLiteral", { type }); } export function buildStationDetail( @@ -142,7 +149,7 @@ export function buildStationDetail( sections.push({ title: token("section.platforms"), type: "table", - rows: platforms.map((p) => [ + rows: platforms.map((p): [Translatable, Translatable, Translatable, Translatable] => [ p.name, p.length ? token("value.metersValue", { count: p.length }) : "-", p.height ? token("value.centimetersValue", { count: p.height }) : "-", @@ -156,7 +163,7 @@ export function buildStationDetail( title: token("section.transferTimes"), type: "table", collapsed: true, - rows: connectingTimes.map((ct) => [ + rows: connectingTimes.map((ct): [I18nToken, Translatable] => [ travelerTypeLabel(ct.type), ct.defaultDuration ? token("value.minutesValue", { count: ct.defaultDuration }) : "-", ]), diff --git a/integrations/geocoding-db-ris/strings/de.json b/integrations/geocoding-db-ris/strings/de.json index fdc3f608..f62b16d2 100644 --- a/integrations/geocoding-db-ris/strings/de.json +++ b/integrations/geocoding-db-ris/strings/de.json @@ -10,6 +10,7 @@ "travelerStandard": "Standard", "travelerOccasional": "Gelegentlich", "travelerMobilityRestricted": "Mobilitätseingeschränkt", + "travelerLiteral": "{type}", "stepFree": "Stufenfrei", "metersValue": "{count} m", "centimetersValue": "{count} cm", diff --git a/integrations/geocoding-db-ris/strings/en.json b/integrations/geocoding-db-ris/strings/en.json index 0f4396dc..01f302c2 100644 --- a/integrations/geocoding-db-ris/strings/en.json +++ b/integrations/geocoding-db-ris/strings/en.json @@ -10,6 +10,7 @@ "travelerStandard": "Standard", "travelerOccasional": "Occasional", "travelerMobilityRestricted": "Mobility-restricted", + "travelerLiteral": "{type}", "stepFree": "Step-free", "metersValue": "{count} m", "centimetersValue": "{count} cm", diff --git a/packages/integration-framework/src/contracts/mobility-data-source-provider.ts b/packages/integration-framework/src/contracts/mobility-data-source-provider.ts index 6a1a325a..a09af507 100644 --- a/packages/integration-framework/src/contracts/mobility-data-source-provider.ts +++ b/packages/integration-framework/src/contracts/mobility-data-source-provider.ts @@ -129,21 +129,28 @@ export interface DataSourceDetailSection { type: "table" | "list" | "text" | "image" | "embed" | "pricing"; columns?: I18nToken[]; /** - * Table rows. Two shapes are accepted: + * Table rows. Two shapes are accepted, distinguished by cell count: * - * - `[label, value]` 2-tuples for the canonical key/value layout where - * the left cell is a row label. The label is `I18nToken`-strict; the - * value is `Translatable` (token, raw string, or number — the right - * column legitimately mixes translated text with upstream pass-through: - * operator addresses, prices, formatted numbers). - * - Wider `Translatable[]` rows for true multi-column tables driven by - * `columns` headers (e.g. EV connector tables). Each cell is a value; - * the "label" semantics live in the column header rather than the row. + * - `[label, value]` 2-tuples for the canonical key/value layout. The + * label (left cell) is `I18nToken`-strict — a raw string here is a + * compile error, which is what keeps un-translated labels off the wire. + * The value (right cell) is `Translatable` (token, raw string, or number), + * because the right column legitimately mixes translated text with + * upstream pass-through: operator addresses, prices, formatted numbers. + * - Wider rows of **3 or more** cells for true multi-column tables driven + * by `columns` headers (e.g. EV connector tables). Each cell is a value; + * the "label" semantics live in the column header rather than the row, so + * cells are unconstrained `Translatable`. * - * The web renderer dispatches on `row.length === 2` to pick between - * label/value and multi-column rendering. + * The ≥3-cell lower bound on the grid form is load-bearing: it prevents a + * 2-cell row from satisfying the grid member and thereby smuggling a + * raw-string label past the token-strict key/value member. The web renderer + * dispatches on `row.length === 2` to pick between label/value and + * multi-column rendering. */ - rows?: [I18nToken, Translatable][] | Translatable[][]; + rows?: + | [I18nToken, Translatable][] + | [Translatable, Translatable, Translatable, ...Translatable[]][]; /** * User-visible list items. Tokens preferred for fixed vocabulary; raw * `string` passthrough allowed for upstream-provided notes (operator diff --git a/packages/mobility-core/src/mapper.ts b/packages/mobility-core/src/mapper.ts index 8c53b1ad..a46011b0 100644 --- a/packages/mobility-core/src/mapper.ts +++ b/packages/mobility-core/src/mapper.ts @@ -210,7 +210,9 @@ export function mapStationToDetail(station: SharedMobilityStation): DataSourceDe const sections: DataSourceDetailSection[] = []; // Availability table - const rows: (Translatable | number)[][] = [[T.row.availableVehicles, station.availableVehicles]]; + const rows: [I18nTokenLike, Translatable][] = [ + [T.row.availableVehicles, station.availableVehicles], + ]; if (station.emptySlots !== undefined) { rows.push([T.row.emptySlots, station.emptySlots]); } @@ -236,7 +238,7 @@ export function mapStationToDetail(station: SharedMobilityStation): DataSourceDe // Transit info if (station.transitInfo?.lines || station.transitInfo?.stops) { - const transitRows: (Translatable | number)[][] = []; + const transitRows: [I18nTokenLike, Translatable][] = []; if (station.transitInfo.lines) { transitRows.push([T.row.busLines, station.transitInfo.lines]); } @@ -253,7 +255,7 @@ export function mapStationToDetail(station: SharedMobilityStation): DataSourceDe // Vehicle type details (structured — from GBFS) if (station.vehicleTypeDetails && station.vehicleTypeDetails.length > 0) { - const vtRows: (Translatable | number)[][] = []; + const vtRows: [I18nTokenLike, Translatable][] = []; for (const vt of station.vehicleTypeDetails) { const labelText: string | undefined = (vt.make && vt.model ? `${vt.make} ${vt.model}` : vt.name) || undefined; @@ -324,7 +326,7 @@ export function mapStationToDetail(station: SharedMobilityStation): DataSourceDe // Rental links if (station.rentalUris) { - const linkRows: (Translatable | number)[][] = []; + const linkRows: [I18nTokenLike, Translatable][] = []; if (station.rentalUris.web) linkRows.push([T.row.web, station.rentalUris.web]); if (station.rentalUris.android) linkRows.push([T.row.android, station.rentalUris.android]); if (station.rentalUris.ios) linkRows.push([T.row.ios, station.rentalUris.ios]); @@ -340,7 +342,7 @@ export function mapStationToDetail(station: SharedMobilityStation): DataSourceDe } if (station.rentalApps) { - const appRows: (Translatable | number)[][] = []; + const appRows: [I18nTokenLike, Translatable][] = []; if (station.rentalApps.ios?.storeUri) appRows.push([T.row.iosApp, station.rentalApps.ios.storeUri]); if (station.rentalApps.android?.storeUri) { @@ -479,7 +481,7 @@ export function mapVehicleToResult(vehicle: SharedMobilityVehicle): DataSourceRe export function mapVehicleToDetail(vehicle: SharedMobilityVehicle): DataSourceDetail { const sections: DataSourceDetailSection[] = []; - const rows: (Translatable | number)[][] = []; + const rows: [I18nTokenLike, Translatable][] = []; rows.push([T.row.type, formFactorToken(vehicle.formFactor)]); if (vehicle.propulsion) { rows.push([T.row.propulsion, propulsionToken(vehicle.propulsion)]); @@ -510,7 +512,7 @@ export function mapVehicleToDetail(vehicle: SharedMobilityVehicle): DataSourceDe }); if (vehicle.rentalUris || vehicle.rentalApps) { - const linkRows: (Translatable | number)[][] = []; + const linkRows: [I18nTokenLike, Translatable][] = []; if (vehicle.rentalUris?.web) linkRows.push([T.row.web, vehicle.rentalUris.web]); if (vehicle.rentalUris?.ios) linkRows.push([T.row.ios, vehicle.rentalUris.ios]); if (vehicle.rentalUris?.android) linkRows.push([T.row.android, vehicle.rentalUris.android]); From 4fd3ca515e565b4a5a56812d332543f060c8beff Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 28 May 2026 09:35:15 +0200 Subject: [PATCH 22/25] ci: grant statuses:write to PR title validation workflow action-semantic-pull-request@v6 reports its result via the commit-statuses API, which needs `statuses: write`. The workflow only granted `pull-requests: write`, so the job failed with "Resource not accessible by integration" on every PR. The title rules themselves were passing. Note: under `pull_request_target` the workflow runs from the base branch, so this only takes effect once merged to main. --- .github/workflows/pr-title.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml index a86729c4..5931092d 100644 --- a/.github/workflows/pr-title.yml +++ b/.github/workflows/pr-title.yml @@ -5,10 +5,14 @@ on: types: [opened, edited, synchronize, reopened] permissions: - # `pull-requests: write` is needed because `wip: true` makes the action - # set the PR check to "pending" while the title contains [WIP]. - # See: https://github.com/amannn/action-semantic-pull-request#wip + # `statuses: write` is required because action-semantic-pull-request@v6 + # reports its result via the commit-statuses API (also used by `wip: true` + # to flag [WIP] titles). Without it the action fails with + # "Resource not accessible by integration". `pull-requests: write` lets it + # read the PR title under `pull_request_target`. + # See: https://github.com/amannn/action-semantic-pull-request#permissions pull-requests: write + statuses: write jobs: validate: From c25ab0ccad3b8fb17fb51c66fffba449c54204af Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 28 May 2026 10:34:20 +0200 Subject: [PATCH 23/25] fix(i18n): restore dataSources.* keys still used by the filter/summary shim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 4.2 deleted 122 dataSources.* keys as "integration-emitted", but the live web-shell shim dataSourceSummaryI18n.ts maps parking filter labels and string-summary fallbacks to ~17 of them via dynamic t(variableKey) calls that the static check-translations script can't see. The result was broken key-path labels (e.g. "rowType", "parkingGarage") in the parking filter sidebar in both locales. Restore the referenced keys — they are web-shell filter chrome, not data-source detail content, so they belong alongside the kept filter* keys. --- packages/i18n/locales/de.json | 17 +++++++++++++++++ packages/i18n/locales/en.json | 17 +++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/packages/i18n/locales/de.json b/packages/i18n/locales/de.json index a7b30228..5ef75877 100644 --- a/packages/i18n/locales/de.json +++ b/packages/i18n/locales/de.json @@ -756,6 +756,23 @@ "filterSpacesAvailable": "Freie Plätze verfügbar", "filterIncludeFull": "Volle Parkplätze einbeziehen", "filterDisabledParking": "Behindertenparken", + "rowType": "Typ", + "rowEvCharging": "E-Ladeplätze", + "rowFeatures": "Ausstattung", + "rowParkAndRide": "Park & Ride", + "sectionFee": "Gebühren", + "sectionAvailability": "Verfügbarkeit", + "onStreet": "Straßenparkplatz", + "parkingGarage": "Parkhaus", + "undergroundGarage": "Tiefgarage", + "surfaceLot": "Parkplatz", + "unknownFee": "Unbekannt", + "summaryAvailabilityStale": "Verfügbarkeit veraltet", + "summaryClosed": "Geschlossen", + "summaryFull": "Voll", + "summaryFreeCapacity": "{free}/{capacity} frei", + "summaryFreeSpaces": "{count, plural, one {# frei} other {# frei}}", + "summarySpaces": "{count, plural, one {# Platz} other {# Plätze}}", "dbStation": "DB-Bahnhof", "parking": "Parken", "freeSpaces": "Freie Plätze", diff --git a/packages/i18n/locales/en.json b/packages/i18n/locales/en.json index 1f0ae1e8..46b36331 100644 --- a/packages/i18n/locales/en.json +++ b/packages/i18n/locales/en.json @@ -756,6 +756,23 @@ "filterSpacesAvailable": "Spaces Available", "filterIncludeFull": "Include Full", "filterDisabledParking": "Disabled Parking", + "rowType": "Type", + "rowEvCharging": "EV Charging", + "rowFeatures": "Features", + "rowParkAndRide": "Park & Ride", + "sectionFee": "Fee", + "sectionAvailability": "Availability", + "onStreet": "On-Street", + "parkingGarage": "Parking Garage", + "undergroundGarage": "Underground Garage", + "surfaceLot": "Surface Lot", + "unknownFee": "Unknown", + "summaryAvailabilityStale": "Availability stale", + "summaryClosed": "Closed", + "summaryFull": "Full", + "summaryFreeCapacity": "{free}/{capacity} free", + "summaryFreeSpaces": "{count, plural, one {# free} other {# free}}", + "summarySpaces": "{count, plural, one {# space} other {# spaces}}", "dbStation": "DB Station", "parking": "Parking", "freeSpaces": "Free Spaces", From 2d2013d994dfd9d8b5cf79a8cd643b8592b42348 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 28 May 2026 10:35:02 +0200 Subject: [PATCH 24/25] fix(integration-framework): localize usageInfo + accessory lists via token resolution usageInfo.type/cost were rendered as raw strings, so mobility-core's `Access: ${method}` and ev-charging's "Public" fallback leaked English in the DE locale. Widen usageInfo.type/cost to Translatable and resolve them in the panel renderer; mobility-core now emits the format.accessMethod token and ev-charging falls back to sharedT.value.public. Also localize the vehicle accessories cell: a row value may now be a Translatable[] that the client resolves and joins, so accessories emit value.accessory.* tokens (English fallback for unknown enums) instead of a pre-joined English string. --- .../panels/place/DataSourceSections.tsx | 18 +++++--- .../ev-charging/providers/station-mapper.ts | 2 +- .../mobility-data-source-provider.ts | 7 ++- .../mobility-core/__tests__/mapper.test.ts | 31 +++++++++++-- packages/mobility-core/src/mapper.ts | 44 +++++++++---------- 5 files changed, 67 insertions(+), 35 deletions(-) diff --git a/apps/web/src/components/panels/place/DataSourceSections.tsx b/apps/web/src/components/panels/place/DataSourceSections.tsx index 920a39a9..73740e3f 100644 --- a/apps/web/src/components/panels/place/DataSourceSections.tsx +++ b/apps/web/src/components/panels/place/DataSourceSections.tsx @@ -169,12 +169,18 @@ function translateStructuredSection( resolveT: (value: Translatable | undefined) => string, ): StructuredSection { const translatedTitle = resolveT(section.title); + // A value cell may be a single Translatable or a list of them (e.g. a + // localized accessory list); resolve each and join with the locale separator. + const resolveCell = (cell: Translatable | Translatable[] | undefined): string | number => + Array.isArray(cell) + ? cell.map((entry) => resolveT(entry)).join(", ") + : typeof cell === "number" + ? cell + : resolveT(cell); const translatedRows: (string | number)[][] | undefined = section.type === "table" && section.rows?.every((row) => row.length === 2) - ? section.rows.map(([label, value]) => [resolveT(label), resolveT(value)]) - : section.rows?.map((row) => - row.map((cell) => (typeof cell === "number" ? cell : resolveT(cell))), - ); + ? section.rows.map(([label, value]) => [resolveT(label), resolveCell(value)]) + : section.rows?.map((row) => row.map((cell) => resolveCell(cell))); const translatedColumns = section.columns?.map((column) => resolveT(column)); // Re-build the StructuredSection explicitly (no spread) so the narrowed // resolved field types win over `DataSourceDetailSection`'s wider @@ -354,10 +360,10 @@ export function DataSourceSections({ detail, domain }: Props) { - {detail.usageInfo.type} + {resolveT(detail.usageInfo.type)} {detail.usageInfo.cost && ( - {detail.usageInfo.cost} + {resolveT(detail.usageInfo.cost)} )} {detail.usageInfo.membershipRequired && ( diff --git a/integrations/ev-charging/providers/station-mapper.ts b/integrations/ev-charging/providers/station-mapper.ts index 86f2ca4e..d286a69b 100644 --- a/integrations/ev-charging/providers/station-mapper.ts +++ b/integrations/ev-charging/providers/station-mapper.ts @@ -206,7 +206,7 @@ export function mapStationToDetail(station: EvChargingStation): DataSourceDetail usageInfo: station.usageType || station.usageCost || station.membershipRequired !== undefined ? { - type: station.usageType ?? "Public", + type: station.usageType ?? sharedT.value.public, cost: station.usageCost, membershipRequired: station.membershipRequired, } diff --git a/packages/integration-framework/src/contracts/mobility-data-source-provider.ts b/packages/integration-framework/src/contracts/mobility-data-source-provider.ts index a09af507..22ad0461 100644 --- a/packages/integration-framework/src/contracts/mobility-data-source-provider.ts +++ b/packages/integration-framework/src/contracts/mobility-data-source-provider.ts @@ -137,6 +137,9 @@ export interface DataSourceDetailSection { * The value (right cell) is `Translatable` (token, raw string, or number), * because the right column legitimately mixes translated text with * upstream pass-through: operator addresses, prices, formatted numbers. + * A value may also be a `Translatable[]` — a list of tokens/strings the + * client resolves individually and joins (e.g. a localized list of vehicle + * accessories in a single cell). * - Wider rows of **3 or more** cells for true multi-column tables driven * by `columns` headers (e.g. EV connector tables). Each cell is a value; * the "label" semantics live in the column header rather than the row, so @@ -149,7 +152,7 @@ export interface DataSourceDetailSection { * multi-column rendering. */ rows?: - | [I18nToken, Translatable][] + | [I18nToken, Translatable | Translatable[]][] | [Translatable, Translatable, Translatable, ...Translatable[]][]; /** * User-visible list items. Tokens preferred for fixed vocabulary; raw @@ -225,7 +228,7 @@ export interface DataSourceDetail { /** Per-record attribution that cannot be expressed statically in the integration manifest. */ attributions?: DataSourceAttribution[]; branding?: DataSourceBranding; - usageInfo?: { type: string; cost?: string; membershipRequired?: boolean }; + usageInfo?: { type: Translatable; cost?: Translatable; membershipRequired?: boolean }; /** OSM-format opening hours string (e.g., "Mo-Fr 06:00-20:00; Sa-Su 08:00-20:00"). */ openingHours?: string; sections: DataSourceDetailSection[]; diff --git a/packages/mobility-core/__tests__/mapper.test.ts b/packages/mobility-core/__tests__/mapper.test.ts index fcb733d7..c2287d41 100644 --- a/packages/mobility-core/__tests__/mapper.test.ts +++ b/packages/mobility-core/__tests__/mapper.test.ts @@ -456,10 +456,31 @@ describe("mapStationToDetail", () => { $t: "value.propulsionKind.electric_assist", }); expect(findRow(section, "row.seats")?.[1]).toBe(1); - expect(findRow(section, "row.features")?.[1]).toBe("Navigation"); + // Accessories emit one token per entry (resolved + joined client-side). + expect(findRow(section, "row.features")?.[1]).toEqual([{ $t: "value.accessory.navigation" }]); expect(findRow(section, "row.co2")?.[1]).toEqual({ $t: "value.zeroEmissions" }); }); + it("emits accessory tokens for known enums and English fallback for unknown", () => { + const detail = mapStationToDetail( + makeStation({ + vehicleTypeDetails: [ + { + name: "Car", + formFactor: "car", + accessories: ["air_conditioning", "heated_seats"], + }, + ], + }), + ); + const section = findSection(detail.sections, "section.vehicleDetails"); + // Known enum → catalog token; unknown enum → readable English fallback. + expect(findRow(section, "row.features")?.[1]).toEqual([ + { $t: "value.accessory.air_conditioning" }, + "heated seats", + ]); + }); + it("shows CO2 value in g/km when > 0", () => { const detail = mapStationToDetail( makeStation({ @@ -595,9 +616,11 @@ describe("mapStationToDetail", () => { name: "TestBikes", url: "https://testbikes.example.com", }); - // usageInfo.type stays an English pass-through during the transitional - // phase; tightening to a token follows in Task 4.1. - expect(detail.usageInfo).toEqual({ type: "Access: App" }); + // usageInfo.type emits the format.accessMethod token; the client resolver + // interpolates the raw access method against the integration catalog. + expect(detail.usageInfo).toEqual({ + type: { $t: "format.accessMethod", values: { method: "App" } }, + }); }); it("omits address when station has no address", () => { diff --git a/packages/mobility-core/src/mapper.ts b/packages/mobility-core/src/mapper.ts index a46011b0..bb80c422 100644 --- a/packages/mobility-core/src/mapper.ts +++ b/packages/mobility-core/src/mapper.ts @@ -255,7 +255,7 @@ export function mapStationToDetail(station: SharedMobilityStation): DataSourceDe // Vehicle type details (structured — from GBFS) if (station.vehicleTypeDetails && station.vehicleTypeDetails.length > 0) { - const vtRows: [I18nTokenLike, Translatable][] = []; + const vtRows: [I18nTokenLike, Translatable | Translatable[]][] = []; for (const vt of station.vehicleTypeDetails) { const labelText: string | undefined = (vt.make && vt.model ? `${vt.make} ${vt.model}` : vt.name) || undefined; @@ -268,17 +268,17 @@ export function mapStationToDetail(station: SharedMobilityStation): DataSourceDe if (vt.propulsion) vtRows.push([T.row.propulsion, propulsionToken(vt.propulsion)]); if (vt.riderCapacity) vtRows.push([T.row.seats, vt.riderCapacity]); if (vt.accessories && vt.accessories.length > 0) { - // Resolver does not currently render token lists inside a comma-joined - // string; emit the resolvable tokens individually as a single string - // joined client-side would lose i18n. For now, keep accessories as - // comma-joined keys client could resolve, but to avoid silent English - // leaks we emit a token list joined to a single token via values is - // not possible. Fall back to emitting each accessory as its own row - // would clutter the table; instead emit the comma-joined raw keys - // wrapped in a passthrough string so the user sees raw keys (which - // surfaces missing strings) — acceptable per the resolver's - // visible-bug doctrine. - vtRows.push([T.row.features, vt.accessories.map(accessoryFallbackString).join(", ")]); + // Emit one token per accessory (resolved against the integration's + // value.accessory.* catalog and joined client-side). Unknown enum + // values fall back to a readable English string. + vtRows.push([ + T.row.features, + vt.accessories.map((accessory) => + Object.hasOwn(ACCESSORY_FALLBACK, accessory) + ? t(`value.accessory.${accessory}`) + : accessoryFallbackString(accessory), + ), + ]); } if (vt.co2PerKm !== undefined) { vtRows.push([ @@ -389,12 +389,12 @@ export function mapStationToDetail(station: SharedMobilityStation): DataSourceDe } : undefined; - // Access method → usageInfo. The label uses the ICU template at - // `format.accessMethod` resolved to the integration's catalog; usageInfo.type - // is `string`, so we pre-format via an inline ICU fallback marker that the - // panel renderer treats as the human label. Pass the raw accessMethod - // through unchanged when no template is available. - const usageInfo = station.accessMethod ? { type: `Access: ${station.accessMethod}` } : undefined; + // Access method → usageInfo. Emit the `format.accessMethod` ICU token so the + // panel renderer resolves it against the integration's catalog (the raw + // accessMethod is interpolated as the `method` value). + const usageInfo = station.accessMethod + ? { type: t("format.accessMethod", { method: station.accessMethod }) } + : undefined; const branding = brandingFromStation(station); return { @@ -554,10 +554,10 @@ export function mapVehicleToDetail(vehicle: SharedMobilityVehicle): DataSourceDe } /** - * Fallback English labels used for fields that are not (yet) emitted as - * tokens: the vehicle `name` (composed with operator prefix), the - * `usageInfo.type` text, and accessory comma-join strings inside vehicle - * details. Kept in sync with the `value.formFactor.*` catalog keys. + * Fallback English labels for the vehicle `name` (composed with an operator + * prefix), used where a token cannot be emitted (the name flows into the OSM + * resolver as an identity input). Kept in sync with the `value.formFactor.*` + * catalog keys. */ const FORM_FACTOR_FALLBACK: Record = { bicycle: "Bicycle", From c0737782c8c764271f636e0599adee43f1d83834 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 28 May 2026 10:35:42 +0200 Subject: [PATCH 25/25] fix(integration-framework): treat empty-string catalog values as missing in resolver An empty-string catalog entry short-circuited the fallback chain, rendering a blank label instead of falling back to the fallback locale, framework catalog, or bare key. lookupKey now treats "" as absent so resolution continues. --- .../strings/__tests__/resolver.test.ts | 12 ++++++++++++ .../integration-framework/strings/src/resolver.ts | 5 ++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/integration-framework/strings/__tests__/resolver.test.ts b/packages/integration-framework/strings/__tests__/resolver.test.ts index 7e1343a0..04e380ab 100644 --- a/packages/integration-framework/strings/__tests__/resolver.test.ts +++ b/packages/integration-framework/strings/__tests__/resolver.test.ts @@ -103,4 +103,16 @@ describe("resolveToken", () => { ); expect(out).toBe("{count, plural, one {x}"); }); + + it("treats an empty-string catalog value as missing and continues the fallback chain", () => { + const integration = { + en: { row: { freeSpaces: "Free Spaces" } }, + de: { row: { freeSpaces: "" } }, + }; + const out = resolveToken( + { $t: "row.freeSpaces" }, + { locale: "de", fallbackLocale: "en", shared, integration }, + ); + expect(out).toBe("Free Spaces"); + }); }); diff --git a/packages/integration-framework/strings/src/resolver.ts b/packages/integration-framework/strings/src/resolver.ts index ec895e30..cd4510ff 100644 --- a/packages/integration-framework/strings/src/resolver.ts +++ b/packages/integration-framework/strings/src/resolver.ts @@ -55,7 +55,10 @@ function lookupKey(catalog: unknown, key: string): string | undefined { if (typeof cur !== "object" || cur === null) return undefined; cur = (cur as Record)[part]; } - return typeof cur === "string" ? cur : undefined; + // Treat an empty string as a missing entry so resolution continues down the + // fallback chain (active locale → fallback locale → framework → bare key) + // rather than rendering a blank label. + return typeof cur === "string" && cur !== "" ? cur : undefined; } function format(