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