Skip to content
Merged
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: 2 additions & 1 deletion .github/zizmor/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# The zizmor image: ci.yml's "Lint workflows" job runs it (its settings are in
# .github/zizmor.yml). Nothing builds this file; ci.yml reads the FROM line. It is here and not
# .github/zizmor.yml), and tools/linux/setup.sh takes the zizmor binary out of it for the WSL
# verify. Nothing builds this file; both read the FROM line. It is here and not
# in ci.yml because Dependabot updates the tag and digest of a Dockerfile's FROM line (the
# docker entry in .github/dependabot.yml) but not a docker:// line in a workflow.
FROM ghcr.io/zizmorcore/zizmor:1.30.1@sha256:a2eb396d886c053073405c7a980f2139ba2248ec172243cfa3841e57196e8101
16 changes: 13 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -866,6 +866,12 @@ kill-on-close job object: check that by hand after a change to the launch.
silently stops being spliced — no error, wrong code ships. Stay on `.mjs`
plus JSDoc.

The code is `tools/splicer.mjs` (tests: `tests/unit/splicer.test.mjs`). A directive is
a `//` comment that is exactly `SPLICER:<TARGET>:<COMMAND>`; any other `// SPLICER:`
comment, unknown target or unknown command fails the build, and the text in a string or
block comment is not a directive (#168). Everything else ships as written, blank lines
included: until 2026-10-03 every blank line was dropped, inside template literals too.

Targets: `EXTENSION`, `FIREFOX`, `WEB`, `NO_UPDATE_CHECKER`; no code carries a
`FIREFOX` block any more, but both Firefox builds still pass it.
(`CENSORYT` and `NO_YOUTUBE` existed before YouTube support was removed
Expand Down Expand Up @@ -998,17 +1004,21 @@ the change went in.
fixtures no longer depend on that (the DASH and HLS ones are encoded with `-bf 0
-sc_threshold 0`; the B-frame one is copied from `sample.mp4`). Before a push, run
both halves of CI here: `pnpm run verify` (Windows), and **`pnpm run verify:linux`**,
which runs CI's Linux verify job and its workflows job (actionlint, and the `run:`
which runs CI's Linux verify job and its workflows job (actionlint, zizmor, and the `run:`
scripts' tests in `tests/workflows`) in WSL, once on each Ubuntu
release CI uses: the one `ubuntu-latest` gives and the newest GitHub offers (24.04 and
26.04 until `ubuntu-latest` has moved, rolled out Oct 19 - Nov 19 2026), read from the runner image table
`runner-images.yml` reads, so the pair follows GitHub; a release WSL lacks is
installed, `--distro Ubuntu-26.04` picks one. Every run first brings the distro up to
date, as a freshly built runner image is: `tools/linux/setup.sh` runs apt update +
full-upgrade, installs the newest release of the Node major `.nvmrc` names
(so it moves with CI) and the current stable Firefox, apt ffmpeg
(so it moves with CI) with the `packageManager` pnpm through its npm (not corepack, which
Node 25+ lacks; Node 26 also needs apt's libatomic1, #238), and the current stable Firefox
(its SHA-512 from the SHA512SUMS Mozilla's release key signs, the key pinned by fingerprint
in setup.sh, #248; Node's sum is still only from nodejs.org itself), apt ffmpeg
with libx264, and the actionlint and shellcheck binaries out of the image digest
`.github/actionlint/Dockerfile` pins (apt's shellcheck is 0.9.0 on 24.04, the image's 0.11.0). Then
`.github/actionlint/Dockerfile` pins (apt's shellcheck is 0.9.0 on 24.04, the image's 0.11.0),
and zizmor's out of the one `.github/zizmor/Dockerfile` pins (#239). Then
`tools/linux/verify.sh` runs on a copy of the working tree with fresh fixtures. wsl.exe
writes stdout and stderr to a redirected file at separate offsets, one over the other,
so both scripts merge them. The Windows build of actionlint hangs driving shellcheck on
Expand Down
145 changes: 1 addition & 144 deletions build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import fs from 'fs';
import path from 'path';
import * as url from 'url';
import webExt from 'web-ext';
import {spliceAndCopy} from './tools/splicer.mjs';

const __dirname = url.fileURLToPath(new URL('.', import.meta.url));
const builtDir = path.resolve(__dirname, 'built');
Expand Down Expand Up @@ -132,150 +133,6 @@ function generateScriptWithAllImports(sourceDir, filePath, resolvedPaths = []) {
return importScripts.join('\n') + '\n' + fileText;
}

function splice(fileText, target, relativePath) {
const lines = fileText.split('\n');
let newLines = [];
let inSplicerRemove = false;
let removedLines = 0;

const initiatorStr = `SPLICER:${target}:`;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const index = line.indexOf(initiatorStr);
if (index >= 0) {
const command = line.substring(index + initiatorStr.length).trim();
if (command === 'REMOVE_FILE') {
console.log(`[Splicer-${target}] Removing file`, relativePath);
return '';
} else if (command === 'REMOVE_LINE') {
console.log(`[Splicer-${target}] Removing line ${i + 1}`, relativePath);
continue;
} else if (command === 'REMOVE_START') {
inSplicerRemove = true;
removedLines = 0;
continue;
} else if (command === 'REMOVE_END') {
if (!inSplicerRemove) {
console.error(`[Splicer-${target}] Unmatched SPLICER:${target}:REMOVE_END`, relativePath);
throw new Error('Unmatched SPLICER');
}
inSplicerRemove = false;
console.log(`[Splicer-${target}] Removing lines ${i - removedLines}-${i + 1}`, relativePath);
continue;
} else if (command === 'INSERT_LOCALE') {
const localesPath = path.join(chromeSourceDir, '_locales/');
// get all locale folders
const localeFolders = fs.readdirSync(localesPath);
const locales = [];

localeFolders.forEach((localeFolder) => {
const localePath = path.join(localesPath, localeFolder, 'messages.json');
// check if file exists
if (!fs.existsSync(localePath)) {
return;
}

const messages = JSON.parse(fs.readFileSync(localePath, 'utf8'));
const translationMap = {};
// Own-property reads only: a messages.json key like __proto__ must not reach
// the lookup (CodeQL js/remote-property-injection).
Object.keys(messages).forEach((key) => {
if (!Object.hasOwn(messages, key)) return;
translationMap[key] = messages[key] && messages[key].message;
});
locales.push({
translationMap,
code: localeFolder,
});
});

// put english first
const defaultLanguage = 'en';
locales.sort((a, b) => {
if (a.code === defaultLanguage) {
return -1;
} else if (b.code === defaultLanguage) {
return 1;
} else {
return a.code.localeCompare(b.code);
}
});

const newLocales = {};
newLocales['LANGUAGES'] = locales.map((locale) => locale.code);
const englishMap = locales[0].translationMap;
Object.keys(englishMap).forEach((key) => {
// Locales that predate a new key would otherwise inline `undefined`
// and render blank strings on the web build; fall back to English.
const translations = locales.map((locale) => locale.translationMap[key] || englishMap[key]);
newLocales[key] = translations;
});

const localeText = JSON.stringify(newLocales, null, 2);
newLines.push(localeText.substring(1, localeText.length - 1).trim());
console.log(`[Splicer-${target}] Inserting locale`, relativePath);
continue;
} else if (command === 'INSERT_VERSION') {
newLines.push(`version = '${packageJson.version}.web';`);
console.log(`[Splicer-${target}] Inserting version`, relativePath);
continue;
}
}

if (inSplicerRemove) {
removedLines++;
continue;
}

newLines.push(line);
}

if (inSplicerRemove) {
console.error(`[Splicer-${target}] Unmatched SPLICER:${target}:REMOVE_START`, relativePath);
throw new Error('Unmatched SPLICER');
}

newLines = newLines.filter((line) => {
return line.trim().length;
});

return newLines.join('\n');
}

function spliceAndCopy(sourceDir, buildDir, spliceTargets = [], excludeFiles = []) {
glob(sourceDir + '/**')
.forEach((file) => {
const fileExtension = path.extname(file);
const relativePath = path.relative(sourceDir, file);
const targetPath = path.resolve(buildDir, relativePath);
const fileText = fs.readFileSync(file, 'utf8');

// See if exclude files shares a prefix with the file
for (let i = 0; i < excludeFiles.length; i++) {
const excludeFile = excludeFiles[i];
if (relativePath.startsWith(excludeFile)) {
return;
}
}

if (fileExtension === '.mjs' || fileExtension === '.js') {
let spliced = fileText;

spliceTargets.forEach((target) => {
spliced = splice(spliced, target, relativePath);
});

if (spliced.length) {
fs.mkdirSync(path.dirname(targetPath), {recursive: true});
fs.writeFileSync(targetPath, spliced + '\n');
}
} else {
fs.mkdirSync(path.dirname(targetPath), {recursive: true});
fs.copyFileSync(file, targetPath);
}
});
}

async function runWebExtBuild(sourceDir, artifactsDir) {
// A failed build rejects: the promise this was wrapped in never settled then, and the
// build hung or died on an unhandled rejection with no word of what failed.
Expand Down
9 changes: 6 additions & 3 deletions docs/amo-source-submission.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Everything below was run on the exact commit the submission was built from.
|---|---|
| Operating system | Any of Linux, macOS or Windows. CI builds on `ubuntu-latest`. |
| Node.js | 22 or newer. `localescript.mjs` uses `Set.prototype.difference`, which Node 20 lacks; `engines` in `package.json` says `>=22`. CI uses the major in `.nvmrc`. |
| Package manager | pnpm 11, the exact version pinned by the `packageManager` field in `package.json` (corepack reads it). |
| Package manager | pnpm 11, the exact version pinned by the `packageManager` field in `package.json` (installed with npm, or with corepack on Node 24 and older). |
| Network access | Needed for `pnpm install` only. The build itself is offline. |

No compilers, native toolchains or system libraries are required. The
Expand All @@ -24,7 +24,7 @@ this build - see "Prebuilt binaries" below.
From the root of the source archive:

```sh
corepack enable # or: npm i -g pnpm@<the version in packageManager>
npm i -g pnpm@<the version in packageManager> # or, on Node 24 and older: corepack enable
pnpm install --frozen-lockfile
pnpm run build:keep
```
Expand Down Expand Up @@ -96,7 +96,10 @@ file times.
### The splicer

`build.mjs` preprocesses sources using `SPLICER:<TARGET>:` comments embedded
in the code. For the AMO target the active tags are:
in the code (`tools/splicer.mjs`). Apart from the lines those comments remove or
insert, every script ships as it is in the tree (a missing final newline is
added). For the AMO target the
active tags are:

```
EXTENSION FIREFOX NO_UPDATE_CHECKER
Expand Down
37 changes: 26 additions & 11 deletions generate-locales.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@ import https from 'https';
import * as url from 'url';

// Simple CLI usage:
// node generate-locales.mjs --lang fr [--source en] [--model gpt-4o-mini] [--input combined-locales.json] [--output combined-locales.json] [--overwrite] [--dry-run] [--batch 25] [--threads 5]
// node generate-locales.mjs --lang fr [--source en] [--model gpt-5-mini] [--input combined-locales.json] [--output combined-locales.json] [--overwrite] [--dry-run] [--batch 25] [--threads 10] [--timeout 300]

const __dirname = url.fileURLToPath(new URL('.', import.meta.url));

// A language code is 2-3 letters or 2-3 letters, a script tag of 4 and a region of 2-4, as
// chrome/_locales names them (fr, pt_BR, zh_CN): anything else (a key like __proto__, or one
// with a path) never writes into the locales (CodeQL js/remote-property-injection).
const LANG_SHAPE = /^[a-z]{2,3}(?:[-_][A-Za-z]{4})?(?:[-_](?:[A-Z]{2}|\d{3}))?$/;

function parseArgs(argv) {
const opts = {
lang: null,
Expand All @@ -19,6 +24,7 @@ function parseArgs(argv) {
dryRun: false,
batch: 25,
threads: 10,
timeout: 300,
};

for (let i = 0; i < argv.length; i++) {
Expand All @@ -32,6 +38,7 @@ function parseArgs(argv) {
else if (a === '--dry-run') opts.dryRun = true;
else if (a === '--batch') opts.batch = parseInt(argv[++i], 10) || opts.batch;
else if (a === '--threads') opts.threads = Math.max(1, parseInt(argv[++i], 10) || opts.threads);
else if (a === '--timeout') opts.timeout = Math.max(1, parseInt(argv[++i], 10) || opts.timeout);
else if (a === '--help' || a === '-h') {
printHelpAndExit();
} else {
Expand All @@ -44,24 +51,31 @@ function parseArgs(argv) {
console.error('Missing required --lang <code>');
printHelpAndExit(1);
}
// Checked here, before any request: it used to be checked only when the translations were
// applied, after every batch had been paid for, and then none was (#246).
if (!LANG_SHAPE.test(opts.lang)) {
console.error(`--lang ${opts.lang}: not a locale code as chrome/_locales names them (fr, pt_BR, zh_CN)`);
printHelpAndExit(1);
}
return opts;
}

function printHelpAndExit(code = 0) {
console.log(`Usage: node generate-locales.mjs --lang <code> [options]

Required:
--lang <code> Target language code (e.g., fr, zh-cn)
--lang <code> Target language code, as chrome/_locales names it (e.g., fr, pt_BR, zh_CN)

Options:
--source <code> Source language code to translate from (default: en)
--model <name> OpenAI model (default: OPENAI_MODEL or gpt-4o-mini). Pass your preferred model, e.g. chatgpt-5 if available in your org
--model <name> OpenAI model (default: OPENAI_MODEL or gpt-5-mini). Pass your preferred model, e.g. chatgpt-5 if available in your org
--input <file> Input combined locales JSON (default: combined-locales.json)
--output <file> Output combined locales JSON (in-place by default)
--overwrite Also refresh existing translations for the target language
--dry-run Do not write files; print proposed changes
--batch <n> Number of keys per request (default: 25)
--threads <n> Number of concurrent batches (default: 5)
--threads <n> Number of concurrent batches (default: 10)
--timeout <seconds> Give up on a request with no answer after this long, and retry (default: 300)

Environment:
OPENAI_API_KEY Your OpenAI API key
Expand Down Expand Up @@ -104,7 +118,7 @@ function chunk(arr, size) {
return out;
}

function callOpenAI({model, messages}) {
function callOpenAI({model, messages, timeoutSeconds}) {
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
throw new Error('OPENAI_API_KEY is not set');
Expand Down Expand Up @@ -147,6 +161,10 @@ function callOpenAI({model, messages}) {
});
});
req.on('error', reject);
// A request that never got an answer used to wait forever (#246).
req.setTimeout(timeoutSeconds * 1000, () => {
req.destroy(new Error(`no answer from ${urlObj.origin} in ${timeoutSeconds} s`));
});
req.write(payload);
req.end();
});
Expand Down Expand Up @@ -188,7 +206,7 @@ async function translateBatch(opts, batch, feedback = null) {
pairs: batch,
feedback,
});
const content = await callOpenAI({model: opts.model, messages});
const content = await callOpenAI({model: opts.model, messages, timeoutSeconds: opts.timeout});
let obj;
try {
obj = JSON.parse(content);
Expand Down Expand Up @@ -354,15 +372,12 @@ async function run() {

// Apply results
let changed = 0;
// A language code is 2-3 letters or 2-3 letters, a script tag of 4 and a region of
// 2-4: anything else (a key like __proto__, or one with a path) never writes into
// items (CodeQL js/remote-property-injection).
const langShape = /^[a-z]{2,3}(?:[-_][A-Za-z]{4})?(?:[-_](?:[A-Z]{2}|\d{3}))?$/;
// parseArgs refused any other --lang; checked again where it is written (LANG_SHAPE).
for (const [key, translation] of Object.entries(results)) {
if (!Object.hasOwn(items, key)) continue; // safety (own-property only)
if (typeof translation !== 'string') continue;
if (typeof items[key] !== 'object' || items[key] === null) continue;
if (!langShape.test(opts.lang)) continue;
if (!LANG_SHAPE.test(opts.lang)) continue;
if (!opts.overwrite && items[key][opts.lang]) continue;
items[key][opts.lang] = translation;
changed++;
Expand Down
Loading
Loading