@webstir-io/webstir-frontend 0.1.40
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/LICENSE +21 -0
- package/README.md +158 -0
- package/dist/assets/assetManifest.d.ts +16 -0
- package/dist/assets/assetManifest.js +31 -0
- package/dist/assets/imageOptimizer.d.ts +6 -0
- package/dist/assets/imageOptimizer.js +93 -0
- package/dist/assets/precompression.d.ts +1 -0
- package/dist/assets/precompression.js +21 -0
- package/dist/builders/contentBuilder.d.ts +2 -0
- package/dist/builders/contentBuilder.js +1052 -0
- package/dist/builders/cssBuilder.d.ts +2 -0
- package/dist/builders/cssBuilder.js +439 -0
- package/dist/builders/htmlBuilder.d.ts +2 -0
- package/dist/builders/htmlBuilder.js +430 -0
- package/dist/builders/index.d.ts +2 -0
- package/dist/builders/index.js +14 -0
- package/dist/builders/jsBuilder.d.ts +2 -0
- package/dist/builders/jsBuilder.js +300 -0
- package/dist/builders/staticAssetsBuilder.d.ts +2 -0
- package/dist/builders/staticAssetsBuilder.js +158 -0
- package/dist/builders/types.d.ts +12 -0
- package/dist/builders/types.js +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +105 -0
- package/dist/config/manifest.d.ts +7 -0
- package/dist/config/manifest.js +17 -0
- package/dist/config/paths.d.ts +3 -0
- package/dist/config/paths.js +11 -0
- package/dist/config/schema.d.ts +413 -0
- package/dist/config/schema.js +44 -0
- package/dist/config/setup.d.ts +2 -0
- package/dist/config/setup.js +12 -0
- package/dist/config/workspace.d.ts +2 -0
- package/dist/config/workspace.js +131 -0
- package/dist/config/workspaceManifest.d.ts +23 -0
- package/dist/config/workspaceManifest.js +1 -0
- package/dist/core/constants.d.ts +70 -0
- package/dist/core/constants.js +70 -0
- package/dist/core/diagnostics.d.ts +15 -0
- package/dist/core/diagnostics.js +21 -0
- package/dist/core/index.d.ts +3 -0
- package/dist/core/index.js +3 -0
- package/dist/core/pages.d.ts +6 -0
- package/dist/core/pages.js +23 -0
- package/dist/hooks.d.ts +19 -0
- package/dist/hooks.js +115 -0
- package/dist/html/criticalCss.d.ts +4 -0
- package/dist/html/criticalCss.js +192 -0
- package/dist/html/htmlSecurity.d.ts +5 -0
- package/dist/html/htmlSecurity.js +73 -0
- package/dist/html/lazyLoad.d.ts +6 -0
- package/dist/html/lazyLoad.js +21 -0
- package/dist/html/pageScaffold.d.ts +10 -0
- package/dist/html/pageScaffold.js +51 -0
- package/dist/html/resourceHints.d.ts +7 -0
- package/dist/html/resourceHints.js +64 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/modes/ssg/index.d.ts +4 -0
- package/dist/modes/ssg/index.js +4 -0
- package/dist/modes/ssg/metadata.d.ts +5 -0
- package/dist/modes/ssg/metadata.js +50 -0
- package/dist/modes/ssg/routing.d.ts +2 -0
- package/dist/modes/ssg/routing.js +186 -0
- package/dist/modes/ssg/seo.d.ts +4 -0
- package/dist/modes/ssg/seo.js +208 -0
- package/dist/modes/ssg/validation.d.ts +3 -0
- package/dist/modes/ssg/validation.js +27 -0
- package/dist/modes/ssg/views.d.ts +2 -0
- package/dist/modes/ssg/views.js +236 -0
- package/dist/operations.d.ts +5 -0
- package/dist/operations.js +102 -0
- package/dist/pipeline.d.ts +7 -0
- package/dist/pipeline.js +71 -0
- package/dist/provider.d.ts +2 -0
- package/dist/provider.js +176 -0
- package/dist/types.d.ts +61 -0
- package/dist/types.js +1 -0
- package/dist/utils/changedFile.d.ts +8 -0
- package/dist/utils/changedFile.js +26 -0
- package/dist/utils/fs.d.ts +11 -0
- package/dist/utils/fs.js +39 -0
- package/dist/utils/hash.d.ts +1 -0
- package/dist/utils/hash.js +5 -0
- package/dist/utils/pagePaths.d.ts +5 -0
- package/dist/utils/pagePaths.js +36 -0
- package/dist/utils/pathMatch.d.ts +3 -0
- package/dist/utils/pathMatch.js +29 -0
- package/dist/watch/frontendFiles.d.ts +3 -0
- package/dist/watch/frontendFiles.js +25 -0
- package/dist/watch/hotUpdateTracker.d.ts +51 -0
- package/dist/watch/hotUpdateTracker.js +205 -0
- package/dist/watch/pipelineHelpers.d.ts +26 -0
- package/dist/watch/pipelineHelpers.js +177 -0
- package/dist/watch/types.d.ts +27 -0
- package/dist/watch/types.js +1 -0
- package/dist/watch/watchCoordinator.d.ts +36 -0
- package/dist/watch/watchCoordinator.js +551 -0
- package/dist/watch/watchDaemon.d.ts +17 -0
- package/dist/watch/watchDaemon.js +127 -0
- package/dist/watch/watchReporter.d.ts +21 -0
- package/dist/watch/watchReporter.js +64 -0
- package/package.json +92 -0
- package/scripts/publish.sh +101 -0
- package/scripts/smoke.mjs +35 -0
- package/scripts/update-contract.sh +121 -0
- package/src/assets/assetManifest.ts +51 -0
- package/src/assets/imageOptimizer.ts +112 -0
- package/src/assets/precompression.ts +25 -0
- package/src/builders/contentBuilder.ts +1400 -0
- package/src/builders/cssBuilder.ts +552 -0
- package/src/builders/htmlBuilder.ts +540 -0
- package/src/builders/index.ts +16 -0
- package/src/builders/jsBuilder.ts +358 -0
- package/src/builders/staticAssetsBuilder.ts +174 -0
- package/src/builders/types.ts +15 -0
- package/src/cli.ts +108 -0
- package/src/config/manifest.ts +24 -0
- package/src/config/paths.ts +14 -0
- package/src/config/schema.ts +49 -0
- package/src/config/setup.ts +14 -0
- package/src/config/workspace.ts +150 -0
- package/src/config/workspaceManifest.ts +27 -0
- package/src/core/constants.ts +73 -0
- package/src/core/diagnostics.ts +40 -0
- package/src/core/index.ts +3 -0
- package/src/core/pages.ts +31 -0
- package/src/hooks.ts +175 -0
- package/src/html/criticalCss.ts +214 -0
- package/src/html/htmlSecurity.ts +86 -0
- package/src/html/lazyLoad.ts +30 -0
- package/src/html/pageScaffold.ts +70 -0
- package/src/html/resourceHints.ts +91 -0
- package/src/index.ts +5 -0
- package/src/modes/ssg/index.ts +4 -0
- package/src/modes/ssg/metadata.ts +63 -0
- package/src/modes/ssg/routing.ts +230 -0
- package/src/modes/ssg/seo.ts +261 -0
- package/src/modes/ssg/validation.ts +37 -0
- package/src/modes/ssg/views.ts +309 -0
- package/src/operations.ts +138 -0
- package/src/pipeline.ts +88 -0
- package/src/provider.ts +249 -0
- package/src/types.ts +67 -0
- package/src/utils/changedFile.ts +39 -0
- package/src/utils/fs.ts +48 -0
- package/src/utils/hash.ts +6 -0
- package/src/utils/pagePaths.ts +43 -0
- package/src/utils/pathMatch.ts +36 -0
- package/src/watch/frontendFiles.ts +32 -0
- package/src/watch/hotUpdateTracker.ts +285 -0
- package/src/watch/pipelineHelpers.ts +242 -0
- package/src/watch/types.ts +23 -0
- package/src/watch/watchCoordinator.ts +666 -0
- package/src/watch/watchDaemon.ts +144 -0
- package/src/watch/watchReporter.ts +98 -0
- package/tests/add-page-defaults.test.js +64 -0
- package/tests/content-pages.test.js +81 -0
- package/tests/css-app-imports.test.js +64 -0
- package/tests/css-page-imports.test.js +100 -0
- package/tests/diagnostics.test.js +48 -0
- package/tests/features.test.js +63 -0
- package/tests/hooks.test.js +71 -0
- package/tests/provider.integration.test.js +137 -0
- package/tests/ssg-defaults.test.js +201 -0
- package/tests/ssg-guardrails.test.js +69 -0
- package/tsconfig.json +27 -0
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { build as esbuild, type Metafile } from 'esbuild';
|
|
3
|
+
import { glob } from 'glob';
|
|
4
|
+
import { FOLDERS, FILES, EXTENSIONS } from '../core/constants.js';
|
|
5
|
+
import type { Builder, BuilderContext } from './types.js';
|
|
6
|
+
import { getPages } from '../core/pages.js';
|
|
7
|
+
import { ensureDir, pathExists, copy, remove, stat } from '../utils/fs.js';
|
|
8
|
+
import { updatePageManifest, updateSharedAssets, readSharedAssets } from '../assets/assetManifest.js';
|
|
9
|
+
import { createCompressedVariants } from '../assets/precompression.js';
|
|
10
|
+
import { shouldProcess } from '../utils/changedFile.js';
|
|
11
|
+
import { findPageFromChangedFile } from '../utils/pathMatch.js';
|
|
12
|
+
|
|
13
|
+
const ENTRY_EXTENSIONS = ['.ts', '.tsx', '.js'];
|
|
14
|
+
const APP_ENTRY_BASENAME = 'app';
|
|
15
|
+
|
|
16
|
+
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
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
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;
|
|
37
|
+
}
|
|
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
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
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);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
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;
|
|
73
|
+
}
|
|
74
|
+
|
|
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;
|
|
128
|
+
}
|
|
129
|
+
|
|
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;
|
|
135
|
+
}
|
|
136
|
+
|
|
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'
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
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
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
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);
|
|
176
|
+
|
|
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
|
+
});
|
|
191
|
+
|
|
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
|
+
}
|
|
197
|
+
|
|
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
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function copyRuntimeScripts(
|
|
214
|
+
config: BuilderContext['config'],
|
|
215
|
+
enable: BuilderContext['enable'],
|
|
216
|
+
isProduction: boolean
|
|
217
|
+
): 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
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
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
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function assertFeatureModulesPresent(config: BuilderContext['config'], enable: BuilderContext['enable']): Promise<void> {
|
|
258
|
+
if (!enable) {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const missing: string[] = [];
|
|
263
|
+
|
|
264
|
+
if (enable.clientNav === true) {
|
|
265
|
+
const hasClientNav = await hasFeatureModule(config, 'client-nav');
|
|
266
|
+
if (!hasClientNav) {
|
|
267
|
+
missing.push('client-nav');
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (enable.search === true) {
|
|
272
|
+
const hasSearch = await hasFeatureModule(config, 'search');
|
|
273
|
+
if (!hasSearch) {
|
|
274
|
+
missing.push('search');
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (enable.contentNav === true) {
|
|
279
|
+
const hasContentNav = await hasFeatureModule(config, 'content-nav');
|
|
280
|
+
if (!hasContentNav) {
|
|
281
|
+
missing.push('content-nav');
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (missing.length === 0) {
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
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
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
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}`));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function resolveAppBundleName(
|
|
304
|
+
outputDir: string,
|
|
305
|
+
entryPoint: string,
|
|
306
|
+
metafile?: Metafile
|
|
307
|
+
): 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]);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const matches = await glob('app-*.js', { cwd: outputDir, nodir: true });
|
|
322
|
+
if (matches.length === 0) {
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (matches.length === 1) {
|
|
327
|
+
return matches[0] ?? null;
|
|
328
|
+
}
|
|
329
|
+
|
|
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
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return latest?.name ?? matches[0] ?? null;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
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
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { FOLDERS, EXTENSIONS, FILES } from '../core/constants.js';
|
|
3
|
+
import { copy, pathExists, emptyDir, ensureDir, remove, writeFile } from '../utils/fs.js';
|
|
4
|
+
import type { Builder, BuilderContext } from './types.js';
|
|
5
|
+
import { shouldProcess } from '../utils/changedFile.js';
|
|
6
|
+
import { optimizeImages } from '../assets/imageOptimizer.js';
|
|
7
|
+
import { relativePathWithin } from '../utils/pathMatch.js';
|
|
8
|
+
|
|
9
|
+
const IMAGE_EXTENSIONS = [
|
|
10
|
+
EXTENSIONS.png,
|
|
11
|
+
EXTENSIONS.jpg,
|
|
12
|
+
EXTENSIONS.jpeg,
|
|
13
|
+
EXTENSIONS.gif,
|
|
14
|
+
EXTENSIONS.svg,
|
|
15
|
+
EXTENSIONS.webp,
|
|
16
|
+
EXTENSIONS.ico
|
|
17
|
+
] as const;
|
|
18
|
+
|
|
19
|
+
const FONT_EXTENSIONS = [
|
|
20
|
+
EXTENSIONS.woff,
|
|
21
|
+
EXTENSIONS.woff2,
|
|
22
|
+
EXTENSIONS.ttf,
|
|
23
|
+
EXTENSIONS.otf,
|
|
24
|
+
EXTENSIONS.eot
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
27
|
+
const MEDIA_EXTENSIONS = [
|
|
28
|
+
EXTENSIONS.mp3,
|
|
29
|
+
EXTENSIONS.m4a,
|
|
30
|
+
EXTENSIONS.wav,
|
|
31
|
+
EXTENSIONS.ogg,
|
|
32
|
+
EXTENSIONS.mp4,
|
|
33
|
+
EXTENSIONS.webm,
|
|
34
|
+
EXTENSIONS.mov
|
|
35
|
+
] as const;
|
|
36
|
+
|
|
37
|
+
const ALLOW_ALL_ROBOTS = 'User-agent: *\nAllow: /\n';
|
|
38
|
+
|
|
39
|
+
export function createStaticAssetsBuilder(context: BuilderContext): Builder {
|
|
40
|
+
return {
|
|
41
|
+
name: 'static-assets',
|
|
42
|
+
async build(): Promise<void> {
|
|
43
|
+
await copyStaticAssets(context, false);
|
|
44
|
+
},
|
|
45
|
+
async publish(): Promise<void> {
|
|
46
|
+
await copyStaticAssets(context, true);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function copyStaticAssets(context: BuilderContext, isProduction: boolean): Promise<void> {
|
|
52
|
+
const { config } = context;
|
|
53
|
+
if (!shouldProcess(context, [
|
|
54
|
+
{ directory: config.paths.src.images, extensions: IMAGE_EXTENSIONS },
|
|
55
|
+
{ directory: config.paths.src.fonts, extensions: FONT_EXTENSIONS },
|
|
56
|
+
{ directory: config.paths.src.media, extensions: MEDIA_EXTENSIONS }
|
|
57
|
+
])) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const targets = [
|
|
62
|
+
{ source: config.paths.src.images, build: config.paths.build.frontend, dist: config.paths.dist.frontend, folder: FOLDERS.images, extensions: IMAGE_EXTENSIONS },
|
|
63
|
+
{ source: config.paths.src.fonts, build: config.paths.build.frontend, dist: config.paths.dist.frontend, folder: FOLDERS.fonts, extensions: FONT_EXTENSIONS },
|
|
64
|
+
{ source: config.paths.src.media, build: config.paths.build.frontend, dist: config.paths.dist.frontend, folder: FOLDERS.media, extensions: MEDIA_EXTENSIONS }
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
for (const target of targets) {
|
|
68
|
+
if (!(await pathExists(target.source))) {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const changedRelative = relativePathWithin(context.changedFile, target.source);
|
|
73
|
+
const buildDestination = path.join(target.build, target.folder);
|
|
74
|
+
|
|
75
|
+
if (!context.changedFile || !changedRelative) {
|
|
76
|
+
await emptyDir(buildDestination);
|
|
77
|
+
await copy(target.source, buildDestination);
|
|
78
|
+
|
|
79
|
+
if (isProduction) {
|
|
80
|
+
const distDestination = path.join(target.dist, target.folder);
|
|
81
|
+
if (target.folder === FOLDERS.images) {
|
|
82
|
+
if (config.features.imageOptimization) {
|
|
83
|
+
await optimizeImages(buildDestination, distDestination);
|
|
84
|
+
} else {
|
|
85
|
+
await emptyDir(distDestination);
|
|
86
|
+
await copy(buildDestination, distDestination);
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
await emptyDir(distDestination);
|
|
90
|
+
await copy(buildDestination, distDestination);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
await copySingleAsset(target.source, buildDestination, changedRelative);
|
|
97
|
+
|
|
98
|
+
if (isProduction) {
|
|
99
|
+
const distDestination = path.join(target.dist, target.folder);
|
|
100
|
+
if (target.folder === FOLDERS.images) {
|
|
101
|
+
if (config.features.imageOptimization) {
|
|
102
|
+
await optimizeImages(buildDestination, distDestination, [changedRelative]);
|
|
103
|
+
} else {
|
|
104
|
+
await syncImageWithoutOptimization(buildDestination, distDestination, changedRelative);
|
|
105
|
+
}
|
|
106
|
+
} else {
|
|
107
|
+
const sourcePath = path.join(target.source, changedRelative);
|
|
108
|
+
const destPath = path.join(distDestination, changedRelative);
|
|
109
|
+
if (await pathExists(sourcePath)) {
|
|
110
|
+
await ensureDir(path.dirname(destPath));
|
|
111
|
+
await copy(sourcePath, destPath);
|
|
112
|
+
} else {
|
|
113
|
+
await remove(destPath).catch(() => undefined);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
await syncRobotsTxt(config, isProduction);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function copySingleAsset(sourceRoot: string, buildRoot: string, relativePath: string): Promise<void> {
|
|
123
|
+
const sourcePath = path.join(sourceRoot, relativePath);
|
|
124
|
+
const destinationPath = path.join(buildRoot, relativePath);
|
|
125
|
+
|
|
126
|
+
if (await pathExists(sourcePath)) {
|
|
127
|
+
await ensureDir(path.dirname(destinationPath));
|
|
128
|
+
await copy(sourcePath, destinationPath);
|
|
129
|
+
} else {
|
|
130
|
+
await remove(destinationPath).catch(() => undefined);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function syncImageWithoutOptimization(buildRoot: string, distRoot: string, relativePath: string): Promise<void> {
|
|
135
|
+
const sourcePath = path.join(buildRoot, relativePath);
|
|
136
|
+
const destinationPath = path.join(distRoot, relativePath);
|
|
137
|
+
|
|
138
|
+
if (await pathExists(sourcePath)) {
|
|
139
|
+
await ensureDir(path.dirname(destinationPath));
|
|
140
|
+
await copy(sourcePath, destinationPath);
|
|
141
|
+
} else {
|
|
142
|
+
await remove(destinationPath).catch(() => undefined);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
await Promise.all([
|
|
146
|
+
remove(`${destinationPath}${EXTENSIONS.webp}`).catch(() => undefined),
|
|
147
|
+
remove(`${destinationPath}${EXTENSIONS.avif}`).catch(() => undefined)
|
|
148
|
+
]);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function syncRobotsTxt(config: BuilderContext['config'], isProduction: boolean): Promise<void> {
|
|
152
|
+
const sourcePath = path.join(config.paths.src.frontend, FILES.robotsTxt);
|
|
153
|
+
const buildPath = path.join(config.paths.build.frontend, FILES.robotsTxt);
|
|
154
|
+
|
|
155
|
+
if (await pathExists(sourcePath)) {
|
|
156
|
+
await ensureDir(path.dirname(buildPath));
|
|
157
|
+
await copy(sourcePath, buildPath);
|
|
158
|
+
|
|
159
|
+
if (isProduction) {
|
|
160
|
+
const distPath = path.join(config.paths.dist.frontend, FILES.robotsTxt);
|
|
161
|
+
await ensureDir(path.dirname(distPath));
|
|
162
|
+
await copy(sourcePath, distPath);
|
|
163
|
+
}
|
|
164
|
+
} else {
|
|
165
|
+
await ensureDir(path.dirname(buildPath));
|
|
166
|
+
await writeFile(buildPath, ALLOW_ALL_ROBOTS);
|
|
167
|
+
|
|
168
|
+
if (isProduction) {
|
|
169
|
+
const distPath = path.join(config.paths.dist.frontend, FILES.robotsTxt);
|
|
170
|
+
await ensureDir(path.dirname(distPath));
|
|
171
|
+
await writeFile(distPath, ALLOW_ALL_ROBOTS);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { EnableFlags, FrontendConfig } from '../types.js';
|
|
2
|
+
|
|
3
|
+
export interface BuilderContext {
|
|
4
|
+
readonly config: FrontendConfig;
|
|
5
|
+
readonly changedFile?: string;
|
|
6
|
+
readonly enable?: EnableFlags;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface Builder {
|
|
10
|
+
readonly name: string;
|
|
11
|
+
build(context: BuilderContext): Promise<void>;
|
|
12
|
+
publish(context: BuilderContext): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type BuilderFactory = (context: BuilderContext) => Builder;
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { runAddPage, runBuild, runPublish, runRebuild } from './operations.js';
|
|
4
|
+
import { WatchDaemon } from './watch/watchDaemon.js';
|
|
5
|
+
|
|
6
|
+
const program = new Command();
|
|
7
|
+
|
|
8
|
+
program
|
|
9
|
+
.name('webstir-frontend')
|
|
10
|
+
.description('Webstir frontend build orchestrator');
|
|
11
|
+
|
|
12
|
+
program
|
|
13
|
+
.command('build')
|
|
14
|
+
.description('Build frontend assets for development workflows')
|
|
15
|
+
.requiredOption('-w, --workspace <path>', 'Absolute path to the workspace root')
|
|
16
|
+
.option('-c, --changed-file <path>', 'Optional path filter for incremental builds')
|
|
17
|
+
.action(async (cmd) => {
|
|
18
|
+
try {
|
|
19
|
+
await runBuild({
|
|
20
|
+
workspaceRoot: cmd.workspace,
|
|
21
|
+
changedFile: cmd.changedFile ?? undefined
|
|
22
|
+
});
|
|
23
|
+
} catch (error) {
|
|
24
|
+
handleError(error);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
program
|
|
29
|
+
.command('publish')
|
|
30
|
+
.description('Build production assets into the dist directory')
|
|
31
|
+
.requiredOption('-w, --workspace <path>', 'Absolute path to the workspace root')
|
|
32
|
+
.option('-m, --mode <mode>', 'Publish mode: bundle or ssg', 'bundle')
|
|
33
|
+
.action(async (cmd) => {
|
|
34
|
+
try {
|
|
35
|
+
await runPublish({
|
|
36
|
+
workspaceRoot: cmd.workspace,
|
|
37
|
+
publishMode: cmd.mode === 'ssg' ? 'ssg' : 'bundle'
|
|
38
|
+
});
|
|
39
|
+
} catch (error) {
|
|
40
|
+
handleError(error);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
program
|
|
45
|
+
.command('rebuild')
|
|
46
|
+
.description('Rebuild frontend assets in response to file changes')
|
|
47
|
+
.requiredOption('-w, --workspace <path>', 'Absolute path to the workspace root')
|
|
48
|
+
.requiredOption('-c, --changed-file <path>', 'Path to the changed file triggering the rebuild')
|
|
49
|
+
.action(async (cmd) => {
|
|
50
|
+
try {
|
|
51
|
+
await runRebuild({
|
|
52
|
+
workspaceRoot: cmd.workspace,
|
|
53
|
+
changedFile: cmd.changedFile ?? undefined
|
|
54
|
+
});
|
|
55
|
+
} catch (error) {
|
|
56
|
+
handleError(error);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
program
|
|
61
|
+
.command('add-page <name>')
|
|
62
|
+
.description('Scaffold a new frontend page (HTML/CSS/TS)')
|
|
63
|
+
.requiredOption('-w, --workspace <path>', 'Absolute path to the workspace root')
|
|
64
|
+
.option('-m, --mode <mode>', 'Page mode: standard or ssg (defaults to ssg when webstir.mode=ssg)')
|
|
65
|
+
.action(async (name, cmd) => {
|
|
66
|
+
try {
|
|
67
|
+
const rawMode = typeof cmd.mode === 'string' ? cmd.mode.toLowerCase() : undefined;
|
|
68
|
+
await runAddPage({
|
|
69
|
+
workspaceRoot: cmd.workspace,
|
|
70
|
+
pageName: name,
|
|
71
|
+
ssg: rawMode === 'ssg' ? true : rawMode === 'standard' ? false : undefined
|
|
72
|
+
});
|
|
73
|
+
} catch (error) {
|
|
74
|
+
handleError(error);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
program
|
|
79
|
+
.command('watch-daemon')
|
|
80
|
+
.description('Run the persistent frontend watch daemon')
|
|
81
|
+
.requiredOption('-w, --workspace <path>', 'Absolute path to the workspace root')
|
|
82
|
+
.option('--no-auto-start', 'Defer startup until a start command is received')
|
|
83
|
+
.option('-v, --verbose', 'Enable verbose watch diagnostics')
|
|
84
|
+
.option('--hmr-verbose', 'Log detailed hot-update diagnostics')
|
|
85
|
+
.action(async (cmd) => {
|
|
86
|
+
try {
|
|
87
|
+
const daemon = new WatchDaemon({
|
|
88
|
+
workspaceRoot: cmd.workspace,
|
|
89
|
+
autoStart: cmd.autoStart,
|
|
90
|
+
verbose: cmd.verbose === true,
|
|
91
|
+
hmrVerbose: cmd.hmrVerbose === true
|
|
92
|
+
});
|
|
93
|
+
await daemon.run();
|
|
94
|
+
} catch (error) {
|
|
95
|
+
handleError(error);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
program.parseAsync(process.argv).catch(handleError);
|
|
100
|
+
|
|
101
|
+
function handleError(error: unknown): void {
|
|
102
|
+
if (error instanceof Error) {
|
|
103
|
+
console.error(error.message);
|
|
104
|
+
} else {
|
|
105
|
+
console.error('Unknown error', error);
|
|
106
|
+
}
|
|
107
|
+
process.exitCode = 1;
|
|
108
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { frontendConfigSchema, type FrontendConfigInput } from './schema.js';
|
|
4
|
+
|
|
5
|
+
export interface WriteManifestOptions {
|
|
6
|
+
readonly outputPath: string;
|
|
7
|
+
readonly data: FrontendConfigInput;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function writeConfigManifest(options: WriteManifestOptions): Promise<void> {
|
|
11
|
+
const parsed = frontendConfigSchema.parse(options.data);
|
|
12
|
+
const directory = path.dirname(options.outputPath);
|
|
13
|
+
await fs.mkdir(directory, { recursive: true });
|
|
14
|
+
const serialized = JSON.stringify(parsed, undefined, 2);
|
|
15
|
+
const tempPath = path.join(directory, `.webstir-frontend-${process.pid}-${Date.now()}.tmp`);
|
|
16
|
+
await fs.writeFile(tempPath, serialized, 'utf8');
|
|
17
|
+
await fs.rename(tempPath, options.outputPath);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function readConfigManifest(manifestPath: string): Promise<FrontendConfigInput> {
|
|
21
|
+
const json = await fs.readFile(manifestPath, 'utf8');
|
|
22
|
+
const parsed = JSON.parse(json) as unknown;
|
|
23
|
+
return frontendConfigSchema.parse(parsed);
|
|
24
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { promises as fs } from 'fs';
|
|
3
|
+
import { FOLDERS } from '../core/constants.js';
|
|
4
|
+
|
|
5
|
+
export const FRONTEND_MANIFEST_FILENAME = 'frontend-manifest.json';
|
|
6
|
+
|
|
7
|
+
export function resolveManifestPath(workspaceRoot: string): string {
|
|
8
|
+
return path.join(workspaceRoot, FOLDERS.webstir, FRONTEND_MANIFEST_FILENAME);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function ensureWebstirDirectory(workspaceRoot: string): Promise<void> {
|
|
12
|
+
const webstirPath = path.join(workspaceRoot, FOLDERS.webstir);
|
|
13
|
+
await fs.mkdir(webstirPath, { recursive: true });
|
|
14
|
+
}
|