@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.
Files changed (110) hide show
  1. package/.cli/init.js +40 -31
  2. package/.storybook/_drupal.js +129 -8
  3. package/.storybook/css-components.js +13 -0
  4. package/.storybook/css-dist.js +5 -0
  5. package/.storybook/emulsifyTheme.js +10 -7
  6. package/.storybook/main.js +417 -65
  7. package/.storybook/manager.js +11 -18
  8. package/.storybook/preview.js +93 -37
  9. package/.storybook/utils.js +70 -69
  10. package/README.md +110 -59
  11. package/config/.stylelintrc.json +2 -6
  12. package/config/a11y.config.js +9 -5
  13. package/config/babel.config.js +5 -0
  14. package/config/eslint.config.js +6 -3
  15. package/config/postcss.config.js +5 -0
  16. package/config/vite/entries.js +227 -0
  17. package/config/vite/environment.js +39 -0
  18. package/config/vite/platforms.js +70 -0
  19. package/config/vite/plugins/copy-src-assets.js +76 -0
  20. package/config/vite/plugins/copy-twig-files.js +84 -0
  21. package/config/vite/plugins/css-asset-relativizer.js +40 -0
  22. package/config/vite/plugins/index.js +105 -0
  23. package/config/vite/plugins/mirror-components.js +358 -0
  24. package/config/vite/plugins/require-context.js +311 -0
  25. package/config/vite/plugins/source-file-index.js +184 -0
  26. package/config/vite/plugins/svg-sprite.js +117 -0
  27. package/config/vite/plugins/twig-extension-installers.js +36 -0
  28. package/config/vite/plugins/twig-module.js +1251 -0
  29. package/config/vite/plugins/virtual-twig-asset-sources.js +404 -0
  30. package/config/vite/plugins/virtual-twig-globs.js +136 -0
  31. package/config/vite/plugins/vituum-patch.js +167 -0
  32. package/config/vite/plugins/yaml-module.js +133 -0
  33. package/config/vite/plugins.js +12 -0
  34. package/config/vite/project-config.js +192 -0
  35. package/config/vite/project-extensions.js +177 -0
  36. package/config/vite/project-structure.js +447 -0
  37. package/config/vite/twig-extensions.js +109 -0
  38. package/config/vite/utils/fs-safe.js +66 -0
  39. package/config/vite/utils/paths.js +40 -0
  40. package/config/vite/utils/react-singleton.js +85 -0
  41. package/config/vite/utils/unique.js +36 -0
  42. package/config/vite/vite.config.js +161 -0
  43. package/package.json +168 -88
  44. package/scripts/a11y.js +70 -16
  45. package/scripts/audit-twig-stories.js +378 -0
  46. package/scripts/audit.js +1602 -0
  47. package/scripts/check-node-version.js +18 -0
  48. package/scripts/loadYaml.js +5 -1
  49. package/src/extensions/index.js +8 -0
  50. package/src/extensions/react/index.js +12 -0
  51. package/src/extensions/react/register.js +45 -0
  52. package/src/extensions/shared/attributes.js +308 -0
  53. package/src/extensions/shared/html.js +41 -0
  54. package/src/extensions/shared/lists.js +38 -0
  55. package/src/extensions/shared/object.js +22 -0
  56. package/src/extensions/twig/function-map.js +20 -0
  57. package/src/extensions/twig/functions/add-attributes.js +39 -0
  58. package/src/extensions/twig/functions/bem.js +166 -0
  59. package/src/extensions/twig/index.js +13 -0
  60. package/src/extensions/twig/register.js +52 -0
  61. package/src/extensions/twig/tag-map.js +16 -0
  62. package/src/extensions/twig/tags/switch.js +266 -0
  63. package/src/storybook/index.js +14 -0
  64. package/src/storybook/main-config.js +132 -0
  65. package/src/storybook/platform-behaviors.js +60 -0
  66. package/src/storybook/preview-parameters.js +81 -0
  67. package/src/storybook/render-twig.js +295 -0
  68. package/src/storybook/twig/drupal-filters.js +7 -0
  69. package/src/storybook/twig/include-function.js +109 -0
  70. package/src/storybook/twig/include.js +28 -0
  71. package/src/storybook/twig/reference-paths.js +294 -0
  72. package/src/storybook/twig/resolver.js +318 -0
  73. package/src/storybook/twig/setup.js +39 -0
  74. package/src/storybook/twig/source-events.js +5 -0
  75. package/src/storybook/twig/source-extensions.js +24 -0
  76. package/src/storybook/twig/source-function.js +239 -0
  77. package/src/storybook/twig/source.js +39 -0
  78. package/.all-contributorsrc +0 -45
  79. package/.editorconfig +0 -5
  80. package/.github/ISSUE_TEMPLATE/BUG_REPORT_TEMPLATE.md +0 -18
  81. package/.github/ISSUE_TEMPLATE/FEATURE_REQUEST_TEMPLATE.md +0 -11
  82. package/.github/PULL_REQUEST_TEMPLATE.md +0 -19
  83. package/.github/dependabot.yml +0 -6
  84. package/.github/workflows/addtoprojects.yml +0 -21
  85. package/.github/workflows/contributors.yml +0 -37
  86. package/.github/workflows/lint.yml +0 -22
  87. package/.github/workflows/semantic-release.yml +0 -24
  88. package/.husky/commit-msg +0 -2
  89. package/.husky/pre-commit +0 -2
  90. package/.nvmrc +0 -1
  91. package/.prettierignore +0 -4
  92. package/.storybook/polyfills/twig-include.js +0 -36
  93. package/.storybook/polyfills/twig-resolver.js +0 -68
  94. package/.storybook/polyfills/twig-source.js +0 -54
  95. package/.storybook/webpack.config.js +0 -193
  96. package/CODE_OF_CONDUCT.md +0 -56
  97. package/commitlint.config.js +0 -5
  98. package/config/jest.config.js +0 -19
  99. package/config/webpack/app.js +0 -1
  100. package/config/webpack/loaders.js +0 -167
  101. package/config/webpack/optimizers.js +0 -17
  102. package/config/webpack/plugins.js +0 -283
  103. package/config/webpack/resolves.js +0 -157
  104. package/config/webpack/sdc-loader.js +0 -16
  105. package/config/webpack/webpack.common.js +0 -268
  106. package/config/webpack/webpack.dev.js +0 -41
  107. package/config/webpack/webpack.prod.js +0 -6
  108. package/release.config.cjs +0 -30
  109. package/scripts/a11y.test.js +0 -172
  110. package/scripts/loadYaml.test.js +0 -30
