@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,49 +1,329 @@
1
1
  /**
2
2
  * @file CSS asset URL relativizer plugin.
3
3
  *
4
- * Rewrites emitted CSS references to root assets so nested CSS files can keep
5
- * resolving copied assets correctly from their final output directories.
6
- *
7
- * Sourcemap warning: this rewrites emitted CSS in `generateBundle` without
8
- * adjusting positions, and every replacement changes the length of the line it
9
- * sits on. Vite does not emit sourcemaps for extracted CSS today, so there is
10
- * nothing to invalidate. If CSS sourcemaps are ever added, this plugin has to
11
- * shift mappings as it rewrites (for example via MagicString) or each mapping
12
- * after the first rewritten `url()` will silently resolve to the wrong column.
4
+ * Rewrites emitted CSS references to project assets so each stylesheet resolves
5
+ * them correctly from wherever it ends up on disk.
6
+ *
7
+ * ## What the path is relative to
8
+ *
9
+ * By default, `dist/` is self-contained and a rewritten URL points at the asset
10
+ * copy inside the output. With `assets.selfContainedOutput: false`,
11
+ * `css-asset-rebase.js` supplies `publishedAssetSources`, which maps each
12
+ * published path to where the file lives in the source tree. That indirection
13
+ * matters for a configured `assets.roots` directory, whose real location is not
14
+ * necessarily `assets/`.
15
+ *
16
+ * Two cases stay output-relative. Component CSS mirrored out of `dist/` already
17
+ * sits at the project root, so its path within the output is the project path.
18
+ * And a Storybook build copies every asset root into its own output and serves
19
+ * them at `/assets`, so nothing there should reach outside that output.
20
+ *
21
+ * An asset with no entry in the map lives in the output. This includes every
22
+ * project asset in the default self-contained mode and generated assets such as
23
+ * the SVG sprite in either mode. In lean mode, a mapped Vite copy is removed
24
+ * only after this plugin actually rewrites a CSS URL to its source location;
25
+ * copies referenced by JavaScript or other emitted files remain available.
26
+ *
27
+ * Development-map caveat: Core captures the Sass/PostCSS map before this
28
+ * plugin rewrites finalized asset URLs. Replacements preserve line structure,
29
+ * so selectors and declarations still resolve to their authored source lines.
30
+ * A length-changing replacement can shift later mapping columns on that same
31
+ * generated line, including positions inside the rewritten `url()` value.
32
+ */
33
+
34
+ import { isAbsolute, posix as pathPosix, relative, resolve } from 'path';
35
+
36
+ import { resolveAssetTail } from '../../utils/asset-roots.js';
37
+ import { replaceStylesheetUrlTokens } from '../../utils/css-urls.js';
38
+ import { toPosixPath } from '../../utils/paths.js';
39
+ import { PUBLIC_ASSET_PREFIX, splitUrlSuffix } from './asset-url-rebase.js';
40
+ import { isStorybookOutput } from './storybook-output.js';
41
+
42
+ /** Stylesheet facades Vite may retain as empty Rollup chunks. */
43
+ const STYLE_FACADE_RE =
44
+ /\.(?:css|p?css|sss|styl|stylus|less|sass|scss)(?:$|\?)/;
45
+
46
+ /**
47
+ * Match an emitted URL to the published path recorded by the rebase plugin.
48
+ *
49
+ * Root-absolute URLs name bundle paths directly. A relative emitted URL is
50
+ * resolved from the CSS asset's bundle location. Protocol-relative URLs are
51
+ * never project output, and an encoded or otherwise non-exact path stays in
52
+ * the bundle rather than being guessed at.
53
+ *
54
+ * @param {string} urlPath - URL path without a query or fragment.
55
+ * @param {string} cssFileName - Stylesheet path inside the bundle.
56
+ * @param {Map<string, string>} publishedAssetSources - Recorded bundle paths.
57
+ * @returns {string} Matching published path, or an empty string.
58
+ */
59
+ function recordedPublishedPath(urlPath, cssFileName, publishedAssetSources) {
60
+ if (!urlPath || urlPath.startsWith('//')) return '';
61
+
62
+ if (urlPath.startsWith('/')) {
63
+ const direct = urlPath.slice(1);
64
+
65
+ return publishedAssetSources.has(direct) ? direct : '';
66
+ }
67
+
68
+ const relativePublished = pathPosix.normalize(
69
+ pathPosix.join(pathPosix.dirname(cssFileName), urlPath),
70
+ );
71
+
72
+ return publishedAssetSources.has(relativePublished) ? relativePublished : '';
73
+ }
74
+
75
+ /**
76
+ * Identify an empty Vite chunk whose asset metadata belongs to extracted CSS.
77
+ *
78
+ * Vite records CSS image dependencies on the stylesheet's empty JavaScript
79
+ * facade as `importedAssets`. Those are not independent JavaScript consumers:
80
+ * the emitted CSS was already rewritten above and the facade is omitted from
81
+ * disk. Treating that metadata as a live JS reference would retain every copy.
82
+ *
83
+ * @param {object} output - Rollup output entry.
84
+ * @returns {boolean} TRUE for an extracted-stylesheet facade.
85
+ */
86
+ function isCssFacadeChunk(output) {
87
+ return Boolean(
88
+ output?.type === 'chunk' &&
89
+ typeof output.code === 'string' &&
90
+ !output.code.trim() &&
91
+ STYLE_FACADE_RE.test(output.facadeModuleId || '') &&
92
+ output.viteMetadata?.importedCss?.size,
93
+ );
94
+ }
95
+
96
+ /**
97
+ * Determine whether an emitted non-CSS file still needs a published asset.
98
+ *
99
+ * Rollup and Vite expose referenced assets as metadata, while scanning emitted
100
+ * text is a conservative fallback for synthetic bundles and other output
101
+ * plugins. A false positive only retains a redundant copy; a false negative
102
+ * would ship a broken reference.
103
+ *
104
+ * @param {string} published - Asset path inside the bundle.
105
+ * @param {object} bundle - Rollup output bundle.
106
+ * @returns {boolean} TRUE when deletion would break another emitted file.
107
+ */
108
+ function isReferencedOutsideCss(published, bundle) {
109
+ for (const [fileName, output] of Object.entries(bundle)) {
110
+ if (fileName === published) continue;
111
+
112
+ const isCssAsset = output.type === 'asset' && fileName.endsWith('.css');
113
+ if (!isCssAsset && !isCssFacadeChunk(output)) {
114
+ if (output.referencedFiles?.includes?.(published)) return true;
115
+ if (output.viteMetadata?.importedAssets?.has?.(published)) return true;
116
+ }
117
+
118
+ if (output.type === 'chunk') {
119
+ if (typeof output.code === 'string' && output.code.includes(published)) {
120
+ return true;
121
+ }
122
+ continue;
123
+ }
124
+
125
+ if (isCssAsset) {
126
+ // String CSS assets were already processed above. If another plugin
127
+ // emitted binary CSS, keep candidates because it could not be rewritten.
128
+ if (typeof output.source !== 'string') return true;
129
+ continue;
130
+ }
131
+
132
+ if (
133
+ typeof output.source === 'string' &&
134
+ output.source.includes(published)
135
+ ) {
136
+ return true;
137
+ }
138
+ }
139
+
140
+ return false;
141
+ }
142
+
143
+ /**
144
+ * Confirm a bundle entry is a copied source asset, not generated output.
145
+ *
146
+ * @param {object} output - Rollup output entry.
147
+ * @returns {boolean} TRUE for Vite copies with source provenance.
13
148
  */
