shakapacker 9.2.0 → 9.3.0.beta.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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/.github/ISSUE_TEMPLATE/bug_report.md +6 -9
  3. data/.github/ISSUE_TEMPLATE/feature_request.md +6 -8
  4. data/.github/workflows/claude-code-review.yml +4 -5
  5. data/.github/workflows/claude.yml +1 -2
  6. data/.github/workflows/dummy.yml +4 -4
  7. data/.github/workflows/generator.yml +9 -9
  8. data/.github/workflows/node.yml +11 -2
  9. data/.github/workflows/ruby.yml +16 -16
  10. data/.github/workflows/test-bundlers.yml +9 -9
  11. data/.gitignore +4 -0
  12. data/CHANGELOG.md +19 -4
  13. data/CLAUDE.md +6 -1
  14. data/CONTRIBUTING.md +0 -1
  15. data/Gemfile.lock +1 -1
  16. data/README.md +14 -14
  17. data/TODO.md +10 -2
  18. data/TODO_v9.md +13 -3
  19. data/bin/export-bundler-config +1 -1
  20. data/conductor-setup.sh +1 -1
  21. data/conductor.json +1 -1
  22. data/docs/cdn_setup.md +13 -8
  23. data/docs/common-upgrades.md +2 -1
  24. data/docs/configuration.md +630 -0
  25. data/docs/css-modules-export-mode.md +120 -100
  26. data/docs/customizing_babel_config.md +16 -16
  27. data/docs/deployment.md +18 -0
  28. data/docs/developing_shakapacker.md +6 -0
  29. data/docs/optional-peer-dependencies.md +9 -4
  30. data/docs/peer-dependencies.md +17 -6
  31. data/docs/precompile_hook.md +342 -0
  32. data/docs/react.md +57 -47
  33. data/docs/releasing.md +0 -2
  34. data/docs/rspack.md +25 -21
  35. data/docs/rspack_migration_guide.md +335 -8
  36. data/docs/sprockets.md +1 -0
  37. data/docs/style_loader_vs_mini_css.md +12 -12
  38. data/docs/subresource_integrity.md +13 -7
  39. data/docs/transpiler-performance.md +40 -19
  40. data/docs/troubleshooting.md +0 -2
  41. data/docs/typescript-migration.md +48 -39
  42. data/docs/typescript.md +12 -8
  43. data/docs/using_esbuild_loader.md +10 -10
  44. data/docs/v6_upgrade.md +33 -20
  45. data/docs/v7_upgrade.md +8 -6
  46. data/docs/v8_upgrade.md +13 -12
  47. data/docs/v9_upgrade.md +2 -1
  48. data/eslint.config.fast.js +134 -0
  49. data/eslint.config.js +140 -0
  50. data/knip.ts +54 -0
  51. data/lib/install/bin/export-bundler-config +1 -1
  52. data/lib/install/config/shakapacker.yml +16 -5
  53. data/lib/shakapacker/compiler.rb +80 -0
  54. data/lib/shakapacker/configuration.rb +33 -5
  55. data/lib/shakapacker/dev_server_runner.rb +140 -1
  56. data/lib/shakapacker/doctor.rb +294 -65
  57. data/lib/shakapacker/instance.rb +8 -3
  58. data/lib/shakapacker/runner.rb +244 -8
  59. data/lib/shakapacker/version.rb +1 -1
  60. data/lib/tasks/shakapacker/doctor.rake +42 -2
  61. data/package/babel/preset.ts +7 -4
  62. data/package/config.ts +42 -30
  63. data/package/configExporter/cli.ts +799 -208
  64. data/package/configExporter/configFile.ts +520 -0
  65. data/package/configExporter/fileWriter.ts +12 -8
  66. data/package/configExporter/index.ts +9 -1
  67. data/package/configExporter/types.ts +36 -2
  68. data/package/configExporter/yamlSerializer.ts +22 -8
  69. data/package/dev_server.ts +1 -1
  70. data/package/environments/__type-tests__/rspack-plugin-compatibility.ts +11 -5
  71. data/package/environments/base.ts +18 -13
  72. data/package/environments/development.ts +1 -1
  73. data/package/environments/production.ts +4 -1
  74. data/package/index.d.ts +50 -3
  75. data/package/index.d.ts.template +50 -0
  76. data/package/index.ts +7 -7
  77. data/package/loaders.d.ts +2 -2
  78. data/package/optimization/rspack.ts +1 -1
  79. data/package/plugins/rspack.ts +15 -4
  80. data/package/plugins/webpack.ts +7 -3
  81. data/package/rspack/index.ts +10 -2
  82. data/package/rules/raw.ts +3 -2
  83. data/package/rules/sass.ts +1 -1
  84. data/package/types/README.md +15 -13
  85. data/package/types/index.ts +5 -5
  86. data/package/types.ts +0 -1
  87. data/package/utils/defaultConfigPath.ts +4 -1
  88. data/package/utils/errorCodes.ts +129 -100
  89. data/package/utils/errorHelpers.ts +34 -29
  90. data/package/utils/getStyleRule.ts +5 -2
  91. data/package/utils/helpers.ts +21 -11
  92. data/package/utils/pathValidation.ts +43 -35
  93. data/package/utils/requireOrError.ts +1 -1
  94. data/package/utils/snakeToCamelCase.ts +1 -1
  95. data/package/utils/typeGuards.ts +132 -83
  96. data/package/utils/validateDependencies.ts +1 -1
  97. data/package/webpack-types.d.ts +3 -3
  98. data/package/webpackDevServerConfig.ts +22 -10
  99. data/package-lock.json +2 -2
  100. data/package.json +36 -28
  101. data/scripts/type-check-no-emit.js +1 -1
  102. data/test/configExporter/configFile.test.js +392 -0
  103. data/test/configExporter/integration.test.js +275 -0
  104. data/test/helpers.js +1 -1
  105. data/test/package/configExporter.test.js +154 -0
  106. data/test/package/helpers.test.js +2 -2
  107. data/test/package/rules/sass-version-parsing.test.js +71 -0
  108. data/test/package/rules/sass.test.js +2 -4
  109. data/test/package/rules/sass1.test.js +1 -3
  110. data/test/package/rules/sass16.test.js +23 -0
  111. data/tools/README.md +15 -5
  112. data/tsconfig.eslint.json +2 -9
  113. data/yarn.lock +1894 -1492
  114. metadata +19 -3
  115. data/.eslintignore +0 -5
