PageSourceSearch

https://woladen.de/i18n.mjs

js woladen.de collected 2026-10-02 01:52:31 UTC 24,996 bytes, 739 lines download raw bytes

1const DEFAULT_LANGUAGE = "en";
2const STORAGE_KEY = "woladen_language_v1";
3
4export const SUPPORTED_LANGUAGES = [
5  "en",
6  "de",
7  "fr",
8  "nl",
9  "da",
10  "fi",
11  "sv",
12  "el",
13  "lv",
14  "lt",
15  "lb",
16  "mt",
17  "nb",
18  "nn",
19  "pl",
20  "pt",
21  "es",
22  "cs",
23  "hu",
24  "sl",
25  "it",
26  "rm",
27  "tr",
28];
29
30const LANGUAGE_ALIASES = new Map([
31  ["no", "nb"],
32  ["no-no", "nb"],
33  ["nb-no", "nb"],
34  ["nn-no", "nn"],
35  ["el-cy", "el"],
36  ["tr-cy", "tr"],
37  ["en-mt", "en"],
38]);
39
40const OG_LOCALES = {
41  en: "en_US",
42  de: "de_DE",
43  fr: "fr_FR",
44  nl: "nl_NL",
45  da: "da_DK",
46  fi: "fi_FI",
47  sv: "sv_SE",
48  el: "el_GR",
49  lv: "lv_LV",
50  lt: "lt_LT",
51  lb: "lb_LU",
52  mt: "mt_MT",
53  nb: "nb_NO",
54  nn: "nn_NO",
55  pl: "pl_PL",
56  pt: "pt_PT",
57  es: "es_ES",
58  cs: "cs_CZ",
59  hu: "hu_HU",
60  sl: "sl_SI",
61  it: "it_IT",
62  rm: "rm_CH",
63  tr: "tr_TR",
64};
65
66const FALLBACK_BUNDLE = {
67  languageName: "English",
68  meta: {
69    title: "woladen - Smart EV Stops in Europe",
70    description: "Find available chargers near great bakeries, restaurants, shops, playgrounds and cafés. Because charging time should be time well spent.",
71    ogTitle: "woladen - Smart EV Stops in Europe",
72    ogDescription: "Find available chargers near great bakeries, restaurants, shops, playgrounds and cafés. Because charging time should be time well spent.",
73    socialAlt: "woladen preview with a Europe charger map and the slogan: The human side of charging.",
74  },
75  seo: {
76    brandName: "woladen",
77    seoName: "woladen - Smart EV Stops in Europe",
78    primaryTagline: "The human side of charging",
79    humanHook: "The human side of charging",
80    timeLine: "Because charging time is your time",
81    productMessage: "Find available and reliable chargers with better places to wait nearby, from bakeries and restaurants to shops, playgrounds, and cafés.",
82  },
83  language: {
84    label: "Language",
85  },
86  nav: {
87    label: "Main navigation",
88    list: "List",
89    map: "Map",
90    route: "Route",
91    favorites: "Favorites",
92    settings: "Settings",
93    stats: "Stats",
94    info: "Info & Set.",
95  },
96  search: {
97    label: "Search place or address",
98    placeholder: "Search place or address",
99    noResults: "No suggestions found.",
100    minChars: "Enter at least two characters.",
101    searching: "Searching place...",
102    unavailable: "Place search is currently unavailable.",
103    notConfigured: "Place search is not configured.",
104  },
105  aria: {
106    map: "Map with charging stations. Use the list for a more accessible results view.",
107    locate: "Use my location",
108    filterOpen: "Open filters",
109    closeFilter: "Close filters",
110    closeChargePlan: "Close rating settings",
111    closeDetail: "Close details",
112    closeAmenity: "Close amenity",
113    saveFavorite: "Save favorite",
114    removeFavorite: "Remove favorite",
115    detailsOpen: "Open details",
116    activeFilters: "Active filters: {labels}",
117  },
118  list: {
119    title: "Charging stations nearby",
120    loading: "Loading charging stations...",
121    loadingRadius: "Loading charging stations within 20 km...",
122    loadingLiveFallback: "Live search is loading. Saved fast chargers are shown until then.",
123    empty: "No charging stations found.",
124    more: "...and {count} more",
125  },
126  chargePlan: {
127    button: "Need",
128    openWithSummary: "Open settings, current need: {summary}",
129    modalTitle: "Rate chargers",
130    modalIntro: "Adjust how much energy you need during this stop.",
131    apply: "Apply",
132    reset: "Reset",
133    compactSummary: "{from}% → {to}% · {kwh} kWh",
134    title: "Match chargers to this break",
135    summary: "{from}% → {to}%, {kwh} kWh needed",
136    minutesLabel: "Stop",
137    minutesUnit: "min",
138    targetLabel: "Need",
139    kwhUnit: "kWh",
140    cardEstimate: "~{kwh} kWh in {minutes} min",
141    tiers: {
142      great: "Great fit",
143      good: "Good fit",
144      partial: "Partial",
145      slow: "Slow",
146      busy: "Busy now",
147      unavailable: "Offline",
148    },
149  },
150  settings: {
151    title: "Settings",
152    intro: "Your vehicle details stay on this device.",
153    vehicleTitle: "Vehicle & energy need",
154    batterySize: "Battery size",
155    consumption: "Energy consumption",
156    chargingAmount: "Charging amount",
157    chargeFrom: "From",
158    chargeTo: "To",
159    averageSpeed: "Average charging speed",
160    calculatedNeed: "Calculated need",
161    addedRange: "≈ {range} km added range",
162    displayTitle: "Display",
163    displayLanguage: "Display language",
164    reset: "Reset settings",
165  },
166  favorites: {
167    title: "Favorites",
168    sort: "Sort",
169    distance: "Distance",
170    rating: "Stars",
171    categoryFilterLabel: "Favorite categories",
172    categoryFilterAria: "Show {category} favorites, {count} stations",
173    all: "All",
174    uncategorized: "Uncategorized",
175    groupCountOne: "{count} station",
176    groupCountMany: "{count} stations",
177    empty: "No favorites saved yet.",
178    emptyHelp: "Tap the star in the detail view to save stations.",
179    loading: "Loading favorites in the current search area...",
180    loadingFallback: "Live search is loading. Favorites from saved fast chargers remain visible.",
181    outsideArea: "Your favorites are not in the current search area.",
182    someOutsideArea: "Some favorites are outside the current search area.",
183  },
184  filters: {
185    title: "Filters",
186    all: "All filters",
187    operator: "Operator",
188    allOperators: "All operators",
189    amenityName: "Name of nearby place",
190    amenityNamePlaceholder: "e.g. McDonald's",
191    availableOnly: "Available now",
192    availableOnlyNote: "Only stations with known availability and at least one free charging point",
193    currentlyOpen: "Open now",
194    currentlyOpenNote: "Only stations with at least one currently open nearby place",
195    minPower: "Min. power: {value} kW",
196    minAmenities: "Min. nearby places: {value}",
197    amenities: "Nearby places",
198    apply: "Apply",
199    reset: "Remove filters",
200    activeAmenity: "{label} filter active",
201    filterAmenity: "Filter by {label}",
202    openWithCount: "Open filters, {count} active: {labels}",
203    selectedOnly: "Selected only: {labels}",
204    namePrefix: "Name: {value}",
205    minPowerLabel: "from {value} kW",
206    minAmenitiesLabel: "{value}+ nearby places",
207  },
208  route: {
209    title: "Route planner",
210    origin: "From",
211    destination: "To",
212    originPlaceholder: "Start address",
213    destinationPlaceholder: "Destination address",
214    useCurrent: "My location",
215    currentLocation: "My location",
216    swap: "Swap route endpoints",
217    submit: "Charging stations along route",
218    empty: "Enter a start and destination to find charging stations along the route.",
219    resolving: "Resolving route endpoints...",
220    loading: "Calculating charging stations along the route...",
221    loadingStill: "Still checking the full route. Long routes can take over a minute.",
222    missingEndpoints: "Choose a start and destination first.",
223    sameEndpoint: "Start and destination are too close together.",
224    notConfigured: "Route planning is not configured.",
225    locationUnavailable: "Current location is not available.",
226    searchError: "Charging stations along the route could not be loaded.",
227    tooLong: "That route is too long for charger search. Split it into shorter legs.",
228    notFound: "The routing service could not find a drivable route between those points. Try nearby addresses.",
229    providerTimeout: "The routing service did not respond in time. Try again in a moment.",
230    providerUnavailable: "Route planning is temporarily unavailable. Try again later.",
231    providerError: "The routing service returned an error. Try again or adju
231st the route endpoints.",
232    capacityExhausted: "Routing capacity is currently exhausted. Please try again later.",
233    noFilteredResults: "No charging stations along the route match the current filters.",
234    filterChanged: "Filters changed since this route was calculated. Broader filters need a new route search.",
235    recalculate: "Recalculate",
236    showMap: "Show on map",
237    addAllFavorites: "Add all as favorites",
238    addAllFavoritesWithCount: "Add all {count} as favorites",
239    addAllFavoritesShort: "all {count}",
240    favoriteCategory: "Route {origin} / {destination}",
241    favoritesAdded: "{count} stations saved as favorites in \"{category}\".",
242    mapFixed: "Map fixed on stations along the route",
243    removeRoute: "Remove route",
244    summaryDistance: "Route",
245    summaryDistanceKm: "Distance (km)",
246    summaryDuration: "Drive time",
247    summaryStations: "Charging stations",
248    resultsCount: "{count} stations",
249    cardAccess: "{distance} from route",
250    cardPosition: "route km {distance}",
251    durationMinutes: "{minutes} min",
252    durationHours: "{hours} h",
253    durationHoursMinutes: "{hours} h {minutes} min",
254  },
255  info: {
256    title: "Info & Help",
257    settingsButton: "Open settings",
258    aboutTitle: "About woladen.de",
259    countriesTitle: "Current country coverage",
260    country: "Country",
261    stations: "Stations",
262    source: "Data source",
263    loadingCountries: "Loading countries...",
264    countryLoadError: "Country coverage could not be loaded.",
265    legendTitle: "Legend",
266    cardBackgroundTitle: "Station tile backgrounds",
267    mapMarkerTitle: "Map markers",
268    legendOneFreeLeft: "Light yellow: one charging point left",
269    legendFullyOccupied: "Grey: fully occupied",
270    legendOutOfOrder: "Light red: currently out of order",
271    legendOftenBroken: "Lighter red: often broken in analytics",
272    legendOftenOccupied: "Lighter grey: often occupied in analytics",
273    legendGold: "More than 10 nearby places",
274    legendSilver: "More than 5 nearby places",
275    legendBronze: "At least 1 nearby place",
276    legendGrey: "No nearby places",
277    legendFavorite: "Favorite station",
278    legendMarkerOutOfOrder: "Live marker: out of order",
279    legendMarkerFullyOccupied: "Live marker: fully occupied",
280    aboutIntro: "woladen knows",
281    aboutStationCountJoin: "stations with",
282    aboutOutro: "charging points in many countries and shows them within 20 km of your location or selected map position. Where live and analytical data is available, availability, outages, and occupancy patterns help you choose. By default, woladen searches fast chargers from 50 kW; the power filter can include normal chargers below 50 kW.",
283    sourceUnknown: "Data source",
284    licensesTitle: "Map data & licenses",
285    osmNote: "Map data and POI data © OpenStreetMap contributors, available under ODbL v1.0.",
286    osmCopyright: "OpenStreetMap: copyright and license notes",
287    odblLicense: "ODbL v1.0: full license text",
288    contactTitle: "Contact & Code",
289    github: "GitHub project",
290    developedBy: "Developed by Prof. Dr. Raphael Volz, Hochschule Pforzheim",
291    distributedBy: "Distributed by",
292    contributorsTitle: "Contributors",
293    studentsGroup: "Students of Hochschule Pforzheim, SS26",
294    privacyTitle: "Privacy",
295    privacyBody: "Location access is optional. When you use it, it focuses the map on your surroundings and sorts nearby stations. Favorites stay locally on your device.",
296    privacyLink: "Privacy policy",
297    imprintLink: "Imprint",
298    dataSourcesTitle: "Data Sources & Licenses",
299    loadingSources: "Loading data sources...",
300    sourceLoadError: "Data sources could not be loaded.",
301    catalogUpdated: "Catalog updated: {date}{counts}",
302    dataUpdated: "Data updated: {date}{counts}",
303    countSuffix: " · {stations} stations · {chargers} charging points",
304  },
305  amenity: {
306    one: "{count} nearby place",
307    many: "{count} nearby places",
308    generic: "Nearby place",
309    other: "Other",
310    noDetails: "No details available.",
311    open: "Open now",
312    closed: "Closed",
313    unknownHours: "Opening hours unknown",
314    hours: "Opening hours",
315    groups: {
316      food: "Food & drink",
317      shopping: "Shopping",
318      leisure: "Leisure & nature",
319      lodging: "Accommodation",
320      other: "Other",
321    },
322    labels: {
323      restaurant: "Restaurant",
324      cafe: "Cafe",
325      fast_food: "Fast food",
326      toilets: "Toilets",
327      supermarket: "Supermarket",
328      bakery: "Bakery",
329      convenience: "Convenience store",
330      pharmacy: "Pharmacy",
331      hotel: "Hotel",
332      museum: "Museum",
333      playground: "Playground",
334      park: "Park",
335      ice_cream: "Ice cream",
336      bbq: "BBQ area",
337      biergarten: "Beer garden",
338      cinema: "Cinema",
339      library: "Library",
340      theatre: "Theatre",
341      atm: "ATM",
342      bank: "Bank",
343      bench: "Bench",
344      bicycle_rental: "Bike rental",
345      car_sharing: "Car sharing",
346      fuel: "Fuel station",
347      hospital: "Hospital",
348      police: "Police",
349      post_box: "Post box",
350      post_office: "Post office",
351      pub: "Pub",
352      school: "School",
353      taxi: "Taxi",
354      waste_basket: "Waste bin",
355      swimming: "Swimming pool",
356      gym: "Fitness",
357      camp_site: "Camping",
358      viewpoint: "Viewpoint",
359      zoo: "Zoo",
360      mall: "Shopping mall",
361      doityourself: "DIY store",
362      electronics: "Electronics",
363    },
364  },
365  station: {
366    chargingPointOne: "{count} charging point",
367    chargingPointMany: "{count} charging points",
368    unknownOperator: "Unknown operator",
369    chargingStation: "Charging station",
370    note: "Note",
371    details: "Details",
372    live: "Live",
373    stationStatus: "Station status",
374    operationalStatus: "Operational status",
375    liveDataAvailable: "Live data available",
376    evse: "Charging point {index}",
377    notInLiveFeed: "Not included in the live feed",
378    typicalOccupancy: "Typical occupancy",
379    typicalOccupancyNote: "Average occupied charging points by hour; unknown states do not count as occupied.",
380    detailsSource: "Details via {source} · updated {date}",
381    detailsSourceOnly: "Details via {source}",
382    liveVia: "Live via {source}",
383    liveViaUpdated: "Live via {source} · updated {date}",
384    updated: "Updated {date}",
385    maxPower: "{power} kW max / {points}",
386    nextSlot: "Next slot",
387    supplementalStatus: "Supplemental status",
388  },
389  common: {
390    yes: "Yes",
391    no: "No",
392    days: "{count} days",
393  },
394  availability: {
395    free: "Free",
396    occupied: "Occupied",
397    out_of_order: "Out of order",
398    unknown: "Unknown",
399    summaryUnknown: "Occupancy unknown",
400    available: "{count} free",
401    occupiedCount: "{count} occupied",
402    outOfOrderCount: "{count} out of order",
403    unknownCount: "{count} unknown",
404  },
405  rating: {
406    label: "Rating",
407    ariaLabel: "Rate charging station",
408    starOne: "{count} star",
409    starMany: "{count} stars",
410    unrated: "Not rated yet",
411    save: "Saving rating...",
412    serverError: "Rating saved locally. Server currently unavailable.",
413    yourRating: "Your rating: {rating} of 5",
414    localOnly: "only on this device",
415    average: "Average from {count}",
416    summary: "Avg {value} from {count}",
417    localTitle: "Your local rating",
418    one: "{count} rating",
419    many: "{count} ratings",
420  },
421  detail: {
422    personalNote: "Personal note",
423    notePlaceholder: "e.g. well lit, tight parking, bakery around the corner",
424    noteSaved: "Note saved locally",
425    noteDeviceOnly: "Stored only on this device",
426    favoriteCategories: "Categories",
427    categoryPlaceholder: "Add category",
428    addCategory: "Add",
429    categoryDeviceOnly: "Stored only on this device",
430    removeCategory: "Remove {category}",
431    help: "Help",
432    helpTitle: "Help {phone}",
433  },
434  staticDetails: {
435    payment: "Payment",
436    access: "Access",
437    connectors: "Connectors",
438    currentType: "Current type",
439    sockets: "{count} sockets",
440    service: "Service",
441    energy: "Energy",
442    renewable: "100% renewable",
443    notRenewable: "Not marked as renewable",
444  },
445  location: {
446    idleTitle: "Find charging stations near you",
447    idleMessage: "Share your location to load charging stations within 20 km.",
448    idleAction: "Share location",
449    pendingTitle: "Finding location",
450    pendingMessage: "Charging stations within 20 km are loading.",
451    deniedTitle: "Location permission needed",
452    deniedMessage: "Enable location access to load charging stations within 20 km.",
453    settingsMessage: "Enable location access for woladen in system settings to load charging stations near you.",
454    openSettings: "Open Settings",
455    usageDescription: "woladen uses your location to show charging points nearby.",
456    unavailableTitle: "Location unavailable",
457    unavailableMessage: "This search needs a browser with location access.",
458    timeoutTitle: "Location search is taking too long",
459    timeoutMessage: "Try again to load charging stations within 20 km.",
460    positionTitle: "Location could not be determined",
461    positionMessage: "Try again when your browser can provide a location.",
462    unknownMessage: "Please try again to load charging stations within 20 km.",
463    retry: "Try again",
464    searchMap: "Search place on map",
465  },
466  errors: {
467    dataLoad: "Error loading data.",
468    catalogTitle: "Charging points could not be loaded",
469    catalogMessage: "No network connection. Sorry, live search will not work until this device is online.",
470    reload: "Reload",
471  },
472  sources: {
473    geocoder: "GEO: openrouteservice Geocoding Autocomplete (Pelias)",
474    easterEgg: "Easter Egg: wake up! 16b by HellMood (Port)",
475  },
476};
477
478let fallbackBundle = FALLBACK_BUNDLE;
479let activeBundle = FALLBACK_BUNDLE;
480let activeLanguage = DEFAULT_LANGUAGE;
481let activeLocale = DEFAULT_LANGUAGE;
482const listeners = new Set();
483
484function deepMerge(base, patch) {
485  const result = { ...base };
486  Object.entries(patch || {}).forEach(([key, value]) => {
487    if (
488      value &&
489      typeof value === "object" &&
490      !Array.isArray(value) &&
491      base[key] &&
492      typeof base[key] === "object" &&
493      !Array.isArray(base[key])
494    ) {
495      result[key] = deepMerge(base[key], value);
496    } else {
497      result[key] = value;
498    }
499  });
500  return result;
501}
502
503function getNestedValue(source, key) {
504  return String(key || "")
505    .split(".")
506    .reduce((value, part) => value?.[part], source);
507}
508
509function interpolate(value, params = {}) {
510  return String(value || "").replace(/\{([A-Za-z0-9_]+)\}/g, (_, name) =>
511    params[name] === undefined || params[name] === null ? "" : String(params[name]),
512  );
513}
514
515export function t(key, params = {}) {
516  const value = getNestedValue(activeBundle, key) ?? getNestedValue(fallbackBundle, key) ?? key;
517  return interpolate(value, params);
518}
519
520export function getLanguage() {
521  return activeLanguage;
522}
523
524export function getLocale() {
525  return activeLocale;
526}
527
528export function ogLocaleForLanguage(language = activeLanguage) {
529  return OG_LOCALES[language] || OG_LOCALES[DEFAULT_LANGUAGE];
530}
531
532export function normalizeLanguage(value) {
533  const raw = String(value || "").trim().toLowerCase().replace("_", "-");
534  if (!raw) {
535    return "";
536  }
537  const alias = LANGUAGE_ALIASES.get(raw);
538  if (alias) {
539    return alias;
540  }
541  if (SUPPORTED_LANGUAGES.includes(raw)) {
542    return raw;
543  }
544  const base = raw.split("-")[0];
545  return LANGUAGE_ALIASES.get(base) || (SUPPORTED_LANGUAGES.includes(base) ? base : "");
546}
547
548function requestedLanguageFromUrl() {
549  const params = new URLSearchParams(window.location.search);
550  return normalizeLanguage(params.get("lang") || params.get("language") || "");
551}
552
553function storedLanguage() {
554  try {
555    return normalizeLanguage(window.localStorage?.getItem(STORAGE_KEY) || "");
556  } catch {
557    return "";
558  }
559}
560
561function browserLanguage() {
562  const languages = Array.isArray(navigator.languages) && navigator.languages.length
563    ? navigator.languages
564    : [navigator.language];
565  for (const language of languages) {
566    const normalized = normalizeLanguage(language);
567    if (normalized) {
568      return normalized;
569    }
570  }
571  return "";
572}
573
574export function resolveInitialLanguage() {
575  return requestedLanguageFromUrl() || storedLanguage() || browserLanguage() || DEFAULT_LANGUAGE;
576}
577
578async function fetchBundle(language) {
579  if (language === DEFAULT_LANGUAGE) {
580    return FALLBACK_BUNDLE;
581  }
582  try {
583    const response = await fetch(new URL(`./i18n/${language}.json?v=20260730-afir-dynamic-fields1`, import.meta.url));
584    if (!response.ok) {
585      return {};
586    }
587    return await response.json();
588  } catch {
589    return {};
590  }
591}
592
593function storeLanguage(language) {
594  try {
595    window.localStorage?.setItem(STORAGE_KEY, language);
596  } catch {
597    // Local storage may be disabled.
598  }
599}
600
601function updateLanguageParam(language) {
602  const url = new URL(window.location.href);
603  if (language) {
604    url.searchParams.set("lang", language);
605  } else {
606    url.searchParams.delete("lang");
607  }
608  const next = `${url.pathname}${url.search}${url.hash}`;
609  window.history.replaceState(window.history.state, "", next);
610}
611
612function updateMeta() {
613  document.documentElement.lang = activeLanguage;
614  document.title = t("meta.title");
615  const canonical = document.querySelector("link[rel='canonical']");
616  if (canonical) {
617    canonical.setAttribute(
618      "href",
619      activeLanguage === DEFAULT_LANGUAGE
620        ? "https://woladen.de/"
621        : `https://woladen.de/?lang=${encodeURIComponent(activeLanguage)}`,
622    );
623  }
624  document.querySelector("meta[name='description']")?.setAttribute("content", t("meta.description"));
625  document.querySelector("meta[property='og:title']")?.setAttribute("content", t("meta.ogTitle"));
626  document.querySelector("meta[property='og:description']")?.setAttribute("content", t("meta.ogDescription"));
627  document.querySelector("meta[property='og:locale']")?.setAttribute("content", ogLocaleForLanguage());
628  document.querySelector("meta[property='og:image:alt']")?.setAttribute("content", t("meta.socialAlt"));
629  document.querySelector("meta[name='twitter:title']")?.setAttribute("content", t("meta.ogTitle"));
630  document.querySelector("meta[name='twitter:description']")?.setAttribute("content", t("meta.ogDescription"));
631  document.querySelector("meta[name='twitter:image:alt']")?.setAttribute("content", t("meta.socialAlt"));
632}
633
634export function applyDocumentTranslations(root = document) {
635  root.querySelectorAll("[data-i18n]").forEach((element) => {
636    element.textContent = t(element.dataset.i18n);
637  });
638  root.querySelectorAll("[data-i18n-placeholder]").forEach((element) => {
639    element.setAttribute("placeholder", t(element.dataset.i18nPlaceholder));
640  });
641  root.querySelectorAll("[data-i18n-aria-label]").forEach((element) => {
642    element.setAttribute("aria-label", t(element.dataset.i18nAri
642aLabel));
643  });
644  root.querySelectorAll("[data-i18n-title]").forEach((element) => {
645    element.setAttribute("title", t(element.dataset.i18nTitle));
646  });
647  updateMeta();
648}
649
650export function populateLanguageSelect(select) {
651  if (!select) {
652    return;
653  }
654  select.replaceChildren();
655  const displayNames = typeof Intl.DisplayNames === "function"
656    ? new Intl.DisplayNames([activeLocale, DEFAULT_LANGUAGE], { type: "language" })
657    : null;
658  SUPPORTED_LANGUAGES.forEach((language) => {
659    const option = document.createElement("option");
660    option.value = language;
661    option.textContent = displayNames?.of(language) || language.toUpperCase();
662    select.appendChild(option);
663  });
664  select.value = activeLanguage;
665}
666
667export async function setLanguage(language, options = {}) {
668  const normalized = normalizeLanguage(language) || DEFAULT_LANGUAGE;
669  const bundle = await fetchBundle(normalized);
670  activeLanguage = normalized;
671  activeLocale = normalized;
672  activeBundle = deepMerge(fallbackBundle, bundle);
673  if (options.persist !== false) {
674    storeLanguage(normalized);
675  }
676  if (options.updateUrl !== false) {
677    updateLanguageParam(normalized);
678  }
679  applyDocumentTranslations();
680  document.querySelectorAll("[data-language-select]").forEach((select) => {
681    populateLanguageSelect(select);
682  });
683  listeners.forEach((listener) => listener(normalized));
684}
685
686export async function initI18n() {
687  fallbackBundle = FALLBACK_BUNDLE;
688  await setLanguage(resolveInitialLanguage(), {
689    persist: false,
690    updateUrl: Boolean(requestedLanguageFromUrl()),
691  });
692}
693
694export function onLanguageChange(listener) {
695  listeners.add(listener);
696  return () => listeners.delete(listener);
697}
698
699export function formatInteger(value) {
700  const numeric = Number(value);
701  if (!Number.isFinite(numeric) || numeric <= 0) {
702    return "";
703  }
704  return new Intl.NumberFormat(getLocale()).format(numeric);
705}
706
707export function formatDateTime(value) {
708  const raw = String(value || "").trim();
709  if (!raw) {
710    return "";
711  }
712  const date = /^\d{11,}$/.test(raw) ? new Date(Number(raw)) : new Date(raw);
713  if (Number.isNaN(date.getTime())) {
714    return raw;
715  }
716  return new Intl.DateTimeFormat(getLocale(), {
717    day: "2-digit",
718    month: "2-digit",
719    year: "numeric",
720    hour: "2-digit",
721    minute: "2-digit",
722  }).format(date);
723}
724
725export function formatDate(value) {
726  const raw = String(value || "").trim();
727  if (!raw) {
728    return "";
729  }
730  const parsed = new Date(`${raw}T00:00:00`);
731  if (Number.isNaN(parsed.getTime())) {
732    return raw;
733  }
734  return new Intl.DateTimeFormat(getLocale(), {
735    day: "2-digit",
736    month: "2-digit",
737    year: "numeric",
738  }).format(parsed);
739}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.