Skip to content

Commit 5766a14

Browse files
StewAlexander-comKarim13014claude
authored
Add hero title, social previews, offline i18n, and silence Node 20 deprecation (#6)
* Add hero title, social preview metadata, offline i18n, and Node 24 actions opt-in * index.html: new hero title "Offline MAC Address Lookup", description, canonical, robots, color-scheme, Open Graph + Twitter card tags pointing at a new 1200x630 share image so X/Twitter, Facebook, iMessage, WhatsApp, WeChat, Signal, Telegram, LinkedIn, Messenger render rich previews when the URL is pasted. * web/icons/og-image.{svg,png}: lightweight, self-contained share image (no external dependency, generated PNG from local SVG). * manifest.webmanifest: updated name/description, added lang/dir/categories so the PWA install card and search engines see the new branding. * web/i18n.js: static, offline, dependency-free i18n. Auto-detects from navigator.languages/region (incl. zh-CN/SG -> Simplified, zh-HK/TW + yue -> Traditional, fil/tl -> Filipino), exposes a manual selector in the header, and persists via localStorage with a URL-hash fallback when storage is unavailable. Updates <html lang> and <html dir> (RTL for Arabic). Covers en, es, fr, de, it, pt, zh-Hans, zh-Hant, ja, ko, hi, fil, ar. IEEE vendor data stays untranslated. * app.js: status and empty-state messages now route through i18n.t() with inline English fallbacks so existing behavior is preserved when i18n has not loaded yet (and the fresh-load test continues to pass). * styles.css: language-selector chrome, RTL-friendly mirror. * sw.js: precaches i18n.js; bumped shell cache version so existing clients pick it up on next activation. * workflows: set FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true on ci.yml, pages.yml, and update-oui.yml so the official actions stop emitting the Node 20 deprecation warning. setup-node bumped to Node 22 for the test job. No action major versions changed. * tests/web/i18n_smoke.mjs: new smoke test (locale normalization, key coverage across every locale, RTL dir switch, interpolation) wired into the CI workflow. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Fix PWA smoke tests under Node 22+: defineProperty for read-only globals Root cause: Node 21+ exposes `navigator` as a getter-only accessor on globalThis (web-platform compat). Plain `globalThis.navigator = ...` throws `TypeError: Cannot set property navigator of #<Object> which has only a getter`. The CI bump in the previous commit moved setup-node from 20 to 22, which triggered this. Both smoke harnesses now stub globals via Object.defineProperty(..., { value, writable: true, configurable: true }) so the replacement works whether the property was unset (Node 20) or pre-defined as a built-in getter (Node 22+/24+). Applied uniformly to navigator, document, window, location, history, indexedDB, localStorage, URL, URLSearchParams, CustomEvent. Verified locally on both Node v20.20.1 and Node v22.11.0: - tests/web/fresh_load.mjs passes - tests/web/i18n_smoke.mjs passes - python tests 16/16 pass - node --check on app.js / sw.js / i18n.js clean Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Code <claude-code@anthropic.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent f383eb5 commit 5766a14

13 files changed

Lines changed: 1172 additions & 48 deletions

File tree

.github/workflows/ci.yml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ on:
88
permissions:
99
contents: read
1010

11+
# Opt the official JavaScript actions (checkout, setup-python, setup-node, …)
12+
# into the Node 24 runtime so we don't keep seeing the Node 20 deprecation
13+
# warning. Safe to remove once GitHub flips the default.
14+
env:
15+
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
16+
1117
jobs:
1218
test:
1319
runs-on: ubuntu-latest
@@ -18,12 +24,15 @@ jobs:
1824
python-version: "3.x"
1925
- uses: actions/setup-node@v4
2026
with:
21-
node-version: "20"
27+
node-version: "22"
2228
- name: JS syntax check
2329
run: |
2430
node --check web/app.js
2531
node --check web/sw.js
32+
node --check web/i18n.js
2633
- name: PWA fresh-load smoke test
2734
run: node tests/web/fresh_load.mjs
35+
- name: PWA i18n smoke test
36+
run: node tests/web/i18n_smoke.mjs
2837
- name: Python tests
2938
run: python3 -m unittest discover -s tests -v

.github/workflows/pages.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ permissions:
2525
pages: write
2626
id-token: write
2727

28+
# Opt the official JavaScript actions into the Node 24 runtime to silence the
29+
# Node 20 deprecation warning. Drop this env block once Node 24 is the default.
30+
env:
31+
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
32+
2833
concurrency:
2934
group: pages
3035
cancel-in-progress: false

.github/workflows/update-oui.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ permissions:
2020
contents: write
2121
pull-requests: write
2222

23+
# Opt the official JavaScript actions into the Node 24 runtime so we don't get
24+
# the Node 20 deprecation warning in scheduled runs. Drop this env block once
25+
# Node 24 becomes the default.
26+
env:
27+
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
28+
2329
jobs:
2430
update:
2531
runs-on: ubuntu-latest

tests/web/fresh_load.mjs

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,12 +94,25 @@ function makeDom() {
9494
};
9595
}
9696

