@stratixlabs/core 1.7.1

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.
@@ -0,0 +1,77 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ function isLeaf(node) {
5
+ if (!node || typeof node !== 'object' || Array.isArray(node)) return false;
6
+ const keys = Object.keys(node);
7
+ return keys.includes('value') && keys.includes('type') && keys.includes('originalVariable');
8
+ }
9
+
10
+ function buildType(node, indentLevel) {
11
+ if (isLeaf(node)) {
12
+ return 'TokenLeaf';
13
+ }
14
+ if (!node || typeof node !== 'object') {
15
+ return 'TokenLeaf';
16
+ }
17
+ const keys = Object.keys(node);
18
+ const indent = ' '.repeat(indentLevel);
19
+ const innerIndent = ' '.repeat(indentLevel + 1);
20
+ const lines = ['{'];
21
+ for (const key of keys) {
22
+ const childType = buildType(node[key], indentLevel + 1);
23
+ lines.push(`${innerIndent}"${key}": ${childType};`);
24
+ }
25
+ lines.push(`${indent}}`);
26
+ return lines.join('\n');
27
+ }
28
+
29
+ function main() {
30
+ const cwd = process.cwd();
31
+ const tokensPath = path.join(cwd, 'tokens.json');
32
+
33
+ if (!fs.existsSync(tokensPath)) {
34
+ console.error('❌ tokens.json not found. Run "npm run build:tokens" first.');
35
+ process.exit(1);
36
+ }
37
+
38
+ const raw = fs.readFileSync(tokensPath, 'utf8');
39
+ const json = JSON.parse(raw);
40
+
41
+ const graphType = buildType(json, 0);
42
+
43
+ const lines = [];
44
+ lines.push("export type TokenType =");
45
+ lines.push(" | 'color'");
46
+ lines.push(" | 'spacing'");
47
+ lines.push(" | 'typography'");
48
+ lines.push(" | 'radius'");
49
+ lines.push(" | 'border'");
50
+ lines.push(" | 'elevation'");
51
+ lines.push(" | 'motion'");
52
+ lines.push(" | 'opacity'");
53
+ lines.push(" | 'breakpoint'");
54
+ lines.push(" | 'semantic'");
55
+ lines.push(" | 'unknown';");
56
+ lines.push('');
57
+ lines.push('export interface TokenLeaf {');
58
+ lines.push(' value: string;');
59
+ lines.push(' type: TokenType;');
60
+ lines.push(' originalVariable: string;');
61
+ lines.push('}');
62
+ lines.push('');
63
+ lines.push('export type Tokens = ' + graphType + ';');
64
+ lines.push('');
65
+ lines.push('declare const tokens: Tokens;');
66
+ lines.push('');
67
+ lines.push('export default tokens;');
68
+ lines.push('');
69
+
70
+ const outputPath = path.join(cwd, 'substrata.d.ts');
71
+ fs.writeFileSync(outputPath, lines.join('\n'));
72
+
73
+ console.log(`✅ Generated ${outputPath} from ${tokensPath}`);
74
+ }
75
+
76
+ main();
77
+
@@ -0,0 +1,138 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ function ensurePathWithinRoot(resolvedPath, rootDir) {
5
+ const resolved = path.resolve(resolvedPath);
6
+ const root = path.resolve(rootDir);
7
+ const normalize = s => s.replace(/\\/g, '/').toLowerCase();
8
+ const resolvedNorm = normalize(resolved);
9
+ const rootNorm = normalize(root);
10
+ if (resolvedNorm !== rootNorm && !resolvedNorm.startsWith(rootNorm + '/')) {
11
+ throw new Error(`Configured path ${resolved} is outside of project root ${root}`);
12
+ }
13
+ }
14
+
15
+ function setDeep(obj, pathParts, value) {
16
+ let current = obj;
17
+ for (let i = 0; i < pathParts.length; i++) {
18
+ const part = pathParts[i];
19
+ if (i === pathParts.length - 1) {
20
+ current[part] = value;
21
+ } else {
22
+ current[part] = current[part] || {};
23
+ current = current[part];
24
+ }
25
+ }
26
+ }
27
+
28
+ function inferTypeFromFilename(filename) {
29
+ const name = filename.toLowerCase();
30
+ if (name.includes('color')) return 'color';
31
+ if (name.includes('spacing')) return 'spacing';
32
+ if (name.includes('typography')) return 'typography';
33
+ if (name.includes('radius')) return 'radius';
34
+ if (name.includes('border')) return 'border';
35
+ if (name.includes('elevation') || name.includes('shadow')) return 'elevation';
36
+ if (name.includes('motion')) return 'motion';
37
+ if (name.includes('opacity')) return 'opacity';
38
+ if (name.includes('breakpoint')) return 'breakpoint';
39
+ if (name.includes('semantic')) return 'semantic';
40
+ return 'unknown';
41
+ }
42
+
43
+ function parseCssVariables(cssContent, fileType) {
44
+ const tokens = {};
45
+ const lines = cssContent.split('\n');
46
+
47
+ for (const line of lines) {
48
+ const match = line.match(/--([\w-]+):\s*([^;]+);/);
49
+ if (match) {
50
+ const variableName = match[1]; // e.g. "color-neutral-100"
51
+ const value = match[2].trim(); // e.g. "#ffffff"
52
+
53
+ const parts = variableName.split('-');
54
+ setDeep(tokens, parts, {
55
+ value: value,
56
+ type: fileType,
57
+ originalVariable: `--${variableName}`
58
+ });
59
+ }
60
+ }
61
+ return tokens;
62
+ }
63
+
64
+ function deepMerge(target, source) {
65
+ for (const key of Object.keys(source)) {
66
+ const srcVal = source[key];
67
+ const tgtVal = target[key];
68
+ if (srcVal && typeof srcVal === 'object' && !Array.isArray(srcVal)) {
69
+ if (!tgtVal || typeof tgtVal !== 'object' || Array.isArray(tgtVal)) {
70
+ target[key] = {};
71
+ }
72
+ deepMerge(target[key], srcVal);
73
+ } else {
74
+ target[key] = srcVal;
75
+ }
76
+ }
77
+ return target;
78
+ }
79
+
80
+ async function generateTokens() {
81
+ try {
82
+ let tokensDir = path.join(__dirname, '../src/tokens');
83
+ let outputFile = path.join(__dirname, '../tokens.json');
84
+
85
+ const rootDir = process.cwd();
86
+ const configPath = path.join(rootDir, 'substrata.config.js');
87
+ if (fs.existsSync(configPath)) {
88
+ console.log('Found substrata.config.js');
89
+ const config = require(configPath);
90
+ if (config.tokens) {
91
+ const resolvedTokensDir = path.resolve(rootDir, config.tokens);
92
+ ensurePathWithinRoot(resolvedTokensDir, rootDir);
93
+ tokensDir = resolvedTokensDir;
94
+ }
95
+ if (config.output) {
96
+ const resolvedOutputFile = path.resolve(rootDir, config.output);
97
+ ensurePathWithinRoot(resolvedOutputFile, rootDir);
98
+ outputFile = resolvedOutputFile;
99
+ }
100
+ }
101
+
102
+ if (!fs.existsSync(tokensDir)) {
103
+ console.error(`❌ Tokens directory not found: ${tokensDir}`);
104
+ process.exit(1);
105
+ }
106
+
107
+ const files = fs.readdirSync(tokensDir);
108
+ const allTokens = {};
109
+
110
+ console.log(`Scanning ${tokensDir}...`);
111
+
112
+ for (const file of files) {
113
+ if (file.endsWith('.css')) {
114
+ const content = fs.readFileSync(path.join(tokensDir, file), 'utf8');
115
+ const fileType = inferTypeFromFilename(file);
116
+ const fileTokens = parseCssVariables(content, fileType);
117
+
118
+ deepMerge(allTokens, fileTokens);
119
+ }
120
+ }
121
+
122
+ const outputContent = JSON.stringify(allTokens, null, 2);
123
+ // Ensure output directory exists
124
+ const outputDir = path.dirname(outputFile);
125
+ if (!fs.existsSync(outputDir)) {
126
+ fs.mkdirSync(outputDir, { recursive: true });
127
+ }
128
+
129
+ fs.writeFileSync(outputFile, outputContent);
130
+
131
+ console.log(`✅ Generated tokens.json at ${outputFile}`);
132
+ } catch (error) {
133
+ console.error('❌ Error generating tokens:', error);
134
+ process.exit(1);
135
+ }
136
+ }
137
+
138
+ generateTokens();
@@ -0,0 +1,186 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const TARGET_DIRS = [
5
+ path.join(__dirname, '../src/components'),
6
+ path.join(__dirname, '../src/consumption'),
7
+ ];
8
+
9
+ const DECL_REGEX = /^\s*([a-zA-Z-]+)\s*:\s*([^;{]+);/;
10
+ const VAR_REGEX = /var\(\s*(--[\w-]+)/g;
11
+
12
+ const COLOR_PROPS = new Set([
13
+ 'color',
14
+ 'background',
15
+ 'background-color',
16
+ 'border-color',
17
+ 'border-top-color',
18
+ 'border-right-color',
19
+ 'border-bottom-color',
20
+ 'border-left-color',
21
+ 'outline-color',
22
+ 'fill',
23
+ 'stroke',
24
+ ]);
25
+
26
+ const SPACING_PROPS = new Set([
27
+ 'margin',
28
+ 'margin-top',
29
+ 'margin-right',
30
+ 'margin-bottom',
31
+ 'margin-left',
32
+ 'padding',
33
+ 'padding-top',
34
+ 'padding-right',
35
+ 'padding-bottom',
36
+ 'padding-left',
37
+ 'gap',
38
+ 'row-gap',
39
+ 'column-gap',
40
+ 'top',
41
+ 'right',
42
+ 'bottom',
43
+ 'left',
44
+ 'inset',
45
+ ]);
46
+
47
+ const TYPOGRAPHY_PROPS = new Set([
48
+ 'font-size',
49
+ 'line-height',
50
+ 'font-weight',
51
+ 'letter-spacing',
52
+ 'font-family',
53
+ ]);
54
+
55
+ const RADIUS_PROPS = new Set([
56
+ 'border-radius',
57
+ 'border-top-left-radius',
58
+ 'border-top-right-radius',
59
+ 'border-bottom-right-radius',
60
+ 'border-bottom-left-radius',
61
+ ]);
62
+
63
+ const ELEVATION_PROPS = new Set(['box-shadow']);
64
+ const OPACITY_PROPS = new Set(['opacity']);
65
+ const MOTION_PROPS = new Set(['transition-duration', 'animation-duration']);
66
+
67
+ const CODE_FILE_REGEX = /\.(css|scss|js|jsx|ts|tsx)$/;
68
+
69
+ function isLeaf(node) {
70
+ if (!node || typeof node !== 'object' || Array.isArray(node)) return false;
71
+ const keys = Object.keys(node);
72
+ return keys.includes('value') && keys.includes('type') && keys.includes('originalVariable');
73
+ }
74
+
75
+ function collectTokenTypes(obj, map) {
76
+ if (isLeaf(obj)) {
77
+ map[obj.originalVariable] = obj.type;
78
+ return;
79
+ }
80
+ if (!obj || typeof obj !== 'object') return;
81
+ for (const key of Object.keys(obj)) {
82
+ collectTokenTypes(obj[key], map);
83
+ }
84
+ }
85
+
86
+ function loadTokenTypeMap() {
87
+ const tokensPath = path.join(process.cwd(), 'tokens.json');
88
+ if (!fs.existsSync(tokensPath)) {
89
+ console.error('❌ tokens.json not found. Run "npm run build:tokens" first.');
90
+ process.exit(1);
91
+ }
92
+ const raw = fs.readFileSync(tokensPath, 'utf8');
93
+ const json = JSON.parse(raw);
94
+ const map = {};
95
+ collectTokenTypes(json, map);
96
+ return map;
97
+ }
98
+
99
+ function getPropertyCategory(prop) {
100
+ if (COLOR_PROPS.has(prop)) return 'color';
101
+ if (SPACING_PROPS.has(prop)) return 'spacing';
102
+ if (TYPOGRAPHY_PROPS.has(prop)) return 'typography';
103
+ if (RADIUS_PROPS.has(prop)) return 'radius';
104
+ if (ELEVATION_PROPS.has(prop)) return 'elevation';
105
+ if (OPACITY_PROPS.has(prop)) return 'opacity';
106
+ if (MOTION_PROPS.has(prop)) return 'motion';
107
+ return null;
108
+ }
109
+
110
+ function isTypeAllowedForCategory(type, category) {
111
+ if (type === 'unknown') return true;
112
+ if (category === 'color') {
113
+ return type === 'color' || type === 'semantic';
114
+ }
115
+ if (category === 'spacing') {
116
+ return type === 'spacing';
117
+ }
118
+ if (category === 'typography') {
119
+ return type === 'typography';
120
+ }
121
+ if (category === 'radius') {
122
+ return type === 'radius';
123
+ }
124
+ if (category === 'elevation') {
125
+ return type === 'elevation';
126
+ }
127
+ if (category === 'opacity') {
128
+ return type === 'opacity';
129
+ }
130
+ if (category === 'motion') {
131
+ return type === 'motion';
132
+ }
133
+ return true;
134
+ }
135
+
136
+ function scanFile(filePath, tokenTypes, errors) {
137
+ const content = fs.readFileSync(filePath, 'utf8');
138
+ const lines = content.split('\n');
139
+ lines.forEach((line, idx) => {
140
+ const match = DECL_REGEX.exec(line);
141
+ if (!match) return;
142
+ const prop = match[1].toLowerCase();
143
+ const category = getPropertyCategory(prop);
144
+ if (!category) return;
145
+ let m;
146
+ VAR_REGEX.lastIndex = 0;
147
+ while ((m = VAR_REGEX.exec(line)) !== null) {
148
+ const variable = m[1];
149
+ const type = tokenTypes[variable];
150
+ const location = `${filePath}:${idx + 1}`;
151
+ if (!type) continue;
152
+ if (!isTypeAllowedForCategory(type, category)) {
153
+ errors.push(
154
+ `${location} uses token ${variable} of type ${type} in property ${prop} (category ${category})`
155
+ );
156
+ }
157
+ }
158
+ });
159
+ }
160
+
161
+ function main() {
162
+ const tokenTypes = loadTokenTypeMap();
163
+ const errors = [];
164
+
165
+ for (const dir of TARGET_DIRS) {
166
+ if (!fs.existsSync(dir)) continue;
167
+ const entries = fs.readdirSync(dir);
168
+ for (const entry of entries) {
169
+ const full = path.join(dir, entry);
170
+ const stat = fs.statSync(full);
171
+ if (stat.isDirectory()) continue;
172
+ if (!CODE_FILE_REGEX.test(entry)) continue;
173
+ scanFile(full, tokenTypes, errors);
174
+ }
175
+ }
176
+
177
+ if (errors.length) {
178
+ console.error('❌ Token-aware code lint failed:');
179
+ for (const e of errors) console.error(' -', e);
180
+ process.exit(1);
181
+ } else {
182
+ console.log('✅ Token-aware code lint passed');
183
+ }
184
+ }
185
+
186
+ main();
@@ -0,0 +1,49 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const TARGET_DIRS = [
5
+ path.join(__dirname, '../src/components'),
6
+ path.join(__dirname, '../src/consumption'),
7
+ ];
8
+
9
+ const HEX_REGEX = /#[0-9a-fA-F]{3,6}/;
10
+
11
+ function scanFile(filePath, errors) {
12
+ const content = fs.readFileSync(filePath, 'utf8');
13
+ const contentWithoutBlockComments = content.replace(/\/\*[\s\S]*?\*\//g, match =>
14
+ match.replace(/[^\n]/g, ' ')
15
+ );
16
+ const lines = contentWithoutBlockComments.split('\n');
17
+ lines.forEach((line, idx) => {
18
+ const codePart = line.split('//')[0];
19
+ if (HEX_REGEX.test(codePart) && !codePart.includes('var(')) {
20
+ errors.push(`${filePath}:${idx + 1} contains hardcoded hex color`);
21
+ }
22
+ });
23
+ }
24
+
25
+ function main() {
26
+ const errors = [];
27
+ for (const dir of TARGET_DIRS) {
28
+ if (!fs.existsSync(dir)) continue;
29
+ const entries = fs.readdirSync(dir);
30
+ for (const entry of entries) {
31
+ const full = path.join(dir, entry);
32
+ const stat = fs.statSync(full);
33
+ if (stat.isDirectory()) continue;
34
+ if (/\.(css|scss)$/.test(entry)) {
35
+ scanFile(full, errors);
36
+ }
37
+ }
38
+ }
39
+
40
+ if (errors.length) {
41
+ console.error('❌ Hardcoded color values detected:');
42
+ for (const e of errors) console.error(' -', e);
43
+ process.exit(1);
44
+ } else {
45
+ console.log('✅ No hardcoded hex color values found in target directories');
46
+ }
47
+ }
48
+
49
+ main();
@@ -0,0 +1,145 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ function ensurePathWithinRoot(resolvedPath, rootDir) {
5
+ const resolved = path.resolve(resolvedPath);
6
+ const root = path.resolve(rootDir);
7
+ const normalize = s => s.replace(/\\/g, '/').toLowerCase();
8
+ const resolvedNorm = normalize(resolved);
9
+ const rootNorm = normalize(root);
10
+ if (resolvedNorm !== rootNorm && !resolvedNorm.startsWith(rootNorm + '/')) {
11
+ throw new Error(`Configured path ${resolved} is outside of project root ${root}`);
12
+ }
13
+ }
14
+
15
+ function resolvePaths() {
16
+ let tokensDir = path.join(__dirname, '../src/tokens');
17
+ let tokensJsonPath = path.join(__dirname, '../tokens.json');
18
+
19
+ const rootDir = process.cwd();
20
+ const configPath = path.join(rootDir, 'substrata.config.js');
21
+ if (fs.existsSync(configPath)) {
22
+ const config = require(configPath);
23
+ if (config.tokens) {
24
+ const resolvedTokensDir = path.resolve(rootDir, config.tokens);
25
+ ensurePathWithinRoot(resolvedTokensDir, rootDir);
26
+ tokensDir = resolvedTokensDir;
27
+ }
28
+ if (config.output) {
29
+ const resolvedTokensJsonPath = path.resolve(rootDir, config.output);
30
+ ensurePathWithinRoot(resolvedTokensJsonPath, rootDir);
31
+ tokensJsonPath = resolvedTokensJsonPath;
32
+ }
33
+ }
34
+
35
+ return { tokensDir, tokensJsonPath };
36
+ }
37
+
38
+ function flattenTokens(obj, prefix, result) {
39
+ if (!obj || typeof obj !== 'object') {
40
+ return result;
41
+ }
42
+
43
+ const keys = Object.keys(obj);
44
+ for (const key of keys) {
45
+ const value = obj[key];
46
+ const nextPath = prefix ? `${prefix}.${key}` : key;
47
+
48
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
49
+ if (
50
+ Object.prototype.hasOwnProperty.call(value, 'value') &&
51
+ Object.prototype.hasOwnProperty.call(value, 'type') &&
52
+ Object.prototype.hasOwnProperty.call(value, 'originalVariable')
53
+ ) {
54
+ result.push({
55
+ path: nextPath,
56
+ type: value.type,
57
+ name: value.originalVariable,
58
+ value: value.value
59
+ });
60
+ } else {
61
+ flattenTokens(value, nextPath, result);
62
+ }
63
+ }
64
+ }
65
+
66
+ return result;
67
+ }
68
+
69
+ const FILE_BY_TYPE = {
70
+ breakpoint: 'breakpoints.css',
71
+ color: 'colors.css',
72
+ semantic: 'semantic-aliases.css',
73
+ spacing: 'spacing.css',
74
+ elevation: 'elevation.css',
75
+ motion: 'motion.css',
76
+ opacity: 'opacity.css',
77
+ radius: 'radius-and-borders.css',
78
+ typography: 'typography.css'
79
+ };
80
+
81
+ function buildCssFiles(tokens, tokensDir) {
82
+ const byFile = new Map();
83
+
84
+ for (const entry of tokens) {
85
+ const fileName = FILE_BY_TYPE[entry.type];
86
+ if (!fileName) continue;
87
+ if (!byFile.has(fileName)) {
88
+ byFile.set(fileName, []);
89
+ }
90
+ byFile.get(fileName).push({
91
+ name: entry.name,
92
+ value: entry.value
93
+ });
94
+ }
95
+
96
+ if (!fs.existsSync(tokensDir)) {
97
+ fs.mkdirSync(tokensDir, { recursive: true });
98
+ }
99
+
100
+ for (const [fileName, vars] of byFile) {
101
+ vars.sort((a, b) => a.name.localeCompare(b.name));
102
+
103
+ const lines = [];
104
+ lines.push(':root {');
105
+ lines.push('');
106
+ for (const v of vars) {
107
+ lines.push(` ${v.name}: ${v.value};`);
108
+ }
109
+ lines.push('');
110
+ lines.push('}');
111
+
112
+ const content = lines.join('\n');
113
+ const filePath = path.join(tokensDir, fileName);
114
+ fs.writeFileSync(filePath, content);
115
+ console.log(`✅ Wrote ${filePath} with ${vars.length} variables.`);
116
+ }
117
+ }
118
+
119
+ function main() {
120
+ const { tokensDir, tokensJsonPath } = resolvePaths();
121
+
122
+ if (!fs.existsSync(tokensJsonPath)) {
123
+ console.error(`❌ tokens.json not found at: ${tokensJsonPath}`);
124
+ process.exit(1);
125
+ }
126
+
127
+ let tokens;
128
+ try {
129
+ tokens = JSON.parse(fs.readFileSync(tokensJsonPath, 'utf8'));
130
+ } catch (error) {
131
+ console.error('❌ Could not parse tokens.json');
132
+ console.error(error);
133
+ process.exit(1);
134
+ }
135
+
136
+ const flat = flattenTokens(tokens, '', []);
137
+ if (flat.length === 0) {
138
+ console.log('ℹ️ No tokens with originalVariable found in tokens.json.');
139
+ return;
140
+ }
141
+
142
+ buildCssFiles(flat, tokensDir);
143
+ }
144
+
145
+ main();
@@ -0,0 +1,113 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const AJV_AVAILABLE = (() => {
5
+ try {
6
+ require.resolve('ajv');
7
+ return true;
8
+ } catch (e) {
9
+ return false;
10
+ }
11
+ })();
12
+
13
+ const ORIGINAL_VARIABLE_REGEX = /^--[\w-]+$/;
14
+
15
+ function isLeaf(node) {
16
+ if (!node || typeof node !== 'object' || Array.isArray(node)) return false;
17
+ const keys = Object.keys(node);
18
+ return keys.includes('value') && keys.includes('type') && keys.includes('originalVariable');
19
+ }
20
+
21
+ function validateLeaf(node, pathStr, errors) {
22
+ if (typeof node.value !== 'string' || node.value.length === 0) {
23
+ errors.push(`Invalid value at ${pathStr}`);
24
+ }
25
+ if (typeof node.type !== 'string' || node.type.length === 0) {
26
+ errors.push(`Invalid or missing type at ${pathStr}`);
27
+ }
28
+ if (typeof node.originalVariable !== 'string' || !ORIGINAL_VARIABLE_REGEX.test(node.originalVariable)) {
29
+ errors.push(
30
+ `Invalid originalVariable "${node.originalVariable}" at ${pathStr}. Expected format: --kebab-case`
31
+ );
32
+ }
33
+ }
34
+
35
+ function traverse(obj, pathParts, errors, stats) {
36
+ if (isLeaf(obj)) {
37
+ const pathStr = pathParts.join('.');
38
+ stats.leaves += 1;
39
+ validateLeaf(obj, pathStr, errors);
40
+ return;
41
+ }
42
+ if (!obj || typeof obj !== 'object') return;
43
+ for (const key of Object.keys(obj)) {
44
+ traverse(obj[key], [...pathParts, key], errors, stats);
45
+ }
46
+ }
47
+
48
+ function runSchemaValidation(tokens, cwd, errors) {
49
+ if (!AJV_AVAILABLE) {
50
+ console.warn('⚠️ AJV not installed — skipping JSON Schema validation.');
51
+ return;
52
+ }
53
+
54
+ try {
55
+ const Ajv = require('ajv');
56
+ const schemaPath = path.join(cwd, 'tokens.schema.json');
57
+ if (!fs.existsSync(schemaPath)) {
58
+ console.warn('⚠️ tokens.schema.json not found — skipping JSON Schema validation.');
59
+ return;
60
+ }
61
+ const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf8'));
62
+ const ajv = new Ajv({ allErrors: true });
63
+ const validate = ajv.compile(schema);
64
+ const valid = validate(tokens);
65
+ if (!valid) {
66
+ errors.push('Schema validation failed:');
67
+ for (const e of validate.errors || []) {
68
+ errors.push(` ${e.instancePath || '/'} ${e.message}`);
69
+ }
70
+ }
71
+ } catch (e) {
72
+ errors.push(`Error running schema validation: ${e.message}`);
73
+ }
74
+ }
75
+
76
+ function main() {
77
+ try {
78
+ const cwd = process.cwd();
79
+ const tokensPath = path.join(cwd, 'tokens.json');
80
+ if (!fs.existsSync(tokensPath)) {
81
+ console.error('❌ tokens.json not found. Run "npm run build:tokens" first.');
82
+ process.exit(1);
83
+ }
84
+ const content = fs.readFileSync(tokensPath, 'utf8');
85
+ const tokens = JSON.parse(content);
86
+
87
+ const errors = [];
88
+ // 1) Schema validation (AJV) if available
89
+ runSchemaValidation(tokens, cwd, errors);
90
+
91
+ // 2) Traditional traversal to ensure leaf count and give friendly messages
92
+ const stats = { leaves: 0 };
93
+ traverse(tokens, [], errors, stats);
94
+
95
+ if (stats.leaves === 0) {
96
+ console.error('❌ No token leaves found in tokens.json');
97
+ process.exit(1);
98
+ }
99
+
100
+ if (errors.length) {
101
+ console.error(`❌ Validation failed with ${errors.length} error(s):`);
102
+ for (const e of errors) console.error(' -', e);
103
+ process.exit(1);
104
+ }
105
+
106
+ console.log(`✅ tokens.json validated successfully (${stats.leaves} tokens)`);
107
+ } catch (error) {
108
+ console.error('❌ Error validating tokens:', error);
109
+ process.exit(1);
110
+ }
111
+ }
112
+
113
+ main();