@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.
Files changed (167) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +158 -0
  3. package/dist/assets/assetManifest.d.ts +16 -0
  4. package/dist/assets/assetManifest.js +31 -0
  5. package/dist/assets/imageOptimizer.d.ts +6 -0
  6. package/dist/assets/imageOptimizer.js +93 -0
  7. package/dist/assets/precompression.d.ts +1 -0
  8. package/dist/assets/precompression.js +21 -0
  9. package/dist/builders/contentBuilder.d.ts +2 -0
  10. package/dist/builders/contentBuilder.js +1052 -0
  11. package/dist/builders/cssBuilder.d.ts +2 -0
  12. package/dist/builders/cssBuilder.js +439 -0
  13. package/dist/builders/htmlBuilder.d.ts +2 -0
  14. package/dist/builders/htmlBuilder.js +430 -0
  15. package/dist/builders/index.d.ts +2 -0
  16. package/dist/builders/index.js +14 -0
  17. package/dist/builders/jsBuilder.d.ts +2 -0
  18. package/dist/builders/jsBuilder.js +300 -0
  19. package/dist/builders/staticAssetsBuilder.d.ts +2 -0
  20. package/dist/builders/staticAssetsBuilder.js +158 -0
  21. package/dist/builders/types.d.ts +12 -0
  22. package/dist/builders/types.js +1 -0
  23. package/dist/cli.d.ts +2 -0
  24. package/dist/cli.js +105 -0
  25. package/dist/config/manifest.d.ts +7 -0
  26. package/dist/config/manifest.js +17 -0
  27. package/dist/config/paths.d.ts +3 -0
  28. package/dist/config/paths.js +11 -0
  29. package/dist/config/schema.d.ts +413 -0
  30. package/dist/config/schema.js +44 -0
  31. package/dist/config/setup.d.ts +2 -0
  32. package/dist/config/setup.js +12 -0
  33. package/dist/config/workspace.d.ts +2 -0
  34. package/dist/config/workspace.js +131 -0
  35. package/dist/config/workspaceManifest.d.ts +23 -0
  36. package/dist/config/workspaceManifest.js +1 -0
  37. package/dist/core/constants.d.ts +70 -0
  38. package/dist/core/constants.js +70 -0
  39. package/dist/core/diagnostics.d.ts +15 -0
  40. package/dist/core/diagnostics.js +21 -0
  41. package/dist/core/index.d.ts +3 -0
  42. package/dist/core/index.js +3 -0
  43. package/dist/core/pages.d.ts +6 -0
  44. package/dist/core/pages.js +23 -0
  45. package/dist/hooks.d.ts +19 -0
  46. package/dist/hooks.js +115 -0
  47. package/dist/html/criticalCss.d.ts +4 -0
  48. package/dist/html/criticalCss.js +192 -0
  49. package/dist/html/htmlSecurity.d.ts +5 -0
  50. package/dist/html/htmlSecurity.js +73 -0
  51. package/dist/html/lazyLoad.d.ts +6 -0
  52. package/dist/html/lazyLoad.js +21 -0
  53. package/dist/html/pageScaffold.d.ts +10 -0
  54. package/dist/html/pageScaffold.js +51 -0
  55. package/dist/html/resourceHints.d.ts +7 -0
  56. package/dist/html/resourceHints.js +64 -0
  57. package/dist/index.d.ts +5 -0
  58. package/dist/index.js +5 -0
  59. package/dist/modes/ssg/index.d.ts +4 -0
  60. package/dist/modes/ssg/index.js +4 -0
  61. package/dist/modes/ssg/metadata.d.ts +5 -0
  62. package/dist/modes/ssg/metadata.js +50 -0
  63. package/dist/modes/ssg/routing.d.ts +2 -0
  64. package/dist/modes/ssg/routing.js +186 -0
  65. package/dist/modes/ssg/seo.d.ts +4 -0
  66. package/dist/modes/ssg/seo.js +208 -0
  67. package/dist/modes/ssg/validation.d.ts +3 -0
  68. package/dist/modes/ssg/validation.js +27 -0
  69. package/dist/modes/ssg/views.d.ts +2 -0
  70. package/dist/modes/ssg/views.js +236 -0
  71. package/dist/operations.d.ts +5 -0
  72. package/dist/operations.js +102 -0
  73. package/dist/pipeline.d.ts +7 -0
  74. package/dist/pipeline.js +71 -0
  75. package/dist/provider.d.ts +2 -0
  76. package/dist/provider.js +176 -0
  77. package/dist/types.d.ts +61 -0
  78. package/dist/types.js +1 -0
  79. package/dist/utils/changedFile.d.ts +8 -0
  80. package/dist/utils/changedFile.js +26 -0
  81. package/dist/utils/fs.d.ts +11 -0
  82. package/dist/utils/fs.js +39 -0
  83. package/dist/utils/hash.d.ts +1 -0
  84. package/dist/utils/hash.js +5 -0
  85. package/dist/utils/pagePaths.d.ts +5 -0
  86. package/dist/utils/pagePaths.js +36 -0
  87. package/dist/utils/pathMatch.d.ts +3 -0
  88. package/dist/utils/pathMatch.js +29 -0
  89. package/dist/watch/frontendFiles.d.ts +3 -0
  90. package/dist/watch/frontendFiles.js +25 -0
  91. package/dist/watch/hotUpdateTracker.d.ts +51 -0
  92. package/dist/watch/hotUpdateTracker.js +205 -0
  93. package/dist/watch/pipelineHelpers.d.ts +26 -0
  94. package/dist/watch/pipelineHelpers.js +177 -0
  95. package/dist/watch/types.d.ts +27 -0
  96. package/dist/watch/types.js +1 -0
  97. package/dist/watch/watchCoordinator.d.ts +36 -0
  98. package/dist/watch/watchCoordinator.js +551 -0
  99. package/dist/watch/watchDaemon.d.ts +17 -0
  100. package/dist/watch/watchDaemon.js +127 -0
  101. package/dist/watch/watchReporter.d.ts +21 -0
  102. package/dist/watch/watchReporter.js +64 -0
  103. package/package.json +92 -0
  104. package/scripts/publish.sh +101 -0
  105. package/scripts/smoke.mjs +35 -0
  106. package/scripts/update-contract.sh +121 -0
  107. package/src/assets/assetManifest.ts +51 -0
  108. package/src/assets/imageOptimizer.ts +112 -0
  109. package/src/assets/precompression.ts +25 -0
  110. package/src/builders/contentBuilder.ts +1400 -0
  111. package/src/builders/cssBuilder.ts +552 -0
  112. package/src/builders/htmlBuilder.ts +540 -0
  113. package/src/builders/index.ts +16 -0
  114. package/src/builders/jsBuilder.ts +358 -0
  115. package/src/builders/staticAssetsBuilder.ts +174 -0
  116. package/src/builders/types.ts +15 -0
  117. package/src/cli.ts +108 -0
  118. package/src/config/manifest.ts +24 -0
  119. package/src/config/paths.ts +14 -0
  120. package/src/config/schema.ts +49 -0
  121. package/src/config/setup.ts +14 -0
  122. package/src/config/workspace.ts +150 -0
  123. package/src/config/workspaceManifest.ts +27 -0
  124. package/src/core/constants.ts +73 -0
  125. package/src/core/diagnostics.ts +40 -0
  126. package/src/core/index.ts +3 -0
  127. package/src/core/pages.ts +31 -0
  128. package/src/hooks.ts +175 -0
  129. package/src/html/criticalCss.ts +214 -0
  130. package/src/html/htmlSecurity.ts +86 -0
  131. package/src/html/lazyLoad.ts +30 -0
  132. package/src/html/pageScaffold.ts +70 -0
  133. package/src/html/resourceHints.ts +91 -0
  134. package/src/index.ts +5 -0
  135. package/src/modes/ssg/index.ts +4 -0
  136. package/src/modes/ssg/metadata.ts +63 -0
  137. package/src/modes/ssg/routing.ts +230 -0
  138. package/src/modes/ssg/seo.ts +261 -0
  139. package/src/modes/ssg/validation.ts +37 -0
  140. package/src/modes/ssg/views.ts +309 -0
  141. package/src/operations.ts +138 -0
  142. package/src/pipeline.ts +88 -0
  143. package/src/provider.ts +249 -0
  144. package/src/types.ts +67 -0
  145. package/src/utils/changedFile.ts +39 -0
  146. package/src/utils/fs.ts +48 -0
  147. package/src/utils/hash.ts +6 -0
  148. package/src/utils/pagePaths.ts +43 -0
  149. package/src/utils/pathMatch.ts +36 -0
  150. package/src/watch/frontendFiles.ts +32 -0
  151. package/src/watch/hotUpdateTracker.ts +285 -0
  152. package/src/watch/pipelineHelpers.ts +242 -0
  153. package/src/watch/types.ts +23 -0
  154. package/src/watch/watchCoordinator.ts +666 -0
  155. package/src/watch/watchDaemon.ts +144 -0
  156. package/src/watch/watchReporter.ts +98 -0
  157. package/tests/add-page-defaults.test.js +64 -0
  158. package/tests/content-pages.test.js +81 -0
  159. package/tests/css-app-imports.test.js +64 -0
  160. package/tests/css-page-imports.test.js +100 -0
  161. package/tests/diagnostics.test.js +48 -0
  162. package/tests/features.test.js +63 -0
  163. package/tests/hooks.test.js +71 -0
  164. package/tests/provider.integration.test.js +137 -0
  165. package/tests/ssg-defaults.test.js +201 -0
  166. package/tests/ssg-guardrails.test.js +69 -0
  167. package/tsconfig.json +27 -0
