@hominis/fireforge 0.13.2 → 0.15.1

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 (78) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +20 -1
  3. package/dist/bin/fireforge.js +19 -5
  4. package/dist/src/commands/config.js +7 -1
  5. package/dist/src/commands/discard.js +6 -1
  6. package/dist/src/commands/doctor.d.ts +12 -0
  7. package/dist/src/commands/doctor.js +6 -1
  8. package/dist/src/commands/download.js +106 -7
  9. package/dist/src/commands/export-shared.js +7 -0
  10. package/dist/src/commands/export.js +5 -0
  11. package/dist/src/commands/furnace/apply.js +147 -47
  12. package/dist/src/commands/furnace/create-templates.d.ts +26 -0
  13. package/dist/src/commands/furnace/create-templates.js +86 -0
  14. package/dist/src/commands/furnace/create.js +77 -103
  15. package/dist/src/commands/furnace/deploy.js +20 -5
  16. package/dist/src/commands/furnace/diff.js +3 -1
  17. package/dist/src/commands/furnace/init.js +25 -7
  18. package/dist/src/commands/furnace/list.js +15 -7
  19. package/dist/src/commands/furnace/override.js +47 -15
  20. package/dist/src/commands/furnace/remove.js +68 -20
  21. package/dist/src/commands/furnace/rename.js +31 -3
  22. package/dist/src/commands/furnace/scan.js +8 -0
  23. package/dist/src/commands/furnace/validate.js +70 -7
  24. package/dist/src/commands/import.js +65 -11
  25. package/dist/src/commands/re-export.js +11 -4
  26. package/dist/src/commands/rebase/abort.js +26 -14
  27. package/dist/src/commands/rebase/confirm.d.ts +15 -2
  28. package/dist/src/commands/rebase/confirm.js +2 -2
  29. package/dist/src/commands/rebase/continue.js +39 -15
  30. package/dist/src/commands/rebase/index.js +2 -1
  31. package/dist/src/commands/rebase/patch-loop.js +90 -33
  32. package/dist/src/commands/register.js +13 -0
  33. package/dist/src/commands/resolve.js +31 -10
  34. package/dist/src/commands/run.js +9 -44
  35. package/dist/src/commands/setup-support.js +25 -7
  36. package/dist/src/commands/status.js +59 -8
  37. package/dist/src/commands/test.js +33 -7
  38. package/dist/src/commands/token.js +11 -1
  39. package/dist/src/commands/watch.js +51 -1
  40. package/dist/src/commands/wire.js +23 -0
  41. package/dist/src/core/config-paths.d.ts +2 -2
  42. package/dist/src/core/config-paths.js +2 -0
  43. package/dist/src/core/config-validate.js +47 -1
  44. package/dist/src/core/furnace-apply-ftl.d.ts +33 -0
  45. package/dist/src/core/furnace-apply-ftl.js +102 -0
  46. package/dist/src/core/furnace-apply-helpers.d.ts +10 -1
  47. package/dist/src/core/furnace-apply-helpers.js +16 -12
  48. package/dist/src/core/furnace-apply.js +7 -4
  49. package/dist/src/core/furnace-config-tokens.d.ts +11 -0
  50. package/dist/src/core/furnace-config-tokens.js +28 -0
  51. package/dist/src/core/furnace-config.d.ts +6 -0
  52. package/dist/src/core/furnace-config.js +8 -1
  53. package/dist/src/core/furnace-constants.d.ts +20 -0
  54. package/dist/src/core/furnace-constants.js +32 -0
  55. package/dist/src/core/furnace-registration-ast.d.ts +13 -1
  56. package/dist/src/core/furnace-registration-ast.js +58 -25
  57. package/dist/src/core/furnace-registration.d.ts +28 -1
  58. package/dist/src/core/furnace-registration.js +98 -1
  59. package/dist/src/core/furnace-staleness.d.ts +17 -0
  60. package/dist/src/core/furnace-staleness.js +58 -0
  61. package/dist/src/core/furnace-validate-accessibility.js +8 -2
  62. package/dist/src/core/furnace-validate-helpers.d.ts +8 -0
  63. package/dist/src/core/furnace-validate-helpers.js +81 -0
  64. package/dist/src/core/furnace-validate-registration.d.ts +8 -2
  65. package/dist/src/core/furnace-validate-registration.js +34 -9
  66. package/dist/src/core/furnace-validate.js +2 -2
  67. package/dist/src/core/marionette-preflight.d.ts +39 -0
  68. package/dist/src/core/marionette-preflight.js +210 -0
  69. package/dist/src/core/signal-critical.d.ts +49 -0
  70. package/dist/src/core/signal-critical.js +80 -0
  71. package/dist/src/errors/download.d.ts +1 -1
  72. package/dist/src/errors/download.js +6 -3
  73. package/dist/src/types/commands/options.d.ts +6 -0
  74. package/dist/src/types/config.d.ts +7 -0
  75. package/dist/src/types/furnace.d.ts +8 -0
  76. package/dist/src/utils/process.d.ts +15 -2
  77. package/dist/src/utils/process.js +73 -0
  78. package/package.json +1 -1
