@hominis/fireforge 0.14.0 → 0.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +41 -3
  3. package/dist/src/commands/build.js +12 -1
  4. package/dist/src/commands/furnace/create-templates.d.ts +47 -0
  5. package/dist/src/commands/furnace/create-templates.js +135 -0
  6. package/dist/src/commands/furnace/create-xpcshell.d.ts +27 -0
  7. package/dist/src/commands/furnace/create-xpcshell.js +53 -0
  8. package/dist/src/commands/furnace/create.js +81 -109
  9. package/dist/src/commands/furnace/deploy.js +3 -3
  10. package/dist/src/commands/furnace/index.js +1 -0
  11. package/dist/src/commands/setup.d.ts +1 -1
  12. package/dist/src/commands/setup.js +3 -2
  13. package/dist/src/commands/test.js +20 -0
  14. package/dist/src/core/build-prepare.js +6 -1
  15. package/dist/src/core/config-paths.d.ts +2 -2
  16. package/dist/src/core/config-paths.js +2 -0
  17. package/dist/src/core/config-validate.js +32 -0
  18. package/dist/src/core/furnace-apply-ftl.d.ts +33 -0
  19. package/dist/src/core/furnace-apply-ftl.js +102 -0
  20. package/dist/src/core/furnace-apply-helpers.d.ts +10 -1
  21. package/dist/src/core/furnace-apply-helpers.js +16 -12
  22. package/dist/src/core/furnace-apply.js +7 -4
  23. package/dist/src/core/furnace-config-tokens.d.ts +17 -0
  24. package/dist/src/core/furnace-config-tokens.js +43 -0
  25. package/dist/src/core/furnace-config.d.ts +6 -0
  26. package/dist/src/core/furnace-config.js +16 -3
  27. package/dist/src/core/furnace-constants.d.ts +20 -0
  28. package/dist/src/core/furnace-constants.js +32 -0
  29. package/dist/src/core/furnace-registration-ast.d.ts +13 -1
  30. package/dist/src/core/furnace-registration-ast.js +58 -25
  31. package/dist/src/core/furnace-registration.d.ts +27 -0
  32. package/dist/src/core/furnace-registration.js +96 -0
  33. package/dist/src/core/furnace-validate-accessibility.js +8 -2
  34. package/dist/src/core/furnace-validate-compatibility.js +18 -7
  35. package/dist/src/core/furnace-validate-helpers.d.ts +39 -1
  36. package/dist/src/core/furnace-validate-helpers.js +182 -18
  37. package/dist/src/core/furnace-validate-registration.d.ts +8 -2
  38. package/dist/src/core/furnace-validate-registration.js +34 -9
  39. package/dist/src/core/furnace-validate.js +2 -2
  40. package/dist/src/core/marionette-preflight.d.ts +46 -0
  41. package/dist/src/core/marionette-preflight.js +260 -0
  42. package/dist/src/core/patch-lint-cross.d.ts +1 -1
  43. package/dist/src/core/patch-lint-cross.js +1 -1
  44. package/dist/src/core/patch-lint.js +29 -9
  45. package/dist/src/types/commands/options.d.ts +16 -0
  46. package/dist/src/types/config.d.ts +7 -0
  47. package/dist/src/types/furnace.d.ts +19 -0
  48. package/dist/src/utils/process.d.ts +15 -2
  49. package/dist/src/utils/process.js +73 -0
  50. package/package.json +1 -1
@@ -6,6 +6,7 @@ import { FurnaceError } from '../errors/furnace.js';
6
6
  import { toError } from '../utils/errors.js';
7
7
  import { copyFile, ensureDir, pathExists, readText, removeFile } from '../utils/fs.js';
8
8
  import { verbose } from '../utils/logger.js';
9
+ import { applyCustomFtlFile, describeLocaleFtlJarMnRegistration, removeCustomFtlJarMnEntry, } from './furnace-apply-ftl.js';
9
10
  import { CUSTOM_ELEMENTS_JS, JAR_MN } from './furnace-constants.js';
10
11
  import { addCustomElementRegistration, addJarMnEntries, validateCustomElementRegistration, validateJarMnEntries, } from './furnace-registration.js';
11
12
  import { recordCreatedDir, snapshotFile } from './furnace-rollback.js';
@@ -138,6 +139,10 @@ export async function undeployCustomFiles(engineDir, config, deletedFiles, ftlDi
138
139
  await removeFile(enginePath);
139
140
  removed.push(relative(engineDir, enginePath));
140
141
  }
142
+ // When an `.ftl` is deleted from the workspace, the corresponding locale
143
+ // jar.mn entry must also be dropped — otherwise the chrome URI points at
144
+ // a missing file and runtime Fluent resolution breaks silently.
145
+ await removeCustomFtlJarMnEntry(engineDir, fileName, ftlDir, rollbackJournal);
141
146
  }
142
147
  return removed;
143
148
  }
@@ -312,6 +317,10 @@ async function buildCustomDryRunActions(name, componentDir, engineDir, config, t
312
317
  target: join(engineDir, ftlDir, ftlFile),
313
318
  description: `Copy ${ftlFile} to ${ftlDir}`,
314
319
  });
320
+ const localeAction = describeLocaleFtlJarMnRegistration(name, ftlDir, ftlFile);
321
+ if (localeAction) {
322
+ actions.push(localeAction);
323
+ }
315
324
  }
316
325
  }