97+
// Some web-platform globals (notably `navigator` in Node >= 21) are exposed
98+
// as getter-only accessors on globalThis. Plain assignment throws there. Use
99+
// Object.defineProperty so we can replace them regardless of whether the
100+
// property is unset (Node 20) or a built-in getter (Node 22+/24+).
101+
function setGlobal(name, value) {
102+
Object.defineProperty(globalThis, name, {
103+
value,
104+
writable: true,
105+
configurable: true,
106+
enumerable: false,
107+
});
108+
}
109+
97110
async function loadApp({ haveIDB = false, baseUrl } = {}) {
98111
const dom = makeDom();
99-
globalThis.document = dom.document;
100-
globalThis.window = { addEventListener() {} };
101-
globalThis.navigator = { onLine: true };
102-
globalThis.indexedDB = haveIDB ? globalThis.__realIDB : undefined;
112+
setGlobal('document', dom.document);
113+
setGlobal('window', { addEventListener() {} });
114+
setGlobal('navigator', { onLine: true });
115+
setGlobal('indexedDB', haveIDB ? globalThis.__realIDB : undefined);
103116

104117
// Redirect data/*.json fetches to the local HTTP server so abort semantics
105118
// are real (browser-style streaming). Node 18+ exposes fetch globally.

tests/web/i18n_smoke.mjs

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
/* i18n smoke test for web/i18n.js
2+
*
3+
* Confirms:
4+
* - All non-English locales define every key that English defines (no
5+
* accidental missing-key gaps that would silently fall back to English).
6+
* - normalizeLocale() maps representative real-world tags correctly:
7+
* en-GB → en, fr-CA → fr, zh-CN/zh-SG → zh-Hans,
8+
* zh-HK/zh-TW/yue-HK → zh-Hant, fil-PH/tl-PH → fil,
9+
* unknown → null
10+
* - Auto-detect picks the first supported tag from navigator.languages.
11+
*
12+
* Run: node tests/web/i18n_smoke.mjs
13+
*/
14+
import { readFileSync } from 'fs';
15+
import { fileURLToPath } from 'url';
16+
import { dirname, resolve } from 'path';
17+
18+
const __dirname = dirname(fileURLToPath(import.meta.url));
19+
const I18N_JS = resolve(__dirname, '..', '..', 'web/i18n.js');
20+
21+
function assert(cond, msg) {
22+
if (!cond) {
23+
console.error('FAIL:', msg);
24+
process.exit(1);
25+
}
26+
}
27+
28+
function makeDom() {
29+
const els = {};
30+
const make = () => ({
31+
_txt: '', _cls: '', value: '', disabled: false,
32+
innerHTML: '',
33+
setAttribute() {}, getAttribute() { return null; },
34+
appendChild() {}, addEventListener() {},
35+
querySelectorAll: () => [],
36+
});
37+
return {
38+
documentElement: { lang: 'en', dir: 'ltr' },
39+
readyState: 'complete',
40+
getElementById: () => null,
41+
addEventListener: () => {},
42+
querySelectorAll: () => [],
43+
createElement: () => make(),
44+
};
45+
}
46+
47+
// `navigator` is a getter-only accessor on globalThis in Node >= 21, so a
48+
// direct assignment throws. defineProperty works whether the global is unset
49+
// (Node 20) or a built-in getter (Node 22+/24+). We use the same helper for
50+
// every shim so behavior is uniform regardless of which globals the runtime
51+
// has already populated.
52+
function setGlobal(name, value) {
53+
Object.defineProperty(globalThis, name, {
54+
value,
55+
writable: true,
56+
configurable: true,
57+
enumerable: false,
58+
});
59+
}
60+
61+
async function loadI18n({ languages = ['en'] } = {}) {
62+
setGlobal('document', makeDom());
63+
setGlobal('window', {
64+
addEventListener() {},
65+
dispatchEvent() {},
66+
});
67+
setGlobal('location', { href: 'http://localhost/', hash: '', search: '' });
68+
setGlobal('history', { replaceState() {} });
69+
setGlobal('navigator', { languages, language: languages[0] });
70+
setGlobal('localStorage', undefined);
71+
setGlobal('URL', URL);
72+
setGlobal('URLSearchParams', URLSearchParams);
73+
setGlobal('CustomEvent', class CustomEvent {
74+
constructor(n, o) { this.name = n; this.detail = o && o.detail; }
75+
});
76+
77+
const src = readFileSync(I18N_JS, 'utf8') + `\n//# salt=${Math.random()}\n`;
78+
await import('data:text/javascript;base64,' + Buffer.from(src).toString('base64'));
79+
return globalThis.window.i18n;
80+
}
81+
82+
async function main() {
83+
const i18n = await loadI18n();
84+
assert(i18n, 'window.i18n must be exposed');
85+
86+
// ---- normalizeLocale ----
87+
const cases = [
88+
['en-GB', 'en'],
89+
['en-US', 'en'],
90+
['fr-CA', 'fr'],
91+
['fr', 'fr'],
92+
['es-419', 'es'],
93+
['pt-BR', 'pt'],
94+
['de-AT', 'de'],
95+
['it-CH', 'it'],
96+
['ja-JP', 'ja'],
97+
['ko-KR', 'ko'],
98+
['hi-IN', 'hi'],
99+
['zh', 'zh-Hans'],
100+
['zh-CN', 'zh-Hans'],
101+
['zh-SG', 'zh-Hans'],
102+
['zh-Hans', 'zh-Hans'],
103+
['zh-HK', 'zh-Hant'],
104+
['zh-TW', 'zh-Hant'],
105+
['zh-Hant', 'zh-Hant'],
106+
['yue', 'zh-Hant'],
107+
['yue-HK', 'zh-Hant'],
108+
['fil', 'fil'],
109+
['fil-PH', 'fil'],
110+
['tl', 'fil'],
111+
['tl-PH', 'fil'],
112+
['ar', 'ar'],
113+
['ar-SA', 'ar'],
114+
['xx-YY', null],
115+
['', null],
116+
];
117+
for (const [tag, want] of cases) {
118+
const got = i18n.normalizeLocale(tag);
119+
assert(got === want, `normalizeLocale(${JSON.stringify(tag)}) → ${got}, want ${want}`);
120+
}
121+
122+
// ---- key coverage ----
123+
const L = i18n._LOCALES;
124+
const enKeys = Object.keys(L.en).filter((k) => k[0] !== '_');
125+
for (const code of i18n.supported()) {
126+
if (code === 'en') continue;
127+
const missing = enKeys.filter((k) => !(k in L[code]));
128+
assert(missing.length === 0, `locale ${code} missing keys: ${missing.join(', ')}`);
129+
assert(L[code]._name, `locale ${code} missing _name`);
130+
assert(L[code]._dir === 'ltr' || L[code]._dir === 'rtl',
131+
`locale ${code} bad _dir: ${L[code]._dir}`);
132+
}
133+
134+
// ---- supported list contains the requested set ----
135+
const want = ['en', 'es', 'fr', 'de', 'it', 'pt',
136+
'zh-Hans', 'zh-Hant', 'ja', 'ko', 'hi', 'fil', 'ar'];
137+
for (const code of want) {
138+
assert(i18n.supported().indexOf(code) >= 0, `expected locale ${code} to be supported`);
139+
}
140+
141+
// ---- t() interpolates positional args ----
142+
i18n.setLocale('en');
143+
const s = i18n.t('refreshed', 1234);
144+
assert(s.includes('1234'), `expected interpolation, got: ${s}`);
145+
146+
// ---- t() falls back to English on missing key in non-English locale ----
147+
i18n.setLocale('fr');
148+
const fr = i18n.t('hero_title');
149+
assert(fr && fr !== 'hero_title' && fr !== 'Offline MAC Address Lookup',
150+
`expected French hero_title, got: ${fr}`);
151+
152+
// ---- direction switches on Arabic ----
153+
i18n.setLocale('ar');
154+
assert(document.documentElement.dir === 'rtl', 'expected dir=rtl for ar');
155+
assert(document.documentElement.lang === 'ar', 'expected lang=ar for ar');
156+
i18n.setLocale('en');
157+
assert(document.documentElement.dir === 'ltr', 'expected dir=ltr for en');
158+
159+
console.log('OK — i18n smoke test passes');
160+
}
161+
162+
main().catch((e) => { console.error('test threw:', e); process.exit(1); });

0 commit comments

Comments
 (0)