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: {}