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:
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/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/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 04316d15..73740e3f 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,204 +164,43 @@ 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 translatedRows =
+ 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]) => {
- const labelStr = String(label);
- const valueStr = String(value);
- return [
- translateDetailText(labelStr, t) ?? labelStr,
- translateDetailText(valueStr, t) ?? 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,
- );
-
+ ? 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
+ // `I18nToken | string` slots.
return {
- ...section,
- content: translateDetailText(section.content, t),
- items: section.items?.map((item) => translateDetailText(item, t) ?? 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 === undefined ? undefined : resolveT(section.imageAlt),
+ linkUrl: section.linkUrl,
+ embedUrl: section.embedUrl,
+ embedType: section.embedType,
+ sectionIcon: section.sectionIcon,
+ pricingPlans: section.pricingPlans,
+ collapsed: section.collapsed,
};
}
@@ -449,9 +290,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 &&
@@ -516,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/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 a607dcac..a700491f 100644
--- a/apps/web/src/providers/IntegrationProvider.tsx
+++ b/apps/web/src/providers/IntegrationProvider.tsx
@@ -4,13 +4,14 @@ 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";
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();
@@ -32,15 +33,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/
@@ -83,7 +86,7 @@ export function IntegrationProvider({ children }: { children: React.ReactNode })
return (
- {children}
+ {children}
);
}
diff --git a/integrations/bike-sharing/strings/de.json b/integrations/bike-sharing/strings/de.json
index f596333f..2f1acacc 100644
--- a/integrations/bike-sharing/strings/de.json
+++ b/integrations/bike-sharing/strings/de.json
@@ -1,6 +1,85 @@
{
"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}}",
+ "batteryRange": "{battery} % · {km} km"
+ },
+ "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..e6cc57e1 100644
--- a/integrations/bike-sharing/strings/en.json
+++ b/integrations/bike-sharing/strings/en.json
@@ -1,6 +1,85 @@
{
"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}}",
+ "batteryRange": "{battery}% · {km} km"
+ },
+ "format": {
+ "batteryPercent": "{value}%",
+ "distanceKm": "{value} km",
+ "co2PerKm": "{value} g/km",
+ "accessMethod": "Access: {method}"
+ },
"dataSources": [
{
"purpose": "Bike-sharing network and station data worldwide",
diff --git a/integrations/car-sharing/strings/de.json b/integrations/car-sharing/strings/de.json
index 6f39ea07..dbcf4126 100644
--- a/integrations/car-sharing/strings/de.json
+++ b/integrations/car-sharing/strings/de.json
@@ -1,6 +1,85 @@
{
"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}}",
+ "batteryRange": "{battery} % · {km} km"
+ },
+ "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..193b4a6c 100644
--- a/integrations/car-sharing/strings/en.json
+++ b/integrations/car-sharing/strings/en.json
@@ -1,6 +1,85 @@
{
"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}}",
+ "batteryRange": "{battery}% · {km} km"
+ },
+ "format": {
+ "batteryPercent": "{value}%",
+ "distanceKm": "{value} km",
+ "co2PerKm": "{value} g/km",
+ "accessMethod": "Access: {method}"
+ },
"dataSources": [
{
"purpose": "Station-based car sharing in Germany and Belgium",
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/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/ev-charging/providers/station-mapper.ts b/integrations/ev-charging/providers/station-mapper.ts
index a7383560..d286a69b 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,13 @@ function formatTimestamp(value: string | undefined): string | undefined {
.replace(/\.\d{3}Z$/, " UTC");
}
-function connectorRows(station: EvChargingStation): (string | number)[][] {
+function connectorRows(
+ station: EvChargingStation,
+): [Translatable, Translatable, Translatable, ...Translatable[]][] {
return [...station.connectors]
.sort((a, b) => (b.powerKw ?? 0) - (a.powerKw ?? 0))
- .map((conn) => [
- conn.type ?? "Unknown",
+ .map((conn): [Translatable, Translatable, Translatable, Translatable, Translatable] => [
+ conn.type ?? sharedT.value.unknown,
formatPower(conn.powerKw),
conn.currentType ?? "-",
connectorQuantity(conn),
@@ -104,33 +125,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 +179,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",
@@ -165,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/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",
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}"
+ }
}
diff --git a/integrations/geocoding-db-ris/stations-mapper.ts b/integrations/geocoding-db-ris/stations-mapper.ts
index 00daa09c..c616fc45 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,31 @@ export interface StationDetail {
}
interface StationDetailSection {
- title: string;
+ title: I18nToken;
type: "table" | "list";
collapsed?: boolean;
- rows?: string[][];
- items?: string[];
+ // 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)[];
}
-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): I18nToken {
+ if (!type) return TRAVELER_TYPE_TOKEN.COMMUTER;
+ // 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(
platforms: RisPlatform[],
connectingTimes: RisConnectingTime[],
@@ -134,32 +147,32 @@ export function buildStationDetail(
if (platforms.length > 0) {
sections.push({
- title: "Platforms",
+ title: token("section.platforms"),
type: "table",
- rows: platforms.map((p) => [
+ rows: platforms.map((p): [Translatable, Translatable, Translatable, Translatable] => [
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` : "-",
+ rows: connectingTimes.map((ct): [I18nToken, Translatable] => [
+ 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..f62b16d2 100644
--- a/integrations/geocoding-db-ris/strings/de.json
+++ b/integrations/geocoding-db-ris/strings/de.json
@@ -1,6 +1,21 @@
{
"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",
+ "travelerLiteral": "{type}",
+ "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..01f302c2 100644
--- a/integrations/geocoding-db-ris/strings/en.json
+++ b/integrations/geocoding-db-ris/strings/en.json
@@ -1,6 +1,21 @@
{
"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",
+ "travelerLiteral": "{type}",
+ "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",
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/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..08f32893 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,32 @@ 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][] | undefined {
const prices = facility.tariff?.prices;
if (!prices || prices.length === 0) return undefined;
- const rows: [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 = DURATION_LABELS[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 cd32020e..a27a6b92 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,35 @@ 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 `[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][] | undefined {
+ if (!Array.isArray(value)) return undefined;
+ 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([{ $t: "tariff.literal", values: { value: 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..ada4bc1b 100644
--- a/integrations/parking/strings/de.json
+++ b/integrations/parking/strings/de.json
@@ -1,6 +1,74 @@
{
"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}}",
+ "literal": "{value}"
+ },
+ "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..749d5353 100644
--- a/integrations/parking/strings/en.json
+++ b/integrations/parking/strings/en.json
@@ -1,6 +1,74 @@
{
"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}}",
+ "literal": "{value}"
+ },
+ "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/integrations/scooter-sharing/strings/de.json b/integrations/scooter-sharing/strings/de.json
index 5399af70..4b22271f 100644
--- a/integrations/scooter-sharing/strings/de.json
+++ b/integrations/scooter-sharing/strings/de.json
@@ -1,6 +1,85 @@
{
"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}}",
+ "batteryRange": "{battery} % · {km} km"
+ },
+ "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..5108e989 100644
--- a/integrations/scooter-sharing/strings/en.json
+++ b/integrations/scooter-sharing/strings/en.json
@@ -1,6 +1,85 @@
{
"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}}",
+ "batteryRange": "{battery}% · {km} km"
+ },
+ "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/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..9f744ae5 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,25 +127,30 @@ 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,
+ imageAlt: token("imageAlt.webcam", { name: raw.name }),
sectionIcon: "videocam",
});
}
- 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..2630a039 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";
@@ -20,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,
};
}
@@ -59,21 +71,21 @@ 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,
+ imageAlt: token("imageAlt.webcam", { name: raw.name }),
sectionIcon: "videocam",
});
}
if (raw.streamUrl) {
sections.push({
- title: "Live Stream",
+ title: token("section.liveStream"),
type: "embed",
embedUrl: raw.streamUrl,
embedType: "video",
@@ -82,12 +94,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..f3b7430e 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";
@@ -105,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,
};
}
@@ -131,26 +143,31 @@ 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,
+ imageAlt: token("imageAlt.webcam", { name: raw.name }),
linkUrl: raw.detailUrl,
sectionIcon: "videocam",
});
}
- 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..22edeb1d 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,40 +82,45 @@ 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,
+ imageAlt: token("imageAlt.webcam", { name: raw.name }),
linkUrl: raw.thumbnailUrl,
sectionIcon: "videocam",
});
} 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..098a5668 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,21 +70,21 @@ 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,
+ imageAlt: token("imageAlt.webcam", { name: raw.name }),
sectionIcon: "videocam",
});
}
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..a7eb0ccb 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";
@@ -78,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,
};
}
@@ -125,14 +137,14 @@ export async function getWindyDetail(webcamId: string): Promise>;
+}
+
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/i18n/locales/de.json b/packages/i18n/locales/de.json
index fe0ff6d5..5ef75877 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,35 @@
"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",
+ "rowFeatures": "Ausstattung",
"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",
+ "sectionFee": "Gebühren",
+ "sectionAvailability": "Verfügbarkeit",
+ "onStreet": "Straßenparkplatz",
"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"
+ "dbStation": "DB-Bahnhof",
+ "parking": "Parken",
+ "freeSpaces": "Freie Plätze",
+ "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",
+ "durationMinutes": "{count} Min.",
+ "durationHours": "{count, plural, one {# Std.} other {# Std.}}",
+ "durationDays": "{count, plural, one {# Tag} other {# Tage}}",
+ "webcams": "Webcams"
},
"account": {
"language": "Sprache",
diff --git a/packages/i18n/locales/en.json b/packages/i18n/locales/en.json
index b3de8bbe..46b36331 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,35 @@
"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",
+ "rowFeatures": "Features",
"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",
+ "sectionFee": "Fee",
+ "sectionAvailability": "Availability",
+ "onStreet": "On-Street",
"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"
+ "dbStation": "DB Station",
+ "parking": "Parking",
+ "freeSpaces": "Free Spaces",
+ "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",
+ "durationMinutes": "{count} min",
+ "durationHours": "{count, plural, one {# hour} other {# hours}}",
+ "durationDays": "{count, plural, one {# day} other {# days}}",
+ "webcams": "Webcams"
},
"account": {
"language": "Language",
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(
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/src/contracts/mobility-data-source-provider.ts b/packages/integration-framework/src/contracts/mobility-data-source-provider.ts
index 815df9d9..22ad0461 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,7 @@ export interface DataSourceResult {
kind?: "station" | "vehicle";
variant: string;
status?: string;
- summary?: string;
+ summary?: I18nToken;
operator?: string;
branding?: DataSourceBranding;
mapContext?: DataSourceMapContextSelection;
@@ -124,16 +125,50 @@ export interface PricingPlanEntry {
}
export interface DataSourceDetailSection {
- title: string;
+ title: I18nToken;
type: "table" | "list" | "text" | "image" | "embed" | "pricing";
- columns?: string[];
- rows?: (string | number)[][];
- items?: string[];
- content?: string;
+ columns?: I18nToken[];
+ /**
+ * Table rows. Two shapes are accepted, distinguished by cell count:
+ *
+ * - `[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.
+ * 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
+ * cells are unconstrained `Translatable`.
+ *
+ * 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[]][]
+ | [Translatable, Translatable, 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).
+ */
+ items?: (I18nToken | string)[];
+ /**
+ * 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. */
@@ -193,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/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/strings/__tests__/resolver.test.ts b/packages/integration-framework/strings/__tests__/resolver.test.ts
new file mode 100644
index 00000000..04e380ab
--- /dev/null
+++ b/packages/integration-framework/strings/__tests__/resolver.test.ts
@@ -0,0 +1,118 @@
+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");
+ });
+
+ 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}");
+ });
+
+ 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/__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/__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..e512f6b5
--- /dev/null
+++ b/packages/integration-framework/strings/index.ts
@@ -0,0 +1,17 @@
+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";
+
+/**
+ * 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/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/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/resolver.ts b/packages/integration-framework/strings/src/resolver.ts
new file mode 100644
index 00000000..cd4510ff
--- /dev/null
+++ b/packages/integration-framework/strings/src/resolver.ts
@@ -0,0 +1,80 @@
+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];
+ }
+ // 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(
+ 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.
+ // 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;
+ }
+}
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;
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/packages/mobility-core/__tests__/mapper.test.ts b/packages/mobility-core/__tests__/mapper.test.ts
index b8819456..c2287d41 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,26 +337,32 @@ 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("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% \u00B7 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();
});
});
});
@@ -344,63 +370,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 +445,40 @@ 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);
+ // 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", () => {
@@ -436,8 +487,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 +501,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 +521,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 +543,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 +566,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 +586,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,7 +616,11 @@ describe("mapStationToDetail", () => {
name: "TestBikes",
url: "https://testbikes.example.com",
});
- 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", () => {
@@ -582,50 +640,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 +707,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 +735,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 +767,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..bb80c422 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,41 +210,44 @@ export function mapStationToDetail(station: SharedMobilityStation): DataSourceDe
const sections: DataSourceDetailSection[] = [];
// Availability table
- const rows: (string | number)[][] = [["Available Vehicles", station.availableVehicles]];
+ const rows: [I18nTokenLike, Translatable][] = [
+ [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,
sectionIcon: "info",
});
// Transit info
if (station.transitInfo?.lines || station.transitInfo?.stops) {
- const transitRows: (string | number)[][] = [];
+ const transitRows: [I18nTokenLike, Translatable][] = [];
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,
sectionIcon: "directions_bus",
});
@@ -198,29 +255,41 @@ 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: [I18nTokenLike, Translatable | Translatable[]][] = [];
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(", ")]);
+ // 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(["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,
sectionIcon: "directions_car",
collapsed: true,
@@ -228,7 +297,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 +316,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,15 +326,14 @@ 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: [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]);
if (linkRows.length > 0) {
sections.push({
- title: "Book",
+ title: T.section.book,
type: "table",
- columns: ["", ""],
rows: linkRows,
sectionIcon: "open_in_new",
collapsed: true,
@@ -274,17 +342,16 @@ export function mapStationToDetail(station: SharedMobilityStation): DataSourceDe
}
if (station.rentalApps) {
- const appRows: (string | number)[][] = [];
+ const appRows: [I18nTokenLike, Translatable][] = [];
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,
sectionIcon: "open_in_new",
collapsed: true,
@@ -295,7 +362,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 +372,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,8 +389,12 @@ export function mapStationToDetail(station: SharedMobilityStation): DataSourceDe
}
: undefined;
- // Access method → usageInfo
- 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 {
@@ -358,21 +429,33 @@ function vehicleVariant(vehicle: SharedMobilityVehicle): string {
return "available";
}
-function vehicleSummary(vehicle: SharedMobilityVehicle): string {
- const parts: string[] = [];
+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 t("summary.batteryRange", {
+ battery: vehicle.batteryLevel,
+ km: (vehicle.rangeMeters / 1000).toFixed(1),
+ });
+ }
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 undefined;
+}
+
+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,45 +481,50 @@ 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: [I18nTokenLike, Translatable][] = [];
+ 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,
sectionIcon: "info",
});
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: [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]);
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,
sectionIcon: "open_in_new",
collapsed: true,
@@ -445,13 +533,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 +553,13 @@ export function mapVehicleToDetail(vehicle: SharedMobilityVehicle): DataSourceDe
};
}
-const FORM_FACTOR_LABELS: Record = {
+/**
+ * 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",
cargo_bicycle: "Cargo Bicycle",
scooter_standing: "E-Scooter",
@@ -476,13 +569,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, " ");
+}
diff --git a/packages/mobility-core/src/types/parking.ts b/packages/mobility-core/src/types/parking.ts
index 54251d13..1e58e724 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 (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][];
access?: "public" | "customers" | "private" | "permit";
operator?: string;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 846c84e2..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:
@@ -1568,6 +1574,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
@@ -1598,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:
@@ -1614,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:
@@ -1642,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:
@@ -1682,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:
@@ -1704,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:
@@ -1716,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:
@@ -1732,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:
@@ -1751,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:
@@ -1763,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:
@@ -1775,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:
@@ -1794,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:
@@ -1852,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:
@@ -1860,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'}
@@ -2083,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==}
@@ -2219,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'}
@@ -2494,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==}
@@ -3655,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]
@@ -3709,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==}
@@ -3930,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'}
@@ -3948,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==}
@@ -4139,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==}
@@ -4394,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==}
@@ -4444,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'}
@@ -4468,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'}
@@ -4525,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==}
@@ -4674,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'}
@@ -5000,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==}
@@ -5143,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'}
@@ -5216,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'}
@@ -5412,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==}
@@ -5446,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'}
@@ -5686,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'}
@@ -5803,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'}
@@ -5902,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==}
@@ -6087,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==}
@@ -6333,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'}
@@ -6414,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'}
@@ -6430,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==}
@@ -6612,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==}
@@ -6670,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'}
@@ -6743,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
@@ -6918,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
@@ -7179,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': {}
@@ -7411,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
@@ -8468,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
@@ -8538,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
@@ -8673,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:
@@ -8761,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:
@@ -8793,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: {}
@@ -8916,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'
@@ -8934,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:
@@ -9207,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: {}
@@ -9249,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
@@ -9277,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:
@@ -9335,6 +9635,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ dom-accessibility-api@0.5.16: {}
+
dom-helpers@5.2.1:
dependencies:
'@babel/runtime': 7.29.2
@@ -9397,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: {}
@@ -9796,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:
@@ -9909,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: {}
@@ -9971,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: {}
@@ -10124,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
@@ -10199,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: {}
@@ -10411,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: {}
@@ -10515,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
@@ -10613,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):
@@ -10808,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: {}
@@ -11089,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: {}
@@ -11206,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:
@@ -11220,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: {}
@@ -11345,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))
@@ -11371,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
@@ -11445,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: {}