@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,6 +1,8 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
 
4
+ import { resolveAssetRoots } from '../config/vite/utils/asset-roots.js';
5
+
4
6
  const projectRoot = process.cwd();
5
7
 
6
8
  /**
@@ -39,14 +41,9 @@ function existingStaticDirs(staticDirs) {
39
41
  * @returns {Array<string|{from: string, to: string}>} Static directory entries.
40
42
  */
41
43
  export function buildAssetStaticDirs(env) {
42
- const configuredAssetRoots = Array.isArray(env.projectStructure?.assetRoots)
43
- ? env.projectStructure.assetRoots
44
- : [];
45
- const assetRoots = [
46
- ...configuredAssetRoots,
47
- path.resolve(projectRoot, 'assets'),
48
- path.resolve(projectRoot, 'src/assets'),
49
- ];
44
+ // One shared list keeps what Storybook serves at /assets identical to what
45
+ // the Vite build and the audit resolve `/assets/...` against.
46
+ const assetRoots = resolveAssetRoots(env, { existingOnly: false });
50
47
 
51
48
  return existingStaticDirs([
52
49
  ...assetRoots.map((root) => ({
@@ -6,6 +6,7 @@ import {
6
6
  mergeReactSingletonResolve,
7
7
  } from '../config/vite/utils/react-singleton.js';
8
8
  import { createDevServerLogger } from '../config/vite/plugins/reporter/vite-logger.js';
9
+ import { STORYBOOK_VITE_ASSETS_DIR } from '../config/vite/plugins/assets/storybook-output.js';
9
10
  import { makeGeneratedDistFilesPlugin } from './main-static-assets.js';
10
11
 
11
12
  // Twig glob maps are provided by config/vite/plugins/twig/virtual-twig-globs.js.
@@ -29,9 +30,13 @@ const twigRuntimeOptimizeDepsExclude = [
29
30
  * keeping generated chunks in a separate folder avoids concurrent writers in
30
31
  * `.out/assets`.
31
32
  *
33
+ * Imported rather than repeated: Core plugins read this value back off the
34
+ * resolved config to tell a Storybook build from a theme build, so the two
35
+ * must never drift.
36
+ *
32
37
  * @type {string}
33
38
  */
34
- const storybookViteAssetsDir = 'storybook-assets';
39
+ const storybookViteAssetsDir = STORYBOOK_VITE_ASSETS_DIR;
35
40
 
36
41
  /**
37
42
  * Merge Storybook and project optimizeDeps excludes with Core Twig runtime IDs.
@@ -123,7 +128,7 @@ function makeTwigVirtualModuleOptimizerPlugin() {
123
128
  * @returns {Function} Storybook `viteFinal` callback.
124
129
  */
125
130
  export function createViteFinal(resolvedStorybookEnv) {
126
- return async function viteFinal(config) {
131
+ return async function viteFinal(config, { configType } = {}) {
127
132
  const { createLogger, mergeConfig } = await import('vite');
128
133
  const env = resolvedStorybookEnv;
129
134
  const storybookBuildConfig = config?.build || {};
@@ -132,9 +137,12 @@ export function createViteFinal(resolvedStorybookEnv) {
132
137
  // has historically consumed that branch, while `mode` still reflects
133
138
  // whether Storybook is running in development or production.
134
139
  const mode = config?.mode || 'development';
140
+ const isStorybookBuild = configType
141
+ ? configType === 'PRODUCTION'
142
+ : mode === 'production';
135
143
  const baseViteConfig =
136
144
  typeof viteConfig === 'function'
137
- ? await viteConfig({ command: 'serve', mode })
145
+ ? await viteConfig({ command: 'serve', mode, isStorybookBuild })
138
146
  : viteConfig;
139
147
  const existingDefine = (config && config.define) || {};
140
148
  const viteDefine = (baseViteConfig && baseViteConfig.define) || {};
package/README.md CHANGED
@@ -35,8 +35,9 @@ implementation details where they belong.
35
35
  - Consumers are supported on Node.js 24.13.0 or later. The strictest published
36
36
  toolchain dependency, `stylelint-selector-bem-pattern` 5, requires that patch.
37
37
  - Contributors should use Node.js 24.18.0, the exact version pinned in `.nvmrc`.
38
- - CI also uses Node.js 24.18.0 by reading `.nvmrc`, so local development and
39
- automated checks share the same recommended runtime.
38
+ - Release-readiness CI runs on both the public 24.13.0 floor and the recommended
39
+ 24.18.0 version. Other CI and release jobs read the exact version from
40
+ `.nvmrc`.
40
41
 
41
42
  ## Project Evolution
42
43
 
@@ -49,6 +50,11 @@ See [Version Evolution](docs/version-evolution.md) for major-version history
49
50
  and the [4.3.0 release notes](docs/releases/4.3.0.md) for the compatibility
50
51
  changes and additions in that release.
51
52
 
53
+ The [compatibility and support policy](docs/version-evolution.md#compatibility-and-support-policy)
54
+ separates current requirements and the existing 4.x compatibility rule from
55
+ maintenance promises. Pending decisions live in the
56
+ [maintainer register](docs/maintainer-decisions.md).
57
+
52
58
  ## Authoring Models
53
59
 
54
60
  Emulsify Core supports Twig and React authoring workflows plus a focused
@@ -167,6 +173,7 @@ The documentation is split by task:
167
173
  | [Migration To 4.x](docs/migration-4x.md) | Upgrading a pre-4.x/Webpack project while preserving existing structures. |
168
174
  | [4.3.0 Release Notes](docs/releases/4.3.0.md) | Reviewing the 4.3.0 scope, compatibility changes, public APIs, limitations, and verification evidence. |
169
175
  | [4.3.1 Release Notes](docs/releases/4.3.1.md) | Reviewing the 4.3.1 develop reporter changes, verbosity controls, and scope limits. |
176
+ | [4.4.0 Release Notes](docs/releases/4.4.0.md) | Reviewing asset resolution, safer audit fixes, incremental output, reporter changes, and release evidence. |
170
177
 
171
178
  ## Known Limitations
172
179
 
@@ -184,9 +191,6 @@ package using it.
184
191
  while raw Twig and text asset sources load lazily when `source()` requests
185
192
  them. Large Twig libraries should still keep Storybook source roots
186
193
  intentional. See [Performance](docs/performance.md).
187
- - Production sourcemaps are enabled by default unless a project overrides Vite
188
- config through `config/emulsify-core/vite/plugins.*`. See
189
- [Performance](docs/performance.md).
190
194
  - Project extensions use the public `config/emulsify-core` directory:
191
195
  `config/emulsify-core/vite/plugins.*` for Vite,
192
196
  `config/emulsify-core/storybook/...` for Storybook, and
@@ -270,7 +274,11 @@ Do not add comments to JSON files, lockfiles, binary assets, generated output,
270
274
  legal documents, or dependency files. Those formats either do not support
271
275
  comments or should remain exact artifacts.
272
276
 
273
- Please also follow the issue template and pull request templates provided. See below for the correct places to post issues:
277
+ Please also follow the issue template and pull request templates provided.
278
+ The issue links below are for ordinary bugs and feature requests, not
279
+ confidential vulnerability details. See the
280
+ [security-reporting status](docs/maintainer-decisions.md#security-reporting-for-older-lines)
281
+ before preparing a security report.
274
282
 
275
283
  1. [Emulsify Drupal](https://github.com/emulsify-ds/emulsify-drupal/issues)
276
284
  2. [Emulsify Tools (Drupal module)](https://www.drupal.org/project/issues/emulsify_tools)
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Optional additions to Pa11y's axe rules for WCAG 2.2 level A and AA.
3
+ * The reviewed Pa11y axe-core 4.11.4 and Core root axe-core 4.13.0 each expose
4
+ * target-size as their only wcag22a/wcag22aa rule.
5
+ * Keep this reviewed list explicit instead of enabling future rules implicitly.
6
+ */
7
+ export default {
8
+ pa11y: {
9
+ rules: ['target-size'],
10
+ },
11
+ };
@@ -64,12 +64,17 @@ function safeSetKey(map, key, value) {
64
64
  const entryPath = (entry) =>
65
65
  typeof entry === 'string' ? entry : entry.absPath;
66
66
 
67
- /** Determine whether a file should be compiled as a JS entry. */
67
+ /**
68
+ * Determine whether a file should be compiled as a JS entry.
69
+ *
70
+ * Authoring-only siblings never become build entries: stories, documentation
71
+ * modules, component metadata, pre-minified bundles, and tests.
72
+ */
68
73
  const isJavaScriptEntry = (entry) => {
69
74
  const filePath = entryPath(entry);
70
75
  return (
71
76
  /\.jsx?$/.test(filePath) &&
72
- !/\.(stories|component|min|test)\.jsx?$/.test(filePath)
77
+ !/\.(stories|docs?|component|min|test)\.jsx?$/.test(filePath)
73
78
  );
74
79
  };
75
80
 
@@ -10,6 +10,8 @@
10
10
  * - `structureOverrides`: true when safe `variant.structureImplementations` exist.
11
11
  * - `structureRoots`: array of directories from `variant.structureImplementations`.
12
12
  * - `assetRoots`: array of directories from safe `assets.roots` config.
13
+ * - `assetRebase`: whether unresolved CSS asset URLs are repaired.
14
+ * - `selfContainedOutput`: whether project assets remain under `dist/assets`.
13
15
  * - `platformAdapter`: active adapter for platform-specific behavior.
14
16
  */
15
17
 
@@ -28,6 +30,8 @@ import { resolveProjectConfig } from './project-config.js';
28
30
  * structureRoots: string[],
29
31
  * structureImplementations: Array<{name: string, directory: string}>,
30
32
  * assetRoots: string[],
33
+ * assetRebase: boolean,
34
+ * selfContainedOutput: boolean,
31
35
  * componentRoots: string[],
32
36
  * globalRoots: string[],
33
37
  * namespaceRoots: Record<string, string>,
@@ -0,0 +1,241 @@
1
+ /**
2
+ * @file CSS asset URL rebasing.
3
+ *
4
+ * Pure decision logic for repairing CSS `url()` references to project assets.
5
+ * The Vite wiring lives in `css-asset-rebase.js`; keeping the rules here means
6
+ * every branch is unit-testable without a build.
7
+ *
8
+ * ## The problem
9
+ *
10
+ * Emulsify accepts both root-absolute `url('/assets/...')` and the namespaced
11
+ * `url('@assets/...')` alias (docs/asset-references.md). Two other forms are
12
+ * common in the wild and both ship broken:
13
+ *
14
+ * 1. A relative URL authored against the *emitted* CSS location rather than
15
+ * the stylesheet — `url('../../assets/images/x.jpg')`. Vite cannot resolve
16
+ * it, leaves it verbatim, and it is then re-anchored to wherever the CSS
17
+ * lands. Drupal SDC output sits two levels below the theme root, so the
18
+ * depth happens to work there and nowhere else.
19
+ * 2. The bare `url('assets/images/x.jpg')` form. Vite treats it as a package
20
+ * specifier, fails to resolve it, and nothing ever emits the asset.
21
+ *
22
+ * A configured `assets.roots` entry hits the same wall: Storybook serves it at
23
+ * `/assets`, so authors write `/assets/logo.png`, but Vite only resolves that
24
+ * against the project root.
25
+ *
26
+ * ## The rule
27
+ *
28
+ * This runs after Vite's own CSS URL resolution, so every non-alias `url()` it
29
+ * sees is one Vite already declined to resolve. The `@assets/...` namespace is
30
+ * the intentional exception: Core reserves it before Vite resolution so a
31
+ * package, project alias, or same-named directory cannot displace the project
32
+ * asset contract. When the `assets/...` tail names exactly one real file under
33
+ * a real asset root, the URL is rewritten to the canonical form and the asset
34
+ * is queued for emit. Anything else is left exactly as it was.
35
+ */
36
+
37
+ import { dirname, posix, resolve } from 'path';
38
+
39
+ import { resolveAssetTail } from '../../utils/asset-roots.js';
40
+ import {
41
+ isNonFilesystemCssUrl,
42
+ replaceStylesheetUrlTokens,
43
+ } from '../../utils/css-urls.js';
44
+ import { safeExists } from '../../utils/fs-safe.js';
45
+ import { toPosixPath } from '../../utils/paths.js';
46
+
47
+ /**
48
+ * Published prefix every asset root is served under.
49
+ *
50
+ * @type {string}
51
+ */
52
+ export const PUBLIC_ASSET_PREFIX = 'assets';
53
+
54
+ /** Namespaced authoring alias for the same published asset root. */
55
+ export const ASSET_ALIAS_PREFIX = '@assets';
56
+
57
+ /**
58
+ * Raw `url()` matcher retained for compatibility with existing deep imports.
59
+ *
60
+ * Internal scanners must use `tokenizeStylesheetUrls` or
61
+ * `replaceStylesheetUrlTokens`, which add the comment and string context this
62
+ * expression cannot represent.
63
+ *
64
+ * @type {RegExp}
65
+ */
66
+ export const CSS_URL_RE =
67
+ /(?<=^|[^\w\-\u0080-\uffff])url\((\s*('[^']*'|"[^"]*")\s*|[^'")]+)\)/gi;
68
+
69
+ /** Leading `./` and `../` segments — "the tail" is what remains after these. */
70
+ const LEADING_RELATIVE_RE = /^(?:\.{1,2}\/)+/;
71
+
72
+ /**
73
+ * Split a URL into its filesystem path and any `?query` / `#hash` suffix.
74
+ *
75
+ * @param {string} value - Raw URL value without quotes.
76
+ * @returns {{path: string, suffix: string}} Split URL.
77
+ */
78
+ export function splitUrlSuffix(value) {
79
+ const index = value.search(/[?#]/);
80
+
81
+ return index === -1
82
+ ? { path: value, suffix: '' }
83
+ : { path: value.slice(0, index), suffix: value.slice(index) };
84
+ }
85
+
86
+ /**
87
+ * Reduce a URL to the asset path it is reaching for.
88
+ *
89
+ * `../../assets/images/x.jpg`, `assets/images/x.jpg`, and
90
+ * `@assets/images/x.jpg` all reduce to `images/x.jpg`. Sass may rebase a URL
91
+ * from an imported partial before this plugin sees it, producing a value such
92
+ * as `../shared/@assets/images/x.jpg`; the alias remains authoritative in that
93
+ * form too. Requiring either the published prefix or an exact alias path
94
+ * segment keeps the repair explainable: a bare `images/x.jpg` is never tried
95
+ * against the asset roots, because nothing about it says "project asset".
96
+ *
97
+ * @param {string} urlPath - URL path without quotes, query, or hash.
98
+ * @returns {string} Asset path relative to an asset root, or an empty string.
99
+ */
100
+ export function assetTailFor(urlPath) {
101
+ const normalized = posix.normalize(toPosixPath(urlPath));
102
+ const relative = normalized.replace(LEADING_RELATIVE_RE, '');
103
+
104
+ // Root-absolute URLs other than `/assets/...` belong to the platform. The
105
+ // embedded alias form exists only because Sass rebases imported partials to
106
+ // their entry stylesheet, and that result is always relative.
107
+ if (!normalized.startsWith('/')) {
108
+ const parts = relative.split('/');
109
+ const aliasIndex = parts.indexOf(ASSET_ALIAS_PREFIX);
110
+
111
+ if (aliasIndex !== -1 && aliasIndex < parts.length - 1) {
112
+ return parts.slice(aliasIndex + 1).join('/');
113
+ }
114
+ }
115
+
116
+ const tail = normalized
117
+ .replace(/^\/+/, '')
118
+ .replace(LEADING_RELATIVE_RE, '')
119
+ .replace(/^\/+/, '');
120
+
121
+ if (!tail.startsWith(`${PUBLIC_ASSET_PREFIX}/`)) return '';
122
+
123
+ const rest = tail.slice(PUBLIC_ASSET_PREFIX.length + 1);
124
+
125
+ return !rest || rest.startsWith('..') ? '' : rest;
126
+ }
127
+
128
+ /**
129
+ * Determine whether a URL path uses the reserved Sass/CSS asset alias.
130
+ *
131
+ * This includes the relative form Vite creates while rebasing a literal URL
132
+ * from an imported Sass partial, such as `../shared/@assets/images/x.svg`.
133
+ *
134
+ * @param {string} urlPath - URL path without quotes, query, or hash.
135
+ * @returns {boolean} TRUE when an exact `@assets` segment names a non-empty tail.
136
+ */
137
+ export function isAssetAliasPath(urlPath) {
138
+ const normalized = posix.normalize(toPosixPath(urlPath));
139
+ if (normalized.startsWith('/')) return false;
140
+
141
+ const parts = normalized.replace(LEADING_RELATIVE_RE, '').split('/');
142
+ const aliasIndex = parts.indexOf(ASSET_ALIAS_PREFIX);
143
+
144
+ return aliasIndex !== -1 && Boolean(parts.slice(aliasIndex + 1).join('/'));
145
+ }
146
+
147
+ /**
148
+ * Decide what a single unresolved CSS `url()` should become.
149
+ *
150
+ * @param {string} value - URL value as written, without quotes.
151
+ * @param {string} importer - Absolute path of the stylesheet being compiled.
152
+ * @param {string[]} roots - Absolute asset roots, in precedence order.
153
+ * @param {string} [quote=''] - URL value quote, or an empty string when unquoted.
154
+ * @returns {{status: 'skipped'|'missing'|'ambiguous'|'aliased'|'rebased'|'publish', url?: string, file?: string, emitAs?: string, candidates?: string[]}} Plan.
155
+ */
156
+ export function planAssetUrl(value, importer, roots = [], quote = '') {
157
+ const trimmed = String(value || '').trim();
158
+ if (isNonFilesystemCssUrl(trimmed, quote)) return { status: 'skipped' };
159
+
160
+ const { path: urlPath, suffix } = splitUrlSuffix(trimmed);
161
+ if (!urlPath) return { status: 'skipped' };
162
+
163
+ const isRootAbsolute = urlPath.startsWith('/');
164
+ const isRelative = LEADING_RELATIVE_RE.test(urlPath);
165
+ const isBareAssets = urlPath.startsWith(`${PUBLIC_ASSET_PREFIX}/`);
166
+ const isAssetAlias = isAssetAliasPath(urlPath);
167
+
168
+ // A bare specifier that is neither `assets/...` nor the reserved
169
+ // `@assets/...` alias belongs to Vite: it may resolve through package exports
170
+ // or another alias, and stealing it would be a regression.
171
+ if (!isRootAbsolute && !isRelative && !isBareAssets && !isAssetAlias) {
172
+ return { status: 'skipped' };
173
+ }
174
+
175
+ // Defence in depth. Under this hook Vite has already proven the URL does not
176
+ // resolve from the stylesheet, but the check keeps the function honest when
177
+ // called in isolation.
178
+ if (
179
+ isRelative &&
180
+ !isAssetAlias &&
181
+ importer &&
182
+ safeExists(resolve(dirname(importer), urlPath))
183
+ ) {
184
+ return { status: 'skipped' };
185
+ }
186
+
187
+ const rest = assetTailFor(urlPath);
188
+ if (!rest) return { status: 'skipped' };
189
+
190
+ const hit = resolveAssetTail(rest, roots);
191
+ if (hit.status !== 'resolved') {
192
+ return {
193
+ status: hit.status,
194
+ originalUrl: trimmed,
195
+ candidates: hit.candidates,
196
+ };
197
+ }
198
+
199
+ const canonical = `/${PUBLIC_ASSET_PREFIX}/${rest}`;
200
+ const emitAs = `${PUBLIC_ASSET_PREFIX}/${rest}`;
201
+
202
+ // A URL already in canonical form only needs its asset published; rewriting
203
+ // it would be a no-op edit that churns the emitted CSS.
204
+ if (urlPath === canonical) {
205
+ return { status: 'publish', originalUrl: trimmed, file: hit.file, emitAs };
206
+ }
207
+
208
+ return {
209
+ status: isAssetAlias ? 'aliased' : 'rebased',
210
+ originalUrl: trimmed,
211
+ url: `${canonical}${suffix}`,
212
+ file: hit.file,
213
+ emitAs,
214
+ };
215
+ }
216
+
217
+ /**
218
+ * Rewrite every repairable asset URL in one stylesheet.
219
+ *
220
+ * @param {string} code - Compiled CSS.
221
+ * @param {string} importer - Absolute path of the stylesheet.
222
+ * @param {string[]} roots - Absolute asset roots, in precedence order.
223
+ * @param {(plan: object, context: {value: string}) => void} [onPlan] - Plan observer.
224
+ * @returns {{code: string, changed: boolean}} Rewritten CSS.
225
+ */
226
+ export function rewriteStylesheetUrls(code, importer, roots = [], onPlan) {
227
+ let changed = false;
228
+
229
+ const next = replaceStylesheetUrlTokens(code, ({ match, quote, value }) => {
230
+ const plan = planAssetUrl(value, importer, roots, quote);
231
+ if (typeof onPlan === 'function') onPlan(plan, { value });
232
+
233
+ if (plan.status !== 'rebased' && plan.status !== 'aliased') return match;
234
+
235
+ changed = true;
236
+
237
+ return `url(${quote}${plan.url}${quote})`;
238
+ });
239
+
240
+ return { code: changed ? next : code, changed };
241
+ }
@@ -5,14 +5,19 @@
5
5
  * them, preserving component and global routing semantics.
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
  findSourceRoot,
15
15
  } from '../../project-structure.js';
16
+ import {
17
+ filesHaveSameBytes,
18
+ removeDestinationSymlink,
19
+ resolveFinalPath,
20
+ } from './output-freshness.js';
16
21
  import {
17
22
  createSourceFileIndex,
18
23
  isStaticSourceAsset,
@@ -21,14 +26,17 @@ import {
21
26
  /**
22
27
  * Copy non-code assets from source roots to `dist/`.
23
28
  *
24
- * @param {{ structure: object, sourceFileIndex?: object }} opts - Plugin options.
29
+ * @param {{ structure: object, sourceFileIndex?: object, diagnostics?: object, outputChanges?: Map<string, {kind: 'written'|'removed', bytes?: number}> }} opts - Plugin options.
25
30
  * @returns {import('vite').PluginOption} Copy plugin.
26
31
  */
27
32
  export function copyAllSrcAssetsPlugin({
28
33
  structure,
29
34
  sourceFileIndex = createSourceFileIndex(structure),
35
+ diagnostics,
36
+ outputChanges,
30
37
  }) {
31
38
  let outDir = 'dist';
39
+ let projectDir = process.cwd();
32
40
  let watching = false;
33
41
  /** @type {Array<{absPath: string, relDest: string}>|undefined} */
34
42
  let plan;
@@ -38,7 +46,10 @@ export function copyAllSrcAssetsPlugin({
38
46
  *
39
47
  * Shared by both hooks for the same reason as the Twig copier: watching and
40
48
  * copying have to be driven by one list, or a file can end up copied on a full
41
- * build and ignored on a save.
49
+ * build and ignored on a save. Structural events for an individually watched
50
+ * file refresh the plan, but new files and component-directory changes sit
51
+ * outside that watch set and require a watcher restart. Previous destinations
52
+ * are not pruned.
42
53
  *
43
54
  * @returns {Array<{absPath: string, relDest: string}>} Copy plan.
44
55
  */
@@ -78,9 +89,16 @@ export function copyAllSrcAssetsPlugin({
78
89
  /** Capture outDir. */
79
90
  configResolved(cfg) {
80
91
  outDir = cfg.build?.outDir || 'dist';
92
+ projectDir = cfg.root || process.cwd();
81
93
  watching = Boolean(cfg.build?.watch);
82
94
  },
83
95
 
96
+ watchChange(_id, { event } = {}) {
97
+ if (!watching || (event !== 'create' && event !== 'delete')) return;
98
+ sourceFileIndex.refresh?.();
99
+ plan = undefined;
100
+ },
101
+
84
102
  // Static assets are copied rather than compiled, so like Twig they are absent
85
103
  // from Rollup's module graph and a save would otherwise go unnoticed. Swapping
86
104
  // an SVG or a font left the old bytes in `dist/` until an unrelated rebuild.
@@ -92,29 +110,81 @@ export function copyAllSrcAssetsPlugin({
92
110
  /** Copy before the mirror plugin moves dist/components to the project root. */
93
111
  writeBundle() {
94
112
  for (const { absPath, relDest } of copyPlan()) {
95
- copyToOutDir(absPath, relDest);
113
+ const copyResult = copyToOutDir(absPath, relDest);
114
+ if (watching && copyResult.status === 'written') {
115
+ outputChanges?.set(relDest, {
116
+ kind: 'written',
117
+ bytes: copyResult.bytes,
118
+ });
119
+ }
120
+ if (copyResult.status === 'failed' && copyResult.error) {
121
+ const errno = copyResult.error.code ?? 'unknown error';
122
+ const message = `Unable to copy ${absPath} to ${join(outDir, relDest)} (${errno}): ${copyResult.error.message}`;
123
+ diagnostics?.recordError?.({
124
+ message,
125
+ file: absPath,
126
+ outputState: 'incomplete',
127
+ });
128
+ this.warn?.(message);
129
+ }
96
130
  }
97
131
  },
98
132
  };
99
133
 
134
+ /**
135
+ * Resolve the output directory to an absolute path.
136
+ *
137
+ * @returns {string} Absolute output directory.
138
+ */
139
+ function absoluteOutDir() {
140
+ return isAbsolute(outDir) ? outDir : resolve(projectDir, outDir);
141
+ }
142
+
100
143
  /**
101
144
  * Copy one file into the output directory.
102
145
  *
103
146
  * @param {string} absPath - Absolute source path.
104
147
  * @param {string} relDest - Destination relative to `outDir`.
105
- * @returns {void}
148
+ * @returns {{status: 'written'|'skipped'|'failed', bytes?: number, error?: Error}} Copy result.
106
149
  */
107
150
  function copyToOutDir(absPath, relDest) {
108
- if (!relDest) return;
151
+ if (!relDest) return { status: 'failed' };
109
152
 
110
- // Copied unconditionally; see the note in copy-twig-files.js — `emptyOutDir`
111
- // clears the destination on every cycle, so nothing is ever up to date.
112
153
  const destPath = join(outDir, relDest);
113
- mkdirSync(dirname(destPath), { recursive: true });
114
154
  try {
155
+ // Skip assets whose bytes already match during watch. One-shot builds
156
+ // continue to copy unconditionally as before.
157
+ if (
158
+ watching &&
159
+ filesHaveSameBytes(
160
+ absPath,
161
+ resolveFinalPath(relDest, {
162
+ outDir: absoluteOutDir(),
163
+ projectDir,
164
+ mirrored: structure?.mirrorComponentOutput,
165
+ }),
166
+ )
167
+ ) {
168
+ return { status: 'skipped' };
169
+ }
170
+
171
+ mkdirSync(dirname(destPath), { recursive: true });
172
+ removeDestinationSymlink(destPath);
115
173
  copyFileSync(absPath, destPath);
116
- } catch {
117
- /* noop */
174
+ let bytes;
175
+ if (watching && outputChanges) {
176
+ try {
177
+ bytes = statSync(destPath).size;
178
+ } catch {
179
+ // The write still succeeded; size is optional reporting metadata.
180
+ }
181
+ }
182
+ return { status: 'written', bytes };
183
+ } catch (error) {
184
+ return {
185
+ status: 'failed',
186
+ error,
187
+ };
118
188
  }
119
189
  }
120
190
  }