@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,236 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import { ensureDir, pathExists, readJson, writeJson } from '../../utils/fs.js';
|
|
4
|
+
import { FOLDERS } from '../../core/constants.js';
|
|
5
|
+
export async function generateSsgViewData(config) {
|
|
6
|
+
const workspaceRoot = config.paths.workspace;
|
|
7
|
+
const pkgPath = path.join(workspaceRoot, 'package.json');
|
|
8
|
+
const pkg = await readJson(pkgPath);
|
|
9
|
+
const moduleConfig = pkg?.webstir?.moduleManifest;
|
|
10
|
+
const viewMetadata = moduleConfig?.views ?? [];
|
|
11
|
+
const workspaceMode = pkg?.webstir?.mode;
|
|
12
|
+
const isSsgWorkspace = typeof workspaceMode === 'string' && workspaceMode.toLowerCase() === 'ssg';
|
|
13
|
+
const moduleDefinition = await loadBackendModuleDefinition(workspaceRoot);
|
|
14
|
+
if (!moduleDefinition?.views || moduleDefinition.views.length === 0) {
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const perPageData = new Map();
|
|
18
|
+
for (const spec of moduleDefinition.views) {
|
|
19
|
+
const definition = spec.definition ?? {};
|
|
20
|
+
const viewName = definition.name ?? '';
|
|
21
|
+
const viewPathTemplate = definition.path ?? '';
|
|
22
|
+
const meta = findViewMetadata(viewMetadata, viewName, viewPathTemplate);
|
|
23
|
+
const renderMode = meta?.renderMode ?? definition.renderMode ?? (isSsgWorkspace ? 'ssg' : undefined);
|
|
24
|
+
if (renderMode !== 'ssg') {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const staticPaths = getEffectiveStaticPaths(meta, definition, isSsgWorkspace);
|
|
28
|
+
if (!spec.load || !Array.isArray(staticPaths) || staticPaths.length === 0) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
for (const rawPath of staticPaths) {
|
|
32
|
+
if (typeof rawPath !== 'string' || rawPath.length === 0) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const normalizedPath = normalizePath(rawPath);
|
|
36
|
+
const params = deriveRouteParams(viewPathTemplate, normalizedPath);
|
|
37
|
+
if (!params) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const ssrContext = createMinimalSsrContext(normalizedPath, params);
|
|
41
|
+
let data;
|
|
42
|
+
try {
|
|
43
|
+
data = await spec.load(ssrContext);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// Best-effort only; skip paths that fail to load.
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const pageName = normalizedPath === '/' ? FOLDERS.home : firstPathSegment(normalizedPath) ?? FOLDERS.home;
|
|
50
|
+
const entries = perPageData.get(pageName) ?? [];
|
|
51
|
+
entries.push({
|
|
52
|
+
viewName: viewName || viewPathTemplate || normalizedPath,
|
|
53
|
+
path: normalizedPath,
|
|
54
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
55
|
+
data
|
|
56
|
+
});
|
|
57
|
+
perPageData.set(pageName, entries);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (perPageData.size === 0) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const pagesRoot = config.paths.dist.pages;
|
|
64
|
+
for (const [pageName, entries] of perPageData.entries()) {
|
|
65
|
+
const pageDir = path.join(pagesRoot, pageName);
|
|
66
|
+
if (!(await pathExists(pageDir))) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const dataPath = path.join(pageDir, 'view-data.json');
|
|
70
|
+
await ensureDir(pageDir);
|
|
71
|
+
await writeJson(dataPath, entries);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function findViewMetadata(views, name, templatePath) {
|
|
75
|
+
return (views.find((view) => (view.name && view.name === name) || (view.path && view.path === templatePath)) ??
|
|
76
|
+
views.find((view) => view.path === templatePath) ??
|
|
77
|
+
views.find((view) => view.name === name));
|
|
78
|
+
}
|
|
79
|
+
async function loadBackendModuleDefinition(workspaceRoot) {
|
|
80
|
+
const buildRoot = path.join(workspaceRoot, 'build', 'backend');
|
|
81
|
+
const candidates = [
|
|
82
|
+
path.join(buildRoot, 'module.js'),
|
|
83
|
+
path.join(buildRoot, 'module.mjs'),
|
|
84
|
+
path.join(buildRoot, 'module', 'index.js'),
|
|
85
|
+
path.join(buildRoot, 'module', 'index.mjs')
|
|
86
|
+
];
|
|
87
|
+
for (const fullPath of candidates) {
|
|
88
|
+
if (!(await pathExists(fullPath))) {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const url = `${pathToFileURL(fullPath).href}?t=${Date.now()}`;
|
|
93
|
+
const imported = (await import(url));
|
|
94
|
+
const candidate = extractModuleDefinition(imported);
|
|
95
|
+
if (candidate) {
|
|
96
|
+
return candidate;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// Best-effort only.
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
function extractModuleDefinition(exports) {
|
|
106
|
+
const keys = ['module', 'moduleDefinition', 'default', 'backendModule'];
|
|
107
|
+
for (const key of keys) {
|
|
108
|
+
if (key in exports) {
|
|
109
|
+
const value = exports[key];
|
|
110
|
+
if (value && typeof value === 'object') {
|
|
111
|
+
return value;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
function normalizePath(value) {
|
|
118
|
+
let s = value.trim();
|
|
119
|
+
if (!s.startsWith('/')) {
|
|
120
|
+
s = `/${s}`;
|
|
121
|
+
}
|
|
122
|
+
if (s.length > 1 && s.endsWith('/')) {
|
|
123
|
+
s = s.slice(0, -1);
|
|
124
|
+
}
|
|
125
|
+
return s;
|
|
126
|
+
}
|
|
127
|
+
function firstPathSegment(pathname) {
|
|
128
|
+
const [, segment] = pathname.split('/');
|
|
129
|
+
if (!segment) {
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
return segment;
|
|
133
|
+
}
|
|
134
|
+
function deriveRouteParams(template, actual) {
|
|
135
|
+
if (!template || !actual) {
|
|
136
|
+
return {};
|
|
137
|
+
}
|
|
138
|
+
const templateSegments = template.split('/').filter(Boolean);
|
|
139
|
+
const actualSegments = actual.split('/').filter(Boolean);
|
|
140
|
+
if (templateSegments.length !== actualSegments.length) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
const params = {};
|
|
144
|
+
for (let i = 0; i < templateSegments.length; i++) {
|
|
145
|
+
const templateSegment = templateSegments[i];
|
|
146
|
+
const actualSegment = actualSegments[i];
|
|
147
|
+
if (templateSegment.startsWith(':')) {
|
|
148
|
+
const key = templateSegment.slice(1);
|
|
149
|
+
if (!key) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
params[key] = decodeURIComponent(actualSegment);
|
|
153
|
+
}
|
|
154
|
+
else if (templateSegment !== actualSegment) {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return params;
|
|
159
|
+
}
|
|
160
|
+
function createMinimalSsrContext(pathname, params) {
|
|
161
|
+
const url = new URL(`http://localhost${pathname}`);
|
|
162
|
+
const envAccessor = {
|
|
163
|
+
get(name) {
|
|
164
|
+
return process.env[name];
|
|
165
|
+
},
|
|
166
|
+
require(name) {
|
|
167
|
+
const value = process.env[name];
|
|
168
|
+
if (value === undefined) {
|
|
169
|
+
throw new Error(`Missing required env variable ${name} for SSG view rendering.`);
|
|
170
|
+
}
|
|
171
|
+
return value;
|
|
172
|
+
},
|
|
173
|
+
entries() {
|
|
174
|
+
return process.env;
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
const logger = {
|
|
178
|
+
level: 'info',
|
|
179
|
+
log(_level, _message, _metadata) {
|
|
180
|
+
// no-op for SSG
|
|
181
|
+
},
|
|
182
|
+
debug(_message, _metadata) {
|
|
183
|
+
// no-op for SSG
|
|
184
|
+
},
|
|
185
|
+
info(_message, _metadata) {
|
|
186
|
+
// no-op for SSG
|
|
187
|
+
},
|
|
188
|
+
warn(_message, _metadata) {
|
|
189
|
+
// no-op for SSG
|
|
190
|
+
},
|
|
191
|
+
error(_message, _metadata) {
|
|
192
|
+
// no-op for SSG
|
|
193
|
+
},
|
|
194
|
+
with(_bindings) {
|
|
195
|
+
return this;
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
return {
|
|
199
|
+
url,
|
|
200
|
+
params,
|
|
201
|
+
cookies: {},
|
|
202
|
+
headers: {},
|
|
203
|
+
auth: undefined,
|
|
204
|
+
session: null,
|
|
205
|
+
env: envAccessor,
|
|
206
|
+
logger,
|
|
207
|
+
now: () => new Date()
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
function getEffectiveStaticPaths(meta, definition, isSsgWorkspace) {
|
|
211
|
+
const explicit = meta?.staticPaths ?? definition.staticPaths ?? [];
|
|
212
|
+
if (Array.isArray(explicit) && explicit.length > 0) {
|
|
213
|
+
return explicit;
|
|
214
|
+
}
|
|
215
|
+
if (!isSsgWorkspace) {
|
|
216
|
+
return [];
|
|
217
|
+
}
|
|
218
|
+
const candidate = meta?.path ?? definition.path ?? '';
|
|
219
|
+
if (!isDefaultStaticPathCandidate(candidate)) {
|
|
220
|
+
return [];
|
|
221
|
+
}
|
|
222
|
+
return [candidate];
|
|
223
|
+
}
|
|
224
|
+
function isDefaultStaticPathCandidate(template) {
|
|
225
|
+
if (typeof template !== 'string') {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
const trimmed = template.trim();
|
|
229
|
+
if (!trimmed.startsWith('/')) {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
if (trimmed.includes(':') || trimmed.includes('*')) {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { AddPageCommandOptions, FrontendCommandOptions } from './types.js';
|
|
2
|
+
export declare function runBuild(options: FrontendCommandOptions): Promise<void>;
|
|
3
|
+
export declare function runPublish(options: FrontendCommandOptions): Promise<void>;
|
|
4
|
+
export declare function runRebuild(options: FrontendCommandOptions): Promise<void>;
|
|
5
|
+
export declare function runAddPage(options: AddPageCommandOptions): Promise<void>;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import { runPipeline } from './pipeline.js';
|
|
3
|
+
import { createPageScaffold } from './html/pageScaffold.js';
|
|
4
|
+
import { prepareWorkspaceConfig } from './config/setup.js';
|
|
5
|
+
import { applySsgRouting, assertNoSsgRoutes, ensureSsgViewMetadataForPage, generateSsgViewData } from './modes/ssg/index.js';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { FOLDERS } from './core/constants.js';
|
|
8
|
+
import { pathExists, readJson, remove } from './utils/fs.js';
|
|
9
|
+
export async function runBuild(options) {
|
|
10
|
+
const config = await prepareWorkspaceConfig(options.workspaceRoot);
|
|
11
|
+
const enable = await readWorkspaceEnableFlags(options.workspaceRoot);
|
|
12
|
+
console.info('[webstir-frontend] Running build pipeline...');
|
|
13
|
+
await runPipeline(config, 'build', { changedFile: options.changedFile, enable });
|
|
14
|
+
console.info('[webstir-frontend] Build pipeline completed.');
|
|
15
|
+
}
|
|
16
|
+
export async function runPublish(options) {
|
|
17
|
+
const config = await prepareWorkspaceConfig(options.workspaceRoot);
|
|
18
|
+
const enable = await readWorkspaceEnableFlags(options.workspaceRoot);
|
|
19
|
+
const publishConfig = options.publishMode === 'ssg' ? applySsgPublishLayout(config) : config;
|
|
20
|
+
const modeLabel = options.publishMode === 'ssg' ? 'SSG publish' : 'publish';
|
|
21
|
+
console.info(`[webstir-frontend] Running ${modeLabel} pipeline...`);
|
|
22
|
+
if (options.publishMode === 'ssg') {
|
|
23
|
+
await assertNoSsgRoutes(config.paths.workspace);
|
|
24
|
+
}
|
|
25
|
+
await runPipeline(publishConfig, 'publish', { enable });
|
|
26
|
+
if (options.publishMode === 'ssg') {
|
|
27
|
+
await generateSsgViewData(publishConfig);
|
|
28
|
+
await applySsgRouting(publishConfig);
|
|
29
|
+
await removeLegacyPagesFolder(publishConfig);
|
|
30
|
+
}
|
|
31
|
+
console.info(`[webstir-frontend] ${modeLabel} pipeline completed.`);
|
|
32
|
+
}
|
|
33
|
+
export async function runRebuild(options) {
|
|
34
|
+
const config = await prepareWorkspaceConfig(options.workspaceRoot);
|
|
35
|
+
const enable = await readWorkspaceEnableFlags(options.workspaceRoot);
|
|
36
|
+
console.info('[webstir-frontend] Running rebuild pipeline...');
|
|
37
|
+
await runPipeline(config, 'build', { changedFile: options.changedFile, enable });
|
|
38
|
+
console.info('[webstir-frontend] Rebuild pipeline completed.');
|
|
39
|
+
}
|
|
40
|
+
export async function runAddPage(options) {
|
|
41
|
+
const config = await prepareWorkspaceConfig(options.workspaceRoot);
|
|
42
|
+
console.info('[webstir-frontend] Creating page scaffold...');
|
|
43
|
+
const isSsgWorkspace = await detectSsgWorkspace(options.workspaceRoot);
|
|
44
|
+
const effectiveSsg = options.ssg ?? isSsgWorkspace;
|
|
45
|
+
await createPageScaffold({
|
|
46
|
+
workspaceRoot: options.workspaceRoot,
|
|
47
|
+
pageName: options.pageName,
|
|
48
|
+
mode: effectiveSsg ? 'ssg' : 'standard',
|
|
49
|
+
paths: {
|
|
50
|
+
pages: config.paths.src.pages,
|
|
51
|
+
app: config.paths.src.app
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
if (effectiveSsg) {
|
|
55
|
+
await ensureSsgViewMetadataForPage({
|
|
56
|
+
workspaceRoot: options.workspaceRoot,
|
|
57
|
+
pageName: options.pageName
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
console.info('[webstir-frontend] Page scaffold created.');
|
|
61
|
+
}
|
|
62
|
+
async function detectSsgWorkspace(workspaceRoot) {
|
|
63
|
+
const pkgPath = path.join(workspaceRoot, 'package.json');
|
|
64
|
+
const pkg = await readJson(pkgPath);
|
|
65
|
+
const mode = pkg?.webstir?.mode;
|
|
66
|
+
return typeof mode === 'string' && mode.toLowerCase() === 'ssg';
|
|
67
|
+
}
|
|
68
|
+
function applySsgPublishLayout(config) {
|
|
69
|
+
const distFrontend = config.paths.dist.frontend;
|
|
70
|
+
const distPages = distFrontend;
|
|
71
|
+
const distContent = path.join(distFrontend, 'docs');
|
|
72
|
+
return {
|
|
73
|
+
...config,
|
|
74
|
+
paths: {
|
|
75
|
+
...config.paths,
|
|
76
|
+
dist: {
|
|
77
|
+
...config.paths.dist,
|
|
78
|
+
pages: distPages,
|
|
79
|
+
content: distContent
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
async function readWorkspaceEnableFlags(workspaceRoot) {
|
|
85
|
+
const pkgPath = path.join(workspaceRoot, 'package.json');
|
|
86
|
+
const pkg = await readJson(pkgPath);
|
|
87
|
+
return pkg?.webstir?.enable;
|
|
88
|
+
}
|
|
89
|
+
async function removeLegacyPagesFolder(config) {
|
|
90
|
+
const legacyPagesRoot = path.join(config.paths.dist.frontend, FOLDERS.pages);
|
|
91
|
+
if (legacyPagesRoot === config.paths.dist.pages) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (!(await pathExists(legacyPagesRoot))) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const entries = await readdir(legacyPagesRoot);
|
|
98
|
+
if (entries.length > 0) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
await remove(legacyPagesRoot);
|
|
102
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { EnableFlags, FrontendConfig } from './types.js';
|
|
2
|
+
export interface PipelineOptions {
|
|
3
|
+
readonly changedFile?: string;
|
|
4
|
+
readonly enable?: EnableFlags;
|
|
5
|
+
}
|
|
6
|
+
export type PipelineMode = 'build' | 'publish';
|
|
7
|
+
export declare function runPipeline(config: FrontendConfig, mode: PipelineMode, options?: PipelineOptions): Promise<void>;
|
package/dist/pipeline.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { performance } from 'node:perf_hooks';
|
|
2
|
+
import { createBuilders } from './builders/index.js';
|
|
3
|
+
import { createHookContext, executeHooks, loadHooks } from './hooks.js';
|
|
4
|
+
export async function runPipeline(config, mode, options = {}) {
|
|
5
|
+
const context = { config, changedFile: options.changedFile, enable: options.enable };
|
|
6
|
+
const builders = createBuilders(context);
|
|
7
|
+
const hooks = await loadHooks(config.paths.workspace, mode === 'build');
|
|
8
|
+
const pipelineContext = createHookContext(config, mode, options.changedFile);
|
|
9
|
+
await executeHooks('pipeline.beforeAll', hooks.pipelineBefore, pipelineContext);
|
|
10
|
+
let pipelineError;
|
|
11
|
+
try {
|
|
12
|
+
for (const builder of builders) {
|
|
13
|
+
const builderContext = createHookContext(config, mode, options.changedFile, builder.name);
|
|
14
|
+
const beforeHooks = hooks.builderBefore.get(builder.name) ?? [];
|
|
15
|
+
const afterHooks = hooks.builderAfter.get(builder.name) ?? [];
|
|
16
|
+
await executeHooks(`builder.${builder.name}.before`, beforeHooks, builderContext);
|
|
17
|
+
const start = performance.now();
|
|
18
|
+
let builderError;
|
|
19
|
+
let afterHookError;
|
|
20
|
+
try {
|
|
21
|
+
if (mode === 'build') {
|
|
22
|
+
await builder.build(context);
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
await builder.publish(context);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
builderError = wrapPipelineError(builder.name, mode, error);
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
await executeHooks(`builder.${builder.name}.after`, afterHooks, builderContext);
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
afterHookError = error;
|
|
36
|
+
}
|
|
37
|
+
const end = performance.now();
|
|
38
|
+
const duration = end - start;
|
|
39
|
+
console.info(`[webstir-frontend] ${mode}:${builder.name} completed in ${duration.toFixed(1)}ms`);
|
|
40
|
+
if (builderError) {
|
|
41
|
+
throw builderError;
|
|
42
|
+
}
|
|
43
|
+
if (afterHookError) {
|
|
44
|
+
throw afterHookError;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
pipelineError = error;
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
try {
|
|
53
|
+
await executeHooks('pipeline.afterAll', hooks.pipelineAfter, pipelineContext);
|
|
54
|
+
}
|
|
55
|
+
catch (hookError) {
|
|
56
|
+
if (!pipelineError) {
|
|
57
|
+
pipelineError = hookError;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (pipelineError) {
|
|
62
|
+
throw pipelineError;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function wrapPipelineError(name, mode, error) {
|
|
66
|
+
if (error instanceof Error) {
|
|
67
|
+
error.message = `[${mode}:${name}] ${error.message}`;
|
|
68
|
+
return error;
|
|
69
|
+
}
|
|
70
|
+
return new Error(`[${mode}:${name}] ${String(error)}`);
|
|
71
|
+
}
|
package/dist/provider.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { readdir } from 'node:fs/promises';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { glob } from 'glob';
|
|
6
|
+
import { runPipeline } from './pipeline.js';
|
|
7
|
+
import { prepareWorkspaceConfig } from './config/setup.js';
|
|
8
|
+
import { FOLDERS } from './core/constants.js';
|
|
9
|
+
import { pathExists, readJson, remove } from './utils/fs.js';
|
|
10
|
+
import { applySsgRouting, assertNoSsgRoutes, generateSsgViewData } from './modes/ssg/index.js';
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
const pkg = require('../package.json');
|
|
13
|
+
function resolveWorkspacePaths(workspaceRoot) {
|
|
14
|
+
return {
|
|
15
|
+
sourceRoot: path.join(workspaceRoot, 'src', 'frontend'),
|
|
16
|
+
buildRoot: path.join(workspaceRoot, 'build', 'frontend'),
|
|
17
|
+
testsRoot: path.join(workspaceRoot, 'src', 'frontend', 'tests')
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
async function buildModule(options) {
|
|
21
|
+
const config = await prepareWorkspaceConfig(options.workspaceRoot);
|
|
22
|
+
const mode = normalizeMode(options.env?.WEBSTIR_MODULE_MODE);
|
|
23
|
+
const workspaceMode = await readWorkspaceMode(options.workspaceRoot);
|
|
24
|
+
const frontendMode = normalizeFrontendMode(options.env?.WEBSTIR_FRONTEND_MODE);
|
|
25
|
+
const shouldRunSsgPublish = mode === 'publish' && (frontendMode === 'ssg' || (frontendMode === undefined && workspaceMode.mode === 'ssg'));
|
|
26
|
+
const publishConfig = shouldRunSsgPublish ? applySsgPublishLayout(config) : config;
|
|
27
|
+
if (shouldRunSsgPublish) {
|
|
28
|
+
await assertNoSsgRoutes(config.paths.workspace);
|
|
29
|
+
}
|
|
30
|
+
await runPipeline(publishConfig, mode, { changedFile: undefined, enable: workspaceMode.enable });
|
|
31
|
+
if (shouldRunSsgPublish) {
|
|
32
|
+
await generateSsgViewData(publishConfig);
|
|
33
|
+
await applySsgRouting(publishConfig);
|
|
34
|
+
await removeLegacyPagesFolder(publishConfig);
|
|
35
|
+
}
|
|
36
|
+
const artifacts = await collectArtifacts(config);
|
|
37
|
+
const manifest = createManifest(config, artifacts, workspaceMode.mode, workspaceMode.isSsg);
|
|
38
|
+
return {
|
|
39
|
+
artifacts,
|
|
40
|
+
manifest
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function applySsgPublishLayout(config) {
|
|
44
|
+
const distFrontend = config.paths.dist.frontend;
|
|
45
|
+
const distPages = distFrontend;
|
|
46
|
+
const distContent = path.join(distFrontend, 'docs');
|
|
47
|
+
return {
|
|
48
|
+
...config,
|
|
49
|
+
paths: {
|
|
50
|
+
...config.paths,
|
|
51
|
+
dist: {
|
|
52
|
+
...config.paths.dist,
|
|
53
|
+
pages: distPages,
|
|
54
|
+
content: distContent
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
async function removeLegacyPagesFolder(config) {
|
|
60
|
+
const legacyPagesRoot = path.join(config.paths.dist.frontend, FOLDERS.pages);
|
|
61
|
+
if (legacyPagesRoot === config.paths.dist.pages) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (!(await pathExists(legacyPagesRoot))) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const entries = await readdir(legacyPagesRoot);
|
|
68
|
+
if (entries.length > 0) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
await remove(legacyPagesRoot);
|
|
72
|
+
}
|
|
73
|
+
function normalizeMode(rawMode) {
|
|
74
|
+
if (typeof rawMode !== 'string') {
|
|
75
|
+
return 'build';
|
|
76
|
+
}
|
|
77
|
+
return rawMode.toLowerCase() === 'publish' ? 'publish' : 'build';
|
|
78
|
+
}
|
|
79
|
+
async function getScaffoldAssets() {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
async function collectArtifacts(config) {
|
|
83
|
+
const buildRoot = config.paths.build.frontend;
|
|
84
|
+
const matches = await glob('**/*', {
|
|
85
|
+
cwd: buildRoot,
|
|
86
|
+
nodir: true,
|
|
87
|
+
dot: false
|
|
88
|
+
});
|
|
89
|
+
return matches.map((relative) => {
|
|
90
|
+
const absolutePath = path.join(buildRoot, relative);
|
|
91
|
+
const ext = path.extname(relative).toLowerCase();
|
|
92
|
+
const artifactType = ext === '.js' || ext === '.mjs' ? 'bundle' : 'asset';
|
|
93
|
+
return {
|
|
94
|
+
path: absolutePath,
|
|
95
|
+
type: artifactType
|
|
96
|
+
};
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
function createManifest(config, assets, workspaceMode, isSsgWorkspace) {
|
|
100
|
+
const entryPoints = [];
|
|
101
|
+
const staticAssets = [];
|
|
102
|
+
const diagnostics = [];
|
|
103
|
+
const normalizedMode = workspaceMode?.toLowerCase();
|
|
104
|
+
const isSsg = isSsgWorkspace || normalizedMode === 'ssg';
|
|
105
|
+
for (const asset of assets) {
|
|
106
|
+
const relativePath = path.relative(config.paths.build.frontend, asset.path);
|
|
107
|
+
const ext = path.extname(relativePath).toLowerCase();
|
|
108
|
+
if (ext === '.js' || ext === '.mjs') {
|
|
109
|
+
entryPoints.push(relativePath);
|
|
110
|
+
}
|
|
111
|
+
else if (ext) {
|
|
112
|
+
staticAssets.push(relativePath);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (entryPoints.length === 0) {
|
|
116
|
+
const fallback = path.join(config.paths.build.app, 'index.js');
|
|
117
|
+
if (fs.existsSync(fallback)) {
|
|
118
|
+
entryPoints.push(path.relative(config.paths.build.frontend, fallback));
|
|
119
|
+
}
|
|
120
|
+
else if (!isSsg) {
|
|
121
|
+
diagnostics.push({
|
|
122
|
+
severity: 'warn',
|
|
123
|
+
message: 'No JavaScript entry points found under build/frontend.'
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
entryPoints,
|
|
129
|
+
staticAssets,
|
|
130
|
+
diagnostics
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
async function readWorkspaceMode(workspaceRoot) {
|
|
134
|
+
const pkgPath = path.join(workspaceRoot, 'package.json');
|
|
135
|
+
const pkg = await readJson(pkgPath);
|
|
136
|
+
const mode = pkg?.webstir?.mode;
|
|
137
|
+
const normalizedMode = typeof mode === 'string' ? mode.toLowerCase() : undefined;
|
|
138
|
+
const views = pkg?.webstir?.moduleManifest?.views;
|
|
139
|
+
const hasSsgView = Array.isArray(views) && views.some(view => view.renderMode?.toLowerCase() === 'ssg');
|
|
140
|
+
return {
|
|
141
|
+
mode,
|
|
142
|
+
isSsg: normalizedMode === 'ssg' || hasSsgView,
|
|
143
|
+
enable: pkg?.webstir?.enable
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function normalizeFrontendMode(value) {
|
|
147
|
+
if (typeof value !== 'string') {
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
const normalized = value.trim().toLowerCase();
|
|
151
|
+
return normalized === 'ssg'
|
|
152
|
+
? 'ssg'
|
|
153
|
+
: normalized === 'bundle'
|
|
154
|
+
? 'bundle'
|
|
155
|
+
: undefined;
|
|
156
|
+
}
|
|
157
|
+
export const frontendProvider = {
|
|
158
|
+
metadata: {
|
|
159
|
+
id: pkg.name ?? '@webstir-io/webstir-frontend',
|
|
160
|
+
kind: 'frontend',
|
|
161
|
+
version: pkg.version ?? '0.0.0',
|
|
162
|
+
compatibility: {
|
|
163
|
+
minCliVersion: '0.1.0',
|
|
164
|
+
nodeRange: pkg.engines?.node ?? '>=20.18.1'
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
resolveWorkspace(options) {
|
|
168
|
+
return resolveWorkspacePaths(options.workspaceRoot);
|
|
169
|
+
},
|
|
170
|
+
async build(options) {
|
|
171
|
+
return await buildModule(options);
|
|
172
|
+
},
|
|
173
|
+
async getScaffoldAssets() {
|
|
174
|
+
return await getScaffoldAssets();
|
|
175
|
+
}
|
|
176
|
+
};
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export type FrontendPublishMode = 'bundle' | 'ssg';
|
|
2
|
+
export interface FrontendCommandOptions {
|
|
3
|
+
readonly workspaceRoot: string;
|
|
4
|
+
readonly changedFile?: string;
|
|
5
|
+
readonly watch?: boolean;
|
|
6
|
+
readonly publishMode?: FrontendPublishMode;
|
|
7
|
+
}
|
|
8
|
+
export interface FrontendConfig {
|
|
9
|
+
readonly version: 1;
|
|
10
|
+
readonly paths: FrontendPathConfig;
|
|
11
|
+
readonly features: FrontendFeatureFlags;
|
|
12
|
+
}
|
|
13
|
+
export interface EnableFlags {
|
|
14
|
+
readonly spa?: boolean;
|
|
15
|
+
readonly clientNav?: boolean;
|
|
16
|
+
readonly backend?: boolean;
|
|
17
|
+
readonly search?: boolean;
|
|
18
|
+
readonly contentNav?: boolean;
|
|
19
|
+
}
|
|
20
|
+
export interface FrontendPathConfig {
|
|
21
|
+
readonly workspace: string;
|
|
22
|
+
readonly src: {
|
|
23
|
+
readonly root: string;
|
|
24
|
+
readonly frontend: string;
|
|
25
|
+
readonly app: string;
|
|
26
|
+
readonly pages: string;
|
|
27
|
+
readonly content: string;
|
|
28
|
+
readonly images: string;
|
|
29
|
+
readonly fonts: string;
|
|
30
|
+
readonly media: string;
|
|
31
|
+
};
|
|
32
|
+
readonly build: {
|
|
33
|
+
readonly root: string;
|
|
34
|
+
readonly frontend: string;
|
|
35
|
+
readonly app: string;
|
|
36
|
+
readonly pages: string;
|
|
37
|
+
readonly content: string;
|
|
38
|
+
readonly images: string;
|
|
39
|
+
readonly fonts: string;
|
|
40
|
+
readonly media: string;
|
|
41
|
+
};
|
|
42
|
+
readonly dist: {
|
|
43
|
+
readonly root: string;
|
|
44
|
+
readonly frontend: string;
|
|
45
|
+
readonly app: string;
|
|
46
|
+
readonly pages: string;
|
|
47
|
+
readonly content: string;
|
|
48
|
+
readonly images: string;
|
|
49
|
+
readonly fonts: string;
|
|
50
|
+
readonly media: string;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export interface FrontendFeatureFlags {
|
|
54
|
+
readonly htmlSecurity: boolean;
|
|
55
|
+
readonly imageOptimization: boolean;
|
|
56
|
+
readonly precompression: boolean;
|
|
57
|
+
}
|
|
58
|
+
export interface AddPageCommandOptions extends FrontendCommandOptions {
|
|
59
|
+
readonly pageName: string;
|
|
60
|
+
readonly ssg?: boolean;
|
|
61
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { BuilderContext } from '../builders/types.js';
|
|
2
|
+
interface Rule {
|
|
3
|
+
readonly directory: string;
|
|
4
|
+
readonly extensions?: readonly string[];
|
|
5
|
+
}
|
|
6
|
+
export declare function shouldProcess(context: BuilderContext, rules: readonly Rule[]): boolean;
|
|
7
|
+
export declare function isPathInside(target: string, directory: string): boolean;
|
|
8
|
+
export {};
|