@santi020k/lumen 1.0.0 → 1.1.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
CHANGED
|
@@ -55,7 +55,9 @@ Complete product recipes are also bundled for `analytics-dashboard`, `saas-admin
|
|
|
55
55
|
`dashboard-shell`, and `validated-form`.
|
|
56
56
|
|
|
57
57
|
Run `lumen doctor` to check adapter/style agreement, Tailwind layer order, Astro runtime mounts,
|
|
58
|
-
and fragile internal selector dependencies.
|
|
58
|
+
and fragile internal selector dependencies. In a workspace, diagnostics are scoped to the nearest
|
|
59
|
+
package boundary, generated build trees are ignored, and application-controlled Astro `Toggle`
|
|
60
|
+
instances do not require the shared runtime. Use `--json` for CI and rollout automation. `lumen
|
|
59
61
|
init --framework <astro|react|elements> [--tailwind]` prints the canonical non-destructive setup.
|
|
60
62
|
|
|
61
63
|
Run `lumen audit-tokens [path]` before incremental adoption when an existing stylesheet may already
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"integration-diagnostics.d.ts","sourceRoot":"","sources":["../src/integration-diagnostics.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"integration-diagnostics.d.ts","sourceRoot":"","sources":["../src/integration-diagnostics.ts"],"names":[],"mappings":"AAWA,MAAM,MAAM,uBAAuB,GAAG,UAAU,GAAG,OAAO,CAAA;AAE1D,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,EAAE,MAAM,CAAA;IACf,WAAW,EAAE,MAAM,CAAA;IACnB,IAAI,EAAE,MAAM,CAAA;IACZ,QAAQ,EAAE,uBAAuB,CAAA;CAClC;AAED,MAAM,WAAW,qBAAqB;IACpC,kBAAkB,EAAE,MAAM,CAAA;IAC1B,QAAQ,EAAE,sBAAsB,EAAE,CAAA;IAClC,UAAU,EAAE,CAAC,OAAO,GAAG,UAAU,GAAG,OAAO,CAAC,EAAE,CAAA;IAC9C,WAAW,EAAE,MAAM,CAAA;IACnB,OAAO,EAAE,OAAO,CAAA;IAChB,UAAU,EAAE,MAAM,CAAA;IAClB,iBAAiB,EAAE,MAAM,EAAE,CAAA;CAC5B;AAoZD,eAAO,MAAM,uBAAuB,GAAU,YAAY,MAAM,KAAG,OAAO,CAAC,qBAAqB,CA6C/F,CAAA;AAED,eAAO,MAAM,sBAAsB,GAAI,QAAQ,qBAAqB,KAAG,MAWtE,CAAA;AAED,eAAO,MAAM,gBAAgB,GAAI,WAAW,OAAO,GAAG,UAAU,GAAG,OAAO,EAAE,kBAAgB,KAAG,MAsC9F,CAAA"}
|
|
@@ -1,25 +1,48 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
3
|
-
import { extname, join, relative, resolve } from 'node:path';
|
|
4
|
-
import { lumenComponentBehavior, lumenStylingContracts } from '@santi020k/lumen-core';
|
|
3
|
+
import { extname, join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { lumenComponentBehavior, lumenGlobalBehaviors, lumenStylingContracts } from '@santi020k/lumen-core';
|
|
5
5
|
const sourceExtensions = new Set(['.astro', '.css', '.js', '.jsx', '.mjs', '.ts', '.tsx']);
|
|
6
|
-
const ignoredDirectories = new Set([
|
|
6
|
+
const ignoredDirectories = new Set([
|
|
7
|
+
'.astro',
|
|
8
|
+
'.git',
|
|
9
|
+
'.next',
|
|
10
|
+
'.open-next',
|
|
11
|
+
'.output',
|
|
12
|
+
'.turbo',
|
|
13
|
+
'.vercel',
|
|
14
|
+
'.vite',
|
|
15
|
+
'.wrangler',
|
|
16
|
+
'build',
|
|
17
|
+
'coverage',
|
|
18
|
+
'dist',
|
|
19
|
+
'node_modules',
|
|
20
|
+
'out'
|
|
21
|
+
]);
|
|
7
22
|
const publicRootSlots = new Set(Object.values(lumenStylingContracts).flatMap(contract => [contract.rootSlot, ...contract.parts]));
|
|
8
|
-
const
|
|
9
|
-
const
|
|
23
|
+
const discoverProject = async (root) => {
|
|
24
|
+
const packageRoots = [];
|
|
25
|
+
const sourceFiles = [];
|
|
10
26
|
const visit = async (directory) => {
|
|
11
27
|
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
12
28
|
if (ignoredDirectories.has(entry.name))
|
|
13
29
|
continue;
|
|
14
30
|
const path = join(directory, entry.name);
|
|
15
|
-
if (entry.isDirectory())
|
|
31
|
+
if (entry.isDirectory()) {
|
|
16
32
|
await visit(path);
|
|
17
|
-
|
|
18
|
-
|
|
33
|
+
}
|
|
34
|
+
else if (entry.name === 'package.json') {
|
|
35
|
+
packageRoots.push(directory);
|
|
36
|
+
}
|
|
37
|
+
else if (sourceExtensions.has(extname(entry.name))) {
|
|
38
|
+
sourceFiles.push(path);
|
|
39
|
+
}
|
|
19
40
|
}
|
|
20
41
|
};
|
|
21
42
|
await visit(root);
|
|
22
|
-
|
|
43
|
+
if (!packageRoots.includes(root))
|
|
44
|
+
packageRoots.push(root);
|
|
45
|
+
return { packageRoots, sourceFiles };
|
|
23
46
|
};
|
|
24
47
|
const importedNames = (source, packageName) => {
|
|
25
48
|
const names = [];
|
|
@@ -34,11 +57,48 @@ const importedNames = (source, packageName) => {
|
|
|
34
57
|
return names;
|
|
35
58
|
};
|
|
36
59
|
const finding = (file, rule, message, remediation, severity = 'error') => ({ file, message, remediation, rule, severity });
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
60
|
+
const packageNamePattern = String.raw `@santi020k\/lumen-(?:astro|elements|react)(?:\/[^'"]*)?`;
|
|
61
|
+
const packageReferencePattern = new RegExp(String.raw `(?:^|\n)\s*(?:export|import)\s+(?:type\s+)?(?:[^'"\n]*?\s+from\s+)?['"]` +
|
|
62
|
+
String.raw `(${packageNamePattern})['"]`, 'g');
|
|
63
|
+
const dynamicPackageReferencePattern = new RegExp(String.raw `import\(\s*['"](${packageNamePattern})['"]\s*\)`, 'g');
|
|
64
|
+
const cssPackageReferencePattern = new RegExp(String.raw `(?:^|\n)\s*@import\s+(?:url\(\s*)?['"](${packageNamePattern})['"]`, 'g');
|
|
65
|
+
const layoutFilePattern = /(?:^|\/)(?:layouts?\/[^/]+|[^/]*layout[^/]*)\.astro$/iu;
|
|
66
|
+
const getPackageReferences = (source) => [
|
|
67
|
+
...source.matchAll(packageReferencePattern),
|
|
68
|
+
...source.matchAll(dynamicPackageReferencePattern),
|
|
69
|
+
...source.matchAll(cssPackageReferencePattern)
|
|
70
|
+
].flatMap(match => match[1] ? [match[1]] : []);
|
|
71
|
+
const packageFramework = (packageName) => {
|
|
72
|
+
const framework = /^@santi020k\/lumen-(astro|elements|react)(?:\/|$)/u.exec(packageName)?.[1];
|
|
73
|
+
return framework === 'astro' || framework === 'elements' || framework === 'react' ?
|
|
74
|
+
framework :
|
|
75
|
+
undefined;
|
|
76
|
+
};
|
|
77
|
+
const getFrameworks = (sources) => [
|
|
78
|
+
...new Set(sources.flatMap(item => getPackageReferences(item.source))
|
|
79
|
+
.filter(packageName => !packageName.endsWith('/styles.css') &&
|
|
80
|
+
!packageName.endsWith('/layers.css'))
|
|
81
|
+
.flatMap(packageName => {
|
|
82
|
+
const framework = packageFramework(packageName);
|
|
83
|
+
return framework ? [framework] : [];
|
|
84
|
+
}))
|
|
85
|
+
];
|
|
86
|
+
const getBoundaryRoot = (file, packageRoots, fallback) => packageRoots
|
|
87
|
+
.filter(packageRoot => file === packageRoot || file.startsWith(`${packageRoot}${sep}`))
|
|
88
|
+
.sort((left, right) => right.length - left.length)[0] ?? fallback;
|
|
89
|
+
const createBoundaries = (root, packageRoots, sources) => {
|
|
90
|
+
const grouped = new Map();
|
|
91
|
+
for (const source of sources) {
|
|
92
|
+
const boundaryRoot = getBoundaryRoot(source.file, packageRoots, root);
|
|
93
|
+
const entries = grouped.get(boundaryRoot) ?? [];
|
|
94
|
+
entries.push(source);
|
|
95
|
+
grouped.set(boundaryRoot, entries);
|
|
96
|
+
}
|
|
97
|
+
return [...grouped].map(([boundaryRoot, boundarySources]) => ({
|
|
98
|
+
root: boundaryRoot,
|
|
99
|
+
sources: boundarySources
|
|
100
|
+
}));
|
|
101
|
+
};
|
|
42
102
|
const collectCompatibilityFindings = (root, sources) => sources.flatMap(item => {
|
|
43
103
|
const file = relative(root, item.file);
|
|
44
104
|
const findings = [];
|
|
@@ -60,25 +120,86 @@ const getCatalogFingerprint = (sources) => {
|
|
|
60
120
|
const contracts = names.map(name => [name, lumenComponentBehavior[name]]);
|
|
61
121
|
return createHash('sha256').update(JSON.stringify(contracts)).digest('hex');
|
|
62
122
|
};
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
const
|
|
123
|
+
const hasOnlyRuntimeBypassedComponents = (source, componentName, bypassAttribute) => {
|
|
124
|
+
const tagPattern = new RegExp(`<${componentName}(?=\\s|/|>)([^>]*)>`, 'g');
|
|
125
|
+
const tags = [...source.matchAll(tagPattern)];
|
|
126
|
+
const falsePattern = new RegExp(String.raw `\b${bypassAttribute}\s*=\s*(?:\{false\}|["']false["'])`, 'u');
|
|
127
|
+
const truePattern = new RegExp(String.raw `\b${bypassAttribute}(?:\s|/|$)|` +
|
|
128
|
+
String.raw `\b${bypassAttribute}\s*=\s*(?:\{true\}|["']true["'])`, 'u');
|
|
129
|
+
return tags.length > 0 && tags.every(match => {
|
|
130
|
+
const attributes = match[1] ?? '';
|
|
131
|
+
if (falsePattern.test(attributes))
|
|
132
|
+
return false;
|
|
133
|
+
return truePattern.test(attributes);
|
|
134
|
+
});
|
|
135
|
+
};
|
|
136
|
+
const componentNeedsAstroRuntime = (name, importingSources) => {
|
|
137
|
+
if (!(name in lumenComponentBehavior))
|
|
138
|
+
return false;
|
|
139
|
+
const behavior = lumenComponentBehavior[name];
|
|
140
|
+
if (behavior.astro !== 'ui-primitives')
|
|
141
|
+
return false;
|
|
142
|
+
if ('astroRuntimeBypass' in behavior && importingSources.length > 0 &&
|
|
143
|
+
importingSources.every(item => hasOnlyRuntimeBypassedComponents(item.source, name, behavior.astroRuntimeBypass)))
|
|
144
|
+
return false;
|
|
145
|
+
return true;
|
|
146
|
+
};
|
|
147
|
+
const getRuntimeImports = (sources) => {
|
|
148
|
+
const imports = sources.flatMap(item => importedNames(item.source, '@santi020k/lumen-astro')
|
|
149
|
+
.map(name => ({ file: item.file, name })));
|
|
150
|
+
return imports.filter(item => componentNeedsAstroRuntime(item.name, sources.filter(source => importedNames(source.source, '@santi020k/lumen-astro').includes(item.name))));
|
|
151
|
+
};
|
|
152
|
+
const getGlobalRuntimeBehaviors = (sources) => lumenGlobalBehaviors
|
|
153
|
+
.filter(behavior => sources.some(item => {
|
|
154
|
+
if (behavior.name === 'form-validation')
|
|
155
|
+
return item.source.includes('data-ui-form');
|
|
156
|
+
return behavior.authoredBy.split(',').some(eventName => item.source.includes(eventName.trim()));
|
|
157
|
+
}))
|
|
158
|
+
.map(behavior => behavior.name);
|
|
159
|
+
const collectRuntimeFindings = (repositoryRoot, boundary) => {
|
|
160
|
+
const runtimeImports = getRuntimeImports(boundary.sources);
|
|
161
|
+
const runtimeComponents = [
|
|
162
|
+
...new Set([
|
|
163
|
+
...runtimeImports.map(item => item.name),
|
|
164
|
+
...getGlobalRuntimeBehaviors(boundary.sources)
|
|
165
|
+
])
|
|
166
|
+
];
|
|
167
|
+
const mountSources = boundary.sources.filter(item => /<UIPrimitives(?:\s|\/|>)/u.test(item.source));
|
|
66
168
|
const findings = [];
|
|
67
|
-
if (runtimeComponents.length > 0 &&
|
|
68
|
-
|
|
169
|
+
if (runtimeComponents.length > 0 && mountSources.length === 0) {
|
|
170
|
+
const firstRuntimeImport = runtimeImports[0];
|
|
171
|
+
findings.push(finding(firstRuntimeImport ? relative(repositoryRoot, firstRuntimeImport.file) : relative(repositoryRoot, boundary.root), 'astro-runtime-missing', `Interactive Astro usage requires UIPrimitives: ${runtimeComponents.join(', ')}.`, 'Mount <UIPrimitives /> once in the application root, or use a documented controlled prop when application code owns the behavior.'));
|
|
69
172
|
}
|
|
70
|
-
for (const mount of
|
|
71
|
-
|
|
173
|
+
for (const mount of mountSources) {
|
|
174
|
+
const mountCount = [...mount.source.matchAll(/<UIPrimitives(?:\s|\/|>)/g)].length;
|
|
175
|
+
if (mountCount > 1) {
|
|
176
|
+
findings.push(finding(relative(repositoryRoot, mount.file), 'astro-runtime-duplicate', 'UIPrimitives is mounted more than once in this file.', 'Keep one mount in the application root layout.'));
|
|
177
|
+
}
|
|
72
178
|
}
|
|
73
|
-
|
|
179
|
+
const layoutMounts = mountSources.filter(item => layoutFilePattern.test(item.file));
|
|
180
|
+
if (layoutMounts.length > 0) {
|
|
181
|
+
for (const mount of mountSources.filter(item => !layoutMounts.includes(item))) {
|
|
182
|
+
findings.push(finding(relative(repositoryRoot, mount.file), 'astro-runtime-duplicate', 'UIPrimitives is mounted in both a layout and a route within this application boundary.', 'Keep the layout mount and remove the route-level mount.'));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return { findings, runtimeComponents };
|
|
74
186
|
};
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
187
|
+
const styleImportByFramework = {
|
|
188
|
+
astro: '@santi020k/lumen-astro/styles.css',
|
|
189
|
+
elements: '@santi020k/lumen-elements/styles.css',
|
|
190
|
+
react: '@santi020k/lumen-react/styles.css'
|
|
191
|
+
};
|
|
192
|
+
const countOccurrences = (source, value) => source.split(value).length - 1;
|
|
193
|
+
const collectCssFindings = (root, sources) => sources
|
|
194
|
+
.filter(item => item.file.endsWith('.css'))
|
|
195
|
+
.flatMap(item => {
|
|
196
|
+
const layerIndex = item.source.search(/^\s*@import\s+(?:url\(\s*)?["'][^"']*\/layers\.css["']/mu);
|
|
197
|
+
const tailwindIndex = item.source.search(/^\s*@import\s+(?:url\(\s*)?["']tailwindcss["']/mu);
|
|
198
|
+
const styleIndex = item.source.search(/^\s*@import\s+(?:url\(\s*)?["'][^"']*\/styles\.css["']/mu);
|
|
79
199
|
const findings = [];
|
|
80
200
|
if (tailwindIndex >= 0 &&
|
|
81
|
-
|
|
201
|
+
styleIndex >= 0 &&
|
|
202
|
+
(layerIndex < 0 || !(layerIndex < tailwindIndex && tailwindIndex < styleIndex))) {
|
|
82
203
|
findings.push(finding(relative(root, item.file), 'tailwind-layer-order', 'Tailwind and Lumen imports do not use the verified cascade order.', 'Import Lumen layers.css, then tailwindcss, then the matching Lumen styles.css.'));
|
|
83
204
|
}
|
|
84
205
|
for (const match of item.source.matchAll(/\.ui-([a-z0-9-]+)/g)) {
|
|
@@ -89,52 +210,62 @@ const collectCssFindings = (root, sources) => sources.filter(item => item.file.e
|
|
|
89
210
|
}
|
|
90
211
|
return findings;
|
|
91
212
|
});
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
react: '@santi020k/lumen-react/styles.css'
|
|
96
|
-
};
|
|
97
|
-
const collectFrameworkStyleFindings = (root, sources, frameworks) => frameworks.flatMap(framework => {
|
|
98
|
-
const references = sources.filter(item => item.source.includes(styleImportByFramework[framework]));
|
|
213
|
+
const collectFrameworkStyleFindings = (repositoryRoot, boundary, frameworks) => frameworks.flatMap(framework => {
|
|
214
|
+
const styleImport = styleImportByFramework[framework];
|
|
215
|
+
const references = boundary.sources.filter(item => item.source.includes(styleImport));
|
|
99
216
|
if (references.length === 0) {
|
|
100
|
-
return [finding('
|
|
217
|
+
return [finding(relative(repositoryRoot, boundary.root) || '.', 'framework-style-missing', `No ${styleImport} import was found in this application boundary.`, 'Load the matching adapter stylesheet once in the application root.')];
|
|
101
218
|
}
|
|
102
|
-
return references.
|
|
219
|
+
return references.flatMap(item => countOccurrences(item.source, styleImport) > 1 ?
|
|
220
|
+
[finding(relative(repositoryRoot, item.file), 'framework-style-duplicate', `The ${framework} stylesheet is loaded more than once in this file.`, 'Keep one stylesheet import in the application root.')] :
|
|
221
|
+
[]);
|
|
103
222
|
});
|
|
104
|
-
const collectAdapterMismatchFindings = (
|
|
105
|
-
const adapters =
|
|
106
|
-
.
|
|
107
|
-
|
|
223
|
+
const collectAdapterMismatchFindings = (repositoryRoot, boundary, frameworks) => {
|
|
224
|
+
const adapters = Object.entries(styleImportByFramework)
|
|
225
|
+
.flatMap(([framework, styleImport]) => boundary.sources.some(item => item.source.includes(styleImport)) ?
|
|
226
|
+
[framework] :
|
|
227
|
+
[]);
|
|
108
228
|
return [...new Set(adapters)]
|
|
109
229
|
.filter(adapter => !frameworks.includes(adapter))
|
|
110
|
-
.map(adapter => finding('
|
|
230
|
+
.map(adapter => finding(relative(repositoryRoot, boundary.root) || '.', 'adapter-style-mismatch', `The ${adapter} stylesheet is loaded without matching ${adapter} imports in this application boundary.`, 'Load the stylesheet from the adapter used by this application boundary.'));
|
|
111
231
|
};
|
|
232
|
+
const uniqueFindings = (findings) => [
|
|
233
|
+
...new Map(findings.map(item => [
|
|
234
|
+
`${item.file}\u0000${item.rule}\u0000${item.message}`,
|
|
235
|
+
item
|
|
236
|
+
])).values()
|
|
237
|
+
];
|
|
112
238
|
export const inspectLumenIntegration = async (repository) => {
|
|
113
239
|
const root = resolve(repository);
|
|
114
240
|
if (!(await stat(root)).isDirectory())
|
|
115
241
|
throw new Error(`Not a directory: ${root}`);
|
|
116
|
-
const
|
|
117
|
-
const sources = await Promise.all(
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
|
|
242
|
+
const discovered = await discoverProject(root);
|
|
243
|
+
const sources = await Promise.all(discovered.sourceFiles.map(async (file) => ({
|
|
244
|
+
file,
|
|
245
|
+
source: await readFile(file, 'utf8')
|
|
246
|
+
})));
|
|
247
|
+
const boundaries = createBoundaries(root, discovered.packageRoots, sources);
|
|
248
|
+
const frameworks = [...new Set(boundaries.flatMap(boundary => getFrameworks(boundary.sources)))];
|
|
249
|
+
const runtimeComponents = new Set();
|
|
123
250
|
const findings = [
|
|
124
251
|
...collectCompatibilityFindings(root, sources),
|
|
125
|
-
...
|
|
126
|
-
...collectCssFindings(root, sources),
|
|
127
|
-
...collectFrameworkStyleFindings(root, sources, frameworks),
|
|
128
|
-
...collectAdapterMismatchFindings(joined, frameworks)
|
|
252
|
+
...collectCssFindings(root, sources)
|
|
129
253
|
];
|
|
254
|
+
for (const boundary of boundaries) {
|
|
255
|
+
const boundaryFrameworks = getFrameworks(boundary.sources);
|
|
256
|
+
const runtime = collectRuntimeFindings(root, boundary);
|
|
257
|
+
runtime.runtimeComponents.forEach(component => runtimeComponents.add(component));
|
|
258
|
+
findings.push(...runtime.findings, ...collectFrameworkStyleFindings(root, boundary, boundaryFrameworks), ...collectAdapterMismatchFindings(root, boundary, boundaryFrameworks));
|
|
259
|
+
}
|
|
260
|
+
const deduplicatedFindings = uniqueFindings(findings);
|
|
130
261
|
return {
|
|
131
262
|
catalogFingerprint: getCatalogFingerprint(sources),
|
|
132
|
-
findings,
|
|
263
|
+
findings: deduplicatedFindings,
|
|
133
264
|
frameworks,
|
|
134
265
|
generatedAt: new Date().toISOString(),
|
|
135
|
-
healthy:
|
|
266
|
+
healthy: deduplicatedFindings.every(item => item.severity !== 'error'),
|
|
136
267
|
repository: root,
|
|
137
|
-
runtimeComponents
|
|
268
|
+
runtimeComponents: [...runtimeComponents]
|
|
138
269
|
};
|
|
139
270
|
};
|
|
140
271
|
export const formatLumenDiagnostics = (report) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@santi020k/lumen",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"semver": "^7.8.0",
|
|
42
|
-
"@santi020k/lumen-core": "^1.
|
|
42
|
+
"@santi020k/lumen-core": "^1.1.0"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@types/node": "^26.1.1",
|