@@ -0,0 +1,133 @@
1
+ /**
2
+ * @file YAML module plugin for Vite imports.
3
+ *
4
+ * This plugin turns YAML imports into ESM modules with default exports and safe
5
+ * named exports for valid top-level keys.
6
+ */
7
+
8
+ import { load as loadYaml } from 'js-yaml';
9
+
10
+ /**
11
+ * Remove the Vite query string from a module id.
12
+ *
13
+ * @param {string} id - Vite module id.
14
+ * @returns {string} Filesystem path without query parameters.
15
+ */
16
+ const stripRequestQuery = (id) => id.split('?')[0];
17
+
18
+ /**
19
+ * Determine whether a Vite request should compile as a YAML module.
20
+ *
21
+ * @param {string} id - Vite module id, including an optional query string.
22
+ * @returns {boolean} TRUE when the request is a YAML data import.
23
+ */
24
+ const isYamlModuleRequest = (id) => {
25
+ const [filePath, query = ''] = id.split('?');
26
+ if (!/\.ya?ml$/i.test(filePath)) return false;
27
+ return !/(^|&)(raw|url)\b/.test(query);
28
+ };
29
+
30
+ const reservedYamlExportIdentifiers = new Set([
31
+ 'await',
32
+ 'break',
33
+ 'case',
34
+ 'catch',
35
+ 'class',
36
+ 'const',
37
+ 'continue',
38
+ 'debugger',
39
+ 'default',
40
+ 'delete',
41
+ 'do',
42
+ 'else',
43
+ 'export',
44
+ 'extends',
45
+ 'finally',
46
+ 'for',
47
+ 'function',
48
+ 'if',
49
+ 'import',
50
+ 'in',
51
+ 'instanceof',
52
+ 'let',
53
+ 'new',
54
+ 'return',
55
+ 'super',
56
+ 'switch',
57
+ 'this',
58
+ 'throw',
59
+ 'try',
60
+ 'typeof',
61
+ 'var',
62
+ 'void',
63
+ 'while',
64
+ 'with',
65
+ 'yield',
66
+ ]);
67
+
68
+ /**
69
+ * Determine whether a YAML key can be emitted as a named ESM export.
70
+ *
71
+ * @param {string} key - Top-level YAML object key.
72
+ * @returns {boolean} TRUE when the key is safe to emit as a named export.
73
+ */
74
+ const isValidYamlExportIdentifier = (key) =>
75
+ /^[A-Za-z_$][0-9A-Za-z_$]*$/.test(key) &&
76
+ !key.startsWith('$') &&
77
+ !reservedYamlExportIdentifiers.has(key);
78
+
79
+ /**
80
+ * Determine whether a parsed YAML value is a plain object.
81
+ *
82
+ * @param {*} value - Parsed YAML value.
83
+ * @returns {boolean} TRUE when the value is a plain object.
84
+ */
85
+ const isPlainObject = (value) =>
86
+ value !== null &&
87
+ typeof value === 'object' &&
88
+ !Array.isArray(value) &&
89
+ [Object.prototype, null].includes(Object.getPrototypeOf(value));
90
+
91
+ /**
92
+ * Transform YAML imports into JavaScript modules.
93
+ *
94
+ * @returns {import('vite').PluginOption} YAML module plugin.
95
+ */
96
+ export function yamlModulePlugin() {
97
+ return {
98
+ name: 'emulsify-yaml',
99
+ enforce: 'pre',
100
+ transform(source, id) {
101
+ if (!isYamlModuleRequest(id)) {
102
+ return null;
103
+ }
104
+
105
+ try {
106
+ const data = loadYaml(source) ?? null;
107
+ const namedExports = isPlainObject(data)
108
+ ? Object.entries(data)
109
+ .filter(([key]) => isValidYamlExportIdentifier(key))
110
+ .map(
111
+ ([key, value]) =>
112
+ `export const ${key} = ${JSON.stringify(value)};`,
113
+ )
114
+ .join('\n')
115
+ : '';
116
+ const defaultExport = `export default ${JSON.stringify(data)};`;
117
+
118
+ return {
119
+ code: `${namedExports}${namedExports ? '\n' : ''}${defaultExport}\n`,
120
+ map: null,
121
+ };
122
+ } catch (error) {
123
+ this.error(
124
+ `Unable to parse YAML module ${stripRequestQuery(id)}: ${
125
+ error?.message || error
126
+ }`,
127
+ );
128
+ }
129
+
130
+ return null;
131
+ },
132
+ };
133
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @file Public barrel for Emulsify Vite plugin helpers.
3
+ *
4
+ * This file preserves the `@emulsify/core/vite/plugins` export path while the
5
+ * implementation lives in focused internal modules under `config/vite/plugins/`.
6
+ */
7
+
8
+ export { makePlugins } from './plugins/index.js';
9
+ export {
10
+ makeTwigNamespaces,
11
+ makeTwigPluginOptions,
12
+ } from './plugins/twig-module.js';
@@ -0,0 +1,192 @@
1
+ /**
2
+ * @file Normalized project configuration for Emulsify Vite and Storybook.
3
+ *
4
+ * This module is the single Node-side reader for `project.emulsify.json`.
5
+ * Validation is intentionally permissive so existing projects can upgrade
6
+ * without reshaping older config files. Resolved config objects are memoized
7
+ * per project directory and relevant environment signature for one process.
8
+ */
9
+
10
+ import { normalize, resolve, sep } from 'path';
11
+ import { getPlatformAdapter } from './platforms.js';
12
+ import { resolveProjectStructure } from './project-structure.js';
13
+ import { safeExists, safeReadJson } from './utils/fs-safe.js';
14
+
15
+ /**
16
+ * Cache normalized project config by project root and relevant env signature.
17
+ *
18
+ * @type {Map<string, Map<string, object>>}
19
+ */
20
+ const projectConfigCache = new Map();
21
+
22
+ /**
23
+ * Ensure an absolute path stays inside the project directory.
24
+ *
25
+ * @param {string} projectDir - Absolute project root.
26
+ * @param {string} candidate - Path to validate (absolute or relative).
27
+ * @returns {string|null} A safe absolute path, or null if outside projectDir.
28
+ */
29
+ export function coerceToProjectPath(projectDir, candidate) {
30
+ if (typeof candidate !== 'string' || !candidate.trim()) return null;
31
+
32
+ const absProject = resolve(projectDir);
33
+ const absCandidate = resolve(projectDir, candidate);
34
+ const inProject =
35
+ absCandidate.startsWith(absProject + sep) || absCandidate === absProject;
36
+ return inProject ? absCandidate : null;
37
+ }
38
+
39
+ /**
40
+ * Normalize config strings to lowercase identifiers.
41
+ *
42
+ * @param {*} value - Candidate value.
43
+ * @returns {string} Normalized string.
44
+ */
45
+ function normalizeIdentifier(value) {
46
+ return (value || '').toString().toLowerCase().trim();
47
+ }
48
+
49
+ /**
50
+ * Build the environment signature for config values that affect resolution.
51
+ *
52
+ * @param {NodeJS.ProcessEnv|Record<string,string>} env - Environment values.
53
+ * @returns {string} Stable cache-key segment.
54
+ */
55
+ function projectConfigEnvSignature(env = {}) {
56
+ return JSON.stringify({
57
+ EMULSIFY_PLATFORM: normalizeIdentifier(env.EMULSIFY_PLATFORM),
58
+ });
59
+ }
60
+
61
+ /**
62
+ * Normalize variant structure implementation declarations.
63
+ *
64
+ * @param {string} projectDir - Absolute project root.
65
+ * @param {Array} implementations - Raw implementation entries.
66
+ * @returns {{name: string, directory: string}[]} Safe implementation entries.
67
+ */
68
+ function normalizeStructureImplementations(projectDir, implementations = []) {
69
+ if (!Array.isArray(implementations)) return [];
70
+
71
+ return implementations
72
+ .map((item, index) => {
73
+ const rawDirectory =
74
+ typeof item?.directory === 'string' ? item.directory : null;
75
+ const directory = rawDirectory
76
+ ? coerceToProjectPath(projectDir, rawDirectory)
77
+ : null;
78
+ if (!directory) return null;
79
+
80
+ const name =
81
+ typeof item?.name === 'string' && item.name.trim()
82
+ ? normalizeIdentifier(item.name)
83
+ : `structure-${index + 1}`;
84
+
85
+ return {
86
+ name,
87
+ directory: normalize(directory),
88
+ };
89
+ })
90
+ .filter(Boolean);
91
+ }
92
+
93
+ /**
94
+ * Normalize project config for current tooling consumers.
95
+ *
96
+ * @param {string} [projectDir=process.cwd()] - Absolute project root.
97
+ * @param {NodeJS.ProcessEnv|Record<string,string>} [env=process.env] - Environment values.
98
+ * @returns {object} Normalized Emulsify environment/config model.
99
+ */
100
+ export function resolveProjectConfig(
101
+ projectDir = process.cwd(),
102
+ env = process.env,
103
+ ) {
104
+ const root = resolve(projectDir);
105
+ const envSignature = projectConfigEnvSignature(env);
106
+ const cachedByEnv = projectConfigCache.get(root);
107
+ if (cachedByEnv?.has(envSignature)) {
108
+ return cachedByEnv.get(envSignature);
109
+ }
110
+
111
+ const configPath = coerceToProjectPath(root, 'project.emulsify.json');
112
+ const rawConfigResult = configPath ? safeReadJson(configPath) : {};
113
+ const rawConfig =
114
+ rawConfigResult?.data && typeof rawConfigResult.data === 'object'
115
+ ? rawConfigResult.data
116
+ : {};
117
+
118
+ const srcCandidate = resolve(root, 'src');
119
+ const srcExists = safeExists(srcCandidate);
120
+ const srcDir = srcExists ? srcCandidate : resolve(root, 'components');
121
+
122
+ const platform =
123
+ normalizeIdentifier(env.EMULSIFY_PLATFORM) ||
124
+ normalizeIdentifier(rawConfig?.project?.platform) ||
125
+ normalizeIdentifier(rawConfig?.variant?.platform) ||
126
+ 'generic';
127
+ const platformAdapter = getPlatformAdapter(platform);
128
+
129
+ const singleDirectoryComponents = Boolean(
130
+ rawConfig?.project?.singleDirectoryComponents,
131
+ );
132
+ const rawStructureImplementations =
133
+ rawConfig?.variant?.structureImplementations;
134
+ const structureImplementations = normalizeStructureImplementations(
135
+ root,
136
+ rawStructureImplementations,
137
+ );
138
+ const structureRoots = structureImplementations.map(
139
+ (implementation) => implementation.directory,
140
+ );
141
+ const projectStructure = resolveProjectStructure({
142
+ projectDir: root,
143
+ srcDir,
144
+ srcExists,
145
+ SDC: singleDirectoryComponents,
146
+ structureImplementations,
147
+ platformAdapter,
148
+ });
149
+
150
+ const config = {
151
+ projectDir: root,
152
+ platform,
153
+ machineName:
154
+ typeof rawConfig?.project?.machineName === 'string'
155
+ ? rawConfig.project.machineName
156
+ : undefined,
157
+ srcExists,
158
+ srcDir,
159
+ singleDirectoryComponents,
160
+ SDC: singleDirectoryComponents,
161
+ structureOverrides: projectStructure.structureOverrides,
162
+ structureImplementations,
163
+ structureRoots,
164
+ componentRoots: projectStructure.componentRoots,
165
+ globalRoots: projectStructure.globalRoots,
166
+ namespaceRoots: projectStructure.namespaceRoots,
167
+ outputStrategy: platformAdapter.outputStrategy,
168
+ outputMode: platformAdapter.outputStrategy,
169
+ projectStructure,
170
+ platformAdapter,
171
+ adapter: platformAdapter,
172
+ projectConfig: rawConfig,
173
+ };
174
+
175
+ const rootCache = cachedByEnv || new Map();
176
+ rootCache.set(envSignature, config);
177
+ projectConfigCache.set(root, rootCache);
178
+
179
+ return config;
180
+ }
181
+
182
+ /**
183
+ * Clear the process-local project config memoization cache.
184
+ *
185
+ * Tests call this to avoid cross-test pollution when they mutate fixture
186
+ * projects in the same Node process.
187
+ *
188
+ * @returns {void}
189
+ */
190
+ export function resetProjectConfigCache() {
191
+ projectConfigCache.clear();
192
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * @file Project-level Vite extension loader.
3
+ *
4
+ * Loads optional project-level Vite plugin extensions from:
5
+ * config/emulsify-core/vite/plugins.(mjs|js|cjs)
6
+ *
7
+ * Supported shapes in that file:
8
+ * 1) export default [vitePlugin(), ...]
9
+ * 2) export default (ctx) => [vitePlugin(), ...]
10
+ * 3) module.exports = [ ... ]
11
+ * 4) export const extendConfig = (config, ctx) => patchObject
12
+ */
13
+
14
+ import { isAbsolute, normalize, relative, resolve } from 'path';
15
+ import { pathToFileURL } from 'url';
16
+ import { createRequire } from 'module';
17
+ import { firstExistingPath } from './utils/fs-safe.js';
18
+
19
+ const extensionCandidates = [
20
+ 'config/emulsify-core/vite/plugins.mjs',
21
+ 'config/emulsify-core/vite/plugins.js',
22
+ 'config/emulsify-core/vite/plugins.cjs',
23
+ ];
24
+
25
+ /**
26
+ * Normalize CommonJS module results into an ESM-like shape.
27
+ *
28
+ * @param {*} mod - Required module result.
29
+ * @returns {object} Module namespace-like object.
30
+ */
31
+ function cjsModule(mod) {
32
+ return mod && typeof mod === 'object' ? mod : { default: mod };
33
+ }
34
+
35
+ /**
36
+ * Determine whether a failed CommonJS load should retry as native ESM.
37
+ *
38
+ * @param {Error} error - CommonJS load error.
39
+ * @returns {boolean} TRUE when native import should handle the module.
40
+ */
41
+ function shouldImportAsEsm(error) {
42
+ return (
43
+ ['ERR_REQUIRE_ESM', 'ERR_REQUIRE_ASYNC_MODULE'].includes(error?.code) ||
44
+ /Cannot use import statement outside a module|Unexpected token 'export'|Unexpected token export/.test(
45
+ error?.message || '',
46
+ )
47
+ );
48
+ }
49
+
50
+ /**
51
+ * Resolve the consuming project root for project-level extensions.
52
+ *
53
+ * @param {object} ctx - Context passed to project plugin factories.
54
+ * @returns {string} Absolute path.
55
+ */
56
+ function projectRoot(ctx = {}) {
57
+ return resolve(ctx?.env?.projectDir || process.cwd());
58
+ }
59
+
60
+ /**
61
+ * Resolve a path inside the consuming project root.
62
+ *
63
+ * @param {string} root - Absolute project root.
64
+ * @param {string} rel - Project-relative path.
65
+ * @returns {string} Absolute path.
66
+ */
67
+ function inProject(root, rel) {
68
+ return resolve(root, rel);
69
+ }
70
+
71
+ /**
72
+ * Determine whether an absolute path stays inside the consuming project root.
73
+ *
74
+ * @param {string} root - Absolute project root.
75
+ * @param {string} abs - Absolute path to inspect.
76
+ * @returns {boolean} TRUE when the path is under the project root.
77
+ */
78
+ function insideProject(root, abs) {
79
+ const target = normalize(abs);
80
+ const rel = relative(root, target);
81
+ return Boolean(rel) && !rel.startsWith('..') && !isAbsolute(rel);
82
+ }
83
+
84
+ /**
85
+ * Load an ESM or CJS module from an absolute path.
86
+ *
87
+ * @param {string|null} absPath - Absolute module path.
88
+ * @returns {Promise<object|null>} Module namespace or null.
89
+ */
90
+ async function loadModule(absPath) {
91
+ if (!absPath) return null;
92
+ const req = createRequire(absPath);
93
+
94
+ if (absPath.endsWith('.cjs')) {
95
+ return cjsModule(req(absPath));
96
+ }
97
+
98
+ if (absPath.endsWith('.js')) {
99
+ try {
100
+ return cjsModule(req(absPath));
101
+ } catch (error) {
102
+ if (!shouldImportAsEsm(error)) {
103
+ throw error;
104
+ }
105
+ }
106
+ }
107
+
108
+ return import(pathToFileURL(absPath).href);
109
+ }
110
+
111
+ /**
112
+ * Normalize CJS and ESM default export shapes.
113
+ *
114
+ * @param {object|null} mod - Loaded module namespace.
115
+ * @returns {*} Supported default export shape.
116
+ */
117
+ function defaultExport(mod) {
118
+ const raw = mod?.default ?? mod;
119
+ if (
120
+ raw &&
121
+ typeof raw === 'object' &&
122
+ (Array.isArray(raw.default) || typeof raw.default === 'function')
123
+ ) {
124
+ return raw.default;
125
+ }
126
+ return raw;
127
+ }
128
+
129
+ /**
130
+ * Normalize named ESM and CJS object exports for extendConfig.
131
+ *
132
+ * @param {object|null} mod - Loaded module namespace.
133
+ * @returns {Function|undefined} Project config patcher, when present.
134
+ */
135
+ function extendConfigExport(mod) {
136
+ if (typeof mod?.extendConfig === 'function') {
137
+ return mod.extendConfig;
138
+ }
139
+ if (typeof mod?.default?.extendConfig === 'function') {
140
+ return mod.default.extendConfig;
141
+ }
142
+ return undefined;
143
+ }
144
+
145
+ /**
146
+ * Load user-supplied plugins and an optional config patcher.
147
+ *
148
+ * @param {object} ctx - Context passed to project plugin factories.
149
+ * @returns {Promise<{ projectPlugins: import('vite').PluginOption[], extendConfig?: Function }>}
150
+ */
151
+ export async function loadProjectExtensions(ctx = {}) {
152
+ const root = projectRoot(ctx);
153
+ const candidate =
154
+ firstExistingPath(
155
+ extensionCandidates
156
+ .map((candidatePath) => inProject(root, candidatePath))
157
+ .filter((candidatePath) => insideProject(root, candidatePath)),
158
+ ) || null;
159
+
160
+ if (!candidate) return { projectPlugins: [] };
161
+
162
+ const mod = await loadModule(candidate);
163
+
164
+ // Normalize supported default export shapes into a plugin array.
165
+ let projectPlugins = [];
166
+ const raw = defaultExport(mod);
167
+ if (Array.isArray(raw)) {
168
+ projectPlugins = raw;
169
+ } else if (typeof raw === 'function') {
170
+ projectPlugins = raw(ctx) || [];
171
+ }
172
+
173
+ // Named extendConfig export lets projects patch the assembled Vite config.
174
+ const extendConfig = extendConfigExport(mod);
175
+
176
+ return { projectPlugins, extendConfig };
177
+ }