@emulsify/core 4.2.0 → 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.storybook/main-vite.js +91 -36
- package/README.md +185 -56
- package/config/a11y.config.js +2 -1
- package/config/vite/entries.js +4 -4
- package/config/vite/plugins/{copy-src-assets.js → assets/copy-src-assets.js} +1 -1
- package/config/vite/plugins/{copy-twig-files.js → assets/copy-twig-files.js} +1 -1
- package/config/vite/plugins/{css-asset-relativizer.js → assets/css-asset-relativizer.js} +9 -0
- package/config/vite/plugins/{mirror-components.js → assets/mirror-components.js} +2 -28
- package/config/vite/plugins/{source-file-index.js → assets/source-file-index.js} +1 -1
- package/config/vite/plugins/{svg-sprite.js → assets/svg-sprite.js} +2 -2
- package/config/vite/plugins/index.js +23 -14
- package/config/vite/plugins/reporter/asset-resolver.js +541 -0
- package/config/vite/plugins/reporter/build-errors.js +284 -0
- package/config/vite/plugins/reporter/diagnostics.js +366 -0
- package/config/vite/plugins/reporter/format.js +199 -0
- package/config/vite/plugins/reporter/index.js +308 -0
- package/config/vite/plugins/reporter/render.js +758 -0
- package/config/vite/plugins/reporter/sass-logger.js +237 -0
- package/config/vite/plugins/reporter/vite-logger.js +188 -0
- package/config/vite/plugins/reporter/watch-mode.js +40 -0
- package/config/vite/plugins/{twig-extension-installers.js → twig/extension-installers.js} +1 -1
- package/config/vite/{twig-extensions.js → plugins/twig/extensions.js} +2 -2
- package/config/vite/plugins/{twig-module.js → twig/twig-module.js} +387 -115
- package/config/vite/plugins/{virtual-twig-asset-sources.js → twig/virtual-twig-asset-sources.js} +37 -136
- package/config/vite/plugins/{virtual-twig-globs.js → twig/virtual-twig-globs.js} +3 -32
- package/config/vite/plugins/{vituum-patch.js → twig/vituum-patch.js} +3 -3
- package/config/vite/plugins.js +1 -1
- package/config/vite/project-config.js +1 -1
- package/config/vite/project-structure.js +1 -1
- package/config/vite/utils/lru.js +77 -0
- package/config/vite/utils/package-version.js +42 -0
- package/config/vite/utils/paths.js +1 -9
- package/config/vite/utils/react-singleton.js +1 -1
- package/config/vite/vite.config.js +51 -5
- package/package.json +93 -58
- package/scripts/a11y.js +115 -23
- package/scripts/audit/checks/core-imports.js +78 -0
- package/scripts/audit/checks/css-asset-references.js +99 -0
- package/scripts/audit/checks/drupal-assumptions.js +48 -0
- package/scripts/audit/checks/files-outside-roots.js +53 -0
- package/scripts/audit/checks/generated-package-scripts.js +113 -0
- package/scripts/audit/checks/legacy-twig-stories.js +33 -0
- package/scripts/audit/checks/package-overrides.js +91 -0
- package/scripts/audit/checks/project-config.js +71 -0
- package/scripts/audit/checks/story-discovery.js +35 -0
- package/scripts/audit/checks/twig-references.js +69 -0
- package/scripts/audit/checks/twig-volume.js +54 -0
- package/scripts/audit/checks/webpack-patterns.js +86 -0
- package/scripts/audit/index.js +177 -0
- package/scripts/audit/lib/css.js +165 -0
- package/scripts/audit/lib/files.js +168 -0
- package/scripts/audit/lib/findings.js +31 -0
- package/scripts/audit/lib/package-json.js +65 -0
- package/scripts/audit/lib/twig.js +227 -0
- package/scripts/audit/report.js +273 -0
- package/scripts/audit-twig-stories.js +115 -78
- package/scripts/audit.js +150 -1632
- package/scripts/check-node-version.js +136 -10
- package/scripts/inspect-components.js +456 -0
- package/scripts/lib/cli.js +179 -0
- package/scripts/lib/fs.js +31 -0
- package/scripts/lib/proc.js +78 -0
- package/scripts/lib/text.js +14 -0
- package/scripts/loadYaml.js +2 -2
- package/src/extensions/shared/attributes.js +3 -3
- package/src/extensions/shared/lists.js +2 -6
- package/src/extensions/shared/root-relative.js +38 -0
- package/src/storybook/index.js +4 -0
- package/src/storybook/render-twig.js +1 -1
- package/src/storybook/render-web-component.js +459 -0
- package/src/storybook/twig/asset-source-runtime.js +193 -0
- package/src/storybook/twig/{source-extensions.js → constants.js} +3 -1
- package/src/storybook/twig/reference-paths.js +2 -13
- package/src/storybook/twig/resolver.js +20 -3
- package/src/storybook/twig/setup.js +12 -2
- package/src/storybook/twig/source-function.js +5 -2
- package/config/vite/utils/unique.js +0 -36
- package/src/storybook/twig/include.js +0 -28
- package/src/storybook/twig/source-events.js +0 -5
|
@@ -3,16 +3,142 @@
|
|
|
3
3
|
* @file Enforces the supported Node.js floor for project scripts.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
import { isCliEntrypoint } from './lib/cli.js';
|
|
8
|
+
|
|
9
|
+
const packageJson = JSON.parse(
|
|
10
|
+
readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
export const SUPPORTED_NODE_ENGINE = packageJson.engines?.node;
|
|
14
|
+
|
|
15
|
+
const MINIMUM_ENGINE_PATTERN =
|
|
16
|
+
/^>=(0|[1-9][0-9]*)(?:\.(0|[1-9][0-9]*))?(?:\.(0|[1-9][0-9]*))?$/;
|
|
17
|
+
const NODE_VERSION_PATTERN =
|
|
18
|
+
/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Parse a Node.js version into comparable numeric parts.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} version - Full Node.js version.
|
|
24
|
+
* @returns {number[]} Major, minor, and patch parts.
|
|
25
|
+
*/
|
|
26
|
+
export function parseNodeVersion(version) {
|
|
27
|
+
const match =
|
|
28
|
+
typeof version === 'string' ? NODE_VERSION_PATTERN.exec(version) : null;
|
|
29
|
+
|
|
30
|
+
if (!match) {
|
|
31
|
+
throw new Error(`Invalid Node.js version "${String(version)}".`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return match.slice(1).map(Number);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Parse the intentionally narrow package engine syntax supported by this check.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} engineExpression - package.json engines.node expression.
|
|
41
|
+
* @returns {{minimumVersion: string, parts: number[]}} Parsed minimum.
|
|
42
|
+
*/
|
|
43
|
+
export function parseMinimumNodeEngine(engineExpression) {
|
|
44
|
+
const match =
|
|
45
|
+
typeof engineExpression === 'string'
|
|
46
|
+
? MINIMUM_ENGINE_PATTERN.exec(engineExpression)
|
|
47
|
+
: null;
|
|
48
|
+
|
|
49
|
+
if (!match) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`Unsupported package.json engines.node expression "${String(
|
|
52
|
+
engineExpression,
|
|
53
|
+
)}". Expected a single minimum such as ">=20" or ">=20.1.2".`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const parts = match.slice(1).map((part) => Number(part ?? 0));
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
minimumVersion: parts.join('.'),
|
|
61
|
+
parts,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Compare two full Node.js versions.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} leftVersion - First version.
|
|
69
|
+
* @param {string} rightVersion - Second version.
|
|
70
|
+
* @returns {-1|0|1} Version ordering.
|
|
71
|
+
*/
|
|
72
|
+
export function compareNodeVersions(leftVersion, rightVersion) {
|
|
73
|
+
const leftParts = parseNodeVersion(leftVersion);
|
|
74
|
+
const rightParts = parseNodeVersion(rightVersion);
|
|
75
|
+
|
|
76
|
+
for (let index = 0; index < leftParts.length; index += 1) {
|
|
77
|
+
if (leftParts[index] !== rightParts[index]) {
|
|
78
|
+
return leftParts[index] < rightParts[index] ? -1 : 1;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Determine whether a Node.js version satisfies the supported minimum.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} currentVersion - Running or candidate Node.js version.
|
|
89
|
+
* @param {string} engineExpression - package.json engines.node expression.
|
|
90
|
+
* @returns {boolean} TRUE when the version is supported.
|
|
91
|
+
*/
|
|
92
|
+
export function isNodeVersionSupported(
|
|
93
|
+
currentVersion,
|
|
94
|
+
engineExpression = SUPPORTED_NODE_ENGINE,
|
|
95
|
+
) {
|
|
96
|
+
const { minimumVersion } = parseMinimumNodeEngine(engineExpression);
|
|
97
|
+
return compareNodeVersions(currentVersion, minimumVersion) >= 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Format the actionable error shown for an unsupported Node.js version.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} currentVersion - Running Node.js version.
|
|
104
|
+
* @param {string} engineExpression - package.json engines.node expression.
|
|
105
|
+
* @returns {string} Failure message.
|
|
106
|
+
*/
|
|
107
|
+
export function formatNodeVersionFailure(
|
|
108
|
+
currentVersion,
|
|
109
|
+
engineExpression = SUPPORTED_NODE_ENGINE,
|
|
110
|
+
) {
|
|
111
|
+
const { minimumVersion } = parseMinimumNodeEngine(engineExpression);
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
`Emulsify Core requires Node.js ${minimumVersion} or later ` +
|
|
115
|
+
`(package.json engines.node: "${engineExpression}"). ` +
|
|
116
|
+
`Current version: ${currentVersion}. Run \`nvm use\` or install a supported Node.js version.`
|
|
14
117
|
);
|
|
15
|
-
process.exit(1);
|
|
16
118
|
}
|
|
17
119
|
|
|
18
|
-
|
|
120
|
+
/**
|
|
121
|
+
* Enforce the supported minimum without mutating the running Node.js version.
|
|
122
|
+
*
|
|
123
|
+
* @param {string} currentVersion - Running Node.js version.
|
|
124
|
+
* @param {string} engineExpression - package.json engines.node expression.
|
|
125
|
+
* @returns {void}
|
|
126
|
+
*/
|
|
127
|
+
export function assertNodeVersionSupported(
|
|
128
|
+
currentVersion = process.versions.node,
|
|
129
|
+
engineExpression = SUPPORTED_NODE_ENGINE,
|
|
130
|
+
) {
|
|
131
|
+
if (!isNodeVersionSupported(currentVersion, engineExpression)) {
|
|
132
|
+
throw new Error(formatNodeVersionFailure(currentVersion, engineExpression));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (isCliEntrypoint(['check-node-version.js'])) {
|
|
137
|
+
try {
|
|
138
|
+
// Keep successful checks quiet so output belongs to the called command.
|
|
139
|
+
assertNodeVersionSupported();
|
|
140
|
+
} catch (error) {
|
|
141
|
+
console.error(error.message || error);
|
|
142
|
+
process.exitCode = 1;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Report Twig component templates recognized by Emulsify Core.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { statSync } from 'node:fs';
|
|
8
|
+
import { basename, dirname, relative, resolve, sep } from 'node:path';
|
|
9
|
+
|
|
10
|
+
import { resolveProjectConfig } from '../config/vite/project-config.js';
|
|
11
|
+
import {
|
|
12
|
+
copiedComponentOutputPath,
|
|
13
|
+
copiedGlobalOutputPath,
|
|
14
|
+
} from '../config/vite/project-structure.js';
|
|
15
|
+
import { walkFiles } from '../config/vite/plugins/assets/source-file-index.js';
|
|
16
|
+
import { toPosixPath } from '../config/vite/utils/paths.js';
|
|
17
|
+
import {
|
|
18
|
+
createUsage,
|
|
19
|
+
isCliEntrypoint,
|
|
20
|
+
parseArgs as parseCliArgs,
|
|
21
|
+
} from './lib/cli.js';
|
|
22
|
+
|
|
23
|
+
const cliFailureExitCode = 2;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Determine whether a path is nested below a root directory.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} filePath - Absolute candidate path.
|
|
29
|
+
* @param {string} rootDir - Absolute root directory.
|
|
30
|
+
* @returns {boolean} TRUE when the candidate is below the root.
|
|
31
|
+
*/
|
|
32
|
+
function isInsideRoot(filePath, rootDir) {
|
|
33
|
+
const relPath = relative(rootDir, filePath);
|
|
34
|
+
return (
|
|
35
|
+
Boolean(relPath) &&
|
|
36
|
+
!relPath.startsWith('..') &&
|
|
37
|
+
!relPath.includes(`..${sep}`)
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Convert an absolute path to a project-relative display path when possible.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} filePath - Absolute file or directory path.
|
|
45
|
+
* @param {string} projectDir - Absolute project root.
|
|
46
|
+
* @returns {string} Portable display path.
|
|
47
|
+
*/
|
|
48
|
+
function displayPath(filePath, projectDir) {
|
|
49
|
+
const relPath = toPosixPath(relative(projectDir, filePath));
|
|
50
|
+
return relPath.startsWith('../')
|
|
51
|
+
? toPosixPath(filePath)
|
|
52
|
+
: `./${relPath || ''}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Remove a supported Twig extension from a file name.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} filePath - Twig template path.
|
|
59
|
+
* @returns {string} Template stem.
|
|
60
|
+
*/
|
|
61
|
+
function twigStem(filePath) {
|
|
62
|
+
return basename(filePath)
|
|
63
|
+
.replace(/\.html\.twig$/i, '')
|
|
64
|
+
.replace(/\.twig$/i, '');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Create a human-readable component label.
|
|
69
|
+
*
|
|
70
|
+
* @param {string} name - Component machine name.
|
|
71
|
+
* @returns {string} Title-cased label.
|
|
72
|
+
*/
|
|
73
|
+
function componentLabel(name) {
|
|
74
|
+
return name
|
|
75
|
+
.replace(/[-_]+/g, ' ')
|
|
76
|
+
.replace(/\b\w/g, (letter) => letter.toUpperCase());
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Remove Twig roots already covered by a parent root.
|
|
81
|
+
*
|
|
82
|
+
* This avoids walking `src/components` separately when the normalized Twig
|
|
83
|
+
* roots already include `src`, while retaining sibling implementation roots.
|
|
84
|
+
*
|
|
85
|
+
* @param {string[]} roots - Absolute Twig roots.
|
|
86
|
+
* @returns {string[]} Minimal roots that cover the same files.
|
|
87
|
+
*/
|
|
88
|
+
function minimalRoots(roots) {
|
|
89
|
+
const uniqueRoots = Array.from(new Set(roots.map((root) => resolve(root))));
|
|
90
|
+
|
|
91
|
+
return uniqueRoots.filter(
|
|
92
|
+
(root) =>
|
|
93
|
+
!uniqueRoots.some(
|
|
94
|
+
(candidate) => candidate !== root && isInsideRoot(root, candidate),
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Discover non-partial Twig templates from normalized project roots.
|
|
101
|
+
*
|
|
102
|
+
* @param {object} env - Normalized Emulsify project configuration.
|
|
103
|
+
* @returns {string[]} Absolute Twig template paths.
|
|
104
|
+
*/
|
|
105
|
+
function collectTwigTemplates(env) {
|
|
106
|
+
const roots = Array.isArray(env.projectStructure?.twigRoots)
|
|
107
|
+
? env.projectStructure.twigRoots
|
|
108
|
+
: [];
|
|
109
|
+
|
|
110
|
+
return Array.from(
|
|
111
|
+
new Set(
|
|
112
|
+
minimalRoots(roots).flatMap((rootDir) =>
|
|
113
|
+
walkFiles(rootDir).filter(
|
|
114
|
+
(filePath) =>
|
|
115
|
+
/\.twig$/i.test(filePath) && !basename(filePath).startsWith('_'),
|
|
116
|
+
),
|
|
117
|
+
),
|
|
118
|
+
),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Assert that a selected project root exists and is a directory.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} projectDir - Absolute project root.
|
|
126
|
+
* @returns {void}
|
|
127
|
+
*/
|
|
128
|
+
function assertProjectDirectory(projectDir) {
|
|
129
|
+
try {
|
|
130
|
+
if (statSync(projectDir).isDirectory()) return;
|
|
131
|
+
} catch {
|
|
132
|
+
// Use the same concise error for missing and unreadable roots.
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
throw new Error(`Project root is not a readable directory: ${projectDir}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Inspect Twig component templates recognized by Emulsify Core.
|
|
140
|
+
*
|
|
141
|
+
* Positional filters use case-insensitive AND matching across names, paths,
|
|
142
|
+
* output locations, and template references.
|
|
143
|
+
*
|
|
144
|
+
* @param {{
|
|
145
|
+
* projectDir?: string,
|
|
146
|
+
* filters?: string[],
|
|
147
|
+
* env?: NodeJS.ProcessEnv|Record<string,string>
|
|
148
|
+
* }} [options={}] - Inspection options.
|
|
149
|
+
* @returns {{project: object, components: object[]}} Component report.
|
|
150
|
+
*/
|
|
151
|
+
export function inspectComponents(options = {}) {
|
|
152
|
+
const projectDir = resolve(options.projectDir || process.cwd());
|
|
153
|
+
const filters = (options.filters || [])
|
|
154
|
+
.map((filter) => filter.toLowerCase().trim())
|
|
155
|
+
.filter(Boolean);
|
|
156
|
+
assertProjectDirectory(projectDir);
|
|
157
|
+
|
|
158
|
+
const env = resolveProjectConfig(
|
|
159
|
+
projectDir,
|
|
160
|
+
options.env === undefined ? process.env : options.env,
|
|
161
|
+
);
|
|
162
|
+
const twigFiles = collectTwigTemplates(env);
|
|
163
|
+
const componentRoot = env.namespaceRoots.components;
|
|
164
|
+
const projectComponentFiles = componentRoot
|
|
165
|
+
? twigFiles.filter((filePath) => isInsideRoot(filePath, componentRoot))
|
|
166
|
+
: [];
|
|
167
|
+
const aliasCounts = projectComponentFiles.reduce((counts, filePath) => {
|
|
168
|
+
const name = twigStem(filePath);
|
|
169
|
+
counts.set(name, (counts.get(name) || 0) + 1);
|
|
170
|
+
return counts;
|
|
171
|
+
}, new Map());
|
|
172
|
+
|
|
173
|
+
const components = twigFiles
|
|
174
|
+
.map((filePath) => {
|
|
175
|
+
const name = twigStem(filePath);
|
|
176
|
+
const exactNamespaces = Object.entries(env.namespaceRoots)
|
|
177
|
+
.filter(([, rootDir]) => isInsideRoot(filePath, rootDir))
|
|
178
|
+
.map(
|
|
179
|
+
([namespace, rootDir]) =>
|
|
180
|
+
`@${namespace}/${toPosixPath(relative(rootDir, filePath))}`,
|
|
181
|
+
);
|
|
182
|
+
const projectNamespace =
|
|
183
|
+
env.machineName &&
|
|
184
|
+
componentRoot &&
|
|
185
|
+
isInsideRoot(filePath, componentRoot)
|
|
186
|
+
? `${env.machineName}:${name}`
|
|
187
|
+
: null;
|
|
188
|
+
const namespaces = [
|
|
189
|
+
...exactNamespaces,
|
|
190
|
+
...(projectNamespace ? [projectNamespace] : []),
|
|
191
|
+
];
|
|
192
|
+
const componentOutput = copiedComponentOutputPath(
|
|
193
|
+
filePath,
|
|
194
|
+
env.projectStructure,
|
|
195
|
+
);
|
|
196
|
+
const outputPath =
|
|
197
|
+
componentOutput ||
|
|
198
|
+
copiedGlobalOutputPath(filePath, env.projectStructure);
|
|
199
|
+
const outputRoot =
|
|
200
|
+
componentOutput && env.projectStructure.mirrorComponentOutput
|
|
201
|
+
? projectDir
|
|
202
|
+
: resolve(projectDir, 'dist');
|
|
203
|
+
const location = outputPath
|
|
204
|
+
? displayPath(dirname(resolve(outputRoot, outputPath)), projectDir)
|
|
205
|
+
: null;
|
|
206
|
+
const source = displayPath(dirname(filePath), projectDir);
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
label: componentLabel(name),
|
|
210
|
+
name,
|
|
211
|
+
namespaces,
|
|
212
|
+
namespaceCollisionCount: aliasCounts.get(name) || 0,
|
|
213
|
+
location,
|
|
214
|
+
source,
|
|
215
|
+
};
|
|
216
|
+
})
|
|
217
|
+
.filter((component) => {
|
|
218
|
+
const haystack = [
|
|
219
|
+
component.name,
|
|
220
|
+
component.source,
|
|
221
|
+
component.location,
|
|
222
|
+
...component.namespaces,
|
|
223
|
+
]
|
|
224
|
+
.filter(Boolean)
|
|
225
|
+
.join(' ')
|
|
226
|
+
.toLowerCase();
|
|
227
|
+
|
|
228
|
+
return filters.every((filter) => haystack.includes(filter));
|
|
229
|
+
})
|
|
230
|
+
.sort(
|
|
231
|
+
(left, right) =>
|
|
232
|
+
left.label.localeCompare(right.label) ||
|
|
233
|
+
left.source.localeCompare(right.source),
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
project: {
|
|
238
|
+
machineName: env.machineName || null,
|
|
239
|
+
namespaceRoots: Object.fromEntries(
|
|
240
|
+
Object.entries(env.namespaceRoots).map(([namespace, rootDir]) => [
|
|
241
|
+
namespace,
|
|
242
|
+
displayPath(rootDir, projectDir),
|
|
243
|
+
]),
|
|
244
|
+
),
|
|
245
|
+
platform: env.platform,
|
|
246
|
+
singleDirectoryComponents: Boolean(env.SDC),
|
|
247
|
+
},
|
|
248
|
+
components,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Format a human-readable component report.
|
|
254
|
+
*
|
|
255
|
+
* @param {{project: object, components: object[]}} report - Component report.
|
|
256
|
+
* @returns {string} Terminal report.
|
|
257
|
+
*/
|
|
258
|
+
export function formatComponentReport(report) {
|
|
259
|
+
const lines = [
|
|
260
|
+
`Project: ${report.project.machineName || '(not configured)'}`,
|
|
261
|
+
`Platform: ${report.project.platform}`,
|
|
262
|
+
`Single Directory Components: ${
|
|
263
|
+
report.project.singleDirectoryComponents ? 'enabled' : 'disabled'
|
|
264
|
+
}`,
|
|
265
|
+
'Namespace roots:',
|
|
266
|
+
];
|
|
267
|
+
|
|
268
|
+
for (const [namespace, rootDir] of Object.entries(
|
|
269
|
+
report.project.namespaceRoots,
|
|
270
|
+
)) {
|
|
271
|
+
lines.push(`- @${namespace}: ${rootDir}`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
lines.push('', `Components: ${report.components.length}`);
|
|
275
|
+
|
|
276
|
+
const duplicateLabels = report.components.reduce((counts, component) => {
|
|
277
|
+
counts.set(component.label, (counts.get(component.label) || 0) + 1);
|
|
278
|
+
return counts;
|
|
279
|
+
}, new Map());
|
|
280
|
+
|
|
281
|
+
for (const component of report.components) {
|
|
282
|
+
const duplicate = (duplicateLabels.get(component.label) || 0) > 1;
|
|
283
|
+
const heading = duplicate
|
|
284
|
+
? `${component.label} (${component.source})`
|
|
285
|
+
: component.label;
|
|
286
|
+
lines.push('', `${heading}:`);
|
|
287
|
+
|
|
288
|
+
for (const namespace of component.namespaces) {
|
|
289
|
+
const collision =
|
|
290
|
+
namespace.includes(':') && component.namespaceCollisionCount > 1
|
|
291
|
+
? ` (ambiguous: ${component.namespaceCollisionCount} templates)`
|
|
292
|
+
: '';
|
|
293
|
+
lines.push(`- Namespace: ${namespace}${collision}`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (component.location) {
|
|
297
|
+
lines.push(`- Location: ${component.location}`);
|
|
298
|
+
}
|
|
299
|
+
if (component.source !== component.location) {
|
|
300
|
+
lines.push(`- Source: ${component.source}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return lines.join('\n');
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Parse command-line arguments while retaining positional text filters.
|
|
309
|
+
*
|
|
310
|
+
* @param {string[]} argv - CLI arguments.
|
|
311
|
+
* @returns {{
|
|
312
|
+
* projectDir: string,
|
|
313
|
+
* filters: string[],
|
|
314
|
+
* json: boolean,
|
|
315
|
+
* help: boolean
|
|
316
|
+
* }} Parsed options.
|
|
317
|
+
*/
|
|
318
|
+
function parseArgs(argv) {
|
|
319
|
+
const cliArgs = [];
|
|
320
|
+
const positionalFilters = [];
|
|
321
|
+
|
|
322
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
323
|
+
const arg = argv[index];
|
|
324
|
+
|
|
325
|
+
if (arg === '--root' || arg === '--filter') {
|
|
326
|
+
cliArgs.push(arg);
|
|
327
|
+
if (argv[index + 1] !== undefined) {
|
|
328
|
+
cliArgs.push(argv[index + 1]);
|
|
329
|
+
index += 1;
|
|
330
|
+
}
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (arg.startsWith('-')) {
|
|
335
|
+
cliArgs.push(arg);
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
positionalFilters.push(arg);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const options = parseCliArgs(cliArgs, {
|
|
343
|
+
defaults: {
|
|
344
|
+
projectDir: process.cwd(),
|
|
345
|
+
filters: [],
|
|
346
|
+
json: false,
|
|
347
|
+
help: false,
|
|
348
|
+
},
|
|
349
|
+
flags: {
|
|
350
|
+
'--json': 'json',
|
|
351
|
+
},
|
|
352
|
+
options: {
|
|
353
|
+
'--filter': {
|
|
354
|
+
key: 'filters',
|
|
355
|
+
append: true,
|
|
356
|
+
missingMessage: '--filter requires a search term.',
|
|
357
|
+
},
|
|
358
|
+
'--root': {
|
|
359
|
+
key: 'projectDir',
|
|
360
|
+
missingMessage: '--root requires a project directory.',
|
|
361
|
+
},
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
options.filters.push(...positionalFilters);
|
|
366
|
+
return options;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* CLI usage text.
|
|
371
|
+
*
|
|
372
|
+
* @returns {string} Usage text.
|
|
373
|
+
*/
|
|
374
|
+
function usage() {
|
|
375
|
+
return createUsage(
|
|
376
|
+
'Usage: emulsify-inspect-components [filters...] [--root <dir>] [--json]',
|
|
377
|
+
[
|
|
378
|
+
' filters Case-insensitive terms matched across component names, paths, and references.',
|
|
379
|
+
' --filter <term> Add a filter explicitly. May be repeated.',
|
|
380
|
+
' --root <dir> Project root to inspect. Defaults to the current directory.',
|
|
381
|
+
' --json Print machine-readable JSON.',
|
|
382
|
+
' --help Print this help text.',
|
|
383
|
+
],
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Format a machine-readable CLI error.
|
|
389
|
+
*
|
|
390
|
+
* @param {Error|*} error - CLI error.
|
|
391
|
+
* @param {string} code - Stable error category.
|
|
392
|
+
* @returns {string} JSON error document.
|
|
393
|
+
*/
|
|
394
|
+
function formatJsonError(error, code) {
|
|
395
|
+
return JSON.stringify(
|
|
396
|
+
{
|
|
397
|
+
error: {
|
|
398
|
+
code,
|
|
399
|
+
message: error?.message || String(error),
|
|
400
|
+
},
|
|
401
|
+
},
|
|
402
|
+
null,
|
|
403
|
+
2,
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Run the component inspector CLI.
|
|
409
|
+
*
|
|
410
|
+
* @param {string[]} argv - CLI arguments.
|
|
411
|
+
* @returns {number} Process exit code.
|
|
412
|
+
*/
|
|
413
|
+
export function runCli(argv = process.argv.slice(2)) {
|
|
414
|
+
const jsonRequested = argv.includes('--json');
|
|
415
|
+
let options;
|
|
416
|
+
|
|
417
|
+
try {
|
|
418
|
+
options = parseArgs(argv);
|
|
419
|
+
if (options.help && options.json) {
|
|
420
|
+
throw new Error('--json cannot be combined with --help.');
|
|
421
|
+
}
|
|
422
|
+
} catch (error) {
|
|
423
|
+
if (jsonRequested) {
|
|
424
|
+
console.log(formatJsonError(error, 'invalid-arguments'));
|
|
425
|
+
} else {
|
|
426
|
+
console.error(`${error.message || error}\n\n${usage()}`);
|
|
427
|
+
}
|
|
428
|
+
return cliFailureExitCode;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (options.help) {
|
|
432
|
+
console.log(usage());
|
|
433
|
+
return 0;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
try {
|
|
437
|
+
const report = inspectComponents(options);
|
|
438
|
+
console.log(
|
|
439
|
+
options.json
|
|
440
|
+
? JSON.stringify(report, null, 2)
|
|
441
|
+
: formatComponentReport(report),
|
|
442
|
+
);
|
|
443
|
+
return 0;
|
|
444
|
+
} catch (error) {
|
|
445
|
+
if (options.json) {
|
|
446
|
+
console.log(formatJsonError(error, 'inspection-failed'));
|
|
447
|
+
} else {
|
|
448
|
+
console.error(`Component inspection failed: ${error.message || error}`);
|
|
449
|
+
}
|
|
450
|
+
return cliFailureExitCode;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (isCliEntrypoint(['inspect-components.js', 'emulsify-inspect-components'])) {
|
|
455
|
+
process.exitCode = runCli();
|
|
456
|
+
}
|