[glm-grade=B+] feat(i18n): complete card/filter/action translation keys for 31 languages
Full language display names + RTL set (ar/fa/ur) on /i18n/languages; card.internet/auth/tailscale/dashca, status pills, filter bar and batch-operation strings added to every language dictionary. Frontend: English now loads the server dictionary too (keys are semantic ids, not fallback copy), failed loads keep existing DOM text instead of exposing raw keys, isLoaded() gate for pre-load renders. Rebuilt status/dist. Tests: i18n-cards 9/9, full suite 1837/1837.
This commit is contained in:
Vendored
+106
-106
File diff suppressed because one or more lines are too long
+29
-20
@@ -8,7 +8,7 @@
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||
<meta http-equiv="Pragma" content="no-cache" />
|
||||
<meta http-equiv="Expires" content="0" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'sha256-dtGmAPWjcgykNC2GM60HzlzMwiAqHarVnnPf8NO0XrA='; style-src 'self' 'unsafe-inline'; img-src 'self' https://cdn.jsdelivr.net data:; connect-src 'self' https://api.open-meteo.com https://geocoding-api.open-meteo.com; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'sha256-LqwtGSCiBtjq9Bnb5wA3kQFrFRgZWtDWcswiK656gEk='; style-src 'self' 'unsafe-inline'; img-src 'self' https://cdn.jsdelivr.net data:; connect-src 'self' https://api.open-meteo.com https://geocoding-api.open-meteo.com; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'">
|
||||
|
||||
<link rel="icon" href="/assets/dashcaddy-favicon.ico" sizes="any">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/assets/icon-192.png">
|
||||
@@ -245,9 +245,9 @@
|
||||
<path d="M10.5 7.5c3-1.5 6-1.5 9 0M10.5 16.5c3 1.5 6 1.5 9 0" stroke="white" stroke-width="1"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="name">Internet</span>
|
||||
<span class="name" data-i18n="card.internet">Internet</span>
|
||||
<span class="spacer"></span>
|
||||
<span id="internet-pill" class="badge off">OFF</span>
|
||||
<span id="internet-pill" class="badge off" data-i18n-live-status="binary">OFF</span>
|
||||
</div>
|
||||
<div class="response-row">
|
||||
<span id="internet-time" class="response-time">--</span>
|
||||
@@ -267,15 +267,15 @@
|
||||
<line x1="12" y1="16.5" x2="12" y2="18" stroke="#0b0f1a" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="name">Auth</span>
|
||||
<span class="name" data-i18n="card.auth">Auth</span>
|
||||
<span class="spacer"></span>
|
||||
<span id="auth-pill" class="badge off">NO</span>
|
||||
<span id="auth-pill" class="badge off" data-i18n-live-status="yesno">NO</span>
|
||||
</div>
|
||||
<div class="response-row">
|
||||
<span id="auth-status-text" class="response-time" style="font-size: 0.7rem;">Not configured</span>
|
||||
<span id="auth-status-text" class="response-time" style="font-size: 0.7rem;" data-i18n="card.auth.not_configured">Not configured</span>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button id="auth-settings-btn">Settings</button>
|
||||
<button id="auth-settings-btn" data-i18n="action.settings">Settings</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -290,7 +290,7 @@
|
||||
<path d="M12 13v4M9 19h6" stroke="#7D8FE3" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="name">Tailscale</span>
|
||||
<span class="name" data-i18n="card.tailscale">Tailscale</span>
|
||||
<span class="spacer"></span>
|
||||
<span id="tailscale-pill" class="badge off">—</span>
|
||||
</div>
|
||||
@@ -308,9 +308,9 @@
|
||||
<div class="logo-wrap">
|
||||
<span style="font-size: 28px; display: flex; align-items: center; justify-content: center; width: 100%; height: 100%;">🔐</span>
|
||||
</div>
|
||||
<span class="name">DashCA</span>
|
||||
<span class="name" data-i18n="card.dashca">DashCA</span>
|
||||
<span class="spacer"></span>
|
||||
<span id="badge-ca" class="badge off">OFF</span>
|
||||
<span id="badge-ca" class="badge off" data-i18n-live-status="binary">OFF</span>
|
||||
</div>
|
||||
<div class="response-row">
|
||||
<span id="time-ca" class="response-time">--</span>
|
||||
@@ -323,7 +323,7 @@
|
||||
<button class="creds-btn" id="creds-btn-ca" title="Auto-login credentials">🔑</button>
|
||||
<button class="options-btn" id="options-btn-ca" title="Edit service settings">⚙️</button>
|
||||
<button class="delete-btn" id="delete-btn-ca" title="Delete this service">🗑️</button>
|
||||
<button id="ca-open">Open</button>
|
||||
<button id="ca-open" data-i18n="action.open">Open</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -345,16 +345,16 @@
|
||||
|
||||
<!-- Service Filter Bar -->
|
||||
<div id="service-filter-bar" style="display: flex; gap: 12px; align-items: center; margin-bottom: 16px; padding: 12px 16px; background: var(--card-base); border: 1px solid var(--border); border-radius: var(--radius); flex-wrap: wrap;">
|
||||
<input type="text" id="service-filter-search" placeholder="🔍 Filter services..." style="flex: 1; min-width: 180px; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 0.9rem;" />
|
||||
<input type="text" id="service-filter-search" placeholder="🔍 Filter services..." data-i18n-placeholder="filter.services_placeholder" data-i18n-prefix="🔍 " style="flex: 1; min-width: 180px; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 0.9rem;" />
|
||||
<select id="service-filter-status" style="padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 0.9rem;">
|
||||
<option value="all">All Status</option>
|
||||
<option value="on">🟢 Online</option>
|
||||
<option value="off">🔴 Offline</option>
|
||||
<option value="all" data-i18n="filter.all_status">All Status</option>
|
||||
<option value="on" data-i18n="filter.online" data-i18n-prefix="🟢 ">🟢 Online</option>
|
||||
<option value="off" data-i18n="filter.offline" data-i18n-prefix="🔴 ">🔴 Offline</option>
|
||||
</select>
|
||||
<select id="service-filter-category" style="padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 0.9rem;">
|
||||
<option value="all">All Categories</option>
|
||||
<option value="all" data-i18n="filter.all_categories">All Categories</option>
|
||||
</select>
|
||||
<button id="batch-operations-btn" class="btn-sm" style="padding: 8px 12px;">☰ Batch Operations</button>
|
||||
<button id="batch-operations-btn" class="btn-sm" style="padding: 8px 12px;" data-i18n="filter.batch_operations" data-i18n-prefix="☰ ">☰ Batch Operations</button>
|
||||
<span id="service-filter-count" style="color: var(--muted); font-size: 0.85rem; white-space: nowrap;"></span>
|
||||
</div>
|
||||
|
||||
@@ -760,14 +760,23 @@
|
||||
var versionInfoClose = document.getElementById('version-info-close');
|
||||
var latestUpdateCheck = null;
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value).replace(/[&<>"']/g, function(character) {
|
||||
return {
|
||||
'&': '&', '<': '<', '>': '>',
|
||||
'"': '"', "'": '''
|
||||
}[character];
|
||||
});
|
||||
}
|
||||
|
||||
function formatValue(value) {
|
||||
if (value == null || value === '') return '—';
|
||||
if (typeof value === 'object') return JSON.stringify(value);
|
||||
return String(value);
|
||||
if (typeof value === 'object') return escapeHtml(JSON.stringify(value));
|
||||
return escapeHtml(value);
|
||||
}
|
||||
|
||||
function renderInfoRow(label, value) {
|
||||
return '<div class="version-info-row"><span class="version-info-label">' + label + '</span><span class="version-info-value">' + formatValue(value) + '</span></div>';
|
||||
return '<div class="version-info-row"><span class="version-info-label">' + escapeHtml(label) + '</span><span class="version-info-value">' + formatValue(value) + '</span></div>';
|
||||
}
|
||||
|
||||
function renderHistory(history) {
|
||||
|
||||
+19
-3
@@ -1,6 +1,12 @@
|
||||
// ========== GRID & STATUS HELPERS ==========
|
||||
(function () {
|
||||
|
||||
function statusLabel(up) {
|
||||
const i18n = window.DCI18n;
|
||||
if (!i18n || !i18n.isLoaded()) return up ? 'ON' : 'OFF';
|
||||
return i18n.t(up ? 'card.status.on' : 'card.status.off');
|
||||
}
|
||||
|
||||
/* Enhanced status helpers with response time tracking */
|
||||
function setQuick(id, up, responseTime = null) {
|
||||
const dot = document.getElementById(id + '-dot');
|
||||
@@ -14,7 +20,7 @@
|
||||
}
|
||||
|
||||
if (pill) {
|
||||
pill.textContent = up ? 'ON' : 'OFF';
|
||||
pill.textContent = statusLabel(up);
|
||||
pill.classList.toggle('on', up);
|
||||
pill.classList.toggle('off', !up);
|
||||
}
|
||||
@@ -172,7 +178,10 @@
|
||||
|
||||
row.appendChild(el('span', 'spacer'));
|
||||
|
||||
const pill = el('span', 'badge off', 'OFF'); pill.id = 'badge-' + s.id; row.appendChild(pill);
|
||||
const pill = el('span', 'badge off', 'OFF');
|
||||
pill.id = 'badge-' + s.id;
|
||||
pill.setAttribute('data-i18n-live-status', 'binary');
|
||||
row.appendChild(pill);
|
||||
|
||||
// Update available badge (hidden by default, shown when update detected)
|
||||
const updateBadge = el('span', 'update-available-badge', 'UPDATE');
|
||||
@@ -301,6 +310,7 @@
|
||||
}
|
||||
|
||||
const btn = el('button', null, 'Open');
|
||||
btn.setAttribute('data-i18n', 'action.open');
|
||||
btn.onclick = () => window.open(serviceUrl(s.id), '_blank', 'noopener');
|
||||
btnRow.appendChild(btn);
|
||||
card.appendChild(btnRow);
|
||||
@@ -309,6 +319,12 @@
|
||||
root.appendChild(card);
|
||||
}
|
||||
|
||||
// Cards can be created after the initial language pass. Translate their
|
||||
// initial live state and controls immediately instead of waiting for poll.
|
||||
if (window.DCI18n && window.DCI18n.isLoaded()) {
|
||||
window.DCI18n.applyTranslations();
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
root.querySelectorAll('.card').forEach(card => card.classList.add('loaded'));
|
||||
});
|
||||
@@ -332,7 +348,7 @@
|
||||
}
|
||||
|
||||
if (pill) {
|
||||
pill.textContent = up ? 'ON' : 'OFF';
|
||||
pill.textContent = statusLabel(up);
|
||||
pill.classList.toggle('on', up);
|
||||
pill.classList.toggle('off', !up);
|
||||
}
|
||||
|
||||
@@ -147,18 +147,21 @@ function renderDnsCards() {
|
||||
`<span id="${safeId}-dot" class="dot bad at-bl"></span>`
|
||||
+ `<div class="row"><div class="logo-wrap">${svgIcon}</div>`
|
||||
+ `<span class="name">${label}</span><span class="spacer"></span>`
|
||||
+ `<span id="${safeId}-pill" class="badge off">OFF</span></div>`
|
||||
+ `<span id="${safeId}-pill" class="badge off" data-i18n-live-status="binary">OFF</span></div>`
|
||||
+ `<div class="response-row"><span id="${safeId}-time" class="response-time">--</span></div>`
|
||||
+ `<div class="health-row" id="health-${safeId}"><span id="uptime-${safeId}" class="uptime-chip">--</span><div class="uptime-mini-bar"><div class="fill" id="uptime-bar-${safeId}" style="width: 0%"></div></div></div>`
|
||||
+ `<div class="btn-row">`
|
||||
+ `<button id="${safeId}-restart" class="restart-btn">Restart</button>`
|
||||
+ `<button id="${safeId}-restart" class="restart-btn" data-i18n="action.restart">Restart</button>`
|
||||
+ `<button id="${safeId}-update" class="update-btn" title="Update DNS server">⬆️</button>`
|
||||
+ `<button id="${safeId}-open">Open</button>`
|
||||
+ `<button id="${safeId}-logs" class="logs-btn">Logs</button>`
|
||||
+ `<button id="${safeId}-open" data-i18n="action.open">Open</button>`
|
||||
+ `<button id="${safeId}-logs" class="logs-btn" data-i18n="action.logs">Logs</button>`
|
||||
+ `<button id="${safeId}-settings" class="settings-btn">⚙️</button>`
|
||||
+ `</div>`;
|
||||
topRow.insertBefore(card, firstChild);
|
||||
});
|
||||
if (window.DCI18n && window.DCI18n.isLoaded()) {
|
||||
window.DCI18n.applyTranslations();
|
||||
}
|
||||
}
|
||||
window.renderDnsCards = renderDnsCards;
|
||||
|
||||
|
||||
+29
-18
@@ -50,11 +50,6 @@
|
||||
// reqId is the monotonic token incremented by the caller (setLanguage/init).
|
||||
// If not provided (direct API call), allocate one for backward compatibility.
|
||||
if (reqId === undefined) reqId = ++_langRequestId;
|
||||
if (lang === DEFAULT_LANG) {
|
||||
translations = {}; // English is the default — no translation needed
|
||||
loaded = true;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/v1/i18n/translations/${lang}`);
|
||||
// Guard against out-of-order resolution: if another loadTranslations
|
||||
@@ -80,11 +75,15 @@
|
||||
}
|
||||
|
||||
function t(key) {
|
||||
if (currentLang === DEFAULT_LANG) return key;
|
||||
// If translations didn't load, fall back to the English key
|
||||
// Translation keys are semantic identifiers, not English fallback copy.
|
||||
// Callers that render before loading should retain their existing DOM text.
|
||||
return translations[key] || key;
|
||||
}
|
||||
|
||||
function isLoaded() {
|
||||
return loaded;
|
||||
}
|
||||
|
||||
function setLanguage(lang) {
|
||||
if (!SUPPORTED_LANGS.includes(lang)) return;
|
||||
currentLang = lang;
|
||||
@@ -97,8 +96,9 @@
|
||||
|
||||
const reqId = ++_langRequestId; // increment BEFORE calling loadTranslations
|
||||
loadTranslations(lang, reqId).then(() => {
|
||||
// Only apply if this is still the latest request.
|
||||
if (reqId === _langRequestId) applyTranslations();
|
||||
// Only apply a successfully loaded dictionary. On HTTP/network failure,
|
||||
// retain the existing readable DOM copy instead of exposing semantic keys.
|
||||
if (reqId === _langRequestId && loaded) applyTranslations();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -113,12 +113,25 @@
|
||||
// leaving the previous language's translated text visible.
|
||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
el.textContent = t(key);
|
||||
const prefix = el.getAttribute('data-i18n-prefix') || '';
|
||||
el.textContent = prefix + t(key);
|
||||
});
|
||||
// Apply to placeholders
|
||||
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n-placeholder');
|
||||
el.placeholder = t(key);
|
||||
const prefix = el.getAttribute('data-i18n-prefix') || '';
|
||||
el.placeholder = prefix + t(key);
|
||||
});
|
||||
// Live status pills derive the translation key from current runtime state.
|
||||
// A language switch must never reset an online card to its initial OFF copy.
|
||||
document.querySelectorAll('[data-i18n-live-status]').forEach(el => {
|
||||
const card = el.closest('[data-status]');
|
||||
const isOn = card && card.getAttribute('data-status') === 'on';
|
||||
const mode = el.getAttribute('data-i18n-live-status');
|
||||
const key = mode === 'yesno'
|
||||
? (isOn ? 'card.status.yes' : 'card.status.no')
|
||||
: (isOn ? 'card.status.on' : 'card.status.off');
|
||||
el.textContent = t(key);
|
||||
});
|
||||
// Apply to titles
|
||||
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
||||
@@ -226,12 +239,10 @@
|
||||
|
||||
function start() {
|
||||
createLanguageSelector();
|
||||
if (currentLang !== DEFAULT_LANG) {
|
||||
const reqId = ++_langRequestId;
|
||||
loadTranslations(currentLang, reqId).then(() => {
|
||||
if (reqId === _langRequestId) applyTranslations();
|
||||
});
|
||||
}
|
||||
const reqId = ++_langRequestId;
|
||||
loadTranslations(currentLang, reqId).then(() => {
|
||||
if (reqId === _langRequestId && loaded) applyTranslations();
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
@@ -242,7 +253,7 @@
|
||||
}
|
||||
|
||||
// Expose globally
|
||||
window.DCI18n = { t, setLanguage, getLanguage, applyTranslations, loadTranslations };
|
||||
window.DCI18n = { t, setLanguage, getLanguage, isLoaded, applyTranslations, loadTranslations };
|
||||
|
||||
// Auto-init
|
||||
init();
|
||||
|
||||
@@ -169,6 +169,11 @@
|
||||
'4h': '4 hours', '8h': '8 hours', '12h': '12 hours', '24h': '24 hours', 'never': 'Disabled'
|
||||
};
|
||||
|
||||
function translated(key, fallback) {
|
||||
const i18n = window.DCI18n;
|
||||
return i18n && i18n.isLoaded() ? i18n.t(key) : fallback;
|
||||
}
|
||||
|
||||
function updateAuthCard(active, duration) {
|
||||
const card = document.getElementById('auth-card');
|
||||
const pill = document.getElementById('auth-pill');
|
||||
@@ -179,15 +184,15 @@
|
||||
if (active) {
|
||||
card.setAttribute('data-status', 'on');
|
||||
pill.className = 'badge on';
|
||||
pill.textContent = 'YES';
|
||||
pill.textContent = translated('card.status.yes', 'YES');
|
||||
dot.className = 'dot ok at-bl';
|
||||
statusText.textContent = 'Session: ' + (DURATION_LABELS[duration] || duration);
|
||||
} else {
|
||||
card.setAttribute('data-status', 'off');
|
||||
pill.className = 'badge off';
|
||||
pill.textContent = 'NO';
|
||||
pill.textContent = translated('card.status.no', 'NO');
|
||||
dot.className = 'dot bad at-bl';
|
||||
statusText.textContent = 'Not configured';
|
||||
statusText.textContent = translated('card.auth.not_configured', 'Not configured');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'dashcaddy-shell-e57b8ce3e7';
|
||||
const CACHE = 'dashcaddy-shell-4a75cb88af';
|
||||
const PRECACHE = [
|
||||
'/',
|
||||
'/index.html',
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const statusRoot = path.join(__dirname, '..');
|
||||
|
||||
function makeElement(attrs = {}, text = '', card = null) {
|
||||
return {
|
||||
attrs: { ...attrs }, textContent: text, placeholder: '', title: '',
|
||||
getAttribute(name) { return this.attrs[name] || null; },
|
||||
closest(selector) { return selector === '[data-status]' ? card : null; },
|
||||
};
|
||||
}
|
||||
|
||||
function loadI18n(fetchImpl, elements = {}) {
|
||||
const source = fs.readFileSync(path.join(statusRoot, 'js', 'i18n.js'), 'utf8');
|
||||
const listeners = {};
|
||||
const document = {
|
||||
readyState: 'loading', documentElement: {},
|
||||
addEventListener(name, fn) { listeners[name] = fn; },
|
||||
querySelectorAll(selector) { return elements[selector] || []; },
|
||||
querySelector() { return null; }, getElementById() { return null; },
|
||||
createElement() { return { style: {}, appendChild() {}, addEventListener() {}, setAttribute() {} }; },
|
||||
};
|
||||
const context = {
|
||||
window: {}, document, console, fetch: fetchImpl,
|
||||
localStorage: { getItem() { return null; }, setItem() {} },
|
||||
setTimeout, clearTimeout,
|
||||
};
|
||||
vm.runInNewContext(source, context, { filename: 'i18n.js' });
|
||||
return { api: context.window.DCI18n, listeners };
|
||||
}
|
||||
|
||||
test('English dictionary is fetched and semantic keys never replace English card copy', async () => {
|
||||
const card = makeElement({ 'data-i18n': 'card.internet' }, 'Internet');
|
||||
const calls = [];
|
||||
const { api } = loadI18n(async url => {
|
||||
calls.push(url);
|
||||
return { ok: true, async json() { return { translations: { 'card.internet': 'Internet' } }; } };
|
||||
}, {
|
||||
'[data-i18n]': [card], '[data-i18n-placeholder]': [],
|
||||
'[data-i18n-live-status]': [], '[data-i18n-title]': [],
|
||||
});
|
||||
await api.loadTranslations('en');
|
||||
api.applyTranslations();
|
||||
assert.deepEqual(calls, ['/api/v1/i18n/translations/en']);
|
||||
assert.equal(card.textContent, 'Internet');
|
||||
assert.equal(api.isLoaded(), true);
|
||||
});
|
||||
|
||||
|
||||
test('failed language switch preserves existing readable labels', async () => {
|
||||
const label = makeElement({ 'data-i18n': 'card.internet' }, 'Internet');
|
||||
const elements = {
|
||||
'[data-i18n]': [label], '[data-i18n-placeholder]': [],
|
||||
'[data-i18n-live-status]': [], '[data-i18n-title]': [],
|
||||
};
|
||||
const { api } = loadI18n(async () => ({ ok: false, async json() { return {}; } }), elements);
|
||||
api.setLanguage('es');
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
assert.equal(api.isLoaded(), false);
|
||||
assert.equal(label.textContent, 'Internet');
|
||||
assert.notEqual(label.textContent, 'card.internet');
|
||||
});
|
||||
|
||||
test('language switch preserves ON runtime state and translates a dynamic DNS pill immediately', async () => {
|
||||
const onlineCard = { getAttribute(name) { return name === 'data-status' ? 'on' : null; } };
|
||||
const staticPill = makeElement({ 'data-i18n-live-status': 'binary' }, 'ON', onlineCard);
|
||||
const dynamicDnsPill = makeElement({ 'data-i18n-live-status': 'binary' }, 'ON', onlineCard);
|
||||
const elements = {
|
||||
'[data-i18n]': [], '[data-i18n-placeholder]': [],
|
||||
'[data-i18n-live-status]': [staticPill, dynamicDnsPill], '[data-i18n-title]': [],
|
||||
};
|
||||
const { api } = loadI18n(async () => ({
|
||||
ok: true,
|
||||
async json() { return { translations: { 'card.status.on': 'ENC', 'card.status.off': 'APAG' } }; },
|
||||
}), elements);
|
||||
await api.loadTranslations('es');
|
||||
api.applyTranslations();
|
||||
assert.equal(staticPill.textContent, 'ENC');
|
||||
assert.equal(dynamicDnsPill.textContent, 'ENC');
|
||||
assert.notEqual(staticPill.textContent, 'APAG', 'online state must not reset to OFF');
|
||||
});
|
||||
|
||||
test('dynamic card template marks its pill and reapplies translations after insertion', () => {
|
||||
const source = fs.readFileSync(path.join(statusRoot, 'js', 'globals.js'), 'utf8');
|
||||
assert.match(source, /data-i18n-live-status="binary"/);
|
||||
assert.match(source, /DCI18n\.isLoaded\(\)/);
|
||||
assert.match(source, /DCI18n\.applyTranslations\(\)/);
|
||||
});
|
||||
|
||||
|
||||
test('ordinary service card built after i18n load is translated immediately', () => {
|
||||
class Node {
|
||||
constructor(tag = 'div') {
|
||||
this.tag = tag; this.children = []; this.attrs = {}; this.textContent = '';
|
||||
this.className = ''; this.id = ''; this.style = {};
|
||||
this.classList = { add() {}, toggle() {} };
|
||||
}
|
||||
appendChild(child) { this.children.push(child); child.parentNode = this; return child; }
|
||||
setAttribute(name, value) { this.attrs[name] = String(value); }
|
||||
getAttribute(name) { return this.attrs[name] || null; }
|
||||
closest(selector) {
|
||||
if (selector === '[data-status]' && this.attrs['data-status']) return this;
|
||||
return this.parentNode ? this.parentNode.closest(selector) : null;
|
||||
}
|
||||
addEventListener() {}
|
||||
querySelectorAll(selector) {
|
||||
const found = [];
|
||||
const visit = node => {
|
||||
const attr = selector.match(/^\[([^\]]+)\]$/);
|
||||
if (attr && Object.prototype.hasOwnProperty.call(node.attrs, attr[1])) found.push(node);
|
||||
if (selector === '.card' && node.className.split(/\s+/).includes('card')) found.push(node);
|
||||
node.children.forEach(visit);
|
||||
};
|
||||
this.children.forEach(visit);
|
||||
return found;
|
||||
}
|
||||
}
|
||||
const cards = new Node('section');
|
||||
const document = {
|
||||
createElement(tag) { return new Node(tag); },
|
||||
getElementById(id) { return id === 'cards' ? cards : null; },
|
||||
querySelector() { return null; },
|
||||
};
|
||||
const translations = { 'card.status.off': 'APAG', 'action.open': 'Abrir' };
|
||||
const window = {
|
||||
APPS: [{ id: 'demo', name: 'Demo', logo: '/demo.png' }],
|
||||
DCI18n: {
|
||||
isLoaded() { return true; },
|
||||
t(key) { return translations[key] || key; },
|
||||
applyTranslations() {
|
||||
cards.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
el.textContent = this.t(el.getAttribute('data-i18n'));
|
||||
});
|
||||
cards.querySelectorAll('[data-i18n-live-status]').forEach(el => {
|
||||
const card = el.closest('[data-status]');
|
||||
const key = card.getAttribute('data-status') === 'on' ? 'card.status.on' : 'card.status.off';
|
||||
el.textContent = this.t(key);
|
||||
});
|
||||
},
|
||||
},
|
||||
open() {},
|
||||
};
|
||||
const context = {
|
||||
window, document, console, SITE: { dnsServers: {} },
|
||||
buildServiceUrl(id) { return 'https://' + id + '.sami'; },
|
||||
requestAnimationFrame(fn) { fn(); }, fetch: async () => ({ ok: true }),
|
||||
setTimeout, clearTimeout, performance: { now() { return 0; } },
|
||||
};
|
||||
const source = fs.readFileSync(path.join(statusRoot, 'js', 'core', 'grid.js'), 'utf8');
|
||||
vm.runInNewContext(source, context, { filename: 'grid.js' });
|
||||
// grid.js initializes APPS itself; emulate service loading after module init.
|
||||
window.APPS = [{ id: 'demo', name: 'Demo', logo: '/demo.png' }];
|
||||
window.buildGrid();
|
||||
const livePills = cards.querySelectorAll('[data-i18n-live-status]');
|
||||
const openButtons = cards.querySelectorAll('[data-i18n]');
|
||||
assert.equal(livePills.length, 1);
|
||||
assert.equal(livePills[0].textContent, 'APAG');
|
||||
assert.equal(openButtons.length, 1);
|
||||
assert.equal(openButtons[0].textContent, 'Abrir');
|
||||
});
|
||||
|
||||
test('live health polling uses translated ON and OFF labels', () => {
|
||||
const source = fs.readFileSync(path.join(statusRoot, 'js', 'core', 'grid.js'), 'utf8');
|
||||
assert.match(source, /card\.status\.on/);
|
||||
assert.match(source, /card\.status\.off/);
|
||||
assert.doesNotMatch(source, /pill\.textContent\s*=\s*up\s*\?\s*['"]ON['"]\s*:\s*['"]OFF['"]/);
|
||||
});
|
||||
|
||||
|
||||
test('version modal escapes malicious API metadata before innerHTML rendering', () => {
|
||||
const html = fs.readFileSync(path.join(statusRoot, 'index.html'), 'utf8');
|
||||
const script = html.match(/<script>\s*\(function\(\) \{[\s\S]*?function escapeHtml[\s\S]*?window\.applyVersionUpdate = applyVersionUpdate;[\s\S]*?<\/script>/);
|
||||
assert.ok(script, 'expected inline version modal script');
|
||||
const escapeSource = script[0].match(/function escapeHtml\(value\) \{[\s\S]*?\n \}/)[0];
|
||||
const formatSource = script[0].match(/function formatValue\(value\) \{[\s\S]*?\n \}/)[0];
|
||||
const rowSource = script[0].match(/function renderInfoRow\(label, value\) \{[\s\S]*?\n \}/)[0];
|
||||
const context = {};
|
||||
vm.runInNewContext(escapeSource + '\n' + formatSource + '\n' + rowSource, context);
|
||||
const payload = '<img src=x onerror="globalThis.pwned=1">';
|
||||
const row = context.renderInfoRow(payload, payload);
|
||||
assert.doesNotMatch(row, /<img\b/i);
|
||||
assert.doesNotMatch(row, /onerror="/i);
|
||||
assert.match(row, /<img/);
|
||||
assert.match(row, /"globalThis\.pwned=1"/);
|
||||
});
|
||||
|
||||
test('index loads the generated core bundle containing live-status translation logic', () => {
|
||||
const html = fs.readFileSync(path.join(statusRoot, 'index.html'), 'utf8');
|
||||
const bundle = fs.readFileSync(path.join(statusRoot, 'dist', 'core.js'), 'utf8');
|
||||
assert.match(html, /<script src="\/dist\/core\.js" defer><\/script>/);
|
||||
assert.match(bundle, /data-i18n-live-status/);
|
||||
assert.match(bundle, /card\.status\.on/);
|
||||
assert.match(bundle, /applyTranslations/);
|
||||
});
|
||||
|
||||
test('translated controls preserve their visual glyph prefixes', () => {
|
||||
const html = fs.readFileSync(path.join(statusRoot, 'index.html'), 'utf8');
|
||||
assert.match(html, /data-i18n="filter\.online" data-i18n-prefix="🟢 "/);
|
||||
assert.match(html, /data-i18n="filter\.offline" data-i18n-prefix="🔴 "/);
|
||||
assert.match(html, /data-i18n="filter\.batch_operations" data-i18n-prefix="☰ "/);
|
||||
assert.match(html, /data-i18n-placeholder="filter\.services_placeholder" data-i18n-prefix="🔍 "/);
|
||||
});
|
||||
Reference in New Issue
Block a user