inkhouse 0.1.0-beta.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/README.md +201 -0
- package/bin/inkhouse.mjs +171 -0
- package/code.js +11802 -0
- package/manifest.json +30 -0
- package/package.json +45 -0
- package/scanner/blob-placement-regression.ts +132 -0
- package/scanner/class-collector.ts +69 -0
- package/scanner/cli.ts +336 -0
- package/scanner/component-scanner.ts +2876 -0
- package/scanner/css-patch-regression.ts +112 -0
- package/scanner/css-token-reader-regression.ts +92 -0
- package/scanner/css-token-reader.ts +477 -0
- package/scanner/font-style-resolver-regression.ts +32 -0
- package/scanner/index.ts +9 -0
- package/scanner/radial-gradient-regression.ts +53 -0
- package/scanner/style-map.ts +145 -0
- package/scanner/tailwind-parser.ts +644 -0
- package/scanner/transform-math-regression.ts +42 -0
- package/scanner/types.ts +298 -0
- package/src/blob-placement.ts +111 -0
- package/src/change-detection.ts +204 -0
- package/src/class-utils.ts +105 -0
- package/src/clip-path-decorative.ts +194 -0
- package/src/color-resolver.ts +98 -0
- package/src/colors.ts +196 -0
- package/src/component-defs.ts +54 -0
- package/src/component-gen.ts +561 -0
- package/src/component-lookup.ts +82 -0
- package/src/config.ts +115 -0
- package/src/design-system.ts +59 -0
- package/src/dev-server.ts +173 -0
- package/src/figma-globals.d.ts +3 -0
- package/src/font-style-resolver.ts +171 -0
- package/src/github.ts +1465 -0
- package/src/icon-builder.ts +607 -0
- package/src/image-cache.ts +22 -0
- package/src/inline-text.ts +271 -0
- package/src/layout-parser.ts +667 -0
- package/src/layout-utils.ts +155 -0
- package/src/main.ts +687 -0
- package/src/node-ir.ts +595 -0
- package/src/pack-provider.ts +148 -0
- package/src/packs.ts +126 -0
- package/src/radial-gradient.ts +84 -0
- package/src/render-context.ts +138 -0
- package/src/responsive-analyzer.ts +139 -0
- package/src/state-analyzer.ts +143 -0
- package/src/story-builder.ts +1706 -0
- package/src/story-layout.ts +38 -0
- package/src/tailwind.ts +2379 -0
- package/src/text-builder.ts +116 -0
- package/src/text-line.ts +42 -0
- package/src/token-source.ts +43 -0
- package/src/tokens.ts +717 -0
- package/src/transform-math.ts +44 -0
- package/src/ui-builder.ts +1996 -0
- package/src/utility-resolver.ts +125 -0
- package/src/variables.ts +1042 -0
- package/src/width-solver.ts +466 -0
- package/templates/patch-tokens-route.ts +165 -0
- package/templates/scan-components-route.ts +57 -0
- package/ui.html +1222 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
|
|
3
|
+
import { patchCssVariables } from '../src/github';
|
|
4
|
+
|
|
5
|
+
function mapFromEntries(entries: Array<[string, Array<[string, string]>]>): Map<string, Map<string, string>> {
|
|
6
|
+
const out = new Map<string, Map<string, string>>();
|
|
7
|
+
for (const [theme, props] of entries) {
|
|
8
|
+
out.set(theme, new Map(props));
|
|
9
|
+
}
|
|
10
|
+
return out;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function runRegression(): void {
|
|
14
|
+
const css = [
|
|
15
|
+
':root {',
|
|
16
|
+
' --primary: oklch(0.5 0.2 120);',
|
|
17
|
+
' --background: oklch(1 0 0);',
|
|
18
|
+
'}',
|
|
19
|
+
'',
|
|
20
|
+
':root[data-theme="secondary"] {',
|
|
21
|
+
' --primary: oklch(0.7 0.2 250);',
|
|
22
|
+
'}',
|
|
23
|
+
'',
|
|
24
|
+
'/* keep this comment exactly */',
|
|
25
|
+
'.button {',
|
|
26
|
+
' color: var(--primary);',
|
|
27
|
+
'}',
|
|
28
|
+
'',
|
|
29
|
+
].join('\n');
|
|
30
|
+
|
|
31
|
+
const updates = mapFromEntries([
|
|
32
|
+
[
|
|
33
|
+
'primary',
|
|
34
|
+
[
|
|
35
|
+
['--primary', 'oklch(0.62 0.19 250)'],
|
|
36
|
+
['--ring', 'oklch(0.72 0.19 250)'],
|
|
37
|
+
],
|
|
38
|
+
],
|
|
39
|
+
['secondary', [['--primary', 'oklch(0.6 0.18 252)']]],
|
|
40
|
+
['dark', [['--background', 'oklch(0.15 0.01 285)']]],
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
const next = patchCssVariables(css, updates);
|
|
44
|
+
|
|
45
|
+
// Existing declarations are updated in-place.
|
|
46
|
+
assert.match(next, /:root \{\n --primary: oklch\(0\.62 0\.19 250\);/);
|
|
47
|
+
assert.match(next, /:root\[data-theme="secondary"\] \{\n --primary: oklch\(0\.6 0\.18 252\);/);
|
|
48
|
+
|
|
49
|
+
// New declarations are appended inside existing block with indentation.
|
|
50
|
+
assert.match(next, /--background: oklch\(1 0 0\);\n --ring: oklch\(0\.72 0\.19 250\);/);
|
|
51
|
+
|
|
52
|
+
// New theme block is created when missing.
|
|
53
|
+
assert.match(next, /:root\[data-theme="dark"\] \{\n --background: oklch\(0\.15 0\.01 285\);\n\}/);
|
|
54
|
+
|
|
55
|
+
// Unrelated CSS remains untouched.
|
|
56
|
+
assert.match(next, /\/\* keep this comment exactly \*\//);
|
|
57
|
+
assert.match(next, /\.button \{\n color: var\(--primary\);\n\}/);
|
|
58
|
+
|
|
59
|
+
// No accidental duplication for replaced declarations.
|
|
60
|
+
const primaryDeclCount = (next.match(/--primary:/g) || []).length;
|
|
61
|
+
assert.equal(primaryDeclCount, 2);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function runThemeRenameRegression(): void {
|
|
65
|
+
const css = [
|
|
66
|
+
':root {',
|
|
67
|
+
' --primary: oklch(0.5 0.2 120);',
|
|
68
|
+
' --primary-foreground: oklch(0.98 0.01 120);',
|
|
69
|
+
'}',
|
|
70
|
+
'',
|
|
71
|
+
':root[data-theme="secondary"] {',
|
|
72
|
+
' --primary: oklch(0.7 0.2 250);',
|
|
73
|
+
' --primary-foreground: oklch(0.98 0.01 250);',
|
|
74
|
+
'}',
|
|
75
|
+
'',
|
|
76
|
+
].join('\n');
|
|
77
|
+
|
|
78
|
+
const updates = mapFromEntries([
|
|
79
|
+
['client2', [['--primary', 'oklch(0.65 0.28 330)']]],
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
const fullThemeUpdates = mapFromEntries([
|
|
83
|
+
[
|
|
84
|
+
'client2',
|
|
85
|
+
[
|
|
86
|
+
['--primary', 'oklch(0.65 0.28 330)'],
|
|
87
|
+
['--primary-foreground', 'oklch(0.98 0.01 330)'],
|
|
88
|
+
['--background', 'oklch(1 0 0)'],
|
|
89
|
+
],
|
|
90
|
+
],
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
const next = patchCssVariables(
|
|
94
|
+
css,
|
|
95
|
+
updates,
|
|
96
|
+
new Set(['primary', 'client2']),
|
|
97
|
+
fullThemeUpdates
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
// Stale theme block should be removed.
|
|
101
|
+
assert.doesNotMatch(next, /:root\[data-theme="secondary"\]/);
|
|
102
|
+
|
|
103
|
+
// New theme block should include the full theme payload, not only changed key.
|
|
104
|
+
assert.match(next, /:root\[data-theme="client2"\] \{/);
|
|
105
|
+
assert.match(next, /--primary: oklch\(0\.65 0\.28 330\);/);
|
|
106
|
+
assert.match(next, /--primary-foreground: oklch\(0\.98 0\.01 330\);/);
|
|
107
|
+
assert.match(next, /--background: oklch\(1 0 0\);/);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
runRegression();
|
|
111
|
+
runThemeRenameRegression();
|
|
112
|
+
console.log('css patch regression passed');
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { parseCssTokenMap, readTokenSourceMap } from './css-token-reader';
|
|
6
|
+
|
|
7
|
+
function testRootAndThemeSelectors(): void {
|
|
8
|
+
const css = `
|
|
9
|
+
:root {
|
|
10
|
+
--primary: oklch(62% 0.17 149);
|
|
11
|
+
--radius: 0.5rem;
|
|
12
|
+
--font-sans: "Open Sans", sans-serif;
|
|
13
|
+
}
|
|
14
|
+
.dark {
|
|
15
|
+
--primary: oklch(70% 0.14 150);
|
|
16
|
+
}
|
|
17
|
+
:root[data-theme="secondary"] {
|
|
18
|
+
--primary: oklch(62% 0.17 250);
|
|
19
|
+
}
|
|
20
|
+
`;
|
|
21
|
+
const map = parseCssTokenMap(css, 'src/app/globals.css', 'auto');
|
|
22
|
+
assert.equal(map.mode, 'css');
|
|
23
|
+
assert.equal(map.colors.primary, 'oklch(62% 0.17 149)');
|
|
24
|
+
assert.equal(map.radius.base, 8);
|
|
25
|
+
assert.equal(map.fonts.sans, '"Open Sans", sans-serif');
|
|
26
|
+
assert.equal(map.themes.dark?.colors.primary, 'oklch(70% 0.14 150)');
|
|
27
|
+
assert.equal(map.themes.secondary?.colors.primary, 'oklch(62% 0.17 250)');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function testTailwindV4ThemeBlock(): void {
|
|
31
|
+
const css = `
|
|
32
|
+
@theme {
|
|
33
|
+
--color-primary: oklch(0.7 0.2 150);
|
|
34
|
+
--spacing-md: 1rem;
|
|
35
|
+
--text-xl: 1.25rem;
|
|
36
|
+
}
|
|
37
|
+
`;
|
|
38
|
+
const map = parseCssTokenMap(css, 'src/app/globals.css', 'auto');
|
|
39
|
+
assert.equal(map.colors.primary, 'oklch(0.7 0.2 150)');
|
|
40
|
+
assert.equal(map.spacing.md, 16);
|
|
41
|
+
assert.equal(map.fontSize.xl, 20);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function testShadowAndColorPrefixes(): void {
|
|
45
|
+
const css = `
|
|
46
|
+
:root {
|
|
47
|
+
--shadow: 0 1px 2px rgba(0,0,0,.1);
|
|
48
|
+
--shadow-md: 0 8px 16px rgba(0,0,0,.2);
|
|
49
|
+
--color-background: oklch(1 0 0);
|
|
50
|
+
}
|
|
51
|
+
`;
|
|
52
|
+
const map = parseCssTokenMap(css, 'src/app/tokens.css', 'auto');
|
|
53
|
+
assert.equal(map.shadows.DEFAULT, '0 1px 2px rgba(0,0,0,.1)');
|
|
54
|
+
assert.equal(map.shadows.md, '0 8px 16px rgba(0,0,0,.2)');
|
|
55
|
+
assert.equal(map.colors.background, 'oklch(1 0 0)');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function testImportedCssSourceResolution(): void {
|
|
59
|
+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'inkhouse-css-reader-'));
|
|
60
|
+
try {
|
|
61
|
+
const appDir = path.join(tmpRoot, 'src', 'app');
|
|
62
|
+
const themeDir = path.join(tmpRoot, 'src', 'theme');
|
|
63
|
+
fs.mkdirSync(appDir, { recursive: true });
|
|
64
|
+
fs.mkdirSync(themeDir, { recursive: true });
|
|
65
|
+
|
|
66
|
+
fs.writeFileSync(
|
|
67
|
+
path.join(appDir, 'globals.css'),
|
|
68
|
+
'@import "../theme/brand.css";\nbody { color: var(--foreground); }\n',
|
|
69
|
+
'utf8'
|
|
70
|
+
);
|
|
71
|
+
fs.writeFileSync(
|
|
72
|
+
path.join(themeDir, 'brand.css'),
|
|
73
|
+
':root { --primary: oklch(0.62 0.19 250); --foreground: oklch(0.14 0.01 286); }\n',
|
|
74
|
+
'utf8'
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const map = readTokenSourceMap({ projectRoot: tmpRoot, tokenSourceMode: 'auto' });
|
|
78
|
+
assert.equal(map.mode, 'css');
|
|
79
|
+
assert.equal(map.source.replace(/\\/g, '/'), 'src/theme/brand.css');
|
|
80
|
+
assert.equal(map.colors.primary, 'oklch(0.62 0.19 250)');
|
|
81
|
+
assert.equal(map.colors.foreground, 'oklch(0.14 0.01 286)');
|
|
82
|
+
} finally {
|
|
83
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
testRootAndThemeSelectors();
|
|
88
|
+
testTailwindV4ThemeBlock();
|
|
89
|
+
testShadowAndColorPrefixes();
|
|
90
|
+
testImportedCssSourceResolution();
|
|
91
|
+
|
|
92
|
+
console.log('css token reader regression passed');
|
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import postcss, { type AtRule, type Declaration, type Rule } from 'postcss';
|
|
4
|
+
import {
|
|
5
|
+
createEmptyScannedTokenMap,
|
|
6
|
+
type ScannedThemeTokens,
|
|
7
|
+
type ScannedTokenMap,
|
|
8
|
+
type TokenSourceMode,
|
|
9
|
+
} from '../src/token-source';
|
|
10
|
+
|
|
11
|
+
const CSS_DISCOVERY_PATHS = [
|
|
12
|
+
'src/app/tokens.css',
|
|
13
|
+
'src/app/globals.css',
|
|
14
|
+
'app/globals.css',
|
|
15
|
+
'styles/globals.css',
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const DEFAULT_DTCG_PATH = 'design-tokens/tokens.dtcg.json';
|
|
19
|
+
const REM_TO_PX = 16;
|
|
20
|
+
|
|
21
|
+
export interface ReadTokenSourceOptions {
|
|
22
|
+
projectRoot: string;
|
|
23
|
+
tokenSourceMode?: TokenSourceMode;
|
|
24
|
+
cssTokenPath?: string;
|
|
25
|
+
dtcgTokenPath?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type TargetTokenGroup = {
|
|
29
|
+
colors: Record<string, string>;
|
|
30
|
+
radius: Record<string, number>;
|
|
31
|
+
fonts: Record<string, string>;
|
|
32
|
+
spacing: Record<string, number>;
|
|
33
|
+
fontSize: Record<string, number>;
|
|
34
|
+
shadows: Record<string, string>;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function toDisplayPath(projectRoot: string, filePath: string): string {
|
|
38
|
+
const rel = path.relative(projectRoot, filePath);
|
|
39
|
+
if (!rel || rel.startsWith('..')) return filePath;
|
|
40
|
+
return rel.replace(/\\/g, '/');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
44
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function discoverFilePath(projectRoot: string, candidate: string): string | null {
|
|
48
|
+
if (!candidate) return null;
|
|
49
|
+
const absolute = path.isAbsolute(candidate) ? candidate : path.resolve(projectRoot, candidate);
|
|
50
|
+
return fs.existsSync(absolute) ? absolute : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function extractImportSpecifier(params: string): string | null {
|
|
54
|
+
const raw = String(params || '').trim();
|
|
55
|
+
if (!raw) return null;
|
|
56
|
+
|
|
57
|
+
const quoted = raw.match(/^(['"])(.+?)\1(?:\s+.*)?$/);
|
|
58
|
+
if (quoted) return quoted[2].trim();
|
|
59
|
+
|
|
60
|
+
const urlMatch = raw.match(/^url\(\s*(['"]?)([^'")]+)\1\s*\)(?:\s+.*)?$/i);
|
|
61
|
+
if (urlMatch) return urlMatch[2].trim();
|
|
62
|
+
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function resolveImportedCssPath(baseFilePath: string, params: string): string | null {
|
|
67
|
+
const specifier = extractImportSpecifier(params);
|
|
68
|
+
if (!specifier) return null;
|
|
69
|
+
const lowered = specifier.toLowerCase();
|
|
70
|
+
if (
|
|
71
|
+
lowered.startsWith('http://') ||
|
|
72
|
+
lowered.startsWith('https://') ||
|
|
73
|
+
lowered.startsWith('//') ||
|
|
74
|
+
lowered.startsWith('data:')
|
|
75
|
+
) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const fromDir = path.dirname(baseFilePath);
|
|
80
|
+
const direct = path.resolve(fromDir, specifier);
|
|
81
|
+
if (fs.existsSync(direct)) return direct;
|
|
82
|
+
if (!path.extname(direct) && fs.existsSync(direct + '.css')) return direct + '.css';
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function cssHasTokenDeclarations(cssText: string): boolean {
|
|
87
|
+
try {
|
|
88
|
+
const root = postcss.parse(cssText);
|
|
89
|
+
let found = false;
|
|
90
|
+
root.walkDecls((decl) => {
|
|
91
|
+
if (found) return;
|
|
92
|
+
if (!decl.prop || !decl.prop.startsWith('--')) return;
|
|
93
|
+
const parent = decl.parent;
|
|
94
|
+
if (!parent) return;
|
|
95
|
+
if (parent.type === 'atrule') {
|
|
96
|
+
const at = parent as AtRule;
|
|
97
|
+
if ((at.name || '').toLowerCase() === 'theme') found = true;
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (parent.type === 'rule') {
|
|
101
|
+
const selector = (parent as Rule).selector || '';
|
|
102
|
+
if (parseThemeSelectors(selector).length > 0) found = true;
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
return found;
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function resolveCssTokenPathFromImports(filePath: string, visited: Set<string> = new Set()): string {
|
|
112
|
+
const absolute = path.resolve(filePath);
|
|
113
|
+
if (visited.has(absolute)) return absolute;
|
|
114
|
+
visited.add(absolute);
|
|
115
|
+
|
|
116
|
+
let cssText = '';
|
|
117
|
+
try {
|
|
118
|
+
cssText = fs.readFileSync(absolute, 'utf-8');
|
|
119
|
+
} catch {
|
|
120
|
+
return absolute;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (cssHasTokenDeclarations(cssText)) return absolute;
|
|
124
|
+
|
|
125
|
+
let root: postcss.Root;
|
|
126
|
+
try {
|
|
127
|
+
root = postcss.parse(cssText, { from: absolute });
|
|
128
|
+
} catch {
|
|
129
|
+
return absolute;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
for (const node of root.nodes || []) {
|
|
133
|
+
if (node.type !== 'atrule') continue;
|
|
134
|
+
const at = node as AtRule;
|
|
135
|
+
if ((at.name || '').toLowerCase() !== 'import') continue;
|
|
136
|
+
const imported = resolveImportedCssPath(absolute, at.params || '');
|
|
137
|
+
if (!imported || visited.has(imported)) continue;
|
|
138
|
+
const resolved = resolveCssTokenPathFromImports(imported, visited);
|
|
139
|
+
if (resolved && resolved !== absolute) return resolved;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return absolute;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function readCssWithImports(filePath: string, visited: Set<string> = new Set()): string {
|
|
146
|
+
const absolute = path.resolve(filePath);
|
|
147
|
+
if (visited.has(absolute)) return '';
|
|
148
|
+
visited.add(absolute);
|
|
149
|
+
|
|
150
|
+
let cssText = '';
|
|
151
|
+
try {
|
|
152
|
+
cssText = fs.readFileSync(absolute, 'utf-8');
|
|
153
|
+
} catch {
|
|
154
|
+
return '';
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let root: postcss.Root;
|
|
158
|
+
try {
|
|
159
|
+
root = postcss.parse(cssText, { from: absolute });
|
|
160
|
+
} catch {
|
|
161
|
+
return cssText;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const parts: string[] = [];
|
|
165
|
+
for (const node of root.nodes || []) {
|
|
166
|
+
if (node.type === 'atrule' && ((node as AtRule).name || '').toLowerCase() === 'import') {
|
|
167
|
+
const imported = resolveImportedCssPath(absolute, (node as AtRule).params || '');
|
|
168
|
+
if (imported) {
|
|
169
|
+
const importedCss = readCssWithImports(imported, visited);
|
|
170
|
+
if (importedCss.trim()) parts.push(importedCss);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
parts.push(node.toString());
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return parts.join('\n');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function discoverCssTokenPath(projectRoot: string, explicitPath?: string): string | null {
|
|
181
|
+
if (explicitPath && explicitPath.trim()) {
|
|
182
|
+
const explicit = discoverFilePath(projectRoot, explicitPath.trim());
|
|
183
|
+
return explicit ? resolveCssTokenPathFromImports(explicit) : null;
|
|
184
|
+
}
|
|
185
|
+
for (const rel of CSS_DISCOVERY_PATHS) {
|
|
186
|
+
const found = discoverFilePath(projectRoot, rel);
|
|
187
|
+
if (found) return resolveCssTokenPathFromImports(found);
|
|
188
|
+
}
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function discoverDtcgTokenPath(projectRoot: string, explicitPath?: string): string | null {
|
|
193
|
+
const candidate = explicitPath && explicitPath.trim() ? explicitPath.trim() : DEFAULT_DTCG_PATH;
|
|
194
|
+
return discoverFilePath(projectRoot, candidate);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function parseDimensionPx(rawValue: string): number | null {
|
|
198
|
+
const value = String(rawValue || '').trim().toLowerCase();
|
|
199
|
+
const pxMatch = value.match(/^(-?\d+(?:\.\d+)?)px$/);
|
|
200
|
+
if (pxMatch) {
|
|
201
|
+
const n = parseFloat(pxMatch[1]);
|
|
202
|
+
return Number.isFinite(n) ? n : null;
|
|
203
|
+
}
|
|
204
|
+
const remMatch = value.match(/^(-?\d+(?:\.\d+)?)rem$/);
|
|
205
|
+
if (remMatch) {
|
|
206
|
+
const n = parseFloat(remMatch[1]);
|
|
207
|
+
return Number.isFinite(n) ? n * REM_TO_PX : null;
|
|
208
|
+
}
|
|
209
|
+
const emMatch = value.match(/^(-?\d+(?:\.\d+)?)em$/);
|
|
210
|
+
if (emMatch) {
|
|
211
|
+
const n = parseFloat(emMatch[1]);
|
|
212
|
+
return Number.isFinite(n) ? n * REM_TO_PX : null;
|
|
213
|
+
}
|
|
214
|
+
const numberMatch = value.match(/^(-?\d+(?:\.\d+)?)$/);
|
|
215
|
+
if (numberMatch) {
|
|
216
|
+
const n = parseFloat(numberMatch[1]);
|
|
217
|
+
return Number.isFinite(n) ? n : null;
|
|
218
|
+
}
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function ensureThemeContainer(map: ScannedTokenMap, themeName: string): ScannedThemeTokens {
|
|
223
|
+
if (!map.themes[themeName]) {
|
|
224
|
+
map.themes[themeName] = { colors: {} };
|
|
225
|
+
}
|
|
226
|
+
return map.themes[themeName];
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function getTargetGroup(map: ScannedTokenMap, themeName: string): TargetTokenGroup {
|
|
230
|
+
if (themeName === 'primary') {
|
|
231
|
+
return {
|
|
232
|
+
colors: map.colors,
|
|
233
|
+
radius: map.radius,
|
|
234
|
+
fonts: map.fonts,
|
|
235
|
+
spacing: map.spacing,
|
|
236
|
+
fontSize: map.fontSize,
|
|
237
|
+
shadows: map.shadows,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
const theme = ensureThemeContainer(map, themeName);
|
|
241
|
+
if (!theme.radius) theme.radius = {};
|
|
242
|
+
if (!theme.fonts) theme.fonts = {};
|
|
243
|
+
if (!theme.spacing) theme.spacing = {};
|
|
244
|
+
if (!theme.fontSize) theme.fontSize = {};
|
|
245
|
+
if (!theme.shadows) theme.shadows = {};
|
|
246
|
+
return {
|
|
247
|
+
colors: theme.colors,
|
|
248
|
+
radius: theme.radius,
|
|
249
|
+
fonts: theme.fonts,
|
|
250
|
+
spacing: theme.spacing,
|
|
251
|
+
fontSize: theme.fontSize,
|
|
252
|
+
shadows: theme.shadows,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function parseThemeSelectors(selector: string): string[] {
|
|
257
|
+
const themes = new Set<string>();
|
|
258
|
+
const raw = String(selector || '').trim();
|
|
259
|
+
if (!raw) return [];
|
|
260
|
+
const normalized = raw.toLowerCase();
|
|
261
|
+
|
|
262
|
+
const dataThemeMatches = normalized.matchAll(/\[data-theme\s*=\s*["']?([^"'\\\]]+)["']?\]/g);
|
|
263
|
+
for (const match of dataThemeMatches) {
|
|
264
|
+
const themeName = String(match[1] || '').trim();
|
|
265
|
+
if (themeName) themes.add(themeName);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (/(\.|:)(dark)\b/.test(normalized)) {
|
|
269
|
+
themes.add('dark');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (normalized.includes(':root') && themes.size === 0) {
|
|
273
|
+
themes.add('primary');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return Array.from(themes);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
type VariableCategory = 'color' | 'radius' | 'font' | 'spacing' | 'fontSize' | 'shadow';
|
|
280
|
+
|
|
281
|
+
function classifyVariable(rawName: string): { category: VariableCategory; key: string } | null {
|
|
282
|
+
const name = String(rawName || '').trim().replace(/^--/, '');
|
|
283
|
+
if (!name) return null;
|
|
284
|
+
|
|
285
|
+
if (name === 'radius') return { category: 'radius', key: 'base' };
|
|
286
|
+
if (name.startsWith('radius-')) return { category: 'radius', key: name.slice('radius-'.length) };
|
|
287
|
+
|
|
288
|
+
if (name.startsWith('font-family-')) return { category: 'font', key: name.slice('font-family-'.length) };
|
|
289
|
+
if (name.startsWith('font-') && !name.startsWith('font-size-')) return { category: 'font', key: name.slice('font-'.length) };
|
|
290
|
+
|
|
291
|
+
if (name.startsWith('spacing-')) return { category: 'spacing', key: name.slice('spacing-'.length) };
|
|
292
|
+
if (name.startsWith('text-')) return { category: 'fontSize', key: name.slice('text-'.length) };
|
|
293
|
+
if (name.startsWith('font-size-')) return { category: 'fontSize', key: name.slice('font-size-'.length) };
|
|
294
|
+
|
|
295
|
+
if (name === 'shadow') return { category: 'shadow', key: 'DEFAULT' };
|
|
296
|
+
if (name.startsWith('shadow-')) return { category: 'shadow', key: name.slice('shadow-'.length) };
|
|
297
|
+
|
|
298
|
+
if (name.startsWith('color-')) return { category: 'color', key: name.slice('color-'.length) };
|
|
299
|
+
return { category: 'color', key: name };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function applyDeclaration(target: TargetTokenGroup, decl: Declaration): void {
|
|
303
|
+
if (!decl.prop || !decl.prop.startsWith('--')) return;
|
|
304
|
+
const classification = classifyVariable(decl.prop);
|
|
305
|
+
if (!classification || !classification.key) return;
|
|
306
|
+
const value = String(decl.value || '').trim();
|
|
307
|
+
if (!value) return;
|
|
308
|
+
|
|
309
|
+
if (classification.category === 'color') {
|
|
310
|
+
target.colors[classification.key] = value;
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (classification.category === 'font') {
|
|
314
|
+
target.fonts[classification.key] = value;
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (classification.category === 'shadow') {
|
|
318
|
+
target.shadows[classification.key] = value;
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (classification.category === 'radius') {
|
|
322
|
+
const px = parseDimensionPx(value);
|
|
323
|
+
if (px != null) target.radius[classification.key] = px;
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (classification.category === 'spacing') {
|
|
327
|
+
const px = parseDimensionPx(value);
|
|
328
|
+
if (px != null) target.spacing[classification.key] = px;
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (classification.category === 'fontSize') {
|
|
332
|
+
const px = parseDimensionPx(value);
|
|
333
|
+
if (px != null) target.fontSize[classification.key] = px;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function applyRuleTokens(map: ScannedTokenMap, rule: Rule): void {
|
|
338
|
+
const selectors = Array.isArray(rule.selectors)
|
|
339
|
+
? rule.selectors
|
|
340
|
+
: String(rule.selector || '').split(',');
|
|
341
|
+
const themes = new Set<string>();
|
|
342
|
+
for (const selector of selectors) {
|
|
343
|
+
for (const themeName of parseThemeSelectors(selector)) {
|
|
344
|
+
themes.add(themeName);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (themes.size === 0) return;
|
|
348
|
+
|
|
349
|
+
for (const themeName of themes) {
|
|
350
|
+
const target = getTargetGroup(map, themeName);
|
|
351
|
+
for (const node of rule.nodes || []) {
|
|
352
|
+
if (node.type !== 'decl') continue;
|
|
353
|
+
applyDeclaration(target, node as Declaration);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function walkCssNodes(map: ScannedTokenMap, container: postcss.Container): void {
|
|
359
|
+
for (const node of container.nodes || []) {
|
|
360
|
+
if (node.type === 'rule') {
|
|
361
|
+
applyRuleTokens(map, node as Rule);
|
|
362
|
+
walkCssNodes(map, node as unknown as postcss.Container);
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
if (node.type === 'atrule') {
|
|
366
|
+
const at = node as AtRule;
|
|
367
|
+
if (at.name === 'theme') {
|
|
368
|
+
const target = getTargetGroup(map, 'primary');
|
|
369
|
+
for (const child of at.nodes || []) {
|
|
370
|
+
if (child.type !== 'decl') continue;
|
|
371
|
+
applyDeclaration(target, child as Declaration);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
walkCssNodes(map, at as unknown as postcss.Container);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export function parseCssTokenMap(cssText: string, source: string, requestedMode: TokenSourceMode = 'auto'): ScannedTokenMap {
|
|
380
|
+
const map = createEmptyScannedTokenMap('css', source, requestedMode);
|
|
381
|
+
const root = postcss.parse(cssText);
|
|
382
|
+
walkCssNodes(map, root);
|
|
383
|
+
return map;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function applyDtcgGroup(
|
|
387
|
+
source: Record<string, unknown> | undefined,
|
|
388
|
+
expectedType: string,
|
|
389
|
+
mapTarget: Record<string, string>,
|
|
390
|
+
): void {
|
|
391
|
+
if (!source || !isPlainObject(source)) return;
|
|
392
|
+
for (const [key, entry] of Object.entries(source)) {
|
|
393
|
+
if (!isPlainObject(entry)) continue;
|
|
394
|
+
if ((entry.$type as string) !== expectedType) continue;
|
|
395
|
+
const value = entry.$value;
|
|
396
|
+
if (typeof value === 'string' && value.trim()) {
|
|
397
|
+
mapTarget[key] = value.trim();
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function applyDtcgDimensionGroup(
|
|
403
|
+
source: Record<string, unknown> | undefined,
|
|
404
|
+
mapTarget: Record<string, number>,
|
|
405
|
+
): void {
|
|
406
|
+
if (!source || !isPlainObject(source)) return;
|
|
407
|
+
for (const [key, entry] of Object.entries(source)) {
|
|
408
|
+
if (!isPlainObject(entry)) continue;
|
|
409
|
+
if ((entry.$type as string) !== 'dimension') continue;
|
|
410
|
+
const value = entry.$value;
|
|
411
|
+
if (typeof value !== 'string' || !value.trim()) continue;
|
|
412
|
+
const px = parseDimensionPx(value);
|
|
413
|
+
if (px != null) mapTarget[key] = px;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function parseDtcgTokenMap(
|
|
418
|
+
dtcgJson: unknown,
|
|
419
|
+
source: string,
|
|
420
|
+
requestedMode: TokenSourceMode = 'auto'
|
|
421
|
+
): ScannedTokenMap {
|
|
422
|
+
const map = createEmptyScannedTokenMap('dtcg', source, requestedMode);
|
|
423
|
+
if (!isPlainObject(dtcgJson)) return map;
|
|
424
|
+
|
|
425
|
+
const core = dtcgJson.core;
|
|
426
|
+
if (isPlainObject(core)) {
|
|
427
|
+
applyDtcgGroup(core.font as Record<string, unknown> | undefined, 'fontFamily', map.fonts);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
for (const [themeName, rawTheme] of Object.entries(dtcgJson)) {
|
|
431
|
+
if (themeName === '$metadata' || themeName === 'core') continue;
|
|
432
|
+
if (!isPlainObject(rawTheme)) continue;
|
|
433
|
+
|
|
434
|
+
const target = getTargetGroup(map, themeName);
|
|
435
|
+
applyDtcgGroup(rawTheme.color as Record<string, unknown> | undefined, 'color', target.colors);
|
|
436
|
+
applyDtcgGroup(rawTheme.font as Record<string, unknown> | undefined, 'fontFamily', target.fonts);
|
|
437
|
+
applyDtcgDimensionGroup(rawTheme.radius as Record<string, unknown> | undefined, target.radius);
|
|
438
|
+
applyDtcgDimensionGroup(rawTheme.spacing as Record<string, unknown> | undefined, target.spacing);
|
|
439
|
+
applyDtcgDimensionGroup(rawTheme.fontSize as Record<string, unknown> | undefined, target.fontSize);
|
|
440
|
+
applyDtcgGroup(rawTheme.shadow as Record<string, unknown> | undefined, 'shadow', target.shadows);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return map;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export function readTokenSourceMap(options: ReadTokenSourceOptions): ScannedTokenMap {
|
|
447
|
+
const projectRoot = path.resolve(options.projectRoot);
|
|
448
|
+
const requestedMode = options.tokenSourceMode || 'auto';
|
|
449
|
+
const cssPath = discoverCssTokenPath(projectRoot, options.cssTokenPath);
|
|
450
|
+
const dtcgPath = discoverDtcgTokenPath(projectRoot, options.dtcgTokenPath);
|
|
451
|
+
|
|
452
|
+
if (requestedMode === 'css') {
|
|
453
|
+
if (!cssPath) return createEmptyScannedTokenMap('embedded', 'embedded:tokens.ts', requestedMode);
|
|
454
|
+
const cssText = readCssWithImports(cssPath);
|
|
455
|
+
return parseCssTokenMap(cssText, toDisplayPath(projectRoot, cssPath), requestedMode);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (requestedMode === 'dtcg') {
|
|
459
|
+
if (!dtcgPath) return createEmptyScannedTokenMap('embedded', 'embedded:tokens.ts', requestedMode);
|
|
460
|
+
const dtcgText = fs.readFileSync(dtcgPath, 'utf-8');
|
|
461
|
+
const dtcgJson = JSON.parse(dtcgText);
|
|
462
|
+
return parseDtcgTokenMap(dtcgJson, toDisplayPath(projectRoot, dtcgPath), requestedMode);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// auto mode: CSS always wins when present.
|
|
466
|
+
if (cssPath) {
|
|
467
|
+
const cssText = readCssWithImports(cssPath);
|
|
468
|
+
return parseCssTokenMap(cssText, toDisplayPath(projectRoot, cssPath), requestedMode);
|
|
469
|
+
}
|
|
470
|
+
if (dtcgPath) {
|
|
471
|
+
const dtcgText = fs.readFileSync(dtcgPath, 'utf-8');
|
|
472
|
+
const dtcgJson = JSON.parse(dtcgText);
|
|
473
|
+
return parseDtcgTokenMap(dtcgJson, toDisplayPath(projectRoot, dtcgPath), requestedMode);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return createEmptyScannedTokenMap('embedded', 'embedded:tokens.ts', requestedMode);
|
|
477
|
+
}
|