@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
@@ -3,6 +3,6 @@
3
3
  * Public re-exports for {@link ./patch-lint.ts}. Split out so the
4
4
  * orchestrator stays within the ESLint `max-lines` budget.
5
5
  */
6
- export { buildPatchQueueContext, collectNewFileCreatorsByPath, extractImportSpecifiers, extractImportSpecifiersWithLines, findForwardImportIgnoreLines, FORWARD_IMPORT_IGNORE_MARKER, isForwardImportableFile, lintPatchQueue, lintPatchQueueDuplicateCreations, lintPatchQueueForwardImports, } from './patch-lint-cross.js';
6
+ export { buildPatchQueueContext, collectForwardImportEdges, collectNewFileCreatorsByPath, extractImportSpecifiers, extractImportSpecifiersWithLines, findForwardImportIgnoreLines, FORWARD_IMPORT_IGNORE_MARKER, isForwardImportableFile, lintPatchQueue, lintPatchQueueDuplicateCreations, lintPatchQueueForwardImports, } from './patch-lint-cross.js';
7
7
  export { buildModifiedFileAdditionsFromDiff, detectNewFilesInDiff } from './patch-lint-diff.js';
8
8
  //# sourceMappingURL=patch-lint-reexports.js.map
@@ -44,9 +44,12 @@ export declare function detectHarnessCrashSignature(output: string): HarnessCras
44
44
  * 1. A recognized crash signature wins regardless of exit code (the
45
45
  * shutdown re-entry shape exits non-zero on an otherwise green run;
46
46
  * the hang shape can even exit zero with a `Passed: 0` summary).
47
- * 2. No `TEST-START` with explicit paths requested means no test ran —
48
- * `no-tests`, even when the exit code is zero. Summary lines are not
49
- * trusted as evidence of execution.
47
+ * 2. No execution signal with explicit paths requested means no test ran —
48
+ * `no-tests`, even when the exit code is zero. The execution signal is
49
+ * a `TEST-START` line (generic `mach test` / browser-chrome dispatch)
50
+ * OR the suite-specific xpcshell result-summary block (the xpcshell
51
+ * dispatch prints no `TEST-START`). Bare `Passed:`/`Failed:` summary
52
+ * lines are still not trusted as evidence of execution.
50
53
  * 3. Exit code zero with tests started is a pass; anything else is a
51
54
  * test failure for the regular diagnosis chain.
52
55
  */
@@ -23,6 +23,20 @@
23
23
  * of reporting phantom test failures (or phantom passes).
24
24
  */
25
25
  const TEST_START_PATTERN = /\bTEST-START\b/;
26
+ /**
27
+ * Execution signals emitted by the suite-specific xpcshell dispatch
28
+ * (`mach xpcshell-test`), which does NOT print `TEST-START` lines the way
29
+ * the generic `mach test` / browser-chrome dispatch does. A passing
30
+ * single-file xpcshell run prints a result-summary block instead
31
+ * (`TEST_END: Test PASS`, `Ran 16 checks`, `Unexpected results: 0`), so
32
+ * keying execution purely on `TEST-START` mis-reads a green xpcshell run as
33
+ * "no tests started" (field report: a single-file xpcshell pass exited 1).
34
+ *
35
+ * These markers are xpcshell-specific on purpose: the bare
36
+ * `Passed: 0` / `Failed: 0` summary that the no-output hang shape prints is
37
+ * deliberately NOT matched here — that case must still read as `no-tests`.
38
+ */
39
+ const XPCSHELL_RESULT_SUMMARY_PATTERN = /\bTEST_END\b|\bRan \d+ checks?\b|\bResult summary:/i;
26
40
  const UNEXPECTED_LINE_PATTERN = /^.*\bTEST-UNEXPECTED-[A-Z-]+\b.*$/gm;
27
41
  const SHUTDOWN_REENTRY_PATTERN = /Application shut down \(without crashing\) in the middle of a test/i;
