@emulsify/core 4.2.1 → 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.
Files changed (78) hide show
  1. package/.storybook/main-vite.js +91 -36
  2. package/README.md +185 -56
  3. package/config/a11y.config.js +2 -1
  4. package/config/vite/entries.js +4 -4
  5. package/config/vite/plugins/{copy-src-assets.js → assets/copy-src-assets.js} +1 -1
  6. package/config/vite/plugins/{copy-twig-files.js → assets/copy-twig-files.js} +1 -1
  7. package/config/vite/plugins/{css-asset-relativizer.js → assets/css-asset-relativizer.js} +9 -0
  8. package/config/vite/plugins/{mirror-components.js → assets/mirror-components.js} +2 -28
  9. package/config/vite/plugins/{source-file-index.js → assets/source-file-index.js} +1 -1
  10. package/config/vite/plugins/{svg-sprite.js → assets/svg-sprite.js} +2 -2
  11. package/config/vite/plugins/index.js +23 -14
  12. package/config/vite/plugins/reporter/asset-resolver.js +541 -0
  13. package/config/vite/plugins/reporter/build-errors.js +284 -0
  14. package/config/vite/plugins/reporter/diagnostics.js +366 -0
  15. package/config/vite/plugins/reporter/format.js +199 -0
  16. package/config/vite/plugins/reporter/index.js +308 -0
  17. package/config/vite/plugins/reporter/render.js +758 -0
  18. package/config/vite/plugins/reporter/sass-logger.js +237 -0
  19. package/config/vite/plugins/reporter/vite-logger.js +188 -0
  20. package/config/vite/plugins/reporter/watch-mode.js +40 -0
  21. package/config/vite/plugins/{twig-extension-installers.js → twig/extension-installers.js} +1 -1
  22. package/config/vite/{twig-extensions.js → plugins/twig/extensions.js} +2 -2
  23. package/config/vite/plugins/{twig-module.js → twig/twig-module.js} +197 -85
  24. package/config/vite/plugins/{virtual-twig-asset-sources.js → twig/virtual-twig-asset-sources.js} +37 -136
  25. package/config/vite/plugins/{virtual-twig-globs.js → twig/virtual-twig-globs.js} +3 -32
  26. package/config/vite/plugins/{vituum-patch.js → twig/vituum-patch.js} +3 -3
  27. package/config/vite/plugins.js +1 -1
  28. package/config/vite/project-config.js +1 -1
  29. package/config/vite/project-structure.js +1 -1
  30. package/config/vite/utils/lru.js +77 -0
  31. package/config/vite/utils/package-version.js +42 -0
  32. package/config/vite/utils/paths.js +1 -9
  33. package/config/vite/utils/react-singleton.js +1 -1
  34. package/config/vite/vite.config.js +51 -5
  35. package/package.json +93 -58
  36. package/scripts/a11y.js +115 -23
  37. package/scripts/audit/checks/core-imports.js +78 -0
  38. package/scripts/audit/checks/css-asset-references.js +99 -0
  39. package/scripts/audit/checks/drupal-assumptions.js +48 -0
  40. package/scripts/audit/checks/files-outside-roots.js +53 -0
  41. package/scripts/audit/checks/generated-package-scripts.js +113 -0
  42. package/scripts/audit/checks/legacy-twig-stories.js +33 -0
  43. package/scripts/audit/checks/package-overrides.js +91 -0
  44. package/scripts/audit/checks/project-config.js +71 -0
  45. package/scripts/audit/checks/story-discovery.js +35 -0
  46. package/scripts/audit/checks/twig-references.js +69 -0
  47. package/scripts/audit/checks/twig-volume.js +54 -0
  48. package/scripts/audit/checks/webpack-patterns.js +86 -0
  49. package/scripts/audit/index.js +177 -0
  50. package/scripts/audit/lib/css.js +165 -0
  51. package/scripts/audit/lib/files.js +168 -0
  52. package/scripts/audit/lib/findings.js +31 -0
  53. package/scripts/audit/lib/package-json.js +65 -0
  54. package/scripts/audit/lib/twig.js +227 -0
  55. package/scripts/audit/report.js +273 -0
  56. package/scripts/audit-twig-stories.js +115 -78
  57. package/scripts/audit.js +150 -1632
  58. package/scripts/check-node-version.js +136 -10
  59. package/scripts/inspect-components.js +456 -0
  60. package/scripts/lib/cli.js +179 -0
  61. package/scripts/lib/fs.js +31 -0
  62. package/scripts/lib/proc.js +78 -0
  63. package/scripts/lib/text.js +14 -0
  64. package/scripts/loadYaml.js +2 -2
  65. package/src/extensions/shared/attributes.js +3 -3
  66. package/src/extensions/shared/lists.js +2 -6
  67. package/src/extensions/shared/root-relative.js +38 -0
  68. package/src/storybook/index.js +4 -0
  69. package/src/storybook/render-twig.js +1 -1
  70. package/src/storybook/render-web-component.js +459 -0
  71. package/src/storybook/twig/asset-source-runtime.js +193 -0
  72. package/src/storybook/twig/{source-extensions.js → constants.js} +3 -1
  73. package/src/storybook/twig/reference-paths.js +2 -13
  74. package/src/storybook/twig/setup.js +12 -2
  75. package/src/storybook/twig/source-function.js +5 -2
  76. package/config/vite/utils/unique.js +0 -36
  77. package/src/storybook/twig/include.js +0 -28
  78. package/src/storybook/twig/source-events.js +0 -5
@@ -8,7 +8,7 @@
8
8
  import { readdirSync } from 'fs';
9
9
  import { join, relative, sep } from 'path';
10
10
 
11
- import { relativeFrom } from '../project-structure.js';
11
+ import { relativeFrom } from '../../project-structure.js';
12
12
 
13
13
  const DEFAULT_SKIP_DIRS = [
14
14
  'node_modules',
@@ -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 '../utils/paths.js';
13
- import { unique } from '../utils/unique.js';
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 { createSourceFileIndex } from './source-file-index.js';
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
- * }} env - Project environment.
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
+ }