@@ -0,0 +1,520 @@
1
+ import { existsSync, readFileSync, realpathSync } from "fs"
2
+ import { resolve, relative, isAbsolute } from "path"
3
+ import { load as loadYaml, FAILSAFE_SCHEMA } from "js-yaml"
4
+ import {
5
+ BundlerConfigFile,
6
+ BuildConfig,
7
+ ResolvedBuildConfig,
8
+ ExportOptions
9
+ } from "./types"
10
+
11
+ /**
12
+ * Loads and validates bundler configuration files
13
+ * @example
14
+ * const loader = new ConfigFileLoader('.bundler-config.yml')
15
+ * const config = loader.load()
16
+ */
17
+ export class ConfigFileLoader {
18
+ private configFilePath: string
19
+
20
+ /**
21
+ * @param configFilePath - Path to config file (defaults to .bundler-config.yml in cwd)
22
+ * @throws Error if path is outside project directory
23
+ */
24
+ constructor(configFilePath?: string) {
25
+ this.configFilePath =
26
+ configFilePath || resolve(process.cwd(), ".bundler-config.yml")
27
+ this.validateConfigPath()
28
+ }
29
+
30
+ /**
31
+ * Validates that the config file path is within the project directory
32
+ * to prevent path traversal attacks (including symlink traversal)
33
+ * @throws Error if path traversal is detected
34
+ */
35
+ private validateConfigPath(): void {
36
+ const absPath = resolve(this.configFilePath)
37
+ const cwd = process.cwd()
38
+
39
+ // Resolve symlinks to get the real path
40
+ let realPath: string
41
+ try {
42
+ // Only resolve symlinks if the file exists
43
+ if (existsSync(absPath)) {
44
+ realPath = realpathSync(absPath)
45
+ } else {
46
+ // If file doesn't exist yet, just use the resolved path
47
+ realPath = absPath
48
+ }
49
+ } catch (error) {
50
+ // If we can't resolve the path, use the original
51
+ realPath = absPath
52
+ }
53
+
54
+ const rel = relative(cwd, realPath)
55
+
56
+ if (
57
+ rel.startsWith("..") ||
58
+ (isAbsolute(rel) && !realPath.startsWith(cwd))
59
+ ) {
60
+ throw new Error(
61
+ `Config file must be within project directory. Attempted path: ${this.configFilePath}`
62
+ )
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Checks if the config file exists
68
+ * @returns true if file exists, false otherwise
69
+ */
70
+ exists(): boolean {
71
+ return existsSync(this.configFilePath)
72
+ }
73
+
74
+ /**
75
+ * Loads and validates the config file
76
+ * @returns Parsed and validated config file
77
+ * @throws Error if file doesn't exist, is invalid YAML, or fails validation
78
+ */
79
+ load(): BundlerConfigFile {
80
+ if (!this.exists()) {
81
+ throw new Error(
82
+ `Config file not found: ${this.configFilePath}\n` +
83
+ `Run 'bin/export-bundler-config --init' to generate a sample config file.`
84
+ )
85
+ }
86
+
87
+ try {
88
+ const content = readFileSync(this.configFilePath, "utf8")
89
+ // Use FAILSAFE_SCHEMA to prevent code execution via YAML parsing
90
+ const parsed = loadYaml(content, {
91
+ schema: FAILSAFE_SCHEMA,
92
+ json: true
93
+ }) as BundlerConfigFile
94
+
95
+ this.validate(parsed)
96
+ return parsed
97
+ } catch (error: any) {
98
+ throw new Error(
99
+ `Failed to load config file ${this.configFilePath}: ${error.message}`
100
+ )
101
+ }
102
+ }
103
+
104
+ private validate(config: BundlerConfigFile): void {
105
+ if (!config.builds || typeof config.builds !== "object") {
106
+ throw new Error("Config file must contain a 'builds' object")
107
+ }
108
+
109
+ if (Object.keys(config.builds).length === 0) {
110
+ throw new Error("Config file must contain at least one build")
111
+ }
112
+
113
+ if (
114
+ config.default_bundler &&
115
+ config.default_bundler !== "webpack" &&
116
+ config.default_bundler !== "rspack"
117
+ ) {
118
+ throw new Error(
119
+ `Invalid default_bundler '${config.default_bundler}'. Must be 'webpack' or 'rspack'.`
120
+ )
121
+ }
122
+
123
+ // Validate each build
124
+ for (const [name, build] of Object.entries(config.builds)) {
125
+ // Guard: ensure build is a non-null plain object
126
+ if (build == null || typeof build !== "object" || Array.isArray(build)) {
127
+ throw new Error(
128
+ `Invalid build '${name}': must be an object, got ${build === null ? "null" : Array.isArray(build) ? "array" : typeof build}`
129
+ )
130
+ }
131
+
132
+ if (
133
+ build.bundler &&
134
+ build.bundler !== "webpack" &&
135
+ build.bundler !== "rspack"
136
+ ) {
137
+ throw new Error(
138
+ `Invalid bundler '${build.bundler}' in build '${name}'. Must be 'webpack' or 'rspack'.`
139
+ )
140
+ }
141
+
142
+ if (build.bundler_env && typeof build.bundler_env !== "object") {
143
+ throw new Error(
144
+ `Invalid bundler_env in build '${name}'. Must be an object.`
145
+ )
146
+ }
147
+
148
+ if (build.environment && typeof build.environment !== "object") {
149
+ throw new Error(
150
+ `Invalid environment in build '${name}'. Must be an object.`
151
+ )
152
+ }
153
+
154
+ if (build.outputs && !Array.isArray(build.outputs)) {
155
+ throw new Error(
156
+ `Invalid outputs in build '${name}'. Must be an array of strings.`
157
+ )
158
+ }
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Resolves a build configuration by name
164
+ * @param buildName - Name of the build from config file
165
+ * @param options - CLI options that may override build settings
166
+ * @param defaultBundler - Fallback bundler if not specified
167
+ * @returns Resolved build configuration with all settings applied
168
+ * @throws Error if build name not found
169
+ */
170
+ resolveBuild(
171
+ buildName: string,
172
+ options: ExportOptions,
173
+ defaultBundler: "webpack" | "rspack"
174
+ ): ResolvedBuildConfig {
175
+ const config = this.load()
176
+ const build = config.builds[buildName]
177
+
178
+ if (!build) {
179
+ const available = Object.keys(config.builds).join(", ")
180
+ throw new Error(
181
+ `Build '${buildName}' not found in config file.\n` +
182
+ `Available builds: ${available}\n` +
183
+ `Use --list-builds to see all available builds.`
184
+ )
185
+ }
186
+
187
+ // Resolve bundler with precedence
188
+ const bundler = this.resolveBundler(
189
+ options.bundler,
190
+ build.bundler,
191
+ config.default_bundler,
192
+ defaultBundler
193
+ )
194
+
195
+ // Expand environment variables
196
+ const environment = this.expandEnvironmentVariables(
197
+ build.environment || {},
198
+ bundler
199
+ )
200
+
201
+ // Convert bundler_env to CLI args
202
+ const bundlerEnvArgs = this.convertBundlerEnvToArgs(build.bundler_env || {})
203
+
204
+ // Resolve and validate outputs
205
+ const outputs = build.outputs || []
206
+
207
+ // Validate edge cases
208
+ if (outputs.length === 0) {
209
+ throw new Error(
210
+ `Build '${buildName}' has empty outputs array. ` +
211
+ `Please specify at least one output type (client, server, or all).`
212
+ )
213
+ }
214
+
215
+ // Check for duplicates
216
+ const uniqueOutputs = new Set(outputs)
217
+ if (uniqueOutputs.size !== outputs.length) {
218
+ throw new Error(
219
+ `Build '${buildName}' has duplicate output types. ` +
220
+ `Each output type should appear only once.`
221
+ )
222
+ }
223
+
224
+ // Resolve config file
225
+ let configFile: string | undefined
226
+ if (build.config) {
227
+ configFile = this.expandEnvironmentVariables(
228
+ { config: build.config },
229
+ bundler
230
+ ).config
231
+
232
+ // Validate config file path (prevent path traversal)
233
+ if (configFile) {
234
+ // Normalize Windows backslashes for validation
235
+ const configFileNormalized = configFile.replace(/\\/g, "/")
236
+ if (
237
+ configFileNormalized.includes("..") ||
238
+ !configFileNormalized.startsWith("config/")
239
+ ) {
240
+ throw new Error(
241
+ `Invalid config file path in build '${buildName}': "${configFile}". ` +
242
+ `Config files must be within the config/ directory.`
243
+ )
244
+ }
245
+ }
246
+ }
247
+
248
+ return {
249
+ name: buildName,
250
+ description: build.description,
251
+ bundler,
252
+ environment,
253
+ bundlerEnvArgs,
254
+ outputs,
255
+ configFile
256
+ }
257
+ }
258
+
259
+ private resolveBundler(
260
+ cliFlag?: "webpack" | "rspack",
261
+ buildBundler?: "webpack" | "rspack",
262
+ defaultBundler?: "webpack" | "rspack",
263
+ fallback: "webpack" | "rspack" = "webpack"
264
+ ): "webpack" | "rspack" {
265
+ return cliFlag || buildBundler || defaultBundler || fallback
266
+ }
267
+
268
+ private expandEnvironmentVariables(
269
+ vars: Record<string, string>,
270
+ bundler: string
271
+ ): Record<string, string> {
272
+ const expanded: Record<string, string> = {}
273
+
274
+ for (const [key, value] of Object.entries(vars)) {
275
+ expanded[key] = this.expandString(value, bundler)
276
+ }
277
+
278
+ return expanded
279
+ }
280
+
281
+ private expandString(str: string, bundler: string): string {
282
+ // Replace \${BUNDLER} with actual bundler
283
+ let expanded = str.replace(/\$\{BUNDLER\}/g, bundler)
284
+
285
+ // Replace ${VAR:-default} with VAR value or default
286
+ expanded = expanded.replace(
287
+ /\$\{([^}:]+):-([^}]*)\}/g,
288
+ (_, varName, defaultValue) => {
289
+ // Validate env var name to prevent regex injection
290
+ if (!this.isValidEnvVarName(varName)) {
291
+ console.warn(
292
+ `[Config Exporter] Warning: Invalid environment variable name: ${varName}`
293
+ )
294
+ return `\${${varName}:-${defaultValue}}`
295
+ }
296
+ return process.env[varName] || defaultValue
297
+ }
298
+ )
299
+
300
+ // Replace ${VAR} with VAR value
301
+ expanded = expanded.replace(/\$\{([^}:]+)\}/g, (_, varName) => {
302
+ // Validate env var name to prevent regex injection
303
+ if (!this.isValidEnvVarName(varName)) {
304
+ console.warn(
305
+ `[Config Exporter] Warning: Invalid environment variable name: ${varName}`
306
+ )
307
+ return `\${${varName}}`
308
+ }
309
+ return process.env[varName] || ""
310
+ })
311
+
312
+ return expanded
313
+ }
314
+
315
+ /**
316
+ * Validates that an environment variable name matches the standard format
317
+ * Must start with letter or underscore, followed by letters, numbers, or underscores
318
+ * @param name - The variable name to validate
319
+ * @returns true if valid, false otherwise
320
+ */
321
+ private isValidEnvVarName(name: string): boolean {
322
+ return /^[A-Z_][A-Z0-9_]*$/i.test(name)
323
+ }
324
+
325
+ private convertBundlerEnvToArgs(
326
+ bundlerEnv: Record<string, string | boolean>
327
+ ): string[] {
328
+ const args: string[] = []
329
+
330
+ for (const [key, value] of Object.entries(bundlerEnv)) {
331
+ // YAML parser converts boolean true to string "true", so check both
332
+ if (value === true || value === "true") {
333
+ // Boolean true becomes --env key
334
+ args.push("--env", key)
335
+ } else if (typeof value === "string" && value !== "false") {
336
+ // String value becomes --env key=value (skip "false" strings)
337
+ args.push("--env", `${key}=${value}`)
338
+ }
339
+ // false or "false" are ignored
340
+ }
341
+
342
+ return args
343
+ }
344
+
345
+ /**
346
+ * Lists all available builds from the config file
347
+ * Prints formatted output to console
348
+ * @throws Error if config file doesn't exist or is invalid
349
+ */
350
+ listBuilds(): void {
351
+ const config = this.load()
352
+ const builds = config.builds
353
+
354
+ console.log(`\nAvailable builds in ${this.configFilePath}:\n`)
355
+
356
+ for (const [name, build] of Object.entries(builds)) {
357
+ const bundler =
358
+ build.bundler || config.default_bundler || "webpack (default)"
359
+ const outputs = build.outputs ? build.outputs.join(", ") : "auto-detect"
360
+
361
+ console.log(` ${name}`)
362
+ if (build.description) {
363
+ console.log(` Description: ${build.description}`)
364
+ }
365
+ console.log(` Bundler: ${bundler}`)
366
+ console.log(` Outputs: ${outputs}`)
367
+ console.log()
368
+ }
369
+ }
370
+ }
371
+
372
+ /**
373
+ * Generates a sample configuration file with examples and documentation
374
+ * @returns YAML content as string ready to be written to file
375
+ */
376
+ export function generateSampleConfigFile(): string {
377
+ // Using ${'$'} to escape template literal substitution in comments
378
+ return `# Bundler Build Configurations
379
+ # Generated by: bin/export-bundler-config --init
380
+ #
381
+ # This file defines build configurations for exporting bundler configs.
382
+ # You can define multiple builds with different environments and settings.
383
+
384
+ # Use these builds as defaults for --doctor mode (optional)
385
+ # When set to true, --doctor will export ALL builds defined below instead of hardcoded defaults
386
+ # shakapacker_doctor_default_builds_here: true
387
+
388
+ # Default bundler for all builds (can be overridden per-build or with --webpack/--rspack flags)
389
+ default_bundler: rspack # Options: webpack | rspack
390
+
391
+ builds:
392
+ # ============================================================================
393
+ # DEVELOPMENT WITH HMR (Hot Module Replacement)
394
+ # ============================================================================
395
+ # For Procfile.dev: WEBPACK_SERVE=true bin/shakapacker-dev-server
396
+ # Creates client bundle with React Fast Refresh enabled
397
+
398
+ dev-hmr:
399
+ description: Client bundle with HMR (React Fast Refresh)
400
+ environment:
401
+ NODE_ENV: development
402
+ RAILS_ENV: development
403
+ WEBPACK_SERVE: "true"
404
+ outputs:
405
+ - client
406
+
407
+ # ============================================================================
408
+ # DEVELOPMENT (Standard)
409
+ # ============================================================================
410
+ # For Procfile.dev-static-assets: bin/shakapacker --watch
411
+ # Creates both client and server bundles without HMR
412
+
413
+ dev:
414
+ description: Development client and server bundles (no HMR)
415
+ environment:
416
+ NODE_ENV: development
417
+ RAILS_ENV: development
418
+ outputs:
419
+ - client
420
+ - server
421
+
422
+ # ============================================================================
423
+ # PRODUCTION
424
+ # ============================================================================
425
+ # For asset precompilation: RAILS_ENV=production bin/shakapacker
426
+ # Creates optimized production bundles
427
+
428
+ prod:
429
+ description: Production client and server bundles
430
+ environment:
431
+ NODE_ENV: production
432
+ RAILS_ENV: production
433
+ outputs:
434
+ - client
435
+ - server
436
+
437
+ # ============================================================================
438
+ # ADDITIONAL EXAMPLES
439
+ # ============================================================================
440
+
441
+ # Example: Single bundle only (client or server)
442
+ # dev-client-only:
443
+ # description: Development client bundle only
444
+ # environment:
445
+ # NODE_ENV: development
446
+ # RAILS_ENV: development
447
+ # CLIENT_BUNDLE_ONLY: "yes"
448
+ # outputs:
449
+ # - client
450
+
451
+ # Example: Using bundler --env flags
452
+ # prod-modern:
453
+ # description: Production with custom webpack/rspack --env flags
454
+ # environment:
455
+ # NODE_ENV: production
456
+ # RAILS_ENV: production
457
+ # bundler_env:
458
+ # target: modern # Becomes: --env target=modern
459
+ # instrumented: true # Becomes: --env instrumented
460
+ # outputs:
461
+ # - client
462
+ # - server
463
+
464
+ # Example: Variable substitution with defaults
465
+ # staging:
466
+ # description: Staging environment with variable substitution
467
+ # environment:
468
+ # NODE_ENV: production
469
+ # RAILS_ENV: ${"$"}{RAILS_ENV:-staging} # Use env var or default to 'staging'
470
+ # outputs:
471
+ # - client
472
+ # - server
473
+
474
+ # Example: Custom config file path (uses ${"$"}{BUNDLER} substitution)
475
+ # custom-config:
476
+ # description: Using custom config file location
477
+ # environment:
478
+ # NODE_ENV: development
479
+ # config: config/${"$"}{BUNDLER}/${"$"}{BUNDLER}.config.js
480
+ # outputs:
481
+ # - client
482
+ # - server
483
+
484
+ # ============================================================================
485
+ # USAGE EXAMPLES
486
+ # ============================================================================
487
+ #
488
+ # Initialize this config file:
489
+ # bin/export-bundler-config --init
490
+ #
491
+ # List all available builds:
492
+ # bin/export-bundler-config --list-builds
493
+ #
494
+ # Export development build configs:
495
+ # bin/export-bundler-config --build=dev-hmr --save
496
+ # Creates: rspack-dev-hmr-client.yml
497
+ #
498
+ # bin/export-bundler-config --build=dev --save
499
+ # Creates: rspack-dev-client.yml, rspack-dev-server.yml
500
+ #
501
+ # Export production build:
502
+ # bin/export-bundler-config --build=prod --save
503
+ # Creates: rspack-prod-client.yml, rspack-prod-server.yml
504
+ #
505
+ # Use webpack instead of default rspack:
506
+ # bin/export-bundler-config --build=prod --save --webpack
507
+ # Creates: webpack-prod-client.yml, webpack-prod-server.yml
508
+ #
509
+ # Export to stdout for inspection (no files created):
510
+ # bin/export-bundler-config --build=dev
511
+ #
512
+ # Export to custom directory:
513
+ # bin/export-bundler-config --build=prod --save-dir=./debug
514
+ #
515
+ # Doctor mode (comprehensive troubleshooting):
516
+ # bin/export-bundler-config --doctor
517
+ # Creates files in: shakapacker-config-exports/
518
+ #
519
+ `
520
+ }
@@ -32,34 +32,38 @@ export class FileWriter {
32
32
  /**
33
33
  * Write a single file
34
34
  */
35
- writeSingleFile(filePath: string, content: string, quiet = false): void {
35
+ writeSingleFile(filePath: string, content: string): void {
36
36
  // Ensure parent directory exists
37
37
  const dir = dirname(filePath)
38
38
  this.ensureDirectory(dir)
39
39
 
40
40
  this.validateOutputPath(filePath)
41
41
  this.writeFile(filePath, content)
42
- if (!quiet && process.env.VERBOSE) {
43
- console.log(`[Config Exporter] Config exported to: ${filePath}`)
44
- }
42
+ console.log(`[Config Exporter] Created: ${filePath}`)
45
43
  }
46
44
 
47
45
  /**
48
46
  * Generate filename for a config export
49
- * Format: {bundler}-{env}-{type}.{ext}
47
+ * Format without build: {bundler}-{env}-{type}.{ext}
48
+ * Format with build: {bundler}-{build}-{type}.{ext}
50
49
  * Examples:
51
50
  * webpack-development-client.yaml
52
51
  * rspack-production-server.yaml
53
52
  * webpack-test-all.json
53
+ * webpack-development-client-hmr.yaml
54
+ * webpack-dev-client.yaml (with build name)
55
+ * rspack-cypress-dev-server.yaml (with build name)
54
56
  */
55
57
  generateFilename(
56
58
  bundler: string,
57
59
  env: string,
58
- configType: "client" | "server" | "all",
59
- format: "yaml" | "json" | "inspect"
60
+ configType: "client" | "server" | "all" | "client-hmr",
61
+ format: "yaml" | "json" | "inspect",
62
+ buildName?: string
60
63
  ): string {
61
64
  const ext = format === "yaml" ? "yaml" : format === "json" ? "json" : "txt"
62
- return `${bundler}-${env}-${configType}.${ext}`
65
+ const name = buildName || env
66
+ return `${bundler}-${name}-${configType}.${ext}`
63
67
  }
64
68
 
65
69
  private writeFile(filePath: string, content: string): void {
@@ -1,5 +1,13 @@
1
1
  export { run } from "./cli"
2
- export type { ExportOptions, ConfigMetadata, FileOutput } from "./types"
2
+ export type {
3
+ ExportOptions,
4
+ ConfigMetadata,
5
+ FileOutput,
6
+ BundlerConfigFile,
7
+ BuildConfig,
8
+ ResolvedBuildConfig
9
+ } from "./types"
3
10
  export { YamlSerializer } from "./yamlSerializer"
4
11
  export { FileWriter } from "./fileWriter"
5
12
  export { getDocForKey } from "./configDocs"
13
+ export { ConfigFileLoader, generateSampleConfigFile } from "./configFile"
@@ -1,7 +1,7 @@
1
1
  export interface ExportOptions {
2
2
  doctor?: boolean
3
- save?: boolean
4
3
  saveDir?: string
4
+ stdout?: boolean
5
5
  bundler?: "webpack" | "rspack"
6
6
  env?: "development" | "production" | "test"
7
7
  clientOnly?: boolean
@@ -12,6 +12,12 @@ export interface ExportOptions {
12
12
  verbose?: boolean
13
13
  depth?: number | null
14
14
  help?: boolean
15
+ // New config file options
16
+ init?: boolean
17
+ configFile?: string
18
+ build?: string
19
+ listBuilds?: boolean
20
+ allBuilds?: boolean
15
21
  }
16
22
 
17
23
  export interface ConfigMetadata {
@@ -19,13 +25,15 @@ export interface ConfigMetadata {
19
25
  bundler: string
20
26
  environment: string
21
27
  configFile: string
22
- configType: "client" | "server" | "all"
28
+ configType: "client" | "server" | "all" | "client-hmr"
23
29
  configCount: number
30
+ buildName?: string // New: name of the build from config file
24
31
  environmentVariables: {
25
32
  NODE_ENV?: string
26
33
  RAILS_ENV?: string
27
34
  CLIENT_BUNDLE_ONLY?: string
28
35
  SERVER_BUNDLE_ONLY?: string
36
+ WEBPACK_SERVE?: string
29
37
  }
30
38
  }
31
39
 
@@ -34,3 +42,29 @@ export interface FileOutput {
34
42
  content: string
35
43
  metadata: ConfigMetadata
36
44
  }
45
+
46
+ // Config file schema types
47
+ export interface BundlerConfigFile {
48
+ default_bundler?: "webpack" | "rspack"
49
+ shakapacker_doctor_default_builds_here?: boolean
50
+ builds: Record<string, BuildConfig>
51
+ }
52
+
53
+ export interface BuildConfig {
54
+ description?: string
55
+ bundler?: "webpack" | "rspack"
56
+ environment?: Record<string, string>
57
+ bundler_env?: Record<string, string | boolean>
58
+ outputs?: string[]
59
+ config?: string
60
+ }
61
+
62
+ export interface ResolvedBuildConfig {
63
+ name: string
64
+ description?: string
65
+ bundler: "webpack" | "rspack"
66
+ environment: Record<string, string>
67
+ bundlerEnvArgs: string[] // Converted bundler_env to CLI args
68
+ outputs: string[]
69
+ configFile?: string
70
+ }
@@ -160,21 +160,35 @@ export class YamlSerializer {
160
160
  if (typeof item === "object" && !Array.isArray(item) && item !== null) {
161
161
  // For objects in arrays, emit marker on its own line and indent content
162
162
  lines.push(`${itemIndent}-`)
163
- serialized
163
+ const nonEmptyLines = serialized
164
164
  .split("\n")
165
165
  .filter((line: string) => line.trim().length > 0)
166
- .forEach((line: string) => {
167
- lines.push(contentIndent + line)
168
- })
166
+ // Compute minimum leading whitespace to preserve relative indentation
167
+ const minIndent = Math.min(
168
+ ...nonEmptyLines.map(
169
+ (line: string) => line.match(/^\s*/)?.[0].length || 0
170
+ )
171
+ )
172
+ nonEmptyLines.forEach((line: string) => {
173
+ // Remove only the common indent, preserving relative indentation
174
+ lines.push(contentIndent + line.substring(minIndent))
175
+ })
169
176
  } else if (serialized.includes("\n")) {
170
177
  // For multiline values, emit marker on its own line and indent content
171
178
  lines.push(`${itemIndent}-`)
172
- serialized
179
+ const nonEmptyLines = serialized
173
180
  .split("\n")
174
181
  .filter((line: string) => line.trim().length > 0)
175
- .forEach((line: string) => {
176
- lines.push(contentIndent + line)
177
- })
182
+ // Compute minimum leading whitespace to preserve relative indentation
183
+ const minIndent = Math.min(
184
+ ...nonEmptyLines.map(
185
+ (line: string) => line.match(/^\s*/)?.[0].length || 0
186
+ )
187
+ )
188
+ nonEmptyLines.forEach((line: string) => {
189
+ // Remove only the common indent, preserving relative indentation
190
+ lines.push(contentIndent + line.substring(minIndent))
191
+ })
178
192
  } else {
179
193
  // For simple values, keep on same line
180
194
  lines.push(`${itemIndent}- ${serialized}`)
@@ -18,7 +18,7 @@ if (devServerConfig) {
18
18
  const envValue = envFetch(`${envPrefix}_${key.toUpperCase()}`)
19
19
  if (envValue !== undefined) {
20
20
  // Use bracket notation to avoid ASI issues
21
- (devServerConfig as Record<string, unknown>)[key] = envValue
21
+ ;(devServerConfig as Record<string, unknown>)[key] = envValue
22
22
  }
23
23
  })
24
24
  }