@emulsify/core 4.3.2 → 4.4.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 (44) hide show
  1. package/.storybook/main-static-assets.js +5 -8
  2. package/.storybook/main-vite.js +11 -3
  3. package/README.md +4 -5
  4. package/config/vite/entries.js +7 -2
  5. package/config/vite/environment.js +4 -0
  6. package/config/vite/plugins/assets/asset-url-rebase.js +241 -0
  7. package/config/vite/plugins/assets/copy-src-assets.js +82 -12
  8. package/config/vite/plugins/assets/copy-twig-files.js +85 -16
  9. package/config/vite/plugins/assets/css-asset-rebase.js +306 -0
  10. package/config/vite/plugins/assets/css-asset-relativizer.js +301 -21
  11. package/config/vite/plugins/assets/development-source-maps.js +273 -0
  12. package/config/vite/plugins/assets/mirror-components.js +98 -82
  13. package/config/vite/plugins/assets/output-freshness.js +235 -0
  14. package/config/vite/plugins/assets/source-file-index.js +7 -1
  15. package/config/vite/plugins/assets/stable-watch-output.js +165 -0
  16. package/config/vite/plugins/assets/storybook-output.js +27 -0
  17. package/config/vite/plugins/index.js +95 -9
  18. package/config/vite/plugins/reporter/asset-resolver.js +34 -6
  19. package/config/vite/plugins/reporter/build-errors.js +7 -3
  20. package/config/vite/plugins/reporter/diagnostics.js +140 -10
  21. package/config/vite/plugins/reporter/index.js +380 -75
  22. package/config/vite/plugins/reporter/render.js +297 -44
  23. package/config/vite/plugins/reporter/sass-logger.js +30 -0
  24. package/config/vite/plugins/reporter/source-roots.js +101 -21
  25. package/config/vite/plugins/reporter/strict-mode.js +99 -0
  26. package/config/vite/plugins/reporter/vite-logger.js +220 -8
  27. package/config/vite/plugins/reporter/watch-mode.js +6 -2
  28. package/config/vite/plugins/twig/virtual-twig-asset-sources.js +48 -49
  29. package/config/vite/project-config.js +121 -21
  30. package/config/vite/project-structure.js +6 -0
  31. package/config/vite/utils/asset-roots.js +205 -0
  32. package/config/vite/utils/css-urls.js +350 -0
  33. package/config/vite/utils/fs-safe.js +38 -1
  34. package/config/vite/utils/source-maps.js +88 -0
  35. package/config/vite/vite.config.js +106 -42
  36. package/package.json +40 -29
  37. package/scripts/audit/checks/css-asset-references.js +256 -24
  38. package/scripts/audit/fix.js +836 -0
  39. package/scripts/audit/index.js +10 -2
  40. package/scripts/audit/lib/css.js +41 -35
  41. package/scripts/audit/lib/twig.js +11 -29
  42. package/scripts/audit/report.js +83 -5
  43. package/scripts/audit.js +87 -2
  44. package/src/storybook/twig/source-function.js +14 -10
@@ -5,13 +5,18 @@
5
5
  * structure using the same routing rules as compiled JS and CSS entries.
6
6
  */
7
7
 
8
- import { copyFileSync, mkdirSync } from 'fs';
9
- import { dirname, join } from 'path';
8
+ import { copyFileSync, mkdirSync, statSync } from 'fs';
9
+ import { dirname, isAbsolute, join, resolve } from 'path';
10
10
 
11
11
  import {
12
12
  copiedComponentOutputPath,
13
13
  copiedGlobalOutputPath,
14
14
  } from '../../project-structure.js';
