@hominis/fireforge 0.32.0 → 0.33.0

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 (35) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/src/commands/patch/split-plan.d.ts +18 -2
  3. package/dist/src/commands/patch/split-plan.js +90 -16
  4. package/dist/src/commands/patch/split.js +12 -3
  5. package/dist/src/commands/token.js +12 -1
  6. package/dist/src/commands/typecheck.js +35 -0
  7. package/dist/src/core/build-prepare.js +23 -3
  8. package/dist/src/core/config-validate.js +26 -0
  9. package/dist/src/core/furnace-apply-dry-run.d.ts +17 -0
  10. package/dist/src/core/furnace-apply-dry-run.js +105 -0
  11. package/dist/src/core/furnace-apply-ftl.d.ts +12 -0
  12. package/dist/src/core/furnace-apply-ftl.js +97 -1
  13. package/dist/src/core/furnace-apply-helpers.js +10 -80
  14. package/dist/src/core/mach-resource-shim.d.ts +21 -0
  15. package/dist/src/core/mach-resource-shim.js +92 -0
  16. package/dist/src/core/mach.js +9 -2
  17. package/dist/src/core/manifest-helpers.js +29 -4
  18. package/dist/src/core/patch-lint-cross.d.ts +31 -0
  19. package/dist/src/core/patch-lint-cross.js +83 -63
  20. package/dist/src/core/patch-lint-reexports.d.ts +1 -1
  21. package/dist/src/core/patch-lint-reexports.js +1 -1
  22. package/dist/src/core/test-harness-crash.d.ts +6 -3
  23. package/dist/src/core/test-harness-crash.js +32 -4
  24. package/dist/src/core/token-dark-mode.d.ts +9 -0
  25. package/dist/src/core/token-dark-mode.js +1 -1
  26. package/dist/src/core/token-docs.d.ts +32 -0
  27. package/dist/src/core/token-docs.js +101 -0
  28. package/dist/src/core/token-manager.d.ts +8 -0
  29. package/dist/src/core/token-manager.js +77 -95
  30. package/dist/src/core/token-variant.d.ts +39 -0
  31. package/dist/src/core/token-variant.js +141 -0
  32. package/dist/src/core/typecheck.js +56 -28
  33. package/dist/src/types/commands/options.d.ts +5 -0
  34. package/dist/src/types/config.d.ts +13 -0
  35. package/package.json +3 -3
