@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
@@ -1,5 +1,4 @@
1
1
  import path from 'node:path';
2
- import { glob } from 'glob';
3
2
  import { marked } from 'marked';
4
3
  import { load } from 'cheerio';
5
4
  import type { Cheerio } from 'cheerio';
@@ -7,6 +6,7 @@ import type { AnyNode } from 'domhandler';
7
6
  import hljs from 'highlight.js/lib/common';
8
7
  import { FOLDERS, FILES, FILE_NAMES, EXTENSIONS } from '../core/constants.js';
9
8
  import { ensureDir, pathExists, readFile, readJson, remove, writeFile } from '../utils/fs.js';
9
+ import { scanGlob } from '../utils/glob.js';
10
10
  import type { Builder, BuilderContext } from './types.js';
11
11
  import { shouldProcess } from '../utils/changedFile.js';
12
12
  import { getPageDirectories } from '../core/pages.js';
@@ -15,1386 +15,1461 @@ import { resolvePageAssetUrl, resolvePagesUrlPrefix } from '../utils/pagePaths.j
15
15
  import { ensureDocsShellCriticalCss } from '../html/criticalCss.js';
16
16
 
17
17
  interface ContentFrontmatter {
18
- title?: string;
19
- description?: string;
20
- order?: number;
18
+ title?: string;
19
+ description?: string;
20
+ order?: number;
21
21
  }
22
22
 
23
23
  interface DocsNavEntry {
24
- readonly path: string;
25
- readonly title: string;
26
- readonly section?: string;
27
- readonly order?: number;
24
+ readonly path: string;
25
+ readonly title: string;
26
+ readonly section?: string;
27
+ readonly order?: number;
28
28
  }
29
29
 
30
30
  interface SidebarOverrideEntry {
31
- readonly path: string;
32
- readonly title?: string;
33
- readonly section?: string;
34
- readonly order?: number;
35
- readonly hidden?: boolean;
31
+ readonly path: string;
32
+ readonly title?: string;
33
+ readonly section?: string;
34
+ readonly order?: number;
35
+ readonly hidden?: boolean;
36
36
  }
37
37
 
38
38
  type SidebarOverrideFile =
39
- | { readonly pages?: readonly SidebarOverrideEntry[] }
40
- | readonly SidebarOverrideEntry[]
41
- | Record<string, Omit<SidebarOverrideEntry, 'path'> & { readonly path?: string }>;
39
+ | { readonly pages?: readonly SidebarOverrideEntry[] }
40
+ | readonly SidebarOverrideEntry[]
41
+ | Record<string, Omit<SidebarOverrideEntry, 'path'> & { readonly path?: string }>;
42
42
 
43
43
  interface SearchEntry {
44
- readonly path: string;
45
- readonly title: string;
46
- readonly description?: string;
47
- readonly headings: readonly string[];
48
- readonly excerpt: string;
49
- readonly kind: 'docs' | 'page';
44
+ readonly path: string;
45
+ readonly title: string;
46
+ readonly description?: string;
47
+ readonly headings: readonly string[];
48
+ readonly excerpt: string;
49
+ readonly kind: 'docs' | 'page';
50
50
  }
51
51
 
52
52
  interface RenderedContentPage {
53
- readonly href: string;
54
- readonly outputDir: string;
55
- readonly outputPath: string;
56
- readonly html: string;
57
- readonly headingIds: ReadonlySet<string>;
58
- readonly sourcePath: string;
53
+ readonly href: string;
54
+ readonly outputDir: string;
55
+ readonly outputPath: string;
56
+ readonly html: string;
57
+ readonly headingIds: ReadonlySet<string>;
58
+ readonly sourcePath: string;
59
59
  }
60
60
 
61
+ type MarkdownRenderer = InstanceType<typeof marked.Renderer>;
62
+
61
63
  export function createContentBuilder(context: BuilderContext): Builder {
62
- return {
63
- name: 'content',
64
- async build(): Promise<void> {
65
- await buildContentPages(context);
66
- await buildContentManifests(context);
67
- },
68
- async publish(): Promise<void> {
69
- await publishContentPages(context);
70
- await publishContentManifests(context);
71
- }
72
- };
64
+ return {
65
+ name: 'content',
66
+ async build(): Promise<void> {
67
+ await buildContentPages(context);
68
+ await buildContentManifests(context);
69
+ },
70
+ async publish(): Promise<void> {
71
+ await publishContentPages(context);
72
+ await publishContentManifests(context);
73
+ },
74
+ };
73
75
  }
74
76
 
75
77
  async function buildContentPages(context: BuilderContext): Promise<void> {
76
- const { config } = context;
77
- const contentRoot = config.paths.src.content;
78
-
79
- if (!(await pathExists(contentRoot))) {
80
- return;
81
- }
82
-
83
- if (!shouldProcess(context, [{ directory: contentRoot, extensions: ['.md'] }])) {
84
- return;
85
- }
86
-
87
- const files = await glob('**/*.md', {
88
- cwd: contentRoot,
89
- nodir: true
90
- });
91
-
92
- if (files.length === 0) {
93
- return;
94
- }
95
-
96
- const appTemplatePath = path.join(config.paths.src.app, FILE_NAMES.htmlAppTemplate);
97
- if (!(await pathExists(appTemplatePath))) {
98
- throw new Error(`Base application HTML file not found for content pages: ${appTemplatePath}`);
99
- }
100
-
101
- const templateHtml = await readFile(appTemplatePath);
102
- validateAppTemplate(templateHtml, appTemplatePath);
103
-
104
- const buildPagesUrlPrefix = resolvePagesUrlPrefix(config.paths.build.frontend, config.paths.build.pages);
105
- const navEntries =
106
- context.enable?.contentNav === true
107
- ? await collectContentManifests(context)
108
- : [];
109
- await removeStaleContentOutputs(context, files, buildPagesUrlPrefix);
110
-
111
- for (const relative of files) {
112
- const sourcePath = path.join(contentRoot, relative);
113
- const markdown = await readFile(sourcePath);
114
- const { frontmatter, content } = extractFrontmatter(markdown);
115
- const htmlBody = (await renderMarkdownDoc(content)).html;
116
-
117
- const segments = resolveDocsSegments(relative);
118
- const pagePath = path.join(...segments);
119
- const href = '/' + segments.join('/') + '/';
120
- const pageTitle = resolveTitle(frontmatter, content, segments);
121
-
122
- const mergedHtml = mergeContentIntoTemplate(
123
- templateHtml,
124
- pageTitle,
125
- htmlBody,
126
- frontmatter.description,
127
- context.enable?.contentNav === true,
128
- buildPagesUrlPrefix,
129
- navEntries,
130
- href
131
- );
132
- const mergedWithOptIn = injectGlobalOptInScripts(mergedHtml, context.enable);
133
-
134
- // Write to build (folder index)
135
- const targetDir = path.join(config.paths.build.pages, pagePath);
136
- await ensureDir(targetDir);
137
- const targetPath = path.join(targetDir, FILES.indexHtml);
138
- await writeFile(targetPath, mergedWithOptIn);
139
- }
78
+ const { config } = context;
79
+ const contentRoot = config.paths.src.content;
80
+
81
+ if (!(await pathExists(contentRoot))) {
82
+ return;
83
+ }
84
+
85
+ if (
86
+ !isSidebarOverrideChange(context, contentRoot) &&
87
+ !shouldProcess(context, [{ directory: contentRoot, extensions: ['.md'] }])
88
+ ) {
89
+ return;
90
+ }
91
+
92
+ const files = await scanGlob('**/*.md', { cwd: contentRoot });
93
+
94
+ if (files.length === 0) {
95
+ return;
96
+ }
97
+
98
+ const appTemplatePath = path.join(config.paths.src.app, FILE_NAMES.htmlAppTemplate);
99
+ if (!(await pathExists(appTemplatePath))) {
100
+ throw new Error(`Base application HTML file not found for content pages: ${appTemplatePath}`);
101
+ }
102
+
103
+ const templateHtml = await readFile(appTemplatePath);
104
+ validateAppTemplate(templateHtml, appTemplatePath);
105
+
106
+ const buildPagesUrlPrefix = resolvePagesUrlPrefix(
107
+ config.paths.build.frontend,
108
+ config.paths.build.pages,
109
+ );
110
+ const navEntries =
111
+ context.enable?.contentNav === true ? await collectContentManifests(context) : [];
112
+ await removeStaleContentOutputs(context, files, buildPagesUrlPrefix);
113
+
114
+ for (const relative of files) {
115
+ const sourcePath = path.join(contentRoot, relative);
116
+ const markdown = await readFile(sourcePath);
117
+ const { frontmatter, content } = extractFrontmatter(markdown);
118
+ const htmlBody = (await renderMarkdownDoc(content)).html;
119
+
120
+ const segments = resolveDocsSegments(relative);
121
+ const pagePath = path.join(...segments);
122
+ const href = `/${segments.join('/')}/`;
123
+ const pageTitle = resolveTitle(frontmatter, content, segments);
124
+
125
+ const mergedHtml = mergeContentIntoTemplate(
126
+ templateHtml,
127
+ pageTitle,
128
+ htmlBody,
129
+ frontmatter.description,
130
+ context.enable?.contentNav === true,
131
+ buildPagesUrlPrefix,
132
+ navEntries,
133
+ href,
134
+ );
135
+ const mergedWithOptIn = injectGlobalOptInScripts(mergedHtml, context.enable);
136
+
137
+ // Write to build (folder index)
138
+ const targetDir = path.join(config.paths.build.pages, pagePath);
139
+ await ensureDir(targetDir);
140
+ const targetPath = path.join(targetDir, FILES.indexHtml);
141
+ await writeFile(targetPath, mergedWithOptIn);
142
+ }
140
143
  }
141
144
 
