@webstir-io/webstir-frontend 0.1.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +158 -0
- package/dist/assets/assetManifest.d.ts +16 -0
- package/dist/assets/assetManifest.js +31 -0
- package/dist/assets/imageOptimizer.d.ts +6 -0
- package/dist/assets/imageOptimizer.js +93 -0
- package/dist/assets/precompression.d.ts +1 -0
- package/dist/assets/precompression.js +21 -0
- package/dist/builders/contentBuilder.d.ts +2 -0
- package/dist/builders/contentBuilder.js +1052 -0
- package/dist/builders/cssBuilder.d.ts +2 -0
- package/dist/builders/cssBuilder.js +439 -0
- package/dist/builders/htmlBuilder.d.ts +2 -0
- package/dist/builders/htmlBuilder.js +430 -0
- package/dist/builders/index.d.ts +2 -0
- package/dist/builders/index.js +14 -0
- package/dist/builders/jsBuilder.d.ts +2 -0
- package/dist/builders/jsBuilder.js +300 -0
- package/dist/builders/staticAssetsBuilder.d.ts +2 -0
- package/dist/builders/staticAssetsBuilder.js +158 -0
- package/dist/builders/types.d.ts +12 -0
- package/dist/builders/types.js +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +105 -0
- package/dist/config/manifest.d.ts +7 -0
- package/dist/config/manifest.js +17 -0
- package/dist/config/paths.d.ts +3 -0
- package/dist/config/paths.js +11 -0
- package/dist/config/schema.d.ts +413 -0
- package/dist/config/schema.js +44 -0
- package/dist/config/setup.d.ts +2 -0
- package/dist/config/setup.js +12 -0
- package/dist/config/workspace.d.ts +2 -0
- package/dist/config/workspace.js +131 -0
- package/dist/config/workspaceManifest.d.ts +23 -0
- package/dist/config/workspaceManifest.js +1 -0
- package/dist/core/constants.d.ts +70 -0
- package/dist/core/constants.js +70 -0
- package/dist/core/diagnostics.d.ts +15 -0
- package/dist/core/diagnostics.js +21 -0
- package/dist/core/index.d.ts +3 -0
- package/dist/core/index.js +3 -0
- package/dist/core/pages.d.ts +6 -0
- package/dist/core/pages.js +23 -0
- package/dist/hooks.d.ts +19 -0
- package/dist/hooks.js +115 -0
- package/dist/html/criticalCss.d.ts +4 -0
- package/dist/html/criticalCss.js +192 -0
- package/dist/html/htmlSecurity.d.ts +5 -0
- package/dist/html/htmlSecurity.js +73 -0
- package/dist/html/lazyLoad.d.ts +6 -0
- package/dist/html/lazyLoad.js +21 -0
- package/dist/html/pageScaffold.d.ts +10 -0
- package/dist/html/pageScaffold.js +51 -0
- package/dist/html/resourceHints.d.ts +7 -0
- package/dist/html/resourceHints.js +64 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/modes/ssg/index.d.ts +4 -0
- package/dist/modes/ssg/index.js +4 -0
- package/dist/modes/ssg/metadata.d.ts +5 -0
- package/dist/modes/ssg/metadata.js +50 -0
- package/dist/modes/ssg/routing.d.ts +2 -0
- package/dist/modes/ssg/routing.js +186 -0
- package/dist/modes/ssg/seo.d.ts +4 -0
- package/dist/modes/ssg/seo.js +208 -0
- package/dist/modes/ssg/validation.d.ts +3 -0
- package/dist/modes/ssg/validation.js +27 -0
- package/dist/modes/ssg/views.d.ts +2 -0
- package/dist/modes/ssg/views.js +236 -0
- package/dist/operations.d.ts +5 -0
- package/dist/operations.js +102 -0
- package/dist/pipeline.d.ts +7 -0
- package/dist/pipeline.js +71 -0
- package/dist/provider.d.ts +2 -0
- package/dist/provider.js +176 -0
- package/dist/types.d.ts +61 -0
- package/dist/types.js +1 -0
- package/dist/utils/changedFile.d.ts +8 -0
- package/dist/utils/changedFile.js +26 -0
- package/dist/utils/fs.d.ts +11 -0
- package/dist/utils/fs.js +39 -0
- package/dist/utils/hash.d.ts +1 -0
- package/dist/utils/hash.js +5 -0
- package/dist/utils/pagePaths.d.ts +5 -0
- package/dist/utils/pagePaths.js +36 -0
- package/dist/utils/pathMatch.d.ts +3 -0
- package/dist/utils/pathMatch.js +29 -0
- package/dist/watch/frontendFiles.d.ts +3 -0
- package/dist/watch/frontendFiles.js +25 -0
- package/dist/watch/hotUpdateTracker.d.ts +51 -0
- package/dist/watch/hotUpdateTracker.js +205 -0
- package/dist/watch/pipelineHelpers.d.ts +26 -0
- package/dist/watch/pipelineHelpers.js +177 -0
- package/dist/watch/types.d.ts +27 -0
- package/dist/watch/types.js +1 -0
- package/dist/watch/watchCoordinator.d.ts +36 -0
- package/dist/watch/watchCoordinator.js +551 -0
- package/dist/watch/watchDaemon.d.ts +17 -0
- package/dist/watch/watchDaemon.js +127 -0
- package/dist/watch/watchReporter.d.ts +21 -0
- package/dist/watch/watchReporter.js +64 -0
- package/package.json +92 -0
- package/scripts/publish.sh +101 -0
- package/scripts/smoke.mjs +35 -0
- package/scripts/update-contract.sh +121 -0
- package/src/assets/assetManifest.ts +51 -0
- package/src/assets/imageOptimizer.ts +112 -0
- package/src/assets/precompression.ts +25 -0
- package/src/builders/contentBuilder.ts +1400 -0
- package/src/builders/cssBuilder.ts +552 -0
- package/src/builders/htmlBuilder.ts +540 -0
- package/src/builders/index.ts +16 -0
- package/src/builders/jsBuilder.ts +358 -0
- package/src/builders/staticAssetsBuilder.ts +174 -0
- package/src/builders/types.ts +15 -0
- package/src/cli.ts +108 -0
- package/src/config/manifest.ts +24 -0
- package/src/config/paths.ts +14 -0
- package/src/config/schema.ts +49 -0
- package/src/config/setup.ts +14 -0
- package/src/config/workspace.ts +150 -0
- package/src/config/workspaceManifest.ts +27 -0
- package/src/core/constants.ts +73 -0
- package/src/core/diagnostics.ts +40 -0
- package/src/core/index.ts +3 -0
- package/src/core/pages.ts +31 -0
- package/src/hooks.ts +175 -0
- package/src/html/criticalCss.ts +214 -0
- package/src/html/htmlSecurity.ts +86 -0
- package/src/html/lazyLoad.ts +30 -0
- package/src/html/pageScaffold.ts +70 -0
- package/src/html/resourceHints.ts +91 -0
- package/src/index.ts +5 -0
- package/src/modes/ssg/index.ts +4 -0
- package/src/modes/ssg/metadata.ts +63 -0
- package/src/modes/ssg/routing.ts +230 -0
- package/src/modes/ssg/seo.ts +261 -0
- package/src/modes/ssg/validation.ts +37 -0
- package/src/modes/ssg/views.ts +309 -0
- package/src/operations.ts +138 -0
- package/src/pipeline.ts +88 -0
- package/src/provider.ts +249 -0
- package/src/types.ts +67 -0
- package/src/utils/changedFile.ts +39 -0
- package/src/utils/fs.ts +48 -0
- package/src/utils/hash.ts +6 -0
- package/src/utils/pagePaths.ts +43 -0
- package/src/utils/pathMatch.ts +36 -0
- package/src/watch/frontendFiles.ts +32 -0
- package/src/watch/hotUpdateTracker.ts +285 -0
- package/src/watch/pipelineHelpers.ts +242 -0
- package/src/watch/types.ts +23 -0
- package/src/watch/watchCoordinator.ts +666 -0
- package/src/watch/watchDaemon.ts +144 -0
- package/src/watch/watchReporter.ts +98 -0
- package/tests/add-page-defaults.test.js +64 -0
- package/tests/content-pages.test.js +81 -0
- package/tests/css-app-imports.test.js +64 -0
- package/tests/css-page-imports.test.js +100 -0
- package/tests/diagnostics.test.js +48 -0
- package/tests/features.test.js +63 -0
- package/tests/hooks.test.js +71 -0
- package/tests/provider.integration.test.js +137 -0
- package/tests/ssg-defaults.test.js +201 -0
- package/tests/ssg-guardrails.test.js +69 -0
- package/tsconfig.json +27 -0
|
@@ -0,0 +1,1052 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { glob } from 'glob';
|
|
3
|
+
import { marked } from 'marked';
|
|
4
|
+
import { load } from 'cheerio';
|
|
5
|
+
import hljs from 'highlight.js/lib/common';
|
|
6
|
+
import { FOLDERS, FILES, FILE_NAMES, EXTENSIONS } from '../core/constants.js';
|
|
7
|
+
import { ensureDir, pathExists, readFile, readJson, remove, writeFile } from '../utils/fs.js';
|
|
8
|
+
import { shouldProcess } from '../utils/changedFile.js';
|
|
9
|
+
import { getPageDirectories } from '../core/pages.js';
|
|
10
|
+
import { readPageManifest, readSharedAssets } from '../assets/assetManifest.js';
|
|
11
|
+
import { resolvePageAssetUrl, resolvePagesUrlPrefix } from '../utils/pagePaths.js';
|
|
12
|
+
import { ensureDocsShellCriticalCss } from '../html/criticalCss.js';
|
|
13
|
+
export function createContentBuilder(context) {
|
|
14
|
+
return {
|
|
15
|
+
name: 'content',
|
|
16
|
+
async build() {
|
|
17
|
+
await buildContentPages(context);
|
|
18
|
+
await buildContentManifests(context);
|
|
19
|
+
},
|
|
20
|
+
async publish() {
|
|
21
|
+
await publishContentPages(context);
|
|
22
|
+
await publishContentManifests(context);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
async function buildContentPages(context) {
|
|
27
|
+
const { config } = context;
|
|
28
|
+
const contentRoot = config.paths.src.content;
|
|
29
|
+
if (!(await pathExists(contentRoot))) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (!shouldProcess(context, [{ directory: contentRoot, extensions: ['.md'] }])) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const files = await glob('**/*.md', {
|
|
36
|
+
cwd: contentRoot,
|
|
37
|
+
nodir: true
|
|
38
|
+
});
|
|
39
|
+
if (files.length === 0) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const appTemplatePath = path.join(config.paths.src.app, FILE_NAMES.htmlAppTemplate);
|
|
43
|
+
if (!(await pathExists(appTemplatePath))) {
|
|
44
|
+
throw new Error(`Base application HTML file not found for content pages: ${appTemplatePath}`);
|
|
45
|
+
}
|
|
46
|
+
const templateHtml = await readFile(appTemplatePath);
|
|
47
|
+
validateAppTemplate(templateHtml, appTemplatePath);
|
|
48
|
+
const buildPagesUrlPrefix = resolvePagesUrlPrefix(config.paths.build.frontend, config.paths.build.pages);
|
|
49
|
+
const navEntries = context.enable?.contentNav === true
|
|
50
|
+
? await collectContentManifests(context)
|
|
51
|
+
: [];
|
|
52
|
+
await removeStaleContentOutputs(context, files, buildPagesUrlPrefix);
|
|
53
|
+
for (const relative of files) {
|
|
54
|
+
const sourcePath = path.join(contentRoot, relative);
|
|
55
|
+
const markdown = await readFile(sourcePath);
|
|
56
|
+
const { frontmatter, content } = extractFrontmatter(markdown);
|
|
57
|
+
const htmlBody = (await renderMarkdownDoc(content)).html;
|
|
58
|
+
const segments = resolveDocsSegments(relative);
|
|
59
|
+
const pagePath = path.join(...segments);
|
|
60
|
+
const href = '/' + segments.join('/') + '/';
|
|
61
|
+
const pageTitle = resolveTitle(frontmatter, content, segments);
|
|
62
|
+
const mergedHtml = mergeContentIntoTemplate(templateHtml, pageTitle, htmlBody, frontmatter.description, context.enable?.contentNav === true, buildPagesUrlPrefix, navEntries, href);
|
|
63
|
+
const mergedWithOptIn = injectGlobalOptInScripts(mergedHtml, context.enable);
|
|
64
|
+
// Write to build (folder index)
|
|
65
|
+
const targetDir = path.join(config.paths.build.pages, pagePath);
|
|
66
|
+
await ensureDir(targetDir);
|
|
67
|
+
const targetPath = path.join(targetDir, FILES.indexHtml);
|
|
68
|
+
await writeFile(targetPath, mergedWithOptIn);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function publishContentPages(context) {
|
|
72
|
+
const { config } = context;
|
|
73
|
+
const contentRoot = config.paths.src.content;
|
|
74
|
+
if (!(await pathExists(contentRoot))) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const files = await glob('**/*.md', {
|
|
78
|
+
cwd: contentRoot,
|
|
79
|
+
nodir: true
|
|
80
|
+
});
|
|
81
|
+
if (files.length === 0) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const appTemplatePath = path.join(config.paths.src.app, FILE_NAMES.htmlAppTemplate);
|
|
85
|
+
if (!(await pathExists(appTemplatePath))) {
|
|
86
|
+
throw new Error(`Base application HTML file not found for content pages: ${appTemplatePath}`);
|
|
87
|
+
}
|
|
88
|
+
const templateHtml = await readFile(appTemplatePath);
|
|
89
|
+
validateAppTemplate(templateHtml, appTemplatePath);
|
|
90
|
+
const pagesUrlPrefix = resolvePagesUrlPrefix(config.paths.dist.frontend, config.paths.dist.pages);
|
|
91
|
+
const buildPagesUrlPrefix = resolvePagesUrlPrefix(config.paths.build.frontend, config.paths.build.pages);
|
|
92
|
+
await removeStaleContentOutputsForRoot(config.paths.dist.content, files, pagesUrlPrefix);
|
|
93
|
+
const shared = await readSharedAssets(config.paths.dist.frontend);
|
|
94
|
+
const navEntries = context.enable?.contentNav === true
|
|
95
|
+
? await collectContentManifests(context)
|
|
96
|
+
: [];
|
|
97
|
+
const docsManifestRoot = path.join(config.paths.dist.pages, 'docs');
|
|
98
|
+
const docsManifest = await readPageManifest(docsManifestRoot, 'docs');
|
|
99
|
+
if (!docsManifest.css || !docsManifest.js) {
|
|
100
|
+
throw new Error("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.");
|
|
101
|
+
}
|
|
102
|
+
const renderedPages = [];
|
|
103
|
+
for (const relative of files) {
|
|
104
|
+
const sourcePath = path.join(contentRoot, relative);
|
|
105
|
+
const markdown = await readFile(sourcePath);
|
|
106
|
+
const { frontmatter, content } = extractFrontmatter(markdown);
|
|
107
|
+
const segments = resolveDocsSegments(relative);
|
|
108
|
+
const pagePath = path.join(...segments);
|
|
109
|
+
const href = '/' + segments.join('/') + '/';
|
|
110
|
+
const pageTitle = resolveTitle(frontmatter, content, segments);
|
|
111
|
+
const rendered = await renderMarkdownDoc(content);
|
|
112
|
+
const htmlBody = rendered.html;
|
|
113
|
+
const mergedHtml = mergeContentIntoTemplate(templateHtml, pageTitle, htmlBody, frontmatter.description, context.enable?.contentNav === true, pagesUrlPrefix, navEntries, href);
|
|
114
|
+
const mergedWithOptIn = injectGlobalOptInScripts(mergedHtml, context.enable);
|
|
115
|
+
const rewritten = await rewriteContentForPublish(mergedWithOptIn, shared, docsManifest, {
|
|
116
|
+
pagesUrlPrefix,
|
|
117
|
+
buildPagesUrlPrefix
|
|
118
|
+
});
|
|
119
|
+
const distDir = path.join(config.paths.dist.pages, pagePath);
|
|
120
|
+
const distPath = path.join(distDir, FILES.indexHtml);
|
|
121
|
+
renderedPages.push({
|
|
122
|
+
href,
|
|
123
|
+
outputDir: distDir,
|
|
124
|
+
outputPath: distPath,
|
|
125
|
+
html: rewritten,
|
|
126
|
+
headingIds: rendered.headingIds,
|
|
127
|
+
sourcePath
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
validateRenderedContentPages(renderedPages);
|
|
131
|
+
for (const page of renderedPages) {
|
|
132
|
+
await ensureDir(page.outputDir);
|
|
133
|
+
await writeFile(page.outputPath, page.html);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
async function removeStaleContentOutputs(context, contentFiles, pagesUrlPrefix) {
|
|
137
|
+
await removeStaleContentOutputsForRoot(context.config.paths.build.content, contentFiles, pagesUrlPrefix);
|
|
138
|
+
}
|
|
139
|
+
async function removeStaleContentOutputsForRoot(docsRoot, contentFiles, pagesUrlPrefix) {
|
|
140
|
+
if (!(await pathExists(docsRoot))) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const expected = new Set();
|
|
144
|
+
for (const relative of contentFiles) {
|
|
145
|
+
const segments = resolveDocsSegments(relative);
|
|
146
|
+
expected.add(path.join(...segments.slice(1)));
|
|
147
|
+
}
|
|
148
|
+
const candidateIndexes = await glob('**/index.html', {
|
|
149
|
+
cwd: docsRoot,
|
|
150
|
+
nodir: true
|
|
151
|
+
});
|
|
152
|
+
const docsPrefix = resolvePageAssetUrl(pagesUrlPrefix, 'docs', '');
|
|
153
|
+
const docsAssetToken = docsPrefix.endsWith('/') ? docsPrefix : `${docsPrefix}/`;
|
|
154
|
+
for (const relativeIndex of candidateIndexes) {
|
|
155
|
+
// Keep the docs hub at `/docs/` (index.html).
|
|
156
|
+
if (relativeIndex === FILES.indexHtml) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const pageDir = path.dirname(relativeIndex);
|
|
160
|
+
if (!pageDir || pageDir === '.' || expected.has(pageDir)) {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const absoluteIndex = path.join(docsRoot, relativeIndex);
|
|
164
|
+
const html = await readFile(absoluteIndex);
|
|
165
|
+
// Only remove pages that were generated by the content pipeline (avoid deleting user-owned pages under /docs).
|
|
166
|
+
const looksLikeContentOutput = html.includes('class="docs-article"')
|
|
167
|
+
&& html.includes(docsAssetToken);
|
|
168
|
+
if (!looksLikeContentOutput) {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
await remove(path.join(docsRoot, pageDir));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
async function buildContentManifests(context) {
|
|
175
|
+
const { config } = context;
|
|
176
|
+
const contentRoot = config.paths.src.content;
|
|
177
|
+
if (!(await pathExists(contentRoot))) {
|
|
178
|
+
// Still allow search.json to be created from regular pages.
|
|
179
|
+
if (context.enable?.search === true) {
|
|
180
|
+
const pageEntries = await collectPageSearchEntries(context);
|
|
181
|
+
if (pageEntries.length > 0) {
|
|
182
|
+
await writeSearchManifest([config.paths.build.frontend], pageEntries);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (!shouldProcess(context, [
|
|
188
|
+
{ directory: contentRoot, extensions: ['.md'] },
|
|
189
|
+
// `webstir enable search` updates package.json and should emit the index immediately.
|
|
190
|
+
{ directory: config.paths.workspace, extensions: ['.json'] }
|
|
191
|
+
])) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const navEntries = await collectContentManifests(context);
|
|
195
|
+
if (navEntries.length === 0) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
await writeContentNavManifest([config.paths.build.frontend], navEntries);
|
|
199
|
+
if (context.enable?.search === true) {
|
|
200
|
+
const [docEntries, pageEntries] = await Promise.all([
|
|
201
|
+
collectContentSearchEntries(context),
|
|
202
|
+
collectPageSearchEntries(context)
|
|
203
|
+
]);
|
|
204
|
+
const searchEntries = [...docEntries, ...pageEntries];
|
|
205
|
+
if (searchEntries.length > 0) {
|
|
206
|
+
await writeSearchManifest([config.paths.build.frontend], searchEntries);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
async function publishContentManifests(context) {
|
|
211
|
+
const { config } = context;
|
|
212
|
+
const contentRoot = config.paths.src.content;
|
|
213
|
+
const hasContent = await pathExists(contentRoot);
|
|
214
|
+
const navEntries = hasContent ? await collectContentManifests(context) : [];
|
|
215
|
+
if (navEntries.length > 0) {
|
|
216
|
+
await writeContentNavManifest([config.paths.dist.frontend], navEntries);
|
|
217
|
+
}
|
|
218
|
+
if (context.enable?.search === true) {
|
|
219
|
+
const [docEntries, pageEntries] = await Promise.all([
|
|
220
|
+
hasContent ? collectContentSearchEntries(context) : Promise.resolve([]),
|
|
221
|
+
collectPageSearchEntries(context)
|
|
222
|
+
]);
|
|
223
|
+
const searchEntries = [...docEntries, ...pageEntries];
|
|
224
|
+
if (searchEntries.length > 0) {
|
|
225
|
+
await writeSearchManifest([config.paths.dist.frontend], searchEntries);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
async function collectContentManifests(context) {
|
|
230
|
+
const { config } = context;
|
|
231
|
+
const contentRoot = config.paths.src.content;
|
|
232
|
+
const overrides = await loadSidebarOverrides(contentRoot);
|
|
233
|
+
const files = await glob('**/*.md', {
|
|
234
|
+
cwd: contentRoot,
|
|
235
|
+
nodir: true
|
|
236
|
+
});
|
|
237
|
+
if (files.length === 0) {
|
|
238
|
+
return [];
|
|
239
|
+
}
|
|
240
|
+
const navEntries = [];
|
|
241
|
+
for (const relative of files) {
|
|
242
|
+
const sourcePath = path.join(contentRoot, relative);
|
|
243
|
+
const markdown = await readFile(sourcePath);
|
|
244
|
+
const { frontmatter, content } = extractFrontmatter(markdown);
|
|
245
|
+
const segments = resolveDocsSegments(relative);
|
|
246
|
+
const parsed = path.parse(relative);
|
|
247
|
+
const section = parsed.dir && parsed.dir.trim().length > 0
|
|
248
|
+
? parsed.dir.split(path.sep)[0]
|
|
249
|
+
: undefined;
|
|
250
|
+
const href = '/' + segments.join('/') + '/';
|
|
251
|
+
const title = resolveTitle(frontmatter, content, segments);
|
|
252
|
+
const order = frontmatter.order;
|
|
253
|
+
const baseEntry = {
|
|
254
|
+
path: href,
|
|
255
|
+
title,
|
|
256
|
+
section,
|
|
257
|
+
order
|
|
258
|
+
};
|
|
259
|
+
const merged = applySidebarOverride(baseEntry, overrides);
|
|
260
|
+
if (merged) {
|
|
261
|
+
navEntries.push(merged);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
navEntries.sort((a, b) => {
|
|
265
|
+
const aSection = a.section ?? '';
|
|
266
|
+
const bSection = b.section ?? '';
|
|
267
|
+
if (aSection !== bSection) {
|
|
268
|
+
return aSection.localeCompare(bSection);
|
|
269
|
+
}
|
|
270
|
+
const aOrder = typeof a.order === 'number' ? a.order : 0;
|
|
271
|
+
const bOrder = typeof b.order === 'number' ? b.order : 0;
|
|
272
|
+
if (aOrder !== bOrder) {
|
|
273
|
+
return aOrder - bOrder;
|
|
274
|
+
}
|
|
275
|
+
return a.path.localeCompare(b.path);
|
|
276
|
+
});
|
|
277
|
+
return navEntries;
|
|
278
|
+
}
|
|
279
|
+
async function writeContentNavManifest(outputRoots, navEntries) {
|
|
280
|
+
for (const outputRoot of outputRoots) {
|
|
281
|
+
const navOutputPath = path.join(outputRoot, 'docs-nav.json');
|
|
282
|
+
await ensureDir(path.dirname(navOutputPath));
|
|
283
|
+
await writeFile(navOutputPath, JSON.stringify(navEntries, undefined, 2));
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
async function collectContentSearchEntries(context) {
|
|
287
|
+
const { config } = context;
|
|
288
|
+
const contentRoot = config.paths.src.content;
|
|
289
|
+
const overrides = await loadSidebarOverrides(contentRoot);
|
|
290
|
+
const files = await glob('**/*.md', {
|
|
291
|
+
cwd: contentRoot,
|
|
292
|
+
nodir: true
|
|
293
|
+
});
|
|
294
|
+
if (files.length === 0) {
|
|
295
|
+
return [];
|
|
296
|
+
}
|
|
297
|
+
const entries = [];
|
|
298
|
+
for (const relative of files) {
|
|
299
|
+
const sourcePath = path.join(contentRoot, relative);
|
|
300
|
+
const markdown = await readFile(sourcePath);
|
|
301
|
+
const { frontmatter, content } = extractFrontmatter(markdown);
|
|
302
|
+
const segments = resolveDocsSegments(relative);
|
|
303
|
+
const href = '/' + segments.join('/') + '/';
|
|
304
|
+
const rawTitle = resolveTitle(frontmatter, content, segments);
|
|
305
|
+
const title = applySidebarTitleOverride(href, rawTitle, overrides);
|
|
306
|
+
if (!title) {
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
const html = (await renderMarkdownDoc(content)).html;
|
|
310
|
+
const document = load(html);
|
|
311
|
+
const headings = document('h2, h3')
|
|
312
|
+
.toArray()
|
|
313
|
+
.map((element) => document(element).text().trim())
|
|
314
|
+
.filter((text) => text.length > 0);
|
|
315
|
+
const plainText = document.text().replace(/\s+/g, ' ').trim();
|
|
316
|
+
const excerpt = plainText.length > 240 ? `${plainText.slice(0, 240).trim()}…` : plainText;
|
|
317
|
+
entries.push({
|
|
318
|
+
path: href,
|
|
319
|
+
title,
|
|
320
|
+
description: frontmatter.description?.trim() ? frontmatter.description.trim() : undefined,
|
|
321
|
+
headings,
|
|
322
|
+
excerpt,
|
|
323
|
+
kind: 'docs'
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
entries.sort((a, b) => a.path.localeCompare(b.path));
|
|
327
|
+
return entries;
|
|
328
|
+
}
|
|
329
|
+
async function loadSidebarOverrides(contentRoot) {
|
|
330
|
+
const overridesPath = path.join(contentRoot, '_sidebar.json');
|
|
331
|
+
if (!(await pathExists(overridesPath))) {
|
|
332
|
+
return new Map();
|
|
333
|
+
}
|
|
334
|
+
const parsed = await readJson(overridesPath);
|
|
335
|
+
const map = new Map();
|
|
336
|
+
if (!parsed) {
|
|
337
|
+
return map;
|
|
338
|
+
}
|
|
339
|
+
const pages = Array.isArray(parsed)
|
|
340
|
+
? parsed
|
|
341
|
+
: Array.isArray(parsed.pages)
|
|
342
|
+
? parsed.pages
|
|
343
|
+
: null;
|
|
344
|
+
if (pages) {
|
|
345
|
+
for (let index = 0; index < pages.length; index += 1) {
|
|
346
|
+
const entry = pages[index];
|
|
347
|
+
if (!entry || typeof entry !== 'object') {
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
const normalized = normalizeDocsOverrideHref(entry.path);
|
|
351
|
+
if (!normalized) {
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
const defaultOrder = typeof entry.order === 'number'
|
|
355
|
+
? entry.order
|
|
356
|
+
: index + 1;
|
|
357
|
+
map.set(normalized, {
|
|
358
|
+
...entry,
|
|
359
|
+
path: normalized,
|
|
360
|
+
order: defaultOrder
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
return map;
|
|
364
|
+
}
|
|
365
|
+
if (typeof parsed === 'object') {
|
|
366
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
367
|
+
if (!value || typeof value !== 'object') {
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
const rawPath = typeof value.path === 'string' ? String(value.path) : key;
|
|
371
|
+
const normalized = normalizeDocsOverrideHref(rawPath);
|
|
372
|
+
if (!normalized) {
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
const title = typeof value.title === 'string' ? String(value.title) : undefined;
|
|
376
|
+
const section = typeof value.section === 'string' ? String(value.section) : undefined;
|
|
377
|
+
const hidden = typeof value.hidden === 'boolean' ? Boolean(value.hidden) : undefined;
|
|
378
|
+
const orderValue = value.order;
|
|
379
|
+
const order = typeof orderValue === 'number' && Number.isFinite(orderValue) ? orderValue : undefined;
|
|
380
|
+
map.set(normalized, { path: normalized, title, section, hidden, order });
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return map;
|
|
384
|
+
}
|
|
385
|
+
function applySidebarOverride(entry, overrides) {
|
|
386
|
+
const key = normalizeDocsOverrideHref(entry.path);
|
|
387
|
+
const override = key ? overrides.get(key) : undefined;
|
|
388
|
+
if (!override) {
|
|
389
|
+
return entry;
|
|
390
|
+
}
|
|
391
|
+
if (override.hidden === true) {
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
const title = typeof override.title === 'string' && override.title.trim().length > 0 ? override.title.trim() : entry.title;
|
|
395
|
+
const section = typeof override.section === 'string' && override.section.trim().length > 0 ? override.section.trim() : entry.section;
|
|
396
|
+
const order = typeof override.order === 'number' && Number.isFinite(override.order) ? override.order : entry.order;
|
|
397
|
+
return {
|
|
398
|
+
path: entry.path,
|
|
399
|
+
title,
|
|
400
|
+
section,
|
|
401
|
+
order
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
function applySidebarTitleOverride(href, fallbackTitle, overrides) {
|
|
405
|
+
const key = normalizeDocsOverrideHref(href);
|
|
406
|
+
const override = key ? overrides.get(key) : undefined;
|
|
407
|
+
if (!override) {
|
|
408
|
+
return fallbackTitle;
|
|
409
|
+
}
|
|
410
|
+
if (override.hidden === true) {
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
const title = typeof override.title === 'string' && override.title.trim().length > 0 ? override.title.trim() : fallbackTitle;
|
|
414
|
+
return title;
|
|
415
|
+
}
|
|
416
|
+
function normalizeDocsOverrideHref(value) {
|
|
417
|
+
const trimmed = String(value ?? '').trim();
|
|
418
|
+
if (!trimmed) {
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
|
422
|
+
if (!withSlash.startsWith('/docs/')) {
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
if (withSlash.endsWith('/')) {
|
|
426
|
+
return withSlash;
|
|
427
|
+
}
|
|
428
|
+
return `${withSlash}/`;
|
|
429
|
+
}
|
|
430
|
+
async function collectPageSearchEntries(context) {
|
|
431
|
+
const { config } = context;
|
|
432
|
+
const pages = await getPageDirectories(config.paths.src.pages);
|
|
433
|
+
if (pages.length === 0) {
|
|
434
|
+
return [];
|
|
435
|
+
}
|
|
436
|
+
const entries = [];
|
|
437
|
+
for (const page of pages) {
|
|
438
|
+
const sourceIndex = path.join(page.directory, FILES.indexHtml);
|
|
439
|
+
if (!(await pathExists(sourceIndex))) {
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
const html = await readFile(sourceIndex);
|
|
443
|
+
const document = load(html);
|
|
444
|
+
const titleFromTag = document('title').first().text().trim();
|
|
445
|
+
const titleFromH1 = document('h1').first().text().trim();
|
|
446
|
+
const title = titleFromTag || titleFromH1 || toTitleCase(page.name);
|
|
447
|
+
const description = document('meta[name="description"]').first().attr('content')?.trim()
|
|
448
|
+
|| undefined;
|
|
449
|
+
const headings = document('h2, h3')
|
|
450
|
+
.toArray()
|
|
451
|
+
.map((element) => document(element).text().trim())
|
|
452
|
+
.filter((text) => text.length > 0);
|
|
453
|
+
const mainText = (document('main').first().text() || document.text()).replace(/\s+/g, ' ').trim();
|
|
454
|
+
const excerpt = mainText.length > 240 ? `${mainText.slice(0, 240).trim()}…` : mainText;
|
|
455
|
+
entries.push({
|
|
456
|
+
path: resolvePageHref(page.name),
|
|
457
|
+
title,
|
|
458
|
+
description,
|
|
459
|
+
headings,
|
|
460
|
+
excerpt,
|
|
461
|
+
kind: 'page'
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
entries.sort((a, b) => a.path.localeCompare(b.path));
|
|
465
|
+
return entries;
|
|
466
|
+
}
|
|
467
|
+
function resolvePageHref(pageName) {
|
|
468
|
+
if (pageName === FOLDERS.home) {
|
|
469
|
+
return '/';
|
|
470
|
+
}
|
|
471
|
+
return `/${pageName}/`;
|
|
472
|
+
}
|
|
473
|
+
async function writeSearchManifest(outputRoots, entries) {
|
|
474
|
+
for (const outputRoot of outputRoots) {
|
|
475
|
+
const outputPath = path.join(outputRoot, 'search.json');
|
|
476
|
+
await ensureDir(path.dirname(outputPath));
|
|
477
|
+
await writeFile(outputPath, JSON.stringify(entries, undefined, 2));
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
function resolveDocsSegments(relative) {
|
|
481
|
+
const parsed = path.parse(relative);
|
|
482
|
+
const segments = ['docs'];
|
|
483
|
+
if (parsed.dir) {
|
|
484
|
+
segments.push(...parsed.dir.split(path.sep));
|
|
485
|
+
}
|
|
486
|
+
const isReadme = parsed.name.toLowerCase() === 'readme';
|
|
487
|
+
const isFolderIndex = parsed.name === 'index' || isReadme;
|
|
488
|
+
// Reserve `/docs/` for a potential docs landing page; root docs become `/docs/<name>/`.
|
|
489
|
+
if (!isFolderIndex || !parsed.dir) {
|
|
490
|
+
segments.push(parsed.name);
|
|
491
|
+
}
|
|
492
|
+
return segments;
|
|
493
|
+
}
|
|
494
|
+
function extractFrontmatter(markdown) {
|
|
495
|
+
const lines = markdown.split(/\r?\n/);
|
|
496
|
+
if (lines.length === 0 || lines[0].trim() !== '---') {
|
|
497
|
+
return { frontmatter: {}, content: markdown };
|
|
498
|
+
}
|
|
499
|
+
const frontmatterLines = [];
|
|
500
|
+
let closingIndex = -1;
|
|
501
|
+
for (let index = 1; index < lines.length; index += 1) {
|
|
502
|
+
const line = lines[index];
|
|
503
|
+
if (line.trim() === '---') {
|
|
504
|
+
closingIndex = index;
|
|
505
|
+
break;
|
|
506
|
+
}
|
|
507
|
+
frontmatterLines.push(line);
|
|
508
|
+
}
|
|
509
|
+
if (closingIndex === -1) {
|
|
510
|
+
return { frontmatter: {}, content: markdown };
|
|
511
|
+
}
|
|
512
|
+
const frontmatter = {};
|
|
513
|
+
for (const line of frontmatterLines) {
|
|
514
|
+
const match = line.match(/^([A-Za-z0-9_-]+)\s*:\s*(.+)$/);
|
|
515
|
+
if (!match) {
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
const key = match[1].trim();
|
|
519
|
+
const rawValue = match[2].trim();
|
|
520
|
+
if (key === 'title') {
|
|
521
|
+
frontmatter.title = rawValue;
|
|
522
|
+
}
|
|
523
|
+
else if (key === 'description') {
|
|
524
|
+
frontmatter.description = rawValue;
|
|
525
|
+
}
|
|
526
|
+
else if (key === 'order') {
|
|
527
|
+
const parsed = Number.parseInt(rawValue, 10);
|
|
528
|
+
if (!Number.isNaN(parsed)) {
|
|
529
|
+
frontmatter.order = parsed;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
const content = lines.slice(closingIndex + 1).join('\n');
|
|
534
|
+
return { frontmatter, content };
|
|
535
|
+
}
|
|
536
|
+
function resolveTitle(frontmatter, content, segments) {
|
|
537
|
+
if (frontmatter.title && frontmatter.title.trim()) {
|
|
538
|
+
return frontmatter.title.trim();
|
|
539
|
+
}
|
|
540
|
+
const headingMatch = content.match(/^#\s+(.+)$/m);
|
|
541
|
+
if (headingMatch) {
|
|
542
|
+
return headingMatch[1].trim();
|
|
543
|
+
}
|
|
544
|
+
const fallbackSegment = segments[segments.length - 1] ?? 'docs';
|
|
545
|
+
const normalized = fallbackSegment.replace(/[-_]/g, ' ');
|
|
546
|
+
return toTitleCase(normalized);
|
|
547
|
+
}
|
|
548
|
+
function toTitleCase(value) {
|
|
549
|
+
return value
|
|
550
|
+
.split(/\s+/)
|
|
551
|
+
.filter((part) => part.length > 0)
|
|
552
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
553
|
+
.join(' ');
|
|
554
|
+
}
|
|
555
|
+
function validateAppTemplate(html, filePath) {
|
|
556
|
+
const doc = load(html);
|
|
557
|
+
if (doc('main').length === 0) {
|
|
558
|
+
throw new Error(`Base template missing <main> container (${filePath}).`);
|
|
559
|
+
}
|
|
560
|
+
if (doc('head').length === 0) {
|
|
561
|
+
throw new Error(`Base template missing <head> section (${filePath}).`);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
function mergeContentIntoTemplate(appHtml, pageName, bodyHtml, description, enableContentNav, pagesUrlPrefix, navEntries, currentPath) {
|
|
565
|
+
const document = load(appHtml);
|
|
566
|
+
const main = document('main').first();
|
|
567
|
+
const head = document('head').first();
|
|
568
|
+
if (main.length === 0 || head.length === 0) {
|
|
569
|
+
throw new Error('Base application template for content pages must include <head> and <main> elements.');
|
|
570
|
+
}
|
|
571
|
+
if (description && description.trim()) {
|
|
572
|
+
const meta = head.find('meta[name="description"]').first();
|
|
573
|
+
if (meta.length > 0) {
|
|
574
|
+
meta.attr('content', description.trim());
|
|
575
|
+
}
|
|
576
|
+
else {
|
|
577
|
+
head.append(`<meta name="description" content="${escapeHtml(description.trim())}" />`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
const defaultDescription = head.find('meta[name="description"]').first().attr('content')?.trim() ?? '';
|
|
581
|
+
const effectiveDescription = (description ?? '').trim() || defaultDescription;
|
|
582
|
+
// Ensure content pages load the shared app styles.
|
|
583
|
+
const cssHref = `/${FOLDERS.app}/app.css`;
|
|
584
|
+
const existingStylesheet = head.find(`link[rel="stylesheet"][href="${cssHref}"]`).first().length > 0
|
|
585
|
+
|| head.find('link[rel="stylesheet"]').toArray().some((element) => {
|
|
586
|
+
const href = document(element).attr('href');
|
|
587
|
+
return typeof href === 'string' && href.includes('/app/app.css');
|
|
588
|
+
});
|
|
589
|
+
if (!existingStylesheet) {
|
|
590
|
+
head.append(`<link rel="stylesheet" href="${cssHref}" />`);
|
|
591
|
+
}
|
|
592
|
+
// Ensure docs pages load the docs layout styles.
|
|
593
|
+
const docsCssHref = resolvePageAssetUrl(pagesUrlPrefix, 'docs', `${FILES.index}${EXTENSIONS.css}`);
|
|
594
|
+
const existingDocsStylesheet = head.find(`link[rel="stylesheet"][href="${docsCssHref}"]`).first().length > 0
|
|
595
|
+
|| head.find('link[rel="stylesheet"]').toArray().some((element) => {
|
|
596
|
+
const href = document(element).attr('href');
|
|
597
|
+
return typeof href === 'string' && href.includes('/docs/index.css');
|
|
598
|
+
});
|
|
599
|
+
if (!existingDocsStylesheet) {
|
|
600
|
+
head.append(`<link rel="stylesheet" href="${docsCssHref}" />`);
|
|
601
|
+
}
|
|
602
|
+
// Best-effort: ensure the document has a sensible title for the content page.
|
|
603
|
+
const title = head.find('title').first();
|
|
604
|
+
if (title.length === 0) {
|
|
605
|
+
head.append(`<title>${escapeHtml(pageName)}</title>`);
|
|
606
|
+
}
|
|
607
|
+
else if (!title.text().trim()) {
|
|
608
|
+
title.text(pageName);
|
|
609
|
+
}
|
|
610
|
+
else {
|
|
611
|
+
const baseTitle = title.text().trim();
|
|
612
|
+
if (!baseTitle.includes(pageName)) {
|
|
613
|
+
title.text(`${pageName} – ${baseTitle}`);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
const effectiveTitle = head.find('title').first().text().trim() || pageName;
|
|
617
|
+
ensureMetaProperty(head, 'og:title', effectiveTitle);
|
|
618
|
+
if (effectiveDescription) {
|
|
619
|
+
ensureMetaProperty(head, 'og:description', effectiveDescription);
|
|
620
|
+
}
|
|
621
|
+
ensureMetaProperty(head, 'og:type', 'website');
|
|
622
|
+
ensureMetaName(head, 'twitter:card', 'summary');
|
|
623
|
+
ensureMetaName(head, 'twitter:title', effectiveTitle);
|
|
624
|
+
if (effectiveDescription) {
|
|
625
|
+
ensureMetaName(head, 'twitter:description', effectiveDescription);
|
|
626
|
+
}
|
|
627
|
+
const contentNav = enableContentNav && navEntries.length > 0
|
|
628
|
+
? buildContentNavHtml(navEntries, currentPath)
|
|
629
|
+
: { navHtml: '', breadcrumbHtml: '', ready: false };
|
|
630
|
+
const docsLayoutHtml = enableContentNav
|
|
631
|
+
? [
|
|
632
|
+
`<section class="docs-layout" data-scope="docs" data-content-nav="true" data-content-nav-ready="${contentNav.ready ? 'true' : 'false'}">`,
|
|
633
|
+
' <div class="ws-container docs-layout__inner">',
|
|
634
|
+
' <aside class="docs-sidebar" id="docs-sidebar" data-docs-sidebar>',
|
|
635
|
+
' <div class="docs-panel__header">',
|
|
636
|
+
' <a class="docs-panel__link" href="/docs/">Docs</a>',
|
|
637
|
+
' </div>',
|
|
638
|
+
` <nav class="docs-nav" data-docs-nav aria-label="Docs navigation">${contentNav.navHtml}</nav>`,
|
|
639
|
+
' </aside>',
|
|
640
|
+
' <div class="docs-main">',
|
|
641
|
+
' <div class="docs-toolbar" data-docs-toolbar>',
|
|
642
|
+
` <nav class="docs-breadcrumb" data-docs-breadcrumb aria-label="Breadcrumb">${contentNav.breadcrumbHtml}</nav>`,
|
|
643
|
+
' </div>',
|
|
644
|
+
' <div class="docs-main__content ws-flow">',
|
|
645
|
+
` <article class="docs-article ws-markdown" data-docs-article>${bodyHtml}</article>`,
|
|
646
|
+
' </div>',
|
|
647
|
+
' </div>',
|
|
648
|
+
' </div>',
|
|
649
|
+
'</section>'
|
|
650
|
+
].join('\n')
|
|
651
|
+
: [
|
|
652
|
+
'<section class="docs-layout" data-scope="docs">',
|
|
653
|
+
' <div class="ws-container docs-layout__inner">',
|
|
654
|
+
' <div class="docs-main ws-flow">',
|
|
655
|
+
` <article class="docs-article ws-markdown">${bodyHtml}</article>`,
|
|
656
|
+
' </div>',
|
|
657
|
+
' </div>',
|
|
658
|
+
'</section>'
|
|
659
|
+
].join('\n');
|
|
660
|
+
main.html(docsLayoutHtml);
|
|
661
|
+
return document.root().html() ?? '';
|
|
662
|
+
}
|
|
663
|
+
function buildContentNavHtml(navEntries, currentPath) {
|
|
664
|
+
if (navEntries.length === 0) {
|
|
665
|
+
return { navHtml: '', breadcrumbHtml: '', ready: false };
|
|
666
|
+
}
|
|
667
|
+
const normalizedPath = normalizeDocsPath(currentPath);
|
|
668
|
+
const tree = buildContentNavTree(navEntries);
|
|
669
|
+
const titleByPath = new Map(navEntries.map((entry) => [normalizeDocsPath(entry.path), entry.title]));
|
|
670
|
+
titleByPath.set('/docs/', titleByPath.get('/docs/') ?? 'Docs');
|
|
671
|
+
const navHtml = renderContentNavList(tree.children, normalizedPath);
|
|
672
|
+
const breadcrumbHtml = renderContentBreadcrumb(titleByPath, normalizedPath);
|
|
673
|
+
return { navHtml, breadcrumbHtml, ready: navHtml.length > 0 };
|
|
674
|
+
}
|
|
675
|
+
function normalizeDocsPath(pathname) {
|
|
676
|
+
if (!pathname.startsWith('/docs')) {
|
|
677
|
+
return pathname;
|
|
678
|
+
}
|
|
679
|
+
if (pathname === '/docs') {
|
|
680
|
+
return '/docs/';
|
|
681
|
+
}
|
|
682
|
+
return pathname.endsWith('/') ? pathname : `${pathname}/`;
|
|
683
|
+
}
|
|
684
|
+
function buildContentNavTree(entries) {
|
|
685
|
+
let position = 0;
|
|
686
|
+
const root = {
|
|
687
|
+
segment: 'docs',
|
|
688
|
+
path: '/docs/',
|
|
689
|
+
title: 'Docs',
|
|
690
|
+
children: [],
|
|
691
|
+
isPage: false,
|
|
692
|
+
position: position++
|
|
693
|
+
};
|
|
694
|
+
for (const entry of entries) {
|
|
695
|
+
const normalizedPath = normalizeDocsPath(entry.path);
|
|
696
|
+
const segments = normalizedPath.split('/').filter(Boolean);
|
|
697
|
+
if (segments.length === 0) {
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
let current = root;
|
|
701
|
+
for (let index = 1; index < segments.length; index += 1) {
|
|
702
|
+
const segment = segments[index];
|
|
703
|
+
const nodePath = `/${segments.slice(0, index + 1).join('/')}/`;
|
|
704
|
+
let child = current.children.find((node) => node.segment === segment);
|
|
705
|
+
if (!child) {
|
|
706
|
+
child = {
|
|
707
|
+
segment,
|
|
708
|
+
path: nodePath,
|
|
709
|
+
title: toTitleCase(segment.replace(/[-_]/g, ' ')),
|
|
710
|
+
children: [],
|
|
711
|
+
isPage: false,
|
|
712
|
+
position: position++
|
|
713
|
+
};
|
|
714
|
+
current.children.push(child);
|
|
715
|
+
}
|
|
716
|
+
current = child;
|
|
717
|
+
}
|
|
718
|
+
current.title = entry.title;
|
|
719
|
+
current.isPage = true;
|
|
720
|
+
}
|
|
721
|
+
return root;
|
|
722
|
+
}
|
|
723
|
+
function renderContentNavList(nodes, currentPath, depth = 0) {
|
|
724
|
+
const listClass = depth === 0 ? 'docs-nav__list' : 'docs-nav__list docs-nav__list--nested';
|
|
725
|
+
const sorted = [...nodes].sort((a, b) => a.position - b.position);
|
|
726
|
+
const items = sorted.map((node) => {
|
|
727
|
+
const isActive = node.path === currentPath;
|
|
728
|
+
const isBranch = !isActive && currentPath.startsWith(node.path);
|
|
729
|
+
const activeAttr = isActive
|
|
730
|
+
? ' data-active="true"'
|
|
731
|
+
: isBranch
|
|
732
|
+
? ' data-active-branch="true"'
|
|
733
|
+
: '';
|
|
734
|
+
const label = node.isPage
|
|
735
|
+
? `<a class="docs-nav__link" href="${node.path}"${isActive ? ' aria-current="page"' : ''}>${escapeHtml(node.title)}</a>`
|
|
736
|
+
: `<span class="docs-nav__label">${escapeHtml(node.title)}</span>`;
|
|
737
|
+
const nested = node.children.length > 0
|
|
738
|
+
? renderContentNavList(node.children, currentPath, depth + 1)
|
|
739
|
+
: '';
|
|
740
|
+
return `<li class="docs-nav__item"${activeAttr}>${label}${nested}</li>`;
|
|
741
|
+
});
|
|
742
|
+
return `<ol class="${listClass}">${items.join('')}</ol>`;
|
|
743
|
+
}
|
|
744
|
+
function renderContentBreadcrumb(titleByPath, currentPath) {
|
|
745
|
+
if (!currentPath.startsWith('/docs/')) {
|
|
746
|
+
return '';
|
|
747
|
+
}
|
|
748
|
+
const crumbs = [];
|
|
749
|
+
const rootTitle = titleByPath.get('/docs/') ?? 'Docs';
|
|
750
|
+
crumbs.push({ title: rootTitle, href: '/docs/' });
|
|
751
|
+
const segments = currentPath.replace(/^\/docs\/?/, '').split('/').filter(Boolean);
|
|
752
|
+
let href = '/docs/';
|
|
753
|
+
for (const segment of segments) {
|
|
754
|
+
href = `${href}${segment}/`;
|
|
755
|
+
const title = titleByPath.get(href) ?? toTitleCase(segment.replace(/[-_]/g, ' '));
|
|
756
|
+
crumbs.push({ title, href });
|
|
757
|
+
}
|
|
758
|
+
const items = crumbs.map((crumb, index) => {
|
|
759
|
+
if (index === crumbs.length - 1) {
|
|
760
|
+
return `<li class="docs-breadcrumb__item"><span aria-current="page">${escapeHtml(crumb.title)}</span></li>`;
|
|
761
|
+
}
|
|
762
|
+
return `<li class="docs-breadcrumb__item"><a class="docs-breadcrumb__link" href="${crumb.href}">${escapeHtml(crumb.title)}</a></li>`;
|
|
763
|
+
});
|
|
764
|
+
return `<ol class="docs-breadcrumb__list">${items.join('')}</ol>`;
|
|
765
|
+
}
|
|
766
|
+
function ensureMetaProperty(head, property, content) {
|
|
767
|
+
const escaped = escapeHtml(content);
|
|
768
|
+
const meta = head.find(`meta[property="${property}"]`).first();
|
|
769
|
+
if (meta.length > 0) {
|
|
770
|
+
meta.attr('content', escaped);
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
head.append(`<meta property="${property}" content="${escaped}" />`);
|
|
774
|
+
}
|
|
775
|
+
function ensureMetaName(head, name, content) {
|
|
776
|
+
const escaped = escapeHtml(content);
|
|
777
|
+
const meta = head.find(`meta[name="${name}"]`).first();
|
|
778
|
+
if (meta.length > 0) {
|
|
779
|
+
meta.attr('content', escaped);
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
head.append(`<meta name="${name}" content="${escaped}" />`);
|
|
783
|
+
}
|
|
784
|
+
async function renderMarkdownDoc(markdown) {
|
|
785
|
+
const renderer = getMarkdownRenderer();
|
|
786
|
+
const expanded = await expandAdmonitions(markdown, renderer);
|
|
787
|
+
const rawHtml = await marked.parse(expanded, { renderer: renderer });
|
|
788
|
+
const linked = rewriteMarkdownLinks(rawHtml);
|
|
789
|
+
const { html, headingIds } = ensureHeadingIds(linked);
|
|
790
|
+
return { html, headingIds };
|
|
791
|
+
}
|
|
792
|
+
function getMarkdownRenderer() {
|
|
793
|
+
const w = globalThis;
|
|
794
|
+
const key = '__webstirMarkedRendererV1';
|
|
795
|
+
const existing = w[key];
|
|
796
|
+
if (existing) {
|
|
797
|
+
return existing;
|
|
798
|
+
}
|
|
799
|
+
const renderer = new marked.Renderer();
|
|
800
|
+
// Marked v12 renderer signature is not stable in TS types; keep it permissive.
|
|
801
|
+
renderer.code = (code, infostring) => {
|
|
802
|
+
const rawLang = typeof infostring === 'string' ? infostring.trim().split(/\s+/)[0] : '';
|
|
803
|
+
const lang = rawLang ? rawLang.toLowerCase() : '';
|
|
804
|
+
try {
|
|
805
|
+
if (lang && hljs.getLanguage(lang)) {
|
|
806
|
+
const highlighted = hljs.highlight(code, { language: lang }).value;
|
|
807
|
+
return `<pre><code class="hljs language-${escapeHtml(lang)}">${highlighted}</code></pre>`;
|
|
808
|
+
}
|
|
809
|
+
const highlighted = hljs.highlightAuto(code).value;
|
|
810
|
+
return `<pre><code class="hljs">${highlighted}</code></pre>`;
|
|
811
|
+
}
|
|
812
|
+
catch {
|
|
813
|
+
return `<pre><code>${escapeHtml(code)}</code></pre>`;
|
|
814
|
+
}
|
|
815
|
+
};
|
|
816
|
+
w[key] = renderer;
|
|
817
|
+
return renderer;
|
|
818
|
+
}
|
|
819
|
+
const ADMONITION_TITLES = {
|
|
820
|
+
note: 'Note',
|
|
821
|
+
tip: 'Tip',
|
|
822
|
+
info: 'Info',
|
|
823
|
+
warning: 'Warning',
|
|
824
|
+
danger: 'Danger'
|
|
825
|
+
};
|
|
826
|
+
async function expandAdmonitions(markdown, renderer) {
|
|
827
|
+
const lines = markdown.split(/\r?\n/);
|
|
828
|
+
const out = [];
|
|
829
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
830
|
+
const line = lines[index] ?? '';
|
|
831
|
+
const match = line.match(/^:::\s*([A-Za-z]+)(?:\s+(.*))?\s*$/);
|
|
832
|
+
if (!match) {
|
|
833
|
+
out.push(line);
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
const kindRaw = match[1]?.toLowerCase() ?? '';
|
|
837
|
+
if (!isAdmonitionKind(kindRaw)) {
|
|
838
|
+
out.push(line);
|
|
839
|
+
continue;
|
|
840
|
+
}
|
|
841
|
+
const title = (match[2] ?? '').trim() || ADMONITION_TITLES[kindRaw];
|
|
842
|
+
const inner = [];
|
|
843
|
+
let closed = false;
|
|
844
|
+
for (index = index + 1; index < lines.length; index += 1) {
|
|
845
|
+
const innerLine = lines[index] ?? '';
|
|
846
|
+
if (innerLine.trim() === ':::') {
|
|
847
|
+
closed = true;
|
|
848
|
+
break;
|
|
849
|
+
}
|
|
850
|
+
inner.push(innerLine);
|
|
851
|
+
}
|
|
852
|
+
if (!closed) {
|
|
853
|
+
// Unterminated block; treat it as literal markdown.
|
|
854
|
+
out.push(line);
|
|
855
|
+
out.push(...inner);
|
|
856
|
+
break;
|
|
857
|
+
}
|
|
858
|
+
const bodyMarkdown = inner.join('\n').trim();
|
|
859
|
+
const bodyHtml = bodyMarkdown.length > 0 ? await marked.parse(bodyMarkdown, { renderer: renderer }) : '';
|
|
860
|
+
out.push([
|
|
861
|
+
`<aside class="docs-callout docs-callout--${kindRaw}">`,
|
|
862
|
+
` <div class="docs-callout__title">${escapeHtml(title)}</div>`,
|
|
863
|
+
` <div class="docs-callout__body">${bodyHtml}</div>`,
|
|
864
|
+
`</aside>`
|
|
865
|
+
].join('\n'));
|
|
866
|
+
}
|
|
867
|
+
return out.join('\n');
|
|
868
|
+
}
|
|
869
|
+
function isAdmonitionKind(value) {
|
|
870
|
+
return value === 'note' || value === 'tip' || value === 'info' || value === 'warning' || value === 'danger';
|
|
871
|
+
}
|
|
872
|
+
function ensureHeadingIds(html) {
|
|
873
|
+
const document = load(html);
|
|
874
|
+
const used = new Set();
|
|
875
|
+
const ids = new Set();
|
|
876
|
+
const headings = document('h1, h2, h3, h4').toArray();
|
|
877
|
+
for (const element of headings) {
|
|
878
|
+
const heading = document(element);
|
|
879
|
+
const existing = heading.attr('id')?.trim();
|
|
880
|
+
if (existing) {
|
|
881
|
+
used.add(existing);
|
|
882
|
+
ids.add(existing);
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
const text = heading.text().trim();
|
|
886
|
+
const base = slugifyHeading(text) || 'section';
|
|
887
|
+
let candidate = base;
|
|
888
|
+
let counter = 2;
|
|
889
|
+
while (used.has(candidate)) {
|
|
890
|
+
candidate = `${base}-${counter}`;
|
|
891
|
+
counter += 1;
|
|
892
|
+
}
|
|
893
|
+
used.add(candidate);
|
|
894
|
+
ids.add(candidate);
|
|
895
|
+
heading.attr('id', candidate);
|
|
896
|
+
}
|
|
897
|
+
return { html: document.root().html() ?? html, headingIds: ids };
|
|
898
|
+
}
|
|
899
|
+
function slugifyHeading(value) {
|
|
900
|
+
return value
|
|
901
|
+
.trim()
|
|
902
|
+
.toLowerCase()
|
|
903
|
+
.replace(/['"]/g, '')
|
|
904
|
+
.replace(/[^a-z0-9\s-]/g, '')
|
|
905
|
+
.replace(/\s+/g, '-')
|
|
906
|
+
.replace(/-+/g, '-');
|
|
907
|
+
}
|
|
908
|
+
function validateRenderedContentPages(pages) {
|
|
909
|
+
if (pages.length === 0) {
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
const headingsByHref = new Map(pages.map((page) => [page.href, page.headingIds]));
|
|
913
|
+
const knownHrefs = new Set(pages.map((page) => page.href));
|
|
914
|
+
knownHrefs.add('/docs/');
|
|
915
|
+
const errors = [];
|
|
916
|
+
for (const page of pages) {
|
|
917
|
+
const document = load(page.html);
|
|
918
|
+
const anchors = document('.docs-article a[href]').toArray();
|
|
919
|
+
for (const element of anchors) {
|
|
920
|
+
const href = document(element).attr('href') ?? '';
|
|
921
|
+
if (!href) {
|
|
922
|
+
continue;
|
|
923
|
+
}
|
|
924
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith('//')) {
|
|
925
|
+
continue;
|
|
926
|
+
}
|
|
927
|
+
const resolved = resolveHref(page.href, href);
|
|
928
|
+
if (!resolved) {
|
|
929
|
+
continue;
|
|
930
|
+
}
|
|
931
|
+
const isDocsPage = resolved.pathname === '/docs'
|
|
932
|
+
|| resolved.pathname === '/docs/'
|
|
933
|
+
|| resolved.pathname.startsWith('/docs/');
|
|
934
|
+
if (!isDocsPage) {
|
|
935
|
+
continue;
|
|
936
|
+
}
|
|
937
|
+
const targetHref = normalizeDocsHref(resolved.pathname);
|
|
938
|
+
if (!knownHrefs.has(targetHref)) {
|
|
939
|
+
errors.push(`${page.sourcePath}: broken docs link '${href}' → '${targetHref}'`);
|
|
940
|
+
continue;
|
|
941
|
+
}
|
|
942
|
+
const hash = resolved.hash.startsWith('#') ? resolved.hash.slice(1) : resolved.hash;
|
|
943
|
+
if (!hash) {
|
|
944
|
+
continue;
|
|
945
|
+
}
|
|
946
|
+
const targetHeadings = headingsByHref.get(targetHref);
|
|
947
|
+
if (!targetHeadings) {
|
|
948
|
+
continue;
|
|
949
|
+
}
|
|
950
|
+
if (!targetHeadings.has(hash)) {
|
|
951
|
+
errors.push(`${page.sourcePath}: broken anchor '${href}' (missing '#${hash}' on ${targetHref})`);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
if (errors.length === 0) {
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
const preview = errors.slice(0, 12).join('\n');
|
|
959
|
+
const suffix = errors.length > 12 ? `\n… and ${errors.length - 12} more.` : '';
|
|
960
|
+
throw new Error(`Markdown content contains broken internal links/anchors:\n${preview}${suffix}`);
|
|
961
|
+
}
|
|
962
|
+
function resolveHref(baseHref, href) {
|
|
963
|
+
try {
|
|
964
|
+
const base = baseHref.endsWith('/') ? baseHref : `${baseHref}/`;
|
|
965
|
+
return new URL(href, `http://webstir.local${base}`);
|
|
966
|
+
}
|
|
967
|
+
catch {
|
|
968
|
+
return null;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
function normalizeDocsHref(pathname) {
|
|
972
|
+
if (pathname === '/docs' || pathname === '/docs/' || pathname === '/docs/index.html') {
|
|
973
|
+
return '/docs/';
|
|
974
|
+
}
|
|
975
|
+
if (pathname.endsWith('/index.html')) {
|
|
976
|
+
return pathname.slice(0, -'index.html'.length);
|
|
977
|
+
}
|
|
978
|
+
if (pathname.startsWith('/docs') && !pathname.endsWith('/')) {
|
|
979
|
+
return `${pathname}/`;
|
|
980
|
+
}
|
|
981
|
+
return pathname;
|
|
982
|
+
}
|
|
983
|
+
function injectGlobalOptInScripts(html, enable) {
|
|
984
|
+
if (!enable) {
|
|
985
|
+
return html;
|
|
986
|
+
}
|
|
987
|
+
return html;
|
|
988
|
+
}
|
|
989
|
+
async function rewriteContentForPublish(html, shared, docsManifest, options) {
|
|
990
|
+
const document = load(html);
|
|
991
|
+
const { pagesUrlPrefix, buildPagesUrlPrefix } = options;
|
|
992
|
+
document('script[src="/hmr.js"]').remove();
|
|
993
|
+
document('script[src="/refresh.js"]').remove();
|
|
994
|
+
if (shared?.css) {
|
|
995
|
+
document(`link[href="/app/app.css"]`).attr('href', `/app/${shared.css}`);
|
|
996
|
+
}
|
|
997
|
+
if (shared?.js) {
|
|
998
|
+
document(`script[src="/app/app.js"]`)
|
|
999
|
+
.attr('src', `/app/${shared.js}`)
|
|
1000
|
+
.attr('type', 'module');
|
|
1001
|
+
}
|
|
1002
|
+
if (docsManifest.css) {
|
|
1003
|
+
const selector = [
|
|
1004
|
+
`link[href="${resolvePageAssetUrl(pagesUrlPrefix, 'docs', `${FILES.index}${EXTENSIONS.css}`)}"]`,
|
|
1005
|
+
`link[href="${resolvePageAssetUrl(buildPagesUrlPrefix, 'docs', `${FILES.index}${EXTENSIONS.css}`)}"]`
|
|
1006
|
+
].join(', ');
|
|
1007
|
+
document(selector).attr('href', resolvePageAssetUrl(pagesUrlPrefix, 'docs', docsManifest.css));
|
|
1008
|
+
}
|
|
1009
|
+
if (docsManifest.js) {
|
|
1010
|
+
const selector = [
|
|
1011
|
+
`script[src="${resolvePageAssetUrl(pagesUrlPrefix, 'docs', `${FILES.index}${EXTENSIONS.js}`)}"]`,
|
|
1012
|
+
`script[src="${resolvePageAssetUrl(buildPagesUrlPrefix, 'docs', `${FILES.index}${EXTENSIONS.js}`)}"]`
|
|
1013
|
+
].join(', ');
|
|
1014
|
+
document(selector)
|
|
1015
|
+
.attr('src', resolvePageAssetUrl(pagesUrlPrefix, 'docs', docsManifest.js))
|
|
1016
|
+
.attr('type', 'module');
|
|
1017
|
+
}
|
|
1018
|
+
if (document('[data-scope="docs"]').length > 0) {
|
|
1019
|
+
ensureDocsShellCriticalCss(document);
|
|
1020
|
+
}
|
|
1021
|
+
return document.root().html() ?? html;
|
|
1022
|
+
}
|
|
1023
|
+
function rewriteMarkdownLinks(html) {
|
|
1024
|
+
const document = load(html);
|
|
1025
|
+
document('a[href]').each((_, element) => {
|
|
1026
|
+
const anchor = document(element);
|
|
1027
|
+
const href = anchor.attr('href');
|
|
1028
|
+
if (!href)
|
|
1029
|
+
return;
|
|
1030
|
+
// Only rewrite local .md links (no protocol, no leading //)
|
|
1031
|
+
if (/^[a-z]+:\/\//i.test(href) || href.startsWith('//')) {
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
const mdMatch = href.match(/^(.*?)(\.md)(#.*)?$/i);
|
|
1035
|
+
if (!mdMatch)
|
|
1036
|
+
return;
|
|
1037
|
+
const base = mdMatch[1];
|
|
1038
|
+
const hash = mdMatch[3] ?? '';
|
|
1039
|
+
const normalizedBase = base.endsWith('/') ? base : `${base}/`;
|
|
1040
|
+
// Preserve relative path segments; remove the .md extension and ensure trailing slash
|
|
1041
|
+
anchor.attr('href', `${normalizedBase}${hash}`);
|
|
1042
|
+
});
|
|
1043
|
+
return document.root().html() ?? html;
|
|
1044
|
+
}
|
|
1045
|
+
function escapeHtml(value) {
|
|
1046
|
+
return value
|
|
1047
|
+
.replace(/&/g, '&')
|
|
1048
|
+
.replace(/</g, '<')
|
|
1049
|
+
.replace(/>/g, '>')
|
|
1050
|
+
.replace(/"/g, '"')
|
|
1051
|
+
.replace(/'/g, ''');
|
|
1052
|
+
}
|