@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
@@ -18,6 +18,12 @@ import {
18
18
  formatAuditJsonErrorReport,
19
19
  formatAuditJsonReport,
20
20
  } from './audit/report.js';
21
+ import {
22
+ findRenderTwigBindings,
23
+ findTwigTemplateBindings,
24
+ parseStoryModule,
25
+ } from './audit/lib/story-ast.js';
26
+ import { classifyStoryRenderPaths } from './audit/lib/story-render-paths.js';
21
27
  import { lineNumberAt } from './lib/text.js';
22
28
 
23
29
  const STORY_GLOB = '**/*.stories.{js,jsx,ts,tsx}';
@@ -115,13 +121,13 @@ export function findDirectTemplateReturns(source, templateNames = []) {
115
121
  }
116
122
 
117
123
  /**
118
- * Analyze one Storybook story source string.
124
+ * Analyze one Storybook story with the legacy regex implementation.
119
125
  *
120
126
  * @param {string} source - Story source.
121
- * @param {string} [filePath=''] - Story file path.
127
+ * @param {string} filePath - Story file path.
122
128
  * @returns {object} Story analysis.
123
129
  */
124
- export function analyzeStorySource(source, filePath = '') {
130
+ function analyzeStorySourceWithRegex(source, filePath) {
125
131
  const twigImports = findTwigImports(source);
126
132
  const templateNames = twigImports.map((item) => item.name);
127
133
  const hasRenderTwig = importsRenderTwig(source);
@@ -160,6 +166,70 @@ export function analyzeStorySource(source, filePath = '') {
160
166
  };
161
167
  }
162
168
 
169
+ /**
170
+ * Analyze one Storybook story source string.
171
+ *
172
+ * @param {string} source - Story source.
173
+ * @param {string} [filePath=''] - Story file path.
174
+ * @returns {object} Story analysis.
175
+ */
176
+ export function analyzeStorySource(source, filePath = '') {
177
+ const parsed = parseStoryModule(source, filePath);
178
+ if (!parsed) return analyzeStorySourceWithRegex(source, filePath);
179
+
180
+ const twigImports = findTwigTemplateBindings(parsed.ast);
181
+ const renderTwigNames = findRenderTwigBindings(parsed.ast);
182
+ const hasRenderTwig = renderTwigNames.size > 0;
183
+ const reasons = [];
184
+
185
+ if (!twigImports.length) {
186
+ return {
187
+ filePath,
188
+ twigImports,
189
+ hasRenderTwig,
190
+ directTemplateReturns: [],
191
+ reasons,
192
+ shouldUpgrade: false,
193
+ };
194
+ }
195
+
196
+ const classification = classifyStoryRenderPaths(parsed.ast, {
197
+ templateNames: twigImports.map((item) => item.name),
198
+ renderTwigNames,
199
+ });
200
+ const fallbackLine = Number.isInteger(twigImports[0]?.line)
201
+ ? twigImports[0].line
202
+ : 1;
203
+ const directTemplateReturns = classification.legacy.map((item) => ({
204
+ name: item.name || twigImports[0].name,
205
+ line: Number.isInteger(item.line) ? item.line : fallbackLine,
206
+ }));
207
+
208
+ if (!hasRenderTwig && classification.legacy.length) {
209
+ reasons.push('imports Twig templates without renderTwig()');
210
+ }
211
+
212
+ if (classification.legacy.length) {
213
+ reasons.push('appears to return Twig HTML strings directly');
214
+ }
215
+
216
+ if (reasons.length && !directTemplateReturns.length) {
217
+ directTemplateReturns.push({
218
+ name: twigImports[0].name,
219
+ line: fallbackLine,
220
+ });
221
+ }
222
+
223
+ return {
224
+ filePath,
225
+ twigImports,
226
+ hasRenderTwig,
227
+ directTemplateReturns,
228
+ reasons,
229
+ shouldUpgrade: reasons.length > 0,
230
+ };
231
+ }
232
+
163
233
  /**
164
234
  * Resolve Storybook source roots for the project.
165
235
  *
package/scripts/audit.js CHANGED
@@ -11,11 +11,14 @@ import {
11
11
  parseArgs as parseCliArgs,
12
12
  } from './lib/cli.js';
13
13
  import { DEFAULT_TWIG_THRESHOLD, runAudits } from './audit/index.js';
14
+ import { applyAuditFixes, remainingFindings } from './audit/fix.js';
14
15
  import {
15
16
  formatAuditJsonErrorReport,
16
17
  formatAuditJsonReport,
17
18
  formatAuditReport,
19
+ summarizeFindings,
18
20
  } from './audit/report.js';
21
+ import { displayPath } from './audit/lib/findings.js';
19
22
 
20
23
  export { auditProject, runAudits } from './audit/index.js';
21
24
  export {
@@ -27,6 +30,7 @@ export {
27
30
  formatAuditReport,
28
31
  } from './audit/report.js';
29
32
  export { collectProjectFiles } from './audit/lib/files.js';
33
+ export { applyAuditFixes, remainingFindings } from './audit/fix.js';
30
34
  export { findCssUrlReferences } from './audit/lib/css.js';
31
35
  export {
32
36
  findTwigIncludeSourceReferences,
@@ -45,10 +49,12 @@ const cliFailureExitCode = 2;
45
49
  */
46
50
  function usage() {
47
51
  return createUsage(
48
- 'Usage: emulsify-audit [--root <dir>] [--json] [--fail-on <severity>] [--fail-on-found] [--twig-threshold <count>]',
52
+ 'Usage: emulsify-audit [--root <dir>] [--json] [--fix] [--dry-run] [--fail-on <severity>] [--fail-on-found] [--twig-threshold <count>]',
49
53
  [
50
54
  ' --root <dir> Project root to scan. Defaults to the current directory.',
51
55
  ' --json Print machine-readable JSON.',
56
+ ' --fix Rewrite unambiguous CSS asset URLs to the canonical /assets/... form.',
57
+ ' --dry-run With --fix, report the rewrites without touching files.',
52
58
  ' --fail-on <severity> Exit with code 1 for error, warn, info, or any findings at that threshold.',
53
59
  ' --fail-on-found Compatibility alias for --fail-on any.',
54
60
  ` --twig-threshold <count> Warn when Storybook roots contain more than this many Twig files. Default: ${DEFAULT_TWIG_THRESHOLD}.`,
@@ -70,6 +76,8 @@ function parseArgs(argv) {
70
76
  failOn: null,
71
77
  json: false,
72
78
  help: false,
79
+ fix: false,
80
+ dryRun: false,
73
81
  twigThreshold: DEFAULT_TWIG_THRESHOLD,
74
82
  },
75
83
  flags: {
@@ -78,6 +86,8 @@ function parseArgs(argv) {
78
86
  value: 'any',
79
87
  },
80
88
  '--json': 'json',
89
+ '--fix': 'fix',
90
+ '--dry-run': 'dryRun',
81
91
  },
82
92
  options: {
83
93
  '--fail-on': {
@@ -167,6 +177,9 @@ export function runCli(argv = process.argv.slice(2)) {
167
177
  if (options.help && options.json) {
168
178
  throw new Error('--json cannot be combined with --help.');
169
179
  }
180
+ if (options.dryRun && !options.fix) {
181
+ throw new Error('--dry-run requires --fix.');
182
+ }
170
183
  } catch (error) {
171
184
  return reportArgumentFailure(error, jsonRequested);
172
185
  }
@@ -178,6 +191,28 @@ export function runCli(argv = process.argv.slice(2)) {
178
191
 
179
192
  try {
180
193
  const result = runAudits(options);
194
+ let findings = result.findings;
195
+
196
+ if (options.fix) {
197
+ try {
198
+ result.fixes = applyAuditFixes(findings, {
199
+ dryRun: options.dryRun,
200
+ projectDir: result.projectDir,
201
+ sourceRoots: result.sourceRoots,
202
+ });
203
+ } catch (error) {
204
+ return reportFixFailure(error, options);
205
+ }
206
+
207
+ // A dry run changes nothing on disk, so nothing is subtracted. A real
208
+ // run removed the findings it fixed from the source, so the threshold
209
+ // must be judged on what is left.
210
+ if (!options.dryRun) {
211
+ result.findings = remainingFindings(findings, result.fixes.applied);
212
+ result.summary = summarizeFindings(result.findings);
213
+ findings = result.findings;
214
+ }
215
+ }
181
216
 
182
217
  if (options.json) {
183
218
  console.log(formatAuditJsonReport(result));
@@ -185,7 +220,7 @@ export function runCli(argv = process.argv.slice(2)) {
185
220
  console.log(formatAuditReport(result));
186
221
  }
187
222
 
188
- return shouldFailAudit(result.findings, options.failOn) ? 1 : 0;
223
+ return shouldFailAudit(findings, options.failOn) ? 1 : 0;
189
224
  } catch (error) {
190
225
  if (options.json) {
191
226
  console.log(
@@ -202,6 +237,56 @@ export function runCli(argv = process.argv.slice(2)) {
202
237
  }
203
238
  }
204
239
 
240
+ /**
241
+ * Print a failure that happened while writing fixes.
242
+ *
243
+ * @param {*} error - Write failure.
244
+ * @param {object} options - Parsed CLI options.
245
+ * @returns {number} Exit code.
246
+ */
247
+ function reportFixFailure(error, options) {
248
+ const projectDir = resolve(options.projectDir);
249
+
250
+ if (options.json) {
251
+ console.log(
252
+ formatAuditJsonErrorReport(error, {
253
+ code: 'fix-failed',
254
+ projectDir,
255
+ fixes: error?.fixes,
256
+ }),
257
+ );
258
+ } else {
259
+ const lines = [`Audit fix failed: ${error.message || error}`];
260
+ const applied = error?.fixes?.applied || [];
261
+ const rewrittenFiles = Array.from(
262
+ new Set(applied.map(({ finding }) => finding.filePath)),
263
+ ).sort();
264
+
265
+ if (rewrittenFiles.length) {
266
+ const verb = error?.fixes?.dryRun ? 'Would apply' : 'Applied';
267
+ lines.push(
268
+ `${verb} ${applied.length} fix(es) across ${rewrittenFiles.length} file(s) before the failure:`,
269
+ ...rewrittenFiles.map(
270
+ (filePath) => ` ${displayPath(projectDir, filePath)}`,
271
+ ),
272
+ );
273
+ }
274
+
275
+ const skipped = error?.fixes?.skipped || [];
276
+ if (skipped.length) {
277
+ lines.push(`Skipped ${skipped.length} fixable finding(s):`);
278
+ for (const { finding, reason } of skipped) {
279
+ const where = `${displayPath(projectDir, finding.filePath)}:${finding.line}`;
280
+ lines.push(` ${where} ${reason}`);
281
+ }
282
+ }
283
+
284
+ console.error(lines.join('\n'));
285
+ }
286
+
287
+ return cliFailureExitCode;
288
+ }
289
+
205
290
  if (isCliEntrypoint(['audit.js', 'emulsify-audit'])) {
206
291
  process.exitCode = runCli();
207
292
  }
@@ -22,14 +22,15 @@ function getRuntimeEnv() {
22
22
  return globalThis.__EMULSIFY_ENV__ || DEFAULT_ENV;
23
23
  }
24
24
 
25
- // GitHub Pages serves static assets from a repository-prefixed base path.
26
- const PUBLIC_ASSET_BASE =
27
- typeof window !== 'undefined' &&
28
- window.location &&
29
- window.location.hostname &&
30
- window.location.hostname.endsWith('github.io')
31
- ? `/${getRuntimeEnv().machineName || ''}/assets/`
32
- : '/assets/';
25
+ // Storybook copies staticDirs beside the preview document, so `iframe.html`
26
+ // and the public `assets/` directory are always siblings in a static build. A
27
+ // document-relative base is resolved against the preview document's own URL:
28
+ // `/iframe.html` at a domain root resolves to `/assets/...`, and
29
+ // `/project/iframe.html` under a deployment subpath resolves to
30
+ // `/project/assets/...`. That covers root deployments, project Pages URLs on
31
+ // any host, custom domains, and arbitrary nested paths without hostname
32
+ // detection or a configured base path.
33
+ const PUBLIC_ASSET_BASE = './assets/';
33
34
 
34
35
  const pendingSourceLoads = new Set();
35
36
  const warnedAssetSources = new Set();
@@ -54,7 +55,10 @@ function normalizeAssetPath(assetPath) {
54
55
  /**
55
56
  * Read a text asset from Storybook's static server.
56
57
  *
57
- * @param {string} relPath - Public asset path below `/assets`.
58
+ * The request URL stays relative to the preview document, so the fallback
59
+ * resolves the same way at a domain root and under a deployment subpath.
60
+ *
61
+ * @param {string} relPath - Public asset path below the public asset base.
58
62
  * @returns {string|undefined} Fetched text when available.
59
63
  */
60
64
  function fetchTextAsset(relPath) {
@@ -77,7 +81,7 @@ function fetchTextAsset(relPath) {
77
81
  /**
78
82
  * Warn once when a text asset cannot use the lazy virtual source map.
79
83
  *
80
- * @param {string} relPath - Public asset path below `/assets`.
84
+ * @param {string} relPath - Public asset path below the public asset base.
81
85
  * @param {string} reason - Short explanation of the missing source.
82
86
  */
83
87
  function warnTextAssetSource(relPath, reason) {