@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.
- package/CHANGELOG.md +11 -0
- 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/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 +26 -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/mach-resource-shim.d.ts +21 -0
- package/dist/src/core/mach-resource-shim.js +92 -0
- package/dist/src/core/mach.js +9 -2
- package/dist/src/core/manifest-helpers.js +29 -4
- 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-reexports.d.ts +1 -1
- package/dist/src/core/patch-lint-reexports.js +1 -1
- 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/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.js +56 -28
- package/dist/src/types/commands/options.d.ts +5 -0
- package/dist/src/types/config.d.ts +13 -0
- 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
|
|
48
|
-
* `no-tests`, even when the exit code is zero.
|
|
49
|
-
*
|
|
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
|
|
99
|
-
* `no-tests`, even when the exit code is zero.
|
|
100
|
-
*
|
|
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
|
-
|
|
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;
|