@emulsify/core 3.5.0 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/.cli/init.js +40 -31
  2. package/.storybook/_drupal.js +129 -8
  3. package/.storybook/css-components.js +13 -0
  4. package/.storybook/css-dist.js +5 -0
  5. package/.storybook/emulsifyTheme.js +9 -6
  6. package/.storybook/main.js +397 -106
  7. package/.storybook/manager.js +9 -16
  8. package/.storybook/preview.js +88 -110
  9. package/.storybook/utils.js +69 -74
  10. package/README.md +110 -59
  11. package/config/.stylelintrc.json +2 -6
  12. package/config/a11y.config.js +9 -5
  13. package/config/babel.config.js +6 -11
  14. package/config/eslint.config.js +31 -3
  15. package/config/postcss.config.js +5 -0
  16. package/config/vite/entries.js +227 -0
  17. package/config/vite/environment.js +39 -0
  18. package/config/vite/platforms.js +70 -0
  19. package/config/vite/plugins/copy-src-assets.js +76 -0
  20. package/config/vite/plugins/copy-twig-files.js +84 -0
  21. package/config/vite/plugins/css-asset-relativizer.js +40 -0
  22. package/config/vite/plugins/index.js +105 -0
  23. package/config/vite/plugins/mirror-components.js +358 -0
  24. package/config/vite/plugins/require-context.js +311 -0
  25. package/config/vite/plugins/source-file-index.js +184 -0
  26. package/config/vite/plugins/svg-sprite.js +117 -0
  27. package/config/vite/plugins/twig-extension-installers.js +36 -0
  28. package/config/vite/plugins/twig-module.js +1251 -0
  29. package/config/vite/plugins/virtual-twig-asset-sources.js +404 -0
  30. package/config/vite/plugins/virtual-twig-globs.js +136 -0
  31. package/config/vite/plugins/vituum-patch.js +167 -0
  32. package/config/vite/plugins/yaml-module.js +133 -0
  33. package/config/vite/plugins.js +12 -0
  34. package/config/vite/project-config.js +192 -0
  35. package/config/vite/project-extensions.js +177 -0
  36. package/config/vite/project-structure.js +447 -0
  37. package/config/vite/twig-extensions.js +109 -0
  38. package/config/vite/utils/fs-safe.js +66 -0
  39. package/config/vite/utils/paths.js +40 -0
  40. package/config/vite/utils/react-singleton.js +85 -0
  41. package/config/vite/utils/unique.js +36 -0
  42. package/config/vite/vite.config.js +161 -0
  43. package/package.json +164 -75
  44. package/scripts/a11y.js +70 -16
  45. package/scripts/audit-twig-stories.js +378 -0
  46. package/scripts/audit.js +1602 -0
  47. package/scripts/check-node-version.js +18 -0
  48. package/scripts/loadYaml.js +5 -1
  49. package/src/extensions/index.js +8 -0
  50. package/src/extensions/react/index.js +12 -0
  51. package/src/extensions/react/register.js +45 -0
  52. package/src/extensions/shared/attributes.js +308 -0
  53. package/src/extensions/shared/html.js +41 -0
  54. package/src/extensions/shared/lists.js +38 -0
  55. package/src/extensions/shared/object.js +22 -0
  56. package/src/extensions/twig/function-map.js +20 -0
  57. package/src/extensions/twig/functions/add-attributes.js +39 -0
  58. package/src/extensions/twig/functions/bem.js +166 -0
  59. package/src/extensions/twig/index.js +13 -0
  60. package/src/extensions/twig/register.js +95 -0
  61. package/src/extensions/twig/tag-map.js +16 -0
  62. package/src/extensions/twig/tags/switch.js +266 -0
  63. package/src/storybook/index.js +14 -0
  64. package/src/storybook/main-config.js +132 -0
  65. package/src/storybook/platform-behaviors.js +60 -0
  66. package/src/storybook/preview-parameters.js +81 -0
  67. package/src/storybook/render-twig.js +295 -0
  68. package/src/storybook/twig/drupal-filters.js +7 -0
  69. package/src/storybook/twig/include-function.js +109 -0
  70. package/src/storybook/twig/include.js +28 -0
  71. package/src/storybook/twig/reference-paths.js +294 -0
  72. package/src/storybook/twig/resolver.js +318 -0
  73. package/src/storybook/twig/setup.js +39 -0
  74. package/src/storybook/twig/source-events.js +5 -0
  75. package/src/storybook/twig/source-extensions.js +24 -0
  76. package/src/storybook/twig/source-function.js +239 -0
  77. package/src/storybook/twig/source.js +39 -0
  78. package/.all-contributorsrc +0 -45
  79. package/.editorconfig +0 -5
  80. package/.github/ISSUE_TEMPLATE/BUG_REPORT_TEMPLATE.md +0 -18
  81. package/.github/ISSUE_TEMPLATE/FEATURE_REQUEST_TEMPLATE.md +0 -11
  82. package/.github/PULL_REQUEST_TEMPLATE.md +0 -19
  83. package/.github/dependabot.yml +0 -6
  84. package/.github/workflows/addtoprojects.yml +0 -21
  85. package/.github/workflows/contributors.yml +0 -37
  86. package/.github/workflows/lint.yml +0 -22
  87. package/.github/workflows/semantic-release.yml +0 -24
  88. package/.husky/commit-msg +0 -2
  89. package/.husky/pre-commit +0 -2
  90. package/.nvmrc +0 -1
  91. package/.prettierignore +0 -4
  92. package/.storybook/polyfills/twig-include.js +0 -40
  93. package/.storybook/polyfills/twig-resolver.js +0 -70
  94. package/.storybook/polyfills/twig-source.js +0 -65
  95. package/.storybook/webpack.config.js +0 -269
  96. package/CODE_OF_CONDUCT.md +0 -56
  97. package/commitlint.config.js +0 -5
  98. package/config/jest.config.js +0 -19
  99. package/config/webpack/app.js +0 -1
  100. package/config/webpack/loaders.js +0 -167
  101. package/config/webpack/optimizers.js +0 -26
  102. package/config/webpack/plugins.js +0 -283
  103. package/config/webpack/resolves.js +0 -157
  104. package/config/webpack/sdc-loader.js +0 -16
  105. package/config/webpack/webpack.common.js +0 -272
  106. package/config/webpack/webpack.dev.js +0 -41
  107. package/config/webpack/webpack.prod.js +0 -6
  108. package/release.config.cjs +0 -30
  109. package/scripts/a11y.test.js +0 -172
  110. package/scripts/loadYaml.test.js +0 -30
