@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,540 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { load } from 'cheerio';
|
|
4
|
+
import type { Cheerio, CheerioAPI } from 'cheerio';
|
|
5
|
+
import type { AnyNode } from 'domhandler';
|
|
6
|
+
import { glob } from 'glob';
|
|
7
|
+
import { minify } from 'html-minifier-terser';
|
|
8
|
+
import { FOLDERS, FILES, FILE_NAMES, EXTENSIONS } from '../core/constants.js';
|
|
9
|
+
import { ensureDir, readFile, writeFile, pathExists, remove } from '../utils/fs.js';
|
|
10
|
+
import type { Builder, BuilderContext } from './types.js';
|
|
11
|
+
import { getPageDirectories } from '../core/pages.js';
|
|
12
|
+
import { readPageManifest, readSharedAssets } from '../assets/assetManifest.js';
|
|
13
|
+
import { createCompressedVariants } from '../assets/precompression.js';
|
|
14
|
+
import { shouldProcess } from '../utils/changedFile.js';
|
|
15
|
+
import { getImageDimensions } from '../assets/imageOptimizer.js';
|
|
16
|
+
import { applyLazyLoading } from '../html/lazyLoad.js';
|
|
17
|
+
import { addSubresourceIntegrity } from '../html/htmlSecurity.js';
|
|
18
|
+
import { injectResourceHints } from '../html/resourceHints.js';
|
|
19
|
+
import { ensureAppShellCriticalCss, ensureDocsShellCriticalCss, inlineCriticalCss } from '../html/criticalCss.js';
|
|
20
|
+
import { findPageFromChangedFile } from '../utils/pathMatch.js';
|
|
21
|
+
import { emitDiagnostic } from '../core/diagnostics.js';
|
|
22
|
+
import type { EnableFlags } from '../types.js';
|
|
23
|
+
import { resolvePageAssetUrl, resolvePageHtmlDir, resolvePagesUrlPrefix } from '../utils/pagePaths.js';
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
export function createHtmlBuilder(context: BuilderContext): Builder {
|
|
28
|
+
return {
|
|
29
|
+
name: 'html',
|
|
30
|
+
async build(): Promise<void> {
|
|
31
|
+
await buildHtml(context);
|
|
32
|
+
},
|
|
33
|
+
async publish(): Promise<void> {
|
|
34
|
+
await publishHtml(context);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function buildHtml(context: BuilderContext): Promise<void> {
|
|
40
|
+
const { config } = context;
|
|
41
|
+
if (!shouldProcess(context, [
|
|
42
|
+
{ directory: config.paths.src.pages, extensions: [EXTENSIONS.html] },
|
|
43
|
+
{ directory: config.paths.src.pages, extensions: [EXTENSIONS.ts, EXTENSIONS.js, '.tsx', '.jsx'] },
|
|
44
|
+
{ directory: config.paths.src.app, extensions: [EXTENSIONS.html, EXTENSIONS.js] }
|
|
45
|
+
,
|
|
46
|
+
// `webstir enable ...` modifies package.json and can change which opt-in scripts should be injected.
|
|
47
|
+
{ directory: config.paths.workspace, extensions: ['.json'] }
|
|
48
|
+
])) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const appTemplatePath = path.join(config.paths.src.app, FILE_NAMES.htmlAppTemplate);
|
|
53
|
+
if (!(await pathExists(appTemplatePath))) {
|
|
54
|
+
throw new Error(`Base application HTML file not found: ${appTemplatePath}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const templateHtml = await readFile(appTemplatePath);
|
|
58
|
+
validateAppTemplate(templateHtml, appTemplatePath);
|
|
59
|
+
|
|
60
|
+
const targetPage = findPageFromChangedFile(context.changedFile, config.paths.src.pages);
|
|
61
|
+
const pages = await getPageDirectories(config.paths.src.pages);
|
|
62
|
+
await ensureDir(config.paths.build.frontend);
|
|
63
|
+
|
|
64
|
+
for (const page of pages) {
|
|
65
|
+
if (targetPage && page.name !== targetPage) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const pageHtmlFiles = await glob('**/*.html', {
|
|
69
|
+
cwd: page.directory,
|
|
70
|
+
nodir: true
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
if (pageHtmlFiles.length === 0) {
|
|
74
|
+
warn(`No HTML fragments found for page '${page.name}'.`);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const targetDir = path.join(config.paths.build.pages, page.name);
|
|
79
|
+
await ensureDir(targetDir);
|
|
80
|
+
|
|
81
|
+
for (const relativeHtml of pageHtmlFiles) {
|
|
82
|
+
const sourceHtmlPath = path.join(page.directory, relativeHtml);
|
|
83
|
+
const fragment = await readFile(sourceHtmlPath);
|
|
84
|
+
validatePageFragment(fragment, sourceHtmlPath);
|
|
85
|
+
|
|
86
|
+
const mergedHtml = mergeTemplates(templateHtml, fragment);
|
|
87
|
+
const mergedWithScripts = injectOptInScripts(
|
|
88
|
+
mergedHtml,
|
|
89
|
+
context.enable,
|
|
90
|
+
page.name,
|
|
91
|
+
page.directory,
|
|
92
|
+
sourceHtmlPath
|
|
93
|
+
);
|
|
94
|
+
const targetPath = path.join(targetDir, path.basename(relativeHtml));
|
|
95
|
+
await writeFile(targetPath, mergedWithScripts);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Copy the app template for reference in the build output.
|
|
100
|
+
const buildAppDir = path.join(config.paths.build.frontend, FOLDERS.app);
|
|
101
|
+
await ensureDir(buildAppDir);
|
|
102
|
+
await writeFile(path.join(buildAppDir, FILE_NAMES.htmlAppTemplate), templateHtml);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function publishHtml(context: BuilderContext): Promise<void> {
|
|
106
|
+
const { config } = context;
|
|
107
|
+
const buildPagesRoot = config.paths.build.pages;
|
|
108
|
+
if (!(await pathExists(buildPagesRoot))) {
|
|
109
|
+
warn('Skipping HTML publish because no build artifacts were found. Run build first.');
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const targetPage = findPageFromChangedFile(context.changedFile, config.paths.src.pages);
|
|
114
|
+
const pages = await getPageDirectories(buildPagesRoot);
|
|
115
|
+
const shared = await readSharedAssets(config.paths.dist.frontend);
|
|
116
|
+
const pagesUrlPrefix = resolvePagesUrlPrefix(config.paths.dist.frontend, config.paths.dist.pages);
|
|
117
|
+
const buildPagesUrlPrefix = resolvePagesUrlPrefix(config.paths.build.frontend, config.paths.build.pages);
|
|
118
|
+
const useRootIndex = pagesUrlPrefix.length === 0;
|
|
119
|
+
|
|
120
|
+
for (const page of pages) {
|
|
121
|
+
if (targetPage && page.name !== targetPage) {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const assetDir = path.join(config.paths.dist.pages, page.name);
|
|
125
|
+
const distDir = resolvePageHtmlDir(config.paths.dist.pages, page.name, useRootIndex);
|
|
126
|
+
|
|
127
|
+
const htmlFiles = await glob('**/*.html', {
|
|
128
|
+
cwd: page.directory,
|
|
129
|
+
nodir: true
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const manifest = await readPageManifest(assetDir, page.name);
|
|
133
|
+
|
|
134
|
+
for (const relativeHtml of htmlFiles) {
|
|
135
|
+
const sourcePath = path.join(page.directory, relativeHtml);
|
|
136
|
+
const html = await readFile(sourcePath);
|
|
137
|
+
const rewritten = await rewriteForPublish(
|
|
138
|
+
context,
|
|
139
|
+
html,
|
|
140
|
+
page.name,
|
|
141
|
+
manifest,
|
|
142
|
+
page.directory,
|
|
143
|
+
shared,
|
|
144
|
+
{
|
|
145
|
+
pagesUrlPrefix,
|
|
146
|
+
buildPagesUrlPrefix,
|
|
147
|
+
useRootIndex
|
|
148
|
+
}
|
|
149
|
+
);
|
|
150
|
+
const outputPath = path.join(distDir, relativeHtml);
|
|
151
|
+
await ensureDir(path.dirname(outputPath));
|
|
152
|
+
await writeFile(outputPath, rewritten);
|
|
153
|
+
await handlePrecompression(context, outputPath);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function mergeTemplates(appHtml: string, pageHtml: string): string {
|
|
159
|
+
const app = load(appHtml);
|
|
160
|
+
const page = load(pageHtml);
|
|
161
|
+
|
|
162
|
+
const appMain = app('main').first();
|
|
163
|
+
const pageMain = page('main').first();
|
|
164
|
+
if (appMain.length === 0) {
|
|
165
|
+
throw new Error('Base application template is missing a <main> element.');
|
|
166
|
+
}
|
|
167
|
+
if (pageMain.length === 0) {
|
|
168
|
+
throw new Error('Page fragment is missing a <main> element.');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const appHead = app('head').first();
|
|
172
|
+
const pageHead = page('head').first();
|
|
173
|
+
if (appHead.length === 0 || pageHead.length === 0) {
|
|
174
|
+
throw new Error('Templates must include a <head> element.');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const appBody = app('body').first();
|
|
178
|
+
const pageBody = page('body').first();
|
|
179
|
+
if (appBody.length && pageBody.length) {
|
|
180
|
+
const pageBodyClass = pageBody.attr('class');
|
|
181
|
+
if (pageBodyClass) {
|
|
182
|
+
const existing = appBody.attr('class');
|
|
183
|
+
const merged = existing ? `${existing} ${pageBodyClass}` : pageBodyClass;
|
|
184
|
+
appBody.attr('class', merged);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
appHead.append(pageHead.children());
|
|
189
|
+
appMain.html(pageMain.html() ?? '');
|
|
190
|
+
|
|
191
|
+
return app.root().html() ?? '';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function injectOptInScripts(
|
|
195
|
+
html: string,
|
|
196
|
+
enable: EnableFlags | undefined,
|
|
197
|
+
pageName: string,
|
|
198
|
+
pageDir: string,
|
|
199
|
+
sourceHtmlPath: string
|
|
200
|
+
): string {
|
|
201
|
+
if (!enable) {
|
|
202
|
+
return html;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const document = load(html);
|
|
206
|
+
|
|
207
|
+
rewritePageRelativeAssets(document, pageName);
|
|
208
|
+
|
|
209
|
+
if (enable.spa) {
|
|
210
|
+
const existing = document(`script[src="/${FOLDERS.pages}/${pageName}/${FILES.index}${EXTENSIONS.js}"]`);
|
|
211
|
+
if (existing.length === 0) {
|
|
212
|
+
document('head').append(`<script type="module" src="/${FOLDERS.pages}/${pageName}/${FILES.index}${EXTENSIONS.js}"></script>`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const tsCandidate = path.join(pageDir, `${FILES.index}${EXTENSIONS.ts}`);
|
|
217
|
+
const tsxCandidate = path.join(pageDir, `${FILES.index}.tsx`);
|
|
218
|
+
const jsCandidate = path.join(pageDir, `${FILES.index}${EXTENSIONS.js}`);
|
|
219
|
+
const jsxCandidate = path.join(pageDir, `${FILES.index}.jsx`);
|
|
220
|
+
const pageScriptExists = [tsCandidate, tsxCandidate, jsCandidate, jsxCandidate]
|
|
221
|
+
.some(candidate => fs.existsSync(candidate));
|
|
222
|
+
if (pageScriptExists) {
|
|
223
|
+
const hasScript = document(`script[src="/${FOLDERS.pages}/${pageName}/${FILES.index}${EXTENSIONS.js}"]`).length > 0;
|
|
224
|
+
if (!hasScript) {
|
|
225
|
+
document('head').append(`<script type="module" src="/${FOLDERS.pages}/${pageName}/${FILES.index}${EXTENSIONS.js}"></script>`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return document.root().html() ?? html;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function rewritePageRelativeAssets(document: CheerioAPI, pageName: string): void {
|
|
233
|
+
const pagePrefix = `/${FOLDERS.pages}/${pageName}/`;
|
|
234
|
+
|
|
235
|
+
document('link[rel="stylesheet"]').each((_, element) => {
|
|
236
|
+
const node = document(element);
|
|
237
|
+
const href = node.attr('href');
|
|
238
|
+
if (!href || href.startsWith('/') || href.startsWith('http:') || href.startsWith('https:') || href.startsWith('data:')) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
node.attr('href', `${pagePrefix}${href}`);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
document('script[src]').each((_, element) => {
|
|
245
|
+
const node = document(element);
|
|
246
|
+
const src = node.attr('src');
|
|
247
|
+
if (!src || src.startsWith('/') || src.startsWith('http:') || src.startsWith('https:') || src.startsWith('data:')) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
node.attr('src', `${pagePrefix}${src}`);
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function rewriteForPublish(
|
|
255
|
+
context: BuilderContext,
|
|
256
|
+
html: string,
|
|
257
|
+
pageName: string,
|
|
258
|
+
manifest: { js?: string; css?: string },
|
|
259
|
+
pageDirectory: string,
|
|
260
|
+
shared: { css?: string; js?: string } | null,
|
|
261
|
+
options: {
|
|
262
|
+
readonly pagesUrlPrefix: string;
|
|
263
|
+
readonly buildPagesUrlPrefix: string;
|
|
264
|
+
readonly useRootIndex: boolean;
|
|
265
|
+
}
|
|
266
|
+
): Promise<string> {
|
|
267
|
+
const document = load(html);
|
|
268
|
+
const { pagesUrlPrefix, buildPagesUrlPrefix, useRootIndex } = options;
|
|
269
|
+
const buildScriptHref = resolvePageAssetUrl(buildPagesUrlPrefix, pageName, `${FILES.index}${EXTENSIONS.js}`);
|
|
270
|
+
const buildCssHref = resolvePageAssetUrl(buildPagesUrlPrefix, pageName, `${FILES.index}${EXTENSIONS.css}`);
|
|
271
|
+
|
|
272
|
+
removeDevScripts(document);
|
|
273
|
+
|
|
274
|
+
const appCssHref = shared?.css ? `/app/${shared.css}` : `/${FOLDERS.app}/app.css`;
|
|
275
|
+
if (shared?.css) {
|
|
276
|
+
document(`link[href="/app/app.css"]`).attr('href', appCssHref);
|
|
277
|
+
}
|
|
278
|
+
ensureStylesheetPreload(document, appCssHref);
|
|
279
|
+
ensureAppShellCriticalCss(document, appCssHref);
|
|
280
|
+
if (document('[data-scope="docs"]').length > 0) {
|
|
281
|
+
ensureDocsShellCriticalCss(document);
|
|
282
|
+
}
|
|
283
|
+
if (shared?.js) {
|
|
284
|
+
document(`script[src="/app/app.js"]`)
|
|
285
|
+
.attr('src', `/app/${shared.js}`)
|
|
286
|
+
.attr('type', 'module');
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const scriptSelector = [
|
|
290
|
+
`script[src="${FILES.index}${EXTENSIONS.js}"]`,
|
|
291
|
+
`script[src="${buildScriptHref}"]`
|
|
292
|
+
].join(', ');
|
|
293
|
+
if (manifest.js) {
|
|
294
|
+
document(scriptSelector)
|
|
295
|
+
.attr('src', resolvePageAssetUrl(pagesUrlPrefix, pageName, manifest.js))
|
|
296
|
+
.attr('type', 'module');
|
|
297
|
+
} else {
|
|
298
|
+
document(scriptSelector).remove();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const cssSelector = [
|
|
302
|
+
`link[href="${FILES.index}${EXTENSIONS.css}"]`,
|
|
303
|
+
`link[href="${buildCssHref}"]`
|
|
304
|
+
].join(', ');
|
|
305
|
+
if (manifest.css) {
|
|
306
|
+
document(cssSelector).attr('href', resolvePageAssetUrl(pagesUrlPrefix, pageName, manifest.css));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
applyLazyLoading(document);
|
|
310
|
+
|
|
311
|
+
if (context.config.features.imageOptimization) {
|
|
312
|
+
await addImageDimensions(document, context, pageDirectory);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (context.config.features.htmlSecurity) {
|
|
316
|
+
await inlineCriticalCss(document, pageName, context.config.paths.dist.pages, pagesUrlPrefix, manifest.css);
|
|
317
|
+
const sriResult = await addSubresourceIntegrity(document);
|
|
318
|
+
if (sriResult.failures.length > 0) {
|
|
319
|
+
const resources = sriResult.failures;
|
|
320
|
+
const message = resources.length === 1
|
|
321
|
+
? `Failed to compute subresource integrity for ${resources[0]}.`
|
|
322
|
+
: `Failed to compute subresource integrity for ${resources.length} resources.`;
|
|
323
|
+
emitDiagnostic({
|
|
324
|
+
code: 'frontend.sri.unresolved',
|
|
325
|
+
kind: 'sri',
|
|
326
|
+
stage: 'html.publish',
|
|
327
|
+
severity: 'warning',
|
|
328
|
+
message,
|
|
329
|
+
data: { resources },
|
|
330
|
+
suggestion: 'Verify the resource is reachable and not blocked by auth or network constraints.'
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const hints = injectResourceHints(document, pageName, pagesUrlPrefix, useRootIndex);
|
|
335
|
+
if (hints.missingHead) {
|
|
336
|
+
emitDiagnostic({
|
|
337
|
+
code: 'frontend.resourceHints.missingHead',
|
|
338
|
+
kind: 'resource-hints',
|
|
339
|
+
stage: 'html.publish',
|
|
340
|
+
severity: 'warning',
|
|
341
|
+
message: 'Unable to inject resource hints because <head> is missing.',
|
|
342
|
+
data: { candidates: hints.candidates }
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
dedupeHeadMeta(document, 'name');
|
|
348
|
+
dedupeHeadMeta(document, 'property');
|
|
349
|
+
dedupeHeadLinks(document, 'rel');
|
|
350
|
+
|
|
351
|
+
const htmlOutput = document.root().html() ?? '';
|
|
352
|
+
return await minifyHtml(htmlOutput);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function handlePrecompression(context: BuilderContext, outputPath: string): Promise<void> {
|
|
356
|
+
if (context.config.features.precompression) {
|
|
357
|
+
await createCompressedVariants(outputPath);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
await Promise.all([
|
|
362
|
+
remove(`${outputPath}${EXTENSIONS.br}`).catch(() => undefined),
|
|
363
|
+
remove(`${outputPath}${EXTENSIONS.gz}`).catch(() => undefined)
|
|
364
|
+
]);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function validateAppTemplate(html: string, filePath: string): void {
|
|
368
|
+
const doc = load(html);
|
|
369
|
+
if (doc('main').length === 0) {
|
|
370
|
+
throw new Error(`Base template missing <main> container (${filePath}).`);
|
|
371
|
+
}
|
|
372
|
+
if (doc('head').length === 0) {
|
|
373
|
+
throw new Error(`Base template missing <head> section (${filePath}).`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function validatePageFragment(html: string, filePath: string): void {
|
|
378
|
+
const doc = load(html);
|
|
379
|
+
if (doc('main').length === 0) {
|
|
380
|
+
throw new Error(`Page fragment missing <main> section (${filePath}).`);
|
|
381
|
+
}
|
|
382
|
+
if (doc('head').length === 0) {
|
|
383
|
+
throw new Error(`Page fragment missing <head> section (${filePath}).`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function warn(message: string): void {
|
|
388
|
+
console.warn(`[webstir-frontend][html] ${message}`);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function ensureStylesheetPreload(document: CheerioAPI, href: string): void {
|
|
392
|
+
const head = document('head').first();
|
|
393
|
+
if (head.length === 0) {
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const existingPreload = document(`link[rel="preload"][href="${href}"]`).first();
|
|
398
|
+
if (existingPreload.length > 0) {
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const stylesheet = document(`link[rel="stylesheet"][href="${href}"]`).first();
|
|
403
|
+
if (stylesheet.length > 0) {
|
|
404
|
+
stylesheet.attr('fetchpriority', 'high');
|
|
405
|
+
}
|
|
406
|
+
const preloadTag = `<link rel="preload" as="style" href="${href}">`;
|
|
407
|
+
if (stylesheet.length > 0) {
|
|
408
|
+
stylesheet.before(preloadTag);
|
|
409
|
+
} else {
|
|
410
|
+
head.append(preloadTag);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function dedupeHeadMeta(document: CheerioAPI, attribute: 'name' | 'property'): void {
|
|
415
|
+
const head = document('head').first();
|
|
416
|
+
if (head.length === 0) {
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const seen = new Map<string, Cheerio<AnyNode>>();
|
|
421
|
+
head.find(`meta[${attribute}]`).each((_, element) => {
|
|
422
|
+
const value = element.attribs?.[attribute];
|
|
423
|
+
if (!value) {
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const key = value.toLowerCase();
|
|
428
|
+
const previous = seen.get(key);
|
|
429
|
+
if (previous) {
|
|
430
|
+
previous.remove();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
seen.set(key, document(element));
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function dedupeHeadLinks(document: CheerioAPI, attribute: 'rel'): void {
|
|
438
|
+
const head = document('head').first();
|
|
439
|
+
if (head.length === 0) {
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const seen = new Map<string, Cheerio<AnyNode>>();
|
|
444
|
+
head.find(`link[${attribute}]`).each((_, element) => {
|
|
445
|
+
const value = element.attribs?.[attribute];
|
|
446
|
+
if (!value) {
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const key = value.toLowerCase();
|
|
451
|
+
const previous = seen.get(key);
|
|
452
|
+
if (previous) {
|
|
453
|
+
previous.remove();
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
seen.set(key, document(element));
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function removeDevScripts(document: CheerioAPI): void {
|
|
461
|
+
removeDevScript(document, `/${FILES.refreshJs}`);
|
|
462
|
+
removeDevScript(document, `/${FILES.hmrJs}`);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function removeDevScript(document: CheerioAPI, selector: string): void {
|
|
466
|
+
document(`script[src="${selector}"]`).each((_, element) => {
|
|
467
|
+
const script = document(element);
|
|
468
|
+
const next = script.next();
|
|
469
|
+
script.remove();
|
|
470
|
+
|
|
471
|
+
if (isWhitespaceTextNode(next)) {
|
|
472
|
+
next.remove();
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function isWhitespaceTextNode(node: Cheerio<AnyNode>): boolean {
|
|
478
|
+
return node.length > 0
|
|
479
|
+
&& node[0].type === 'text'
|
|
480
|
+
&& (node[0].data ?? '').trim().length === 0;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async function minifyHtml(html: string): Promise<string> {
|
|
484
|
+
return minify(html, {
|
|
485
|
+
collapseWhitespace: true,
|
|
486
|
+
keepClosingSlash: true,
|
|
487
|
+
minifyCSS: true,
|
|
488
|
+
minifyJS: false,
|
|
489
|
+
removeComments: true,
|
|
490
|
+
removeOptionalTags: false,
|
|
491
|
+
removeAttributeQuotes: false
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async function addImageDimensions(document: CheerioAPI, context: BuilderContext, pageDirectory: string): Promise<void> {
|
|
496
|
+
const { config } = context;
|
|
497
|
+
const images = document('img').toArray();
|
|
498
|
+
|
|
499
|
+
await Promise.all(images.map(async (element) => {
|
|
500
|
+
const img = document(element);
|
|
501
|
+
if (img.attr('width') || img.attr('height')) {
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const src = img.attr('src');
|
|
506
|
+
if (!src || isExternalSource(src)) {
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const assetPath = resolveAssetPath(src, pageDirectory, config.paths.build.frontend);
|
|
511
|
+
if (!assetPath || !(await pathExists(assetPath))) {
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const dimensions = await getImageDimensions(assetPath);
|
|
516
|
+
if (!dimensions) {
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
img.attr('width', dimensions.width.toString());
|
|
521
|
+
img.attr('height', dimensions.height.toString());
|
|
522
|
+
}));
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function isExternalSource(src: string): boolean {
|
|
526
|
+
return src.startsWith('http://')
|
|
527
|
+
|| src.startsWith('https://')
|
|
528
|
+
|| src.startsWith('data:')
|
|
529
|
+
|| src.startsWith('//');
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function resolveAssetPath(src: string, pageDirectory: string, buildRoot: string): string | null {
|
|
533
|
+
const normalized = src.replace(/\\/g, '/');
|
|
534
|
+
if (normalized.startsWith('/')) {
|
|
535
|
+
const relative = normalized.replace(/^\//, '');
|
|
536
|
+
return path.join(buildRoot, relative);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
return path.join(pageDirectory, normalized);
|
|
540
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Builder, BuilderContext } from './types.js';
|
|
2
|
+
import { createCssBuilder } from './cssBuilder.js';
|
|
3
|
+
import { createHtmlBuilder } from './htmlBuilder.js';
|
|
4
|
+
import { createJavaScriptBuilder } from './jsBuilder.js';
|
|
5
|
+
import { createStaticAssetsBuilder } from './staticAssetsBuilder.js';
|
|
6
|
+
import { createContentBuilder } from './contentBuilder.js';
|
|
7
|
+
|
|
8
|
+
export function createBuilders(context: BuilderContext): Builder[] {
|
|
9
|
+
return [
|
|
10
|
+
createJavaScriptBuilder(context),
|
|
11
|
+
createCssBuilder(context),
|
|
12
|
+
createHtmlBuilder(context),
|
|
13
|
+
createContentBuilder(context),
|
|
14
|
+
createStaticAssetsBuilder(context)
|
|
15
|
+
];
|
|
16
|
+
}
|