@emulsify/core 4.2.0 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) 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} +387 -115
  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/resolver.js +20 -3
  75. package/src/storybook/twig/setup.js +12 -2
  76. package/src/storybook/twig/source-function.js +5 -2
  77. package/config/vite/utils/unique.js +0 -36
  78. package/src/storybook/twig/include.js +0 -28
  79. package/src/storybook/twig/source-events.js +0 -5
@@ -0,0 +1,227 @@
1
+ /**
2
+ * @file Twig reference parsing and resolution helpers for the project audit.
3
+ */
4
+
5
+ import { dirname, isAbsolute, resolve } from 'node:path';
6
+ import { safeExists } from '../../../config/vite/utils/fs-safe.js';
7
+ import { candidateKeysForReference } from '../../../src/storybook/twig/reference-paths.js';
8
+ import { lineNumberAt } from '../../lib/text.js';
9
+ import { isSameOrInside } from './files.js';
10
+
11
+ const GENERATED_ASSET_ALIASES = new Set(['icons.svg']);
12
+
13
+ /**
14
+ * Extract string arguments passed to include() or source().
15
+ *
16
+ * @param {string} source - Twig source.
17
+ * @returns {{type: string, value: string, line: number}[]} References.
18
+ */
19
+ export function findTwigIncludeSourceReferences(source) {
20
+ const references = [];
21
+ const callPattern = /\b(include|source)\s*\(([\s\S]*?)\)/g;
22
+
23
+ for (const callMatch of source.matchAll(callPattern)) {
24
+ const type = callMatch[1];
25
+ const args = firstArgumentText(callMatch[2]);
26
+ const argsOffset = (callMatch.index || 0) + callMatch[0].indexOf(args);
27
+ const stringPattern = /['"]([^'"]+)['"]/g;
28
+
29
+ for (const stringMatch of args.matchAll(stringPattern)) {
30
+ references.push({
31
+ type,
32
+ value: stringMatch[1],
33
+ line: lineNumberAt(source, argsOffset + (stringMatch.index || 0)),
34
+ });
35
+ }
36
+ }
37
+
38
+ return references;
39
+ }
40
+
41
+ /**
42
+ * Extract the first function argument, including array syntax.
43
+ *
44
+ * Twig include()/source() only use the first argument as the template/source
45
+ * reference. Later object values may also be strings, but they are context
46
+ * values and should not be treated as template references.
47
+ *
48
+ * @param {string} args - Function argument source.
49
+ * @returns {string} First argument source.
50
+ */
51
+ function firstArgumentText(args) {
52
+ let quote = '';
53
+ let depth = 0;
54
+
55
+ for (let index = 0; index < args.length; index += 1) {
56
+ const char = args[index];
57
+ const prev = args[index - 1];
58
+
59
+ if (quote) {
60
+ if (char === quote && prev !== '\\') {
61
+ quote = '';
62
+ }
63
+ continue;
64
+ }
65
+
66
+ if (char === '"' || char.charCodeAt(0) === 39) {
67
+ quote = char;
68
+ continue;
69
+ }
70
+ if (char === '[' || char === '{' || char === '(') {
71
+ depth += 1;
72
+ continue;
73
+ }
74
+ if (char === ']' || char === '}' || char === ')') {
75
+ depth = Math.max(0, depth - 1);
76
+ continue;
77
+ }
78
+ if (char === ',' && depth === 0) {
79
+ return args.slice(0, index);
80
+ }
81
+ }
82
+
83
+ return args;
84
+ }
85
+
86
+ /**
87
+ * Extract Twig namespace references such as @components/card/card.twig.
88
+ *
89
+ * @param {string} source - Twig source.
90
+ * @returns {{namespace: string, value: string, line: number}[]} Namespace refs.
91
+ */
92
+ export function findTwigNamespaceReferences(source) {
93
+ const references = [];
94
+ const pattern = /@([A-Za-z][\w-]*)\/[A-Za-z0-9_./-]+/g;
95
+
96
+ for (const match of source.matchAll(pattern)) {
97
+ references.push({
98
+ namespace: match[1],
99
+ value: match[0],
100
+ line: lineNumberAt(source, match.index || 0),
101
+ });
102
+ }
103
+
104
+ return references;
105
+ }
106
+
107
+ /**
108
+ * Build candidate paths for a relative Twig reference.
109
+ *
110
+ * @param {string} filePath - Referencing file.
111
+ * @param {string} reference - Twig reference.
112
+ * @returns {string[]} Absolute candidate paths.
113
+ */
114
+ function relativeTwigCandidates(filePath, reference) {
115
+ const base = resolve(dirname(filePath), reference);
116
+ if (/\.[A-Za-z0-9]+$/.test(reference)) {
117
+ return [base];
118
+ }
119
+
120
+ return [`${base}.twig`, `${base}.html.twig`];
121
+ }
122
+
123
+ /**
124
+ * Convert resolver candidate keys into absolute filesystem paths.
125
+ *
126
+ * @param {string[]} keys - Root-relative Vite keys.
127
+ * @param {object} env - Normalized environment.
128
+ * @returns {string[]} Absolute candidate paths.
129
+ */
130
+ function candidateKeysToFiles(keys, env) {
131
+ const projectDir = env.projectDir || process.cwd();
132
+
133
+ return keys.map((key) =>
134
+ key.startsWith('/') ? resolve(projectDir, key.slice(1)) : resolve(key),
135
+ );
136
+ }
137
+
138
+ /**
139
+ * Resolve an audit asset root using Storybook's root-relative convention.
140
+ *
141
+ * @param {string} projectDir - Absolute project root.
142
+ * @param {string} assetRoot - Configured, absolute, or project-relative root.
143
+ * @returns {string} Absolute filesystem path, or an empty string.
144
+ */
145
+ export function resolveAuditAssetRoot(projectDir, assetRoot) {
146
+ if (typeof assetRoot !== 'string' || !assetRoot.trim()) return '';
147
+
148
+ const normalizedProjectDir = resolve(projectDir || process.cwd());
149
+ const normalizedRoot = assetRoot.trim();
150
+
151
+ if (isAbsolute(normalizedRoot)) {
152
+ const absoluteRoot = resolve(normalizedRoot);
153
+
154
+ return safeExists(absoluteRoot)
155
+ ? absoluteRoot
156
+ : resolve(normalizedProjectDir, `.${normalizedRoot}`);
157
+ }
158
+
159
+ return resolve(normalizedProjectDir, normalizedRoot);
160
+ }
161
+
162
+ /**
163
+ * Return filesystem roots that Storybook can use for @assets source() calls.
164
+ *
165
+ * @param {object} env - Normalized environment.
166
+ * @param {object} [options={}] - Asset root options.
167
+ * @param {boolean} [options.includeGenerated=false] - Include generated roots.
168
+ * @returns {string[]} Absolute asset roots.
169
+ */
170
+ export function auditAssetRoots(env = {}, { includeGenerated = false } = {}) {
171
+ const projectDir = env.projectDir || process.cwd();
172
+ const configuredRoots = Array.isArray(env?.projectStructure?.assetRoots)
173
+ ? env.projectStructure.assetRoots
174
+ : [];
175
+ const fallbackRoots = ['assets', 'src/assets'];
176
+ const generatedRoots = includeGenerated ? ['dist/assets'] : [];
177
+
178
+ return Array.from(
179
+ new Set(
180
+ [...fallbackRoots, ...configuredRoots, ...generatedRoots]
181
+ .map((root) => resolveAuditAssetRoot(projectDir, root))
182
+ .filter(Boolean),
183
+ ),
184
+ );
185
+ }
186
+
187
+ /**
188
+ * Determine whether an @assets reference resolves through Storybook asset roots.
189
+ *
190
+ * @param {string} reference - Twig @assets reference.
191
+ * @param {object} env - Normalized environment.
192
+ * @returns {boolean} TRUE when a candidate exists.
193
+ */
194
+ function resolvesAssetReference(reference, env) {
195
+ const relAsset = reference.replace(/^@assets\//, '');
196
+ if (!relAsset) return false;
197
+ const includeGenerated = GENERATED_ASSET_ALIASES.has(relAsset);
198
+
199
+ return auditAssetRoots(env, { includeGenerated }).some((root) => {
200
+ const candidate = resolve(root, relAsset);
201
+
202
+ return isSameOrInside(candidate, root) && safeExists(candidate);
203
+ });
204
+ }
205
+
206
+ /**
207
+ * Determine whether a Twig include/source reference resolves.
208
+ *
209
+ * @param {string} reference - Twig reference.
210
+ * @param {string} filePath - Referencing file path.
211
+ * @param {object} env - Normalized environment.
212
+ * @returns {boolean} TRUE when a candidate exists.
213
+ */
214
+ export function resolvesTwigReference(reference, filePath, env) {
215
+ if (!reference || /^https?:\/\//i.test(reference)) return true;
216
+
217
+ if (reference.startsWith('@assets/')) {
218
+ return resolvesAssetReference(reference, env);
219
+ }
220
+
221
+ const candidates =
222
+ reference.startsWith('./') || reference.startsWith('../')
223
+ ? relativeTwigCandidates(filePath, reference)
224
+ : candidateKeysToFiles(candidateKeysForReference(reference, env), env);
225
+
226
+ return candidates.some(safeExists);
227
+ }
@@ -0,0 +1,273 @@
1
+ /**
2
+ * @file Report formatting for the project audit.
3
+ */
4
+
5
+ import { createRequire } from 'node:module';
6
+ import { displayPath } from './lib/findings.js';
7
+
8
+ const require = createRequire(import.meta.url);
9
+ const corePackage = require('../../package.json');
10
+ const summarySeverities = ['error', 'warn', 'info'];
11
+ const fileCountKeys = ['stories', 'twig', 'code', 'styles'];
12
+
13
+ export const AUDIT_REPORT_SCHEMA_VERSION = 1;
14
+
15
+ /**
16
+ * Format one finding for terminal output.
17
+ *
18
+ * @param {object} finding - Finding to format.
19
+ * @param {string} projectDir - Project root.
20
+ * @returns {string[]} Output lines.
21
+ */
22
+ function formatFinding(finding, projectDir) {
23
+ const location = finding.filePath
24
+ ? `${displayPath(projectDir, finding.filePath)}${
25
+ finding.line ? `:${finding.line}` : ''
26
+ }`
27
+ : 'project';
28
+ const lines = [
29
+ `[${finding.severity}] ${finding.id}`,
30
+ ` ${location}`,
31
+ ` ${finding.message}`,
32
+ ];
33
+
34
+ for (const detail of finding.details || []) {
35
+ lines.push(` ${detail}`);
36
+ }
37
+ if (finding.docs) {
38
+ lines.push(` Docs: ${finding.docs}`);
39
+ }
40
+
41
+ return lines;
42
+ }
43
+
44
+ /**
45
+ * Format the combined audit report.
46
+ *
47
+ * @param {{projectDir: string, summary: object, files: object, findings: object[]}} result
48
+ * Audit result.
49
+ * @returns {string} Human-readable report.
50
+ */
51
+ export function formatAuditReport(result) {
52
+ const lines = [
53
+ 'Emulsify project audit',
54
+ `Project: ${result.projectDir}`,
55
+ `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).`,
56
+ `Findings: ${result.summary.error} error(s), ${result.summary.warn} warning(s), ${result.summary.info} info item(s).`,
57
+ ];
58
+
59
+ if (!result.findings.length) {
60
+ lines.push('No audit findings found.');
61
+ return lines.join('\n');
62
+ }
63
+
64
+ for (const finding of result.findings) {
65
+ lines.push('', ...formatFinding(finding, result.projectDir));
66
+ }
67
+
68
+ return lines.join('\n');
69
+ }
70
+
71
+ /**
72
+ * Count findings by severity for machine-readable reports.
73
+ *
74
+ * @param {object[]} [findings=[]] - Findings to summarize.
75
+ * @param {string} [defaultSeverity='warn'] - Severity for findings without one.
76
+ * @returns {{error: number, warn: number, info: number}} Summary counts.
77
+ */
78
+ export function summarizeFindings(findings = [], defaultSeverity = 'warn') {
79
+ return findings.reduce(
80
+ (summary, finding) => {
81
+ const severity = summarySeverities.includes(finding.severity)
82
+ ? finding.severity
83
+ : defaultSeverity;
84
+
85
+ if (summarySeverities.includes(severity)) {
86
+ summary[severity] += 1;
87
+ }
88
+
89
+ return summary;
90
+ },
91
+ {
92
+ error: 0,
93
+ warn: 0,
94
+ info: 0,
95
+ },
96
+ );
97
+ }
98
+
99
+ /**
100
+ * Return the package identity recorded in machine-readable audit documents.
101
+ *
102
+ * @returns {{name: string, version: string}} Tool identity.
103
+ */
104
+ function createToolIdentity() {
105
+ return {
106
+ name: corePackage.name,
107
+ version: corePackage.version,
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Remove the scanned absolute root from human-facing finding text.
113
+ *
114
+ * Internal checks may retain absolute paths. Machine-readable documents do not.
115
+ *
116
+ * @param {*} value - Finding text.
117
+ * @param {string} projectDir - Absolute scanned root.
118
+ * @returns {string} Portable text.
119
+ */
120
+ function normalizeReportText(value, projectDir) {
121
+ let text = String(value);
122
+ const rootVariants = new Set([
123
+ projectDir,
124
+ projectDir.replaceAll('\\', '/'),
125
+ projectDir.replaceAll('/', '\\'),
126
+ ]);
127
+
128
+ for (const root of rootVariants) {
129
+ if (root) {
130
+ text = text.replaceAll(root, '.');
131
+ }
132
+ }
133
+
134
+ return text;
135
+ }
136
+
137
+ /**
138
+ * Normalize one internal finding for the public JSON contract.
139
+ *
140
+ * @param {object} finding - Internal finding.
141
+ * @param {string} projectDir - Absolute scanned root.
142
+ * @param {string} defaultSeverity - Severity for unclassified findings.
143
+ * @returns {object} Machine-readable finding.
144
+ */
145
+ export function normalizeAuditFinding(
146
+ finding,
147
+ projectDir,
148
+ defaultSeverity = 'warn',
149
+ ) {
150
+ if (typeof finding.id !== 'string' || !finding.id.trim()) {
151
+ throw new TypeError('Audit finding is missing a non-empty "id".');
152
+ }
153
+ if (typeof finding.message !== 'string' || !finding.message.trim()) {
154
+ throw new TypeError(
155
+ `Audit finding "${finding.id}" is missing a non-empty "message".`,
156
+ );
157
+ }
158
+
159
+ const severity = summarySeverities.includes(finding.severity)
160
+ ? finding.severity
161
+ : summarySeverities.includes(defaultSeverity)
162
+ ? defaultSeverity
163
+ : 'warn';
164
+ const normalized = {
165
+ id: finding.id,
166
+ severity,
167
+ };
168
+
169
+ if (finding.filePath) {
170
+ normalized.path = displayPath(projectDir, finding.filePath) || '.';
171
+ }
172
+ if (Number.isInteger(finding.line) && finding.line > 0) {
173
+ normalized.line = finding.line;
174
+ }
175
+
176
+ normalized.message = normalizeReportText(finding.message, projectDir);
177
+
178
+ if (Array.isArray(finding.details) && finding.details.length) {
179
+ const details = finding.details
180
+ .filter((detail) => detail != null)
181
+ .map((detail) => normalizeReportText(detail, projectDir));
182
+ if (details.length) {
183
+ normalized.details = details;
184
+ }
185
+ }
186
+ if (typeof finding.docs === 'string' && finding.docs) {
187
+ normalized.docs = finding.docs;
188
+ }
189
+
190
+ return normalized;
191
+ }
192
+
193
+ /**
194
+ * Normalize scan counts into the fixed schema-v1 shape.
195
+ *
196
+ * @param {object} [files={}] - Internal file counts.
197
+ * @returns {{stories: number, twig: number, code: number, styles: number}}
198
+ * File counts.
199
+ */
200
+ function normalizeFileCounts(files = {}) {
201
+ return Object.fromEntries(
202
+ fileCountKeys.map((key) => [
203
+ key,
204
+ Number.isInteger(files[key]) && files[key] >= 0 ? files[key] : 0,
205
+ ]),
206
+ );
207
+ }
208
+
209
+ /**
210
+ * Create the machine-readable audit report document.
211
+ *
212
+ * @param {{projectDir: string, files: object, findings: object[]}} result
213
+ * Audit result.
214
+ * @param {{defaultSeverity?: string}} [options={}] - Formatting options.
215
+ * @returns {object} JSON report document.
216
+ */
217
+ export function createAuditJsonReport(result, options = {}) {
218
+ const findings = (result.findings || []).map((finding) =>
219
+ normalizeAuditFinding(finding, result.projectDir, options.defaultSeverity),
220
+ );
221
+
222
+ return {
223
+ schemaVersion: AUDIT_REPORT_SCHEMA_VERSION,
224
+ tool: createToolIdentity(),
225
+ root: '.',
226
+ summary: summarizeFindings(findings),
227
+ files: normalizeFileCounts(result.files),
228
+ findings,
229
+ };
230
+ }
231
+
232
+ /**
233
+ * Create a structured machine-readable CLI or audit failure.
234
+ *
235
+ * @param {*} error - Failure value.
236
+ * @param {{code?: string, projectDir?: string}} [options={}] - Error options.
237
+ * @returns {object} JSON error document.
238
+ */
239
+ export function createAuditJsonErrorReport(error, options = {}) {
240
+ const message = error?.message || error;
241
+
242
+ return {
243
+ schemaVersion: AUDIT_REPORT_SCHEMA_VERSION,
244
+ tool: createToolIdentity(),
245
+ error: {
246
+ code: options.code || 'audit-failed',
247
+ message: normalizeReportText(message, options.projectDir || ''),
248
+ },
249
+ };
250
+ }
251
+
252
+ /**
253
+ * Format the audit report as machine-readable JSON.
254
+ *
255
+ * @param {{projectDir: string, files: object, findings: object[]}} result
256
+ * Audit result.
257
+ * @param {{defaultSeverity?: string}} [options={}] - Options.
258
+ * @returns {string} JSON report.
259
+ */
260
+ export function formatAuditJsonReport(result, options = {}) {
261
+ return JSON.stringify(createAuditJsonReport(result, options), null, 2);
262
+ }
263
+
264
+ /**
265
+ * Format a CLI or audit failure as machine-readable JSON.
266
+ *
267
+ * @param {*} error - Failure value.
268
+ * @param {{code?: string, projectDir?: string}} [options={}] - Error options.
269
+ * @returns {string} JSON error document.
270
+ */
271
+ export function formatAuditJsonErrorReport(error, options = {}) {
272
+ return JSON.stringify(createAuditJsonErrorReport(error, options), null, 2);
273
+ }