@emulsify/core 4.3.2 → 4.5.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.
Files changed (54) hide show
  1. package/.storybook/main-static-assets.js +5 -8
  2. package/.storybook/main-vite.js +11 -3
  3. package/README.md +14 -6
  4. package/config/a11y-wcag22.js +11 -0
  5. package/config/vite/entries.js +7 -2
  6. package/config/vite/environment.js +4 -0
  7. package/config/vite/plugins/assets/asset-url-rebase.js +241 -0
  8. package/config/vite/plugins/assets/copy-src-assets.js +82 -12
  9. package/config/vite/plugins/assets/copy-twig-files.js +85 -16
  10. package/config/vite/plugins/assets/css-asset-rebase.js +306 -0
  11. package/config/vite/plugins/assets/css-asset-relativizer.js +301 -21
  12. package/config/vite/plugins/assets/development-source-maps.js +273 -0
  13. package/config/vite/plugins/assets/mirror-components.js +98 -82
  14. package/config/vite/plugins/assets/output-freshness.js +235 -0
  15. package/config/vite/plugins/assets/source-file-index.js +13 -13
  16. package/config/vite/plugins/assets/stable-watch-output.js +165 -0
  17. package/config/vite/plugins/assets/storybook-output.js +27 -0
  18. package/config/vite/plugins/index.js +95 -9
  19. package/config/vite/plugins/reporter/asset-resolver.js +34 -6
  20. package/config/vite/plugins/reporter/build-errors.js +7 -3
  21. package/config/vite/plugins/reporter/diagnostics.js +140 -10
  22. package/config/vite/plugins/reporter/index.js +380 -75
  23. package/config/vite/plugins/reporter/render.js +297 -44
  24. package/config/vite/plugins/reporter/sass-logger.js +30 -0
  25. package/config/vite/plugins/reporter/source-roots.js +101 -21
  26. package/config/vite/plugins/reporter/strict-mode.js +99 -0
  27. package/config/vite/plugins/reporter/vite-logger.js +220 -8
  28. package/config/vite/plugins/reporter/watch-mode.js +6 -2
  29. package/config/vite/plugins/twig/twig-module.js +35 -258
  30. package/config/vite/plugins/twig/virtual-twig-asset-sources.js +48 -49
  31. package/config/vite/project-config.js +121 -21
  32. package/config/vite/project-structure.js +6 -0
  33. package/config/vite/utils/asset-roots.js +205 -0
  34. package/config/vite/utils/css-urls.js +350 -0
  35. package/config/vite/utils/fs-safe.js +38 -1
  36. package/config/vite/utils/source-directory-skips.js +13 -0
  37. package/config/vite/utils/source-maps.js +88 -0
  38. package/config/vite/utils/twig-component-resolver.js +316 -0
  39. package/config/vite/vite.config.js +106 -42
  40. package/package.json +54 -40
  41. package/scripts/a11y.js +88 -9
  42. package/scripts/audit/checks/css-asset-references.js +256 -24
  43. package/scripts/audit/checks/twig-references.js +16 -5
  44. package/scripts/audit/fix.js +836 -0
  45. package/scripts/audit/index.js +10 -2
  46. package/scripts/audit/lib/css.js +41 -35
  47. package/scripts/audit/lib/story-ast.js +392 -0
  48. package/scripts/audit/lib/story-render-paths.js +600 -0
  49. package/scripts/audit/lib/story-selection.js +190 -0
  50. package/scripts/audit/lib/twig.js +372 -80
  51. package/scripts/audit/report.js +83 -5
  52. package/scripts/audit-twig-stories.js +73 -3
  53. package/scripts/audit.js +87 -2
  54. package/src/storybook/twig/source-function.js +14 -10