@@ -0,0 +1,300 @@
1
+ import path from 'node:path';
2
+ import { build as esbuild } from 'esbuild';
3
+ import { glob } from 'glob';
4
+ import { FOLDERS, FILES, EXTENSIONS } from '../core/constants.js';
5
+ import { getPages } from '../core/pages.js';
6
+ import { ensureDir, pathExists, copy, remove, stat } from '../utils/fs.js';
7
+ import { updatePageManifest, updateSharedAssets, readSharedAssets } from '../assets/assetManifest.js';
8
+ import { createCompressedVariants } from '../assets/precompression.js';
9
+ import { shouldProcess } from '../utils/changedFile.js';
10
+ import { findPageFromChangedFile } from '../utils/pathMatch.js';
11
+ const ENTRY_EXTENSIONS = ['.ts', '.tsx', '.js'];
12
+ const APP_ENTRY_BASENAME = 'app';
13
+ export function createJavaScriptBuilder(context) {
14
+ return {
15
+ name: 'javascript',
16
+ async build() {
17
+ await bundleJavaScript(context, false);
18
+ },
19
+ async publish() {
20
+ await bundleJavaScript(context, true);
21
+ }
22
+ };
23
+ }
24
+ async function bundleJavaScript(context, isProduction) {
25
+ const { config } = context;
26
+ if (!shouldProcess(context, [
27
+ {
28
+ directory: config.paths.src.frontend,
29
+ extensions: [EXTENSIONS.ts, EXTENSIONS.js, '.tsx', '.jsx']
30
+ }
31
+ ])) {
32
+ return;
33
+ }
34
+ const targetPage = findPageFromChangedFile(context.changedFile, config.paths.src.pages);
35
+ const pages = await getPages(config.paths.src.pages);
36
+ let builtAny = false;
37
+ await assertFeatureModulesPresent(config, context.enable);
38
+ await compileAppTypeScript(config, isProduction);
39
+ for (const page of pages) {
40
+ if (targetPage && page.name !== targetPage) {
41
+ continue;
42
+ }
43
+ const entryPoint = await resolveEntryPoint(page.directory);
44
+ if (!entryPoint) {
45
+ continue;
46
+ }
47
+ builtAny = true;
48
+ if (isProduction) {
49
+ await buildForProduction(config, page.name, entryPoint);
50
+ }
51
+ else {
52
+ await buildForDevelopment(config, page.name, entryPoint);
53
+ }
54
+ }
55
+ // Always copy dev runtime scripts in dev builds to support HMR/refresh even when no page JS exists.
56
+ if (!isProduction || context.enable?.clientNav || context.enable?.search) {
57
+ await copyRuntimeScripts(config, context.enable, isProduction);
58
+ }
59
+ }
60
+ async function compileAppTypeScript(config, isProduction) {
61
+ const appRoot = config.paths.src.app;
62
+ if (!(await pathExists(appRoot))) {
63
+ return;
64
+ }
65
+ if (isProduction) {
66
+ const entryPoint = await resolveAppEntry(appRoot);
67
+ if (!entryPoint) {
68
+ return;
69
+ }
70
+ const outputDir = path.join(config.paths.dist.frontend, FOLDERS.app);
71
+ await ensureDir(outputDir);
72
+ const result = await esbuild({
73
+ entryPoints: [entryPoint],
74
+ outdir: outputDir,
75
+ format: 'esm',
76
+ target: 'es2020',
77
+ platform: 'browser',
78
+ minify: true,
79
+ sourcemap: false,
80
+ bundle: true,
81
+ entryNames: 'app-[hash]',
82
+ assetNames: 'assets/[name]-[hash]',
83
+ metafile: true,
84
+ logLevel: 'silent'
85
+ });
86
+ const fileName = await resolveAppBundleName(outputDir, entryPoint, result.metafile);
87
+ if (!fileName) {
88
+ throw new Error(`esbuild did not produce an app bundle for ${entryPoint}.`);
89
+ }
90
+ const absolutePath = path.join(outputDir, fileName);
91
+ if (config.features.precompression) {
92
+ await createCompressedVariants(absolutePath);
93
+ }
94
+ else {
95
+ await Promise.all([
96
+ remove(`${absolutePath}${EXTENSIONS.br}`).catch(() => undefined),
97
+ remove(`${absolutePath}${EXTENSIONS.gz}`).catch(() => undefined)
98
+ ]);
99
+ }
100
+ const existing = await readSharedAssets(config.paths.dist.frontend);
101
+ const previousFile = existing?.js;
102
+ if (previousFile && previousFile !== fileName) {
103
+ const previousPath = path.join(outputDir, previousFile);
104
+ await remove(previousPath).catch(() => undefined);
105
+ await remove(`${previousPath}${EXTENSIONS.br}`).catch(() => undefined);
106
+ await remove(`${previousPath}${EXTENSIONS.gz}`).catch(() => undefined);
107
+ }
108
+ await updateSharedAssets(config.paths.dist.frontend, shared => {
109
+ shared.js = fileName;
110
+ });
111
+ return;
112
+ }
113
+ const entryPoints = (await glob('**/*.{ts,tsx}', { cwd: appRoot, nodir: true }))
114
+ .filter((relativePath) => !relativePath.endsWith('.d.ts'))
115
+ .map((relativePath) => path.join(appRoot, relativePath));
116
+ if (entryPoints.length === 0) {
117
+ return;
118
+ }
119
+ const outdir = isProduction
120
+ ? path.join(config.paths.dist.frontend, FOLDERS.app)
121
+ : path.join(config.paths.build.frontend, FOLDERS.app);
122
+ await ensureDir(outdir);
123
+ await esbuild({
124
+ entryPoints,
125
+ outdir,
126
+ format: 'esm',
127
+ target: 'es2020',
128
+ platform: 'browser',
129
+ sourcemap: !isProduction,
130
+ minify: isProduction,
131
+ bundle: false,
132
+ outbase: appRoot,
133
+ logLevel: 'silent'
134
+ });
135
+ }
136
+ async function buildForDevelopment(config, pageName, entryPoint) {
137
+ const outputDir = path.join(config.paths.build.pages, pageName);
138
+ await ensureDir(outputDir);
139
+ const outfile = path.join(outputDir, `${FILES.index}${EXTENSIONS.js}`);
140
+ await esbuild({
141
+ entryPoints: [entryPoint],
142
+ bundle: true,
143
+ format: 'esm',
144
+ target: 'es2020',
145
+ platform: 'browser',
146
+ sourcemap: true,
147
+ outfile,
148
+ logLevel: 'silent'
149
+ });
150
+ }
151
+ async function buildForProduction(config, pageName, entryPoint) {
152
+ const outputDir = path.join(config.paths.dist.pages, pageName);
153
+ await ensureDir(outputDir);
154
+ const result = await esbuild({
155
+ entryPoints: [entryPoint],
156
+ bundle: true,
157
+ format: 'esm',
158
+ target: 'es2020',
159
+ platform: 'browser',
160
+ minify: true,
161
+ sourcemap: false,
162
+ outdir: outputDir,
163
+ entryNames: `${FILES.index}-[hash]`,
164
+ assetNames: 'assets/[name]-[hash]',
165
+ metafile: true,
166
+ logLevel: 'silent'
167
+ });
168
+ const outputs = result.metafile?.outputs ?? {};
169
+ const scriptPath = Object.keys(outputs).find((file) => file.endsWith('.js'));
170
+ if (!scriptPath) {
171
+ throw new Error(`esbuild did not produce a JavaScript bundle for page '${pageName}'.`);
172
+ }
173
+ const fileName = path.basename(scriptPath);
174
+ const absolutePath = path.join(outputDir, fileName);
175
+ if (config.features.precompression) {
176
+ await createCompressedVariants(absolutePath);
177
+ }
178
+ else {
179
+ await Promise.all([
180
+ remove(`${absolutePath}${EXTENSIONS.br}`).catch(() => undefined),
181
+ remove(`${absolutePath}${EXTENSIONS.gz}`).catch(() => undefined)
182
+ ]);
183
+ }
184
+ await updatePageManifest(outputDir, pageName, (manifest) => {
185
+ manifest.js = fileName;
186
+ });
187
+ }
188
+ async function copyRuntimeScripts(config, enable, isProduction) {
189
+ const scripts = [
190
+ // Always copy dev runtime in dev builds to support live reload, even if no page JS exists.
191
+ { name: FILES.refreshJs, copyToDist: false, required: !isProduction },
192
+ { name: FILES.hmrJs, copyToDist: false, required: !isProduction }
193
+ ];
194
+ for (const script of scripts) {
195
+ if (!script.required) {
196
+ continue;
197
+ }
198
+ const source = path.join(config.paths.src.app, script.name);
199
+ if (!(await pathExists(source))) {
200
+ continue;
201
+ }
202
+ const buildDestination = path.join(config.paths.build.frontend, script.name);
203
+ await ensureDir(path.dirname(buildDestination));
204
+ await copy(source, buildDestination);
205
+ if (isProduction && script.copyToDist) {
206
+ const distDestination = path.join(config.paths.dist.frontend, script.name);
207
+ await ensureDir(path.dirname(distDestination));
208
+ await copy(source, distDestination);
209
+ }
210
+ }
211
+ }
212
+ async function resolveEntryPoint(pageDirectory) {
213
+ for (const extension of ENTRY_EXTENSIONS) {
214
+ const candidate = path.join(pageDirectory, `${FILES.index}${extension}`);
215
+ if (await pathExists(candidate)) {
216
+ return candidate;
217
+ }
218
+ }
219
+ return null;
220
+ }
221
+ async function assertFeatureModulesPresent(config, enable) {
222
+ if (!enable) {
223
+ return;
224
+ }
225
+ const missing = [];
226
+ if (enable.clientNav === true) {
227
+ const hasClientNav = await hasFeatureModule(config, 'client-nav');
228
+ if (!hasClientNav) {
229
+ missing.push('client-nav');
230
+ }
231
+ }
232
+ if (enable.search === true) {
233
+ const hasSearch = await hasFeatureModule(config, 'search');
234
+ if (!hasSearch) {
235
+ missing.push('search');
236
+ }
237
+ }
238
+ if (enable.contentNav === true) {
239
+ const hasContentNav = await hasFeatureModule(config, 'content-nav');
240
+ if (!hasContentNav) {
241
+ missing.push('content-nav');
242
+ }
243
+ }
244
+ if (missing.length === 0) {
245
+ return;
246
+ }
247
+ const expected = missing
248
+ .map((name) => `src/frontend/app/scripts/features/${name}.ts`)
249
+ .join(', ');
250
+ throw new Error(`Enabled feature module(s) missing: ${missing.join(', ')}. Run 'webstir enable <feature>' to scaffold them (expected: ${expected}).`);
251
+ }
252
+ async function hasFeatureModule(config, name) {
253
+ const root = path.join(config.paths.src.app, 'scripts', 'features');
254
+ return await pathExists(path.join(root, `${name}${EXTENSIONS.ts}`))
255
+ || await pathExists(path.join(root, `${name}${EXTENSIONS.js}`));
256
+ }
257
+ async function resolveAppBundleName(outputDir, entryPoint, metafile) {
258
+ const outputs = metafile?.outputs ?? {};
259
+ const outputEntries = Object.entries(outputs);
260
+ const entryOutput = outputEntries.find(([, meta]) => {
261
+ if (!meta.entryPoint) {
262
+ return false;
263
+ }
264
+ return path.resolve(meta.entryPoint) === path.resolve(entryPoint);
265
+ });
266
+ if (entryOutput) {
267
+ return path.basename(entryOutput[0]);
268
+ }
269
+ const matches = await glob('app-*.js', { cwd: outputDir, nodir: true });
270
+ if (matches.length === 0) {
271
+ return null;
272
+ }
273
+ if (matches.length === 1) {
274
+ return matches[0] ?? null;
275
+ }
276
+ let latest = null;
277
+ for (const name of matches) {
278
+ const info = await stat(path.join(outputDir, name));
279
+ const time = info.mtimeMs;
280
+ if (!latest || time > latest.time) {
281
+ latest = { name, time };
282
+ }
283
+ }
284
+ return latest?.name ?? matches[0] ?? null;
285
+ }
286
+ async function resolveAppEntry(appRoot) {
287
+ const candidates = [
288
+ `${APP_ENTRY_BASENAME}${EXTENSIONS.ts}`,
289
+ `${APP_ENTRY_BASENAME}.tsx`,
290
+ `${APP_ENTRY_BASENAME}${EXTENSIONS.js}`,
291
+ `${APP_ENTRY_BASENAME}.jsx`
292
+ ];
293
+ for (const candidate of candidates) {
294
+ const fullPath = path.join(appRoot, candidate);
295
+ if (await pathExists(fullPath)) {
296
+ return fullPath;
297
+ }
298
+ }
299
+ return null;
300
+ }
@@ -0,0 +1,2 @@
1
+ import type { Builder, BuilderContext } from './types.js';
2
+ export declare function createStaticAssetsBuilder(context: BuilderContext): Builder;
@@ -0,0 +1,158 @@
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 { shouldProcess } from '../utils/changedFile.js';
5
+ import { optimizeImages } from '../assets/imageOptimizer.js';
6
+ import { relativePathWithin } from '../utils/pathMatch.js';
7
+ const IMAGE_EXTENSIONS = [
8
+ EXTENSIONS.png,
9
+ EXTENSIONS.jpg,
10
+ EXTENSIONS.jpeg,
11
+ EXTENSIONS.gif,
12
+ EXTENSIONS.svg,
13
+ EXTENSIONS.webp,
14
+ EXTENSIONS.ico
15
+ ];
16
+ const FONT_EXTENSIONS = [
17
+ EXTENSIONS.woff,
18
+ EXTENSIONS.woff2,
19
+ EXTENSIONS.ttf,
20
+ EXTENSIONS.otf,
21
+ EXTENSIONS.eot
22
+ ];
23
+ const MEDIA_EXTENSIONS = [
24
+ EXTENSIONS.mp3,
25
+ EXTENSIONS.m4a,
26
+ EXTENSIONS.wav,
27
+ EXTENSIONS.ogg,
28
+ EXTENSIONS.mp4,
29
+ EXTENSIONS.webm,
30
+ EXTENSIONS.mov
31
+ ];
32
+ const ALLOW_ALL_ROBOTS = 'User-agent: *\nAllow: /\n';
33
+ export function createStaticAssetsBuilder(context) {
34
+ return {
35
+ name: 'static-assets',
36
+ async build() {
37
+ await copyStaticAssets(context, false);
38
+ },
39
+ async publish() {
40
+ await copyStaticAssets(context, true);
41
+ }
42
+ };
43
+ }
44
+ async function copyStaticAssets(context, isProduction) {
45
+ const { config } = context;
46
+ if (!shouldProcess(context, [
47
+ { directory: config.paths.src.images, extensions: IMAGE_EXTENSIONS },
48
+ { directory: config.paths.src.fonts, extensions: FONT_EXTENSIONS },
49
+ { directory: config.paths.src.media, extensions: MEDIA_EXTENSIONS }
50
+ ])) {
51
+ return;
52
+ }
53
+ const targets = [
54
+ { source: config.paths.src.images, build: config.paths.build.frontend, dist: config.paths.dist.frontend, folder: FOLDERS.images, extensions: IMAGE_EXTENSIONS },
55
+ { source: config.paths.src.fonts, build: config.paths.build.frontend, dist: config.paths.dist.frontend, folder: FOLDERS.fonts, extensions: FONT_EXTENSIONS },
56
+ { source: config.paths.src.media, build: config.paths.build.frontend, dist: config.paths.dist.frontend, folder: FOLDERS.media, extensions: MEDIA_EXTENSIONS }
57
+ ];
58
+ for (const target of targets) {
59
+ if (!(await pathExists(target.source))) {
60
+ continue;
61
+ }
62
+ const changedRelative = relativePathWithin(context.changedFile, target.source);
63
+ const buildDestination = path.join(target.build, target.folder);
64
+ if (!context.changedFile || !changedRelative) {
65
+ await emptyDir(buildDestination);
66
+ await copy(target.source, buildDestination);
67
+ if (isProduction) {
68
+ const distDestination = path.join(target.dist, target.folder);
69
+ if (target.folder === FOLDERS.images) {
70
+ if (config.features.imageOptimization) {
71
+ await optimizeImages(buildDestination, distDestination);
72
+ }
73
+ else {
74
+ await emptyDir(distDestination);
75
+ await copy(buildDestination, distDestination);
76
+ }
77
+ }
78
+ else {
79
+ await emptyDir(distDestination);
80
+ await copy(buildDestination, distDestination);
81
+ }
82
+ }
83
+ continue;
84
+ }
85
+ await copySingleAsset(target.source, buildDestination, changedRelative);
86
+ if (isProduction) {
87
+ const distDestination = path.join(target.dist, target.folder);
88
+ if (target.folder === FOLDERS.images) {
89
+ if (config.features.imageOptimization) {
90
+ await optimizeImages(buildDestination, distDestination, [changedRelative]);
91
+ }
92
+ else {
93
+ await syncImageWithoutOptimization(buildDestination, distDestination, changedRelative);
94
+ }
95
+ }
96
+ else {
97
+ const sourcePath = path.join(target.source, changedRelative);
98
+ const destPath = path.join(distDestination, changedRelative);
99
+ if (await pathExists(sourcePath)) {
100
+ await ensureDir(path.dirname(destPath));
101
+ await copy(sourcePath, destPath);
102
+ }
103
+ else {
104
+ await remove(destPath).catch(() => undefined);
105
+ }
106
+ }
107
+ }
108
+ }
109
+ await syncRobotsTxt(config, isProduction);
110
+ }
111
+ async function copySingleAsset(sourceRoot, buildRoot, relativePath) {
112
+ const sourcePath = path.join(sourceRoot, relativePath);
113
+ const destinationPath = path.join(buildRoot, relativePath);
114
+ if (await pathExists(sourcePath)) {
115
+ await ensureDir(path.dirname(destinationPath));
116
+ await copy(sourcePath, destinationPath);
117
+ }
118
+ else {
119
+ await remove(destinationPath).catch(() => undefined);
120
+ }
121
+ }
122
+ async function syncImageWithoutOptimization(buildRoot, distRoot, relativePath) {
123
+ const sourcePath = path.join(buildRoot, relativePath);
124
+ const destinationPath = path.join(distRoot, relativePath);
125
+ if (await pathExists(sourcePath)) {
126
+ await ensureDir(path.dirname(destinationPath));
127
+ await copy(sourcePath, destinationPath);
128
+ }
129
+ else {
130
+ await remove(destinationPath).catch(() => undefined);
131
+ }
132
+ await Promise.all([
133
+ remove(`${destinationPath}${EXTENSIONS.webp}`).catch(() => undefined),
134
+ remove(`${destinationPath}${EXTENSIONS.avif}`).catch(() => undefined)
135
+ ]);
136
+ }
137
+ async function syncRobotsTxt(config, isProduction) {
138
+ const sourcePath = path.join(config.paths.src.frontend, FILES.robotsTxt);
139
+ const buildPath = path.join(config.paths.build.frontend, FILES.robotsTxt);
140
+ if (await pathExists(sourcePath)) {
141
+ await ensureDir(path.dirname(buildPath));
142
+ await copy(sourcePath, buildPath);
143
+ if (isProduction) {
144
+ const distPath = path.join(config.paths.dist.frontend, FILES.robotsTxt);
145
+ await ensureDir(path.dirname(distPath));
146
+ await copy(sourcePath, distPath);
147
+ }
148
+ }
149
+ else {
150
+ await ensureDir(path.dirname(buildPath));
151
+ await writeFile(buildPath, ALLOW_ALL_ROBOTS);
152
+ if (isProduction) {
153
+ const distPath = path.join(config.paths.dist.frontend, FILES.robotsTxt);
154
+ await ensureDir(path.dirname(distPath));
155
+ await writeFile(distPath, ALLOW_ALL_ROBOTS);
156
+ }
157
+ }
158
+ }
@@ -0,0 +1,12 @@
1
+ import type { EnableFlags, FrontendConfig } from '../types.js';
2
+ export interface BuilderContext {
3
+ readonly config: FrontendConfig;
4
+ readonly changedFile?: string;
5
+ readonly enable?: EnableFlags;
6
+ }
7
+ export interface Builder {
8
+ readonly name: string;
9
+ build(context: BuilderContext): Promise<void>;
10
+ publish(context: BuilderContext): Promise<void>;
11
+ }
12
+ export type BuilderFactory = (context: BuilderContext) => Builder;
@@ -0,0 +1 @@
1
+ export {};
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,105 @@
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
+ const program = new Command();
6
+ program
7
+ .name('webstir-frontend')
8
+ .description('Webstir frontend build orchestrator');
9
+ program
10
+ .command('build')
11
+ .description('Build frontend assets for development workflows')
12
+ .requiredOption('-w, --workspace <path>', 'Absolute path to the workspace root')
13
+ .option('-c, --changed-file <path>', 'Optional path filter for incremental builds')
14
+ .action(async (cmd) => {
15
+ try {
16
+ await runBuild({
17
+ workspaceRoot: cmd.workspace,
18
+ changedFile: cmd.changedFile ?? undefined
19
+ });
20
+ }
21
+ catch (error) {
22
+ handleError(error);
23
+ }
24
+ });
25
+ program
26
+ .command('publish')
27
+ .description('Build production assets into the dist directory')
28
+ .requiredOption('-w, --workspace <path>', 'Absolute path to the workspace root')
29
+ .option('-m, --mode <mode>', 'Publish mode: bundle or ssg', 'bundle')
30
+ .action(async (cmd) => {
31
+ try {
32
+ await runPublish({
33
+ workspaceRoot: cmd.workspace,
34
+ publishMode: cmd.mode === 'ssg' ? 'ssg' : 'bundle'
35
+ });
36
+ }
37
+ catch (error) {
38
+ handleError(error);
39
+ }
40
+ });
41
+ program
42
+ .command('rebuild')
43
+ .description('Rebuild frontend assets in response to file changes')
44
+ .requiredOption('-w, --workspace <path>', 'Absolute path to the workspace root')
45
+ .requiredOption('-c, --changed-file <path>', 'Path to the changed file triggering the rebuild')
46
+ .action(async (cmd) => {
47
+ try {
48
+ await runRebuild({
49
+ workspaceRoot: cmd.workspace,
50
+ changedFile: cmd.changedFile ?? undefined
51
+ });
52
+ }
53
+ catch (error) {
54
+ handleError(error);
55
+ }
56
+ });
57
+ program
58
+ .command('add-page <name>')
59
+ .description('Scaffold a new frontend page (HTML/CSS/TS)')
60
+ .requiredOption('-w, --workspace <path>', 'Absolute path to the workspace root')
61
+ .option('-m, --mode <mode>', 'Page mode: standard or ssg (defaults to ssg when webstir.mode=ssg)')
62
+ .action(async (name, cmd) => {
63
+ try {
64
+ const rawMode = typeof cmd.mode === 'string' ? cmd.mode.toLowerCase() : undefined;
65
+ await runAddPage({
66
+ workspaceRoot: cmd.workspace,
67
+ pageName: name,
68
+ ssg: rawMode === 'ssg' ? true : rawMode === 'standard' ? false : undefined
69
+ });
70
+ }
71
+ catch (error) {
72
+ handleError(error);
73
+ }
74
+ });
75
+ program
76
+ .command('watch-daemon')
77
+ .description('Run the persistent frontend watch daemon')
78
+ .requiredOption('-w, --workspace <path>', 'Absolute path to the workspace root')
79
+ .option('--no-auto-start', 'Defer startup until a start command is received')
80
+ .option('-v, --verbose', 'Enable verbose watch diagnostics')
81
+ .option('--hmr-verbose', 'Log detailed hot-update diagnostics')
82
+ .action(async (cmd) => {
83
+ try {
84
+ const daemon = new WatchDaemon({
85
+ workspaceRoot: cmd.workspace,
86
+ autoStart: cmd.autoStart,
87
+ verbose: cmd.verbose === true,
88
+ hmrVerbose: cmd.hmrVerbose === true
89
+ });
90
+ await daemon.run();
91
+ }
92
+ catch (error) {
93
+ handleError(error);
94
+ }
95
+ });
96
+ program.parseAsync(process.argv).catch(handleError);
97
+ function handleError(error) {
98
+ if (error instanceof Error) {
99
+ console.error(error.message);
100
+ }
101
+ else {
102
+ console.error('Unknown error', error);
103
+ }
104
+ process.exitCode = 1;
105
+ }
@@ -0,0 +1,7 @@
1
+ import { type FrontendConfigInput } from './schema.js';
2
+ export interface WriteManifestOptions {
3
+ readonly outputPath: string;
4
+ readonly data: FrontendConfigInput;
5
+ }
6
+ export declare function writeConfigManifest(options: WriteManifestOptions): Promise<void>;
7
+ export declare function readConfigManifest(manifestPath: string): Promise<FrontendConfigInput>;
@@ -0,0 +1,17 @@
1
+ import { promises as fs } from 'fs';
2
+ import path from 'path';
3
+ import { frontendConfigSchema } from './schema.js';
4
+ export async function writeConfigManifest(options) {
5
+ const parsed = frontendConfigSchema.parse(options.data);
6
+ const directory = path.dirname(options.outputPath);
7
+ await fs.mkdir(directory, { recursive: true });
8
+ const serialized = JSON.stringify(parsed, undefined, 2);
9
+ const tempPath = path.join(directory, `.webstir-frontend-${process.pid}-${Date.now()}.tmp`);
10
+ await fs.writeFile(tempPath, serialized, 'utf8');
11
+ await fs.rename(tempPath, options.outputPath);
12
+ }
13
+ export async function readConfigManifest(manifestPath) {
14
+ const json = await fs.readFile(manifestPath, 'utf8');
15
+ const parsed = JSON.parse(json);
16
+ return frontendConfigSchema.parse(parsed);
17
+ }
@@ -0,0 +1,3 @@
1
+ export declare const FRONTEND_MANIFEST_FILENAME = "frontend-manifest.json";
2
+ export declare function resolveManifestPath(workspaceRoot: string): string;
3
+ export declare function ensureWebstirDirectory(workspaceRoot: string): Promise<void>;
@@ -0,0 +1,11 @@
1
+ import path from 'path';
2
+ import { promises as fs } from 'fs';
3
+ import { FOLDERS } from '../core/constants.js';
4
+ export const FRONTEND_MANIFEST_FILENAME = 'frontend-manifest.json';
5
+ export function resolveManifestPath(workspaceRoot) {
6
+ return path.join(workspaceRoot, FOLDERS.webstir, FRONTEND_MANIFEST_FILENAME);
7
+ }
8
+ export async function ensureWebstirDirectory(workspaceRoot) {
9
+ const webstirPath = path.join(workspaceRoot, FOLDERS.webstir);
10
+ await fs.mkdir(webstirPath, { recursive: true });
11
+ }