@webstir-io/webstir-frontend 0.1.40 → 0.1.41

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 (138) hide show
  1. package/README.md +124 -60
  2. package/dist/assets/imageOptimizer.js +10 -15
  3. package/dist/assets/precompression.js +1 -1
  4. package/dist/builders/contentBuilder.js +102 -90
  5. package/dist/builders/cssBuilder.js +25 -19
  6. package/dist/builders/htmlBuilder.js +57 -42
  7. package/dist/builders/index.js +1 -1
  8. package/dist/builders/jsBuilder.js +219 -76
  9. package/dist/builders/staticAssetsBuilder.js +27 -9
  10. package/dist/builders/types.d.ts +1 -0
  11. package/dist/cli.d.ts +1 -1
  12. package/dist/cli.js +6 -30
  13. package/dist/config/manifest.js +7 -6
  14. package/dist/config/paths.js +2 -2
  15. package/dist/config/schema.d.ts +8 -0
  16. package/dist/config/schema.js +7 -6
  17. package/dist/config/setup.js +1 -1
  18. package/dist/config/workspace.js +11 -9
  19. package/dist/core/constants.d.ts +1 -1
  20. package/dist/core/constants.js +5 -5
  21. package/dist/core/diagnostics.js +1 -1
  22. package/dist/core/pages.js +4 -4
  23. package/dist/hooks.js +3 -3
  24. package/dist/html/criticalCss.js +6 -3
  25. package/dist/html/htmlSecurity.d.ts +6 -1
  26. package/dist/html/htmlSecurity.js +28 -14
  27. package/dist/html/lazyLoad.js +1 -1
  28. package/dist/html/pageScaffold.js +1 -1
  29. package/dist/html/resourceHints.js +5 -2
  30. package/dist/index.d.ts +2 -0
  31. package/dist/index.js +2 -0
  32. package/dist/inspect.d.ts +2 -0
  33. package/dist/inspect.js +110 -0
  34. package/dist/modes/ssg/metadata.js +4 -4
  35. package/dist/modes/ssg/routing.js +2 -5
  36. package/dist/modes/ssg/seo.js +5 -5
  37. package/dist/modes/ssg/views.js +17 -11
  38. package/dist/operations.js +18 -10
  39. package/dist/pipeline.d.ts +1 -0
  40. package/dist/pipeline.js +6 -1
  41. package/dist/provider.js +28 -24
  42. package/dist/runtime/boundary.d.ts +28 -0
  43. package/dist/runtime/boundary.js +247 -0
  44. package/dist/runtime/index.d.ts +1 -0
  45. package/dist/runtime/index.js +1 -0
  46. package/dist/types.d.ts +52 -0
  47. package/dist/utils/fs.d.ts +11 -10
  48. package/dist/utils/fs.js +48 -20
  49. package/dist/utils/glob.d.ts +8 -0
  50. package/dist/utils/glob.js +21 -0
  51. package/dist/utils/hash.js +1 -2
  52. package/dist/utils/pagePaths.js +2 -2
  53. package/package.json +19 -14
  54. package/scripts/publish.sh +2 -94
  55. package/scripts/update-contract.sh +12 -10
  56. package/src/assets/assetManifest.ts +39 -29
  57. package/src/assets/imageOptimizer.ts +91 -82
  58. package/src/assets/precompression.ts +22 -16
  59. package/src/builders/contentBuilder.ts +1224 -1149
  60. package/src/builders/cssBuilder.ts +466 -417
  61. package/src/builders/htmlBuilder.ts +511 -448
  62. package/src/builders/index.ts +7 -7
  63. package/src/builders/jsBuilder.ts +538 -280
  64. package/src/builders/staticAssetsBuilder.ts +166 -135
  65. package/src/builders/types.ts +7 -6
  66. package/src/cli.ts +66 -90
  67. package/src/config/manifest.ts +16 -14
  68. package/src/config/paths.ts +5 -5
  69. package/src/config/schema.ts +38 -37
  70. package/src/config/setup.ts +7 -7
  71. package/src/config/workspace.ts +118 -116
  72. package/src/config/workspaceManifest.ts +14 -14
  73. package/src/core/constants.ts +62 -62
  74. package/src/core/diagnostics.ts +26 -26
  75. package/src/core/pages.ts +19 -19
  76. package/src/hooks.ts +128 -118
  77. package/src/html/criticalCss.ts +84 -77
  78. package/src/html/htmlSecurity.ts +107 -66
  79. package/src/html/lazyLoad.ts +22 -19
  80. package/src/html/pageScaffold.ts +37 -28
  81. package/src/html/resourceHints.ts +83 -74
  82. package/src/index.ts +2 -0
  83. package/src/inspect.ts +158 -0
  84. package/src/modes/ssg/metadata.ts +53 -51
  85. package/src/modes/ssg/routing.ts +177 -177
  86. package/src/modes/ssg/seo.ts +208 -200
  87. package/src/modes/ssg/validation.ts +31 -25
  88. package/src/modes/ssg/views.ts +257 -238
  89. package/src/operations.ts +105 -95
  90. package/src/pipeline.ts +81 -69
  91. package/src/provider.ts +184 -176
  92. package/src/runtime/boundary.ts +325 -0
  93. package/src/runtime/index.ts +1 -0
  94. package/src/types.ts +107 -48
  95. package/src/utils/changedFile.ts +22 -22
  96. package/src/utils/fs.ts +73 -26
  97. package/src/utils/glob.ts +38 -0
  98. package/src/utils/hash.ts +2 -4
  99. package/src/utils/pagePaths.ts +35 -23
  100. package/src/utils/pathMatch.ts +26 -23
  101. package/tests/add-page-defaults.test.js +44 -39
  102. package/tests/bundlerParity.test.js +252 -0
  103. package/tests/cli.contract.test.js +13 -0
  104. package/tests/content-pages.test.js +108 -13
  105. package/tests/css-app-imports.test.js +22 -11
  106. package/tests/css-page-imports.test.js +26 -13
  107. package/tests/diagnostics.test.js +39 -36
  108. package/tests/features.test.js +48 -43
  109. package/tests/hooks.test.js +58 -42
  110. package/tests/htmlSecurity.test.js +66 -0
  111. package/tests/inspect.test.js +148 -0
  112. package/tests/provider.integration.test.js +71 -20
  113. package/tests/runtime.test.js +493 -0
  114. package/tests/ssg-defaults.test.js +284 -177
  115. package/tests/ssg-guardrails.test.js +51 -51
  116. package/tsconfig.json +3 -10
  117. package/dist/watch/frontendFiles.d.ts +0 -3
  118. package/dist/watch/frontendFiles.js +0 -25
  119. package/dist/watch/hotUpdateTracker.d.ts +0 -51
  120. package/dist/watch/hotUpdateTracker.js +0 -205
  121. package/dist/watch/pipelineHelpers.d.ts +0 -26
  122. package/dist/watch/pipelineHelpers.js +0 -177
  123. package/dist/watch/types.d.ts +0 -27
  124. package/dist/watch/types.js +0 -1
  125. package/dist/watch/watchCoordinator.d.ts +0 -36
  126. package/dist/watch/watchCoordinator.js +0 -551
  127. package/dist/watch/watchDaemon.d.ts +0 -17
  128. package/dist/watch/watchDaemon.js +0 -127
  129. package/dist/watch/watchReporter.d.ts +0 -21
  130. package/dist/watch/watchReporter.js +0 -64
  131. package/scripts/smoke.mjs +0 -35
  132. package/src/watch/frontendFiles.ts +0 -32
  133. package/src/watch/hotUpdateTracker.ts +0 -285
  134. package/src/watch/pipelineHelpers.ts +0 -242
  135. package/src/watch/types.ts +0 -23
  136. package/src/watch/watchCoordinator.ts +0 -666
  137. package/src/watch/watchDaemon.ts +0 -144
  138. package/src/watch/watchReporter.ts +0 -98
