@hominis/fireforge 0.15.1 → 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 (32) hide show
  1. package/CHANGELOG.md +15 -3
  2. package/README.md +22 -3
  3. package/dist/src/commands/build.js +12 -1
  4. package/dist/src/commands/furnace/create-templates.d.ts +21 -0
  5. package/dist/src/commands/furnace/create-templates.js +49 -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 +17 -8
  9. package/dist/src/commands/furnace/index.js +1 -0
  10. package/dist/src/commands/setup.d.ts +1 -1
  11. package/dist/src/commands/setup.js +3 -2
  12. package/dist/src/core/build-prepare.js +6 -1
  13. package/dist/src/core/furnace-apply-helpers.d.ts +1 -1
  14. package/dist/src/core/furnace-config-tokens.d.ts +6 -0
  15. package/dist/src/core/furnace-config-tokens.js +15 -0
  16. package/dist/src/core/furnace-config.js +10 -4
  17. package/dist/src/core/furnace-registration-ast.d.ts +2 -2
  18. package/dist/src/core/furnace-registration-ast.js +1 -1
  19. package/dist/src/core/furnace-validate-compatibility.js +18 -7
  20. package/dist/src/core/furnace-validate-helpers.d.ts +31 -1
  21. package/dist/src/core/furnace-validate-helpers.js +101 -18
  22. package/dist/src/core/furnace-validate-registration.d.ts +1 -1
  23. package/dist/src/core/furnace-validate-registration.js +1 -1
  24. package/dist/src/core/marionette-preflight.d.ts +14 -7
  25. package/dist/src/core/marionette-preflight.js +94 -44
  26. package/dist/src/core/patch-lint-cross.d.ts +1 -1
  27. package/dist/src/core/patch-lint-cross.js +1 -1
  28. package/dist/src/core/patch-lint.js +29 -9
  29. package/dist/src/types/commands/options.d.ts +10 -0
  30. package/dist/src/types/config.d.ts +1 -1
  31. package/dist/src/types/furnace.d.ts +12 -1
  32. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,7 +5,7 @@
5
5
  ### Furnace registration
6
6
 
7
7
  - `furnace apply` idempotency check is marker-comment-tolerant. Previously the single-line substring match (`content.includes('["tag",')`) missed multi-line entries, and the standalone-line regex anchored on `\s*$`, which did not allow trailing `// <marker>:` comments an operator may have appended to a previously-written entry. A duplicate tag was then inserted on every re-apply, and the second `setElementCreationCallback` invocation threw `NotSupportedError: Operation is not supported` at every window-load. The idempotency check now matches on tag-name column 0 (both single- and multi-line array shapes) and tolerates trailing `//` comments on the line.
8
- - New optional fireforge.json field `markerComment` (e.g. `"HOMINIS"`) is appended as a ` // HOMINIS:` suffix to every line FireForge writes into `customElements.js`. Keeps fork modifications discoverable and re-applies idempotent without hand-tagging after each apply. The field is threaded through `applyCustomComponent` and `furnace deploy`, not just `furnace create`.
8
+ - New optional fireforge.json field `markerComment` (e.g. `"MYBROWSER"`) is appended as a ` // MYBROWSER:` suffix to every line FireForge writes into `customElements.js`. Keeps fork modifications discoverable and re-applies idempotent without hand-tagging after each apply. The field is threaded through `applyCustomComponent` and `furnace deploy`, not just `furnace create`.
9
9
  - `addCustomElementRegistration` and its regex fallback both accept the new marker as an optional parameter; the AST idempotency check and the regex-fallback idempotency check share a single helper (`isTagAlreadyRegistered`).
10
10
 
11
11
  ### Furnace `--localized`
@@ -17,19 +17,31 @@
17
17
 
18
18
  ### Furnace validate
19
19
 
20
- - `missing-token-link` now reads `tokenHostDocuments` from furnace.json and scans every configured chrome document for the tokens CSS link. Warning fires only when NONE of them link the tokens CSS; the warning enumerates the documents it actually checked. Previously the check was hardcoded to `browser/base/content/browser.xhtml`, which false-positived on forks that mount components in a different chrome document (e.g. `hominis.xhtml`). Defaults to `["browser/base/content/browser.xhtml"]` when omitted — behaviour is unchanged for projects that never set the field.
20
+ - `missing-token-link` now reads `tokenHostDocuments` from furnace.json and scans every configured chrome document for the tokens CSS link. Warning fires only when NONE of them link the tokens CSS; the warning enumerates the documents it actually checked. Previously the check was hardcoded to `browser/base/content/browser.xhtml`, which false-positived on forks that mount components in a different chrome document (e.g. `mybrowser.xhtml`). Defaults to `["browser/base/content/browser.xhtml"]` when omitted — behaviour is unchanged for projects that never set the field.
21
21
  - `no-keyboard-handler` no longer warns when `@click` sits on a native interactive element (`<button>`, `<a href>`, `<input>`, `<select>`, `<textarea>`, `<summary>`, `<details>`, or the Firefox `moz-button`/`moz-toggle`/`moz-checkbox`/`moz-radio`/`moz-menulist` widgets). Those elements dispatch `click` on Enter and Space via the platform, so a duplicate `@keydown`/`@keypress` handler would double-fire. The rule still fires for synthetic interactive markup (e.g. `<div @click>`) and for bare `<a>` without an `href` attribute, which are the real keyboard-a11y hazards.
