@controlfront/detect 0.0.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.
- package/bin/cfb.js +202 -0
- package/package.json +64 -0
- package/src/commands/baseline.js +198 -0
- package/src/commands/init.js +309 -0
- package/src/commands/login.js +71 -0
- package/src/commands/logout.js +44 -0
- package/src/commands/scan.js +1547 -0
- package/src/commands/snapshot.js +191 -0
- package/src/commands/sync.js +127 -0
- package/src/config/baseUrl.js +49 -0
- package/src/data/tailwind-core-spec.js +149 -0
- package/src/engine/runRules.js +210 -0
- package/src/lib/collectDeclaredTokensAuto.js +67 -0
- package/src/lib/collectTokenMatches.js +330 -0
- package/src/lib/collectTokenMatches.js.regex +252 -0
- package/src/lib/loadRules.js +73 -0
- package/src/rules/core/no-hardcoded-colors.js +28 -0
- package/src/rules/core/no-hardcoded-spacing.js +29 -0
- package/src/rules/core/no-inline-styles.js +28 -0
- package/src/utils/authorId.js +106 -0
- package/src/utils/buildAIContributions.js +224 -0
- package/src/utils/buildBlameData.js +388 -0
- package/src/utils/buildDeclaredCssVars.js +185 -0
- package/src/utils/buildDeclaredJson.js +214 -0
- package/src/utils/buildFileChanges.js +372 -0
- package/src/utils/buildRuntimeUsage.js +337 -0
- package/src/utils/detectDeclaredDrift.js +59 -0
- package/src/utils/extractImports.js +178 -0
- package/src/utils/fileExtensions.js +65 -0
- package/src/utils/generateInsights.js +332 -0
- package/src/utils/getAllFiles.js +63 -0
- package/src/utils/getCommitMetaData.js +102 -0
- package/src/utils/getLine.js +14 -0
- package/src/utils/resolveProjectForFolder/index.js +47 -0
- package/src/utils/twClassify.js +138 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import postcss from "postcss";
|
|
3
|
+
import safeParser from "postcss-safe-parser";
|
|
4
|
+
import { buildDeclaredJson } from "./buildDeclaredJson.js";
|
|
5
|
+
|
|
6
|
+
const DEBUG = false;
|
|
7
|
+
|
|
8
|
+
// Helper to extract theme name from a selector
|
|
9
|
+
function extractThemeName(selector) {
|
|
10
|
+
selector = selector.trim();
|
|
11
|
+
if (selector === ":root") {
|
|
12
|
+
return null;
|
|
13
|
+
} else if (selector.startsWith(".")) {
|
|
14
|
+
return selector.substring(1);
|
|
15
|
+
} else if (selector.startsWith(":")) {
|
|
16
|
+
return selector.substring(1);
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Recursive function to process nodes and extract CSS vars
|
|
22
|
+
function processNodes(nodes, theme = null, tokens = [], file) {
|
|
23
|
+
nodes.forEach((node) => {
|
|
24
|
+
if (node.type === "rule") {
|
|
25
|
+
const currentTheme = extractThemeName(node.selector) ?? theme;
|
|
26
|
+
node.walkDecls((decl) => {
|
|
27
|
+
if (decl.prop.startsWith("--") && decl.value) {
|
|
28
|
+
tokens.push({
|
|
29
|
+
name: decl.prop.slice(2),
|
|
30
|
+
cssVar: decl.prop,
|
|
31
|
+
value: decl.value.trim().replace(/^['"]|['"]$/g, ""),
|
|
32
|
+
type: null,
|
|
33
|
+
theme: currentTheme,
|
|
34
|
+
source: "css",
|
|
35
|
+
file,
|
|
36
|
+
line: decl.source?.start?.line || null,
|
|
37
|
+
});
|
|
38
|
+
} else if (DEBUG && decl.prop.startsWith("--")) {
|
|
39
|
+
console.warn(`[buildDeclaredCssVars] Skipped CSS var with missing value in ${file}: ${decl.prop}`);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
} else if (node.type === "atrule") {
|
|
43
|
+
if (node.name === "theme" && node.params.trim() === "inline") {
|
|
44
|
+
// @theme inline { ... }
|
|
45
|
+
processNodes(node.nodes || [], "tailwind-inline", tokens, file);
|
|
46
|
+
} else {
|
|
47
|
+
if (node.nodes) {
|
|
48
|
+
processNodes(node.nodes, theme, tokens, file);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
return tokens;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Unified parsing function using PostCSS AST
|
|
57
|
+
function parseCssVarsWithPostcss(content, file) {
|
|
58
|
+
const tokens = [];
|
|
59
|
+
let root;
|
|
60
|
+
try {
|
|
61
|
+
root = postcss.parse(content, { from: file, parser: safeParser });
|
|
62
|
+
} catch (e) {
|
|
63
|
+
// If parsing fails, return empty tokens
|
|
64
|
+
return tokens;
|
|
65
|
+
}
|
|
66
|
+
return processNodes(root.nodes, null, tokens, file);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Main function to build declared CSS variable tokens from project files
|
|
70
|
+
export async function buildDeclaredCssVars({ projectRoot, files }) {
|
|
71
|
+
if (!files?.length) return [];
|
|
72
|
+
let declaredJsonTokens = [];
|
|
73
|
+
try {
|
|
74
|
+
const maybeTokens = await buildDeclaredJson({ projectRoot, files });
|
|
75
|
+
declaredJsonTokens = Array.isArray(maybeTokens) ? maybeTokens : [];
|
|
76
|
+
} catch {
|
|
77
|
+
declaredJsonTokens = [];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Fallback to ensure cssVar and theme are properly normalized
|
|
81
|
+
for (const t of declaredJsonTokens) {
|
|
82
|
+
if (!t.cssVar && t.name) {
|
|
83
|
+
const normalizedName = t.name.replace(/\./g, "-");
|
|
84
|
+
t.cssVar = normalizedName.startsWith("--") ? normalizedName : `--${normalizedName}`;
|
|
85
|
+
}
|
|
86
|
+
if (!t.theme || t.theme === "root") {
|
|
87
|
+
t.theme = "default";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const allTokens = [];
|
|
92
|
+
for (const file of files) {
|
|
93
|
+
const content = await fs.promises.readFile(file, "utf8");
|
|
94
|
+
const parsedTokens = parseCssVarsWithPostcss(content, file);
|
|
95
|
+
allTokens.push(...parsedTokens);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Normalize themes for all tokens
|
|
99
|
+
for (const t of allTokens) {
|
|
100
|
+
if (t.theme == null || t.theme === "root") t.theme = "default";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Consistent key normalization
|
|
104
|
+
const normalizeKey = (cssVar, theme) => {
|
|
105
|
+
if (!cssVar) return "";
|
|
106
|
+
return `${cssVar.trim().toLowerCase()}|${(theme || "default").trim().toLowerCase()}`;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// Build declaredJsonGroupMap using normalized keys
|
|
110
|
+
const declaredJsonGroupMap = declaredJsonTokens.reduce((map, token) => {
|
|
111
|
+
const key = normalizeKey(token.cssVar, token.theme);
|
|
112
|
+
if (!map.has(key)) map.set(key, []);
|
|
113
|
+
map.get(key).push(token);
|
|
114
|
+
return map;
|
|
115
|
+
}, new Map());
|
|
116
|
+
|
|
117
|
+
const normalizeValue = (v) => {
|
|
118
|
+
if (typeof v !== "string") return v;
|
|
119
|
+
const s = v
|
|
120
|
+
.replace(/^['"]|['"]$/g, "")
|
|
121
|
+
.replace(/[;]+$/, "")
|
|
122
|
+
.replace(/\s+/g, " ")
|
|
123
|
+
.trim()
|
|
124
|
+
.toLowerCase();
|
|
125
|
+
if (/^#[0-9a-f]{3}$/.test(s)) {
|
|
126
|
+
return "#" + s[1] + s[1] + s[2] + s[2] + s[3] + s[3];
|
|
127
|
+
}
|
|
128
|
+
return s;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
for (const token of allTokens) {
|
|
132
|
+
const themeKey = token.theme || "default";
|
|
133
|
+
const exactKey = normalizeKey(token.cssVar, themeKey);
|
|
134
|
+
|
|
135
|
+
// Exact theme match first
|
|
136
|
+
let candidates = declaredJsonGroupMap.get(exactKey) || [];
|
|
137
|
+
|
|
138
|
+
// Fallback to default only for default theme
|
|
139
|
+
if (!candidates.length && themeKey === "default") {
|
|
140
|
+
candidates =
|
|
141
|
+
declaredJsonGroupMap.get(normalizeKey(token.cssVar, "default")) ||
|
|
142
|
+
declaredJsonGroupMap.get(normalizeKey(token.cssVar, null)) ||
|
|
143
|
+
[];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let matchedJson = null;
|
|
147
|
+
if (candidates.length) {
|
|
148
|
+
const eqVal = (t) => normalizeValue(t.value) === normalizeValue(token.value);
|
|
149
|
+
matchedJson = candidates.find(eqVal) || candidates[0] || null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
token.token_backed = matchedJson;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Group tokens by cssVar and theme
|
|
156
|
+
const groupedMap = new Map();
|
|
157
|
+
for (const token of allTokens) {
|
|
158
|
+
const cssVar = token.cssVar;
|
|
159
|
+
const name = token.name;
|
|
160
|
+
const themeKey = token.theme === null ? "default" : token.theme;
|
|
161
|
+
if (!groupedMap.has(cssVar)) {
|
|
162
|
+
groupedMap.set(cssVar, {
|
|
163
|
+
cssVar,
|
|
164
|
+
name,
|
|
165
|
+
themes: {},
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
const group = groupedMap.get(cssVar);
|
|
169
|
+
if (!group.themes[themeKey]) {
|
|
170
|
+
group.themes[themeKey] = { entries: [] };
|
|
171
|
+
}
|
|
172
|
+
group.themes[themeKey].entries.push({
|
|
173
|
+
value: token.value,
|
|
174
|
+
file: token.file,
|
|
175
|
+
source: token.source,
|
|
176
|
+
token_backed: token.token_backed,
|
|
177
|
+
line: token.line,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const result = Array.from(groupedMap.values());
|
|
182
|
+
|
|
183
|
+
if (DEBUG) console.log(`[buildDeclaredCssVars] Found ${allTokens.length} CSS variable tokens.`);
|
|
184
|
+
return result;
|
|
185
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Flatten W3C / Style Dictionary token JSON into a simple array.
|
|
6
|
+
*/
|
|
7
|
+
function flattenTokens(input, filePath = "", prefix = "") {
|
|
8
|
+
if (!input || typeof input !== "object") return [];
|
|
9
|
+
|
|
10
|
+
// Determine the correct root object to traverse
|
|
11
|
+
const root = (() => {
|
|
12
|
+
// Start from tokens if present, otherwise the whole input
|
|
13
|
+
const base = "tokens" in input && typeof input.tokens === "object" ? input.tokens : input;
|
|
14
|
+
|
|
15
|
+
// Preserve top-level modes / $themes even if tokens key exists
|
|
16
|
+
const merged = { ...base };
|
|
17
|
+
if (input.modes && typeof input.modes === "object") {
|
|
18
|
+
merged.modes = input.modes;
|
|
19
|
+
}
|
|
20
|
+
if (input.$themes && typeof input.$themes === "object") {
|
|
21
|
+
merged.$themes = input.$themes;
|
|
22
|
+
}
|
|
23
|
+
return merged;
|
|
24
|
+
})();
|
|
25
|
+
|
|
26
|
+
const out = [];
|
|
27
|
+
// The deduplication map is now declared inside each flattenTokens call,
|
|
28
|
+
// so deduplication is per-file and not across files.
|
|
29
|
+
const seen = new Map(); // dedupeKey -> token object
|
|
30
|
+
|
|
31
|
+
function inferType(name, explicitType) {
|
|
32
|
+
if (explicitType) return explicitType;
|
|
33
|
+
if (name.startsWith("color.")) return "color";
|
|
34
|
+
if (name.startsWith("space.") || name.startsWith("size.")) return "dimension";
|
|
35
|
+
if (name.startsWith("radius.") || name.startsWith("border.")) return "dimension";
|
|
36
|
+
if (name.startsWith("font-size.")) return "dimension";
|
|
37
|
+
if (name.startsWith("font.")) return "fontFamily";
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
for (const [key, val] of Object.entries(root)) {
|
|
42
|
+
const pathName = prefix ? `${prefix}.${key}` : key;
|
|
43
|
+
|
|
44
|
+
// Handle W3C $themes or Style Dictionary modes
|
|
45
|
+
if ((key === "$themes" || key === "modes") && val && typeof val === "object") {
|
|
46
|
+
for (const [themeName, themeTokens] of Object.entries(val)) {
|
|
47
|
+
if (!themeTokens || typeof themeTokens !== "object") continue;
|
|
48
|
+
for (const [themeTokenKey, themeTokenVal] of Object.entries(themeTokens)) {
|
|
49
|
+
if (!themeTokenVal || typeof themeTokenVal !== "object") continue;
|
|
50
|
+
const name = prefix ? `${prefix}.${themeTokenKey}` : themeTokenKey;
|
|
51
|
+
const theme = themeName || "default";
|
|
52
|
+
|
|
53
|
+
if ("$value" in themeTokenVal || "value" in themeTokenVal) {
|
|
54
|
+
const type = inferType(name, themeTokenVal.$type ?? themeTokenVal.type ?? null);
|
|
55
|
+
// Always push tokens from themes/modes without deduplication across files
|
|
56
|
+
out.push({
|
|
57
|
+
name,
|
|
58
|
+
value: themeTokenVal.$value ?? themeTokenVal.value,
|
|
59
|
+
type,
|
|
60
|
+
cssVar: `--${name.replace(/\./g, "-")}`,
|
|
61
|
+
source: "json",
|
|
62
|
+
file: filePath,
|
|
63
|
+
theme,
|
|
64
|
+
});
|
|
65
|
+
} else {
|
|
66
|
+
const nestedTokens = flattenTokens(themeTokenVal, filePath, name);
|
|
67
|
+
nestedTokens.forEach(t => {
|
|
68
|
+
t.theme = theme;
|
|
69
|
+
out.push(t);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// Handle CSS-like theme selectors e.g. .dark, .light
|
|
76
|
+
else if (
|
|
77
|
+
key.startsWith(".") &&
|
|
78
|
+
val && typeof val === "object"
|
|
79
|
+
) {
|
|
80
|
+
const themeName = key.slice(1) || "default";
|
|
81
|
+
const flattenedThemeTokens = flattenTokens(val, filePath, prefix);
|
|
82
|
+
flattenedThemeTokens.forEach(token => {
|
|
83
|
+
token.theme = themeName;
|
|
84
|
+
});
|
|
85
|
+
// Do not deduplicate theme selector tokens by value or name+theme.
|
|
86
|
+
// This ensures all dark-only tokens and multiple per-theme file entries are preserved.
|
|
87
|
+
flattenedThemeTokens.forEach(token => {
|
|
88
|
+
out.push(token);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
// W3C format ($value/$type)
|
|
92
|
+
else if (val && typeof val === "object" && ("$value" in val || "value" in val)) {
|
|
93
|
+
const name = pathName;
|
|
94
|
+
const theme = "default";
|
|
95
|
+
const dedupeKey = `${name}::${theme}::${filePath}`;
|
|
96
|
+
const type = inferType(name, val.$type ?? val.type ?? null);
|
|
97
|
+
const newToken = {
|
|
98
|
+
name,
|
|
99
|
+
value: val.$value ?? val.value,
|
|
100
|
+
type,
|
|
101
|
+
cssVar: `--${name.replace(/\./g, "-")}`,
|
|
102
|
+
source: "json",
|
|
103
|
+
file: filePath,
|
|
104
|
+
theme,
|
|
105
|
+
};
|
|
106
|
+
if (seen.has(dedupeKey)) {
|
|
107
|
+
// Merge logic for default tokens
|
|
108
|
+
const existing = seen.get(dedupeKey);
|
|
109
|
+
if (existing.file !== newToken.file) {
|
|
110
|
+
const files = new Set((existing.file + "," + newToken.file).split(","));
|
|
111
|
+
existing.file = Array.from(files).join(",");
|
|
112
|
+
}
|
|
113
|
+
if (existing.value === undefined && newToken.value !== undefined) {
|
|
114
|
+
existing.value = newToken.value;
|
|
115
|
+
}
|
|
116
|
+
if (existing.type === undefined && newToken.type !== undefined) {
|
|
117
|
+
existing.type = newToken.type;
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
out.push(newToken);
|
|
121
|
+
seen.set(dedupeKey, newToken);
|
|
122
|
+
}
|
|
123
|
+
} else if (val && typeof val === "object") {
|
|
124
|
+
const nestedTokens = flattenTokens(val, filePath, pathName);
|
|
125
|
+
nestedTokens.forEach(t => {
|
|
126
|
+
t.theme = t.theme || "default";
|
|
127
|
+
// For nested tokens, retain deduplication per file for non-theme tokens.
|
|
128
|
+
const dedupeKey = `${t.name}::${t.theme}::${filePath}`;
|
|
129
|
+
if (seen.has(dedupeKey)) {
|
|
130
|
+
// Merge logic for nested tokens
|
|
131
|
+
const existing = seen.get(dedupeKey);
|
|
132
|
+
if (existing.file !== t.file) {
|
|
133
|
+
const files = new Set((existing.file + "," + t.file).split(","));
|
|
134
|
+
existing.file = Array.from(files).join(",");
|
|
135
|
+
}
|
|
136
|
+
if (existing.value === undefined && t.value !== undefined) {
|
|
137
|
+
existing.value = t.value;
|
|
138
|
+
}
|
|
139
|
+
if (existing.type === undefined && t.type !== undefined) {
|
|
140
|
+
existing.type = t.type;
|
|
141
|
+
}
|
|
142
|
+
} else {
|
|
143
|
+
out.push(t);
|
|
144
|
+
seen.set(dedupeKey, t);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Alias expansion logic
|
|
151
|
+
const expanded = [...out];
|
|
152
|
+
for (const t of out) {
|
|
153
|
+
let refName = null;
|
|
154
|
+
if (t.name.startsWith("alias.")) {
|
|
155
|
+
refName = t.name.replace(/^alias\./, "");
|
|
156
|
+
} else if (typeof t.value === "string") {
|
|
157
|
+
const match = t.value.match(/^\{([^}]+)\}$/);
|
|
158
|
+
if (match) {
|
|
159
|
+
refName = match[1];
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (refName) {
|
|
163
|
+
expanded.push({
|
|
164
|
+
name: t.name.startsWith("alias.") ? refName : t.name,
|
|
165
|
+
cssVar: t.cssVar.replace(/^--alias-/, "--"),
|
|
166
|
+
value: `var(--${refName.replace(/\./g, "-")})`,
|
|
167
|
+
resolvedFrom: t.name,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return expanded;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Build declared_json model for ControlFront.
|
|
177
|
+
*
|
|
178
|
+
* Note: This implementation ensures that all dark mode tokens and
|
|
179
|
+
* multiple per-theme file entries are preserved in the output array,
|
|
180
|
+
* even if they have the same name, theme, and value, but come from different files.
|
|
181
|
+
* Deduplication now happens per file, not across files, and value-based deduplication
|
|
182
|
+
* for theme tokens has been removed.
|
|
183
|
+
*
|
|
184
|
+
* File discovery now happens in scan.js.
|
|
185
|
+
*/
|
|
186
|
+
export async function buildDeclaredJson({ projectRoot, files } = {}) {
|
|
187
|
+
const root = projectRoot || process.cwd();
|
|
188
|
+
const searchFiles = ["tokens.json", "tokens.w3c.json"];
|
|
189
|
+
const allFiles = Array.isArray(files) ? files : [];
|
|
190
|
+
|
|
191
|
+
const foundFiles = allFiles.filter(f => searchFiles.includes(path.basename(f)));
|
|
192
|
+
if (foundFiles.length === 0) return [];
|
|
193
|
+
|
|
194
|
+
const tokens = [];
|
|
195
|
+
for (const file of foundFiles) {
|
|
196
|
+
try {
|
|
197
|
+
// Normalize to absolute path
|
|
198
|
+
const absFile = path.resolve(file);
|
|
199
|
+
const rawContent = await fs.promises.readFile(absFile, "utf8");
|
|
200
|
+
const parsed = JSON.parse(rawContent);
|
|
201
|
+
const flattened = flattenTokens(parsed, path.relative(root, absFile));
|
|
202
|
+
if (Array.isArray(flattened)) {
|
|
203
|
+
tokens.push(...flattened);
|
|
204
|
+
}
|
|
205
|
+
} catch (err) {
|
|
206
|
+
console.warn(`⚠️ Failed to parse ${file}:`, err.message);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (!Array.isArray(tokens)) return [];
|
|
212
|
+
|
|
213
|
+
return tokens;
|
|
214
|
+
}
|