feat: add multilingual interface support

This commit is contained in:
2026-07-24 16:00:12 +01:00
parent d2567c59ce
commit 16383c2a3f
10 changed files with 788 additions and 84 deletions
+1
View File
@@ -7,6 +7,7 @@ RUN npm ci --omit=dev
COPY server.js ./
COPY index.html ./
COPY i18n.js ./
COPY app.js ./
COPY styles.css ./
COPY assets ./assets
+12
View File
@@ -70,3 +70,15 @@ base map are changed. An open parcel or building information dialog is included
too. Copy the URL to share that exact state; opening it restores the same map
view and selected feature. Invalid or incomplete map parameters fall back to
the normal default view.
## Translations
The interface selects English, Spanish, or Portuguese from the browser
preference on the first visit and remembers subsequent changes made in
Settings. Unsupported languages and missing messages fall back to English.
To add another language:
1. Add a dictionary with the same keys to `TRANSLATIONS` in `i18n.js`.
2. Translate every value, including accessibility labels and status messages.
3. Run `npm test` to verify that the dictionary is complete.
+136 -56
View File
@@ -1,3 +1,6 @@
const i18n = XProI18n.createI18n();
const t = i18n.t;
const DEFAULT_CENTER = [-8.65, 39.55];
const DEFAULT_ZOOM = 7;
const DEFAULT_ROTATION = 0;
@@ -41,12 +44,20 @@ const els = {
parcelSearchInput: document.getElementById("parcel-search-input"),
parcelSearchStatus: document.getElementById("parcel-search-status"),
backgroundSelect: document.getElementById("background-select"),
settingsOpen: document.getElementById("settings-open"),
settingsModal: document.getElementById("settings-modal"),
settingsModalClose: document.getElementById("settings-modal-close"),
settingsForm: document.getElementById("settings-form"),
languageSelect: document.getElementById("language-select"),
featureModal: document.getElementById("feature-modal"),
featureModalDialog: document.querySelector(".feature-modal-dialog"),
featureModalClose: document.getElementById("feature-modal-close"),
featureModalContent: document.getElementById("feature-modal-content"),
};
localizeDocument();
populateLanguageOptions();
// A view link must be complete before it is applied. This prevents a typo or
// a partially copied URL from leaving the map in an arbitrary half-restored
// state.
@@ -57,8 +68,7 @@ let applyingSharedMapState = false;
const softBasemapLayer = new ol.layer.Tile({
source: new ol.source.XYZ({
url: "https://{a-d}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}.png",
attributions:
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
attributions: t("softAttribution"),
}),
});
@@ -71,8 +81,7 @@ const satelliteLayer = new ol.layer.Tile({
visible: false,
source: new ol.source.XYZ({
url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
attributions:
'Tiles © <a href="https://www.esri.com/en-us/legal/terms/full-master-agreement">Esri</a> — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community',
attributions: t("satelliteAttribution"),
}),
});
@@ -159,7 +168,16 @@ let featureModalRestoreRequestId = 0;
const map = new ol.Map({
target: "map",
controls: ol.control
.defaults.defaults({ rotate: false })
.defaults.defaults({
rotate: false,
zoomOptions: {
zoomInTipLabel: t("zoomIn"),
zoomOutTipLabel: t("zoomOut"),
},
attributionOptions: {
tipLabel: t("attribution"),
},
})
.extend([
createVisibleLayersExtentControl(),
createAreaZoomControl(),
@@ -195,6 +213,14 @@ if (window.matchMedia(SMALL_SCREEN_MEDIA_QUERY).matches) {
els.parcelSearchForm.addEventListener("submit", searchParcela);
els.backgroundSelect.addEventListener("change", syncBackground);
els.settingsOpen.addEventListener("click", openSettingsModal);
els.settingsModalClose.addEventListener("click", closeSettingsModal);
els.settingsForm.addEventListener("submit", applySettings);
els.settingsModal.addEventListener("click", (event) => {
if (event.target.matches("[data-settings-modal-close]")) {
closeSettingsModal();
}
});
els.sidebarOpen.addEventListener("click", () => setSidebarCollapsed(false));
els.sidebarClose.addEventListener("click", () => setSidebarCollapsed(true));
els.featureModalClose.addEventListener("click", closeFeatureModal);
@@ -210,7 +236,9 @@ els.featureModal.addEventListener("click", (event) => {
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
if (!els.featureModal.hidden) {
if (!els.settingsModal.hidden) {
closeSettingsModal();
} else if (!els.featureModal.hidden) {
closeFeatureModal();
} else if (areaZoomInteraction.getActive()) {
setAreaZoomMode(false);
@@ -255,6 +283,54 @@ function setSidebarCollapsed(collapsed, { focus = true } = {}) {
}
}
function localizeDocument() {
document.documentElement.lang = i18n.language;
document.querySelectorAll("[data-i18n]").forEach((element) => {
element.textContent = t(element.dataset.i18n);
});
document.querySelectorAll("[data-i18n-aria-label]").forEach((element) => {
element.setAttribute("aria-label", t(element.dataset.i18nAriaLabel));
});
document.querySelectorAll("[data-i18n-placeholder]").forEach((element) => {
element.setAttribute("placeholder", t(element.dataset.i18nPlaceholder));
});
}
function populateLanguageOptions() {
const browserOption = document.createElement("option");
browserOption.value = "browser";
browserOption.textContent = t("useBrowserDefault");
const languageOptions = i18n.supportedLanguages.map((language) => {
const option = document.createElement("option");
option.value = language;
option.lang = language;
option.textContent = XProI18n.TRANSLATIONS[language].languageName;
return option;
});
els.languageSelect.replaceChildren(browserOption, ...languageOptions);
els.languageSelect.value = i18n.preference || "browser";
}
function openSettingsModal() {
els.languageSelect.value = i18n.preference || "browser";
els.settingsModal.hidden = false;
els.languageSelect.focus();
}
function closeSettingsModal() {
els.settingsModal.hidden = true;
els.settingsOpen.focus();
}
function applySettings(event) {
event.preventDefault();
i18n.setLanguage(els.languageSelect.value === "browser" ? null : els.languageSelect.value);
// Reload so all transient statuses, map controls, and any open feature
// dialog are recreated consistently in the newly selected language. The
// current map view is already represented in the URL and is preserved.
window.location.reload();
}
function createNorthPointerIcon() {
const icon = document.createElement("img");
icon.className = "north-pointer-icon";
@@ -267,7 +343,7 @@ function createNorthPointerControl() {
const control = new ol.control.Rotate({
autoHide: true,
label: createNorthPointerIcon(),
tipLabel: "Reset map orientation to north",
tipLabel: t("resetNorth"),
});
// Resetting the view while compass orientation remains subscribed lets the
@@ -288,8 +364,8 @@ function createVisibleLayersExtentControl() {
const button = document.createElement("button");
button.type = "button";
button.title = "Zoom to visible layers";
button.setAttribute("aria-label", "Zoom to visible layers");
button.title = t("zoomVisibleLayers");
button.setAttribute("aria-label", t("zoomVisibleLayers"));
button.textContent = "⤢";
button.addEventListener("click", () => fitToVisibleLayers());
@@ -303,8 +379,8 @@ function createShareControl() {
const button = document.createElement("button");
button.type = "button";
button.title = "Copy link to this map view";
button.setAttribute("aria-label", "Copy link to this map view");
button.title = t("copyMapLink");
button.setAttribute("aria-label", t("copyMapLink"));
const icon = document.createElement("span");
icon.className = "material-symbols-outlined";
icon.setAttribute("aria-hidden", "true");
@@ -323,14 +399,14 @@ async function copyCurrentViewUrl() {
try {
if (!navigator.clipboard?.writeText) {
throw new Error("Clipboard access is unavailable.");
throw new Error(t("clipboardUnavailable"));
}
await navigator.clipboard.writeText(window.location.href);
showMessage("Link to this map view copied to your clipboard.");
showMessage(t("mapLinkCopied"));
} catch (error) {
console.error(error);
showMessage("Unable to copy the map link. Please copy the address from your browser.", {
showMessage(t("mapLinkCopyFailed"), {
type: "error",
});
}
@@ -342,8 +418,8 @@ function createAreaZoomControl() {
const button = document.createElement("button");
button.type = "button";
button.title = "Zoom to area";
button.setAttribute("aria-label", "Zoom to area");
button.title = t("zoomArea");
button.setAttribute("aria-label", t("zoomArea"));
button.setAttribute("aria-pressed", "false");
const icon = document.createElement("img");
@@ -397,19 +473,19 @@ async function toggleDeviceCompass() {
}
if (!window.isSecureContext) {
updateDeviceCompassButton("Device compass requires HTTPS; HTTP works only on localhost.");
updateDeviceCompassButton(t("compassRequiresHttps"));
return;
}
if (!("DeviceOrientationEvent" in window)) {
updateDeviceCompassButton("Device compass is unavailable on this device.");
updateDeviceCompassButton(t("compassUnavailable"));
return;
}
try {
const permission = await requestDeviceOrientationPermission();
if (permission !== "granted") {
updateDeviceCompassButton("Device compass permission was not granted.");
updateDeviceCompassButton(t("compassPermissionDenied"));
return;
}
@@ -424,10 +500,10 @@ async function toggleDeviceCompass() {
window.addEventListener(DEVICE_ORIENTATION_EVENTS[1], syncMapToDeviceCompass);
}
}, 300);
updateDeviceCompassButton("Device compass orientation is on.");
updateDeviceCompassButton(t("compassOn"));
} catch (error) {
console.error(error);
updateDeviceCompassButton("Unable to start the device compass.");
updateDeviceCompassButton(t("compassStartFailed"));
}
}
@@ -454,7 +530,7 @@ function stopDeviceCompass() {
deviceCompassSource = null;
lastCompassHeading = null;
deviceCompassActive = false;
updateDeviceCompassButton("Device compass orientation is off.");
updateDeviceCompassButton(t("compassOff"));
}
function syncMapToDeviceCompass(event) {
@@ -527,8 +603,8 @@ function updateDeviceCompassButton(message) {
}
const label = deviceCompassActive
? "Turn off device compass orientation"
: "Align map with device compass";
? t("compassTurnOff")
: t("compassAlign");
deviceCompassButton.setAttribute("aria-pressed", String(deviceCompassActive));
deviceCompassButton.setAttribute("aria-label", label);
deviceCompassButton.title = message || label;
@@ -563,7 +639,7 @@ function showMessage(message, { type = "info", duration = MESSAGE_DURATIONS[type
const dismiss = document.createElement("button");
dismiss.className = "message-dismiss";
dismiss.type = "button";
dismiss.setAttribute("aria-label", "Dismiss message");
dismiss.setAttribute("aria-label", t("dismissMessage"));
dismiss.textContent = "×";
const progress = document.createElement("span");
@@ -593,7 +669,7 @@ function setAreaZoomMode(active) {
areaZoomButton?.setAttribute("aria-pressed", String(active));
areaZoomButton?.setAttribute(
"title",
active ? "Exit area zoom (Esc)" : "Zoom to area",
active ? t("exitAreaZoom") : t("zoomArea"),
);
if (active) {
@@ -621,12 +697,12 @@ async function searchParcela(event) {
const parcelNumber = els.parcelSearchInput.value.trim();
if (!parcelNumber) {
updateParcelSearchStatus("Enter a parcel number to search.", "error");
showMessage("Enter a parcel number to search.", { type: "warning" });
updateParcelSearchStatus(t("enterParcel"), "error");
showMessage(t("enterParcel"), { type: "warning" });
return;
}
updateParcelSearchStatus("Searching for parcel");
updateParcelSearchStatus(t("searchingParcel"));
parcelSearchSource.clear();
try {
@@ -642,7 +718,7 @@ async function searchParcela(event) {
const response = await fetch(`${API_WMS}?${params}`);
if (!response.ok) {
throw new Error(`Search failed with status ${response.status}.`);
throw new Error(t("searchFailedStatus", { status: response.status }));
}
const featureCollection = await response.json();
@@ -651,8 +727,9 @@ async function searchParcela(event) {
});
if (!features.length) {
updateParcelSearchStatus(`No parcel found with number “${parcelNumber}”.`, "error");
showMessage(`No parcel found with number “${parcelNumber}”.`, { type: "warning" });
const notFoundMessage = t("noParcelFound", { parcelNumber });
updateParcelSearchStatus(notFoundMessage, "error");
showMessage(notFoundMessage, { type: "warning" });
return;
}
@@ -664,13 +741,13 @@ async function searchParcela(event) {
});
const searchMessage = features.length === 1
? `Found parcel ${parcelNumber}.`
: `Found ${features.length} parcels matching ${parcelNumber}.`;
? t("foundParcel", { parcelNumber })
: t("foundParcels", { count: features.length, parcelNumber });
updateParcelSearchStatus(searchMessage, "success");
showMessage(searchMessage);
} catch (error) {
console.error(error);
const searchMessage = error.message || "Unable to search for that parcel.";
const searchMessage = error.message || t("parcelSearchFailed");
updateParcelSearchStatus(searchMessage, "error");
showMessage(searchMessage, { type: "error" });
}
@@ -689,7 +766,7 @@ function buildParcelSearchFilter(parcelNumber) {
}
async function loadLayers() {
updateStatus("Loading WMS layers...");
updateStatus(t("loadingWmsLayers"));
try {
const capabilities = await loadCapabilities();
@@ -706,10 +783,10 @@ async function loadLayers() {
}
restoreSharedFeatureModal();
updateStatus(`Showing ${availableLayers.length} layer(s) from GeoServer.`, "success");
updateStatus("");
} catch (error) {
console.error(error);
const loadMessage = error.message || "Unable to load the WMS service.";
const loadMessage = error.message || t("wmsLoadFailed");
updateStatus(loadMessage, "error");
showMessage(loadMessage, { type: "error" });
availableLayers = [];
@@ -733,7 +810,7 @@ async function loadCapabilities() {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`GeoServer connection failed with status ${response.status}.`);
throw new Error(t("geoserverConnectionFailed", { status: response.status }));
}
return response.text();
@@ -771,7 +848,7 @@ function renderLayerList() {
if (!userVisibleLayers.length) {
els.layerList.className = "layer-list empty";
els.layerList.textContent = "No published layers were returned by the service.";
els.layerList.textContent = t("noPublishedLayers");
return;
}
@@ -970,20 +1047,20 @@ async function renderFeatureModal(feature, isParcel, requestId) {
const properties = feature.properties || {};
const featureId = isParcel ? properties.cod_parcela : properties.cod_predio;
content.append(createFeatureHeading(isParcel ? "Parcel" : "Building", featureId));
content.append(createFeatureDetails([["Area", formatFeatureArea(feature)]]));
content.append(createFeatureHeading(isParcel ? t("parcel") : t("building"), featureId));
content.append(createFeatureDetails([[t("area"), formatFeatureArea(feature)]]));
if (isParcel) {
if (isDwellingParcel(feature)) {
const inhabited = document.createElement("p");
inhabited.className = "feature-inhabited";
inhabited.textContent = "Inhabited";
inhabited.textContent = t("inhabited");
content.append(inhabited);
}
if (properties.predio_uuid) {
content.append(createFeatureNavigation(
"View corresponding building",
t("viewCorrespondingBuilding"),
BUILDING_LAYER_NAME,
properties.predio_uuid,
));
@@ -993,7 +1070,7 @@ async function renderFeatureModal(feature, isParcel, requestId) {
const section = document.createElement("section");
section.className = "feature-associations";
const heading = document.createElement("h3");
heading.textContent = "Associated parcels";
heading.textContent = t("associatedParcels");
section.append(heading);
if (parcels.length) {
@@ -1006,10 +1083,13 @@ async function renderFeatureModal(feature, isParcel, requestId) {
parcel.properties?.parcela_uuid,
);
link.className = "feature-association";
link.setAttribute("aria-label", `View parcel ${parcel.properties?.cod_parcela || ""}`.trim());
const parcelNumber = parcel.properties?.cod_parcela || "";
link.setAttribute("aria-label", t("viewParcel", { parcelNumber }).trim());
const label = document.createElement("span");
label.className = "feature-association-label";
label.textContent = `Parcel ${parcel.properties?.cod_parcela || "Unavailable"}`;
label.textContent = t("parcelWithNumber", {
parcelNumber: parcelNumber || t("unavailable"),
});
const area = document.createElement("span");
area.className = "feature-association-area";
area.textContent = formatFeatureArea(parcel);
@@ -1017,7 +1097,7 @@ async function renderFeatureModal(feature, isParcel, requestId) {
if (isDwellingParcel(parcel)) {
const inhabited = document.createElement("span");
inhabited.className = "feature-association-inhabited";
inhabited.textContent = "Inhabited";
inhabited.textContent = t("inhabited");
link.append(inhabited);
}
item.append(link);
@@ -1026,7 +1106,7 @@ async function renderFeatureModal(feature, isParcel, requestId) {
section.append(list);
} else {
const empty = document.createElement("p");
empty.textContent = "No associated parcels were found.";
empty.textContent = t("noAssociatedParcels");
section.append(empty);
}
@@ -1048,8 +1128,8 @@ function createFeatureModalShareButton() {
const button = document.createElement("button");
button.type = "button";
button.className = "feature-modal-share";
button.title = "Copy link to this map view";
button.setAttribute("aria-label", "Copy link to this map view");
button.title = t("copyMapLink");
button.setAttribute("aria-label", t("copyMapLink"));
const icon = document.createElement("span");
icon.className = "material-symbols-outlined";
@@ -1232,7 +1312,7 @@ function formatFeatureArea(feature) {
const area = Number(areaEntry?.[1]);
if (Number.isFinite(area) && area >= 0) {
return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(area)} m²`;
return `${new Intl.NumberFormat(i18n.locale, { maximumFractionDigits: 2 }).format(area)} m²`;
}
const geometry = new ol.format.GeoJSON().readGeometry(feature.geometry, {
@@ -1240,8 +1320,8 @@ function formatFeatureArea(feature) {
});
const calculatedArea = geometry?.getArea?.();
return Number.isFinite(calculatedArea)
? `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(calculatedArea)} m²`
: "Area unavailable";
? `${new Intl.NumberFormat(i18n.locale, { maximumFractionDigits: 2 }).format(calculatedArea)} m²`
: t("areaUnavailable");
}
function fitToLayerExtent(layer) {
@@ -1264,8 +1344,8 @@ function fitToVisibleLayers() {
.map((layer) => ol.proj.transformExtent(layer.extent, "EPSG:4326", "EPSG:3857"));
if (!projectedExtents.length) {
updateStatus("Select a layer with a published extent to zoom to it.", "error");
showMessage("Select a layer with a published extent to zoom to it.", { type: "warning" });
updateStatus(t("selectLayerToZoom"), "error");
showMessage(t("selectLayerToZoom"), { type: "warning" });
return;
}
+4 -4
View File
@@ -1,6 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 66.295753 33.036644">
<g transform="translate(-15.909682 -167.46353)">
<path fill="#1574c3" d="m 51.662106,186.67656 h 1.857378 v -3.80966 h 0.59653 c 1.423537,0 2.074297,-0.10846 2.630155,-0.4474 0.81345,-0.51518 1.315077,-1.51844 1.315077,-2.67082 0,-1.15239 -0.48807,-2.10142 -1.35575,-2.63016 -0.569415,-0.33894 -1.24729,-0.46095 -2.562367,-0.46095 h -2.481023 z m 1.857378,-5.58569 v -2.65727 h 0.623645 c 0.610087,0 0.894795,0.0271 1.179502,0.0949 0.5423,0.14913 0.86768,0.62365 0.86768,1.24729 0,0.48807 -0.21692,0.86768 -0.610087,1.0846 -0.284708,0.14913 -0.786335,0.23048 -1.51844,0.23048 z m 5.500524,5.58569 h 1.803148 v -4.17571 c -0.04067,-1.11172 0.528742,-1.73536 1.613342,-1.77603 v -1.73536 h -0.135575 c -0.772777,0 -1.152387,0.21692 -1.6269,0.90835 v -0.73211 h -1.654015 z m 7.683272,-7.6871 c -2.182758,0 -3.972348,1.77603 -3.972348,3.93167 0,2.1692 1.78959,3.93168 3.985905,3.93168 2.182758,0 3.999463,-1.76248 3.999463,-3.87745 0,-2.23699 -1.748918,-3.9859 -4.01302,-3.9859 z m 0.01356,1.65401 c 1.206618,0 2.182758,1.01681 2.182758,2.27766 0,1.26085 -0.97614,2.27766 -2.1692,2.27766 -1.220175,0 -2.182758,-1.01681 -2.182758,-2.30477 0,-1.23374 0.97614,-2.25055 2.1692,-2.25055 z" transform="matrix(1.729104 0 0 1.729104 -47.763707 -130.17409)"/>
<path fill="#f8a621" d="m 41.157585,186.71507 h 2.209873 l 2.33189,-3.45716 2.399677,3.45716 h 2.155643 l -3.47072,-4.98916 3.47072,-5.02983 h -2.155643 l -2.399677,3.49784 -2.33189,-3.49784 h -2.209873 l 3.443605,5.02983 z" transform="matrix(1.729104 0 0 1.729104 -47.763707 -130.17409)"/>
<svg xmlns="http://www.w3.org/2000/svg" width="51.110092" height="17.62863" viewBox="0 0 51.110092 17.62863">
<g transform="translate(-23.402038 -175.28522) matrix(1.729104 0 0 1.729104 -47.763707 -130.17409)">
<path fill="#1574c3" d="m 51.662106,186.67656 h 1.857378 v -3.80966 h 0.59653 c 1.423537,0 2.074297,-0.10846 2.630155,-0.4474 0.81345,-0.51518 1.315077,-1.51844 1.315077,-2.67082 0,-1.15239 -0.48807,-2.10142 -1.35575,-2.63016 -0.569415,-0.33894 -1.24729,-0.46095 -2.562367,-0.46095 h -2.481023 z m 1.857378,-5.58569 v -2.65727 h 0.623645 c 0.610087,0 0.894795,0.0271 1.179502,0.0949 0.5423,0.14913 0.86768,0.62365 0.86768,1.24729 0,0.48807 -0.21692,0.86768 -0.610087,1.0846 -0.284708,0.14913 -0.786335,0.23048 -1.51844,0.23048 z m 5.500524,5.58569 h 1.803148 v -4.17571 c -0.04067,-1.11172 0.528742,-1.73536 1.613342,-1.77603 v -1.73536 h -0.135575 c -0.772777,0 -1.152387,0.21692 -1.6269,0.90835 v -0.73211 h -1.654015 z m 7.683272,-7.6871 c -2.182758,0 -3.972348,1.77603 -3.972348,3.93167 0,2.1692 1.78959,3.93168 3.985905,3.93168 2.182758,0 3.999463,-1.76248 3.999463,-3.87745 0,-2.23699 -1.748918,-3.9859 -4.01302,-3.9859 z m 0.01356,1.65401 c 1.206618,0 2.182758,1.01681 2.182758,2.27766 0,1.26085 -0.97614,2.27766 -2.1692,2.27766 -1.220175,0 -2.182758,-1.01681 -2.182758,-2.30477 0,-1.23374 0.97614,-2.25055 2.1692,-2.25055 z"/>
<path fill="#f8a621" d="m 41.157585,186.71507 h 2.209873 l 2.33189,-3.45716 2.399677,3.45716 h 2.155643 l -3.47072,-4.98916 3.47072,-5.02983 h -2.155643 l -2.399677,3.49784 -2.33189,-3.49784 h -2.209873 l 3.443605,5.02983 z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

+358
View File
@@ -0,0 +1,358 @@
(function initializeI18n(globalScope) {
"use strict";
const DEFAULT_LANGUAGE = "en";
const STORAGE_KEY = "xprov-language";
const TRANSLATIONS = {
en: {
languageName: "English",
pageTitle: "XPro Map Visualizer",
applicationMessages: "Application messages",
openMapControls: "Open map controls",
mapControls: "Map controls",
closeMapControls: "Close map controls",
mapVisualizer: "Map Visualizer",
openSettings: "Open settings",
closeSettings: "Close settings",
settings: "Settings",
applySettings: "Apply",
useBrowserDefault: "Use browser default",
find: "Find",
parcelExample: "e.g. 57 or 2669.1",
parcelNumber: "Parcel number",
findOnMap: "Find on map",
layers: "Layers",
waitingForConnection: "Waiting for connection.",
loadingPublishedLayers: "Loading published layers.",
baseMap: "Base map",
softMinimal: "Soft minimal",
openStreetMap: "OpenStreetMap",
satelliteImagery: "Satellite imagery",
language: "Language",
applicationLanguage: "Application language",
mapView: "Map view",
closeFeatureInformation: "Close feature information",
resetNorth: "Reset map orientation to north",
zoomVisibleLayers: "Zoom to visible layers",
copyMapLink: "Copy link to this map view",
clipboardUnavailable: "Clipboard access is unavailable.",
mapLinkCopied: "Link to this map view copied to your clipboard.",
mapLinkCopyFailed:
"Unable to copy the map link. Please copy the address from your browser.",
zoomArea: "Zoom to area",
exitAreaZoom: "Exit area zoom (Esc)",
compassRequiresHttps: "Device compass requires HTTPS; HTTP works only on localhost.",
compassUnavailable: "Device compass is unavailable on this device.",
compassPermissionDenied: "Device compass permission was not granted.",
compassOn: "Device compass orientation is on.",
compassStartFailed: "Unable to start the device compass.",
compassOff: "Device compass orientation is off.",
compassTurnOff: "Turn off device compass orientation",
compassAlign: "Align map with device compass",
dismissMessage: "Dismiss message",
enterParcel: "Enter a parcel number to search.",
searchingParcel: "Searching for parcel…",
searchFailedStatus: "Parcel search failed (status {status}).",
noParcelFound: "No parcel found with number “{parcelNumber}”.",
foundParcel: "Found parcel {parcelNumber}.",
foundParcels: "Found {count} parcels matching {parcelNumber}.",
parcelSearchFailed: "Unable to search for that parcel.",
loadingWmsLayers: "Loading WMS layers…",
wmsLoadFailed: "Unable to load the WMS service.",
geoserverConnectionFailed: "GeoServer connection failed (status {status}).",
noPublishedLayers: "No published layers were returned by the service.",
selectLayerToZoom: "Select a layer with a published extent to zoom to it.",
parcel: "Parcel",
building: "Building",
area: "Area",
areaUnavailable: "Area unavailable",
inhabited: "Inhabited",
viewCorrespondingBuilding: "View corresponding building",
associatedParcels: "Associated parcels",
viewParcel: "View parcel {parcelNumber}",
parcelWithNumber: "Parcel {parcelNumber}",
unavailable: "Unavailable",
noAssociatedParcels: "No associated parcels were found.",
zoomIn: "Zoom in",
zoomOut: "Zoom out",
attribution: "Attributions",
softAttribution:
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
satelliteAttribution:
'Tiles © <a href="https://www.esri.com/en-us/legal/terms/full-master-agreement">Esri</a> — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community',
},
es: {
languageName: "Español",
pageTitle: "Visualizador de Mapas XPro",
applicationMessages: "Mensajes de la aplicación",
openMapControls: "Abrir controles del mapa",
mapControls: "Controles del mapa",
closeMapControls: "Cerrar controles del mapa",
mapVisualizer: "Visualizador de Mapas",
openSettings: "Abrir configuración",
closeSettings: "Cerrar configuración",
settings: "Configuración",
applySettings: "Aplicar",
useBrowserDefault: "Usar el idioma predeterminado del navegador",
find: "Buscar",
parcelExample: "p. ej., 57 o 2669.1",
parcelNumber: "Número de parcela",
findOnMap: "Localizar en el mapa",
layers: "Capas",
waitingForConnection: "Esperando conexión.",
loadingPublishedLayers: "Cargando capas publicadas.",
baseMap: "Mapa base",
softMinimal: "Minimalista suave",
openStreetMap: "OpenStreetMap",
satelliteImagery: "Imágenes por satélite",
language: "Idioma",
applicationLanguage: "Idioma de la aplicación",
mapView: "Vista del mapa",
closeFeatureInformation: "Cerrar información del elemento",
resetNorth: "Restablecer la orientación del mapa hacia el norte",
zoomVisibleLayers: "Ajustar a las capas visibles",
copyMapLink: "Copiar enlace a esta vista del mapa",
clipboardUnavailable: "El acceso al portapapeles no está disponible.",
mapLinkCopied: "El enlace a esta vista se ha copiado al portapapeles.",
mapLinkCopyFailed:
"No se pudo copiar el enlace. Copie la dirección desde el navegador.",
zoomArea: "Ampliar un área",
exitAreaZoom: "Salir de la ampliación de área (Esc)",
compassRequiresHttps:
"La brújula del dispositivo requiere HTTPS; HTTP solo funciona en localhost.",
compassUnavailable: "La brújula no está disponible en este dispositivo.",
compassPermissionDenied: "No se concedió permiso para usar la brújula.",
compassOn: "La orientación mediante la brújula está activada.",
compassStartFailed: "No se pudo iniciar la brújula del dispositivo.",
compassOff: "La orientación mediante la brújula está desactivada.",
compassTurnOff: "Desactivar la orientación mediante la brújula",
compassAlign: "Alinear el mapa con la brújula",
dismissMessage: "Descartar mensaje",
enterParcel: "Introduzca un número de parcela para buscar.",
searchingParcel: "Buscando la parcela…",
searchFailedStatus: "La búsqueda de la parcela falló (estado {status}).",
noParcelFound: "No se encontró la parcela con el número «{parcelNumber}».",
foundParcel: "Se encontró la parcela {parcelNumber}.",
foundParcels: "Se encontraron {count} parcelas que coinciden con {parcelNumber}.",
parcelSearchFailed: "No se pudo buscar esa parcela.",
loadingWmsLayers: "Cargando capas WMS…",
wmsLoadFailed: "No se pudo cargar el servicio WMS.",
geoserverConnectionFailed: "La conexión con GeoServer falló (estado {status}).",
noPublishedLayers: "El servicio no devolvió ninguna capa publicada.",
selectLayerToZoom: "Seleccione una capa con extensión publicada para encuadrarla.",
parcel: "Parcela",
building: "Edificio",
area: "Área",
areaUnavailable: "Área no disponible",
inhabited: "Habitada",
viewCorrespondingBuilding: "Ver el edificio correspondiente",
associatedParcels: "Parcelas asociadas",
viewParcel: "Ver parcela {parcelNumber}",
parcelWithNumber: "Parcela {parcelNumber}",
unavailable: "No disponible",
noAssociatedParcels: "No se encontraron parcelas asociadas.",
zoomIn: "Acercar",
zoomOut: "Alejar",
attribution: "Atribuciones",
softAttribution:
'© Colaboradores de <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/attributions">CARTO</a>',
satelliteAttribution:
'Mosaicos © <a href="https://www.esri.com/en-us/legal/terms/full-master-agreement">Esri</a> — Fuente: Esri, Maxar, Earthstar Geographics y la comunidad de usuarios de SIG',
},
"pt-PT": {
languageName: "Português",
pageTitle: "Visualizador de Mapas XPro",
applicationMessages: "Mensagens da aplicação",
openMapControls: "Abrir controlos do mapa",
mapControls: "Controlos do mapa",
closeMapControls: "Fechar controlos do mapa",
mapVisualizer: "Visualizador de Mapas",
openSettings: "Abrir definições",
closeSettings: "Fechar definições",
settings: "Definições",
applySettings: "Aplicar",
useBrowserDefault: "Usar predefinição do navegador",
find: "Pesquisar",
parcelExample: "p. ex. 57 ou 2669.1",
parcelNumber: "Número da parcela",
findOnMap: "Localizar no mapa",
layers: "Camadas",
waitingForConnection: "A aguardar ligação.",
loadingPublishedLayers: "A carregar camadas publicadas.",
baseMap: "Mapa base",
softMinimal: "Minimalista suave",
openStreetMap: "OpenStreetMap",
satelliteImagery: "Imagem de satélite",
language: "Idioma",
applicationLanguage: "Idioma da aplicação",
mapView: "Vista do mapa",
closeFeatureInformation: "Fechar informação do elemento",
resetNorth: "Repor a orientação do mapa para norte",
zoomVisibleLayers: "Ajustar às camadas visíveis",
copyMapLink: "Copiar ligação para esta vista do mapa",
clipboardUnavailable: "O acesso à área de transferência não está disponível.",
mapLinkCopied: "A ligação para esta vista foi copiada para a área de transferência.",
mapLinkCopyFailed:
"Não foi possível copiar a ligação. Copie o endereço a partir do navegador.",
zoomArea: "Ampliar uma área",
exitAreaZoom: "Sair da ampliação de área (Esc)",
compassRequiresHttps:
"A bússola do dispositivo requer HTTPS; HTTP funciona apenas em localhost.",
compassUnavailable: "A bússola não está disponível neste dispositivo.",
compassPermissionDenied: "A permissão para usar a bússola não foi concedida.",
compassOn: "A orientação pela bússola está ativa.",
compassStartFailed: "Não foi possível iniciar a bússola do dispositivo.",
compassOff: "A orientação pela bússola está desativada.",
compassTurnOff: "Desativar orientação pela bússola",
compassAlign: "Alinhar mapa com a bússola",
dismissMessage: "Dispensar mensagem",
enterParcel: "Introduza um número de parcela para pesquisar.",
searchingParcel: "A pesquisar a parcela…",
searchFailedStatus: "A pesquisa da parcela falhou (estado {status}).",
noParcelFound: "Não foi encontrada a parcela com o número “{parcelNumber}”.",
foundParcel: "Parcela {parcelNumber} encontrada.",
foundParcels: "Foram encontradas {count} parcelas correspondentes a {parcelNumber}.",
parcelSearchFailed: "Não foi possível pesquisar essa parcela.",
loadingWmsLayers: "A carregar camadas WMS…",
wmsLoadFailed: "Não foi possível carregar o serviço WMS.",
geoserverConnectionFailed: "A ligação ao GeoServer falhou (estado {status}).",
noPublishedLayers: "O serviço não devolveu camadas publicadas.",
selectLayerToZoom: "Selecione uma camada com extensão publicada para a enquadrar.",
parcel: "Parcela",
building: "Prédio",
area: "Área",
areaUnavailable: "Área indisponível",
inhabited: "Habitada",
viewCorrespondingBuilding: "Ver prédio correspondente",
associatedParcels: "Parcelas associadas",
viewParcel: "Ver parcela {parcelNumber}",
parcelWithNumber: "Parcela {parcelNumber}",
unavailable: "Indisponível",
noAssociatedParcels: "Não foram encontradas parcelas associadas.",
zoomIn: "Ampliar",
zoomOut: "Reduzir",
attribution: "Atribuições",
softAttribution:
'© Colaboradores do <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/attributions">CARTO</a>',
satelliteAttribution:
'Mosaicos © <a href="https://www.esri.com/en-us/legal/terms/full-master-agreement">Esri</a> — Fonte: Esri, Maxar, Earthstar Geographics e comunidade de utilizadores de SIG',
},
};
function normalizeLanguage(language) {
if (typeof language !== "string") {
return null;
}
const normalized = language.replace("_", "-").toLowerCase();
return Object.keys(TRANSLATIONS).find((candidate) => {
const candidateNormalized = candidate.toLowerCase();
return candidateNormalized === normalized || candidateNormalized.split("-")[0] === normalized.split("-")[0];
}) || null;
}
function readStoredLanguage(storage) {
try {
return normalizeLanguage(storage?.getItem(STORAGE_KEY));
} catch {
return null;
}
}
function detectLanguage({ storage, languages = [] } = {}) {
const storedLanguage = readStoredLanguage(storage);
if (storedLanguage) {
return storedLanguage;
}
for (const language of languages) {
const supportedLanguage = normalizeLanguage(language);
if (supportedLanguage) {
return supportedLanguage;
}
}
return DEFAULT_LANGUAGE;
}
function detectBrowserLanguage(languages = []) {
for (const language of languages) {
const supportedLanguage = normalizeLanguage(language);
if (supportedLanguage) {
return supportedLanguage;
}
}
return DEFAULT_LANGUAGE;
}
function createI18n({
storage = globalScope.localStorage,
languages = globalScope.navigator?.languages || [globalScope.navigator?.language],
} = {}) {
const browserLanguage = detectBrowserLanguage(languages);
let preference = readStoredLanguage(storage);
let language = preference || browserLanguage;
function translate(key, values = {}) {
const template = TRANSLATIONS[language]?.[key]
?? TRANSLATIONS[DEFAULT_LANGUAGE]?.[key]
?? key;
return template.replace(/\{(\w+)\}/g, (match, name) =>
Object.hasOwn(values, name) ? String(values[name]) : match,
);
}
return {
get language() {
return language;
},
get locale() {
return language;
},
get preference() {
return preference;
},
get supportedLanguages() {
return Object.keys(TRANSLATIONS);
},
setLanguage(nextLanguage) {
const useBrowserDefault = nextLanguage == null || nextLanguage === "browser";
preference = useBrowserDefault
? null
: normalizeLanguage(nextLanguage) || DEFAULT_LANGUAGE;
language = preference || browserLanguage;
try {
if (preference) {
storage?.setItem(STORAGE_KEY, preference);
} else {
storage?.removeItem(STORAGE_KEY);
}
} catch {
// A blocked storage API must not prevent language selection.
}
return language;
},
t: translate,
tp(singularKey, pluralKey, count, values = {}) {
return translate(count === 1 ? singularKey : pluralKey, { ...values, count });
},
};
}
const exported = {
DEFAULT_LANGUAGE,
STORAGE_KEY,
TRANSLATIONS,
normalizeLanguage,
detectLanguage,
detectBrowserLanguage,
createI18n,
};
if (typeof module !== "undefined" && module.exports) {
module.exports = exported;
}
globalScope.XProI18n = exported;
})(typeof window === "undefined" ? globalThis : window);
+73 -19
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>XPro Map Visualizer</title>
<title data-i18n="pageTitle">XPro Map Visualizer</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,400,0,0" />
<link rel="stylesheet" href="./vendor/ol.css" />
<link rel="stylesheet" href="./styles.css" />
@@ -14,6 +14,7 @@
id="message-region"
class="message-region"
aria-label="Application messages"
data-i18n-aria-label="applicationMessages"
aria-live="polite"
aria-relevant="additions"
></div>
@@ -26,13 +27,18 @@
aria-expanded="true"
>
<span aria-hidden="true"></span>
<span class="visually-hidden">Open map controls</span>
<span class="visually-hidden" data-i18n="openMapControls">Open map controls</span>
</button>
<aside id="sidebar" class="sidebar" aria-label="Map controls">
<aside
id="sidebar"
class="sidebar"
aria-label="Map controls"
data-i18n-aria-label="mapControls"
>
<button id="sidebar-close" class="sidebar-toggle sidebar-close" type="button">
<span aria-hidden="true">×</span>
<span class="visually-hidden">Close map controls</span>
<span class="visually-hidden" data-i18n="closeMapControls">Close map controls</span>
</button>
<div class="sidebar-content">
<div class="brand">
@@ -40,12 +46,23 @@
<img class="brand-logo" src="./assets/brand/xpro-logo-simple.svg" alt="XPro" />
<span class="visually-hidden">XPro</span>
</h1>
<p class="brand-subtitle">Map Visualizer</p>
<div class="brand-footer">
<p class="brand-subtitle" data-i18n="mapVisualizer">Map Visualizer</p>
<button
id="settings-open"
class="settings-open"
type="button"
aria-label="Open settings"
data-i18n-aria-label="openSettings"
>
<span class="material-symbols-outlined" aria-hidden="true">settings</span>
</button>
</div>
</div>
<section class="panel">
<div class="panel-heading">
<h2>Find</h2>
<h2 data-i18n="find">Find</h2>
</div>
<form id="parcel-search-form" class="parcel-search-form">
<input
@@ -54,44 +71,51 @@
type="search"
inputmode="text"
placeholder="e.g. 57 or 2669.1"
data-i18n-placeholder="parcelExample"
autocomplete="off"
aria-label="Parcel number"
data-i18n-aria-label="parcelNumber"
required
/>
<button type="submit">Find on map</button>
<button type="submit" data-i18n="findOnMap">Find on map</button>
</form>
<p id="parcel-search-status" class="status" aria-live="polite"></p>
</section>
<section class="panel">
<div class="panel-heading">
<h2>Layers</h2>
<h2 data-i18n="layers">Layers</h2>
<span id="layer-count" class="badge">0</span>
</div>
<p id="status" class="status">Waiting for connection.</p>
<div id="layer-list" class="layer-list empty">
<p id="status" class="status" data-i18n="waitingForConnection">Waiting for connection.</p>
<div id="layer-list" class="layer-list empty" data-i18n="loadingPublishedLayers">
Loading published layers.
</div>
</section>
<section class="panel">
<div class="panel-heading">
<h2>Background</h2>
<h2 data-i18n="baseMap">Base map</h2>
</div>
<label class="background-selector" for="background-select">
<span>Base map</span>
<select id="background-select" name="background">
<option value="soft">Soft minimal</option>
<option value="openstreetmap">OpenStreetMap</option>
<option value="satellite">Satellite imagery</option>
<div class="background-selector">
<select
id="background-select"
name="background"
aria-label="Base map"
data-i18n-aria-label="baseMap"
>
<option value="soft" data-i18n="softMinimal">Soft minimal</option>
<option value="openstreetmap" data-i18n="openStreetMap">OpenStreetMap</option>
<option value="satellite" data-i18n="satelliteImagery">Satellite imagery</option>
</select>
</label>
</div>
</section>
</div>
</aside>
<main class="map-wrap">
<div id="map" aria-label="Map view"></div>
<div id="map" aria-label="Map view" data-i18n-aria-label="mapView"></div>
</main>
<div id="feature-modal" class="feature-modal" hidden>
@@ -107,15 +131,45 @@
class="feature-modal-close"
type="button"
aria-label="Close feature information"
data-i18n-aria-label="closeFeatureInformation"
>
×
</button>
<div id="feature-modal-content"></div>
</section>
</div>
<div id="settings-modal" class="settings-modal" hidden>
<div class="settings-modal-backdrop" data-settings-modal-close></div>
<section
class="settings-modal-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="settings-modal-title"
>
<button
id="settings-modal-close"
class="settings-modal-close"
type="button"
aria-label="Close settings"
data-i18n-aria-label="closeSettings"
>
×
</button>
<h2 id="settings-modal-title" data-i18n="settings">Settings</h2>
<form id="settings-form" class="settings-form">
<label for="language-select">
<span data-i18n="applicationLanguage">Application language</span>
<select id="language-select" name="language"></select>
</label>
<button type="submit" data-i18n="applySettings">Apply</button>
</form>
</section>
</div>
</div>
<script src="./vendor/ol.js"></script>
<script src="./i18n.js"></script>
<script src="./app.js"></script>
</body>
</html>
+2 -1
View File
@@ -3,7 +3,8 @@
"version": "0.1.0",
"private": true,
"scripts": {
"start": "node --env-file-if-exists=.env server.js"
"start": "node --env-file-if-exists=.env server.js",
"test": "node --test"
},
"dependencies": {
"ol": "10.6.1"
+93 -4
View File
@@ -241,14 +241,49 @@ body {
height: auto;
}
.brand-subtitle {
.brand-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 8px;
/* Extend through the brand's reserved close-button clearance so the gear
and sidebar close controls share the same right-hand axis. */
margin-right: -60px;
}
.brand-subtitle {
color: var(--muted);
font-size: 1rem;
font-weight: 700;
font-weight: 400;
letter-spacing: 0.04em;
}
.settings-open {
display: grid;
width: 42px;
height: 42px;
min-width: 42px;
padding: 0;
place-items: center;
border: 1px solid var(--panel-border);
border-radius: 50%;
background: rgba(255, 255, 255, 0.55);
color: var(--ink);
box-shadow: none;
}
.settings-open:hover,
.settings-open:focus-visible {
background: rgba(31, 122, 98, 0.1);
outline: 2px solid rgba(31, 122, 98, 0.25);
outline-offset: 2px;
}
.settings-open .material-symbols-outlined {
font-size: 1.3rem;
}
.visually-hidden {
position: absolute;
width: 1px;
@@ -370,6 +405,10 @@ button:disabled {
color: var(--muted);
}
.status:empty {
display: none;
}
.status.error {
color: #9d2e21;
}
@@ -427,7 +466,12 @@ button:disabled {
display: none;
}
.feature-modal {
.settings-modal[hidden] {
display: none;
}
.feature-modal,
.settings-modal {
position: fixed;
z-index: 20;
inset: 0;
@@ -436,13 +480,53 @@ button:disabled {
place-items: center;
}
.feature-modal-backdrop {
.feature-modal-backdrop,
.settings-modal-backdrop {
position: absolute;
inset: 0;
background: rgba(31, 42, 46, 0.35);
backdrop-filter: blur(1px);
}
.settings-modal-dialog {
position: relative;
width: min(100%, 380px);
padding: 28px;
border: 1px solid var(--panel-border);
border-radius: 24px;
background: var(--panel);
backdrop-filter: blur(16px);
box-shadow: var(--shadow);
}
.settings-modal-close {
position: absolute;
top: 12px;
right: 12px;
display: grid;
width: 36px;
height: 36px;
padding: 0;
place-items: center;
border: 1px solid var(--panel-border);
border-radius: 50%;
background: transparent;
color: var(--ink);
box-shadow: none;
font-size: 1.5rem;
line-height: 1;
}
.settings-modal-dialog h2 {
padding-right: 44px;
}
.settings-form {
display: grid;
gap: 20px;
margin-top: 24px;
}
.feature-modal-dialog {
position: relative;
width: min(100%, 360px);
@@ -506,9 +590,12 @@ button:disabled {
}
.feature-details dd {
min-width: 0;
margin: 0;
font-size: 1.1rem;
font-weight: 700;
overflow-wrap: anywhere;
text-align: right;
}
.feature-inhabited {
@@ -564,7 +651,9 @@ button:disabled {
}
.feature-association-label {
min-width: 0;
font-weight: 700;
overflow-wrap: anywhere;
}
.feature-association-area {
+24
View File
@@ -0,0 +1,24 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const root = path.resolve(__dirname, "..");
test("the Docker image includes every application script referenced by the page", () => {
const html = fs.readFileSync(path.join(root, "index.html"), "utf8");
const dockerfile = fs.readFileSync(path.join(root, "Dockerfile"), "utf8");
const applicationScripts = [...html.matchAll(/<script src="\.\/([^"]+\.js)"/g)]
.map((match) => match[1])
.filter((scriptPath) => !scriptPath.startsWith("vendor/"));
assert.ok(applicationScripts.length > 0);
applicationScripts.forEach((scriptPath) => {
assert.ok(fs.existsSync(path.join(root, scriptPath)), `${scriptPath} must exist`);
assert.match(
dockerfile,
new RegExp(`^COPY ${scriptPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} \\.\\/$`, "m"),
`${scriptPath} must be copied by the Dockerfile`,
);
});
});
+85
View File
@@ -0,0 +1,85 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
DEFAULT_LANGUAGE,
TRANSLATIONS,
createI18n,
detectLanguage,
normalizeLanguage,
} = require("../i18n.js");
function createStorage(initialValue) {
let value = initialValue;
return {
getItem() {
return value;
},
setItem(key, nextValue) {
value = nextValue;
},
removeItem() {
value = undefined;
},
};
}
test("normalizes exact and base language tags", () => {
assert.equal(normalizeLanguage("pt-PT"), "pt-PT");
assert.equal(normalizeLanguage("pt-BR"), "pt-PT");
assert.equal(normalizeLanguage("en-US"), "en");
assert.equal(normalizeLanguage("fr"), null);
});
test("prefers a stored language over browser preferences", () => {
const storage = createStorage("en");
assert.equal(detectLanguage({ storage, languages: ["pt-PT"] }), "en");
});
test("uses the first supported browser language and otherwise falls back", () => {
assert.equal(detectLanguage({ storage: createStorage(), languages: ["fr", "pt-BR"] }), "pt-PT");
assert.equal(detectLanguage({ storage: createStorage(), languages: ["es-MX"] }), "es");
assert.equal(detectLanguage({ storage: createStorage(), languages: ["fr"] }), DEFAULT_LANGUAGE);
});
test("persists language changes and interpolates translated values", () => {
const storage = createStorage();
const i18n = createI18n({ storage, languages: ["en"] });
assert.equal(i18n.setLanguage("pt"), "pt-PT");
assert.equal(storage.getItem(), "pt-PT");
assert.equal(
i18n.t("noParcelFound", { parcelNumber: "57" }),
"Não foi encontrada a parcela com o número “57”.",
);
});
test("browser-default mode removes the preference and follows the browser language", () => {
const storage = createStorage("en");
const i18n = createI18n({ storage, languages: ["pt-PT"] });
assert.equal(i18n.setLanguage(null), "pt-PT");
assert.equal(i18n.preference, null);
assert.equal(storage.getItem(), undefined);
});
test("uses English for missing translations and the key for unknown messages", () => {
const storage = createStorage("pt-PT");
const originalTranslation = TRANSLATIONS["pt-PT"].zoomIn;
delete TRANSLATIONS["pt-PT"].zoomIn;
try {
const i18n = createI18n({ storage, languages: [] });
assert.equal(i18n.t("zoomIn"), TRANSLATIONS.en.zoomIn);
assert.equal(i18n.t("notARealMessage"), "notARealMessage");
} finally {
TRANSLATIONS["pt-PT"].zoomIn = originalTranslation;
}
});
test("all shipped languages contain the default translation keys", () => {
const defaultKeys = Object.keys(TRANSLATIONS[DEFAULT_LANGUAGE]).sort();
Object.entries(TRANSLATIONS).forEach(([language, translations]) => {
assert.deepEqual(Object.keys(translations).sort(), defaultKeys, language);
});
});