@@ -0,0 +1,102 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * `.ftl` apply/undeploy helpers for custom components. Extracted from
4
+ * `furnace-apply-helpers.ts` so the main helper module stays under the
5
+ * per-file LOC budget.
6
+ *
7
+ * Every helper here degrades gracefully: if the locale jar.mn is missing or
8
+ * the FTL tree is non-standard, apply logs a `stepError` rather than
9
+ * aborting the whole command. Missing jar.mn on a fork without a locale
10
+ * package should not block a working `.mjs`/`.css` from shipping.
11
+ */
12
+ import { join, relative } from 'node:path';
13
+ import { toError } from '../utils/errors.js';
14
+ import { copyFile, pathExists } from '../utils/fs.js';
15
+ import { resolveFtlChromeSubPath, resolveFtlLocaleJarMnPath } from './furnace-constants.js';
16
+ import { addLocaleFtlJarMnEntry, removeLocaleFtlJarMnEntry } from './furnace-registration.js';
17
+ import { snapshotFile } from './furnace-rollback.js';
18
+ /**
19
+ * Copies a component's `.ftl` into the FTL tree and registers the chrome URI
20
+ * in the locale jar.mn.
21
+ *
22
+ * Failure modes (missing jar.mn, regex write error) are captured as
23
+ * stepErrors rather than thrown — a well-formed `.mjs`/`.css` must never be
24
+ * blocked by a broken locale path.
25
+ */
26
+ export async function applyCustomFtlFile(engineDir, name, componentDir, ftlDir, affectedPaths, stepErrors, rollbackJournal) {
27
+ const ftlFile = `${name}.ftl`;
28
+ const ftlSrc = join(componentDir, ftlFile);
29
+ if (!(await pathExists(ftlSrc)))
30
+ return;
31
+ const ftlDest = join(engineDir, ftlDir, ftlFile);
32
+ if (rollbackJournal) {
33
+ await snapshotFile(rollbackJournal, ftlDest);
34
+ }
35
+ await copyFile(ftlSrc, ftlDest);
36
+ affectedPaths.push(relative(engineDir, ftlDest));
37
+ const chromeSubPath = resolveFtlChromeSubPath(ftlDir);
38
+ const localeJarRel = resolveFtlLocaleJarMnPath(ftlDir);
39
+ if (chromeSubPath === undefined || localeJarRel === undefined)
40
+ return;
41
+ const localeJarAbs = join(engineDir, localeJarRel);
42
+ if (!(await pathExists(localeJarAbs))) {
43
+ stepErrors.push({
44
+ step: 'locale jar.mn registration',
45
+ error: `Locale jar.mn not found at ${localeJarRel}; component "${name}" ships without a chrome URI for ${ftlFile}. Add the file manually or set furnace.json "ftlBasePath" to a tree that owns a jar.mn.`,
46
+ });
47
+ return;
48
+ }
49
+ try {
50
+ if (rollbackJournal) {
51
+ await snapshotFile(rollbackJournal, localeJarAbs);
52
+ }
53
+ const inserted = await addLocaleFtlJarMnEntry(engineDir, localeJarRel, name, chromeSubPath);
54
+ if (inserted > 0) {
55
+ affectedPaths.push(localeJarRel);
56
+ }
57
+ }
58
+ catch (error) {
59
+ stepErrors.push({
60
+ step: 'locale jar.mn registration',
61
+ error: toError(error).message,
62
+ });
63
+ }
64
+ }
65
+ /**
66
+ * Returns a dry-run action for registering a locale jar.mn entry for the
67
+ * `.ftl` that `applyCustomFtlFile` would write. `undefined` when the FTL
68
+ * tree does not expose a locale jar.mn we can confidently name.
69
+ */
70
+ export function describeLocaleFtlJarMnRegistration(name, ftlDir, ftlFile) {
71
+ const chromeSubPath = resolveFtlChromeSubPath(ftlDir);
72
+ const localeJarRel = resolveFtlLocaleJarMnPath(ftlDir);
73
+ if (chromeSubPath === undefined || localeJarRel === undefined)
74
+ return undefined;
75
+ return {
76
+ component: name,
77
+ action: 'register-jar',
78
+ description: `Register ${chromeSubPath}/${ftlFile} in ${localeJarRel}`,
79
+ };
80
+ }
81
+ /**
82
+ * Drops the locale jar.mn entry for `fileName` when it's a `.ftl` whose
83
+ * source workspace file has been deleted. Idempotent — absent entries are a
84
+ * no-op.
85
+ */
86
+ export async function removeCustomFtlJarMnEntry(engineDir, fileName, ftlDir, rollbackJournal) {
87
+ if (!fileName.endsWith('.ftl'))
88
+ return;
89
+ const tagName = fileName.slice(0, -'.ftl'.length);
90
+ const chromeSubPath = resolveFtlChromeSubPath(ftlDir);
91
+ const localeJarRel = resolveFtlLocaleJarMnPath(ftlDir);
92
+ if (chromeSubPath === undefined || localeJarRel === undefined)
93
+ return;
94
+ const localeJarAbs = join(engineDir, localeJarRel);
95
+ if (!(await pathExists(localeJarAbs)))
96
+ return;
97
+ if (rollbackJournal) {
98
+ await snapshotFile(rollbackJournal, localeJarAbs);
99
+ }
100
+ await removeLocaleFtlJarMnEntry(engineDir, localeJarRel, tagName, chromeSubPath);
101
+ }
102
+ //# sourceMappingURL=furnace-apply-ftl.js.map
@@ -90,8 +90,17 @@ export declare function hasOverrideEngineDrift(engineDir: string, componentDir:
90
90
  * the validate command.
91
91
  */
92
92
  export declare function hasCustomEngineDrift(root: string, name: string, componentDir: string, config: CustomComponentConfig, ftlDir: string): Promise<boolean>;
93
+ /** Extra knobs threaded into `applyCustomComponent` from the project config. */
94
+ export interface CustomApplyOptions {
95
+ /**
96
+ * Trailing project marker appended to inserted `customElements.js` entries
97
+ * (e.g. `"HOMINIS"` emits ` // HOMINIS:` on each line). Mirrors the
98
+ * `markerComment` field in fireforge.json.
99
+ */
100
+ markerComment?: string;
101
+ }
93
102
  /** Applies a custom component into the engine tree and captures registration step errors. */
94
- export declare function applyCustomComponent(engineDir: string, name: string, componentDir: string, config: CustomComponentConfig, ftlDir: string, dryRun?: boolean, rollbackJournal?: RollbackJournal): Promise<{
103
+ export declare function applyCustomComponent(engineDir: string, name: string, componentDir: string, config: CustomComponentConfig, ftlDir: string, dryRun?: boolean, rollbackJournal?: RollbackJournal, applyOptions?: CustomApplyOptions): Promise<{
95
104
  affectedPaths: string[];
96
105
  stepErrors: StepError[];
97
106
  actions?: DryRunAction[];
@@ -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,11 @@
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;
@@ -0,0 +1,28 @@
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
+ //# 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 { 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,9 @@ export function validateFurnaceConfig(data) {
182
183
  if (migrated['tokenAllowlist'] !== undefined) {
183
184
  parseStringArray(migrated['tokenAllowlist'], 'tokenAllowlist');
184
185
  }
186
+ // Validate optional tokenHostDocuments — list of chrome XHTMLs that the
187
+ // `missing-token-link` validator scans for the tokens CSS link.
188
+ validateTokenHostDocuments(migrated['tokenHostDocuments']);
185
189
  const stock = parseStringArray(migrated['stock'], 'stock');
186
190
  const stockSet = new Set();
187
191
  for (const name of stock) {
@@ -238,6 +242,9 @@ export function validateFurnaceConfig(data) {
238
242
  if (migrated['tokenAllowlist'] !== undefined) {
239
243
  config.tokenAllowlist = parseStringArray(migrated['tokenAllowlist'], 'tokenAllowlist');
240
244
  }
245
+ if (migrated['tokenHostDocuments'] !== undefined) {
246
+ config.tokenHostDocuments = parseStringArray(migrated['tokenHostDocuments'], 'tokenHostDocuments');
247
+ }
241
248
  // Validate optional ftlBasePath
242
249
  if (migrated['ftlBasePath'] !== undefined) {
243
250
  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. `"HOMINIS"`
12
+ * produces ` // HOMINIS:` 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 `// HOMINIS:`)
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');
@@ -34,7 +34,34 @@ export { addCustomElementRegistration, removeCustomElementRegistration, validate
34
34
  * @param tagName - Custom element tag name
35
35
  * @param files - Filenames to register (e.g. ["moz-widget.mjs", "moz-widget.css"])
36
36
  */
37
- export declare function addJarMnEntries(engineDir: string, tagName: string, files: string[]): Promise<void>;
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
  *