@@ -142,7 +142,7 @@ export function runAuditChecks(context) {
142
142
  * Run the combined Emulsify audit.
143
143
  *
144
144
  * @param {{projectDir?: string, twigThreshold?: number}} [options={}] - Options.
145
- * @returns {{projectDir: string, summary: object, files: object, findings: object[]}} Audit result.
145
+ * @returns {{projectDir: string, sourceRoots: string[], summary: object, files: object, findings: object[]}} Audit result.
146
146
  */
147
147
  export function runAudits(options = {}) {
148
148
  resetFileReadCache();
@@ -161,7 +161,7 @@ export function runAudits(options = {}) {
161
161
  },
162
162
  );
163
163
 
164
- return {
164
+ const result = {
165
165
  projectDir: context.projectDir,
166
166
  summary,
167
167
  files: {
@@ -172,6 +172,14 @@ export function runAudits(options = {}) {
172
172
  },
173
173
  findings,
174
174
  };
175
+
176
+ // The fix scope is CLI-internal plumbing. Keep raw audit serialization
177
+ // stable while still sharing the normalized roots with the fix phase.
178
+ Object.defineProperty(result, 'sourceRoots', {
179
+ value: context.sourceRoots,
180
+ });
181
+
182
+ return result;
175
183
  }
176
184
 
177
185
  export { runAudits as auditProject };
@@ -3,6 +3,11 @@
3
3
  */
4
4
 
5
5
  import { basename, dirname, resolve } from 'node:path';
6
+ import {
7
+ assetTailFor,
8
+ isAssetAliasPath,
9
+ } from '../../../config/vite/plugins/assets/asset-url-rebase.js';
10
+ import { tokenizeStylesheetUrls } from '../../../config/vite/utils/css-urls.js';
6
11
  import {
7
12
  compiledAssetOutputPath,
8
13
  storybookStyleOutputPath,
@@ -43,40 +48,32 @@ function resolveSassUrlValue(value, variables) {
43
48
  );
44
49
  }
45
50
 
46
- /**
47
- * Mask style comments while preserving line and character positions.
48
- *
49
- * @param {string} source - Stylesheet source.
50
- * @returns {string} Source with comments replaced by whitespace.
51
- */
52
- function maskStyleComments(source) {
53
- const blank = (match) => match.replace(/[^\n]/g, ' ');
54
-
55
- return source
56
- .replace(/\/\*[\s\S]*?\*\//g, blank)
57
- .replace(/^[\t ]*\/\/.*$/gm, blank);
58
- }
59
-
60
51
  /**
61
52
  * Extract URL references from CSS or Sass source.
62
53
  *
54
+ * `start` and `end` bracket the specifier *without* its quotes, so an autofix
55
+ * can splice a replacement in without disturbing quote style. The shared
56
+ * tokenizer preserves original positions, so `source.slice(start, end) === raw`.
57
+ *
63
58
  * @param {string} source - Stylesheet source.
64
- * @returns {{value: string, raw: string, line: number}[]} URL references.
59
+ * @returns {{value: string, raw: string, quote: string, line: number, start: number, end: number}[]} URL references.
65
60
  */
66
61
  export function findCssUrlReferences(source) {
67
- const scanSource = maskStyleComments(source);
68
- const variables = findSassStringVariables(scanSource);
62
+ const { urls, sourceWithoutComments } = tokenizeStylesheetUrls(source);
63
+ const variables = findSassStringVariables(sourceWithoutComments);
69
64
  const references = [];
70
- const pattern = /url\(\s*(?:(['"])(.*?)\1|([^'")][^)]*?))\s*\)/g;
71
65
 
72
- for (const match of scanSource.matchAll(pattern)) {
73
- const raw = (match[2] ?? match[3] ?? '').trim();
66
+ for (const token of urls) {
67
+ const raw = token.value;
74
68
  const value = resolveSassUrlValue(raw, variables).trim();
75
69
 
76
70
  references.push({
77
71
  value,
78
72
  raw,
79
- line: lineNumberAt(source, match.index || 0),
73
+ quote: token.quote,
74
+ line: lineNumberAt(source, token.start),
75
+ start: token.valueStart,
76
+ end: token.valueEnd,
80
77
  });
81
78
  }
82
79
 
@@ -84,23 +81,32 @@ export function findCssUrlReferences(source) {
84
81
  }
85
82
 
86
83
  /**
87
- * Determine whether a CSS URL should be skipped by filesystem checks.
84
+ * Classify how a filesystem-ish CSS URL should be resolved.
85
+ *
86
+ * - `asset-root` — `/assets/...`, `@assets/...`, or legacy `assets/...`.
87
+ * Resolved against the project asset roots, which is what Storybook serves
88
+ * and what the build rebases to.
89
+ * - `runtime` — some other absolute URL (`/sites/default/files/...`). The
90
+ * platform serves it; the audit has nothing to check.
91
+ * - `relative` — resolved from the stylesheet's own directory.
88
92
  *
89
93
  * @param {string} value - URL value.
90
- * @returns {boolean} TRUE when the URL is not a local relative asset path.
94
+ * @returns {'asset-root'|'runtime'|'relative'} Resolution strategy.
91
95
  */
92
- export function isNonFilesystemCssUrl(value) {
93
- return (
94
- !value ||
95
- value.startsWith('#') ||
96
- value.startsWith('/') ||
97
- value.startsWith('//') ||
98
- value.startsWith('$') ||
99
- value.startsWith('#{') ||
100
- /^[a-z][a-z0-9+.-]*:/i.test(value) ||
101
- /^var\(/i.test(value) ||
102
- /^env\(/i.test(value)
103
- );
96
+ export function classifyCssAssetUrl(value) {
97
+ if (assetTailFor(cssUrlPath(value))) return 'asset-root';
98
+
99
+ return value.startsWith('/') ? 'runtime' : 'relative';
100
+ }
101
+
102
+ /**
103
+ * Determine whether a CSS URL uses the exact namespaced asset alias.
104
+ *
105
+ * @param {string} value - URL path without query or hash.
106
+ * @returns {boolean} TRUE for `@assets/...` paths.
107
+ */
108
+ export function isCssAssetAlias(value) {
109
+ return isAssetAliasPath(String(value));
104
110
  }
105
111
 
106
112
  /**
@@ -0,0 +1,392 @@
1
+ /**
2
+ * @file AST parsing and binding helpers for Storybook story modules.
3
+ */
4
+
5
+ import { extname } from 'node:path';
6
+ import { parse } from '@babel/parser';
7
+
8
+ const TWIG_SPECIFIER_PATTERN = /\.twig(?:\?.*)?$/;
9
+ const STORYBOOK_SPECIFIER = '@emulsify/core/storybook';
10
+ const VARIABLE_KINDS = new Set(['const', 'let', 'var']);
11
+ const EXPRESSION_WRAPPERS = new Set([
12
+ 'ChainExpression',
13
+ 'ParenthesizedExpression',
14
+ 'TSAsExpression',
15
+ 'TSNonNullExpression',
16
+ 'TSSatisfiesExpression',
17
+ 'TSTypeAssertion',
18
+ 'TypeCastExpression',
19
+ ]);
20
+
21
+ /**
22
+ * Select Babel parser plugins for a story file.
23
+ *
24
+ * @param {string} filePath - Story file path.
25
+ * @returns {string[]} Babel parser plugins.
26
+ */
27
+ function parserPlugins(filePath) {
28
+ const extension = extname(filePath).toLowerCase();
29
+
30
+ if (extension === '.ts') return ['typescript'];
31
+ if (extension === '.tsx') return ['jsx', 'typescript'];
32
+
33
+ return ['jsx'];
34
+ }
35
+
36
+ /**
37
+ * Visit every Babel AST node.
38
+ *
39
+ * @param {object} node - Current AST node.
40
+ * @param {(node: object) => void} visitor - Node visitor.
41
+ * @returns {void}
42
+ */
43
+ function visitAst(node, visitor) {
44
+ if (!node || typeof node !== 'object' || typeof node.type !== 'string') {
45
+ return;
46
+ }
47
+
48
+ visitor(node);
49
+
50
+ for (const value of Object.values(node)) {
51
+ if (Array.isArray(value)) {
52
+ for (const child of value) visitAst(child, visitor);
53
+ } else if (value && typeof value === 'object') {
54
+ visitAst(value, visitor);
55
+ }
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Read a source line from a Babel node.
61
+ *
62
+ * @param {object} node - Babel AST node.
63
+ * @returns {number} One-based source line.
64
+ */
65
+ function nodeLine(node) {
66
+ return node.loc?.start?.line || 1;
67
+ }
68
+
69
+ /**
70
+ * Determine whether a module specifier references a Twig file.
71
+ *
72
+ * @param {unknown} value - Module specifier value.
73
+ * @returns {boolean} TRUE when the value is a Twig module specifier.
74
+ */
75
+ function isTwigSpecifier(value) {
76
+ return typeof value === 'string' && TWIG_SPECIFIER_PATTERN.test(value);
77
+ }
78
+
79
+ /**
80
+ * Read a Twig specifier from a require() initializer.
81
+ *
82
+ * @param {object} declarator - Babel variable declarator.
83
+ * @returns {string} Twig specifier, or an empty string.
84
+ */
85
+ export function requiredTwigSpecifier(declarator) {
86
+ const init = declarator.init;
87
+
88
+ if (
89
+ declarator.id?.type !== 'Identifier' ||
90
+ init?.type !== 'CallExpression' ||
91
+ init.callee?.type !== 'Identifier' ||
92
+ init.callee.name !== 'require' ||
93
+ init.arguments.length !== 1 ||
94
+ init.arguments[0]?.type !== 'StringLiteral' ||
95
+ !isTwigSpecifier(init.arguments[0].value)
96
+ ) {
97
+ return '';
98
+ }
99
+
100
+ return init.arguments[0].value;
101
+ }
102
+
103
+ /**
104
+ * Parse a Storybook story module without allowing syntax errors to escape.
105
+ *
106
+ * @param {string} source - Story source.
107
+ * @param {string} [filePath=''] - Story file path.
108
+ * @returns {{ast: object}|null} Parsed Babel AST, or null on failure.
109
+ */
110
+ export function parseStoryModule(source, filePath = '') {
111
+ try {
112
+ const ast = parse(source, {
113
+ sourceType: 'module',
114
+ errorRecovery: true,
115
+ plugins: parserPlugins(filePath),
116
+ });
117
+
118
+ return { ast };
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Find local bindings for imported or required Twig templates.
126
+ *
127
+ * @param {object} ast - Babel story module AST.
128
+ * @returns {{name: string, specifier: string, line: number}[]} Twig bindings.
129
+ */
130
+ export function findTwigTemplateBindings(ast) {
131
+ const bindings = [];
132
+
133
+ visitAst(ast, (node) => {
134
+ if (
135
+ node.type === 'ImportDeclaration' &&
136
+ node.importKind !== 'type' &&
137
+ isTwigSpecifier(node.source?.value)
138
+ ) {
139
+ for (const specifier of node.specifiers) {
140
+ if (
141
+ specifier.importKind !== 'type' &&
142
+ (specifier.type === 'ImportDefaultSpecifier' ||
143
+ specifier.type === 'ImportNamespaceSpecifier')
144
+ ) {
145
+ bindings.push({
146
+ name: specifier.local.name,
147
+ specifier: node.source.value,
148
+ line: nodeLine(node),
149
+ });
150
+ }
151
+ }
152
+ }
153
+
154
+ if (node.type === 'VariableDeclaration' && VARIABLE_KINDS.has(node.kind)) {
155
+ for (const declarator of node.declarations) {
156
+ const specifier = requiredTwigSpecifier(declarator);
157
+ if (!specifier) continue;
158
+
159
+ bindings.push({
160
+ name: declarator.id.name,
161
+ specifier,
162
+ line: nodeLine(node),
163
+ });
164
+ }
165
+ }
166
+ });
167
+
168
+ return bindings;
169
+ }
170
+
171
+ /**
172
+ * Find local bindings for the public renderTwig named export.
173
+ *
174
+ * @param {object} ast - Babel story module AST.
175
+ * @returns {Set<string>} Local renderTwig binding names.
176
+ */
177
+ export function findRenderTwigBindings(ast) {
178
+ const bindings = new Set();
179
+
180
+ visitAst(ast, (node) => {
181
+ if (
182
+ node.type !== 'ImportDeclaration' ||
183
+ node.source?.value !== STORYBOOK_SPECIFIER ||
184
+ node.importKind === 'type'
185
+ ) {
186
+ return;
187
+ }
188
+
189
+ for (const specifier of node.specifiers) {
190
+ if (
191
+ specifier.type === 'ImportSpecifier' &&
192
+ specifier.importKind !== 'type' &&
193
+ specifier.imported?.name === 'renderTwig'
194
+ ) {
195
+ bindings.add(specifier.local.name);
196
+ }
197
+ }
198
+ });
199
+
200
+ return bindings;
201
+ }
202
+
203
+ /**
204
+ * Determine whether a value is a Babel AST node.
205
+ *
206
+ * @param {unknown} value - Possible AST node.
207
+ * @returns {boolean} TRUE when the value is an AST node.
208
+ */
209
+ export function isNode(value) {
210
+ return Boolean(
211
+ value && typeof value === 'object' && typeof value.type === 'string',
212
+ );
213
+ }
214
+
215
+ /**
216
+ * Remove transparent syntax wrappers from an expression.
217
+ *
218
+ * @param {object} node - Babel expression node.
219
+ * @returns {object} Unwrapped expression.
220
+ */
221
+ export function unwrapExpression(node) {
222
+ let value = node;
223
+
224
+ while (isNode(value) && EXPRESSION_WRAPPERS.has(value.type)) {
225
+ value = value.expression;
226
+ }
227
+
228
+ return value;
229
+ }
230
+
231
+ /**
232
+ * Get the module statements from a Babel File or Program.
233
+ *
234
+ * @param {object} ast - Babel File or Program.
235
+ * @returns {object[]} Module statements.
236
+ */
237
+ export function moduleBody(ast) {
238
+ if (ast?.type === 'File') return ast.program?.body || [];
239
+ if (ast?.type === 'Program') return ast.body || [];
240
+
241
+ return [];
242
+ }
243
+
244
+ /**
245
+ * Record module-scope function and variable declarations.
246
+ *
247
+ * @param {object} declaration - Babel declaration.
248
+ * @param {Map<string, object>} declarations - Declaration map.
249
+ * @returns {void}
250
+ */
251
+ function addModuleDeclaration(declaration, declarations) {
252
+ if (declaration?.type === 'FunctionDeclaration' && declaration.id?.name) {
253
+ declarations.set(declaration.id.name, {
254
+ node: declaration,
255
+ value: declaration,
256
+ });
257
+ return;
258
+ }
259
+
260
+ if (declaration?.type !== 'VariableDeclaration') return;
261
+
262
+ for (const declarator of declaration.declarations) {
263
+ if (declarator.id?.type !== 'Identifier' || !declarator.init) continue;
264
+
265
+ declarations.set(declarator.id.name, {
266
+ node: declarator,
267
+ value: declarator.init,
268
+ });
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Collect module-scope declarations by local name.
274
+ *
275
+ * @param {object} ast - Babel File or Program.
276
+ * @returns {Map<string, object>} Module declaration map.
277
+ */
278
+ export function collectModuleDeclarations(ast) {
279
+ const declarations = new Map();
280
+
281
+ for (const statement of moduleBody(ast)) {
282
+ if (statement.type === 'ExportNamedDeclaration') {
283
+ addModuleDeclaration(statement.declaration, declarations);
284
+ } else {
285
+ addModuleDeclaration(statement, declarations);
286
+ }
287
+ }
288
+
289
+ return declarations;
290
+ }
291
+
292
+ /**
293
+ * Read a static property name.
294
+ *
295
+ * @param {object} property - Babel object or member property.
296
+ * @returns {string} Property name, or an empty string.
297
+ */
298
+ export function staticPropertyName(property) {
299
+ const key = property?.key || property?.property;
300
+
301
+ if (key?.type === 'Identifier' && !property.computed) return key.name;
302
+ if (key?.type === 'StringLiteral') return key.value;
303
+
304
+ return '';
305
+ }
306
+
307
+ /**
308
+ * Resolve identifier aliases to their module-scope value.
309
+ *
310
+ * @param {object} node - Babel expression.
311
+ * @param {Map<string, object>} declarations - Module declaration map.
312
+ * @param {Set<object>} [visited] - Values already resolved.
313
+ * @returns {object} Resolved expression.
314
+ */
315
+ export function resolveModuleValue(node, declarations, visited = new Set()) {
316
+ const value = unwrapExpression(node);
317
+ if (!isNode(value) || visited.has(value)) return value;
318
+ visited.add(value);
319
+
320
+ if (value.type === 'MemberExpression' && staticPropertyName(value)) {
321
+ const property = readStaticProperty(
322
+ value.object,
323
+ staticPropertyName(value),
324
+ declarations,
325
+ visited,
326
+ );
327
+ return property.state === 'known'
328
+ ? resolveModuleValue(property.node, declarations, visited)
329
+ : value;
330
+ }
331
+
332
+ if (value.type !== 'Identifier' || !declarations.has(value.name)) {
333
+ return value;
334
+ }
335
+
336
+ return resolveModuleValue(
337
+ declarations.get(value.name).value,
338
+ declarations,
339
+ visited,
340
+ );
341
+ }
342
+
343
+ /**
344
+ * Read an imported or exported identifier name.
345
+ *
346
+ * @param {object} node - Babel identifier or string literal.
347
+ * @returns {string} Static name, or an empty string.
348
+ */
349
+ export function identifierName(node) {
350
+ if (node?.type === 'Identifier') return node.name;
351
+ if (node?.type === 'StringLiteral') return node.value;
352
+
353
+ return '';
354
+ }
355
+
356
+ /**
357
+ * Read a property without assuming an unresolved spread or computed key is safe.
358
+ *
359
+ * @param {object} node - Object expression or module-local alias.
360
+ * @param {string} name - Property name.
361
+ * @param {Map<string, object>} declarations - Module declarations.
362
+ * @param {Set<object>} [visited] - Values on the current alias path.
363
+ * @returns {object} Known, absent, or unknown property and its source location.
364
+ */
365
+ export function readStaticProperty(
366
+ node,
367
+ name,
368
+ declarations,
369
+ visited = new Set(),
370
+ ) {
371
+ if (!node) return { state: 'absent' };
372
+ const value = resolveModuleValue(node, declarations, visited);
373
+ if (value?.type !== 'ObjectExpression') return { state: 'unknown' };
374
+
375
+ for (let index = value.properties.length - 1; index >= 0; index -= 1) {
376
+ const property = value.properties[index];
377
+ const key = staticPropertyName(property);
378
+ if (property.type === 'SpreadElement' || !key) {
379
+ return { state: 'unknown' };
380
+ }
381
+ if (key !== name) continue;
382
+ if (property.type === 'ObjectProperty') {
383
+ return { state: 'known', node: property.value, lineNode: property };
384
+ }
385
+ if (property.type === 'ObjectMethod' && property.kind === 'method') {
386
+ return { state: 'known', node: property, lineNode: property };
387
+ }
388
+ return { state: 'unknown' };
389
+ }
390
+
391
+ return { state: 'absent' };
392
+ }