28
42
  const FOCUS_STALL_PATTERN = /must wait for focus/i;
@@ -49,6 +63,16 @@ function findLine(output, patterns) {
49
63
  }
50
64
  return undefined;
51
65
  }
66
+ /**
67
+ * True when the captured output carries the suite-specific xpcshell
68
+ * result-summary block, which proves tests executed even though the
69
+ * xpcshell dispatch emits no `TEST-START` line. Used alongside
70
+ * `TEST_START_PATTERN` so a green single-file xpcshell run is not
71
+ * mis-classified as `no-tests`. Exported for direct unit testing.
72
+ */
73
+ function hasXpcshellResultSummary(output) {
74
+ return XPCSHELL_RESULT_SUMMARY_PATTERN.test(output);
75
+ }
52
76
  /** Unexpected-failure lines that are NOT the shutdown re-entry artifact. */
53
77
  function realUnexpectedFailureLines(output) {
54
78
  const matches = output.match(UNEXPECTED_LINE_PATTERN) ?? [];
@@ -95,9 +119,12 @@ export function detectHarnessCrashSignature(output) {
95
119
  * 1. A recognized crash signature wins regardless of exit code (the
96
120
  * shutdown re-entry shape exits non-zero on an otherwise green run;
97
121
  * the hang shape can even exit zero with a `Passed: 0` summary).
98
- * 2. No `TEST-START` with explicit paths requested means no test ran —
99
- * `no-tests`, even when the exit code is zero. Summary lines are not
100
- * trusted as evidence of execution.
122
+ * 2. No execution signal with explicit paths requested means no test ran —
123
+ * `no-tests`, even when the exit code is zero. The execution signal is
124
+ * a `TEST-START` line (generic `mach test` / browser-chrome dispatch)
125
+ * OR the suite-specific xpcshell result-summary block (the xpcshell
126
+ * dispatch prints no `TEST-START`). Bare `Passed:`/`Failed:` summary
127
+ * lines are still not trusted as evidence of execution.
101
128
  * 3. Exit code zero with tests started is a pass; anything else is a
102
129
  * test failure for the regular diagnosis chain.
103
130
  */
@@ -106,7 +133,8 @@ export function classifyHarnessRun(exitCode, output, requestedPaths) {
106
133
  if (signature) {
107
134
  return { kind: 'harness-crash', signature };
108
135
  }
109
- if (!TEST_START_PATTERN.test(output) && requestedPaths.length > 0) {
136
+ const ranTests = TEST_START_PATTERN.test(output) || hasXpcshellResultSummary(output);
137
+ if (!ranTests && requestedPaths.length > 0) {
110
138
  return { kind: 'no-tests' };
111
139
  }
112
140
  return exitCode === 0 ? { kind: 'tests-ran-ok' } : { kind: 'test-failures' };
@@ -38,6 +38,15 @@ export declare function stripBlockCommentsInLines(lines: string[]): string[];
38
38
  * can splice into the original `lines` array at the returned index.
39
39
  */
40
40
  export declare function findDarkRootInsertionIndex(lines: string[]): number | null;
41
+ /**
42
+ * Depth-counts braces from `startLine` (whose lines must already have
43
+ * block comments stripped), returning the index of the line on which the
44
+ * block opened there returns to its entry depth — i.e. the line carrying
45
+ * the block's closing `}` — or -1 when the block never closes. The first
46
+ * `{` encountered sets the entry depth, so the scan may start on the
47
+ * selector/at-rule line itself rather than on the opener.
48
+ */
49
+ export declare function findBlockCloseIndex(stripped: string[], startLine: number): number;
41
50
  /**
42
51
  * Finds the closing `}` of the outermost
43
52
  * `@media (prefers-color-scheme: dark)` block. Used as the fallback
@@ -122,7 +122,7 @@ export function findDarkRootInsertionIndex(lines) {
122
122
  * `{` encountered sets the entry depth, so the scan may start on the
123
123
  * selector/at-rule line itself rather than on the opener.
124
124
  */
125
- function findBlockCloseIndex(stripped, startLine) {
125
+ export function findBlockCloseIndex(stripped, startLine) {
126
126
  let depth = 0;
127
127
  let entryDepth = 0;
128
128
  let enteredBlock = false;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Documentation-table updates for `fireforge token add`. Extracted from
3
+ * `token-manager.ts` so the CSS-mutation path and the Markdown-table path
4
+ * each stay within the per-file line budget. Consumed only by token-manager.
5
+ */
6
+ /**
7
+ * Minimal token shape the docs updater needs. Declared locally (rather than
8
+ * importing `AddTokenOptions`) so this module has no edge back to
9
+ * `token-manager.ts` — `AddTokenOptions` is structurally compatible.
10
+ */
11
+ export interface TokenDocInput {
12
+ tokenName: string;
13
+ value: string;
14
+ category: string;
15
+ mode: string;
16
+ description?: string | undefined;
17
+ }
18
+ /**
19
+ * Adds a token row to the main token table, the unmapped table (for
20
+ * literal values), and bumps the mode count table. Each sub-update runs
21
+ * against a freshly parsed view of the document so that splice indices
22
+ * stay valid as rewrites are layered.
23
+ *
24
+ * @param annotation - The mode annotation string the caller already computed
25
+ * (kept here as a parameter so this module needs no dependency on
26
+ * token-manager's `getModeAnnotation`).
27
+ */
28
+ export declare function addTokenToDocs(engineDir: string, options: TokenDocInput, annotation: string): Promise<{
29
+ docsAdded: boolean;
30
+ unmappedAdded: boolean;
31
+ countUpdated: boolean;
32
+ }>;
@@ -0,0 +1,101 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Documentation-table updates for `fireforge token add`. Extracted from
4
+ * `token-manager.ts` so the CSS-mutation path and the Markdown-table path
5
+ * each stay within the per-file line budget. Consumed only by token-manager.
6
+ */
7
+ import { join } from 'node:path';
8
+ import { pathExists, readText, writeText } from '../utils/fs.js';
9
+ import { findTableAfterHeading, findTableByColumns, insertRow, rewriteTableRows, updateCellByKey, } from './markdown-table.js';
10
+ const TOKENS_DOC = 'docs/design/SRC_TOKENS.md';
11
+ /**
12
+ * Strips surrounding backticks from a cell, if present. Token cells are
13
+ * usually wrapped in inline code fences (`` `--foo` ``) and the parser
14
+ * returns them verbatim.
15
+ */
16
+ function stripInlineCode(cell) {
17
+ const trimmed = cell.trim();
18
+ if (trimmed.startsWith('`') && trimmed.endsWith('`') && trimmed.length >= 2) {
19
+ return trimmed.slice(1, -1);
20
+ }
21
+ return trimmed;
22
+ }
23
+ /**
24
+ * Adds a token row to the main token table, the unmapped table (for
25
+ * literal values), and bumps the mode count table. Each sub-update runs
26
+ * against a freshly parsed view of the document so that splice indices
27
+ * stay valid as rewrites are layered.
28
+ *
29
+ * @param annotation - The mode annotation string the caller already computed
30
+ * (kept here as a parameter so this module needs no dependency on
31
+ * token-manager's `getModeAnnotation`).
32
+ */
33
+ export async function addTokenToDocs(engineDir, options, annotation) {
34
+ const filePath = join(engineDir, '..', TOKENS_DOC);
35
+ if (!(await pathExists(filePath))) {
36
+ // Docs file is optional
37
+ return { docsAdded: false, unmappedAdded: false, countUpdated: false };
38
+ }
39
+ const originalContent = await readText(filePath);
40
+ let lines = originalContent.split('\n');
41
+ let docsAdded = false;
42
+ let unmappedAdded = false;
43
+ let countUpdated = false;
44
+ const isLiteral = !options.value.startsWith('var(');
45
+ const mapsTo = isLiteral ? '—' : options.value.replace(/var\(([^)]+)\)/, '$1');
46
+ const tokenCell = `\`${options.tokenName}\``;
47
+ const valueCell = `\`${options.value}\``;
48
+ // --- Main token table: Category | Token | Value | Maps to | Mode ---
49
+ const mainTable = findTableByColumns(lines, ['Category', 'Token', 'Value', 'Mode']);
50
+ if (mainTable) {
51
+ // The doc convention allows the Category cell to be blank on
52
+ // continuation rows that belong to the previous category. Group rows
53
+ // by carrying the last non-empty Category value forward.
54
+ let lastGroupRowIndex = -1;
55
+ let currentCategory = '';
56
+ for (let i = 0; i < mainTable.rows.length; i++) {
57
+ const row = mainTable.rows[i];
58
+ if (!row)
59
+ continue;
60
+ const cell = row[0]?.trim() ?? '';
61
+ if (cell) {
62
+ currentCategory = cell;
63
+ }
64
+ if (currentCategory === options.category) {
65
+ lastGroupRowIndex = i;
66
+ }
67
+ }
68
+ if (lastGroupRowIndex !== -1) {
69
+ insertRow(mainTable, ['', tokenCell, valueCell, mapsTo, annotation], lastGroupRowIndex + 1);
70
+ lines = rewriteTableRows(lines, mainTable);
71
+ docsAdded = true;
72
+ }
73
+ }
74
+ // --- Unmapped table: populated for literal (non-var()) values only ---
75
+ if (isLiteral) {
76
+ const unmappedTable = findTableAfterHeading(lines, /not yet mapped|unmapped/i);
77
+ if (unmappedTable) {
78
+ insertRow(unmappedTable, [tokenCell, valueCell, options.description ?? ''], unmappedTable.rows.length);
79
+ lines = rewriteTableRows(lines, unmappedTable);
80
+ unmappedAdded = true;
81
+ }
82
+ }
83
+ // --- Mode behavior count table: Mode | Count ---
84
+ const modeTable = findTableByColumns(lines, ['Mode', 'Count']);
85
+ if (modeTable) {
86
+ const modeIndex = modeTable.headers.indexOf('Mode');
87
+ const countIndex = modeTable.headers.indexOf('Count');
88
+ const existing = modeTable.rows.find((row) => stripInlineCode(row[modeIndex] ?? '') === options.mode);
89
+ if (existing) {
90
+ const oldCount = parseInt(existing[countIndex] ?? '0', 10);
91
+ const updated = updateCellByKey(modeTable, 'Mode', existing[modeIndex] ?? options.mode, 'Count', String((Number.isNaN(oldCount) ? 0 : oldCount) + 1));
92
+ if (updated) {
93
+ lines = rewriteTableRows(lines, modeTable);
94
+ countUpdated = true;
95
+ }
96
+ }
97
+ }
98
+ await writeText(filePath, lines.join('\n'));
99
+ return { docsAdded, unmappedAdded, countUpdated };
100
+ }
101
+ //# sourceMappingURL=token-docs.js.map
@@ -22,6 +22,14 @@ export interface AddTokenOptions {
22
22
  dryRun?: boolean | undefined;
23
23
  /** Declare the category banner in the tokens CSS when it does not exist yet. */
24
24
  createCategory?: boolean | undefined;
25
+ /**
26
+ * Attribute selector fragment (e.g. `[data-skin="precision"]` or
27
+ * `[data-private]`) that routes the declaration into a top-level
28
+ * `:root<variant>` block instead of the base `:root` / category section.
29
+ * The block is created if absent and appended to if present. Variant
30
+ * overrides are CSS-only — the base token already owns its docs row.
31
+ */
32
+ variant?: string | undefined;
25
33
  }
26
34
  /**
27
35
  * Result of adding a token.
@@ -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;