@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
@@ -0,0 +1,316 @@
1
+ /**
2
+ * @file Node-safe component template resolution shared by Twig compilation and audits.
3
+ *
4
+ * Callers own the grouping-directory cache so a build can invalidate it on
5
+ * filesystem changes and an audit can discard it after each pass.
6
+ */
7
+
8
+ import fs from 'node:fs';
9
+ import { basename, isAbsolute, relative, resolve } from 'node:path';
10
+ import { toPosixPath } from './paths.js';
11
+ import { DEFAULT_SKIP_DIRS } from './source-directory-skips.js';
12
+ import { unique } from '../../../src/extensions/shared/lists.js';
13
+
14
+ /**
15
+ * Build likely filesystem candidates for a Twig template reference.
16
+ *
17
+ * @param {string} baseDir - Directory used as the resolution root.
18
+ * @param {string} templatePath - Template path from Twig source.
19
+ * @returns {string[]} Candidate absolute paths.
20
+ */
21
+ export const buildTemplateFileCandidates = (baseDir, templatePath) => {
22
+ const normalizedTemplatePath = toPosixPath(templatePath);
23
+ const withoutTwigExt = normalizedTemplatePath.replace(/\.twig$/i, '');
24
+ const stem = basename(withoutTwigExt);
25
+
26
+ return unique(
27
+ [
28
+ resolve(baseDir, normalizedTemplatePath),
29
+ resolve(baseDir, `${normalizedTemplatePath}.twig`),
30
+ resolve(baseDir, `${normalizedTemplatePath}.html.twig`),
31
+ resolve(baseDir, withoutTwigExt, `${stem}.twig`),
32
+ resolve(baseDir, withoutTwigExt, `${stem}.html.twig`),
33
+ ].filter(Boolean),
34
+ );
35
+ };
36
+
37
+ /**
38
+ * Determine whether a file path is equal to or below a candidate root.
39
+ *
40
+ * @param {string} root - Absolute root path.
41
+ * @param {string} filePath - Absolute file path.
42
+ * @returns {boolean} TRUE when the file belongs to the root.
43
+ */
44
+ export const isWithinRoot = (root, filePath) => {
45
+ const rootRelativePath = relative(root, filePath);
46
+ return (
47
+ rootRelativePath === '' ||
48
+ (!!rootRelativePath &&
49
+ !rootRelativePath.startsWith('..') &&
50
+ !isAbsolute(rootRelativePath))
51
+ );
52
+ };
53
+
54
+ /**
55
+ * Return the first component template candidate contained by its configured root.
56
+ *
57
+ * Both lexical and real paths are checked so `..` segments and symlinks cannot
58
+ * escape the component root.
59
+ *
60
+ * @param {Iterable<string>} paths - Candidate absolute paths in precedence order.
61
+ * @param {string} componentRoot - Absolute component root path.
62
+ * @returns {string|undefined} Existing component template path.
63
+ */
64
+ const findExistingComponentTemplateFile = (paths, componentRoot) => {
65
+ const absoluteRoot = resolve(componentRoot);
66
+ let realRoot;
67
+
68
+ try {
69
+ realRoot = fs.realpathSync(absoluteRoot);
70
+ } catch {
71
+ return undefined;
72
+ }
73
+
74
+ for (const filePath of paths) {
75
+ if (!filePath) continue;
76
+ const absoluteFilePath = resolve(filePath);
77
+ if (!isWithinRoot(absoluteRoot, absoluteFilePath)) {
78
+ continue;
79
+ }
80
+
81
+ try {
82
+ if (
83
+ fs.statSync(absoluteFilePath).isFile() &&
84
+ isWithinRoot(realRoot, fs.realpathSync(absoluteFilePath))
85
+ ) {
86
+ return filePath;
87
+ }
88
+ } catch {
89
+ // A missing or unreadable candidate does not prevent later matches.
90
+ }
91
+ }
92
+ return undefined;
93
+ };
94
+
95
+ /**
96
+ * Resolve Twig namespace syntax to a namespace root and relative path.
97
+ *
98
+ * @param {string} templatePath - Template reference from Twig source.
99
+ * @param {Record<string, string>} [namespaces={}] - Namespace root map.
100
+ * @returns {{ namespace: string, root: string, path: string }|null}
101
+ * Namespace lookup result.
102
+ */
103
+ export const parseTwigNamespaceReference = (templatePath, namespaces = {}) => {
104
+ const namespaceNames = Object.keys(namespaces);
105
+ const atNamespace = templatePath.match(/^@([^/]+)\/(.+)$/);
106
+ if (atNamespace && namespaces[atNamespace[1]]) {
107
+ return {
108
+ namespace: atNamespace[1],
109
+ root: namespaces[atNamespace[1]],
110
+ path: atNamespace[2],
111
+ };
112
+ }
113
+
114
+ const doubleColon = templatePath.match(/^([^:]+)::(.+)$/);
115
+ if (doubleColon && namespaces[doubleColon[1]]) {
116
+ return {
117
+ namespace: doubleColon[1],
118
+ root: namespaces[doubleColon[1]],
119
+ path: doubleColon[2],
120
+ };
121
+ }
122
+
123
+ const singleColon = templatePath.match(/^([^:/.]+):(.+)$/);
124
+ if (singleColon && namespaces[singleColon[1]]) {
125
+ return {
126
+ namespace: singleColon[1],
127
+ root: namespaces[singleColon[1]],
128
+ path: singleColon[2],
129
+ };
130
+ }
131
+
132
+ const slashNamespace = namespaceNames.find((namespace) =>
133
+ templatePath.startsWith(`${namespace}/`),
134
+ );
135
+ if (slashNamespace) {
136
+ return {
137
+ namespace: slashNamespace,
138
+ // Namespace names come from the normalized Twig namespace map.
139
+ root: namespaces[slashNamespace],
140
+ path: templatePath.slice(slashNamespace.length + 1),
141
+ };
142
+ }
143
+
144
+ return null;
145
+ };
146
+
147
+ /**
148
+ * Return grouping directories below the configured component root.
149
+ *
150
+ * Breadth-first traversal preserves direct and one-level behavior before
151
+ * searching deeper groups. Siblings use UTF-16 code-unit order so duplicate
152
+ * shorthand names resolve consistently across filesystems.
153
+ *
154
+ * @param {string} componentRoot - Absolute component root path.
155
+ * @param {Map<string, string[]>} componentGroupRootsCache - Caller-scoped directory cache.
156
+ * @returns {string[]} Absolute grouping directory paths.
157
+ */
158
+ const componentGroupRoots = (componentRoot, componentGroupRootsCache) => {
159
+ if (!componentRoot) return [];
160
+
161
+ const absoluteRoot = resolve(componentRoot);
162
+ if (componentGroupRootsCache.has(absoluteRoot)) {
163
+ return componentGroupRootsCache.get(absoluteRoot);
164
+ }
165
+
166
+ const groupRoots = [];
167
+ const pendingDirectories = [absoluteRoot];
168
+
169
+ for (let index = 0; index < pendingDirectories.length; index += 1) {
170
+ const directory = pendingDirectories[index];
171
+ let entries;
172
+
173
+ try {
174
+ entries = fs.readdirSync(directory, { withFileTypes: true });
175
+ } catch {
176
+ continue;
177
+ }
178
+
179
+ const childDirectories = entries
180
+ .filter(
181
+ (entry) =>
182
+ entry.isDirectory() && !DEFAULT_SKIP_DIRS.includes(entry.name),
183
+ )
184
+ .sort(({ name: left }, { name: right }) =>
185
+ left === right ? 0 : left < right ? -1 : 1,
186
+ )
187
+ .map((entry) => resolve(directory, entry.name))
188
+ .filter((childDirectory) => isWithinRoot(absoluteRoot, childDirectory));
189
+
190
+ groupRoots.push(...childDirectories);
191
+ pendingDirectories.push(...childDirectories);
192
+ }
193
+
194
+ componentGroupRootsCache.set(absoluteRoot, groupRoots);
195
+ return groupRoots;
196
+ };
197
+
198
+ /**
199
+ * Resolve a component reference through recursively grouped directories.
200
+ *
201
+ * Project-scoped component IDs can use the component name (`project:button`)
202
+ * even when projects organize components under grouping paths such as
203
+ * `atoms/text`.
204
+ *
205
+ * @param {string} templatePath - Component-relative template reference.
206
+ * @param {string} componentRoot - Absolute component root path.
207
+ * @param {Map<string, string[]>} componentGroupRootsCache - Caller-scoped directory cache.
208
+ * @returns {string|null} Existing template path when found.
209
+ */
210
+ const resolveGroupedComponentTemplate = (
211
+ templatePath,
212
+ componentRoot,
213
+ componentGroupRootsCache,
214
+ ) => {
215
+ const groupRoots = componentGroupRoots(
216
+ componentRoot,
217
+ componentGroupRootsCache,
218
+ );
219
+ function* candidates() {
220
+ for (const groupRoot of groupRoots) {
221
+ yield* buildTemplateFileCandidates(groupRoot, templatePath);
222
+ }
223
+ }
224
+
225
+ return findExistingComponentTemplateFile(candidates(), componentRoot) || null;
226
+ };
227
+
228
+ /**
229
+ * Resolve shorthand component references against the components namespace.
230
+ *
231
+ * @param {string} templatePath - Template reference from Twig source.
232
+ * @param {string} componentRoot - Absolute component root path.
233
+ * @param {Map<string, string[]>} componentGroupRootsCache - Caller-scoped directory cache.
234
+ * @returns {string|null} Existing template path when found.
235
+ */
236
+ const resolveComponentShorthandReference = (
237
+ templatePath,
238
+ componentRoot,
239
+ componentGroupRootsCache,
240
+ ) => {
241
+ if (!componentRoot || templatePath.startsWith('.')) return null;
242
+
243
+ const shorthandPath =
244
+ templatePath.startsWith('@') && !templatePath.includes('/')
245
+ ? templatePath.slice(1)
246
+ : templatePath;
247
+ const directComponentPath = findExistingComponentTemplateFile(
248
+ buildTemplateFileCandidates(componentRoot, shorthandPath),
249
+ componentRoot,
250
+ );
251
+ if (directComponentPath) {
252
+ return directComponentPath;
253
+ }
254
+
255
+ // A bare directory path keeps every segment; only explicit namespace syntax
256
+ // can drop its prefix before searching component groups.
257
+ const genericNamespace = templatePath.match(/^(?:@[^/:]+\/|@?[^/:]+:)(.+)$/);
258
+ if (!genericNamespace) {
259
+ return null;
260
+ }
261
+
262
+ const genericComponentPath = genericNamespace[1];
263
+
264
+ return (
265
+ findExistingComponentTemplateFile(
266
+ buildTemplateFileCandidates(componentRoot, genericComponentPath),
267
+ componentRoot,
268
+ ) ||
269
+ resolveGroupedComponentTemplate(
270
+ genericComponentPath,
271
+ componentRoot,
272
+ componentGroupRootsCache,
273
+ )
274
+ );
275
+ };
276
+
277
+ /**
278
+ * Resolve component namespace paths and project-scoped component shorthand.
279
+ *
280
+ * Configured non-component namespaces remain scoped to their own roots. Direct
281
+ * candidates precede grouped candidates, which retain breadth-first/UTF-16
282
+ * order for duplicate component names.
283
+ *
284
+ * @param {string} templatePath - Template reference from Twig source.
285
+ * @param {Record<string, string>} namespaces - Normalized namespace root map.
286
+ * @param {Map<string, string[]>} componentGroupRootsCache - Caller-scoped directory cache.
287
+ * @returns {string|null} Existing component template path when found.
288
+ */
289
+ export const resolveComponentReference = (
290
+ templatePath,
291
+ namespaces,
292
+ componentGroupRootsCache,
293
+ ) => {
294
+ const namespaced = parseTwigNamespaceReference(templatePath, namespaces);
295
+ if (namespaced) {
296
+ if (namespaced.namespace !== 'components') return null;
297
+
298
+ return (
299
+ findExistingComponentTemplateFile(
300
+ buildTemplateFileCandidates(namespaced.root, namespaced.path),
301
+ namespaced.root,
302
+ ) ||
303
+ resolveGroupedComponentTemplate(
304
+ namespaced.path,
305
+ namespaced.root,
306
+ componentGroupRootsCache,
307
+ )
308
+ );
309
+ }
310
+
311
+ return resolveComponentShorthandReference(
312
+ templatePath,
313
+ namespaces?.components,
314
+ componentGroupRootsCache,
315
+ );
316
+ };
@@ -13,18 +13,13 @@
13
13
  * parts of it by returning a patch object from `extendConfig(...)`.
