feat: Update legal text for user consent and data processing across multiple locales, ensuring clarity and compliance with regulations
This commit is contained in:
282
docs/i18n.md
Normal file
282
docs/i18n.md
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
# Internationalization (i18n)
|
||||||
|
|
||||||
|
This project uses [i18next](https://www.i18next.com/) with [react-i18next](https://react.i18next.com/). The setup works with both function components and class components (HOCs).
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
- **Default language:** German (`de`), bundled at startup.
|
||||||
|
- **Other languages:** Loaded on demand when the user switches (see `loadLanguage` in `src/i18n/index.js`).
|
||||||
|
- **Persistence:** Language choice is stored in `sessionStorage` / `localStorage` (`i18nextLng`) via a custom detector in `src/i18n/index.js`.
|
||||||
|
- **HTML `lang`:** Updated when the language changes (see `LanguageProvider` in `src/i18n/withTranslation.js`).
|
||||||
|
- **Fallback:** `fallbackLng` is `de`.
|
||||||
|
|
||||||
|
### Product-facing behavior
|
||||||
|
|
||||||
|
- **Language switcher** — `src/components/LanguageSwitcher.js`: shows current language with flag, dropdown to change language, persists selection.
|
||||||
|
- **Translated areas** — Navigation, auth, cart, checkout, product UI, footer, profile, and more; legal pages use dedicated **`legal-*`** namespaces (long-form text).
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
| Path | Role |
|
||||||
|
|------|------|
|
||||||
|
| `src/i18n/index.js` | Initializes i18n, registers namespaces, lazy-loads non-German locales |
|
||||||
|
| `src/i18n/withTranslation.js` | `LanguageProvider`, `withTranslation`, `withI18n`, `withLanguage` |
|
||||||
|
| `src/i18n/locales/<lang>/` | Per-language modules (`de`, `en`, …) |
|
||||||
|
|
||||||
|
Each language folder contains:
|
||||||
|
|
||||||
|
- **`index.js`** — Merges feature modules into the main **`translation`** namespace (shop UI: `navigation`, `cart`, `product`, …). Some legal content is also re-exported here under camelCase keys (e.g. `legalAgbDelivery`) for convenience; runtime legal pages use separate namespaces (see below).
|
||||||
|
- **One file per topic** — e.g. `cart.js`, `navigation.js`, `product.js` (not a single `translation.json`).
|
||||||
|
- **Legal bundles** — e.g. `legal-agb-delivery.js`, `legal-datenschutz-basic.js`, `legal-impressum.js`, registered as their **own i18next namespaces** in `src/i18n/index.js`.
|
||||||
|
|
||||||
|
## Namespaces
|
||||||
|
|
||||||
|
- **`translation`** (default) — Everything merged in `locales/<lang>/index.js`. Keys are dot paths such as `cart.addToCart` or `product.title`.
|
||||||
|
- **`legal-*`** — Long-form legal copy. Loaded the same way, but you select the namespace in `useTranslation('legal-impressum')` (or equivalent). Keys are relative to that namespace, e.g. `sections.operator.title`, not `legal-impressum.sections.operator.title`.
|
||||||
|
|
||||||
|
Default namespace is `translation`; omit the argument for shop UI:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const { t } = useTranslation();
|
||||||
|
// same as useTranslation('translation')
|
||||||
|
```
|
||||||
|
|
||||||
|
Legal page example:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const { t } = useTranslation('legal-impressum');
|
||||||
|
return t('title');
|
||||||
|
```
|
||||||
|
|
||||||
|
Multiple namespaces in one component:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const { t: tDelivery } = useTranslation('legal-agb-delivery');
|
||||||
|
const { t: tPayment } = useTranslation('legal-agb-payment');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage in components
|
||||||
|
|
||||||
|
### Function components
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
const MyComponent = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return <Typography>{t('navigation.home')}</Typography>;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Class components — `withI18n` (translation + language context)
|
||||||
|
|
||||||
|
`withI18n` combines translation and language context. Use it when you need `t` and optionally `languageContext`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { withI18n } from '../i18n/withTranslation.js';
|
||||||
|
|
||||||
|
class MyComponent extends Component {
|
||||||
|
render() {
|
||||||
|
const { t } = this.props;
|
||||||
|
|
||||||
|
return <Typography>{t('navigation.home')}</Typography>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n()(MyComponent);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Class components — `withTranslation` + `withLanguage`
|
||||||
|
|
||||||
|
Some components only need `t` and `languageContext` from separate HOCs:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { withTranslation } from 'react-i18next';
|
||||||
|
import { withLanguage } from '../i18n/withTranslation.js';
|
||||||
|
|
||||||
|
export default withTranslation()(withLanguage(MyComponent));
|
||||||
|
```
|
||||||
|
|
||||||
|
Destructuring in `render`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const { t, title } = this.props;
|
||||||
|
t('product.new');
|
||||||
|
```
|
||||||
|
|
||||||
|
`this.props.t('…')` resolves against the default `translation` namespace.
|
||||||
|
|
||||||
|
### Language context (`changeLanguage`)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { withLanguage } from '../i18n/withTranslation.js';
|
||||||
|
|
||||||
|
class MyComponent extends Component {
|
||||||
|
render() {
|
||||||
|
const { languageContext } = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button onClick={() => languageContext.changeLanguage('en')}>
|
||||||
|
English
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withLanguage(MyComponent);
|
||||||
|
```
|
||||||
|
|
||||||
|
Available language codes are defined on `LanguageProvider` (`allLanguages` in `withTranslation.js`); the switcher only offers languages that are loaded or loading.
|
||||||
|
|
||||||
|
## Key naming
|
||||||
|
|
||||||
|
- Use **dot notation** for nested objects in the JS exports: `product.inclVat`, `delivery.times.standard2to3Days`.
|
||||||
|
- File names in `locales/de/` map to **top-level segments** inside `index.js` (e.g. `cart.js` → `cart.*`). Keep new strings in the appropriate module and **export them from `index.js`** if you add a new file.
|
||||||
|
|
||||||
|
## Interpolation
|
||||||
|
|
||||||
|
i18next interpolation uses `{{name}}` in strings and an options object:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
t('product.weight', { weight: '1,2' });
|
||||||
|
```
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// locale string example: "Gewicht: {{weight}} kg"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Language switching
|
||||||
|
|
||||||
|
`LanguageProvider` coordinates `i18n.changeLanguage` and lazy loading. Non-default languages are loaded via `loadLanguage` in `src/i18n/index.js`, which dynamic-imports the same file layout under `locales/<lang>/`.
|
||||||
|
|
||||||
|
## Adding or changing copy
|
||||||
|
|
||||||
|
1. Edit the right module under `src/i18n/locales/de/` (source of truth for structure).
|
||||||
|
2. Mirror changes in other languages under `src/i18n/locales/<lang>/` as needed, or use the project’s translation tooling (`npm run translate`, etc. — see `translate-i18n.js` and `package.json` scripts).
|
||||||
|
3. For a **new top-level section**, add the module import and export in `locales/<lang>/index.js` for every language you ship.
|
||||||
|
|
||||||
|
### Example (new keys in an existing file)
|
||||||
|
|
||||||
|
`src/i18n/locales/de/cart.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
export default {
|
||||||
|
// ...
|
||||||
|
newFeature: {
|
||||||
|
title: 'Neuer Titel',
|
||||||
|
description: 'Neue Beschreibung',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Use in code: `t('cart.newFeature.title')` (assuming the `cart` object is merged under `cart` in `index.js`).
|
||||||
|
|
||||||
|
## Adding a new language
|
||||||
|
|
||||||
|
1. Add a folder `src/i18n/locales/<code>/` with the same **file set** as German (at minimum `index.js` and every module `index.js` imports, plus all `legal-*.js` files registered in `src/i18n/index.js`).
|
||||||
|
2. Ensure `loadLanguage` in `src/i18n/index.js` can import that folder (pattern already uses `import(\`./locales/${language}/...\`)`).
|
||||||
|
3. Include the code in `allLanguages` (and any UI lists) in `withTranslation.js` / `LanguageSwitcher.js` as needed.
|
||||||
|
4. Clear the language cache logic if you add special cases (`languageCache` in `index.js`).
|
||||||
|
|
||||||
|
## Configuration notes
|
||||||
|
|
||||||
|
- **Detection:** Custom detector in `src/i18n/index.js` prefers session/localStorage, then defaults to `de` (browser language is not used for the initial default).
|
||||||
|
- **Debug:** i18next `debug` follows `NODE_ENV` (on in development).
|
||||||
|
- **Missing keys:** `saveMissing` can run in development (see `src/i18n/index.js`).
|
||||||
|
|
||||||
|
## SEO
|
||||||
|
|
||||||
|
- Document `lang` updates with language changes.
|
||||||
|
- You can extend the app with hreflang, localized routes, or metadata per language as needed.
|
||||||
|
|
||||||
|
## Best practices
|
||||||
|
|
||||||
|
1. **Keep structure parallel** across `locales/<lang>/` files.
|
||||||
|
2. **Prefer German (`de`) as the structural source**; keep `fallbackLng` aligned with your primary market.
|
||||||
|
3. **Use interpolation** for variable fragments: `t('key', { name: value })`.
|
||||||
|
4. **Test** critical flows after adding strings or languages.
|
||||||
|
|
||||||
|
## Translation coverage (high level)
|
||||||
|
|
||||||
|
Coverage varies by screen; shop flows, auth, cart, products, and legal pages have substantial strings. Remaining gaps are normal for a growing app—search for hard-coded German/English in components when polishing.
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **German** is in the initial bundle.
|
||||||
|
- **Other languages** load on first switch (dynamic import), then stay cached in the i18n layer for the session.
|
||||||
|
|
||||||
|
## Browser support
|
||||||
|
|
||||||
|
Modern browsers with ES modules, `localStorage` / `sessionStorage`, and your supported React version.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key usage check (`scripts/check-i18n-keys.mjs`)
|
||||||
|
|
||||||
|
The check answers: *which keys defined for German (`src/i18n/locales/de`) appear to be unused in `src/`?*
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run i18n:check-keys
|
||||||
|
# or
|
||||||
|
node scripts/check-i18n-keys.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Machine-readable output:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/check-i18n-keys.mjs --json
|
||||||
|
```
|
||||||
|
|
||||||
|
### What the script detects
|
||||||
|
|
||||||
|
The script only understands **static** key strings:
|
||||||
|
|
||||||
|
- `t('cart.addToCart')`, `t("…")`
|
||||||
|
- `this.props.t('…')`, `props.t('…')`
|
||||||
|
- Aliases from `useTranslation('namespace')`, e.g. `const { t: tDelivery } = useTranslation('legal-agb-delivery');` then `tDelivery('deliveryTerms.1')`
|
||||||
|
- Destructuring `const { t } = this.props` / `props` for HOC-wrapped components
|
||||||
|
- Fully static template literals without interpolation: `` t(`some.key`) ``
|
||||||
|
|
||||||
|
It flattens locale objects the same way i18next does (leaf keys only).
|
||||||
|
|
||||||
|
### Embedded legal copy vs separate namespaces
|
||||||
|
|
||||||
|
Legal strings exist in two shapes:
|
||||||
|
|
||||||
|
1. **Separate namespace** (what pages use): `legal-agb-delivery` + key `deliveryTerms.1`.
|
||||||
|
2. **Embedded in `translation`**: `legalAgbDelivery.deliveryTerms.1` inside `locales/de/index.js`.
|
||||||
|
|
||||||
|
The script treats a key as used in the **embedded** `translation` tree when the matching **separate-namespace** key is used, so you do not see false “unused” duplicates for the same text.
|
||||||
|
|
||||||
|
### Dynamic keys (`t(\`…${x}…\`)`)
|
||||||
|
|
||||||
|
If the key is built at runtime, the static scan will not see it. The script includes **hard-coded expansions** for known patterns on:
|
||||||
|
|
||||||
|
- `src/pages/AGB.js`
|
||||||
|
- `src/pages/Datenschutz.js` (basic Datenschutz sections)
|
||||||
|
- `src/pages/Impressum.js`
|
||||||
|
|
||||||
|
If you add **new** dynamic patterns elsewhere, either:
|
||||||
|
|
||||||
|
- Prefer **static** keys where reasonable (`t('feature.section.title')` per section), or
|
||||||
|
- Extend `addKnownDynamicLegalKeys` (or equivalent) in `scripts/check-i18n-keys.mjs` so the checker knows which key paths can occur.
|
||||||
|
|
||||||
|
Other files may still be listed under “template literals in `t(...)`” when the script cannot prove all keys.
|
||||||
|
|
||||||
|
### Making new keys “count” for the checker
|
||||||
|
|
||||||
|
1. Call `t` with a **string literal** (or a template literal **without** `${…}`) as the first argument.
|
||||||
|
2. Use **`useTranslation('namespace')`** with a **string literal** namespace so aliases like `tFoo` map correctly.
|
||||||
|
3. For class components, keep **`const { t } = this.props`** (or `props`) so the script ties `t` to `translation`.
|
||||||
|
|
||||||
|
Avoid only referencing a key from non-`src` code (build scripts, server-only files): the checker only scans `src/` and skips `src/i18n/locales/**`.
|
||||||
|
|
||||||
|
### Interpreting results
|
||||||
|
|
||||||
|
- **Unused** — No static (and no expanded dynamic) reference found; may still be dead copy, or only used via variables/dynamic templates.
|
||||||
|
- **Used but not in locale files** — A reference in code does not match any defined German key (typo or missing `de` entry).
|
||||||
|
|
||||||
|
The report is **best-effort**; use it to find obvious dead strings and drift, not as a formal guarantee.
|
||||||
368
scripts/check-i18n-keys.mjs
Normal file
368
scripts/check-i18n-keys.mjs
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Reports which keys from src/i18n/locales/de are referenced in application code.
|
||||||
|
*
|
||||||
|
* - Loads the same namespaces as src/i18n/index.js (translation bundle + legal-* bundles).
|
||||||
|
* - Parses static t("...") / t('...') calls and maps useTranslation() aliases to namespaces.
|
||||||
|
* - Legal text is duplicated: separate namespaces (e.g. legal-agb-delivery) AND embedded under
|
||||||
|
* translation (legalAgbDelivery.*). When a separate-namespace key is used, the embedded
|
||||||
|
* translation::* copy is treated as used too.
|
||||||
|
* - Known dynamic patterns from AGB.js, Datenschutz.js, Impressum.js are expanded so those
|
||||||
|
* keys are not falsely listed as unused.
|
||||||
|
*
|
||||||
|
* Usage: node scripts/check-i18n-keys.mjs
|
||||||
|
* node scripts/check-i18n-keys.mjs --json
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFile, readdir } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const ROOT = path.join(__dirname, '..');
|
||||||
|
const SRC = path.join(ROOT, 'src');
|
||||||
|
const LOCALE_DE = path.join(SRC, 'i18n', 'locales', 'de');
|
||||||
|
|
||||||
|
const SEPARATE_NAMESPACES = [
|
||||||
|
'legal-agb-delivery',
|
||||||
|
'legal-agb-payment',
|
||||||
|
'legal-agb-consumer',
|
||||||
|
'legal-datenschutz-basic',
|
||||||
|
'legal-datenschutz-customer',
|
||||||
|
'legal-datenschutz-google-orders',
|
||||||
|
'legal-datenschutz-newsletter',
|
||||||
|
'legal-datenschutz-chatbot',
|
||||||
|
'legal-datenschutz-push',
|
||||||
|
'legal-datenschutz-cookies-payment',
|
||||||
|
'legal-datenschutz-rights',
|
||||||
|
'legal-impressum',
|
||||||
|
'legal-widerruf',
|
||||||
|
'legal-batterie',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Separate i18n namespace -> key prefix inside translation bundle (locales/de/index.js). */
|
||||||
|
const NS_TO_EMBEDDED_PREFIX = {
|
||||||
|
'legal-agb-delivery': 'legalAgbDelivery',
|
||||||
|
'legal-agb-payment': 'legalAgbPayment',
|
||||||
|
'legal-agb-consumer': 'legalAgbConsumer',
|
||||||
|
'legal-datenschutz-basic': 'legalDatenschutzBasic',
|
||||||
|
'legal-datenschutz-customer': 'legalDatenschutzCustomer',
|
||||||
|
'legal-datenschutz-google-orders': 'legalDatenschutzGoogleOrders',
|
||||||
|
'legal-datenschutz-newsletter': 'legalDatenschutzNewsletter',
|
||||||
|
'legal-datenschutz-chatbot': 'legalDatenschutzChatbot',
|
||||||
|
'legal-datenschutz-cookies-payment': 'legalDatenschutzCookiesPayment',
|
||||||
|
'legal-datenschutz-rights': 'legalDatenschutzRights',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keys reached only via t(`…${var}…`) in a few pages — expand so they count as used.
|
||||||
|
*/
|
||||||
|
function addKnownDynamicLegalKeys(used) {
|
||||||
|
for (let n = 1; n <= 14; n++) {
|
||||||
|
used.add(keySet('legal-agb-delivery', `deliveryTerms.${n}`));
|
||||||
|
}
|
||||||
|
for (const n of [1, 2, 3, 5, 6, 7, 8]) {
|
||||||
|
used.add(keySet('legal-agb-consumer', `distanceSelling.sections.${n}.title`));
|
||||||
|
used.add(keySet('legal-agb-consumer', `distanceSelling.sections.${n}.content`));
|
||||||
|
}
|
||||||
|
for (const section of ['informationDeletion', 'serverLogfiles']) {
|
||||||
|
used.add(keySet('legal-datenschutz-basic', `sections.${section}.title`));
|
||||||
|
used.add(keySet('legal-datenschutz-basic', `sections.${section}.content`));
|
||||||
|
}
|
||||||
|
for (const section of ['operator', 'contact', 'vatId', 'disclaimer', 'copyright']) {
|
||||||
|
used.add(keySet('legal-impressum', `sections.${section}.title`));
|
||||||
|
used.add(keySet('legal-impressum', `sections.${section}.content`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** If legal-agb-delivery::foo is used, translation::legalAgbDelivery.foo is the same strings. */
|
||||||
|
function propagateEmbeddedCopies(used) {
|
||||||
|
const additions = [];
|
||||||
|
for (const entry of used) {
|
||||||
|
const sep = entry.indexOf('::');
|
||||||
|
if (sep === -1) continue;
|
||||||
|
const ns = entry.slice(0, sep);
|
||||||
|
const keyPath = entry.slice(sep + 2);
|
||||||
|
const prefix = NS_TO_EMBEDDED_PREFIX[ns];
|
||||||
|
if (!prefix) continue;
|
||||||
|
additions.push(keySet('translation', `${prefix}.${keyPath}`));
|
||||||
|
}
|
||||||
|
for (const a of additions) used.add(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenLeaves(obj, prefix = '') {
|
||||||
|
const keys = [];
|
||||||
|
if (obj === null || obj === undefined) return keys;
|
||||||
|
if (typeof obj !== 'object' || Array.isArray(obj)) {
|
||||||
|
if (prefix) keys.push(prefix);
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
const entries = Object.entries(obj);
|
||||||
|
if (entries.length === 0 && prefix) keys.push(prefix);
|
||||||
|
for (const [k, v] of entries) {
|
||||||
|
const next = prefix ? `${prefix}.${k}` : k;
|
||||||
|
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
|
||||||
|
keys.push(...flattenLeaves(v, next));
|
||||||
|
} else {
|
||||||
|
keys.push(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
function keySet(ns, keyPath) {
|
||||||
|
return `${ns}::${keyPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDefinedKeys() {
|
||||||
|
const translationMod = await import(
|
||||||
|
pathToFileUrl(path.join(LOCALE_DE, 'index.js'))
|
||||||
|
);
|
||||||
|
const translation = translationMod.default;
|
||||||
|
const defined = new Map();
|
||||||
|
defined.set('translation', new Set(flattenLeaves(translation)));
|
||||||
|
|
||||||
|
for (const ns of SEPARATE_NAMESPACES) {
|
||||||
|
const mod = await import(pathToFileUrl(path.join(LOCALE_DE, `${ns}.js`)));
|
||||||
|
defined.set(ns, new Set(flattenLeaves(mod.default)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return defined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathToFileUrl(p) {
|
||||||
|
const normalized = path.resolve(p);
|
||||||
|
return new URL(`file://${normalized}`).href;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectSourceFiles(dir, out = []) {
|
||||||
|
const entries = await readdir(dir, { withFileTypes: true });
|
||||||
|
for (const e of entries) {
|
||||||
|
const full = path.join(dir, e.name);
|
||||||
|
if (e.isDirectory()) {
|
||||||
|
if (full.includes(`${path.sep}i18n${path.sep}locales`)) continue;
|
||||||
|
await collectSourceFiles(full, out);
|
||||||
|
} else if (/\.(jsx?|tsx?)$/.test(e.name)) {
|
||||||
|
out.push(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per file: map translation function alias -> i18next namespace.
|
||||||
|
*/
|
||||||
|
function buildAliasMap(source) {
|
||||||
|
const map = new Map();
|
||||||
|
|
||||||
|
const named = /const\s*\{\s*t:\s*(\w+)\s*\}\s*=\s*useTranslation\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
||||||
|
let m;
|
||||||
|
while ((m = named.exec(source))) {
|
||||||
|
map.set(m[1], m[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tWithNs =
|
||||||
|
/const\s*\{\s*t\s*\}\s*=\s*useTranslation\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
||||||
|
while ((m = tWithNs.exec(source))) {
|
||||||
|
map.set('t', m[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tDefault = /const\s*\{\s*t\s*\}\s*=\s*useTranslation\s*\(\s*\)/g;
|
||||||
|
while (tDefault.exec(source)) {
|
||||||
|
map.set('t', 'translation');
|
||||||
|
}
|
||||||
|
|
||||||
|
// withTranslation HOC: t comes from props (any position in destructuring)
|
||||||
|
const tFromThisProps =
|
||||||
|
/const\s*\{[^}]*\bt(?:\s*:\s*(\w+))?\b[^}]*\}\s*=\s*this\.props/;
|
||||||
|
const mProps = tFromThisProps.exec(source);
|
||||||
|
if (mProps) {
|
||||||
|
map.set(mProps[1] || 't', 'translation');
|
||||||
|
}
|
||||||
|
const tFromProps =
|
||||||
|
/const\s*\{[^}]*\bt(?:\s*:\s*(\w+))?\b[^}]*\}\s*=\s*props\b/;
|
||||||
|
const mP = tFromProps.exec(source);
|
||||||
|
if (mP) {
|
||||||
|
map.set(mP[1] || 't', 'translation');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map.has('t')) {
|
||||||
|
map.set('t', 'translation');
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRe(s) {
|
||||||
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract static keys from t-like calls for known aliases.
|
||||||
|
*/
|
||||||
|
function extractUsedKeysFromSource(source, filePath) {
|
||||||
|
const aliasMap = buildAliasMap(source);
|
||||||
|
const used = new Set();
|
||||||
|
const dynamicHints = [];
|
||||||
|
let m;
|
||||||
|
|
||||||
|
const propsT =
|
||||||
|
/(?:this\.props\.t|props\.t)\(\s*['"]([^'"]+)['"]/g;
|
||||||
|
while ((m = propsT.exec(source))) {
|
||||||
|
used.add(keySet('translation', m[1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [alias, ns] of aliasMap) {
|
||||||
|
const re = new RegExp(
|
||||||
|
`\\b${escapeRe(alias)}\\(\\s*['"]([^'"]+)['"]`,
|
||||||
|
'g'
|
||||||
|
);
|
||||||
|
while ((m = re.exec(source))) {
|
||||||
|
used.add(keySet(ns, m[1]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tplStatic = /\b(\w+)\(\s*`([^`${}]*)`\s*\)/g;
|
||||||
|
while ((m = tplStatic.exec(source))) {
|
||||||
|
const alias = m[1];
|
||||||
|
const key = m[2].trim();
|
||||||
|
if (!key || !aliasMap.has(alias)) continue;
|
||||||
|
if (/^[a-zA-Z][a-zA-Z0-9_.]*$/.test(key)) {
|
||||||
|
used.add(keySet(aliasMap.get(alias), key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
/(?:this\.props\.t|props\.t|\bt\w*)\(\s*`[^`]*\$\{/.test(source)
|
||||||
|
) {
|
||||||
|
dynamicHints.push(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { used, dynamicHints };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeSets(into, from) {
|
||||||
|
for (const x of from) into.add(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const jsonOut = process.argv.includes('--json');
|
||||||
|
|
||||||
|
const defined = await loadDefinedKeys();
|
||||||
|
const files = await collectSourceFiles(SRC);
|
||||||
|
|
||||||
|
const allUsed = new Set();
|
||||||
|
const dynamicFiles = new Set();
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const source = await readFile(file, 'utf8');
|
||||||
|
const { used, dynamicHints } = extractUsedKeysFromSource(source, file);
|
||||||
|
mergeSets(allUsed, used);
|
||||||
|
if (dynamicHints.length) dynamicFiles.add(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
addKnownDynamicLegalKeys(allUsed);
|
||||||
|
propagateEmbeddedCopies(allUsed);
|
||||||
|
|
||||||
|
const unusedByNs = new Map();
|
||||||
|
let totalDefined = 0;
|
||||||
|
let totalUnused = 0;
|
||||||
|
|
||||||
|
for (const [ns, keys] of defined) {
|
||||||
|
const unused = [];
|
||||||
|
for (const k of keys) {
|
||||||
|
totalDefined++;
|
||||||
|
const full = keySet(ns, k);
|
||||||
|
if (!allUsed.has(full)) {
|
||||||
|
unused.push(k);
|
||||||
|
totalUnused++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (unused.length) unusedByNs.set(ns, unused.sort());
|
||||||
|
}
|
||||||
|
|
||||||
|
const orphans = new Set();
|
||||||
|
for (const u of allUsed) {
|
||||||
|
const [ns, keyPath] = u.split('::');
|
||||||
|
const set = defined.get(ns);
|
||||||
|
if (!set || !set.has(keyPath)) {
|
||||||
|
orphans.add(u);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jsonOut) {
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
totalDefined,
|
||||||
|
totalUsed: allUsed.size,
|
||||||
|
totalUnused,
|
||||||
|
unusedByNamespace: Object.fromEntries(unusedByNs),
|
||||||
|
usedButNotDefined: [...orphans].sort(),
|
||||||
|
filesWithLikelyDynamicT: [...dynamicFiles].map((f) =>
|
||||||
|
path.relative(ROOT, f)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('i18n key usage (locale: de)\n');
|
||||||
|
console.log(
|
||||||
|
'Legal strings exist twice: separate namespaces (AGB, Datenschutz, …) and embedded copies'
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
'under translation (legalAgbDelivery.*, …). The latter are marked used when the former are.\n'
|
||||||
|
);
|
||||||
|
console.log(`Defined keys (all namespaces): ${totalDefined}`);
|
||||||
|
console.log(`References after static scan + known dynamic legal patterns: ${allUsed.size}`);
|
||||||
|
console.log(`Unused (best-effort): ${totalUnused}`);
|
||||||
|
console.log(`Used but not in locale files: ${orphans.size}\n`);
|
||||||
|
|
||||||
|
const stillDynamic = [...dynamicFiles].filter(
|
||||||
|
(f) =>
|
||||||
|
![
|
||||||
|
'pages/AGB.js',
|
||||||
|
'pages/Datenschutz.js',
|
||||||
|
'pages/Impressum.js',
|
||||||
|
].includes(path.relative(SRC, f).replace(/\\/g, '/'))
|
||||||
|
);
|
||||||
|
if (stillDynamic.length) {
|
||||||
|
console.log(
|
||||||
|
'Other files with t(`…${…}…`) (may still hide unused keys — not expanded):'
|
||||||
|
);
|
||||||
|
for (const f of stillDynamic.sort()) {
|
||||||
|
console.log(` ${path.relative(ROOT, f)}`);
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [ns, keys] of [...unusedByNs.entries()].sort((a, b) =>
|
||||||
|
a[0].localeCompare(b[0])
|
||||||
|
)) {
|
||||||
|
console.log(`--- ${ns} (${keys.length} unused) ---`);
|
||||||
|
for (const k of keys) {
|
||||||
|
console.log(` ${k}`);
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orphans.size) {
|
||||||
|
console.log('--- Referenced in code but missing from de locale ---');
|
||||||
|
for (const o of [...orphans].sort()) {
|
||||||
|
console.log(` ${o.replace('::', ' / ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
'\nNote: Keys built from arbitrary variables or unknown dynamic templates may still look unused.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
49
src/utils/jsErrorTelemetry.js
Normal file
49
src/utils/jsErrorTelemetry.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* POST client errors to /api/telemetry/js-errors (shop API).
|
||||||
|
* Uses same-origin relative URL so the dev-server proxy forwards to the backend.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TELEMETRY_PATH = '/api/telemetry/js-errors';
|
||||||
|
|
||||||
|
function send(payload) {
|
||||||
|
fetch(TELEMETRY_PATH, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register global listeners once. Safe to call in browser only.
|
||||||
|
*/
|
||||||
|
export function installJsErrorTelemetry() {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('error', (event) => {
|
||||||
|
send({
|
||||||
|
message: event.message || 'Error',
|
||||||
|
name: event.error?.name,
|
||||||
|
filename: event.filename,
|
||||||
|
lineno: event.lineno,
|
||||||
|
colno: event.colno,
|
||||||
|
url: window.location.href,
|
||||||
|
stack: event.error && event.error.stack,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('unhandledrejection', (event) => {
|
||||||
|
const reason = event.reason;
|
||||||
|
send({
|
||||||
|
message:
|
||||||
|
reason && typeof reason.message === 'string'
|
||||||
|
? reason.message
|
||||||
|
: String(reason),
|
||||||
|
name: reason && reason.name,
|
||||||
|
stack: reason && reason.stack,
|
||||||
|
url: window.location.href,
|
||||||
|
context: { type: 'unhandledrejection' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user