317
326
  if (config.register) {
@@ -353,7 +362,7 @@ async function buildCustomDryRunActions(name, componentDir, engineDir, config, t
353
362
  return { actions, stepErrors };
354
363
  }
355
364
  /** Applies a custom component into the engine tree and captures registration step errors. */
356
- export async function applyCustomComponent(engineDir, name, componentDir, config, ftlDir, dryRun = false, rollbackJournal) {
365
+ export async function applyCustomComponent(engineDir, name, componentDir, config, ftlDir, dryRun = false, rollbackJournal, applyOptions = {}) {
357
366
  if (!/^[a-z][a-z0-9-]*$/.test(name)) {
358
367
  throw new FurnaceError(`Invalid component name "${name}": must match /^[a-z][a-z0-9-]*$/`);
359
368
  }
@@ -394,16 +403,7 @@ export async function applyCustomComponent(engineDir, name, componentDir, config
394
403
  copiedFileNames.push(entry.name);
395
404
  }));
396
405
  if (config.localized) {
397
- const ftlFile = `${name}.ftl`;
398
- const ftlSrc = join(componentDir, ftlFile);
399
- if (await pathExists(ftlSrc)) {
400
- const ftlDest = join(engineDir, ftlDir, ftlFile);
401
- if (rollbackJournal) {
402
- await snapshotFile(rollbackJournal, ftlDest);
403
- }
404
- await copyFile(ftlSrc, ftlDest);
405
- affectedPaths.push(relative(engineDir, ftlDest));
406
- }
406
+ await applyCustomFtlFile(engineDir, name, componentDir, ftlDir, affectedPaths, stepErrors, rollbackJournal);
407
407
  }
408
408
  if (config.register) {
409
409
  try {
@@ -411,7 +411,11 @@ export async function applyCustomComponent(engineDir, name, componentDir, config
411
411
  if (rollbackJournal) {
412
412
  await snapshotFile(rollbackJournal, join(engineDir, CUSTOM_ELEMENTS_JS));
413
413
  }
414
- await addCustomElementRegistration(engineDir, name, modulePath);
414
+ await addCustomElementRegistration(engineDir, name, modulePath, {
415
+ ...(applyOptions.markerComment !== undefined
416
+ ? { markerComment: applyOptions.markerComment }
417
+ : {}),
418
+ });
415
419
  affectedPaths.push(CUSTOM_ELEMENTS_JS);
416
420
  }
417
421
  catch (error) {
@@ -4,7 +4,7 @@ import { FurnaceError } from '../errors/furnace.js';
4
4
  import { toError } from '../utils/errors.js';
5
5
  import { pathExists } from '../utils/fs.js';
6
6
  import { info } from '../utils/logger.js';
7
- import { getProjectPaths } from './config.js';
7
+ import { getProjectPaths, loadConfig } from './config.js';
8
8
  import { applyCustomComponent, applyOverrideComponent, computeComponentChecksums, diffDeletedFiles, extractComponentChecksums, getOverrideEngineTargetPath, hasComponentChanged, hasCustomEngineDrift, hasOverrideEngineDrift, prefixChecksums, undeployCustomFiles, undeployOverrideFiles, } from './furnace-apply-helpers.js';
9
9
  import { getFurnacePaths, loadFurnaceConfig, loadFurnaceState, updateFurnaceState, } from './furnace-config.js';
10
10
  import { CUSTOM_ELEMENTS_JS, JAR_MN, resolveFtlDir } from './furnace-constants.js';
@@ -161,7 +161,7 @@ async function reconcileCustomRegistrationAfterUndeploy(engineDir, name, config,
161
161
  filesAffected.push(CUSTOM_ELEMENTS_JS);
162
162
  }
163
163
  }
164
- async function applyCustomBatch(root, config, furnacePaths, state, engineDir, ftlDir, dryRun, result, allActions, newChecksums, rollbackJournal, componentName) {
164
+ async function applyCustomBatch(root, config, furnacePaths, state, engineDir, ftlDir, dryRun, result, allActions, newChecksums, rollbackJournal, componentName, markerComment) {
165
165
  const allKnown = new Set([
166
166
  ...config.stock,
167
167
  ...Object.keys(config.overrides),
@@ -246,7 +246,7 @@ async function applyCustomBatch(root, config, furnacePaths, state, engineDir, ft
246
246
  const removed = await undeployCustomFiles(engineDir, customConfig, deletedFiles, ftlDir, rollbackJournal);
247
247
  filesAffectedTotal.push(...removed);
248
248
  }
249
- const { affectedPaths: filesAffected, stepErrors, actions, } = await applyCustomComponent(engineDir, name, componentDir, customConfig, ftlDir, dryRun, rollbackJournal);
249
+ const { affectedPaths: filesAffected, stepErrors, actions, } = await applyCustomComponent(engineDir, name, componentDir, customConfig, ftlDir, dryRun, rollbackJournal, markerComment !== undefined ? { markerComment } : {});
250
250
  if (dryRun && actions) {
251
251
  allActions.push(...actions);
252
252
  }
@@ -304,6 +304,9 @@ export async function applyAllComponents(root, dryRun = false, options) {
304
304
  const { engine: engineDir } = getProjectPaths(root);
305
305
  const furnacePaths = getFurnacePaths(root);
306
306
  const ftlDir = resolveFtlDir(config.ftlBasePath);
307
+ const markerComment = await loadConfig(root)
308
+ .then((forgeConfig) => forgeConfig.markerComment)
309
+ .catch(() => undefined);
307
310
  if (!(await pathExists(engineDir))) {
308
311
  throw new FurnaceError('Engine directory not found. Run "fireforge download" first.');
309
312
  }
@@ -328,7 +331,7 @@ export async function applyAllComponents(root, dryRun = false, options) {
328
331
  }
329
332
  }
330
333
  await applyOverrideBatch(config, furnacePaths, state, engineDir, ftlDir, dryRun, result, allActions, newChecksums, rollbackJournal, componentName);
331
- await applyCustomBatch(root, config, furnacePaths, state, engineDir, ftlDir, dryRun, result, allActions, newChecksums, rollbackJournal, componentName);
334
+ await applyCustomBatch(root, config, furnacePaths, state, engineDir, ftlDir, dryRun, result, allActions, newChecksums, rollbackJournal, componentName, markerComment);
332
335
  // Check for any partial failures (step errors on applied components).
333
336
  const hasStepErrors = result.applied.some((entry) => 'stepErrors' in entry && entry.stepErrors.length > 0);
334
337
  // Orphaned components are implicitly cleaned up: newChecksums only
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Small validation helpers for furnace.json token-related fields. Extracted
3
+ * from `furnace-config.ts` so the main config module stays under the per-file
4
+ * LOC budget.
5
+ */
6
+ /**
7
+ * Validates a `tokenHostDocuments` raw value. Each entry must be a non-empty
8
+ * relative path contained in the engine tree. Throws `FurnaceError` on
9
+ * violation; does nothing for `undefined` (field is optional).
10
+ */
11
+ export declare function validateTokenHostDocuments(raw: unknown): void;
12
+ /**
13
+ * Validates a `runtimeVariables` raw value. Each entry must start with `--`
14
+ * (it is a CSS custom property name). Throws `FurnaceError` on violation;
15
+ * does nothing for `undefined` (field is optional).
16
+ */
17
+ export declare function validateRuntimeVariables(raw: unknown): void;
@@ -0,0 +1,43 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Small validation helpers for furnace.json token-related fields. Extracted
4
+ * from `furnace-config.ts` so the main config module stays under the per-file
5
+ * LOC budget.
6
+ */
7
+ import { FurnaceError } from '../errors/furnace.js';
8
+ import { isContainedRelativePath } from '../utils/paths.js';
9
+ import { parseStringArray } from './furnace-config.js';
10
+ /**
11
+ * Validates a `tokenHostDocuments` raw value. Each entry must be a non-empty
12
+ * relative path contained in the engine tree. Throws `FurnaceError` on
13
+ * violation; does nothing for `undefined` (field is optional).
14
+ */
15
+ export function validateTokenHostDocuments(raw) {
16
+ if (raw === undefined)
17
+ return;
18
+ const docs = parseStringArray(raw, 'tokenHostDocuments');
19
+ for (const doc of docs) {
20
+ if (doc.trim() === '') {
21
+ throw new FurnaceError('Furnace config: "tokenHostDocuments" entries must be non-empty strings');
22
+ }
23
+ if (!isContainedRelativePath(doc)) {
24
+ throw new FurnaceError(`Furnace config: "tokenHostDocuments" entry "${doc}" must stay within the engine tree (no absolute paths, no "..")`);
25
+ }
26
+ }
27
+ }
28
+ /**
29
+ * Validates a `runtimeVariables` raw value. Each entry must start with `--`
30
+ * (it is a CSS custom property name). Throws `FurnaceError` on violation;
31
+ * does nothing for `undefined` (field is optional).
32
+ */
33
+ export function validateRuntimeVariables(raw) {
34
+ if (raw === undefined)
35
+ return;
36
+ const vars = parseStringArray(raw, 'runtimeVariables');
37
+ for (const name of vars) {
38
+ if (!name.startsWith('--')) {
39
+ throw new FurnaceError(`Furnace config: "runtimeVariables" entries must start with "--" (got ${JSON.stringify(name)})`);
40
+ }
41
+ }
42
+ }
43
+ //# sourceMappingURL=furnace-config-tokens.js.map
@@ -38,6 +38,12 @@ export declare function getFurnacePaths(root: string): FurnacePaths;
38
38
  * @returns True if furnace.json exists
39
39
  */
40
40
  export declare function furnaceConfigExists(root: string): Promise<boolean>;
41
+ /**
42
+ * Validates an override component config object.
43
+ * @param data - Raw data to validate
44
+ * @param name - Component name for error messages
45
+ */
46
+ export declare function parseStringArray(value: unknown, fieldName: string): string[];
41
47
  /**
42
48
  * Migrates a furnace config from an older schema version to the current one.
43
49
  * Returns the data unchanged if it is already at the current version.
@@ -7,6 +7,7 @@ import { warn } from '../utils/logger.js';
7
7
  import { isExplicitAbsolutePath } from '../utils/paths.js';
8
8
  import { isArray, isBoolean, isObject, isString } from '../utils/validation.js';
9
9
  import { FIREFORGE_DIR } from './config.js';
10
+ import { validateRuntimeVariables, validateTokenHostDocuments } from './furnace-config-tokens.js';
10
11
  import { resolveFtlDir } from './furnace-constants.js';
11
12
  import { detectComposesCycles, validateComposesReferences } from './furnace-graph-utils.js';
12
13
  import { quarantineStateFile, withStateFileLock } from './state-file.js';
@@ -50,7 +51,7 @@ export async function furnaceConfigExists(root) {
50
51
  * @param data - Raw data to validate
51
52
  * @param name - Component name for error messages
52
53
  */
53
- function parseStringArray(value, fieldName) {
54
+ export function parseStringArray(value, fieldName) {
54
55
  if (!isArray(value)) {
55
56
  throw new FurnaceError(`Furnace config: "${fieldName}" must be an array`);
56
57
  }
@@ -182,6 +183,12 @@ export function validateFurnaceConfig(data) {
182
183
  if (migrated['tokenAllowlist'] !== undefined) {
183
184
  parseStringArray(migrated['tokenAllowlist'], 'tokenAllowlist');
184
185
  }
186
+ // Validate optional runtimeVariables — CSS runtime state channels
187
+ // (e.g. `--cam-x`) that are exempt from `token-prefix-violation`.
188
+ validateRuntimeVariables(migrated['runtimeVariables']);
189
+ // Validate optional tokenHostDocuments — list of chrome XHTMLs that the
190
+ // `missing-token-link` validator scans for the tokens CSS link.
191
+ validateTokenHostDocuments(migrated['tokenHostDocuments']);
185
192
  const stock = parseStringArray(migrated['stock'], 'stock');
186
193
  const stockSet = new Set();
187
194
  for (const name of stock) {
@@ -232,12 +239,18 @@ export function validateFurnaceConfig(data) {
232
239
  overrides,
233
240
  custom,
234
241
  };
235
- if (migrated['tokenPrefix'] !== undefined) {
242
+ if (migrated['tokenPrefix'] !== undefined)
236
243
  config.tokenPrefix = migrated['tokenPrefix'];
237
- }
238
244
  if (migrated['tokenAllowlist'] !== undefined) {
239
245
  config.tokenAllowlist = parseStringArray(migrated['tokenAllowlist'], 'tokenAllowlist');
240
246
  }
247
+ if (migrated['runtimeVariables'] !== undefined) {
248
+ config.runtimeVariables = parseStringArray(migrated['runtimeVariables'], 'runtimeVariables');
249
+ }
250
+ if (migrated['tokenHostDocuments'] !== undefined) {
251
+ const docs = parseStringArray(migrated['tokenHostDocuments'], 'tokenHostDocuments');
252
+ config.tokenHostDocuments = docs;
253
+ }
241
254
  // Validate optional ftlBasePath
242
255
  if (migrated['ftlBasePath'] !== undefined) {
243
256
  if (!isString(migrated['ftlBasePath'])) {
@@ -13,6 +13,26 @@ export declare function isComponentSourceFile(fileName: string): boolean;
13
13
  * `furnace.json` over the built-in default.
14
14
  */
15
15
  export declare function resolveFtlDir(configuredPath?: string): string;
16
+ /**
17
+ * Resolves the chrome sub-path that `document.l10n` / `insertFTLIfNeeded`
18
+ * expects for a given `ftlBasePath`. Strips the mandatory `locales/<LOCALE>/`
19
+ * segment. For the default `toolkit/locales/en-US/toolkit/global` this yields
20
+ * `toolkit/global`.
21
+ *
22
+ * Returns `undefined` when no `locales/<LOCALE>/` segment is present. Callers
23
+ * must treat that as "cannot confidently locate the locale jar.mn entry" and
24
+ * degrade gracefully rather than inventing a path.
25
+ */
26
+ export declare function resolveFtlChromeSubPath(ftlBasePath?: string): string | undefined;
27
+ /**
28
+ * Returns the engine-relative locale jar.mn that owns the FTL tree for a
29
+ * given `ftlBasePath`. For the default toolkit tree this yields
30
+ * `toolkit/locales/jar.mn`.
31
+ *
32
+ * Returns `undefined` when the path does not contain a `locales/` segment —
33
+ * callers must treat that as "cannot locate" and degrade gracefully.
34
+ */
35
+ export declare function resolveFtlLocaleJarMnPath(ftlBasePath?: string): string | undefined;
16
36
  /**
17
37
  * Converts a kebab-case tag name to PascalCase class name.
18
38
  * e.g. "moz-sidebar-panel" → "MozSidebarPanel"
@@ -18,6 +18,38 @@ export function isComponentSourceFile(fileName) {
18
18
  export function resolveFtlDir(configuredPath) {
19
19
  return configuredPath ?? FTL_DIR;
20
20
  }
21
+ /**
22
+ * Resolves the chrome sub-path that `document.l10n` / `insertFTLIfNeeded`
23
+ * expects for a given `ftlBasePath`. Strips the mandatory `locales/<LOCALE>/`
24
+ * segment. For the default `toolkit/locales/en-US/toolkit/global` this yields
25
+ * `toolkit/global`.
26
+ *
27
+ * Returns `undefined` when no `locales/<LOCALE>/` segment is present. Callers
28
+ * must treat that as "cannot confidently locate the locale jar.mn entry" and
29
+ * degrade gracefully rather than inventing a path.
30
+ */
31
+ export function resolveFtlChromeSubPath(ftlBasePath) {
32
+ const path = (ftlBasePath ?? FTL_DIR).replace(/\\/g, '/');
33
+ const match = /^(.*?)\/locales\/[^/]+\/(.+?)\/?$/.exec(path);
34
+ if (!match?.[2])
35
+ return undefined;
36
+ return match[2];
37
+ }
38
+ /**
39
+ * Returns the engine-relative locale jar.mn that owns the FTL tree for a
40
+ * given `ftlBasePath`. For the default toolkit tree this yields
41
+ * `toolkit/locales/jar.mn`.
42
+ *
43
+ * Returns `undefined` when the path does not contain a `locales/` segment —
44
+ * callers must treat that as "cannot locate" and degrade gracefully.
45
+ */
46
+ export function resolveFtlLocaleJarMnPath(ftlBasePath) {
47
+ const path = (ftlBasePath ?? FTL_DIR).replace(/\\/g, '/');
48
+ const match = /^(.*?)\/locales\/[^/]+\//.exec(path);
49
+ if (!match?.[1])
50
+ return undefined;
51
+ return `${match[1]}/locales/jar.mn`;
52
+ }
21
53
  /**
22
54
  * Converts a kebab-case tag name to PascalCase class name.
23
55
  * e.g. "moz-sidebar-panel" → "MozSidebarPanel"
@@ -2,6 +2,18 @@
2
2
  * AST-based custom element registration updates for customElements.js.
3
3
  * Removal logic is in furnace-registration-remove.ts.
4
4
  */
5
+ /**
6
+ * Options that shape the lines `addCustomElementRegistration` writes into
7
+ * `customElements.js`. Kept optional so existing call sites keep working.
8
+ */
9
+ export interface RegistrationWriteOptions {
10
+ /**
11
+ * Trailing project marker appended to every inserted line (e.g. `"MYBROWSER"`
12
+ * produces ` // MYBROWSER:` at end-of-line). Keeps modifications discoverable
13
+ * without requiring the operator to hand-tag them post-apply.
14
+ */
15
+ markerComment?: string;
16
+ }
5
17
  export { removeCustomElementRegistration } from './furnace-registration-remove.js';
6
18
  export { CUSTOM_ELEMENTS_JS, JAR_MN } from './furnace-constants.js';
7
19
  /**
@@ -21,7 +33,7 @@ export { CUSTOM_ELEMENTS_JS, JAR_MN } from './furnace-constants.js';
21
33
  * @param tagName - Custom element tag name
22
34
  * @param modulePath - chrome:// URI for the module
23
35
  */
24
- export declare function addCustomElementRegistration(engineDir: string, tagName: string, modulePath: string): Promise<void>;
36
+ export declare function addCustomElementRegistration(engineDir: string, tagName: string, modulePath: string, options?: RegistrationWriteOptions): Promise<void>;
25
37
  /**
26
38
  * Validates that a custom element registration *would* succeed without
27
39
  * writing anything. Used by dry-run to surface registration errors early.
@@ -9,9 +9,43 @@ import { FurnaceError } from '../errors/furnace.js';
9
9
  import { toError } from '../utils/errors.js';
10
10
  import { pathExists, readText, writeText } from '../utils/fs.js';
11
11
  import { verbose, warn } from '../utils/logger.js';
12
+ import { escapeRegex } from '../utils/regex.js';
12
13
  import { detectIndent, getNodeSource, parseScript, walkAST, } from './ast-utils.js';
13
14
  import { CUSTOM_ELEMENTS_JS } from './furnace-constants.js';
14
15
  import { validateRegistrationPlacement, validateTagName } from './furnace-registration-validate.js';
16
+ /**
17
+ * Returns true when `content` already contains a registration for `tagName`.
18
+ *
19
+ * Tolerates trailing line comments (e.g. a project marker like `// MYBROWSER:`)
20
+ * that an operator may have appended to entries written by a previous apply.
21
+ * Without this, a re-apply would insert a duplicate entry, and the second
22
+ * `setElementCreationCallback` at window-load would throw `NotSupportedError`.
23
+ *
24
+ * Matches any of:
25
+ * 1. `setElementCreationCallback("tag"` / `setElementCreationCallback('tag'`
26
+ * 2. Single-line array entry: `["tag", "..."]` — column 0 only, comments allowed after
27
+ * 3. Multi-line array entry: `"tag",` on its own line, optional trailing `//` comment
28
+ */
29
+ function isTagAlreadyRegistered(content, tagName) {
30
+ const escaped = escapeRegex(tagName);
31
+ if (content.includes(`setElementCreationCallback("${tagName}"`) ||
32
+ content.includes(`setElementCreationCallback('${tagName}'`)) {
33
+ return true;
34
+ }
35
+ if (new RegExp(`^\\s*\\[\\s*["']${escaped}["']\\s*,`, 'm').test(content)) {
36
+ return true;
37
+ }
38
+ if (new RegExp(`^\\s*["']${escaped}["']\\s*,\\s*(?:\\/\\/.*)?$`, 'm').test(content)) {
39
+ return true;
40
+ }
41
+ return false;
42
+ }
43
+ /** Validates a markerComment value and returns the formatted suffix (with leading spaces). */
44
+ function formatMarkerSuffix(markerComment) {
45
+ if (!markerComment)
46
+ return '';
47
+ return ` // ${markerComment}:`;
48
+ }
15
49
  // Re-export from split modules so existing import sites continue working
16
50
  export { removeCustomElementRegistration } from './furnace-registration-remove.js';
17
51
  // Re-export constants so existing import sites continue working
@@ -62,23 +96,27 @@ function selectRegistrationTarget(targets, isESModule, tagName) {
62
96
  }
63
97
  throw new FurnaceError(`${tagName} would land in the DOMContentLoaded/importESModule block (Pattern B) instead of the loadSubScript block (Pattern A) — no non-DOMContentLoaded registration array found in customElements.js. The file structure may have changed upstream — manual intervention required.`, tagName);
64
98
  }
65
- function buildRegistrationEntry(referenceEntry, tagName, modulePath) {
99
+ function buildRegistrationEntry(referenceEntry, tagName, modulePath, markerComment) {
100
+ const marker = formatMarkerSuffix(markerComment);
66
101
  if (!referenceEntry) {
67
- return ` ["${tagName}", "${modulePath}"],`;
102
+ return ` ["${tagName}", "${modulePath}"],${marker}`;
68
103
  }
69
104
  if (referenceEntry.isMultiLine) {
70
105
  const indent = referenceEntry.indent;
71
106
  const inner = referenceEntry.innerIndent ?? indent + ' ';
72
- return `${indent}[\n${inner}"${tagName}",\n${inner}"${modulePath}",\n${indent}],`;
107
+ return (`${indent}[${marker}\n` +
108
+ `${inner}"${tagName}",${marker}\n` +
109
+ `${inner}"${modulePath}",${marker}\n` +
110
+ `${indent}],${marker}`);
73
111
  }
74
- return `${referenceEntry.indent}["${tagName}", "${modulePath}"],`;
112
+ return `${referenceEntry.indent}["${tagName}", "${modulePath}"],${marker}`;
75
113
  }
76
114
  /**
77
115
  * AST-based implementation: parses customElements.js, walks to find the
78
116
  * target ForOfStatement array, and inserts the new entry at the correct
79
117
  * alphabetical position using magic-string.
80
118
  */
81
- function addRegistrationAST(content, tagName, modulePath, isESModule) {
119
+ function addRegistrationAST(content, tagName, modulePath, isESModule, markerComment) {
82
120
  validateTagName(tagName);
83
121
  const ast = parseScript(content);
84
122
  const ancestors = [];
@@ -141,7 +179,7 @@ function addRegistrationAST(content, tagName, modulePath, isESModule) {
141
179
  referenceEntry = entry;
142
180
  }
143
181
  // Build new entry string matching detected format
144
- const newEntry = buildRegistrationEntry(referenceEntry, tagName, modulePath);
182
+ const newEntry = buildRegistrationEntry(referenceEntry, tagName, modulePath, markerComment);
145
183
  const ms = new MagicString(content);
146
184
  // Helper: find the start-of-line position for a given offset
147
185
  function lineStart(pos) {
@@ -181,7 +219,7 @@ function addRegistrationAST(content, tagName, modulePath, isESModule) {
181
219
  * validate indentation or multi-line format — but it is robust against
182
220
  * upstream syntax changes that break the parser.
183
221
  */
184
- function addRegistrationRegexFallback(content, tagName, modulePath, isESModule) {
222
+ function addRegistrationRegexFallback(content, tagName, modulePath, isESModule, markerComment) {
185
223
  // Find all registration entries: ["tag", "path"],
186
224
  const entryPattern = /^(\s*)\["([^"]+)",\s*"[^"]+"\],?\s*$/gm;
187
225
  let lastMatch = null;
@@ -212,7 +250,8 @@ function addRegistrationRegexFallback(content, tagName, modulePath, isESModule)
212
250
  throw new FurnaceError(`Regex fallback could not find any registration entries in the ${isESModule ? 'DOMContentLoaded' : 'non-DOMContentLoaded'} block of ${CUSTOM_ELEMENTS_JS}.`, tagName);
213
251
  }
214
252
  const indent = lastMatch[1] ?? ' ';
215
- const newEntry = `${indent}["${tagName}", "${modulePath}"],`;
253
+ const marker = formatMarkerSuffix(markerComment);
254
+ const newEntry = `${indent}["${tagName}", "${modulePath}"],${marker}`;
216
255
  if (insertAfterMatch) {
217
256
  // Insert after the last entry that sorts before tagName
218
257
  const insertPos = insertAfterMatch.index + insertAfterMatch[0].length;
@@ -243,20 +282,18 @@ function addRegistrationRegexFallback(content, tagName, modulePath, isESModule)
243
282
  * @param tagName - Custom element tag name
244
283
  * @param modulePath - chrome:// URI for the module
245
284
  */
246
- export async function addCustomElementRegistration(engineDir, tagName, modulePath) {
285
+ export async function addCustomElementRegistration(engineDir, tagName, modulePath, options = {}) {
247
286
  const filePath = join(engineDir, CUSTOM_ELEMENTS_JS);
248
287
  if (!(await pathExists(filePath))) {
249
288
  throw new FurnaceError('customElements.js not found in engine', tagName);
250
289
  }
251
290
  const content = await readText(filePath);
252
- // Idempotency: already registered (standalone block or array entry).
253
- // Check both double-quote and single-quote variants upstream Firefox
254
- // sources may use either style.
255
- if (content.includes(`setElementCreationCallback("${tagName}"`) ||
256
- content.includes(`setElementCreationCallback('${tagName}'`) ||
257
- content.includes(`["${tagName}",`) ||
258
- content.includes(`['${tagName}',`) ||
259
- new RegExp(`^\\s*["']${tagName}["'],\\s*$`, 'm').test(content)) {
291
+ // Idempotency: check column-0 of each array entry rather than a literal
292
+ // substring match. A previous apply may have written this entry with
293
+ // trailing marker comments (see `options.markerComment`); matching on the
294
+ // full line would then miss it and insert a duplicate on re-apply, which
295
+ // throws NotSupportedError at every window-load.
296
+ if (isTagAlreadyRegistered(content, tagName)) {
260
297
  return;
261
298
  }
262
299
  // Validate upfront — tag name errors must not fall through to the regex fallback.
@@ -279,7 +316,7 @@ export async function addCustomElementRegistration(engineDir, tagName, modulePat
279
316
  }
280
317
  let nextContent;
281
318
  try {
282
- nextContent = addRegistrationAST(content, tagName, modulePath, isESModule);
319
+ nextContent = addRegistrationAST(content, tagName, modulePath, isESModule, options.markerComment);
283
320
  }
284
321
  catch (error) {
285
322
  if (error instanceof FurnaceError) {
@@ -287,7 +324,7 @@ export async function addCustomElementRegistration(engineDir, tagName, modulePat
287
324
  warn(`AST-based registration failed for ${tagName}: ${error.message}. ` +
288
325
  'Falling back to regex-based insertion. Please report this so the AST parser can be updated.');
289
326
  try {
290
- nextContent = addRegistrationRegexFallback(content, tagName, modulePath, isESModule);
327
+ nextContent = addRegistrationRegexFallback(content, tagName, modulePath, isESModule, options.markerComment);
291
328
  verbose(`Regex fallback succeeded for ${tagName}. The registration may be less precise than the AST approach.`);
292
329
  }
293
330
  catch {
@@ -300,7 +337,7 @@ export async function addCustomElementRegistration(engineDir, tagName, modulePat
300
337
  warn(`AST parser threw an unexpected error for ${tagName}: ${parserError.message}. ` +
301
338
  'Falling back to regex-based insertion.');
302
339
  try {
303
- nextContent = addRegistrationRegexFallback(content, tagName, modulePath, isESModule);
340
+ nextContent = addRegistrationRegexFallback(content, tagName, modulePath, isESModule, options.markerComment);
304
341
  verbose(`Regex fallback succeeded for ${tagName}. The registration may be less precise than the AST approach.`);
305
342
  }
306
343
  catch {
@@ -321,11 +358,7 @@ export async function validateCustomElementRegistration(engineDir, tagName, modu
321
358
  throw new FurnaceError('customElements.js not found in engine', tagName);
322
359
  }
323
360
  const content = await readText(filePath);
324
- if (content.includes(`setElementCreationCallback("${tagName}"`) ||
325
- content.includes(`setElementCreationCallback('${tagName}'`) ||
326
- content.includes(`["${tagName}",`) ||
327
- content.includes(`['${tagName}',`) ||
328
- new RegExp(`^\\s*["']${tagName}["'],\\s*$`, 'm').test(content)) {
361
+ if (isTagAlreadyRegistered(content, tagName)) {
329
362
  return;
330
363
  }
331
364
  const isESModule = modulePath.endsWith('.mjs');
@@ -35,6 +35,33 @@ export { addCustomElementRegistration, removeCustomElementRegistration, validate
35
35
  * @param files - Filenames to register (e.g. ["moz-widget.mjs", "moz-widget.css"])
36
36
  */
37
37
  export declare function addJarMnEntries(engineDir: string, tagName: string, files: string[]): Promise<number>;
38
+ /**
39
+ * Adds a locale jar.mn entry mapping `<chromeSubPath>/<tagName>.ftl` to the
40
+ * on-disk `.ftl` that `furnace apply` just copied under the FTL tree. Without
41
+ * this entry the chrome URI passed to `window.MozXULElement.insertFTLIfNeeded`
42
+ * does not resolve at runtime, so the generated `--localized` component
43
+ * silently ships broken l10n.
44
+ *
45
+ * Degrades gracefully — if the locale jar.mn (e.g. `toolkit/locales/jar.mn`)
46
+ * does not exist, returns 0 rather than throwing, so a custom fork without a
47
+ * standard locales package can still apply a localized component.
48
+ *
49
+ * The written entry mirrors the Mozilla convention for toolkit widgets:
50
+ *
51
+ * locale/@AB_CD@/<chromeSubPath>/<tagName>.ftl (%<chromeSubPath>/<tagName>.ftl)
52
+ *
53
+ * @param engineDir - Path to the Firefox engine source root
54
+ * @param jarMnRelPath - Engine-relative path to the locale jar.mn
55
+ * @param tagName - Custom element tag name (base of the `.ftl` file)
56
+ * @param chromeSubPath - Chrome sub-path (e.g. `toolkit/global`)
57
+ * @returns Number of entries inserted (0 when already present, or jar.mn missing)
58
+ */
59
+ export declare function addLocaleFtlJarMnEntry(engineDir: string, jarMnRelPath: string, tagName: string, chromeSubPath: string): Promise<number>;
60
+ /**
61
+ * Removes a locale jar.mn entry previously written by `addLocaleFtlJarMnEntry`.
62
+ * Idempotent — if the entry is absent or the file is missing, nothing happens.
63
+ */
64
+ export declare function removeLocaleFtlJarMnEntry(engineDir: string, jarMnRelPath: string, tagName: string, chromeSubPath: string): Promise<void>;
38
65
  /**
39
66
  * Removes all jar.mn entries for a given tag name.
40
67
  *
@@ -113,6 +113,102 @@ export async function addJarMnEntries(engineDir, tagName, files) {
113
113
  await writeText(filePath, content);
114
114
  return newFiles.length;
115
115
  }
116
+ /**
117
+ * Adds a locale jar.mn entry mapping `<chromeSubPath>/<tagName>.ftl` to the
118
+ * on-disk `.ftl` that `furnace apply` just copied under the FTL tree. Without
119
+ * this entry the chrome URI passed to `window.MozXULElement.insertFTLIfNeeded`
120
+ * does not resolve at runtime, so the generated `--localized` component
121
+ * silently ships broken l10n.
122
+ *
123
+ * Degrades gracefully — if the locale jar.mn (e.g. `toolkit/locales/jar.mn`)
124
+ * does not exist, returns 0 rather than throwing, so a custom fork without a
125
+ * standard locales package can still apply a localized component.
126
+ *
127
+ * The written entry mirrors the Mozilla convention for toolkit widgets:
128
+ *
129
+ * locale/@AB_CD@/<chromeSubPath>/<tagName>.ftl (%<chromeSubPath>/<tagName>.ftl)
130
+ *
131
+ * @param engineDir - Path to the Firefox engine source root
132
+ * @param jarMnRelPath - Engine-relative path to the locale jar.mn
133
+ * @param tagName - Custom element tag name (base of the `.ftl` file)
134
+ * @param chromeSubPath - Chrome sub-path (e.g. `toolkit/global`)
135
+ * @returns Number of entries inserted (0 when already present, or jar.mn missing)
136
+ */
137
+ export async function addLocaleFtlJarMnEntry(engineDir, jarMnRelPath, tagName, chromeSubPath) {
138
+ const filePath = join(engineDir, jarMnRelPath);
139
+ if (!(await pathExists(filePath))) {
140
+ return 0;
141
+ }
142
+ const content = await readText(filePath);
143
+ const lines = content.split('\n');
144
+ const ftlFile = `${tagName}.ftl`;
145
+ const escapedTag = escapeForRegex(tagName);
146
+ const escapedChrome = escapeForRegex(chromeSubPath);
147
+ const presencePattern = new RegExp(`locale\\/(?:@AB_CD@|[a-zA-Z-]+)\\/${escapedChrome}\\/${escapedTag}\\.ftl`, 'm');
148
+ if (presencePattern.test(content)) {
149
+ return 0;
150
+ }
151
+ const indent = detectLocaleJarMnIndent(lines, chromeSubPath);
152
+ const newEntry = `${indent}locale/@AB_CD@/${chromeSubPath}/${ftlFile} (%${chromeSubPath}/${ftlFile})`;
153
+ const sectionPattern = new RegExp(`^(\\s+)locale\\/(?:@AB_CD@|[a-zA-Z-]+)\\/${escapedChrome}\\/([^.\\s]+)\\.ftl`);
154
+ let insertIndex = -1;
155
+ for (let i = 0; i < lines.length; i++) {
156
+ const line = lines[i];
157
+ if (line === undefined)
158
+ continue;
159
+ const match = sectionPattern.exec(line);
160
+ if (match) {
161
+ const existingTag = match[2] ?? '';
162
+ if (existingTag > tagName) {
163
+ insertIndex = i;
164
+ break;
165
+ }
166
+ insertIndex = i + 1;
167
+ }
168
+ }
169
+ if (insertIndex === -1) {
170
+ // No existing entries under this chrome sub-path. Fall back to end-of-file
171
+ // placement so the operator can reorder manually if desired.
172
+ insertIndex = lines.length;
173
+ }
174
+ lines.splice(insertIndex, 0, newEntry);
175
+ await writeText(filePath, lines.join('\n'));
176
+ return 1;
177
+ }
178
+ /** Detects locale jar.mn indentation by sampling an existing matching entry. */
179
+ function detectLocaleJarMnIndent(lines, chromeSubPath) {
180
+ const escapedChrome = escapeForRegex(chromeSubPath);
181
+ const pattern = new RegExp(`^(\\s+)locale\\/(?:@AB_CD@|[a-zA-Z-]+)\\/${escapedChrome}\\/`);
182
+ for (const line of lines) {
183
+ const match = pattern.exec(line);
184
+ if (match?.[1])
185
+ return match[1];
186
+ }
187
+ // Fall back to detecting any existing `locale/...` indent before giving up.
188
+ for (const line of lines) {
189
+ const match = /^(\s+)locale\//.exec(line);
190
+ if (match?.[1])
191
+ return match[1];
192
+ }
193
+ return ' ';
194
+ }
195
+ /**
196
+ * Removes a locale jar.mn entry previously written by `addLocaleFtlJarMnEntry`.
197
+ * Idempotent — if the entry is absent or the file is missing, nothing happens.
198
+ */
199
+ export async function removeLocaleFtlJarMnEntry(engineDir, jarMnRelPath, tagName, chromeSubPath) {
200
+ const filePath = join(engineDir, jarMnRelPath);
201
+ if (!(await pathExists(filePath))) {
202
+ return;
203
+ }
204
+ const content = await readText(filePath);
205
+ const lines = content.split('\n');
206
+ const pattern = new RegExp(`locale\\/(?:@AB_CD@|[a-zA-Z-]+)\\/${escapeForRegex(chromeSubPath)}\\/${escapeForRegex(tagName)}\\.ftl`);
207
+ const filtered = lines.filter((line) => !pattern.test(line));
208
+ if (filtered.length === lines.length)
209
+ return;
210
+ await writeText(filePath, filtered.join('\n'));
211
+ }
116
212
  /**
117
213
  * Removes all jar.mn entries for a given tag name.
118
214
  *