@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
@@ -1,9 +1,21 @@
1
1
  /**
2
2
  * @file CSS asset reference audit check.
3
+ *
4
+ * Emulsify accepts root-absolute `url('/assets/...')` and namespaced
5
+ * `url('@assets/...')` references. Two other forms are common and both used to
6
+ * ship broken: a relative URL authored against the emitted CSS location, and
7
+ * the bare `assets/...` form. The build repairs both when the target is
8
+ * unambiguous (config/vite/plugins/assets/css-asset-rebase.js), so what this
9
+ * check reports is (a) references nothing can resolve, and (b) references the
10
+ * build has to repair, which are worth writing canonically in source.
3
11
  */
4
12
 
5
13
  import { dirname, resolve } from 'node:path';
6
- import { firstExistingPath } from '../../../config/vite/utils/fs-safe.js';
14
+ import { assetTailFor } from '../../../config/vite/plugins/assets/asset-url-rebase.js';
15
+ import { resolveAssetTail } from '../../../config/vite/utils/asset-roots.js';
16
+ import { isNonFilesystemCssUrl } from '../../../config/vite/utils/css-urls.js';
17
+ import { firstExistingFile } from '../../../config/vite/utils/fs-safe.js';
18
+ import { createAuditFixTargetChecker } from '../fix.js';
7
19
  import { displayPath, makeFinding } from '../lib/findings.js';
