@emulsify/core 3.4.1 → 4.0.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/.cli/init.js +40 -31
- package/.storybook/_drupal.js +129 -8
- package/.storybook/css-components.js +13 -0
- package/.storybook/css-dist.js +5 -0
- package/.storybook/emulsifyTheme.js +10 -7
- package/.storybook/main.js +417 -65
- package/.storybook/manager.js +11 -18
- package/.storybook/preview.js +93 -37
- package/.storybook/utils.js +70 -69
- package/README.md +110 -59
- package/config/.stylelintrc.json +2 -6
- package/config/a11y.config.js +9 -5
- package/config/babel.config.js +5 -0
- package/config/eslint.config.js +6 -3
- package/config/postcss.config.js +5 -0
- package/config/vite/entries.js +227 -0
- package/config/vite/environment.js +39 -0
- package/config/vite/platforms.js +70 -0
- package/config/vite/plugins/copy-src-assets.js +76 -0
- package/config/vite/plugins/copy-twig-files.js +84 -0
- package/config/vite/plugins/css-asset-relativizer.js +40 -0
- package/config/vite/plugins/index.js +105 -0
- package/config/vite/plugins/mirror-components.js +358 -0
- package/config/vite/plugins/require-context.js +311 -0
- package/config/vite/plugins/source-file-index.js +184 -0
- package/config/vite/plugins/svg-sprite.js +117 -0
- package/config/vite/plugins/twig-extension-installers.js +36 -0
- package/config/vite/plugins/twig-module.js +1251 -0
- package/config/vite/plugins/virtual-twig-asset-sources.js +404 -0
- package/config/vite/plugins/virtual-twig-globs.js +136 -0
- package/config/vite/plugins/vituum-patch.js +167 -0
- package/config/vite/plugins/yaml-module.js +133 -0
- package/config/vite/plugins.js +12 -0
- package/config/vite/project-config.js +192 -0
- package/config/vite/project-extensions.js +177 -0
- package/config/vite/project-structure.js +447 -0
- package/config/vite/twig-extensions.js +109 -0
- package/config/vite/utils/fs-safe.js +66 -0
- package/config/vite/utils/paths.js +40 -0
- package/config/vite/utils/react-singleton.js +85 -0
- package/config/vite/utils/unique.js +36 -0
- package/config/vite/vite.config.js +161 -0
- package/package.json +168 -88
- package/scripts/a11y.js +70 -16
- package/scripts/audit-twig-stories.js +378 -0
- package/scripts/audit.js +1602 -0
- package/scripts/check-node-version.js +18 -0
- package/scripts/loadYaml.js +5 -1
- package/src/extensions/index.js +8 -0
- package/src/extensions/react/index.js +12 -0
- package/src/extensions/react/register.js +45 -0
- package/src/extensions/shared/attributes.js +308 -0
- package/src/extensions/shared/html.js +41 -0
- package/src/extensions/shared/lists.js +38 -0
- package/src/extensions/shared/object.js +22 -0
- package/src/extensions/twig/function-map.js +20 -0
- package/src/extensions/twig/functions/add-attributes.js +39 -0
- package/src/extensions/twig/functions/bem.js +166 -0
- package/src/extensions/twig/index.js +13 -0
- package/src/extensions/twig/register.js +52 -0
- package/src/extensions/twig/tag-map.js +16 -0
- package/src/extensions/twig/tags/switch.js +266 -0
- package/src/storybook/index.js +14 -0
- package/src/storybook/main-config.js +132 -0
- package/src/storybook/platform-behaviors.js +60 -0
- package/src/storybook/preview-parameters.js +81 -0
- package/src/storybook/render-twig.js +295 -0
- package/src/storybook/twig/drupal-filters.js +7 -0
- package/src/storybook/twig/include-function.js +109 -0
- package/src/storybook/twig/include.js +28 -0
- package/src/storybook/twig/reference-paths.js +294 -0
- package/src/storybook/twig/resolver.js +318 -0
- package/src/storybook/twig/setup.js +39 -0
- package/src/storybook/twig/source-events.js +5 -0
- package/src/storybook/twig/source-extensions.js +24 -0
- package/src/storybook/twig/source-function.js +239 -0
- package/src/storybook/twig/source.js +39 -0
- package/.all-contributorsrc +0 -45
- package/.editorconfig +0 -5
- package/.github/ISSUE_TEMPLATE/BUG_REPORT_TEMPLATE.md +0 -18
- package/.github/ISSUE_TEMPLATE/FEATURE_REQUEST_TEMPLATE.md +0 -11
- package/.github/PULL_REQUEST_TEMPLATE.md +0 -19
- package/.github/dependabot.yml +0 -6
- package/.github/workflows/addtoprojects.yml +0 -21
- package/.github/workflows/contributors.yml +0 -37
- package/.github/workflows/lint.yml +0 -22
- package/.github/workflows/semantic-release.yml +0 -24
- package/.husky/commit-msg +0 -2
- package/.husky/pre-commit +0 -2
- package/.nvmrc +0 -1
- package/.prettierignore +0 -4
- package/.storybook/polyfills/twig-include.js +0 -36
- package/.storybook/polyfills/twig-resolver.js +0 -68
- package/.storybook/polyfills/twig-source.js +0 -54
- package/.storybook/webpack.config.js +0 -193
- package/CODE_OF_CONDUCT.md +0 -56
- package/commitlint.config.js +0 -5
- package/config/jest.config.js +0 -19
- package/config/webpack/app.js +0 -1
- package/config/webpack/loaders.js +0 -167
- package/config/webpack/optimizers.js +0 -17
- package/config/webpack/plugins.js +0 -283
- package/config/webpack/resolves.js +0 -157
- package/config/webpack/sdc-loader.js +0 -16
- package/config/webpack/webpack.common.js +0 -268
- package/config/webpack/webpack.dev.js +0 -41
- package/config/webpack/webpack.prod.js +0 -6
- package/release.config.cjs +0 -30
- package/scripts/a11y.test.js +0 -172
- package/scripts/loadYaml.test.js +0 -30
package/scripts/audit.js
ADDED
|
@@ -0,0 +1,1602 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Combined Emulsify project readiness audit.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { lstatSync, readdirSync, statSync } from 'node:fs';
|
|
8
|
+
import { basename, dirname, relative, resolve, sep } from 'node:path';
|
|
9
|
+
import { globSync } from 'glob';
|
|
10
|
+
import { resolveProjectConfig } from '../config/vite/project-config.js';
|
|
11
|
+
import {
|
|
12
|
+
compiledAssetOutputPath,
|
|
13
|
+
storybookStyleOutputPath,
|
|
14
|
+
} from '../config/vite/project-structure.js';
|
|
15
|
+
import {
|
|
16
|
+
firstExistingPath,
|
|
17
|
+
safeExists,
|
|
18
|
+
safeReadFile,
|
|
19
|
+
safeReadJson,
|
|
20
|
+
} from '../config/vite/utils/fs-safe.js';
|
|
21
|
+
import { toPosixPath } from '../config/vite/utils/paths.js';
|
|
22
|
+
import { candidateKeysForReference } from '../src/storybook/twig/reference-paths.js';
|
|
23
|
+
import { analyzeStorySource, collectStoryFiles } from './audit-twig-stories.js';
|
|
24
|
+
|
|
25
|
+
const STORY_GLOB = '**/*.stories.{js,jsx,ts,tsx}';
|
|
26
|
+
const CODE_GLOB = '**/*.{js,jsx,ts,tsx,mjs,cjs}';
|
|
27
|
+
const TWIG_GLOB = '**/*.twig';
|
|
28
|
+
const STYLE_GLOB = '**/*.{css,scss,sass}';
|
|
29
|
+
const DEFAULT_IGNORES = [
|
|
30
|
+
'**/.coverage/**',
|
|
31
|
+
'**/.git/**',
|
|
32
|
+
'**/.github/**',
|
|
33
|
+
'**/.out/**',
|
|
34
|
+
'**/dist/**',
|
|
35
|
+
'**/*.min.css',
|
|
36
|
+
'**/*.test.{js,jsx,ts,tsx,mjs,cjs}',
|
|
37
|
+
'**/node_modules/**',
|
|
38
|
+
'**/scripts/audit.js',
|
|
39
|
+
'**/vendor/**',
|
|
40
|
+
];
|
|
41
|
+
const PUBLIC_CORE_IMPORTS = new Set([
|
|
42
|
+
'@emulsify/core',
|
|
43
|
+
'@emulsify/core/extensions',
|
|
44
|
+
'@emulsify/core/extensions/react',
|
|
45
|
+
'@emulsify/core/extensions/twig',
|
|
46
|
+
'@emulsify/core/package.json',
|
|
47
|
+
'@emulsify/core/storybook',
|
|
48
|
+
'@emulsify/core/vite',
|
|
49
|
+
'@emulsify/core/vite/plugins',
|
|
50
|
+
]);
|
|
51
|
+
const DEFAULT_TWIG_THRESHOLD = 250;
|
|
52
|
+
const RECOMMENDED_PACKAGE_OVERRIDES = [
|
|
53
|
+
{
|
|
54
|
+
label: 'glob',
|
|
55
|
+
value: '^13.0.6',
|
|
56
|
+
paths: [['glob']],
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
label: 'locutus',
|
|
60
|
+
value: '^3.0.36',
|
|
61
|
+
paths: [['locutus']],
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
label: 'minimatch@3.0.x',
|
|
65
|
+
value: '^3.1.5',
|
|
66
|
+
paths: [['minimatch@3.0.x']],
|
|
67
|
+
},
|
|
68
|
+
];
|
|
69
|
+
const GENERATED_PACKAGE_SCRIPT_DOCS =
|
|
70
|
+
'https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/migration-4x.md#manual-packagejson-updates';
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Cache source file reads for one top-level audit run.
|
|
74
|
+
*
|
|
75
|
+
* @type {Map<string, string|null>}
|
|
76
|
+
*/
|
|
77
|
+
const fileReadCache = new Map();
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Clear the per-run source file read cache.
|
|
81
|
+
*
|
|
82
|
+
* @returns {void}
|
|
83
|
+
*/
|
|
84
|
+
function resetFileReadCache() {
|
|
85
|
+
fileReadCache.clear();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Read a text source file once per top-level audit run.
|
|
90
|
+
*
|
|
91
|
+
* Missing files are cached as null internally but still return an empty string
|
|
92
|
+
* to preserve safeReadFile() behavior for existing checks.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} filePath - Absolute or relative file path.
|
|
95
|
+
* @returns {string} File contents, or an empty string when unavailable.
|
|
96
|
+
*/
|
|
97
|
+
function cachedReadFile(filePath) {
|
|
98
|
+
const absPath = resolve(filePath);
|
|
99
|
+
if (fileReadCache.has(absPath)) {
|
|
100
|
+
return fileReadCache.get(absPath) ?? '';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const source = safeReadFile(absPath);
|
|
104
|
+
const cachedSource = source === '' && !safeExists(absPath) ? null : source;
|
|
105
|
+
fileReadCache.set(absPath, cachedSource);
|
|
106
|
+
|
|
107
|
+
return cachedSource ?? '';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Return a project-relative path for report output.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} projectDir - Absolute project root.
|
|
114
|
+
* @param {string} filePath - Absolute file path.
|
|
115
|
+
* @returns {string} Project-relative POSIX path.
|
|
116
|
+
*/
|
|
117
|
+
function displayPath(projectDir, filePath) {
|
|
118
|
+
return toPosixPath(relative(projectDir, filePath));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Determine whether a candidate is a directory.
|
|
123
|
+
*
|
|
124
|
+
* @param {string} filePath - Absolute path.
|
|
125
|
+
* @returns {boolean} TRUE when the path is a directory.
|
|
126
|
+
*/
|
|
127
|
+
function safeIsDirectory(filePath) {
|
|
128
|
+
try {
|
|
129
|
+
return lstatSync(filePath).isDirectory();
|
|
130
|
+
} catch {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Find the 1-based line number for a character index.
|
|
137
|
+
*
|
|
138
|
+
* @param {string} source - File source.
|
|
139
|
+
* @param {number} index - Character index.
|
|
140
|
+
* @returns {number} 1-based line number.
|
|
141
|
+
*/
|
|
142
|
+
function lineNumberAt(source, index) {
|
|
143
|
+
return source.slice(0, index).split('\n').length;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Build a report finding.
|
|
148
|
+
*
|
|
149
|
+
* @param {object} finding - Finding details.
|
|
150
|
+
* @returns {object} Normalized finding.
|
|
151
|
+
*/
|
|
152
|
+
function makeFinding(finding) {
|
|
153
|
+
return {
|
|
154
|
+
severity: 'warn',
|
|
155
|
+
docs: undefined,
|
|
156
|
+
...finding,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Collect files from a project.
|
|
162
|
+
*
|
|
163
|
+
* @param {string} projectDir - Absolute project root.
|
|
164
|
+
* @param {string|string[]} patterns - Glob pattern or patterns.
|
|
165
|
+
* @returns {string[]} Absolute file paths.
|
|
166
|
+
*/
|
|
167
|
+
export function collectProjectFiles(projectDir, patterns) {
|
|
168
|
+
return globSync(patterns, {
|
|
169
|
+
cwd: projectDir,
|
|
170
|
+
nodir: true,
|
|
171
|
+
absolute: true,
|
|
172
|
+
ignore: DEFAULT_IGNORES,
|
|
173
|
+
})
|
|
174
|
+
.map((filePath) => resolve(filePath))
|
|
175
|
+
.sort();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Return a normalized, project-contained root list.
|
|
180
|
+
*
|
|
181
|
+
* @param {string} projectDir - Absolute project root.
|
|
182
|
+
* @param {string[]} roots - Absolute candidate roots.
|
|
183
|
+
* @returns {string[]} Existing roots inside the project.
|
|
184
|
+
*/
|
|
185
|
+
function normalizeAuditRoots(projectDir, roots = []) {
|
|
186
|
+
const resolvedProject = resolve(projectDir);
|
|
187
|
+
|
|
188
|
+
return Array.from(
|
|
189
|
+
new Set(
|
|
190
|
+
roots
|
|
191
|
+
.filter(Boolean)
|
|
192
|
+
.map((root) => resolve(root))
|
|
193
|
+
.filter(
|
|
194
|
+
(root) =>
|
|
195
|
+
isSameOrInside(root, resolvedProject) && safeIsDirectory(root),
|
|
196
|
+
),
|
|
197
|
+
),
|
|
198
|
+
).sort();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Collect files from normalized audit roots only.
|
|
203
|
+
*
|
|
204
|
+
* @param {string} projectDir - Absolute project root.
|
|
205
|
+
* @param {string|string[]} patterns - Glob pattern or patterns.
|
|
206
|
+
* @param {string[]} roots - Absolute roots to scan.
|
|
207
|
+
* @returns {string[]} Absolute file paths.
|
|
208
|
+
*/
|
|
209
|
+
function collectRootedProjectFiles(projectDir, patterns, roots = []) {
|
|
210
|
+
const files = new Set();
|
|
211
|
+
|
|
212
|
+
for (const root of normalizeAuditRoots(projectDir, roots)) {
|
|
213
|
+
for (const filePath of globSync(patterns, {
|
|
214
|
+
cwd: root,
|
|
215
|
+
nodir: true,
|
|
216
|
+
absolute: true,
|
|
217
|
+
ignore: DEFAULT_IGNORES,
|
|
218
|
+
})) {
|
|
219
|
+
files.add(resolve(filePath));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return Array.from(files).sort();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Determine whether a file is inside one of the roots.
|
|
228
|
+
*
|
|
229
|
+
* @param {string} filePath - Absolute file path.
|
|
230
|
+
* @param {string[]} roots - Absolute roots.
|
|
231
|
+
* @returns {boolean} TRUE when inside a root.
|
|
232
|
+
*/
|
|
233
|
+
function isInsideAnyRoot(filePath, roots = []) {
|
|
234
|
+
return roots.some((root) => {
|
|
235
|
+
const rel = relative(root, filePath);
|
|
236
|
+
return Boolean(rel) && !rel.startsWith('..') && !rel.includes(`..${sep}`);
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Determine whether a path is the same as, or inside, a root directory.
|
|
242
|
+
*
|
|
243
|
+
* @param {string} filePath - Absolute file path.
|
|
244
|
+
* @param {string} root - Absolute root path.
|
|
245
|
+
* @returns {boolean} TRUE when the path is inside or equal to the root.
|
|
246
|
+
*/
|
|
247
|
+
function isSameOrInside(filePath, root) {
|
|
248
|
+
const rel = relative(root, filePath);
|
|
249
|
+
return !rel || (!rel.startsWith('..') && !rel.includes(`..${sep}`));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Return a nested object value.
|
|
254
|
+
*
|
|
255
|
+
* @param {object} obj - Object to inspect.
|
|
256
|
+
* @param {string[]} pathParts - Nested object path.
|
|
257
|
+
* @returns {*} Nested value.
|
|
258
|
+
*/
|
|
259
|
+
function valueAtPath(obj, pathParts) {
|
|
260
|
+
return pathParts.reduce(
|
|
261
|
+
(current, key) =>
|
|
262
|
+
current && typeof current === 'object' ? current[key] : undefined,
|
|
263
|
+
obj,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Determine whether a package manifest depends on Emulsify Core.
|
|
269
|
+
*
|
|
270
|
+
* @param {object} packageJson - Parsed package.json.
|
|
271
|
+
* @returns {boolean} TRUE when package.json is Core or consumes Core.
|
|
272
|
+
*/
|
|
273
|
+
function packageUsesEmulsifyCore(packageJson = {}) {
|
|
274
|
+
if (packageJson.name === '@emulsify/core') {
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return [
|
|
279
|
+
'dependencies',
|
|
280
|
+
'devDependencies',
|
|
281
|
+
'peerDependencies',
|
|
282
|
+
'optionalDependencies',
|
|
283
|
+
].some((section) =>
|
|
284
|
+
Object.prototype.hasOwnProperty.call(
|
|
285
|
+
packageJson[section] || {},
|
|
286
|
+
'@emulsify/core',
|
|
287
|
+
),
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Determine whether a package manifest is Emulsify Core itself.
|
|
293
|
+
*
|
|
294
|
+
* @param {object} packageJson - Parsed package.json.
|
|
295
|
+
* @returns {boolean} TRUE when package.json is Core.
|
|
296
|
+
*/
|
|
297
|
+
function packageIsEmulsifyCore(packageJson = {}) {
|
|
298
|
+
return packageJson.name === '@emulsify/core';
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Determine whether a recommended override is already present.
|
|
303
|
+
*
|
|
304
|
+
* @param {object} overrides - package.json overrides object.
|
|
305
|
+
* @param {{paths: string[][]}} recommendation - Override recommendation.
|
|
306
|
+
* @returns {boolean} TRUE when any equivalent override path exists.
|
|
307
|
+
*/
|
|
308
|
+
function hasRecommendedOverride(overrides = {}, recommendation) {
|
|
309
|
+
return recommendation.paths.some(
|
|
310
|
+
(pathParts) => valueAtPath(overrides, pathParts) !== undefined,
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Normalize the project config, retaining any resolution failure.
|
|
316
|
+
*
|
|
317
|
+
* @param {string} projectDir - Absolute project root.
|
|
318
|
+
* @returns {{env: object, configExists: boolean, error?: Error}}
|
|
319
|
+
*/
|
|
320
|
+
function resolveAuditEnvironment(projectDir) {
|
|
321
|
+
const configExists = safeExists(resolve(projectDir, 'project.emulsify.json'));
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
return {
|
|
325
|
+
env: resolveProjectConfig(projectDir, process.env),
|
|
326
|
+
configExists,
|
|
327
|
+
};
|
|
328
|
+
} catch (error) {
|
|
329
|
+
return {
|
|
330
|
+
env: {
|
|
331
|
+
projectDir,
|
|
332
|
+
platform: 'generic',
|
|
333
|
+
namespaceRoots: {},
|
|
334
|
+
projectStructure: {},
|
|
335
|
+
},
|
|
336
|
+
configExists,
|
|
337
|
+
error,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Audit basic project configuration and structure root health.
|
|
344
|
+
*
|
|
345
|
+
* @param {object} context - Audit context.
|
|
346
|
+
* @returns {object[]} Findings.
|
|
347
|
+
*/
|
|
348
|
+
function auditProjectConfig(context) {
|
|
349
|
+
const { configExists, env, error, projectDir } = context;
|
|
350
|
+
const findings = [];
|
|
351
|
+
|
|
352
|
+
if (!configExists) {
|
|
353
|
+
findings.push(
|
|
354
|
+
makeFinding({
|
|
355
|
+
id: 'missing-project-config',
|
|
356
|
+
severity: 'error',
|
|
357
|
+
message:
|
|
358
|
+
'project.emulsify.json is missing, so platform and structure defaults may not match the project.',
|
|
359
|
+
docs: 'https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/project-structure.md',
|
|
360
|
+
}),
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (error) {
|
|
365
|
+
findings.push(
|
|
366
|
+
makeFinding({
|
|
367
|
+
id: 'project-config-resolution-failed',
|
|
368
|
+
severity: 'error',
|
|
369
|
+
message: `Unable to resolve project.emulsify.json: ${error.message || error}`,
|
|
370
|
+
}),
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
for (const implementation of env.structureImplementations || []) {
|
|
375
|
+
if (!safeIsDirectory(implementation.directory)) {
|
|
376
|
+
findings.push(
|
|
377
|
+
makeFinding({
|
|
378
|
+
id: 'missing-structure-implementation',
|
|
379
|
+
severity: 'error',
|
|
380
|
+
filePath: resolve(projectDir, 'project.emulsify.json'),
|
|
381
|
+
message: `Configured structureImplementation "${implementation.name}" does not exist: ${displayPath(
|
|
382
|
+
projectDir,
|
|
383
|
+
implementation.directory,
|
|
384
|
+
)}`,
|
|
385
|
+
docs: 'https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/project-structure.md',
|
|
386
|
+
}),
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return findings;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Audit package-level dependency override policy for installed projects.
|
|
396
|
+
*
|
|
397
|
+
* npm only applies `overrides` from the root package being installed. When
|
|
398
|
+
* Emulsify Core is installed into a generated theme, Core's own overrides do
|
|
399
|
+
* not protect that theme's transitive dependency graph.
|
|
400
|
+
*
|
|
401
|
+
* @param {object} context - Audit context.
|
|
402
|
+
* @returns {object[]} Findings.
|
|
403
|
+
*/
|
|
404
|
+
function auditPackageOverrides(context) {
|
|
405
|
+
const { projectDir } = context;
|
|
406
|
+
const packagePath = resolve(projectDir, 'package.json');
|
|
407
|
+
|
|
408
|
+
if (!safeExists(packagePath)) {
|
|
409
|
+
return [];
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const { data: packageJson, error } = safeReadJson(packagePath);
|
|
413
|
+
if (error) {
|
|
414
|
+
return [
|
|
415
|
+
makeFinding({
|
|
416
|
+
id: 'package-json-unreadable',
|
|
417
|
+
severity: 'warn',
|
|
418
|
+
filePath: packagePath,
|
|
419
|
+
message: `Unable to parse package.json: ${error.message || error}`,
|
|
420
|
+
}),
|
|
421
|
+
];
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (!packageUsesEmulsifyCore(packageJson)) {
|
|
425
|
+
return [];
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const overrides = packageJson.overrides || {};
|
|
429
|
+
const missing = RECOMMENDED_PACKAGE_OVERRIDES.filter(
|
|
430
|
+
(recommendation) => !hasRecommendedOverride(overrides, recommendation),
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
if (!missing.length) {
|
|
434
|
+
return [];
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return [
|
|
438
|
+
makeFinding({
|
|
439
|
+
id: 'recommended-package-overrides-missing',
|
|
440
|
+
severity: 'warn',
|
|
441
|
+
filePath: packagePath,
|
|
442
|
+
message:
|
|
443
|
+
'package.json is missing recommended root npm overrides for Emulsify Core transitive install warnings.',
|
|
444
|
+
details: missing.map(
|
|
445
|
+
(recommendation) =>
|
|
446
|
+
`Add overrides.${recommendation.label}: ${recommendation.value}.`,
|
|
447
|
+
),
|
|
448
|
+
docs: 'https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/migration-4x.md#install-warning-controls',
|
|
449
|
+
}),
|
|
450
|
+
];
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Audit generated-theme package scripts that must be updated manually.
|
|
455
|
+
*
|
|
456
|
+
* Generated themes copy their root package.json from the starter at creation
|
|
457
|
+
* time. Whisk updates do not automatically flow into existing themes, so the
|
|
458
|
+
* audit flags stale Webpack-era scripts and missing Core 4 audit/Vite scripts.
|
|
459
|
+
*
|
|
460
|
+
* @param {object} context - Audit context.
|
|
461
|
+
* @returns {object[]} Findings.
|
|
462
|
+
*/
|
|
463
|
+
function auditGeneratedPackageScripts(context) {
|
|
464
|
+
const { env, projectDir } = context;
|
|
465
|
+
const packagePath = resolve(projectDir, 'package.json');
|
|
466
|
+
|
|
467
|
+
if (!safeExists(packagePath)) {
|
|
468
|
+
return [];
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const { data: packageJson, error } = safeReadJson(packagePath);
|
|
472
|
+
if (error || !packageUsesEmulsifyCore(packageJson)) {
|
|
473
|
+
return [];
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (packageIsEmulsifyCore(packageJson)) {
|
|
477
|
+
return [];
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const scripts = packageJson.scripts || {};
|
|
481
|
+
const starterRepository = env.projectConfig?.starter?.repository;
|
|
482
|
+
const fromGeneratedStarter =
|
|
483
|
+
typeof starterRepository === 'string' &&
|
|
484
|
+
/emulsify-(drupal|wordpress|craftcms|starter)|emulsify-ds/i.test(
|
|
485
|
+
starterRepository,
|
|
486
|
+
);
|
|
487
|
+
const usesGeneratedCoreScripts = Object.values(scripts).some(
|
|
488
|
+
(script) =>
|
|
489
|
+
typeof script === 'string' &&
|
|
490
|
+
/node_modules\/@emulsify\/core\/(?:config\/(?:webpack|vite)|scripts\/audit)/.test(
|
|
491
|
+
script,
|
|
492
|
+
),
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
if (!fromGeneratedStarter && !usesGeneratedCoreScripts) {
|
|
496
|
+
return [];
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const findings = [];
|
|
500
|
+
const details = [];
|
|
501
|
+
const buildScript = scripts.build || '';
|
|
502
|
+
|
|
503
|
+
if (/\bwebpack\b|config\/webpack/.test(buildScript)) {
|
|
504
|
+
details.push('Replace scripts.build with the Vite build command.');
|
|
505
|
+
} else if (
|
|
506
|
+
/node_modules\/@emulsify\/core\/config\/vite\/vite\.config\.js/.test(
|
|
507
|
+
buildScript,
|
|
508
|
+
) &&
|
|
509
|
+
/\bvite\s+(?:--config|-c)\b/.test(buildScript)
|
|
510
|
+
) {
|
|
511
|
+
details.push('Replace scripts.build with the Vite build command.');
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (Object.prototype.hasOwnProperty.call(scripts, 'build-dev')) {
|
|
515
|
+
details.push('Remove scripts.build-dev; the Vite build replaces it.');
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (/\bwebpack\b|npm:webpack|config\/webpack/.test(scripts.develop || '')) {
|
|
519
|
+
details.push('Replace scripts.develop with the Vite/Storybook watcher.');
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (Object.prototype.hasOwnProperty.call(scripts, 'webpack')) {
|
|
523
|
+
details.push('Replace scripts.webpack with scripts.vite.');
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
for (const scriptName of ['audit', 'audit:twig-stories', 'vite']) {
|
|
527
|
+
if (!Object.prototype.hasOwnProperty.call(scripts, scriptName)) {
|
|
528
|
+
details.push(`Add scripts.${scriptName}.`);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (details.length) {
|
|
533
|
+
findings.push(
|
|
534
|
+
makeFinding({
|
|
535
|
+
id: 'generated-package-json-migration-needed',
|
|
536
|
+
severity: 'warn',
|
|
537
|
+
filePath: packagePath,
|
|
538
|
+
message:
|
|
539
|
+
'package.json does not match the generated-theme scripts expected by Emulsify Core 4.',
|
|
540
|
+
details,
|
|
541
|
+
docs: GENERATED_PACKAGE_SCRIPT_DOCS,
|
|
542
|
+
}),
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
return findings;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Audit story files that will not be discovered by Storybook.
|
|
551
|
+
*
|
|
552
|
+
* @param {object} context - Audit context.
|
|
553
|
+
* @returns {object[]} Findings.
|
|
554
|
+
*/
|
|
555
|
+
function auditStoryDiscovery(context) {
|
|
556
|
+
const { projectDir, storyFiles } = context;
|
|
557
|
+
const discovered = new Set(collectStoryFiles(projectDir));
|
|
558
|
+
const findings = [];
|
|
559
|
+
|
|
560
|
+
for (const storyFile of storyFiles) {
|
|
561
|
+
if (discovered.has(storyFile)) continue;
|
|
562
|
+
|
|
563
|
+
findings.push(
|
|
564
|
+
makeFinding({
|
|
565
|
+
id: 'story-outside-discovered-roots',
|
|
566
|
+
severity: 'error',
|
|
567
|
+
filePath: storyFile,
|
|
568
|
+
message:
|
|
569
|
+
'Story file is outside the normalized Storybook roots and will not be discovered.',
|
|
570
|
+
docs: 'https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/project-structure.md',
|
|
571
|
+
}),
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
return findings;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Add legacy Twig story migration findings.
|
|
580
|
+
*
|
|
581
|
+
* @param {object} context - Audit context.
|
|
582
|
+
* @returns {object[]} Findings.
|
|
583
|
+
*/
|
|
584
|
+
function auditLegacyTwigStories(context) {
|
|
585
|
+
const { storyFiles } = context;
|
|
586
|
+
const findings = storyFiles
|
|
587
|
+
.map((filePath) => analyzeStorySource(cachedReadFile(filePath), filePath))
|
|
588
|
+
.filter((result) => result.shouldUpgrade);
|
|
589
|
+
|
|
590
|
+
return findings.map((finding) =>
|
|
591
|
+
makeFinding({
|
|
592
|
+
id: 'legacy-twig-story',
|
|
593
|
+
severity: 'warn',
|
|
594
|
+
filePath: finding.filePath,
|
|
595
|
+
line: finding.directTemplateReturns[0]?.line,
|
|
596
|
+
message:
|
|
597
|
+
'Twig story appears to return an HTML string directly. This remains compatible, but renderTwig() is preferred for active migrations.',
|
|
598
|
+
details: finding.reasons,
|
|
599
|
+
docs: 'https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/storybook.md#legacy-twig-story-compatibility',
|
|
600
|
+
}),
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Extract string arguments passed to include() or source().
|
|
606
|
+
*
|
|
607
|
+
* @param {string} source - Twig source.
|
|
608
|
+
* @returns {{type: string, value: string, line: number}[]} References.
|
|
609
|
+
*/
|
|
610
|
+
export function findTwigIncludeSourceReferences(source) {
|
|
611
|
+
const references = [];
|
|
612
|
+
const callPattern = /\b(include|source)\s*\(([\s\S]*?)\)/g;
|
|
613
|
+
|
|
614
|
+
for (const callMatch of source.matchAll(callPattern)) {
|
|
615
|
+
const type = callMatch[1];
|
|
616
|
+
const args = firstArgumentText(callMatch[2]);
|
|
617
|
+
const argsOffset = (callMatch.index || 0) + callMatch[0].indexOf(args);
|
|
618
|
+
const stringPattern = /['"]([^'"]+)['"]/g;
|
|
619
|
+
|
|
620
|
+
for (const stringMatch of args.matchAll(stringPattern)) {
|
|
621
|
+
references.push({
|
|
622
|
+
type,
|
|
623
|
+
value: stringMatch[1],
|
|
624
|
+
line: lineNumberAt(source, argsOffset + (stringMatch.index || 0)),
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
return references;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Extract the first function argument, including array syntax.
|
|
634
|
+
*
|
|
635
|
+
* Twig include()/source() only use the first argument as the template/source
|
|
636
|
+
* reference. Later object values may also be strings, but they are context
|
|
637
|
+
* values and should not be treated as template references.
|
|
638
|
+
*
|
|
639
|
+
* @param {string} args - Function argument source.
|
|
640
|
+
* @returns {string} First argument source.
|
|
641
|
+
*/
|
|
642
|
+
function firstArgumentText(args) {
|
|
643
|
+
let quote = '';
|
|
644
|
+
let depth = 0;
|
|
645
|
+
|
|
646
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
647
|
+
const char = args[index];
|
|
648
|
+
const prev = args[index - 1];
|
|
649
|
+
|
|
650
|
+
if (quote) {
|
|
651
|
+
if (char === quote && prev !== '\\') {
|
|
652
|
+
quote = '';
|
|
653
|
+
}
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (char === '"' || char.charCodeAt(0) === 39) {
|
|
658
|
+
quote = char;
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
if (char === '[' || char === '{' || char === '(') {
|
|
662
|
+
depth += 1;
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
if (char === ']' || char === '}' || char === ')') {
|
|
666
|
+
depth = Math.max(0, depth - 1);
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
if (char === ',' && depth === 0) {
|
|
670
|
+
return args.slice(0, index);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
return args;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Extract Twig namespace references such as @components/card/card.twig.
|
|
679
|
+
*
|
|
680
|
+
* @param {string} source - Twig source.
|
|
681
|
+
* @returns {{namespace: string, value: string, line: number}[]} Namespace refs.
|
|
682
|
+
*/
|
|
683
|
+
export function findTwigNamespaceReferences(source) {
|
|
684
|
+
const references = [];
|
|
685
|
+
const pattern = /@([A-Za-z][\w-]*)\/[A-Za-z0-9_./-]+/g;
|
|
686
|
+
|
|
687
|
+
for (const match of source.matchAll(pattern)) {
|
|
688
|
+
references.push({
|
|
689
|
+
namespace: match[1],
|
|
690
|
+
value: match[0],
|
|
691
|
+
line: lineNumberAt(source, match.index || 0),
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
return references;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Build candidate paths for a relative Twig reference.
|
|
700
|
+
*
|
|
701
|
+
* @param {string} filePath - Referencing file.
|
|
702
|
+
* @param {string} reference - Twig reference.
|
|
703
|
+
* @returns {string[]} Absolute candidate paths.
|
|
704
|
+
*/
|
|
705
|
+
function relativeTwigCandidates(filePath, reference) {
|
|
706
|
+
const base = resolve(dirname(filePath), reference);
|
|
707
|
+
if (/\.[A-Za-z0-9]+$/.test(reference)) {
|
|
708
|
+
return [base];
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
return [`${base}.twig`, `${base}.html.twig`];
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Convert resolver candidate keys into absolute filesystem paths.
|
|
716
|
+
*
|
|
717
|
+
* @param {string[]} keys - Root-relative Vite keys.
|
|
718
|
+
* @param {object} env - Normalized environment.
|
|
719
|
+
* @returns {string[]} Absolute candidate paths.
|
|
720
|
+
*/
|
|
721
|
+
function candidateKeysToFiles(keys, env) {
|
|
722
|
+
const projectDir = env.projectDir || process.cwd();
|
|
723
|
+
|
|
724
|
+
return keys.map((key) =>
|
|
725
|
+
key.startsWith('/') ? resolve(projectDir, key.slice(1)) : resolve(key),
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Determine whether a Twig include/source reference resolves.
|
|
731
|
+
*
|
|
732
|
+
* @param {string} reference - Twig reference.
|
|
733
|
+
* @param {string} filePath - Referencing file path.
|
|
734
|
+
* @param {object} env - Normalized environment.
|
|
735
|
+
* @returns {boolean} TRUE when a candidate exists.
|
|
736
|
+
*/
|
|
737
|
+
export function resolvesTwigReference(reference, filePath, env) {
|
|
738
|
+
if (!reference || /^https?:\/\//i.test(reference)) return true;
|
|
739
|
+
|
|
740
|
+
if (reference.startsWith('@assets/')) {
|
|
741
|
+
const relAsset = reference.replace(/^@assets\//, '');
|
|
742
|
+
return safeExists(resolve(env.projectDir, 'assets', relAsset));
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const candidates =
|
|
746
|
+
reference.startsWith('./') || reference.startsWith('../')
|
|
747
|
+
? relativeTwigCandidates(filePath, reference)
|
|
748
|
+
: candidateKeysToFiles(candidateKeysForReference(reference, env), env);
|
|
749
|
+
|
|
750
|
+
return candidates.some(safeExists);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* Audit Twig namespace and include/source resolution.
|
|
755
|
+
*
|
|
756
|
+
* @param {object} context - Audit context.
|
|
757
|
+
* @returns {object[]} Findings.
|
|
758
|
+
*/
|
|
759
|
+
function auditTwigReferences(context) {
|
|
760
|
+
const { env, projectDir, twigFiles } = context;
|
|
761
|
+
const namespaceRoots = env.namespaceRoots || {};
|
|
762
|
+
const knownNamespaces = new Set([...Object.keys(namespaceRoots), 'assets']);
|
|
763
|
+
const findings = [];
|
|
764
|
+
const seen = new Set();
|
|
765
|
+
|
|
766
|
+
for (const twigFile of twigFiles) {
|
|
767
|
+
const source = cachedReadFile(twigFile);
|
|
768
|
+
|
|
769
|
+
for (const ref of findTwigNamespaceReferences(source)) {
|
|
770
|
+
if (knownNamespaces.has(ref.namespace)) continue;
|
|
771
|
+
|
|
772
|
+
const key = `${twigFile}:${ref.line}:unknown:${ref.namespace}`;
|
|
773
|
+
if (seen.has(key)) continue;
|
|
774
|
+
seen.add(key);
|
|
775
|
+
|
|
776
|
+
findings.push(
|
|
777
|
+
makeFinding({
|
|
778
|
+
id: 'unknown-twig-namespace',
|
|
779
|
+
severity: 'warn',
|
|
780
|
+
filePath: twigFile,
|
|
781
|
+
line: ref.line,
|
|
782
|
+
message: `Twig namespace "@${ref.namespace}" is not configured in the normalized project structure.`,
|
|
783
|
+
docs: 'https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/project-structure.md#twig-namespaces',
|
|
784
|
+
}),
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
for (const ref of findTwigIncludeSourceReferences(source)) {
|
|
789
|
+
if (!resolvesTwigReference(ref.value, twigFile, env)) {
|
|
790
|
+
findings.push(
|
|
791
|
+
makeFinding({
|
|
792
|
+
id: 'unresolved-twig-reference',
|
|
793
|
+
severity: 'warn',
|
|
794
|
+
filePath: twigFile,
|
|
795
|
+
line: ref.line,
|
|
796
|
+
message: `${ref.type}() reference "${ref.value}" could not be resolved from the normalized Twig roots.`,
|
|
797
|
+
docs: 'https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/storybook.md#include',
|
|
798
|
+
}),
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
return findings.map((finding) => ({
|
|
805
|
+
...finding,
|
|
806
|
+
filePath: finding.filePath || resolve(projectDir, 'project.emulsify.json'),
|
|
807
|
+
}));
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* Extract simple same-file Sass string variables.
|
|
812
|
+
*
|
|
813
|
+
* @param {string} source - Stylesheet source.
|
|
814
|
+
* @returns {Map<string, string>} Variable value map.
|
|
815
|
+
*/
|
|
816
|
+
function findSassStringVariables(source) {
|
|
817
|
+
const variables = new Map();
|
|
818
|
+
const pattern = /^\s*\$([\w-]+)\s*:\s*(['"])(.*?)\2\s*;?/gm;
|
|
819
|
+
|
|
820
|
+
for (const match of source.matchAll(pattern)) {
|
|
821
|
+
variables.set(match[1], match[3]);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
return variables;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* Resolve same-file Sass variable interpolation in a URL value.
|
|
829
|
+
*
|
|
830
|
+
* This intentionally handles only simple string variables. It is enough to make
|
|
831
|
+
* common asset roots such as `#{$font-url}/Avenir.woff2` auditable without
|
|
832
|
+
* pretending to be a Sass compiler.
|
|
833
|
+
*
|
|
834
|
+
* @param {string} value - Raw URL value.
|
|
835
|
+
* @param {Map<string, string>} variables - Sass variable map.
|
|
836
|
+
* @returns {string} URL value with known interpolations expanded.
|
|
837
|
+
*/
|
|
838
|
+
function resolveSassUrlValue(value, variables) {
|
|
839
|
+
return value.replace(/#\{\$([\w-]+)\}/g, (match, name) =>
|
|
840
|
+
variables.has(name) ? variables.get(name) : match,
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* Mask style comments while preserving line and character positions.
|
|
846
|
+
*
|
|
847
|
+
* @param {string} source - Stylesheet source.
|
|
848
|
+
* @returns {string} Source with comments replaced by whitespace.
|
|
849
|
+
*/
|
|
850
|
+
function maskStyleComments(source) {
|
|
851
|
+
const blank = (match) => match.replace(/[^\n]/g, ' ');
|
|
852
|
+
|
|
853
|
+
return source
|
|
854
|
+
.replace(/\/\*[\s\S]*?\*\//g, blank)
|
|
855
|
+
.replace(/^[\t ]*\/\/.*$/gm, blank);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Extract URL references from CSS or Sass source.
|
|
860
|
+
*
|
|
861
|
+
* @param {string} source - Stylesheet source.
|
|
862
|
+
* @returns {{value: string, raw: string, line: number}[]} URL references.
|
|
863
|
+
*/
|
|
864
|
+
export function findCssUrlReferences(source) {
|
|
865
|
+
const scanSource = maskStyleComments(source);
|
|
866
|
+
const variables = findSassStringVariables(scanSource);
|
|
867
|
+
const references = [];
|
|
868
|
+
const pattern = /url\(\s*(?:(['"])(.*?)\1|([^'")][^)]*?))\s*\)/g;
|
|
869
|
+
|
|
870
|
+
for (const match of scanSource.matchAll(pattern)) {
|
|
871
|
+
const raw = (match[2] ?? match[3] ?? '').trim();
|
|
872
|
+
const value = resolveSassUrlValue(raw, variables).trim();
|
|
873
|
+
|
|
874
|
+
references.push({
|
|
875
|
+
value,
|
|
876
|
+
raw,
|
|
877
|
+
line: lineNumberAt(source, match.index || 0),
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
return references;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* Determine whether a CSS URL should be skipped by filesystem checks.
|
|
886
|
+
*
|
|
887
|
+
* @param {string} value - URL value.
|
|
888
|
+
* @returns {boolean} TRUE when the URL is not a local relative asset path.
|
|
889
|
+
*/
|
|
890
|
+
function isNonFilesystemCssUrl(value) {
|
|
891
|
+
return (
|
|
892
|
+
!value ||
|
|
893
|
+
value.startsWith('#') ||
|
|
894
|
+
value.startsWith('/') ||
|
|
895
|
+
value.startsWith('//') ||
|
|
896
|
+
value.startsWith('$') ||
|
|
897
|
+
value.startsWith('#{') ||
|
|
898
|
+
/^[a-z][a-z0-9+.-]*:/i.test(value) ||
|
|
899
|
+
/^var\(/i.test(value) ||
|
|
900
|
+
/^env\(/i.test(value)
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* Remove query string and hash suffixes from a URL path.
|
|
906
|
+
*
|
|
907
|
+
* @param {string} value - URL value.
|
|
908
|
+
* @returns {string} Path portion.
|
|
909
|
+
*/
|
|
910
|
+
function cssUrlPath(value) {
|
|
911
|
+
return value.split(/[?#]/)[0];
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* Resolve an emitted CSS output key to the actual CSS file path.
|
|
916
|
+
*
|
|
917
|
+
* Vite entry keys use `__style` internally to avoid JS/CSS collisions. The
|
|
918
|
+
* shared Vite config removes that suffix from emitted CSS file names.
|
|
919
|
+
*
|
|
920
|
+
* @param {string} key - Output key without extension.
|
|
921
|
+
* @returns {string} Emitted CSS file path relative to output root.
|
|
922
|
+
*/
|
|
923
|
+
function emittedCssRelativePath(key) {
|
|
924
|
+
return `${key.replace(/__style$/i, '')}.css`;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Return possible runtime directories for a style file's emitted CSS.
|
|
929
|
+
*
|
|
930
|
+
* @param {string} filePath - Source stylesheet.
|
|
931
|
+
* @param {object} env - Normalized environment.
|
|
932
|
+
* @param {string} projectDir - Project root.
|
|
933
|
+
* @returns {string[]} Absolute runtime directories.
|
|
934
|
+
*/
|
|
935
|
+
function styleRuntimeDirectories(filePath, env, projectDir) {
|
|
936
|
+
if (!/\.(scss|sass|css)$/i.test(filePath)) return [];
|
|
937
|
+
if (basename(filePath).startsWith('_')) return [];
|
|
938
|
+
|
|
939
|
+
const structure = env.projectStructure || {};
|
|
940
|
+
if (!structure.output) return [];
|
|
941
|
+
|
|
942
|
+
const ctx = {
|
|
943
|
+
projectDir,
|
|
944
|
+
srcDir: env.srcDir || resolve(projectDir, 'src'),
|
|
945
|
+
SDC: Boolean(env.SDC),
|
|
946
|
+
};
|
|
947
|
+
const fileName = basename(filePath);
|
|
948
|
+
const isStorybookStyle = /^(cl-|sb-)/.test(fileName);
|
|
949
|
+
const key = isStorybookStyle
|
|
950
|
+
? storybookStyleOutputPath(filePath, structure, ctx)
|
|
951
|
+
: compiledAssetOutputPath(filePath, 'css', structure, ctx);
|
|
952
|
+
|
|
953
|
+
if (!key) return [];
|
|
954
|
+
|
|
955
|
+
const relCss = emittedCssRelativePath(key);
|
|
956
|
+
const directories = [dirname(resolve(projectDir, 'dist', relCss))];
|
|
957
|
+
|
|
958
|
+
if (structure.mirrorComponentOutput && relCss.startsWith('components/')) {
|
|
959
|
+
directories.push(dirname(resolve(projectDir, relCss)));
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
return Array.from(new Set(directories));
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* Audit local CSS/Sass asset URLs that Vite may leave to runtime resolution.
|
|
967
|
+
*
|
|
968
|
+
* @param {object} context - Audit context.
|
|
969
|
+
* @returns {object[]} Findings.
|
|
970
|
+
*/
|
|
971
|
+
function auditCssAssetReferences(context) {
|
|
972
|
+
const { env, projectDir, styleFiles } = context;
|
|
973
|
+
const findings = [];
|
|
974
|
+
const projectAssetsDir = resolve(projectDir, 'assets');
|
|
975
|
+
const styleSourceRoots = env.projectStructure?.sourceRoots || [];
|
|
976
|
+
|
|
977
|
+
for (const filePath of styleFiles) {
|
|
978
|
+
if (
|
|
979
|
+
styleSourceRoots.length &&
|
|
980
|
+
!isInsideAnyRoot(filePath, styleSourceRoots)
|
|
981
|
+
) {
|
|
982
|
+
continue;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
const source = cachedReadFile(filePath);
|
|
986
|
+
const runtimeDirs = styleRuntimeDirectories(filePath, env, projectDir);
|
|
987
|
+
|
|
988
|
+
for (const ref of findCssUrlReferences(source)) {
|
|
989
|
+
if (isNonFilesystemCssUrl(ref.value)) continue;
|
|
990
|
+
|
|
991
|
+
const assetPath = cssUrlPath(ref.value);
|
|
992
|
+
if (!assetPath) continue;
|
|
993
|
+
|
|
994
|
+
const sourceAsset = firstExistingPath([
|
|
995
|
+
resolve(dirname(filePath), assetPath),
|
|
996
|
+
]);
|
|
997
|
+
const runtimeAsset = firstExistingPath(
|
|
998
|
+
runtimeDirs.map((directory) => resolve(directory, assetPath)),
|
|
999
|
+
);
|
|
1000
|
+
const resolvedAsset = sourceAsset || runtimeAsset;
|
|
1001
|
+
|
|
1002
|
+
if (!resolvedAsset) {
|
|
1003
|
+
findings.push(
|
|
1004
|
+
makeFinding({
|
|
1005
|
+
id: 'unresolved-css-asset-reference',
|
|
1006
|
+
severity: 'warn',
|
|
1007
|
+
filePath,
|
|
1008
|
+
line: ref.line,
|
|
1009
|
+
message: `CSS asset URL "${ref.raw}" could not be resolved from the source file or expected emitted CSS location.`,
|
|
1010
|
+
details: [
|
|
1011
|
+
'Check for a typo, move the asset into a source-root-relative location Vite can resolve, or rewrite the URL to a stable Drupal/theme public path.',
|
|
1012
|
+
],
|
|
1013
|
+
docs: 'https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/migration-4x.md#css-asset-urls',
|
|
1014
|
+
}),
|
|
1015
|
+
);
|
|
1016
|
+
continue;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
if (
|
|
1020
|
+
isSameOrInside(resolvedAsset, projectAssetsDir) &&
|
|
1021
|
+
(!sourceAsset || runtimeAsset || assetPath.startsWith('..'))
|
|
1022
|
+
) {
|
|
1023
|
+
findings.push(
|
|
1024
|
+
makeFinding({
|
|
1025
|
+
id: 'css-runtime-asset-reference',
|
|
1026
|
+
severity: 'info',
|
|
1027
|
+
filePath,
|
|
1028
|
+
line: ref.line,
|
|
1029
|
+
message: `CSS asset URL "${ref.raw}" resolves to project-level assets and may be left unchanged by Vite for runtime resolution.`,
|
|
1030
|
+
details: [
|
|
1031
|
+
`Resolved asset: ${displayPath(projectDir, resolvedAsset)}.`,
|
|
1032
|
+
'This is acceptable when Drupal serves the asset at that runtime URL. To make Vite bundle or rebase it, move the asset under a source root and reference it from the authored stylesheet.',
|
|
1033
|
+
],
|
|
1034
|
+
docs: 'https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/migration-4x.md#css-asset-urls',
|
|
1035
|
+
}),
|
|
1036
|
+
);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
return findings;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/**
|
|
1045
|
+
* Audit Webpack-era files and code patterns.
|
|
1046
|
+
*
|
|
1047
|
+
* @param {object} context - Audit context.
|
|
1048
|
+
* @returns {object[]} Findings.
|
|
1049
|
+
*/
|
|
1050
|
+
function auditWebpackPatterns(context) {
|
|
1051
|
+
const { codeFiles, projectDir } = context;
|
|
1052
|
+
const findings = [];
|
|
1053
|
+
const webpackConfig = resolve(projectDir, '.storybook/webpack.config.js');
|
|
1054
|
+
const webpackDir = resolve(projectDir, 'config/webpack');
|
|
1055
|
+
|
|
1056
|
+
if (safeExists(webpackConfig)) {
|
|
1057
|
+
findings.push(
|
|
1058
|
+
makeFinding({
|
|
1059
|
+
id: 'webpack-config-file',
|
|
1060
|
+
severity: 'warn',
|
|
1061
|
+
filePath: webpackConfig,
|
|
1062
|
+
message:
|
|
1063
|
+
'Webpack-specific Storybook config is present and should be migrated to Vite/Storybook overrides.',
|
|
1064
|
+
docs: 'https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/migration-4x.md#vite-customization',
|
|
1065
|
+
}),
|
|
1066
|
+
);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
if (safeIsDirectory(webpackDir)) {
|
|
1070
|
+
findings.push(
|
|
1071
|
+
makeFinding({
|
|
1072
|
+
id: 'webpack-config-directory',
|
|
1073
|
+
severity: 'warn',
|
|
1074
|
+
filePath: webpackDir,
|
|
1075
|
+
message:
|
|
1076
|
+
'config/webpack exists. Webpack-specific customization should move to Vite plugins or extendConfig().',
|
|
1077
|
+
docs: 'https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/extension-points.md#vite-plugins-and-config-patches',
|
|
1078
|
+
}),
|
|
1079
|
+
);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
const patterns = [
|
|
1083
|
+
{
|
|
1084
|
+
regex: /\brequire\.context\s*\(/,
|
|
1085
|
+
message: 'require.context() is Webpack-specific and should be migrated.',
|
|
1086
|
+
},
|
|
1087
|
+
{
|
|
1088
|
+
regex:
|
|
1089
|
+
/\b(raw-loader|twig-loader|style-loader|file-loader|sass-loader)\b/,
|
|
1090
|
+
message: 'Webpack loader references should be migrated to Vite plugins.',
|
|
1091
|
+
},
|
|
1092
|
+
{
|
|
1093
|
+
regex: /from\s+['"][^'"]+![^'"]+['"]|import\s+['"][^'"]+![^'"]+['"]/,
|
|
1094
|
+
message: 'Inline Webpack loader import syntax should be removed.',
|
|
1095
|
+
},
|
|
1096
|
+
];
|
|
1097
|
+
|
|
1098
|
+
for (const filePath of codeFiles) {
|
|
1099
|
+
const source = cachedReadFile(filePath);
|
|
1100
|
+
|
|
1101
|
+
for (const pattern of patterns) {
|
|
1102
|
+
const match = pattern.regex.exec(source);
|
|
1103
|
+
if (!match) continue;
|
|
1104
|
+
|
|
1105
|
+
findings.push(
|
|
1106
|
+
makeFinding({
|
|
1107
|
+
id: 'webpack-era-pattern',
|
|
1108
|
+
severity: 'warn',
|
|
1109
|
+
filePath,
|
|
1110
|
+
line: lineNumberAt(source, match.index || 0),
|
|
1111
|
+
message: pattern.message,
|
|
1112
|
+
docs: 'https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/migration-4x.md#vite-customization',
|
|
1113
|
+
}),
|
|
1114
|
+
);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
return findings;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/**
|
|
1122
|
+
* Extract import specifiers from JavaScript source.
|
|
1123
|
+
*
|
|
1124
|
+
* @param {string} source - JavaScript source.
|
|
1125
|
+
* @returns {{specifier: string, index: number}[]} Import specifiers.
|
|
1126
|
+
*/
|
|
1127
|
+
function findImportSpecifiers(source) {
|
|
1128
|
+
const imports = [];
|
|
1129
|
+
const patterns = [
|
|
1130
|
+
/(?:import|export)\s+(?:[^'"]+\s+from\s+)?['"]([^'"]+)['"]/g,
|
|
1131
|
+
/import\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
|
|
1132
|
+
/require\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
|
|
1133
|
+
];
|
|
1134
|
+
|
|
1135
|
+
for (const pattern of patterns) {
|
|
1136
|
+
for (const match of source.matchAll(pattern)) {
|
|
1137
|
+
imports.push({
|
|
1138
|
+
specifier: match[1],
|
|
1139
|
+
index: match.index || 0,
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
return imports;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
/**
|
|
1148
|
+
* Audit direct imports of Emulsify Core internals.
|
|
1149
|
+
*
|
|
1150
|
+
* @param {object} context - Audit context.
|
|
1151
|
+
* @returns {object[]} Findings.
|
|
1152
|
+
*/
|
|
1153
|
+
function auditCoreImports(context) {
|
|
1154
|
+
const { codeFiles } = context;
|
|
1155
|
+
const findings = [];
|
|
1156
|
+
|
|
1157
|
+
for (const filePath of codeFiles) {
|
|
1158
|
+
const source = cachedReadFile(filePath);
|
|
1159
|
+
|
|
1160
|
+
for (const item of findImportSpecifiers(source)) {
|
|
1161
|
+
const { specifier } = item;
|
|
1162
|
+
if (!specifier.startsWith('@emulsify/core/')) continue;
|
|
1163
|
+
if (PUBLIC_CORE_IMPORTS.has(specifier)) continue;
|
|
1164
|
+
|
|
1165
|
+
findings.push(
|
|
1166
|
+
makeFinding({
|
|
1167
|
+
id: 'internal-core-import',
|
|
1168
|
+
severity: 'warn',
|
|
1169
|
+
filePath,
|
|
1170
|
+
line: lineNumberAt(source, item.index),
|
|
1171
|
+
message: `Import "${specifier}" uses an internal Emulsify Core path. Prefer a public package export.`,
|
|
1172
|
+
docs: 'https://github.com/emulsify-ds/emulsify-core/blob/4.x/README.md#public-imports',
|
|
1173
|
+
}),
|
|
1174
|
+
);
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
return findings;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
/**
|
|
1182
|
+
* Audit Drupal assumptions in non-Drupal projects.
|
|
1183
|
+
*
|
|
1184
|
+
* @param {object} context - Audit context.
|
|
1185
|
+
* @returns {object[]} Findings.
|
|
1186
|
+
*/
|
|
1187
|
+
function auditDrupalAssumptions(context) {
|
|
1188
|
+
const { codeFiles, env } = context;
|
|
1189
|
+
if (env.platform === 'drupal') return [];
|
|
1190
|
+
|
|
1191
|
+
const findings = [];
|
|
1192
|
+
const patterns = [
|
|
1193
|
+
/\bDrupal\.attachBehaviors\b/,
|
|
1194
|
+
/\bwindow\.Drupal\b/,
|
|
1195
|
+
/\bglobalThis\.Drupal\b/,
|
|
1196
|
+
/['"][^'"]*_drupal\.js['"]/,
|
|
1197
|
+
/['"]twig-drupal-filters['"]/,
|
|
1198
|
+
];
|
|
1199
|
+
|
|
1200
|
+
for (const filePath of codeFiles) {
|
|
1201
|
+
const source = cachedReadFile(filePath);
|
|
1202
|
+
const match = patterns.map((pattern) => pattern.exec(source)).find(Boolean);
|
|
1203
|
+
|
|
1204
|
+
if (!match) continue;
|
|
1205
|
+
|
|
1206
|
+
findings.push(
|
|
1207
|
+
makeFinding({
|
|
1208
|
+
id: 'drupal-assumption-non-drupal',
|
|
1209
|
+
severity: 'warn',
|
|
1210
|
+
filePath,
|
|
1211
|
+
line: lineNumberAt(source, match.index || 0),
|
|
1212
|
+
message:
|
|
1213
|
+
'Drupal-specific Storybook/runtime code was found, but the active platform is not drupal.',
|
|
1214
|
+
docs: 'https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/platform-adapters.md',
|
|
1215
|
+
}),
|
|
1216
|
+
);
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
return findings;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/**
|
|
1223
|
+
* Audit files that look like component Twig files outside source roots.
|
|
1224
|
+
*
|
|
1225
|
+
* @param {object} context - Audit context.
|
|
1226
|
+
* @returns {object[]} Findings.
|
|
1227
|
+
*/
|
|
1228
|
+
function auditFilesOutsideRoots(context) {
|
|
1229
|
+
const { env, projectDir, twigFiles } = context;
|
|
1230
|
+
const roots = [
|
|
1231
|
+
...(env.projectStructure?.twigRoots || []),
|
|
1232
|
+
...(env.projectStructure?.sourceRoots || []),
|
|
1233
|
+
];
|
|
1234
|
+
|
|
1235
|
+
if (!roots.length) return [];
|
|
1236
|
+
|
|
1237
|
+
return twigFiles
|
|
1238
|
+
.filter((filePath) => !isInsideAnyRoot(filePath, roots))
|
|
1239
|
+
.map((filePath) =>
|
|
1240
|
+
makeFinding({
|
|
1241
|
+
id: 'twig-file-outside-source-roots',
|
|
1242
|
+
severity: 'info',
|
|
1243
|
+
filePath,
|
|
1244
|
+
message:
|
|
1245
|
+
'Twig file is outside normalized source roots and will not be available to Storybook include()/source() unless another integration loads it.',
|
|
1246
|
+
docs: 'https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/project-structure.md',
|
|
1247
|
+
}),
|
|
1248
|
+
)
|
|
1249
|
+
.filter((finding) => !isNonComponentTwigFile(projectDir, finding.filePath));
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/**
|
|
1253
|
+
* Determine whether a Twig file is intentionally outside component roots.
|
|
1254
|
+
*
|
|
1255
|
+
* @param {string} projectDir - Absolute project root.
|
|
1256
|
+
* @param {string} filePath - Absolute Twig file path.
|
|
1257
|
+
* @returns {boolean} TRUE when the file should not be treated as component source.
|
|
1258
|
+
*/
|
|
1259
|
+
function isNonComponentTwigFile(projectDir, filePath) {
|
|
1260
|
+
const relPath = displayPath(projectDir, filePath);
|
|
1261
|
+
|
|
1262
|
+
return (
|
|
1263
|
+
relPath.startsWith('docs/') ||
|
|
1264
|
+
relPath.startsWith('templates/') ||
|
|
1265
|
+
relPath.includes('/templates/')
|
|
1266
|
+
);
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
/**
|
|
1270
|
+
* Recursively measure a directory size.
|
|
1271
|
+
*
|
|
1272
|
+
* @param {string} directory - Directory path.
|
|
1273
|
+
* @returns {number} Size in bytes.
|
|
1274
|
+
*/
|
|
1275
|
+
function directorySize(directory) {
|
|
1276
|
+
let total = 0;
|
|
1277
|
+
|
|
1278
|
+
try {
|
|
1279
|
+
for (const entry of readdirSync(directory)) {
|
|
1280
|
+
const entryPath = resolve(directory, entry);
|
|
1281
|
+
const stats = statSync(entryPath);
|
|
1282
|
+
total += stats.isDirectory() ? directorySize(entryPath) : stats.size;
|
|
1283
|
+
}
|
|
1284
|
+
} catch {
|
|
1285
|
+
return total;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
return total;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
/**
|
|
1292
|
+
* Audit Twig volume under Storybook roots.
|
|
1293
|
+
*
|
|
1294
|
+
* @param {object} context - Audit context.
|
|
1295
|
+
* @returns {object[]} Findings.
|
|
1296
|
+
*/
|
|
1297
|
+
function auditTwigVolume(context) {
|
|
1298
|
+
const { env, twigThreshold } = context;
|
|
1299
|
+
const roots = Array.from(new Set(env.projectStructure?.twigRoots || []));
|
|
1300
|
+
const twigFiles = new Set();
|
|
1301
|
+
|
|
1302
|
+
for (const root of roots) {
|
|
1303
|
+
if (!safeIsDirectory(root)) continue;
|
|
1304
|
+
for (const filePath of globSync(TWIG_GLOB, {
|
|
1305
|
+
cwd: root,
|
|
1306
|
+
absolute: true,
|
|
1307
|
+
nodir: true,
|
|
1308
|
+
ignore: DEFAULT_IGNORES,
|
|
1309
|
+
})) {
|
|
1310
|
+
twigFiles.add(resolve(filePath));
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
if (twigFiles.size <= twigThreshold) return [];
|
|
1315
|
+
|
|
1316
|
+
const totalBytes = roots.reduce(
|
|
1317
|
+
(total, root) => total + directorySize(root),
|
|
1318
|
+
0,
|
|
1319
|
+
);
|
|
1320
|
+
|
|
1321
|
+
return [
|
|
1322
|
+
makeFinding({
|
|
1323
|
+
id: 'large-twig-storybook-roots',
|
|
1324
|
+
severity: 'info',
|
|
1325
|
+
message: `${twigFiles.size} Twig files are under Storybook Twig roots. Eager Twig imports are reliable but can increase Storybook startup/build cost for large libraries.`,
|
|
1326
|
+
details: [
|
|
1327
|
+
`Approximate Twig root size: ${Math.round(totalBytes / 1024)} KB.`,
|
|
1328
|
+
],
|
|
1329
|
+
docs: 'https://github.com/emulsify-ds/emulsify-core/blob/4.x/docs/performance.md#storybook-twig-imports',
|
|
1330
|
+
}),
|
|
1331
|
+
];
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
/**
|
|
1335
|
+
* Run the combined Emulsify audit.
|
|
1336
|
+
*
|
|
1337
|
+
* @param {{projectDir?: string, twigThreshold?: number}} [options={}] - Options.
|
|
1338
|
+
* @returns {{projectDir: string, summary: object, findings: object[]}} Audit result.
|
|
1339
|
+
*/
|
|
1340
|
+
export function auditProject(options = {}) {
|
|
1341
|
+
resetFileReadCache();
|
|
1342
|
+
|
|
1343
|
+
const projectDir = resolve(options.projectDir || process.cwd());
|
|
1344
|
+
const envResult = resolveAuditEnvironment(projectDir);
|
|
1345
|
+
const structure = envResult.env.projectStructure || {};
|
|
1346
|
+
const sourceRoots = normalizeAuditRoots(
|
|
1347
|
+
projectDir,
|
|
1348
|
+
structure.sourceRoots || [],
|
|
1349
|
+
);
|
|
1350
|
+
const storyRoots = normalizeAuditRoots(
|
|
1351
|
+
projectDir,
|
|
1352
|
+
structure.storyRoots || sourceRoots,
|
|
1353
|
+
);
|
|
1354
|
+
const twigRoots = normalizeAuditRoots(
|
|
1355
|
+
projectDir,
|
|
1356
|
+
structure.twigRoots || sourceRoots,
|
|
1357
|
+
);
|
|
1358
|
+
const storyFiles = collectRootedProjectFiles(
|
|
1359
|
+
projectDir,
|
|
1360
|
+
STORY_GLOB,
|
|
1361
|
+
storyRoots,
|
|
1362
|
+
);
|
|
1363
|
+
const codeFiles = collectRootedProjectFiles(
|
|
1364
|
+
projectDir,
|
|
1365
|
+
CODE_GLOB,
|
|
1366
|
+
sourceRoots,
|
|
1367
|
+
);
|
|
1368
|
+
const twigFiles = collectRootedProjectFiles(projectDir, TWIG_GLOB, twigRoots);
|
|
1369
|
+
const styleFiles = collectRootedProjectFiles(
|
|
1370
|
+
projectDir,
|
|
1371
|
+
STYLE_GLOB,
|
|
1372
|
+
sourceRoots,
|
|
1373
|
+
);
|
|
1374
|
+
const context = {
|
|
1375
|
+
...envResult,
|
|
1376
|
+
projectDir,
|
|
1377
|
+
sourceRoots,
|
|
1378
|
+
storyRoots,
|
|
1379
|
+
twigRoots,
|
|
1380
|
+
storyFiles,
|
|
1381
|
+
codeFiles,
|
|
1382
|
+
twigFiles,
|
|
1383
|
+
styleFiles,
|
|
1384
|
+
twigThreshold: Number.isFinite(options.twigThreshold)
|
|
1385
|
+
? options.twigThreshold
|
|
1386
|
+
: DEFAULT_TWIG_THRESHOLD,
|
|
1387
|
+
};
|
|
1388
|
+
const findings = [
|
|
1389
|
+
...auditProjectConfig(context),
|
|
1390
|
+
...auditPackageOverrides(context),
|
|
1391
|
+
...auditGeneratedPackageScripts(context),
|
|
1392
|
+
...auditStoryDiscovery(context),
|
|
1393
|
+
...auditLegacyTwigStories(context),
|
|
1394
|
+
...auditTwigReferences(context),
|
|
1395
|
+
...auditCssAssetReferences(context),
|
|
1396
|
+
...auditWebpackPatterns(context),
|
|
1397
|
+
...auditCoreImports(context),
|
|
1398
|
+
...auditDrupalAssumptions(context),
|
|
1399
|
+
...auditFilesOutsideRoots(context),
|
|
1400
|
+
...auditTwigVolume(context),
|
|
1401
|
+
];
|
|
1402
|
+
const summary = findings.reduce(
|
|
1403
|
+
(totals, finding) => ({
|
|
1404
|
+
...totals,
|
|
1405
|
+
[finding.severity]: (totals[finding.severity] || 0) + 1,
|
|
1406
|
+
}),
|
|
1407
|
+
{
|
|
1408
|
+
error: 0,
|
|
1409
|
+
warn: 0,
|
|
1410
|
+
info: 0,
|
|
1411
|
+
},
|
|
1412
|
+
);
|
|
1413
|
+
|
|
1414
|
+
return {
|
|
1415
|
+
projectDir,
|
|
1416
|
+
summary,
|
|
1417
|
+
files: {
|
|
1418
|
+
stories: storyFiles.length,
|
|
1419
|
+
twig: twigFiles.length,
|
|
1420
|
+
code: codeFiles.length,
|
|
1421
|
+
styles: styleFiles.length,
|
|
1422
|
+
},
|
|
1423
|
+
findings,
|
|
1424
|
+
};
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
/**
|
|
1428
|
+
* Format one finding for terminal output.
|
|
1429
|
+
*
|
|
1430
|
+
* @param {object} finding - Finding to format.
|
|
1431
|
+
* @param {string} projectDir - Project root.
|
|
1432
|
+
* @returns {string[]} Output lines.
|
|
1433
|
+
*/
|
|
1434
|
+
function formatFinding(finding, projectDir) {
|
|
1435
|
+
const location = finding.filePath
|
|
1436
|
+
? `${displayPath(projectDir, finding.filePath)}${
|
|
1437
|
+
finding.line ? `:${finding.line}` : ''
|
|
1438
|
+
}`
|
|
1439
|
+
: 'project';
|
|
1440
|
+
const lines = [
|
|
1441
|
+
`[${finding.severity}] ${finding.id}`,
|
|
1442
|
+
` ${location}`,
|
|
1443
|
+
` ${finding.message}`,
|
|
1444
|
+
];
|
|
1445
|
+
|
|
1446
|
+
for (const detail of finding.details || []) {
|
|
1447
|
+
lines.push(` ${detail}`);
|
|
1448
|
+
}
|
|
1449
|
+
if (finding.docs) {
|
|
1450
|
+
lines.push(` Docs: ${finding.docs}`);
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
return lines;
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
/**
|
|
1457
|
+
* Format the combined audit report.
|
|
1458
|
+
*
|
|
1459
|
+
* @param {{projectDir: string, summary: object, files: object, findings: object[]}} result
|
|
1460
|
+
* Audit result.
|
|
1461
|
+
* @returns {string} Human-readable report.
|
|
1462
|
+
*/
|
|
1463
|
+
export function formatAuditReport(result) {
|
|
1464
|
+
const lines = [
|
|
1465
|
+
'Emulsify project audit',
|
|
1466
|
+
`Project: ${result.projectDir}`,
|
|
1467
|
+
`Scanned ${result.files.stories} story file(s), ${result.files.twig} Twig file(s), ${result.files.code} code file(s), and ${result.files.styles} style file(s).`,
|
|
1468
|
+
`Findings: ${result.summary.error} error(s), ${result.summary.warn} warning(s), ${result.summary.info} info item(s).`,
|
|
1469
|
+
];
|
|
1470
|
+
|
|
1471
|
+
if (!result.findings.length) {
|
|
1472
|
+
lines.push('No audit findings found.');
|
|
1473
|
+
return lines.join('\n');
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
for (const finding of result.findings) {
|
|
1477
|
+
lines.push('', ...formatFinding(finding, result.projectDir));
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
return lines.join('\n');
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
/**
|
|
1484
|
+
* CLI usage text.
|
|
1485
|
+
*
|
|
1486
|
+
* @returns {string} Usage text.
|
|
1487
|
+
*/
|
|
1488
|
+
function usage() {
|
|
1489
|
+
return [
|
|
1490
|
+
'Usage: emulsify-audit [--root <dir>] [--json] [--fail-on-found] [--twig-threshold <count>]',
|
|
1491
|
+
'',
|
|
1492
|
+
'Options:',
|
|
1493
|
+
' --root <dir> Project root to scan. Defaults to the current directory.',
|
|
1494
|
+
' --json Print machine-readable JSON.',
|
|
1495
|
+
' --fail-on-found Exit with code 1 when any finding is reported.',
|
|
1496
|
+
` --twig-threshold <count> Warn when Storybook roots contain more than this many Twig files. Default: ${DEFAULT_TWIG_THRESHOLD}.`,
|
|
1497
|
+
' --help Print this help text.',
|
|
1498
|
+
].join('\n');
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
/**
|
|
1502
|
+
* Parse command-line arguments.
|
|
1503
|
+
*
|
|
1504
|
+
* @param {string[]} argv - CLI arguments.
|
|
1505
|
+
* @returns {object} Parsed options.
|
|
1506
|
+
*/
|
|
1507
|
+
function parseArgs(argv) {
|
|
1508
|
+
const options = {
|
|
1509
|
+
projectDir: process.cwd(),
|
|
1510
|
+
failOnFound: false,
|
|
1511
|
+
json: false,
|
|
1512
|
+
help: false,
|
|
1513
|
+
twigThreshold: DEFAULT_TWIG_THRESHOLD,
|
|
1514
|
+
};
|
|
1515
|
+
|
|
1516
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
1517
|
+
const arg = argv[index];
|
|
1518
|
+
|
|
1519
|
+
if (arg === '--help' || arg === '-h') {
|
|
1520
|
+
options.help = true;
|
|
1521
|
+
continue;
|
|
1522
|
+
}
|
|
1523
|
+
if (arg === '--fail-on-found') {
|
|
1524
|
+
options.failOnFound = true;
|
|
1525
|
+
continue;
|
|
1526
|
+
}
|
|
1527
|
+
if (arg === '--json') {
|
|
1528
|
+
options.json = true;
|
|
1529
|
+
continue;
|
|
1530
|
+
}
|
|
1531
|
+
if (arg === '--root') {
|
|
1532
|
+
const value = argv[index + 1];
|
|
1533
|
+
if (!value || value.startsWith('--')) {
|
|
1534
|
+
throw new Error('--root requires a project directory.');
|
|
1535
|
+
}
|
|
1536
|
+
options.projectDir = value;
|
|
1537
|
+
index += 1;
|
|
1538
|
+
continue;
|
|
1539
|
+
}
|
|
1540
|
+
if (arg.startsWith('--root=')) {
|
|
1541
|
+
options.projectDir = arg.slice('--root='.length);
|
|
1542
|
+
continue;
|
|
1543
|
+
}
|
|
1544
|
+
if (arg === '--twig-threshold') {
|
|
1545
|
+
const value = Number(argv[index + 1]);
|
|
1546
|
+
if (!Number.isFinite(value)) {
|
|
1547
|
+
throw new Error('--twig-threshold requires a number.');
|
|
1548
|
+
}
|
|
1549
|
+
options.twigThreshold = value;
|
|
1550
|
+
index += 1;
|
|
1551
|
+
continue;
|
|
1552
|
+
}
|
|
1553
|
+
if (arg.startsWith('--twig-threshold=')) {
|
|
1554
|
+
const value = Number(arg.slice('--twig-threshold='.length));
|
|
1555
|
+
if (!Number.isFinite(value)) {
|
|
1556
|
+
throw new Error('--twig-threshold requires a number.');
|
|
1557
|
+
}
|
|
1558
|
+
options.twigThreshold = value;
|
|
1559
|
+
continue;
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
return options;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
/**
|
|
1569
|
+
* Run the CLI.
|
|
1570
|
+
*
|
|
1571
|
+
* @param {string[]} argv - CLI arguments.
|
|
1572
|
+
* @returns {number} Exit code.
|
|
1573
|
+
*/
|
|
1574
|
+
export function runCli(argv = process.argv.slice(2)) {
|
|
1575
|
+
const options = parseArgs(argv);
|
|
1576
|
+
|
|
1577
|
+
if (options.help) {
|
|
1578
|
+
console.log(usage());
|
|
1579
|
+
return 0;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
const result = auditProject(options);
|
|
1583
|
+
|
|
1584
|
+
if (options.json) {
|
|
1585
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1586
|
+
} else {
|
|
1587
|
+
console.log(formatAuditReport(result));
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
return options.failOnFound && result.findings.length ? 1 : 0;
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
if (process.argv[1]?.split(/[\\/]/).pop() === 'audit.js') {
|
|
1594
|
+
try {
|
|
1595
|
+
process.exitCode = runCli();
|
|
1596
|
+
} catch (error) {
|
|
1597
|
+
console.error(error.message || error);
|
|
1598
|
+
console.error('');
|
|
1599
|
+
console.error(usage());
|
|
1600
|
+
process.exitCode = 1;
|
|
1601
|
+
}
|
|
1602
|
+
}
|