@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. Use `--json` for CI and rollout automation. `lumen
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":"AAMA,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;AA8LD,eAAO,MAAM,uBAAuB,GAAU,YAAY,MAAM,KAAG,OAAO,CAAC,qBAAqB,CA+B/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
+ {"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(['.git', '.next', '.turbo', 'coverage', 'dist', 'node_modules']);
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 findSourceFiles = async (root) => {
9
- const files = [];
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
- else if (sourceExtensions.has(extname(entry.name)))
18
- files.push(path);
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
- return files;
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 getFrameworks = (joined) => [
38
- joined.includes('@santi020k/lumen-astro') && 'astro',
39
- joined.includes('@santi020k/lumen-elements') && 'elements',
40
- joined.includes('@santi020k/lumen-react') && 'react'
41
- ].filter((value) => Boolean(value));
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 collectRuntimeFindings = (root, sources, runtimeComponents) => {
64
- const imports = sources.flatMap(item => importedNames(item.source, '@santi020k/lumen-astro').map(name => ({ file: item.file, name })));
65
- const mounts = sources.filter(item => /<UIPrimitives(?:\s|\/|>)/u.test(item.source));
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 && mounts.length === 0) {
68
- findings.push(finding(imports[0] ? relative(root, imports[0].file) : 'package.json', 'astro-runtime-missing', `Interactive Astro imports require UIPrimitives: ${runtimeComponents.join(', ')}.`, 'Mount <UIPrimitives /> once in the root layout.'));
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 mounts.slice(1)) {
71
- findings.push(finding(relative(root, mount.file), 'astro-runtime-duplicate', 'UIPrimitives is mounted more than once.', 'Keep one mount in the application root layout.'));
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
- return findings;
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 collectCssFindings = (root, sources) => sources.filter(item => item.file.endsWith('.css')).flatMap(item => {
76
- const layerIndex = item.source.indexOf('/layers.css');
77
- const tailwindIndex = item.source.indexOf('tailwindcss');
78
- const styleIndex = item.source.indexOf('/styles.css');
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
- (layerIndex < 0 || styleIndex < 0 || !(layerIndex < tailwindIndex && tailwindIndex < styleIndex))) {
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 styleImportByFramework = {
93
- astro: '@santi020k/lumen-astro/styles.css',
94
- elements: '@santi020k/lumen-elements/styles.css',
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('styles', 'framework-style-missing', `No ${styleImportByFramework[framework]} import was found.`, 'Load the matching adapter stylesheet once in the application root.')];
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.slice(1).map(item => finding(relative(root, item.file), 'framework-style-duplicate', `The ${framework} stylesheet is loaded more than once.`, 'Keep one stylesheet import in the application root.'));
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 = (joined, frameworks) => {
105
- const adapters = [...joined.matchAll(/@santi020k\/lumen-(astro|elements|react)\/styles\.css/g)]
106
- .map(match => match[1])
107
- .filter((adapter) => adapter === 'astro' || adapter === 'elements' || adapter === 'react');
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('styles', 'adapter-style-mismatch', `The ${adapter} stylesheet is loaded without matching ${adapter} imports.`, 'Load the stylesheet from the adapter used by this application boundary.'));
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 files = await findSourceFiles(root);
117
- const sources = await Promise.all(files.map(async (file) => ({ file, source: await readFile(file, 'utf8') })));
118
- const joined = sources.map(item => item.source).join('\n');
119
- const frameworks = getFrameworks(joined);
120
- const astroNames = sources.flatMap(item => importedNames(item.source, '@santi020k/lumen-astro'));
121
- const runtimeComponents = [...new Set(astroNames.filter(name => name in lumenComponentBehavior &&
122
- lumenComponentBehavior[name].astro === 'ui-primitives'))];
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
- ...collectRuntimeFindings(root, sources, runtimeComponents),
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: findings.every(item => item.severity !== 'error'),
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.0.0",
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.0.0"
42
+ "@santi020k/lumen-core": "^1.1.0"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@types/node": "^26.1.1",