Skip to content

feat(viewer): add VIEWER_LANGUAGE env + EN/DE locales#541

Open
ChristianWalterMedia wants to merge 13 commits into
rohitg00:mainfrom
ChristianWalterMedia:feat/viewer-i18n
Open

feat(viewer): add VIEWER_LANGUAGE env + EN/DE locales#541
ChristianWalterMedia wants to merge 13 commits into
rohitg00:mainfrom
ChristianWalterMedia:feat/viewer-i18n

Conversation

@ChristianWalterMedia
Copy link
Copy Markdown

@ChristianWalterMedia ChristianWalterMedia commented May 19, 2026

Summary

Adds an .env-driven viewer UI translation mechanism with English and German shipped. Designed so additional languages arrive as drop-in JSON files via community PRs — no code changes required.

Closes #483.

How it works

  • src/viewer/locales/{en,de}.json hold the UI strings (243 keys each, structurally identical).
  • src/viewer/locales.ts resolves VIEWER_LANGUAGE (defaults en, normalizes de-DE / de_DE / DE / whitespace → canonical form).
  • src/viewer/document.ts injects the locale bundle into the rendered viewer HTML via the same nonced-script-placeholder pattern already used for __AGENTMEMORY_VIEWER_NONCE__ and __AGENTMEMORY_VERSION__. No new <script> tag, no external fetch, the existing CSP nonce model is preserved.
  • A ~30-line IIFE in src/viewer/index.html exposes t(key, vars) with EN-per-key fallback and {placeholder} interpolation, plus a DOMContentLoaded pass that replaces [data-i18n] text content and [data-i18n-attr="attr:key"] attributes.
  • The original English text stays in the markup as a no-JS fallback.
  • Dynamic-render strings inside the viewer's inline script are routed through t() instead of embedded English literals.

What translates

UI chrome (tabs, dashboard cards, table headers, empty states, buttons, search placeholders, filter labels, modal text) plus display-only mapping for backend enums (type, graph node types, status labels). API values, search, filters, and persistence keep operating on the raw enum strings — CSS class names like .type-architecture stay raw.

Adding a new language

  1. Copy src/viewer/locales/en.json to <lang>.json.
  2. Translate values, keep keys/nesting/{placeholder} markers identical.
  3. Open PR — the structural-parity test verifies top-level key parity.

Documented in CONTRIBUTING.md.

Test plan

  • npm test — 1053 tests pass (1034 baseline + 19 new i18n tests covering env normalization, locale loading, bundle building, document injection, CSP-safe < escape, t() semantics, and structural parity)
  • Manual: VIEWER_LANGUAGE=en agentmemory && open localhost:3113 — viewer is visually identical to main
  • Manual: VIEWER_LANGUAGE=de agentmemory && open localhost:3113 — UI renders in German
  • CSP nonce model verified: locale JSON injection lives inside the existing nonced <script> (new test in viewer-security.test.ts)
  • DCO sign-off on every commit

Commits

Ten focused commits:

  1. feat(viewer): extract UI strings into en.json locale source
  2. feat(viewer): introduce locale loader and VIEWER_LANGUAGE resolver
  3. feat(viewer): inject locale bundle into rendered document
  4. feat(viewer): add t() helper and data-i18n DOM pass
  5. feat(viewer): tag static markup with data-i18n attributes
  6. feat(viewer): route dynamic-render strings through t()
  7. feat(viewer): add German (de) translation
  8. build(viewer): ship locale files in the published package
  9. docs: document VIEWER_LANGUAGE and translation contributions
  10. feat(viewer): cover remaining hardcoded English strings

Out of scope

UI language picker (would conflict with .env-driven config pattern), per-user localStorage, translation of user-generated memory content, plural rules (CLDR), RTL languages, date/number localization (existing toLocaleString() calls already respect browser locale).

Summary by CodeRabbit

  • New Features

    • Viewer UI supports selectable languages (built-in: English, German) via VIEWER_LANGUAGE; UI strings localized across the viewer; bundled locales are shipped with builds.
  • Documentation

    • README and contributing docs updated with viewer language setup, fallback behavior, restart requirement, and guidance for adding translations.
  • Tests

    • Expanded i18n and security tests covering locale loading, normalization, fallbacks, parity, injection escaping, and nonce-backed script placement.

Review Change Stack

Inventory of visible English strings in the viewer (~237 keys across
nav, dashboard, memories, sessions, timeline, lessons, actions,
crystals, audit, activity, profile, replay, graph, status, types,
table, buttons, loading, modal). Source of truth for upcoming i18n
work; no runtime behavior change yet.