149
+ function isCopiedAsset(output) {
150
+ if (output?.type !== 'asset') return false;
151
+ const originals = Array.isArray(output.originalFileNames)
152
+ ? output.originalFileNames
153
+ : output.originalFileName
154
+ ? [output.originalFileName]
155
+ : [];
14
156
 
15
- import { posix as pathPosix } from 'path';
157
+ return originals.length > 0;
158
+ }
16
159
 
17
160
  /**
18
161
  * Rewrites any `url(assets/...)` found in emitted CSS to a path relative to the
19
- * CSS file's directory.
162
+ * CSS file's location on disk.
20
163
  *
21
- * @param {{ assetsRoot?: string }} [opts] - Plugin options.
164
+ * @param {{assetsRoot?: string, env?: object, publishedAssetSources?: Map<string, string>, removablePublishedAssets?: Set<string>}} [opts] - Plugin options.
22
165
  * @returns {import('vite').PluginOption} CSS asset URL plugin.
23
166
  */
24
- export function cssAssetUrlRelativizer({ assetsRoot = 'assets' } = {}) {
167
+ export function cssAssetUrlRelativizer({
168
+ assetsRoot = 'assets',
169
+ env = {},
170
+ publishedAssetSources = new Map(),
171
+ removablePublishedAssets = new Set(),
172
+ } = {}) {
173
+ const enabled = env?.projectStructure?.assetRebase !== false;
174
+ const selfContainedOutput =
175
+ env?.projectStructure?.selfContainedOutput !== false;
176
+ const projectDir = env?.projectDir || process.cwd();
177
+ const mirrorComponentOutput = Boolean(
178
+ env?.projectStructure?.mirrorComponentOutput,
179
+ );
180
+
181
+ let outDirFromProject = 'dist';
182
+ let ownsOutput = true;
183
+ let copiedPublicDir = '';
184
+
185
+ /**
186
+ * Resolve the directory an emitted stylesheet occupies, project-relative.
187
+ *
188
+ * @param {string} fileName - Emitted CSS path within the output directory.
189
+ * @returns {string} Directory the URL resolves from.
190
+ */
191
+ const stylesheetDirectory = (fileName) => {
192
+ const withinOutput = pathPosix.dirname(fileName);
193
+
194
+ // Storybook output is self-contained, and mirrored component CSS is moved
195
+ // out of the output directory to the project root; in both cases the path
196
+ // within the output is already the right base.
197
+ if (!ownsOutput) return withinOutput;
198
+ if (mirrorComponentOutput && fileName.startsWith('components/')) {
199
+ return withinOutput;
200
+ }
201
+
202
+ return pathPosix.join(outDirFromProject, withinOutput);
203
+ };
204
+
25
205
  return {
26
206
  name: 'emulsify-css-asset-url-relativizer',
27
207
  apply: 'build',
208
+
209
+ configResolved(config) {
210
+ // Vite resolves `outDir` against the project root before handing it over,
211
+ // but accept a relative value too so the plugin is testable in isolation.
212
+ const outDir = config?.build?.outDir || 'dist';
213
+ const absoluteOutDir = isAbsolute(outDir)
214
+ ? outDir
215
+ : resolve(projectDir, outDir);
216
+
217
+ outDirFromProject = toPosixPath(relative(projectDir, absoluteOutDir));
218
+ ownsOutput = !isStorybookOutput(config);
219
+
220
+ const publicDir = config?.publicDir;
221
+ copiedPublicDir =
222
+ config?.build?.copyPublicDir !== false &&
223
+ typeof publicDir === 'string' &&
224
+ publicDir
225
+ ? isAbsolute(publicDir)
226
+ ? publicDir
227
+ : resolve(projectDir, publicDir)
228
+ : '';
229
+ },
230
+
28
231
  generateBundle(_, bundle) {
232
+ if (!enabled) return;
233
+
234
+ const rewrittenPublishedAssets = new Set();
235
+ const publicAssetCopies = new Map();
236
+
237
+ const hasPublicAssetCopy = (published) => {
238
+ if (!copiedPublicDir) return false;
239
+ if (!publicAssetCopies.has(published)) {
240
+ publicAssetCopies.set(
241
+ published,
242
+ resolveAssetTail(published, [copiedPublicDir]).status ===
243
+ 'resolved',
244
+ );
245
+ }
246
+
247
+ return publicAssetCopies.get(published);
248
+ };
249
+
29
250
  for (const [fileName, chunk] of Object.entries(bundle)) {
30
251
  if (chunk.type !== 'asset') continue;
31
252
  if (!fileName.endsWith('.css')) continue;
32
253
  if (typeof chunk.source !== 'string') continue;
33
254
 
34
- const fromDir = pathPosix.dirname(fileName);
255
+ const fromDir = stylesheetDirectory(fileName);
256
+
257
+ // Length-changing rewrite: read the development-map caveat in the file
258
+ // header before changing how or where this transform runs.
259
+ chunk.source = replaceStylesheetUrlTokens(
260
+ chunk.source,
261
+ ({ match, quote, value }) => {
262
+ const { path: urlPath, suffix } = splitUrlSuffix(value);
263
+ const absolutePrefix = `/${PUBLIC_ASSET_PREFIX}/`;
264
+ const barePrefix = `${PUBLIC_ASSET_PREFIX}/`;
265
+ const rest = urlPath.startsWith(absolutePrefix)
266
+ ? urlPath.slice(absolutePrefix.length)
267
+ : urlPath.startsWith(barePrefix)
268
+ ? urlPath.slice(barePrefix.length)
269
+ : '';
270
+ // `/assets/...` remains the public alias and honors a customized
271
+ // output-side root. Other Vite-resolved URLs name their recorded
272
+ // bundle path directly, such as `/src/assets/...`.
273
+ const published = rest
274
+ ? pathPosix.join(assetsRoot, rest)
275
+ : recordedPublishedPath(urlPath, fileName, publishedAssetSources);
276
+
277
+ if (!published) return match;
35
278
 
36
- // Length-changing rewrite: read the sourcemap warning in the file
37
- // header before pairing this plugin with CSS sourcemaps.
38
- chunk.source = chunk.source.replace(
39
- /url\((['"]?)\/?assets\/([^)'"]+)\1\)/g,
40
- (match, quote = '', rest) => {
41
- const target = pathPosix.join(assetsRoot, rest);
279
+ // Only rewrite toward a target something actually put there. The
280
+ // rebase plugin emits nothing for an `ambiguous` or `missing` URL,
281
+ // so rewriting those would invent a confident path into the output
282
+ // that no file occupies — turning a reported problem into a broken
283
+ // URL that survives a green build. Leaving the authored URL alone
284
+ // keeps the reporter's diagnostic the only account of it.
285
+ //
286
+ // Restricted to `ownsOutput`: a Storybook build also copies every
287
+ // static directory beside its bundle, so there the bundle is not
288
+ // the whole truth about what the output contains.
289
+ if (
290
+ ownsOutput &&
291
+ !Object.hasOwn(bundle, published) &&
292
+ !publishedAssetSources.has(published) &&
293
+ !hasPublicAssetCopy(published)
294
+ ) {
295
+ return match;
296
+ }
297
+
298
+ // A copied or generated asset is reached inside the output. In lean
299
+ // mode, a mapped project asset is reached where it lives in source.
300
+ // Both targets are project-relative so the same subtraction works.
301
+ const inOutput = ownsOutput
302
+ ? pathPosix.join(outDirFromProject, published)
303
+ : published;
304
+ const target = publishedAssetSources.get(published) || inOutput;
42
305
  const rel = pathPosix.relative(fromDir, target);
43
- return `url(${quote}${rel}${quote})`;
306
+
307
+ if (removablePublishedAssets.has(published)) {
308
+ rewrittenPublishedAssets.add(published);
309
+ }
310
+
311
+ return `url(${quote}${rel}${suffix}${quote})`;
44
312
  },
45
313
  );
46
314
  }
315
+
316
+ if (!ownsOutput || selfContainedOutput) return;
317
+
318
+ for (const published of rewrittenPublishedAssets) {
319
+ if (!removablePublishedAssets.has(published)) continue;
320
+
321
+ const output = bundle[published];
322
+ if (!isCopiedAsset(output)) continue;
323
+ if (isReferencedOutsideCss(published, bundle)) continue;
324
+
325
+ delete bundle[published];
326
+ }
47
327
  },
48
328
  };
49
329
  }
@@ -0,0 +1,273 @@
1
+ /**
2
+ * @file Development source maps for Vite's extracted CSS assets.
3
+ *
4
+ * Vite keeps the Sass/PostCSS map through its CSS transform, then discards it
5
+ * when `vite build` extracts the stylesheet as a Rollup asset. The capture
6
+ * plugin runs after Vite compiles CSS but before Core rewrites asset URLs. The
7
+ * emitter later pairs each direct stylesheet entry with its finalized CSS
8
+ * asset, writes a sibling map, and adds the browser-facing map comment.
9
+ *
10
+ * Core's later URL rewrites preserve line structure. They can shift columns
11
+ * inside a rewritten `url()`, but selector and declaration line mappings stay
12
+ * anchored to their authored Sass sources, which is what browser style
13
+ * inspection uses.
14
+ */
15
+
16
+ import { readFileSync } from 'node:fs';
17
+ import { dirname, isAbsolute, posix, relative, resolve } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ import { safeExists } from '../../utils/fs-safe.js';
21
+ import { toPosixPath } from '../../utils/paths.js';
22
+
23
+ /** Stylesheet requests whose compiled maps can be captured. */
24
+ const STYLE_REQUEST_RE =
25
+ /\.(?:css|p?css|sss|styl|stylus|less|sass|scss)(?:$|\?)/;
26
+
27
+ /** Query suffixes that represent asset contents rather than a stylesheet. */
28
+ const NON_STYLE_QUERY_RE = /[?&](?:raw|url)(?:&|$)/;
29
+
30
+ /** Existing external source-map annotations supplied by a project plugin. */
31
+ const SOURCE_MAP_COMMENT_RE = /\/\*[#@]\s*sourceMappingURL=[^*]+\*\//;
32
+
33
+ /** URI schemes that are not local filesystem paths. */
34
+ const URI_SCHEME_RE = /^[a-z][a-z\d+.-]*:/i;
35
+
36
+ /** Remove Vite's request query from a module id. */
37
+ const cleanId = (id) => String(id).split('?', 1)[0];
38
+
39
+ /** Clone a combined Rollup map before another transform can mutate it. */
40
+ const cloneMap = (map) => JSON.parse(JSON.stringify(map));
41
+
42
+ /**
43
+ * Resolve one captured map source to a local file when possible.
44
+ *
45
+ * @param {string} source - Captured source-map source.
46
+ * @param {string} sourceRoot - Optional captured source root.
47
+ * @param {string} entryId - Absolute stylesheet entry id.
48
+ * @returns {string} Absolute source path, or an empty string for virtual/remote sources.
49
+ */
50
+ function resolveMapSource(source, sourceRoot, entryId) {
51
+ if (!source || source.startsWith('\0')) return '';
52
+
53
+ if (source.startsWith('file:')) {
54
+ try {
55
+ return fileURLToPath(source);
56
+ } catch {
57
+ return '';
58
+ }
59
+ }
60
+
61
+ if (isAbsolute(source)) return source;
62
+ if (URI_SCHEME_RE.test(source) || source.startsWith('//')) return '';
63
+
64
+ let base = dirname(entryId);
65
+ if (sourceRoot) {
66
+ if (sourceRoot.startsWith('file:')) {
67
+ try {
68
+ base = fileURLToPath(sourceRoot);
69
+ } catch {
70
+ return '';
71
+ }
72
+ } else if (isAbsolute(sourceRoot)) {
73
+ base = sourceRoot;
74
+ } else if (URI_SCHEME_RE.test(sourceRoot) || sourceRoot.startsWith('//')) {
75
+ return '';
76
+ } else {
77
+ base = resolve(base, sourceRoot);
78
+ }
79
+ }
80
+
81
+ return resolve(base, source);
82
+ }
83
+
84
+ /**
85
+ * Make captured source paths portable from the emitted map's directory.
86
+ *
87
+ * @param {object} captured - Captured combined source map.
88
+ * @param {string} entryId - Absolute stylesheet entry id.
89
+ * @param {string} mapFile - Absolute emitted map path.
90
+ * @param {string} cssFileName - Output-relative CSS filename.
91
+ * @returns {object} External source-map payload.
92
+ */
93
+ function externalizeMap(captured, entryId, mapFile, cssFileName) {
94
+ const sourceRoot = captured.sourceRoot || '';
95
+ const preserveSourceRoot = Boolean(
96
+ sourceRoot &&
97
+ !sourceRoot.startsWith('file:') &&
98
+ !isAbsolute(sourceRoot) &&
99
+ (URI_SCHEME_RE.test(sourceRoot) || sourceRoot.startsWith('//')),
100
+ );
101
+ const sourceFiles = captured.sources.map((source) =>
102
+ resolveMapSource(source, sourceRoot, entryId),
103
+ );
104
+ const sources = captured.sources.map((source, index) => {
105
+ const sourceFile = sourceFiles[index];
106
+ if (!sourceFile) return source;
107
+ return toPosixPath(relative(dirname(mapFile), sourceFile));
108
+ });
109
+ const capturedContentsAreAligned =
110
+ captured.sourcesContent?.length === captured.sources.length;
111
+ const sourcesContent = captured.sources.map((_, index) => {
112
+ const existing = capturedContentsAreAligned
113
+ ? captured.sourcesContent[index]
114
+ : null;
115
+ if (existing != null) return existing;
116
+
117
+ const sourceFile = sourceFiles[index];
118
+ if (!sourceFile || !safeExists(sourceFile)) return null;
119
+
120
+ try {
121
+ return readFileSync(sourceFile, 'utf8');
122
+ } catch {
123
+ return null;
124
+ }
125
+ });
126
+
127
+ const result = {
128
+ ...captured,
129
+ file: posix.basename(cssFileName),
130
+ sources,
131
+ sourcesContent,
132
+ };
133
+ if (!preserveSourceRoot) delete result.sourceRoot;
134
+ return result;
135
+ }
136
+
137
+ /** Add a candidate entry id for one emitted CSS asset. */
138
+ function addCandidate(candidates, cssFileName, entryId) {
139
+ if (!candidates.has(cssFileName)) candidates.set(cssFileName, new Set());
140
+ candidates.get(cssFileName).add(entryId);
141
+ }
142
+
143
+ /**
144
+ * Create the two plugins that bridge Vite's extracted-CSS source-map gap.
145
+ *
146
+ * The capture plugin and emitter must surround Core's CSS URL plugins in the
147
+ * shared plugin array. They intentionally share one map cache across watch
148
+ * rebuilds because Rollup may reuse an unchanged stylesheet's transform.
149
+ *
150
+ * @param {{projectDir?: string, developmentBuild?: boolean}} [opts={}] - Project paths and invocation mode.
151
+ * @returns {{capture: import('vite').PluginOption, emit: import('vite').PluginOption}}
152
+ */
153
+ export function developmentCssSourceMapPlugins({
154
+ projectDir = process.cwd(),
155
+ developmentBuild = false,
156
+ } = {}) {
157
+ /** @type {Map<string, object>} */
158
+ const capturedMaps = new Map();
159
+ let outDir = resolve(projectDir, 'dist');
160
+ let watching = Boolean(developmentBuild);
161
+ let sourceMapsEnabled = false;
162
+
163
+ const configResolved = (config) => {
164
+ // The invocation signal is available before Vite resolves its config;
165
+ // checking the resolved watch option as well supports projects that turn
166
+ // watch mode on from an extension instead of the CLI flag.
167
+ watching = Boolean(developmentBuild || config?.build?.watch);
168
+ // Consumer overrides remain authoritative. Core emits visible external CSS
169
+ // maps only for its default `true` policy, not `false`, `hidden`, or inline.
170
+ sourceMapsEnabled = config?.build?.sourcemap === true;
171
+ const configuredOutDir = config?.build?.outDir || 'dist';
172
+ outDir = isAbsolute(configuredOutDir)
173
+ ? configuredOutDir
174
+ : resolve(projectDir, configuredOutDir);
175
+ };
176
+
177
+ const capture = {
178
+ name: 'emulsify-development-css-map-capture',
179
+ apply: 'build',
180
+ configResolved,
181
+ transform(_code, id) {
182
+ if (!watching || !sourceMapsEnabled) return null;
183
+ if (!STYLE_REQUEST_RE.test(id) || NON_STYLE_QUERY_RE.test(id)) {
184
+ return null;
185
+ }
186
+
187
+ const map = this.getCombinedSourcemap();
188
+ if (
189
+ !map?.mappings ||
190
+ !Array.isArray(map.sources) ||
191
+ !map.sources.length
192
+ ) {
193
+ return null;
194
+ }
195
+
196
+ capturedMaps.set(cleanId(id), cloneMap(map));
197
+ return null;
198
+ },
199
+ };
200
+
201
+ const emit = {
202
+ name: 'emulsify-development-css-map-emit',
203
+ apply: 'build',
204
+ configResolved,
205
+ generateBundle(_options, bundle) {
206
+ if (!watching || !sourceMapsEnabled || !capturedMaps.size) return;
207
+
208
+ /** @type {Map<string, Set<string>>} */
209
+ const candidates = new Map();
210
+
211
+ // A pure CSS entry still has its facade chunk at this point. Vite removes
212
+ // that empty JavaScript placeholder in its later CSS generateBundle hook.
213
+ for (const output of Object.values(bundle)) {
214
+ if (output.type !== 'chunk' || !output.facadeModuleId) continue;
215
+
216
+ const entryId = cleanId(output.facadeModuleId);
217
+ if (!capturedMaps.has(entryId)) continue;
218
+
219
+ for (const cssFileName of output.viteMetadata?.importedCss || []) {
220
+ addCandidate(candidates, cssFileName, entryId);
221
+ }
222
+ }
223
+
224
+ for (const [cssFileName, output] of Object.entries(bundle)) {
225
+ if (output.type !== 'asset' || !cssFileName.endsWith('.css')) continue;
226
+ if (typeof output.source !== 'string') continue;
227
+ if (SOURCE_MAP_COMMENT_RE.test(output.source)) continue;
228
+
229
+ // Metadata is a fallback for Vite-compatible emitters that omit the
230
+ // empty facade. A map is emitted only for one unambiguous direct entry;
231
+ // concatenated CSS from several modules is deliberately skipped.
232
+ const entryIds = candidates.get(cssFileName) || new Set();
233
+ if (!entryIds.size) {
234
+ const originals = Array.isArray(output.originalFileNames)
235
+ ? output.originalFileNames
236
+ : output.originalFileName
237
+ ? [output.originalFileName]
238
+ : [];
239
+ for (const original of originals) {
240
+ const entryId = isAbsolute(original)
241
+ ? cleanId(original)
242
+ : resolve(projectDir, cleanId(original));
243
+ if (capturedMaps.has(entryId)) entryIds.add(entryId);
244
+ }
245
+ }
246
+ if (entryIds.size !== 1) continue;
247
+
248
+ const [entryId] = entryIds;
249
+ const mapFileName = `${cssFileName}.map`;
250
+ if (Object.hasOwn(bundle, mapFileName)) continue;
251
+
252
+ const mapFile = resolve(outDir, ...mapFileName.split('/'));
253
+ const sourceMap = externalizeMap(
254
+ capturedMaps.get(entryId),
255
+ entryId,
256
+ mapFile,
257
+ cssFileName,
258
+ );
259
+
260
+ this.emitFile({
261
+ type: 'asset',
262
+ fileName: mapFileName,
263
+ source: `${JSON.stringify(sourceMap)}\n`,
264
+ });
265
+ output.source = `${output.source.trimEnd()}\n/*# sourceMappingURL=${posix.basename(
266
+ mapFileName,
267
+ )} */\n`;
268
+ }
269
+ },
270
+ };
271
+
272
+ return { capture, emit };
273
+ }