@hominis/fireforge 0.38.0 → 0.40.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 +41 -0
- package/README.md +25 -1
- package/dist/src/cli.js +7 -0
- package/dist/src/commands/build.js +8 -3
- package/dist/src/commands/discard.js +75 -50
- package/dist/src/commands/export-flow.d.ts +1 -1
- package/dist/src/commands/export-flow.js +21 -10
- package/dist/src/commands/export-placement-gate.js +1 -1
- package/dist/src/commands/export-shared.js +31 -5
- package/dist/src/commands/import.js +5 -0
- package/dist/src/commands/lint-per-patch.js +97 -18
- package/dist/src/commands/lint-report.d.ts +21 -0
- package/dist/src/commands/lint-report.js +62 -0
- package/dist/src/commands/lint.js +23 -19
- package/dist/src/commands/manifest.js +2 -0
- package/dist/src/commands/patch/lint-ignore.js +20 -1
- package/dist/src/commands/patch/move-files-into.d.ts +47 -0
- package/dist/src/commands/patch/move-files-into.js +245 -0
- package/dist/src/commands/patch/move-files.js +27 -13
- package/dist/src/commands/patch/rename.js +5 -2
- package/dist/src/commands/patch/staged-dependency.js +102 -14
- package/dist/src/commands/re-export-adjacent.d.ts +34 -0
- package/dist/src/commands/re-export-adjacent.js +106 -0
- package/dist/src/commands/re-export-options.js +3 -0
- package/dist/src/commands/re-export.js +42 -53
- package/dist/src/commands/reset.js +5 -0
- package/dist/src/commands/run.js +18 -2
- package/dist/src/commands/status-check.d.ts +35 -0
- package/dist/src/commands/status-check.js +88 -0
- package/dist/src/commands/status-output.d.ts +9 -0
- package/dist/src/commands/status-output.js +33 -1
- package/dist/src/commands/status.js +33 -8
- package/dist/src/commands/test-diagnose.d.ts +1 -1
- package/dist/src/commands/test-diagnose.js +9 -3
- package/dist/src/commands/test-modes.d.ts +21 -0
- package/dist/src/commands/test-modes.js +42 -0
- package/dist/src/commands/test-run.d.ts +2 -0
- package/dist/src/commands/test-run.js +26 -5
- package/dist/src/commands/test.js +38 -32
- package/dist/src/commands/token.js +4 -1
- package/dist/src/commands/tree.d.ts +16 -0
- package/dist/src/commands/tree.js +174 -0
- package/dist/src/core/build-baseline-types.d.ts +7 -0
- package/dist/src/core/build-baseline.d.ts +1 -1
- package/dist/src/core/build-baseline.js +2 -1
- package/dist/src/core/build-prepare.d.ts +9 -0
- package/dist/src/core/build-prepare.js +24 -0
- package/dist/src/core/config-validate.js +25 -0
- package/dist/src/core/discard-baseline.d.ts +60 -0
- package/dist/src/core/discard-baseline.js +232 -0
- package/dist/src/core/license-headers.d.ts +12 -0
- package/dist/src/core/license-headers.js +27 -0
- package/dist/src/core/lint-cache.d.ts +15 -5
- package/dist/src/core/lint-cache.js +17 -5
- package/dist/src/core/patch-export.d.ts +12 -3
- package/dist/src/core/patch-export.js +13 -3
- package/dist/src/core/patch-lint-checkjs.d.ts +25 -2
- package/dist/src/core/patch-lint-checkjs.js +85 -16
- package/dist/src/core/patch-lint-cross.d.ts +9 -1
- package/dist/src/core/patch-lint-cross.js +69 -9
- package/dist/src/core/patch-lint-css.js +64 -16
- package/dist/src/core/patch-lint-diff.d.ts +8 -0
- package/dist/src/core/patch-lint-diff.js +31 -0
- package/dist/src/core/patch-lint-mozbuild.d.ts +18 -0
- package/dist/src/core/patch-lint-mozbuild.js +104 -0
- package/dist/src/core/patch-lint-ownership.d.ts +21 -0
- package/dist/src/core/patch-lint-ownership.js +30 -0
- package/dist/src/core/patch-lint-reexports.d.ts +1 -1
- package/dist/src/core/patch-lint.d.ts +23 -0
- package/dist/src/core/patch-lint.js +34 -3
- package/dist/src/core/patch-manifest.d.ts +1 -1
- package/dist/src/core/patch-policy.d.ts +14 -1
- package/dist/src/core/patch-policy.js +13 -2
- package/dist/src/core/smoke-patterns.d.ts +24 -8
- package/dist/src/core/smoke-patterns.js +23 -12
- package/dist/src/core/status-classify.d.ts +16 -0
- package/dist/src/core/status-classify.js +20 -6
- package/dist/src/core/test-harness-crash.d.ts +10 -0
- package/dist/src/core/test-harness-crash.js +16 -0
- package/dist/src/core/test-stale-check.d.ts +15 -0
- package/dist/src/core/test-stale-check.js +37 -1
- package/dist/src/core/token-category.d.ts +40 -0
- package/dist/src/core/token-category.js +264 -0
- package/dist/src/core/token-manager.d.ts +7 -0
- package/dist/src/core/token-manager.js +82 -193
- package/dist/src/core/token-variant.d.ts +7 -0
- package/dist/src/core/token-variant.js +9 -0
- package/dist/src/core/tree-cow.d.ts +18 -0
- package/dist/src/core/tree-cow.js +71 -0
- package/dist/src/core/tree-guard.d.ts +35 -0
- package/dist/src/core/tree-guard.js +124 -0
- package/dist/src/core/tree-store.d.ts +53 -0
- package/dist/src/core/tree-store.js +214 -0
- package/dist/src/core/typecheck-shim.d.ts +27 -0
- package/dist/src/core/typecheck-shim.js +58 -4
- package/dist/src/core/typecheck.js +14 -3
- package/dist/src/core/xpcshell-profile-dir.d.ts +12 -0
- package/dist/src/core/xpcshell-profile-dir.js +45 -0
- package/dist/src/errors/git.d.ts +8 -0
- package/dist/src/errors/git.js +17 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +1 -0
- package/dist/src/types/commands/options.d.ts +45 -2
- package/dist/src/types/config.d.ts +34 -0
- package/dist/src/utils/validation.d.ts +10 -0
- package/dist/src/utils/validation.js +29 -0
- package/package.json +6 -6
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* Category banner and token-declaration helpers for the tokens CSS file
|
|
4
|
+
* (FORGE G3). Split out of `token-manager.ts` to stay inside the per-file
|
|
5
|
+
* line budget.
|
|
6
|
+
*
|
|
7
|
+
* Banner matching is EXACT: the text between the `=` runs (single-line
|
|
8
|
+
* shape) or on the block line (multi-line shape) must equal the category
|
|
9
|
+
* after trimming. The pre-0.40.0 substring match let a TOC comment or a
|
|
10
|
+
* longer banner (`Colors — Canvas` for `Colors`) satisfy the lookup, so
|
|
11
|
+
* `token add` wrote into the wrong section — or no-oped for 19
|
|
12
|
+
* consecutive calls — with exit 0.
|
|
13
|
+
*/
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { GeneralError } from '../errors/base.js';
|
|
16
|
+
import { pathExists, readText } from '../utils/fs.js';
|
|
17
|
+
import { escapeRegex } from '../utils/regex.js';
|
|
18
|
+
function singleLineBannerPattern(category) {
|
|
19
|
+
return new RegExp(`\\/\\*\\s*=+\\s*${escapeRegex(category)}\\s*=+\\s*\\*\\/`);
|
|
20
|
+
}
|
|
21
|
+
function multiLineBlockNameMatches(blockLine, category) {
|
|
22
|
+
return blockLine.replace(/^\s*\*\s*/, '').trim() === category;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* True when `lines` contain a category header (single-line or multi-line
|
|
26
|
+
* banner shape) whose name EQUALS `category`. Shared by the pre-add
|
|
27
|
+
* assertion, the banner creation path, and the section finder so all
|
|
28
|
+
* agree on what "exists" means.
|
|
29
|
+
*/
|
|
30
|
+
export function categoryHeaderExists(lines, category) {
|
|
31
|
+
const singleLinePattern = singleLineBannerPattern(category);
|
|
32
|
+
for (let i = 0; i < lines.length; i++) {
|
|
33
|
+
const line = lines[i] ?? '';
|
|
34
|
+
if (singleLinePattern.test(line)) {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
if (/^\s*\/\*\s*=+/.test(line) && !/\*\//.test(line)) {
|
|
38
|
+
for (let j = i + 1; j < Math.min(i + 6, lines.length); j++) {
|
|
39
|
+
const blockLine = lines[j] ?? '';
|
|
40
|
+
if (multiLineBlockNameMatches(blockLine, category)) {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
if (/\*\//.test(blockLine))
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Scans a tokens CSS file for category header comments and returns the
|
|
52
|
+
* category names in document order. Used to enrich the "category not
|
|
53
|
+
* found" error body with concrete alternatives the operator can copy.
|
|
54
|
+
*
|
|
55
|
+
* Mirrors the shapes `findCategorySection` recognises:
|
|
56
|
+
* - Single-line: `/* = Foo = *\/`
|
|
57
|
+
* - Multi-line: `/* =====` opening, `Foo` on any of the next ~5 lines,
|
|
58
|
+
* closing `*\/`.
|
|
59
|
+
*
|
|
60
|
+
* This helper exists as a pure inspector; it never throws on malformed
|
|
61
|
+
* headers and silently skips shapes it cannot parse.
|
|
62
|
+
*/
|
|
63
|
+
function discoverCategoryHeaders(lines) {
|
|
64
|
+
const categories = new Set();
|
|
65
|
+
const singleLinePattern = /\/\*\s*=+\s*(.+?)\s*=+\s*\*\//;
|
|
66
|
+
for (let i = 0; i < lines.length; i++) {
|
|
67
|
+
const line = lines[i] ?? '';
|
|
68
|
+
const singleMatch = singleLinePattern.exec(line);
|
|
69
|
+
if (singleMatch?.[1]) {
|
|
70
|
+
const extracted = singleMatch[1].trim();
|
|
71
|
+
if (extracted.length > 0)
|
|
72
|
+
categories.add(extracted);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (/^\s*\/\*\s*=+/.test(line) && !/\*\//.test(line)) {
|
|
76
|
+
for (let j = i + 1; j < Math.min(i + 6, lines.length); j++) {
|
|
77
|
+
const blockLine = lines[j] ?? '';
|
|
78
|
+
if (/\*\//.test(blockLine))
|
|
79
|
+
break;
|
|
80
|
+
const trimmed = blockLine.replace(/^\s*\*\s*/, '').trim();
|
|
81
|
+
if (trimmed.length === 0)
|
|
82
|
+
continue;
|
|
83
|
+
if (/^=+$/.test(trimmed))
|
|
84
|
+
continue;
|
|
85
|
+
categories.add(trimmed);
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return [...categories];
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Asserts the category banner exists (or that creation was requested).
|
|
94
|
+
* Throws a GeneralError naming the available categories and the
|
|
95
|
+
* `--create-category` remedy otherwise.
|
|
96
|
+
*/
|
|
97
|
+
export async function assertTokenCategoryExists(engineDir, tokensCssPath, category, createCategory = false) {
|
|
98
|
+
const filePath = join(engineDir, tokensCssPath);
|
|
99
|
+
if (!(await pathExists(filePath))) {
|
|
100
|
+
throw new GeneralError(`Token CSS file not found: ${tokensCssPath}`);
|
|
101
|
+
}
|
|
102
|
+
const content = await readText(filePath);
|
|
103
|
+
const lines = content.split('\n');
|
|
104
|
+
if (categoryHeaderExists(lines, category))
|
|
105
|
+
return;
|
|
106
|
+
// The write path declares the banner in the same edit as the token
|
|
107
|
+
// insertion, so a missing category is fine when creation was requested.
|
|
108
|
+
if (createCategory)
|
|
109
|
+
return;
|
|
110
|
+
const discoveredCategories = discoverCategoryHeaders(lines);
|
|
111
|
+
const available = discoveredCategories.length > 0
|
|
112
|
+
? `Available categories in the file: ${discoveredCategories.map((name) => `"${name}"`).join(', ')}.`
|
|
113
|
+
: 'The file currently has no category headers.';
|
|
114
|
+
throw new GeneralError(`Category "${category}" not found in ${tokensCssPath}.\n\n` +
|
|
115
|
+
`${available}\n\n` +
|
|
116
|
+
'Categories are declared by comment headers. Single-line shape: /* = My Category = */. ' +
|
|
117
|
+
'Multi-line shape: /* =============\\n * My Category\\n * ============= */.\n\n' +
|
|
118
|
+
'Re-run with --create-category to declare the banner and insert the token in one step.');
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Splices a new single-line category banner ("= Name =" comment shape, the
|
|
122
|
+
* same format `discoverCategoryHeaders` recognises) just before the closing
|
|
123
|
+
* brace of the `:root` block, making the new category the last section.
|
|
124
|
+
* Mutates `lines` in place.
|
|
125
|
+
*/
|
|
126
|
+
export function declareCategoryBanner(lines, category, tokensCssPath) {
|
|
127
|
+
const bounds = findBaseRootBounds(lines);
|
|
128
|
+
if (bounds === null) {
|
|
129
|
+
throw new GeneralError(`Cannot create category "${category}": no :root block found in ${tokensCssPath}. ` +
|
|
130
|
+
'Run "fireforge furnace init --force" to re-scaffold the tokens CSS file.');
|
|
131
|
+
}
|
|
132
|
+
if (bounds.close === -1) {
|
|
133
|
+
throw new GeneralError(`Cannot create category "${category}": the :root block in ${tokensCssPath} never closes.`);
|
|
134
|
+
}
|
|
135
|
+
lines.splice(bounds.close, 0, '', ` /* = ${category} = */`);
|
|
136
|
+
}
|
|
137
|
+
/** Locates and bounds the named category section. Throws when absent. */
|
|
138
|
+
export function findCategorySection(lines, category, tokensCssPath) {
|
|
139
|
+
const singleLinePattern = singleLineBannerPattern(category);
|
|
140
|
+
let categoryLine = -1;
|
|
141
|
+
for (let i = 0; i < lines.length; i++) {
|
|
142
|
+
const line = lines[i] ?? '';
|
|
143
|
+
// Check single-line format: /* = Category = */
|
|
144
|
+
if (singleLinePattern.test(line)) {
|
|
145
|
+
categoryLine = i;
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
// Check multi-line format: line opens a block comment with === but does NOT close it
|
|
149
|
+
if (/^\s*\/\*\s*=+/.test(line) && !/\*\//.test(line)) {
|
|
150
|
+
for (let j = i + 1; j < Math.min(i + 6, lines.length); j++) {
|
|
151
|
+
const blockLine = lines[j] ?? '';
|
|
152
|
+
if (multiLineBlockNameMatches(blockLine, category)) {
|
|
153
|
+
categoryLine = i;
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
if (/\*\//.test(blockLine))
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
if (categoryLine !== -1)
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (categoryLine === -1) {
|
|
164
|
+
const discoveredCategories = discoverCategoryHeaders(lines);
|
|
165
|
+
const available = discoveredCategories.length > 0
|
|
166
|
+
? `Available categories in the file: ${discoveredCategories.map((name) => `"${name}"`).join(', ')}.`
|
|
167
|
+
: 'The file currently has no category headers.';
|
|
168
|
+
throw new GeneralError(`Category "${category}" not found in ${tokensCssPath}.\n\n` +
|
|
169
|
+
`${available}\n\n` +
|
|
170
|
+
'Add a header by hand inside the :root block (format: "/* = My Category = */") or re-run "fireforge furnace init --force" to re-seed the default categories.');
|
|
171
|
+
}
|
|
172
|
+
// Find the end of this category section (next section header or closing })
|
|
173
|
+
// Skip past the current header block first
|
|
174
|
+
let scanStart = categoryLine + 1;
|
|
175
|
+
for (let i = categoryLine + 1; i < lines.length; i++) {
|
|
176
|
+
const line = lines[i] ?? '';
|
|
177
|
+
if (/^\s*\/\*\s*=/.test(line) || /^\s*\*\s*=/.test(line) || /^\s*\*\//.test(line)) {
|
|
178
|
+
scanStart = i + 1;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
let sectionEnd = lines.length;
|
|
184
|
+
for (let i = scanStart; i < lines.length; i++) {
|
|
185
|
+
const line = lines[i] ?? '';
|
|
186
|
+
if (/\/\*\s*=.*=\s*\*\//.test(line) ||
|
|
187
|
+
(/^\s*\/\*\s*=+/.test(line) && !/\*\//.test(line)) ||
|
|
188
|
+
/^\s*\}/.test(line)) {
|
|
189
|
+
sectionEnd = i;
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return { categoryLine, sectionEnd };
|
|
194
|
+
}
|
|
195
|
+
/** 0-based open/close line indices of the base `:root {` block. */
|
|
196
|
+
function findBaseRootBounds(lines) {
|
|
197
|
+
let open = -1;
|
|
198
|
+
for (let i = 0; i < lines.length; i++) {
|
|
199
|
+
if (/:root\s*\{/.test(lines[i] ?? '')) {
|
|
200
|
+
open = i;
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (open === -1)
|
|
205
|
+
return null;
|
|
206
|
+
for (let i = open + 1; i < lines.length; i++) {
|
|
207
|
+
if (/^\s*\}/.test(lines[i] ?? '')) {
|
|
208
|
+
return { open, close: i };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return { open, close: -1 };
|
|
212
|
+
}
|
|
213
|
+
/** Masks block-comment content per line so declarations inside comments never match. */
|
|
214
|
+
function maskCommentLines(lines) {
|
|
215
|
+
const masked = lines.join('\n').replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '));
|
|
216
|
+
return masked.split('\n');
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Finds an existing declaration of `tokenName` inside the base `:root`
|
|
220
|
+
* block. Dark `@media` and `:root[variant]` companion blocks are
|
|
221
|
+
* deliberately excluded — they mirror the base declaration, they do not
|
|
222
|
+
* own the token. Comment content never matches, and the name match is
|
|
223
|
+
* exact (`--foo-bar` does not match `--x-foo-bar`).
|
|
224
|
+
*/
|
|
225
|
+
export function findTokenDeclarationInRoot(lines, tokenName) {
|
|
226
|
+
const bounds = findBaseRootBounds(lines);
|
|
227
|
+
if (bounds === null || bounds.close === -1)
|
|
228
|
+
return undefined;
|
|
229
|
+
const masked = maskCommentLines(lines);
|
|
230
|
+
const declPattern = new RegExp(`^\\s*${escapeRegex(tokenName)}\\s*:`);
|
|
231
|
+
for (let i = bounds.open + 1; i < bounds.close; i++) {
|
|
232
|
+
if (!declPattern.test(masked[i] ?? ''))
|
|
233
|
+
continue;
|
|
234
|
+
const category = findSectionNameAbove(lines, i);
|
|
235
|
+
return category === undefined ? { line: i + 1 } : { line: i + 1, category };
|
|
236
|
+
}
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
/** Nearest category banner name above `lineIndex`, if any. */
|
|
240
|
+
function findSectionNameAbove(lines, lineIndex) {
|
|
241
|
+
const singleLinePattern = /\/\*\s*=+\s*(.+?)\s*=+\s*\*\//;
|
|
242
|
+
for (let i = lineIndex - 1; i >= 0; i--) {
|
|
243
|
+
const line = lines[i] ?? '';
|
|
244
|
+
const singleMatch = singleLinePattern.exec(line);
|
|
245
|
+
if (singleMatch?.[1]) {
|
|
246
|
+
const extracted = singleMatch[1].trim();
|
|
247
|
+
if (extracted.length > 0 && !/^=+$/.test(extracted))
|
|
248
|
+
return extracted;
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (/^\s*\/\*\s*=+/.test(line) && !/\*\//.test(line)) {
|
|
252
|
+
for (let j = i + 1; j < Math.min(i + 6, lines.length); j++) {
|
|
253
|
+
const blockLine = lines[j] ?? '';
|
|
254
|
+
if (/\*\//.test(blockLine))
|
|
255
|
+
break;
|
|
256
|
+
const trimmed = blockLine.replace(/^\s*\*\s*/, '').trim();
|
|
257
|
+
if (trimmed.length === 0 || /^=+$/.test(trimmed))
|
|
258
|
+
continue;
|
|
259
|
+
return trimmed;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type TokenDeclarationLocation } from './token-category.js';
|
|
1
2
|
/**
|
|
2
3
|
* Dark mode behavior for a token.
|
|
3
4
|
*/
|
|
@@ -47,6 +48,12 @@ export interface AddTokenResult {
|
|
|
47
48
|
skipped: boolean;
|
|
48
49
|
/** Whether a new category banner was declared by this add. */
|
|
49
50
|
categoryCreated?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* When the add skipped because the token already lives in the TARGET
|
|
53
|
+
* category, names where the existing base-`:root` declaration sits so
|
|
54
|
+
* the skip message can point at it (FORGE G3).
|
|
55
|
+
*/
|
|
56
|
+
skippedExisting?: TokenDeclarationLocation;
|
|
50
57
|
}
|
|
51
58
|
/** Returns the token CSS path relative to engine root for a given binary name. */
|
|
52
59
|
export declare function getTokensCssPath(binaryName: string): string;
|
|
@@ -4,14 +4,14 @@ import { GeneralError, InvalidArgumentError } from '../errors/base.js';
|
|
|
4
4
|
import { FurnaceError } from '../errors/furnace.js';
|
|
5
5
|
import { toError } from '../utils/errors.js';
|
|
6
6
|
import { pathExists, readText, writeText } from '../utils/fs.js';
|
|
7
|
-
import { warn } from '../utils/logger.js';
|
|
8
|
-
import { escapeRegex } from '../utils/regex.js';
|
|
7
|
+
import { info, warn } from '../utils/logger.js';
|
|
9
8
|
import { validateTokenName } from '../utils/validation.js';
|
|
10
9
|
import { getProjectPaths, loadConfig } from './config.js';
|
|
11
10
|
import { loadFurnaceConfig } from './furnace-config.js';
|
|
11
|
+
import { assertTokenCategoryExists, categoryHeaderExists, declareCategoryBanner, findCategorySection, findTokenDeclarationInRoot, } from './token-category.js';
|
|
12
12
|
import { findDarkMediaCloseIndex, findDarkRootInsertionIndex } from './token-dark-mode.js';
|
|
13
13
|
import { addTokenToDocs } from './token-docs.js';
|
|
14
|
-
import { insertVariantDeclaration, validateVariantSelector, variantBlockHasToken, } from './token-variant.js';
|
|
14
|
+
import { insertVariantDeclaration, validateVariantSelector, variantBlockExists, variantBlockHasToken, } from './token-variant.js';
|
|
15
15
|
/** Returns the token CSS path relative to engine root for a given binary name. */
|
|
16
16
|
export function getTokensCssPath(binaryName) {
|
|
17
17
|
return `browser/themes/shared/${binaryName}-tokens.css`;
|
|
@@ -83,6 +83,10 @@ function normalizeVariantOption(options) {
|
|
|
83
83
|
throw new InvalidArgumentError('Cannot combine --variant with --mode override; author the variant declaration with ' +
|
|
84
84
|
'--mode auto/static instead.', 'variant');
|
|
85
85
|
}
|
|
86
|
+
if (options.createCategory === true) {
|
|
87
|
+
throw new InvalidArgumentError('--create-category cannot be combined with --variant; variant declarations are routed ' +
|
|
88
|
+
'into a :root<selector> block, not a category section.', 'variant');
|
|
89
|
+
}
|
|
86
90
|
const result = validateVariantSelector(options.variant);
|
|
87
91
|
if (!result.ok) {
|
|
88
92
|
throw new InvalidArgumentError(`--variant ${result.reason}.`, 'variant');
|
|
@@ -113,95 +117,26 @@ async function addVariantTokenToCSS(engineDir, options, tokensCssPath, variant)
|
|
|
113
117
|
return { added: true };
|
|
114
118
|
}
|
|
115
119
|
/**
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
120
|
+
* Evaluates base-`:root` idempotency for one add (FORGE G3): a token
|
|
121
|
+
* already declared in the TARGET category skips; a token declared
|
|
122
|
+
* anywhere else in the base block refuses loud — the pre-0.40.0
|
|
123
|
+
* whole-file substring check silently skipped (and discarded
|
|
124
|
+
* `--create-category`) in that case, exit 0.
|
|
119
125
|
*/
|
|
120
|
-
function
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
return true;
|
|
127
|
-
}
|
|
128
|
-
if (/^\s*\/\*\s*=+/.test(line) && !/\*\//.test(line)) {
|
|
129
|
-
for (let j = i + 1; j < Math.min(i + 6, lines.length); j++) {
|
|
130
|
-
const blockLine = lines[j] ?? '';
|
|
131
|
-
if (new RegExp(escapedCategory).test(blockLine)) {
|
|
132
|
-
return true;
|
|
133
|
-
}
|
|
134
|
-
if (/\*\//.test(blockLine))
|
|
135
|
-
break;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
126
|
+
function evaluateBaseTokenIdempotency(lines, options, tokensCssPath) {
|
|
127
|
+
const existing = findTokenDeclarationInRoot(lines, options.tokenName);
|
|
128
|
+
if (!existing)
|
|
129
|
+
return { skipped: false };
|
|
130
|
+
if (existing.category === options.category) {
|
|
131
|
+
return { skipped: true, existing };
|
|
138
132
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const content = await readText(filePath);
|
|
147
|
-
const lines = content.split('\n');
|
|
148
|
-
if (categoryHeaderExists(lines, category))
|
|
149
|
-
return;
|
|
150
|
-
// The write path declares the banner in the same edit as the token
|
|
151
|
-
// insertion, so a missing category is fine when creation was requested.
|
|
152
|
-
if (createCategory)
|
|
153
|
-
return;
|
|
154
|
-
const discoveredCategories = discoverCategoryHeaders(lines);
|
|
155
|
-
const available = discoveredCategories.length > 0
|
|
156
|
-
? `Available categories in the file: ${discoveredCategories.map((name) => `"${name}"`).join(', ')}.`
|
|
157
|
-
: 'The file currently has no category headers.';
|
|
158
|
-
throw new GeneralError(`Category "${category}" not found in ${tokensCssPath}.\n\n` +
|
|
159
|
-
`${available}\n\n` +
|
|
160
|
-
'Categories are declared by comment headers. Single-line shape: /* = My Category = */. ' +
|
|
161
|
-
'Multi-line shape: /* =============\\n * My Category\\n * ============= */.\n\n' +
|
|
162
|
-
'Re-run with --create-category to declare the banner and insert the token in one step.');
|
|
163
|
-
}
|
|
164
|
-
/**
|
|
165
|
-
* Scans a tokens CSS file for category header comments and returns the
|
|
166
|
-
* category names in document order. Used to enrich the "category not
|
|
167
|
-
* found" error body with concrete alternatives the operator can copy.
|
|
168
|
-
*
|
|
169
|
-
* Mirrors the shapes `findCategorySection` already recognises:
|
|
170
|
-
* - Single-line: `/* = Foo = *\/`
|
|
171
|
-
* - Multi-line: `/* =====` opening, `Foo` on any of the next ~5 lines,
|
|
172
|
-
* closing `*\/`.
|
|
173
|
-
*
|
|
174
|
-
* This helper exists as a pure inspector; it never throws on malformed
|
|
175
|
-
* headers and silently skips shapes it cannot parse.
|
|
176
|
-
*/
|
|
177
|
-
function discoverCategoryHeaders(lines) {
|
|
178
|
-
const categories = new Set();
|
|
179
|
-
const singleLinePattern = /\/\*\s*=+\s*(.+?)\s*=+\s*\*\//;
|
|
180
|
-
for (let i = 0; i < lines.length; i++) {
|
|
181
|
-
const line = lines[i] ?? '';
|
|
182
|
-
const singleMatch = singleLinePattern.exec(line);
|
|
183
|
-
if (singleMatch?.[1]) {
|
|
184
|
-
const extracted = singleMatch[1].trim();
|
|
185
|
-
if (extracted.length > 0)
|
|
186
|
-
categories.add(extracted);
|
|
187
|
-
continue;
|
|
188
|
-
}
|
|
189
|
-
if (/^\s*\/\*\s*=+/.test(line) && !/\*\//.test(line)) {
|
|
190
|
-
for (let j = i + 1; j < Math.min(i + 6, lines.length); j++) {
|
|
191
|
-
const blockLine = lines[j] ?? '';
|
|
192
|
-
if (/\*\//.test(blockLine))
|
|
193
|
-
break;
|
|
194
|
-
const trimmed = blockLine.replace(/^\s*\*\s*/, '').trim();
|
|
195
|
-
if (trimmed.length === 0)
|
|
196
|
-
continue;
|
|
197
|
-
if (/^=+$/.test(trimmed))
|
|
198
|
-
continue;
|
|
199
|
-
categories.add(trimmed);
|
|
200
|
-
break;
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
return [...categories];
|
|
133
|
+
const sectionSuffix = existing.category === undefined ? '' : `, section "${existing.category}"`;
|
|
134
|
+
const remedy = existing.category === undefined
|
|
135
|
+
? 'remove the stray declaration first'
|
|
136
|
+
: `pass --category "${existing.category}" to target the existing declaration, or remove it first`;
|
|
137
|
+
throw new GeneralError(`Token "${options.tokenName}" is already declared outside category "${options.category}" ` +
|
|
138
|
+
`(${tokensCssPath}:${String(existing.line)}${sectionSuffix}). ` +
|
|
139
|
+
`A token belongs to exactly one category — ${remedy}.`);
|
|
205
140
|
}
|
|
206
141
|
/**
|
|
207
142
|
* Validates token-add inputs without mutating files.
|
|
@@ -214,10 +149,11 @@ export async function validateTokenAdd(root, options) {
|
|
|
214
149
|
validateTokenNameSyntax(options.tokenName);
|
|
215
150
|
await validateTokenPrefix(root, options);
|
|
216
151
|
validateDarkValue(options);
|
|
217
|
-
// Variant mode targets a `:root<attr>` block, not a category section
|
|
218
|
-
//
|
|
152
|
+
// Variant mode targets a `:root<attr>` block, not a category section —
|
|
153
|
+
// but the required --category must still name a REAL category rather
|
|
154
|
+
// than being silently discarded (FORGE G3 bypass 2).
|
|
219
155
|
if (normalizeVariantOption(options) !== undefined) {
|
|
220
|
-
await
|
|
156
|
+
await assertTokenCategoryExists(engineDir, tokensCssPath, options.category, false);
|
|
221
157
|
return;
|
|
222
158
|
}
|
|
223
159
|
await assertTokenCategoryExists(engineDir, tokensCssPath, options.category, options.createCategory === true);
|
|
@@ -249,14 +185,14 @@ export async function addToken(root, options) {
|
|
|
249
185
|
skipped,
|
|
250
186
|
};
|
|
251
187
|
}
|
|
252
|
-
const
|
|
253
|
-
const skipped = stripped.includes(options.tokenName + ':');
|
|
188
|
+
const { skipped, existing } = evaluateBaseTokenIdempotency(content.split('\n'), options, tokensCssPath);
|
|
254
189
|
return {
|
|
255
190
|
cssAdded: !skipped,
|
|
256
191
|
docsAdded: !skipped,
|
|
257
192
|
unmappedAdded: !skipped && !options.value.startsWith('var('),
|
|
258
193
|
countUpdated: !skipped,
|
|
259
194
|
skipped,
|
|
195
|
+
...(existing !== undefined ? { skippedExisting: existing } : {}),
|
|
260
196
|
};
|
|
261
197
|
}
|
|
262
198
|
// Variant overrides are CSS-only: route the declaration into the
|
|
@@ -264,6 +200,9 @@ export async function addToken(root, options) {
|
|
|
264
200
|
// docs row). Done before the base-CSS path so an override of an existing
|
|
265
201
|
// base token is not short-circuited by the global idempotency check.
|
|
266
202
|
if (normalizedVariant !== undefined) {
|
|
203
|
+
// The required --category must name a real category even though the
|
|
204
|
+
// declaration routes into the variant block (FORGE G3 bypass 2).
|
|
205
|
+
await assertTokenCategoryExists(engineDir, tokensCssPath, options.category, false);
|
|
267
206
|
const { added } = await addVariantTokenToCSS(engineDir, options, tokensCssPath, normalizedVariant);
|
|
268
207
|
return {
|
|
269
208
|
cssAdded: added,
|
|
@@ -274,7 +213,7 @@ export async function addToken(root, options) {
|
|
|
274
213
|
};
|
|
275
214
|
}
|
|
276
215
|
// --- CSS file ---
|
|
277
|
-
const { added: cssAdded, categoryCreated } = await addTokenToCSS(engineDir, options, tokensCssPath);
|
|
216
|
+
const { added: cssAdded, categoryCreated, existing, } = await addTokenToCSS(engineDir, options, tokensCssPath);
|
|
278
217
|
if (!cssAdded) {
|
|
279
218
|
return {
|
|
280
219
|
cssAdded: false,
|
|
@@ -282,6 +221,7 @@ export async function addToken(root, options) {
|
|
|
282
221
|
unmappedAdded: false,
|
|
283
222
|
countUpdated: false,
|
|
284
223
|
skipped: true,
|
|
224
|
+
...(existing !== undefined ? { skippedExisting: existing } : {}),
|
|
285
225
|
};
|
|
286
226
|
}
|
|
287
227
|
// --- Documentation ---
|
|
@@ -296,98 +236,37 @@ export async function addToken(root, options) {
|
|
|
296
236
|
};
|
|
297
237
|
}
|
|
298
238
|
/**
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
239
|
+
* Explicit theme-attribute selectors the override path keeps in sync with
|
|
240
|
+
* the `@media (prefers-color-scheme: dark)` block. Consumer scaffolds that
|
|
241
|
+
* pair the media query with `:root[data-theme="dark"]` /
|
|
242
|
+
* `:root[data-theme="light"]` blocks (viewer-toggle theming) require every
|
|
243
|
+
* themed list to declare identical token sets, so an override add that only
|
|
244
|
+
* wrote the media block was a guaranteed half-finished edit (FORGE F8).
|
|
303
245
|
*/
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
break;
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
if (rootClose === -1) {
|
|
324
|
-
throw new GeneralError(`Cannot create category "${category}": the :root block in ${tokensCssPath} never closes.`);
|
|
325
|
-
}
|
|
326
|
-
lines.splice(rootClose, 0, '', ` /* = ${category} = */`);
|
|
327
|
-
}
|
|
328
|
-
function findCategorySection(lines, category, tokensCssPath) {
|
|
329
|
-
const escapedCategory = escapeRegex(category);
|
|
330
|
-
const singleLinePattern = new RegExp(`\\/\\*\\s*=.*${escapedCategory}.*=\\s*\\*\\/`);
|
|
331
|
-
let categoryLine = -1;
|
|
332
|
-
for (let i = 0; i < lines.length; i++) {
|
|
333
|
-
const line = lines[i] ?? '';
|
|
334
|
-
// Check single-line format: /* = Category = */
|
|
335
|
-
if (singleLinePattern.test(line)) {
|
|
336
|
-
categoryLine = i;
|
|
337
|
-
break;
|
|
338
|
-
}
|
|
339
|
-
// Check multi-line format: line opens a block comment with === but does NOT close it
|
|
340
|
-
// e.g., "/* ================================================================"
|
|
341
|
-
// (NOT "/* ================================================= */" which closes on the same line)
|
|
342
|
-
if (/^\s*\/\*\s*=+/.test(line) && !/\*\//.test(line)) {
|
|
343
|
-
// Look ahead within the comment block (up to 5 lines) for the category text
|
|
344
|
-
for (let j = i + 1; j < Math.min(i + 6, lines.length); j++) {
|
|
345
|
-
const blockLine = lines[j] ?? '';
|
|
346
|
-
if (new RegExp(escapedCategory).test(blockLine)) {
|
|
347
|
-
categoryLine = i;
|
|
348
|
-
break;
|
|
349
|
-
}
|
|
350
|
-
// Stop if we've exited the comment block
|
|
351
|
-
if (/\*\//.test(blockLine))
|
|
352
|
-
break;
|
|
353
|
-
}
|
|
354
|
-
if (categoryLine !== -1)
|
|
355
|
-
break;
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
if (categoryLine === -1) {
|
|
359
|
-
const discoveredCategories = discoverCategoryHeaders(lines);
|
|
360
|
-
const available = discoveredCategories.length > 0
|
|
361
|
-
? `Available categories in the file: ${discoveredCategories.map((name) => `"${name}"`).join(', ')}.`
|
|
362
|
-
: 'The file currently has no category headers.';
|
|
363
|
-
throw new GeneralError(`Category "${category}" not found in ${tokensCssPath}.\n\n` +
|
|
364
|
-
`${available}\n\n` +
|
|
365
|
-
'Add a header by hand inside the :root block (format: "/* = My Category = */") or re-run "fireforge furnace init --force" to re-seed the default categories.');
|
|
366
|
-
}
|
|
367
|
-
// Find the end of this category section (next section header or closing })
|
|
368
|
-
// Handles both single-line (/* =...= */) and multi-line (/* ===...) section delimiters
|
|
369
|
-
// Skip past the current header block first
|
|
370
|
-
let scanStart = categoryLine + 1;
|
|
371
|
-
for (let i = categoryLine + 1; i < lines.length; i++) {
|
|
372
|
-
const line = lines[i] ?? '';
|
|
373
|
-
// Skip lines that are part of the current header comment block
|
|
374
|
-
if (/^\s*\/\*\s*=/.test(line) || /^\s*\*\s*=/.test(line) || /^\s*\*\//.test(line)) {
|
|
375
|
-
scanStart = i + 1;
|
|
246
|
+
const THEME_ATTRIBUTE_VARIANTS = [
|
|
247
|
+
{ variant: '[data-theme="dark"]', pick: 'dark' },
|
|
248
|
+
{ variant: '[data-theme="light"]', pick: 'light' },
|
|
249
|
+
];
|
|
250
|
+
/**
|
|
251
|
+
* Mirrors an override's light/dark values into existing top-level
|
|
252
|
+
* `:root[data-theme="dark"]` / `:root[data-theme="light"]` blocks. Blocks
|
|
253
|
+
* that do not exist are left alone (scaffolded files have none; behavior is
|
|
254
|
+
* unchanged there). Idempotent per block. Returns the selectors written.
|
|
255
|
+
*/
|
|
256
|
+
function insertThemeAttributeOverrides(lines, options) {
|
|
257
|
+
if (options.mode !== 'override' || !options.darkValue)
|
|
258
|
+
return [];
|
|
259
|
+
const written = [];
|
|
260
|
+
for (const { variant, pick } of THEME_ATTRIBUTE_VARIANTS) {
|
|
261
|
+
if (!variantBlockExists(lines, variant))
|
|
376
262
|
continue;
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
const line = lines[i] ?? '';
|
|
383
|
-
if (/\/\*\s*=.*=\s*\*\//.test(line) ||
|
|
384
|
-
(/^\s*\/\*\s*=+/.test(line) && !/\*\//.test(line)) ||
|
|
385
|
-
/^\s*\}/.test(line)) {
|
|
386
|
-
sectionEnd = i;
|
|
387
|
-
break;
|
|
388
|
-
}
|
|
263
|
+
if (variantBlockHasToken(lines, variant, options.tokenName))
|
|
264
|
+
continue;
|
|
265
|
+
const value = pick === 'dark' ? options.darkValue : options.value;
|
|
266
|
+
insertVariantDeclaration(lines, variant, ` ${options.tokenName}: ${value};`);
|
|
267
|
+
written.push(`:root${variant}`);
|
|
389
268
|
}
|
|
390
|
-
return
|
|
269
|
+
return written;
|
|
391
270
|
}
|
|
392
271
|
function insertDarkModeOverride(lines, options) {
|
|
393
272
|
if (options.mode !== 'override' || !options.darkValue)
|
|
@@ -417,13 +296,20 @@ function insertDarkModeOverride(lines, options) {
|
|
|
417
296
|
async function addTokenToCSS(engineDir, options, tokensCssPath) {
|
|
418
297
|
const filePath = join(engineDir, tokensCssPath);
|
|
419
298
|
await assertTokenCategoryExists(engineDir, tokensCssPath, options.category, options.createCategory === true);
|
|
420
|
-
|
|
421
|
-
// Idempotency check — strip CSS block comments so we don't match inside them
|
|
422
|
-
const stripped = content.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
423
|
-
if (stripped.includes(options.tokenName + ':')) {
|
|
424
|
-
return { added: false, categoryCreated: false };
|
|
425
|
-
}
|
|
299
|
+
const content = await readText(filePath);
|
|
426
300
|
const lines = content.split('\n');
|
|
301
|
+
// Per-category idempotency (FORGE G3): a declaration in the TARGET
|
|
302
|
+
// category skips; a declaration elsewhere in the base :root block
|
|
303
|
+
// throws instead of silently skipping (and silently discarding
|
|
304
|
+
// --create-category).
|
|
305
|
+
const idempotency = evaluateBaseTokenIdempotency(lines, options, tokensCssPath);
|
|
306
|
+
if (idempotency.skipped) {
|
|
307
|
+
return {
|
|
308
|
+
added: false,
|
|
309
|
+
categoryCreated: false,
|
|
310
|
+
...(idempotency.existing !== undefined ? { existing: idempotency.existing } : {}),
|
|
311
|
+
};
|
|
312
|
+
}
|
|
427
313
|
const annotation = getModeAnnotation(options.mode, options.value);
|
|
428
314
|
// Declare a missing category banner in the same in-memory edit as the
|
|
429
315
|
// token insertion — the file is written exactly once, so a failure
|
|
@@ -451,7 +337,10 @@ async function addTokenToCSS(engineDir, options, tokensCssPath) {
|
|
|
451
337
|
}
|
|
452
338
|
lines.splice(insertIndex, 0, ...insertLines);
|
|
453
339
|
insertDarkModeOverride(lines, options);
|
|
454
|
-
|
|
455
|
-
|
|
340
|
+
const themeBlocksWritten = insertThemeAttributeOverrides(lines, options);
|
|
341
|
+
if (themeBlocksWritten.length > 0) {
|
|
342
|
+
info(`Override also written to ${themeBlocksWritten.join(' and ')}.`);
|
|
343
|
+
}
|
|
344
|
+
await writeText(filePath, lines.join('\n'));
|
|
456
345
|
return { added: true, categoryCreated };
|
|
457
346
|
}
|
|
@@ -29,6 +29,13 @@ export type VariantValidation = {
|
|
|
29
29
|
* @param raw - Raw `--variant` value from the CLI / programmatic caller.
|
|
30
30
|
*/
|
|
31
31
|
export declare function validateVariantSelector(raw: unknown): VariantValidation;
|
|
32
|
+
/**
|
|
33
|
+
* True when a top-level `:root<variant>` block exists in `lines`. Used by
|
|
34
|
+
* the override path to write theme-attribute blocks only when the scaffold
|
|
35
|
+
* already declares them — `insertVariantDeclaration` would otherwise create
|
|
36
|
+
* the block, which is only wanted for the explicit `--variant` flow.
|
|
37
|
+
*/
|
|
38
|
+
export declare function variantBlockExists(lines: string[], variant: string): boolean;
|
|
32
39
|
/** True when the `:root<variant>` block already declares `tokenName`. */
|
|
33
40
|
export declare function variantBlockHasToken(lines: string[], variant: string, tokenName: string): boolean;
|
|
34
41
|
/**
|