14
14
  *
15
15
  * Notes:
16
- * - JS sourcemaps come from `build.sourcemap`. Extracted CSS gets no map from
17
- * `vite build`: `vite:css-post` emits CSS through
18
- * `this.emitFile({ type: 'asset' })`, Rollup/Rolldown assets carry no map,
19
- * and `finalizeCss()` -> `minifyCSS()` returns code only. To trace a rule
20
- * back to its `.scss` partial, let Vite compile the SCSS in Storybook: set
21
- * `parameters.emulsify.loadAllCSS = false` in
22
- * `config/emulsify-core/storybook/preview.js` and import the SCSS entry
23
- * there, so `css.devSourcemap` can chain the map to source. Loading the
24
- * compiled CSS instead yields an identity map whose only source is the
25
- * compiled `.css` file.
26
- * - CSS is left unminified during `vite build --watch` so the develop loop
27
- * stays readable; one-shot builds keep minification.
16
+ * - `vite build --watch` emits external JS and CSS maps while one-shot
17
+ * production builds ship neither. Vite discards maps when it extracts CSS as
18
+ * an asset, so Core captures the Sass/PostCSS map before URL rewriting and
19
+ * attaches it to the finalized stylesheet. URL rewrites preserve source
20
+ * lines, though a changed URL length can shift columns inside that value.
21
+ * - JS and CSS stay unminified during `vite build --watch` so generated output
22
+ * remains readable. One-shot builds keep Vite's production minification.
28
23
  * - CSS assets keep their path and drop the internal `__style` suffix if present.
