@fractaldesign/fractalstyler 0.0.0-stage → 0.9.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/LICENSE +19 -0
- package/README.md +123 -2
- package/cli/main.mjs +141 -0
- package/cli/scaffold.mjs +406 -0
- package/data/recipes.json +61 -0
- package/dist/asset.d.ts +3 -0
- package/dist/css/fractalstyler.css +2453 -0
- package/dist/css/fractalstyler.min.css +2 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/styles/_00_config.sass +29 -0
- package/dist/styles/_00_fonts.sass +58 -0
- package/dist/styles/_00_tokens.sass +211 -0
- package/dist/styles/_01_base.sass +58 -0
- package/dist/styles/_02_dimensions.sass +70 -0
- package/dist/styles/_03_typography.sass +170 -0
- package/dist/styles/_04_containers.sass +173 -0
- package/dist/styles/_05_layouts.sass +104 -0
- package/dist/styles/_06_shells.sass +135 -0
- package/dist/styles/_07_interactions.sass +212 -0
- package/dist/styles/_08_visuals.sass +136 -0
- package/dist/styles/_09_own.sass +174 -0
- package/dist/styles/colorpacks.sass +119 -0
- package/dist/styles/index.sass +14 -0
- package/dist/styles/themeplates.sass +159 -0
- package/docs/REGISTRY.api.md +477 -0
- package/docs/REGISTRY.md +14 -0
- package/docs/references/configurations-api.md +220 -0
- package/docs/references/configurations.md +258 -0
- package/lint/browser.mjs +79 -0
- package/lint/cli.mjs +212 -0
- package/lint/lib/agent-reporter.mjs +51 -0
- package/lint/lib/fuzzy.mjs +45 -0
- package/lint/lib/registry.mjs +107 -0
- package/lint/lib/sass-linter.mjs +71 -0
- package/lint/lib/svelte-linter.mjs +116 -0
- package/lint/lib/token-linter.mjs +97 -0
- package/package.json +112 -5
- package/registry.json +3999 -0
- package/scripts/class-vocab.js +197 -0
- package/scripts/update-registry.js +934 -0
- package/skills/fractal-styler/references/fractals.md +630 -0
- package/skills/fractal-styler/references/tokens.md +226 -0
- package/skills/fractalstyler/SKILL.md +59 -0
- package/skills/fractalstyler/references/fractals.md +630 -0
- package/skills/fractalstyler/references/tokens.md +226 -0
package/lint/cli.mjs
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// =============================================================================
|
|
3
|
+
// fractalstyler lint — the design system contract linter & agent guard.
|
|
4
|
+
//
|
|
5
|
+
// Verifies code against the styling system contract:
|
|
6
|
+
// - Svelte files: no scoped <style>, no style="..." attributes, all classes verified
|
|
7
|
+
// - Stylesheets: token purity (no raw hex, >2px budget, valid var(--tokens))
|
|
8
|
+
// - Sass files: valid interpolation, radius ladder usage
|
|
9
|
+
//
|
|
10
|
+
// Supports:
|
|
11
|
+
// Self mode: node lint/cli.mjs (inside fractalstyler repo)
|
|
12
|
+
// Consumer mode: node lint/cli.mjs <dir> (linting any project using fractalstyler)
|
|
13
|
+
// Flags: --json (agent/CI output), --own <file>, --registry <file>
|
|
14
|
+
// =============================================================================
|
|
15
|
+
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { loadRegistry } from './lib/registry.mjs';
|
|
20
|
+
import { lintSvelteFile } from './lib/svelte-linter.mjs';
|
|
21
|
+
import { TokenLinter } from './lib/token-linter.mjs';
|
|
22
|
+
import { lintUninterpolatedFunctions, lintHardcodedRadius } from './lib/sass-linter.mjs';
|
|
23
|
+
import { reportDiagnostics } from './lib/agent-reporter.mjs';
|
|
24
|
+
|
|
25
|
+
const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
26
|
+
|
|
27
|
+
function usage() {
|
|
28
|
+
console.log(`Usage:
|
|
29
|
+
fractalstyler lint # self mode: audit fractalstyler package
|
|
30
|
+
fractalstyler lint <dir> # consumer mode: audit project directory
|
|
31
|
+
Options:
|
|
32
|
+
--own <file> extension stylesheet (default <dir>/_09_own.sass)
|
|
33
|
+
--registry <f> registry.json override
|
|
34
|
+
--json machine-readable JSON diagnostics for agents and CI
|
|
35
|
+
--help show this help message`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const SVELTE = /\.svelte$/;
|
|
39
|
+
const STYLESHEET = /\.(sass|scss|css)$/;
|
|
40
|
+
|
|
41
|
+
function walk(dir, filter, out = [], base = dir) {
|
|
42
|
+
if (!fs.existsSync(dir)) return out;
|
|
43
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
44
|
+
if (
|
|
45
|
+
entry.name === 'node_modules' ||
|
|
46
|
+
entry.name === '.svelte-kit' ||
|
|
47
|
+
entry.name === 'dist' ||
|
|
48
|
+
entry.name === '.git' ||
|
|
49
|
+
entry.name === 'reserve' ||
|
|
50
|
+
entry.name.startsWith('.')
|
|
51
|
+
)
|
|
52
|
+
continue;
|
|
53
|
+
|
|
54
|
+
const full = path.join(base, entry.name);
|
|
55
|
+
if (entry.isDirectory()) walk(full, filter, out, full);
|
|
56
|
+
else if (filter(entry.name)) out.push(full);
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const rel = (root, file) => path.relative(root, file);
|
|
62
|
+
|
|
63
|
+
function harvestOwnClasses(content) {
|
|
64
|
+
const names = new Set();
|
|
65
|
+
for (const m of content.matchAll(/(?:^|[\s,{}])(\.)([a-zA-Z][a-zA-Z0-9_-]*)/g)) {
|
|
66
|
+
names.add(m[2]);
|
|
67
|
+
}
|
|
68
|
+
return names;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function collectLocalProperties(stylesheets) {
|
|
72
|
+
const names = new Set();
|
|
73
|
+
for (const file of stylesheets) {
|
|
74
|
+
try {
|
|
75
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
76
|
+
for (const m of content.matchAll(/(--[a-zA-Z0-9_-]+)\s*:/g)) {
|
|
77
|
+
names.add(m[1]);
|
|
78
|
+
}
|
|
79
|
+
} catch {}
|
|
80
|
+
}
|
|
81
|
+
return names;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function lintSelf(flags) {
|
|
85
|
+
const diagnostics = [];
|
|
86
|
+
const { classes, tokens, stems } = loadRegistry(PKG_ROOT, flags.registry);
|
|
87
|
+
const tokenLinter = new TokenLinter(PKG_ROOT, { tokens });
|
|
88
|
+
|
|
89
|
+
// 1. Lint Svelte files in src/
|
|
90
|
+
const svelteFiles = walk(path.join(PKG_ROOT, 'src'), (f) => SVELTE.test(f));
|
|
91
|
+
for (const file of svelteFiles) {
|
|
92
|
+
const source = fs.readFileSync(file, 'utf8');
|
|
93
|
+
const relPath = rel(PKG_ROOT, file);
|
|
94
|
+
const result = lintSvelteFile(relPath, source, { allowlist: classes, stems });
|
|
95
|
+
diagnostics.push(...result.diagnostics);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 2. Lint Sass files in src/lib/styles
|
|
99
|
+
const sassFiles = walk(path.join(PKG_ROOT, 'src', 'lib', 'styles'), (f) => STYLESHEET.test(f));
|
|
100
|
+
for (const file of sassFiles) {
|
|
101
|
+
const source = fs.readFileSync(file, 'utf8');
|
|
102
|
+
const relPath = rel(PKG_ROOT, file);
|
|
103
|
+
diagnostics.push(...lintUninterpolatedFunctions(relPath, source));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return diagnostics;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function lintConsumer(targetDir, flags) {
|
|
110
|
+
const diagnostics = [];
|
|
111
|
+
const targetRoot = path.resolve(targetDir);
|
|
112
|
+
|
|
113
|
+
// Load canonical registry from fractalstyler package
|
|
114
|
+
const { classes: canonicalClasses, tokens, stems } = loadRegistry(PKG_ROOT, flags.registry);
|
|
115
|
+
|
|
116
|
+
// Find consumer's own extension classes
|
|
117
|
+
const ownFile = flags.own
|
|
118
|
+
? path.resolve(flags.own)
|
|
119
|
+
: path.join(targetRoot, 'src', 'lib', 'styles', '_09_own.sass');
|
|
120
|
+
|
|
121
|
+
const ownClasses = new Set();
|
|
122
|
+
if (fs.existsSync(ownFile)) {
|
|
123
|
+
const ownContent = fs.readFileSync(ownFile, 'utf8');
|
|
124
|
+
for (const cls of harvestOwnClasses(ownContent)) {
|
|
125
|
+
ownClasses.add(cls);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Full allowlist = canonical registry union with own classes
|
|
130
|
+
const allowlist = new Set([...canonicalClasses, ...ownClasses]);
|
|
131
|
+
|
|
132
|
+
// Collect locally defined CSS variables across target
|
|
133
|
+
const stylesheets = walk(targetRoot, (f) => STYLESHEET.test(f));
|
|
134
|
+
const localProps = collectLocalProperties(stylesheets);
|
|
135
|
+
|
|
136
|
+
const tokenLinter = new TokenLinter(targetRoot, { tokens });
|
|
137
|
+
|
|
138
|
+
// Lint Svelte files
|
|
139
|
+
const svelteFiles = walk(targetRoot, (f) => SVELTE.test(f));
|
|
140
|
+
for (const file of svelteFiles) {
|
|
141
|
+
const source = fs.readFileSync(file, 'utf8');
|
|
142
|
+
const relPath = rel(targetRoot, file);
|
|
143
|
+
const result = lintSvelteFile(relPath, source, { allowlist, stems });
|
|
144
|
+
diagnostics.push(...result.diagnostics);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Lint stylesheets (skip canonical system files if present inside target)
|
|
148
|
+
for (const file of stylesheets) {
|
|
149
|
+
const base = path.basename(file);
|
|
150
|
+
// Layer partials _00 to _08 belong to system, and so do the palette
|
|
151
|
+
// modules (raw hex ladders by design — they are where the hex lives);
|
|
152
|
+
// _09_own belongs to the user and stays linted.
|
|
153
|
+
if (
|
|
154
|
+
/^_(0[0-8]|config|tokens|base|dimensions|containers|layouts|shells|visuals|interactions|typography)/.test(base) ||
|
|
155
|
+
/^(colorpacks|themeplates|index)\b/.test(base)
|
|
156
|
+
) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const source = fs.readFileSync(file, 'utf8');
|
|
161
|
+
const relPath = rel(targetRoot, file);
|
|
162
|
+
|
|
163
|
+
diagnostics.push(...tokenLinter.lintFile(relPath, source, localProps));
|
|
164
|
+
diagnostics.push(...lintUninterpolatedFunctions(relPath, source));
|
|
165
|
+
diagnostics.push(...lintHardcodedRadius(relPath, source));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return diagnostics;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function main() {
|
|
172
|
+
const args = process.argv.slice(2);
|
|
173
|
+
const flags = { json: false, own: null, registry: null };
|
|
174
|
+
const positional = [];
|
|
175
|
+
|
|
176
|
+
for (let i = 0; i < args.length; i++) {
|
|
177
|
+
const arg = args[i];
|
|
178
|
+
if (arg === '--help' || arg === '-h') {
|
|
179
|
+
usage();
|
|
180
|
+
process.exit(0);
|
|
181
|
+
} else if (arg === '--json') {
|
|
182
|
+
flags.json = true;
|
|
183
|
+
} else if (arg === '--own' && i + 1 < args.length) {
|
|
184
|
+
flags.own = args[++i];
|
|
185
|
+
} else if (arg === '--registry' && i + 1 < args.length) {
|
|
186
|
+
flags.registry = args[++i];
|
|
187
|
+
} else if (!arg.startsWith('-')) {
|
|
188
|
+
positional.push(arg);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const targetDir = positional[0] === 'lint' ? positional[1] : positional[0];
|
|
193
|
+
|
|
194
|
+
let diagnostics = [];
|
|
195
|
+
try {
|
|
196
|
+
if (!targetDir || targetDir === '.') {
|
|
197
|
+
diagnostics = await lintSelf(flags);
|
|
198
|
+
} else {
|
|
199
|
+
diagnostics = await lintConsumer(targetDir, flags);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
reportDiagnostics(diagnostics, { json: flags.json });
|
|
203
|
+
const hasErrors = diagnostics.some((d) => d.severity === 'error');
|
|
204
|
+
process.exit(hasErrors ? 1 : 0);
|
|
205
|
+
} catch (err) {
|
|
206
|
+
console.error(`\x1b[31mError during lint: ${err.message}\x1b[0m`);
|
|
207
|
+
if (process.env.DEBUG) console.error(err);
|
|
208
|
+
process.exit(2);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
main();
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Agent-first diagnostic formatter.
|
|
3
|
+
//
|
|
4
|
+
// Box-drawing output is deliberate: it survives terminal piping and gives
|
|
5
|
+
// coding agents an unambiguous, self-describing block per violation, with a
|
|
6
|
+
// prescriptive fix.
|
|
7
|
+
// =============================================================================
|
|
8
|
+
|
|
9
|
+
const LABEL = 'FRACTALSTYLER';
|
|
10
|
+
|
|
11
|
+
export function reportDiagnostics(diagnostics, options = {}) {
|
|
12
|
+
const { json = false, ok = 'All styling system contracts verified. 0 violations found.' } = options;
|
|
13
|
+
|
|
14
|
+
if (json) {
|
|
15
|
+
console.log(JSON.stringify(diagnostics, null, 2));
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (diagnostics.length === 0) {
|
|
20
|
+
console.log(`\n\x1b[32m✔ ${ok}\x1b[0m\n`);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
console.log(
|
|
25
|
+
`\n\x1b[1m\x1b[31m[${LABEL}] Found ${diagnostics.length} system contract violation${diagnostics.length > 1 ? 's' : ''}:\x1b[0m\n`
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
for (const d of diagnostics) {
|
|
29
|
+
const isError = d.severity === 'error';
|
|
30
|
+
const badgeColor = isError ? '\x1b[31m' : '\x1b[33m';
|
|
31
|
+
const loc = d.line ? `${d.file}:${d.line}${d.column ? `:${d.column}` : ''}` : d.file;
|
|
32
|
+
|
|
33
|
+
console.log(`${badgeColor}┌─ [${LABEL} ${d.severity.toUpperCase()}: ${d.rule}] ─────────────────────────────────────────────\x1b[0m`);
|
|
34
|
+
console.log(`│ \x1b[1mLocation:\x1b[0m ${loc}`);
|
|
35
|
+
console.log(`│ \x1b[1mFound:\x1b[0m \x1b[36m${d.found}\x1b[0m`);
|
|
36
|
+
console.log(`├─ \x1b[1m[SYSTEM RATIONALE]\x1b[0m`);
|
|
37
|
+
console.log(`│ ${d.rationale}`);
|
|
38
|
+
console.log(`├─ \x1b[1m\x1b[32m[PRESCRIPTIVE AGENT FIX]\x1b[0m`);
|
|
39
|
+
|
|
40
|
+
const suggestionLines = String(d.suggestion || '').split('\n');
|
|
41
|
+
for (const sLine of suggestionLines) {
|
|
42
|
+
console.log(`│ \x1b[32m${sLine}\x1b[0m`);
|
|
43
|
+
}
|
|
44
|
+
console.log(`${badgeColor}└─────────────────────────────────────────────────────────────────────────────────\x1b[0m\n`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const errors = diagnostics.filter((d) => d.severity === 'error').length;
|
|
48
|
+
const warnings = diagnostics.filter((d) => d.severity === 'warning').length;
|
|
49
|
+
|
|
50
|
+
console.log(`\x1b[1mResult: ${errors} error${errors === 1 ? '' : 's'}, ${warnings} warning${warnings === 1 ? '' : 's'}.\x1b[0m\n`);
|
|
51
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Levenshtein distance & fuzzy match suggestions.
|
|
3
|
+
// Zero dependencies. Used to suggest closest registered classes/tokens to agents.
|
|
4
|
+
// =============================================================================
|
|
5
|
+
|
|
6
|
+
function levenshtein(a, b) {
|
|
7
|
+
const m = a.length;
|
|
8
|
+
const n = b.length;
|
|
9
|
+
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
10
|
+
|
|
11
|
+
for (let i = 0; i <= m; i++) dp[i][0] = i;
|
|
12
|
+
for (let j = 0; j <= n; j++) dp[0][j] = j;
|
|
13
|
+
|
|
14
|
+
for (let i = 1; i <= m; i++) {
|
|
15
|
+
for (let j = 1; j <= n; j++) {
|
|
16
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
17
|
+
dp[i][j] = Math.min(
|
|
18
|
+
dp[i - 1][j] + 1, // deletion
|
|
19
|
+
dp[i][j - 1] + 1, // insertion
|
|
20
|
+
dp[i - 1][j - 1] + cost // substitution
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return dp[m][n];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Finds closest matches in a candidates set or array.
|
|
29
|
+
* @param {string} target
|
|
30
|
+
* @param {Iterable<string>} candidates
|
|
31
|
+
* @param {number} maxResults
|
|
32
|
+
* @param {number} maxDistance
|
|
33
|
+
* @returns {string[]}
|
|
34
|
+
*/
|
|
35
|
+
export function findClosestMatches(target, candidates, maxResults = 3, maxDistance = 4) {
|
|
36
|
+
const scored = [];
|
|
37
|
+
for (const cand of candidates) {
|
|
38
|
+
const dist = levenshtein(target, cand);
|
|
39
|
+
if (dist <= maxDistance) {
|
|
40
|
+
scored.push({ cand, dist });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
scored.sort((a, b) => a.dist - b.dist);
|
|
44
|
+
return scored.slice(0, maxResults).map((s) => s.cand);
|
|
45
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Registry loader — the source of truth for every class and token check.
|
|
3
|
+
//
|
|
4
|
+
// Reads registry.json and pairs it with compiled stylesheet verification
|
|
5
|
+
// so loop-generated wildcard families (.gap-*, .pad-*, .radius-*) and exact
|
|
6
|
+
// selectors are both known without guesswork.
|
|
7
|
+
// =============================================================================
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import * as sass from 'sass';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Loads the style registry for verification.
|
|
15
|
+
* @param {string} rootDir Root of the fractalstyler package or consumer project.
|
|
16
|
+
* @param {string} [registryPath] Path to registry.json override.
|
|
17
|
+
*/
|
|
18
|
+
export function loadRegistry(rootDir, registryPath) {
|
|
19
|
+
const defaultRegistry = path.join(rootDir, 'registry.json');
|
|
20
|
+
const regFile = registryPath || (fs.existsSync(defaultRegistry) ? defaultRegistry : path.join(rootDir, 'src', 'lib', 'data', 'registry.json'));
|
|
21
|
+
|
|
22
|
+
if (!fs.existsSync(regFile)) {
|
|
23
|
+
throw new Error(`registry.json not found at ${regFile} — run "pnpm registry" first.`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const data = JSON.parse(fs.readFileSync(regFile, 'utf8'));
|
|
27
|
+
const classes = new Set();
|
|
28
|
+
const tokens = new Set();
|
|
29
|
+
const ownLayerClasses = new Set();
|
|
30
|
+
const layerIds = new Set();
|
|
31
|
+
const layerOf = new Map();
|
|
32
|
+
const fileOfLayer = new Map();
|
|
33
|
+
const stems = [];
|
|
34
|
+
|
|
35
|
+
// 1. Process registry.json (handles both array format and { layers: [...] } format)
|
|
36
|
+
if (Array.isArray(data)) {
|
|
37
|
+
for (const entry of data) {
|
|
38
|
+
const rawClass = (entry.class || '').replace(/^\./, '').trim();
|
|
39
|
+
if (!rawClass) continue;
|
|
40
|
+
|
|
41
|
+
if (rawClass.includes('*')) {
|
|
42
|
+
stems.push(rawClass.replace(/\*.*$/, ''));
|
|
43
|
+
} else {
|
|
44
|
+
classes.add(rawClass);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const layer = entry.layer || 'L0';
|
|
48
|
+
layerIds.add(layer);
|
|
49
|
+
layerOf.set(rawClass, layer);
|
|
50
|
+
if (entry.file) fileOfLayer.set(layer, entry.file);
|
|
51
|
+
|
|
52
|
+
if (layer.toLowerCase().includes('own') || (entry.file && entry.file.includes('own'))) {
|
|
53
|
+
ownLayerClasses.add(rawClass);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} else if (data.layers && Array.isArray(data.layers)) {
|
|
57
|
+
for (const layer of data.layers) {
|
|
58
|
+
const isOwn = /own/i.test(layer.id || '') || /own/i.test(layer.file || '');
|
|
59
|
+
layerIds.add(layer.id);
|
|
60
|
+
if (!fileOfLayer.has(layer.id)) fileOfLayer.set(layer.id, layer.file || layer.id);
|
|
61
|
+
|
|
62
|
+
for (const item of layer.items || []) {
|
|
63
|
+
if (item.kind === 'class') {
|
|
64
|
+
const name = item.name.replace(/^\./, '');
|
|
65
|
+
if (name.includes('*')) {
|
|
66
|
+
stems.push(name.replace(/\*.*$/, ''));
|
|
67
|
+
} else {
|
|
68
|
+
classes.add(name);
|
|
69
|
+
}
|
|
70
|
+
if (!layerOf.has(name)) layerOf.set(name, layer.id);
|
|
71
|
+
if (isOwn) ownLayerClasses.add(name);
|
|
72
|
+
} else if (item.kind === 'token') {
|
|
73
|
+
tokens.add(item.name);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 2. Also harvest compiled stylesheet if index.sass exists (for exact wildcard matches)
|
|
80
|
+
const indexSass = path.join(rootDir, 'src', 'lib', 'styles', 'index.sass');
|
|
81
|
+
if (fs.existsSync(indexSass)) {
|
|
82
|
+
try {
|
|
83
|
+
const compiled = sass.compile(indexSass, { style: 'expanded' }).css;
|
|
84
|
+
for (const m of compiled.matchAll(/\.([a-zA-Z][\w-]*)/g)) {
|
|
85
|
+
classes.add(m[1]);
|
|
86
|
+
}
|
|
87
|
+
for (const m of compiled.matchAll(/(--[a-zA-Z0-9_-]+):/g)) {
|
|
88
|
+
tokens.add(m[1]);
|
|
89
|
+
}
|
|
90
|
+
} catch (e) {
|
|
91
|
+
// If standalone compile fails in consumer mode, fallback to registry.json
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 3. Fallback token extraction from _00_tokens.sass if tokens set is empty
|
|
96
|
+
if (tokens.size === 0) {
|
|
97
|
+
const tokenFile = path.join(rootDir, 'src', 'lib', 'styles', '_00_tokens.sass');
|
|
98
|
+
if (fs.existsSync(tokenFile)) {
|
|
99
|
+
const tokenContent = fs.readFileSync(tokenFile, 'utf8');
|
|
100
|
+
for (const m of tokenContent.matchAll(/(--[a-zA-Z0-9_-]+):/g)) {
|
|
101
|
+
tokens.add(m[1]);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { classes, tokens, ownLayerClasses, layerIds, layerOf, fileOfLayer, stems };
|
|
107
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Sass declaration linters for fractalstyler.
|
|
3
|
+
//
|
|
4
|
+
// 1. fs/sass-interpolation — Sass parses custom-property values literally.
|
|
5
|
+
// Functions like list.nth(...) or map.get(...) inside `--name: ...`
|
|
6
|
+
// must be wrapped in `#{...}` to be evaluated rather than emitted verbatim.
|
|
7
|
+
//
|
|
8
|
+
// 2. fs/hardcoded-radius — Corners should route through the radius ladder
|
|
9
|
+
// `var(--radius-*)` so theme shape modifiers can restyle them.
|
|
10
|
+
// =============================================================================
|
|
11
|
+
|
|
12
|
+
const SASS_NAMESPACED = /\b(?:map|list|math|color|string|meta|selector|sass)\.[a-z-]+\s*\(/;
|
|
13
|
+
const SASS_CORE =
|
|
14
|
+
/\b(?:if|nth|append|join|zip|index|list-separator|is-bracketed|length|set-nth|unit|unitless|percentage|floor|ceil|abs|random|quote|unquote|str-length|str-insert|str-index|str-slice|to-upper-case|to-lower-case|type-of|comparable|call|get-function|inspect|variable-exists|function-exists|global-variable-exists|feature-exists|keywords|mix|lighten|darken|saturate|desaturate|grayscale|invert|opacify|fade-in|transparentize|fade-out|adjust-hue|scale-color|change-color|adjust-color|ie-hex-str|red|green|blue|hue|saturation|lightness|alpha|opacity)\s*\(/;
|
|
15
|
+
|
|
16
|
+
export function lintUninterpolatedFunctions(relPath, content) {
|
|
17
|
+
const diagnostics = [];
|
|
18
|
+
content.split('\n').forEach((line, idx) => {
|
|
19
|
+
const cleanLine = line.replace(/\/\/.*$/, '').replace(/\/\*[\s\S]*?\*\//, '');
|
|
20
|
+
const decl = cleanLine.match(/(^|\s)(--[a-zA-Z0-9_-]+)\s*:\s*(.+)$/);
|
|
21
|
+
if (!decl) return;
|
|
22
|
+
|
|
23
|
+
const value = decl[3].replace(/#\{[\s\S]*?\}/g, '');
|
|
24
|
+
const hit = value.match(SASS_NAMESPACED) || value.match(SASS_CORE);
|
|
25
|
+
if (!hit) return;
|
|
26
|
+
|
|
27
|
+
diagnostics.push({
|
|
28
|
+
rule: 'fs/sass-interpolation',
|
|
29
|
+
severity: 'error',
|
|
30
|
+
file: relPath,
|
|
31
|
+
line: idx + 1,
|
|
32
|
+
column: line.indexOf(decl[2]) + 1,
|
|
33
|
+
found: `${decl[2]}: ${decl[3].trim()}`,
|
|
34
|
+
rationale:
|
|
35
|
+
'Sass parses custom-property values literally: this function call is NOT evaluated — its source text ships to the browser as the value. Only #{…} interpolation is evaluated inside a custom property.',
|
|
36
|
+
suggestion: `Wrap the function call in interpolation: ${decl[2]}: #{${decl[3].trim()}} — or compute the value into a Sass variable first.`
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
return diagnostics;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const RADIUS_OK = /^(?:0(?:px)?|inherit|initial|unset|var\(--radius[^)]*\))$/;
|
|
43
|
+
|
|
44
|
+
export function lintHardcodedRadius(relPath, content) {
|
|
45
|
+
const diagnostics = [];
|
|
46
|
+
content.split('\n').forEach((line, idx) => {
|
|
47
|
+
const cleanLine = line.replace(/\/\/.*$/, '').replace(/\/\*[\s\S]*?\*\//, '');
|
|
48
|
+
const decl = cleanLine.match(/(^|\s)(border-radius)\s*:\s*(.+)$/);
|
|
49
|
+
if (!decl) return;
|
|
50
|
+
|
|
51
|
+
const bad = decl[3]
|
|
52
|
+
.replace(/#\{[^}]*\}/g, ' ')
|
|
53
|
+
.trim()
|
|
54
|
+
.split(/\s+/)
|
|
55
|
+
.filter((token) => token && !RADIUS_OK.test(token));
|
|
56
|
+
if (bad.length === 0) return;
|
|
57
|
+
|
|
58
|
+
diagnostics.push({
|
|
59
|
+
rule: 'fs/hardcoded-radius',
|
|
60
|
+
severity: 'warning',
|
|
61
|
+
file: relPath,
|
|
62
|
+
line: idx + 1,
|
|
63
|
+
column: line.indexOf(decl[2]) + 1,
|
|
64
|
+
found: `border-radius: ${decl[3].trim()}`,
|
|
65
|
+
rationale:
|
|
66
|
+
'Literal border-radius values are unreachable by the theme shape axis. Data attributes rewrite the --radius-* ladder.',
|
|
67
|
+
suggestion: `Route the corner through the ladder — var(--radius-xs/sm/md/bs/lg/xl) or system radius classes.`
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
return diagnostics;
|
|
71
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Svelte contract scanner for fractalstyler.
|
|
3
|
+
//
|
|
4
|
+
// Zero-dependency regex scanner that runs via CLI or in CI. Enforces:
|
|
5
|
+
// fs/no-in-component-styles — no scoped <style> blocks.
|
|
6
|
+
// fs/no-inline-styles — no style="..." attributes.
|
|
7
|
+
// fs/unknown-class — every class used in markup must exist in registry.
|
|
8
|
+
// =============================================================================
|
|
9
|
+
|
|
10
|
+
import { findClosestMatches } from './fuzzy.mjs';
|
|
11
|
+
|
|
12
|
+
const CLASS_ATTR = /\sclass\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
|
|
13
|
+
const CLASS_DIRECTIVE = /\sclass:([a-zA-Z0-9_-]+)/g;
|
|
14
|
+
const STYLE_BLOCK = /<style[\s>]/;
|
|
15
|
+
const STYLE_ATTR = /\sstyle\s*=/;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Lints a Svelte file for styling system compliance.
|
|
19
|
+
* @param {string} relPath
|
|
20
|
+
* @param {string} source
|
|
21
|
+
* @param {{ allowlist?: Set<string>, stems?: string[] }} [options]
|
|
22
|
+
* @returns {{ diagnostics: Array, classes: Set<string> }}
|
|
23
|
+
*/
|
|
24
|
+
export function lintSvelteFile(relPath, source, options = {}) {
|
|
25
|
+
const diagnostics = [];
|
|
26
|
+
const classes = new Set();
|
|
27
|
+
const { allowlist, stems = [] } = options;
|
|
28
|
+
|
|
29
|
+
// Rule 1: No scoped <style> blocks
|
|
30
|
+
if (STYLE_BLOCK.test(source)) {
|
|
31
|
+
const line = source.slice(0, source.search(STYLE_BLOCK)).split('\n').length;
|
|
32
|
+
diagnostics.push({
|
|
33
|
+
rule: 'fs/no-in-component-styles',
|
|
34
|
+
severity: 'error',
|
|
35
|
+
file: relPath,
|
|
36
|
+
line,
|
|
37
|
+
found: '<style> block detected',
|
|
38
|
+
rationale:
|
|
39
|
+
'Components must remain pure markup & logic. Scoped styles fragment the design system, block global theme changes, and leak ad-hoc CSS.',
|
|
40
|
+
suggestion:
|
|
41
|
+
'Remove the <style> block and express the design using fractalstyler classes. If a class is missing, add it to the designated layer in src/lib/styles or your _09_own.sass.'
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Strip <script> content first so JS assignments like `style = ...` are not flagged
|
|
46
|
+
const scriptStart = source.indexOf('<script');
|
|
47
|
+
let markup = source;
|
|
48
|
+
if (scriptStart !== -1) {
|
|
49
|
+
const scriptEnd = source.indexOf('</script>', scriptStart);
|
|
50
|
+
if (scriptEnd !== -1) {
|
|
51
|
+
markup = source.slice(0, scriptStart) + source.slice(scriptEnd + '</script>'.length);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Rule 2: No inline style="..." attributes
|
|
56
|
+
if (STYLE_ATTR.test(markup)) {
|
|
57
|
+
const match = markup.match(STYLE_ATTR);
|
|
58
|
+
const line = markup.slice(0, match.index).split('\n').length;
|
|
59
|
+
diagnostics.push({
|
|
60
|
+
rule: 'fs/no-inline-styles',
|
|
61
|
+
severity: 'error',
|
|
62
|
+
file: relPath,
|
|
63
|
+
line,
|
|
64
|
+
found: match[0].trim(),
|
|
65
|
+
rationale:
|
|
66
|
+
'Inline styles are unconstrained ad-hoc CSS: invisible to the registry, unreachable by theme switches, and unaccountable to the design system.',
|
|
67
|
+
suggestion:
|
|
68
|
+
'Express the styling intent using canonical classes (e.g. .w-full, .pad-sm, .gap-md). Never patch elements with inline CSS.'
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Harvest classes
|
|
73
|
+
for (const m of markup.matchAll(CLASS_ATTR)) {
|
|
74
|
+
const value = m[1] ?? m[2] ?? '';
|
|
75
|
+
for (const name of value.split(/\s+/)) {
|
|
76
|
+
// Skip interpolated fragments (class="x {expr} y")
|
|
77
|
+
if (name && !/[{()}]/.test(name) && !name.startsWith('{')) {
|
|
78
|
+
classes.add(name);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
for (const m of markup.matchAll(CLASS_DIRECTIVE)) {
|
|
83
|
+
classes.add(m[1]);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Rule 3: Check unknown classes against allowlist if provided
|
|
87
|
+
if (allowlist && allowlist.size > 0) {
|
|
88
|
+
for (const cls of classes) {
|
|
89
|
+
// Check exact match, modifier, or stem match
|
|
90
|
+
const isAllowed =
|
|
91
|
+
allowlist.has(cls) ||
|
|
92
|
+
stems.some((stem) => cls.startsWith(stem)) ||
|
|
93
|
+
/-(mob|desk)$/.test(cls);
|
|
94
|
+
|
|
95
|
+
if (!isAllowed) {
|
|
96
|
+
const closest = findClosestMatches(cls, allowlist, 3, 4);
|
|
97
|
+
const suggestion =
|
|
98
|
+
closest.length > 0
|
|
99
|
+
? `Class ".${cls}" is not in the system registry. Did you mean one of these?\n - .${closest.join('\n - .')}`
|
|
100
|
+
: `Class ".${cls}" is not in the system registry. Compose using existing classes in REGISTRY.md, or add it to the appropriate system partial in src/lib/styles (or _09_own.sass).`;
|
|
101
|
+
|
|
102
|
+
diagnostics.push({
|
|
103
|
+
rule: 'fs/unknown-class',
|
|
104
|
+
severity: 'error',
|
|
105
|
+
file: relPath,
|
|
106
|
+
found: `.${cls}`,
|
|
107
|
+
rationale:
|
|
108
|
+
`The closed-world styling contract forbids ad-hoc or hallucinated classes. Every class must resolve to the verified system registry.`,
|
|
109
|
+
suggestion
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { diagnostics, classes };
|
|
116
|
+
}
|