diff --git a/.storybook/emulsifyTheme.js b/.storybook/emulsifyTheme.js
index bc53751..691a735 100644
--- a/.storybook/emulsifyTheme.js
+++ b/.storybook/emulsifyTheme.js
@@ -1,5 +1,5 @@
// Documentation on theming Storybook: https://storybook.js.org/docs/configurations/theming/
-import { create } from '@storybook/theming';
+import { create } from 'storybook/theming';
export default create({
base: 'dark',
diff --git a/.storybook/main.js b/.storybook/main.js
index 5b5c1d6..e15e7b7 100644
--- a/.storybook/main.js
+++ b/.storybook/main.js
@@ -7,23 +7,24 @@
* @module .storybook/main
*/
-import { resolve } from 'path';
import fs from 'fs';
-import path from 'path';
+import path, { resolve } from 'path';
import { fileURLToPath } from 'url';
import configOverrides from '../../../../config/emulsify-core/storybook/main.js';
+import viteConfig from '../config/vite/vite.config.js';
+import { resolveEnvironment } from '../config/vite/environment.js';
/**
* The full path to the current file (ESM compatible).
* @type {string}
*/
-const __filename = fileURLToPath(import.meta.url);
+const _filename = fileURLToPath(import.meta.url);
/**
* The directory name of the current module file.
* @type {string}
*/
-const __dirname = path.dirname(__filename);
+const _dirname = path.dirname(_filename);
/**
* Safely apply any user-provided overrides or fall back to an empty object.
@@ -41,7 +42,7 @@ const config = {
* @type {string[]}
*/
stories: [
- '../../../../(src|components)/**/*.stories.@(js|jsx|ts|tsx)',
+ '../../../../@(src|components)/**/*.stories.@(js|jsx|ts|tsx)',
],
/**
@@ -59,11 +60,9 @@ const config = {
* @type {string[]}
*/
addons: [
- '../../../@storybook/addon-a11y',
- '../../../@storybook/addon-links',
- '../../../@storybook/addon-essentials',
- '../../../@storybook/addon-themes',
- '../../../@storybook/addon-styling-webpack',
+ '@storybook/addon-a11y',
+ '@storybook/addon-links',
+ '@storybook/addon-themes',
],
/**
@@ -71,16 +70,16 @@ const config = {
* @type {{builder: string, disableTelemetry: boolean}}
*/
core: {
- builder: 'webpack5',
+ builder: '@storybook/builder-vite',
disableTelemetry: true,
},
/**
- * Framework specification for Storybook (HTML + Webpack5).
+ * Framework specification for Storybook (HTML + Vite).
* @type {{name: string, options: object}}
*/
framework: {
- name: '@storybook/html-webpack5',
+ name: '@storybook/react-vite',
options: {},
},
@@ -204,7 +203,7 @@ const config = {
// load external manager-head.html if present
const externalManagerHeadPath = resolve(
- __dirname,
+ _dirname,
'../../../../config/emulsify-core/storybook/manager-head.html'
);
let externalManagerHtml = '';
@@ -213,8 +212,8 @@ const config = {
}
return `${head}
-${inlineStyles}
-${externalManagerHtml}`;
+ ${inlineStyles}
+ ${externalManagerHtml}`;
},
/**
@@ -224,7 +223,7 @@ ${externalManagerHtml}`;
*/
previewHead: (head) => {
const externalHeadPath = resolve(
- __dirname,
+ _dirname,
'../../../../config/emulsify-core/storybook/preview-head.html'
);
@@ -234,7 +233,97 @@ ${externalManagerHtml}`;
}
return `${head}
-${externalHtml}`;
+ ${externalHtml}`;
+ },
+
+ // Storybook specific Vite configuration.
+ async viteFinal(config) {
+ const { mergeConfig } = await import('vite');
+ const env = resolveEnvironment();
+ const baseViteConfig =
+ typeof viteConfig === 'function'
+ ? await viteConfig({ command: 'serve', mode: config?.mode || 'development' })
+ : viteConfig;
+ const existingDefine = (config && config.define) || {};
+ const viteDefine = (baseViteConfig && baseViteConfig.define) || {};
+ const allowList = new Set([
+ ...(config?.server?.fs?.allow || []),
+ env.projectDir,
+ path.resolve(env.projectDir, 'src'),
+ path.resolve(env.projectDir, 'components'),
+ path.resolve(env.projectDir, 'dist'),
+ ]);
+ const assetsInclude = Array.from(
+ new Set([...(config.assetsInclude || []), ...(baseViteConfig.assetsInclude || []), '**/*.twig']),
+ );
+ const toRootRel = (abs) => {
+ const rel = path.relative(env.projectDir, abs);
+ const normalized = rel.split(path.sep).join('/');
+ return `/${normalized}`.replace(/\/{2,}/g, '/');
+ };
+ const candidateRoots =
+ env.structureOverrides && Array.isArray(env.structureRoots) && env.structureRoots.length
+ ? env.structureRoots
+ : env.srcDir
+ ? [path.join(env.srcDir, 'components')]
+ : [];
+ const rootRels = candidateRoots.map(toRootRel);
+ const globBases = rootRels.length ? rootRels : ['/src/components', '/components'];
+ const twigGlobImports = `mergeGlobMaps([\n${globBases
+ .map((base) => ` import.meta.glob('${base}/**/*.twig', { eager: true })`)
+ .join(',\n')}\n])`;
+
+ return mergeConfig(config, {
+ ...baseViteConfig,
+ define: {
+ ...viteDefine,
+ ...existingDefine,
+ __EMULSIFY_ENV__: JSON.stringify(env),
+ },
+ server: {
+ ...(baseViteConfig?.server || {}),
+ fs: {
+ allow: Array.from(allowList),
+ },
+ },
+ assetsInclude,
+ plugins: [
+ ...(baseViteConfig?.plugins || []),
+ {
+ name: 'emulsify-inject-twig-globs',
+ enforce: 'pre',
+ transform(code, id) {
+ const cleanId = id.split('?')[0];
+ if (!cleanId.endsWith('/.storybook/polyfills/twig-resolver.js')) return null;
+ const replaced = code.replace(
+ /__EMULSIFY_TWIG_GLOB_IMPORTS__/g,
+ twigGlobImports,
+ );
+ return replaced === code ? null : replaced;
+ },
+ },
+ ],
+ esbuild: {
+ 'jsx': 'automatic',
+ loader: 'jsx',
+ include: /.*\.jsx?$/,
+ exclude: [],
+ },
+ optimizeDeps: {
+ include: [
+ 'path',
+ 'twig',
+ 'twig-drupal-filters',
+ 'bem-twig-extension',
+ 'add-attributes-twig-extension',
+ ],
+ esbuildOptions: {
+ loader: {
+ '.js': 'jsx',
+ },
+ },
+ },
+ })
},
// Merge in user overrides without modifying original logic
diff --git a/.storybook/manager.js b/.storybook/manager.js
index de66b5b..ed903ca 100644
--- a/.storybook/manager.js
+++ b/.storybook/manager.js
@@ -1,6 +1,6 @@
// .storybook/manager.js
-import { addons } from '@storybook/manager-api';
+import { addons } from 'storybook/manager-api';
import emulsifyTheme from './emulsifyTheme';
/**
@@ -42,4 +42,3 @@ import('../../../../config/emulsify-core/storybook/theme')
theme: emulsifyTheme,
});
});
-
\ No newline at end of file
diff --git a/.storybook/polyfills/twig-include.js b/.storybook/polyfills/twig-include.js
new file mode 100644
index 0000000..d715465
--- /dev/null
+++ b/.storybook/polyfills/twig-include.js
@@ -0,0 +1,36 @@
+
+import resolveTemplate from './twig-resolver.js';
+
+/**
+ * Twig `include()` polyfill.
+ * Mirrors Drupal behaviour inside Storybook.
+ * @param {string} templateName
+ * @param {Object} [variables]
+ * @param {boolean} [withContext=false]
+ * @return {string}
+ */
+function twigInclude(Twig) {
+ Twig.extendFunction('include', (...args) => {
+ let [templateName, variables = {}, withContext = false] = args;
+ if (typeof withContext !== 'boolean' && variables && typeof variables.with_context !== 'undefined') {
+ withContext = variables.with_context;
+ delete variables.with_context;
+ }
+
+ try {
+ const templateFn = resolveTemplate(templateName);
+ if (!templateFn) return '';
+
+ const finalContext = withContext && typeof this === 'object'
+ ? { ...(this.context || {}), ...variables }
+ : variables;
+
+ return templateFn(finalContext);
+ } catch (err) {
+ console.error(`Twig include() failed for: ${templateName}`, err);
+ return '';
+ }
+ });
+};
+
+export default twigInclude;
diff --git a/.storybook/polyfills/twig-resolver.js b/.storybook/polyfills/twig-resolver.js
new file mode 100644
index 0000000..085ff9b
--- /dev/null
+++ b/.storybook/polyfills/twig-resolver.js
@@ -0,0 +1,129 @@
+import { getProjectMachineName } from '../utils';
+
+const namespace = getProjectMachineName();
+
+/**
+ * Build a dynamic module map of Twig files from all possible component roots.
+ * We rely on __EMULSIFY_ENV__ injected in .storybook/main.js via viteFinal(),
+ * using the same “structure overrides / roots” logic you use in environment.js.
+ */
+const ENV = (typeof __EMULSIFY_ENV__ !== 'undefined' && __EMULSIFY_ENV__) || {};
+
+// Determine candidate roots: prefer structure overrides, otherwise src/components.
+const candidateRoots = Array.isArray(ENV?.structureRoots) && ENV?.structureOverrides && ENV.structureRoots.length
+ ? ENV.structureRoots
+ : (ENV?.srcDir ? [`${ENV.srcDir}/components`] : []);
+
+/**
+ * Convert an absolute path to a Vite project-root-relative path, prefixed with "/".
+ * Keys produced by import.meta.glob() will use these forms.
+ * @param {string} abs
+ * @returns {string}
+ */
+function toRootRel(abs) {
+ if (!abs) return '';
+ const projectDir = ENV?.projectDir || '';
+ if (projectDir && abs.startsWith(projectDir)) {
+ const rel = abs.slice(projectDir.length);
+ return rel.startsWith('/') ? rel : `/${rel}`;
+ }
+ // Fall back to assuming it's already project-root-relative-ish.
+ return abs.startsWith('/') ? abs : `/${abs}`;
+}
+
+// Build globs for each candidate root. We’ll eagerly import all Twig modules.
+const rootRels = candidateRoots.map(toRootRel);
+
+// Vite doesn’t support an array directly in a single import.meta.glob(),
+// so merge multiple glob maps into one.
+function mergeGlobMaps(maps) {
+ return Object.assign({}, ...maps);
+}
+
+// Typical component layouts we want to support:
+// - Nested component folders: /root/thing/thing.twig
+// - Flat component files: /root/thing.twig
+// We pre-load everything under each root so resolution is O(1).
+const twigModules = mergeGlobMaps(
+ rootRels.flatMap((base) => [
+ import.meta.glob(`${base}/**/*.twig`, { eager: true }),
+ ])
+);
+
+// Helper: generate likely keys for a given component “part” under every root.
+// We try the canonical “part/part.twig”, then “part.twig”.
+function candidateKeysForPart(part) {
+ const keys = [];
+ for (const base of rootRels) {
+ keys.push(`${base}/${part}/${part}.twig`);
+ keys.push(`${base}/${part}.twig`);
+ }
+ return keys;
+}
+
+/**
+ * Resolve template identifier to compiled Twig function.
+ * Supports: @component.twig, namespace:component, @namespace/component, namespace/component
+ * @param {string} name Template identifier
+ * @returns {Function|undefined} Compiled function or noop
+ */
+function resolveTemplate(name) {
+ // namespace:icon, @namespace/icon.twig
+ if (name.startsWith(`${namespace}:`) || name.startsWith(`@${namespace}/`)) {
+ const part = name.startsWith(`${namespace}:`)
+ ? name.split(':')[1]
+ : name.replace(new RegExp(`^@?${namespace}/`), '').replace(/\.twig$/, '');
+
+ const candidates = candidateKeysForPart(part);
+ for (const key of candidates) {
+ const mod = twigModules[key];
+ if (mod) {
+ return mod.default ?? mod;
+ }
+ }
+
+ // eslint-disable-next-line no-console
+ console.error(`Cannot resolve Twig component for '${name}'. Tried: ${candidates.join(', ')}`);
+ }
+
+ // @icon.twig → icon/icon.twig (fallback to icon.twig)
+ if (name.startsWith('@') && name.endsWith('.twig')) {
+ const part = name.slice(1, -5); // remove leading @ and trailing .twig
+ const candidates = candidateKeysForPart(part);
+ for (const key of candidates) {
+ const mod = twigModules[key];
+ if (mod) {
+ return mod.default ?? mod;
+ }
+ }
+ // eslint-disable-next-line no-console
+ console.error(`Cannot resolve Twig shorthand template '${name}'. Tried: ${candidates.join(', ')}`);
+ }
+
+ // namespace/icon.twig via alias-like usage (without @)
+ if (name.startsWith(`${namespace}/`)) {
+ const part = name.replace(new RegExp(`^${namespace}/`), '').replace(/\.twig$/, '');
+ const candidates = candidateKeysForPart(part);
+ for (const key of candidates) {
+ const mod = twigModules[key];
+ if (mod) {
+ return mod.default ?? mod;
+ }
+ }
+ // eslint-disable-next-line no-console
+ console.error(`Cannot resolve Twig alias template '${name}'. Tried: ${candidates.join(', ')}`);
+ }
+
+ // Final attempt: direct key access if caller passed an exact glob key.
+ const direct = twigModules[name];
+ if (direct) {
+ return direct.default ?? direct;
+ }
+
+ // Vite environment: avoid require() fallback; return a safe noop.
+ // eslint-disable-next-line no-console
+ console.error(`Cannot resolve Twig template '${name}'`);
+ return () => '';
+}
+
+export default resolveTemplate;
diff --git a/.storybook/polyfills/twig-source.js b/.storybook/polyfills/twig-source.js
new file mode 100644
index 0000000..185f11b
--- /dev/null
+++ b/.storybook/polyfills/twig-source.js
@@ -0,0 +1,54 @@
+import { getProjectMachineName } from '../utils';
+
+const namespace = getProjectMachineName();
+
+// Constants used by the `source()` polyfill.
+const PUBLIC_ASSET_BASE = (typeof window !== 'undefined' && window.location && window.location.hostname && window.location.hostname.endsWith('github.io'))
+ ? `/${namespace}/assets/`
+ : '/assets/';
+
+const INLINE_ASSET_EXTS = new Set(['svg', 'html', 'twig', 'css', 'js', 'json', 'txt', 'md']);
+const IMAGE_ASSET_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif']);
+
+/**
+ * Twig `source()` polyfill.
+ * Returns an
tag or URL for @assets paths.
+ * @param {string} assetPath
+ * @return {string}
+ */
+function twigSource(Twig) {
+ Twig.extendFunction('source', (assetPath) => {
+ if (typeof assetPath !== 'string') return '';
+
+ // Strip Drupal-style alias and extract file extension.
+ const relPath = assetPath.replace(/^@assets\//, '');
+ const extension = relPath.split('.').pop().toLowerCase();
+
+ // Inline raw content for textual assets.
+ if (INLINE_ASSET_EXTS.has(extension)) {
+ try {
+ const xhr = new XMLHttpRequest();
+ xhr.open('GET', `${PUBLIC_ASSET_BASE}${relPath}`, false); // synchronous
+ xhr.send(null);
+ if (xhr.status >= 200 && xhr.status < 300) {
+ return xhr.responseText;
+ }
+ // eslint-disable-next-line no-console
+ console.error(`source(): ${xhr.status} while fetching ${relPath}`);
+ } catch (err) {
+ // eslint-disable-next-line no-console
+ console.error(`source(): failed to fetch ${relPath}`, err);
+ }
+ }
+
+ // Auto-render raster images.
+ if (IMAGE_ASSET_EXTS.has(extension)) {
+ return `
`;
+ }
+
+ // Fallback: return public URL.
+ return `${PUBLIC_ASSET_BASE}${relPath}`;
+ });
+};
+
+export default twigSource;
diff --git a/.storybook/preview.js b/.storybook/preview.js
index 0f8eeb2..0b3e3c6 100644
--- a/.storybook/preview.js
+++ b/.storybook/preview.js
@@ -1,8 +1,8 @@
// .storybook/preview.js
-import { useEffect } from '@storybook/preview-api';
-import Twig from 'twig';
-import { setupTwig, fetchCSSFiles } from './utils.js';
import { getRules } from 'axe-core';
+import { useEffect } from 'storybook/preview-api';
+import Twig from 'twig';
+import { fetchCSSFiles, setupTwig } from './utils.js';
/**
* External override parameters loaded from project config file, if present.
diff --git a/.storybook/utils.js b/.storybook/utils.js
index 2e67914..a215a99 100644
--- a/.storybook/utils.js
+++ b/.storybook/utils.js
@@ -1,18 +1,9 @@
-import { resolve, dirname } from 'path';
-import twigDrupal from 'twig-drupal-filters';
-import twigBEM from 'bem-twig-extension';
import twigAddAttributes from 'add-attributes-twig-extension';
+import twigBEM from 'bem-twig-extension';
+import twigDrupal from 'twig-drupal-filters';
import emulsifyConfig from '../../../../project.emulsify.json' with { type: 'json' };
-
-// Create __filename from import.meta.url without fileURLToPath
-let _filename = decodeURIComponent(new URL(import.meta.url).pathname);
-
-// On Windows, remove the leading slash (e.g. "/C:/path" -> "C:/path")
-if (process.platform === 'win32' && _filename.startsWith('/')) {
- _filename = _filename.slice(1);
-}
-
-const _dirname = dirname(_filename);
+import twigInclude from './polyfills/twig-include';
+import twigSource from './polyfills/twig-source';
/**
* Fetches project-based variant configuration. If no such configuration
@@ -42,24 +33,32 @@ const fetchVariantConfig = () => {
const fetchCSSFiles = () => {
try {
// Load all CSS files from 'dist'.
- const cssFiles = require.context('../../../../dist', true, /\.css$/);
- cssFiles.keys().forEach((file) => cssFiles(file));
+ const cssFiles = import.meta.glob('../../../../dist/**/*.css', { eager: true });
+ Object.values(cssFiles).forEach((css) => css);
// Load all CSS files from 'components' for 'drupal' platform.
if (emulsifyConfig.project.platform === 'drupal') {
- const drupalCSSFiles = require.context('../../../../components', true, /\.css$/);
- drupalCSSFiles.keys().forEach((file) => drupalCSSFiles(file));
+ const drupalCSSFiles = import.meta.glob('../../../../components/**/*.css', { eager: true });
+ Object.values(drupalCSSFiles).forEach((css) => css);
}
} catch (e) {
return undefined;
}
};
-// Build namespaces mapping.
-export const namespaces = {};
-for (const { name, directory } of fetchVariantConfig()) {
- namespaces[name] = resolve(_dirname, '../../../../', directory);
-}
+/**
+ * Fetches the project machine name from Emulsify configuration.
+ * Returns undefined if the config is unavailable or machineName is not set.
+ *
+ * @returns {string|undefined} Project machine name string, or undefined if not available
+ */
+export function getProjectMachineName() {
+ try {
+ return emulsifyConfig.project.machineName;
+ } catch (e) {
+ return undefined;
+ }
+};
/**
* Configures and extends a standard Twig object.
@@ -72,6 +71,8 @@ export function setupTwig(twig) {
twigDrupal(twig);
twigBEM(twig);
twigAddAttributes(twig);
+ twigInclude(twig);
+ twigSource(twig);
return twig;
}
diff --git a/.storybook/webpack.config.js b/.storybook/webpack.config.js
deleted file mode 100644
index 04f31d7..0000000
--- a/.storybook/webpack.config.js
+++ /dev/null
@@ -1,193 +0,0 @@
-import { dirname, resolve } from 'path';
-import globImporter from 'node-sass-glob-importer';
-import _StyleLintPlugin from 'stylelint-webpack-plugin';
-import ESLintPlugin from 'eslint-webpack-plugin';
-import resolves from '../config/webpack/resolves.js';
-import emulsifyConfig from '../../../../project.emulsify.json' with { type: 'json' };
-
-// Create __filename from import.meta.url without fileURLToPath
-let _filename = decodeURIComponent(new URL(import.meta.url).pathname);
-
-// On Windows, remove the leading slash (e.g. "/C:/path" -> "C:/path")
-if (process.platform === 'win32' && _filename.startsWith('/')) {
- _filename = _filename.slice(1);
-}
-
-/**
- * Directory name of the current file.
- * @type {string}
- */
-const _dirname = dirname(_filename);
-
-/**
- * Absolute path to the project root directory.
- * @type {string}
- */
-const projectDir = resolve(_dirname, '../../../../..');
-
-/**
- * Webpack plugin to resolve custom namespace imports.
- * Transforms `:` into `/` paths.
- */
-class ProjectNameResolverPlugin {
- /**
- * @param {object} options - Plugin options.
- * @param {string} options.projectName - Prefix for the project namespace.
- */
- constructor(options = {}) {
- this.prefix = options.projectName;
- }
-
- /**
- * Apply the webpack resolver hook.
- * @param {object} resolver - The webpack resolver instance.
- */
- apply(resolver) {
- const target = resolver.ensureHook('resolve');
- resolver.getHook('before-resolve').tapAsync(
- 'ProjectNameResolverPlugin',
- /**
- * @param {object} request - The resolve request object.
- * @param {object} resolveContext - Context for resolving.
- * @param {Function} callback - Callback to continue resolution.
- */
- (request, resolveContext, callback) => {
- const requestPath = request.request;
-
- if (
- requestPath &&
- requestPath.startsWith(`${this.prefix}:`)
- ) {
- const newRequestPath = requestPath.replace(
- `${this.prefix}:`,
- `${this.prefix}/`
- );
- const newRequest = {
- ...request,
- request: newRequestPath,
- };
-
- resolver.doResolve(
- target,
- newRequest,
- `Resolved ${this.prefix} URI: ${resolves.TwigResolve.alias[requestPath]}`,
- resolveContext,
- callback
- );
- } else {
- callback();
- }
- }
- );
- }
-}
-
-/**
- * Export a function to customize the Webpack config for Storybook.
- * @param {object} param0 - The Storybook configuration object.
- * @param {object} param0.config - The existing webpack config to modify.
- * @returns {object} The updated webpack config.
- */
-export default async function ({ config }) {
- // Alias
- Object.assign(config.resolve.alias, resolves.TwigResolve.alias);
-
- // Twig loader
- config.module.rules.push({
- /**
- * @type {RegExp}
- */
- test: /\.twig$/,
- use: [
- {
- /**
- * Custom loader for svg/spritemap integration.
- * @type {string}
- */
- loader: resolve(_dirname, '../config/webpack/sdc-loader.js'),
- options: {
- /**
- * Name of the Emulsify project for resolving.
- * @type {string}
- */
- projectName: emulsifyConfig.project.name,
- },
- },
- {
- /**
- * Standard Twig JS loader.
- * @type {string}
- */
- loader: 'twigjs-loader',
- },
- ],
- });
-
- // SCSS Loader configuration
- config.module.rules.push({
- test: /\.s[ac]ss$/i,
- use: [
- 'style-loader',
- {
- loader: 'css-loader',
- options: {
- /**
- * Enable source maps for CSS.
- * @type {boolean}
- */
- sourceMap: true,
- },
- },
- {
- loader: 'sass-loader',
- options: {
- sourceMap: true,
- sassOptions: {
- importer: globImporter(),
- },
- },
- },
- ],
- });
-
- // YAML loader
- config.module.rules.push({
- /**
- * @type {RegExp}
- */
- test: /\.ya?ml$/,
- loader: 'js-yaml-loader',
- });
-
- // StyleLint and ESLint plugins
- config.plugins.push(
- new _StyleLintPlugin({
- configFile: resolve(projectDir, '../', '.stylelintrc.json'),
- context: resolve(projectDir, '../', 'src'),
- files: '**/*.scss',
- failOnError: false,
- quiet: false,
- }),
- new ESLintPlugin({
- context: resolve(projectDir, '../', 'src'),
- extensions: ['js'],
- }),
- );
-
- // Custom resolver plugin for namespaced imports
- config.resolve.plugins = [
- new ProjectNameResolverPlugin({
- projectName: emulsifyConfig.project.name,
- }),
- ];
-
- // Fallback for optional modules
- config.resolve.fallback = {
- /**
- * Prevent resolution of components directory if missing.
- */
- '../../../../components': false,
- };
-
- return config;
-}
diff --git a/README.md b/README.md
index d56ebea..2cd0e2d 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
An open-source toolset for creating and implementing design systems.
-**Emulsify Core** provides a [Storybook](https://storybook.js.org/) component library and a [Webpack](https://webpack.js.org/) development environment. It is meant to make project setup and ongoing development easier by bundling all necessary configuration and providing it as an extendable package for your theme or standalone project.
+**Emulsify Core** provides a [Storybook](https://storybook.js.org/) component library and a [Vite](https://vite.dev/) development environment. It is meant to make project setup and ongoing development easier by bundling all necessary configuration and providing it as an extendable package for your theme or standalone project.
## Installation and usage
Installation and configuration is setup by the provided base theme project(s). As of this writing, Emulsify Drupal is the only base theme project [with this integration](https://github.com/emulsify-ds/emulsify-drupal/blob/main/whisk/package.json#L36).
diff --git a/config/vite/entries.js b/config/vite/entries.js
new file mode 100644
index 0000000..eda2ea8
--- /dev/null
+++ b/config/vite/entries.js
@@ -0,0 +1,326 @@
+/**
+ * @file Entries map builder for Vite/Rollup.
+ *
+ * Builds a keyed input map (for `build.rollupOptions.input`) where the map key
+ * encodes the final folder inside the Vite outDir (default `dist/`).
+ *
+ * Modern projects:
+ * - Global/base assets → "global/..."
+ * - Component assets → "components/..." (or mirrored to ./components when Drupal)
+ * - SDC=true removes the injected "/css" or "/js" bucket
+ *
+ * Component Structure Overrides projects (project.emulsify.json: variant.structureImplementations):
+ * - **Only** compile JS/SCSS.
+ * - JS → "js/"
+ * - CSS → "css/"
+ * - No Twig/assets copying here (handled in plugins and disabled for Component Structure Overrides).
+ * - cl-* / sb-* SCSS → "storybook/"
+ */
+
+import fs from 'fs';
+import { resolve, sep } from 'path';
+import { globSync } from 'glob';
+
+/** Normalize filesystem paths to POSIX for Rollup keys. */
+export const toPosix = (p) => p.split(sep).join('/');
+
+/** Remove characters that would confuse Rollup naming or file systems. */
+export const sanitizePath = (s) => s.replace(/[^a-zA-Z0-9/_-]/g, '');
+
+/** Replace last slash with an injected subdir (e.g., '/css/' or '/js/'). */
+export function replaceLastSlash(str, replacement) {
+ const i = str.lastIndexOf('/');
+ if (i === -1) return str;
+ return str.slice(0, i) + replacement + str.slice(i + 1);
+}
+
+/**
+ * @typedef {Object} BuildContext
+ * @property {string} projectDir
+ * @property {string} srcDir
+ * @property {boolean} srcExists
+ * @property {boolean} isDrupal - kept for downstream logic parity
+ * @property {boolean} SDC
+ * @property {boolean} structureOverrides
+ * @property {string[]} [structureRoots]
+ */
+
+/* -------------------------------------------------------------------------- */
+/* Patterns */
+/* -------------------------------------------------------------------------- */
+
+/**
+ * Create all glob patterns for modern (non-legacy) flow.
+ * @param {BuildContext} ctx
+ * @returns {{
+ * BaseScssPattern: string,
+ * ComponentScssPattern: string,
+ * ComponentLibraryScssPattern: string,
+ * BaseJsPattern: string,
+ * ComponentJsPattern: string,
+ * SpritePattern: string
+ * }}
+ */
+export function makePatterns(ctx) {
+ const { projectDir, srcDir, srcExists } = ctx;
+
+ // SCSS
+ const BaseScssPattern = srcExists
+ ? resolve(srcDir, '!(components|util)/**/!(_*|cl-*|sb-*).scss')
+ : '';
+ const ComponentScssPattern = srcExists
+ ? resolve(srcDir, 'components/**/!(_*|cl-*|sb-*).scss')
+ : resolve(srcDir, '**/!(_*|cl-*|sb-*).scss');
+ const ComponentLibraryScssPattern = resolve(srcDir, '**/*{cl-*,sb-*}.scss');
+
+ // JS
+ const BaseJsPattern = srcExists
+ ? resolve(
+ srcDir,
+ '!(components|util)/**/!(*.stories|*.component|*.min|*.test).js',
+ )
+ : '';
+ const ComponentJsPattern = srcExists
+ ? resolve(srcDir, 'components/**/!(*.stories|*.component|*.min|*.test).js')
+ : resolve(srcDir, '**/!(*.stories|*.component|*.min|*.test).js');
+
+ // Icons (not used here but preserved for parity)
+ const SpritePattern = resolve(projectDir, 'assets/icons/**/*.svg');
+
+ return {
+ BaseScssPattern,
+ ComponentScssPattern,
+ ComponentLibraryScssPattern,
+ BaseJsPattern,
+ ComponentJsPattern,
+ SpritePattern,
+ };
+}
+
+/* -------------------------------------------------------------------------- */
+/* Utilities */
+/* -------------------------------------------------------------------------- */
+
+/**
+ * Safe map setter that avoids prototype pollution keys.
+ * @param {Record} map
+ * @param {string} key
+ * @param {string} value
+ */
+function safeSetKey(map, key, value) {
+ const forbidden = ['__proto__', 'prototype', 'constructor'];
+ if (!key || forbidden.some((bad) => key.includes(bad))) return;
+ map[key] = value; // eslint-disable-line security/detect-object-injection
+}
+
+/**
+ * Relativize path from base directory (POSIX).
+ * @param {string} abs
+ * @param {string} base
+ */
+function relFrom(abs, base) {
+ const posixAbs = toPosix(abs);
+ const posixBase = toPosix(base).replace(/\/$/, '');
+ const needle = `${posixBase}/`;
+ return posixAbs.startsWith(needle) ? posixAbs.slice(needle.length) : posixAbs;
+}
+
+/** Insert "/css|js" bucket unless SDC=true; strip extension. */
+function injectBucket(rel, bucket, SDC) {
+ const withoutExt = rel.replace(/\.(scss|js)$/i, '');
+ if (SDC) {
+ // When SDC=true we avoid a bucket folder. Add a suffix for CSS to avoid collisions with JS.
+ return bucket === 'css' ? `${withoutExt}__style` : withoutExt;
+ }
+ return replaceLastSlash(rel, `/${bucket}/`).replace(/\.(scss|js)$/i, '');
+}
+
+/* -------------------------------------------------------------------------- */
+/* Inputs builder */
+/* -------------------------------------------------------------------------- */
+
+/**
+ * Build the Rollup/Vite input map.
+ *
+ * Keys are paths **relative to outDir**, without extensions. Examples:
+ * - "global/layout/css/layout"
+ * - "components/accordion/js/accordion" (or without "/js" when SDC=true)
+ *
+ * For Component Structure Overrides (variant.structureImplementations present),
+ * only JS/CSS keys are produced under "js/**" and "css/**".
+ *
+ * @param {BuildContext} ctx
+ * @param {ReturnType} patterns
+ * @returns {Record}
+ */
+export function buildInputs(ctx, patterns) {
+ const {
+ projectDir,
+ srcDir,
+ SDC,
+ structureOverrides,
+ structureRoots = [],
+ } = ctx;
+
+ /** @type {Record} */
+ const inputs = {};
+
+ /**
+ * Add a key/file pair into the inputs map safely (sanitized + POSIX).
+ * @param {string} key
+ * @param {string} abs
+ */
+ const add = (key, abs) => {
+ const clean = sanitizePath(toPosix(key)).replace(/^\/+/, '');
+ if (!clean) return;
+ safeSetKey(inputs, clean, abs);
+ };
+
+ /* ------------------------------------------------------------------------ */
+ /* STRUCTURE OVERRIDES BRANCH */
+ /* ------------------------------------------------------------------------ */
+ if (structureOverrides && structureRoots.length) {
+ // Gather *.js and *.scss from each declared variant root directory.
+ const jsFiles = [];
+ const scssFiles = [];
+ const storybookScss = [];
+
+ for (const rootAbs of structureRoots) {
+ const jsGlob = resolve(
+ rootAbs,
+ '**/!(*.stories|*.component|*.min|*.test).js',
+ );
+ const scssGlob = resolve(rootAbs, '**/!(_*|cl-*|sb-*).scss');
+ const clSbGlob = resolve(rootAbs, '**/*{cl-*,sb-*}.scss');
+
+ jsFiles.push(...globSync(toPosix(jsGlob)));
+ scssFiles.push(...globSync(toPosix(scssGlob)));
+ storybookScss.push(...globSync(toPosix(clSbGlob)));
+ }
+
+ // JS → dist/js/
+ for (const file of jsFiles) {
+ // Compute path relative to the top-level `components/` folder if present,
+ // else relative to the project root as a fallback.
+ const relFromProj = relFrom(file, projectDir);
+ const relFromComponents = relFromProj.includes('components/')
+ ? relFromProj.split('components/')[1]
+ : relFromProj;
+
+ const outKey = `js/${relFromComponents.replace(/\.js$/i, '')}`;
+ add(outKey, file);
+ }
+
+ // CSS → dist/css/
+ for (const file of scssFiles) {
+ const relFromProj = relFrom(file, projectDir);
+ const relFromComponents = relFromProj.includes('components/')
+ ? relFromProj.split('components/')[1]
+ : relFromProj;
+
+ const outKey = `css/${relFromComponents.replace(/\.scss$/i, '')}`;
+ add(outKey, file);
+ }
+
+ // Storybook/CL styles → dist/storybook/
+ for (const file of storybookScss) {
+ const relFromProj = relFrom(file, projectDir).replace(/\.scss$/i, '');
+ const outKey = `storybook/${relFromProj}`;
+ add(outKey, file);
+ }
+
+ return inputs;
+ }
+
+ /* ------------------------------------------------------------------------ */
+ /* MODERN BRANCH (existing behavior preserved) */
+ /* ------------------------------------------------------------------------ */
+ const {
+ BaseJsPattern,
+ ComponentJsPattern,
+ BaseScssPattern,
+ ComponentScssPattern,
+ ComponentLibraryScssPattern,
+ } = patterns;
+
+ const componentRoot = 'components'; // keys are under "components/..." (plugins may mirror)
+
+ // Global JS
+ if (BaseJsPattern) {
+ for (const file of globSync(toPosix(BaseJsPattern))) {
+ const rel = relFrom(file, srcDir);
+ const key = `global/${injectBucket(rel, 'js', SDC)}`;
+ add(key, file);
+ }
+ }
+
+ // Component JS
+ for (const file of globSync(toPosix(ComponentJsPattern))) {
+ const posix = toPosix(file);
+ const idx = posix.indexOf('/components/');
+ const after =
+ idx !== -1
+ ? posix.slice(idx + '/components/'.length)
+ : relFrom(file, srcDir);
+ const key = `${componentRoot}/${injectBucket(`components/${after}`, 'js', SDC).replace(/^components\//, '')}`;
+ add(key, file);
+ }
+
+ // Global SCSS
+ if (BaseScssPattern) {
+ for (const file of globSync(toPosix(BaseScssPattern))) {
+ const rel = relFrom(file, srcDir);
+ const key = `global/${injectBucket(rel, 'css', SDC)}`;
+ add(key, file);
+ }
+ }
+
+ // Component SCSS
+ for (const file of globSync(toPosix(ComponentScssPattern))) {
+ const posix = toPosix(file);
+ const idx = posix.indexOf('/components/');
+ const after =
+ idx !== -1
+ ? posix.slice(idx + '/components/'.length)
+ : relFrom(file, srcDir);
+ const key = `${componentRoot}/${injectBucket(`components/${after}`, 'css', SDC).replace(/^components\//, '')}`;
+ add(key, file);
+ }
+
+ // Storybook/CL SCSS
+ for (const file of globSync(toPosix(ComponentLibraryScssPattern))) {
+ const rel = relFrom(file, srcDir).replace(/\.scss$/i, '');
+ add(`storybook/${rel}`, file);
+ }
+
+ return inputs;
+}
+
+/**
+ * Convenience wrapper that infers `srcDir` and returns an inputs map.
+ * @param {string} projectDir
+ * @param {boolean} [isDrupal=false]
+ * @param {boolean} [SDC=false]
+ * @returns {Record}
+ */
+export function buildInputsFromProject(
+ projectDir,
+ isDrupal = false,
+ SDC = false,
+) {
+ const srcPath = resolve(projectDir, 'src');
+ const srcExists = fs.existsSync(srcPath);
+ const srcDir = srcExists ? srcPath : resolve(projectDir, 'components');
+
+ const ctx = {
+ projectDir,
+ srcDir,
+ srcExists,
+ isDrupal,
+ SDC,
+ structureOverrides: false,
+ structureRoots: [],
+ };
+ const patterns = makePatterns(ctx);
+ return buildInputs(ctx, patterns);
+}
diff --git a/config/vite/environment.js b/config/vite/environment.js
new file mode 100644
index 0000000..f512fe7
--- /dev/null
+++ b/config/vite/environment.js
@@ -0,0 +1,138 @@
+/**
+ * @file Environment resolution for Emulsify + Vite.
+ *
+ * Reads project settings and exposes a normalized “env” object used by
+ * entries, plugins, and the Vite config.
+ *
+ * Highlights:
+ * - `platform`: from env var or project.emulsify.json (default "generic").
+ * - `SDC`: boolean from project.emulsify.json `project.singleDirectoryComponents`.
+ * - `legacyVariant`: true when `variant.structureImplementations` exists and is non-empty.
+ * - `variantRoots`: array of directories from `variant.structureImplementations`.
+ */
+
+import fs from 'fs';
+import { resolve, normalize, sep } from 'path';
+
+/**
+ * Ensure an absolute path stays inside the project directory.
+ *
+ * @param {string} projectDir - Absolute project root.
+ * @param {string} candidate - Path to validate (absolute or relative).
+ * @returns {string|null} A safe absolute path, or null if outside projectDir.
+ */
+function coerceToProjectPath(projectDir, candidate) {
+ const absProject = resolve(projectDir);
+ const absCandidate = resolve(projectDir, candidate);
+ const inProject =
+ absCandidate.startsWith(absProject + sep) || absCandidate === absProject;
+ return inProject ? absCandidate : null;
+}
+
+/**
+ * Safe existence check (guards path is inside project root).
+ *
+ * NOTE: Using this wrapper avoids sprinkling fs.* calls over non-literal paths.
+ * If eslint still flags it, it’s one narrow, justified place to disable.
+ *
+ * @param {string} absPath
+ * @param {string} projectDir
+ */
+function safeExistsSync(absPath, projectDir) {
+ const safe = coerceToProjectPath(projectDir, absPath);
+ if (!safe) return false;
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
+ return fs.existsSync(safe);
+}
+
+/**
+ * Safe JSON reader (only for known, in-repo files).
+ *
+ * @param {string} projectDir
+ * @param {string} relFilename
+ * @returns {any|null}
+ */
+function safeReadJson(projectDir, relFilename) {
+ const safe = coerceToProjectPath(projectDir, relFilename);
+ if (!safe) return null;
+ try {
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
+ if (!fs.existsSync(safe)) return null;
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
+ const raw = fs.readFileSync(safe, 'utf8');
+ return JSON.parse(raw);
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Resolve environment details for the current project.
+ *
+ * @returns {{
+ * projectDir: string,
+ * srcDir: string,
+ * srcExists: boolean,
+ * platform: 'drupal' | 'generic' | string,
+ * SDC: boolean,
+ * structureOverrides: boolean,
+ * structureRoots: string[]
+ * }}
+ */
+export function resolveEnvironment() {
+ const projectDir = process.cwd();
+
+ // Prefer /src when present; else /components (legacy repos).
+ const srcCandidate = resolve(projectDir, 'src');
+ const srcExists = safeExistsSync(srcCandidate, projectDir);
+ const srcDir = srcExists ? srcCandidate : resolve(projectDir, 'components');
+
+ // Platform: ENV wins, then JSON, else default.
+ let platform = (process.env.EMULSIFY_PLATFORM || '')
+ .toString()
+ .toLowerCase()
+ .trim();
+ const emulsifyJson = safeReadJson(projectDir, 'project.emulsify.json');
+
+ if (!platform) {
+ platform = (
+ emulsifyJson?.project?.platform ||
+ emulsifyJson?.variant?.platform ||
+ 'generic'
+ )
+ .toString()
+ .toLowerCase()
+ .trim();
+ }
+
+ // Single Directory Components flag (if present).
+ const SDC = Boolean(emulsifyJson?.project?.singleDirectoryComponents);
+
+ // Legacy variant support (structureImplementations).
+ const structureRoots = Array.isArray(
+ emulsifyJson?.variant?.structureImplementations,
+ )
+ ? emulsifyJson.variant.structureImplementations
+ .map((item) =>
+ typeof item?.directory === 'string' ? item.directory : null,
+ )
+ .filter(Boolean)
+ .map((dir) => {
+ const coerced = coerceToProjectPath(projectDir, dir);
+ return coerced ? normalize(coerced) : null;
+ })
+ .filter(Boolean)
+ : [];
+
+ const structureOverrides = structureRoots.length > 0;
+
+ return {
+ projectDir,
+ srcDir,
+ srcExists,
+ platform,
+ SDC,
+ structureOverrides,
+ structureRoots,
+ };
+}
diff --git a/config/vite/plugins.js b/config/vite/plugins.js
new file mode 100644
index 0000000..a287bac
--- /dev/null
+++ b/config/vite/plugins.js
@@ -0,0 +1,533 @@
+/**
+ * @file Vite plugins factory for Emulsify.
+ *
+ * @description
+ * - Copies TWIGs/metadata into `dist/` using the same routing rules as JS/CSS:
+ * • `src/components/**` → `dist/components/**`
+ * • `src/!(components|util)/**` → `dist/global/**`
+ * - Copies **all non-code assets** found under `src/` to the same routed locations.
+ * - Builds a **physical** spritemap at `dist/assets/icons.sprite.svg`.
+ * - If `env.platform === 'drupal'` and a `src/` dir exists, mirrors `dist/components/**`
+ * to `./components/**` and prunes any empty folders left behind.
+ *
+ * Component Structure Overrides behavior:
+ * - When `env.structureOverrides === true`, we **skip** copying Twig and assets, and also
+ * **skip** mirroring. (Only JS/CSS compile is needed.)
+ */
+
+import { resolve, join, dirname, basename, posix as pathPosix } from 'path';
+import {
+ mkdirSync,
+ copyFileSync,
+ unlinkSync,
+ readdirSync,
+ rmdirSync,
+ statSync,
+ existsSync,
+ readFileSync,
+} from 'fs';
+import { globSync } from 'glob';
+import sassGlobImports from 'vite-plugin-sass-glob-import';
+import yml from '@modyfi/vite-plugin-yaml';
+import twig from 'vite-plugin-twig-drupal';
+
+/* ============================================================================
+ * Small, focused helpers
+ * ========================================================================== */
+
+/** Determine whether a Twig file is a partial (filename starts with `_`). */
+const isPartial = (filePath) =>
+ (filePath.split('/')?.pop() || '').trim().startsWith('_');
+
+/**
+ * Depth-first walk to list **all files** (no directories) under a given root.
+ * @param {string} rootDir
+ * @returns {string[]}
+ */
+const walkFiles = (rootDir) => {
+ const files = [];
+ const stack = [rootDir];
+
+ while (stack.length) {
+ const currentDir = stack.pop();
+ if (!currentDir) continue;
+
+ let entryNames = [];
+ try {
+ entryNames = readdirSync(currentDir);
+ } catch {
+ continue; // unreadable directory
+ }
+
+ for (const name of entryNames) {
+ const fullPath = join(currentDir, name);
+ try {
+ const stats = statSync(fullPath);
+ if (stats.isDirectory()) stack.push(fullPath);
+ else files.push(fullPath);
+ } catch {
+ // ignore unreadable entries
+ }
+ }
+ }
+ return files;
+};
+
+/**
+ * Remove empty parent directories from a start directory **up to (but not including)**
+ * a stopping boundary directory.
+ * @param {string} startDir
+ * @param {string} stopAtDir
+ */
+const pruneEmptyDirsUpTo = (startDir, stopAtDir) => {
+ const stopAbs = resolve(stopAtDir);
+ let cursor = resolve(startDir);
+
+ const isEmpty = (dir) => {
+ try {
+ return readdirSync(dir).length === 0;
+ } catch {
+ return false;
+ }
+ };
+
+ while (cursor.startsWith(stopAbs)) {
+ if (!isEmpty(cursor)) break;
+
+ try {
+ rmdirSync(cursor);
+ } catch {
+ // cannot remove (in use or permissions) → stop trying here
+ break;
+ }
+
+ const parent = dirname(cursor);
+ if (parent === cursor || parent === stopAbs) break;
+ cursor = parent;
+ }
+};
+
+/* ============================================================================
+ * Plugin: Copy Twig files (+ component metadata) using JS/CSS-like routing
+ * ========================================================================== */
+
+/**
+ * Copy Twig templates and component metadata from `src/` to `dist/`,
+ * respecting the same routing used for JS/CSS.
+ *
+ * @param {{ srcDir: string }} opts
+ * @returns {import('vite').PluginOption}
+ */
+function copyTwigFilesPlugin({ srcDir }) {
+ let outDir = 'dist';
+ const posix = (p) => p.replace(/\\/g, '/');
+
+ return {
+ name: 'emulsify-copy-twig-files',
+ apply: 'build',
+ enforce: 'post',
+
+ /** Capture the final outDir. */
+ configResolved(cfg) {
+ outDir = cfg.build?.outDir || 'dist';
+ },
+
+ /** Perform the copying after the bundle has been written. */
+ closeBundle() {
+ // components/**/*.twig
+ const componentTwigs = globSync(
+ posix(join(srcDir, 'components/**/*.twig')),
+ );
+ for (const absPath of componentTwigs) {
+ const relFromSrc = posix(absPath).split(posix(srcDir) + '/')[1]; // "components/x/y.twig"
+ const withinComponents = relFromSrc.replace(/^components\//, '');
+ if (isPartial(withinComponents)) continue; // skip `_*.twig`
+ const destPath = join(outDir, 'components', withinComponents);
+ mkdirSync(dirname(destPath), { recursive: true });
+ try {
+ copyFileSync(absPath, destPath);
+ } catch {
+ /* noop */
+ }
+ }
+
+ // components/**/*.component.(yml|yaml|json)
+ for (const pattern of [
+ 'components/**/*.component.@(yml|yaml)',
+ 'components/**/*.component.json',
+ ]) {
+ const metaFiles = globSync(posix(join(srcDir, pattern)));
+ for (const absPath of metaFiles) {
+ const rel = posix(absPath)
+ .split(posix(srcDir) + '/')[1]
+ .replace(/^components\//, '');
+ const destPath = join(outDir, 'components', rel);
+ mkdirSync(dirname(destPath), { recursive: true });
+ try {
+ copyFileSync(absPath, destPath);
+ } catch {
+ /* noop */
+ }
+ }
+ }
+
+ // global Twig: everything under src except components/, util/, and partials
+ const globalTwigs = globSync(posix(join(srcDir, '**/*.twig')), {
+ ignore: [
+ posix(join(srcDir, 'components/**')),
+ posix(join(srcDir, 'util/**')),
+ posix(join(srcDir, '**/_*.twig')),
+ ],
+ });
+
+ for (const absPath of globalTwigs) {
+ const rel = posix(absPath).split(posix(srcDir) + '/')[1];
+ const destPath = join(outDir, 'global', rel);
+ mkdirSync(dirname(destPath), { recursive: true });
+ try {
+ copyFileSync(absPath, destPath);
+ } catch {
+ /* noop */
+ }
+ }
+ },
+ };
+}
+
+/* ============================================================================
+ * Plugin: Copy **all non-code** assets under `src/` with the same routing
+ * ========================================================================== */
+
+/**
+ * Copies anything in `src/` that is **not** a code/template file into
+ * either `dist/components/**` or `dist/global/**`, preserving relative paths.
+ *
+ * Excludes: .js, .scss, .twig, source maps, and `*.component.(yml|yaml|json)`.
+ *
+ * @param {{ srcDir: string }} opts
+ * @returns {import('vite').PluginOption}
+ */
+function copyAllSrcAssetsPlugin({ srcDir }) {
+ let outDir = 'dist';
+ const posix = (p) => p.replace(/\\/g, '/');
+
+ return {
+ name: 'emulsify-copy-all-src-assets',
+ apply: 'build',
+ enforce: 'post',
+
+ /** Capture outDir. */
+ configResolved(cfg) {
+ outDir = cfg.build?.outDir || 'dist';
+ },
+
+ /** Copy component/global assets. */
+ closeBundle() {
+ // Component-side assets → dist/components
+ const componentAssets = globSync(posix(join(srcDir, 'components/**/*')), {
+ nodir: true,
+ ignore: [
+ posix(join(srcDir, 'components/**/*.js')),
+ posix(join(srcDir, 'components/**/*.scss')),
+ posix(join(srcDir, 'components/**/*.twig')),
+ posix(join(srcDir, 'components/**/*.component.@(yml|yaml|json)')),
+ posix(join(srcDir, 'components/**/*.map')),
+ ],
+ });
+ for (const absPath of componentAssets) {
+ const rel = posix(absPath)
+ .split(posix(srcDir) + '/')[1]
+ .replace(/^components\//, '');
+ const destPath = join(outDir, 'components', rel);
+ mkdirSync(dirname(destPath), { recursive: true });
+ try {
+ copyFileSync(absPath, destPath);
+ } catch {
+ /* noop */
+ }
+ }
+
+ // Global-side assets → dist/global
+ const globalAssets = globSync(posix(join(srcDir, '**/*')), {
+ nodir: true,
+ ignore: [
+ posix(join(srcDir, 'components/**')),
+ posix(join(srcDir, 'util/**')),
+ posix(join(srcDir, '**/*.js')),
+ posix(join(srcDir, '**/*.scss')),
+ posix(join(srcDir, '**/*.twig')),
+ posix(join(srcDir, '**/*.component.@(yml|yaml|json)')),
+ posix(join(srcDir, '**/*.map')),
+ ],
+ });
+ for (const absPath of globalAssets) {
+ const rel = posix(absPath).split(posix(srcDir) + '/')[1];
+ const destPath = join(outDir, 'global', rel);
+ mkdirSync(dirname(destPath), { recursive: true });
+ try {
+ copyFileSync(absPath, destPath);
+ } catch {
+ /* noop */
+ }
+ }
+ },
+ };
+}
+
+/* ============================================================================
+ * Plugin: Build a **physical** SVG spritemap at dist/assets/icons.sprite.svg
+ * ========================================================================== */
+
+/**
+ * Builds a single SVG sprite file from a set of icon globs and emits it as
+ * `assets/icons.sprite.svg`. Only the options you’re using are supported:
+ *
+ * @param {{ include: string|string[], symbolId?: string }} options
+ * @returns {import('vite').PluginOption}
+ */
+function svgSpriteFilePlugin({ include, symbolId = '[name]' }) {
+ const toArray = (x) => (Array.isArray(x) ? x : [x]).filter(Boolean);
+ const posix = (p) => p.replace(/\\/g, '/');
+
+ /** @type {string[]} */
+ let patterns = [];
+
+ return {
+ name: 'emulsify-svg-sprite-file',
+ apply: 'build',
+
+ /** Register icons for watch. */
+ buildStart() {
+ patterns = toArray(include).map(posix);
+ const files = patterns.flatMap((p) => globSync(p));
+ for (const f of files) {
+ try {
+ this.addWatchFile(f);
+ } catch {
+ /* noop */
+ }
+ }
+ },
+
+ /** Concatenate all matched SVGs into a single sprite. */
+ generateBundle() {
+ const files = patterns
+ .flatMap((p) => globSync(p))
+ .sort((a, b) => posix(a).localeCompare(posix(b)));
+
+ if (!files.length) return;
+
+ const used = new Set();
+ const makeId = (abs) => {
+ const stem = basename(abs).replace(/\.svg$/i, '');
+ let id = symbolId
+ .replace('[name]', stem)
+ .toLowerCase()
+ .replace(/[^a-z0-9_-]+/g, '-')
+ .replace(/^-+|-+$/g, '');
+ if (!used.has(id)) {
+ used.add(id);
+ return id;
+ }
+ let i = 2;
+ while (used.has(`${id}-${i}`)) i += 1;
+ id = `${id}-${i}`;
+ used.add(id);
+ return id;
+ };
+
+ const symbols = files
+ .map((abs) => {
+ let content = '';
+ try {
+ content = readFileSync(abs, 'utf8');
+ } catch {
+ return '';
+ }
+ const m = content.match(/