8
20
  import {
9
21
  cachedReadFile,
@@ -13,14 +25,188 @@ import {
13
25
  } from '../lib/files.js';
14
26
  import { auditAssetRoots } from '../lib/twig.js';
15
27
  import {
28
+ classifyCssAssetUrl,
16
29
  cssUrlPath,
17
30
  findCssUrlReferences,
18
- isNonFilesystemCssUrl,
31
+ isCssAssetAlias,
19
32
  styleRuntimeDirectories,
20
33
  } from '../lib/css.js';
21
34
 
35
+ const ASSET_DOCS =
36
+ 'https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/asset-references.md#sass-and-css';
37
+
38
+ /**
39
+ * Build the fix payload an autofix can apply to the authored stylesheet.
40
+ *
41
+ * Interpolated URLs are deliberately unfixable: the edit belongs on the
42
+ * variable declaration, and same-file variable scanning cannot see who else
43
+ * depends on it.
44
+ *
45
+ * @param {string} filePath - Absolute stylesheet path.
46
+ * @param {{raw: string, start: number, end: number}} ref - URL reference.
47
+ * @param {string} replacement - Canonical URL.
48
+ * @param {boolean} fixWritable - Whether audit policy permits a rewrite.
49
+ * @returns {object|undefined} Fix payload, when safe to apply.
50
+ */
51
+ function makeUrlFix(filePath, ref, replacement, fixWritable) {
52
+ if (!fixWritable || ref.raw.includes('#{') || ref.raw === replacement) {
53
+ return undefined;
54
+ }
55
+
56
+ return {
57
+ filePath,
58
+ start: ref.start,
59
+ end: ref.end,
60
+ original: ref.raw,
61
+ replacement,
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Build the finding for a CSS asset URL nothing can resolve.
67
+ *
68
+ * @param {object} params - Reference context.
69
+ * @param {string} params.filePath - Absolute stylesheet path.
70
+ * @param {string} [params.projectDir] - Absolute project root.
71
+ * @param {object} params.ref - URL reference.
72
+ * @param {object} params.resolution - Asset tail resolution.
73
+ * @returns {object} Finding.
74
+ */
75
+ function unresolvedFinding({ filePath, projectDir = '', ref, resolution }) {
76
+ const ambiguous = resolution.status === 'ambiguous';
77
+
78
+ return makeFinding({
79
+ id: 'unresolved-css-asset-reference',
80
+ severity: 'warn',
81
+ filePath,
82
+ line: ref.line,
83
+ message: ambiguous
84
+ ? `CSS asset URL "${ref.raw}" matches more than one project asset root.`
85
+ : `CSS asset URL "${ref.raw}" could not be resolved from the source file or any project asset root.`,
86
+ details: ambiguous
87
+ ? [
88
+ `Candidates: ${resolution.candidates
89
+ .map((candidate) => displayPath(projectDir, candidate))
90
+ .join(', ')}.`,
91
+ 'Remove the duplicate, or narrow assets.roots in project.emulsify.json so one file answers to the URL.',
92
+ ]
93
+ : [
94
+ 'Reference project assets with url(/assets/...) or url(@assets/...), and keep the file under assets/ or a root declared in project.emulsify.json assets.roots.',
95
+ 'Otherwise check the filename for a typo.',
96
+ ],
97
+ docs: ASSET_DOCS,
98
+ });
99
+ }
100
+
101
+ /**
102
+ * Report the Core-only stylesheet alias when its resolver is disabled.
103
+ *
104
+ * @param {object} params - Reference context.
105
+ * @param {string} params.filePath - Absolute stylesheet path.
106
+ * @param {object} params.ref - URL reference.
107
+ * @returns {object} Finding.
108
+ */
109
+ function disabledAliasFinding({ filePath, ref }) {
110
+ return makeFinding({
111
+ id: 'unresolved-css-asset-reference',
112
+ severity: 'warn',
113
+ filePath,
114
+ line: ref.line,
115
+ message: `CSS asset URL "${ref.raw}" uses the @assets stylesheet alias, but assets.rebase is disabled.`,
116
+ details: [
117
+ 'Enable assets.rebase to use @assets/..., or write a URL that Vite can resolve without the Emulsify asset pipeline.',
118
+ ],
119
+ docs: ASSET_DOCS,
120
+ });
121
+ }
122
+
123
+ /**
124
+ * Audit a URL that names the published `assets/` prefix.
125
+ *
126
+ * @param {object} params - Reference context.
127
+ * @param {string} params.assetPath - URL path without query or hash.
128
+ * @param {string[]} params.assetRoots - Absolute project asset roots.
129
+ * @param {string} params.filePath - Absolute stylesheet path.
130
+ * @param {Function} params.canFix - Lazily check whether policy permits a rewrite.
131
+ * @param {string} params.projectDir - Absolute project root.
132
+ * @param {object} params.ref - URL reference.
133
+ * @returns {object[]} Findings.
134
+ */
135
+ function auditAssetRootReference({
136
+ assetPath,
137
+ assetRoots,
138
+ canFix,
139
+ filePath,
140
+ projectDir,
141
+ ref,
142
+ }) {
143
+ const tail = assetTailFor(assetPath);
144
+ const resolution = resolveAssetTail(tail, assetRoots);
145
+
146
+ if (resolution.status !== 'resolved') {
147
+ return [unresolvedFinding({ filePath, projectDir, ref, resolution })];
148
+ }
149
+
150
+ const resolvedSuffix = ref.value.slice(cssUrlPath(ref.value).length);
151
+ const resolvedCanonical = `/assets/${tail}${resolvedSuffix}`;
152
+ const resolvedAlias = `@assets/${tail}${resolvedSuffix}`;
153
+
154
+ // The scanner expands simple same-file Sass variables in `ref.value` while
155
+ // retaining the authored interpolation in `ref.raw`. Accept an expansion
156
+ // that lands exactly on either first-class form before deciding that an
157
+ // interpolated reference needs manual review.
158
+ if (ref.value === resolvedCanonical || ref.value === resolvedAlias) return [];
159
+
160
+ const interpolated = ref.raw.includes('#{');
161
+ // A `?v=2` or `#id` suffix is part of the authored URL, not of the asset
162
+ // path, so it survives the rewrite untouched. Interpolation also begins
163
+ // with `#`, but it is authored Sass rather than a URL fragment; deriving a
164
+ // replacement from it would append the complete raw value as a suffix.
165
+ const canonical = interpolated
166
+ ? undefined
167
+ : `/assets/${tail}${ref.raw.slice(cssUrlPath(ref.raw).length)}`;
168
+ const alias = interpolated
169
+ ? undefined
170
+ : `@assets/${tail}${ref.raw.slice(cssUrlPath(ref.raw).length)}`;
171
+
172
+ // Both public spellings are accepted authoring forms.
173
+ if (ref.raw === canonical || ref.raw === alias) return [];
174
+
175
+ const fix = canonical
176
+ ? makeUrlFix(filePath, ref, canonical, canFix())
177
+ : undefined;
178
+ const details = [
179
+ `Resolved asset: ${displayPath(projectDir, resolution.file)}.`,
180
+ ];
181
+
182
+ if (canonical) {
183
+ details.push(`Rewrite it as url(${canonical}).`);
184
+ } else {
185
+ details.push(
186
+ 'This URL contains Sass interpolation, so review its variable declaration instead of rewriting the reference automatically.',
187
+ );
188
+ }
189
+
190
+ if (fix) {
191
+ details.push('Run `emulsify-audit --fix` to apply this automatically.');
192
+ }
193
+
194
+ return [
195
+ makeFinding({
196
+ id: 'css-runtime-asset-reference',
197
+ severity: 'info',
198
+ filePath,
199
+ line: ref.line,
200
+ message: `CSS asset URL "${ref.raw}" is not the canonical asset form, so the build has to repair it.`,
201
+ details,
202
+ docs: ASSET_DOCS,
203
+ fix,
204
+ }),
205
+ ];
206
+ }
207
+
22
208
  /**
23
- * Audit local CSS/Sass asset URLs that Vite may leave to runtime resolution.
209
+ * Audit local CSS/Sass asset URLs against the project's asset roots.
24
210
  *
25
211
  * @param {object} context - Audit context.
26
212
  * @returns {object[]} Findings.
@@ -28,8 +214,16 @@ import {
28
214
  export function auditCssAssetReferences(context) {
29
215
  const { env, projectDir, styleFiles } = context;
30
216
  const findings = [];
31
- const projectAssetRoots = auditAssetRoots(env).filter(safeIsDirectory);
32
- const styleSourceRoots = env.projectStructure?.sourceRoots || [];
217
+ const assetRoots = auditAssetRoots(env).filter(safeIsDirectory);
218
+ const assetAliasEnabled = env.projectStructure?.assetRebase !== false;
219
+ const sourceRoots = Array.isArray(context.sourceRoots)
220
+ ? context.sourceRoots
221
+ : env.projectStructure?.sourceRoots;
222
+ const styleSourceRoots = sourceRoots || [];
223
+ const isFixTargetWritable = createAuditFixTargetChecker({
224
+ projectDir,
225
+ sourceRoots,
226
+ });
33
227
 
34
228
  for (const filePath of styleFiles) {
35
229
  if (
@@ -39,43 +233,81 @@ export function auditCssAssetReferences(context) {
39
233
  continue;
40
234
  }
41
235
 
236
+ let fixWritable;
237
+ const canFix = () => {
238
+ fixWritable ??= isFixTargetWritable(filePath);
239
+ return fixWritable;
240
+ };
42
241
  const source = cachedReadFile(filePath);
43
242
  const runtimeDirs = styleRuntimeDirectories(filePath, env, projectDir);
44
243
 
45
244
  for (const ref of findCssUrlReferences(source)) {
46
- if (isNonFilesystemCssUrl(ref.value)) continue;
245
+ if (isNonFilesystemCssUrl(ref.value, ref.quote)) continue;
47
246
 
48
247
  const assetPath = cssUrlPath(ref.value);
49
248
  if (!assetPath) continue;
50
249
 
51
- const sourceAsset = firstExistingPath([
52
- resolve(dirname(filePath), assetPath),
53
- ]);
54
- const runtimeAsset = firstExistingPath(
250
+ if (isCssAssetAlias(assetPath) && !assetAliasEnabled) {
251
+ findings.push(disabledAliasFinding({ filePath, ref }));
252
+ continue;
253
+ }
254
+
255
+ // CSS resolves non-absolute URLs from the source stylesheet first. The
256
+ // build plugin only sees literals Vite already failed to resolve, but the
257
+ // audit scans authored source and must preserve that precedence itself.
258
+ // Do not probe `/assets/...` against the filesystem root: it is the
259
+ // canonical project-asset form, not a source-relative path.
260
+ const sourceAsset =
261
+ assetPath.startsWith('/') || isCssAssetAlias(assetPath)
262
+ ? undefined
263
+ : firstExistingFile([resolve(dirname(filePath), assetPath)]);
264
+ const classification = classifyCssAssetUrl(ref.value);
265
+
266
+ // Some other absolute URL: the platform serves it, and there is no
267
+ // project file to check it against.
268
+ if (classification === 'runtime') continue;
269
+
270
+ if (classification === 'asset-root') {
271
+ // Rewriting a working local reference could select a different file
272
+ // with the same tail under a project asset root. It needs no repair and
273
+ // is deliberately ineligible for --fix.
274
+ if (sourceAsset) continue;
275
+
276
+ findings.push(
277
+ ...auditAssetRootReference({
278
+ assetPath,
279
+ assetRoots,
280
+ canFix,
281
+ filePath,
282
+ projectDir,
283
+ ref,
284
+ }),
285
+ );
286
+ continue;
287
+ }
288
+
289
+ const runtimeAsset = firstExistingFile(
55
290
  runtimeDirs.map((directory) => resolve(directory, assetPath)),
56
291
  );
57
292
  const resolvedAsset = sourceAsset || runtimeAsset;
58
293
 
59
294
  if (!resolvedAsset) {
60
295
  findings.push(
61
- makeFinding({
62
- id: 'unresolved-css-asset-reference',
63
- severity: 'warn',
296
+ unresolvedFinding({
64
297
  filePath,
65
- line: ref.line,
66
- message: `CSS asset URL "${ref.raw}" could not be resolved from the source file or expected emitted CSS location.`,
67
- details: [
68
- 'Check for a typo, move the asset into a source-root-relative location Vite can resolve, or rewrite the URL to a stable Drupal/theme public path.',
69
- ],
70
- docs: 'https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/migration-4x.md#css-asset-urls',
298
+ projectDir,
299
+ ref,
300
+ resolution: { status: 'missing' },
71
301
  }),
72
302
  );
73
303
  continue;
74
304
  }
75
305
 
306
+ // A relative URL that only resolves once the CSS is emitted is exactly
307
+ // the shape that breaks when the output shape changes.
76
308
  if (
77
- projectAssetRoots.some((root) => isSameOrInside(resolvedAsset, root)) &&
78
- (!sourceAsset || runtimeAsset || assetPath.startsWith('..'))
309
+ assetRoots.some((root) => isSameOrInside(resolvedAsset, root)) &&
310
+ (!sourceAsset || runtimeAsset)
79
311
  ) {
80
312
  findings.push(
81
313
  makeFinding({
@@ -83,12 +315,12 @@ export function auditCssAssetReferences(context) {
83
315
  severity: 'info',
84
316
  filePath,
85
317
  line: ref.line,
86
- message: `CSS asset URL "${ref.raw}" resolves to project-level assets and may be left unchanged by Vite for runtime resolution.`,
318
+ message: `CSS asset URL "${ref.raw}" reaches project assets by a path that only resolves once the CSS is emitted.`,
87
319
  details: [
88
320
  `Resolved asset: ${displayPath(projectDir, resolvedAsset)}.`,
89
- 'This is acceptable when Drupal serves the asset at that runtime URL. To make Vite bundle or rebase it, move the asset under a source root and reference it from the authored stylesheet.',
321
+ 'Write it as url(/assets/...) so the same source works in Storybook and in every emitted CSS location.',
90
322
  ],
91
- docs: 'https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/migration-4x.md#css-asset-urls',
323
+ docs: ASSET_DOCS,
92
324
  }),
93
325
  );
94
326
  }
@@ -6,7 +6,7 @@ import { resolve } from 'node:path';
6
6
  import { makeFinding } from '../lib/findings.js';
7
7
  import { cachedReadFile } from '../lib/files.js';
8
8
  import {
9
- findTwigIncludeSourceReferences,
9
+ findTwigReferenceCalls,
10
10
  findTwigNamespaceReferences,
11
11
  resolvesTwigReference,
12
12
  } from '../lib/twig.js';
@@ -23,6 +23,7 @@ export function auditTwigReferences(context) {
23
23
  const knownNamespaces = new Set([...Object.keys(namespaceRoots), 'assets']);
24
24
  const findings = [];
25
25
  const seen = new Set();
26
+ const componentGroupRootsCache = new Map();
26
27
 
27
28
  for (const twigFile of twigFiles) {
28
29
  const source = cachedReadFile(twigFile);
@@ -46,15 +47,25 @@ export function auditTwigReferences(context) {
46
47
  );
47
48
  }
48
49
 
49
- for (const ref of findTwigIncludeSourceReferences(source)) {
50
- if (!resolvesTwigReference(ref.value, twigFile, env)) {
50
+ for (const call of findTwigReferenceCalls(source)) {
51
+ // Optional or uncertain calls cannot establish a required missing target.
52
+ if (call.ignoreMissing !== false || call.hasDynamicCandidates) continue;
53
+ const resolved = call.candidates.some(
54
+ ({ value }) =>
55
+ value !== '' &&
56
+ resolvesTwigReference(value, twigFile, env, componentGroupRootsCache),
57
+ );
58
+ if (!resolved) {
59
+ const description = call.isFallbackArray
60
+ ? `fallback candidates ${JSON.stringify(call.candidates.map(({ value }) => value))}`
61
+ : `reference "${call.candidates[0].value}"`;
51
62
  findings.push(
52
63
  makeFinding({
53
64
  id: 'unresolved-twig-reference',
54
65
  severity: 'warn',
55
66
  filePath: twigFile,
56
- line: ref.line,
57
- message: `${ref.type}() reference "${ref.value}" could not be resolved from the normalized Twig roots.`,
67
+ line: call.line,
68
+ message: `${call.type}() ${description} could not be resolved from the normalized Twig roots.`,
58
69
  docs: 'https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/storybook.md#include',
59
70
  }),
60
71
  );