Skip to content

Commit b4f98e8

Browse files
authored
Merge pull request #20747 from calixteman/check__l10n
Add a script for searching the unused fluent ids
2 parents 4dd51a2 + 82fdeaa commit b4f98e8

3 files changed

Lines changed: 206 additions & 1 deletion

File tree

eslint.config.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -479,7 +479,7 @@ export default [
479479
Other
480480
\* ======================================================================== */
481481
{
482-
files: ["gulpfile.mjs"],
482+
files: ["gulpfile.mjs", "check_l10n.mjs"],
483483
languageOptions: { globals: globals.node },
484484
},
485485
];

external/check_l10n/check_l10n.mjs

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
#!/usr/bin/env node
2+
3+
/* Copyright 2026 Mozilla Foundation
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
/**
19+
* Checks that every message ID defined in l10n/en-US/viewer.ftl is referenced
20+
* in at least one HTML or JS/MJS file under the web/ directory.
21+
*
22+
* Usage: node external/check_l10n/check_l10n.mjs
23+
*/
24+
25+
import { extname, join } from "path";
26+
import { readdirSync, readFileSync, statSync } from "fs";
27+
28+
const ROOT = join(import.meta.dirname, "..", "..");
29+
const FTL_PATH = join(ROOT, "l10n", "en-US", "viewer.ftl");
30+
const SEARCH_DIRS = ["web", "src"];
31+
const SEARCH_EXTENSIONS = new Set([".html", ".js", ".mjs"]);
32+
// Minimum number of characters a prefix or suffix fragment must have to be
33+
// considered a meaningful match when detecting dynamically-built IDs.
34+
const MIN_FRAGMENT_LENGTH = 6;
35+
36+
/**
37+
* Extract all message IDs from a Fluent (.ftl) file.
38+
* A message ID is an identifier at the start of a line followed by " =".
39+
* @param {string} ftlPath - Absolute path to the .ftl file.
40+
* @returns {string[]} Ordered list of message IDs.
41+
*/
42+
function extractFtlIds(ftlPath) {
43+
const lines = readFileSync(ftlPath, "utf8").split("\n");
44+
const ids = [];
45+
for (const line of lines) {
46+
const match = line.match(/^([a-zA-Z][a-zA-Z0-9-]*)\s*=/);
47+
if (match) {
48+
ids.push(match[1]);
49+
}
50+
}
51+
return ids;
52+
}
53+
54+
/**
55+
* Recursively collect all files with matching extensions under a directory.
56+
* @param {string} dir - Directory to walk.
57+
* @param {Set<string>} extensions - Allowed file extensions (e.g. `".js"`).
58+
* @returns {string[]} Absolute paths of matching files.
59+
*/
60+
function collectFiles(dir, extensions) {
61+
const results = [];
62+
for (const entry of readdirSync(dir)) {
63+
const fullPath = join(dir, entry);
64+
const stat = statSync(fullPath);
65+
if (stat.isDirectory()) {
66+
results.push(...collectFiles(fullPath, extensions));
67+
} else if (extensions.has(extname(entry))) {
68+
results.push(fullPath);
69+
}
70+
}
71+
return results;
72+
}
73+
74+
/**
75+
* Load the contents of all source files found under the given directories.
76+
* @param {string[]} dirs - Directory names relative to ROOT.
77+
* @param {Set<string>} extensions - Allowed file extensions.
78+
* @returns {{ path: string, content: string }[]}
79+
*/
80+
function loadSources(dirs, extensions) {
81+
const files = dirs.flatMap(d => collectFiles(join(ROOT, d), extensions));
82+
return files.map(f => ({ path: f, content: readFileSync(f, "utf8") }));
83+
}
84+
85+
/**
86+
* Check whether a message ID appears as a quoted string literal in any source
87+
* file. Handles double quotes, single quotes, and backticks, covering:
88+
* - `data-l10n-id="pdfjs-foo"` (HTML attribute)
89+
* - `"pdfjs-foo"` / `'pdfjs-foo'` / `` `pdfjs-foo` `` (JS string literals,
90+
* `setAttribute`, `l10n.get`, …)
91+
* @param {string} id - Message ID to look up.
92+
* @param {{ path: string, content: string }[]} sources
93+
* @returns {boolean}
94+
*/
95+
function isUsed(id, sources) {
96+
const dq = `"${id}"`;
97+
const sq = `'${id}'`;
98+
const bt = `\`${id}\``;
99+
return sources.some(
100+
({ content }) =>
101+
content.includes(dq) || content.includes(sq) || content.includes(bt)
102+
);
103+
}
104+
105+
/**
106+
* For IDs not found as complete literals, check whether the ID is likely
107+
* constructed dynamically via a template literal such as:
108+
* `pdfjs-editor-${editorType}-added-alert`
109+
*
110+
* Strategy: try every (prefix, suffix) pair obtained by splitting the ID's
111+
* dash-separated components, leaving one or more components as the "variable"
112+
* gap. The prefix must appear immediately followed by `${` in a template
113+
* literal; the suffix (if non-empty) must also appear in the same file.
114+
* Minimum length guards prevent matches on trivially short fragments.
115+
*
116+
* @param {string} id - Message ID to test.
117+
* @param {{ path: string, content: string }[]} sources
118+
* @returns {{ path: string, line: number } | null} Location of the first
119+
* matching template literal, or `null` if none found.
120+
*/
121+
function findDynamicLocation(id, sources) {
122+
const parts = id.split("-");
123+
// i = end of prefix (exclusive), j = start of suffix (inclusive)
124+
for (let i = 1; i < parts.length; i++) {
125+
for (let j = i + 1; j <= parts.length; j++) {
126+
const prefix = parts.slice(0, i).join("-") + "-"; // e.g. "pdfjs-editor-"
127+
const suffix = j < parts.length ? "-" + parts.slice(j).join("-") : ""; // e.g. "-added-alert"
128+
if (prefix.length < MIN_FRAGMENT_LENGTH) {
129+
continue;
130+
}
131+
if (suffix !== "" && suffix.length < MIN_FRAGMENT_LENGTH) {
132+
continue;
133+
}
134+
// The prefix must be immediately followed by "${" in a template literal.
135+
const prefixWithVar = prefix + "${";
136+
for (const { path, content } of sources) {
137+
if (
138+
content.includes(prefixWithVar) &&
139+
(suffix === "" || content.includes(suffix))
140+
) {
141+
const idx = content.indexOf(prefixWithVar);
142+
const line = content.slice(0, idx).split("\n").length;
143+
return { path, line };
144+
}
145+
}
146+
}
147+
}
148+
return null;
149+
}
150+
151+
function main() {
152+
const ids = extractFtlIds(FTL_PATH);
153+
console.log(`Found ${ids.length} message IDs in viewer.ftl\n`);
154+
155+
const sources = loadSources(SEARCH_DIRS, SEARCH_EXTENSIONS);
156+
console.log(
157+
`Searching in ${sources.length} files under: ${SEARCH_DIRS.join(", ")}\n`
158+
);
159+
160+
const notFound = ids.filter(id => !isUsed(id, sources));
161+
const dynamicEntries = notFound
162+
.map(id => ({ id, loc: findDynamicLocation(id, sources) }))
163+
.filter(({ loc }) => loc !== null);
164+
const dynamicIds = new Set(dynamicEntries.map(({ id }) => id));
165+
const unused = notFound.filter(id => !dynamicIds.has(id));
166+
167+
if (dynamicEntries.length > 0) {
168+
console.log(
169+
`~ ${dynamicEntries.length} ID(s) likely built dynamically (template literals):\n`
170+
);
171+
for (const { id, loc } of dynamicEntries) {
172+
const rel = loc.path.replace(ROOT + "/", "").replace(ROOT + "\\", "");
173+
console.log(` ${id}`);
174+
console.log(` → ${rel}:${loc.line}`);
175+
}
176+
console.log();
177+
}
178+
179+
if (unused.length === 0) {
180+
console.log("✓ All remaining message IDs are used.");
181+
} else {
182+
console.log(`✗ ${unused.length} unused message ID(s):\n`);
183+
for (const id of unused) {
184+
console.log(` ${id}`);
185+
}
186+
process.exitCode = 1;
187+
}
188+
}
189+
190+
main();

gulpfile.mjs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2252,6 +2252,21 @@ gulp.task("importl10n", async function () {
22522252
await downloadL10n(L10N_DIR);
22532253
});
22542254

2255+
gulp.task("check_l10n", function (done) {
2256+
console.log("\n### Checking for unused l10n IDs");
2257+
2258+
const checkProcess = startNode(["external/check_l10n/check_l10n.mjs"], {
2259+
stdio: "inherit",
2260+
});
2261+
checkProcess.on("close", function (code) {
2262+
if (code !== 0) {
2263+
done(new Error("check_l10n failed."));
2264+
return;
2265+
}
2266+
done();
2267+
});
2268+
});
2269+
22552270
function ghPagesPrepare() {
22562271
console.log("\n### Creating web site");
22572272

0 commit comments

Comments
 (0)