Merge pull request #20747 from calixteman/check__l10n

Add a script for searching the unused fluent ids
This commit is contained in:
calixteman 2026-02-27 22:19:50 +01:00 committed by GitHub
commit b4f98e8a9b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 206 additions and 1 deletions

View File

@ -479,7 +479,7 @@ export default [
Other
\* ======================================================================== */
{
files: ["gulpfile.mjs"],
files: ["gulpfile.mjs", "check_l10n.mjs"],
languageOptions: { globals: globals.node },
},
];

190
external/check_l10n/check_l10n.mjs vendored Normal file
View File

@ -0,0 +1,190 @@
#!/usr/bin/env node
/* Copyright 2026 Mozilla Foundation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Checks that every message ID defined in l10n/en-US/viewer.ftl is referenced
* in at least one HTML or JS/MJS file under the web/ directory.
*
* Usage: node external/check_l10n/check_l10n.mjs
*/
import { extname, join } from "path";
import { readdirSync, readFileSync, statSync } from "fs";
const ROOT = join(import.meta.dirname, "..", "..");
const FTL_PATH = join(ROOT, "l10n", "en-US", "viewer.ftl");
const SEARCH_DIRS = ["web", "src"];
const SEARCH_EXTENSIONS = new Set([".html", ".js", ".mjs"]);
// Minimum number of characters a prefix or suffix fragment must have to be
// considered a meaningful match when detecting dynamically-built IDs.
const MIN_FRAGMENT_LENGTH = 6;
/**
* Extract all message IDs from a Fluent (.ftl) file.
* A message ID is an identifier at the start of a line followed by " =".
* @param {string} ftlPath - Absolute path to the .ftl file.
* @returns {string[]} Ordered list of message IDs.
*/
function extractFtlIds(ftlPath) {
const lines = readFileSync(ftlPath, "utf8").split("\n");
const ids = [];
for (const line of lines) {
const match = line.match(/^([a-zA-Z][a-zA-Z0-9-]*)\s*=/);
if (match) {
ids.push(match[1]);
}
}
return ids;
}
/**
* Recursively collect all files with matching extensions under a directory.
* @param {string} dir - Directory to walk.
* @param {Set<string>} extensions - Allowed file extensions (e.g. `".js"`).
* @returns {string[]} Absolute paths of matching files.
*/
function collectFiles(dir, extensions) {
const results = [];
for (const entry of readdirSync(dir)) {
const fullPath = join(dir, entry);
const stat = statSync(fullPath);
if (stat.isDirectory()) {
results.push(...collectFiles(fullPath, extensions));
} else if (extensions.has(extname(entry))) {
results.push(fullPath);
}
}
return results;
}
/**
* Load the contents of all source files found under the given directories.
* @param {string[]} dirs - Directory names relative to ROOT.
* @param {Set<string>} extensions - Allowed file extensions.
* @returns {{ path: string, content: string }[]}
*/
function loadSources(dirs, extensions) {
const files = dirs.flatMap(d => collectFiles(join(ROOT, d), extensions));
return files.map(f => ({ path: f, content: readFileSync(f, "utf8") }));
}
/**
* Check whether a message ID appears as a quoted string literal in any source
* file. Handles double quotes, single quotes, and backticks, covering:
* - `data-l10n-id="pdfjs-foo"` (HTML attribute)
* - `"pdfjs-foo"` / `'pdfjs-foo'` / `` `pdfjs-foo` `` (JS string literals,
* `setAttribute`, `l10n.get`, )
* @param {string} id - Message ID to look up.
* @param {{ path: string, content: string }[]} sources
* @returns {boolean}
*/
function isUsed(id, sources) {
const dq = `"${id}"`;
const sq = `'${id}'`;
const bt = `\`${id}\``;
return sources.some(
({ content }) =>
content.includes(dq) || content.includes(sq) || content.includes(bt)
);
}
/**
* For IDs not found as complete literals, check whether the ID is likely
* constructed dynamically via a template literal such as:
* `pdfjs-editor-${editorType}-added-alert`
*
* Strategy: try every (prefix, suffix) pair obtained by splitting the ID's
* dash-separated components, leaving one or more components as the "variable"
* gap. The prefix must appear immediately followed by `${` in a template
* literal; the suffix (if non-empty) must also appear in the same file.
* Minimum length guards prevent matches on trivially short fragments.
*
* @param {string} id - Message ID to test.
* @param {{ path: string, content: string }[]} sources
* @returns {{ path: string, line: number } | null} Location of the first
* matching template literal, or `null` if none found.
*/
function findDynamicLocation(id, sources) {
const parts = id.split("-");
// i = end of prefix (exclusive), j = start of suffix (inclusive)
for (let i = 1; i < parts.length; i++) {
for (let j = i + 1; j <= parts.length; j++) {
const prefix = parts.slice(0, i).join("-") + "-"; // e.g. "pdfjs-editor-"
const suffix = j < parts.length ? "-" + parts.slice(j).join("-") : ""; // e.g. "-added-alert"
if (prefix.length < MIN_FRAGMENT_LENGTH) {
continue;
}
if (suffix !== "" && suffix.length < MIN_FRAGMENT_LENGTH) {
continue;
}
// The prefix must be immediately followed by "${" in a template literal.
const prefixWithVar = prefix + "${";
for (const { path, content } of sources) {
if (
content.includes(prefixWithVar) &&
(suffix === "" || content.includes(suffix))
) {
const idx = content.indexOf(prefixWithVar);
const line = content.slice(0, idx).split("\n").length;
return { path, line };
}
}
}
}
return null;
}
function main() {
const ids = extractFtlIds(FTL_PATH);
console.log(`Found ${ids.length} message IDs in viewer.ftl\n`);
const sources = loadSources(SEARCH_DIRS, SEARCH_EXTENSIONS);
console.log(
`Searching in ${sources.length} files under: ${SEARCH_DIRS.join(", ")}\n`
);
const notFound = ids.filter(id => !isUsed(id, sources));
const dynamicEntries = notFound
.map(id => ({ id, loc: findDynamicLocation(id, sources) }))
.filter(({ loc }) => loc !== null);
const dynamicIds = new Set(dynamicEntries.map(({ id }) => id));
const unused = notFound.filter(id => !dynamicIds.has(id));
if (dynamicEntries.length > 0) {
console.log(
`~ ${dynamicEntries.length} ID(s) likely built dynamically (template literals):\n`
);
for (const { id, loc } of dynamicEntries) {
const rel = loc.path.replace(ROOT + "/", "").replace(ROOT + "\\", "");
console.log(` ${id}`);
console.log(`${rel}:${loc.line}`);
}
console.log();
}
if (unused.length === 0) {
console.log("✓ All remaining message IDs are used.");
} else {
console.log(`${unused.length} unused message ID(s):\n`);
for (const id of unused) {
console.log(` ${id}`);
}
process.exitCode = 1;
}
}
main();

View File

@ -2252,6 +2252,21 @@ gulp.task("importl10n", async function () {
await downloadL10n(L10N_DIR);
});
gulp.task("check_l10n", function (done) {
console.log("\n### Checking for unused l10n IDs");
const checkProcess = startNode(["external/check_l10n/check_l10n.mjs"], {
stdio: "inherit",
});
checkProcess.on("close", function (code) {
if (code !== 0) {
done(new Error("check_l10n failed."));
return;
}
done();
});
});
function ghPagesPrepare() {
console.log("\n### Creating web site");