Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ docker exec -it <container_name> bash -c "eslint src --ext .jsx,.js"
docker exec -it oclmap-web-1 bash -c "npm test"
```

### i18n key audit
`npm run i18n:audit` lists the English keys in `src/i18n/locales/en/translations.json` that the source doesn't use, and compares each locale with English. See [scripts/i18n-audit/README.md](scripts/i18n-audit/README.md).

#### Major/minor version increase

In order to increase major/minor version you need to set the new version in [package.json](package.json).
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@
"eslint": "./node_modules/.bin/eslint ./src --ext .jsx,.js",
"test": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test 'src/**/__tests__/**/*.test.js'",
"test:integration": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --import ./test-integration-setup.js --test 'src/**/__integration__/**/*.test.js'",
"test:all": "npm test && npm run test:integration"
"test:all": "npm test && npm run test:integration",
"i18n:audit": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/i18n-audit/audit.mjs"
},
"devDependencies": {
"@babel/core": "^7.26.10",
Expand Down
132 changes: 132 additions & 0 deletions scripts/i18n-audit/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# i18n key audit

`npm run i18n:audit` reports which English keys in
`src/i18n/locales/en/translations.json` the Mapper's source uses, and how each
other locale compares with English (OpenConceptLab/ocl_issues#2880).

- [`report.md`](report.md) is its output for this tree.
- [`findings.md`](findings.md) is the review done with it in October 2026:
the manual check of the unreferenced keys, and the claims it was asked to
assess.
- [`families.json`](families.json) declares the keys some dynamic call sites
can produce (see [Namespace-wide families](#namespace-wide-families)).
- The logic is in `src/i18n/audit.js`, tested by `src/i18n/__tests__/audit.test.js`
and `auditCli.test.js`; [`audit.mjs`](audit.mjs) reads the files and prints
the report.

## Run it

```bash
npm run i18n:audit # print the report
npm run i18n:audit -- --out scripts/i18n-audit/report.md # refresh report.md
npm run i18n:audit -- --root <checkout> # audit another tree, e.g. an older commit
```

`--root` reads `src/` from the other checkout but keeps this checkout's
`families.json`; the report flags any declaration that doesn't fit that tree.
To audit an older commit: `git worktree add <dir> <commit>`, then
`npm run i18n:audit -- --root <dir>` from here.

The same tree always gives the same report: it has no dates or absolute paths,
and no list in it depends on the order files are read in or on the locale. To check that `report.md` is current, refresh
it and run `git diff --exit-code scripts/i18n-audit/report.md`.

## What counts as a reference

The audit parses every `.js` and `.jsx` file under `src/`, except those in
`__tests__/` and `__integration__/`, with Babel. It reads the key argument of
every call to `t` or `<anything>.t` (`t()`, `i18n.t()`, `this.props.t()`,
optional ones like `i18n?.t()` too), and every `i18nKey` attribute. For each
it works out the keys it can produce:

| Key expression | Counts as |
|---|---|
| `'common.save'`, or a template literal without a placeholder | that key |
| `cond ? 'a.x' : 'a.y'`, `custom \|\| 'a.x'` | each branch |
| `['a.x', 'a.y']` (i18next fallback keys) | each element |
| a variable assigned once (`const key = …`) | its value, worked out the same way |
| `` `map_project.preview_limit_title_${kind}` ``, `'map_project.view_' + state` | a dynamic key family: every key the pattern (`map_project.view_*`) matches |
| a parameter, a property, a function call | a call site the audit can't read |

A string literal anywhere in the scanned source that equals a key counts as
well. That covers keys stored in config and passed to `t()` later, such as
the column definitions in `ResultConstants.js` (`labelKey: 'common.id'`) that
`TableResults.jsx` passes to `t(headCell.labelKey)`.

Each English key goes in the first of these groups that fits: referenced by a
literal key, matched only by a dynamic key family, mentioned only as a string
literal, or unreferenced.

### Namespace-wide families

A family whose whole key after the namespace is dynamic, such as
`'map_project.' + id`, matches every key in that namespace, so on its own it
would hide every unused `map_project` key. `families.json` declares the keys
such a call site can produce, worked out by reading the code, with the evidence:

```json
{
"file": "src/components/map-projects/ScoreBucketButton.jsx",
"expression": "'map_project.' + id",
"keys": ["map_project.recommended", "map_project.available", "map_project.low_ranked"],
"evidence": "ScoreBucketButton renders BucketButton with id recommended, available and low_ranked."
}
```

`expression` is the key argument's source text with its whitespace collapsed,
as the report shows it; the declaration covers every call in `file` with that
text. A narrower family can be declared the same way to tighten it.

The report's *Needs attention* section lists:

- a namespace-wide family with no declaration (every key it matches then
counts as used);
- a declared key the expression can't produce, or the catalog lacks;
- a declaration whose call site has gone;
- a literal key the English catalog lacks (i18next shows the raw key, unless
the call passes a default).

## Locales

Every directory under `src/i18n/locales/` is compared with English, key by key:

| | Means |
|---|---|
| Missing | Not in the locale; i18next falls back to English. |
| Blank | Empty or only whitespace; i18next shows the blank, so the text disappears. |
| Same as English | Untranslated, or right as it is (`ID`, `JSON`, `OK`); needs a translator to tell. |
| Not in English | Only this locale has it: a leftover, or a key English lacks. |

## Limits of static detection

The audit can count a key as used when it isn't, and the opposite in the cases
below. Check a key by hand before removing it.

- A translation function under another name (`const { t: translate } = …`),
or reached through a computed property (`i18n['t']`), isn't read. Today every
call is `t()` or `this.props.t()`.
- A key built outside the call (`const key = 'map_project.' + id; t(key)`)
is read only when it's a variable in scope that's assigned once; a key built
in another function and passed in is a call site the audit can't read.
- A pattern matches by text, so `map_project.decision_*` covers
`map_project.decision_tab_*` too. An undeclared family can only over-count,
so a key it matches may still be unused.
- A declaration is only as good as its evidence. The audit notices when a
call site's text changes, not when the code that feeds it values does.
- A string literal that equals a key counts even if it's never translated, or
sits in config the Mapper never shows: `ResultConstants.js` has columns for
users, orgs and repos, but the Mapper only lists concepts.
- Code that never runs still counts, such as `ConceptHeader.jsx`'s
`false && …` block, which calls `t('common.actions')`.
- Plural and context suffixes (`key_one`, `key_other`, `key_male`) aren't
derived from `t(key, {count})`, and i18next nesting (`$t(other.key)` in a
value) isn't followed. The catalogs use neither today.
- A `<Trans>` without `i18nKey` uses its children as the key; the audit lists
it as a call site it can't read. There are none today.

## Removing keys

The unreferenced list is a list of candidates. Check each key by hand: search
the source for its last segment, and look at the history of the code that last
used it (`git log -S'<key>' -- src`). Then remove keys from every locale at once,
in a separate, reviewed change.
43 changes: 43 additions & 0 deletions scripts/i18n-audit/audit.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#!/usr/bin/env node
// Prints the i18n key audit (OpenConceptLab/ocl_issues#2880) for this
// checkout, or for another one with --root. See README.md.
//
// npm run i18n:audit # print the report
// npm run i18n:audit -- --out scripts/i18n-audit/report.md # write it
// npm run i18n:audit -- --root <checkout> # audit another checkout's src/
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { parseArgs } from 'node:util'

import { auditKeys, renderReport } from '../../src/i18n/audit.js'

const { values: options } = parseArgs({ options: { root: { type: 'string' }, out: { type: 'string' } } })
const here = path.dirname(fileURLToPath(import.meta.url))
const root = path.resolve(options.root || path.join(here, '..', '..'))

const SKIP = new Set(['__tests__', '__integration__', 'node_modules'])
const listSources = dir => fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
const full = path.join(dir, entry.name)
if(entry.isDirectory())
return SKIP.has(entry.name) ? [] : listSources(full)
return /\.jsx?$/.test(entry.name) ? [full] : []
})

const relative = file => path.relative(root, file).split(path.sep).join('/')
const sources = listSources(path.join(root, 'src')).map(file => ({ file: relative(file), code: fs.readFileSync(file, 'utf8') }))

const localesDir = path.join(root, 'src', 'i18n', 'locales')
const catalogs = Object.fromEntries(fs.readdirSync(localesDir).map(locale => [
locale, JSON.parse(fs.readFileSync(path.join(localesDir, locale, 'translations.json'), 'utf8'))
]))

// This checkout's declarations, also for --root: an older tree has none, and
// the report flags any that no longer fit it.
const families = JSON.parse(fs.readFileSync(path.join(here, 'families.json'), 'utf8'))

const report = renderReport(auditKeys({ sources, catalogs, families }))
if(options.out)
fs.writeFileSync(options.out, report)
else
process.stdout.write(report)
32 changes: 32 additions & 0 deletions scripts/i18n-audit/families.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
[
{
"file": "src/components/concepts/ConceptProperties.jsx",
"expression": "`concept.${prop}`",
"keys": ["concept.concept_class", "concept.datatype"],
"evidence": "prop is a property code. The first call translates only concept_class and datatype; the second runs when the concept has no properties, and sortedProperties is then those same two."
},
{
"file": "src/components/map-projects/MapProject.jsx",
"expression": "'map_project.' + qualityBucket",
"keys": ["map_project.recommended", "map_project.available", "map_project.low_ranked"],
"evidence": "getScoreDetails() in viewBuilders.js sets qualityBucket to recommended, available or low_ranked, or leaves it unset, and the call then uses map_project.unranked."
},
{
"file": "src/components/map-projects/MapProject.jsx",
"expression": "'map_project.' + quality.bucket",
"keys": ["map_project.recommended", "map_project.available", "map_project.low_ranked"],
"evidence": "getRowQuality() in rowQuality.js: getBucket() returns recommended, available or low_ranked, and a proposed match without a score is low_ranked."
},
{
"file": "src/components/map-projects/ScoreBucketButton.jsx",
"expression": "'map_project.' + id",
"keys": ["map_project.recommended", "map_project.available", "map_project.low_ranked"],
"evidence": "ScoreBucketButton renders BucketButton with id recommended, available and low_ranked."
},
{
"file": "src/components/search/SearchResults.jsx",
"expression": "`search.${resource}`",
"keys": ["search.concepts"],
"evidence": "map-projects/Search.jsx and Candidates.jsx render SearchResults, both with resource='concepts'."
}
]
147 changes: 147 additions & 0 deletions scripts/i18n-audit/findings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# i18n audit findings, October 2026

The review OpenConceptLab/ocl_issues#2880 asked for, done with `npm run i18n:audit`
on `main` at 0f6b0be. [`report.md`](report.md) has the full lists, and the
[README](README.md) the method and its limits. No catalog key is removed here:
that's a separate, reviewed change.

## Counts

Of the 778 English keys:

| | Keys |
|---|---:|
| Referenced by a literal key | 487 |
| Matched only by a dynamic key family | 62 |
| Mentioned only as a string literal | 21 |
| Unreferenced | 208 |

Every dynamic key family is accounted for. Of the 20, 13 have a literal part
narrow enough to count every key their pattern matches. The other 7 span a
whole namespace: two calls of `` `concept.${prop}` ``, one of
`` `search.${resource}` `` and four of `'map_project.' + <bucket>`. For those,
`families.json` declares the keys, citing the code that produces them.

Of the 4 call sites the audit can't read, three get their keys from string
literals, and the fourth gets none today:

- `AnnouncementBanner.jsx` `t(labelKey)`: `ANNOUNCEMENT_LINKS`, in the same file.
- `MapperQuotaChip.jsx` `t(labelKey)`: the three `meterLine('map_project.preview_quota_…')` calls below it.
- `TableResults.jsx` `t(headCell.labelKey)`: column configs in `ResultConstants.js`, `Candidates.jsx` and `Search.jsx`.
- `MultiAlgoSelector.jsx` `t(algo.name?.props?.i18nKey)`: the `i18nKey` of an algorithm whose name is a
`<Trans>` element. No algorithm defines its name that way today.

Apart from `src/i18n/audit.js` itself, every file the audit scans is imported,
directly or through others, from `src/index.jsx`, so no reference comes from a
file the app never loads.

## The unreferenced keys, checked by hand

For each of the 208 keys, the source (tests excluded) was searched for the
key's last segment inside any string or template literal. 58 keys had hits,
and none of the hits builds a key:

- CSS values and class names (`clear`, `display`, `visibility`), URL segments
(`/compare`, `versions/`), API fields (`first_name`, `include_retired`,
`map_types`) and storage keys (`session_expired`).
- Log action types: `ProjectLogs.jsx` picks an icon by action (`created`,
`auto_matched`); it doesn't translate it.
- Display modes: `DisplayMenu.jsx` labels `card` and `table` with
`search.list_view` and `search.table_view`, not `search.card` and `search.table`.
- Sort fields: `SortMenu.jsx` labels them with `startCase()`, not `t()`, so
`search.score` is unused.

All 208 stand.

14 of the 21 keys mentioned only as a string literal are probably unused too.
They appear only in `ALL_COLUMNS` in `ResultConstants.js`, in the column sets
for mappings, repos, orgs, users and the URL registry. `TableResults.jsx` is the
only reader, through `SearchResults`, and both places that render
`SearchResults` pass `resource='concepts'`. These are `common.created_on`, the
three `mapping.*`, `repo.repo_id`, `repo.repo_type`, the two `url_registry.*`
and the six `user.*` keys in the report's string-literal table. Removing them
means trimming those column sets as well.

## Keys the code uses that English lacks

The report's *Needs attention* section lists five:

- `common.none` (`MatchDetailsPanel.jsx`), `common.saved` (`MultiAlgoSelector.jsx`)
and `search.save_default_filters` (`SearchFilters.jsx`) render as the raw key
in every language.
- `map_project.candidates_legacy_shape` (`MapProject.jsx`) and
`map_project.target_repo_version_required` (`ConfigurationForm.jsx`,
`MapProject.jsx`) pass an English default, so they show English in every
language.

## Locales

| Locale | Keys | Missing | Blank | Same as English | Not in English |
|---|---:|---:|---:|---:|---:|
| es | 780 | 0 | 0 | 9 | 2 |
| pt-BR | 778 | 0 | 0 | 12 | 0 |
| zh | 778 | 0 | 0 | 5 | 0 |

- No locale is missing a key or has a blank value. The 8 keys es and zh each
lacked, as the ticket records, were added by a0de2db
(OpenConceptLab/oclmap#93) on 2026-10-08.
- Most values that are the same as English are names or acronyms that read the
same (`ID`, `JSON`, `OK`, `AutoMatch`, `Token`, `Avatar`). For a translator to
confirm: pt-BR `concept.namespace` (`Namespace`), `repo.source` and
`map_project.source` (`Source`), and zh `common.with` (`with`, which is
unreferenced anyway).
- es has two keys English doesn't: `common.or_go_to_your` and `concept.history`.

## The claims in the review of OpenConceptLab/oclmap#90

### "228 of 752 English keys are unused"

752 English keys is the state of the pt-BR branch from b05133c to 25e2f8a,
2026-10-02 to 2026-10-05. ca4024e then removed three keys, and the merge
(06d8841) left `main` with 771. At 25e2f8a, the audit counts 211 of the 752 as
unreferenced (460 literal, 62 dynamic, 19 string literal only). To repeat it:
`git worktree add <dir> 25e2f8a`, then `npm run i18n:audit -- --root <dir>`.

228 can't be reproduced exactly, since the claim doesn't state its rules, and
the count depends on them. The table applies simpler rules to the same tree:
the first two rows are plain regexes over the source text, the next four use
the audit's scan with fewer rules, and only the last row is the audit's own
output. At 25e2f8a:

| Rules | Unused of 752 |
|---|---:|
| A plain regex for `t('key')` | 307 |
| …and for `i18nKey='key'` | 300 |
| Parsed: literal keys, conditionals, fallback arrays, constants | 292 |
| …and template-literal families | 212 |
| …and concatenation families (each counting all keys it matches) | 171 |
| …and string literals | 155 |
| The audit: as above, with namespace-wide families declared | 211 |

Of the first 35 keys in the claim's list, `common.a` to `common.read_more`,
the audit at 25e2f8a has 33 unreferenced. The other two are in use: column
configs pass `common.action` (`Candidates.jsx`, `Search.jsx`) and
`common.created_on` (`ResultConstants.js`) to `t()` as `labelKey`. Rules that miss keys passed that way would add the 19
string-literal-only keys to the audit's 211, giving 230, close to 228.

The ticket's "301 unmatched by a basic static regex" fits the plain regex for
`t('key')` and `i18nKey='key'` (300).

### `user.recent_activity`, `user.own_recent_activity`, `user.recent_activity_of`

The claim that all three are unused is right:

- `user.recent_activity` came in with the import from oclweb3 (5224e29,
2025-02-06). Its only call site, in `src/components/common/Events.jsx`, went
when c619b5e deleted that file on 2026-06-30, so it was unused from then on.
- `user.own_recent_activity` and `user.recent_activity_of` were added by
b05133c and removed by ca4024e, both on the pt-BR branch, so neither reached
`main`. No source file outside the catalogs ever named them
(`git log -S'own_recent_activity' -- src` finds only those two commits).
- At 25e2f8a the audit lists all three as unreferenced; they are the only
difference between its list there and its list on `main` today. ca4024e
removed all three, so none exists on `main`. The later note that the last two
were "never added" holds for `main` only.

`user.your`, the other key `Events.jsx` used, is still in every catalog and is
unreferenced.
Loading
Loading