@@ -1,14 +1,18 @@
1
- module.exports = {
1
+ /**
2
+ * @file Shared accessibility linting configuration.
3
+ *
4
+ * These defaults are consumed by the pa11y script and can be extended by
5
+ * consuming projects through their local Emulsify config.
6
+ */
7
+
8
+ export default {
2
9
  storybookBuildDir: '../../../../.out',
3
10
  pa11y: {
4
11
  includeNotices: false,
5
12
  includeWarnings: false,
6
13
  runners: ['axe'],
7
14
  },
8
- // A11y linting is done on a component-by-component
9
- // basis, which results in the linter reporting some errors that
10
- // should be ignored. These codes and descriptions allow for those
11
- // errors to be targeted specifically.
15
+ // Ignore rules that are noisy for isolated component pages.
12
16
  ignore: {
13
17
  codes: ['landmark-one-main', 'page-has-heading-one'],
14
18
  descriptions: ['Ensures all page content is contained by landmarks'],
@@ -1,17 +1,12 @@
1
+ /**
2
+ * @file Babel configuration for test and legacy transpilation paths.
3
+ */
4
+
1
5
  export default (api) => {
2
6
  api.cache(true);
3
7
 
4
- const presets = [
5
- [
6
- 'minify',
7
- {
8
- builtIns: false,
9
- mangle: {
10
- reserved: ['Drupal', 'drupalSettings', 'once'],
11
- },
12
- },
13
- ],
14
- ];
8
+ // Disable Babel's generated comments so minified output stays compact.
9
+ const presets = [['minify', { builtIns: false }]];
15
10
  const comments = false;
16
11
 
17
12
  return { presets, comments };
@@ -1,19 +1,32 @@
1
- // Import ESLint Flat Config and required plugins
1
+ /**
2
+ * @file ESLint flat configuration for Emulsify Core.
3
+ */
4
+
2
5
  import js from '@eslint/js';
6
+ import babelParser from '@babel/eslint-parser';
7
+ import importPlugin from 'eslint-plugin-import';
3
8
  import pluginSecurity from 'eslint-plugin-security';
4
9
  import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
5
10
 
6
11
  export default [
7
- // Base ESLint recommended rules
12
+ // Start with core and plugin recommendations before project overrides.
8
13
  js.configs.recommended,
9
14
 
10
- // Plugin configurations
15
+ importPlugin.flatConfigs.recommended,
11
16
  pluginSecurity.configs.recommended,
12
17
  eslintPluginPrettierRecommended,
13
18
 
14
19
  {
15
20
  name: 'emulsify-core-config',
16
21
  languageOptions: {
22
+ parser: babelParser,
23
+ parserOptions: {
24
+ requireConfigFile: false,
25
+ babelOptions: {
26
+ babelrc: false,
27
+ configFile: false,
28
+ },
29
+ },
17
30
  sourceType: 'module',
18
31
  ecmaVersion: 'latest',
19
32
  globals: {
@@ -28,10 +41,15 @@ export default [
28
41
  ignores: ['**/*.min.js', '**/node_modules/**/*'],
29
42
 
30
43
  rules: {
44
+ // Keep historical project conventions while warning on risky patterns.
31
45
  strict: 0,
32
46
  'consistent-return': 'off',
33
47
  'no-underscore-dangle': 'off',
34
48
  'max-nested-callbacks': ['warn', 3],
49
+ 'import/extensions': 'off',
50
+ 'import/no-unresolved': 'off',
51
+ 'import/no-extraneous-dependencies': 'warn',
52
+ 'import/no-mutable-exports': 'warn',
35
53
  'no-plusplus': ['warn', { allowForLoopAfterthoughts: true }],
36
54
  'no-param-reassign': 'off',
37
55
  'no-prototype-builtins': 'off',
@@ -45,5 +63,15 @@ export default [
45
63
  ],
46
64
  quotes: ['error', 'single'],
47
65
  },
66
+
67
+ settings: {
68
+ 'import/ignore': ['\\.(scss|less|css)$'],
69
+ 'import/resolver': {
70
+ node: {
71
+ extensions: ['.js', '.jsx'],
72
+ moduleDirectory: ['src', 'node_modules'],
73
+ },
74
+ },
75
+ },
48
76
  },
49
77
  ];
@@ -1,5 +1,10 @@
1
+ /**
2
+ * @file PostCSS plugin configuration.
3
+ */
4
+
1
5
  import autoPrefixer from 'autoprefixer';
2
6
 
3
7
  export default {
8
+ // Autoprefixer keeps compiled CSS compatible with supported browsers.
4
9
  plugins: [autoPrefixer()],
5
10
  };
@@ -0,0 +1,227 @@
1
+ /**
2
+ * @file Entries map builder for Vite/Rollup.
3
+ *
4
+ * Builds a keyed input map (for `build.rollupOptions.input`) where the map key
5
+ * encodes the final folder inside the Vite outDir (default `dist/`).
6
+ *
7
+ * Modern projects:
8
+ * - Global/base assets -> "global/..."
9
+ * - Component assets -> "components/..." (or mirrored to ./components when Drupal)
10
+ * - SDC=true removes the injected "/css" or "/js" bucket
11
+ *
12
+ * Component Structure Overrides projects (project.emulsify.json: variant.structureImplementations):
13
+ * - Only compile JS/SCSS.
14
+ * - JS -> "js/<relative-without-ext>"
15
+ * - CSS -> "css/<relative-without-ext>"
16
+ * - Twig/assets copying is handled by plugins using the same structure model.
17
+ * - cl-* / sb-* SCSS -> "storybook/<path-without-ext>"
18
+ */
19
+
20
+ import fs from 'fs';
21
+ import { basename, resolve } from 'path';
22
+ import {
23
+ compiledAssetOutputPath,
24
+ resolveProjectStructure,
25
+ storybookStyleOutputPath,
26
+ } from './project-structure.js';
27
+ import { createSourceFileIndex } from './plugins/source-file-index.js';
28
+ import { replaceLastSlash, toPosix } from './utils/paths.js';
29
+
30
+ export { replaceLastSlash, toPosix };
31
+
32
+ /** Remove characters that would confuse Rollup naming or file systems. */
33
+ export const sanitizePath = (s) => s.replace(/[^a-zA-Z0-9/_-]/g, '');
34
+
35
+ /**
36
+ * @typedef {Object} BuildContext
37
+ * @property {string} projectDir
38
+ * @property {string} srcDir
39
+ * @property {boolean} srcExists
40
+ * @property {boolean} isDrupal - kept for downstream logic parity
41
+ * @property {boolean} SDC
42
+ * @property {boolean} structureOverrides
43
+ * @property {string[]} [structureRoots]
44
+ * @property {object} [sourceFileIndex]
45
+ */
46
+
47
+ /* -------------------------------------------------------------------------- */
48
+ /* Utilities */
49
+ /* -------------------------------------------------------------------------- */
50
+
51
+ /**
52
+ * Safe map setter that avoids prototype pollution keys.
53
+ * @param {Record<string,string>} map
54
+ * @param {string} key
55
+ * @param {string} value
56
+ */
57
+ function safeSetKey(map, key, value) {
58
+ const forbidden = ['__proto__', 'prototype', 'constructor'];
59
+ if (!key || forbidden.some((bad) => key.includes(bad))) return;
60
+ map[key] = value; // eslint-disable-line security/detect-object-injection
61
+ }
62
+
63
+ /** Return an absolute path from a source index entry or string. */
64
+ const entryPath = (entry) =>
65
+ typeof entry === 'string' ? entry : entry.absPath;
66
+
67
+ /** Determine whether a file should be compiled as a JS entry. */
68
+ const isJavaScriptEntry = (entry) => {
69
+ const filePath = entryPath(entry);
70
+ return (
71
+ /\.jsx?$/.test(filePath) &&
72
+ !/\.(stories|component|min|test)\.jsx?$/.test(filePath)
73
+ );
74
+ };
75
+
76
+ /** Determine whether a file should be compiled as a regular SCSS entry. */
77
+ const isScssEntry = (entry) => {
78
+ const filePath = entryPath(entry);
79
+ const name = basename(filePath);
80
+ return (
81
+ /\.scss$/.test(name) &&
82
+ !name.startsWith('_') &&
83
+ !name.startsWith('cl-') &&
84
+ !name.startsWith('sb-')
85
+ );
86
+ };
87
+
88
+ /** Determine whether a file should be emitted under the Storybook style path. */
89
+ const isStorybookScssEntry = (entry) => {
90
+ const filePath = entryPath(entry);
91
+ return /\.scss$/.test(filePath) && /(?:cl-|sb-)/.test(basename(filePath));
92
+ };
93
+
94
+ /* -------------------------------------------------------------------------- */
95
+ /* Inputs builder */
96
+ /* -------------------------------------------------------------------------- */
97
+
98
+ /**
99
+ * Build the Rollup/Vite input map.
100
+ *
101
+ * Keys are paths **relative to outDir**, without extensions. Examples:
102
+ * - "global/layout/css/layout"
103
+ * - "components/accordion/js/accordion" (or without "/js" when SDC=true)
104
+ *
105
+ * For Component Structure Overrides (variant.structureImplementations present),
106
+ * only JS/CSS keys are produced under "js/**" and "css/**".
107
+ *
108
+ * @param {BuildContext} ctx
109
+ * @returns {Record<string, string>}
110
+ */
111
+ export function buildInputs(ctx) {
112
+ const structure = ctx.projectStructure || resolveProjectStructure(ctx);
113
+ const sourceFileIndex =
114
+ ctx.sourceFileIndex || createSourceFileIndex(structure);
115
+
116
+ /** @type {Record<string, string>} */
117
+ const inputs = {};
118
+
119
+ /**
120
+ * Add a key/file pair into the inputs map safely (sanitized + POSIX).
121
+ * @param {string|null} key
122
+ * @param {string} abs
123
+ */
124
+ const add = (key, abs) => {
125
+ if (!key) return;
126
+ const clean = sanitizePath(toPosix(key)).replace(/^\/+/, '');
127
+ if (!clean) return;
128
+ safeSetKey(inputs, clean, abs);
129
+ };
130
+
131
+ /* ------------------------------------------------------------------------ */
132
+ /* STRUCTURE OVERRIDES BRANCH */
133
+ /* ------------------------------------------------------------------------ */
134
+ if (structure.structureOverrides) {
135
+ // Gather JS and SCSS from each declared variant root directory.
136
+ const componentFiles = sourceFileIndex.componentFiles();
137
+ const jsFiles = componentFiles.filter(isJavaScriptEntry);
138
+ const scssFiles = componentFiles.filter(isScssEntry);
139
+ const storybookScss = componentFiles.filter(isStorybookScssEntry);
140
+
141
+ // JS files emit under dist/js using the path below components when possible.
142
+ for (const entry of jsFiles) {
143
+ const file = entryPath(entry);
144
+ add(compiledAssetOutputPath(file, 'js', structure, ctx), file);
145
+ }
146
+
147
+ // SCSS files emit under dist/css using the same relative path rules.
148
+ for (const entry of scssFiles) {
149
+ const file = entryPath(entry);
150
+ add(compiledAssetOutputPath(file, 'css', structure, ctx), file);
151
+ }
152
+
153
+ // Storybook and component-library styles stay under dist/storybook.
154
+ for (const entry of storybookScss) {
155
+ const file = entryPath(entry);
156
+ add(storybookStyleOutputPath(file, structure, ctx), file);
157
+ }
158
+
159
+ return inputs;
160
+ }
161
+
162
+ /* ------------------------------------------------------------------------ */
163
+ /* MODERN BRANCH (existing behavior preserved) */
164
+ /* ------------------------------------------------------------------------ */
165
+ const globalFiles = sourceFileIndex.globalFiles();
166
+ const componentFiles = sourceFileIndex.componentFiles();
167
+
168
+ // Global JS
169
+ for (const entry of globalFiles.filter(isJavaScriptEntry)) {
170
+ const file = entryPath(entry);
171
+ add(compiledAssetOutputPath(file, 'js', structure, ctx), file);
172
+ }
173
+
174
+ // Component JS
175
+ for (const entry of componentFiles.filter(isJavaScriptEntry)) {
176
+ const file = entryPath(entry);
177
+ add(compiledAssetOutputPath(file, 'js', structure, ctx), file);
178
+ }
179
+
180
+ // Global SCSS
181
+ for (const entry of globalFiles.filter(isScssEntry)) {
182
+ const file = entryPath(entry);
183
+ add(compiledAssetOutputPath(file, 'css', structure, ctx), file);
184
+ }
185
+
186
+ // Component SCSS
187
+ for (const entry of componentFiles.filter(isScssEntry)) {
188
+ const file = entryPath(entry);
189
+ add(compiledAssetOutputPath(file, 'css', structure, ctx), file);
190
+ }
191
+
192
+ // Storybook/CL SCSS
193
+ for (const entry of sourceFileIndex.all().filter(isStorybookScssEntry)) {
194
+ const file = entryPath(entry);
195
+ add(storybookStyleOutputPath(file, structure, ctx), file);
196
+ }
197
+
198
+ return inputs;
199
+ }
200
+
201
+ /**
202
+ * Convenience wrapper that infers `srcDir` and returns an inputs map.
203
+ * @param {string} projectDir
204
+ * @param {boolean} [isDrupal=false]
205
+ * @param {boolean} [SDC=false]
206
+ * @returns {Record<string,string>}
207
+ */
208
+ export function buildInputsFromProject(
209
+ projectDir,
210
+ isDrupal = false,
211
+ SDC = false,
212
+ ) {
213
+ const srcPath = resolve(projectDir, 'src');
214
+ const srcExists = fs.existsSync(srcPath);
215
+ const srcDir = srcExists ? srcPath : resolve(projectDir, 'components');
216
+
217
+ const ctx = {
218
+ projectDir,
219
+ srcDir,
220
+ srcExists,
221
+ isDrupal,
222
+ SDC,
223
+ structureOverrides: false,
224
+ structureRoots: [],
225
+ };
226
+ return buildInputs(ctx);
227
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * @file Environment resolution for Emulsify + Vite.
3
+ *
4
+ * Reads project settings and exposes a normalized "env" object used by
5
+ * entries, plugins, and the Vite config.
6
+ *
7
+ * Highlights:
8
+ * - `platform`: from env var or project.emulsify.json (default "generic").
9
+ * - `SDC`: boolean from project.emulsify.json `project.singleDirectoryComponents`.
10
+ * - `structureOverrides`: true when safe `variant.structureImplementations` exist.
11
+ * - `structureRoots`: array of directories from `variant.structureImplementations`.
12
+ * - `platformAdapter`: active adapter for platform-specific behavior.
13
+ */
14
+
15
+ import { resolveProjectConfig } from './project-config.js';
16
+
17
+ /**
18
+ * Resolve environment details for the current project.
19
+ *
20
+ * @returns {{
21
+ * projectDir: string,
22
+ * srcDir: string,
23
+ * srcExists: boolean,
24
+ * platform: 'drupal' | 'generic' | string,
25
+ * SDC: boolean,
26
+ * structureOverrides: boolean,
27
+ * structureRoots: string[],
28
+ * structureImplementations: Array<{name: string, directory: string}>,
29
+ * componentRoots: string[],
30
+ * globalRoots: string[],
31
+ * namespaceRoots: Record<string, string>,
32
+ * outputStrategy: string,
33
+ * projectStructure: object,
34
+ * platformAdapter: object
35
+ * }}
36
+ */
37
+ export function resolveEnvironment() {
38
+ return resolveProjectConfig(process.cwd(), process.env);
39
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @file Platform adapter definitions for Emulsify project behavior.
3
+ *
4
+ * Adapters expose platform-specific defaults as serializable data so the same
5
+ * decisions can be used by Node-side Vite config and Storybook browser code.
6
+ */
7
+
8
+ const genericAdapter = {
9
+ name: 'generic',
10
+ outputStrategy: 'dist',
11
+ storybook: {
12
+ loadDrupalBehaviorShim: false,
13
+ attachDrupalBehaviors: false,
14
+ registerDrupalTwigFilters: false,
15
+ loadMirroredComponentCss: false,
16
+ allowSyncXhrSource: false,
17
+ },
18
+ build: {
19
+ mirrorDistComponentsToRoot: false,
20
+ },
21
+ };
22
+
23
+ const drupalAdapter = {
24
+ name: 'drupal',
25
+ outputStrategy: 'drupal-sdc',
26
+ storybook: {
27
+ loadDrupalBehaviorShim: true,
28
+ attachDrupalBehaviors: true,
29
+ registerDrupalTwigFilters: true,
30
+ loadMirroredComponentCss: true,
31
+ allowSyncXhrSource: false,
32
+ },
33
+ build: {
34
+ mirrorDistComponentsToRoot: true,
35
+ },
36
+ };
37
+
38
+ const adapters = {
39
+ generic: genericAdapter,
40
+ drupal: drupalAdapter,
41
+ };
42
+
43
+ /**
44
+ * Deep-clone an adapter so callers can safely serialize or extend it.
45
+ *
46
+ * @param {object} adapter - Adapter definition.
47
+ * @returns {object} Adapter clone.
48
+ */
49
+ function cloneAdapter(adapter) {
50
+ return JSON.parse(JSON.stringify(adapter));
51
+ }
52
+
53
+ /**
54
+ * Resolve the platform adapter for a normalized platform name.
55
+ *
56
+ * Unknown platforms intentionally use generic behavior while preserving the
57
+ * resolved `platform` string separately on the environment object.
58
+ *
59
+ * @param {string} [platform='generic'] - Normalized platform name.
60
+ * @returns {object} Serializable platform adapter.
61
+ */
62
+ export function getPlatformAdapter(platform = 'generic') {
63
+ const key = (platform || 'generic').toString().toLowerCase().trim();
64
+ if (key === 'drupal') {
65
+ return cloneAdapter(drupalAdapter);
66
+ }
67
+ return cloneAdapter(genericAdapter);
68
+ }
69
+
70
+ export { adapters };
@@ -0,0 +1,76 @@
1
+ /**
2
+ * @file Static source asset copy plugin.
3
+ *
4
+ * Copies non-code source assets beside the JS/CSS/Twig output that references
5
+ * them, preserving component and global routing semantics.
6
+ */
7
+
8
+ import { copyFileSync, mkdirSync } from 'fs';
9
+ import { dirname, join } from 'path';
10
+
11
+ import {
12
+ copiedComponentOutputPath,
13
+ copiedGlobalOutputPath,
14
+ findSourceRoot,
15
+ } from '../project-structure.js';
16
+ import {
17
+ createSourceFileIndex,
18
+ isStaticSourceAsset,
19
+ } from './source-file-index.js';
20
+
21
+ /**
22
+ * Copy non-code assets from source roots to `dist/`.
23
+ *
24
+ * @param {{ structure: object, sourceFileIndex?: object }} opts - Plugin options.
25
+ * @returns {import('vite').PluginOption} Copy plugin.
26
+ */
27
+ export function copyAllSrcAssetsPlugin({
28
+ structure,
29
+ sourceFileIndex = createSourceFileIndex(structure),
30
+ }) {
31
+ let outDir = 'dist';
32
+
33
+ const copyToOutDir = (absPath, relDest) => {
34
+ if (!relDest) return;
35
+ const destPath = join(outDir, relDest);
36
+ mkdirSync(dirname(destPath), { recursive: true });
37
+ try {
38
+ copyFileSync(absPath, destPath);
39
+ } catch {
40
+ /* noop */
41
+ }
42
+ };
43
+
44
+ return {
45
+ name: 'emulsify-copy-all-src-assets',
46
+ apply: 'build',
47
+ enforce: 'post',
48
+
49
+ /** Capture outDir. */
50
+ configResolved(cfg) {
51
+ outDir = cfg.build?.outDir || 'dist';
52
+ },
53
+
54
+ /** Copy before the mirror plugin moves dist/components to the project root. */
55
+ writeBundle() {
56
+ for (const file of sourceFileIndex.componentFiles()) {
57
+ if (!isStaticSourceAsset(file.absPath)) continue;
58
+ copyToOutDir(
59
+ file.absPath,
60
+ copiedComponentOutputPath(file.absPath, structure),
61
+ );
62
+ }
63
+
64
+ for (const file of sourceFileIndex.globalFiles()) {
65
+ if (!isStaticSourceAsset(file.absPath)) continue;
66
+ if (findSourceRoot(file.absPath, structure.componentRootRecords)) {
67
+ continue;
68
+ }
69
+ copyToOutDir(
70
+ file.absPath,
71
+ copiedGlobalOutputPath(file.absPath, structure),
72
+ );
73
+ }
74
+ },
75
+ };
76
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @file Twig template and component metadata copy plugin.
3
+ *
4
+ * Copies canonical source Twig files and component metadata to the emitted dist
5
+ * structure using the same routing rules as compiled JS and CSS entries.
6
+ */
7
+
8
+ import { copyFileSync, mkdirSync } from 'fs';
9
+ import { dirname, join } from 'path';
10
+
11
+ import {
12
+ copiedComponentOutputPath,
13
+ copiedGlobalOutputPath,
14
+ } from '../project-structure.js';
15
+ import {
16
+ createSourceFileIndex,
17
+ isComponentMetadataFile,
18
+ } from './source-file-index.js';
19
+
20
+ /** Determine whether a Twig file is a partial (filename starts with `_`). */
21
+ const isPartial = (filePath) =>
22
+ (filePath.split('/')?.pop() || '').trim().startsWith('_');
23
+
24
+ /**
25
+ * Copy Twig templates and component metadata to `dist/`.
26
+ *
27
+ * @param {{ structure: object, sourceFileIndex?: object }} opts - Plugin options.
28
+ * @returns {import('vite').PluginOption} Copy plugin.
29
+ */
30
+ export function copyTwigFilesPlugin({
31
+ structure,
32
+ sourceFileIndex = createSourceFileIndex(structure),
33
+ }) {
34
+ let outDir = 'dist';
35
+
36
+ const copyToOutDir = (absPath, relDest) => {
37
+ if (!relDest) return;
38
+ const destPath = join(outDir, relDest);
39
+ mkdirSync(dirname(destPath), { recursive: true });
40
+ try {
41
+ copyFileSync(absPath, destPath);
42
+ } catch {
43
+ /* noop */
44
+ }
45
+ };
46
+
47
+ return {
48
+ name: 'emulsify-copy-twig-files',
49
+ apply: 'build',
50
+ enforce: 'post',
51
+
52
+ /** Capture the final outDir. */
53
+ configResolved(cfg) {
54
+ outDir = cfg.build?.outDir || 'dist';
55
+ },
56
+
57
+ /** Copy before the mirror plugin moves dist/components to the project root. */
58
+ writeBundle() {
59
+ for (const file of sourceFileIndex.componentFiles()) {
60
+ if (file.absPath.endsWith('.twig')) {
61
+ if (isPartial(file.relPath)) continue;
62
+ copyToOutDir(
63
+ file.absPath,
64
+ copiedComponentOutputPath(file.absPath, structure),
65
+ );
66
+ } else if (isComponentMetadataFile(file.absPath)) {
67
+ copyToOutDir(
68
+ file.absPath,
69
+ copiedComponentOutputPath(file.absPath, structure),
70
+ );
71
+ }
72
+ }
73
+
74
+ for (const file of sourceFileIndex.globalFiles()) {
75
+ if (!file.absPath.endsWith('.twig')) continue;
76
+ if (isPartial(file.relPath)) continue;
77
+ copyToOutDir(
78
+ file.absPath,
79
+ copiedGlobalOutputPath(file.absPath, structure),
80
+ );
81
+ }
82
+ },
83
+ };
84
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @file CSS asset URL relativizer plugin.
3
+ *
4
+ * Rewrites emitted CSS references to root assets so nested CSS files can keep
5
+ * resolving copied assets correctly from their final output directories.
6
+ */
7
+
8
+ import { posix as pathPosix } from 'path';
9
+
10
+ /**
11
+ * Rewrites any `url(assets/...)` found in emitted CSS to a path relative to the
12
+ * CSS file's directory.
13
+ *
14
+ * @param {{ assetsRoot?: string }} [opts] - Plugin options.
15
+ * @returns {import('vite').PluginOption} CSS asset URL plugin.
16
+ */
17
+ export function cssAssetUrlRelativizer({ assetsRoot = 'assets' } = {}) {
18
+ return {
19
+ name: 'emulsify-css-asset-url-relativizer',
20
+ apply: 'build',
21
+ generateBundle(_, bundle) {
22
+ for (const [fileName, chunk] of Object.entries(bundle)) {
23
+ if (chunk.type !== 'asset') continue;
24
+ if (!fileName.endsWith('.css')) continue;
25
+ if (typeof chunk.source !== 'string') continue;
26
+
27
+ const fromDir = pathPosix.dirname(fileName);
28
+
29
+ chunk.source = chunk.source.replace(
30
+ /url\((['"]?)\/?assets\/([^)'"]+)\1\)/g,
31
+ (match, quote = '', rest) => {
32
+ const target = pathPosix.join(assetsRoot, rest);
33
+ const rel = pathPosix.relative(fromDir, target);
34
+ return `url(${quote}${rel}${quote})`;
35
+ },
36
+ );
37
+ }
38
+ },
39
+ };
40
+ }