@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.
- package/README.md +124 -60
- package/dist/assets/imageOptimizer.js +10 -15
- package/dist/assets/precompression.js +1 -1
- package/dist/builders/contentBuilder.js +102 -90
- package/dist/builders/cssBuilder.js +25 -19
- package/dist/builders/htmlBuilder.js +57 -42
- package/dist/builders/index.js +1 -1
- package/dist/builders/jsBuilder.js +219 -76
- package/dist/builders/staticAssetsBuilder.js +27 -9
- package/dist/builders/types.d.ts +1 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +6 -30
- package/dist/config/manifest.js +7 -6
- package/dist/config/paths.js +2 -2
- package/dist/config/schema.d.ts +8 -0
- package/dist/config/schema.js +7 -6
- package/dist/config/setup.js +1 -1
- package/dist/config/workspace.js +11 -9
- package/dist/core/constants.d.ts +1 -1
- package/dist/core/constants.js +5 -5
- package/dist/core/diagnostics.js +1 -1
- package/dist/core/pages.js +4 -4
- package/dist/hooks.js +3 -3
- package/dist/html/criticalCss.js +6 -3
- package/dist/html/htmlSecurity.d.ts +6 -1
- package/dist/html/htmlSecurity.js +28 -14
- package/dist/html/lazyLoad.js +1 -1
- package/dist/html/pageScaffold.js +1 -1
- package/dist/html/resourceHints.js +5 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/inspect.d.ts +2 -0
- package/dist/inspect.js +110 -0
- package/dist/modes/ssg/metadata.js +4 -4
- package/dist/modes/ssg/routing.js +2 -5
- package/dist/modes/ssg/seo.js +5 -5
- package/dist/modes/ssg/views.js +17 -11
- package/dist/operations.js +18 -10
- package/dist/pipeline.d.ts +1 -0
- package/dist/pipeline.js +6 -1
- package/dist/provider.js +28 -24
- package/dist/runtime/boundary.d.ts +28 -0
- package/dist/runtime/boundary.js +247 -0
- package/dist/runtime/index.d.ts +1 -0
- package/dist/runtime/index.js +1 -0
- package/dist/types.d.ts +52 -0
- package/dist/utils/fs.d.ts +11 -10
- package/dist/utils/fs.js +48 -20
- package/dist/utils/glob.d.ts +8 -0
- package/dist/utils/glob.js +21 -0
- package/dist/utils/hash.js +1 -2
- package/dist/utils/pagePaths.js +2 -2
- package/package.json +19 -14
- package/scripts/publish.sh +2 -94
- package/scripts/update-contract.sh +12 -10
- package/src/assets/assetManifest.ts +39 -29
- package/src/assets/imageOptimizer.ts +91 -82
- package/src/assets/precompression.ts +22 -16
- package/src/builders/contentBuilder.ts +1224 -1149
- package/src/builders/cssBuilder.ts +466 -417
- package/src/builders/htmlBuilder.ts +511 -448
- package/src/builders/index.ts +7 -7
- package/src/builders/jsBuilder.ts +538 -280
- package/src/builders/staticAssetsBuilder.ts +166 -135
- package/src/builders/types.ts +7 -6
- package/src/cli.ts +66 -90
- package/src/config/manifest.ts +16 -14
- package/src/config/paths.ts +5 -5
- package/src/config/schema.ts +38 -37
- package/src/config/setup.ts +7 -7
- package/src/config/workspace.ts +118 -116
- package/src/config/workspaceManifest.ts +14 -14
- package/src/core/constants.ts +62 -62
- package/src/core/diagnostics.ts +26 -26
- package/src/core/pages.ts +19 -19
- package/src/hooks.ts +128 -118
- package/src/html/criticalCss.ts +84 -77
- package/src/html/htmlSecurity.ts +107 -66
- package/src/html/lazyLoad.ts +22 -19
- package/src/html/pageScaffold.ts +37 -28
- package/src/html/resourceHints.ts +83 -74
- package/src/index.ts +2 -0
- package/src/inspect.ts +158 -0
- package/src/modes/ssg/metadata.ts +53 -51
- package/src/modes/ssg/routing.ts +177 -177
- package/src/modes/ssg/seo.ts +208 -200
- package/src/modes/ssg/validation.ts +31 -25
- package/src/modes/ssg/views.ts +257 -238
- package/src/operations.ts +105 -95
- package/src/pipeline.ts +81 -69
- package/src/provider.ts +184 -176
- package/src/runtime/boundary.ts +325 -0
- package/src/runtime/index.ts +1 -0
- package/src/types.ts +107 -48
- package/src/utils/changedFile.ts +22 -22
- package/src/utils/fs.ts +73 -26
- package/src/utils/glob.ts +38 -0
- package/src/utils/hash.ts +2 -4
- package/src/utils/pagePaths.ts +35 -23
- package/src/utils/pathMatch.ts +26 -23
- package/tests/add-page-defaults.test.js +44 -39
- package/tests/bundlerParity.test.js +252 -0
- package/tests/cli.contract.test.js +13 -0
- package/tests/content-pages.test.js +108 -13
- package/tests/css-app-imports.test.js +22 -11
- package/tests/css-page-imports.test.js +26 -13
- package/tests/diagnostics.test.js +39 -36
- package/tests/features.test.js +48 -43
- package/tests/hooks.test.js +58 -42
- package/tests/htmlSecurity.test.js +66 -0
- package/tests/inspect.test.js +148 -0
- package/tests/provider.integration.test.js +71 -20
- package/tests/runtime.test.js +493 -0
- package/tests/ssg-defaults.test.js +284 -177
- package/tests/ssg-guardrails.test.js +51 -51
- package/tsconfig.json +3 -10
- package/dist/watch/frontendFiles.d.ts +0 -3
- package/dist/watch/frontendFiles.js +0 -25
- package/dist/watch/hotUpdateTracker.d.ts +0 -51
- package/dist/watch/hotUpdateTracker.js +0 -205
- package/dist/watch/pipelineHelpers.d.ts +0 -26
- package/dist/watch/pipelineHelpers.js +0 -177
- package/dist/watch/types.d.ts +0 -27
- package/dist/watch/types.js +0 -1
- package/dist/watch/watchCoordinator.d.ts +0 -36
- package/dist/watch/watchCoordinator.js +0 -551
- package/dist/watch/watchDaemon.d.ts +0 -17
- package/dist/watch/watchDaemon.js +0 -127
- package/dist/watch/watchReporter.d.ts +0 -21
- package/dist/watch/watchReporter.js +0 -64
- package/scripts/smoke.mjs +0 -35
- package/src/watch/frontendFiles.ts +0 -32
- package/src/watch/hotUpdateTracker.ts +0 -285
- package/src/watch/pipelineHelpers.ts +0 -242
- package/src/watch/types.ts +0 -23
- package/src/watch/watchCoordinator.ts +0 -666
- package/src/watch/watchDaemon.ts +0 -144
- 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 {
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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(
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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(
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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(
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
358
|
+
config: BuilderContext['config'],
|
|
359
|
+
_enable: BuilderContext['enable'],
|
|
360
|
+
isProduction: boolean,
|
|
217
361
|
): Promise<void> {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
398
|
+
return null;
|
|
255
399
|
}
|
|
256
400
|
|
|
257
|
-
async function assertFeatureModulesPresent(
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
409
|
+
const missing: string[] = [];
|
|
263
410
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
432
|
+
if (missing.length === 0) {
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
288
435
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
451
|
+
outputDir: string,
|
|
452
|
+
entryPoint: string,
|
|
453
|
+
metafile?: Metafile,
|
|
307
454
|
): Promise<string | null> {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
}
|
|
464
|
+
if (entryOutput) {
|
|
465
|
+
return path.basename(entryOutput[0]);
|
|
466
|
+
}
|
|
325
467
|
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
615
|
+
return null;
|
|
358
616
|
}
|