@@ -1,358 +1,616 @@
1
1
  import path from 'node:path';
2
2
  import { build as esbuild, type Metafile } from 'esbuild';
3
- import { glob } from 'glob';
4
3
  import { FOLDERS, FILES, EXTENSIONS } from '../core/constants.js';
5
4
  import type { Builder, BuilderContext } from './types.js';
6
5
  import { getPages } from '../core/pages.js';
7
6
  import { ensureDir, pathExists, copy, remove, stat } from '../utils/fs.js';
8
- import { updatePageManifest, updateSharedAssets, readSharedAssets } from '../assets/assetManifest.js';
7
+ import { scanGlob } from '../utils/glob.js';
8
+ import {
9
+ updatePageManifest,
10
+ updateSharedAssets,
11
+ readSharedAssets,
12
+ } from '../assets/assetManifest.js';
9
13
  import { createCompressedVariants } from '../assets/precompression.js';
10
14
  import { shouldProcess } from '../utils/changedFile.js';
11
15
  import { findPageFromChangedFile } from '../utils/pathMatch.js';
12
16
 
13
17
  const ENTRY_EXTENSIONS = ['.ts', '.tsx', '.js'];
14
18
  const APP_ENTRY_BASENAME = 'app';
19
+ type JavaScriptBundler = 'esbuild' | 'bun';
20
+
21
+ interface BunBuildOutputFile {
22
+ readonly path: string;
23
+ readonly kind?: string;
24
+ }
25
+
26
+ interface BunBuildLog {
27
+ readonly level?: string;
28
+ readonly message?: string;
29
+ readonly text?: string;
30
+ readonly position?: {
31
+ readonly file?: string;
32
+ readonly line?: number;
33
+ readonly column?: number;
34
+ } | null;
35
+ }
36
+
37
+ interface BunBuildOutput {
38
+ readonly success: boolean;
39
+ readonly outputs?: readonly BunBuildOutputFile[];
40
+ readonly logs?: readonly BunBuildLog[];
41
+ }
42
+
43
+ type BunBuildFunction = (config: Record<string, unknown>) => Promise<BunBuildOutput>;
15
44
 
16
45
  export function createJavaScriptBuilder(context: BuilderContext): Builder {
17
- return {
18
- name: 'javascript',
19
- async build(): Promise<void> {
20
- await bundleJavaScript(context, false);
21
- },
22
- async publish(): Promise<void> {
23
- await bundleJavaScript(context, true);
24
- }
25
- };
46
+ return {
47
+ name: 'javascript',
48
+ async build(): Promise<void> {
49
+ await bundleJavaScript(context, false);
50
+ },
51
+ async publish(): Promise<void> {
52
+ await bundleJavaScript(context, true);
53
+ },
54
+ };
26
55
  }
27
56
 
