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.