@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.
- package/.storybook/main-static-assets.js +5 -8
- package/.storybook/main-vite.js +11 -3
- package/README.md +14 -6
- package/config/a11y-wcag22.js +11 -0
- package/config/vite/entries.js +7 -2
- package/config/vite/environment.js +4 -0
- package/config/vite/plugins/assets/asset-url-rebase.js +241 -0
- package/config/vite/plugins/assets/copy-src-assets.js +82 -12
- package/config/vite/plugins/assets/copy-twig-files.js +85 -16
- package/config/vite/plugins/assets/css-asset-rebase.js +306 -0
- package/config/vite/plugins/assets/css-asset-relativizer.js +301 -21
- package/config/vite/plugins/assets/development-source-maps.js +273 -0
- package/config/vite/plugins/assets/mirror-components.js +98 -82
- package/config/vite/plugins/assets/output-freshness.js +235 -0
- package/config/vite/plugins/assets/source-file-index.js +13 -13
- package/config/vite/plugins/assets/stable-watch-output.js +165 -0
- package/config/vite/plugins/assets/storybook-output.js +27 -0
- package/config/vite/plugins/index.js +95 -9
- package/config/vite/plugins/reporter/asset-resolver.js +34 -6
- package/config/vite/plugins/reporter/build-errors.js +7 -3
- package/config/vite/plugins/reporter/diagnostics.js +140 -10
- package/config/vite/plugins/reporter/index.js +380 -75
- package/config/vite/plugins/reporter/render.js +297 -44
- package/config/vite/plugins/reporter/sass-logger.js +30 -0
- package/config/vite/plugins/reporter/source-roots.js +101 -21
- package/config/vite/plugins/reporter/strict-mode.js +99 -0
- package/config/vite/plugins/reporter/vite-logger.js +220 -8
- package/config/vite/plugins/reporter/watch-mode.js +6 -2
- package/config/vite/plugins/twig/twig-module.js +35 -258
- package/config/vite/plugins/twig/virtual-twig-asset-sources.js +48 -49
- package/config/vite/project-config.js +121 -21
- package/config/vite/project-structure.js +6 -0
- package/config/vite/utils/asset-roots.js +205 -0
- package/config/vite/utils/css-urls.js +350 -0
- package/config/vite/utils/fs-safe.js +38 -1
- package/config/vite/utils/source-directory-skips.js +13 -0
- package/config/vite/utils/source-maps.js +88 -0
- package/config/vite/utils/twig-component-resolver.js +316 -0
- package/config/vite/vite.config.js +106 -42
- package/package.json +54 -40
- package/scripts/a11y.js +88 -9
- package/scripts/audit/checks/css-asset-references.js +256 -24
- package/scripts/audit/checks/twig-references.js +16 -5
- package/scripts/audit/fix.js +836 -0
- package/scripts/audit/index.js +10 -2
- package/scripts/audit/lib/css.js +41 -35
- package/scripts/audit/lib/story-ast.js +392 -0
- package/scripts/audit/lib/story-render-paths.js +600 -0
- package/scripts/audit/lib/story-selection.js +190 -0
- package/scripts/audit/lib/twig.js +372 -80
- package/scripts/audit/report.js +83 -5
- package/scripts/audit-twig-stories.js +73 -3
- package/scripts/audit.js +87 -2
- 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
|
-
|
|
43
|
-
|
|
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) => ({
|
package/.storybook/main-vite.js
CHANGED
|
@@ -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 =
|
|
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
|
|
39
|
-
|
|
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.
|
|
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
|
+
};
|
package/config/vite/entries.js
CHANGED
|
@@ -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
|
-
/**
|
|
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 {
|
|
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
|
-
|
|
117
|
-
|
|
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
|
}
|