@finggujadhav/compiler 0.9.4 → 0.9.6
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/a11y.js +101 -0
- package/cli.js +96 -2
- package/engine.js +1 -1
- package/package.json +8 -2
- package/theme-check.js +480 -0
package/a11y.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FingguFlux A11y Scanner
|
|
3
|
+
* Validates template ARIA compliance against FingguFlux contracts.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const ARIA_CONTRACTS = {
|
|
7
|
+
'ff-accordion': {
|
|
8
|
+
required: ['role="button"', 'aria-expanded'],
|
|
9
|
+
recommended: ['aria-controls'],
|
|
10
|
+
stateSync: 'aria-expanded'
|
|
11
|
+
},
|
|
12
|
+
'ff-tabs': {
|
|
13
|
+
required: ['role="tablist"', 'role="tab"', 'role="tabpanel"'],
|
|
14
|
+
stateSync: 'aria-selected'
|
|
15
|
+
},
|
|
16
|
+
'ff-modal': {
|
|
17
|
+
required: ['role="dialog"', 'aria-modal="true"']
|
|
18
|
+
},
|
|
19
|
+
'ff-dropdown': {
|
|
20
|
+
required: ['aria-haspopup="true"'],
|
|
21
|
+
stateSync: 'aria-expanded'
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function validateTemplate(content) {
|
|
26
|
+
const issues = [];
|
|
27
|
+
|
|
28
|
+
// Check Accordion patterns
|
|
29
|
+
if (content.includes('ff-accordion-header')) {
|
|
30
|
+
const headers = content.match(/<button[^>]*class="[^"]*ff-accordion-header[^"]*"[^>]*>/g) || [];
|
|
31
|
+
headers.forEach(header => {
|
|
32
|
+
if (!header.includes('aria-expanded')) {
|
|
33
|
+
issues.push({
|
|
34
|
+
component: 'Accordion',
|
|
35
|
+
type: 'Error',
|
|
36
|
+
message: 'Missing aria-expanded on ff-accordion-header'
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
if (header.includes('data-ff-state')) {
|
|
40
|
+
const state = header.match(/data-ff-state="([^"]*)"/);
|
|
41
|
+
const aria = header.match(/aria-expanded="([^"]*)"/);
|
|
42
|
+
if (state && aria) {
|
|
43
|
+
const expected = state[1] === 'open' ? 'true' : 'false';
|
|
44
|
+
if (aria[1] !== expected) {
|
|
45
|
+
issues.push({
|
|
46
|
+
component: 'Accordion',
|
|
47
|
+
type: 'Warning',
|
|
48
|
+
message: `aria-expanded ("${aria[1]}") inconsistent with data-ff-state ("${state[1]}")`
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Check Modal patterns
|
|
57
|
+
if (content.includes('ff-modal')) {
|
|
58
|
+
const modals = content.match(/<div[^>]*class="[^"]*ff-modal[^"]*"[^>]*>/g) || [];
|
|
59
|
+
modals.forEach(modal => {
|
|
60
|
+
if (!modal.includes('role="dialog"') && !modal.includes('role="alertdialog"')) {
|
|
61
|
+
issues.push({
|
|
62
|
+
component: 'Modal',
|
|
63
|
+
type: 'Error',
|
|
64
|
+
message: 'Missing role="dialog" or "alertdialog" on ff-modal'
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
if (!modal.includes('aria-modal="true"')) {
|
|
68
|
+
issues.push({
|
|
69
|
+
component: 'Modal',
|
|
70
|
+
type: 'Warning',
|
|
71
|
+
message: 'Missing aria-modal="true" on ff-modal'
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return issues;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Basic Contrast Ratio Calculator (WCAG 2.1)
|
|
82
|
+
* @param {string} hex1
|
|
83
|
+
* @param {string} hex2
|
|
84
|
+
* @returns {number}
|
|
85
|
+
*/
|
|
86
|
+
export function getContrastRatio(hex1, hex2) {
|
|
87
|
+
const getL = (c) => {
|
|
88
|
+
let rv = parseInt(c, 16) / 255;
|
|
89
|
+
return rv <= 0.03928 ? rv / 12.92 : Math.pow((rv + 0.055) / 1.055, 2.4);
|
|
90
|
+
};
|
|
91
|
+
const getLuminance = (hex) => {
|
|
92
|
+
const r = hex.slice(1, 3);
|
|
93
|
+
const g = hex.slice(3, 5);
|
|
94
|
+
const b = hex.slice(5, 7);
|
|
95
|
+
return 0.2126 * getL(r) + 0.7152 * getL(g) + 0.0722 * getL(b);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const l1 = getLuminance(hex1);
|
|
99
|
+
const l2 = getLuminance(hex2);
|
|
100
|
+
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
|
101
|
+
}
|
package/cli.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
3
|
* FingguFlux CLI
|
|
4
|
-
* v0.9.
|
|
4
|
+
* v0.9.6 Theme Engine Stabilization & Contract Freeze
|
|
5
5
|
*/
|
|
6
6
|
import fs from 'fs';
|
|
7
7
|
import path from 'path';
|
|
8
8
|
import { scanFiles, getProjectFiles } from './scanner.js';
|
|
9
9
|
import { CompilerEngine } from './engine.js';
|
|
10
|
+
import { runThemeCheck, printThemeCheckReport } from './theme-check.js';
|
|
10
11
|
|
|
11
12
|
const args = process.argv.slice(2);
|
|
12
13
|
const command = args[0] || 'build';
|
|
@@ -31,9 +32,15 @@ async function main() {
|
|
|
31
32
|
case 'doctor':
|
|
32
33
|
await runDoctor();
|
|
33
34
|
break;
|
|
35
|
+
case 'a11y':
|
|
36
|
+
await runA11y();
|
|
37
|
+
break;
|
|
38
|
+
case 'theme-check':
|
|
39
|
+
await runThemeCheckCommand();
|
|
40
|
+
break;
|
|
34
41
|
default:
|
|
35
42
|
console.error(`Unknown command: ${command}`);
|
|
36
|
-
console.log('Available commands: build, analyze, doctor');
|
|
43
|
+
console.log('Available commands: build, analyze, doctor, a11y, theme-check');
|
|
37
44
|
process.exit(1);
|
|
38
45
|
}
|
|
39
46
|
}
|
|
@@ -159,6 +166,93 @@ async function runDoctor() {
|
|
|
159
166
|
else console.log(`\n❌ Project has issues. See details above.`);
|
|
160
167
|
}
|
|
161
168
|
|
|
169
|
+
async function runA11y() {
|
|
170
|
+
console.log(`\n♿ FingguFlux Accessibility Audit`);
|
|
171
|
+
const { validateTemplate, getContrastRatio } = await import('./a11y.js');
|
|
172
|
+
const files = getProjectFiles(path.resolve(inputDir));
|
|
173
|
+
|
|
174
|
+
let totalIssues = 0;
|
|
175
|
+
|
|
176
|
+
// 1. Template Validation
|
|
177
|
+
files.forEach(file => {
|
|
178
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
179
|
+
const fileIssues = validateTemplate(content);
|
|
180
|
+
if (fileIssues.length > 0) {
|
|
181
|
+
console.log(`\nFile: ${path.relative(process.cwd(), file)}`);
|
|
182
|
+
fileIssues.forEach(issue => {
|
|
183
|
+
const typeColor = issue.type === 'Error' ? '\x1b[31m' : '\x1b[33m';
|
|
184
|
+
console.log(` [${typeColor}${issue.type}\x1b[0m] ${issue.component}: ${issue.message}`);
|
|
185
|
+
totalIssues++;
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// 2. Token Contrast Check
|
|
191
|
+
console.log(`\n🎨 Semantic Token Contrast Check:`);
|
|
192
|
+
const possibleCorePaths = [
|
|
193
|
+
path.resolve('./packages/core/tokens.css'),
|
|
194
|
+
path.resolve('./node_modules/@finggujadhav/core/tokens.css')
|
|
195
|
+
];
|
|
196
|
+
const corePath = possibleCorePaths.find(p => fs.existsSync(p));
|
|
197
|
+
|
|
198
|
+
if (corePath) {
|
|
199
|
+
const tokens = fs.readFileSync(corePath, 'utf8');
|
|
200
|
+
const pairings = [
|
|
201
|
+
['--ff-success-surface', '--ff-success-content', 'Success'],
|
|
202
|
+
['--ff-warning-surface', '--ff-warning-content', 'Warning'],
|
|
203
|
+
['--ff-danger-surface', '--ff-danger-content', 'Danger']
|
|
204
|
+
];
|
|
205
|
+
|
|
206
|
+
pairings.forEach(([bgVar, fgVar, label]) => {
|
|
207
|
+
const bgMatch = tokens.match(new RegExp(`${bgVar}: ([#\\w]+)`));
|
|
208
|
+
const fgMatch = tokens.match(new RegExp(`${fgVar}: ([#\\w]+)`));
|
|
209
|
+
if (bgMatch && fgMatch) {
|
|
210
|
+
const ratio = getContrastRatio(bgMatch[1], fgMatch[1]);
|
|
211
|
+
const pass = ratio >= 4.5;
|
|
212
|
+
const status = pass ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
|
|
213
|
+
console.log(` ${label.padEnd(10)}: ${status} (${ratio.toFixed(2)}:1)`);
|
|
214
|
+
if (!pass) totalIssues++;
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (totalIssues === 0) {
|
|
220
|
+
console.log(`\n✨ No accessibility issues found!`);
|
|
221
|
+
} else {
|
|
222
|
+
console.log(`\n❌ Found ${totalIssues} accessibility issues.`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function runThemeCheckCommand() {
|
|
227
|
+
// Locate the tokens CSS
|
|
228
|
+
const possiblePaths = [
|
|
229
|
+
path.resolve('./packages/core/tokens.css'),
|
|
230
|
+
path.resolve('./node_modules/@finggujadhav/core/tokens.css'),
|
|
231
|
+
path.resolve('../../packages/core/tokens.css'),
|
|
232
|
+
];
|
|
233
|
+
const tokensPath = possiblePaths.find(p => fs.existsSync(p));
|
|
234
|
+
if (!tokensPath) {
|
|
235
|
+
console.error('❌ tokens.css not found. Searched:');
|
|
236
|
+
possiblePaths.forEach(p => console.error(' ' + p));
|
|
237
|
+
process.exit(1);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const css = fs.readFileSync(tokensPath, 'utf8');
|
|
241
|
+
const verbose = args.includes('--verbose');
|
|
242
|
+
|
|
243
|
+
let result;
|
|
244
|
+
try {
|
|
245
|
+
result = runThemeCheck(css);
|
|
246
|
+
} catch (e) {
|
|
247
|
+
console.error('❌ theme-check failed:', e.message);
|
|
248
|
+
process.exit(1);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
printThemeCheckReport(result, { verbose });
|
|
252
|
+
|
|
253
|
+
if (!result.pass) process.exit(1);
|
|
254
|
+
}
|
|
255
|
+
|
|
162
256
|
main().catch(err => {
|
|
163
257
|
console.error('Command failed:', err);
|
|
164
258
|
process.exit(1);
|
package/engine.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@finggujadhav/compiler",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.6",
|
|
4
4
|
"description": "The FingguFlux static analysis and selector hardening engine.",
|
|
5
5
|
"main": "engine.js",
|
|
6
6
|
"bin": {
|
|
@@ -9,9 +9,15 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"engine.js",
|
|
11
11
|
"scanner.js",
|
|
12
|
-
"cli.js"
|
|
12
|
+
"cli.js",
|
|
13
|
+
"a11y.js",
|
|
14
|
+
"theme-check.js"
|
|
13
15
|
],
|
|
14
16
|
"type": "module",
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "node --test test/theme-check.test.js",
|
|
19
|
+
"test:all": "node --test test/**/*.test.js"
|
|
20
|
+
},
|
|
15
21
|
"engines": {
|
|
16
22
|
"node": ">=16.0.0"
|
|
17
23
|
},
|
package/theme-check.js
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FingguFlux Theme-Check Engine
|
|
3
|
+
* v0.9.6 – Theme Engine Stabilization & Contract Freeze
|
|
4
|
+
*
|
|
5
|
+
* ZERO RUNTIME OVERHEAD: This module is a pure build-time static analyser.
|
|
6
|
+
* It reads CSS files and the TOKENS_REGISTRY.json contract, then emits
|
|
7
|
+
* structured diagnostics. It produces NO code that ships to the browser.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from 'fs';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
|
|
13
|
+
// ─── ANSI colour helpers ──────────────────────────────────────────────────────
|
|
14
|
+
const C = {
|
|
15
|
+
reset: '\x1b[0m',
|
|
16
|
+
bold: '\x1b[1m',
|
|
17
|
+
red: '\x1b[31m',
|
|
18
|
+
green: '\x1b[32m',
|
|
19
|
+
yellow: '\x1b[33m',
|
|
20
|
+
cyan: '\x1b[36m',
|
|
21
|
+
grey: '\x1b[90m',
|
|
22
|
+
};
|
|
23
|
+
const clr = (code, text) => `${code}${text}${C.reset}`;
|
|
24
|
+
|
|
25
|
+
// ─── Token extraction ─────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Parse every `--ff-*` custom-property *definition* from a CSS string.
|
|
29
|
+
* Only captures tokens that appear on the LEFT-hand side of a colon,
|
|
30
|
+
* i.e. actual definitions, not var() references.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} css
|
|
33
|
+
* @returns {Set<string>}
|
|
34
|
+
*/
|
|
35
|
+
export function extractDefinedTokens(css) {
|
|
36
|
+
const defined = new Set();
|
|
37
|
+
// Match: --ff-something-here : <value>
|
|
38
|
+
const re = /(--ff-[\w-]+)\s*:/g;
|
|
39
|
+
let m;
|
|
40
|
+
while ((m = re.exec(css)) !== null) {
|
|
41
|
+
// Skip tokens that appear inside var(...) — they are references, not definitions
|
|
42
|
+
const before = css.slice(0, m.index);
|
|
43
|
+
const lastVar = before.lastIndexOf('var(');
|
|
44
|
+
const lastClose = before.lastIndexOf(')');
|
|
45
|
+
if (lastVar !== -1 && lastVar > lastClose) continue;
|
|
46
|
+
defined.add(m[1]);
|
|
47
|
+
}
|
|
48
|
+
return defined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build a context map: token → array of CSS at-rule / selector scopes
|
|
54
|
+
* where the token is defined.
|
|
55
|
+
*
|
|
56
|
+
* Scopes we care about:
|
|
57
|
+
* 'light' — :root or [data-ff-theme="light"]
|
|
58
|
+
* 'dark' — [data-ff-theme="dark"]
|
|
59
|
+
* 'system' — @media (prefers-color-scheme: dark)
|
|
60
|
+
* 'global' — anything else (e.g. root-level without a theme wrapper)
|
|
61
|
+
*
|
|
62
|
+
* @param {string} css
|
|
63
|
+
* @returns {Map<string, Set<string>>} token → scopes
|
|
64
|
+
*/
|
|
65
|
+
export function extractTokensByScope(css) {
|
|
66
|
+
/** @type {Map<string, Set<string>>} */
|
|
67
|
+
const map = new Map();
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* We parse character-by-character to maintain an accurate scope stack.
|
|
71
|
+
* scopeStack[i] holds the classified scope name for depth i+1.
|
|
72
|
+
*
|
|
73
|
+
* Scopes:
|
|
74
|
+
* 'light' — :root or [data-ff-theme="light"] (or comma-list containing either)
|
|
75
|
+
* 'dark' — [data-ff-theme="dark"]
|
|
76
|
+
* 'system' — @media (prefers-color-scheme: dark) { ... }
|
|
77
|
+
* 'global' — anything else
|
|
78
|
+
*
|
|
79
|
+
* Nesting rule: 'system' dominates — any inner selector inside a system
|
|
80
|
+
* @media block stays classified as 'system', even if it contains ':root'.
|
|
81
|
+
*/
|
|
82
|
+
const scopeStack = []; // one entry per open brace depth
|
|
83
|
+
let depth = 0;
|
|
84
|
+
|
|
85
|
+
let pending = ''; // text accumulating before the next '{'
|
|
86
|
+
let inString = false;
|
|
87
|
+
let stringChar = '';
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @param {string} sel - the raw selector / at-rule text
|
|
91
|
+
* @param {string} parentScope - the scope of the enclosing block ('global', 'system', etc.)
|
|
92
|
+
*/
|
|
93
|
+
const classifySelector = (sel, parentScope) => {
|
|
94
|
+
const s = sel.toLowerCase().trim();
|
|
95
|
+
if (!s) return parentScope;
|
|
96
|
+
|
|
97
|
+
// @media rule itself
|
|
98
|
+
if (s.includes('@media')) {
|
|
99
|
+
if (s.includes('prefers-color-scheme') && s.includes('dark')) {
|
|
100
|
+
return 'system';
|
|
101
|
+
}
|
|
102
|
+
return 'global';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// If we're already inside a system (dark media) block, any inner rule is system too
|
|
106
|
+
if (parentScope === 'system') return 'system';
|
|
107
|
+
|
|
108
|
+
// Explicit dark theme selector
|
|
109
|
+
if (s.includes('[data-ff-theme="dark"]') || s.includes("[data-ff-theme='dark']")) {
|
|
110
|
+
return 'dark';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Light: :root or comma-list like ":root, [data-ff-theme='light']"
|
|
114
|
+
if (
|
|
115
|
+
s.includes(':root') ||
|
|
116
|
+
s.includes('[data-ff-theme="light"]') ||
|
|
117
|
+
s.includes("[data-ff-theme='light']")
|
|
118
|
+
) {
|
|
119
|
+
return 'light';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return 'global';
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const addToken = (tok, scope) => {
|
|
126
|
+
if (!map.has(tok)) map.set(tok, new Set());
|
|
127
|
+
map.get(tok).add(scope);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const extractTokensFromText = (text, scope) => {
|
|
131
|
+
// Match only actual custom-property definitions:
|
|
132
|
+
// --ff-name : <value>
|
|
133
|
+
// We use a regex that matches --ff-name followed by whitespace and colon,
|
|
134
|
+
// then verify it is NOT inside a var() call.
|
|
135
|
+
const re = /(--ff-[\w-]+)\s*:/g;
|
|
136
|
+
let m;
|
|
137
|
+
while ((m = re.exec(text)) !== null) {
|
|
138
|
+
// Exclude matches that are inside var(...) — check what precedes the token
|
|
139
|
+
const before = text.slice(0, m.index);
|
|
140
|
+
const lastVar = before.lastIndexOf('var(');
|
|
141
|
+
const lastClose = before.lastIndexOf(')');
|
|
142
|
+
if (lastVar !== -1 && lastVar > lastClose) {
|
|
143
|
+
continue; // inside a var() call
|
|
144
|
+
}
|
|
145
|
+
addToken(m[1], scope);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// Determine effective scope from the stack (outermost non-global wins for system)
|
|
150
|
+
const currentScope = () => {
|
|
151
|
+
// 'system' takes absolute priority (outermost) when present anywhere in stack
|
|
152
|
+
if (scopeStack.includes('system')) return 'system';
|
|
153
|
+
// Otherwise find the innermost meaningful scope
|
|
154
|
+
for (let i = scopeStack.length - 1; i >= 0; i--) {
|
|
155
|
+
if (scopeStack[i] !== 'global') return scopeStack[i];
|
|
156
|
+
}
|
|
157
|
+
return 'global';
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const parentScope = () => {
|
|
161
|
+
if (scopeStack.length === 0) return 'global';
|
|
162
|
+
// Same logic as currentScope but without the new entry
|
|
163
|
+
if (scopeStack.includes('system')) return 'system';
|
|
164
|
+
for (let i = scopeStack.length - 1; i >= 0; i--) {
|
|
165
|
+
if (scopeStack[i] !== 'global') return scopeStack[i];
|
|
166
|
+
}
|
|
167
|
+
return 'global';
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
for (let i = 0; i < css.length; i++) {
|
|
171
|
+
const ch = css[i];
|
|
172
|
+
|
|
173
|
+
// Basic string tracking (to avoid misinterpreting { } inside strings/comments)
|
|
174
|
+
if (!inString && (ch === '"' || ch === "'")) {
|
|
175
|
+
inString = true;
|
|
176
|
+
stringChar = ch;
|
|
177
|
+
pending += ch;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (inString && ch === stringChar && css[i - 1] !== '\\') {
|
|
181
|
+
inString = false;
|
|
182
|
+
pending += ch;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (inString) {
|
|
186
|
+
pending += ch;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (ch === '{') {
|
|
191
|
+
const scope = classifySelector(pending, parentScope());
|
|
192
|
+
scopeStack.push(scope);
|
|
193
|
+
depth++;
|
|
194
|
+
pending = '';
|
|
195
|
+
} else if (ch === '}') {
|
|
196
|
+
// Flush any pending text before closing the block
|
|
197
|
+
if (pending.trim()) {
|
|
198
|
+
extractTokensFromText(pending, currentScope());
|
|
199
|
+
}
|
|
200
|
+
scopeStack.pop();
|
|
201
|
+
depth--;
|
|
202
|
+
pending = '';
|
|
203
|
+
} else {
|
|
204
|
+
pending += ch;
|
|
205
|
+
// Flush at semicolons to keep memory usage bounded
|
|
206
|
+
if (ch === ';' && depth > 0) {
|
|
207
|
+
extractTokensFromText(pending, currentScope());
|
|
208
|
+
pending = '';
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return map;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
// ─── Registry loader ──────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Locate and parse TOKENS_REGISTRY.json.
|
|
222
|
+
* Searches: CWD, packages/core, ../../packages/core (monorepo root).
|
|
223
|
+
*
|
|
224
|
+
* @returns {{ registry: object, registryPath: string }}
|
|
225
|
+
*/
|
|
226
|
+
export function loadRegistry() {
|
|
227
|
+
const candidates = [
|
|
228
|
+
path.resolve('./packages/core/TOKENS_REGISTRY.json'),
|
|
229
|
+
path.resolve('../../packages/core/TOKENS_REGISTRY.json'),
|
|
230
|
+
path.resolve('./node_modules/@finggujadhav/core/TOKENS_REGISTRY.json'),
|
|
231
|
+
];
|
|
232
|
+
|
|
233
|
+
for (const p of candidates) {
|
|
234
|
+
if (fs.existsSync(p)) {
|
|
235
|
+
try {
|
|
236
|
+
return { registry: JSON.parse(fs.readFileSync(p, 'utf8')), registryPath: p };
|
|
237
|
+
} catch (e) {
|
|
238
|
+
throw new Error(`TOKENS_REGISTRY.json at ${p} is malformed JSON: ${e.message}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
throw new Error(
|
|
244
|
+
'TOKENS_REGISTRY.json not found. ' +
|
|
245
|
+
'Expected at packages/core/TOKENS_REGISTRY.json. ' +
|
|
246
|
+
'Run the FingguFlux bootstrap task to regenerate it.'
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Returns the flat set of ALL token names from every category in the registry.
|
|
252
|
+
*
|
|
253
|
+
* @param {object} registry
|
|
254
|
+
* @returns {Set<string>}
|
|
255
|
+
*/
|
|
256
|
+
export function registryAllTokens(registry) {
|
|
257
|
+
const all = new Set();
|
|
258
|
+
for (const cat of Object.values(registry.categories)) {
|
|
259
|
+
for (const tok of cat.tokens) all.add(tok);
|
|
260
|
+
}
|
|
261
|
+
return all;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ─── Core checks ─────────────────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* REMOVAL DETECTION
|
|
268
|
+
* Finds tokens present in the registry but ABSENT from the actual CSS.
|
|
269
|
+
*
|
|
270
|
+
* @param {Set<string>} registryTokens
|
|
271
|
+
* @param {Set<string>} definedTokens
|
|
272
|
+
* @returns {string[]} removed token names
|
|
273
|
+
*/
|
|
274
|
+
export function detectRemovals(registryTokens, definedTokens) {
|
|
275
|
+
const removed = [];
|
|
276
|
+
for (const tok of registryTokens) {
|
|
277
|
+
if (!definedTokens.has(tok)) removed.push(tok);
|
|
278
|
+
}
|
|
279
|
+
return removed.sort();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* RENAME DETECTION
|
|
284
|
+
* Checks the registry's `renames` array for tokens that have been moved
|
|
285
|
+
* but whose *old* name still appears as a definition in the CSS.
|
|
286
|
+
*
|
|
287
|
+
* Registry renames format:
|
|
288
|
+
* { "from": "--ff-old-name", "to": "--ff-new-name", "since": "0.9.6" }
|
|
289
|
+
*
|
|
290
|
+
* @param {Array<{from:string,to:string,since:string}>} renames
|
|
291
|
+
* @param {Set<string>} definedTokens
|
|
292
|
+
* @returns {Array<{from:string,to:string,since:string}>}
|
|
293
|
+
*/
|
|
294
|
+
export function detectRenames(renames, definedTokens) {
|
|
295
|
+
return renames.filter(r => definedTokens.has(r.from));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* ADDITION DETECTION
|
|
300
|
+
* Tokens defined in CSS that are NOT present in the registry (undocumented additions).
|
|
301
|
+
*
|
|
302
|
+
* @param {Set<string>} registryTokens
|
|
303
|
+
* @param {Set<string>} definedTokens
|
|
304
|
+
* @returns {string[]}
|
|
305
|
+
*/
|
|
306
|
+
export function detectAdditions(registryTokens, definedTokens) {
|
|
307
|
+
const added = [];
|
|
308
|
+
for (const tok of definedTokens) {
|
|
309
|
+
if (!registryTokens.has(tok)) added.push(tok);
|
|
310
|
+
}
|
|
311
|
+
return added.sort();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* THEME COMPLETENESS
|
|
316
|
+
* Validates that every token listed under registry.theming[theme].required
|
|
317
|
+
* is defined (has a definition) within the correct CSS scope.
|
|
318
|
+
*
|
|
319
|
+
* @param {object} theming registry.theming
|
|
320
|
+
* @param {Map<string, Set<string>>} tokensByScope
|
|
321
|
+
* @returns {Object.<string, string[]>} theme → missing tokens
|
|
322
|
+
*/
|
|
323
|
+
export function validateThemeCompleteness(theming, tokensByScope) {
|
|
324
|
+
const results = {};
|
|
325
|
+
|
|
326
|
+
for (const [theme, config] of Object.entries(theming)) {
|
|
327
|
+
const missing = [];
|
|
328
|
+
const required = config.required || [];
|
|
329
|
+
|
|
330
|
+
for (const tok of required) {
|
|
331
|
+
const scopes = tokensByScope.get(tok);
|
|
332
|
+
if (!scopes) {
|
|
333
|
+
missing.push(tok);
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
let satisfied = false;
|
|
338
|
+
|
|
339
|
+
if (theme === 'light') {
|
|
340
|
+
// Light theme: token must be present in 'light' scope (which includes :root)
|
|
341
|
+
satisfied = scopes.has('light');
|
|
342
|
+
} else {
|
|
343
|
+
// Dark / system: token MUST be explicitly overridden in that specific scope.
|
|
344
|
+
// A token that only exists in 'light' scope is NOT considered satisfied
|
|
345
|
+
// for dark/system, because that is precisely what theme-check is enforcing —
|
|
346
|
+
// that the author hasn't forgotten to provide the dark/system overrides.
|
|
347
|
+
satisfied = scopes.has(theme);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (!satisfied) missing.push(tok);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
results[theme] = missing;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return results;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ─── Main runner ──────────────────────────────────────────────────────────────
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Run the full theme-check suite against a CSS string.
|
|
363
|
+
*
|
|
364
|
+
* @param {string} css Combined tokens CSS content
|
|
365
|
+
* @param {object} [registryOverride] Inject registry directly (used in tests)
|
|
366
|
+
* @returns {{
|
|
367
|
+
* pass: boolean,
|
|
368
|
+
* removals: string[],
|
|
369
|
+
* renames: Array,
|
|
370
|
+
* additions: string[],
|
|
371
|
+
* themeGaps: Object,
|
|
372
|
+
* warnings: string[]
|
|
373
|
+
* }}
|
|
374
|
+
*/
|
|
375
|
+
export function runThemeCheck(css, registryOverride) {
|
|
376
|
+
const { registry } = registryOverride
|
|
377
|
+
? { registry: registryOverride }
|
|
378
|
+
: loadRegistry();
|
|
379
|
+
|
|
380
|
+
const definedTokens = extractDefinedTokens(css);
|
|
381
|
+
const tokensByScope = extractTokensByScope(css);
|
|
382
|
+
const registryTokens = registryAllTokens(registry);
|
|
383
|
+
|
|
384
|
+
const removals = detectRemovals(registryTokens, definedTokens);
|
|
385
|
+
const renames = detectRenames(registry.renames || [], definedTokens);
|
|
386
|
+
const additions = detectAdditions(registryTokens, definedTokens);
|
|
387
|
+
const themeGaps = validateThemeCompleteness(registry.theming || {}, tokensByScope);
|
|
388
|
+
|
|
389
|
+
const warnings = [];
|
|
390
|
+
if (additions.length > 0) {
|
|
391
|
+
warnings.push(
|
|
392
|
+
`${additions.length} token(s) defined in CSS but absent from TOKENS_REGISTRY.json. ` +
|
|
393
|
+
'Add them to the registry to freeze the contract.'
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const pass =
|
|
398
|
+
removals.length === 0 &&
|
|
399
|
+
renames.length === 0 &&
|
|
400
|
+
Object.values(themeGaps).every(arr => arr.length === 0);
|
|
401
|
+
|
|
402
|
+
return { pass, removals, renames, additions, themeGaps, warnings };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ─── Pretty-printer ───────────────────────────────────────────────────────────
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Print a human-readable theme-check report to stdout.
|
|
409
|
+
*
|
|
410
|
+
* @param {{ pass: boolean, removals: string[], renames: Array, additions: string[], themeGaps: Object, warnings: string[] }} result
|
|
411
|
+
* @param {{ verbose?: boolean }} [opts]
|
|
412
|
+
*/
|
|
413
|
+
export function printThemeCheckReport(result, opts = {}) {
|
|
414
|
+
const { pass, removals, renames, additions, themeGaps, warnings } = result;
|
|
415
|
+
const verbose = opts.verbose || false;
|
|
416
|
+
|
|
417
|
+
console.log('');
|
|
418
|
+
console.log(clr(C.bold + C.cyan, '🎨 FingguFlux Theme-Check – Contract Freeze Audit'));
|
|
419
|
+
console.log(clr(C.grey, '─'.repeat(54)));
|
|
420
|
+
|
|
421
|
+
// ── Removals ──────────────────────────────────────────────
|
|
422
|
+
if (removals.length === 0) {
|
|
423
|
+
console.log(clr(C.green, '✅ No token removals detected.'));
|
|
424
|
+
} else {
|
|
425
|
+
console.log(clr(C.red, `❌ ${removals.length} token(s) REMOVED (breaking change!):`));
|
|
426
|
+
removals.forEach(t => console.log(clr(C.red, ` – ${t}`)));
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ── Renames ───────────────────────────────────────────────
|
|
430
|
+
if (renames.length === 0) {
|
|
431
|
+
console.log(clr(C.green, '✅ No stale renamed tokens in CSS.'));
|
|
432
|
+
} else {
|
|
433
|
+
console.log(clr(C.yellow, `⚠️ ${renames.length} stale rename(s) found:`));
|
|
434
|
+
renames.forEach(r =>
|
|
435
|
+
console.log(clr(C.yellow, ` ${r.from} → ${r.to} (since v${r.since})`))
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ── Unregistered additions ────────────────────────────────
|
|
440
|
+
if (additions.length === 0) {
|
|
441
|
+
console.log(clr(C.green, '✅ All defined tokens are registered.'));
|
|
442
|
+
} else if (verbose) {
|
|
443
|
+
console.log(clr(C.yellow, `⚠️ ${additions.length} unregistered token(s):`));
|
|
444
|
+
additions.forEach(t => console.log(clr(C.grey, ` + ${t}`)));
|
|
445
|
+
} else {
|
|
446
|
+
console.log(clr(C.yellow, `⚠️ ${additions.length} unregistered token(s). Use --verbose to list.`));
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ── Theme completeness ────────────────────────────────────
|
|
450
|
+
console.log('');
|
|
451
|
+
console.log(clr(C.bold, ' Theme Completeness'));
|
|
452
|
+
console.log(clr(C.grey, ' ' + '─'.repeat(40)));
|
|
453
|
+
|
|
454
|
+
for (const [theme, missing] of Object.entries(themeGaps)) {
|
|
455
|
+
const icon = missing.length === 0 ? clr(C.green, '✅') : clr(C.red, '❌');
|
|
456
|
+
const label = theme.padEnd(8);
|
|
457
|
+
if (missing.length === 0) {
|
|
458
|
+
console.log(` ${icon} ${label} — all required tokens present`);
|
|
459
|
+
} else {
|
|
460
|
+
console.log(` ${icon} ${label} — ${missing.length} missing:`);
|
|
461
|
+
missing.forEach(t => console.log(clr(C.red, ` – ${t}`)));
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ── Warnings ──────────────────────────────────────────────
|
|
466
|
+
if (warnings.length > 0) {
|
|
467
|
+
console.log('');
|
|
468
|
+
warnings.forEach(w => console.log(clr(C.yellow, ` ⚡ ${w}`)));
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ── Final verdict ─────────────────────────────────────────
|
|
472
|
+
console.log('');
|
|
473
|
+
console.log(clr(C.grey, '─'.repeat(54)));
|
|
474
|
+
if (pass) {
|
|
475
|
+
console.log(clr(C.bold + C.green, '✨ Theme contract STABLE. No issues found.'));
|
|
476
|
+
} else {
|
|
477
|
+
console.log(clr(C.bold + C.red, '💥 Theme contract UNSTABLE. Fix the issues above.'));
|
|
478
|
+
}
|
|
479
|
+
console.log('');
|
|
480
|
+
}
|