28
57
  async function bundleJavaScript(context: BuilderContext, isProduction: boolean): Promise<void> {
29
- const { config } = context;
30
- if (!shouldProcess(context, [
31
- {
32
- directory: config.paths.src.frontend,
33
- extensions: [EXTENSIONS.ts, EXTENSIONS.js, '.tsx', '.jsx']
34
- }
35
- ])) {
36
- return;
58
+ const { config } = context;
59
+ const bundler = resolveJavaScriptBundler(context.env);
60
+ if (
61
+ !shouldProcess(context, [
62
+ {
63
+ directory: config.paths.src.frontend,
64
+ extensions: [EXTENSIONS.ts, EXTENSIONS.js, '.tsx', '.jsx'],
65
+ },
66
+ ])
67
+ ) {
68
+ return;
69
+ }
70
+ const targetPage = findPageFromChangedFile(context.changedFile, config.paths.src.pages);
71
+ const pages = await getPages(config.paths.src.pages);
72
+
73
+ await assertFeatureModulesPresent(config, context.enable);
74
+ await compileAppTypeScript(context, isProduction, bundler);
75
+
76
+ for (const page of pages) {
77
+ if (targetPage && page.name !== targetPage) {
78
+ continue;
37
79
  }
38
- const targetPage = findPageFromChangedFile(context.changedFile, config.paths.src.pages);
39
- const pages = await getPages(config.paths.src.pages);
40
- let builtAny = false;
41
-
42
- await assertFeatureModulesPresent(config, context.enable);
43
- await compileAppTypeScript(config, isProduction);
44
-
45
- for (const page of pages) {
46
- if (targetPage && page.name !== targetPage) {
47
- continue;
48
- }
49
- const entryPoint = await resolveEntryPoint(page.directory);
50
- if (!entryPoint) {
51
- continue;
52
- }
53
-
54
- builtAny = true;
55
-
56
- if (isProduction) {
57
- await buildForProduction(config, page.name, entryPoint);
58
- } else {
59
- await buildForDevelopment(config, page.name, entryPoint);
60
- }
80
+ const entryPoint = await resolveEntryPoint(page.directory);
81
+ if (!entryPoint) {
82
+ continue;
61
83
  }
62
84
 
63
- // Always copy dev runtime scripts in dev builds to support HMR/refresh even when no page JS exists.
64
- if (!isProduction || context.enable?.clientNav || context.enable?.search) {
65
- await copyRuntimeScripts(config, context.enable, isProduction);
85
+ if (isProduction) {
86
+ await buildForProduction(config, page.name, entryPoint, bundler);
87
+ } else {
88
+ await buildForDevelopment(config, page.name, entryPoint, bundler);
66
89
  }
90
+ }
91
+
92
+ // Always copy dev runtime scripts in dev builds to support HMR/refresh even when no page JS exists.
93
+ if (!isProduction || context.enable?.clientNav || context.enable?.search) {
94
+ await copyRuntimeScripts(config, context.enable, isProduction);
95
+ }
67
96
  }
68
97
 
69
- async function compileAppTypeScript(config: BuilderContext['config'], isProduction: boolean): Promise<void> {
70
- const appRoot = config.paths.src.app;
71
- if (!(await pathExists(appRoot))) {
72
- return;
98
+ async function compileAppTypeScript(
99
+ context: BuilderContext,
100
+ isProduction: boolean,
101
+ bundler: JavaScriptBundler,
102
+ ): Promise<void> {
103
+ const { config } = context;
104
+ const appRoot = config.paths.src.app;
105
+ if (!(await pathExists(appRoot))) {
106
+ return;
107
+ }
108
+
109
+ if (isProduction) {
110
+ const entryPoint = await resolveAppEntry(appRoot);
111
+ if (!entryPoint) {
112
+ return;
73
113
  }
74
114
 
75
- if (isProduction) {
76
- const entryPoint = await resolveAppEntry(appRoot);
77
- if (!entryPoint) {
78
- return;
79
- }
80
-
81
- const outputDir = path.join(config.paths.dist.frontend, FOLDERS.app);
82
- await ensureDir(outputDir);
83
-
84
- const result = await esbuild({
85
- entryPoints: [entryPoint],
86
- outdir: outputDir,
87
- format: 'esm',
88
- target: 'es2020',
89
- platform: 'browser',
90
- minify: true,
91
- sourcemap: false,
92
- bundle: true,
93
- entryNames: 'app-[hash]',
94
- assetNames: 'assets/[name]-[hash]',
95
- metafile: true,
96
- logLevel: 'silent'
97
- });
98
-
99
- const fileName = await resolveAppBundleName(outputDir, entryPoint, result.metafile);
100
- if (!fileName) {
101
- throw new Error(`esbuild did not produce an app bundle for ${entryPoint}.`);
102
- }
103
- const absolutePath = path.join(outputDir, fileName);
104
-
105
- if (config.features.precompression) {
106
- await createCompressedVariants(absolutePath);
107
- } else {
108
- await Promise.all([
109
- remove(`${absolutePath}${EXTENSIONS.br}`).catch(() => undefined),
110
- remove(`${absolutePath}${EXTENSIONS.gz}`).catch(() => undefined)
111
- ]);
112
- }
113
-
114
- const existing = await readSharedAssets(config.paths.dist.frontend);
115
- const previousFile = existing?.js;
116
- if (previousFile && previousFile !== fileName) {
117
- const previousPath = path.join(outputDir, previousFile);
118
- await remove(previousPath).catch(() => undefined);
119
- await remove(`${previousPath}${EXTENSIONS.br}`).catch(() => undefined);
120
- await remove(`${previousPath}${EXTENSIONS.gz}`).catch(() => undefined);
121
- }
122
-
123
- await updateSharedAssets(config.paths.dist.frontend, shared => {
124
- shared.js = fileName;
125
- });
126
-
127
- return;
115
+ const outputDir = path.join(config.paths.dist.frontend, FOLDERS.app);
116
+ await ensureDir(outputDir);
117
+
118
+ const fileName =
119
+ bundler === 'bun'
120
+ ? await buildAppForProductionWithBun(outputDir, entryPoint)
121
+ : await buildAppForProductionWithEsbuild(outputDir, entryPoint);
122
+ const absolutePath = path.join(outputDir, fileName);
123
+
124
+ if (config.features.precompression) {
125
+ await createCompressedVariants(absolutePath);
126
+ } else {
127
+ await Promise.all([
128
+ remove(`${absolutePath}${EXTENSIONS.br}`).catch(() => undefined),
129
+ remove(`${absolutePath}${EXTENSIONS.gz}`).catch(() => undefined),
130
+ ]);
128
131
  }
129
132
 
130
- const entryPoints = (await glob('**/*.{ts,tsx}', { cwd: appRoot, nodir: true }))
131
- .filter((relativePath) => !relativePath.endsWith('.d.ts'))
132
- .map((relativePath) => path.join(appRoot, relativePath));
133
- if (entryPoints.length === 0) {
134
- return;
133
+ const existing = await readSharedAssets(config.paths.dist.frontend);
134
+ const previousFile = existing?.js;
135
+ if (previousFile && previousFile !== fileName) {
136
+ const previousPath = path.join(outputDir, previousFile);
137
+ await remove(previousPath).catch(() => undefined);
138
+ await remove(`${previousPath}${EXTENSIONS.br}`).catch(() => undefined);
139
+ await remove(`${previousPath}${EXTENSIONS.gz}`).catch(() => undefined);
135
140
  }
136
141
 
137
- const outdir = isProduction
138
- ? path.join(config.paths.dist.frontend, FOLDERS.app)
139
- : path.join(config.paths.build.frontend, FOLDERS.app);
140
- await ensureDir(outdir);
141
-
142
- await esbuild({
143
- entryPoints,
144
- outdir,
145
- format: 'esm',
146
- target: 'es2020',
147
- platform: 'browser',
148
- sourcemap: !isProduction,
149
- minify: isProduction,
150
- bundle: false,
151
- outbase: appRoot,
152
- logLevel: 'silent'
142
+ await updateSharedAssets(config.paths.dist.frontend, (shared) => {
143
+ shared.js = fileName;
153
144
  });
145
+
146
+ return;
147
+ }
148
+
149
+ const entryPoint = await resolveAppEntry(appRoot);
150
+ if (!entryPoint) {
151
+ return;
152
+ }
153
+
154
+ const outdir = path.join(config.paths.build.frontend, FOLDERS.app);
155
+ await ensureDir(outdir);
156
+
157
+ if (bundler === 'bun') {
158
+ await buildForDevelopmentWithBun(outdir, entryPoint);
159
+ return;
160
+ }
161
+
162
+ await buildAppForDevelopmentWithEsbuild(outdir, entryPoint);
154
163
  }
155
164
 
156
- async function buildForDevelopment(config: BuilderContext['config'], pageName: string, entryPoint: string): Promise<void> {
157
- const outputDir = path.join(config.paths.build.pages, pageName);
158
- await ensureDir(outputDir);
159
- const outfile = path.join(outputDir, `${FILES.index}${EXTENSIONS.js}`);
160
-
161
- await esbuild({
162
- entryPoints: [entryPoint],
163
- bundle: true,
164
- format: 'esm',
165
- target: 'es2020',
166
- platform: 'browser',
167
- sourcemap: true,
168
- outfile,
169
- logLevel: 'silent'
170
- });
165
+ async function buildForDevelopment(
166
+ config: BuilderContext['config'],
167
+ pageName: string,
168
+ entryPoint: string,
169
+ bundler: JavaScriptBundler,
170
+ ): Promise<void> {
171
+ const outputDir = path.join(config.paths.build.pages, pageName);
172
+ await ensureDir(outputDir);
173
+ if (bundler === 'bun') {
174
+ await buildForDevelopmentWithBun(outputDir, entryPoint);
175
+ return;
176
+ }
177
+
178
+ const outfile = path.join(outputDir, `${FILES.index}${EXTENSIONS.js}`);
179
+
180
+ await esbuild({
181
+ entryPoints: [entryPoint],
182
+ bundle: true,
183
+ format: 'esm',
184
+ target: 'es2020',
185
+ platform: 'browser',
186
+ sourcemap: true,
187
+ outfile,
188
+ logLevel: 'silent',
189
+ });
171
190
  }
172
191
 
173
- async function buildForProduction(config: BuilderContext['config'], pageName: string, entryPoint: string): Promise<void> {
174
- const outputDir = path.join(config.paths.dist.pages, pageName);
175
- await ensureDir(outputDir);
192
+ async function buildForProduction(
193
+ config: BuilderContext['config'],
194
+ pageName: string,
195
+ entryPoint: string,
196
+ bundler: JavaScriptBundler,
197
+ ): Promise<void> {
198
+ const outputDir = path.join(config.paths.dist.pages, pageName);
199
+ await ensureDir(outputDir);
200
+
201
+ const fileName =
202
+ bundler === 'bun'
203
+ ? await buildPageForProductionWithBun(outputDir, pageName, entryPoint)
204
+ : await buildPageForProductionWithEsbuild(outputDir, pageName, entryPoint);
205
+ const absolutePath = path.join(outputDir, fileName);
206
+ if (config.features.precompression) {
207
+ await createCompressedVariants(absolutePath);
208
+ } else {
209
+ await Promise.all([
210
+ remove(`${absolutePath}${EXTENSIONS.br}`).catch(() => undefined),
211
+ remove(`${absolutePath}${EXTENSIONS.gz}`).catch(() => undefined),
212
+ ]);
213
+ }
214
+ await updatePageManifest(outputDir, pageName, (manifest) => {
215
+ manifest.js = fileName;
216
+ });
217
+ }
176
218
 
177
- const result = await esbuild({
178
- entryPoints: [entryPoint],
179
- bundle: true,
180
- format: 'esm',
181
- target: 'es2020',
182
- platform: 'browser',
183
- minify: true,
184
- sourcemap: false,
185
- outdir: outputDir,
186
- entryNames: `${FILES.index}-[hash]`,
187
- assetNames: 'assets/[name]-[hash]',
188
- metafile: true,
189
- logLevel: 'silent'
190
- });
219
+ async function buildAppForProductionWithEsbuild(
220
+ outputDir: string,
221
+ entryPoint: string,
222
+ ): Promise<string> {
223
+ const result = await esbuild({
224
+ entryPoints: [entryPoint],
225
+ outdir: outputDir,
226
+ format: 'esm',
227
+ target: 'es2020',
228
+ platform: 'browser',
229
+ minify: true,
230
+ sourcemap: false,
231
+ bundle: true,
232
+ entryNames: 'app-[hash]',
233
+ assetNames: 'assets/[name]-[hash]',
234
+ metafile: true,
235
+ logLevel: 'silent',
236
+ });
237
+
238
+ const fileName = await resolveAppBundleName(outputDir, entryPoint, result.metafile);
239
+ if (!fileName) {
240
+ throw new Error(`esbuild did not produce an app bundle for ${entryPoint}.`);
241
+ }
242
+
243
+ return fileName;
244
+ }
191
245
 
192
- const outputs = result.metafile?.outputs ?? {};
193
- const scriptPath = Object.keys(outputs).find((file) => file.endsWith('.js'));
194
- if (!scriptPath) {
195
- throw new Error(`esbuild did not produce a JavaScript bundle for page '${pageName}'.`);
196
- }
246
+ async function buildAppForProductionWithBun(
247
+ outputDir: string,
248
+ entryPoint: string,
249
+ ): Promise<string> {
250
+ const result = await runBunBrowserBuild({
251
+ entryPoint,
252
+ root: path.dirname(entryPoint),
253
+ outputDir,
254
+ minify: true,
255
+ sourcemap: 'none',
256
+ naming: {
257
+ entry: 'app-[hash].js',
258
+ asset: 'assets/[name]-[hash].[ext]',
259
+ },
260
+ });
261
+ ensureBunBuildSucceeded(result, `app bundle ${entryPoint}`);
262
+
263
+ const fileName = resolveBunEntryOutputName(result.outputs, outputDir, (name) => {
264
+ return name.startsWith('app-') && name.endsWith(EXTENSIONS.js);
265
+ });
266
+ if (!fileName) {
267
+ throw new Error(`Bun.build() did not produce an app bundle for ${entryPoint}.`);
268
+ }
269
+
270
+ return fileName;
271
+ }
197
272
 
198
- const fileName = path.basename(scriptPath);
199
- const absolutePath = path.join(outputDir, fileName);
200
- if (config.features.precompression) {
201
- await createCompressedVariants(absolutePath);
202
- } else {
203
- await Promise.all([
204
- remove(`${absolutePath}${EXTENSIONS.br}`).catch(() => undefined),
205
- remove(`${absolutePath}${EXTENSIONS.gz}`).catch(() => undefined)
206
- ]);
207
- }
208
- await updatePageManifest(outputDir, pageName, (manifest) => {
209
- manifest.js = fileName;
210
- });
273
+ async function buildForDevelopmentWithBun(outputDir: string, entryPoint: string): Promise<void> {
274
+ const result = await runBunBrowserBuild({
275
+ entryPoint,
276
+ root: path.dirname(entryPoint),
277
+ outputDir,
278
+ minify: false,
279
+ sourcemap: 'linked',
280
+ });
281
+ ensureBunBuildSucceeded(result, `development bundle ${entryPoint}`);
282
+ }
283
+
284
+ async function buildAppForDevelopmentWithEsbuild(
285
+ outputDir: string,
286
+ entryPoint: string,
287
+ ): Promise<void> {
288
+ await esbuild({
289
+ entryPoints: [entryPoint],
290
+ outdir: outputDir,
291
+ format: 'esm',
292
+ target: 'es2020',
293
+ platform: 'browser',
294
+ sourcemap: 'linked',
295
+ bundle: true,
296
+ logLevel: 'silent',
297
+ });
298
+ }
299
+
300
+ async function buildPageForProductionWithEsbuild(
301
+ outputDir: string,
302
+ pageName: string,
303
+ entryPoint: string,
304
+ ): Promise<string> {
305
+ const result = await esbuild({
306
+ entryPoints: [entryPoint],
307
+ bundle: true,
308
+ format: 'esm',
309
+ target: 'es2020',
310
+ platform: 'browser',
311
+ minify: true,
312
+ sourcemap: false,
313
+ outdir: outputDir,
314
+ entryNames: `${FILES.index}-[hash]`,
315
+ assetNames: 'assets/[name]-[hash]',
316
+ metafile: true,
317
+ logLevel: 'silent',
318
+ });
319
+
320
+ const outputs = result.metafile?.outputs ?? {};
321
+ const scriptPath = Object.keys(outputs).find((file) => file.endsWith('.js'));
322
+ if (!scriptPath) {
323
+ throw new Error(`esbuild did not produce a JavaScript bundle for page '${pageName}'.`);
324
+ }
325
+
326
+ return path.basename(scriptPath);
327
+ }
328
+
329
+ async function buildPageForProductionWithBun(
330
+ outputDir: string,
331
+ pageName: string,
332
+ entryPoint: string,
333
+ ): Promise<string> {
334
+ const result = await runBunBrowserBuild({
335
+ entryPoint,
336
+ root: path.dirname(entryPoint),
337
+ outputDir,
338
+ minify: true,
339
+ sourcemap: 'none',
340
+ naming: {
341
+ entry: `${FILES.index}-[hash].js`,
342
+ asset: 'assets/[name]-[hash].[ext]',
343
+ },
344
+ });
345
+ ensureBunBuildSucceeded(result, `page bundle '${pageName}'`);
346
+
347
+ const fileName = resolveBunEntryOutputName(result.outputs, outputDir, (name) => {
348
+ return name.startsWith(`${FILES.index}-`) && name.endsWith(EXTENSIONS.js);
349
+ });
350
+ if (!fileName) {
351
+ throw new Error(`Bun.build() did not produce a JavaScript bundle for page '${pageName}'.`);
352
+ }
353
+
354
+ return fileName;
211
355
  }
212
356
 
213
357
  async function copyRuntimeScripts(
214
- config: BuilderContext['config'],
215
- enable: BuilderContext['enable'],
216
- isProduction: boolean
358
+ config: BuilderContext['config'],
359
+ _enable: BuilderContext['enable'],
360
+ isProduction: boolean,
217
361
  ): Promise<void> {
218
- const scripts = [
219
- // Always copy dev runtime in dev builds to support live reload, even if no page JS exists.
220
- { name: FILES.refreshJs, copyToDist: false, required: !isProduction },
221
- { name: FILES.hmrJs, copyToDist: false, required: !isProduction }
222
- ];
223
-
224
- for (const script of scripts) {
225
- if (!script.required) {
226
- continue;
227
- }
228
-
229
- const source = path.join(config.paths.src.app, script.name);
230
- if (!(await pathExists(source))) {
231
- continue;
232
- }
233
-
234
- const buildDestination = path.join(config.paths.build.frontend, script.name);
235
- await ensureDir(path.dirname(buildDestination));
236
- await copy(source, buildDestination);
237
-
238
- if (isProduction && script.copyToDist) {
239
- const distDestination = path.join(config.paths.dist.frontend, script.name);
240
- await ensureDir(path.dirname(distDestination));
241
- await copy(source, distDestination);
242
- }
362
+ const scripts = [
363
+ // Always copy dev runtime in dev builds to support live reload, even if no page JS exists.
364
+ { name: FILES.refreshJs, copyToDist: false, required: !isProduction },
365
+ { name: FILES.hmrJs, copyToDist: false, required: !isProduction },
366
+ ];
367
+
368
+ for (const script of scripts) {
369
+ if (!script.required) {
370
+ continue;
371
+ }
372
+
373
+ const source = path.join(config.paths.src.app, script.name);
374
+ if (!(await pathExists(source))) {
375
+ continue;
376
+ }
377
+
378
+ const buildDestination = path.join(config.paths.build.frontend, script.name);
379
+ await ensureDir(path.dirname(buildDestination));
380
+ await copy(source, buildDestination);
381
+
382
+ if (isProduction && script.copyToDist) {
383
+ const distDestination = path.join(config.paths.dist.frontend, script.name);
384
+ await ensureDir(path.dirname(distDestination));
385
+ await copy(source, distDestination);
243
386
  }
387
+ }
244
388
  }
245
389
 
246
390
  async function resolveEntryPoint(pageDirectory: string): Promise<string | null> {
247
- for (const extension of ENTRY_EXTENSIONS) {
248
- const candidate = path.join(pageDirectory, `${FILES.index}${extension}`);
249
- if (await pathExists(candidate)) {
250
- return candidate;
251
- }
391
+ for (const extension of ENTRY_EXTENSIONS) {
392
+ const candidate = path.join(pageDirectory, `${FILES.index}${extension}`);
393
+ if (await pathExists(candidate)) {
394
+ return candidate;
252
395
  }
396
+ }
253
397
 
254
- return null;
398
+ return null;
255
399
  }
256
400
 
257
- async function assertFeatureModulesPresent(config: BuilderContext['config'], enable: BuilderContext['enable']): Promise<void> {
258
- if (!enable) {
259
- return;
260
- }
401
+ async function assertFeatureModulesPresent(
402
+ config: BuilderContext['config'],
403
+ enable: BuilderContext['enable'],
404
+ ): Promise<void> {
405
+ if (!enable) {
406
+ return;
407
+ }
261
408
 
262
- const missing: string[] = [];
409
+ const missing: string[] = [];
263
410
 
264
- if (enable.clientNav === true) {
265
- const hasClientNav = await hasFeatureModule(config, 'client-nav');
266
- if (!hasClientNav) {
267
- missing.push('client-nav');
268
- }
411
+ if (enable.clientNav === true) {
412
+ const hasClientNav = await hasFeatureModule(config, 'client-nav');
413
+ if (!hasClientNav) {
414
+ missing.push('client-nav');
269
415
  }
416
+ }
270
417
 
271
- if (enable.search === true) {
272
- const hasSearch = await hasFeatureModule(config, 'search');
273
- if (!hasSearch) {
274
- missing.push('search');
275
- }
418
+ if (enable.search === true) {
419
+ const hasSearch = await hasFeatureModule(config, 'search');
420
+ if (!hasSearch) {
421
+ missing.push('search');
276
422
  }
423
+ }
277
424
 
278
- if (enable.contentNav === true) {
279
- const hasContentNav = await hasFeatureModule(config, 'content-nav');
280
- if (!hasContentNav) {
281
- missing.push('content-nav');
282
- }
425
+ if (enable.contentNav === true) {
426
+ const hasContentNav = await hasFeatureModule(config, 'content-nav');
427
+ if (!hasContentNav) {
428
+ missing.push('content-nav');
283
429
  }
430
+ }
284
431
 
285
- if (missing.length === 0) {
286
- return;
287
- }
432
+ if (missing.length === 0) {
433
+ return;
434
+ }
288
435
 
289
- const expected = missing
290
- .map((name) => `src/frontend/app/scripts/features/${name}.ts`)
291
- .join(', ');
292
- throw new Error(
293
- `Enabled feature module(s) missing: ${missing.join(', ')}. Run 'webstir enable <feature>' to scaffold them (expected: ${expected}).`
294
- );
436
+ const expected = missing.map((name) => `src/frontend/app/scripts/features/${name}.ts`).join(', ');
437
+ throw new Error(
438
+ `Enabled feature module(s) missing: ${missing.join(', ')}. Run 'webstir enable <feature>' to scaffold them (expected: ${expected}).`,
439
+ );
295
440
  }
296
441
 
297
442
  async function hasFeatureModule(config: BuilderContext['config'], name: string): Promise<boolean> {
298
- const root = path.join(config.paths.src.app, 'scripts', 'features');
299
- return await pathExists(path.join(root, `${name}${EXTENSIONS.ts}`))
300
- || await pathExists(path.join(root, `${name}${EXTENSIONS.js}`));
443
+ const root = path.join(config.paths.src.app, 'scripts', 'features');
444
+ return (
445
+ (await pathExists(path.join(root, `${name}${EXTENSIONS.ts}`))) ||
446
+ (await pathExists(path.join(root, `${name}${EXTENSIONS.js}`)))
447
+ );
301
448
  }
302
449
 
303
450
  async function resolveAppBundleName(
304
- outputDir: string,
305
- entryPoint: string,
306
- metafile?: Metafile
451
+ outputDir: string,
452
+ entryPoint: string,
453
+ metafile?: Metafile,
307
454
  ): Promise<string | null> {
308
- const outputs = metafile?.outputs ?? {};
309
- const outputEntries = Object.entries(outputs) as Array<[string, Metafile['outputs'][string]]>;
310
- const entryOutput = outputEntries.find(([, meta]) => {
311
- if (!meta.entryPoint) {
312
- return false;
313
- }
314
- return path.resolve(meta.entryPoint) === path.resolve(entryPoint);
315
- });
316
-
317
- if (entryOutput) {
318
- return path.basename(entryOutput[0]);
455
+ const outputs = metafile?.outputs ?? {};
456
+ const outputEntries = Object.entries(outputs) as Array<[string, Metafile['outputs'][string]]>;
457
+ const entryOutput = outputEntries.find(([, meta]) => {
458
+ if (!meta.entryPoint) {
459
+ return false;
319
460
  }
461
+ return path.resolve(meta.entryPoint) === path.resolve(entryPoint);
462
+ });
320
463
 
321
- const matches = await glob('app-*.js', { cwd: outputDir, nodir: true });
322
- if (matches.length === 0) {
323
- return null;
324
- }
464
+ if (entryOutput) {
465
+ return path.basename(entryOutput[0]);
466
+ }
325
467
 
326
- if (matches.length === 1) {
327
- return matches[0] ?? null;
468
+ const matches = await scanGlob('app-*.js', { cwd: outputDir });
469
+ if (matches.length === 0) {
470
+ return null;
471
+ }
472
+
473
+ if (matches.length === 1) {
474
+ return matches[0] ?? null;
475
+ }
476
+
477
+ let latest: { name: string; time: number } | null = null;
478
+ for (const name of matches) {
479
+ const info = await stat(path.join(outputDir, name));
480
+ const time = info.mtimeMs;
481
+ if (!latest || time > latest.time) {
482
+ latest = { name, time };
328
483
  }
484
+ }
329
485
 
330
- let latest: { name: string; time: number } | null = null;
331
- for (const name of matches) {
332
- const info = await stat(path.join(outputDir, name));
333
- const time = info.mtimeMs;
334
- if (!latest || time > latest.time) {
335
- latest = { name, time };
336
- }
486
+ return latest?.name ?? matches[0] ?? null;
487
+ }
488
+
489
+ async function runBunBrowserBuild(options: {
490
+ readonly entryPoint: string;
491
+ readonly root: string;
492
+ readonly outputDir: string;
493
+ readonly minify: boolean;
494
+ readonly sourcemap: 'linked' | 'none';
495
+ readonly naming?: {
496
+ readonly entry: string;
497
+ readonly asset: string;
498
+ };
499
+ }): Promise<BunBuildOutput> {
500
+ const build = getBunBuild();
501
+ if (!build) {
502
+ throw new Error('Bun.build() is not available in the current runtime.');
503
+ }
504
+
505
+ return await build({
506
+ entrypoints: [options.entryPoint],
507
+ root: options.root,
508
+ outdir: options.outputDir,
509
+ target: 'browser',
510
+ format: 'esm',
511
+ minify: options.minify,
512
+ sourcemap: options.sourcemap,
513
+ splitting: false,
514
+ naming: options.naming,
515
+ throw: false,
516
+ });
517
+ }
518
+
519
+ function resolveJavaScriptBundler(
520
+ env: Record<string, string | undefined> | undefined,
521
+ ): JavaScriptBundler {
522
+ const requestedBundler = normalizeJavaScriptBundler(env?.WEBSTIR_FRONTEND_BUNDLER);
523
+ if (requestedBundler !== 'bun') {
524
+ return 'esbuild';
525
+ }
526
+
527
+ if (!getBunBuild()) {
528
+ console.warn(
529
+ '[webstir-frontend] WEBSTIR_FRONTEND_BUNDLER=bun requested outside a Bun runtime; falling back to esbuild.',
530
+ );
531
+ return 'esbuild';
532
+ }
533
+
534
+ return 'bun';
535
+ }
536
+
537
+ function normalizeJavaScriptBundler(rawBundler: unknown): JavaScriptBundler {
538
+ return typeof rawBundler === 'string' && rawBundler.trim().toLowerCase() === 'bun'
539
+ ? 'bun'
540
+ : 'esbuild';
541
+ }
542
+
543
+ function resolveBunEntryOutputName(
544
+ outputs: readonly BunBuildOutputFile[] | undefined,
545
+ outputDir: string,
546
+ matcher: (fileName: string) => boolean,
547
+ ): string | null {
548
+ const normalizedOutputDir = path.resolve(outputDir);
549
+ for (const output of outputs ?? []) {
550
+ if (output.kind !== 'entry-point') {
551
+ continue;
337
552
  }
553
+ if (path.resolve(path.dirname(output.path)) !== normalizedOutputDir) {
554
+ continue;
555
+ }
556
+ const fileName = path.basename(output.path);
557
+ if (matcher(fileName)) {
558
+ return fileName;
559
+ }
560
+ }
561
+
562
+ return null;
563
+ }
564
+
565
+ function ensureBunBuildSucceeded(result: BunBuildOutput, label: string): void {
566
+ const errors = (result.logs ?? [])
567
+ .filter((entry) => entry.level === 'error')
568
+ .map((entry) => formatBunBuildMessage(entry));
569
+ if (!result.success || errors.length > 0) {
570
+ throw new Error(errors[0] ?? `Bun.build() failed for ${label}.`);
571
+ }
572
+ }
338
573
 
339
- return latest?.name ?? matches[0] ?? null;
574
+ function formatBunBuildMessage(entry: BunBuildLog): string {
575
+ const text =
576
+ typeof entry.message === 'string'
577
+ ? entry.message
578
+ : typeof entry.text === 'string'
579
+ ? entry.text
580
+ : 'Bun.build() failed.';
581
+ const position = entry.position;
582
+ if (position?.file) {
583
+ const line = typeof position.line === 'number' ? position.line : 1;
584
+ const column = typeof position.column === 'number' ? position.column : 1;
585
+ return `${position.file}:${line}:${column} ${text}`;
586
+ }
587
+ return text;
588
+ }
589
+
590
+ function getBunBuild(): BunBuildFunction | undefined {
591
+ const runtime = globalThis as typeof globalThis & {
592
+ Bun?: {
593
+ build?: BunBuildFunction;
594
+ };
595
+ };
596
+ const build = runtime.Bun?.build;
597
+ return typeof build === 'function' ? build.bind(runtime.Bun) : undefined;
340
598
  }
341
599
 
342
600
  async function resolveAppEntry(appRoot: string): Promise<string | null> {
343
- const candidates = [
344
- `${APP_ENTRY_BASENAME}${EXTENSIONS.ts}`,
345
- `${APP_ENTRY_BASENAME}.tsx`,
346
- `${APP_ENTRY_BASENAME}${EXTENSIONS.js}`,
347
- `${APP_ENTRY_BASENAME}.jsx`
348
- ];
349
-
350
- for (const candidate of candidates) {
351
- const fullPath = path.join(appRoot, candidate);
352
- if (await pathExists(fullPath)) {
353
- return fullPath;
354
- }
601
+ const candidates = [
602
+ `${APP_ENTRY_BASENAME}${EXTENSIONS.ts}`,
603
+ `${APP_ENTRY_BASENAME}.tsx`,
604
+ `${APP_ENTRY_BASENAME}${EXTENSIONS.js}`,
605
+ `${APP_ENTRY_BASENAME}.jsx`,
606
+ ];
607
+
608
+ for (const candidate of candidates) {
609
+ const fullPath = path.join(appRoot, candidate);
610
+ if (await pathExists(fullPath)) {
611
+ return fullPath;
355
612
  }
613
+ }
356
614
 
357
- return null;
615
+ return null;
358
616
  }