Signed-off-by: Christian Walter <chris.walter@mail.de>
Adds src/viewer/locales.ts with:
- resolveViewerLanguage() — reads VIEWER_LANGUAGE env, normalizes
  de-DE/de_DE → de, lowercases, defaults to en
- loadLocale(lang) — reads src/viewer/locales/<lang>.json with
  process-level cache, returns {} when missing (no throw)
- buildLocaleBundle(lang) — returns { lang, messages, fallback } with
  en fallback for non-en languages, empty fallback for en itself

Tests cover env normalization, missing-file handling, and bundle shape.
No wiring into the rendered viewer yet — that comes in the next commit.

Signed-off-by: Christian Walter <chris.walter@mail.de>
Adds VIEWER_LOCALE_PLACEHOLDER and wires buildLocaleBundle() through
renderViewerDocument(). The bundle JSON is injected as
window.__AM_LOCALE__ inside the existing nonced <script> tag — no new
script element, no external fetch, the CSP nonce model is unchanged.

< is escaped to < in the payload so a translation containing
'</script' cannot break out of the inline script.

The viewer template gets a single seeding line that holds the
placeholder; the runtime t() helper arrives in the next commit.

Signed-off-by: Christian Walter <chris.walter@mail.de>
Adds a 30-line IIFE in the viewer's inline script:
- t(key, vars) — dot-path lookup against window.__AM_LOCALE__.messages
  with en fallback per key and {placeholder} interpolation
- applyI18n(root) — replaces textContent on [data-i18n] elements and
  attributes on [data-i18n-attr="attr:key,attr:key"] elements
- DOMContentLoaded hook runs applyI18n(document) once and sets the
  <html lang> attribute

Markup tagging and dynamic-string conversion come in the next two
commits.

Signed-off-by: Christian Walter <chris.walter@mail.de>
Tags visible static elements with data-i18n="<namespace>.<key>" matching
the keys in src/viewer/locales/en.json. The original English text stays
in place as a no-JS fallback; the DOMContentLoaded pass added in the
previous commit overwrites textContent (and selected attributes via
data-i18n-attr) with the resolved locale string.

Dynamic strings built inside the inline <script> render functions are
not touched here — those go through t() calls in the next commit.

No visible change in the en case: t("nav.dashboard") returns
"Dashboard", identical to the literal already in markup.

Signed-off-by: Christian Walter <chris.walter@mail.de>
Converts template-literal strings inside the viewer's inline script
(renderDashboard, renderMemories, renderSessions, renderTimeline,
renderLessons, renderActions, renderCrystals, renderAudit,
renderActivity, renderProfile, renderReplay, empty-state generators,
table headers, type badges, status labels, loading placeholders) to call
t(key) instead of embedding English literals.

API enum values (m.type, node.type, s.status, etc.) are untouched —
only their display form is mapped via t(). Filters, search, and
persistence keep operating on the raw enum strings.

Adds var t = window.t at module scope so that render functions can call
t() as a plain identifier in VM sandbox contexts (required by the
viewer-session-id unit tests).

Signed-off-by: Christian Walter <chris.walter@mail.de>
Mirrors the structure of src/viewer/locales/en.json with German values
for ~237 UI strings: nav, dashboard cards, gauges, first-run hero,
memories empty state, sessions table, status labels (AKTIV/
ABGESCHLOSSEN/…), type and graph-node-type display names, replay
detail labels, modal text, and per-tab content for timeline, lessons,
actions, crystals, audit, activity, profile, replay, graph.

Adds a structural-parity test that fails if a future en.json change
adds a top-level key without a corresponding de.json entry.