142
145
  async function publishContentPages(context: BuilderContext): Promise<void> {
143
- const { config } = context;
144
- const contentRoot = config.paths.src.content;
145
-
146
- if (!(await pathExists(contentRoot))) {
147
- return;
148
- }
149
-
150
- const files = await glob('**/*.md', {
151
- cwd: contentRoot,
152
- nodir: true
146
+ const { config } = context;
147
+ const contentRoot = config.paths.src.content;
148
+
149
+ if (!(await pathExists(contentRoot))) {
150
+ return;
151
+ }
152
+
153
+ const files = await scanGlob('**/*.md', { cwd: contentRoot });
154
+
155
+ if (files.length === 0) {
156
+ return;
157
+ }
158
+
159
+ const appTemplatePath = path.join(config.paths.src.app, FILE_NAMES.htmlAppTemplate);
160
+ if (!(await pathExists(appTemplatePath))) {
161
+ throw new Error(`Base application HTML file not found for content pages: ${appTemplatePath}`);
162
+ }
163
+
164
+ const templateHtml = await readFile(appTemplatePath);
165
+ validateAppTemplate(templateHtml, appTemplatePath);
166
+
167
+ const pagesUrlPrefix = resolvePagesUrlPrefix(config.paths.dist.frontend, config.paths.dist.pages);
168
+ const buildPagesUrlPrefix = resolvePagesUrlPrefix(
169
+ config.paths.build.frontend,
170
+ config.paths.build.pages,
171
+ );
172
+ await removeStaleContentOutputsForRoot(config.paths.dist.content, files, pagesUrlPrefix);
173
+
174
+ const shared = await readSharedAssets(config.paths.dist.frontend);
175
+ const navEntries =
176
+ context.enable?.contentNav === true ? await collectContentManifests(context) : [];
177
+ const docsManifestRoot = path.join(config.paths.dist.pages, 'docs');
178
+ const docsManifest = await readPageManifest(docsManifestRoot, 'docs');
179
+
180
+ if (!docsManifest.css || !docsManifest.js) {
181
+ throw new Error(
182
+ "Content pages require the docs hub assets. Ensure 'src/frontend/pages/docs/index.css' and 'src/frontend/pages/docs/index.(ts|js)' exist, then re-run publish.",
183
+ );
184
+ }
185
+
186
+ const renderedPages: RenderedContentPage[] = [];
187
+
188
+ for (const relative of files) {
189
+ const sourcePath = path.join(contentRoot, relative);
190
+ const markdown = await readFile(sourcePath);
191
+ const { frontmatter, content } = extractFrontmatter(markdown);
192
+
193
+ const segments = resolveDocsSegments(relative);
194
+ const pagePath = path.join(...segments);
195
+ const href = `/${segments.join('/')}/`;
196
+ const pageTitle = resolveTitle(frontmatter, content, segments);
197
+
198
+ const rendered = await renderMarkdownDoc(content);
199
+ const htmlBody = rendered.html;
200
+
201
+ const mergedHtml = mergeContentIntoTemplate(
202
+ templateHtml,
203
+ pageTitle,
204
+ htmlBody,
205
+ frontmatter.description,
206
+ context.enable?.contentNav === true,
207
+ pagesUrlPrefix,
208
+ navEntries,
209
+ href,
210
+ );
211
+ const mergedWithOptIn = injectGlobalOptInScripts(mergedHtml, context.enable);
212
+ const rewritten = await rewriteContentForPublish(mergedWithOptIn, shared, docsManifest, {
213
+ pagesUrlPrefix,
214
+ buildPagesUrlPrefix,
153
215
  });
154
216
 
155
- if (files.length === 0) {
156
- return;
157
- }
158
-
159
- const appTemplatePath = path.join(config.paths.src.app, FILE_NAMES.htmlAppTemplate);
160
- if (!(await pathExists(appTemplatePath))) {
161
- throw new Error(`Base application HTML file not found for content pages: ${appTemplatePath}`);
162
- }
163
-
164
- const templateHtml = await readFile(appTemplatePath);
165
- validateAppTemplate(templateHtml, appTemplatePath);
166
-
167
- const pagesUrlPrefix = resolvePagesUrlPrefix(config.paths.dist.frontend, config.paths.dist.pages);
168
- const buildPagesUrlPrefix = resolvePagesUrlPrefix(config.paths.build.frontend, config.paths.build.pages);
169
- await removeStaleContentOutputsForRoot(config.paths.dist.content, files, pagesUrlPrefix);
217
+ const distDir = path.join(config.paths.dist.pages, pagePath);
218
+ const distPath = path.join(distDir, FILES.indexHtml);
170
219
 
171
- const shared = await readSharedAssets(config.paths.dist.frontend);
172
- const navEntries =
173
- context.enable?.contentNav === true
174
- ? await collectContentManifests(context)
175
- : [];
176
- const docsManifestRoot = path.join(config.paths.dist.pages, 'docs');
177
- const docsManifest = await readPageManifest(docsManifestRoot, 'docs');
178
-
179
- if (!docsManifest.css || !docsManifest.js) {
180
- throw new Error(
181
- "Content pages require the docs hub assets. Ensure 'src/frontend/pages/docs/index.css' and 'src/frontend/pages/docs/index.(ts|js)' exist, then re-run publish."
182
- );
183
- }
184
-
185
- const renderedPages: RenderedContentPage[] = [];
186
-
187
- for (const relative of files) {
188
- const sourcePath = path.join(contentRoot, relative);
189
- const markdown = await readFile(sourcePath);
190
- const { frontmatter, content } = extractFrontmatter(markdown);
191
-
192
- const segments = resolveDocsSegments(relative);
193
- const pagePath = path.join(...segments);
194
- const href = '/' + segments.join('/') + '/';
195
- const pageTitle = resolveTitle(frontmatter, content, segments);
196
-
197
- const rendered = await renderMarkdownDoc(content);
198
- const htmlBody = rendered.html;
199
-
200
- const mergedHtml = mergeContentIntoTemplate(
201
- templateHtml,
202
- pageTitle,
203
- htmlBody,
204
- frontmatter.description,
205
- context.enable?.contentNav === true,
206
- pagesUrlPrefix,
207
- navEntries,
208
- href
209
- );
210
- const mergedWithOptIn = injectGlobalOptInScripts(mergedHtml, context.enable);
211
- const rewritten = await rewriteContentForPublish(mergedWithOptIn, shared, docsManifest, {
212
- pagesUrlPrefix,
213
- buildPagesUrlPrefix
214
- });
215
-
216
- const distDir = path.join(config.paths.dist.pages, pagePath);
217
- const distPath = path.join(distDir, FILES.indexHtml);
218
-
219
- renderedPages.push({
220
- href,
221
- outputDir: distDir,
222
- outputPath: distPath,
223
- html: rewritten,
224
- headingIds: rendered.headingIds,
225
- sourcePath
226
- });
227
- }
220
+ renderedPages.push({
221
+ href,
222
+ outputDir: distDir,
223
+ outputPath: distPath,
224
+ html: rewritten,
225
+ headingIds: rendered.headingIds,
226
+ sourcePath,
227
+ });
228
+ }
228
229
 
229
- validateRenderedContentPages(renderedPages);
230
+ validateRenderedContentPages(renderedPages);
230
231
 
231
- for (const page of renderedPages) {
232
- await ensureDir(page.outputDir);
233
- await writeFile(page.outputPath, page.html);
234
- }
232
+ for (const page of renderedPages) {
233
+ await ensureDir(page.outputDir);
234
+ await writeFile(page.outputPath, page.html);
235
+ }
235
236
  }
236
237
 
237
238
  async function removeStaleContentOutputs(
238
- context: BuilderContext,
239
- contentFiles: readonly string[],
240
- pagesUrlPrefix: string
239
+ context: BuilderContext,
240
+ contentFiles: readonly string[],
241
+ pagesUrlPrefix: string,
241
242
  ): Promise<void> {
242
- await removeStaleContentOutputsForRoot(context.config.paths.build.content, contentFiles, pagesUrlPrefix);
243
+ await removeStaleContentOutputsForRoot(
244
+ context.config.paths.build.content,
245
+ contentFiles,
246
+ pagesUrlPrefix,
247
+ );
243
248
  }
244
249
 
245
250
  async function removeStaleContentOutputsForRoot(
246
- docsRoot: string,
247
- contentFiles: readonly string[],
248
- pagesUrlPrefix: string
251
+ docsRoot: string,
252
+ contentFiles: readonly string[],
253
+ pagesUrlPrefix: string,
249
254
  ): Promise<void> {
250
- if (!(await pathExists(docsRoot))) {
251
- return;
252
- }
255
+ if (!(await pathExists(docsRoot))) {
256
+ return;
257
+ }
253
258
 
254
- const expected = new Set<string>();
255
- for (const relative of contentFiles) {
256
- const segments = resolveDocsSegments(relative);
257
- expected.add(path.join(...segments.slice(1)));
258
- }
259
+ const expected = new Set<string>();
260
+ for (const relative of contentFiles) {
261
+ const segments = resolveDocsSegments(relative);
262
+ expected.add(path.join(...segments.slice(1)));
263
+ }
259
264
 
260
- const candidateIndexes = await glob('**/index.html', {
261
- cwd: docsRoot,
262
- nodir: true
263
- });
265
+ const candidateIndexes = await scanGlob('**/index.html', { cwd: docsRoot });
264
266
 
265
- const docsPrefix = resolvePageAssetUrl(pagesUrlPrefix, 'docs', '');
266
- const docsAssetToken = docsPrefix.endsWith('/') ? docsPrefix : `${docsPrefix}/`;
267
+ const docsPrefix = resolvePageAssetUrl(pagesUrlPrefix, 'docs', '');
268
+ const docsAssetToken = docsPrefix.endsWith('/') ? docsPrefix : `${docsPrefix}/`;
267
269
 
268
- for (const relativeIndex of candidateIndexes) {
269
- // Keep the docs hub at `/docs/` (index.html).
270
- if (relativeIndex === FILES.indexHtml) {
271
- continue;
272
- }
273
-
274
- const pageDir = path.dirname(relativeIndex);
275
- if (!pageDir || pageDir === '.' || expected.has(pageDir)) {
276
- continue;
277
- }
278
-
279
- const absoluteIndex = path.join(docsRoot, relativeIndex);
280
- const html = await readFile(absoluteIndex);
281
-
282
- // Only remove pages that were generated by the content pipeline (avoid deleting user-owned pages under /docs).
283
- const looksLikeContentOutput = html.includes('class="docs-article"')
284
- && html.includes(docsAssetToken);
285
- if (!looksLikeContentOutput) {
286
- continue;
287
- }
288
-
289
- await remove(path.join(docsRoot, pageDir));
270
+ for (const relativeIndex of candidateIndexes) {
271
+ // Keep the docs hub at `/docs/` (index.html).
272
+ if (relativeIndex === FILES.indexHtml) {
273
+ continue;
290
274
  }
291
- }
292
275
 
293
- async function buildContentManifests(context: BuilderContext): Promise<void> {
294
- const { config } = context;
295
- const contentRoot = config.paths.src.content;
296
-
297
- if (!(await pathExists(contentRoot))) {
298
- // Still allow search.json to be created from regular pages.
299
- if (context.enable?.search === true) {
300
- const pageEntries = await collectPageSearchEntries(context);
301
- if (pageEntries.length > 0) {
302
- await writeSearchManifest([config.paths.build.frontend], pageEntries);
303
- }
304
- }
305
- return;
276
+ const pageDir = path.dirname(relativeIndex);
277
+ if (!pageDir || pageDir === '.' || expected.has(pageDir)) {
278
+ continue;
306
279
  }
307
280
 
308
- if (!shouldProcess(context, [
309
- { directory: contentRoot, extensions: ['.md'] },
310
- // `webstir enable search` updates package.json and should emit the index immediately.
311
- { directory: config.paths.workspace, extensions: ['.json'] }
312
- ])) {
313
- return;
314
- }
281
+ const absoluteIndex = path.join(docsRoot, relativeIndex);
282
+ const html = await readFile(absoluteIndex);
315
283
 
316
- const navEntries = await collectContentManifests(context);
317
- if (navEntries.length === 0) {
318
- return;
284
+ // Only remove pages that were generated by the content pipeline (avoid deleting user-owned pages under /docs).
285
+ const looksLikeContentOutput =
286
+ html.includes('class="docs-article"') && html.includes(docsAssetToken);
287
+ if (!looksLikeContentOutput) {
288
+ continue;
319
289
  }
320
290
 
321
- await writeContentNavManifest([config.paths.build.frontend], navEntries);
291
+ await remove(path.join(docsRoot, pageDir));
292
+ }
293
+ }
294
+
295
+ async function buildContentManifests(context: BuilderContext): Promise<void> {
296
+ const { config } = context;
297
+ const contentRoot = config.paths.src.content;
322
298
 
299
+ if (!(await pathExists(contentRoot))) {
300
+ // Still allow search.json to be created from regular pages.
323
301
  if (context.enable?.search === true) {
324
- const [docEntries, pageEntries] = await Promise.all([
325
- collectContentSearchEntries(context),
326
- collectPageSearchEntries(context)
327
- ]);
328
- const searchEntries = [...docEntries, ...pageEntries];
329
- if (searchEntries.length > 0) {
330
- await writeSearchManifest([config.paths.build.frontend], searchEntries);
331
- }
332
- }
302
+ const pageEntries = await collectPageSearchEntries(context);
303
+ if (pageEntries.length > 0) {
304
+ await writeSearchManifest([config.paths.build.frontend], pageEntries);
305
+ }
306
+ }
307
+ return;
308
+ }
309
+
310
+ if (
311
+ !isSidebarOverrideChange(context, contentRoot) &&
312
+ !shouldProcess(context, [
313
+ { directory: contentRoot, extensions: ['.md'] },
314
+ // `webstir enable search` updates package.json and should emit the index immediately.
315
+ { directory: config.paths.workspace, extensions: ['.json'] },
316
+ ])
317
+ ) {
318
+ return;
319
+ }
320
+
321
+ const navEntries = await collectContentManifests(context);
322
+ if (navEntries.length === 0) {
323
+ return;
324
+ }
325
+
326
+ await writeContentNavManifest([config.paths.build.frontend], navEntries);
327
+
328
+ if (context.enable?.search === true) {
329
+ const [docEntries, pageEntries] = await Promise.all([
330
+ collectContentSearchEntries(context),
331
+ collectPageSearchEntries(context),
332
+ ]);
333
+ const searchEntries = [...docEntries, ...pageEntries];
334
+ if (searchEntries.length > 0) {
335
+ await writeSearchManifest([config.paths.build.frontend], searchEntries);
336
+ }
337
+ }
333
338
  }
334
339
 
335
340
  async function publishContentManifests(context: BuilderContext): Promise<void> {
336
- const { config } = context;
337
- const contentRoot = config.paths.src.content;
341
+ const { config } = context;
342
+ const contentRoot = config.paths.src.content;
338
343
 
339
- const hasContent = await pathExists(contentRoot);
344
+ const hasContent = await pathExists(contentRoot);
340
345
 
341
- const navEntries = hasContent ? await collectContentManifests(context) : [];
346
+ const navEntries = hasContent ? await collectContentManifests(context) : [];
342
347
 
343
- if (navEntries.length > 0) {
344
- await writeContentNavManifest([config.paths.dist.frontend], navEntries);
345
- }
348
+ if (navEntries.length > 0) {
349
+ await writeContentNavManifest([config.paths.dist.frontend], navEntries);
350
+ }
346
351
 
347
- if (context.enable?.search === true) {
348
- const [docEntries, pageEntries] = await Promise.all([
349
- hasContent ? collectContentSearchEntries(context) : Promise.resolve([]),
350
- collectPageSearchEntries(context)
351
- ]);
352
- const searchEntries = [...docEntries, ...pageEntries];
353
- if (searchEntries.length > 0) {
354
- await writeSearchManifest([config.paths.dist.frontend], searchEntries);
355
- }
352
+ if (context.enable?.search === true) {
353
+ const [docEntries, pageEntries] = await Promise.all([
354
+ hasContent ? collectContentSearchEntries(context) : Promise.resolve([]),
355
+ collectPageSearchEntries(context),
356
+ ]);
357
+ const searchEntries = [...docEntries, ...pageEntries];
358
+ if (searchEntries.length > 0) {
359
+ await writeSearchManifest([config.paths.dist.frontend], searchEntries);
356
360
  }
361
+ }
357
362
  }
358
363
 
359
364
  async function collectContentManifests(context: BuilderContext): Promise<DocsNavEntry[]> {
360
- const { config } = context;
361
- const contentRoot = config.paths.src.content;
362
- const overrides = await loadSidebarOverrides(contentRoot);
365
+ const { config } = context;
366
+ const contentRoot = config.paths.src.content;
367
+ const overrides = await loadSidebarOverrides(contentRoot);
363
368
 
364
- const files = await glob('**/*.md', {
365
- cwd: contentRoot,
366
- nodir: true
367
- });
369
+ const files = await scanGlob('**/*.md', { cwd: contentRoot });
368
370
 
369
- if (files.length === 0) {
370
- return [];
371
- }
371
+ if (files.length === 0) {
372
+ return [];
373
+ }
372
374
 
373
- const navEntries: DocsNavEntry[] = [];
374
-
375
- for (const relative of files) {
376
- const sourcePath = path.join(contentRoot, relative);
377
- const markdown = await readFile(sourcePath);
378
- const { frontmatter, content } = extractFrontmatter(markdown);
379
-
380
- const segments = resolveDocsSegments(relative);
381
- const parsed = path.parse(relative);
382
- const section =
383
- parsed.dir && parsed.dir.trim().length > 0
384
- ? parsed.dir.split(path.sep)[0]
385
- : undefined;
386
-
387
- const href = '/' + segments.join('/') + '/';
388
- const title = resolveTitle(frontmatter, content, segments);
389
- const order = frontmatter.order;
390
-
391
- const baseEntry: DocsNavEntry = {
392
- path: href,
393
- title,
394
- section,
395
- order
396
- };
375
+ const navEntries: DocsNavEntry[] = [];
397
376
 
398
- const merged = applySidebarOverride(baseEntry, overrides);
399
- if (merged) {
400
- navEntries.push(merged);
401
- }
377
+ for (const relative of files) {
378
+ const sourcePath = path.join(contentRoot, relative);
379
+ const markdown = await readFile(sourcePath);
380
+ const { frontmatter, content } = extractFrontmatter(markdown);
381
+
382
+ const segments = resolveDocsSegments(relative);
383
+ const parsed = path.parse(relative);
384
+ const section =
385
+ parsed.dir && parsed.dir.trim().length > 0 ? parsed.dir.split(path.sep)[0] : undefined;
386
+
387
+ const href = `/${segments.join('/')}/`;
388
+ const title = resolveTitle(frontmatter, content, segments);
389
+ const order = frontmatter.order;
390
+
391
+ const baseEntry: DocsNavEntry = {
392
+ path: href,
393
+ title,
394
+ section,
395
+ order,
396
+ };
397
+
398
+ const merged = applySidebarOverride(baseEntry, overrides);
399
+ if (merged) {
400
+ navEntries.push(merged);
402
401
  }
402
+ }
403
403
 
404
- navEntries.sort((a, b) => {
405
- const aSection = a.section ?? '';
406
- const bSection = b.section ?? '';
407
- if (aSection !== bSection) {
408
- return aSection.localeCompare(bSection);
409
- }
404
+ navEntries.sort((a, b) => {
405
+ const aSection = a.section ?? '';
406
+ const bSection = b.section ?? '';
407
+ if (aSection !== bSection) {
408
+ return aSection.localeCompare(bSection);
409
+ }
410
410
 
411
- const aOrder = typeof a.order === 'number' ? a.order : 0;
412
- const bOrder = typeof b.order === 'number' ? b.order : 0;
413
- if (aOrder !== bOrder) {
414
- return aOrder - bOrder;
415
- }
411
+ const aOrder = typeof a.order === 'number' ? a.order : 0;
412
+ const bOrder = typeof b.order === 'number' ? b.order : 0;
413
+ if (aOrder !== bOrder) {
414
+ return aOrder - bOrder;
415
+ }
416
416
 
417
- return a.path.localeCompare(b.path);
418
- });
417
+ return a.path.localeCompare(b.path);
418
+ });
419
419
 
420
- return navEntries;
420
+ return navEntries;
421
421
  }
422
422
 
423
423
  async function writeContentNavManifest(
424
- outputRoots: readonly string[],
425
- navEntries: readonly DocsNavEntry[]
424
+ outputRoots: readonly string[],
425
+ navEntries: readonly DocsNavEntry[],
426
426
  ): Promise<void> {
427
- for (const outputRoot of outputRoots) {
428
- const navOutputPath = path.join(outputRoot, 'docs-nav.json');
427
+ for (const outputRoot of outputRoots) {
428
+ const navOutputPath = path.join(outputRoot, 'docs-nav.json');
429
429
 
430
- await ensureDir(path.dirname(navOutputPath));
431
- await writeFile(navOutputPath, JSON.stringify(navEntries, undefined, 2));
432
- }
430
+ await ensureDir(path.dirname(navOutputPath));
431
+ await writeFile(navOutputPath, JSON.stringify(navEntries, undefined, 2));
432
+ }
433
433
  }
434
434
 
435
435
  async function collectContentSearchEntries(context: BuilderContext): Promise<SearchEntry[]> {
436
- const { config } = context;
437
- const contentRoot = config.paths.src.content;
438
- const overrides = await loadSidebarOverrides(contentRoot);
436
+ const { config } = context;
437
+ const contentRoot = config.paths.src.content;
438
+ const overrides = await loadSidebarOverrides(contentRoot);
439
439
 
440
- const files = await glob('**/*.md', {
441
- cwd: contentRoot,
442
- nodir: true
443
- });
440
+ const files = await scanGlob('**/*.md', { cwd: contentRoot });
444
441
 
445
- if (files.length === 0) {
446
- return [];
447
- }
442
+ if (files.length === 0) {
443
+ return [];
444
+ }
448
445
 
449
- const entries: SearchEntry[] = [];
450
-
451
- for (const relative of files) {
452
- const sourcePath = path.join(contentRoot, relative);
453
- const markdown = await readFile(sourcePath);
454
- const { frontmatter, content } = extractFrontmatter(markdown);
455
-
456
- const segments = resolveDocsSegments(relative);
457
- const href = '/' + segments.join('/') + '/';
458
- const rawTitle = resolveTitle(frontmatter, content, segments);
459
- const title = applySidebarTitleOverride(href, rawTitle, overrides);
460
- if (!title) {
461
- continue;
462
- }
463
-
464
- const html = (await renderMarkdownDoc(content)).html;
465
- const document = load(html);
466
- const headings = document('h2, h3')
467
- .toArray()
468
- .map((element) => document(element).text().trim())
469
- .filter((text) => text.length > 0);
470
-
471
- const plainText = document.text().replace(/\s+/g, ' ').trim();
472
- const excerpt = plainText.length > 240 ? `${plainText.slice(0, 240).trim()}…` : plainText;
473
-
474
- entries.push({
475
- path: href,
476
- title,
477
- description: frontmatter.description?.trim() ? frontmatter.description.trim() : undefined,
478
- headings,
479
- excerpt,
480
- kind: 'docs'
481
- });
482
- }
446
+ const entries: SearchEntry[] = [];
483
447
 
484
- entries.sort((a, b) => a.path.localeCompare(b.path));
485
- return entries;
486
- }
448
+ for (const relative of files) {
449
+ const sourcePath = path.join(contentRoot, relative);
450
+ const markdown = await readFile(sourcePath);
451
+ const { frontmatter, content } = extractFrontmatter(markdown);
487
452
 
488
- async function loadSidebarOverrides(contentRoot: string): Promise<Map<string, SidebarOverrideEntry>> {
489
- const overridesPath = path.join(contentRoot, '_sidebar.json');
490
- if (!(await pathExists(overridesPath))) {
491
- return new Map();
453
+ const segments = resolveDocsSegments(relative);
454
+ const href = `/${segments.join('/')}/`;
455
+ const rawTitle = resolveTitle(frontmatter, content, segments);
456
+ const title = applySidebarTitleOverride(href, rawTitle, overrides);
457
+ if (!title) {
458
+ continue;
492
459
  }
493
460
 
494
- const parsed = await readJson<SidebarOverrideFile>(overridesPath);
495
- const map = new Map<string, SidebarOverrideEntry>();
461
+ const html = (await renderMarkdownDoc(content)).html;
462
+ const document = load(html);
463
+ const headings = document('h2, h3')
464
+ .toArray()
465
+ .map((element) => document(element).text().trim())
466
+ .filter((text) => text.length > 0);
467
+
468
+ const plainText = document.text().replace(/\s+/g, ' ').trim();
469
+ const excerpt = plainText.length > 240 ? `${plainText.slice(0, 240).trim()}…` : plainText;
470
+
471
+ entries.push({
472
+ path: href,
473
+ title,
474
+ description: frontmatter.description?.trim() ? frontmatter.description.trim() : undefined,
475
+ headings,
476
+ excerpt,
477
+ kind: 'docs',
478
+ });
479
+ }
496
480
 
497
- if (!parsed) {
498
- return map;
499
- }
481
+ entries.sort((a, b) => a.path.localeCompare(b.path));
482
+ return entries;
483
+ }
500
484
 
501
- const pages = Array.isArray(parsed)
502
- ? parsed
503
- : Array.isArray((parsed as { pages?: unknown }).pages)
504
- ? (parsed as { pages: unknown }).pages as readonly SidebarOverrideEntry[]
505
- : null;
506
-
507
- if (pages) {
508
- for (let index = 0; index < pages.length; index += 1) {
509
- const entry = pages[index];
510
- if (!entry || typeof entry !== 'object') {
511
- continue;
512
- }
513
-
514
- const normalized = normalizeDocsOverrideHref((entry as SidebarOverrideEntry).path);
515
- if (!normalized) {
516
- continue;
517
- }
518
-
519
- const defaultOrder = typeof (entry as SidebarOverrideEntry).order === 'number'
520
- ? (entry as SidebarOverrideEntry).order
521
- : index + 1;
522
-
523
- map.set(normalized, {
524
- ...entry,
525
- path: normalized,
526
- order: defaultOrder
527
- });
528
- }
529
-
530
- return map;
531
- }
485
+ async function loadSidebarOverrides(
486
+ contentRoot: string,
487
+ ): Promise<Map<string, SidebarOverrideEntry>> {
488
+ const overridesPath = path.join(contentRoot, '_sidebar.json');
489
+ if (!(await pathExists(overridesPath))) {
490
+ return new Map();
491
+ }
492
+
493
+ const parsed = await readJson<SidebarOverrideFile>(overridesPath);
494
+ const map = new Map<string, SidebarOverrideEntry>();
532
495
 
533
- if (typeof parsed === 'object') {
534
- for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
535
- if (!value || typeof value !== 'object') {
536
- continue;
537
- }
538
-
539
- const rawPath = typeof (value as { path?: unknown }).path === 'string' ? String((value as { path?: unknown }).path) : key;
540
- const normalized = normalizeDocsOverrideHref(rawPath);
541
- if (!normalized) {
542
- continue;
543
- }
544
-
545
- const title = typeof (value as { title?: unknown }).title === 'string' ? String((value as { title?: unknown }).title) : undefined;
546
- const section = typeof (value as { section?: unknown }).section === 'string' ? String((value as { section?: unknown }).section) : undefined;
547
- const hidden = typeof (value as { hidden?: unknown }).hidden === 'boolean' ? Boolean((value as { hidden?: unknown }).hidden) : undefined;
548
- const orderValue = (value as { order?: unknown }).order;
549
- const order = typeof orderValue === 'number' && Number.isFinite(orderValue) ? orderValue : undefined;
550
-
551
- map.set(normalized, { path: normalized, title, section, hidden, order });
552
- }
496
+ if (!parsed) {
497
+ return map;
498
+ }
499
+
500
+ const pages = Array.isArray(parsed)
501
+ ? parsed
502
+ : Array.isArray((parsed as { pages?: unknown }).pages)
503
+ ? ((parsed as { pages: unknown }).pages as readonly SidebarOverrideEntry[])
504
+ : null;
505
+
506
+ if (pages) {
507
+ for (let index = 0; index < pages.length; index += 1) {
508
+ const entry = pages[index];
509
+ if (!entry || typeof entry !== 'object') {
510
+ continue;
511
+ }
512
+
513
+ const normalized = normalizeDocsOverrideHref((entry as SidebarOverrideEntry).path);
514
+ if (!normalized) {
515
+ continue;
516
+ }
517
+
518
+ const defaultOrder =
519
+ typeof (entry as SidebarOverrideEntry).order === 'number'
520
+ ? (entry as SidebarOverrideEntry).order
521
+ : index + 1;
522
+
523
+ map.set(normalized, {
524
+ ...entry,
525
+ path: normalized,
526
+ order: defaultOrder,
527
+ });
553
528
  }
554
529
 
555
530
  return map;
531
+ }
532
+
533
+ if (typeof parsed === 'object') {
534
+ for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
535
+ if (!value || typeof value !== 'object') {
536
+ continue;
537
+ }
538
+
539
+ const rawPath =
540
+ typeof (value as { path?: unknown }).path === 'string'
541
+ ? String((value as { path?: unknown }).path)
542
+ : key;
543
+ const normalized = normalizeDocsOverrideHref(rawPath);
544
+ if (!normalized) {
545
+ continue;
546
+ }
547
+
548
+ const title =
549
+ typeof (value as { title?: unknown }).title === 'string'
550
+ ? String((value as { title?: unknown }).title)
551
+ : undefined;
552
+ const section =
553
+ typeof (value as { section?: unknown }).section === 'string'
554
+ ? String((value as { section?: unknown }).section)
555
+ : undefined;
556
+ const hidden =
557
+ typeof (value as { hidden?: unknown }).hidden === 'boolean'
558
+ ? Boolean((value as { hidden?: unknown }).hidden)
559
+ : undefined;
560
+ const orderValue = (value as { order?: unknown }).order;
561
+ const order =
562
+ typeof orderValue === 'number' && Number.isFinite(orderValue) ? orderValue : undefined;
563
+
564
+ map.set(normalized, { path: normalized, title, section, hidden, order });
565
+ }
566
+ }
567
+
568
+ return map;
556
569
  }
557
570
 
558
- function applySidebarOverride(entry: DocsNavEntry, overrides: ReadonlyMap<string, SidebarOverrideEntry>): DocsNavEntry | null {
559
- const key = normalizeDocsOverrideHref(entry.path);
560
- const override = key ? overrides.get(key) : undefined;
561
- if (!override) {
562
- return entry;
563
- }
564
-
565
- if (override.hidden === true) {
566
- return null;
567
- }
571
+ function isSidebarOverrideChange(context: BuilderContext, contentRoot: string): boolean {
572
+ if (!context.changedFile) {
573
+ return false;
574
+ }
568
575
 
569
- const title = typeof override.title === 'string' && override.title.trim().length > 0 ? override.title.trim() : entry.title;
570
- const section = typeof override.section === 'string' && override.section.trim().length > 0 ? override.section.trim() : entry.section;
571
- const order = typeof override.order === 'number' && Number.isFinite(override.order) ? override.order : entry.order;
576
+ return (
577
+ path.resolve(context.changedFile) === path.join(path.resolve(contentRoot), '_sidebar.json')
578
+ );
579
+ }
572
580
 
573
- return {
574
- path: entry.path,
575
- title,
576
- section,
577
- order
578
- };
581
+ function applySidebarOverride(
582
+ entry: DocsNavEntry,
583
+ overrides: ReadonlyMap<string, SidebarOverrideEntry>,
584
+ ): DocsNavEntry | null {
585
+ const key = normalizeDocsOverrideHref(entry.path);
586
+ const override = key ? overrides.get(key) : undefined;
587
+ if (!override) {
588
+ return entry;
589
+ }
590
+
591
+ if (override.hidden === true) {
592
+ return null;
593
+ }
594
+
595
+ const title =
596
+ typeof override.title === 'string' && override.title.trim().length > 0
597
+ ? override.title.trim()
598
+ : entry.title;
599
+ const section =
600
+ typeof override.section === 'string' && override.section.trim().length > 0
601
+ ? override.section.trim()
602
+ : entry.section;
603
+ const order =
604
+ typeof override.order === 'number' && Number.isFinite(override.order)
605
+ ? override.order
606
+ : entry.order;
607
+
608
+ return {
609
+ path: entry.path,
610
+ title,
611
+ section,
612
+ order,
613
+ };
579
614
  }
580
615
 
581
616
  function applySidebarTitleOverride(
582
- href: string,
583
- fallbackTitle: string,
584
- overrides: ReadonlyMap<string, SidebarOverrideEntry>
617
+ href: string,
618
+ fallbackTitle: string,
619
+ overrides: ReadonlyMap<string, SidebarOverrideEntry>,
585
620
  ): string | null {
586
- const key = normalizeDocsOverrideHref(href);
587
- const override = key ? overrides.get(key) : undefined;
588
- if (!override) {
589
- return fallbackTitle;
590
- }
591
-
592
- if (override.hidden === true) {
593
- return null;
594
- }
595
-
596
- const title = typeof override.title === 'string' && override.title.trim().length > 0 ? override.title.trim() : fallbackTitle;
597
- return title;
621
+ const key = normalizeDocsOverrideHref(href);
622
+ const override = key ? overrides.get(key) : undefined;
623
+ if (!override) {
624
+ return fallbackTitle;
625
+ }
626
+
627
+ if (override.hidden === true) {
628
+ return null;
629
+ }
630
+
631
+ const title =
632
+ typeof override.title === 'string' && override.title.trim().length > 0
633
+ ? override.title.trim()
634
+ : fallbackTitle;
635
+ return title;
598
636
  }
599
637
 
600
638
  function normalizeDocsOverrideHref(value: string): string | null {
601
- const trimmed = String(value ?? '').trim();
602
- if (!trimmed) {
603
- return null;
604
- }
639
+ const trimmed = String(value ?? '').trim();
640
+ if (!trimmed) {
641
+ return null;
642
+ }
605
643
 
606
- const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
607
- if (!withSlash.startsWith('/docs/')) {
608
- return null;
609
- }
644
+ const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
645
+ if (!withSlash.startsWith('/docs/')) {
646
+ return null;
647
+ }
610
648
 
611
- if (withSlash.endsWith('/')) {
612
- return withSlash;
613
- }
649
+ if (withSlash.endsWith('/')) {
650
+ return withSlash;
651
+ }
614
652
 
615
- return `${withSlash}/`;
653
+ return `${withSlash}/`;
616
654
  }
617
655
 
618
656
  async function collectPageSearchEntries(context: BuilderContext): Promise<SearchEntry[]> {
619
- const { config } = context;
620
- const pages = await getPageDirectories(config.paths.src.pages);
621
- if (pages.length === 0) {
622
- return [];
623
- }
657
+ const { config } = context;
658
+ const pages = await getPageDirectories(config.paths.src.pages);
659
+ if (pages.length === 0) {
660
+ return [];
661
+ }
662
+
663
+ const entries: SearchEntry[] = [];
624
664
 
625
- const entries: SearchEntry[] = [];
626
-
627
- for (const page of pages) {
628
- const sourceIndex = path.join(page.directory, FILES.indexHtml);
629
- if (!(await pathExists(sourceIndex))) {
630
- continue;
631
- }
632
-
633
- const html = await readFile(sourceIndex);
634
- const document = load(html);
635
-
636
- const titleFromTag = document('title').first().text().trim();
637
- const titleFromH1 = document('h1').first().text().trim();
638
- const title = titleFromTag || titleFromH1 || toTitleCase(page.name);
639
-
640
- const description =
641
- document('meta[name="description"]').first().attr('content')?.trim()
642
- || undefined;
643
-
644
- const headings = document('h2, h3')
645
- .toArray()
646
- .map((element) => document(element).text().trim())
647
- .filter((text) => text.length > 0);
648
-
649
- const mainText = (document('main').first().text() || document.text()).replace(/\s+/g, ' ').trim();
650
- const excerpt = mainText.length > 240 ? `${mainText.slice(0, 240).trim()}…` : mainText;
651
-
652
- entries.push({
653
- path: resolvePageHref(page.name),
654
- title,
655
- description,
656
- headings,
657
- excerpt,
658
- kind: 'page'
659
- });
665
+ for (const page of pages) {
666
+ const sourceIndex = path.join(page.directory, FILES.indexHtml);
667
+ if (!(await pathExists(sourceIndex))) {
668
+ continue;
660
669
  }
661
670
 
662
- entries.sort((a, b) => a.path.localeCompare(b.path));
663
- return entries;
671
+ const html = await readFile(sourceIndex);
672
+ const document = load(html);
673
+
674
+ const titleFromTag = document('title').first().text().trim();
675
+ const titleFromH1 = document('h1').first().text().trim();
676
+ const title = titleFromTag || titleFromH1 || toTitleCase(page.name);
677
+
678
+ const description =
679
+ document('meta[name="description"]').first().attr('content')?.trim() || undefined;
680
+
681
+ const headings = document('h2, h3')
682
+ .toArray()
683
+ .map((element) => document(element).text().trim())
684
+ .filter((text) => text.length > 0);
685
+
686
+ const mainText = (document('main').first().text() || document.text())
687
+ .replace(/\s+/g, ' ')
688
+ .trim();
689
+ const excerpt = mainText.length > 240 ? `${mainText.slice(0, 240).trim()}…` : mainText;
690
+
691
+ entries.push({
692
+ path: resolvePageHref(page.name),
693
+ title,
694
+ description,
695
+ headings,
696
+ excerpt,
697
+ kind: 'page',
698
+ });
699
+ }
700
+
701
+ entries.sort((a, b) => a.path.localeCompare(b.path));
702
+ return entries;
664
703
  }
665
704
 
666
705
  function resolvePageHref(pageName: string): string {
667
- if (pageName === FOLDERS.home) {
668
- return '/';
669
- }
670
- return `/${pageName}/`;
706
+ if (pageName === FOLDERS.home) {
707
+ return '/';
708
+ }
709
+ return `/${pageName}/`;
671
710
  }
672
711
 
673
- async function writeSearchManifest(outputRoots: readonly string[], entries: readonly SearchEntry[]): Promise<void> {
674
- for (const outputRoot of outputRoots) {
675
- const outputPath = path.join(outputRoot, 'search.json');
676
- await ensureDir(path.dirname(outputPath));
677
- await writeFile(outputPath, JSON.stringify(entries, undefined, 2));
678
- }
712
+ async function writeSearchManifest(
713
+ outputRoots: readonly string[],
714
+ entries: readonly SearchEntry[],
715
+ ): Promise<void> {
716
+ for (const outputRoot of outputRoots) {
717
+ const outputPath = path.join(outputRoot, 'search.json');
718
+ await ensureDir(path.dirname(outputPath));
719
+ await writeFile(outputPath, JSON.stringify(entries, undefined, 2));
720
+ }
679
721
  }
680
722
 
681
723
  function resolveDocsSegments(relative: string): string[] {
682
- const parsed = path.parse(relative);
683
- const segments: string[] = ['docs'];
724
+ const parsed = path.parse(relative);
725
+ const segments: string[] = ['docs'];
684
726
 
685
- if (parsed.dir) {
686
- segments.push(...parsed.dir.split(path.sep));
687
- }
727
+ if (parsed.dir) {
728
+ segments.push(...parsed.dir.split(path.sep));
729
+ }
688
730
 
689
- const isReadme = parsed.name.toLowerCase() === 'readme';
690
- const isFolderIndex = parsed.name === 'index' || isReadme;
731
+ const isReadme = parsed.name.toLowerCase() === 'readme';
732
+ const isFolderIndex = parsed.name === 'index' || isReadme;
691
733
 
692
- // Reserve `/docs/` for a potential docs landing page; root docs become `/docs/<name>/`.
693
- if (!isFolderIndex || !parsed.dir) {
694
- segments.push(parsed.name);
695
- }
734
+ // Reserve `/docs/` for a potential docs landing page; root docs become `/docs/<name>/`.
735
+ if (!isFolderIndex || !parsed.dir) {
736
+ segments.push(parsed.name);
737
+ }
696
738
 
697
- return segments;
739
+ return segments;
698
740
  }
699
741
 
700
- function extractFrontmatter(markdown: string): { frontmatter: ContentFrontmatter; content: string } {
701
- const lines = markdown.split(/\r?\n/);
702
- if (lines.length === 0 || lines[0].trim() !== '---') {
703
- return { frontmatter: {}, content: markdown };
704
- }
742
+ function extractFrontmatter(markdown: string): {
743
+ frontmatter: ContentFrontmatter;
744
+ content: string;
745
+ } {
746
+ const lines = markdown.split(/\r?\n/);
747
+ if (lines.length === 0 || lines[0].trim() !== '---') {
748
+ return { frontmatter: {}, content: markdown };
749
+ }
705
750
 
706
- const frontmatterLines: string[] = [];
707
- let closingIndex = -1;
751
+ const frontmatterLines: string[] = [];
752
+ let closingIndex = -1;
708
753
 
709
- for (let index = 1; index < lines.length; index += 1) {
710
- const line = lines[index];
711
- if (line.trim() === '---') {
712
- closingIndex = index;
713
- break;
714
- }
715
- frontmatterLines.push(line);
754
+ for (let index = 1; index < lines.length; index += 1) {
755
+ const line = lines[index];
756
+ if (line.trim() === '---') {
757
+ closingIndex = index;
758
+ break;
716
759
  }
760
+ frontmatterLines.push(line);
761
+ }
717
762
 
718
- if (closingIndex === -1) {
719
- return { frontmatter: {}, content: markdown };
720
- }
763
+ if (closingIndex === -1) {
764
+ return { frontmatter: {}, content: markdown };
765
+ }
766
+
767
+ const frontmatter: ContentFrontmatter = {};
721
768
 
722
- const frontmatter: ContentFrontmatter = {};
723
-
724
- for (const line of frontmatterLines) {
725
- const match = line.match(/^([A-Za-z0-9_-]+)\s*:\s*(.+)$/);
726
- if (!match) {
727
- continue;
728
- }
729
-
730
- const key = match[1].trim();
731
- const rawValue = match[2].trim();
732
-
733
- if (key === 'title') {
734
- frontmatter.title = rawValue;
735
- } else if (key === 'description') {
736
- frontmatter.description = rawValue;
737
- } else if (key === 'order') {
738
- const parsed = Number.parseInt(rawValue, 10);
739
- if (!Number.isNaN(parsed)) {
740
- frontmatter.order = parsed;
741
- }
742
- }
769
+ for (const line of frontmatterLines) {
770
+ const match = line.match(/^([A-Za-z0-9_-]+)\s*:\s*(.+)$/);
771
+ if (!match) {
772
+ continue;
743
773
  }
744
774
 
745
- const content = lines.slice(closingIndex + 1).join('\n');
746
- return { frontmatter, content };
747
- }
775
+ const key = match[1].trim();
776
+ const rawValue = match[2].trim();
748
777
 
749
- function resolveTitle(frontmatter: ContentFrontmatter, content: string, segments: string[]): string {
750
- if (frontmatter.title && frontmatter.title.trim()) {
751
- return frontmatter.title.trim();
778
+ if (key === 'title') {
779
+ frontmatter.title = rawValue;
780
+ } else if (key === 'description') {
781
+ frontmatter.description = rawValue;
782
+ } else if (key === 'order') {
783
+ const parsed = Number.parseInt(rawValue, 10);
784
+ if (!Number.isNaN(parsed)) {
785
+ frontmatter.order = parsed;
786
+ }
752
787
  }
788
+ }
753
789
 
754
- const headingMatch = content.match(/^#\s+(.+)$/m);
755
- if (headingMatch) {
756
- return headingMatch[1].trim();
757
- }
790
+ const content = lines.slice(closingIndex + 1).join('\n');
791
+ return { frontmatter, content };
792
+ }
758
793
 
759
- const fallbackSegment = segments[segments.length - 1] ?? 'docs';
760
- const normalized = fallbackSegment.replace(/[-_]/g, ' ');
761
- return toTitleCase(normalized);
794
+ function resolveTitle(
795
+ frontmatter: ContentFrontmatter,
796
+ content: string,
797
+ segments: string[],
798
+ ): string {
799
+ if (frontmatter.title?.trim()) {
800
+ return frontmatter.title.trim();
801
+ }
802
+
803
+ const headingMatch = content.match(/^#\s+(.+)$/m);
804
+ if (headingMatch) {
805
+ return headingMatch[1].trim();
806
+ }
807
+
808
+ const fallbackSegment = segments[segments.length - 1] ?? 'docs';
809
+ const normalized = fallbackSegment.replace(/[-_]/g, ' ');
810
+ return toTitleCase(normalized);
762
811
  }
763
812
 
764
813
  function toTitleCase(value: string): string {
765
- return value
766
- .split(/\s+/)
767
- .filter((part) => part.length > 0)
768
- .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
769
- .join(' ');
814
+ return value
815
+ .split(/\s+/)
816
+ .filter((part) => part.length > 0)
817
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
818
+ .join(' ');
770
819
  }
771
820
 
772
821
  function validateAppTemplate(html: string, filePath: string): void {
773
- const doc = load(html);
774
- if (doc('main').length === 0) {
775
- throw new Error(`Base template missing <main> container (${filePath}).`);
776
- }
777
- if (doc('head').length === 0) {
778
- throw new Error(`Base template missing <head> section (${filePath}).`);
779
- }
822
+ const doc = load(html);
823
+ if (doc('main').length === 0) {
824
+ throw new Error(`Base template missing <main> container (${filePath}).`);
825
+ }
826
+ if (doc('head').length === 0) {
827
+ throw new Error(`Base template missing <head> section (${filePath}).`);
828
+ }
780
829
  }
781
830
 
782
831
  function mergeContentIntoTemplate(
783
- appHtml: string,
784
- pageName: string,
785
- bodyHtml: string,
786
- description: string | undefined,
787
- enableContentNav: boolean,
788
- pagesUrlPrefix: string,
789
- navEntries: readonly DocsNavEntry[],
790
- currentPath: string
832
+ appHtml: string,
833
+ pageName: string,
834
+ bodyHtml: string,
835
+ description: string | undefined,
836
+ enableContentNav: boolean,
837
+ pagesUrlPrefix: string,
838
+ navEntries: readonly DocsNavEntry[],
839
+ currentPath: string,
791
840
  ): string {
792
- const document = load(appHtml);
841
+ const document = load(appHtml);
793
842
 
794
- const main = document('main').first();
795
- const head = document('head').first();
796
- if (main.length === 0 || head.length === 0) {
797
- throw new Error('Base application template for content pages must include <head> and <main> elements.');
798
- }
799
-
800
- if (description && description.trim()) {
801
- const meta = head.find('meta[name="description"]').first();
802
- if (meta.length > 0) {
803
- meta.attr('content', description.trim());
804
- } else {
805
- head.append(`<meta name="description" content="${escapeHtml(description.trim())}" />`);
806
- }
807
- }
808
- const defaultDescription = head.find('meta[name="description"]').first().attr('content')?.trim() ?? '';
809
- const effectiveDescription = (description ?? '').trim() || defaultDescription;
810
-
811
- // Ensure content pages load the shared app styles.
812
- const cssHref = `/${FOLDERS.app}/app.css`;
813
- const existingStylesheet =
814
- head.find(`link[rel="stylesheet"][href="${cssHref}"]`).first().length > 0
815
- || head.find('link[rel="stylesheet"]').toArray().some((element) => {
816
- const href = document(element).attr('href');
817
- return typeof href === 'string' && href.includes('/app/app.css');
818
- });
819
- if (!existingStylesheet) {
820
- head.append(`<link rel="stylesheet" href="${cssHref}" />`);
821
- }
822
-
823
- // Ensure docs pages load the docs layout styles.
824
- const docsCssHref = resolvePageAssetUrl(pagesUrlPrefix, 'docs', `${FILES.index}${EXTENSIONS.css}`);
825
- const existingDocsStylesheet =
826
- head.find(`link[rel="stylesheet"][href="${docsCssHref}"]`).first().length > 0
827
- || head.find('link[rel="stylesheet"]').toArray().some((element) => {
828
- const href = document(element).attr('href');
829
- return typeof href === 'string' && href.includes('/docs/index.css');
830
- });
831
- if (!existingDocsStylesheet) {
832
- head.append(`<link rel="stylesheet" href="${docsCssHref}" />`);
833
- }
843
+ const main = document('main').first();
844
+ const head = document('head').first();
845
+ if (main.length === 0 || head.length === 0) {
846
+ throw new Error(
847
+ 'Base application template for content pages must include <head> and <main> elements.',
848
+ );
849
+ }
834
850
 
835
- // Best-effort: ensure the document has a sensible title for the content page.
836
- const title = head.find('title').first();
837
- if (title.length === 0) {
838
- head.append(`<title>${escapeHtml(pageName)}</title>`);
839
- } else if (!title.text().trim()) {
840
- title.text(pageName);
851
+ if (description?.trim()) {
852
+ const meta = head.find('meta[name="description"]').first();
853
+ if (meta.length > 0) {
854
+ meta.attr('content', description.trim());
841
855
  } else {
842
- const baseTitle = title.text().trim();
843
- if (!baseTitle.includes(pageName)) {
844
- title.text(`${pageName} – ${baseTitle}`);
845
- }
846
- }
847
- const effectiveTitle = head.find('title').first().text().trim() || pageName;
848
-
849
- ensureMetaProperty(head, 'og:title', effectiveTitle);
850
- if (effectiveDescription) {
851
- ensureMetaProperty(head, 'og:description', effectiveDescription);
852
- }
853
- ensureMetaProperty(head, 'og:type', 'website');
854
- ensureMetaName(head, 'twitter:card', 'summary');
855
- ensureMetaName(head, 'twitter:title', effectiveTitle);
856
- if (effectiveDescription) {
857
- ensureMetaName(head, 'twitter:description', effectiveDescription);
858
- }
859
-
860
- const contentNav =
861
- enableContentNav && navEntries.length > 0
862
- ? buildContentNavHtml(navEntries, currentPath)
863
- : { navHtml: '', breadcrumbHtml: '', ready: false };
864
-
865
- const docsLayoutHtml = enableContentNav
866
- ? [
867
- `<section class="docs-layout" data-scope="docs" data-content-nav="true" data-content-nav-ready="${contentNav.ready ? 'true' : 'false'}">`,
868
- ' <div class="ws-container docs-layout__inner">',
869
- ' <aside class="docs-sidebar" id="docs-sidebar" data-docs-sidebar>',
870
- ' <div class="docs-panel__header">',
871
- ' <a class="docs-panel__link" href="/docs/">Docs</a>',
872
- ' </div>',
873
- ` <nav class="docs-nav" data-docs-nav aria-label="Docs navigation">${contentNav.navHtml}</nav>`,
874
- ' </aside>',
875
- ' <div class="docs-main">',
876
- ' <div class="docs-toolbar" data-docs-toolbar>',
877
- ` <nav class="docs-breadcrumb" data-docs-breadcrumb aria-label="Breadcrumb">${contentNav.breadcrumbHtml}</nav>`,
878
- ' </div>',
879
- ' <div class="docs-main__content ws-flow">',
880
- ` <article class="docs-article ws-markdown" data-docs-article>${bodyHtml}</article>`,
881
- ' </div>',
882
- ' </div>',
883
- ' </div>',
884
- '</section>'
885
- ].join('\n')
886
- : [
887
- '<section class="docs-layout" data-scope="docs">',
888
- ' <div class="ws-container docs-layout__inner">',
889
- ' <div class="docs-main ws-flow">',
890
- ` <article class="docs-article ws-markdown">${bodyHtml}</article>`,
891
- ' </div>',
892
- ' </div>',
893
- '</section>'
894
- ].join('\n');
895
-
896
- main.html(docsLayoutHtml);
897
-
898
- return document.root().html() ?? '';
856
+ head.append(`<meta name="description" content="${escapeHtml(description.trim())}" />`);
857
+ }
858
+ }
859
+ const defaultDescription =
860
+ head.find('meta[name="description"]').first().attr('content')?.trim() ?? '';
861
+ const effectiveDescription = (description ?? '').trim() || defaultDescription;
862
+
863
+ // Ensure content pages load the shared app styles.
864
+ const cssHref = `/${FOLDERS.app}/app.css`;
865
+ const existingStylesheet =
866
+ head.find(`link[rel="stylesheet"][href="${cssHref}"]`).first().length > 0 ||
867
+ head
868
+ .find('link[rel="stylesheet"]')
869
+ .toArray()
870
+ .some((element) => {
871
+ const href = document(element).attr('href');
872
+ return typeof href === 'string' && href.includes('/app/app.css');
873
+ });
874
+ if (!existingStylesheet) {
875
+ head.append(`<link rel="stylesheet" href="${cssHref}" />`);
876
+ }
877
+
878
+ // Ensure docs pages load the docs layout styles.
879
+ const docsCssHref = resolvePageAssetUrl(
880
+ pagesUrlPrefix,
881
+ 'docs',
882
+ `${FILES.index}${EXTENSIONS.css}`,
883
+ );
884
+ const existingDocsStylesheet =
885
+ head.find(`link[rel="stylesheet"][href="${docsCssHref}"]`).first().length > 0 ||
886
+ head
887
+ .find('link[rel="stylesheet"]')
888
+ .toArray()
889
+ .some((element) => {
890
+ const href = document(element).attr('href');
891
+ return typeof href === 'string' && href.includes('/docs/index.css');
892
+ });
893
+ if (!existingDocsStylesheet) {
894
+ head.append(`<link rel="stylesheet" href="${docsCssHref}" />`);
895
+ }
896
+
897
+ // Best-effort: ensure the document has a sensible title for the content page.
898
+ const title = head.find('title').first();
899
+ if (title.length === 0) {
900
+ head.append(`<title>${escapeHtml(pageName)}</title>`);
901
+ } else if (!title.text().trim()) {
902
+ title.text(pageName);
903
+ } else {
904
+ const baseTitle = title.text().trim();
905
+ if (!baseTitle.includes(pageName)) {
906
+ title.text(`${pageName} – ${baseTitle}`);
907
+ }
908
+ }
909
+ const effectiveTitle = head.find('title').first().text().trim() || pageName;
910
+
911
+ ensureMetaProperty(head, 'og:title', effectiveTitle);
912
+ if (effectiveDescription) {
913
+ ensureMetaProperty(head, 'og:description', effectiveDescription);
914
+ }
915
+ ensureMetaProperty(head, 'og:type', 'website');
916
+ ensureMetaName(head, 'twitter:card', 'summary');
917
+ ensureMetaName(head, 'twitter:title', effectiveTitle);
918
+ if (effectiveDescription) {
919
+ ensureMetaName(head, 'twitter:description', effectiveDescription);
920
+ }
921
+
922
+ const contentNav =
923
+ enableContentNav && navEntries.length > 0
924
+ ? buildContentNavHtml(navEntries, currentPath)
925
+ : { navHtml: '', breadcrumbHtml: '', ready: false };
926
+
927
+ const docsLayoutHtml = enableContentNav
928
+ ? [
929
+ `<section class="docs-layout" data-scope="docs" data-content-nav="true" data-content-nav-ready="${contentNav.ready ? 'true' : 'false'}">`,
930
+ ' <div class="ws-container docs-layout__inner">',
931
+ ' <aside class="docs-sidebar" id="docs-sidebar" data-docs-sidebar>',
932
+ ' <div class="docs-panel__header">',
933
+ ' <a class="docs-panel__link" href="/docs/">Docs</a>',
934
+ ' </div>',
935
+ ` <nav class="docs-nav" data-docs-nav aria-label="Docs navigation">${contentNav.navHtml}</nav>`,
936
+ ' </aside>',
937
+ ' <div class="docs-main">',
938
+ ' <div class="docs-toolbar" data-docs-toolbar>',
939
+ ` <nav class="docs-breadcrumb" data-docs-breadcrumb aria-label="Breadcrumb">${contentNav.breadcrumbHtml}</nav>`,
940
+ ' </div>',
941
+ ' <div class="docs-main__content ws-flow">',
942
+ ` <article class="docs-article ws-markdown" data-docs-article>${bodyHtml}</article>`,
943
+ ' </div>',
944
+ ' </div>',
945
+ ' </div>',
946
+ '</section>',
947
+ ].join('\n')
948
+ : [
949
+ '<section class="docs-layout" data-scope="docs">',
950
+ ' <div class="ws-container docs-layout__inner">',
951
+ ' <div class="docs-main ws-flow">',
952
+ ` <article class="docs-article ws-markdown">${bodyHtml}</article>`,
953
+ ' </div>',
954
+ ' </div>',
955
+ '</section>',
956
+ ].join('\n');
957
+
958
+ main.html(docsLayoutHtml);
959
+
960
+ return document.root().html() ?? '';
899
961
  }
900
962
 
901
963
  type DocsNavNode = {
902
- segment: string;
903
- path: string;
904
- title: string;
905
- children: DocsNavNode[];
906
- isPage: boolean;
907
- position: number;
964
+ segment: string;
965
+ path: string;
966
+ title: string;
967
+ children: DocsNavNode[];
968
+ isPage: boolean;
969
+ position: number;
908
970
  };
909
971
 
910
972
  function buildContentNavHtml(
911
- navEntries: readonly DocsNavEntry[],
912
- currentPath: string
973
+ navEntries: readonly DocsNavEntry[],
974
+ currentPath: string,
913
975
  ): { navHtml: string; breadcrumbHtml: string; ready: boolean } {
914
- if (navEntries.length === 0) {
915
- return { navHtml: '', breadcrumbHtml: '', ready: false };
916
- }
917
-
918
- const normalizedPath = normalizeDocsPath(currentPath);
919
- const tree = buildContentNavTree(navEntries);
920
- const titleByPath = new Map<string, string>(
921
- navEntries.map((entry) => [normalizeDocsPath(entry.path), entry.title])
922
- );
923
- titleByPath.set('/docs/', titleByPath.get('/docs/') ?? 'Docs');
924
-
925
- const navHtml = renderContentNavList(tree.children, normalizedPath);
926
- const breadcrumbHtml = renderContentBreadcrumb(titleByPath, normalizedPath);
927
- return { navHtml, breadcrumbHtml, ready: navHtml.length > 0 };
976
+ if (navEntries.length === 0) {
977
+ return { navHtml: '', breadcrumbHtml: '', ready: false };
978
+ }
979
+
980
+ const normalizedPath = normalizeDocsPath(currentPath);
981
+ const tree = buildContentNavTree(navEntries);
982
+ const titleByPath = new Map<string, string>(
983
+ navEntries.map((entry) => [normalizeDocsPath(entry.path), entry.title]),
984
+ );
985
+ titleByPath.set('/docs/', titleByPath.get('/docs/') ?? 'Docs');
986
+
987
+ const navHtml = renderContentNavList(tree.children, normalizedPath);
988
+ const breadcrumbHtml = renderContentBreadcrumb(titleByPath, normalizedPath);
989
+ return { navHtml, breadcrumbHtml, ready: navHtml.length > 0 };
928
990
  }
929
991
 
930
992
  function normalizeDocsPath(pathname: string): string {
931
- if (!pathname.startsWith('/docs')) {
932
- return pathname;
933
- }
934
- if (pathname === '/docs') {
935
- return '/docs/';
936
- }
937
- return pathname.endsWith('/') ? pathname : `${pathname}/`;
993
+ if (!pathname.startsWith('/docs')) {
994
+ return pathname;
995
+ }
996
+ if (pathname === '/docs') {
997
+ return '/docs/';
998
+ }
999
+ return pathname.endsWith('/') ? pathname : `${pathname}/`;
938
1000
  }
939
1001
 
940
1002
  function buildContentNavTree(entries: readonly DocsNavEntry[]): DocsNavNode {
941
- let position = 0;
942
- const root: DocsNavNode = {
943
- segment: 'docs',
944
- path: '/docs/',
945
- title: 'Docs',
946
- children: [],
947
- isPage: false,
948
- position: position++
949
- };
950
-
951
- for (const entry of entries) {
952
- const normalizedPath = normalizeDocsPath(entry.path);
953
- const segments = normalizedPath.split('/').filter(Boolean);
954
- if (segments.length === 0) {
955
- continue;
956
- }
957
-
958
- let current = root;
959
- for (let index = 1; index < segments.length; index += 1) {
960
- const segment = segments[index];
961
- const nodePath = `/${segments.slice(0, index + 1).join('/')}/`;
962
- let child = current.children.find((node) => node.segment === segment);
963
- if (!child) {
964
- child = {
965
- segment,
966
- path: nodePath,
967
- title: toTitleCase(segment.replace(/[-_]/g, ' ')),
968
- children: [],
969
- isPage: false,
970
- position: position++
971
- };
972
- current.children.push(child);
973
- }
974
- current = child;
975
- }
976
-
977
- current.title = entry.title;
978
- current.isPage = true;
1003
+ let position = 0;
1004
+ const root: DocsNavNode = {
1005
+ segment: 'docs',
1006
+ path: '/docs/',
1007
+ title: 'Docs',
1008
+ children: [],
1009
+ isPage: false,
1010
+ position: position++,
1011
+ };
1012
+
1013
+ for (const entry of entries) {
1014
+ const normalizedPath = normalizeDocsPath(entry.path);
1015
+ const segments = normalizedPath.split('/').filter(Boolean);
1016
+ if (segments.length === 0) {
1017
+ continue;
1018
+ }
1019
+
1020
+ let current = root;
1021
+ for (let index = 1; index < segments.length; index += 1) {
1022
+ const segment = segments[index];
1023
+ const nodePath = `/${segments.slice(0, index + 1).join('/')}/`;
1024
+ let child = current.children.find((node) => node.segment === segment);
1025
+ if (!child) {
1026
+ child = {
1027
+ segment,
1028
+ path: nodePath,
1029
+ title: toTitleCase(segment.replace(/[-_]/g, ' ')),
1030
+ children: [],
1031
+ isPage: false,
1032
+ position: position++,
1033
+ };
1034
+ current.children.push(child);
1035
+ }
1036
+ current = child;
979
1037
  }
980
1038
 
981
- return root;
982
- }
1039
+ current.title = entry.title;
1040
+ current.isPage = true;
1041
+ }
983
1042
 
984
- function renderContentNavList(nodes: readonly DocsNavNode[], currentPath: string, depth = 0): string {
985
- const listClass = depth === 0 ? 'docs-nav__list' : 'docs-nav__list docs-nav__list--nested';
986
- const sorted = [...nodes].sort((a, b) => a.position - b.position);
987
-
988
- const items = sorted.map((node) => {
989
- const isActive = node.path === currentPath;
990
- const isBranch = !isActive && currentPath.startsWith(node.path);
991
- const activeAttr = isActive
992
- ? ' data-active="true"'
993
- : isBranch
994
- ? ' data-active-branch="true"'
995
- : '';
996
-
997
- const label = node.isPage
998
- ? `<a class="docs-nav__link" href="${node.path}"${isActive ? ' aria-current="page"' : ''}>${escapeHtml(node.title)}</a>`
999
- : `<span class="docs-nav__label">${escapeHtml(node.title)}</span>`;
1000
- const nested = node.children.length > 0
1001
- ? renderContentNavList(node.children, currentPath, depth + 1)
1002
- : '';
1003
-
1004
- return `<li class="docs-nav__item"${activeAttr}>${label}${nested}</li>`;
1005
- });
1043
+ return root;
1044
+ }
1006
1045
 
1007
- return `<ol class="${listClass}">${items.join('')}</ol>`;
1046
+ function renderContentNavList(
1047
+ nodes: readonly DocsNavNode[],
1048
+ currentPath: string,
1049
+ depth = 0,
1050
+ ): string {
1051
+ const listClass = depth === 0 ? 'docs-nav__list' : 'docs-nav__list docs-nav__list--nested';
1052
+ const sorted = [...nodes].sort((a, b) => a.position - b.position);
1053
+
1054
+ const items = sorted.map((node) => {
1055
+ const isActive = node.path === currentPath;
1056
+ const isBranch = !isActive && currentPath.startsWith(node.path);
1057
+ const activeAttr = isActive
1058
+ ? ' data-active="true"'
1059
+ : isBranch
1060
+ ? ' data-active-branch="true"'
1061
+ : '';
1062
+
1063
+ const label = node.isPage
1064
+ ? `<a class="docs-nav__link" href="${node.path}"${isActive ? ' aria-current="page"' : ''}>${escapeHtml(node.title)}</a>`
1065
+ : `<span class="docs-nav__label">${escapeHtml(node.title)}</span>`;
1066
+ const nested =
1067
+ node.children.length > 0 ? renderContentNavList(node.children, currentPath, depth + 1) : '';
1068
+
1069
+ return `<li class="docs-nav__item"${activeAttr}>${label}${nested}</li>`;
1070
+ });
1071
+
1072
+ return `<ol class="${listClass}">${items.join('')}</ol>`;
1008
1073
  }
1009
1074
 
1010
1075
  function renderContentBreadcrumb(
1011
- titleByPath: ReadonlyMap<string, string>,
1012
- currentPath: string
1076
+ titleByPath: ReadonlyMap<string, string>,
1077
+ currentPath: string,
1013
1078
  ): string {
1014
- if (!currentPath.startsWith('/docs/')) {
1015
- return '';
1016
- }
1017
-
1018
- const crumbs: Array<{ title: string; href: string }> = [];
1019
- const rootTitle = titleByPath.get('/docs/') ?? 'Docs';
1020
- crumbs.push({ title: rootTitle, href: '/docs/' });
1021
-
1022
- const segments = currentPath.replace(/^\/docs\/?/, '').split('/').filter(Boolean);
1023
- let href = '/docs/';
1024
- for (const segment of segments) {
1025
- href = `${href}${segment}/`;
1026
- const title = titleByPath.get(href) ?? toTitleCase(segment.replace(/[-_]/g, ' '));
1027
- crumbs.push({ title, href });
1028
- }
1029
-
1030
- const items = crumbs.map((crumb, index) => {
1031
- if (index === crumbs.length - 1) {
1032
- return `<li class="docs-breadcrumb__item"><span aria-current="page">${escapeHtml(crumb.title)}</span></li>`;
1033
- }
1034
- return `<li class="docs-breadcrumb__item"><a class="docs-breadcrumb__link" href="${crumb.href}">${escapeHtml(crumb.title)}</a></li>`;
1035
- });
1036
-
1037
- return `<ol class="docs-breadcrumb__list">${items.join('')}</ol>`;
1079
+ if (!currentPath.startsWith('/docs/')) {
1080
+ return '';
1081
+ }
1082
+
1083
+ const crumbs: Array<{ title: string; href: string }> = [];
1084
+ const rootTitle = titleByPath.get('/docs/') ?? 'Docs';
1085
+ crumbs.push({ title: rootTitle, href: '/docs/' });
1086
+
1087
+ const segments = currentPath
1088
+ .replace(/^\/docs\/?/, '')
1089
+ .split('/')
1090
+ .filter(Boolean);
1091
+ let href = '/docs/';
1092
+ for (const segment of segments) {
1093
+ href = `${href}${segment}/`;
1094
+ const title = titleByPath.get(href) ?? toTitleCase(segment.replace(/[-_]/g, ' '));
1095
+ crumbs.push({ title, href });
1096
+ }
1097
+
1098
+ const items = crumbs.map((crumb, index) => {
1099
+ if (index === crumbs.length - 1) {
1100
+ return `<li class="docs-breadcrumb__item"><span aria-current="page">${escapeHtml(crumb.title)}</span></li>`;
1101
+ }
1102
+ return `<li class="docs-breadcrumb__item"><a class="docs-breadcrumb__link" href="${crumb.href}">${escapeHtml(crumb.title)}</a></li>`;
1103
+ });
1104
+
1105
+ return `<ol class="docs-breadcrumb__list">${items.join('')}</ol>`;
1038
1106
  }
1039
1107
 
1040
1108
  function ensureMetaProperty(head: Cheerio<AnyNode>, property: string, content: string): void {
1041
- const escaped = escapeHtml(content);
1042
- const meta = head.find(`meta[property="${property}"]`).first();
1043
- if (meta.length > 0) {
1044
- meta.attr('content', escaped);
1045
- return;
1046
- }
1047
- head.append(`<meta property="${property}" content="${escaped}" />`);
1109
+ const escaped = escapeHtml(content);
1110
+ const meta = head.find(`meta[property="${property}"]`).first();
1111
+ if (meta.length > 0) {
1112
+ meta.attr('content', escaped);
1113
+ return;
1114
+ }
1115
+ head.append(`<meta property="${property}" content="${escaped}" />`);
1048
1116
  }
1049
1117
 
1050
1118
  function ensureMetaName(head: Cheerio<AnyNode>, name: string, content: string): void {
1051
- const escaped = escapeHtml(content);
1052
- const meta = head.find(`meta[name="${name}"]`).first();
1053
- if (meta.length > 0) {
1054
- meta.attr('content', escaped);
1055
- return;
1056
- }
1057
- head.append(`<meta name="${name}" content="${escaped}" />`);
1119
+ const escaped = escapeHtml(content);
1120
+ const meta = head.find(`meta[name="${name}"]`).first();
1121
+ if (meta.length > 0) {
1122
+ meta.attr('content', escaped);
1123
+ return;
1124
+ }
1125
+ head.append(`<meta name="${name}" content="${escaped}" />`);
1058
1126
  }
1059
1127
 
1060
- async function renderMarkdownDoc(markdown: string): Promise<{ html: string; headingIds: ReadonlySet<string> }> {
1061
- const renderer = getMarkdownRenderer();
1062
- const expanded = await expandAdmonitions(markdown, renderer);
1063
- const rawHtml = await marked.parse(expanded, { renderer: renderer as any });
1064
- const linked = rewriteMarkdownLinks(rawHtml);
1065
- const { html, headingIds } = ensureHeadingIds(linked);
1066
- return { html, headingIds };
1128
+ async function renderMarkdownDoc(
1129
+ markdown: string,
1130
+ ): Promise<{ html: string; headingIds: ReadonlySet<string> }> {
1131
+ const renderer = getMarkdownRenderer();
1132
+ const expanded = await expandAdmonitions(markdown, renderer);
1133
+ const rawHtml = await marked.parse(expanded, { renderer });
1134
+ const linked = rewriteMarkdownLinks(rawHtml);
1135
+ const { html, headingIds } = ensureHeadingIds(linked);
1136
+ return { html, headingIds };
1067
1137
  }
1068
1138
 
1069
- function getMarkdownRenderer(): unknown {
1070
- const w = globalThis as unknown as Record<string, unknown>;
1071
- const key = '__webstirMarkedRendererV1';
1072
- const existing = w[key] as unknown | undefined;
1073
- if (existing) {
1074
- return existing;
1075
- }
1139
+ function getMarkdownRenderer(): MarkdownRenderer {
1140
+ const w = globalThis as unknown as Record<string, unknown>;
1141
+ const key = '__webstirMarkedRendererV1';
1142
+ const existing = w[key] as MarkdownRenderer | undefined;
1143
+ if (existing) {
1144
+ return existing;
1145
+ }
1076
1146
 
1077
- const renderer = new marked.Renderer();
1078
-
1079
- // Marked v12 renderer signature is not stable in TS types; keep it permissive.
1080
- (renderer as unknown as { code: (code: string, infostring?: string) => string }).code = (
1081
- code: string,
1082
- infostring?: string
1083
- ): string => {
1084
- const rawLang = typeof infostring === 'string' ? infostring.trim().split(/\s+/)[0] : '';
1085
- const lang = rawLang ? rawLang.toLowerCase() : '';
1086
-
1087
- try {
1088
- if (lang && hljs.getLanguage(lang)) {
1089
- const highlighted = hljs.highlight(code, { language: lang }).value;
1090
- return `<pre><code class="hljs language-${escapeHtml(lang)}">${highlighted}</code></pre>`;
1091
- }
1092
-
1093
- const highlighted = hljs.highlightAuto(code).value;
1094
- return `<pre><code class="hljs">${highlighted}</code></pre>`;
1095
- } catch {
1096
- return `<pre><code>${escapeHtml(code)}</code></pre>`;
1097
- }
1098
- };
1147
+ const renderer = new marked.Renderer();
1148
+
1149
+ // Marked v12 renderer signature is not stable in TS types; keep it permissive.
1150
+ (renderer as unknown as { code: (code: string, infostring?: string) => string }).code = (
1151
+ code: string,
1152
+ infostring?: string,
1153
+ ): string => {
1154
+ const rawLang = typeof infostring === 'string' ? infostring.trim().split(/\s+/)[0] : '';
1155
+ const lang = rawLang ? rawLang.toLowerCase() : '';
1156
+
1157
+ try {
1158
+ if (lang && hljs.getLanguage(lang)) {
1159
+ const highlighted = hljs.highlight(code, { language: lang }).value;
1160
+ return `<pre><code class="hljs language-${escapeHtml(lang)}">${highlighted}</code></pre>`;
1161
+ }
1099
1162
 
1100
- w[key] = renderer;
1101
- return renderer;
1163
+ const highlighted = hljs.highlightAuto(code).value;
1164
+ return `<pre><code class="hljs">${highlighted}</code></pre>`;
1165
+ } catch {
1166
+ return `<pre><code>${escapeHtml(code)}</code></pre>`;
1167
+ }
1168
+ };
1169
+
1170
+ w[key] = renderer;
1171
+ return renderer;
1102
1172
  }
1103
1173
 
1104
1174
  type AdmonitionKind = 'note' | 'tip' | 'info' | 'warning' | 'danger';
1105
1175
 
1106
1176
  const ADMONITION_TITLES: Record<AdmonitionKind, string> = {
1107
- note: 'Note',
1108
- tip: 'Tip',
1109
- info: 'Info',
1110
- warning: 'Warning',
1111
- danger: 'Danger'
1177
+ note: 'Note',
1178
+ tip: 'Tip',
1179
+ info: 'Info',
1180
+ warning: 'Warning',
1181
+ danger: 'Danger',
1112
1182
  };
1113
1183
 
1114
- async function expandAdmonitions(markdown: string, renderer: unknown): Promise<string> {
1115
- const lines = markdown.split(/\r?\n/);
1116
- const out: string[] = [];
1117
-
1118
- for (let index = 0; index < lines.length; index += 1) {
1119
- const line = lines[index] ?? '';
1120
- const match = line.match(/^:::\s*([A-Za-z]+)(?:\s+(.*))?\s*$/);
1121
- if (!match) {
1122
- out.push(line);
1123
- continue;
1124
- }
1125
-
1126
- const kindRaw = match[1]?.toLowerCase() ?? '';
1127
- if (!isAdmonitionKind(kindRaw)) {
1128
- out.push(line);
1129
- continue;
1130
- }
1131
-
1132
- const title = (match[2] ?? '').trim() || ADMONITION_TITLES[kindRaw];
1133
-
1134
- const inner: string[] = [];
1135
- let closed = false;
1136
- for (index = index + 1; index < lines.length; index += 1) {
1137
- const innerLine = lines[index] ?? '';
1138
- if (innerLine.trim() === ':::') {
1139
- closed = true;
1140
- break;
1141
- }
1142
- inner.push(innerLine);
1143
- }
1144
-
1145
- if (!closed) {
1146
- // Unterminated block; treat it as literal markdown.
1147
- out.push(line);
1148
- out.push(...inner);
1149
- break;
1150
- }
1151
-
1152
- const bodyMarkdown = inner.join('\n').trim();
1153
- const bodyHtml = bodyMarkdown.length > 0 ? await marked.parse(bodyMarkdown, { renderer: renderer as any }) : '';
1154
-
1155
- out.push(
1156
- [
1157
- `<aside class="docs-callout docs-callout--${kindRaw}">`,
1158
- ` <div class="docs-callout__title">${escapeHtml(title)}</div>`,
1159
- ` <div class="docs-callout__body">${bodyHtml}</div>`,
1160
- `</aside>`
1161
- ].join('\n')
1162
- );
1184
+ async function expandAdmonitions(markdown: string, renderer: MarkdownRenderer): Promise<string> {
1185
+ const lines = markdown.split(/\r?\n/);
1186
+ const out: string[] = [];
1187
+
1188
+ for (let index = 0; index < lines.length; index += 1) {
1189
+ const line = lines[index] ?? '';
1190
+ const match = line.match(/^:::\s*([A-Za-z]+)(?:\s+(.*))?\s*$/);
1191
+ if (!match) {
1192
+ out.push(line);
1193
+ continue;
1194
+ }
1195
+
1196
+ const kindRaw = match[1]?.toLowerCase() ?? '';
1197
+ if (!isAdmonitionKind(kindRaw)) {
1198
+ out.push(line);
1199
+ continue;
1163
1200
  }
1164
1201
 
1165
- return out.join('\n');
1202
+ const title = (match[2] ?? '').trim() || ADMONITION_TITLES[kindRaw];
1203
+
1204
+ const inner: string[] = [];
1205
+ let closed = false;
1206
+ for (index = index + 1; index < lines.length; index += 1) {
1207
+ const innerLine = lines[index] ?? '';
1208
+ if (innerLine.trim() === ':::') {
1209
+ closed = true;
1210
+ break;
1211
+ }
1212
+ inner.push(innerLine);
1213
+ }
1214
+
1215
+ if (!closed) {
1216
+ // Unterminated block; treat it as literal markdown.
1217
+ out.push(line);
1218
+ out.push(...inner);
1219
+ break;
1220
+ }
1221
+
1222
+ const bodyMarkdown = inner.join('\n').trim();
1223
+ const bodyHtml = bodyMarkdown.length > 0 ? await marked.parse(bodyMarkdown, { renderer }) : '';
1224
+
1225
+ out.push(
1226
+ [
1227
+ `<aside class="docs-callout docs-callout--${kindRaw}">`,
1228
+ ` <div class="docs-callout__title">${escapeHtml(title)}</div>`,
1229
+ ` <div class="docs-callout__body">${bodyHtml}</div>`,
1230
+ `</aside>`,
1231
+ ].join('\n'),
1232
+ );
1233
+ }
1234
+
1235
+ return out.join('\n');
1166
1236
  }
1167
1237
 
1168
1238
  function isAdmonitionKind(value: string): value is AdmonitionKind {
1169
- return value === 'note' || value === 'tip' || value === 'info' || value === 'warning' || value === 'danger';
1239
+ return (
1240
+ value === 'note' ||
1241
+ value === 'tip' ||
1242
+ value === 'info' ||
1243
+ value === 'warning' ||
1244
+ value === 'danger'
1245
+ );
1170
1246
  }
1171
1247
 
1172
1248
  function ensureHeadingIds(html: string): { html: string; headingIds: ReadonlySet<string> } {
1173
- const document = load(html);
1174
- const used = new Set<string>();
1175
- const ids = new Set<string>();
1176
-
1177
- const headings = document('h1, h2, h3, h4').toArray();
1178
- for (const element of headings) {
1179
- const heading = document(element);
1180
- const existing = heading.attr('id')?.trim();
1181
- if (existing) {
1182
- used.add(existing);
1183
- ids.add(existing);
1184
- continue;
1185
- }
1186
-
1187
- const text = heading.text().trim();
1188
- const base = slugifyHeading(text) || 'section';
1189
- let candidate = base;
1190
- let counter = 2;
1191
- while (used.has(candidate)) {
1192
- candidate = `${base}-${counter}`;
1193
- counter += 1;
1194
- }
1195
-
1196
- used.add(candidate);
1197
- ids.add(candidate);
1198
- heading.attr('id', candidate);
1249
+ const document = load(html);
1250
+ const used = new Set<string>();
1251
+ const ids = new Set<string>();
1252
+
1253
+ const headings = document('h1, h2, h3, h4').toArray();
1254
+ for (const element of headings) {
1255
+ const heading = document(element);
1256
+ const existing = heading.attr('id')?.trim();
1257
+ if (existing) {
1258
+ used.add(existing);
1259
+ ids.add(existing);
1260
+ continue;
1261
+ }
1262
+
1263
+ const text = heading.text().trim();
1264
+ const base = slugifyHeading(text) || 'section';
1265
+ let candidate = base;
1266
+ let counter = 2;
1267
+ while (used.has(candidate)) {
1268
+ candidate = `${base}-${counter}`;
1269
+ counter += 1;
1199
1270
  }
1200
1271
 
1201
- return { html: document.root().html() ?? html, headingIds: ids };
1272
+ used.add(candidate);
1273
+ ids.add(candidate);
1274
+ heading.attr('id', candidate);
1275
+ }
1276
+
1277
+ return { html: document.root().html() ?? html, headingIds: ids };
1202
1278
  }
1203
1279
 
1204
1280
  function slugifyHeading(value: string): string {
1205
- return value
1206
- .trim()
1207
- .toLowerCase()
1208
- .replace(/['"]/g, '')
1209
- .replace(/[^a-z0-9\s-]/g, '')
1210
- .replace(/\s+/g, '-')
1211
- .replace(/-+/g, '-');
1281
+ return value
1282
+ .trim()
1283
+ .toLowerCase()
1284
+ .replace(/['"]/g, '')
1285
+ .replace(/[^a-z0-9\s-]/g, '')
1286
+ .replace(/\s+/g, '-')
1287
+ .replace(/-+/g, '-');
1212
1288
  }
1213
1289
 
1214
1290
  function validateRenderedContentPages(pages: readonly RenderedContentPage[]): void {
1215
- if (pages.length === 0) {
1216
- return;
1217
- }
1218
-
1219
- const headingsByHref = new Map<string, ReadonlySet<string>>(pages.map((page) => [page.href, page.headingIds]));
1220
- const knownHrefs = new Set<string>(pages.map((page) => page.href));
1221
- knownHrefs.add('/docs/');
1222
-
1223
- const errors: string[] = [];
1224
-
1225
- for (const page of pages) {
1226
- const document = load(page.html);
1227
- const anchors = document('.docs-article a[href]').toArray();
1228
-
1229
- for (const element of anchors) {
1230
- const href = document(element).attr('href') ?? '';
1231
- if (!href) {
1232
- continue;
1233
- }
1234
-
1235
- if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith('//')) {
1236
- continue;
1237
- }
1238
-
1239
- const resolved = resolveHref(page.href, href);
1240
- if (!resolved) {
1241
- continue;
1242
- }
1243
-
1244
- const isDocsPage =
1245
- resolved.pathname === '/docs'
1246
- || resolved.pathname === '/docs/'
1247
- || resolved.pathname.startsWith('/docs/');
1248
- if (!isDocsPage) {
1249
- continue;
1250
- }
1251
-
1252
- const targetHref = normalizeDocsHref(resolved.pathname);
1253
- if (!knownHrefs.has(targetHref)) {
1254
- errors.push(`${page.sourcePath}: broken docs link '${href}' → '${targetHref}'`);
1255
- continue;
1256
- }
1257
-
1258
- const hash = resolved.hash.startsWith('#') ? resolved.hash.slice(1) : resolved.hash;
1259
- if (!hash) {
1260
- continue;
1261
- }
1262
-
1263
- const targetHeadings = headingsByHref.get(targetHref);
1264
- if (!targetHeadings) {
1265
- continue;
1266
- }
1267
-
1268
- if (!targetHeadings.has(hash)) {
1269
- errors.push(`${page.sourcePath}: broken anchor '${href}' (missing '#${hash}' on ${targetHref})`);
1270
- }
1271
- }
1291
+ if (pages.length === 0) {
1292
+ return;
1293
+ }
1294
+
1295
+ const headingsByHref = new Map<string, ReadonlySet<string>>(
1296
+ pages.map((page) => [page.href, page.headingIds]),
1297
+ );
1298
+ const knownHrefs = new Set<string>(pages.map((page) => page.href));
1299
+ knownHrefs.add('/docs/');
1300
+
1301
+ const errors: string[] = [];
1302
+
1303
+ for (const page of pages) {
1304
+ const document = load(page.html);
1305
+ const anchors = document('.docs-article a[href]').toArray();
1306
+
1307
+ for (const element of anchors) {
1308
+ const href = document(element).attr('href') ?? '';
1309
+ if (!href) {
1310
+ continue;
1311
+ }
1312
+
1313
+ if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith('//')) {
1314
+ continue;
1315
+ }
1316
+
1317
+ const resolved = resolveHref(page.href, href);
1318
+ if (!resolved) {
1319
+ continue;
1320
+ }
1321
+
1322
+ const isDocsPage =
1323
+ resolved.pathname === '/docs' ||
1324
+ resolved.pathname === '/docs/' ||
1325
+ resolved.pathname.startsWith('/docs/');
1326
+ if (!isDocsPage) {
1327
+ continue;
1328
+ }
1329
+
1330
+ const targetHref = normalizeDocsHref(resolved.pathname);
1331
+ if (!knownHrefs.has(targetHref)) {
1332
+ errors.push(`${page.sourcePath}: broken docs link '${href}' → '${targetHref}'`);
1333
+ continue;
1334
+ }
1335
+
1336
+ const hash = resolved.hash.startsWith('#') ? resolved.hash.slice(1) : resolved.hash;
1337
+ if (!hash) {
1338
+ continue;
1339
+ }
1340
+
1341
+ const targetHeadings = headingsByHref.get(targetHref);
1342
+ if (!targetHeadings) {
1343
+ continue;
1344
+ }
1345
+
1346
+ if (!targetHeadings.has(hash)) {
1347
+ errors.push(
1348
+ `${page.sourcePath}: broken anchor '${href}' (missing '#${hash}' on ${targetHref})`,
1349
+ );
1350
+ }
1272
1351
  }
1352
+ }
1273
1353
 
1274
- if (errors.length === 0) {
1275
- return;
1276
- }
1354
+ if (errors.length === 0) {
1355
+ return;
1356
+ }
1277
1357
 
1278
- const preview = errors.slice(0, 12).join('\n');
1279
- const suffix = errors.length > 12 ? `\n… and ${errors.length - 12} more.` : '';
1280
- throw new Error(`Markdown content contains broken internal links/anchors:\n${preview}${suffix}`);
1358
+ const preview = errors.slice(0, 12).join('\n');
1359
+ const suffix = errors.length > 12 ? `\n… and ${errors.length - 12} more.` : '';
1360
+ throw new Error(`Markdown content contains broken internal links/anchors:\n${preview}${suffix}`);
1281
1361
  }
1282
1362
 
1283
1363
  function resolveHref(baseHref: string, href: string): URL | null {
1284
- try {
1285
- const base = baseHref.endsWith('/') ? baseHref : `${baseHref}/`;
1286
- return new URL(href, `http://webstir.local${base}`);
1287
- } catch {
1288
- return null;
1289
- }
1364
+ try {
1365
+ const base = baseHref.endsWith('/') ? baseHref : `${baseHref}/`;
1366
+ return new URL(href, `http://webstir.local${base}`);
1367
+ } catch {
1368
+ return null;
1369
+ }
1290
1370
  }
1291
1371
 
1292
1372
  function normalizeDocsHref(pathname: string): string {
1293
- if (pathname === '/docs' || pathname === '/docs/' || pathname === '/docs/index.html') {
1294
- return '/docs/';
1295
- }
1373
+ if (pathname === '/docs' || pathname === '/docs/' || pathname === '/docs/index.html') {
1374
+ return '/docs/';
1375
+ }
1296
1376
 
1297
- if (pathname.endsWith('/index.html')) {
1298
- return pathname.slice(0, -'index.html'.length);
1299
- }
1377
+ if (pathname.endsWith('/index.html')) {
1378
+ return pathname.slice(0, -'index.html'.length);
1379
+ }
1300
1380
 
1301
- if (pathname.startsWith('/docs') && !pathname.endsWith('/')) {
1302
- return `${pathname}/`;
1303
- }
1381
+ if (pathname.startsWith('/docs') && !pathname.endsWith('/')) {
1382
+ return `${pathname}/`;
1383
+ }
1304
1384
 
1305
- return pathname;
1385
+ return pathname;
1306
1386
  }
1307
1387
 
1308
- function injectGlobalOptInScripts(
1309
- html: string,
1310
- enable: BuilderContext['enable']
1311
- ): string {
1312
- if (!enable) {
1313
- return html;
1314
- }
1388
+ function injectGlobalOptInScripts(html: string, enable: BuilderContext['enable']): string {
1389
+ if (!enable) {
1315
1390
  return html;
1391
+ }
1392
+ return html;
1316
1393
  }
1317
1394
 
1318
1395
  async function rewriteContentForPublish(
1319
- html: string,
1320
- shared: { css?: string; js?: string } | null,
1321
- docsManifest: { js?: string; css?: string },
1322
- options: {
1323
- readonly pagesUrlPrefix: string;
1324
- readonly buildPagesUrlPrefix: string;
1325
- }
1396
+ html: string,
1397
+ shared: { css?: string; js?: string } | null,
1398
+ docsManifest: { js?: string; css?: string },
1399
+ options: {
1400
+ readonly pagesUrlPrefix: string;
1401
+ readonly buildPagesUrlPrefix: string;
1402
+ },
1326
1403
  ): Promise<string> {
1327
- const document = load(html);
1328
- const { pagesUrlPrefix, buildPagesUrlPrefix } = options;
1329
-
1330
- document('script[src="/hmr.js"]').remove();
1331
- document('script[src="/refresh.js"]').remove();
1332
-
1333
- if (shared?.css) {
1334
- document(`link[href="/app/app.css"]`).attr('href', `/app/${shared.css}`);
1335
- }
1336
- if (shared?.js) {
1337
- document(`script[src="/app/app.js"]`)
1338
- .attr('src', `/app/${shared.js}`)
1339
- .attr('type', 'module');
1340
- }
1341
-
1342
- if (docsManifest.css) {
1343
- const selector = [
1344
- `link[href="${resolvePageAssetUrl(pagesUrlPrefix, 'docs', `${FILES.index}${EXTENSIONS.css}`)}"]`,
1345
- `link[href="${resolvePageAssetUrl(buildPagesUrlPrefix, 'docs', `${FILES.index}${EXTENSIONS.css}`)}"]`
1346
- ].join(', ');
1347
- document(selector).attr('href', resolvePageAssetUrl(pagesUrlPrefix, 'docs', docsManifest.css));
1348
- }
1349
-
1350
- if (docsManifest.js) {
1351
- const selector = [
1352
- `script[src="${resolvePageAssetUrl(pagesUrlPrefix, 'docs', `${FILES.index}${EXTENSIONS.js}`)}"]`,
1353
- `script[src="${resolvePageAssetUrl(buildPagesUrlPrefix, 'docs', `${FILES.index}${EXTENSIONS.js}`)}"]`
1354
- ].join(', ');
1355
- document(selector)
1356
- .attr('src', resolvePageAssetUrl(pagesUrlPrefix, 'docs', docsManifest.js))
1357
- .attr('type', 'module');
1358
- }
1359
-
1360
- if (document('[data-scope="docs"]').length > 0) {
1361
- ensureDocsShellCriticalCss(document);
1362
- }
1363
-
1364
- return document.root().html() ?? html;
1404
+ const document = load(html);
1405
+ const { pagesUrlPrefix, buildPagesUrlPrefix } = options;
1406
+
1407
+ document('script[src="/hmr.js"]').remove();
1408
+ document('script[src="/refresh.js"]').remove();
1409
+
1410
+ if (shared?.css) {
1411
+ document(`link[href="/app/app.css"]`).attr('href', `/app/${shared.css}`);
1412
+ }
1413
+ if (shared?.js) {
1414
+ document(`script[src="/app/app.js"]`).attr('src', `/app/${shared.js}`).attr('type', 'module');
1415
+ }
1416
+
1417
+ if (docsManifest.css) {
1418
+ const selector = [
1419
+ `link[href="${resolvePageAssetUrl(pagesUrlPrefix, 'docs', `${FILES.index}${EXTENSIONS.css}`)}"]`,
1420
+ `link[href="${resolvePageAssetUrl(buildPagesUrlPrefix, 'docs', `${FILES.index}${EXTENSIONS.css}`)}"]`,
1421
+ ].join(', ');
1422
+ document(selector).attr('href', resolvePageAssetUrl(pagesUrlPrefix, 'docs', docsManifest.css));
1423
+ }
1424
+
1425
+ if (docsManifest.js) {
1426
+ const selector = [
1427
+ `script[src="${resolvePageAssetUrl(pagesUrlPrefix, 'docs', `${FILES.index}${EXTENSIONS.js}`)}"]`,
1428
+ `script[src="${resolvePageAssetUrl(buildPagesUrlPrefix, 'docs', `${FILES.index}${EXTENSIONS.js}`)}"]`,
1429
+ ].join(', ');
1430
+ document(selector)
1431
+ .attr('src', resolvePageAssetUrl(pagesUrlPrefix, 'docs', docsManifest.js))
1432
+ .attr('type', 'module');
1433
+ }
1434
+
1435
+ if (document('[data-scope="docs"]').length > 0) {
1436
+ ensureDocsShellCriticalCss(document);
1437
+ }
1438
+
1439
+ return document.root().html() ?? html;
1365
1440
  }
1366
1441
 
1367
1442
  function rewriteMarkdownLinks(html: string): string {
1368
- const document = load(html);
1369
- document('a[href]').each((_, element) => {
1370
- const anchor = document(element);
1371
- const href = anchor.attr('href');
1372
- if (!href) return;
1443
+ const document = load(html);
1444
+ document('a[href]').each((_, element) => {
1445
+ const anchor = document(element);
1446
+ const href = anchor.attr('href');
1447
+ if (!href) return;
1373
1448
 
1374
- // Only rewrite local .md links (no protocol, no leading //)
1375
- if (/^[a-z]+:\/\//i.test(href) || href.startsWith('//')) {
1376
- return;
1377
- }
1449
+ // Only rewrite local .md links (no protocol, no leading //)
1450
+ if (/^[a-z]+:\/\//i.test(href) || href.startsWith('//')) {
1451
+ return;
1452
+ }
1378
1453
 
1379
- const mdMatch = href.match(/^(.*?)(\.md)(#.*)?$/i);
1380
- if (!mdMatch) return;
1454
+ const mdMatch = href.match(/^(.*?)(\.md)(#.*)?$/i);
1455
+ if (!mdMatch) return;
1381
1456
 
1382
- const base = mdMatch[1];
1383
- const hash = mdMatch[3] ?? '';
1384
- const normalizedBase = base.endsWith('/') ? base : `${base}/`;
1457
+ const base = mdMatch[1];
1458
+ const hash = mdMatch[3] ?? '';
1459
+ const normalizedBase = base.endsWith('/') ? base : `${base}/`;
1385
1460
 
1386
- // Preserve relative path segments; remove the .md extension and ensure trailing slash
1387
- anchor.attr('href', `${normalizedBase}${hash}`);
1388
- });
1461
+ // Preserve relative path segments; remove the .md extension and ensure trailing slash
1462
+ anchor.attr('href', `${normalizedBase}${hash}`);
1463
+ });
1389
1464
 
1390
- return document.root().html() ?? html;
1465
+ return document.root().html() ?? html;
1391
1466
  }
1392
1467
 
1393
1468
  function escapeHtml(value: string): string {
1394
- return value
1395
- .replace(/&/g, '&amp;')
1396
- .replace(/</g, '&lt;')
1397
- .replace(/>/g, '&gt;')
1398
- .replace(/"/g, '&quot;')
1399
- .replace(/'/g, '&#39;');
1469
+ return value
1470
+ .replace(/&/g, '&amp;')
1471
+ .replace(/</g, '&lt;')
1472
+ .replace(/>/g, '&gt;')
1473
+ .replace(/"/g, '&quot;')
1474
+ .replace(/'/g, '&#39;');
1400
1475
  }