@emulsify/core 4.0.4 → 4.1.1

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.
@@ -6,7 +6,10 @@
6
6
  * dist/components so the mirrored component CSS is not loaded twice.
7
7
  */
8
8
 
9
+ // Load mirrored component output first so Drupal adapter stories match theme roots.
9
10
  import.meta.glob('../../../../components/**/*.css', { eager: true });
11
+
12
+ // Keep shared dist CSS while avoiding duplicate generated component CSS.
10
13
  import.meta.glob(
11
14
  ['../../../../dist/**/*.css', '!../../../../dist/components/**/*.css'],
12
15
  { eager: true },
@@ -2,4 +2,5 @@
2
2
  * @file Storybook compiled dist CSS side-effect loader.
3
3
  */
4
4
 
5
+ // Import matching CSS for Vite side effects so CSS HMR stays on the native path.
5
6
  import.meta.glob('../../../../dist/**/*.css', { eager: true });
@@ -0,0 +1,217 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ const projectRoot = process.cwd();
5
+
6
+ /**
7
+ * Keeps Storybook static directory config aligned to the consuming project.
8
+ *
9
+ * @param {Array<string|{from: string, to: string}>} staticDirs - Static directory entries.
10
+ * @returns {Array<string|{from: string, to: string}>} Existing static directory entries.
11
+ */
12
+ function existingStaticDirs(staticDirs) {
13
+ const seen = new Set();
14
+ const existing = [];
15
+
16
+ for (const staticDir of staticDirs) {
17
+ const directory =
18
+ typeof staticDir === 'string' ? staticDir : staticDir.from;
19
+
20
+ if (!directory || !fs.existsSync(directory)) continue;
21
+
22
+ const key =
23
+ typeof staticDir === 'string'
24
+ ? staticDir
25
+ : `${staticDir.from || ''}\0${staticDir.to || ''}`;
26
+ if (seen.has(key)) continue;
27
+
28
+ seen.add(key);
29
+ existing.push(staticDir);
30
+ }
31
+
32
+ return existing;
33
+ }
34
+
35
+ /**
36
+ * Build static directory mounts for normalized project asset roots.
37
+ *
38
+ * @param {object} env - Resolved project paths used by Storybook.
39
+ * @returns {Array<string|{from: string, to: string}>} Static directory entries.
40
+ */
41
+ export function buildAssetStaticDirs(env) {
42
+ const configuredAssetRoots = Array.isArray(env.projectStructure?.assetRoots)
43
+ ? env.projectStructure.assetRoots
44
+ : [];
45
+ const assetRoots = [
46
+ ...configuredAssetRoots,
47
+ path.resolve(projectRoot, 'assets'),
48
+ path.resolve(projectRoot, 'src/assets'),
49
+ ];
50
+
51
+ return existingStaticDirs([
52
+ ...assetRoots.map((root) => ({
53
+ from: root,
54
+ to: '/assets',
55
+ })),
56
+ {
57
+ from: path.resolve(projectRoot, 'dist/assets'),
58
+ to: '/assets',
59
+ },
60
+ {
61
+ from: path.resolve(projectRoot, 'dist/assets'),
62
+ to: '/',
63
+ },
64
+ ]);
65
+ }
66
+
67
+ /**
68
+ * Checks whether a resolved file path stays inside an expected directory.
69
+ *
70
+ * @param {string} filePath - Resolved candidate file path.
71
+ * @param {string} directory - Resolved directory that must contain the file.
72
+ * @returns {boolean} Whether the file path is inside the directory.
73
+ */
74
+ function isWithinDirectory(filePath, directory) {
75
+ // `path.relative()` exposes traversal attempts as `..` or absolute paths.
76
+ const relativePath = path.relative(directory, filePath);
77
+ return Boolean(
78
+ relativePath &&
79
+ !relativePath.startsWith('..') &&
80
+ !path.isAbsolute(relativePath),
81
+ );
82
+ }
83
+
84
+ /**
85
+ * Returns a browser content type for generated files served by Storybook.
86
+ *
87
+ * @param {string} filePath - Resolved file path being served.
88
+ * @returns {string} HTTP content type header value.
89
+ */
90
+ function contentTypeForFile(filePath) {
91
+ const extension = path.extname(filePath).toLowerCase();
92
+ // Keep this map small; unknown generated files can still download as binary.
93
+ const types = {
94
+ '.css': 'text/css; charset=utf-8',
95
+ '.gif': 'image/gif',
96
+ '.jpg': 'image/jpeg',
97
+ '.jpeg': 'image/jpeg',
98
+ '.js': 'text/javascript; charset=utf-8',
99
+ '.json': 'application/json; charset=utf-8',
100
+ '.png': 'image/png',
101
+ '.svg': 'image/svg+xml; charset=utf-8',
102
+ '.webp': 'image/webp',
103
+ '.woff': 'font/woff',
104
+ '.woff2': 'font/woff2',
105
+ };
106
+
107
+ return types[extension] || 'application/octet-stream';
108
+ }
109
+
110
+ /**
111
+ * Serves generated dist files that may not exist when `staticDirs` is built.
112
+ *
113
+ * Storybook validates static directories during config load, but Emulsify
114
+ * projects often generate `dist` after Storybook starts. This middleware keeps
115
+ * those generated asset URLs available without replacing Vite's CSS HMR.
116
+ *
117
+ * @param {import('http').IncomingMessage} req - Vite dev server request.
118
+ * @param {import('http').ServerResponse} res - Vite dev server response.
119
+ * @param {Function} next - Next middleware callback.
120
+ * @returns {void}
121
+ */
122
+ function serveGeneratedDistFile(req, res, next) {
123
+ const method = req.method || 'GET';
124
+ if (method !== 'GET' && method !== 'HEAD') {
125
+ next();
126
+ return;
127
+ }
128
+
129
+ let pathname = '';
130
+ try {
131
+ // Malformed URLs should fall through to Storybook's normal Vite server.
132
+ pathname = decodeURIComponent(
133
+ new URL(req.url || '/', 'http://localhost').pathname,
134
+ );
135
+ } catch {
136
+ next();
137
+ return;
138
+ }
139
+
140
+ // These URL shapes match Emulsify's compiled CSS and sprite references.
141
+ const routes = [
142
+ {
143
+ pathname: '/icons.svg',
144
+ file: path.resolve(projectRoot, 'dist/assets/icons.svg'),
145
+ },
146
+ {
147
+ prefix: '/assets/',
148
+ directory: path.resolve(projectRoot, 'dist/assets'),
149
+ },
150
+ {
151
+ prefix: '/dist/',
152
+ directory: path.resolve(projectRoot, 'dist'),
153
+ },
154
+ ];
155
+ const route = routes.find(({ prefix, pathname: routePathname }) =>
156
+ routePathname ? pathname === routePathname : pathname.startsWith(prefix),
157
+ );
158
+ if (!route) {
159
+ next();
160
+ return;
161
+ }
162
+
163
+ const filePath = route.file
164
+ ? route.file
165
+ : path.resolve(route.directory, pathname.slice(route.prefix.length));
166
+ // Resolve from known roots only, then reject traversal before reading.
167
+ if (route.directory && !isWithinDirectory(filePath, route.directory)) {
168
+ next();
169
+ return;
170
+ }
171
+
172
+ try {
173
+ const stats = fs.statSync(filePath);
174
+ if (!stats.isFile()) {
175
+ next();
176
+ return;
177
+ }
178
+ if (path.extname(filePath).toLowerCase() === '.css') {
179
+ next();
180
+ return;
181
+ }
182
+
183
+ res.statusCode = 200;
184
+ res.setHeader('Content-Type', contentTypeForFile(filePath));
185
+ res.setHeader('Content-Length', stats.size);
186
+ if (method === 'HEAD') {
187
+ res.end();
188
+ return;
189
+ }
190
+ res.end(fs.readFileSync(filePath));
191
+ } catch {
192
+ next();
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Adds Vite dev-server access to generated dist files.
198
+ *
199
+ * CSS itself is still imported through native `import.meta.glob()` calls in the
200
+ * preview runtime; this plugin only fills the static-file gap for late-created
201
+ * dist assets.
202
+ *
203
+ * @returns {import('vite').Plugin} Vite middleware plugin.
204
+ */
205
+ export function makeGeneratedDistFilesPlugin() {
206
+ return {
207
+ name: 'emulsify-generated-dist-files',
208
+ configureServer(server) {
209
+ server.middlewares.use(serveGeneratedDistFile);
210
+ // Watch generated assets so Vite notices files created after startup.
211
+ server.watcher.add([
212
+ path.join(projectRoot, 'dist/**/*.css'),
213
+ path.join(projectRoot, 'dist/assets/**/*'),
214
+ ]);
215
+ },
216
+ };
217
+ }
@@ -0,0 +1,224 @@
1
+ import path from 'path';
2
+ import viteConfig from '../config/vite/vite.config.js';
3
+ import { twigExtensionModuleSpecifiers } from '../config/vite/twig-extensions.js';
4
+ import {
5
+ mergeReactSingletonOptimizeDeps,
6
+ mergeReactSingletonResolve,
7
+ } from '../config/vite/utils/react-singleton.js';
8
+ import { makeGeneratedDistFilesPlugin } from './main-static-assets.js';
9
+
10
+ // Twig glob maps are provided by config/vite/plugins/virtual-twig-globs.js.
11
+ const twigVirtualModuleIds = [
12
+ 'virtual:emulsify-twig-globs',
13
+ 'virtual:emulsify-twig-asset-sources',
14
+ 'virtual:emulsify-twig-extension-installers',
15
+ ];
16
+
17
+ const twigRuntimeOptimizeDepsExclude = [
18
+ ...twigVirtualModuleIds,
19
+ '@emulsify/core/storybook/twig/source-function',
20
+ '@emulsify/core/storybook/twig/source',
21
+ '@emulsify/core/storybook/twig/resolver',
22
+ ];
23
+
24
+ /**
25
+ * Vite-generated Storybook chunks should not share `/assets` with project
26
+ * static files. Storybook copies staticDirs while the preview build runs, so
27
+ * keeping generated chunks in a separate folder avoids concurrent writers in
28
+ * `.out/assets`.
29
+ *
30
+ * @type {string}
31
+ */
32
+ const storybookViteAssetsDir = 'storybook-assets';
33
+
34
+ /**
35
+ * Merge Storybook and project optimizeDeps excludes with Core Twig runtime IDs.
36
+ *
37
+ * Storybook's dependency optimizer runs before normal Vite virtual module
38
+ * resolution. Core Twig runtime modules import virtual IDs that must stay in
39
+ * the Vite module graph so Emulsify's virtual plugins can resolve them.
40
+ *
41
+ * @param {...string[]} excludeLists - Existing optimizeDeps exclude arrays.
42
+ * @returns {string[]} Merged exclude list.
43
+ */
44
+ function mergeTwigRuntimeOptimizeDepsExcludes(...excludeLists) {
45
+ return Array.from(
46
+ new Set([
47
+ ...excludeLists.flatMap((excludeList) =>
48
+ Array.isArray(excludeList) ? excludeList : [],
49
+ ),
50
+ ...twigRuntimeOptimizeDepsExclude,
51
+ ]),
52
+ );
53
+ }
54
+
55
+ /**
56
+ * Keep Emulsify Twig virtual imports out of Storybook dependency prebundles.
57
+ *
58
+ * @returns {import('esbuild').Plugin} Esbuild plugin for optimizeDeps.
59
+ */
60
+ function makeTwigVirtualModuleOptimizerPlugin() {
61
+ return {
62
+ name: 'emulsify-twig-virtual-modules',
63
+ setup(build) {
64
+ build.onResolve(
65
+ { filter: /^virtual:emulsify-twig-(?:globs|asset-sources)$/ },
66
+ (args) => ({
67
+ path: args.path,
68
+ external: true,
69
+ }),
70
+ );
71
+ },
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Builds the Storybook Vite config merger.
77
+ *
78
+ * @param {object} resolvedStorybookEnv - Resolved project paths used by Storybook.
79
+ * @returns {Function} Storybook `viteFinal` callback.
80
+ */
81
+ export function createViteFinal(resolvedStorybookEnv) {
82
+ return async function viteFinal(config) {
83
+ const { mergeConfig } = await import('vite');
84
+ const env = resolvedStorybookEnv;
85
+ const storybookBuildConfig = config?.build || {};
86
+
87
+ // Keep using the `serve` branch of the shared Vite config here. Storybook
88
+ // has historically consumed that branch, while `mode` still reflects
89
+ // whether Storybook is running in development or production.
90
+ const mode = config?.mode || 'development';
91
+ const baseViteConfig =
92
+ typeof viteConfig === 'function'
93
+ ? await viteConfig({ command: 'serve', mode })
94
+ : viteConfig;
95
+ const existingDefine = (config && config.define) || {};
96
+ const viteDefine = (baseViteConfig && baseViteConfig.define) || {};
97
+
98
+ // Allow Storybook's dev server to read component sources from the project
99
+ // root and any structure override paths used by Emulsify consumers.
100
+ const allowList = new Set([
101
+ ...(config?.server?.fs?.allow || []),
102
+ env.projectDir,
103
+ path.resolve(env.projectDir, 'src'),
104
+ path.resolve(env.projectDir, 'components'),
105
+ path.resolve(env.projectDir, 'dist'),
106
+ ...(Array.isArray(env.projectStructure?.sourceRoots)
107
+ ? env.projectStructure.sourceRoots
108
+ : []),
109
+ ...(Array.isArray(env.componentRoots) ? env.componentRoots : []),
110
+ ...(Array.isArray(env.structureRoots) ? env.structureRoots : []),
111
+ ...(env.namespaceRoots && typeof env.namespaceRoots === 'object'
112
+ ? Object.values(env.namespaceRoots)
113
+ : []),
114
+ ...(Array.isArray(env.projectStructure?.assetRoots)
115
+ ? env.projectStructure.assetRoots
116
+ : []),
117
+ ]);
118
+
119
+ // Twig files are loaded through custom resolvers/plugins, so they need to
120
+ // be treated as importable assets by Storybook's Vite pipeline.
121
+ const assetsInclude = Array.from(
122
+ new Set([
123
+ ...(config.assetsInclude || []),
124
+ ...(baseViteConfig.assetsInclude || []),
125
+ '**/*.twig',
126
+ ]),
127
+ );
128
+ const optimizeDepsInclude = mergeReactSingletonOptimizeDeps(
129
+ baseViteConfig?.optimizeDeps?.include,
130
+ config?.optimizeDeps?.include,
131
+ [
132
+ 'twig',
133
+ '@emulsify/core/extensions/twig',
134
+ ...twigExtensionModuleSpecifiers(env),
135
+ ],
136
+ );
137
+
138
+ const mergedConfig = mergeConfig(config, {
139
+ ...baseViteConfig,
140
+ resolve: mergeReactSingletonResolve(baseViteConfig, config),
141
+ define: {
142
+ // Preserve shared and Storybook-provided constants, then publish the
143
+ // resolved Emulsify environment to client-side code.
144
+ ...viteDefine,
145
+ ...existingDefine,
146
+ __EMULSIFY_ENV__: JSON.stringify(env),
147
+ 'globalThis.__EMULSIFY_ENV__': JSON.stringify(env),
148
+ },
149
+ server: {
150
+ ...(baseViteConfig?.server || {}),
151
+ fs: {
152
+ allow: Array.from(allowList),
153
+ },
154
+ },
155
+ assetsInclude,
156
+ plugins: [
157
+ ...(baseViteConfig?.plugins || []),
158
+ makeGeneratedDistFilesPlugin(),
159
+ ],
160
+ esbuild: {
161
+ // Some downstream code is authored as `.js` files containing JSX, so
162
+ // keep Storybook's esbuild settings aligned with the shared Vite config.
163
+ jsx: 'automatic',
164
+ loader: 'jsx',
165
+ include: /.*\.jsx?$/,
166
+ exclude: [],
167
+ },
168
+ optimizeDeps: {
169
+ ...(baseViteConfig?.optimizeDeps || {}),
170
+ ...(config?.optimizeDeps || {}),
171
+ include: optimizeDepsInclude,
172
+ exclude: mergeTwigRuntimeOptimizeDepsExcludes(
173
+ baseViteConfig?.optimizeDeps?.exclude,
174
+ config?.optimizeDeps?.exclude,
175
+ ),
176
+ esbuildOptions: {
177
+ ...(baseViteConfig?.optimizeDeps?.esbuildOptions || {}),
178
+ ...(config?.optimizeDeps?.esbuildOptions || {}),
179
+ plugins: [
180
+ ...(baseViteConfig?.optimizeDeps?.esbuildOptions?.plugins || []),
181
+ ...(config?.optimizeDeps?.esbuildOptions?.plugins || []),
182
+ makeTwigVirtualModuleOptimizerPlugin(),
183
+ ],
184
+ loader: {
185
+ ...(baseViteConfig?.optimizeDeps?.esbuildOptions?.loader || {}),
186
+ ...(config?.optimizeDeps?.esbuildOptions?.loader || {}),
187
+ // Pre-bundle `.js` dependencies with the JSX loader for packages
188
+ // that ship JSX without a `.jsx` extension.
189
+ '.js': 'jsx',
190
+ },
191
+ },
192
+ },
193
+ });
194
+
195
+ return {
196
+ ...mergedConfig,
197
+ build: {
198
+ ...(mergedConfig.build || {}),
199
+ ...(storybookBuildConfig.outDir
200
+ ? { outDir: storybookBuildConfig.outDir }
201
+ : {}),
202
+ assetsDir: storybookViteAssetsDir,
203
+ emptyOutDir: false,
204
+ },
205
+ resolve: mergeReactSingletonResolve(mergedConfig),
206
+ optimizeDeps: {
207
+ ...(mergedConfig.optimizeDeps || {}),
208
+ include: mergeReactSingletonOptimizeDeps(
209
+ mergedConfig.optimizeDeps?.include,
210
+ ),
211
+ exclude: mergeTwigRuntimeOptimizeDepsExcludes(
212
+ mergedConfig.optimizeDeps?.exclude,
213
+ ),
214
+ esbuildOptions: {
215
+ ...(mergedConfig.optimizeDeps?.esbuildOptions || {}),
216
+ loader: {
217
+ ...(mergedConfig.optimizeDeps?.esbuildOptions?.loader || {}),
218
+ '.js': 'jsx',
219
+ },
220
+ },
221
+ },
222
+ };
223
+ };
224
+ }