Signed-off-by: Christian Walter <chris.walter@mail.de>
Extends the build script so src/viewer/locales/*.json is copied to
dist/viewer/locales/ during build. Without this, an npm-installed
agentmemory would not find any locale files at runtime and would
silently fall back to the empty-locale path.

Signed-off-by: Christian Walter <chris.walter@mail.de>
- README gains a 'Viewer language' subsection showing the env switch
  and pointing contributors at CONTRIBUTING.md.
- .env.example documents VIEWER_LANGUAGE next to the other UI flags.
- CONTRIBUTING.md gets a 'Contributing a translation' how-to.
- CHANGELOG.md records the change under Unreleased.

Signed-off-by: Christian Walter <chris.walter@mail.de>
Final i18n coverage pass. Adds six new keys covering compound or
suffix strings that the per-render conversion missed:
- dashboard.edges_count — '{n} edges' suffix on Graph Nodes card
- dashboard.token_savings_sub — Token Savings compound sub-label
- timeline.no_obs_filter_suffix — empty-state filter branch
- timeline.no_obs_session_suffix — empty-state session branch
- actions.three_ways_intro — Actions empty-state intro line
- audit.empty_body — Audit empty-state second paragraph

en.json / de.json both updated; structural parity test still green.

Signed-off-by: Christian Walter <chris.walter@mail.de>
@vercel
Copy link
Copy Markdown

vercel Bot commented May 19, 2026

@ChristianWalterMedia is attempting to deploy a commit to the rohitg00's projects Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 19, 2026

📝 Walkthrough

Walkthrough

This PR adds viewer i18n: server builds/injects a locale bundle (from VIEWER_LANGUAGE), client-side t()/applyI18n runtime applies translations, ships en and de locale files, updates build to include locale JSON, extends tests for safety and parity, and documents contribution/configuration.

Changes

Viewer Internationalization

Layer / File(s) Summary
Documentation and examples
.env.example, CHANGELOG.md, CONTRIBUTING.md, README.md
Documents VIEWER_LANGUAGE, built-in locales (en, de), .env example, contributor steps to add translations, and changelog entry.
Build configuration for locale assets
package.json
Updates build script to create dist/viewer/locales/ and copy src/viewer/locales/*.json into the built distribution.
Locale system infrastructure
src/auth.ts, src/viewer/locales.ts
Adds VIEWER_LOCALE_PLACEHOLDER and a locales.ts module to resolve env language, probe and load locale JSON files with validation and caching, and build a LocaleBundle with English fallback.
Locale data files
src/viewer/locales/en.json, src/viewer/locales/de.json
Provides complete English and German JSON locale resources used by the viewer UI, including placeholder-marked strings.
Server-side locale bundle injection
src/viewer/document.ts
Imports locale utilities, resolves the viewer language, builds a locale bundle, JSON-escapes <, and injects the bundle via VIEWER_LOCALE_PLACEHOLDER during HTML rendering.
Client i18n runtime bootstrap
src/viewer/index.html
Adds client runtime that binds injected bundle to window.__AM_LOCALE__, provides t()/tRaw()/te() helpers, applyI18n() for data-i18n/data-i18n-attr with a safe-attribute allowlist, and sets document.documentElement.lang.
UI text localization
src/viewer/index.html
Replaces hardcoded UI strings with t(...)/te(...) keys across header, tabs, theme toggle, dashboard, graph, memories, timeline, sessions, lessons, actions, crystals, audit, profile, replay, and status texts.
i18n and security tests
test/viewer-i18n.test.ts, test/viewer-security.test.ts
Adds tests for language resolution, loadLocale hardening (reject path traversal/invalid values), bundle building and fallback, injection escaping and nonce-backed placement, structural parity between en and de, and t() helper semantics.

Sequence Diagram

sequenceDiagram
  participant Server
  participant Filesystem
  participant Browser

  Server->>Filesystem: read src/viewer/locales/<lang>.json
  Filesystem-->>Server: locale JSON
  Server->>Server: buildLocaleBundle(lang) (messages + fallback)
  Server->>Browser: renderViewerDocument() injects window.__AM_LOCALE__ in nonce script
  Browser->>Browser: on DOMContentLoaded run window.applyI18n()
  Browser->>Browser: t()/tRaw() resolve keys and update DOM elements
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • rohitg00/agentmemory#108: Both PRs update the viewer's nonce-backed script injection; this PR injects the locale bundle while #108 established nonce/CSP groundwork.

🐰 A Rabbit's Translation Song

"I hop through keys and JSON trees,
I copy 'en' with gentle ease.
I tuck the bundle in the page,
So viewers speak from any stage.
Hop, translate, patch — I cheers with cheese!"

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main changes: adding a VIEWER_LANGUAGE environment variable and English/German locale support to the viewer UI.
Linked Issues check ✅ Passed The PR fully addresses issue #483's requirements: implements i18n support via environment variable, provides JSON locale files for community contributions, establishes a simple PR-based mechanism for adding new languages, and ships English/German locales.
Out of Scope Changes check ✅ Passed All changes are narrowly scoped to locale management and viewer UI translation. Out-of-scope items (UI picker, per-user storage, RTL, plural rules, date formatting) are explicitly deferred.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch feat/viewer-i18n

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (3)
src/viewer/locales.ts (1)

10-25: 💤 Low value

Consider caching the locales directory path at module level.

The localesDir() function is called on every loadLocale cache miss, but its result is stable for the process lifetime. Caching the result at module scope would avoid redundant filesystem probes.

♻️ Proposed optimization
+let cachedLocalesDir: string | null = null;
+
 function localesDir(): string {
+  if (cachedLocalesDir !== null) return cachedLocalesDir;
   const base = dirname(fileURLToPath(import.meta.url));
   for (const candidate of [
     join(base, "..", "src", "viewer", "locales"),
     join(base, "..", "viewer", "locales"),
     join(base, "viewer", "locales"),
   ]) {
     try {
       readdirSync(candidate);
+      cachedLocalesDir = candidate;
       return candidate;
     } catch {
       // candidate path does not exist; try next
     }
   }
+  cachedLocalesDir = "";
   return "";
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/viewer/locales.ts` around lines 10 - 25, Cache the resolved locales
directory at module scope instead of computing it on every call: compute the
directory once using the existing logic in localesDir (which probes join(...)
candidates and calls readdirSync) and store the result in a module-level
constant (e.g., LOCALES_DIR or resolvedLocalesDir), then have localesDir() or
loadLocale() read from that cached constant so subsequent cache misses avoid
repeated filesystem probes; ensure the initial computation preserves the
existing fallback behavior (returning empty string if none found).
test/viewer-i18n.test.ts (1)

110-116: ⚡ Quick win

Consider extending structural parity to nested keys.

The current test validates only top-level key parity between en and de locale files. While this matches the PR's stated "top-level key parity" scope, nested structure mismatches (e.g., en has nav.dashboard.title but de has nav.dashboard.header) would cause silent fallback-to-English at runtime. Extending the test to compare all leaf key paths would catch translation gaps more comprehensively.

🔍 Example nested key parity check
 describe("viewer i18n: structural parity en ↔ de", () => {
   it("de.json has every top-level key that en.json has", () => {
     const en = loadLocale("en");
     const de = loadLocale("de");
     for (const key of Object.keys(en)) {
       expect(de[key], `missing top-level key '${key}' in de.json`).toBeDefined();
     }
   });
+
+  it("de.json has every nested key path that en.json has", () => {
+    function getLeafPaths(obj: unknown, prefix = ""): string[] {
+      if (typeof obj !== "object" || obj === null) return [];
+      const paths: string[] = [];
+      for (const [key, val] of Object.entries(obj)) {
+        const path = prefix ? `${prefix}.${key}` : key;
+        if (typeof val === "string") {
+          paths.push(path);
+        } else if (typeof val === "object" && val !== null) {
+          paths.push(...getLeafPaths(val, path));
+        }
+      }
+      return paths;
+    }
+    const en = loadLocale("en");
+    const de = loadLocale("de");
+    const enPaths = getLeafPaths(en);
+    const dePaths = new Set(getLeafPaths(de));
+    for (const path of enPaths) {
+      expect(dePaths.has(path), `missing key path '${path}' in de.json`).toBe(true);
+    }
+  });
 });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/viewer-i18n.test.ts` around lines 110 - 116, The test only compares
top-level keys; extend it to collect and compare all leaf key paths so nested
mismatches are caught. Add a helper (e.g., getLeafPaths(obj): string[]) that
recursively builds dot-delimited paths for every leaf in the loaded locale
objects, then in the test call loadLocale("en") and loadLocale("de"), compute
getLeafPaths(en) and getLeafPaths(de), and assert for each path from en that
de[path] is defined (use the same expect message). Update the existing test
block that declares en and de and the for-loop to use these leaf paths instead
of Object.keys.
src/viewer/index.html (1)

1025-1033: 💤 Low value

Document safety constraints for data-i18n-attr to prevent future misuse.

The data-i18n-attr mechanism sets arbitrary attributes from translated strings without validation. While not currently exploited, using it for href, src, or event-handler attributes would create XSS vectors.

Safe:

<input data-i18n-attr="placeholder:actions.search_placeholder">

Unsafe (do not use):

<a data-i18n-attr="href:footer.docs_link">  <!-- href could become javascript: -->
<img data-i18n-attr="src:branding.logo_url">  <!-- src could load attacker-controlled image -->

Add an inline comment documenting the constraint, or implement an attribute allowlist:

  root.querySelectorAll("[data-i18n-attr]").forEach(function (el) {
+   // SAFETY: only use for non-executable attributes (placeholder, title, aria-label).
+   // Never use for href, src, or on* event handlers.
    el.getAttribute("data-i18n-attr").split(",").forEach(function (pair) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/viewer/index.html` around lines 1025 - 1033, The code currently sets
arbitrary attributes from data-i18n-attr via root.querySelectorAll(...) and
el.setAttribute(attr, window.t(key)), which can create XSS if attrs like href,
src or event handlers are used; add either an inline comment above this block
documenting that data-i18n-attr must only be used for safe attributes (e.g.,
placeholder, title, alt, aria-*), or implement an explicit allowlist check
inside the loop that only permits known-safe attribute names (e.g.,
"placeholder","title","alt","aria-label","aria-labelledby") and skips/logs any
others before calling el.setAttribute; ensure you reference the same symbols
(data-i18n-attr, window.t, el.setAttribute, root.querySelectorAll) when changing
logic or adding the comment.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.env.example:
- Line 107: Update the VIEWER_LANGUAGE comment to avoid suggesting editing a
src/ tree for packaged/npm installs: locate the commented env variable
"VIEWER_LANGUAGE" and change the sentence that currently reads “Drop
src/viewer/locales/<lang>.json to add more” to either instruct contributors to
"contribute a locale via PR" or explicitly state that the src/... path applies
only to source checkouts (and packaged installs must submit PRs or use other
extension mechanisms); keep the note about built-in fallbacks (en, de) and
fallback behavior intact.

In `@README.md`:
- Around line 919-929: Update the "Viewer language" section so it doesn’t imply
users can add locale files in packaged installs; change the sentence that
references src/viewer/locales/en.json to clearly state this is for source
checkouts or for contributing via a PR (e.g., “for source checkouts or to
contribute a translation via PR, copy src/viewer/locales/en.json, translate, and
open a PR”), and mirror the same clarification at the other occurrence in the
README; edit the "Viewer language" heading content and the duplicate line near
the later section to explicitly call out “source checkouts / contribution”
instead of suggesting runtime modification in packaged installs.

In `@src/viewer/index.html`:
- Around line 1013-1019: window.t currently returns raw translation strings from
getPath(bundle.messages, key) and getPath(bundle.fallback, key) which enables
XSS; change it to HTML-escape the translation text before interpolation (i.e.,
call esc(...) on the resolved v and fb) and then pass the escaped string into
interpolate so variables can still be inserted safely; if esc() is defined
later, hoist it above window.t or inline equivalent escaping logic to ensure
translations are sanitized before being returned by window.t.

In `@src/viewer/locales.ts`:
- Around line 27-43: The loadLocale(lang: string) function accepts an
unvalidated lang and builds a file path (via localesDir() and join) which can
enable path traversal; update loadLocale to validate and normalize lang before
using it: reject or sanitize any input containing path separators or traversal
sequences (e.g., '/', '\\', '..') and enforce a strict language-tag pattern (for
example allow only ASCII letters, digits, hyphen and underscore, or a regex such
as simple BCP47-like subset) or check that lang exists in the localesDir listing
before reading; ensure you still use cache.set(lang, {}) for rejected/unknown
values and keep failures in the same try/catch flow, referring to loadLocale,
localesDir, and cache in your changes.

---

Nitpick comments:
In `@src/viewer/index.html`:
- Around line 1025-1033: The code currently sets arbitrary attributes from
data-i18n-attr via root.querySelectorAll(...) and el.setAttribute(attr,
window.t(key)), which can create XSS if attrs like href, src or event handlers
are used; add either an inline comment above this block documenting that
data-i18n-attr must only be used for safe attributes (e.g., placeholder, title,
alt, aria-*), or implement an explicit allowlist check inside the loop that only
permits known-safe attribute names (e.g.,
"placeholder","title","alt","aria-label","aria-labelledby") and skips/logs any
others before calling el.setAttribute; ensure you reference the same symbols
(data-i18n-attr, window.t, el.setAttribute, root.querySelectorAll) when changing
logic or adding the comment.

In `@src/viewer/locales.ts`:
- Around line 10-25: Cache the resolved locales directory at module scope
instead of computing it on every call: compute the directory once using the
existing logic in localesDir (which probes join(...) candidates and calls
readdirSync) and store the result in a module-level constant (e.g., LOCALES_DIR
or resolvedLocalesDir), then have localesDir() or loadLocale() read from that
cached constant so subsequent cache misses avoid repeated filesystem probes;
ensure the initial computation preserves the existing fallback behavior
(returning empty string if none found).

In `@test/viewer-i18n.test.ts`:
- Around line 110-116: The test only compares top-level keys; extend it to
collect and compare all leaf key paths so nested mismatches are caught. Add a
helper (e.g., getLeafPaths(obj): string[]) that recursively builds dot-delimited
paths for every leaf in the loaded locale objects, then in the test call
loadLocale("en") and loadLocale("de"), compute getLeafPaths(en) and
getLeafPaths(de), and assert for each path from en that de[path] is defined (use
the same expect message). Update the existing test block that declares en and de
and the for-loop to use these leaf paths instead of Object.keys.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 374bff84-1614-4628-a9d0-eae80987f0d1

📥 Commits

Reviewing files that changed from the base of the PR and between c2f231f and b9d0b4c.

📒 Files selected for processing (13)
  • .env.example
  • CHANGELOG.md
  • CONTRIBUTING.md
  • README.md
  • package.json
  • src/auth.ts
  • src/viewer/document.ts
  • src/viewer/index.html
  • src/viewer/locales.ts
  • src/viewer/locales/de.json
  • src/viewer/locales/en.json
  • test/viewer-i18n.test.ts
  • test/viewer-security.test.ts

Comment thread .env.example Outdated
Comment thread README.md
Comment thread src/viewer/index.html
Comment thread src/viewer/locales.ts
Addresses CodeRabbit review on rohitg00#541.

t() now HTML-escapes the resolved translation string before
interpolation. Translations arrive via community PRs; escaping at the
boundary stops a malicious translation from injecting markup at the
~200 sites that concatenate t() output into innerHTML. Interpolation
vars stay raw so the hardcoded HTML fragments used in memories.title_intro
(e.g. <code>memory_remember</code>) still render.

A new tRaw() returns the unescaped value, for the small number of
callers that assign to textContent or setAttribute on safe attributes
— textContent treats entities literally, so escape would otherwise
show "&amp;" as four characters in the DOM.

data-i18n-attr now enforces a SAFE_I18N_ATTRS allowlist
(placeholder, title, alt, aria-label/labelledby/describedby/
roledescription). Any other attribute name is silently skipped, so a
contributor cannot turn the mechanism into an href:/src:/onclick:
injection vector by adding a data-i18n-attr to an element.

loadLocale() validates lang against /^[a-z]{2,3}$/i before touching
the filesystem. resolveViewerLanguage() already normalizes inputs
down to the primary subtag, so callers in this codebase are unchanged;
this is a defence-in-depth guard for the exported boundary.

Tests:
- new: rejects path-traversal sequences (../etc/passwd, ..\\windows, en/../en)
- new: rejects non-language inputs ("", "123", "en-US", single letter, 4+ letters)
- new: verifies the IIFE in index.html ships escI18n() wired into t()
- new: verifies SAFE_I18N_ATTRS allowlist exists and excludes href/src/on*
- new: structural parity now covers every nested leaf path, not just top level
- new: placeholder-marker parity between en.json and de.json

Signed-off-by: Christian Walter <chris.walter@mail.de>
Addresses CodeRabbit review on rohitg00#541.

Both README.md (Viewer language section + the env table at the bottom)
and .env.example referenced 'Drop src/viewer/locales/<lang>.json',
which is misleading for npm/global installs that ship dist/ without a
src/ tree. Rewords to explicitly call out the PR-against-repo path
(source checkout required) so users do not assume a runtime drop-in
mechanism exists.

Signed-off-by: Christian Walter <chris.walter@mail.de>
@ChristianWalterMedia
Copy link
Copy Markdown
Author

Thanks for the review! Pushed two new commits addressing the feedback:

46d59aa — security hardening

  • 🟠 XSS via translations (src/viewer/index.html:1019): t() now HTML-escapes the resolved translation string before interpolation. Interpolation vars stay raw so the hardcoded <code>…</code> fragments in memories.title_intro still render. Added a tRaw() companion for the ~5 callers that assign to textContent (where entity escaping would otherwise show &amp; literally).
  • 🟡 Path traversal (src/viewer/locales.ts:43): loadLocale() now validates lang against /^[a-z]{2,3}$/i before touching the filesystem. resolveViewerLanguage() already normalizes to the primary subtag, so callers are unchanged — this is defence-in-depth for the exported boundary.
  • 💤 data-i18n-attr allowlist (src/viewer/index.html:1025): added explicit SAFE_I18N_ATTRS set (placeholder, title, alt, aria-label/labelledby/describedby/roledescription). Any other attribute name is silently skipped, so href:/src:/onclick: cannot be turned into injection vectors via markup.
  • 💤 localesDir() module cache: cached the resolved path at module scope; no more repeated filesystem probes on cache miss.
  • 6 new tests covering all the above + nested-leaf-path parity + {placeholder} marker parity between en.json and de.json. Total: 1059/1059.

001ed98 — docs wording

  • 🟡 README + .env.example: both occurrences of "Drop src/viewer/locales/<lang>.json to add more" were misleading for npm-installed users (no src/ tree at runtime). Reworded to explicitly say new locales ship via PR against the repo from a source checkout.

All commits signed off; no force-push, history is b9d0b4c46d59aa001ed98.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/viewer/index.html (1)

944-944: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Localize the runtime WS states too.

This initial label is translated, but startPolling() and the reconnect path still set hardcoded English strings (polling · … and connecting...). After the first socket transition the badge flips back to English.

Suggested direction
-      setWsStatus('polling · ' + (POLL_INTERVAL_MS / 1000) + 's', 'disconnected');
+      setWsStatus(t('status.polling', { seconds: POLL_INTERVAL_MS / 1000 }), 'disconnected');

-            setWsStatus('connecting...', 'disconnected');
+            setWsStatus(t('status.connecting'), 'disconnected');

You'd also need matching keys in the locale bundles.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/viewer/index.html` at line 944, The runtime WebSocket status strings are
being set in code (e.g., in startPolling() and the reconnect logic) to hardcoded
English, causing the badge (element id "ws-status") to revert to English after
transitions; update those places to use the i18n/localization lookup (the same
mechanism used for the initial data-i18n attribute) instead of literal strings,
reference the "ws-status" element by id when updating textContent/class, and add
matching keys for the new states (e.g., polling, connecting, reconnecting) to
the locale bundles so translations are available at runtime.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/viewer/index.html`:
- Around line 1048-1050: SAFE_I18N_ATTRS currently includes IDREF ARIA
attributes that must reference element IDs; remove "aria-labelledby" and
"aria-describedby" from the SAFE_I18N_ATTRS Set so i18n text is not inserted
into IDREF attributes (keep "aria-label" and "aria-roledescription"); update
both occurrences (the Set definitions around SAFE_I18N_ATTRS) and ensure any
logic that reads data-i18n-attr still allows the other attributes unchanged.
- Line 1115: The sandboxed VM requires plain identifier aliases for global
functions; add an alias for tRaw the same way t is aliased so bare tRaw() calls
don't throw ReferenceError. Update the initialization that creates var t =
window.t; to also declare var tRaw = window.tRaw; (or replace bare tRaw() usages
with window.tRaw() consistently) so references in the code (e.g., the button
text update and the other occurrences that call tRaw) resolve correctly in the
VM.

In `@src/viewer/locales.ts`:
- Around line 35-40: In loadLocale, normalize the incoming lang (e.g., lang =
lang.trim().toLowerCase()) before running VALID_LANG, using it as the cache key
and when constructing the locale filename so lookups use a canonical form;
update all references to the original lang in this function
(VALID_LANG.test(lang), cache.get/set(lang), and the file lookup logic) to use
the normalized variable so loadLocale("EN") will match en.json and the cache
consistently.

In `@test/viewer-i18n.test.ts`:
- Around line 193-195: The test currently skips comparisons when deVal is falsy,
so empty-string translations are ignored; change the check that skips missing
German values from a truthiness test to an explicit undefined/null check (e.g.,
only continue when deVal is undefined or null) so that empty strings are tested
for placeholder parity with enMarkers; update the conditional around deVal (used
with path and enMarkers in viewer-i18n.test.ts) to only skip genuinely missing
entries rather than empty-string values.

---

Outside diff comments:
In `@src/viewer/index.html`:
- Line 944: The runtime WebSocket status strings are being set in code (e.g., in
startPolling() and the reconnect logic) to hardcoded English, causing the badge
(element id "ws-status") to revert to English after transitions; update those
places to use the i18n/localization lookup (the same mechanism used for the
initial data-i18n attribute) instead of literal strings, reference the
"ws-status" element by id when updating textContent/class, and add matching keys
for the new states (e.g., polling, connecting, reconnecting) to the locale
bundles so translations are available at runtime.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6117a243-ee98-4ed2-804b-3b4697ced024

📥 Commits

Reviewing files that changed from the base of the PR and between b9d0b4c and 001ed98.

📒 Files selected for processing (5)
  • .env.example
  • README.md
  • src/viewer/index.html
  • src/viewer/locales.ts
  • test/viewer-i18n.test.ts
✅ Files skipped from review due to trivial changes (2)
  • .env.example
  • README.md

Comment thread src/viewer/index.html Outdated
Comment thread src/viewer/index.html
Comment thread src/viewer/locales.ts Outdated
Comment thread test/viewer-i18n.test.ts
Four follow-up findings from the review on rohitg00#541.

1. SAFE_I18N_ATTRS no longer includes IDREF ARIA attributes.
   aria-labelledby and aria-describedby must reference element IDs,
   not free text — the first caller using data-i18n-attr on either
   would have silently broken the accessible name/description wiring.
   Removed both; kept aria-label and aria-roledescription.

2. Aliased tRaw beside t for the VM sandbox.
   The viewer unit tests run render functions inside a VM where
   window !== globalThis. There was already a 'var t = window.t;'
   module-scope alias for the same reason; the new tRaw() calls
   added in the previous commit would have thrown ReferenceError
   in that environment. Added 'var tRaw = window.tRaw;'.

3. loadLocale() now normalizes lang before validate/cache/file.
   VALID_LANG was case-insensitive, so loadLocale("EN") passed
   validation but then tried to read EN.json (which does not exist)
   and cached the failure under the uppercase key. Now lowercases
   and trims before everything, so loadLocale("EN"), "  en  ",
   and "En" all resolve to the same en.json bundle and the same
   cache slot. VALID_LANG simplified to /^[a-z]{2,3}$/ (no /i).

4. Placeholder-parity test no longer hides empty-string regressions.
   Previously 'if (!deVal) continue;' skipped both missing keys and
   intentional empty-string translations. Switched to a typeof check
   so empty strings are still validated for placeholder parity.

Signed-off-by: Christian Walter <chris.walter@mail.de>
@ChristianWalterMedia
Copy link
Copy Markdown
Author

Second-pass feedback addressed in bc7b683:

  • 🟠 IDREF ARIA attrs removed from SAFE_I18N_ATTRS (src/viewer/index.html:1048): dropped aria-labelledby and aria-describedby — those must reference element IDs, not free text. Kept aria-label and aria-roledescription.
  • 🟠 tRaw alias for VM sandbox (src/viewer/index.html:1115): added var tRaw = window.tRaw; beside the existing var t = window.t; — the bare tRaw() calls added in the prior fix would have thrown ReferenceError inside the test VM. Good catch.
  • 🟡 loadLocale normalization (src/viewer/locales.ts:40): now lang.trim().toLowerCase() before validate/cache/file lookup. loadLocale("EN") and loadLocale(" en ") now correctly resolve to en.json and share a single cache slot. Simplified VALID_LANG to /^[a-z]{2,3}$/ (no longer case-insensitive since input is normalized).
  • 🟡 Empty-string parity check (test/viewer-i18n.test.ts:195): swapped if (!deVal) for typeof deVal !== "string" so empty-string translations are still tested for placeholder parity instead of silently skipped.

Tests: 1060 / 1060 (+1 new test for loadLocale mixed-case normalization).

Branch: b9d0b4c46d59aa001ed98bc7b683.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/viewer/locales.ts`:
- Around line 37-57: buildLocaleBundle currently returns and compares the raw
lang string while loadLocale canonicalizes it; update buildLocaleBundle to
normalize the incoming lang the same way (trim and toLowerCase and validate with
VALID_LANG) before any comparisons or cache lookups so that messages, fallback,
and the returned lang use the canonical form; ensure when invalid or missing
locale you still set/return the canonical empty cache entry and return lang as
the normalized value (refer to buildLocaleBundle, loadLocale, VALID_LANG,
messages, fallback, and lang).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 11615e5a-ad07-47e5-b161-cbdd3a484fc9

📥 Commits

Reviewing files that changed from the base of the PR and between 001ed98 and bc7b683.

📒 Files selected for processing (3)
  • src/viewer/index.html
  • src/viewer/locales.ts
  • test/viewer-i18n.test.ts

Comment thread src/viewer/locales.ts
Comment on lines +37 to +57
// Normalize before validate/cache/file so loadLocale("EN") and loadLocale(" en ")
// both resolve to en.json with a single cache entry.
const normalized = lang.trim().toLowerCase();
if (!VALID_LANG.test(normalized)) {
cache.set(normalized, {});
return {};
}
if (cache.has(normalized)) return cache.get(normalized)!;
const dir = localesDir();
if (!dir) {
cache.set(normalized, {});
return {};
}
try {
const text = readFileSync(join(dir, `${normalized}.json`), "utf-8");
const data = JSON.parse(text) as Locale;
cache.set(normalized, data);
return data;
} catch {
cache.set(normalized, {});
return {};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Normalize buildLocaleBundle() on the same canonical form.

loadLocale() now canonicalizes casing/whitespace, but buildLocaleBundle() still compares and returns the raw lang. buildLocaleBundle("EN") produces messages === en, fallback === en, and lang === "EN", so callers that bypass resolveViewerLanguage() ship a duplicated English bundle and set a non-canonical document language.

Suggested fix
 export function buildLocaleBundle(lang: string): LocaleBundle {
-  const messages = loadLocale(lang);
-  const fallback = lang === FALLBACK_LANG ? {} : loadLocale(FALLBACK_LANG);
-  return { lang, messages, fallback };
+  const normalized = lang.trim().toLowerCase();
+  const messages = loadLocale(normalized);
+  const fallback = normalized === FALLBACK_LANG ? {} : loadLocale(FALLBACK_LANG);
+  return { lang: normalized, messages, fallback };
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/viewer/locales.ts` around lines 37 - 57, buildLocaleBundle currently
returns and compares the raw lang string while loadLocale canonicalizes it;
update buildLocaleBundle to normalize the incoming lang the same way (trim and
toLowerCase and validate with VALID_LANG) before any comparisons or cache
lookups so that messages, fallback, and the returned lang use the canonical
form; ensure when invalid or missing locale you still set/return the canonical
empty cache entry and return lang as the normalized value (refer to
buildLocaleBundle, loadLocale, VALID_LANG, messages, fallback, and lang).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature request: i18n / Chinese localization for viewer UI

1 participant