@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,309 @@
1
+ import path from 'node:path';
2
+ import { pathToFileURL } from 'node:url';
3
+ import type { FrontendConfig } from '../../types.js';
4
+ import { ensureDir, pathExists, readJson, writeJson } from '../../utils/fs.js';
5
+ import { FOLDERS } from '../../core/constants.js';
6
+ import type { WorkspaceModuleView, WorkspacePackageJson } from '../../config/workspaceManifest.js';
7
+
8
+ interface ViewDefinitionLike {
9
+ readonly name?: string;
10
+ readonly path?: string;
11
+ readonly renderMode?: 'ssg' | 'ssr' | 'spa';
12
+ readonly staticPaths?: readonly string[];
13
+ }
14
+
15
+ interface ViewSpecLike {
16
+ readonly definition?: ViewDefinitionLike;
17
+ readonly load?: (context: any) => unknown | Promise<unknown>;
18
+ }
19
+
20
+ interface ModuleDefinitionLike {
21
+ readonly views?: readonly ViewSpecLike[];
22
+ }
23
+
24
+ interface ViewDataEntry {
25
+ readonly viewName: string;
26
+ readonly path: string;
27
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
28
+ readonly data: any;
29
+ }
30
+
31
+ export async function generateSsgViewData(config: FrontendConfig): Promise<void> {
32
+ const workspaceRoot = config.paths.workspace;
33
+ const pkgPath = path.join(workspaceRoot, 'package.json');
34
+ const pkg = await readJson<WorkspacePackageJson>(pkgPath);
35
+ const moduleConfig = pkg?.webstir?.moduleManifest;
36
+ const viewMetadata = moduleConfig?.views ?? [];
37
+ const workspaceMode = pkg?.webstir?.mode;
38
+ const isSsgWorkspace = typeof workspaceMode === 'string' && workspaceMode.toLowerCase() === 'ssg';
39
+
40
+ const moduleDefinition = await loadBackendModuleDefinition(workspaceRoot);
41
+ if (!moduleDefinition?.views || moduleDefinition.views.length === 0) {
42
+ return;
43
+ }
44
+
45
+ const perPageData = new Map<string, ViewDataEntry[]>();
46
+
47
+ for (const spec of moduleDefinition.views) {
48
+ const definition = spec.definition ?? {};
49
+ const viewName = definition.name ?? '';
50
+ const viewPathTemplate = definition.path ?? '';
51
+ const meta = findViewMetadata(viewMetadata, viewName, viewPathTemplate);
52
+ const renderMode = meta?.renderMode ?? definition.renderMode ?? (isSsgWorkspace ? 'ssg' : undefined);
53
+ if (renderMode !== 'ssg') {
54
+ continue;
55
+ }
56
+
57
+ const staticPaths = getEffectiveStaticPaths(meta, definition, isSsgWorkspace);
58
+ if (!spec.load || !Array.isArray(staticPaths) || staticPaths.length === 0) {
59
+ continue;
60
+ }
61
+
62
+ for (const rawPath of staticPaths) {
63
+ if (typeof rawPath !== 'string' || rawPath.length === 0) {
64
+ continue;
65
+ }
66
+
67
+ const normalizedPath = normalizePath(rawPath);
68
+ const params = deriveRouteParams(viewPathTemplate, normalizedPath);
69
+ if (!params) {
70
+ continue;
71
+ }
72
+
73
+ const ssrContext = createMinimalSsrContext(normalizedPath, params);
74
+
75
+ let data: unknown;
76
+ try {
77
+ data = await spec.load(ssrContext);
78
+ } catch {
79
+ // Best-effort only; skip paths that fail to load.
80
+ continue;
81
+ }
82
+
83
+ const pageName = normalizedPath === '/' ? FOLDERS.home : firstPathSegment(normalizedPath) ?? FOLDERS.home;
84
+ const entries = perPageData.get(pageName) ?? [];
85
+ entries.push({
86
+ viewName: viewName || viewPathTemplate || normalizedPath,
87
+ path: normalizedPath,
88
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
89
+ data
90
+ });
91
+ perPageData.set(pageName, entries);
92
+ }
93
+ }
94
+
95
+ if (perPageData.size === 0) {
96
+ return;
97
+ }
98
+
99
+ const pagesRoot = config.paths.dist.pages;
100
+
101
+ for (const [pageName, entries] of perPageData.entries()) {
102
+ const pageDir = path.join(pagesRoot, pageName);
103
+ if (!(await pathExists(pageDir))) {
104
+ continue;
105
+ }
106
+
107
+ const dataPath = path.join(pageDir, 'view-data.json');
108
+ await ensureDir(pageDir);
109
+ await writeJson(dataPath, entries);
110
+ }
111
+ }
112
+
113
+ function findViewMetadata(
114
+ views: readonly WorkspaceModuleView[],
115
+ name: string,
116
+ templatePath: string
117
+ ): WorkspaceModuleView | undefined {
118
+ return (
119
+ views.find((view) => (view.name && view.name === name) || (view.path && view.path === templatePath)) ??
120
+ views.find((view) => view.path === templatePath) ??
121
+ views.find((view) => view.name === name)
122
+ );
123
+ }
124
+
125
+ async function loadBackendModuleDefinition(workspaceRoot: string): Promise<ModuleDefinitionLike | undefined> {
126
+ const buildRoot = path.join(workspaceRoot, 'build', 'backend');
127
+ const candidates = [
128
+ path.join(buildRoot, 'module.js'),
129
+ path.join(buildRoot, 'module.mjs'),
130
+ path.join(buildRoot, 'module', 'index.js'),
131
+ path.join(buildRoot, 'module', 'index.mjs')
132
+ ];
133
+
134
+ for (const fullPath of candidates) {
135
+ if (!(await pathExists(fullPath))) {
136
+ continue;
137
+ }
138
+
139
+ try {
140
+ const url = `${pathToFileURL(fullPath).href}?t=${Date.now()}`;
141
+ const imported = (await import(url)) as Record<string, unknown>;
142
+ const candidate = extractModuleDefinition(imported);
143
+ if (candidate) {
144
+ return candidate;
145
+ }
146
+ } catch {
147
+ // Best-effort only.
148
+ }
149
+ }
150
+
151
+ return undefined;
152
+ }
153
+
154
+ function extractModuleDefinition(exports: Record<string, unknown>): ModuleDefinitionLike | undefined {
155
+ const keys = ['module', 'moduleDefinition', 'default', 'backendModule'];
156
+ for (const key of keys) {
157
+ if (key in exports) {
158
+ const value = exports[key as keyof typeof exports];
159
+ if (value && typeof value === 'object') {
160
+ return value as ModuleDefinitionLike;
161
+ }
162
+ }
163
+ }
164
+ return undefined;
165
+ }
166
+
167
+ function normalizePath(value: string): string {
168
+ let s = value.trim();
169
+ if (!s.startsWith('/')) {
170
+ s = `/${s}`;
171
+ }
172
+ if (s.length > 1 && s.endsWith('/')) {
173
+ s = s.slice(0, -1);
174
+ }
175
+ return s;
176
+ }
177
+
178
+ function firstPathSegment(pathname: string): string | undefined {
179
+ const [, segment] = pathname.split('/');
180
+ if (!segment) {
181
+ return undefined;
182
+ }
183
+ return segment;
184
+ }
185
+
186
+ function deriveRouteParams(template: string, actual: string): Record<string, string> | null {
187
+ if (!template || !actual) {
188
+ return {};
189
+ }
190
+
191
+ const templateSegments = template.split('/').filter(Boolean);
192
+ const actualSegments = actual.split('/').filter(Boolean);
193
+
194
+ if (templateSegments.length !== actualSegments.length) {
195
+ return null;
196
+ }
197
+
198
+ const params: Record<string, string> = {};
199
+
200
+ for (let i = 0; i < templateSegments.length; i++) {
201
+ const templateSegment = templateSegments[i];
202
+ const actualSegment = actualSegments[i];
203
+
204
+ if (templateSegment.startsWith(':')) {
205
+ const key = templateSegment.slice(1);
206
+ if (!key) {
207
+ return null;
208
+ }
209
+ params[key] = decodeURIComponent(actualSegment);
210
+ } else if (templateSegment !== actualSegment) {
211
+ return null;
212
+ }
213
+ }
214
+
215
+ return params;
216
+ }
217
+
218
+ function createMinimalSsrContext(pathname: string, params: Record<string, string>): unknown {
219
+ const url = new URL(`http://localhost${pathname}`);
220
+
221
+ const envAccessor = {
222
+ get(name: string): string | undefined {
223
+ return process.env[name];
224
+ },
225
+ require(name: string): string {
226
+ const value = process.env[name];
227
+ if (value === undefined) {
228
+ throw new Error(`Missing required env variable ${name} for SSG view rendering.`);
229
+ }
230
+ return value;
231
+ },
232
+ entries(): Record<string, string | undefined> {
233
+ return process.env as Record<string, string | undefined>;
234
+ }
235
+ };
236
+
237
+ const logger = {
238
+ level: 'info',
239
+ log(_level: string, _message: string, _metadata?: Record<string, unknown>): void {
240
+ // no-op for SSG
241
+ },
242
+ debug(_message: string, _metadata?: Record<string, unknown>): void {
243
+ // no-op for SSG
244
+ },
245
+ info(_message: string, _metadata?: Record<string, unknown>): void {
246
+ // no-op for SSG
247
+ },
248
+ warn(_message: string, _metadata?: Record<string, unknown>): void {
249
+ // no-op for SSG
250
+ },
251
+ error(_message: string, _metadata?: Record<string, unknown>): void {
252
+ // no-op for SSG
253
+ },
254
+ with(_bindings: Record<string, unknown>) {
255
+ return this;
256
+ }
257
+ };
258
+
259
+ return {
260
+ url,
261
+ params,
262
+ cookies: {},
263
+ headers: {},
264
+ auth: undefined,
265
+ session: null,
266
+ env: envAccessor,
267
+ logger,
268
+ now: () => new Date()
269
+ };
270
+ }
271
+
272
+ function getEffectiveStaticPaths(
273
+ meta: WorkspaceModuleView | undefined,
274
+ definition: ViewDefinitionLike,
275
+ isSsgWorkspace: boolean
276
+ ): readonly string[] {
277
+ const explicit = meta?.staticPaths ?? definition.staticPaths ?? [];
278
+ if (Array.isArray(explicit) && explicit.length > 0) {
279
+ return explicit;
280
+ }
281
+
282
+ if (!isSsgWorkspace) {
283
+ return [];
284
+ }
285
+
286
+ const candidate = meta?.path ?? definition.path ?? '';
287
+ if (!isDefaultStaticPathCandidate(candidate)) {
288
+ return [];
289
+ }
290
+
291
+ return [candidate];
292
+ }
293
+
294
+ function isDefaultStaticPathCandidate(template: string): boolean {
295
+ if (typeof template !== 'string') {
296
+ return false;
297
+ }
298
+
299
+ const trimmed = template.trim();
300
+ if (!trimmed.startsWith('/')) {
301
+ return false;
302
+ }
303
+
304
+ if (trimmed.includes(':') || trimmed.includes('*')) {
305
+ return false;
306
+ }
307
+
308
+ return true;
309
+ }
@@ -0,0 +1,138 @@
1
+ import { readdir } from 'node:fs/promises';
2
+ import type { AddPageCommandOptions, EnableFlags, FrontendCommandOptions } from './types.js';
3
+ import { runPipeline } from './pipeline.js';
4
+ import { createPageScaffold } from './html/pageScaffold.js';
5
+ import { prepareWorkspaceConfig } from './config/setup.js';
6
+ import {
7
+ applySsgRouting,
8
+ assertNoSsgRoutes,
9
+ ensureSsgViewMetadataForPage,
10
+ generateSsgViewData
11
+ } from './modes/ssg/index.js';
12
+ import path from 'node:path';
13
+ import { FOLDERS } from './core/constants.js';
14
+ import { pathExists, readJson, remove } from './utils/fs.js';
15
+
16
+ export async function runBuild(options: FrontendCommandOptions): Promise<void> {
17
+ const config = await prepareWorkspaceConfig(options.workspaceRoot);
18
+ const enable = await readWorkspaceEnableFlags(options.workspaceRoot);
19
+
20
+ console.info('[webstir-frontend] Running build pipeline...');
21
+ await runPipeline(config, 'build', { changedFile: options.changedFile, enable });
22
+ console.info('[webstir-frontend] Build pipeline completed.');
23
+ }
24
+
25
+ export async function runPublish(options: FrontendCommandOptions): Promise<void> {
26
+ const config = await prepareWorkspaceConfig(options.workspaceRoot);
27
+ const enable = await readWorkspaceEnableFlags(options.workspaceRoot);
28
+ const publishConfig = options.publishMode === 'ssg' ? applySsgPublishLayout(config) : config;
29
+
30
+ const modeLabel = options.publishMode === 'ssg' ? 'SSG publish' : 'publish';
31
+ console.info(`[webstir-frontend] Running ${modeLabel} pipeline...`);
32
+
33
+ if (options.publishMode === 'ssg') {
34
+ await assertNoSsgRoutes(config.paths.workspace);
35
+ }
36
+
37
+ await runPipeline(publishConfig, 'publish', { enable });
38
+ if (options.publishMode === 'ssg') {
39
+ await generateSsgViewData(publishConfig);
40
+ await applySsgRouting(publishConfig);
41
+ await removeLegacyPagesFolder(publishConfig);
42
+ }
43
+ console.info(`[webstir-frontend] ${modeLabel} pipeline completed.`);
44
+ }
45
+
46
+ export async function runRebuild(options: FrontendCommandOptions): Promise<void> {
47
+ const config = await prepareWorkspaceConfig(options.workspaceRoot);
48
+ const enable = await readWorkspaceEnableFlags(options.workspaceRoot);
49
+
50
+ console.info('[webstir-frontend] Running rebuild pipeline...');
51
+ await runPipeline(config, 'build', { changedFile: options.changedFile, enable });
52
+ console.info('[webstir-frontend] Rebuild pipeline completed.');
53
+ }
54
+
55
+ export async function runAddPage(options: AddPageCommandOptions): Promise<void> {
56
+ const config = await prepareWorkspaceConfig(options.workspaceRoot);
57
+ console.info('[webstir-frontend] Creating page scaffold...');
58
+
59
+ const isSsgWorkspace = await detectSsgWorkspace(options.workspaceRoot);
60
+ const effectiveSsg = options.ssg ?? isSsgWorkspace;
61
+ await createPageScaffold({
62
+ workspaceRoot: options.workspaceRoot,
63
+ pageName: options.pageName,
64
+ mode: effectiveSsg ? 'ssg' : 'standard',
65
+ paths: {
66
+ pages: config.paths.src.pages,
67
+ app: config.paths.src.app
68
+ }
69
+ });
70
+ if (effectiveSsg) {
71
+ await ensureSsgViewMetadataForPage({
72
+ workspaceRoot: options.workspaceRoot,
73
+ pageName: options.pageName
74
+ });
75
+ }
76
+ console.info('[webstir-frontend] Page scaffold created.');
77
+ }
78
+
79
+ interface WorkspacePackageJsonMode {
80
+ readonly webstir?: {
81
+ readonly mode?: string;
82
+ };
83
+ }
84
+
85
+ async function detectSsgWorkspace(workspaceRoot: string): Promise<boolean> {
86
+ const pkgPath = path.join(workspaceRoot, 'package.json');
87
+ const pkg = await readJson<WorkspacePackageJsonMode>(pkgPath);
88
+ const mode = pkg?.webstir?.mode;
89
+ return typeof mode === 'string' && mode.toLowerCase() === 'ssg';
90
+ }
91
+
92
+ function applySsgPublishLayout(config: import('./types.js').FrontendConfig): import('./types.js').FrontendConfig {
93
+ const distFrontend = config.paths.dist.frontend;
94
+ const distPages = distFrontend;
95
+ const distContent = path.join(distFrontend, 'docs');
96
+
97
+ return {
98
+ ...config,
99
+ paths: {
100
+ ...config.paths,
101
+ dist: {
102
+ ...config.paths.dist,
103
+ pages: distPages,
104
+ content: distContent
105
+ }
106
+ }
107
+ };
108
+ }
109
+
110
+ interface WorkspacePackageJsonEnable {
111
+ readonly webstir?: {
112
+ readonly enable?: EnableFlags;
113
+ };
114
+ }
115
+
116
+ async function readWorkspaceEnableFlags(workspaceRoot: string): Promise<EnableFlags | undefined> {
117
+ const pkgPath = path.join(workspaceRoot, 'package.json');
118
+ const pkg = await readJson<WorkspacePackageJsonEnable>(pkgPath);
119
+ return pkg?.webstir?.enable;
120
+ }
121
+
122
+ async function removeLegacyPagesFolder(config: import('./types.js').FrontendConfig): Promise<void> {
123
+ const legacyPagesRoot = path.join(config.paths.dist.frontend, FOLDERS.pages);
124
+ if (legacyPagesRoot === config.paths.dist.pages) {
125
+ return;
126
+ }
127
+
128
+ if (!(await pathExists(legacyPagesRoot))) {
129
+ return;
130
+ }
131
+
132
+ const entries = await readdir(legacyPagesRoot);
133
+ if (entries.length > 0) {
134
+ return;
135
+ }
136
+
137
+ await remove(legacyPagesRoot);
138
+ }
@@ -0,0 +1,88 @@
1
+ import { performance } from 'node:perf_hooks';
2
+ import type { EnableFlags, FrontendConfig } from './types.js';
3
+ import { createBuilders } from './builders/index.js';
4
+ import type { Builder, BuilderContext } from './builders/types.js';
5
+ import { createHookContext, executeHooks, loadHooks } from './hooks.js';
6
+
7
+ export interface PipelineOptions {
8
+ readonly changedFile?: string;
9
+ readonly enable?: EnableFlags;
10
+ }
11
+
12
+ export type PipelineMode = 'build' | 'publish';
13
+
14
+ export async function runPipeline(config: FrontendConfig, mode: PipelineMode, options: PipelineOptions = {}): Promise<void> {
15
+ const context: BuilderContext = { config, changedFile: options.changedFile, enable: options.enable };
16
+ const builders: Builder[] = createBuilders(context);
17
+ const hooks = await loadHooks(config.paths.workspace, mode === 'build');
18
+ const pipelineContext = createHookContext(config, mode, options.changedFile);
19
+
20
+ await executeHooks('pipeline.beforeAll', hooks.pipelineBefore, pipelineContext);
21
+
22
+ let pipelineError: unknown;
23
+
24
+ try {
25
+ for (const builder of builders) {
26
+ const builderContext = createHookContext(config, mode, options.changedFile, builder.name);
27
+ const beforeHooks = hooks.builderBefore.get(builder.name) ?? [];
28
+ const afterHooks = hooks.builderAfter.get(builder.name) ?? [];
29
+
30
+ await executeHooks(`builder.${builder.name}.before`, beforeHooks, builderContext);
31
+
32
+ const start = performance.now();
33
+ let builderError: Error | undefined;
34
+ let afterHookError: Error | undefined;
35
+
36
+ try {
37
+ if (mode === 'build') {
38
+ await builder.build(context);
39
+ } else {
40
+ await builder.publish(context);
41
+ }
42
+ } catch (error) {
43
+ builderError = wrapPipelineError(builder.name, mode, error);
44
+ }
45
+
46
+ try {
47
+ await executeHooks(`builder.${builder.name}.after`, afterHooks, builderContext);
48
+ } catch (error) {
49
+ afterHookError = error as Error;
50
+ }
51
+
52
+ const end = performance.now();
53
+ const duration = end - start;
54
+ console.info(`[webstir-frontend] ${mode}:${builder.name} completed in ${duration.toFixed(1)}ms`);
55
+
56
+ if (builderError) {
57
+ throw builderError;
58
+ }
59
+
60
+ if (afterHookError) {
61
+ throw afterHookError;
62
+ }
63
+ }
64
+ } catch (error) {
65
+ pipelineError = error;
66
+ } finally {
67
+ try {
68
+ await executeHooks('pipeline.afterAll', hooks.pipelineAfter, pipelineContext);
69
+ } catch (hookError) {
70
+ if (!pipelineError) {
71
+ pipelineError = hookError;
72
+ }
73
+ }
74
+ }
75
+
76
+ if (pipelineError) {
77
+ throw pipelineError;
78
+ }
79
+ }
80
+
81
+ function wrapPipelineError(name: string, mode: PipelineMode, error: unknown): Error {
82
+ if (error instanceof Error) {
83
+ error.message = `[${mode}:${name}] ${error.message}`;
84
+ return error;
85
+ }
86
+
87
+ return new Error(`[${mode}:${name}] ${String(error)}`);
88
+ }