@webstir-io/webstir-frontend 0.1.40 → 0.1.41

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