@emulsify/core 3.4.1 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +10 -7
  6. package/.storybook/main.js +417 -65
  7. package/.storybook/manager.js +11 -18
  8. package/.storybook/preview.js +93 -37
  9. package/.storybook/utils.js +70 -69
  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 +5 -0
  14. package/config/eslint.config.js +6 -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 +168 -88
  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 +52 -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 -36
  93. package/.storybook/polyfills/twig-resolver.js +0 -68
  94. package/.storybook/polyfills/twig-source.js +0 -54
  95. package/.storybook/webpack.config.js +0 -193
  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 -17
  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 -268
  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
package/scripts/a11y.js CHANGED
@@ -5,25 +5,74 @@
5
5
  * and reports issues.
6
6
  */
7
7
 
8
- const R = require('ramda');
9
- const path = require('path');
10
- const pa11y = require('pa11y');
8
+ import { existsSync } from 'fs';
9
+ import path from 'path';
10
+ import { fileURLToPath, pathToFileURL } from 'url';
11
+ import * as R from 'ramda';
12
+ import pa11y from 'pa11y';
11
13
 
12
- const {
13
- storybookBuildDir,
14
- pa11y: pa11yConfig,
15
- } = require('../config/a11y.config.js');
14
+ import a11yConfig from '../config/a11y.config.js';
15
+
16
+ const __filename = fileURLToPath(import.meta.url);
17
+ const __dirname = path.dirname(__filename);
18
+
19
+ const { storybookBuildDir, pa11y: pa11yConfig } = a11yConfig;
16
20
 
17
21
  // Project-specific configuration.
18
- const {
19
- ignore,
20
- components,
21
- } = require('../../../config/emulsify-core/a11y.config.js');
22
+ let { ignore = {}, components = [] } = a11yConfig;
22
23
 
23
24
  /** Absolute path to Storybook build directory. */
24
25
  const STORYBOOK_BUILD_DIR = path.resolve(__dirname, '../', storybookBuildDir);
25
26
  /** Absolute path to Storybook iframe file used for per-story rendering. */
26
27
  const STORYBOOK_IFRAME = path.join(STORYBOOK_BUILD_DIR, 'iframe.html');
28
+ /** Project-specific accessibility config path used by generated themes. */
29
+ const PROJECT_A11Y_CONFIG = path.resolve(
30
+ __dirname,
31
+ '../../../config/emulsify-core/a11y.config.js',
32
+ );
33
+
34
+ /**
35
+ * Load project-specific accessibility config when a consuming project provides one.
36
+ *
37
+ * @returns {Promise<object>} Project accessibility config, when present.
38
+ */
39
+ const loadProjectA11yConfig = async () => {
40
+ if (!existsSync(PROJECT_A11Y_CONFIG)) {
41
+ return {};
42
+ }
43
+
44
+ const configModule = await import(pathToFileURL(PROJECT_A11Y_CONFIG).href);
45
+ return configModule.default || configModule;
46
+ };
47
+
48
+ /**
49
+ * Apply project-specific a11y config values over shared defaults.
50
+ *
51
+ * @param {{ignore?: object, components?: string[]}} config - Project config.
52
+ * @returns {void}
53
+ */
54
+ const applyProjectA11yConfig = (config = {}) => {
55
+ ignore = config.ignore || ignore;
56
+ components = config.components || components;
57
+ };
58
+
59
+ /**
60
+ * Print CLI help.
61
+ *
62
+ * @returns {void}
63
+ */
64
+ const printHelp = () => {
65
+ // eslint-disable-next-line no-console
66
+ console.log(
67
+ [
68
+ 'Usage: node scripts/a11y.js [options]',
69
+ '',
70
+ 'Options:',
71
+ ' -r Run pa11y against configured Storybook component IDs.',
72
+ ' -h, --help Print this help text.',
73
+ ].join('\n'),
74
+ );
75
+ };
27
76
 
28
77
  /**
29
78
  * Map pa11y/axe severity to a label (historically a color name).
@@ -56,7 +105,8 @@ const severityToColor = R.cond([
56
105
  const issueIsValid = (issue) => {
57
106
  const code = issue?.code;
58
107
  const description = issue?.runnerExtras?.description;
59
- const codeIgnored = Array.isArray(ignore?.codes) && ignore.codes.includes(code);
108
+ const codeIgnored =
109
+ Array.isArray(ignore?.codes) && ignore.codes.includes(code);
60
110
  const descIgnored =
61
111
  description &&
62
112
  Array.isArray(ignore?.descriptions) &&
@@ -137,12 +187,16 @@ const lintReportAndExit = R.pipe(
137
187
 
138
188
  // Only perform linting/reporting when instructed via "-r".
139
189
  /* istanbul ignore next */
