@hominis/fireforge 0.31.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.
- package/CHANGELOG.md +22 -0
- package/dist/src/commands/export-all.js +4 -1
- package/dist/src/commands/export-shared.js +10 -1
- package/dist/src/commands/export.js +5 -1
- package/dist/src/commands/lint-per-patch.d.ts +2 -0
- package/dist/src/commands/lint-per-patch.js +206 -44
- package/dist/src/commands/lint.js +100 -7
- package/dist/src/commands/patch/split-plan.d.ts +18 -2
- package/dist/src/commands/patch/split-plan.js +90 -16
- package/dist/src/commands/patch/split.js +12 -3
- package/dist/src/commands/re-export-files.js +4 -1
- package/dist/src/commands/re-export.js +8 -1
- package/dist/src/commands/test-run.d.ts +10 -0
- package/dist/src/commands/test-run.js +13 -4
- package/dist/src/commands/test.js +46 -7
- package/dist/src/commands/token.js +12 -1
- package/dist/src/commands/typecheck.js +35 -0
- package/dist/src/core/build-prepare.js +23 -3
- package/dist/src/core/config-validate.js +52 -0
- package/dist/src/core/furnace-apply-dry-run.d.ts +17 -0
- package/dist/src/core/furnace-apply-dry-run.js +105 -0
- package/dist/src/core/furnace-apply-ftl.d.ts +12 -0
- package/dist/src/core/furnace-apply-ftl.js +97 -1
- package/dist/src/core/furnace-apply-helpers.js +10 -80
- package/dist/src/core/furnace-jsconfig.js +22 -2
- package/dist/src/core/git-base.d.ts +15 -0
- package/dist/src/core/git-base.js +32 -0
- package/dist/src/core/git-diff.d.ts +8 -0
- package/dist/src/core/git-diff.js +224 -59
- package/dist/src/core/git-file-ops.d.ts +39 -0
- package/dist/src/core/git-file-ops.js +82 -1
- package/dist/src/core/mach-resource-shim.d.ts +21 -0
- package/dist/src/core/mach-resource-shim.js +92 -0
- package/dist/src/core/mach.d.ts +17 -0
- package/dist/src/core/mach.js +30 -2
- package/dist/src/core/manifest-helpers.js +29 -4
- package/dist/src/core/patch-lint-checkjs.d.ts +75 -21
- package/dist/src/core/patch-lint-checkjs.js +213 -67
- package/dist/src/core/patch-lint-cross.d.ts +31 -0
- package/dist/src/core/patch-lint-cross.js +83 -63
- package/dist/src/core/patch-lint-css.d.ts +23 -0
- package/dist/src/core/patch-lint-css.js +172 -0
- package/dist/src/core/patch-lint-reexports.d.ts +1 -1
- package/dist/src/core/patch-lint-reexports.js +1 -1
- package/dist/src/core/patch-lint.d.ts +34 -11
- package/dist/src/core/patch-lint.js +19 -163
- package/dist/src/core/test-harness-crash.d.ts +6 -3
- package/dist/src/core/test-harness-crash.js +32 -4
- package/dist/src/core/test-xpcshell-retry.d.ts +9 -2
- package/dist/src/core/test-xpcshell-retry.js +9 -4
- package/dist/src/core/token-dark-mode.d.ts +9 -0
- package/dist/src/core/token-dark-mode.js +1 -1
- package/dist/src/core/token-docs.d.ts +32 -0
- package/dist/src/core/token-docs.js +101 -0
- package/dist/src/core/token-manager.d.ts +8 -0
- package/dist/src/core/token-manager.js +77 -95
- package/dist/src/core/token-variant.d.ts +39 -0
- package/dist/src/core/token-variant.js +141 -0
- package/dist/src/core/typecheck-shim.d.ts +3 -1
- package/dist/src/core/typecheck-shim.js +43 -3
- package/dist/src/core/typecheck.js +56 -28
- package/dist/src/types/commands/options.d.ts +22 -0
- package/dist/src/types/config.d.ts +24 -2
- package/package.json +3 -3
|
@@ -9,13 +9,13 @@ import { escapeRegex } from '../utils/regex.js';
|
|
|
9
9
|
import { validateTokenName } from '../utils/validation.js';
|
|
10
10
|
import { getProjectPaths, loadConfig } from './config.js';
|
|
11
11
|
import { loadFurnaceConfig } from './furnace-config.js';
|
|
12
|
-
import { findTableAfterHeading, findTableByColumns, insertRow, rewriteTableRows, updateCellByKey, } from './markdown-table.js';
|
|
13
12
|
import { findDarkMediaCloseIndex, findDarkRootInsertionIndex } from './token-dark-mode.js';
|
|
13
|
+
import { addTokenToDocs } from './token-docs.js';
|
|
14
|
+
import { insertVariantDeclaration, validateVariantSelector, variantBlockHasToken, } from './token-variant.js';
|
|
14
15
|
/** Returns the token CSS path relative to engine root for a given binary name. */
|
|
15
16
|
export function getTokensCssPath(binaryName) {
|
|
16
17
|
return `browser/themes/shared/${binaryName}-tokens.css`;
|
|
17
18
|
}
|
|
18
|
-
const TOKENS_DOC = 'docs/design/SRC_TOKENS.md';
|
|
19
19
|
/**
|
|
20
20
|
* Determines the mode annotation string for the CSS comment.
|
|
21
21
|
*/
|
|
@@ -69,6 +69,49 @@ function validateDarkValue(options) {
|
|
|
69
69
|
throw new InvalidArgumentError('Override mode requires --dark-value to be specified.', 'darkValue');
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Validates and normalizes the `--variant` attribute selector. Returns the
|
|
74
|
+
* normalized (quoted) selector when set, or `undefined` when no variant was
|
|
75
|
+
* requested. Rejects combining `--variant` with `--mode override`: an
|
|
76
|
+
* override authors a dark `@media :root` block, which variant routing
|
|
77
|
+
* bypasses, so the combination would silently drop the dark value.
|
|
78
|
+
*/
|
|
79
|
+
function normalizeVariantOption(options) {
|
|
80
|
+
if (options.variant === undefined)
|
|
81
|
+
return undefined;
|
|
82
|
+
if (options.mode === 'override') {
|
|
83
|
+
throw new InvalidArgumentError('Cannot combine --variant with --mode override; author the variant declaration with ' +
|
|
84
|
+
'--mode auto/static instead.', 'variant');
|
|
85
|
+
}
|
|
86
|
+
const result = validateVariantSelector(options.variant);
|
|
87
|
+
if (!result.ok) {
|
|
88
|
+
throw new InvalidArgumentError(`--variant ${result.reason}.`, 'variant');
|
|
89
|
+
}
|
|
90
|
+
return result.value;
|
|
91
|
+
}
|
|
92
|
+
/** Throws when the tokens CSS file is missing (variant mode skips category checks). */
|
|
93
|
+
async function assertTokensCssExists(engineDir, tokensCssPath) {
|
|
94
|
+
if (!(await pathExists(join(engineDir, tokensCssPath)))) {
|
|
95
|
+
throw new GeneralError(`Token CSS file not found: ${tokensCssPath}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Routes a declaration into the top-level `:root<variant>` block — creating
|
|
100
|
+
* the block after the base `:root` block if absent, or appending to it if
|
|
101
|
+
* present. Idempotent within the block. Returns `{ added: false }` when the
|
|
102
|
+
* token already lives in that block.
|
|
103
|
+
*/
|
|
104
|
+
async function addVariantTokenToCSS(engineDir, options, tokensCssPath, variant) {
|
|
105
|
+
await assertTokensCssExists(engineDir, tokensCssPath);
|
|
106
|
+
const filePath = join(engineDir, tokensCssPath);
|
|
107
|
+
const lines = (await readText(filePath)).split('\n');
|
|
108
|
+
if (variantBlockHasToken(lines, variant, options.tokenName))
|
|
109
|
+
return { added: false };
|
|
110
|
+
const annotation = getModeAnnotation(options.mode, options.value);
|
|
111
|
+
insertVariantDeclaration(lines, variant, ` ${options.tokenName}: ${options.value}; /* ${annotation} */`);
|
|
112
|
+
await writeText(filePath, lines.join('\n'));
|
|
113
|
+
return { added: true };
|
|
114
|
+
}
|
|
72
115
|
/**
|
|
73
116
|
* True when `lines` contain a category header (single-line or multi-line
|
|
74
117
|
* banner shape) naming `category`. Shared by the pre-add assertion and the
|
|
@@ -171,6 +214,12 @@ export async function validateTokenAdd(root, options) {
|
|
|
171
214
|
validateTokenNameSyntax(options.tokenName);
|
|
172
215
|
await validateTokenPrefix(root, options);
|
|
173
216
|
validateDarkValue(options);
|
|
217
|
+
// Variant mode targets a `:root<attr>` block, not a category section, so it
|
|
218
|
+
// only needs the tokens CSS file to exist — category checks do not apply.
|
|
219
|
+
if (normalizeVariantOption(options) !== undefined) {
|
|
220
|
+
await assertTokensCssExists(engineDir, tokensCssPath);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
174
223
|
await assertTokenCategoryExists(engineDir, tokensCssPath, options.category, options.createCategory === true);
|
|
175
224
|
}
|
|
176
225
|
/**
|
|
@@ -185,10 +234,21 @@ export async function addToken(root, options) {
|
|
|
185
234
|
validateTokenNameSyntax(options.tokenName);
|
|
186
235
|
await validateTokenPrefix(root, options);
|
|
187
236
|
validateDarkValue(options);
|
|
237
|
+
const normalizedVariant = normalizeVariantOption(options);
|
|
188
238
|
if (options.dryRun) {
|
|
189
239
|
await validateTokenAdd(root, options);
|
|
190
240
|
const filePath = join(engineDir, tokensCssPath);
|
|
191
241
|
const content = await readText(filePath);
|
|
242
|
+
if (normalizedVariant !== undefined) {
|
|
243
|
+
const skipped = variantBlockHasToken(content.split('\n'), normalizedVariant, options.tokenName);
|
|
244
|
+
return {
|
|
245
|
+
cssAdded: !skipped,
|
|
246
|
+
docsAdded: false,
|
|
247
|
+
unmappedAdded: false,
|
|
248
|
+
countUpdated: false,
|
|
249
|
+
skipped,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
192
252
|
const stripped = content.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
193
253
|
const skipped = stripped.includes(options.tokenName + ':');
|
|
194
254
|
return {
|
|
@@ -199,6 +259,20 @@ export async function addToken(root, options) {
|
|
|
199
259
|
skipped,
|
|
200
260
|
};
|
|
201
261
|
}
|
|
262
|
+
// Variant overrides are CSS-only: route the declaration into the
|
|
263
|
+
// `:root<attr>` block and leave docs untouched (the base token owns its
|
|
264
|
+
// docs row). Done before the base-CSS path so an override of an existing
|
|
265
|
+
// base token is not short-circuited by the global idempotency check.
|
|
266
|
+
if (normalizedVariant !== undefined) {
|
|
267
|
+
const { added } = await addVariantTokenToCSS(engineDir, options, tokensCssPath, normalizedVariant);
|
|
268
|
+
return {
|
|
269
|
+
cssAdded: added,
|
|
270
|
+
docsAdded: false,
|
|
271
|
+
unmappedAdded: false,
|
|
272
|
+
countUpdated: false,
|
|
273
|
+
skipped: !added,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
202
276
|
// --- CSS file ---
|
|
203
277
|
const { added: cssAdded, categoryCreated } = await addTokenToCSS(engineDir, options, tokensCssPath);
|
|
204
278
|
if (!cssAdded) {
|
|
@@ -211,7 +285,7 @@ export async function addToken(root, options) {
|
|
|
211
285
|
};
|
|
212
286
|
}
|
|
213
287
|
// --- Documentation ---
|
|
214
|
-
const docsResult = await addTokenToDocs(engineDir, options);
|
|
288
|
+
const docsResult = await addTokenToDocs(engineDir, options, getModeAnnotation(options.mode, options.value));
|
|
215
289
|
return {
|
|
216
290
|
cssAdded,
|
|
217
291
|
docsAdded: docsResult.docsAdded,
|
|
@@ -381,96 +455,4 @@ async function addTokenToCSS(engineDir, options, tokensCssPath) {
|
|
|
381
455
|
await writeText(filePath, content);
|
|
382
456
|
return { added: true, categoryCreated };
|
|
383
457
|
}
|
|
384
|
-
/**
|
|
385
|
-
* Strips surrounding backticks from a cell, if present. Token cells are
|
|
386
|
-
* usually wrapped in inline code fences (`` `--foo` ``) and the parser
|
|
387
|
-
* returns them verbatim.
|
|
388
|
-
*/
|
|
389
|
-
function stripInlineCode(cell) {
|
|
390
|
-
const trimmed = cell.trim();
|
|
391
|
-
if (trimmed.startsWith('`') && trimmed.endsWith('`') && trimmed.length >= 2) {
|
|
392
|
-
return trimmed.slice(1, -1);
|
|
393
|
-
}
|
|
394
|
-
return trimmed;
|
|
395
|
-
}
|
|
396
|
-
/**
|
|
397
|
-
* Adds a token row to the main token table, the unmapped table (for
|
|
398
|
-
* literal values), and bumps the mode count table. Each sub-update runs
|
|
399
|
-
* against a freshly parsed view of the document so that splice indices
|
|
400
|
-
* stay valid as rewrites are layered.
|
|
401
|
-
*
|
|
402
|
-
* The old implementation walked `split('\n')` by hand, detected rows by
|
|
403
|
-
* literal `|`-prefix, and used a whitespace-sensitive regex to increment
|
|
404
|
-
* the mode count. Switching to {@link findTableByColumns} and
|
|
405
|
-
* {@link updateCellByKey} removes those formatting traps.
|
|
406
|
-
*/
|
|
407
|
-
async function addTokenToDocs(engineDir, options) {
|
|
408
|
-
const filePath = join(engineDir, '..', TOKENS_DOC);
|
|
409
|
-
if (!(await pathExists(filePath))) {
|
|
410
|
-
// Docs file is optional
|
|
411
|
-
return { docsAdded: false, unmappedAdded: false, countUpdated: false };
|
|
412
|
-
}
|
|
413
|
-
const originalContent = await readText(filePath);
|
|
414
|
-
let lines = originalContent.split('\n');
|
|
415
|
-
let docsAdded = false;
|
|
416
|
-
let unmappedAdded = false;
|
|
417
|
-
let countUpdated = false;
|
|
418
|
-
const annotation = getModeAnnotation(options.mode, options.value);
|
|
419
|
-
const isLiteral = !options.value.startsWith('var(');
|
|
420
|
-
const mapsTo = isLiteral ? '—' : options.value.replace(/var\(([^)]+)\)/, '$1');
|
|
421
|
-
const tokenCell = `\`${options.tokenName}\``;
|
|
422
|
-
const valueCell = `\`${options.value}\``;
|
|
423
|
-
// --- Main token table: Category | Token | Value | Maps to | Mode ---
|
|
424
|
-
const mainTable = findTableByColumns(lines, ['Category', 'Token', 'Value', 'Mode']);
|
|
425
|
-
if (mainTable) {
|
|
426
|
-
// The doc convention allows the Category cell to be blank on
|
|
427
|
-
// continuation rows that belong to the previous category. Group rows
|
|
428
|
-
// by carrying the last non-empty Category value forward.
|
|
429
|
-
let lastGroupRowIndex = -1;
|
|
430
|
-
let currentCategory = '';
|
|
431
|
-
for (let i = 0; i < mainTable.rows.length; i++) {
|
|
432
|
-
const row = mainTable.rows[i];
|
|
433
|
-
if (!row)
|
|
434
|
-
continue;
|
|
435
|
-
const cell = row[0]?.trim() ?? '';
|
|
436
|
-
if (cell) {
|
|
437
|
-
currentCategory = cell;
|
|
438
|
-
}
|
|
439
|
-
if (currentCategory === options.category) {
|
|
440
|
-
lastGroupRowIndex = i;
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
if (lastGroupRowIndex !== -1) {
|
|
444
|
-
insertRow(mainTable, ['', tokenCell, valueCell, mapsTo, annotation], lastGroupRowIndex + 1);
|
|
445
|
-
lines = rewriteTableRows(lines, mainTable);
|
|
446
|
-
docsAdded = true;
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
// --- Unmapped table: populated for literal (non-var()) values only ---
|
|
450
|
-
if (isLiteral) {
|
|
451
|
-
const unmappedTable = findTableAfterHeading(lines, /not yet mapped|unmapped/i);
|
|
452
|
-
if (unmappedTable) {
|
|
453
|
-
insertRow(unmappedTable, [tokenCell, valueCell, options.description ?? ''], unmappedTable.rows.length);
|
|
454
|
-
lines = rewriteTableRows(lines, unmappedTable);
|
|
455
|
-
unmappedAdded = true;
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
// --- Mode behavior count table: Mode | Count ---
|
|
459
|
-
const modeTable = findTableByColumns(lines, ['Mode', 'Count']);
|
|
460
|
-
if (modeTable) {
|
|
461
|
-
const modeIndex = modeTable.headers.indexOf('Mode');
|
|
462
|
-
const countIndex = modeTable.headers.indexOf('Count');
|
|
463
|
-
const existing = modeTable.rows.find((row) => stripInlineCode(row[modeIndex] ?? '') === options.mode);
|
|
464
|
-
if (existing) {
|
|
465
|
-
const oldCount = parseInt(existing[countIndex] ?? '0', 10);
|
|
466
|
-
const updated = updateCellByKey(modeTable, 'Mode', existing[modeIndex] ?? options.mode, 'Count', String((Number.isNaN(oldCount) ? 0 : oldCount) + 1));
|
|
467
|
-
if (updated) {
|
|
468
|
-
lines = rewriteTableRows(lines, modeTable);
|
|
469
|
-
countUpdated = true;
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
await writeText(filePath, lines.join('\n'));
|
|
474
|
-
return { docsAdded, unmappedAdded, countUpdated };
|
|
475
|
-
}
|
|
476
458
|
//# sourceMappingURL=token-manager.js.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attribute-variant block helpers for the tokens CSS scaffold.
|
|
3
|
+
*
|
|
4
|
+
* `fireforge token add --mode` can author the base `:root { }` block and the
|
|
5
|
+
* dark `@media (prefers-color-scheme: dark)` block, but there was no way to
|
|
6
|
+
* target an attribute-keyed selector such as `:root[data-skin="precision"]`
|
|
7
|
+
* or `:root[data-private]` — forcing those override blocks to be hand-edited.
|
|
8
|
+
* These helpers locate (or compute the insertion point for) a top-level
|
|
9
|
+
* `:root<variant>` block so `token-manager.ts` can splice a declaration into
|
|
10
|
+
* it, keeping all token authoring in the CLI.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Outcome of {@link validateVariantSelector}. `ok: true` carries the
|
|
14
|
+
* normalized (quoted) selector fragment; `ok: false` carries a
|
|
15
|
+
* human-readable reason suitable for throwing as an `InvalidArgumentError`.
|
|
16
|
+
*/
|
|
17
|
+
export type VariantValidation = {
|
|
18
|
+
ok: true;
|
|
19
|
+
value: string;
|
|
20
|
+
} | {
|
|
21
|
+
ok: false;
|
|
22
|
+
reason: string;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Validates a `--variant` attribute selector fragment and normalizes any
|
|
26
|
+
* `=value` form to the double-quoted `="value"` shape (Mozilla convention).
|
|
27
|
+
* Boolean-attribute fragments are returned unchanged.
|
|
28
|
+
*
|
|
29
|
+
* @param raw - Raw `--variant` value from the CLI / programmatic caller.
|
|
30
|
+
*/
|
|
31
|
+
export declare function validateVariantSelector(raw: unknown): VariantValidation;
|
|
32
|
+
/** True when the `:root<variant>` block already declares `tokenName`. */
|
|
33
|
+
export declare function variantBlockHasToken(lines: string[], variant: string, tokenName: string): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Splices `declLine` into the `:root<variant>` block, creating the block
|
|
36
|
+
* (after the base `:root` block) when absent or appending after the last
|
|
37
|
+
* non-blank line when present. Mutates `lines` in place.
|
|
38
|
+
*/
|
|
39
|
+
export declare function insertVariantDeclaration(lines: string[], variant: string, declLine: string): void;
|
|
@@ -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
|
|
@@ -40,7 +40,9 @@ export interface ComposedShim {
|
|
|
40
40
|
* direction is intentional (declarations later in concat order
|
|
41
41
|
* augment earlier ones), so a project that wants to refine `Services`
|
|
42
42
|
* with a more specific type can do so by declaring it in the extra
|
|
43
|
-
* shim.
|
|
43
|
+
* shim. Any triple-slash `/// <reference path="…">` directives inside the
|
|
44
|
+
* extra shim are inlined (resolved against the extra shim's own directory)
|
|
45
|
+
* so they are not silently dropped at the synthetic shim path.
|
|
44
46
|
*
|
|
45
47
|
* Missing extra-shim files raise a clear error rather than failing
|
|
46
48
|
* silently with a confusing "type not found" downstream — this is the
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* still fail `fireforge typecheck`, or vice versa, for reasons the
|
|
11
11
|
* operator could not infer from the rule names.
|
|
12
12
|
*/
|
|
13
|
-
import { resolve } from 'node:path';
|
|
13
|
+
import { dirname, resolve } from 'node:path';
|
|
14
14
|
import { pathExists, readText } from '../utils/fs.js';
|
|
15
15
|
/** Filename used for the synthetic Firefox-globals shim source file. */
|
|
16
16
|
export const SHIM_FILENAME = '__fireforge_firefox_globals.d.ts';
|
|
@@ -110,6 +110,43 @@ export const SUPPRESSED_DIAGNOSTIC_CODES = new Set([
|
|
|
110
110
|
2580, // Cannot find name '{0}'. Do you need to install type definitions...
|
|
111
111
|
7016, // Could not find a declaration file for module '{0}'.
|
|
112
112
|
]);
|
|
113
|
+
/** Matches a lone triple-slash `/// <reference path="…" />` directive line. */
|
|
114
|
+
const TRIPLE_SLASH_REFERENCE = /^\s*\/\/\/\s*<reference\s+path\s*=\s*["']([^"']+)["']\s*\/?>\s*$/;
|
|
115
|
+
/**
|
|
116
|
+
* Inlines triple-slash `/// <reference path="…">` directives in shim source.
|
|
117
|
+
*
|
|
118
|
+
* Both shim consumers feed the text to the compiler at a *synthetic* path
|
|
119
|
+
* (an in-memory source file, not the extra shim's real location), so TS
|
|
120
|
+
* resolves a relative `/// <reference>` against that synthetic directory and
|
|
121
|
+
* silently drops it. Inlining the referenced file's contents (recursively,
|
|
122
|
+
* resolved against the *referencing* file's directory, deduped by absolute
|
|
123
|
+
* path) makes the directives self-contained so their declarations survive.
|
|
124
|
+
*
|
|
125
|
+
* @param source - Shim source possibly containing reference directives
|
|
126
|
+
* @param baseDir - Directory the directives' relative paths resolve against
|
|
127
|
+
* @param seen - Absolute paths already inlined (cycle / duplicate guard)
|
|
128
|
+
*/
|
|
129
|
+
async function inlineTripleSlashReferences(source, baseDir, seen) {
|
|
130
|
+
const out = [];
|
|
131
|
+
for (const line of source.split('\n')) {
|
|
132
|
+
const match = TRIPLE_SLASH_REFERENCE.exec(line);
|
|
133
|
+
if (!match?.[1]) {
|
|
134
|
+
out.push(line);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const absolute = resolve(baseDir, match[1]);
|
|
138
|
+
if (seen.has(absolute))
|
|
139
|
+
continue;
|
|
140
|
+
seen.add(absolute);
|
|
141
|
+
if (!(await pathExists(absolute))) {
|
|
142
|
+
out.push(`// (fireforge: unresolved /// <reference path="${match[1]}">)`);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const referenced = await readText(absolute);
|
|
146
|
+
out.push(await inlineTripleSlashReferences(referenced, dirname(absolute), seen));
|
|
147
|
+
}
|
|
148
|
+
return out.join('\n');
|
|
149
|
+
}
|
|
113
150
|
/**
|
|
114
151
|
* Composes the synthetic shim source by concatenating the built-in
|
|
115
152
|
* Firefox globals shim with the contents of an optional user-supplied
|
|
@@ -117,7 +154,9 @@ export const SUPPRESSED_DIAGNOSTIC_CODES = new Set([
|
|
|
117
154
|
* direction is intentional (declarations later in concat order
|
|
118
155
|
* augment earlier ones), so a project that wants to refine `Services`
|
|
119
156
|
* with a more specific type can do so by declaring it in the extra
|
|
120
|
-
* shim.
|
|
157
|
+
* shim. Any triple-slash `/// <reference path="…">` directives inside the
|
|
158
|
+
* extra shim are inlined (resolved against the extra shim's own directory)
|
|
159
|
+
* so they are not silently dropped at the synthetic shim path.
|
|
121
160
|
*
|
|
122
161
|
* Missing extra-shim files raise a clear error rather than failing
|
|
123
162
|
* silently with a confusing "type not found" downstream — this is the
|
|
@@ -137,8 +176,9 @@ export async function composeShimSource(projectRoot, extraShimPath) {
|
|
|
137
176
|
'Check the path in fireforge.json or create the file.');
|
|
138
177
|
}
|
|
139
178
|
const extra = await readText(absoluteShim);
|
|
179
|
+
const inlinedExtra = await inlineTripleSlashReferences(extra, dirname(absoluteShim), new Set([absoluteShim]));
|
|
140
180
|
return {
|
|
141
|
-
source: `${FIREFOX_GLOBALS_SHIM}\n// ── extraShim: ${extraShimPath} ──\n${
|
|
181
|
+
source: `${FIREFOX_GLOBALS_SHIM}\n// ── extraShim: ${extraShimPath} ──\n${inlinedExtra}`,
|
|
142
182
|
extraShimAppended: true,
|
|
143
183
|
};
|
|
144
184
|
}
|
|
@@ -84,42 +84,70 @@ export async function runTypecheck(projectRoot, cfg) {
|
|
|
84
84
|
filesChecked: 0,
|
|
85
85
|
}));
|
|
86
86
|
}
|
|
87
|
-
// Compose the shim
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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 ${
|
|
101
|
+
verbose(`typecheck: extra shim ${extraShim ?? ''} appended to Firefox globals shim`);
|
|
97
102
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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);
|
|
@@ -428,6 +428,15 @@ export interface TestOptions {
|
|
|
428
428
|
* command layer.
|
|
429
429
|
*/
|
|
430
430
|
harnessRetries?: number;
|
|
431
|
+
/**
|
|
432
|
+
* Force dispatch through the generic `mach test` command instead of the
|
|
433
|
+
* suite-specific `mach xpcshell-test` / `mach mochitest` commands a
|
|
434
|
+
* single-suite run auto-selects. Escape hatch for the rare case where a
|
|
435
|
+
* suite-specific command misbehaves; on a healthy host the generic command
|
|
436
|
+
* is equivalent. The default (auto suite dispatch) skips the mozlog
|
|
437
|
+
* resource monitor that crashes `mach test` on a broken host (E1).
|
|
438
|
+
*/
|
|
439
|
+
genericMachTest?: boolean;
|
|
431
440
|
/**
|
|
432
441
|
* Commander negation flag for `--no-shard`. When false, multiple test
|
|
433
442
|
* paths run in one combined mach invocation; by default they shard into
|
|
@@ -696,6 +705,11 @@ export interface TokenAddOptions {
|
|
|
696
705
|
dryRun?: boolean;
|
|
697
706
|
/** Declare the category banner in the tokens CSS when it does not exist yet. */
|
|
698
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;
|
|
699
713
|
}
|
|
700
714
|
/**
|
|
701
715
|
* Options for the doctor command.
|
|
@@ -774,6 +788,14 @@ export interface LintCommandOptions {
|
|
|
774
788
|
* scope contracts are different.
|
|
775
789
|
*/
|
|
776
790
|
perPatch?: boolean;
|
|
791
|
+
/**
|
|
792
|
+
* Restrict `--per-patch` to a named subset of the queue (by filename,
|
|
793
|
+
* filename ± `.patch`, or manifest `name`). Lets a change that touches a
|
|
794
|
+
* handful of patches run the per-patch gate over just those instead of
|
|
795
|
+
* the full ~90-patch queue. Only valid with {@link perPatch}; queue-level
|
|
796
|
+
* findings (policy, cross-patch) are scoped to files the subset touches.
|
|
797
|
+
*/
|
|
798
|
+
patches?: string[];
|
|
777
799
|
/**
|
|
778
800
|
* Maximum warning count tolerated before lint exits non-zero. Mirrors
|
|
779
801
|
* ESLint's `--max-warnings` shape for release gates that want advisory
|