29
24
  */
30
25
 
@@ -36,7 +31,10 @@ import { makePlugins } from './plugins.js';
36
31
  import { buildInputs } from './entries.js';
37
32
  import { createSourceFileIndex } from './plugins/assets/source-file-index.js';
38
33
  import { createDiagnosticsCollector } from './plugins/reporter/diagnostics.js';
39
- import { createSassOptions } from './plugins/reporter/sass-logger.js';
34
+ import {
35
+ createSassOptions,
36
+ shouldQuietSass,
37
+ } from './plugins/reporter/sass-logger.js';
40
38
  import {
41
39
  createReporterLogger,
42
40
  isVerbose,
@@ -45,7 +43,7 @@ import { isWatchInvocation } from './plugins/reporter/watch-mode.js';
45
43
  import { loadProjectExtensions } from './project-extensions.js';
46
44
  import { mergeReactSingletonResolve } from './utils/react-singleton.js';
47
45
 
48
- export default defineConfig(async () => {
46
+ async function createViteConfig({ command, isStorybookBuild = false } = {}) {
49
47
  /**
50
48
  * Environment details for this build (project paths, platform, flags).
51
49
  * @typedef {Object} EmulsifyEnv
@@ -56,20 +54,42 @@ export default defineConfig(async () => {
56
54
  * @property {boolean} [SDC] - Single Directory Components toggle, if available.
57
55
  * @property {boolean} [structureOverrides] - Whether component structure overrides are enabled.
58
56
  * @property {string[]} [structureRoots] - Override roots, if provided.
57
+ * @property {boolean} [assetRebase] - Whether unresolved CSS asset URLs are repaired.
58
+ * @property {boolean} [selfContainedOutput] - Whether project assets remain in the output.
59
59
  * @property {object} [platformAdapter] - Active platform behavior adapter.
60
+ * @property {boolean} [developmentBuild] - Whether this is the long-running develop build.
60
61
  */
61
62
 
62
63
  /** @type {EmulsifyEnv} */
63
64
  const env = resolveEnvironment();
64
65
  const sourceFileIndex = createSourceFileIndex(env.projectStructure);
65
66
 
66
- // The develop reporter takes over output only for `vite build --watch`, the
67
- // watcher `npm run develop` runs. One-shot builds, Storybook, and the release
68
- // fixture verifications keep their existing output untouched, so no warning
69
- // is ever collected without also being reported.
67
+ // The full develop summary runs only for `vite build --watch`, the watcher
68
+ // `npm run develop` starts. One-shot builds keep their normal output and add
69
+ // a compact diagnostic block only when the collector has something to say.
70
70
  const watching = isWatchInvocation();
71
- const diagnostics = watching ? createDiagnosticsCollector() : undefined;
72
- const envWithSourceFileIndex = { ...env, sourceFileIndex, diagnostics };
71
+
72
+ // The collector itself is a handful of Maps, and one-shot builds need one
73
+ // too: an unresolved CSS asset URL used to print a single raw Vite line and
74
+ // exit 0, so a broken asset path shipped through CI unnoticed. The reporter
75
+ // plugin decides whether to speak, and for a one-shot build it stays silent
76
+ // unless there is an asset problem or a collected Sass deprecation tally —
77
+ // a clean project's output is unchanged.
78
+ const diagnostics = createDiagnosticsCollector();
79
+ const envWithSourceFileIndex = {
80
+ ...env,
81
+ sourceFileIndex,
82
+ diagnostics,
83
+ developmentBuild: watching,
84
+ };
85
+
86
+ // `vite build` and `vite build --watch` both resolve `command: 'build'`.
87
+ // Storybook pins `serve` for both of its commands, so its Vite adapter
88
+ // supplies the separate static-build signal. Raw verbose output still needs
89
+ // the wrapper: it passes the notice through while retaining a copy for
90
+ // strict asset mode.
91
+ const captureViteNotices =
92
+ !watching && (command === 'build' || isStorybookBuild);
73
93
 
74
94
  // Build the Rollup/Vite entry map: keys encode output paths, values source files.
75
95
  /** @type {Record<string, string>} */
@@ -93,7 +113,9 @@ export default defineConfig(async () => {
93
113
  * extendConfig?: (base: import('vite').UserConfig, ctx: { env: EmulsifyEnv }) => import('vite').UserConfig
94
114
  * }}
95
115
  */
96
- const { projectPlugins, extendConfig } = await loadProjectExtensions({ env });
116
+ const { projectPlugins, extendConfig } = await loadProjectExtensions({
117
+ env,
118
+ });
97
119
 
98
120
  // Assemble the base config before applying project extensions.
99
121
  /** @type {import('vite').UserConfig} */
@@ -137,7 +159,15 @@ export default defineConfig(async () => {
137
159
  // build reporter consults the level and never consults the logger.
138
160
  // `build.reportCompressedSize` is deliberately left alone; it suppresses
139
161
  // only the gzip column, and the table it belongs to is already gone.
140
- ...(diagnostics
162
+ //
163
+ // A one-shot build takes only the second switch. `logLevel: 'warn'` is what
164
+ // stops Rolldown instrumenting transforms, so setting it there would delete
165
+ // the module count and the per-file asset table from `npm run build` — the
166
+ // one command whose output people actually read. The comment above already
167
+ // establishes the two are independent, and this relies on that: the logger
168
+ // captures the unresolved-URL notices, the reporter prints them back as one
169
+ // block, and Rolldown's report is untouched.
170
+ ...(watching
141
171
  ? {
142
172
  logLevel: isVerbose() ? 'info' : 'warn',
143
173
  customLogger: createReporterLogger(
@@ -145,42 +175,52 @@ export default defineConfig(async () => {
145
175
  createLogger(isVerbose() ? 'info' : 'warn'),
146
176
  ),
147
177
  }
148
- : {}),
178
+ : captureViteNotices
179
+ ? { customLogger: createReporterLogger(diagnostics, createLogger()) }
180
+ : {}),
149
181
 
150
182
  // Keep React-based story helpers on the consumer project's React singleton.
151
183
  resolve: mergeReactSingletonResolve(),
152
184
 
153
- // Generate CSS sourcemaps in dev; JS sourcemaps are set in `build.sourcemap`.
154
- // These map only what Vite itself compiles. A preview that imports
155
- // already-compiled CSS gets an identity map pointing at that `.css` file,
156
- // so import SCSS entries when styles need to resolve to their partials.
185
+ // Ask Sass/PostCSS for maps. Vite uses them directly in its dev server;
186
+ // Core's development map plugins retain them for extracted watch-build CSS.
187
+ // JS sourcemaps are controlled by `build.sourcemap` below.
157
188
  css: {
158
189
  devSourcemap: true,
159
190
 
160
- // During a watch build, route Sass warnings into the diagnostics
161
- // collector instead of letting Dart Sass print a formatted block per
162
- // occurrence. The reporter prints one deduplicated tally per cycle, so
163
- // the deprecation debt stays visible without the repetition.
164
- ...(diagnostics
191
+ // Route Sass warnings into the diagnostics collector instead of letting
192
+ // Dart Sass print a formatted block per occurrence. The reporter prints
193
+ // one deduplicated tally per develop session or standalone Storybook
194
+ // build, so the debt stays visible without the repetition.
195
+ // `shouldQuietSass` owns which invocations get this.
196
+ ...(shouldQuietSass({ watching, command, verbose: isVerbose() })
165
197
  ? { preprocessorOptions: { scss: createSassOptions(diagnostics) } }
166
198
  : {}),
167
199
  },
168
200
 
169
201
  build: {
170
- // Clean the output directory before building.
202
+ // Clean the output directory before building. Vite re-empties it on every
203
+ // watch rebuild, not just the first, which rewrites stylesheets no edit
204
+ // touched; `stableWatchOutputPlugin` turns that off once the develop
205
+ // loop's first cycle has produced a clean tree.
171
206
  emptyOutDir: true,
172
207
 
173
208
  // All outputs are written into ./dist/
174
209
  outDir: 'dist/',
175
210
 
176
- // Emit JS sourcemaps. Extracted CSS is not covered; see the file header.
177
- sourcemap: true,
211
+ // Keep source maps available to the develop watcher without shipping
212
+ // them in one-shot production builds. Core bridges the extracted-CSS gap
213
+ // left by Vite; see the file header.
214
+ sourcemap: watching,
215
+
216
+ // Readable generated JavaScript plus its source map makes the watch
217
+ // output useful on both sides of devtools. Production retains Vite's
218
+ // default minification behavior through the explicit TRUE value.
219
+ minify: !watching,
178
220
 
179
- // Vite cannot map extracted CSS, so during `vite build --watch` the
180
- // readable stylesheet is the debugging aid: keep it unminified so
181
- // devtools shows one declaration per line instead of a single long line.
182
- // One-shot `vite build`, `storybook build`, and the release fixture
183
- // verifications still minify, so nothing a platform ships changes.
221
+ // Keep development styles readable as well as mapped. One-shot
222
+ // `vite build`, `storybook build`, and release fixtures still minify, so
223
+ // nothing a platform ships changes.
184
224
  cssMinify: !watching,
185
225
 
186
226
  rollupOptions: {
@@ -238,10 +278,34 @@ export default defineConfig(async () => {
238
278
 
239
279
  // Let project extensions patch the final Vite config.
240
280
  /** @type {import('vite').UserConfig} */
281
+ const extensionPatch =
282
+ typeof extendConfig === 'function' ? extendConfig(base, { env }) || {} : {};
241
283
  const patched =
242
284
  typeof extendConfig === 'function'
243
- ? mergeConfig(base, extendConfig(base, { env }) || {})
285
+ ? mergeConfig(base, extensionPatch)
244
286
  : base;
245
287
 
288
+ // A project extension can enable watch mode without a CLI flag. Apply the
289
+ // same development defaults in that case while preserving any explicit
290
+ // sourcemap or minification choices in the extension itself.
291
+ if (!watching && patched.build?.watch) {
292
+ const extensionBuild = extensionPatch.build || {};
293
+ return {
294
+ ...patched,
295
+ build: {
296
+ ...patched.build,
297
+ ...(!Object.hasOwn(extensionBuild, 'sourcemap')
298
+ ? { sourcemap: true }
299
+ : {}),
300
+ ...(!Object.hasOwn(extensionBuild, 'minify') ? { minify: false } : {}),
301
+ ...(!Object.hasOwn(extensionBuild, 'cssMinify')
302
+ ? { cssMinify: false }
303
+ : {}),
304
+ },
305
+ };
306
+ }
307
+
246
308
  return patched;
247
- });
309
+ }
310
+
311
+ export default defineConfig(createViteConfig);