22
+ - `token-prefix-violation` stops flagging component-owned runtime CSS custom properties. Previously every `var(--foo)` that did not match `tokenPrefix` was rejected as a design-token escape, even when the property was a per-frame state channel (`--cam-x`, `--tile-z`) both written and read by the component. Two relaxations ship together: (a) a new optional `runtimeVariables: string[]` field in `furnace.json` explicitly allowlists cross-component runtime channels (e.g. set in JS, read in the child's CSS); (b) variables that are both declared (`--foo: value;`) and consumed (`var(--foo)`) inside the same CSS file are auto-exempted — no config entry required. The same relaxations apply to the patch-stack lint. The violation message now calls out `runtimeVariables` as the third escape hatch alongside `tokenPrefix` and `tokenAllowlist`.
23
+ - `hardcoded-text` narrowed from a bare `>…<` scan to three context-aware probes: text inside Lit `` html`…` `` template literals, string literals assigned via `.textContent = "…"` / `.innerHTML = "…"`, and XUL-attribute values set via `setAttribute("label"|"title"|"tooltiptext", "…")`. Previously the rule also matched JS comparisons (`if (x > 0 && y < 100)`), long diagnostic strings (`console.error("…")`), and identifier literals passed to `querySelector`, producing noise that trained authors to ignore validator warnings. The file-wide `// furnace-ignore: hardcoded-text` escape hatch is preserved.
22
24
 
23
25
  ### Run / test
24
26
 
25
27
  - `fireforge run`, `fireforge watch`, `fireforge build`, and every other `mach` invocation launched with inherited stdio now forward parent `SIGINT`/`SIGTERM` to the child as `SIGTERM` and wait ~1.5 s before escalating to `SIGKILL`. A second Ctrl-C during the grace window escalates immediately (matches the usual "hit Ctrl-C twice to force-quit" UX). Previously the parent could exit before Gecko's `AsyncShutdown` / `profileBeforeChange` blockers finished flushing in-memory state, losing the last few seconds of edits. The grace window is configurable via a new `shutdownGraceMs` option on `execInherit` / `execInheritCapture`.
26
28
  - New `fireforge test --doctor` runs a short marionette handshake preflight before (optionally) invoking `mach test`. Spawns the built browser headless, opens a TCP socket to `127.0.0.1:2828`, waits for the handshake bytes, and reports PASS/FAIL with the tail of stderr on FAIL. When `--doctor` is supplied with no test paths, it exits after the preflight — a sub-minute way to tell "marionette wedged" apart from "test failed to discover" when `mach test` hangs for the full 360 s marionette timeout. When supplied with test paths, a FAIL preflight short-circuits before `mach test` runs.
29
+ - `fireforge test --doctor` is now a cascade of six layered probes (engine-present → mach-available → python-available → profile-creatable → browser-spawns → marionette-handshake) with tight per-layer budgets. Previously the single 30 s socket poll gave the same generic "socket did not respond" diagnostic whether mach was missing, Python was unavailable, `/tmp` was not writable, or the browser binary crashed at startup — so operators had no lead on where to start debugging. Each layer now short-circuits with a `[layer N/6: <name>]`-prefixed detail message so the first broken layer is surfaced immediately, and a crashing browser is caught by a short settle window at layer 5 instead of wasting the full budget waiting for bytes that never come.
30
+
31
+ ### Furnace build
32
+
33
+ - `fireforge build` already auto-applies Furnace components (via `prepareBuildEnvironment`) before the mach build step, but the behaviour was undocumented and silent — operators who edited `components/custom/` and then ran `fireforge build` could not distinguish "auto-apply wrote files" from "nothing changed". A loud `Furnace: source → engine sync wrote N component(s) before build (...)` banner now fires whenever apply wrote files, naming every component that was synced. The `fireforge build --help` description and help footer now call out that apply runs before the build step.
34
+
35
+ ### Furnace create
36
+
37
+ - New `furnace create --xpcshell` flag scaffolds an xpcshell test harness alongside (or instead of) the browser-chrome mochitest that `--with-tests` already produces. xpcshell runs headless without a `tabbrowser`, so storage-layer / observer-driven / module-loading code on forks that do not mount the upstream browser chrome (no `openLinkIn` → `URILoadingHelper`) can be covered without the harness complaining about a missing tab strip. Writes `test_<name>_module_loads.js` and an `xpcshell.toml` manifest into `engine/browser/base/content/test/<binary-name>-xpcshell/<component-name>/`. Registration in `XPCSHELL_TESTS_MANIFESTS` is left to the operator — the moz.build that should own the entry depends on where the component lives.
27
38
 
28
39
  ### Internal
29
40
 
30
41
  - Extracted `furnace-apply-ftl.ts`, `furnace-config-tokens.ts`, and `create-templates.ts` to keep apply / config / scaffolding files under the per-file LOC budget after the new features landed. `parseStringArray` is now exported from `furnace-config.ts` for cross-module reuse.
31
42
  - New `src/core/marionette-preflight.ts` owns the `--doctor` probe and its teardown semantics.
32
43
  - Test mocks for `furnace-registration.js` now cover the new `addLocaleFtlJarMnEntry` / `removeLocaleFtlJarMnEntry` exports; `config.js` mocks in apply-batch tests now cover `loadConfig` because the apply path reads `markerComment` from fireforge.json.
44
+ - Repo-wide scrub of fork-example mentions (`hominis.xhtml`, `HOMINIS` marker-comment examples, fixture tag names) in favour of a generic `mybrowser` / `MYBROWSER` placeholder. FireForge reads as fork-agnostic in docs and fixtures; the npm identity (`@hominis/fireforge`) is unchanged.
33
45
 
34
46
  ## 0.14.0
35
47
 
@@ -249,7 +261,7 @@
249
261
  ### General improvements
250
262
 
251
263
  - getPackageRoot up to this point expected hardcoded `@hominis/fireforge`, was changed to just the package name for potential forks and more flexibility when changing project name.
252
- - Some test generators were derived from early Hominis Browser fork additions, the references to Hominis have been replaced with generic naming.
264
+ - Some test generators were derived from an early downstream fork; the fork-specific names have been replaced with generic naming so the templates apply to any Firefox fork.
253
265
 
254
266
  ### Build and Git reliability
255
267
 
package/README.md CHANGED
@@ -298,7 +298,7 @@ fireforge furnace status # workspace vs engine drift
298
298
  fireforge furnace diff moz-button # unified diff against baseline
299
299
  ```
300
300
 
301
- `furnace deploy` validates components before applying. As always, errors block, warnings are advisory. `fireforge build` and `fireforge test --build` run apply automatically. Use `fireforge doctor --repair-furnace` if the engine gets out of sync.
301
+ `furnace deploy` validates components before applying. As always, errors block, warnings are advisory. `fireforge build` and `fireforge test --build` run apply automatically — when apply wrote files during a build, the build prints a `Furnace: source → engine sync wrote N component(s) …` banner naming every component that was synced, so it is obvious whether engine/ was freshly updated. Use `fireforge doctor --repair-furnace` if the engine gets out of sync.
302
302
 
303
303
  ## Additional Commands
304
304
 
@@ -367,7 +367,7 @@ fireforge token --name "--my-color" --value "light-dark(#fff, #000)"
367
367
  "wire": { "subscriptDir": "browser/components/mybrowser" },
368
368
  "patchLint": {
369
369
  "checkJs": true,
370
- "rawColorAllowlist": ["hominis-tokens.css"]
370
+ "rawColorAllowlist": ["mybrowser-tokens.css"]
371
371
  },
372
372
  "markerComment": "MYBROWSER"
373
373
  }
@@ -375,7 +375,7 @@ fireforge token --name "--my-color" --value "light-dark(#fff, #000)"
375
375
 
376
376
  **`markerComment`** (optional). Appended as a ` // <marker>:` suffix to every line FireForge writes into upstream Firefox source files (starting with `customElements.js`). Keeps fork modifications discoverable and makes re-apply idempotent without hand-tagging entries after each `furnace apply`. Reject list: empty strings, leading/trailing whitespace, newlines, `*/` (would close an enclosing block comment), control characters.
377
377
 
378
- **`furnace.json.tokenHostDocuments`** (optional). List of chrome XHTML documents the `missing-token-link` validator scans for the tokens CSS link. Forks with a second chrome host (e.g. `hominis.xhtml` alongside `browser.xhtml`) should list every document that may own the link — the rule fires only when NONE of them link the tokens CSS. Defaults to `["browser/base/content/browser.xhtml"]` when omitted.
378
+ **`furnace.json.tokenHostDocuments`** (optional). List of chrome XHTML documents the `missing-token-link` validator scans for the tokens CSS link. Forks with a second chrome host (e.g. `mybrowser.xhtml` alongside `browser.xhtml`) should list every document that may own the link — the rule fires only when NONE of them link the tokens CSS. Defaults to `["browser/base/content/browser.xhtml"]` when omitted.
379
379
 
380
380
  ### `furnace create --localized` for `MozLitElement`
381
381
 
@@ -391,6 +391,25 @@ fireforge test --doctor browser/base/content/test/foo/browser_bar.js
391
391
 
392
392
  Spawns the built browser headless, waits for a marionette handshake on `127.0.0.1:2828`, and reports PASS/FAIL with the tail of the browser's stderr on FAIL. Distinguishes "marionette wedged" (socket silent) from "mach test discovery failed" — both otherwise surface as a silent 360-second hang followed by `Passed: 0, Failed: 0`. Useful as a prefix on routine `fireforge test` invocations when marionette has been flaky.
393
393
 
394
+ The probe is a cascade of six layered checks — engine-present → mach-available → python-available → profile-creatable → browser-spawns → marionette-handshake. Each failure is tagged `[layer N/6: <name>]` so the first broken layer is surfaced immediately instead of the whole cascade blocking on the final socket poll. When the browser binary crashes at startup (missing dylib, wrong CPU arch, corrupt profile) the cascade fails at layer 5 within the settle window, not after the full socket timeout.
395
+
396
+ ### Runtime CSS variables in Furnace
397
+
398
+ Design tokens imported from the fork's palette are enforced by `tokenPrefix`, but some components write and read CSS custom properties as runtime state channels (`--cam-x` per frame, `--tile-z` from a hit-test observer). Two escape hatches exist:
399
+
400
+ - **Auto-exempt** — a variable that is both declared (`--foo: 0;`) and consumed (`var(--foo)`) inside the same component's CSS file is recognised as a component-local runtime channel. No config entry required.
401
+ - **`furnace.json.runtimeVariables`** — explicit allowlist for names that are _written_ in JS and _read_ in a different file's CSS (cross-component runtime channels that the CSS-only auto-exempt cannot see). Entries must start with `--`.
402
+
403
+ Both rules compose with the existing `tokenPrefix` / `tokenAllowlist` checks and apply to both component validation and patch-stack lint.
404
+
405
+ ### Test harness options
406
+
407
+ `fireforge furnace create --with-tests` scaffolds a **browser-chrome mochitest**. Use this when the component renders UI that depends on the tab strip (`openLinkIn` → `URILoadingHelper`, `gBrowser`, etc.).
408
+
409
+ `fireforge furnace create --xpcshell` scaffolds an **xpcshell test harness** instead. Use this when the component's code path is storage-only, observer-driven, or module-loading logic that does not touch a `tabbrowser`. xpcshell runs headless without browser chrome, so forks without an upstream tab strip can still cover these paths. The scaffolder writes `test_<name>_module_loads.js` + `xpcshell.toml` into `engine/browser/base/content/test/<binary-name>-xpcshell/<component-name>/` and prints a note: registration in `XPCSHELL_TESTS_MANIFESTS` is the operator's call (the moz.build that should own the entry depends on where the component actually lives).
410
+
411
+ The two flags can be combined — `--with-tests --xpcshell` writes both harnesses.
412
+
394
413
  ## Roadmap
395
414
 
396
415
  Planned but not yet implemented:
@@ -96,10 +96,21 @@ export async function buildCommand(projectRoot, options) {
96
96
  export function registerBuild(program, { getProjectRoot, withErrorHandling }) {
97
97
  program
98
98
  .command('build')
99
- .description('Build the browser')
99
+ .description('Build the browser (auto-applies Furnace components first)')
100
100
  .option('--ui', 'Fast UI-only rebuild')
101
101
  .option('-j, --jobs <n>', 'Number of parallel jobs', parseJobCount)
102
102
  .option('--brand <name>', 'Build specific brand')
103
+ .addHelpText('after', [
104
+ '',
105
+ 'Furnace apply runs automatically before the build step, so edits in',
106
+ 'components/custom/ and components/overrides/ are propagated to the',
107
+ 'engine/ tree every time. The command prints a banner listing the',
108
+ 'components synced during the current invocation.',
109
+ '',
110
+ 'If you want to preview the engine state without triggering a build,',
111
+ 'run `fireforge furnace apply` directly. For source-change-driven',
112
+ 'rebuild loops during development, use `fireforge watch`.',
113
+ ].join('\n'))
103
114
  .action(withErrorHandling(async (options) => {
104
115
  await buildCommand(getProjectRoot(), pickDefined(options));
105
116
  }));
@@ -24,3 +24,24 @@ export declare function generateMjsContent(name: string, className: string, desc
24
24
  export declare function generateCssContent(header: string): string;
25
25
  /** Generates the .ftl file content for a custom component. */
26
26
  export declare function generateFtlContent(name: string, header: string): string;
27
+ /** Returns the canonical xpcshell test file basename for a component. */
28
+ export declare function xpcshellTestFileName(name: string): string;
29
+ /**
30
+ * Generates an xpcshell test file for a custom component.
31
+ *
32
+ * xpcshell tests run headless without a `tabbrowser`, so they suit
33
+ * storage/observer/module-loading code in forks that do not mount the
34
+ * upstream browser chrome (and therefore lack `openLinkIn` →
35
+ * `URILoadingHelper`). The scaffold imports the component module via
36
+ * `ChromeUtils.importESModule` and asserts the module resolves — enough
37
+ * to catch registration regressions without touching DOM rendering paths
38
+ * that xpcshell cannot execute.
39
+ */
40
+ export declare function generateXpcshellTestContent(name: string, header: string): string;
41
+ /**
42
+ * Generates an `xpcshell.toml` manifest for a custom component's test
43
+ * directory. Kept minimal — adding prefs, head.js, and support-files is
44
+ * left to the operator because those decisions depend on what the
45
+ * component actually touches (Services.storage, observer topics, etc.).
46
+ */
47
+ export declare function generateXpcshellManifestContent(name: string, header: string): string;
@@ -83,4 +83,53 @@ export function generateFtlContent(name, header) {
83
83
  ## Strings for the ${name} component
84
84
  `;
85
85
  }
86
+ /** Returns the canonical xpcshell test file basename for a component. */
87
+ export function xpcshellTestFileName(name) {
88
+ return `test_${name.replace(/-/g, '_')}_module_loads.js`;
89
+ }
90
+ /**
91
+ * Generates an xpcshell test file for a custom component.
92
+ *
93
+ * xpcshell tests run headless without a `tabbrowser`, so they suit
94
+ * storage/observer/module-loading code in forks that do not mount the
95
+ * upstream browser chrome (and therefore lack `openLinkIn` →
96
+ * `URILoadingHelper`). The scaffold imports the component module via
97
+ * `ChromeUtils.importESModule` and asserts the module resolves — enough
98
+ * to catch registration regressions without touching DOM rendering paths
99
+ * that xpcshell cannot execute.
100
+ */
101
+ export function generateXpcshellTestContent(name, header) {
102
+ return `${header}
103
+
104
+ "use strict";
105
+
106
+ add_task(async function test_${name.replace(/-/g, '_')}_module_loads() {
107
+ // Module-load smoke check: resolves the ESM at its registered chrome URI.
108
+ // Replace or extend with storage-layer assertions as the component grows
109
+ // (Services.storage, observer topics, JSONFile, etc. are all available
110
+ // here without a tabbrowser).
111
+ const moduleUri = "chrome://global/content/elements/${name}.mjs";
112
+ const module = await ChromeUtils.importESModule(moduleUri);
113
+ Assert.ok(
114
+ module,
115
+ "${name}.mjs should load under xpcshell (storage-layer code path).",
116
+ );
117
+ });
118
+ `;
119
+ }
120
+ /**
121
+ * Generates an `xpcshell.toml` manifest for a custom component's test
122
+ * directory. Kept minimal — adding prefs, head.js, and support-files is
123
+ * left to the operator because those decisions depend on what the
124
+ * component actually touches (Services.storage, observer topics, etc.).
125
+ */
126
+ export function generateXpcshellManifestContent(name, header) {
127
+ return `${header}
128
+
129
+ [DEFAULT]
130
+ head = ""
131
+
132
+ ["${xpcshellTestFileName(name)}"]
133
+ `;
134
+ }
86
135
  //# sourceMappingURL=create-templates.js.map
@@ -0,0 +1,27 @@
1
+ /**
2
+ * xpcshell test-harness scaffolder for `fireforge furnace create --xpcshell`.
3
+ * Extracted from `create.ts` so the command entrypoint stays under the
4
+ * per-file LOC budget and the scaffolder is unit-testable in isolation.
5
+ */
6
+ import { type RollbackJournal } from '../../core/furnace-rollback.js';
7
+ import type { ProjectLicense } from '../../types/config.js';
8
+ /**
9
+ * Scaffolds an xpcshell test harness for a newly created custom component.
10
+ *
11
+ * xpcshell is the appropriate harness for storage-layer code on forks
12
+ * without a `tabbrowser` (no `openLinkIn` → `URILoadingHelper`). Browser
13
+ * chrome mochitests require tabbrowser; xpcshell does not, so storage,
14
+ * observers, and ESM-loading logic can be covered headless.
15
+ *
16
+ * Writes `test_<name>_module_loads.js` and an `xpcshell.toml` manifest
17
+ * into `engine/browser/base/content/test/<binary-name>-xpcshell/
18
+ * <component-name>/`. moz.build registration is intentionally left to the
19
+ * operator — wiring an `XPCSHELL_TESTS_MANIFESTS` entry requires a
20
+ * deliberate choice about which moz.build should own it, and an
21
+ * auto-insertion that guessed wrong would be worse than a note.
22
+ */
23
+ export declare function scaffoldXpcshellTestFiles(componentName: string, license: ProjectLicense, forgeConfig: {
24
+ binaryName: string;
25
+ }, paths: {
26
+ engine: string;
27
+ }, journal?: RollbackJournal): Promise<string[]>;
@@ -0,0 +1,53 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * xpcshell test-harness scaffolder for `fireforge furnace create --xpcshell`.
4
+ * Extracted from `create.ts` so the command entrypoint stays under the
5
+ * per-file LOC budget and the scaffolder is unit-testable in isolation.
6
+ */
7
+ import { join } from 'node:path';
8
+ import { recordCreatedDir, snapshotFile, } from '../../core/furnace-rollback.js';
9
+ import { getLicenseHeader } from '../../core/license-headers.js';
10
+ import { ensureDir, pathExists, writeText } from '../../utils/fs.js';
11
+ import { warn } from '../../utils/logger.js';
12
+ import { generateXpcshellManifestContent, generateXpcshellTestContent, xpcshellTestFileName, } from './create-templates.js';
13
+ /**
14
+ * Scaffolds an xpcshell test harness for a newly created custom component.
15
+ *
16
+ * xpcshell is the appropriate harness for storage-layer code on forks
17
+ * without a `tabbrowser` (no `openLinkIn` → `URILoadingHelper`). Browser
18
+ * chrome mochitests require tabbrowser; xpcshell does not, so storage,
19
+ * observers, and ESM-loading logic can be covered headless.
20
+ *
21
+ * Writes `test_<name>_module_loads.js` and an `xpcshell.toml` manifest
22
+ * into `engine/browser/base/content/test/<binary-name>-xpcshell/
23
+ * <component-name>/`. moz.build registration is intentionally left to the
24
+ * operator — wiring an `XPCSHELL_TESTS_MANIFESTS` entry requires a
25
+ * deliberate choice about which moz.build should own it, and an
26
+ * auto-insertion that guessed wrong would be worse than a note.
27
+ */
28
+ export async function scaffoldXpcshellTestFiles(componentName, license, forgeConfig, paths, journal) {
29
+ const parentDirName = `${forgeConfig.binaryName}-xpcshell`;
30
+ const testDir = join(paths.engine, 'browser/base/content/test', parentDirName, componentName);
31
+ if (journal && !(await pathExists(testDir))) {
32
+ recordCreatedDir(journal, testDir);
33
+ }
34
+ await ensureDir(testDir);
35
+ const jsHeader = getLicenseHeader(license, 'js');
36
+ const hashHeader = getLicenseHeader(license, 'hash');
37
+ const testFiles = [];
38
+ const testFileName = xpcshellTestFileName(componentName);
39
+ const testFilePath = join(testDir, testFileName);
40
+ if (journal)
41
+ await snapshotFile(journal, testFilePath);
42
+ await writeText(testFilePath, generateXpcshellTestContent(componentName, jsHeader));
43
+ testFiles.push(testFileName);
44
+ const manifestPath = join(testDir, 'xpcshell.toml');
45
+ if (journal)
46
+ await snapshotFile(journal, manifestPath);
47
+ await writeText(manifestPath, generateXpcshellManifestContent(componentName, hashHeader));
48
+ testFiles.push('xpcshell.toml');
49
+ warn(`xpcshell scaffold written under browser/base/content/test/${parentDirName}/${componentName}/. ` +
50
+ 'Add the directory to XPCSHELL_TESTS_MANIFESTS in the nearest moz.build to run it via "fireforge test".');
51
+ return testFiles;
52
+ }
53
+ //# sourceMappingURL=create-xpcshell.js.map
@@ -16,6 +16,7 @@ import { toError } from '../../utils/errors.js';
16
16
  import { ensureDir, pathExists, readText, writeText } from '../../utils/fs.js';
17
17
  import { cancel, intro, isCancel, note, outro, success, warn } from '../../utils/logger.js';
18
18
  import { generateCssContent, generateFtlContent, generateMjsContent } from './create-templates.js';
19
+ import { scaffoldXpcshellTestFiles } from './create-xpcshell.js';
19
20
  async function loadAuthoringFurnaceConfig(projectRoot) {
20
21
  if (await furnaceConfigExists(projectRoot)) {
21
22
  return loadFurnaceConfig(projectRoot);
@@ -262,6 +263,10 @@ async function performCreateMutations(args) {
262
263
  const scafFiles = await scaffoldTestFiles(args.componentName, args.license, args.forgeConfig, args.paths, journal);
263
264
  testFiles.push(...scafFiles);
264
265
  }
266
+ if (args.xpcshellTests) {
267
+ const xpcshellFiles = await scaffoldXpcshellTestFiles(args.componentName, args.license, args.forgeConfig, args.paths, journal);
268
+ testFiles.push(...xpcshellFiles);
269
+ }
265
270
  }
266
271
  catch (error) {
267
272
  try {
@@ -396,12 +401,14 @@ export async function furnaceCreateCommand(projectRoot, name, options = {}) {
396
401
  }
397
402
  const { localized, register } = featureSelection;
398
403
  // --with-tests writes files under engine/browser/base/content/test/ and
399
- // registers them in moz.build. Guard against a missing engine now rather
400
- // than letting scaffoldTestFiles fabricate a partial engine tree with
401
- // ensureDir.
404
+ // registers them in moz.build. --xpcshell is the equivalent for forks
405
+ // without a tabbrowser (storage-layer code). Guard against a missing
406
+ // engine now rather than letting the scaffolders fabricate a partial
407
+ // engine tree with ensureDir.
402
408
  const withTests = options.withTests ?? false;
403
- if (withTests && !(await pathExists(paths.engine))) {
404
- throw new FurnaceError('Engine directory not found. Run "fireforge download" first to use --with-tests.', componentName);
409
+ const xpcshellTests = options.xpcshell ?? false;
410
+ if ((withTests || xpcshellTests) && !(await pathExists(paths.engine))) {
411
+ throw new FurnaceError('Engine directory not found. Run "fireforge download" first to use --with-tests or --xpcshell.', componentName);
405
412
  }
406
413
  // --- Generate component files ---
407
414
  const className = tagNameToClassName(componentName);
@@ -438,6 +445,7 @@ export async function furnaceCreateCommand(projectRoot, name, options = {}) {
438
445
  paths,
439
446
  license,
440
447
  withTests,
448
+ xpcshellTests,
441
449
  ftlChromeSubPath,
442
450
  operationContext: ctx,
443
451
  }));
@@ -445,9 +453,10 @@ export async function furnaceCreateCommand(projectRoot, name, options = {}) {
445
453
  let noteParts = `Files created in components/custom/${componentName}/:\n` +
446
454
  files.map((f) => ` ${f}`).join('\n');
447
455
  if (testFiles.length > 0) {
448
- noteParts +=
449
- `\n\nTest files in engine/browser/base/content/test/${forgeConfig.binaryName}/:\n` +
450
- testFiles.map((f) => ` ${f}`).join('\n');
456
+ const testRoot = xpcshellTests
457
+ ? `engine/browser/base/content/test/${forgeConfig.binaryName}-xpcshell/${componentName}/`
458
+ : `engine/browser/base/content/test/${forgeConfig.binaryName}/`;
459
+ noteParts += `\n\nTest files in ${testRoot}:\n` + testFiles.map((f) => ` ${f}`).join('\n');
451
460
  }
452
461
  noteParts +=
453
462
  '\n\n' +
@@ -72,6 +72,7 @@ function registerFurnaceInfoCommands(furnace, context) {
72
72
  .option('--localized', 'Include Fluent l10n support')
73
73
  .option('--no-register', 'Skip customElements.js registration')
74
74
  .option('--with-tests', 'Scaffold Mochitest directory and register in moz.build')
75
+ .option('--xpcshell', 'Scaffold an xpcshell test harness (for storage-layer code on forks without tabbrowser)')
75
76
  .option('--compose <tags>', 'Record stock tags composed internally (metadata only, comma-separated)', (val) => val.split(',').map((s) => s.trim()))
76
77
  .action(withErrorHandling(async (name, options) => {
77
78
  await furnaceCreateCommand(getProjectRoot(), name, options);
@@ -8,4 +8,4 @@ import type { SetupOptions } from '../types/commands/index.js';
8
8
  */
9
9
  export declare function setupCommand(projectRoot: string, options?: SetupOptions): Promise<void>;
10
10
  /** Registers the setup command on the CLI program. */
11
- export declare function registerSetup(program: Command, { getProjectRoot, withErrorHandling }: CommandContext): void;
11
+ export declare function registerSetup(program: Command, { withErrorHandling }: CommandContext): void;
@@ -1,4 +1,5 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
+ import { resolve } from 'node:path';
2
3
  import { confirm } from '@clack/prompts';
3
4
  import { Option } from 'commander';
4
5
  import { configExists } from '../core/config.js';
@@ -57,7 +58,7 @@ export async function setupCommand(projectRoot, options = {}) {
57
58
  }
58
59
  }
59
60
  /** Registers the setup command on the CLI program. */
60
- export function registerSetup(program, { getProjectRoot, withErrorHandling }) {
61
+ export function registerSetup(program, { withErrorHandling }) {
61
62
  program
62
63
  .command('setup')
63
64
  .description('Initialize a new FireForge project')
@@ -88,7 +89,7 @@ export function registerSetup(program, { getProjectRoot, withErrorHandling }) {
88
89
  setupOptions.license = parsedLicense;
89
90
  }
90
91
  }
91
- await setupCommand(getProjectRoot(), setupOptions);
92
+ await setupCommand(resolve(process.cwd()), setupOptions);
92
93
  }));
93
94
  }
94
95
  //# sourceMappingURL=setup.js.map
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { FurnaceError } from '../errors/furnace.js';
7
7
  import { pathExists } from '../utils/fs.js';
8
- import { spinner, warn } from '../utils/logger.js';
8
+ import { info, spinner, warn } from '../utils/logger.js';
9
9
  import { isBrandingSetup, setupBranding } from './branding.js';
10
10
  import { applyAllComponents } from './furnace-apply.js';
11
11
  import { furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, loadFurnaceState, } from './furnace-config.js';
@@ -95,7 +95,12 @@ export async function prepareBuildEnvironment(projectRoot, paths, config) {
95
95
  throw new FurnaceError(`${totalApplyFailures} component${totalApplyFailures === 1 ? '' : 's'} failed to apply cleanly`);
96
96
  }
97
97
  if (furnaceApplied > 0) {
98
+ const appliedNames = result.applied.map((entry) => entry.name).join(', ');
98
99
  furnaceSpinner.stop(`Applied ${furnaceApplied} component${furnaceApplied === 1 ? '' : 's'}`);
100
+ // Loud banner: the build operator needs to see that engine/ was
101
+ // updated before this build, otherwise a silent re-apply is
102
+ // indistinguishable from a build that shipped stale components.
103
+ info(`Furnace: source → engine sync wrote ${furnaceApplied} component${furnaceApplied === 1 ? '' : 's'} before build (${appliedNames}). engine/ now matches components/.`);
99
104
  }
100
105
  else {
101
106
  furnaceSpinner.stop('Components up to date');
@@ -94,7 +94,7 @@ export declare function hasCustomEngineDrift(root: string, name: string, compone
94
94
  export interface CustomApplyOptions {
95
95
  /**
96
96
  * Trailing project marker appended to inserted `customElements.js` entries
97
- * (e.g. `"HOMINIS"` emits ` // HOMINIS:` on each line). Mirrors the
97
+ * (e.g. `"MYBROWSER"` emits ` // MYBROWSER:` on each line). Mirrors the
98
98
  * `markerComment` field in fireforge.json.
99
99
  */
100
100
  markerComment?: string;
@@ -9,3 +9,9 @@
9
9
  * violation; does nothing for `undefined` (field is optional).
10
10
  */
11
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;
@@ -25,4 +25,19 @@ export function validateTokenHostDocuments(raw) {
25
25
  }
26
26
  }
27
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
+ }
28
43
  //# sourceMappingURL=furnace-config-tokens.js.map
@@ -7,7 +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
+ import { validateRuntimeVariables, validateTokenHostDocuments } from './furnace-config-tokens.js';
11
11
  import { resolveFtlDir } from './furnace-constants.js';
12
12
  import { detectComposesCycles, validateComposesReferences } from './furnace-graph-utils.js';
13
13
  import { quarantineStateFile, withStateFileLock } from './state-file.js';
@@ -183,6 +183,9 @@ export function validateFurnaceConfig(data) {
183
183
  if (migrated['tokenAllowlist'] !== undefined) {
184
184
  parseStringArray(migrated['tokenAllowlist'], 'tokenAllowlist');
185
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']);
186
189
  // Validate optional tokenHostDocuments — list of chrome XHTMLs that the
187
190
  // `missing-token-link` validator scans for the tokens CSS link.
188
191
  validateTokenHostDocuments(migrated['tokenHostDocuments']);
@@ -236,14 +239,17 @@ export function validateFurnaceConfig(data) {
236
239
  overrides,
237
240
  custom,
238
241
  };
239
- if (migrated['tokenPrefix'] !== undefined) {
242
+ if (migrated['tokenPrefix'] !== undefined)
240
243
  config.tokenPrefix = migrated['tokenPrefix'];
241
- }
242
244
  if (migrated['tokenAllowlist'] !== undefined) {
243
245
  config.tokenAllowlist = parseStringArray(migrated['tokenAllowlist'], 'tokenAllowlist');
244
246
  }
247
+ if (migrated['runtimeVariables'] !== undefined) {
248
+ config.runtimeVariables = parseStringArray(migrated['runtimeVariables'], 'runtimeVariables');
249
+ }
245
250
  if (migrated['tokenHostDocuments'] !== undefined) {
246
- config.tokenHostDocuments = parseStringArray(migrated['tokenHostDocuments'], 'tokenHostDocuments');
251
+ const docs = parseStringArray(migrated['tokenHostDocuments'], 'tokenHostDocuments');
252
+ config.tokenHostDocuments = docs;
247
253
  }
248
254
  // Validate optional ftlBasePath
249
255
  if (migrated['ftlBasePath'] !== undefined) {
@@ -8,8 +8,8 @@
8
8
  */
9
9
  export interface RegistrationWriteOptions {
10
10
  /**
11
- * Trailing project marker appended to every inserted line (e.g. `"HOMINIS"`
12
- * produces ` // HOMINIS:` at end-of-line). Keeps modifications discoverable
11
+ * Trailing project marker appended to every inserted line (e.g. `"MYBROWSER"`
12
+ * produces ` // MYBROWSER:` at end-of-line). Keeps modifications discoverable
13
13
  * without requiring the operator to hand-tag them post-apply.
14
14
  */
15
15
  markerComment?: string;
@@ -16,7 +16,7 @@ import { validateRegistrationPlacement, validateTagName } from './furnace-regist
16
16
  /**
17
17
  * Returns true when `content` already contains a registration for `tagName`.
18
18
  *
19
- * Tolerates trailing line comments (e.g. a project marker like `// HOMINIS:`)
19
+ * Tolerates trailing line comments (e.g. a project marker like `// MYBROWSER:`)
20
20
  * that an operator may have appended to entries written by a previous apply.
21
21
  * Without this, a re-apply would insert a duplicate entry, and the second
22
22
  * `setElementCreationCallback` at window-load would throw `NotSupportedError`.
@@ -2,7 +2,7 @@
2
2
  import { join } from 'node:path';
3
3
  import { pathExists, readText } from '../utils/fs.js';
4
4
  import { hasRawCssColors } from '../utils/regex.js';
5
- import { classExtendsMozLitElement, collectCssVariableReferences, createIssue, getTokenPrefixContext, hasCustomElementDefineCall, hasRelativeModuleImport, stripCssBlockComments, } from './furnace-validate-helpers.js';
5
+ import { classExtendsMozLitElement, collectCssVariableDeclarations, collectCssVariableReferences, createIssue, getTokenPrefixContext, hasCustomElementDefineCall, hasRelativeModuleImport, stripCssBlockComments, } from './furnace-validate-helpers.js';
6
6
  async function validateMjsCompatibility(mjsPath, tagName) {
7
7
  if (!(await pathExists(mjsPath)))
8
8
  return [];
@@ -29,13 +29,24 @@ async function validateCssCompatibility(cssPath, tagName, type, config, root) {
29
29
  issues.push(createIssue(tagName, 'error', 'raw-color-value', 'Raw color value found. Use CSS custom properties (var(--...)) for design token consistency.'));
30
30
  }
31
31
  if (config?.tokenPrefix) {
32
- const { allowlist, inheritedOverrideVars } = await getTokenPrefixContext(tagName, type, config, root);
32
+ const { allowlist, inheritedOverrideVars, runtimeVariables } = await getTokenPrefixContext(tagName, type, config, root);
33
+ // Auto-exempt component-local runtime channels: a CSS custom property
34
+ // both declared and consumed in the same file is a runtime state
35
+ // channel (e.g. `--cam-x`), not a design-token reference. See
36
+ // `runtimeVariables` in furnace.json for cross-component cases.
37
+ const localDeclarations = collectCssVariableDeclarations(cssContent);
33
38
  for (const prop of collectCssVariableReferences(cssContent)) {
34
- if (!prop.startsWith(config.tokenPrefix) &&
35
- !allowlist.has(prop) &&
36
- !inheritedOverrideVars.has(prop)) {
37
- issues.push(createIssue(tagName, 'error', 'token-prefix-violation', `CSS references var(${prop}) which does not match the required token prefix "${config.tokenPrefix}". Use a design token or add to tokenAllowlist.`));
38
- }
39
+ if (prop.startsWith(config.tokenPrefix))
40
+ continue;
41
+ if (allowlist.has(prop))
42
+ continue;
43
+ if (inheritedOverrideVars.has(prop))
44
+ continue;
45
+ if (runtimeVariables.has(prop))
46
+ continue;
47
+ if (localDeclarations.has(prop))
48
+ continue;
49
+ issues.push(createIssue(tagName, 'error', 'token-prefix-violation', `CSS references var(${prop}) which does not match the required token prefix "${config.tokenPrefix}". Use a design token, add to tokenAllowlist, or (for runtime state channels) list the variable in runtimeVariables.`));
39
50
  }
40
51
  }
41
52
  // Flag excessive !important usage
@@ -21,7 +21,25 @@ export declare function hasTemplateKeyboardHandler(content: string): boolean;
21
21
  * interactive element — those already fire `click` on keyboard activation.
22
22
  */
23
23
  export declare function hasTemplateClickOnSyntheticInteractive(content: string): boolean;
24
- /** Detects hardcoded user-visible template text that should usually be localized. */
24
+ /**
25
+ * Detects hardcoded user-visible template text that should usually be
26
+ * localized.
27
+ *
28
+ * Scoped to three positive contexts rather than scanning the whole file,
29
+ * because a bare `>…<` regex catches JS comparisons (`if (x > 0 && y <
30
+ * 100)`), diagnostic strings (`console.error("Failed <id> lookup")`), and
31
+ * identifier literals that are never shown to a user. Only matches that
32
+ * actually enter a UI render path count:
33
+ *
34
+ * 1. Content inside a Lit `` html`…` `` tagged template literal.
35
+ * 2. The string literal on the RHS of `.textContent = "…"` or
36
+ * `.innerHTML = "…"`.
37
+ * 3. The string literal assigned to an XUL-widget `label=`,
38
+ * `title=`, or `tooltiptext=` attribute when constructing DOM in JS.
39
+ *
40
+ * A file-wide `// furnace-ignore: hardcoded-text` comment suppresses all
41
+ * findings (matches the pre-existing escape hatch).
42
+ */
25
43
  export declare function containsHardcodedTemplateText(content: string): boolean;
26
44
  /** Detects whether a component opts into shadow-root focus delegation. */
27
45
  export declare function hasDelegatesFocusEnabled(content: string): boolean;
@@ -35,8 +53,20 @@ export declare function hasCustomElementDefineCall(mjsContent: string): boolean;
35
53
  export declare function classExtendsMozLitElement(mjsContent: string): boolean;
36
54
  /** Collects CSS custom property references used via var(--token-name). */
37
55
  export declare function collectCssVariableReferences(cssContent: string): string[];
56
+ /**
57
+ * Collects CSS custom property *declarations* — names appearing on the
58
+ * left-hand side of a `--name:` declaration. Used to auto-exempt
59
+ * component-local runtime variables from the token-prefix check: if the
60
+ * component both declares and consumes a variable in its own CSS file, it
61
+ * is a local runtime channel, not a design-token reference.
62
+ *
63
+ * The anchor `(?:^|[{;,\s])` rules out `var(--name)` occurrences (which are
64
+ * always preceded by `(`), so references are not mistaken for declarations.
65
+ */
66
+ export declare function collectCssVariableDeclarations(cssContent: string): Set<string>;
38
67
  /** Builds token-validation context from the config allowlist and inherited override CSS. */
39
68
  export declare function getTokenPrefixContext(tagName: string, type: ComponentType, config: FurnaceConfig, root: string | undefined): Promise<{
40
69
  allowlist: Set<string>;
41
70
  inheritedOverrideVars: Set<string>;
71
+ runtimeVariables: Set<string>;
42
72
  }>;
@@ -148,28 +148,88 @@ function isWithinLocalizedElement(content, matchIndex) {
148
148
  const tagContent = contentBefore.slice(lastTagOpen, matchIndex + 1);
149
149
  return /data-l10n-id\s*=/.test(tagContent);
150
150
  }
151
- /** Detects hardcoded user-visible template text that should usually be localized. */
151
+ /**
152
+ * Detects hardcoded user-visible template text that should usually be
153
+ * localized.
154
+ *
155
+ * Scoped to three positive contexts rather than scanning the whole file,
156
+ * because a bare `>…<` regex catches JS comparisons (`if (x > 0 && y <
157
+ * 100)`), diagnostic strings (`console.error("Failed <id> lookup")`), and
158
+ * identifier literals that are never shown to a user. Only matches that
159
+ * actually enter a UI render path count:
160
+ *
161
+ * 1. Content inside a Lit `` html`…` `` tagged template literal.
162
+ * 2. The string literal on the RHS of `.textContent = "…"` or
163
+ * `.innerHTML = "…"`.
164
+ * 3. The string literal assigned to an XUL-widget `label=`,
165
+ * `title=`, or `tooltiptext=` attribute when constructing DOM in JS.
166
+ *
167
+ * A file-wide `// furnace-ignore: hardcoded-text` comment suppresses all
168
+ * findings (matches the pre-existing escape hatch).
169
+ */
152
170
  export function containsHardcodedTemplateText(content) {
153
171
  if (/furnace-ignore:\s*hardcoded-text/.test(content)) {
154
172
  return false;
155
173
  }
156
- const textPattern = />([^<$\s][^<$]*)</g;
157
- let textMatch;
158
- while ((textMatch = textPattern.exec(content)) !== null) {
159
- const text = textMatch[1]?.trim() ?? '';
160
- if (/\$\{/.test(text)) {
161
- continue;
162
- }
163
- if (Array.from(text).length <= 1) {
164
- continue;
165
- }
166
- if (isSymbolOnlyText(text)) {
167
- continue;
168
- }
169
- if (isWithinLocalizedElement(content, textMatch.index)) {
170
- continue;
174
+ return (hasFlaggedTextInLitTemplates(content) ||
175
+ hasFlaggedTextInDomAssignment(content) ||
176
+ hasFlaggedTextInXulAttribute(content));
177
+ }
178
+ function isFlaggableText(text) {
179
+ const trimmed = text.trim();
180
+ if (!trimmed)
181
+ return false;
182
+ if (/\$\{/.test(trimmed))
183
+ return false;
184
+ if (Array.from(trimmed).length <= 1)
185
+ return false;
186
+ if (isSymbolOnlyText(trimmed))
187
+ return false;
188
+ return true;
189
+ }
190
+ function hasFlaggedTextInLitTemplates(content) {
191
+ // Match `html\`…\`` regions, anchored on a non-identifier char before `html`
192
+ // so substrings like `otherhtml` do not spuriously open a template.
193
+ const htmlPattern = /(?:^|[^a-zA-Z0-9_$])html`([\s\S]*?)`/g;
194
+ let litMatch;
195
+ while ((litMatch = htmlPattern.exec(content)) !== null) {
196
+ const region = litMatch[1] ?? '';
197
+ const textPattern = />([^<$\s][^<$]*)</g;
198
+ let textMatch;
199
+ while ((textMatch = textPattern.exec(region)) !== null) {
200
+ const text = textMatch[1] ?? '';
201
+ if (!isFlaggableText(text))
202
+ continue;
203
+ if (isWithinLocalizedElement(region, textMatch.index))
204
+ continue;
205
+ return true;
171
206
  }
172
- return true;
207
+ }
208
+ return false;
209
+ }
210
+ function hasFlaggedTextInDomAssignment(content) {
211
+ // `<expr>.textContent = "abc"` and `<expr>.innerHTML = "abc"` — these are
212
+ // user-visible render paths. Template-literal RHS is excluded (usually
213
+ // dynamic), matching the `${` guard used elsewhere in this helper.
214
+ const assignPattern = /\.(?:textContent|innerHTML)\s*=\s*(["'])((?:\\.|(?!\1).)*)\1/g;
215
+ let match;
216
+ while ((match = assignPattern.exec(content)) !== null) {
217
+ const text = match[2] ?? '';
218
+ if (isFlaggableText(text))
219
+ return true;
220
+ }
221
+ return false;
222
+ }
223
+ function hasFlaggedTextInXulAttribute(content) {
224
+ // Assignments like `node.setAttribute("label", "Save")` or JS-built XUL
225
+ // attributes `label="…"` / `title="…"` / `tooltiptext="…"` in template
226
+ // literals outside Lit blocks. Covers DOM built via createXULElement.
227
+ const setAttrPattern = /setAttribute\s*\(\s*["'](?:label|title|tooltiptext)["']\s*,\s*(["'])((?:\\.|(?!\1).)*)\1/g;
228
+ let setAttrMatch;
229
+ while ((setAttrMatch = setAttrPattern.exec(content)) !== null) {
230
+ const text = setAttrMatch[2] ?? '';
231
+ if (isFlaggableText(text))
232
+ return true;
173
233
  }
174
234
  return false;
175
235
  }
@@ -214,6 +274,27 @@ export function collectCssVariableReferences(cssContent) {
214
274
  }
215
275
  return referencedVariables;
216
276
  }
277
+ /**
278
+ * Collects CSS custom property *declarations* — names appearing on the
279
+ * left-hand side of a `--name:` declaration. Used to auto-exempt
280
+ * component-local runtime variables from the token-prefix check: if the
281
+ * component both declares and consumes a variable in its own CSS file, it
282
+ * is a local runtime channel, not a design-token reference.
283
+ *
284
+ * The anchor `(?:^|[{;,\s])` rules out `var(--name)` occurrences (which are
285
+ * always preceded by `(`), so references are not mistaken for declarations.
286
+ */
287
+ export function collectCssVariableDeclarations(cssContent) {
288
+ const declared = new Set();
289
+ const pattern = /(?:^|[{;,\s])(--[\w-]+)\s*:/g;
290
+ let match;
291
+ while ((match = pattern.exec(cssContent)) !== null) {
292
+ const name = match[1];
293
+ if (name)
294
+ declared.add(name);
295
+ }
296
+ return declared;
297
+ }
217
298
  async function collectInheritedOverrideVariables(tagName, config, root) {
218
299
  const inheritedVariables = new Set();
219
300
  const basePath = config.overrides[tagName]?.basePath;
@@ -234,12 +315,14 @@ async function collectInheritedOverrideVariables(tagName, config, root) {
234
315
  /** Builds token-validation context from the config allowlist and inherited override CSS. */
235
316
  export async function getTokenPrefixContext(tagName, type, config, root) {
236
317
  const allowlist = new Set(config.tokenAllowlist ?? []);
318
+ const runtimeVariables = new Set(config.runtimeVariables ?? []);
237
319
  if (type !== 'override' || !root) {
238
- return { allowlist, inheritedOverrideVars: new Set() };
320
+ return { allowlist, inheritedOverrideVars: new Set(), runtimeVariables };
239
321
  }
240
322
  return {
241
323
  allowlist,
242
324
  inheritedOverrideVars: await collectInheritedOverrideVariables(tagName, config, root),
325
+ runtimeVariables,
243
326
  };
244
327
  }
245
328
  //# sourceMappingURL=furnace-validate-helpers.js.map
@@ -35,7 +35,7 @@ export declare function validateJarMnEntries(root: string, config: FurnaceConfig
35
35
  * linked in at least one chrome host document. Without the link, tokens
36
36
  * silently resolve to nothing at runtime.
37
37
  *
38
- * Forks with multiple chrome host documents (e.g. `hominis.xhtml` beside
38
+ * Forks with multiple chrome host documents (e.g. `mybrowser.xhtml` beside
39
39
  * `browser.xhtml`) can enumerate them via `tokenHostDocuments` in
40
40
  * furnace.json; the warning fires only when NONE of the configured
41
41
  * documents link the tokens CSS.
@@ -216,7 +216,7 @@ const DEFAULT_TOKEN_HOST_DOCUMENTS = ['browser/base/content/browser.xhtml'];
216
216
  * linked in at least one chrome host document. Without the link, tokens
217
217
  * silently resolve to nothing at runtime.
218
218
  *
219
- * Forks with multiple chrome host documents (e.g. `hominis.xhtml` beside
219
+ * Forks with multiple chrome host documents (e.g. `mybrowser.xhtml` beside
220
220
  * `browser.xhtml`) can enumerate them via `tokenHostDocuments` in
221
221
  * furnace.json; the warning fires only when NONE of the configured
222
222
  * documents link the tokens CSS.
@@ -6,18 +6,17 @@
6
6
  * to discover"; this helper surfaces the failure in under a minute with a
7
7
  * clear PASS/FAIL line and the tail of the browser's stderr.
8
8
  *
9
- * The probe is intentionally narrow it does not replace mach test or try
10
- * to execute anything via marionette. It spawns `mach run --marionette
11
- * --headless` (plus a throwaway profile) and waits for the marionette server
12
- * to accept a TCP connection on the conventional port. Any byte read from
13
- * the socket proves a handshake payload is being produced.
9
+ * The probe is a cascade of layered checks (engine mach python
10
+ * profile spawn handshake). Each layer has a tight per-attempt budget
11
+ * so a broken earlier layer fails fast with a specific diagnosis rather
12
+ * than blocking on the final socket poll for the full overall budget.
14
13
  */
15
14
  import { spawn } from 'node:child_process';
16
15
  import net from 'node:net';
17
16
  export interface MarionettePreflightResult {
18
17
  ok: boolean;
19
18
  durationMs: number;
20
- /** Human-readable summary. */
19
+ /** Human-readable summary. On FAIL, prefixed with `[layer N/6: <name>]`. */
21
20
  detail: string;
22
21
  }
23
22
  export interface MarionettePreflightOptions {
@@ -25,13 +24,21 @@ export interface MarionettePreflightOptions {
25
24
  timeoutMs?: number;
26
25
  /** Overrides marionette TCP port — primarily used in tests. */
27
26
  port?: number;
27
+ /**
28
+ * Grace window after spawn() before the browser is considered "running
29
+ * OK." Catches immediate crashes (missing dylib, wrong CPU arch, corrupt
30
+ * profile) at the spawn layer rather than the handshake layer. Default:
31
+ * {@link SPAWN_SETTLE_MS}. Tests may set this to 0 to skip the settle.
32
+ */
33
+ spawnSettleMs?: number;
28
34
  /** Test seam: spawn and socket connect factories. */
29
35
  spawner?: typeof spawn;
30
36
  connect?: typeof net.createConnection;
31
37
  }
32
38
  /**
33
39
  * Runs the marionette preflight. Returns PASS on first byte read from the
34
- * marionette socket within the budget; FAIL otherwise. Always tears down the
40
+ * marionette socket within the budget; FAIL otherwise, with a diagnostic
41
+ * identifying which layer of the cascade broke. Always tears down the
35
42
  * spawned browser before returning.
36
43
  */
37
44
  export declare function runMarionettePreflight(engineDir: string, options?: MarionettePreflightOptions): Promise<MarionettePreflightResult>;
@@ -7,11 +7,10 @@
7
7
  * to discover"; this helper surfaces the failure in under a minute with a
8
8
  * clear PASS/FAIL line and the tail of the browser's stderr.
9
9
  *
10
- * The probe is intentionally narrow it does not replace mach test or try
11
- * to execute anything via marionette. It spawns `mach run --marionette
12
- * --headless` (plus a throwaway profile) and waits for the marionette server
13
- * to accept a TCP connection on the conventional port. Any byte read from
14
- * the socket proves a handshake payload is being produced.
10
+ * The probe is a cascade of layered checks (engine mach python
11
+ * profile spawn handshake). Each layer has a tight per-attempt budget
12
+ * so a broken earlier layer fails fast with a specific diagnosis rather
13
+ * than blocking on the final socket poll for the full overall budget.
15
14
  */
16
15
  import { spawn } from 'node:child_process';
17
16
  import { mkdtemp, rm } from 'node:fs/promises';
@@ -28,60 +27,112 @@ const MARIONETTE_PORT = 2828;
28
27
  const DEFAULT_PREFLIGHT_TIMEOUT_MS = 30_000;
29
28
  /** Per-attempt socket connect timeout. Polling continues until the overall budget expires. */
30
29
  const SOCKET_ATTEMPT_TIMEOUT_MS = 2_000;
30
+ /**
31
+ * Grace window after spawn() returns before we accept the child as
32
+ * "spawned OK". A browser binary that exits immediately (missing dylib,
33
+ * wrong CPU arch, corrupt profile) must be caught here — not 30 seconds
34
+ * later at the socket layer.
35
+ */
36
+ const SPAWN_SETTLE_MS = 750;
31
37
  /** Tail of stderr preserved for FAIL diagnostics. */
32
38
  const STDERR_TAIL_LIMIT = 8 * 1024;
39
+ /**
40
+ * Layer names, ordered by the probe sequence. Surfaced in `detail` so the
41
+ * operator sees which layer failed without having to guess.
42
+ */
43
+ const LAYER_NAMES = [
44
+ 'engine-present',
45
+ 'mach-available',
46
+ 'python-available',
47
+ 'profile-creatable',
48
+ 'browser-spawns',
49
+ 'marionette-handshake',
50
+ ];
51
+ function layerTag(name) {
52
+ const index = LAYER_NAMES.indexOf(name) + 1;
53
+ return `[layer ${index}/${LAYER_NAMES.length}: ${name}]`;
54
+ }
33
55
  /**
34
56
  * Runs the marionette preflight. Returns PASS on first byte read from the
35
- * marionette socket within the budget; FAIL otherwise. Always tears down the
57
+ * marionette socket within the budget; FAIL otherwise, with a diagnostic
58
+ * identifying which layer of the cascade broke. Always tears down the
36
59
  * spawned browser before returning.
37
60
  */
38
61
  export async function runMarionettePreflight(engineDir, options = {}) {
39
62
  const timeoutMs = options.timeoutMs ?? DEFAULT_PREFLIGHT_TIMEOUT_MS;
63
+ const spawnSettleMs = options.spawnSettleMs ?? SPAWN_SETTLE_MS;
40
64
  const port = options.port ?? MARIONETTE_PORT;
41
65
  const spawnerFn = options.spawner ?? spawn;
42
66
  const connectFn = options.connect ?? net.createConnection;
43
67
  const startedAt = Date.now();
44
68
  const elapsed = () => Date.now() - startedAt;
69
+ // Layer 1: engine directory exists.
45
70
  if (!(await pathExists(engineDir))) {
46
- return {
47
- ok: false,
48
- durationMs: elapsed(),
49
- detail: 'Engine directory not found — run "fireforge download" first.',
50
- };
71
+ return fail('engine-present', 'Engine directory not found — run "fireforge download" first.', elapsed());
51
72
  }
73
+ // Layer 2: mach binary resolves in the engine.
52
74
  try {
53
75
  await ensureMach(engineDir);
54
76
  }
55
77
  catch (error) {
56
- return {
57
- ok: false,
58
- durationMs: elapsed(),
59
- detail: `mach not available in engine: ${error.message}`,
60
- };
78
+ return fail('mach-available', `mach not available in engine: ${error.message}`, elapsed());
79
+ }
80
+ // Layer 3: Python that mach requires is discoverable.
81
+ let python;
82
+ try {
83
+ python = await getPython(engineDir);
84
+ }
85
+ catch (error) {
86
+ return fail('python-available', `Python interpreter required by mach is not available: ${error.message}`, elapsed());
87
+ }
88
+ // Layer 4: throwaway browser profile directory is creatable.
89
+ let profileDir;
90
+ try {
91
+ profileDir = await mkdtemp(join(tmpdir(), 'fireforge-marionette-'));
92
+ }
93
+ catch (error) {
94
+ return fail('profile-creatable', `Could not create a throwaway browser profile in ${tmpdir()}: ${error.message}`, elapsed());
61
95
  }
62
- const python = await getPython(engineDir);
63
- const profileDir = await mkdtemp(join(tmpdir(), 'fireforge-marionette-'));
64
96
  let child;
65
97
  let stderrTail = '';
66
98
  try {
67
- child = spawnerFn(python, [
68
- join(engineDir, 'mach'),
69
- 'run',
70
- '--marionette',
71
- '--headless',
72
- '--no-remote',
73
- '-profile',
74
- profileDir,
75
- ], {
76
- cwd: engineDir,
77
- env: { ...process.env, MOZ_HEADLESS: '1' },
78
- stdio: ['ignore', 'ignore', 'pipe'],
79
- });
99
+ // Layer 5: browser spawns and does not crash within the settle window.
100
+ try {
101
+ child = spawnerFn(python, [
102
+ join(engineDir, 'mach'),
103
+ 'run',
104
+ '--marionette',
105
+ '--headless',
106
+ '--no-remote',
107
+ '-profile',
108
+ profileDir,
109
+ ], {
110
+ cwd: engineDir,
111
+ env: { ...process.env, MOZ_HEADLESS: '1' },
112
+ stdio: ['ignore', 'ignore', 'pipe'],
113
+ });
114
+ }
115
+ catch (error) {
116
+ return fail('browser-spawns', `Could not spawn mach run: ${error.message}`, elapsed());
117
+ }
118
+ const spawnedChild = child;
80
119
  child.stderr?.on('data', (data) => {
81
120
  const chunk = data.toString();
82
121
  stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_LIMIT);
83
122
  });
84
- const spawnedChild = child;
123
+ // Short settle window — catches "binary exits immediately" failures
124
+ // (missing dylib, wrong CPU arch, corrupt profile) before the socket
125
+ // poll swallows the full overall budget waiting for bytes that will
126
+ // never come.
127
+ const settleDeadline = Math.min(spawnSettleMs, Math.max(0, timeoutMs - elapsed()));
128
+ if (settleDeadline > 0) {
129
+ await delay(settleDeadline);
130
+ }
131
+ if (hasChildExited(spawnedChild)) {
132
+ return fail('browser-spawns', `Browser process exited during spawn (exit code ${String(spawnedChild.exitCode)}, signal ${spawnedChild.signalCode ?? 'none'}). ` +
133
+ `stderr tail: ${stderrTail.trim().slice(-2_000) || '(empty)'}`, elapsed());
134
+ }
135
+ // Layer 6: marionette handshake within the remaining budget.
85
136
  const socketResult = await waitForMarionetteSocket(port, connectFn, () => {
86
137
  return elapsed() < timeoutMs && !hasChildExited(spawnedChild);
87
138
  });
@@ -95,19 +146,11 @@ export async function runMarionettePreflight(engineDir, options = {}) {
95
146
  // Child may have exited before the socket was ever ready — surface that
96
147
  // distinctly from "socket never answered" so the operator has a lead.
97
148
  if (hasChildExited(spawnedChild)) {
98
- return {
99
- ok: false,
100
- durationMs: elapsed(),
101
- detail: `Browser process exited before marionette handshake (exit code ${String(spawnedChild.exitCode)}, signal ${spawnedChild.signalCode ?? 'none'}). ` +
102
- `stderr tail: ${stderrTail.trim().slice(-2_000) || '(empty)'}`,
103
- };
149
+ return fail('marionette-handshake', `Browser process exited before marionette handshake (exit code ${String(spawnedChild.exitCode)}, signal ${spawnedChild.signalCode ?? 'none'}). ` +
150
+ `stderr tail: ${stderrTail.trim().slice(-2_000) || '(empty)'}`, elapsed());
104
151
  }
105
- return {
106
- ok: false,
107
- durationMs: elapsed(),
108
- detail: `Marionette socket on 127.0.0.1:${port} did not respond within ${timeoutMs}ms. ` +
109
- `stderr tail: ${stderrTail.trim().slice(-2_000) || '(empty)'}`,
110
- };
152
+ return fail('marionette-handshake', `Marionette socket on 127.0.0.1:${port} did not respond within ${timeoutMs}ms. ` +
153
+ `stderr tail: ${stderrTail.trim().slice(-2_000) || '(empty)'}`, elapsed());
111
154
  }
112
155
  finally {
113
156
  if (child && !hasChildExited(child)) {
@@ -137,6 +180,13 @@ export async function runMarionettePreflight(engineDir, options = {}) {
137
180
  }
138
181
  }
139
182
  }
183
+ function fail(layer, message, durationMs) {
184
+ return {
185
+ ok: false,
186
+ durationMs,
187
+ detail: `${layerTag(layer)} ${message}`,
188
+ };
189
+ }
140
190
  /** Returns true when the child process has exited (normal or signaled). */
141
191
  function hasChildExited(child) {
142
192
  return child.exitCode !== null || child.signalCode !== null;
@@ -91,7 +91,7 @@ export declare function collectNewFileCreatorsByPath(ctx: PatchQueueContext): Ma
91
91
  /**
92
92
  * Cross-patch lint rule: the same path is newly created (`--- /dev/null →
93
93
  * +++ b/path`) by more than one patch. This is the failure mode that
94
- * motivated the rule — Hominis landed three patches each trying to create
94
+ * motivated the rule — a fork landed three patches each trying to create
95
95
  * the same file, and the error surfaced only when import rolled back
96
96
  * mid-apply.
97
97
  *
@@ -114,7 +114,7 @@ export function collectNewFileCreatorsByPath(ctx) {
114
114
  /**
115
115
  * Cross-patch lint rule: the same path is newly created (`--- /dev/null →
116
116
  * +++ b/path`) by more than one patch. This is the failure mode that
117
- * motivated the rule — Hominis landed three patches each trying to create
117
+ * motivated the rule — a fork landed three patches each trying to create
118
118
  * the same file, and the error surfaced only when import rolled back
119
119
  * mid-apply.
120
120
  *
@@ -110,12 +110,14 @@ export async function lintPatchedCss(repoDir, affectedFiles, diffContent, config
110
110
  // Load furnace config gracefully — skip token-prefix check if unavailable
111
111
  let tokenPrefix;
112
112
  let tokenAllowlist;
113
+ let runtimeVariables;
113
114
  try {
114
115
  const root = join(repoDir, '..');
115
116
  const furnaceConfig = await loadFurnaceConfig(root);
116
117
  if (furnaceConfig.tokenPrefix) {
117
118
  tokenPrefix = furnaceConfig.tokenPrefix;
118
119
  tokenAllowlist = new Set(furnaceConfig.tokenAllowlist ?? []);
120
+ runtimeVariables = new Set(furnaceConfig.runtimeVariables ?? []);
119
121
  }
120
122
  }
121
123
  catch (error) {
@@ -154,20 +156,38 @@ export async function lintPatchedCss(repoDir, affectedFiles, diffContent, config
154
156
  });
155
157
  }
156
158
  }
157
- // Check for non-tokenized custom properties
159
+ // Check for non-tokenized custom properties. A variable that is both
160
+ // declared and consumed inside the same file is auto-exempted as a
161
+ // runtime state channel (see furnace.json → runtimeVariables).
158
162
  if (tokenPrefix) {
163
+ const declarationPattern = /(?:^|[{;,\s])(--[\w-]+)\s*:/g;
164
+ const localDeclarations = new Set();
165
+ let declMatch;
166
+ while ((declMatch = declarationPattern.exec(cssContent)) !== null) {
167
+ const name = declMatch[1];
168
+ if (name)
169
+ localDeclarations.add(name);
170
+ }
159
171
  const varPattern = /var\(\s*(--[\w-]+)/g;
160
172
  let match;
161
173
  while ((match = varPattern.exec(cssContent)) !== null) {
162
174
  const prop = match[1];
163
- if (prop && !prop.startsWith(tokenPrefix) && !tokenAllowlist?.has(prop)) {
164
- issues.push({
165
- file,
166
- check: 'token-prefix-violation',
167
- message: `CSS references var(${prop}) which does not match the required token prefix "${tokenPrefix}". Use a design token or add to tokenAllowlist.`,
168
- severity: 'error',
169
- });
170
- }
175
+ if (!prop)
176
+ continue;
177
+ if (prop.startsWith(tokenPrefix))
178
+ continue;
179
+ if (tokenAllowlist?.has(prop))
180
+ continue;
181
+ if (runtimeVariables?.has(prop))
182
+ continue;
183
+ if (localDeclarations.has(prop))
184
+ continue;
185
+ issues.push({
186
+ file,
187
+ check: 'token-prefix-violation',
188
+ message: `CSS references var(${prop}) which does not match the required token prefix "${tokenPrefix}". Use a design token, add to tokenAllowlist, or (for runtime state channels) list the variable in runtimeVariables.`,
189
+ severity: 'error',
190
+ });
171
191
  }
172
192
  }
173
193
  }
@@ -271,6 +271,16 @@ export interface FurnaceCreateOptions {
271
271
  register?: boolean;
272
272
  /** Scaffold Mochitest directory and register in moz.build */
273
273
  withTests?: boolean;
274
+ /**
275
+ * Scaffold an xpcshell test harness (headless, no tabbrowser) instead of
276
+ * browser-chrome. Required for forks without a `tabbrowser` (storage-only
277
+ * code, observer-driven modules). Implies `withTests` when set. Writes an
278
+ * `xpcshell.toml` + `test_<name>.js` under
279
+ * `engine/browser/base/content/test/<binary-name>-xpcshell/` and leaves
280
+ * moz.build registration to the operator (add the directory to
281
+ * `XPCSHELL_TESTS_MANIFESTS`).
282
+ */
283
+ xpcshell?: boolean;
274
284
  /** Stock component tag names composed internally by this component */
275
285
  compose?: string[];
276
286
  }
@@ -47,7 +47,7 @@ export interface FireForgeConfig {
47
47
  /**
48
48
  * Project marker prefix appended to lines FireForge writes into
49
49
  * upstream Firefox source files (e.g. the `customElements.js` tag list).
50
- * `"HOMINIS"` emits a trailing ` // HOMINIS:` on each inserted line.
50
+ * `"MYBROWSER"` emits a trailing ` // MYBROWSER:` on each inserted line.
51
51
  * Keeps modifications discoverable and re-applies idempotent.
52
52
  */
53
53
  markerComment?: string;
@@ -63,10 +63,21 @@ export interface FurnaceConfig {
63
63
  tokenPrefix?: string;
64
64
  /** Custom properties allowed even though they don't match tokenPrefix (e.g. ["--background-color-box"]) */
65
65
  tokenAllowlist?: string[];
66
+ /**
67
+ * Custom properties used as runtime state channels — written and read by the
68
+ * component itself (e.g. per-frame camera/tile positions) rather than
69
+ * consumed as design tokens. Listed names are exempt from the
70
+ * `token-prefix-violation` check even when they do not match `tokenPrefix`
71
+ * and are not in `tokenAllowlist`. Use this for cross-component runtime
72
+ * variables (e.g. set in JS, read in CSS of a child). Component-local
73
+ * variables that are both declared and consumed inside the same component's
74
+ * own CSS file are auto-exempted and do not need an entry here.
75
+ */
76
+ runtimeVariables?: string[];
66
77
  /**
67
78
  * Chrome documents scanned by the `missing-token-link` validator to confirm
68
79
  * the tokens CSS file is `<link>`ed. Forks with multiple chrome host
69
- * documents (e.g. `hominis.xhtml` beside the stock `browser.xhtml`) should
80
+ * documents (e.g. `mybrowser.xhtml` beside the stock `browser.xhtml`) should
70
81
  * list every document that may own the link. When omitted, defaults to
71
82
  * `['browser/base/content/browser.xhtml']` — the upstream Firefox path.
72
83
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hominis/fireforge",
3
- "version": "0.15.1",
3
+ "version": "0.15.2",
4
4
  "description": "FireForge — a build tool for customizing Firefox",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",