140
- if (R.pathEq(['argv', 2], '-r')(process)) {
141
- // eslint-disable-next-line promise/catch-or-return
142
- lintReportAndExit(components);
190
+ if (R.includes(process.argv[2], ['-h', '--help'])) {
191
+ printHelp();
192
+ } else if (R.pathEq(['argv', 2], '-r')(process)) {
193
+ loadProjectA11yConfig().then((projectConfig) => {
194
+ applyProjectA11yConfig(projectConfig);
195
+ return lintReportAndExit(components);
196
+ });
143
197
  }
144
198
 
145
- module.exports = {
199
+ export {
146
200
  severityToColor,
147
201
  issueIsValid,
148
202
  logIssue,
@@ -0,0 +1,378 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * @file Report legacy Twig Storybook stories that should migrate to renderTwig().
5
+ */
6
+
7
+ import { existsSync, readFileSync } from 'node:fs';
8
+ import { relative, resolve } from 'node:path';
9
+ import { globSync } from 'glob';
10
+ import { resolveProjectConfig } from '../config/vite/project-config.js';
11
+ import { toPosixPath } from '../config/vite/utils/paths.js';
12
+
13
+ const STORY_GLOB = '**/*.stories.{js,jsx,ts,tsx}';
14
+ const IDENTIFIER_PATTERN = '[A-Za-z_$][\\w$]*';
15
+ const DEFAULT_IGNORES = [
16
+ '**/node_modules/**',
17
+ '**/dist/**',
18
+ '**/.out/**',
19
+ '**/.coverage/**',
20
+ ];
21
+
22
+ /**
23
+ * Escape a string for use inside a regular expression.
24
+ *
25
+ * @param {string} value - Raw string.
26
+ * @returns {string} Escaped string.
27
+ */
28
+ function escapeRegExp(value) {
29
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
30
+ }
31
+
32
+ /**
33
+ * Find the 1-based line number for a character index.
34
+ *
35
+ * @param {string} source - File source.
36
+ * @param {number} index - Character index.
37
+ * @returns {number} 1-based line number.
38
+ */
39
+ function lineNumberAt(source, index) {
40
+ return source.slice(0, index).split('\n').length;
41
+ }
42
+
43
+ /**
44
+ * Find imported Twig template identifiers.
45
+ *
46
+ * @param {string} source - Story source.
47
+ * @returns {{name: string, specifier: string, line: number}[]} Twig imports.
48
+ */
49
+ export function findTwigImports(source) {
50
+ const imports = [];
51
+ const patterns = [
52
+ new RegExp(
53
+ `import\\s+(${IDENTIFIER_PATTERN})\\s+from\\s+['"]([^'"]+\\.twig(?:\\?[^'"]*)?)['"]`,
54
+ 'g',
55
+ ),
56
+ new RegExp(
57
+ `(?:const|let|var)\\s+(${IDENTIFIER_PATTERN})\\s*=\\s*require\\(\\s*['"]([^'"]+\\.twig(?:\\?[^'"]*)?)['"]\\s*\\)`,
58
+ 'g',
59
+ ),
60
+ ];
61
+
62
+ for (const pattern of patterns) {
63
+ for (const match of source.matchAll(pattern)) {
64
+ imports.push({
65
+ name: match[1],
66
+ specifier: match[2],
67
+ line: lineNumberAt(source, match.index || 0),
68
+ });
69
+ }
70
+ }
71
+
72
+ return imports;
73
+ }
74
+
75
+ /**
76
+ * Determine whether a story imports renderTwig from Emulsify Core.
77
+ *
78
+ * @param {string} source - Story source.
79
+ * @returns {boolean} TRUE when renderTwig is imported from the public helper.
80
+ */
81
+ export function importsRenderTwig(source) {
82
+ return (
83
+ /\brenderTwig\b/.test(source) &&
84
+ /from\s+['"]@emulsify\/core\/storybook['"]/.test(source)
85
+ );
86
+ }
87
+
88
+ /**
89
+ * Find likely direct returns of imported Twig template functions.
90
+ *
91
+ * @param {string} source - Story source.
92
+ * @param {string[]} templateNames - Imported Twig template identifiers.
93
+ * @returns {{name: string, line: number}[]} Direct template calls.
94
+ */
95
+ export function findDirectTemplateReturns(source, templateNames = []) {
96
+ const calls = [];
97
+
98
+ for (const templateName of templateNames) {
99
+ const pattern = new RegExp(
100
+ `(?:return\\s+|=>\\s*(?:\\(\\s*)?)${escapeRegExp(templateName)}\\s*\\(`,
101
+ 'g',
102
+ );
103
+
104
+ for (const match of source.matchAll(pattern)) {
105
+ calls.push({
106
+ name: templateName,
107
+ line: lineNumberAt(source, match.index || 0),
108
+ });
109
+ }
110
+ }
111
+
112
+ return calls;
113
+ }
114
+
115
+ /**
116
+ * Analyze one Storybook story source string.
117
+ *
118
+ * @param {string} source - Story source.
119
+ * @param {string} [filePath=''] - Story file path.
120
+ * @returns {object} Story analysis.
121
+ */
122
+ export function analyzeStorySource(source, filePath = '') {
123
+ const twigImports = findTwigImports(source);
124
+ const templateNames = twigImports.map((item) => item.name);
125
+ const hasRenderTwig = importsRenderTwig(source);
126
+ const directTemplateReturns = findDirectTemplateReturns(
127
+ source,
128
+ templateNames,
129
+ );
130
+ const reasons = [];
131
+
132
+ if (!twigImports.length) {
133
+ return {
134
+ filePath,
135
+ twigImports,
136
+ hasRenderTwig,
137
+ directTemplateReturns,
138
+ reasons,
139
+ shouldUpgrade: false,
140
+ };
141
+ }
142
+
143
+ if (!hasRenderTwig) {
144
+ reasons.push('imports Twig templates without renderTwig()');
145
+ }
146
+
147
+ if (directTemplateReturns.length) {
148
+ reasons.push('appears to return Twig HTML strings directly');
149
+ }
150
+
151
+ return {
152
+ filePath,
153
+ twigImports,
154
+ hasRenderTwig,
155
+ directTemplateReturns,
156
+ reasons,
157
+ shouldUpgrade: reasons.length > 0,
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Resolve Storybook source roots for the project.
163
+ *
164
+ * @param {string} projectDir - Absolute project root.
165
+ * @returns {string[]} Absolute story roots.
166
+ */
167
+ export function resolveStoryRoots(projectDir) {
168
+ try {
169
+ const env = resolveProjectConfig(projectDir, process.env);
170
+ const storyRoots = env.projectStructure?.storyRoots;
171
+ if (Array.isArray(storyRoots) && storyRoots.length) {
172
+ return storyRoots;
173
+ }
174
+ } catch {
175
+ // Fall back to conventional roots when project config is absent or invalid.
176
+ }
177
+
178
+ return [resolve(projectDir, 'src'), resolve(projectDir, 'components')];
179
+ }
180
+
181
+ /**
182
+ * Collect Storybook story files from normalized project roots.
183
+ *
184
+ * @param {string} projectDir - Absolute project root.
185
+ * @returns {string[]} Absolute story file paths.
186
+ */
187
+ export function collectStoryFiles(projectDir) {
188
+ const files = new Set();
189
+
190
+ for (const root of resolveStoryRoots(projectDir)) {
191
+ if (!existsSync(root)) continue;
192
+
193
+ for (const match of globSync(STORY_GLOB, {
194
+ cwd: root,
195
+ nodir: true,
196
+ absolute: true,
197
+ ignore: DEFAULT_IGNORES,
198
+ })) {
199
+ files.add(resolve(match));
200
+ }
201
+ }
202
+
203
+ return Array.from(files).sort();
204
+ }
205
+
206
+ /**
207
+ * Analyze all discovered story files in a project.
208
+ *
209
+ * @param {{projectDir?: string}} [options={}] - Audit options.
210
+ * @returns {{projectDir: string, files: string[], findings: object[]}} Results.
211
+ */
212
+ export function auditTwigStories(options = {}) {
213
+ const projectDir = resolve(options.projectDir || process.cwd());
214
+ const files = collectStoryFiles(projectDir);
215
+ const findings = files
216
+ .map((filePath) => {
217
+ const source = readFileSync(filePath, 'utf8');
218
+ return analyzeStorySource(source, filePath);
219
+ })
220
+ .filter((result) => result.shouldUpgrade);
221
+
222
+ return {
223
+ projectDir,
224
+ files,
225
+ findings,
226
+ };
227
+ }
228
+
229
+ /**
230
+ * Format audit findings for terminal output.
231
+ *
232
+ * @param {{projectDir: string, files: string[], findings: object[]}} result
233
+ * Audit result.
234
+ * @returns {string} Human-readable report.
235
+ */
236
+ export function formatAuditReport(result) {
237
+ const lines = [
238
+ 'Twig story migration audit',
239
+ `Scanned ${result.files.length} story file(s).`,
240
+ ];
241
+
242
+ if (!result.findings.length) {
243
+ lines.push('No legacy Twig story candidates found.');
244
+ return lines.join('\n');
245
+ }
246
+
247
+ lines.push(
248
+ `Found ${result.findings.length} story file(s) that should be reviewed:`,
249
+ );
250
+
251
+ for (const finding of result.findings) {
252
+ const relPath = toPosixPath(relative(result.projectDir, finding.filePath));
253
+ const importNames = finding.twigImports.map((item) => item.name).join(', ');
254
+
255
+ lines.push('', `- ${relPath}`);
256
+ lines.push(` Twig imports: ${importNames}`);
257
+ for (const reason of finding.reasons) {
258
+ lines.push(` Reason: ${reason}.`);
259
+ }
260
+ for (const call of finding.directTemplateReturns) {
261
+ lines.push(
262
+ ` Line ${call.line}: ${call.name}() appears to be returned directly.`,
263
+ );
264
+ }
265
+ }
266
+
267
+ lines.push(
268
+ '',
269
+ 'Suggested migration: import { renderTwig } from "@emulsify/core/storybook" and move any argument mapping into renderTwig(template, { context }).',
270
+ );
271
+
272
+ return lines.join('\n');
273
+ }
274
+
275
+ /**
276
+ * Parse command-line arguments.
277
+ *
278
+ * @param {string[]} argv - CLI arguments.
279
+ * @returns {{projectDir: string, failOnFound: boolean, json: boolean, help: boolean}}
280
+ * Parsed options.
281
+ */
282
+ function parseArgs(argv) {
283
+ const options = {
284
+ projectDir: process.cwd(),
285
+ failOnFound: false,
286
+ json: false,
287
+ help: false,
288
+ };
289
+
290
+ for (let index = 0; index < argv.length; index += 1) {
291
+ const arg = argv[index];
292
+
293
+ if (arg === '--help' || arg === '-h') {
294
+ options.help = true;
295
+ continue;
296
+ }
297
+ if (arg === '--fail-on-found') {
298
+ options.failOnFound = true;
299
+ continue;
300
+ }
301
+ if (arg === '--json') {
302
+ options.json = true;
303
+ continue;
304
+ }
305
+ if (arg === '--root') {
306
+ const value = argv[index + 1];
307
+ if (!value || value.startsWith('--')) {
308
+ throw new Error('--root requires a project directory.');
309
+ }
310
+ options.projectDir = value;
311
+ index += 1;
312
+ continue;
313
+ }
314
+ if (arg.startsWith('--root=')) {
315
+ options.projectDir = arg.slice('--root='.length);
316
+ continue;
317
+ }
318
+
319
+ throw new Error(`Unknown option: ${arg}`);
320
+ }
321
+
322
+ return options;
323
+ }
324
+
325
+ /**
326
+ * CLI usage text.
327
+ *
328
+ * @returns {string} Usage text.
329
+ */
330
+ function usage() {
331
+ return [
332
+ 'Usage: emulsify-audit-twig-stories [--root <dir>] [--json] [--fail-on-found]',
333
+ '',
334
+ 'Options:',
335
+ ' --root <dir> Project root to scan. Defaults to the current directory.',
336
+ ' --json Print machine-readable JSON.',
337
+ ' --fail-on-found Exit with code 1 when migration candidates are found.',
338
+ ' --help Print this help text.',
339
+ ].join('\n');
340
+ }
341
+
342
+ /**
343
+ * Run the CLI.
344
+ *
345
+ * @param {string[]} argv - CLI arguments.
346
+ * @returns {number} Process exit code.
347
+ */
348
+ export function runCli(argv = process.argv.slice(2)) {
349
+ const options = parseArgs(argv);
350
+
351
+ if (options.help) {
352
+ console.log(usage());
353
+ return 0;
354
+ }
355
+
356
+ const result = auditTwigStories({
357
+ projectDir: options.projectDir,
358
+ });
359
+
360
+ if (options.json) {
361
+ console.log(JSON.stringify(result, null, 2));
362
+ } else {
363
+ console.log(formatAuditReport(result));
364
+ }
365
+
366
+ return options.failOnFound && result.findings.length ? 1 : 0;
367
+ }
368
+
369
+ if (process.argv[1]?.split(/[\\/]/).pop() === 'audit-twig-stories.js') {
370
+ try {
371
+ process.exitCode = runCli();
372
+ } catch (error) {
373
+ console.error(error.message || error);
374
+ console.error('');
375
+ console.error(usage());
376
+ process.exitCode = 1;
377
+ }
378
+ }