@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.
- package/README.md +124 -60
- package/dist/assets/imageOptimizer.js +10 -15
- package/dist/assets/precompression.js +1 -1
- package/dist/builders/contentBuilder.js +102 -90
- package/dist/builders/cssBuilder.js +25 -19
- package/dist/builders/htmlBuilder.js +57 -42
- package/dist/builders/index.js +1 -1
- package/dist/builders/jsBuilder.js +219 -76
- package/dist/builders/staticAssetsBuilder.js +27 -9
- package/dist/builders/types.d.ts +1 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +6 -30
- package/dist/config/manifest.js +7 -6
- package/dist/config/paths.js +2 -2
- package/dist/config/schema.d.ts +8 -0
- package/dist/config/schema.js +7 -6
- package/dist/config/setup.js +1 -1
- package/dist/config/workspace.js +11 -9
- package/dist/core/constants.d.ts +1 -1
- package/dist/core/constants.js +5 -5
- package/dist/core/diagnostics.js +1 -1
- package/dist/core/pages.js +4 -4
- package/dist/hooks.js +3 -3
- package/dist/html/criticalCss.js +6 -3
- package/dist/html/htmlSecurity.d.ts +6 -1
- package/dist/html/htmlSecurity.js +28 -14
- package/dist/html/lazyLoad.js +1 -1
- package/dist/html/pageScaffold.js +1 -1
- package/dist/html/resourceHints.js +5 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/inspect.d.ts +2 -0
- package/dist/inspect.js +110 -0
- package/dist/modes/ssg/metadata.js +4 -4
- package/dist/modes/ssg/routing.js +2 -5
- package/dist/modes/ssg/seo.js +5 -5
- package/dist/modes/ssg/views.js +17 -11
- package/dist/operations.js +18 -10
- package/dist/pipeline.d.ts +1 -0
- package/dist/pipeline.js +6 -1
- package/dist/provider.js +28 -24
- package/dist/runtime/boundary.d.ts +28 -0
- package/dist/runtime/boundary.js +247 -0
- package/dist/runtime/index.d.ts +1 -0
- package/dist/runtime/index.js +1 -0
- package/dist/types.d.ts +52 -0
- package/dist/utils/fs.d.ts +11 -10
- package/dist/utils/fs.js +48 -20
- package/dist/utils/glob.d.ts +8 -0
- package/dist/utils/glob.js +21 -0
- package/dist/utils/hash.js +1 -2
- package/dist/utils/pagePaths.js +2 -2
- package/package.json +19 -14
- package/scripts/publish.sh +2 -94
- package/scripts/update-contract.sh +12 -10
- package/src/assets/assetManifest.ts +39 -29
- package/src/assets/imageOptimizer.ts +91 -82
- package/src/assets/precompression.ts +22 -16
- package/src/builders/contentBuilder.ts +1224 -1149
- package/src/builders/cssBuilder.ts +466 -417
- package/src/builders/htmlBuilder.ts +511 -448
- package/src/builders/index.ts +7 -7
- package/src/builders/jsBuilder.ts +538 -280
- package/src/builders/staticAssetsBuilder.ts +166 -135
- package/src/builders/types.ts +7 -6
- package/src/cli.ts +66 -90
- package/src/config/manifest.ts +16 -14
- package/src/config/paths.ts +5 -5
- package/src/config/schema.ts +38 -37
- package/src/config/setup.ts +7 -7
- package/src/config/workspace.ts +118 -116
- package/src/config/workspaceManifest.ts +14 -14
- package/src/core/constants.ts +62 -62
- package/src/core/diagnostics.ts +26 -26
- package/src/core/pages.ts +19 -19
- package/src/hooks.ts +128 -118
- package/src/html/criticalCss.ts +84 -77
- package/src/html/htmlSecurity.ts +107 -66
- package/src/html/lazyLoad.ts +22 -19
- package/src/html/pageScaffold.ts +37 -28
- package/src/html/resourceHints.ts +83 -74
- package/src/index.ts +2 -0
- package/src/inspect.ts +158 -0
- package/src/modes/ssg/metadata.ts +53 -51
- package/src/modes/ssg/routing.ts +177 -177
- package/src/modes/ssg/seo.ts +208 -200
- package/src/modes/ssg/validation.ts +31 -25
- package/src/modes/ssg/views.ts +257 -238
- package/src/operations.ts +105 -95
- package/src/pipeline.ts +81 -69
- package/src/provider.ts +184 -176
- package/src/runtime/boundary.ts +325 -0
- package/src/runtime/index.ts +1 -0
- package/src/types.ts +107 -48
- package/src/utils/changedFile.ts +22 -22
- package/src/utils/fs.ts +73 -26
- package/src/utils/glob.ts +38 -0
- package/src/utils/hash.ts +2 -4
- package/src/utils/pagePaths.ts +35 -23
- package/src/utils/pathMatch.ts +26 -23
- package/tests/add-page-defaults.test.js +44 -39
- package/tests/bundlerParity.test.js +252 -0
- package/tests/cli.contract.test.js +13 -0
- package/tests/content-pages.test.js +108 -13
- package/tests/css-app-imports.test.js +22 -11
- package/tests/css-page-imports.test.js +26 -13
- package/tests/diagnostics.test.js +39 -36
- package/tests/features.test.js +48 -43
- package/tests/hooks.test.js +58 -42
- package/tests/htmlSecurity.test.js +66 -0
- package/tests/inspect.test.js +148 -0
- package/tests/provider.integration.test.js +71 -20
- package/tests/runtime.test.js +493 -0
- package/tests/ssg-defaults.test.js +284 -177
- package/tests/ssg-guardrails.test.js +51 -51
- package/tsconfig.json +3 -10
- package/dist/watch/frontendFiles.d.ts +0 -3
- package/dist/watch/frontendFiles.js +0 -25
- package/dist/watch/hotUpdateTracker.d.ts +0 -51
- package/dist/watch/hotUpdateTracker.js +0 -205
- package/dist/watch/pipelineHelpers.d.ts +0 -26
- package/dist/watch/pipelineHelpers.js +0 -177
- package/dist/watch/types.d.ts +0 -27
- package/dist/watch/types.js +0 -1
- package/dist/watch/watchCoordinator.d.ts +0 -36
- package/dist/watch/watchCoordinator.js +0 -551
- package/dist/watch/watchDaemon.d.ts +0 -17
- package/dist/watch/watchDaemon.js +0 -127
- package/dist/watch/watchReporter.d.ts +0 -21
- package/dist/watch/watchReporter.js +0 -64
- package/scripts/smoke.mjs +0 -35
- package/src/watch/frontendFiles.ts +0 -32
- package/src/watch/hotUpdateTracker.ts +0 -285
- package/src/watch/pipelineHelpers.ts +0 -242
- package/src/watch/types.ts +0 -23
- package/src/watch/watchCoordinator.ts +0 -666
- package/src/watch/watchDaemon.ts +0 -144
- 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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
title?: string;
|
|
19
|
+
description?: string;
|
|
20
|
+
order?: number;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
interface DocsNavEntry {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
| { readonly pages?: readonly SidebarOverrideEntry[] }
|
|
40
|
+
| readonly SidebarOverrideEntry[]
|
|
41
|
+
| Record<string, Omit<SidebarOverrideEntry, 'path'> & { readonly path?: string }>;
|
|
42
42
|
|
|
43
43
|
interface SearchEntry {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
await
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
230
|
+
validateRenderedContentPages(renderedPages);
|
|
230
231
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
239
|
+
context: BuilderContext,
|
|
240
|
+
contentFiles: readonly string[],
|
|
241
|
+
pagesUrlPrefix: string,
|
|
241
242
|
): Promise<void> {
|
|
242
|
-
|
|
243
|
+
await removeStaleContentOutputsForRoot(
|
|
244
|
+
context.config.paths.build.content,
|
|
245
|
+
contentFiles,
|
|
246
|
+
pagesUrlPrefix,
|
|
247
|
+
);
|
|
243
248
|
}
|
|
244
249
|
|
|
245
250
|
async function removeStaleContentOutputsForRoot(
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
251
|
+
docsRoot: string,
|
|
252
|
+
contentFiles: readonly string[],
|
|
253
|
+
pagesUrlPrefix: string,
|
|
249
254
|
): Promise<void> {
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
255
|
+
if (!(await pathExists(docsRoot))) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
253
258
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
261
|
-
cwd: docsRoot,
|
|
262
|
-
nodir: true
|
|
263
|
-
});
|
|
265
|
+
const candidateIndexes = await scanGlob('**/index.html', { cwd: docsRoot });
|
|
264
266
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
+
const docsPrefix = resolvePageAssetUrl(pagesUrlPrefix, 'docs', '');
|
|
268
|
+
const docsAssetToken = docsPrefix.endsWith('/') ? docsPrefix : `${docsPrefix}/`;
|
|
267
269
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
309
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
337
|
-
|
|
341
|
+
const { config } = context;
|
|
342
|
+
const contentRoot = config.paths.src.content;
|
|
338
343
|
|
|
339
|
-
|
|
344
|
+
const hasContent = await pathExists(contentRoot);
|
|
340
345
|
|
|
341
|
-
|
|
346
|
+
const navEntries = hasContent ? await collectContentManifests(context) : [];
|
|
342
347
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
348
|
+
if (navEntries.length > 0) {
|
|
349
|
+
await writeContentNavManifest([config.paths.dist.frontend], navEntries);
|
|
350
|
+
}
|
|
346
351
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
365
|
+
const { config } = context;
|
|
366
|
+
const contentRoot = config.paths.src.content;
|
|
367
|
+
const overrides = await loadSidebarOverrides(contentRoot);
|
|
363
368
|
|
|
364
|
-
|
|
365
|
-
cwd: contentRoot,
|
|
366
|
-
nodir: true
|
|
367
|
-
});
|
|
369
|
+
const files = await scanGlob('**/*.md', { cwd: contentRoot });
|
|
368
370
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
371
|
+
if (files.length === 0) {
|
|
372
|
+
return [];
|
|
373
|
+
}
|
|
372
374
|
|
|
373
|
-
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
418
|
-
|
|
417
|
+
return a.path.localeCompare(b.path);
|
|
418
|
+
});
|
|
419
419
|
|
|
420
|
-
|
|
420
|
+
return navEntries;
|
|
421
421
|
}
|
|
422
422
|
|
|
423
423
|
async function writeContentNavManifest(
|
|
424
|
-
|
|
425
|
-
|
|
424
|
+
outputRoots: readonly string[],
|
|
425
|
+
navEntries: readonly DocsNavEntry[],
|
|
426
426
|
): Promise<void> {
|
|
427
|
-
|
|
428
|
-
|
|
427
|
+
for (const outputRoot of outputRoots) {
|
|
428
|
+
const navOutputPath = path.join(outputRoot, 'docs-nav.json');
|
|
429
429
|
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
436
|
+
const { config } = context;
|
|
437
|
+
const contentRoot = config.paths.src.content;
|
|
438
|
+
const overrides = await loadSidebarOverrides(contentRoot);
|
|
439
439
|
|
|
440
|
-
|
|
441
|
-
cwd: contentRoot,
|
|
442
|
-
nodir: true
|
|
443
|
-
});
|
|
440
|
+
const files = await scanGlob('**/*.md', { cwd: contentRoot });
|
|
444
441
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
442
|
+
if (files.length === 0) {
|
|
443
|
+
return [];
|
|
444
|
+
}
|
|
448
445
|
|
|
449
|
-
|
|
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
|
-
|
|
485
|
-
|
|
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
|
-
|
|
489
|
-
const
|
|
490
|
-
|
|
491
|
-
|
|
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
|
|
495
|
-
const
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
481
|
+
entries.sort((a, b) => a.path.localeCompare(b.path));
|
|
482
|
+
return entries;
|
|
483
|
+
}
|
|
500
484
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
|
|
570
|
-
|
|
571
|
-
|
|
576
|
+
return (
|
|
577
|
+
path.resolve(context.changedFile) === path.join(path.resolve(contentRoot), '_sidebar.json')
|
|
578
|
+
);
|
|
579
|
+
}
|
|
572
580
|
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
-
|
|
583
|
-
|
|
584
|
-
|
|
617
|
+
href: string,
|
|
618
|
+
fallbackTitle: string,
|
|
619
|
+
overrides: ReadonlyMap<string, SidebarOverrideEntry>,
|
|
585
620
|
): string | null {
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
639
|
+
const trimmed = String(value ?? '').trim();
|
|
640
|
+
if (!trimmed) {
|
|
641
|
+
return null;
|
|
642
|
+
}
|
|
605
643
|
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
644
|
+
const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
|
645
|
+
if (!withSlash.startsWith('/docs/')) {
|
|
646
|
+
return null;
|
|
647
|
+
}
|
|
610
648
|
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
649
|
+
if (withSlash.endsWith('/')) {
|
|
650
|
+
return withSlash;
|
|
651
|
+
}
|
|
614
652
|
|
|
615
|
-
|
|
653
|
+
return `${withSlash}/`;
|
|
616
654
|
}
|
|
617
655
|
|
|
618
656
|
async function collectPageSearchEntries(context: BuilderContext): Promise<SearchEntry[]> {
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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
|
-
|
|
663
|
-
|
|
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
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
706
|
+
if (pageName === FOLDERS.home) {
|
|
707
|
+
return '/';
|
|
708
|
+
}
|
|
709
|
+
return `/${pageName}/`;
|
|
671
710
|
}
|
|
672
711
|
|
|
673
|
-
async function writeSearchManifest(
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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
|
-
|
|
683
|
-
|
|
724
|
+
const parsed = path.parse(relative);
|
|
725
|
+
const segments: string[] = ['docs'];
|
|
684
726
|
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
727
|
+
if (parsed.dir) {
|
|
728
|
+
segments.push(...parsed.dir.split(path.sep));
|
|
729
|
+
}
|
|
688
730
|
|
|
689
|
-
|
|
690
|
-
|
|
731
|
+
const isReadme = parsed.name.toLowerCase() === 'readme';
|
|
732
|
+
const isFolderIndex = parsed.name === 'index' || isReadme;
|
|
691
733
|
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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
|
-
|
|
739
|
+
return segments;
|
|
698
740
|
}
|
|
699
741
|
|
|
700
|
-
function extractFrontmatter(markdown: string): {
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
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
|
-
|
|
707
|
-
|
|
751
|
+
const frontmatterLines: string[] = [];
|
|
752
|
+
let closingIndex = -1;
|
|
708
753
|
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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
|
-
|
|
719
|
-
|
|
720
|
-
|
|
763
|
+
if (closingIndex === -1) {
|
|
764
|
+
return { frontmatter: {}, content: markdown };
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const frontmatter: ContentFrontmatter = {};
|
|
721
768
|
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
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
|
|
746
|
-
|
|
747
|
-
}
|
|
775
|
+
const key = match[1].trim();
|
|
776
|
+
const rawValue = match[2].trim();
|
|
748
777
|
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
}
|
|
790
|
+
const content = lines.slice(closingIndex + 1).join('\n');
|
|
791
|
+
return { frontmatter, content };
|
|
792
|
+
}
|
|
758
793
|
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
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
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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
|
-
|
|
841
|
+
const document = load(appHtml);
|
|
793
842
|
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
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
|
-
|
|
836
|
-
const
|
|
837
|
-
if (
|
|
838
|
-
|
|
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
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
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
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
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
|
-
|
|
912
|
-
|
|
973
|
+
navEntries: readonly DocsNavEntry[],
|
|
974
|
+
currentPath: string,
|
|
913
975
|
): { navHtml: string; breadcrumbHtml: string; ready: boolean } {
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
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
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
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
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
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
|
-
|
|
982
|
-
|
|
1039
|
+
current.title = entry.title;
|
|
1040
|
+
current.isPage = true;
|
|
1041
|
+
}
|
|
983
1042
|
|
|
984
|
-
|
|
985
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1012
|
-
|
|
1076
|
+
titleByPath: ReadonlyMap<string, string>,
|
|
1077
|
+
currentPath: string,
|
|
1013
1078
|
): string {
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
}
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
return `<
|
|
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
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
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
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
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(
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
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():
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
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
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
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
|
-
|
|
1101
|
-
|
|
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
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
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:
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
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
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
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
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1354
|
+
if (errors.length === 0) {
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1277
1357
|
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
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
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
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
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1373
|
+
if (pathname === '/docs' || pathname === '/docs/' || pathname === '/docs/index.html') {
|
|
1374
|
+
return '/docs/';
|
|
1375
|
+
}
|
|
1296
1376
|
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1377
|
+
if (pathname.endsWith('/index.html')) {
|
|
1378
|
+
return pathname.slice(0, -'index.html'.length);
|
|
1379
|
+
}
|
|
1300
1380
|
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1381
|
+
if (pathname.startsWith('/docs') && !pathname.endsWith('/')) {
|
|
1382
|
+
return `${pathname}/`;
|
|
1383
|
+
}
|
|
1304
1384
|
|
|
1305
|
-
|
|
1385
|
+
return pathname;
|
|
1306
1386
|
}
|
|
1307
1387
|
|
|
1308
|
-
function injectGlobalOptInScripts(
|
|
1309
|
-
|
|
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
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
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
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
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
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
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
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
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
|
-
|
|
1380
|
-
|
|
1454
|
+
const mdMatch = href.match(/^(.*?)(\.md)(#.*)?$/i);
|
|
1455
|
+
if (!mdMatch) return;
|
|
1381
1456
|
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1457
|
+
const base = mdMatch[1];
|
|
1458
|
+
const hash = mdMatch[3] ?? '';
|
|
1459
|
+
const normalizedBase = base.endsWith('/') ? base : `${base}/`;
|
|
1385
1460
|
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1461
|
+
// Preserve relative path segments; remove the .md extension and ensure trailing slash
|
|
1462
|
+
anchor.attr('href', `${normalizedBase}${hash}`);
|
|
1463
|
+
});
|
|
1389
1464
|
|
|
1390
|
-
|
|
1465
|
+
return document.root().html() ?? html;
|
|
1391
1466
|
}
|
|
1392
1467
|
|
|
1393
1468
|
function escapeHtml(value: string): string {
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1469
|
+
return value
|
|
1470
|
+
.replace(/&/g, '&')
|
|
1471
|
+
.replace(/</g, '<')
|
|
1472
|
+
.replace(/>/g, '>')
|
|
1473
|
+
.replace(/"/g, '"')
|
|
1474
|
+
.replace(/'/g, ''');
|
|
1400
1475
|
}
|