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..525cd96 100644
--- a/.storybook/main.js
+++ b/.storybook/main.js
@@ -1,29 +1,118 @@
// .storybook/main.js
/**
- * Storybook main configuration file.
- * This configures stories, static directories, addons, core builder,
- * framework, documentation settings, manager head styles, and overrides.
+ * Central Storybook configuration for Emulsify.
+ *
+ * This shared config defines the default Storybook behavior for consumers of
+ * the package, then lets a project layer local overrides on top at the end.
+ * The main custom behavior here is:
+ * - injecting manager/preview head markup
+ * - adapting the shared Vite config for Storybook
+ * - wiring Twig template discovery into the Storybook build
+ *
* @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';
+
+/**
+ * Minimal subset of the resolved Emulsify environment used by this file.
+ *
+ * @typedef {object} StorybookEnvironment
+ * @property {string} projectDir - Absolute path to the consuming project root.
+ * @property {boolean} [structureOverrides] - Whether custom structure roots are enabled.
+ * @property {string[]} [structureRoots] - Absolute component root paths when overrides are active.
+ * @property {string} [srcDir] - Absolute path to the project's `src` directory when present.
+ */
+
+/**
+ * Storybook config type used for editor hints in this plain JS file.
+ * @typedef {import('@storybook/core-common').StorybookConfig} StorybookConfig
+ */
/**
* 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);
+
+/**
+ * Reads an optional HTML fragment relative to this config file.
+ *
+ * Missing files are treated as empty content so downstream projects can opt in
+ * to extra markup without making Storybook fail on startup.
+ *
+ * @param {string} relativePath - Relative path from this file to the HTML fragment.
+ * @returns {string} File contents when the fragment exists, otherwise an empty string.
+ */
+function readOptionalHtmlFragment(relativePath) {
+ const fragmentPath = resolve(_dirname, relativePath);
+
+ if (!fs.existsSync(fragmentPath)) {
+ return '';
+ }
+
+ return fs.readFileSync(fragmentPath, 'utf8');
+}
+
+/**
+ * Converts an absolute path inside the project into the root-relative format
+ * Vite expects for `import.meta.glob()` patterns.
+ *
+ * The path separator normalization matters because Storybook may run on
+ * Windows as well as POSIX systems.
+ *
+ * @param {string} projectDir - Absolute path to the consuming project root.
+ * @param {string} absolutePath - Absolute path that should become root-relative.
+ * @returns {string} Vite-compatible root-relative path.
+ */
+function toRootRelativePath(projectDir, absolutePath) {
+ const rel = path.relative(projectDir, absolutePath);
+ const normalized = rel.split(path.sep).join('/');
+
+ return `/${normalized}`.replace(/\/{2,}/g, '/');
+}
+
+/**
+ * Builds the `import.meta.glob()` expression injected into the Twig resolver.
+ *
+ * The component roots can move when a project enables structure overrides, so
+ * the import list is generated at runtime instead of hard-coded.
+ *
+ * @param {StorybookEnvironment} env - Resolved project paths used by Storybook.
+ * @returns {string} JavaScript source that eagerly imports Twig templates.
+ */
+function buildTwigGlobImports(env) {
+ const candidateRoots =
+ env.structureOverrides &&
+ Array.isArray(env.structureRoots) &&
+ env.structureRoots.length
+ ? env.structureRoots
+ : env.srcDir
+ ? [path.join(env.srcDir, 'components')]
+ : [];
+ const rootRelativePaths = candidateRoots.map((root) =>
+ toRootRelativePath(env.projectDir, root),
+ );
+ const globBases = rootRelativePaths.length
+ ? rootRelativePaths
+ : ['/src/components', '/components'];
+
+ return `mergeGlobMaps([\n${globBases
+ .map((base) => ` import.meta.glob('${base}/**/*.twig', { eager: true })`)
+ .join(',\n')}\n])`;
+}
/**
* Safely apply any user-provided overrides or fall back to an empty object.
@@ -33,19 +122,25 @@ const safeConfigOverrides = configOverrides || {};
/**
* Primary Storybook configuration object.
- * @type {import('@storybook/core-common').StorybookConfig}
+ * @type {StorybookConfig}
*/
const config = {
/**
- * Patterns for locating story files under src or components directories.
+ * Discover stories from both supported component roots.
+ *
+ * This shared config supports projects that keep stories under `src` as well
+ * as projects that expose a top-level `components` directory.
+ *
* @type {string[]}
*/
- stories: [
- '../../../../(src|components)/**/*.stories.@(js|jsx|ts|tsx)',
- ],
+ stories: ['../../../../@(src|components)/**/*.stories.@(js|jsx|ts|tsx)'],
/**
- * Directories to serve as static assets in the Storybook build.
+ * Mount shared assets into Storybook's static file server.
+ *
+ * Anything referenced by URL inside stories should live in one of these
+ * directories so it works in both `storybook dev` and static builds.
+ *
* @type {string[]}
*/
staticDirs: [
@@ -55,37 +150,43 @@ const config = {
],
/**
- * List of Storybook addons to enable various features.
+ * Enable the default addon set used by Emulsify.
+ *
+ * `a11y` adds accessibility tooling, `links` supports story-to-story
+ * navigation, and `themes` exposes theme switching in the Storybook UI.
+ *
* @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',
],
/**
- * Core builder configuration for Storybook.
+ * Force the Vite builder and disable Storybook telemetry for shared usage.
* @type {{builder: string, disableTelemetry: boolean}}
*/
core: {
- builder: 'webpack5',
+ builder: '@storybook/builder-vite',
disableTelemetry: true,
},
/**
- * Framework specification for Storybook (HTML + Webpack5).
+ * Tell Storybook to use the React + Vite framework package.
* @type {{name: string, options: object}}
*/
framework: {
- name: '@storybook/html-webpack5',
+ name: '@storybook/react-vite',
options: {},
},
/**
- * Documentation settings for Storybook autodocs.
+ * Disable automatic docs generation.
+ *
+ * Storybook will only render documentation pages that are authored
+ * explicitly instead of generating them from component metadata.
+ *
* @type {{autodocs: boolean}}
*/
docs: {
@@ -93,13 +194,17 @@ const config = {
},
/**
- * Custom styles injected into the Storybook manager (sidebar) head,
- * plus any external manager-head.html snippet.
- * @param {string} head - Existing head HTML.
- * @returns {string} Modified head HTML.
+ * Appends Emulsify branding to the Storybook manager UI.
+ *
+ * This only affects Storybook's chrome, such as the sidebar, toolbar, and
+ * addon panels. It does not affect the iframe where stories actually render.
+ *
+ * @param {string} head - Existing manager head markup provided by Storybook.
+ * @returns {string} Manager head markup with Emulsify additions appended.
*/
managerHead: (head) => {
- // inline theme styles
+ // Keep the manager styling inline so consumers inherit the branded UI
+ // without having to maintain a separate manager-only stylesheet.
const inlineStyles = `
`;
-
- // load external manager-head.html if present
- const externalManagerHeadPath = resolve(
- __dirname,
- '../../../../config/emulsify-core/storybook/manager-head.html'
+ const externalManagerHtml = readOptionalHtmlFragment(
+ '../../../../config/emulsify-core/storybook/manager-head.html',
);
- let externalManagerHtml = '';
- if (fs.existsSync(externalManagerHeadPath)) {
- externalManagerHtml = fs.readFileSync(externalManagerHeadPath, 'utf8');
- }
return `${head}
-${inlineStyles}
-${externalManagerHtml}`;
+ ${inlineStyles}
+ ${externalManagerHtml}`;
},
/**
- * Function to load and append an external preview-head.html into the preview iframe.
- * @param {string} head - Existing preview head HTML.
- * @returns {string} Combined head HTML including external snippet if present.
+ * Appends project-level head markup to the story preview iframe.
+ *
+ * This is the place for preview-only fonts, scripts, or meta tags that the
+ * rendered component output depends on.
+ *
+ * @param {string} head - Existing preview head markup provided by Storybook.
+ * @returns {string} Preview head markup with optional project HTML appended.
*/
previewHead: (head) => {
- const externalHeadPath = resolve(
- __dirname,
- '../../../../config/emulsify-core/storybook/preview-head.html'
+ const externalHtml = readOptionalHtmlFragment(
+ '../../../../config/emulsify-core/storybook/preview-head.html',
);
- let externalHtml = '';
- if (fs.existsSync(externalHeadPath)) {
- externalHtml = fs.readFileSync(externalHeadPath, 'utf8');
- }
-
return `${head}
-${externalHtml}`;
+ ${externalHtml}`;
+ },
+
+ /**
+ * Merges Storybook's generated Vite config with Emulsify's shared Vite config.
+ *
+ * Storybook supplies a baseline config, but Emulsify still needs to expose
+ * the resolved environment, expand filesystem access, and inject the Twig
+ * template globs used by the runtime resolver.
+ *
+ * @param {import('vite').UserConfig} config - Storybook's generated Vite config.
+ * @returns {Promise} Final Vite config used by Storybook.
+ */
+ async viteFinal(config) {
+ const { mergeConfig } = await import('vite');
+ /** @type {StorybookEnvironment} */
+ const env = resolveEnvironment();
+
+ // Keep using the `serve` branch of the shared Vite config here. Storybook
+ // has historically consumed that branch, while `mode` still reflects
+ // whether Storybook is running in development or production.
+ const mode = config?.mode || 'development';
+ const baseViteConfig =
+ typeof viteConfig === 'function'
+ ? await viteConfig({ command: 'serve', mode })
+ : viteConfig;
+ const existingDefine = (config && config.define) || {};
+ const viteDefine = (baseViteConfig && baseViteConfig.define) || {};
+
+ // Allow Storybook's dev server to read component sources from the project
+ // root and any structure override paths used by Emulsify consumers.
+ 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'),
+ ]);
+
+ // Twig files are loaded through custom resolvers/plugins, so they need to
+ // be treated as importable assets by Storybook's Vite pipeline.
+ const assetsInclude = Array.from(
+ new Set([
+ ...(config.assetsInclude || []),
+ ...(baseViteConfig.assetsInclude || []),
+ '**/*.twig',
+ ]),
+ );
+ const twigGlobImports = buildTwigGlobImports(env);
+
+ return mergeConfig(config, {
+ ...baseViteConfig,
+ define: {
+ // Preserve shared and Storybook-provided constants, then publish the
+ // resolved Emulsify environment to client-side code.
+ ...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;
+ }
+
+ // Replace the placeholder token in the Twig resolver polyfill with
+ // the project-specific import list computed above.
+ const replaced = code.replace(
+ /__EMULSIFY_TWIG_GLOB_IMPORTS__/g,
+ twigGlobImports,
+ );
+ return replaced === code ? null : replaced;
+ },
+ },
+ ],
+ esbuild: {
+ // Some downstream code is authored as `.js` files containing JSX, so
+ // keep Storybook's esbuild settings aligned with the shared Vite config.
+ jsx: 'automatic',
+ loader: 'jsx',
+ include: /.*\.jsx?$/,
+ exclude: [],
+ },
+ optimizeDeps: {
+ include: [
+ 'path',
+ 'twig',
+ 'twig-drupal-filters',
+ 'bem-twig-extension',
+ 'add-attributes-twig-extension',
+ ],
+ esbuildOptions: {
+ loader: {
+ // Pre-bundle `.js` dependencies with the JSX loader for packages
+ // that ship JSX without a `.jsx` extension.
+ '.js': 'jsx',
+ },
+ },
+ },
+ });
},
- // Merge in user overrides without modifying original logic
+ // Spread consumer overrides last so local projects can replace any default above.
...safeConfigOverrides,
};
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(/