@emulsify/core 3.3.2 → 3.5.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.
@@ -32,14 +32,14 @@ window.Drupal = { behaviors: {} };
32
32
  Drupal.attachBehaviors = function (context, settings) {
33
33
  context = context || document;
34
34
  settings = settings || drupalSettings;
35
- /** @type {Object.<string, {attach: Function}>} */
36
- const behaviors = Drupal.behaviors;
35
+ /** @type {Array<{attach?: Function}>} */
36
+ const behaviors = Object.values(Drupal.behaviors);
37
37
 
38
38
  // Iterate through each behavior and invoke its attach method if defined.
39
- Object.keys(behaviors).forEach(function (i) {
40
- if (typeof behaviors[i].attach === 'function') {
39
+ behaviors.forEach(function (behavior) {
40
+ if (typeof behavior.attach === 'function') {
41
41
  try {
42
- behaviors[i].attach(context, settings);
42
+ behavior.attach(context, settings);
43
43
  } catch (e) {
44
44
  Drupal.throwError(e);
45
45
  }
@@ -1,5 +1,5 @@
1
1
  // Documentation on theming Storybook: https://storybook.js.org/docs/configurations/theming/
2
- import { create } from '@storybook/theming';
2
+ import { create } from 'storybook/theming';
3
3
 
4
4
  export default create({
5
5
  base: 'dark',
@@ -11,7 +11,10 @@ import { resolve } from 'path';
11
11
  import fs from 'fs';
12
12
  import path from 'path';
13
13
  import { fileURLToPath } from 'url';
14
- import configOverrides from '../../../../config/emulsify-core/storybook/main.js';
14
+ import { createRequire } from 'module';
15
+ import extendWebpackConfig from './webpack.config.js';
16
+
17
+ const require = createRequire(import.meta.url);
15
18
 
16
19
  /**
17
20
  * The full path to the current file (ESM compatible).
@@ -23,26 +26,70 @@ const _filename = fileURLToPath(import.meta.url);
23
26
  * The directory name of the current module file.
24
27
  * @type {string}
25
28
  */
26
- const _dirname = path.dirname(_filename);
29
+ const _dirname = path.dirname(_filename);
30
+
31
+ /**
32
+ * Migrate the consumer Storybook theme import from "@storybook/theming" to
33
+ * "storybook/theming" when needed.
34
+ *
35
+ * This runs opportunistically during startup and never throws so Storybook
36
+ * startup is resilient across all projects.
37
+ */
38
+ const migrateConsumerThemeImport = () => {
39
+ try {
40
+ const themeConfigPath = resolve(
41
+ _dirname,
42
+ '../../../../config/emulsify-core/storybook/theme.js',
43
+ );
44
+
45
+ if (!fs.existsSync(themeConfigPath)) {
46
+ return;
47
+ }
48
+
49
+ const originalThemeConfig = fs.readFileSync(themeConfigPath, 'utf8');
50
+
51
+ if (!originalThemeConfig.includes('@storybook/theming')) {
52
+ return;
53
+ }
54
+
55
+ const migratedThemeConfig = originalThemeConfig.replace(
56
+ /(['"])@storybook\/theming\1/g,
57
+ '$1storybook/theming$1',
58
+ );
59
+
60
+ if (migratedThemeConfig !== originalThemeConfig) {
61
+ fs.writeFileSync(themeConfigPath, migratedThemeConfig, 'utf8');
62
+ }
63
+ } catch {
64
+ // Ignore migration failures so Storybook startup is never blocked.
65
+ }
66
+ };
67
+
68
+ migrateConsumerThemeImport();
27
69
 
28
70
  /**
29
71
  * Safely apply any user-provided overrides or fall back to an empty object.
30
72
  * @type {object}
31
73
  */
32
- const safeConfigOverrides = configOverrides || {};
74
+ const safeConfigOverrides = (() => {
75
+ try {
76
+ const overridesModule = require('../../../../config/emulsify-core/storybook/main.js');
77
+ return overridesModule.default || overridesModule || {};
78
+ } catch {
79
+ return {};
80
+ }
81
+ })();
33
82
 
34
83
  /**
35
84
  * Primary Storybook configuration object.
36
- * @type {import('@storybook/core-common').StorybookConfig}
85
+ * @type {import('storybook/internal/types').StorybookConfig}
37
86
  */
38
87
  const config = {
39
88
  /**
40
89
  * Patterns for locating story files under src or components directories.
41
90
  * @type {string[]}
42
91
  */
43
- stories: [
44
- '../../../../(src|components)/**/*.stories.@(js|jsx|ts|tsx)',
45
- ],
92
+ stories: ['../../../../@(src|components)/**/*.stories.@(js|jsx|ts|tsx)'],
46
93
 
47
94
  /**
48
95
  * Directories to serve as static assets in the Storybook build.
@@ -52,6 +99,7 @@ const config = {
52
99
  '../../../../assets/images',
53
100
  '../../../../assets/icons',
54
101
  '../../../../dist',
102
+ '../../../../assets/videos',
55
103
  ],
56
104
 
57
105
  /**
@@ -59,28 +107,31 @@ const config = {
59
107
  * @type {string[]}
60
108
  */
61
109
  addons: [
62
- '../../../@storybook/addon-a11y',
63
- '../../../@storybook/addon-links',
64
- '../../../@storybook/addon-essentials',
65
- '../../../@storybook/addon-themes',
66
- '../../../@storybook/addon-styling-webpack',
110
+ '@storybook/addon-a11y',
111
+ '@storybook/addon-links',
112
+ '@storybook/addon-themes',
113
+ '@storybook/addon-styling-webpack',
67
114
  ],
68
115
 
69
116
  /**
70
117
  * Core builder configuration for Storybook.
71
- * @type {{builder: string, disableTelemetry: boolean}}
118
+ * Storybook 9 splits the HTML renderer from the webpack builder, so the
119
+ * builder must be declared explicitly instead of relying on html-webpack5.
120
+ * @type {{builder: {name: string}, disableTelemetry: boolean}}
72
121
  */
73
122
  core: {
74
- builder: 'webpack5',
123
+ builder: {
124
+ name: '@storybook/builder-webpack5',
125
+ },
75
126
  disableTelemetry: true,
76
127
  },
77
128
 
78
129
  /**
79
- * Framework specification for Storybook (HTML + Webpack5).
130
+ * Framework specification for Storybook's HTML renderer.
80
131
  * @type {{name: string, options: object}}
81
132
  */
82
133
  framework: {
83
- name: '@storybook/html-webpack5',
134
+ name: '@storybook/server-webpack5',
84
135
  options: {},
85
136
  },
86
137
 
@@ -205,7 +256,7 @@ const config = {
205
256
  // load external manager-head.html if present
206
257
  const externalManagerHeadPath = resolve(
207
258
  _dirname,
208
- '../../../../config/emulsify-core/storybook/manager-head.html'
259
+ '../../../../config/emulsify-core/storybook/manager-head.html',
209
260
  );
210
261
  let externalManagerHtml = '';
211
262
  if (fs.existsSync(externalManagerHeadPath)) {
@@ -225,7 +276,7 @@ ${externalManagerHtml}`;
225
276
  previewHead: (head) => {
226
277
  const externalHeadPath = resolve(
227
278
  _dirname,
228
- '../../../../config/emulsify-core/storybook/preview-head.html'
279
+ '../../../../config/emulsify-core/storybook/preview-head.html',
229
280
  );
230
281
 
231
282
  let externalHtml = '';
@@ -237,6 +288,16 @@ ${externalManagerHtml}`;
237
288
  ${externalHtml}`;
238
289
  },
239
290
 
291
+ /**
292
+ * Forward Storybook 9's webpack hook to the existing shared webpack helper so
293
+ * custom Twig, Sass, YAML, and resolver behavior still applies.
294
+ * @param {object} storybookConfig - Storybook's generated webpack config.
295
+ * @param {object} options - Storybook webpack hook options.
296
+ * @returns {Promise<object>} The merged webpack config.
297
+ */
298
+ webpackFinal: async (storybookConfig, options) =>
299
+ extendWebpackConfig({ config: storybookConfig, ...options }),
300
+
240
301
  // Merge in user overrides without modifying original logic
241
302
  ...safeConfigOverrides,
242
303
  };
@@ -1,7 +1,7 @@
1
1
  // .storybook/manager.js
2
2
 
3
- import { addons } from '@storybook/manager-api';
4
- import emulsifyTheme from './emulsifyTheme';
3
+ import { addons } from 'storybook/manager-api';
4
+ import emulsifyTheme from './emulsifyTheme.js';
5
5
 
6
6
  /**
7
7
  * Dynamically import the user-provided Storybook theme override.
@@ -19,7 +19,8 @@ import('../../../../config/emulsify-core/storybook/theme')
19
19
  */
20
20
  const isEmptyObject =
21
21
  !customTheme ||
22
- (typeof customTheme === 'object' && Object.keys(customTheme).length === 0);
22
+ (typeof customTheme === 'object' &&
23
+ Object.keys(customTheme).length === 0);
23
24
 
24
25
  /**
25
26
  * Apply the chosen theme to Storybook’s manager UI configuration.
@@ -42,4 +43,3 @@ import('../../../../config/emulsify-core/storybook/theme')
42
43
  theme: emulsifyTheme,
43
44
  });
44
45
  });
45
-
@@ -0,0 +1,40 @@
1
+ import resolveTemplate from './twig-resolver.js';
2
+
3
+ /**
4
+ * Twig `include()` polyfill.
5
+ * Mirrors Drupal behaviour inside Storybook.
6
+ * @param {string} templateName
7
+ * @param {Object} [variables]
8
+ * @param {boolean} [withContext=false]
9
+ * @return {string}
10
+ */
11
+ function twigInclude(Twig) {
12
+ Twig.extendFunction('include', (...args) => {
13
+ let [templateName, variables = {}, withContext = false] = args;
14
+ if (
15
+ typeof withContext !== 'boolean' &&
16
+ variables &&
17
+ typeof variables.with_context !== 'undefined'
18
+ ) {
19
+ withContext = variables.with_context;
20
+ delete variables.with_context;
21
+ }
22
+
23
+ try {
24
+ const templateFn = resolveTemplate(templateName);
25
+ if (!templateFn) return '';
26
+
27
+ const finalContext =
28
+ withContext && typeof this === 'object'
29
+ ? { ...(this.context || {}), ...variables }
30
+ : variables;
31
+
32
+ return templateFn(finalContext);
33
+ } catch (err) {
34
+ console.error(`Twig include() failed for: ${templateName}`, err);
35
+ return '';
36
+ }
37
+ });
38
+ }
39
+
40
+ export default twigInclude;
@@ -0,0 +1,70 @@
1
+ import { getProjectMachineName } from '../utils.js';
2
+
3
+ const namespace = getProjectMachineName();
4
+
5
+ const twigComponents = require.context(
6
+ '../../../../../src/components/',
7
+ true,
8
+ /\.twig$/,
9
+ );
10
+
11
+ /**
12
+ * Resolve template identifier to compiled Twig function.
13
+ * Supports: @component.twig, namespace:component, @namespace/component, namespace/component
14
+ * @param {string} name Template identifier
15
+ * @returns {Function|undefined} Compiled function or noop
16
+ */
17
+ function resolveTemplate(name) {
18
+ // namespace:icon, @namespace/icon.twig
19
+ if (name.startsWith(`${namespace}:`) || name.startsWith(`@${namespace}/`)) {
20
+ const part = name.startsWith(`${namespace}:`)
21
+ ? name.split(':')[1]
22
+ : name.replace(`${namespace}/`, '').replace('.twig', '');
23
+ const path = `./${part}/${part}.twig`;
24
+ try {
25
+ const mod = twigComponents(path);
26
+ return mod && mod.default ? mod.default : mod;
27
+ } catch {
28
+ console.error(`Cannot resolve Twig component for '${name}' at '${path}'`);
29
+ }
30
+ }
31
+
32
+ // @icon.twig → icon/icon.twig
33
+ if (name.startsWith('@') && name.endsWith('.twig')) {
34
+ const part = name.slice(1, -5); // remove leading @ and trailing .twig
35
+ const path = `./${part}/${part}.twig`;
36
+ try {
37
+ return twigComponents(path).default || twigComponents(path);
38
+ } catch {
39
+ console.error(
40
+ `Cannot resolve Twig shorthand template '${name}' at '${path}'`,
41
+ );
42
+ }
43
+ }
44
+
45
+ // namespace/icon.twig via webpack alias
46
+ if (name.startsWith(`${namespace}/`)) {
47
+ const part = name.slice(namespace.length + 1).replace('.twig', '');
48
+ const path = `./${part}/${part}.twig`;
49
+ try {
50
+ return twigComponents(path).default || twigComponents(path);
51
+ } catch {
52
+ console.error(
53
+ `Cannot resolve Twig alias template '${name}' at '${path}'`,
54
+ );
55
+ }
56
+ }
57
+
58
+ try {
59
+ // Storybook resolves runtime Twig requests through webpack, so this
60
+ // fallback intentionally loads a module path determined at render time.
61
+ // eslint-disable-next-line security/detect-non-literal-require
62
+ const mod = require(name);
63
+ return mod && mod.default ? mod.default : mod;
64
+ } catch (error) {
65
+ console.error(`Cannot resolve Twig template '${name}'`, error);
66
+ return () => '';
67
+ }
68
+ }
69
+
70
+ export default resolveTemplate;
@@ -0,0 +1,65 @@
1
+ import { getProjectMachineName } from '../utils.js';
2
+
3
+ const namespace = getProjectMachineName();
4
+
5
+ // Constants used by the `source()` polyfill.
6
+ const PUBLIC_ASSET_BASE =
7
+ typeof window !== 'undefined' &&
8
+ window.location &&
9
+ window.location.hostname &&
10
+ window.location.hostname.endsWith('github.io')
11
+ ? `/${namespace}/assets/`
12
+ : '/assets/';
13
+
14
+ const INLINE_ASSET_EXTS = new Set([
15
+ 'svg',
16
+ 'html',
17
+ 'twig',
18
+ 'css',
19
+ 'js',
20
+ 'json',
21
+ 'txt',
22
+ 'md',
23
+ ]);
24
+ const IMAGE_ASSET_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif']);
25
+
26
+ /**
27
+ * Twig `source()` polyfill.
28
+ * Returns an <img> tag or URL for @assets paths.
29
+ * @param {string} assetPath
30
+ * @return {string}
31
+ */
32
+ function twigSource(Twig) {
33
+ Twig.extendFunction('source', (assetPath) => {
34
+ if (typeof assetPath !== 'string') return '';
35
+
36
+ // Strip Drupal-style alias and extract file extension.
37
+ const relPath = assetPath.replace(/^@assets\//, '');
38
+ const extension = relPath.split('.').pop().toLowerCase();
39
+
40
+ // Inline raw content for textual assets.
41
+ if (INLINE_ASSET_EXTS.has(extension)) {
42
+ try {
43
+ const xhr = new XMLHttpRequest();
44
+ xhr.open('GET', `${PUBLIC_ASSET_BASE}${relPath}`, false); // synchronous
45
+ xhr.send(null);
46
+ if (xhr.status >= 200 && xhr.status < 300) {
47
+ return xhr.responseText;
48
+ }
49
+ console.error(`source(): ${xhr.status} while fetching ${relPath}`);
50
+ } catch (err) {
51
+ console.error(`source(): failed to fetch ${relPath}`, err);
52
+ }
53
+ }
54
+
55
+ // Auto-render raster images.
56
+ if (IMAGE_ASSET_EXTS.has(extension)) {
57
+ return `<img src="${PUBLIC_ASSET_BASE}${relPath}" alt="" role="img">`;
58
+ }
59
+
60
+ // Fallback: return public URL.
61
+ return `${PUBLIC_ASSET_BASE}${relPath}`;
62
+ });
63
+ }
64
+
65
+ export default twigSource;
@@ -1,5 +1,5 @@
1
1
  // .storybook/preview.js
2
- import { useEffect } from '@storybook/preview-api';
2
+ import { useEffect } from 'storybook/preview-api';
3
3
  import Twig from 'twig';
4
4
  import { setupTwig, fetchCSSFiles } from './utils.js';
5
5
  import { getRules } from 'axe-core';
@@ -8,7 +8,7 @@ import { getRules } from 'axe-core';
8
8
  * External override parameters loaded from project config file, if present.
9
9
  * @type {object}
10
10
  */
11
- let externalOverrides = {};
11
+ let externalOverrides;
12
12
 
13
13
  // Load the preview.js from the project config overrides.
14
14
  try {
@@ -16,10 +16,9 @@ try {
16
16
  * Dynamically require external preview overrides.
17
17
  * @module '../../../../config/emulsify-core/storybook/preview.js'
18
18
  */
19
- externalOverrides = require(
20
- '../../../../config/emulsify-core/storybook/preview.js'
21
- ).default;
22
- } catch (err) {
19
+ externalOverrides =
20
+ require('../../../../config/emulsify-core/storybook/preview.js').default;
21
+ } catch {
23
22
  // no override file? swallow the error and use {}
24
23
  externalOverrides = {};
25
24
  }
@@ -34,10 +33,10 @@ import './_drupal.js';
34
33
  */
35
34
  function enableRulesByTag(tags = []) {
36
35
  const allRules = getRules();
37
- return allRules.map(rule =>
38
- tags.some(t => rule.tags.includes(t))
36
+ return allRules.map((rule) =>
37
+ tags.some((t) => rule.tags.includes(t))
39
38
  ? { id: rule.ruleId, enabled: true }
40
- : { id: rule.ruleId, enabled: false }
39
+ : { id: rule.ruleId, enabled: false },
41
40
  );
42
41
  }
43
42
 
@@ -54,13 +53,81 @@ const AxeRules = enableRulesByTag([
54
53
  'best-practice',
55
54
  ]);
56
55
 
56
+ /**
57
+ * Cache of rendered story output keyed by story id.
58
+ * Storybook server renderer calls `storyFn()` before `fetchStoryHtml`, so
59
+ * decorators can stash markup here and fetch can read it without re-rendering.
60
+ *
61
+ * @type {Map<string, unknown>}
62
+ */
63
+ const renderedStoryCache = new Map();
64
+
65
+ /**
66
+ * Converts a rendered story return value into an HTML string.
67
+ *
68
+ * @param {unknown} rendered
69
+ * The rendered story result.
70
+ *
71
+ * @returns {string}
72
+ * Normalized HTML string.
73
+ */
74
+ function toHtmlString(rendered) {
75
+ if (typeof rendered === 'string') {
76
+ return rendered;
77
+ }
78
+
79
+ if (rendered && typeof rendered === 'object') {
80
+ if (typeof rendered.outerHTML === 'string') {
81
+ return rendered.outerHTML;
82
+ }
83
+ if (typeof rendered.html === 'string') {
84
+ return rendered.html;
85
+ }
86
+ }
87
+
88
+ return '';
89
+ }
90
+
91
+ /**
92
+ * Default server renderer adapter for Storybook 9 server-webpack5.
93
+ * Falls back to local story functions so projects do not need a remote
94
+ * `parameters.server.url` endpoint for basic HTML/Twig stories.
95
+ *
96
+ * @param {string} _url
97
+ * Unused URL from server parameters.
98
+ * @param {string} _path
99
+ * Unused story path/id from server parameters.
100
+ * @param {object} _params
101
+ * Unused merged server params.
102
+ * @param {object} storyContext
103
+ * Story context from Storybook.
104
+ *
105
+ * @returns {Promise<string>}
106
+ * Story markup as an HTML string.
107
+ */
108
+ async function fetchStoryHtmlFromStoryContext(
109
+ _url,
110
+ _path,
111
+ _params,
112
+ storyContext,
113
+ ) {
114
+ const storyId = storyContext?.id || _path;
115
+ if (!storyId || !renderedStoryCache.has(storyId)) {
116
+ return '';
117
+ }
118
+
119
+ const rendered = await Promise.resolve(renderedStoryCache.get(storyId));
120
+ return toHtmlString(rendered);
121
+ }
122
+
57
123
  // Initialize Twig and load any CSS that your stories need.
58
124
  setupTwig(Twig);
59
125
  fetchCSSFiles();
60
126
 
61
127
  /**
62
128
  * Storybook decorators to apply Drupal behaviors before rendering each story.
63
- * @type {Array<import('@storybook/react').Decorator>}
129
+ * The HTML renderer still uses the generic Storybook decorator signature.
130
+ * @type {Function[]}
64
131
  */
65
132
  export const decorators = [
66
133
  /**
@@ -69,11 +136,17 @@ export const decorators = [
69
136
  * @param {object} context Story context including args.
70
137
  * @returns {Function} Rendered story.
71
138
  */
72
- (Story, { args }) => {
139
+ (Story, context) => {
140
+ const { args, id } = context;
73
141
  useEffect(() => {
74
142
  Drupal.attachBehaviors();
75
143
  }, [args]);
76
- return Story();
144
+
145
+ const rendered = Story();
146
+ if (id) {
147
+ renderedStoryCache.set(id, rendered);
148
+ }
149
+ return rendered;
77
150
  },
78
151
  ];
79
152
 
@@ -91,6 +164,11 @@ const defaultParams = {
91
164
  },
92
165
  },
93
166
  layout: 'fullscreen',
167
+ server: {
168
+ url: '',
169
+ fetchStoryHtml: fetchStoryHtmlFromStoryContext,
170
+ params: {},
171
+ },
94
172
  };
95
173
 
96
174
  /**
@@ -3,6 +3,8 @@ import twigDrupal from 'twig-drupal-filters';
3
3
  import twigBEM from 'bem-twig-extension';
4
4
  import twigAddAttributes from 'add-attributes-twig-extension';
5
5
  import emulsifyConfig from '../../../../project.emulsify.json' with { type: 'json' };
6
+ import twigInclude from './polyfills/twig-include.js';
7
+ import twigSource from './polyfills/twig-source.js';
6
8
 
7
9
  // Create __filename from import.meta.url without fileURLToPath
8
10
  let _filename = decodeURIComponent(new URL(import.meta.url).pathname);
@@ -23,7 +25,7 @@ const _dirname = dirname(_filename);
23
25
  const fetchVariantConfig = () => {
24
26
  try {
25
27
  return emulsifyConfig.variant.structureImplementations;
26
- } catch (e) {
28
+ } catch {
27
29
  return [
28
30
  {
29
31
  name: 'components',
@@ -47,20 +49,40 @@ const fetchCSSFiles = () => {
47
49
 
48
50
  // Load all CSS files from 'components' for 'drupal' platform.
49
51
  if (emulsifyConfig.project.platform === 'drupal') {
50
- const drupalCSSFiles = require.context('../../../../components', true, /\.css$/);
52
+ const drupalCSSFiles = require.context(
53
+ '../../../../components',
54
+ true,
55
+ /\.css$/,
56
+ );
51
57
  drupalCSSFiles.keys().forEach((file) => drupalCSSFiles(file));
52
58
  }
53
- } catch (e) {
59
+ } catch {
54
60
  return undefined;
55
61
  }
56
62
  };
57
63
 
58
- // Build namespaces mapping.
59
- export const namespaces = {};
60
- for (const { name, directory } of fetchVariantConfig()) {
61
- namespaces[name] = resolve(_dirname, '../../../../', directory);
64
+ /**
65
+ * Fetches the project machine name from Emulsify configuration.
66
+ * Returns undefined if the config is unavailable or machineName is not set.
67
+ *
68
+ * @returns {string|undefined} Project machine name string, or undefined if not available
69
+ */
70
+ export function getProjectMachineName() {
71
+ try {
72
+ return emulsifyConfig.project.machineName;
73
+ } catch {
74
+ return undefined;
75
+ }
62
76
  }
63
77
 
78
+ // Build namespaces mapping.
79
+ export const namespaces = Object.fromEntries(
80
+ fetchVariantConfig().map(({ name, directory }) => [
81
+ name,
82
+ resolve(_dirname, '../../../../', directory),
83
+ ]),
84
+ );
85
+
64
86
  /**
65
87
  * Configures and extends a standard Twig object.
66
88
  *
@@ -72,6 +94,8 @@ export function setupTwig(twig) {
72
94
  twigDrupal(twig);
73
95
  twigBEM(twig);
74
96
  twigAddAttributes(twig);
97
+ twigInclude(twig);
98
+ twigSource(twig);
75
99
  return twig;
76
100
  }
77
101
 
@@ -1,10 +1,13 @@
1
1
  import { dirname, resolve } from 'path';
2
+ import { createRequire } from 'module';
2
3
  import globImporter from 'node-sass-glob-importer';
3
4
  import _StyleLintPlugin from 'stylelint-webpack-plugin';
4
- import ESLintPlugin from 'eslint-webpack-plugin';
5
+ import webpack from 'webpack';
5
6
  import resolves from '../config/webpack/resolves.js';
6
7
  import emulsifyConfig from '../../../../project.emulsify.json' with { type: 'json' };
7
8
 
9
+ const require = createRequire(import.meta.url);
10
+
8
11
  // Create __filename from import.meta.url without fileURLToPath
9
12
  let _filename = decodeURIComponent(new URL(import.meta.url).pathname);
10
13
 
@@ -54,13 +57,10 @@ class ProjectNameResolverPlugin {
54
57
  (request, resolveContext, callback) => {
55
58
  const requestPath = request.request;
56
59
 
57
- if (
58
- requestPath &&
59
- requestPath.startsWith(`${this.prefix}:`)
60
- ) {
60
+ if (requestPath && requestPath.startsWith(`${this.prefix}:`)) {
61
61
  const newRequestPath = requestPath.replace(
62
62
  `${this.prefix}:`,
63
- `${this.prefix}/`
63
+ `${this.prefix}/`,
64
64
  );
65
65
  const newRequest = {
66
66
  ...request,
@@ -70,14 +70,14 @@ class ProjectNameResolverPlugin {
70
70
  resolver.doResolve(
71
71
  target,
72
72
  newRequest,
73
- `Resolved ${this.prefix} URI: ${resolves.TwigResolve.alias[requestPath]}`,
73
+ `Resolved ${this.prefix} URI`,
74
74
  resolveContext,
75
- callback
75
+ callback,
76
76
  );
77
77
  } else {
78
78
  callback();
79
79
  }
80
- }
80
+ },
81
81
  );
82
82
  }
83
83
  }
@@ -89,77 +89,138 @@ class ProjectNameResolverPlugin {
89
89
  * @returns {object} The updated webpack config.
90
90
  */
91
91
  export default async function ({ config }) {
92
+ config.resolve = config.resolve || {};
93
+ config.plugins = config.plugins || [];
94
+
95
+ config.module = config.module || {};
96
+ config.module.rules = config.module.rules || [];
97
+
98
+ const hasLoader = (rule, loaderName) => {
99
+ if (!rule) {
100
+ return false;
101
+ }
102
+
103
+ if (typeof rule.loader === 'string' && rule.loader.includes(loaderName)) {
104
+ return true;
105
+ }
106
+
107
+ const use = rule.use;
108
+ if (typeof use === 'string') {
109
+ return use.includes(loaderName);
110
+ }
111
+ if (Array.isArray(use)) {
112
+ return use.some((entry) => {
113
+ if (typeof entry === 'string') {
114
+ return entry.includes(loaderName);
115
+ }
116
+ return (
117
+ entry &&
118
+ typeof entry.loader === 'string' &&
119
+ entry.loader.includes(loaderName)
120
+ );
121
+ });
122
+ }
123
+
124
+ return false;
125
+ };
126
+
127
+ const hasRule = (testRegex, loaderName) =>
128
+ config.module.rules.some(
129
+ (rule) =>
130
+ rule &&
131
+ rule.test &&
132
+ String(rule.test) === String(testRegex) &&
133
+ hasLoader(rule, loaderName),
134
+ );
135
+
136
+ const pushRuleOnce = (rule, loaderName) => {
137
+ if (!hasRule(rule.test, loaderName)) {
138
+ config.module.rules.push(rule);
139
+ }
140
+ };
141
+
92
142
  // Alias
93
143
  Object.assign(config.resolve.alias, resolves.TwigResolve.alias);
94
144
 
95
145
  // Twig loader
96
- config.module.rules.push({
97
- /**
98
- * @type {RegExp}
99
- */
100
- test: /\.twig$/,
101
- use: [
102
- {
103
- /**
104
- * Custom loader for svg/spritemap integration.
105
- * @type {string}
106
- */
107
- loader: resolve(_dirname, '../config/webpack/sdc-loader.js'),
108
- options: {
146
+ pushRuleOnce(
147
+ {
148
+ /**
149
+ * @type {RegExp}
150
+ */
151
+ test: /\.twig$/,
152
+ use: [
153
+ {
109
154
  /**
110
- * Name of the Emulsify project for resolving.
155
+ * Custom loader for svg/spritemap integration.
111
156
  * @type {string}
112
157
  */
113
- projectName: emulsifyConfig.project.name,
158
+ loader: resolve(_dirname, '../config/webpack/sdc-loader.js'),
159
+ options: {
160
+ /**
161
+ * Name of the Emulsify project for resolving.
162
+ * @type {string}
163
+ */
164
+ projectName: emulsifyConfig.project.name,
165
+ },
114
166
  },
115
- },
116
- {
117
- /**
118
- * Standard Twig JS loader.
119
- * @type {string}
120
- */
121
- loader: 'twigjs-loader',
122
- },
123
- ],
124
- });
125
-
126
- // SCSS Loader configuration
127
- config.module.rules.push({
128
- test: /\.s[ac]ss$/i,
129
- use: [
130
- 'style-loader',
131
- {
132
- loader: 'css-loader',
133
- options: {
167
+ {
134
168
  /**
135
- * Enable source maps for CSS.
136
- * @type {boolean}
169
+ * Standard Twig JS loader.
170
+ * @type {string}
137
171
  */
138
- sourceMap: true,
172
+ loader: 'twigjs-loader',
139
173
  },
140
- },
141
- {
142
- loader: 'sass-loader',
143
- options: {
144
- sourceMap: true,
145
- sassOptions: {
146
- importer: globImporter(),
174
+ ],
175
+ },
176
+ 'twigjs-loader',
177
+ );
178
+
179
+ // SCSS Loader configuration
180
+ pushRuleOnce(
181
+ {
182
+ test: /\.s[ac]ss$/i,
183
+ use: [
184
+ 'style-loader',
185
+ {
186
+ loader: 'css-loader',
187
+ options: {
188
+ /**
189
+ * Enable source maps for CSS.
190
+ * @type {boolean}
191
+ */
192
+ sourceMap: true,
147
193
  },
148
194
  },
149
- },
150
- ],
151
- });
195
+ {
196
+ loader: 'sass-loader',
197
+ options: {
198
+ sourceMap: true,
199
+ sassOptions: {
200
+ importer: globImporter(),
201
+ },
202
+ },
203
+ },
204
+ ],
205
+ },
206
+ 'sass-loader',
207
+ );
152
208
 
153
209
  // YAML loader
154
- config.module.rules.push({
155
- /**
156
- * @type {RegExp}
157
- */
158
- test: /\.ya?ml$/,
159
- loader: 'js-yaml-loader',
160
- });
210
+ pushRuleOnce(
211
+ {
212
+ /**
213
+ * @type {RegExp}
214
+ */
215
+ test: /\.ya?ml$/,
216
+ loader: 'js-yaml-loader',
217
+ },
218
+ 'js-yaml-loader',
219
+ );
161
220
 
162
- // StyleLint and ESLint plugins
221
+ // Keep style linting in the Storybook webpack build. ESLint runs via the
222
+ // dedicated npm scripts instead, which avoids coupling Storybook to a
223
+ // specific ESLint major version.
163
224
  config.plugins.push(
164
225
  new _StyleLintPlugin({
165
226
  configFile: resolve(projectDir, '../', '.stylelintrc.json'),
@@ -168,10 +229,6 @@ export default async function ({ config }) {
168
229
  failOnError: false,
169
230
  quiet: false,
170
231
  }),
171
- new ESLintPlugin({
172
- context: resolve(projectDir, '../', 'src'),
173
- extensions: ['js'],
174
- }),
175
232
  );
176
233
 
177
234
  // Custom resolver plugin for namespaced imports
@@ -181,13 +238,32 @@ export default async function ({ config }) {
181
238
  }),
182
239
  ];
183
240
 
184
- // Fallback for optional modules
241
+ // Merge fallbacks so we do not clobber Storybook defaults.
185
242
  config.resolve.fallback = {
243
+ ...(config.resolve.fallback || {}),
244
+ process: require.resolve('process/browser'),
186
245
  /**
187
246
  * Prevent resolution of components directory if missing.
188
247
  */
189
248
  '../../../../components': false,
190
249
  };
191
250
 
251
+ // Provide global `process` for browser bundles that pull in node-style libs.
252
+ const hasProcessProvidePlugin = config.plugins.some(
253
+ (plugin) =>
254
+ plugin &&
255
+ plugin.constructor &&
256
+ plugin.constructor.name === 'ProvidePlugin' &&
257
+ plugin.definitions &&
258
+ Object.prototype.hasOwnProperty.call(plugin.definitions, 'process'),
259
+ );
260
+ if (!hasProcessProvidePlugin) {
261
+ config.plugins.push(
262
+ new webpack.ProvidePlugin({
263
+ process: 'process/browser',
264
+ }),
265
+ );
266
+ }
267
+
192
268
  return config;
193
269
  }
@@ -1,7 +1,17 @@
1
1
  export default (api) => {
2
2
  api.cache(true);
3
3
 
4
- const presets = [['minify', { builtIns: false }]];
4
+ const presets = [
5
+ [
6
+ 'minify',
7
+ {
8
+ builtIns: false,
9
+ mangle: {
10
+ reserved: ['Drupal', 'drupalSettings', 'once'],
11
+ },
12
+ },
13
+ ],
14
+ ];
5
15
  const comments = false;
6
16
 
7
17
  return { presets, comments };
@@ -1,7 +1,5 @@
1
1
  // Import ESLint Flat Config and required plugins
2
2
  import js from '@eslint/js';
3
- import babelParser from '@babel/eslint-parser';
4
- import importPlugin from 'eslint-plugin-import';
5
3
  import pluginSecurity from 'eslint-plugin-security';
6
4
  import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
7
5
 
@@ -10,21 +8,12 @@ export default [
10
8
  js.configs.recommended,
11
9
 
12
10
  // Plugin configurations
13
- importPlugin.flatConfigs.recommended,
14
11
  pluginSecurity.configs.recommended,
15
12
  eslintPluginPrettierRecommended,
16
13
 
17
14
  {
18
15
  name: 'emulsify-core-config',
19
16
  languageOptions: {
20
- parser: babelParser,
21
- parserOptions: {
22
- requireConfigFile: false,
23
- babelOptions: {
24
- babelrc: false,
25
- configFile: false,
26
- },
27
- },
28
17
  sourceType: 'module',
29
18
  ecmaVersion: 'latest',
30
19
  globals: {
@@ -43,10 +32,6 @@ export default [
43
32
  'consistent-return': 'off',
44
33
  'no-underscore-dangle': 'off',
45
34
  'max-nested-callbacks': ['warn', 3],
46
- 'import/extensions': 'off',
47
- 'import/no-unresolved': 'off',
48
- 'import/no-extraneous-dependencies': 'warn',
49
- 'import/no-mutable-exports': 'warn',
50
35
  'no-plusplus': ['warn', { allowForLoopAfterthoughts: true }],
51
36
  'no-param-reassign': 'off',
52
37
  'no-prototype-builtins': 'off',
@@ -60,15 +45,5 @@ export default [
60
45
  ],
61
46
  quotes: ['error', 'single'],
62
47
  },
63
-
64
- settings: {
65
- 'import/ignore': ['\\.(scss|less|css)$'],
66
- 'import/resolver': {
67
- node: {
68
- extensions: ['.js', '.jsx'],
69
- moduleDirectory: ['src', 'node_modules'],
70
- },
71
- },
72
- },
73
48
  },
74
49
  ];
@@ -1,4 +1,5 @@
1
1
  import ImageMinimizerPlugin from 'image-minimizer-webpack-plugin';
2
+ import TerserPlugin from 'terser-webpack-plugin';
2
3
 
3
4
  const ImageMinimizer = new ImageMinimizerPlugin({
4
5
  minimizer: {
@@ -12,6 +13,14 @@ const ImageMinimizer = new ImageMinimizerPlugin({
12
13
  },
13
14
  });
14
15
 
16
+ const TerserMinimizer = new TerserPlugin({
17
+ terserOptions: {
18
+ mangle: {
19
+ reserved: ['Drupal', 'drupalSettings', 'once'],
20
+ },
21
+ },
22
+ });
23
+
15
24
  export default {
16
- minimizer: [ImageMinimizer],
25
+ minimizer: [ImageMinimizer, TerserMinimizer],
17
26
  };
@@ -232,6 +232,10 @@ const buildEntries = () => {
232
232
  export default {
233
233
  target: 'web',
234
234
  stats: { errorDetails: true },
235
+ externals: {
236
+ drupal: 'Drupal',
237
+ drupalSettings: 'drupalSettings',
238
+ },
235
239
  entry: buildEntries(),
236
240
  module: {
237
241
  rules: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emulsify/core",
3
- "version": "3.3.2",
3
+ "version": "3.5.0",
4
4
  "description": "Bundled tooling for Storybook development + Webpack Build",
5
5
  "keywords": [
6
6
  "component library",
@@ -37,35 +37,29 @@
37
37
  "husky:pre-commit": "npm run lint",
38
38
  "lint": "npm run lint-js",
39
39
  "lint-fix": "npm run lint-js -- --fix",
40
- "lint-js": "eslint --config config/eslint.config.js --no-error-on-unmatched-pattern ./config ./storybook",
40
+ "lint-js": "eslint --config config/eslint.config.js --no-error-on-unmatched-pattern ./config ./.storybook",
41
41
  "lint-staged": "lint-staged",
42
42
  "prepare": "[ -d '.git' ] && (husky install) || true",
43
43
  "prettier": "prettier --config config/prettierrc.json --ignore-unknown \"**/*.{js,yml,scss,md}\"",
44
44
  "prettier-fix": "prettier --config config/prettierrc.json --write --ignore-unknown \"**/*.{js,yml,scss,md}\"",
45
45
  "semantic-release": "semantic-release --config ./release.config.cjs",
46
- "storybook": "NODE_OPTIONS=--no-deprecation storybook dev --ci -s ../../dist,../../assets/images,../../assets/icons,../../assets/videos -p 6006",
47
- "storybook-build": "storybook build -s ../../dist,../../assets/images,../../assets/icons,../../assets/videos -o .out",
46
+ "storybook": "NODE_OPTIONS=--no-deprecation storybook dev --ci -p 6006",
47
+ "storybook-build": "storybook build -o .out",
48
48
  "storybook-deploy": "storybook-to-ghpages -o .out",
49
49
  "test": "jest --coverage --config ./config/jest.config.js",
50
50
  "twatch": "jest --no-coverage --watch --verbose"
51
51
  },
52
52
  "dependencies": {
53
- "@babel/core": "^7.28.4",
54
- "@babel/eslint-parser": "^7.28.4",
55
- "@babel/preset-env": "^7.28.3",
53
+ "@babel/core": "^7.28.5",
54
+ "@babel/preset-env": "^7.28.5",
56
55
  "@emulsify/cli": "^1.11.4",
57
- "@eslint/js": "^9.35.0",
58
- "@storybook/addon-a11y": "^8.6.14",
59
- "@storybook/addon-actions": "^8.6.14",
60
- "@storybook/addon-essentials": "^8.6.14",
61
- "@storybook/addon-links": "^8.6.14",
62
- "@storybook/addon-styling-webpack": "^1.0.1",
63
- "@storybook/addon-themes": "^8.6.14",
64
- "@storybook/html": "^8.6.14",
65
- "@storybook/html-webpack5": "^8.6.14",
66
- "@storybook/manager-api": "^8.6.14",
67
- "@storybook/preview-api": "^8.6.14",
68
- "@storybook/theming": "^8.6.14",
56
+ "@eslint/js": "^10.0.1",
57
+ "@storybook/addon-a11y": "^9.1.20",
58
+ "@storybook/addon-links": "^9.1.20",
59
+ "@storybook/addon-styling-webpack": "^2.0.0",
60
+ "@storybook/addon-themes": "^9.1.20",
61
+ "@storybook/builder-webpack5": "^9.1.20",
62
+ "@storybook/server-webpack5": "^9.1.20",
69
63
  "add-attributes-twig-extension": "^0.1.0",
70
64
  "autoprefixer": "^10.4.21",
71
65
  "babel-loader": "^10.0.0",
@@ -74,76 +68,73 @@
74
68
  "breakpoint-sass": "^3.0.0",
75
69
  "clean-webpack-plugin": "^4.0.0",
76
70
  "concurrently": "^9.2.1",
77
- "copy-webpack-plugin": "^13.0.1",
71
+ "copy-webpack-plugin": "^14.0.0",
78
72
  "css-loader": "^7.1.1",
79
- "eslint": "^9.35.0",
73
+ "eslint": "^10.1.0",
80
74
  "eslint-config-prettier": "^10.1.8",
81
- "eslint-plugin-import": "^2.32.0",
82
- "eslint-plugin-jest": "^29.0.1",
75
+ "eslint-plugin-jest": "^29.15.1",
83
76
  "eslint-plugin-prettier": "^5.5.4",
84
- "eslint-plugin-security": "^3.0.1",
85
- "eslint-plugin-storybook": "^0.12.0",
86
- "eslint-webpack-plugin": "^5.0.2",
77
+ "eslint-plugin-security": "^4.0.0",
87
78
  "file-loader": "^6.2.0",
88
- "fs-extra": "^11.3.1",
89
- "glob": "^11.0.3",
79
+ "fs-extra": "^11.3.2",
80
+ "glob": "^13.0.6",
90
81
  "graceful-fs": "^4.2.11",
91
82
  "html-webpack-plugin": "^5.6.4",
92
- "image-minimizer-webpack-plugin": "^4.1.4",
83
+ "image-minimizer-webpack-plugin": "^5.0.0",
93
84
  "imagemin": "^9.0.1",
94
85
  "imagemin-jpegtran": "^8.0.0",
95
86
  "imagemin-optipng": "^8.0.0",
96
- "jest": "^30.1.3",
97
- "jest-environment-jsdom": "^30.1.2",
87
+ "jest": "^30.2.0",
88
+ "jest-environment-jsdom": "^30.2.0",
98
89
  "js-yaml": "^4.1.0",
99
90
  "js-yaml-loader": "^1.2.2",
100
91
  "mini-css-extract-plugin": "^2.9.4",
101
92
  "node-sass-glob-importer": "^5.3.3",
102
93
  "normalize.css": "^8.0.1",
103
- "open-cli": "^8.0.0",
104
- "pa11y": "^9.0.0",
94
+ "open-cli": "^9.0.0",
95
+ "pa11y": "^9.0.1",
105
96
  "postcss": "^8.5.6",
106
97
  "postcss-loader": "^8.2.0",
107
98
  "postcss-scss": "^4.0.9",
108
- "ramda": "^0.31.3",
99
+ "ramda": "^0.32.0",
109
100
  "regenerator-runtime": "^0.14.1",
110
- "sass": "^1.92.1",
111
- "sass-loader": "^16.0.5",
112
- "storybook": "^8.6.14",
113
- "style-dictionary": "^5.0.4",
114
- "stylelint": "^16.24.0",
115
- "stylelint-config-standard-scss": "^15.0.1",
101
+ "sass": "^1.93.2",
102
+ "sass-loader": "^16.0.6",
103
+ "storybook": "^9.1.20",
104
+ "style-dictionary": "^5.1.1",
105
+ "stylelint": "^17.7.0",
106
+ "stylelint-config-standard-scss": "^17.0.0",
116
107
  "stylelint-prettier": "^5.0.3",
117
- "stylelint-selector-bem-pattern": "^4.0.1",
118
108
  "stylelint-webpack-plugin": "^5.0.1",
119
- "svg-spritemap-webpack-plugin": "^5.0.1",
109
+ "svg-spritemap-webpack-plugin": "^5.0.3",
110
+ "terser-webpack-plugin": "^5.3.9",
120
111
  "token-transformer": "^0.0.33",
121
112
  "twig-drupal-filters": "^3.2.0",
122
113
  "twig-testing-library": "^1.2.0",
123
114
  "twigjs-loader": "^1.0.3",
124
- "webpack": "^5.101.3",
125
- "webpack-cli": "^6.0.1",
115
+ "webpack": "^5.102.1",
116
+ "webpack-cli": "^7.0.2",
126
117
  "webpack-merge": "^6.0.1",
127
118
  "webpack-remove-empty-scripts": "^1.1.1",
128
119
  "yaml": "^2.8.1"
129
120
  },
130
121
  "devDependencies": {
131
- "@commitlint/cli": "^19.8.1",
132
- "@commitlint/config-conventional": "^19.8.1",
122
+ "@commitlint/cli": "^20.1.0",
123
+ "@commitlint/config-conventional": "^20.0.0",
133
124
  "@semantic-release/changelog": "^6.0.2",
134
125
  "@semantic-release/commit-analyzer": "^13.0.1",
135
126
  "@semantic-release/git": "^10.0.1",
136
- "@semantic-release/github": "^11.0.5",
127
+ "@semantic-release/github": "^12.0.6",
137
128
  "@semantic-release/release-notes-generator": "^14.1.0",
138
129
  "all-contributors-cli": "^6.26.1",
139
130
  "husky": "^9.1.7",
140
- "lint-staged": "^16.1.6",
141
- "semantic-release": "^24.2.7"
131
+ "lint-staged": "^16.2.6",
132
+ "semantic-release": "^25.0.3"
142
133
  },
143
134
  "overrides": {
144
135
  "inflight": "^1.0.7",
145
136
  "graceful-fs": "^4.2.11",
146
- "glob": "^11.0.3",
137
+ "glob": "^13.0.6",
147
138
  "rimraf": "^4.3.0",
148
139
  "source-map-url": "^0.4.1",
149
140
  "source-map-resolve": "^0.6.0",
@@ -24,7 +24,7 @@ module.exports = {
24
24
  }
25
25
  }
26
26
  ],
27
- ['@semantic-release/npm', { npmPublish: false }],
27
+ ['@semantic-release/npm', { npmPublish: true }],
28
28
  '@semantic-release/github'
29
29
  ]
30
30
  }