[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:
@@ -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