@emulsify/core 4.2.0 → 4.3.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-vite.js +91 -36
- package/README.md +185 -56
- package/config/a11y.config.js +2 -1
- package/config/vite/entries.js +4 -4
- package/config/vite/plugins/{copy-src-assets.js → assets/copy-src-assets.js} +1 -1
- package/config/vite/plugins/{copy-twig-files.js → assets/copy-twig-files.js} +1 -1
- package/config/vite/plugins/{css-asset-relativizer.js → assets/css-asset-relativizer.js} +9 -0
- package/config/vite/plugins/{mirror-components.js → assets/mirror-components.js} +2 -28
- package/config/vite/plugins/{source-file-index.js → assets/source-file-index.js} +1 -1
- package/config/vite/plugins/{svg-sprite.js → assets/svg-sprite.js} +2 -2
- package/config/vite/plugins/index.js +23 -14
- package/config/vite/plugins/reporter/asset-resolver.js +541 -0
- package/config/vite/plugins/reporter/build-errors.js +284 -0
- package/config/vite/plugins/reporter/diagnostics.js +366 -0
- package/config/vite/plugins/reporter/format.js +199 -0
- package/config/vite/plugins/reporter/index.js +308 -0
- package/config/vite/plugins/reporter/render.js +758 -0
- package/config/vite/plugins/reporter/sass-logger.js +237 -0
- package/config/vite/plugins/reporter/vite-logger.js +188 -0
- package/config/vite/plugins/reporter/watch-mode.js +40 -0
- package/config/vite/plugins/{twig-extension-installers.js → twig/extension-installers.js} +1 -1
- package/config/vite/{twig-extensions.js → plugins/twig/extensions.js} +2 -2
- package/config/vite/plugins/{twig-module.js → twig/twig-module.js} +387 -115
- package/config/vite/plugins/{virtual-twig-asset-sources.js → twig/virtual-twig-asset-sources.js} +37 -136
- package/config/vite/plugins/{virtual-twig-globs.js → twig/virtual-twig-globs.js} +3 -32
- package/config/vite/plugins/{vituum-patch.js → twig/vituum-patch.js} +3 -3
- package/config/vite/plugins.js +1 -1
- package/config/vite/project-config.js +1 -1
- package/config/vite/project-structure.js +1 -1
- package/config/vite/utils/lru.js +77 -0
- package/config/vite/utils/package-version.js +42 -0
- package/config/vite/utils/paths.js +1 -9
- package/config/vite/utils/react-singleton.js +1 -1
- package/config/vite/vite.config.js +51 -5
- package/package.json +93 -58
- package/scripts/a11y.js +115 -23
- package/scripts/audit/checks/core-imports.js +78 -0
- package/scripts/audit/checks/css-asset-references.js +99 -0
- package/scripts/audit/checks/drupal-assumptions.js +48 -0
- package/scripts/audit/checks/files-outside-roots.js +53 -0
- package/scripts/audit/checks/generated-package-scripts.js +113 -0
- package/scripts/audit/checks/legacy-twig-stories.js +33 -0
- package/scripts/audit/checks/package-overrides.js +91 -0
- package/scripts/audit/checks/project-config.js +71 -0
- package/scripts/audit/checks/story-discovery.js +35 -0
- package/scripts/audit/checks/twig-references.js +69 -0
- package/scripts/audit/checks/twig-volume.js +54 -0
- package/scripts/audit/checks/webpack-patterns.js +86 -0
- package/scripts/audit/index.js +177 -0
- package/scripts/audit/lib/css.js +165 -0
- package/scripts/audit/lib/files.js +168 -0
- package/scripts/audit/lib/findings.js +31 -0
- package/scripts/audit/lib/package-json.js +65 -0
- package/scripts/audit/lib/twig.js +227 -0
- package/scripts/audit/report.js +273 -0
- package/scripts/audit-twig-stories.js +115 -78
- package/scripts/audit.js +150 -1632
- package/scripts/check-node-version.js +136 -10
- package/scripts/inspect-components.js +456 -0
- package/scripts/lib/cli.js +179 -0
- package/scripts/lib/fs.js +31 -0
- package/scripts/lib/proc.js +78 -0
- package/scripts/lib/text.js +14 -0
- package/scripts/loadYaml.js +2 -2
- package/src/extensions/shared/attributes.js +3 -3
- package/src/extensions/shared/lists.js +2 -6
- package/src/extensions/shared/root-relative.js +38 -0
- package/src/storybook/index.js +4 -0
- package/src/storybook/render-twig.js +1 -1
- package/src/storybook/render-web-component.js +459 -0
- package/src/storybook/twig/asset-source-runtime.js +193 -0
- package/src/storybook/twig/{source-extensions.js → constants.js} +3 -1
- package/src/storybook/twig/reference-paths.js +2 -13
- package/src/storybook/twig/resolver.js +20 -3
- package/src/storybook/twig/setup.js +12 -2
- package/src/storybook/twig/source-function.js +5 -2
- package/config/vite/utils/unique.js +0 -36
- package/src/storybook/twig/include.js +0 -28
- package/src/storybook/twig/source-events.js +0 -5
|
@@ -9,8 +9,8 @@ import { readFileSync } from 'fs';
|
|
|
9
9
|
import { basename } from 'path';
|
|
10
10
|
import { globSync } from 'glob';
|
|
11
11
|
|
|
12
|
-
import { toPosixPath } from '
|
|
13
|
-
import { unique } from '
|
|
12
|
+
import { toPosixPath } from '../../utils/paths.js';
|
|
13
|
+
import { unique } from '../../../../src/extensions/shared/lists.js';
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* Builds a single SVG sprite file from a set of icon globs.
|
|
@@ -5,26 +5,27 @@
|
|
|
5
5
|
* each individual plugin concern to focused internal modules.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import sassGlobImports from 'vite-plugin-sass-glob-import';
|
|
8
|
+
import sassGlobImports from '@mlnop/vite-plugin-sass-glob-import';
|
|
9
9
|
|
|
10
10
|
import { getPlatformAdapter } from '../platforms.js';
|
|
11
11
|
import { resolveProjectStructure } from '../project-structure.js';
|
|
12
12
|
import { toPosixPath } from '../utils/paths.js';
|
|
13
|
-
import { copyAllSrcAssetsPlugin } from './copy-src-assets.js';
|
|
14
|
-
import { copyTwigFilesPlugin } from './copy-twig-files.js';
|
|
15
|
-
import { cssAssetUrlRelativizer } from './css-asset-relativizer.js';
|
|
16
|
-
import { mirrorComponentsToRoot } from './mirror-components.js';
|
|
13
|
+
import { copyAllSrcAssetsPlugin } from './assets/copy-src-assets.js';
|
|
14
|
+
import { copyTwigFilesPlugin } from './assets/copy-twig-files.js';
|
|
15
|
+
import { cssAssetUrlRelativizer } from './assets/css-asset-relativizer.js';
|
|
16
|
+
import { mirrorComponentsToRoot } from './assets/mirror-components.js';
|
|
17
|
+
import { createSourceFileIndex } from './assets/source-file-index.js';
|
|
18
|
+
import { svgSpriteFilePlugin } from './assets/svg-sprite.js';
|
|
19
|
+
import { developReporterPlugin } from './reporter/index.js';
|
|
17
20
|
import { requireContextCompatPlugin } from './require-context.js';
|
|
18
|
-
import {
|
|
19
|
-
import { svgSpriteFilePlugin } from './svg-sprite.js';
|
|
20
|
-
import { virtualTwigExtensionInstallersPlugin } from './twig-extension-installers.js';
|
|
21
|
+
import { virtualTwigExtensionInstallersPlugin } from './twig/extension-installers.js';
|
|
21
22
|
import {
|
|
22
23
|
emulsifyTwigModulePlugin,
|
|
23
24
|
makeTwigPluginOptions,
|
|
24
|
-
} from './twig-module.js';
|
|
25
|
-
import { virtualTwigAssetSourcesPlugin } from './virtual-twig-asset-sources.js';
|
|
26
|
-
import { virtualTwigGlobsPlugin } from './virtual-twig-globs.js';
|
|
27
|
-
import { makeTwigPlugins } from './vituum-patch.js';
|
|
25
|
+
} from './twig/twig-module.js';
|
|
26
|
+
import { virtualTwigAssetSourcesPlugin } from './twig/virtual-twig-asset-sources.js';
|
|
27
|
+
import { virtualTwigGlobsPlugin } from './twig/virtual-twig-globs.js';
|
|
28
|
+
import { makeTwigPlugins } from './twig/vituum-patch.js';
|
|
28
29
|
import { yamlModulePlugin } from './yaml-module.js';
|
|
29
30
|
|
|
30
31
|
/**
|
|
@@ -35,8 +36,10 @@ import { yamlModulePlugin } from './yaml-module.js';
|
|
|
35
36
|
* platform: string,
|
|
36
37
|
* srcDir: string,
|
|
37
38
|
* srcExists: boolean,
|
|
38
|
-
* structureOverrides?: boolean
|
|
39
|
-
*
|
|
39
|
+
* structureOverrides?: boolean,
|
|
40
|
+
* diagnostics?: object
|
|
41
|
+
* }} env - Project environment. When `diagnostics` is present the develop
|
|
42
|
+
* reporter is appended; it is supplied only for watch builds.
|
|
40
43
|
* @returns {import('vite').PluginOption[]} Emulsify Vite plugins.
|
|
41
44
|
*/
|
|
42
45
|
export function makePlugins(env) {
|
|
@@ -101,5 +104,11 @@ export function makePlugins(env) {
|
|
|
101
104
|
enabled: structure.mirrorComponentOutput,
|
|
102
105
|
projectDir,
|
|
103
106
|
}),
|
|
107
|
+
|
|
108
|
+
// Summarize the build for `npm run develop`. Present only when the Vite
|
|
109
|
+
// config supplied a diagnostics collector, which it does for watch builds.
|
|
110
|
+
...(env.diagnostics
|
|
111
|
+
? [developReporterPlugin({ env, diagnostics: env.diagnostics })]
|
|
112
|
+
: []),
|
|
104
113
|
];
|
|
105
114
|
}
|
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Enrichment for unresolved CSS asset URLs.
|
|
3
|
+
*
|
|
4
|
+
* Vite reports that a `url()` did not resolve, but not much else. In practice
|
|
5
|
+
* it reports the URL as its own importer, so the raw notice cannot even say
|
|
6
|
+
* which stylesheet to open. That leaves an author with a list of strings and no
|
|
7
|
+
* way to tell a typo from a genuinely missing file.
|
|
8
|
+
*
|
|
9
|
+
* This module answers the two questions the notice leaves open:
|
|
10
|
+
*
|
|
11
|
+
* - Where is the URL written? Found by parsing `url()` specifiers out of the
|
|
12
|
+
* project's stylesheets, which also yields a line number.
|
|
13
|
+
* - Does the file exist anywhere? Found by matching the basename against the
|
|
14
|
+
* source file index Vite already built for this run.
|
|
15
|
+
*
|
|
16
|
+
* Both lookups run against the in-memory index and only when a build actually
|
|
17
|
+
* produced unresolved URLs, so a clean build pays nothing.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { readFileSync } from 'node:fs';
|
|
21
|
+
import { basename, dirname, posix, relative, resolve } from 'node:path';
|
|
22
|
+
|
|
23
|
+
import { walkFiles } from '../assets/source-file-index.js';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Directories worth skipping on top of the shared defaults.
|
|
27
|
+
*
|
|
28
|
+
* @type {string[]}
|
|
29
|
+
*/
|
|
30
|
+
const EXTRA_SKIP_DIRS = ['vendor', '.ddev', '.lando', 'storybook-static'];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Matches a CSS `url()` call and captures its specifier.
|
|
34
|
+
*
|
|
35
|
+
* The specifier is compared exactly rather than by substring: `images/a.png`
|
|
36
|
+
* appears inside `../images/a.png`, so a substring test attributes a URL to
|
|
37
|
+
* stylesheets that never referenced it.
|
|
38
|
+
*
|
|
39
|
+
* @type {RegExp}
|
|
40
|
+
*/
|
|
41
|
+
const URL_CALL = /url\(\s*(['"]?)([^'")]+)\1\s*\)/g;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Stylesheets a human actually edits.
|
|
45
|
+
*
|
|
46
|
+
* @type {RegExp}
|
|
47
|
+
*/
|
|
48
|
+
const AUTHORED_STYLESHEET = /\.(scss|sass)$/i;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Any stylesheet, including compiled output.
|
|
52
|
+
*
|
|
53
|
+
* @type {RegExp}
|
|
54
|
+
*/
|
|
55
|
+
const ANY_STYLESHEET = /\.(scss|sass|css)$/i;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Convert an absolute path to a forward-slash path relative to the project.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} filePath - Absolute path.
|
|
61
|
+
* @param {string} projectDir - Project root.
|
|
62
|
+
* @returns {string} Project-relative path.
|
|
63
|
+
*/
|
|
64
|
+
const toProjectPath = (filePath, projectDir) =>
|
|
65
|
+
relative(projectDir, filePath).split('\\').join('/');
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Keep the trailing segments of a path so the column stays narrow.
|
|
69
|
+
*
|
|
70
|
+
* @param {string} filePath - Path to shorten.
|
|
71
|
+
* @param {number} [segments] - Segments to keep.
|
|
72
|
+
* @returns {string} Shortened path.
|
|
73
|
+
*/
|
|
74
|
+
const tailSegments = (filePath, segments = 2) =>
|
|
75
|
+
filePath.split('/').slice(-segments).join('/');
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Strip a query string or fragment from an asset URL.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} url - Asset URL.
|
|
81
|
+
* @returns {string} URL without suffixes.
|
|
82
|
+
*/
|
|
83
|
+
const cleanUrl = (url) => url.split('?')[0].split('#')[0];
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Count the 1-based line number at a character offset.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} source - File contents.
|
|
89
|
+
* @param {number} index - Character offset.
|
|
90
|
+
* @returns {number} 1-based line number.
|
|
91
|
+
*/
|
|
92
|
+
const lineAt = (source, index) => source.slice(0, index).split('\n').length;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Create a resolver for unresolved CSS asset URLs.
|
|
96
|
+
*
|
|
97
|
+
* @param {{projectDir?: string}} env - Project environment.
|
|
98
|
+
* @returns {{
|
|
99
|
+
* locate: (url: string) => {status: string, label: string},
|
|
100
|
+
* references: (url: string) => Array<{file: string, line: number}>
|
|
101
|
+
* }} Resolver.
|
|
102
|
+
*/
|
|
103
|
+
export function createAssetResolver({ projectDir = '' } = {}) {
|
|
104
|
+
// Deliberately not `sourceFileIndex`. That index only covers component and
|
|
105
|
+
// global *source* roots, so a theme keeping its images in a project-root
|
|
106
|
+
// `assets/` directory — the Emulsify default — has none of them indexed, and
|
|
107
|
+
// every URL resolves as "not found". Walking the project root instead covers
|
|
108
|
+
// assets, src, and components wherever a project happens to put them.
|
|
109
|
+
//
|
|
110
|
+
// The walk is lazy and cached, so a build with no unresolved URLs never
|
|
111
|
+
// performs it, and one with ten pays for it once.
|
|
112
|
+
/** @type {string[]|undefined} */
|
|
113
|
+
let cachedFiles;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* List every project file worth searching.
|
|
117
|
+
*
|
|
118
|
+
* @returns {string[]} Absolute file paths.
|
|
119
|
+
*/
|
|
120
|
+
const allFiles = () => {
|
|
121
|
+
if (cachedFiles) return cachedFiles;
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
cachedFiles = projectDir
|
|
125
|
+
? walkFiles(projectDir, {
|
|
126
|
+
shouldSkipDir: (directory) =>
|
|
127
|
+
EXTRA_SKIP_DIRS.includes(basename(directory)),
|
|
128
|
+
})
|
|
129
|
+
: [];
|
|
130
|
+
} catch {
|
|
131
|
+
// A build summary must never be the thing that breaks a build.
|
|
132
|
+
cachedFiles = [];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return cachedFiles;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// Stylesheets are read at most once per cycle, however many URLs are checked.
|
|
139
|
+
/** @type {Map<string, string|undefined>} */
|
|
140
|
+
const contents = new Map();
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Read a stylesheet, remembering failures so they are not retried.
|
|
144
|
+
*
|
|
145
|
+
* @param {string} absPath - Absolute file path.
|
|
146
|
+
* @returns {string|undefined} File contents.
|
|
147
|
+
*/
|
|
148
|
+
const read = (absPath) => {
|
|
149
|
+
if (contents.has(absPath)) return contents.get(absPath);
|
|
150
|
+
|
|
151
|
+
let source;
|
|
152
|
+
try {
|
|
153
|
+
source = readFileSync(absPath, 'utf8');
|
|
154
|
+
} catch {
|
|
155
|
+
source = undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
contents.set(absPath, source);
|
|
159
|
+
return source;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
/**
|
|
164
|
+
* Determine whether the referenced file exists in the project source.
|
|
165
|
+
*
|
|
166
|
+
* Matching is by basename, so a project containing two files of the same
|
|
167
|
+
* name is reported as ambiguous rather than resolved to an arbitrary one.
|
|
168
|
+
*
|
|
169
|
+
* @param {string} url - Unresolved asset URL.
|
|
170
|
+
* @returns {{status: 'found'|'missing'|'ambiguous'|'unknown', label: string}} Location.
|
|
171
|
+
*/
|
|
172
|
+
locate(url) {
|
|
173
|
+
const files = allFiles();
|
|
174
|
+
if (files.length === 0) return { status: 'unknown', label: '' };
|
|
175
|
+
|
|
176
|
+
const name = basename(cleanUrl(url));
|
|
177
|
+
const hits = files.filter((file) => basename(file) === name);
|
|
178
|
+
|
|
179
|
+
if (hits.length === 0) return { status: 'missing', label: 'not found' };
|
|
180
|
+
if (hits.length > 1) {
|
|
181
|
+
return { status: 'ambiguous', label: `${hits.length} candidates` };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// The full path, not just the directory: assets are commonly nested a
|
|
185
|
+
// few levels deep and the subfolder is the part that differs from what
|
|
186
|
+
// the stylesheet wrote.
|
|
187
|
+
return { status: 'found', label: toProjectPath(hits[0], projectDir) };
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Locate the first of several candidate filenames that exists.
|
|
192
|
+
*
|
|
193
|
+
* Sass tries a handful of names for one specifier, so the lookup has to
|
|
194
|
+
* try them in the same order rather than matching a single basename.
|
|
195
|
+
*
|
|
196
|
+
* @param {string[]} candidates - Candidate basenames, most likely first.
|
|
197
|
+
* @returns {{status: 'found'|'missing'|'unknown', label: string}} Location.
|
|
198
|
+
*/
|
|
199
|
+
locateAny(candidates = []) {
|
|
200
|
+
const files = allFiles();
|
|
201
|
+
if (files.length === 0) return { status: 'unknown', label: '' };
|
|
202
|
+
|
|
203
|
+
for (const candidate of candidates) {
|
|
204
|
+
const hit = files.find((file) => basename(file) === candidate);
|
|
205
|
+
if (hit) {
|
|
206
|
+
return { status: 'found', label: toProjectPath(hit, projectDir) };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return { status: 'missing', label: 'not found' };
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Find every authored stylesheet line containing a literal.
|
|
215
|
+
*
|
|
216
|
+
* @param {string} literal - Text to find.
|
|
217
|
+
* @returns {Array<{file: string, line: number, text: string}>} Matches.
|
|
218
|
+
*/
|
|
219
|
+
findLiteral(literal) {
|
|
220
|
+
if (!literal) return [];
|
|
221
|
+
|
|
222
|
+
const found = [];
|
|
223
|
+
|
|
224
|
+
for (const absPath of allFiles()) {
|
|
225
|
+
if (!AUTHORED_STYLESHEET.test(absPath)) continue;
|
|
226
|
+
|
|
227
|
+
const source = read(absPath);
|
|
228
|
+
if (!source || !source.includes(literal)) continue;
|
|
229
|
+
|
|
230
|
+
const lines = source.split('\n');
|
|
231
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
232
|
+
if (!lines[index].includes(literal)) continue;
|
|
233
|
+
|
|
234
|
+
found.push({
|
|
235
|
+
file: tailSegments(toProjectPath(absPath, projectDir)),
|
|
236
|
+
line: index + 1,
|
|
237
|
+
text: lines[index].trim(),
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return found;
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Find the stylesheets that write this URL, with line numbers.
|
|
247
|
+
*
|
|
248
|
+
* @param {string} url - Unresolved asset URL.
|
|
249
|
+
* @returns {Array<{file: string, line: number}>} References, in file order.
|
|
250
|
+
*/
|
|
251
|
+
references(url) {
|
|
252
|
+
const files = allFiles();
|
|
253
|
+
const name = basename(cleanUrl(url));
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Collect matches across a set of stylesheets using one predicate.
|
|
257
|
+
*
|
|
258
|
+
* @param {string[]} stylesheets - Files to search.
|
|
259
|
+
* @param {(source: string) => Array<number>} findOffsets - Offset finder.
|
|
260
|
+
* @returns {Array<{file: string, line: number}>} Matches.
|
|
261
|
+
*/
|
|
262
|
+
const scan = (stylesheets, findOffsets) => {
|
|
263
|
+
const found = [];
|
|
264
|
+
|
|
265
|
+
for (const absPath of stylesheets) {
|
|
266
|
+
const source = read(absPath);
|
|
267
|
+
if (!source) continue;
|
|
268
|
+
|
|
269
|
+
for (const offset of findOffsets(source)) {
|
|
270
|
+
found.push({
|
|
271
|
+
file: tailSegments(toProjectPath(absPath, projectDir)),
|
|
272
|
+
line: lineAt(source, offset),
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return found.sort(
|
|
278
|
+
(a, b) => a.file.localeCompare(b.file) || a.line - b.line,
|
|
279
|
+
);
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Offsets of every `url()` whose specifier satisfies a predicate.
|
|
284
|
+
*
|
|
285
|
+
* @param {(specifier: string) => boolean} matches - Specifier predicate.
|
|
286
|
+
* @returns {(source: string) => Array<number>} Offset finder.
|
|
287
|
+
*/
|
|
288
|
+
const urlCalls = (matches) => (source) =>
|
|
289
|
+
[...source.matchAll(URL_CALL)]
|
|
290
|
+
.filter((match) => matches(match[2].trim()))
|
|
291
|
+
.map((match) => match.index);
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Offsets of the bare filename anywhere in a source.
|
|
295
|
+
*
|
|
296
|
+
* @param {string} source - File contents.
|
|
297
|
+
* @returns {Array<number>} Match offsets.
|
|
298
|
+
*/
|
|
299
|
+
const bareFilename = (source) => {
|
|
300
|
+
const offsets = [];
|
|
301
|
+
let offset = source.indexOf(name);
|
|
302
|
+
|
|
303
|
+
while (offset !== -1) {
|
|
304
|
+
offsets.push(offset);
|
|
305
|
+
offset = source.indexOf(name, offset + name.length);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return offsets;
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
const finders = [
|
|
312
|
+
// Tier 1: the URL is written literally. Precise, and the common case.
|
|
313
|
+
urlCalls((specifier) => specifier === url),
|
|
314
|
+
|
|
315
|
+
// Tier 2: the path is interpolated, as in `url('#{$path}/x.png')`, so
|
|
316
|
+
// the resolved URL never appears literally but the filename does.
|
|
317
|
+
urlCalls(
|
|
318
|
+
(specifier) => specifier === name || specifier.endsWith(`/${name}`),
|
|
319
|
+
),
|
|
320
|
+
|
|
321
|
+
// Tier 3: the whole path lives in a variable, so `url()` holds only the
|
|
322
|
+
// variable name. The declaration is still the line to edit.
|
|
323
|
+
bareFilename,
|
|
324
|
+
];
|
|
325
|
+
|
|
326
|
+
// Authored stylesheets are searched exhaustively before compiled CSS is
|
|
327
|
+
// considered at all. Drupal themes that mirror component output back to
|
|
328
|
+
// the project root keep generated CSS beside their source, and a match
|
|
329
|
+
// there points at a one-line build artifact nobody can usefully edit.
|
|
330
|
+
const searchOrder = [
|
|
331
|
+
files.filter((file) => AUTHORED_STYLESHEET.test(file)),
|
|
332
|
+
files.filter((file) => ANY_STYLESHEET.test(file)),
|
|
333
|
+
];
|
|
334
|
+
|
|
335
|
+
for (const stylesheets of searchOrder) {
|
|
336
|
+
for (const findOffsets of finders) {
|
|
337
|
+
const found = scan(stylesheets, findOffsets);
|
|
338
|
+
if (found.length > 0) return found;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return [];
|
|
343
|
+
},
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Maximum stylesheets a single literal may appear in and still count as a lead.
|
|
349
|
+
*
|
|
350
|
+
* A token found in twenty files locates nothing.
|
|
351
|
+
*
|
|
352
|
+
* @type {number}
|
|
353
|
+
*/
|
|
354
|
+
const MAX_LITERAL_MATCHES = 6;
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Pull the searchable literals out of a generated CSS declaration.
|
|
358
|
+
*
|
|
359
|
+
* The minifier reports against the concatenated bundle, so there is no source
|
|
360
|
+
* location to report — but the declaration itself was written by somebody, and
|
|
361
|
+
* its unusual tokens survive compilation. A value like `60.9375rem` is far more
|
|
362
|
+
* likely to appear once in the source than a keyword like `min-width`.
|
|
363
|
+
*
|
|
364
|
+
* Ordered most distinctive first: precise decimals, then other dimensions, then
|
|
365
|
+
* identifiers.
|
|
366
|
+
*
|
|
367
|
+
* @param {string} declaration - Offending CSS declaration.
|
|
368
|
+
* @returns {string[]} Candidate literals.
|
|
369
|
+
*/
|
|
370
|
+
export function extractSourceTokens(declaration = '') {
|
|
371
|
+
const text = String(declaration);
|
|
372
|
+
const dimensions =
|
|
373
|
+
text.match(/\d*\.?\d+(?:rem|em|px|vh|vw|ch|%|s|ms)/g) || [];
|
|
374
|
+
const colors = text.match(/#[0-9a-f]{3,8}\b/gi) || [];
|
|
375
|
+
const identifiers = text.match(/[a-z][\w-]{4,}/gi) || [];
|
|
376
|
+
|
|
377
|
+
const ranked = [
|
|
378
|
+
// A decimal is nearly always authored verbatim somewhere.
|
|
379
|
+
...dimensions.filter((token) => token.includes('.')),
|
|
380
|
+
...colors,
|
|
381
|
+
...dimensions.filter((token) => !token.includes('.')),
|
|
382
|
+
// Custom names beat CSS keywords, which appear everywhere.
|
|
383
|
+
...identifiers.filter((token) => token.includes('-')),
|
|
384
|
+
];
|
|
385
|
+
|
|
386
|
+
return [...new Set(ranked)];
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Candidate filenames Sass would try for an import specifier.
|
|
391
|
+
*
|
|
392
|
+
* Sass resolves `grid/grid-item` to `_grid-item.scss` before `grid-item.scss`,
|
|
393
|
+
* and a bare directory to its `_index` partial.
|
|
394
|
+
*
|
|
395
|
+
* @param {string} specifier - Import specifier.
|
|
396
|
+
* @returns {string[]} Candidate basenames.
|
|
397
|
+
*/
|
|
398
|
+
export function sassImportCandidates(specifier) {
|
|
399
|
+
const name = basename(specifier);
|
|
400
|
+
|
|
401
|
+
return [
|
|
402
|
+
`_${name}.scss`,
|
|
403
|
+
`${name}.scss`,
|
|
404
|
+
`_${name}.sass`,
|
|
405
|
+
`${name}.sass`,
|
|
406
|
+
'_index.scss',
|
|
407
|
+
'_index.sass',
|
|
408
|
+
];
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Build rows for the missing-import table.
|
|
413
|
+
*
|
|
414
|
+
* Each row keeps the importing site and the specifier as written. The on-disk
|
|
415
|
+
* column distinguishes a partial that was deleted from one that merely moved,
|
|
416
|
+
* which is the difference between rewriting an import and restoring a file.
|
|
417
|
+
*
|
|
418
|
+
* @param {Array<{file?: string, line?: number, specifier: string}>} errors - Import errors.
|
|
419
|
+
* @param {ReturnType<createAssetResolver>} resolver - Asset resolver.
|
|
420
|
+
* @param {string} projectDir - Project root.
|
|
421
|
+
* @returns {Array<{where: string, specifier: string, status: string, label: string, expected: string|undefined}>} Rows.
|
|
422
|
+
*/
|
|
423
|
+
export function buildImportRows(errors, resolver, projectDir = '') {
|
|
424
|
+
const rows = errors.map((error) => {
|
|
425
|
+
const found = resolver.locateAny(sassImportCandidates(error.specifier));
|
|
426
|
+
|
|
427
|
+
// Where the specifier would have landed, used to derive the shared
|
|
428
|
+
// directory that every failing import points into.
|
|
429
|
+
const expected =
|
|
430
|
+
error.file && error.specifier
|
|
431
|
+
? resolve(dirname(error.file), error.specifier)
|
|
432
|
+
: undefined;
|
|
433
|
+
|
|
434
|
+
return {
|
|
435
|
+
where: `${tailSegments(toProjectPath(error.file || '', projectDir))}${
|
|
436
|
+
error.line == null ? '' : `:${error.line}`
|
|
437
|
+
}`,
|
|
438
|
+
specifier: error.specifier,
|
|
439
|
+
expected,
|
|
440
|
+
...(found.status === 'found'
|
|
441
|
+
? { status: 'moved', label: `moved? ${found.label}` }
|
|
442
|
+
: found),
|
|
443
|
+
};
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
// Sorted on the shortened path that is actually displayed, so the column
|
|
447
|
+
// reads in order rather than by the absolute paths behind it.
|
|
448
|
+
return rows.sort(
|
|
449
|
+
(a, b) =>
|
|
450
|
+
a.where.localeCompare(b.where) || a.specifier.localeCompare(b.specifier),
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Find the probable source of a generated CSS declaration.
|
|
456
|
+
*
|
|
457
|
+
* Tokens are tried most distinctive first and the first one that narrows to a
|
|
458
|
+
* workable number of stylesheets wins. Nothing here is a mapping — it is a
|
|
459
|
+
* search — so the caller labels the result as likely rather than certain.
|
|
460
|
+
*
|
|
461
|
+
* @param {string|undefined} declaration - Offending CSS declaration.
|
|
462
|
+
* @param {ReturnType<createAssetResolver>} resolver - Asset resolver.
|
|
463
|
+
* @returns {{token: string, matches: Array<{file: string, line: number, text: string}>}|undefined} Best lead.
|
|
464
|
+
*/
|
|
465
|
+
export function findLikelySource(declaration, resolver) {
|
|
466
|
+
for (const token of extractSourceTokens(declaration)) {
|
|
467
|
+
const matches = resolver.findLiteral(token);
|
|
468
|
+
|
|
469
|
+
// Zero means the token did not survive compilation intact; too many means
|
|
470
|
+
// it is a keyword rather than a lead.
|
|
471
|
+
if (matches.length === 0 || matches.length > MAX_LITERAL_MATCHES) continue;
|
|
472
|
+
|
|
473
|
+
return { token, matches };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return undefined;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Derive the deepest directory every failing import points into.
|
|
481
|
+
*
|
|
482
|
+
* When one deleted directory breaks a dozen imports, naming it once explains
|
|
483
|
+
* the whole failure better than any individual row can.
|
|
484
|
+
*
|
|
485
|
+
* @param {Array<{expected?: string}>} rows - Import rows.
|
|
486
|
+
* @param {string} projectDir - Project root.
|
|
487
|
+
* @returns {string|undefined} Shared project-relative directory.
|
|
488
|
+
*/
|
|
489
|
+
export function sharedMissingDirectory(rows, projectDir = '') {
|
|
490
|
+
const directories = rows
|
|
491
|
+
.map((row) => row.expected && dirname(row.expected))
|
|
492
|
+
.filter(Boolean)
|
|
493
|
+
.map((directory) => toProjectPath(directory, projectDir).split('/'));
|
|
494
|
+
|
|
495
|
+
if (directories.length === 0) return undefined;
|
|
496
|
+
|
|
497
|
+
const [first, ...rest] = directories;
|
|
498
|
+
const shared = [];
|
|
499
|
+
|
|
500
|
+
for (let index = 0; index < first.length; index += 1) {
|
|
501
|
+
const segment = first[index];
|
|
502
|
+
if (!rest.every((parts) => parts[index] === segment)) break;
|
|
503
|
+
shared.push(segment);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// A single shared segment is usually just `src`, which explains nothing.
|
|
507
|
+
return shared.length > 1 ? `${posix.join(...shared)}/` : undefined;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Expand unresolved URLs into one row per place they are written.
|
|
512
|
+
*
|
|
513
|
+
* With the stylesheet in the leading column the block reads as a worklist, so
|
|
514
|
+
* every row has to be somewhere to go. A URL written in three stylesheets
|
|
515
|
+
* becomes three rows rather than one row with a repeat count.
|
|
516
|
+
*
|
|
517
|
+
* @param {Array<{url: string}>} assets - Unresolved assets from the collector.
|
|
518
|
+
* @param {ReturnType<createAssetResolver>} resolver - Asset resolver.
|
|
519
|
+
* @returns {Array<{where: string, url: string, status: string, label: string}>} Table rows.
|
|
520
|
+
*/
|
|
521
|
+
export function buildAssetRows(assets, resolver) {
|
|
522
|
+
const rows = assets.flatMap((asset) => {
|
|
523
|
+
const location = resolver.locate(asset.url);
|
|
524
|
+
const references = resolver.references(asset.url);
|
|
525
|
+
|
|
526
|
+
if (references.length === 0) {
|
|
527
|
+
return [{ where: '—', url: asset.url, ...location }];
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return references.map((reference) => ({
|
|
531
|
+
where: `${reference.file}:${reference.line}`,
|
|
532
|
+
url: asset.url,
|
|
533
|
+
...location,
|
|
534
|
+
}));
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
// File order top to bottom matches the order the fixes get made.
|
|
538
|
+
return rows.sort(
|
|
539
|
+
(a, b) => a.where.localeCompare(b.where) || a.url.localeCompare(b.url),
|
|
540
|
+
);
|
|
541
|
+
}
|