|
| 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(); |
0 commit comments