15
+ import {
16
+ filesHaveSameBytes,
17
+ removeDestinationSymlink,
18
+ resolveFinalPath,
19
+ } from './output-freshness.js';
15
20
  import {
16
21
  createSourceFileIndex,
17
22
  isComponentMetadataFile,
@@ -20,14 +25,17 @@ import {
20
25
  /**
21
26
  * Copy Twig templates and component metadata to `dist/`.
22
27
  *
23
- * @param {{ structure: object, sourceFileIndex?: object }} opts - Plugin options.
28
+ * @param {{ structure: object, sourceFileIndex?: object, diagnostics?: object, outputChanges?: Map<string, {kind: 'written'|'removed', bytes?: number}> }} opts - Plugin options.
24
29
  * @returns {import('vite').PluginOption} Copy plugin.
25
30
  */
26
31
  export function copyTwigFilesPlugin({
27
32
  structure,
28
33
  sourceFileIndex = createSourceFileIndex(structure),
34
+ diagnostics,
35
+ outputChanges,
29
36
  }) {
30
37
  let outDir = 'dist';
38
+ let projectDir = process.cwd();
31
39
  let watching = false;
32
40
  /** @type {Array<{absPath: string, relDest: string}>|undefined} */
33
41
  let plan;
@@ -35,10 +43,12 @@ export function copyTwigFilesPlugin({
35
43
  /**
36
44
  * Resolve every file this plugin copies, paired with where it lands.
37
45
  *
38
- * Built once and reused, because the source index is resolved at config time
39
- * and does not change across watch cycles. Both hooks below read this same
40
- * list, which is what keeps "gets copied to dist" and "a save triggers the
41
- * copy" from drifting apart a file cannot be added to one without the other.
46
+ * Shared by both hooks, which keeps "gets copied to dist" and "a save
47
+ * triggers the copy" from drifting apart. Structural events for an
48
+ * individually watched file reset the plan, so a single-file rename can be
49
+ * copied on the next cycle. New files and component-directory changes sit
50
+ * outside that watch set and require a watcher restart. Previous destinations
51
+ * are not pruned; content-only edits keep the cached filesystem walk.
42
52
  *
43
53
  * @returns {Array<{absPath: string, relDest: string}>} Copy plan.
44
54
  */
@@ -86,9 +96,16 @@ export function copyTwigFilesPlugin({
86
96
  /** Capture the final outDir. */
87
97
  configResolved(cfg) {
88
98
  outDir = cfg.build?.outDir || 'dist';
99
+ projectDir = cfg.root || process.cwd();
89
100
  watching = Boolean(cfg.build?.watch);
90
101
  },
91
102
 
103
+ watchChange(_id, { event } = {}) {
104
+ if (!watching || (event !== 'create' && event !== 'delete')) return;
105
+ sourceFileIndex.refresh?.();
106
+ plan = undefined;
107
+ },
108
+
92
109
  // Twig is copied rather than compiled, so none of it reaches Rollup's module
93
110
  // graph, and Rollup only watches what is in that graph. Without this, saving
94
111
  // a template produced no rebuild at all: `dist/` kept the previous version
@@ -103,30 +120,82 @@ export function copyTwigFilesPlugin({
103
120
  /** Copy before the mirror plugin moves dist/components to the project root. */
104
121
  writeBundle() {
105
122
  for (const { absPath, relDest } of copyPlan()) {
106
- copyToOutDir(absPath, relDest);
123
+ const copyResult = copyToOutDir(absPath, relDest);
124
+ if (watching && copyResult.status === 'written') {
125
+ outputChanges?.set(relDest, {
126
+ kind: 'written',
127
+ bytes: copyResult.bytes,
128
+ });
129
+ }
130
+ if (copyResult.status === 'failed' && copyResult.error) {
131
+ const errno = copyResult.error.code ?? 'unknown error';
132
+ const message = `Unable to copy ${absPath} to ${join(outDir, relDest)} (${errno}): ${copyResult.error.message}`;
133
+ diagnostics?.recordError?.({
134
+ message,
135
+ file: absPath,
136
+ outputState: 'incomplete',
137
+ });
138
+ this.warn?.(message);
139
+ }
107
140
  }
108
141
  },
109
142
  };
110
143
 
144
+ /**
145
+ * Resolve the output directory to an absolute path.
146
+ *
147
+ * @returns {string} Absolute output directory.
148
+ */
149
+ function absoluteOutDir() {
150
+ return isAbsolute(outDir) ? outDir : resolve(projectDir, outDir);
151
+ }
152
+
111
153
  /**
112
154
  * Copy one file into the output directory.
113
155
  *
114
156
  * @param {string} absPath - Absolute source path.
115
157
  * @param {string} relDest - Destination relative to `outDir`.
116
- * @returns {void}
158
+ * @returns {{status: 'written'|'skipped'|'failed', bytes?: number, error?: Error}} Copy result.
117
159
  */
118
160
  function copyToOutDir(absPath, relDest) {
119
- if (!relDest) return;
161
+ if (!relDest) return { status: 'failed' };
120
162
 
121
- // Copied unconditionally, because `build.emptyOutDir` clears the output
122
- // directory on every watch cycle and not just the first — a freshness check
123
- // against the destination can never find anything to skip.
124
163
  const destPath = join(outDir, relDest);
125
- mkdirSync(dirname(destPath), { recursive: true });
126
164
  try {
165
+ // A rewritten template in the output tree is a full preview reload rather
166
+ // than a style swap, so byte-identical templates are skipped during watch.
167
+ // One-shot builds continue to copy unconditionally as before.
168
+ if (
169
+ watching &&
170
+ filesHaveSameBytes(
171
+ absPath,
172
+ resolveFinalPath(relDest, {
173
+ outDir: absoluteOutDir(),
174
+ projectDir,
175
+ mirrored: structure?.mirrorComponentOutput,
176
+ }),
177
+ )
178
+ ) {
179
+ return { status: 'skipped' };
180
+ }
181
+
182
+ mkdirSync(dirname(destPath), { recursive: true });
183
+ removeDestinationSymlink(destPath);
127
184
  copyFileSync(absPath, destPath);
128
- } catch {
129
- /* noop */
185
+ let bytes;
186
+ if (watching && outputChanges) {
187
+ try {
188
+ bytes = statSync(destPath).size;
189
+ } catch {
190
+ // The write still succeeded; size is optional reporting metadata.
191
+ }
192
+ }
193
+ return { status: 'written', bytes };
194
+ } catch (error) {
195
+ return {
196
+ status: 'failed',
197
+ error,
198
+ };
130
199
  }
131
200
  }
132
201
  }
@@ -0,0 +1,306 @@
1
+ /**
2
+ * @file CSS asset URL rebase plugin.
3
+ *
4
+ * Repairs CSS `url()` references to project assets that Vite could not resolve.
5
+ * By default, repaired assets are emitted into the self-contained build output;
6
+ * projects that deploy the whole theme can opt into lean output that references
7
+ * the source asset tree instead. See `asset-url-rebase.js` for the repair rules;
8
+ * this module is the Vite wiring.
9
+ *
10
+ * ## Why this runs in a normal-order `transform`
11
+ *
12
+ * The rewrite has to happen where the importing stylesheet is known, which
13
+ * rules out `generateBundle`. It also has to see Sass partials, which rules out
14
+ * `enforce: 'pre'`: `@use`d partials are loaded inside Dart Sass through Vite's
15
+ * own importer, never enter the module graph, and reach no plugin hook. A
16
+ * normal-order `transform` runs after `vite:css` has compiled Sass and
17
+ * attempted URL resolution, so it sees compiled CSS with every partial inlined
18
+ * and every interpolation expanded.
19
+ *
20
+ * That ordering also keeps ordinary URLs non-destructive: a resolved URL is
21
+ * already a `__VITE_ASSET__` placeholder by this point, so the only literals
22
+ * left are ones Vite gave up on. The reserved `@assets/...` namespace is the
23
+ * deliberate exception; the resolver bridge below prevents consumer aliases,
24
+ * packages, and same-named directories from claiming it first.
25
+ *
26
+ * ## Self-contained and lean output
27
+ *
28
+ * The default keeps `dist/` deployable on its own. Vite already copies assets it
29
+ * resolves, while this plugin explicitly emits assets for the URL forms Vite
30
+ * could not resolve. In both cases the relativizer points CSS at the output copy.
31
+ *
32
+ * With `assets.selfContainedOutput: false`, each source path is instead recorded
33
+ * in `publishedAssetSources`, keyed by the path the output copy would have had.
34
+ * `css-asset-relativizer.js` points CSS URLs at the source tree and removes a
35
+ * Vite copy only after that rewrite actually happens. Copies still referenced
36
+ * by JavaScript or another emitted file remain in the output. This matters for
37
+ * configured `assets.roots`, whose real location is not necessarily `assets/`.
38
+ */
39
+
40
+ import { readFileSync } from 'fs';
41
+ import { relative } from 'path';
42
+
43
+ import { resolveAssetRoots } from '../../utils/asset-roots.js';
44
+ import { toPosixPath } from '../../utils/paths.js';
45
+ import {
46
+ ASSET_ALIAS_PREFIX,
47
+ rewriteStylesheetUrls,
48
+ } from './asset-url-rebase.js';
49
+ import { isStorybookOutput } from './storybook-output.js';
50
+
51
+ /** Stylesheet requests this plugin inspects. */
52
+ const STYLE_REQUEST_RE = /\.(css|p?css|sss|styl|stylus|less|sass|scss)(?:$|\?)/;
53
+
54
+ /** Query suffixes that are not stylesheet content. */
55
+ const NON_STYLE_QUERY_RE = /[?&](raw|url)(?:&|$)/;
56
+
57
+ /** Case-insensitive CSS URL function marker. */
58
+ const URL_FUNCTION_RE = /url\(/i;
59
+
60
+ /**
61
+ * Strip the Vite request query from a module id.
62
+ *
63
+ * @param {string} id - Module id.
64
+ * @returns {string} Filesystem path.
65
+ */
66
+ function stripRequestQuery(id) {
67
+ const index = id.indexOf('?');
68
+ return index === -1 ? id : id.slice(0, index);
69
+ }
70
+
71
+ /**
72
+ * Determine whether an emitted asset is a copy of a project asset root file.
73
+ *
74
+ * Rollup records the source path an asset came from in `originalFileNames`.
75
+ * Anything the build generated — the SVG sprite, a JS chunk — has none, so this
76
+ * never mistakes generated output for a copy.
77
+ *
78
+ * @param {object} chunk - Emitted bundle entry.
79
+ * @param {string[]} assetRootPrefixes - Project-relative asset root prefixes.
80
+ * @returns {string} Project-relative source path, or an empty string.
81
+ */
82
+ function copiedAssetSource(chunk, assetRootPrefixes) {
83
+ const original = Array.isArray(chunk.originalFileNames)
84
+ ? chunk.originalFileNames[0]
85
+ : chunk.originalFileName;
86
+ if (!original) return '';
87
+
88
+ const source = toPosixPath(original).replace(/^\.?\//, '');
89
+
90
+ return assetRootPrefixes.some((prefix) => source.startsWith(prefix))
91
+ ? source
92
+ : '';
93
+ }
94
+
95
+ /**
96
+ * Match the reserved alias at the start of a URL or after the relative path
97
+ * Sass inserts when rebasing an imported partial. The captured prefix is put
98
+ * back unchanged by the alias entry below.
99
+ *
100
+ * @type {RegExp}
101
+ */
102
+ const ASSET_ALIAS_RESOLUTION_RE = new RegExp(
103
+ `^(?!/)((?:[^?#]*/)?)(?=${ASSET_ALIAS_PREFIX}/)`,
104
+ );
105
+
106
+ /**
107
+ * Stop Vite's private CSS resolver after it recognizes the Core alias.
108
+ *
109
+ * Vite's CSS resolver does not call user `resolveId` hooks. A truthy result
110
+ * with an empty id makes its isolated alias container stop without resolving a
111
+ * project alias, package, or same-named directory. Vite then leaves the URL for
112
+ * this plugin's normal-order transform, which preserves Core's configured-root
113
+ * resolution, diagnostics, and deterministic output paths.
114
+ *
115
+ * @returns {{id: string}} Empty resolution veto.
116
+ */
117
+ const reserveAssetAlias = () => ({ id: '' });
118
+
119
+ /**
120
+ * Install the resolver veto after Vite has normalized config.
121
+ *
122
+ * Late installation is intentional: Vite warns about alias custom resolvers
123
+ * while normalizing config, but creates its private CSS resolver lazily on the
124
+ * first stylesheet transform. Its normal module alias plugin has already
125
+ * captured consumer entries, so ordinary JavaScript imports keep their
126
+ * existing behavior. A custom resolver created after this hook sees `@assets`
127
+ * as reserved too; projects must not use that stylesheet namespace as a
128
+ * package alias.
129
+ *
130
+ * This bridge intentionally targets Vite 8, which Core pins in package.json.
131
+ * Vite 9 removes alias custom resolvers, so a Vite-major upgrade must replace
132
+ * this bridge; the conflicting-alias release fixture is the fail-loud contract
133
+ * test for that upgrade.
134
+ *
135
+ * @param {import('vite').ResolvedConfig|object} config - Resolved Vite config.
136
+ * @returns {void}
137
+ */
138
+ function reserveAssetAliasForCss(config) {
139
+ const aliasLists = new Set([
140
+ config?.resolve?.alias,
141
+ ...Object.values(config?.environments || {}).map(
142
+ (environment) => environment?.resolve?.alias,
143
+ ),
144
+ ]);
145
+
146
+ for (const aliases of aliasLists) {
147
+ if (!Array.isArray(aliases)) continue;
148
+ if (aliases.some((entry) => entry?.customResolver === reserveAssetAlias)) {
149
+ continue;
150
+ }
151
+
152
+ aliases.unshift({
153
+ find: ASSET_ALIAS_RESOLUTION_RE,
154
+ replacement: '$1',
155
+ customResolver: reserveAssetAlias,
156
+ });
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Rebase unresolvable CSS asset URLs and manage their output target.
162
+ *
163
+ * @param {{env?: object, diagnostics?: object, publishedAssetSources?: Map<string, string>, removablePublishedAssets?: Set<string>}} [opts={}] - Plugin options.
164
+ * @returns {import('vite').PluginOption} Rebase plugin.
165
+ */
166
+ export function cssAssetRebasePlugin({
167
+ env = {},
168
+ diagnostics,
169
+ publishedAssetSources = new Map(),
170
+ removablePublishedAssets = new Set(),
171
+ } = {}) {
172
+ const enabled = env?.projectStructure?.assetRebase !== false;
173
+ const selfContainedOutput =
174
+ env?.projectStructure?.selfContainedOutput !== false;
175
+ const projectDir = env?.projectDir || process.cwd();
176
+ /** @type {Map<string, string>} Published path -> absolute source file. */
177
+ const pendingAssetEmissions = new Map();
178
+
179
+ /** @type {string[]} */
180
+ let roots = [];
181
+ /** @type {string[]} */
182
+ let assetRootPrefixes = [];
183
+ let ownsOutput = true;
184
+
185
+ return {
186
+ name: 'emulsify-css-asset-rebase',
187
+
188
+ configResolved(config) {
189
+ roots = resolveAssetRoots(env);
190
+ assetRootPrefixes = roots
191
+ .map((root) => `${toPosixPath(relative(projectDir, root))}/`)
192
+ .filter((prefix) => prefix !== '/' && !prefix.startsWith('..'));
193
+
194
+ // Storybook serves every asset root at `/assets` through staticDirs and
195
+ // copies them into its own output, so this plugin never owns that output.
196
+ ownsOutput = !isStorybookOutput(config);
197
+
198
+ if (enabled) reserveAssetAliasForCss(config);
199
+ },
200
+
201
+ // Watch rebuilds must not inherit stale publication or emission state.
202
+ buildStart() {
203
+ publishedAssetSources.clear();
204
+ removablePublishedAssets.clear();
205
+ pendingAssetEmissions.clear();
206
+ },
207
+
208
+ transform(code, id) {
209
+ if (!enabled || !roots.length) return null;
210
+ if (!STYLE_REQUEST_RE.test(id) || NON_STYLE_QUERY_RE.test(id)) {
211
+ return null;
212
+ }
213
+ if (!URL_FUNCTION_RE.test(code)) return null;
214
+
215
+ const importer = stripRequestQuery(id);
216
+
217
+ const { code: next, changed } = rewriteStylesheetUrls(
218
+ code,
219
+ importer,
220
+ roots,
221
+ (plan) => {
222
+ if (
223
+ plan.status === 'aliased' ||
224
+ plan.status === 'rebased' ||
225
+ plan.status === 'publish'
226
+ ) {
227
+ if (ownsOutput) {
228
+ if (selfContainedOutput) {
229
+ const fileName = plan.emitAs.replace(/^\/+/, '');
230
+ pendingAssetEmissions.set(fileName, plan.file);
231
+ } else {
232
+ publishedAssetSources.set(
233
+ plan.emitAs,
234
+ toPosixPath(relative(projectDir, plan.file)),
235
+ );
236
+ }
237
+ }
238
+ // Static assets are outside Rollup's module graph, so a swapped
239
+ // image would otherwise go unnoticed until an unrelated rebuild.
240
+ this.addWatchFile(plan.file);
241
+ }
242
+
243
+ // `missing` is deliberately not recorded: Vite already warned about
244
+ // that exact URL and the reporter's logger captures it. Recording it
245
+ // again would double the occurrence count.
246
+ if (
247
+ plan.status === 'aliased' ||
248
+ plan.status === 'rebased' ||
249
+ plan.status === 'ambiguous'
250
+ ) {
251
+ diagnostics?.recordAssetRebase?.({
252
+ status: plan.status,
253
+ url: plan.originalUrl,
254
+ rewritten: plan.url,
255
+ importer,
256
+ resolvedAsset: plan.file,
257
+ candidates: plan.candidates,
258
+ });
259
+ }
260
+ },
261
+ );
262
+
263
+ if (!changed) return null;
264
+
265
+ // Core has already captured Vite's combined Sass/PostCSS map for its
266
+ // development emitter. This empty transform map is what Vite itself uses
267
+ // when CSS maps are off and keeps Rollup from warning about this rewrite.
268
+ return { code: next, map: { mappings: '' } };
269
+ },
270
+
271
+ // Lean output records every Vite copy the relativizer may redirect. The
272
+ // relativizer owns deletion because only an actual CSS rewrite proves the
273
+ // copy is redundant; JS-only and generated assets must survive.
274
+ generateBundle(_, bundle) {
275
+ if (!enabled || !ownsOutput) return;
276
+
277
+ if (selfContainedOutput) {
278
+ // Vite may already have emitted this exact published path for an
279
+ // equivalent `/assets/...` CSS reference or a JavaScript import. Wait
280
+ // until the bundle is known so Core can fill only the missing paths;
281
+ // emitting eagerly from `transform` produces FILE_NAME_CONFLICT noise
282
+ // for the same file under the two accepted stylesheet spellings.
283
+ for (const [fileName, file] of pendingAssetEmissions) {
284
+ if (Object.hasOwn(bundle, fileName)) continue;
285
+
286
+ this.emitFile({
287
+ type: 'asset',
288
+ fileName,
289
+ source: readFileSync(file),
290
+ });
291
+ }
292
+ return;
293
+ }
294
+
295
+ for (const [fileName, chunk] of Object.entries(bundle)) {
296
+ if (chunk.type !== 'asset' || fileName.endsWith('.css')) continue;
297
+
298
+ const source = copiedAssetSource(chunk, assetRootPrefixes);
299
+ if (!source) continue;
300
+
301
+ publishedAssetSources.set(fileName, source);
302
+ removablePublishedAssets.add(fileName);
303
+ }
304
+ },
305
+ };
306
+ }