@@ -0,0 +1,141 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Attribute-variant block helpers for the tokens CSS scaffold.
4
+ *
5
+ * `fireforge token add --mode` can author the base `:root { }` block and the
6
+ * dark `@media (prefers-color-scheme: dark)` block, but there was no way to
7
+ * target an attribute-keyed selector such as `:root[data-skin="precision"]`
8
+ * or `:root[data-private]` — forcing those override blocks to be hand-edited.
9
+ * These helpers locate (or compute the insertion point for) a top-level
10
+ * `:root<variant>` block so `token-manager.ts` can splice a declaration into
11
+ * it, keeping all token authoring in the CLI.
12
+ */
13
+ import { findBlockCloseIndex, stripBlockCommentsInLines } from './token-dark-mode.js';
14
+ /**
15
+ * Accepts a single attribute selector fragment: `[data-private]` (boolean
16
+ * attribute) or `[data-skin=precision]` / `[data-skin="precision"]`
17
+ * (attribute with value). The value half is restricted to identifier-safe
18
+ * characters so the fragment can be spliced into CSS verbatim without
19
+ * escaping concerns.
20
+ */
21
+ const VARIANT_PATTERN = /^\[[a-zA-Z][a-zA-Z0-9_-]*(?:=(?:"[a-zA-Z0-9_-]+"|'[a-zA-Z0-9_-]+'|[a-zA-Z0-9_-]+))?\]$/;
22
+ /**
23
+ * Validates a `--variant` attribute selector fragment and normalizes any
24
+ * `=value` form to the double-quoted `="value"` shape (Mozilla convention).
25
+ * Boolean-attribute fragments are returned unchanged.
26
+ *
27
+ * @param raw - Raw `--variant` value from the CLI / programmatic caller.
28
+ */
29
+ export function validateVariantSelector(raw) {
30
+ if (typeof raw !== 'string') {
31
+ return { ok: false, reason: 'must be a string when set' };
32
+ }
33
+ const value = raw.trim();
34
+ if (!VARIANT_PATTERN.test(value)) {
35
+ return {
36
+ ok: false,
37
+ reason: 'must be a single attribute selector like [data-private] or [data-skin=precision] ' +
38
+ '(identifier-safe attribute name and value only)',
39
+ };
40
+ }
41
+ const normalized = value
42
+ .replace(/='([a-zA-Z0-9_-]+)'\]$/, '="$1"]')
43
+ .replace(/=([a-zA-Z0-9_-]+)\]$/, '="$1"]');
44
+ return { ok: true, value: normalized };
45
+ }
46
+ /**
47
+ * Reduces a `:root<attr>` selector to a quote- and whitespace-insensitive
48
+ * canonical form so `[data-skin="precision"]`, `[data-skin='precision']`,
49
+ * and `[data-skin=precision]` all compare equal when matching an existing
50
+ * block against the requested variant.
51
+ */
52
+ function canonicalSelector(selector) {
53
+ return selector.replace(/\s+/g, '').replace(/["']/g, '');
54
+ }
55
+ /**
56
+ * Finds the top-level `:root<variant>` block whose attribute selector
57
+ * matches `variant` (compared canonically). Returns the opening-brace and
58
+ * closing-brace line indices, or `null` when no such block exists.
59
+ *
60
+ * Scans a comment-stripped mirror of `lines` so braces inside comments do
61
+ * not offset the depth counter; the returned indices line up with the
62
+ * original array.
63
+ */
64
+ function findVariantBlock(lines, variant) {
65
+ const stripped = stripBlockCommentsInLines(lines);
66
+ const want = canonicalSelector(`:root${variant}`);
67
+ for (let i = 0; i < stripped.length; i++) {
68
+ const line = stripped[i] ?? '';
69
+ const match = /:root\[[^{]*\]/.exec(line);
70
+ if (!match || canonicalSelector(match[0]) !== want)
71
+ continue;
72
+ let openLine = /\{/.test(line) ? i : -1;
73
+ if (openLine === -1) {
74
+ for (let j = i + 1; j < stripped.length; j++) {
75
+ const next = stripped[j] ?? '';
76
+ if (/\{/.test(next)) {
77
+ openLine = j;
78
+ break;
79
+ }
80
+ if (/[};]/.test(next))
81
+ break;
82
+ }
83
+ }
84
+ if (openLine === -1)
85
+ continue;
86
+ const close = findBlockCloseIndex(stripped, openLine);
87
+ if (close === -1)
88
+ continue;
89
+ return { open: openLine, close };
90
+ }
91
+ return null;
92
+ }
93
+ /**
94
+ * Returns the line index at which a brand-new `:root<variant>` block should
95
+ * be spliced: immediately after the base `:root { }` block's closing brace
96
+ * (so attribute variants sit between the base block and any dark `@media`
97
+ * block). Falls back to end-of-file when no base `:root` block is found.
98
+ */
99
+ function findVariantBlockInsertionPoint(lines) {
100
+ const stripped = stripBlockCommentsInLines(lines);
101
+ for (let i = 0; i < stripped.length; i++) {
102
+ if (/^\s*:root\s*\{/.test(stripped[i] ?? '')) {
103
+ const close = findBlockCloseIndex(stripped, i);
104
+ if (close !== -1)
105
+ return close + 1;
106
+ }
107
+ }
108
+ return lines.length;
109
+ }
110
+ /** True when the `:root<variant>` block already declares `tokenName`. */
111
+ export function variantBlockHasToken(lines, variant, tokenName) {
112
+ const block = findVariantBlock(lines, variant);
113
+ if (!block)
114
+ return false;
115
+ const blockText = lines
116
+ .slice(block.open, block.close + 1)
117
+ .join('\n')
118
+ .replace(/\/\*[\s\S]*?\*\//g, '');
119
+ return blockText.includes(`${tokenName}:`);
120
+ }
121
+ /**
122
+ * Splices `declLine` into the `:root<variant>` block, creating the block
123
+ * (after the base `:root` block) when absent or appending after the last
124
+ * non-blank line when present. Mutates `lines` in place.
125
+ */
126
+ export function insertVariantDeclaration(lines, variant, declLine) {
127
+ const block = findVariantBlock(lines, variant);
128
+ if (block) {
129
+ let insertIndex = block.close;
130
+ for (let i = block.close - 1; i > block.open; i--) {
131
+ if ((lines[i] ?? '').trim()) {
132
+ insertIndex = i + 1;
133
+ break;
134
+ }
135
+ }
136
+ lines.splice(insertIndex, 0, declLine);
137
+ return;
138
+ }
139
+ lines.splice(findVariantBlockInsertionPoint(lines), 0, '', `:root${variant} {`, declLine, '}');
140
+ }
141
+ //# sourceMappingURL=token-variant.js.map
@@ -84,42 +84,70 @@ export async function runTypecheck(projectRoot, cfg) {
84
84
  filesChecked: 0,
85
85
  }));
86
86
  }
87
- // Compose the shim once extraShim is shared across all projects.
88
- // A missing or unreadable shim is a project-wide failure, so we
89
- // surface it as one issue per project rather than letting one
90
- // project's read failure silently affect the others' results.
91
- let shimSource;
92
- try {
93
- const composed = await composeShimSource(projectRoot, cfg.extraShim);
94
- shimSource = composed.source;
87
+ // Compose the shim PER project: the effective extraShim is the per-project
88
+ // override (a path, or `null` to opt out) when present, else the shared
89
+ // top-level extraShim. A project that narrows `lib`/`types` can opt out of
90
+ // a Gecko-lib shim hub that another project needs, so the composed shim is
91
+ // no longer injected identically everywhere. Compositions are cached by the
92
+ // resolved extraShim path so projects sharing a shim don't recompose it.
93
+ const shimCache = new Map();
94
+ const composeForProject = async (extraShim) => {
95
+ const key = extraShim ?? '';
96
+ const cached = shimCache.get(key);
97
+ if (cached !== undefined)
98
+ return cached;
99
+ const composed = await composeShimSource(projectRoot, extraShim);
95
100
  if (composed.extraShimAppended) {
96
- verbose(`typecheck: extra shim ${cfg.extraShim ?? ''} appended to Firefox globals shim`);
101
+ verbose(`typecheck: extra shim ${extraShim ?? ''} appended to Firefox globals shim`);
97
102
  }
98
- }
99
- catch (err) {
100
- const message = err instanceof Error ? err.message : String(err);
101
- return cfg.projects.map((project) => ({
102
- project,
103
- issues: [
104
- {
105
- file: cfg.extraShim ?? '(typecheck)',
106
- line: 1,
107
- column: 1,
108
- code: 0,
109
- category: 'error',
110
- message,
111
- project,
112
- },
113
- ],
114
- filesChecked: 0,
115
- }));
116
- }
103
+ shimCache.set(key, composed.source);
104
+ return composed.source;
105
+ };
117
106
  const results = [];
118
107
  for (const projectPath of cfg.projects) {
108
+ const extraShim = resolveProjectExtraShim(cfg, projectPath);
109
+ let shimSource;
110
+ try {
111
+ shimSource = await composeForProject(extraShim);
112
+ }
113
+ catch (err) {
114
+ // A missing or unreadable shim fails only the project(s) that use it,
115
+ // not the whole run — projects with a different (or no) shim still run.
116
+ const message = err instanceof Error ? err.message : String(err);
117
+ results.push({
118
+ project: projectPath,
119
+ issues: [
120
+ {
121
+ file: extraShim ?? '(typecheck)',
122
+ line: 1,
123
+ column: 1,
124
+ code: 0,
125
+ category: 'error',
126
+ message,
127
+ project: projectPath,
128
+ },
129
+ ],
130
+ filesChecked: 0,
131
+ });
132
+ continue;
133
+ }
119
134
  results.push(await runTypecheckForProject(ts, projectRoot, projectPath, shimSource));
120
135
  }
121
136
  return results;
122
137
  }
138
+ /**
139
+ * Resolves the effective extra shim for a single project: a `projectOverrides`
140
+ * entry wins (a string path overrides; `null` opts out → `undefined`), else
141
+ * the shared top-level `extraShim` applies.
142
+ */
143
+ function resolveProjectExtraShim(cfg, projectPath) {
144
+ const overrides = cfg.projectOverrides;
145
+ if (overrides && Object.prototype.hasOwnProperty.call(overrides, projectPath)) {
146
+ const value = overrides[projectPath];
147
+ return value === null ? undefined : value;
148
+ }
149
+ return cfg.extraShim;
150
+ }
123
151
  /** Runs typecheck for a single jsconfig path, isolating its failures. */
124
152
  async function runTypecheckForProject(ts, projectRoot, projectPath, shimSource) {
125
153
  const absConfig = resolve(projectRoot, projectPath);
@@ -705,6 +705,11 @@ export interface TokenAddOptions {
705
705
  dryRun?: boolean;
706
706
  /** Declare the category banner in the tokens CSS when it does not exist yet. */
707
707
  createCategory?: boolean;
708
+ /**
709
+ * Attribute selector fragment (e.g. `[data-skin=precision]` or
710
+ * `[data-private]`) routing the declaration into a `:root<variant>` block.
711
+ */
712
+ variant?: string;
708
713
  }
709
714
  /**
710
715
  * Options for the doctor command.
@@ -129,6 +129,19 @@ export interface TypecheckConfig {
129
129
  * built-in shim first, extraShim second — augment, don't redeclare.
130
130
  */
131
131
  extraShim?: string;
132
+ /**
133
+ * Per-project override of {@link extraShim}, keyed by the project's path
134
+ * exactly as it appears in {@link projects}. A string value points the
135
+ * project at a different `.d.ts`; `null` opts the project out of the shared
136
+ * extra shim entirely (it absorbs only the built-in Firefox globals shim).
137
+ *
138
+ * Needed because the shared shim is injected into every project: a shim hub
139
+ * that references Gecko declaration libs (`lib.gecko.dom.d.ts`, …) is wanted
140
+ * by projects that include it but collides with a project that narrows
141
+ * `lib: ["ES2024", "DOM"]` (Element/Node identity splits, nsIPrincipal
142
+ * mismatch). A narrowed project sets `null` here to stay clean.
143
+ */
144
+ projectOverrides?: Record<string, string | null>;
132
145
  }
133
146
  /**
134
147
  * Wire command configuration.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hominis/fireforge",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "description": "FireForge — a build tool for customizing Firefox",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",
@@ -63,7 +63,7 @@
63
63
  "devDependencies": {
64
64
  "@eslint/js": "^10.0.0",
65
65
  "@types/estree": "^1.0.8",
66
- "@types/node": "^25.5.2",
66
+ "@types/node": "^26.0.0",
67
67
  "@vitest/coverage-v8": "^4.1.2",
68
68
  "dpdm": "4.2.0",
69
69
  "eslint": "^10.0.0",
@@ -72,7 +72,7 @@
72
72
  "eslint-plugin-simple-import-sort": "^13.0.0",
73
73
  "fast-check": "^4.6.0",
74
74
  "husky": "^9.1.7",
75
- "knip": "6.16.1",
75
+ "knip": "6.17.1",
76
76
  "lint-staged": "^17.0.4",
77
77
  "prettier": "^3.7.4",
78
78
  "tsx": "^4.7.0",