@writedocs/generator 0.9.3 → 0.10.0
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/astro.config.mjs +7 -0
- package/package.json +1 -1
- package/src/cli/run-pagefind.js +72 -1
- package/src/cli/write-redirects-file.js +33 -0
- package/src/components/ApiPlayground.astro +47 -6
- package/src/components/ApiSchemaField.astro +5 -2
- package/src/components/Hint.astro +46 -5
- package/src/components/Tooltip.astro +1 -0
- package/src/layout/components/MobileMenu.astro +2 -2
- package/src/layout/components/TopBar.astro +1 -1
- package/src/lib/config-schema.js +10 -1
- package/src/lib/config-schema.ts +21 -6
- package/src/lib/config.ts +22 -7
- package/src/lib/content-check.js +14 -0
- package/src/lib/folder-redirects.js +47 -0
- package/src/lib/hidden-sections.js +58 -0
- package/src/lib/json-schema-descriptions.js +6 -0
- package/src/lib/link-check.js +7 -1
- package/src/lib/llms-index.ts +1 -1
- package/src/lib/mcp-index.ts +4 -9
- package/src/lib/openapi-markdown.ts +154 -0
- package/src/lib/selector-placement.js +4 -1
- package/src/pages/[...slug].astro +32 -5
- package/src/pages/[...slug].md.ts +3 -13
- package/src/pages/llms-full.txt.ts +3 -13
- package/src/scripts/search.ts +4 -1
- package/writedocs.schema.json +350 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Hidden navigation items (`"hidden": true` on a tab, product, version,
|
|
2
|
+
// language or dropdown): reachable only by their address.
|
|
3
|
+
//
|
|
4
|
+
// - Nothing lists one: its switcher, tab row or menu option is left out
|
|
5
|
+
// everywhere, and `/` never redirects into it (firstSlugAmong() in
|
|
6
|
+
// lib/config.ts skips it).
|
|
7
|
+
// - Inside one, the level it sits on isn't shown - that switcher would
|
|
8
|
+
// list the other items at its level. Everything below it works as
|
|
9
|
+
// usual. (buildSelectors() marks that level `hidden`; the placement in
|
|
10
|
+
// lib/selector-placement.js leaves it out.)
|
|
11
|
+
// - Its pages are a search space of their own: they search only among
|
|
12
|
+
// themselves, and the site's public search never finds them. With
|
|
13
|
+
// `"searchPublic": true` its search finds the public pages too. The
|
|
14
|
+
// build writes one index per space (cli/run-pagefind.js); a page names
|
|
15
|
+
// its space in the markup, and src/scripts/search.ts loads that index.
|
|
16
|
+
//
|
|
17
|
+
// Hidden isn't private: anyone with the address can read the pages. The
|
|
18
|
+
// sitemap, llms.txt and the MCP index still list them unless a page sets
|
|
19
|
+
// `noindex`.
|
|
20
|
+
|
|
21
|
+
/** Whether a navigation item is hidden. */
|
|
22
|
+
export function isHidden(item) {
|
|
23
|
+
return Boolean(item && typeof item === 'object' && item.hidden === true);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const NAME_FIELD = { tab: 'tab', version: 'version', language: 'language', dropdown: 'dropdown', product: 'product' };
|
|
27
|
+
|
|
28
|
+
function slug(text) {
|
|
29
|
+
return String(text ?? '')
|
|
30
|
+
.normalize('NFKD')
|
|
31
|
+
.replace(/[̀-ͯ]/g, '')
|
|
32
|
+
.toLowerCase()
|
|
33
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
34
|
+
.replace(/^-+|-+$/g, '');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A path's id, from the names of its levels - "product-admin",
|
|
38
|
+
* "tab-api--version-v2". Readable in the index folder's name, and the
|
|
39
|
+
* same on every build. */
|
|
40
|
+
export function spaceIdOf(path) {
|
|
41
|
+
return path
|
|
42
|
+
.map((segment) => {
|
|
43
|
+
const item = segment.items[segment.index] ?? {};
|
|
44
|
+
return `${segment.kind}-${slug(item[NAME_FIELD[segment.kind]]) || segment.index}`;
|
|
45
|
+
})
|
|
46
|
+
.join('--');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The search space a section belongs to: the innermost hidden item on its
|
|
50
|
+
* path - `{ id, searchPublic }` - or null for the public pages. */
|
|
51
|
+
export function spaceOf(path = []) {
|
|
52
|
+
let space = null;
|
|
53
|
+
path.forEach((segment, i) => {
|
|
54
|
+
const item = segment.items[segment.index];
|
|
55
|
+
if (isHidden(item)) space = { id: spaceIdOf(path.slice(0, i + 1)), searchPublic: item.searchPublic === true };
|
|
56
|
+
});
|
|
57
|
+
return space;
|
|
58
|
+
}
|
|
@@ -43,9 +43,15 @@ const CONTAINERS = {
|
|
|
43
43
|
},
|
|
44
44
|
};
|
|
45
45
|
|
|
46
|
+
const HIDDEN = {
|
|
47
|
+
hidden: 'Reachable only by its address: no tab, switcher or menu lists it, and inside it this level isn\'t shown. Its pages search only among themselves.',
|
|
48
|
+
searchPublic: 'For a hidden item: its search also finds the site\'s public pages.',
|
|
49
|
+
};
|
|
50
|
+
|
|
46
51
|
const containerEntries = Object.entries(CONTAINERS).flatMap(([type, fields]) => [
|
|
47
52
|
...Object.entries(fields).map(([field, text]) => [`${type}.${field}`, text]),
|
|
48
53
|
...Object.entries(CHILDREN).map(([field, text]) => [`${type}.${field}`, text]),
|
|
54
|
+
...Object.entries(HIDDEN).map(([field, text]) => [`${type}.${field}`, text]),
|
|
49
55
|
]);
|
|
50
56
|
|
|
51
57
|
const logo = (where) => ({
|
package/src/lib/link-check.js
CHANGED
|
@@ -35,6 +35,7 @@ import { visit } from 'unist-util-visit';
|
|
|
35
35
|
import { findAllPages } from './pages.js';
|
|
36
36
|
import { createJsonLocator, contextMenuOptions } from './config-schema.js';
|
|
37
37
|
import { writedocsTempDir } from './writedocs-temp-dir.js';
|
|
38
|
+
import { folderAddresses } from './folder-redirects.js';
|
|
38
39
|
|
|
39
40
|
// The same github-slugger Astro builds page URLs and heading ids with -
|
|
40
41
|
// resolved through Astro itself, so the two can't drift apart.
|
|
@@ -295,6 +296,11 @@ export async function checkLinks(contentDir, configText) {
|
|
|
295
296
|
const special = new Set(['/llms.txt', '/llms-full.txt', '/404.html', '/404/']);
|
|
296
297
|
if (config.domain) ['/sitemap.xml', '/sitemap-index.xml'].forEach((p) => special.add(p));
|
|
297
298
|
const knownUrls = [...pages.keys(), ...generated, ...redirects];
|
|
299
|
+
// Folder addresses with no page of their own redirect to one under them
|
|
300
|
+
// (lib/folder-redirects.js), so a link to one isn't broken.
|
|
301
|
+
const folders = new Set(
|
|
302
|
+
[...folderAddresses([...pages.keys(), ...generated].map((url) => url.replace(/^\/+|\/+$/g, '')))].map((folder) => `/${folder}/`)
|
|
303
|
+
);
|
|
298
304
|
|
|
299
305
|
// Anchors a page can be linked to: its own, plus those of every snippet
|
|
300
306
|
// it imports (they render inside it).
|
|
@@ -398,7 +404,7 @@ export async function checkLinks(contentDir, configText) {
|
|
|
398
404
|
return;
|
|
399
405
|
}
|
|
400
406
|
// "/" always exists: the home page, or a redirect to the first page.
|
|
401
|
-
if (pageKey === '/' || generated.has(pageKey) || redirects.has(pageKey) || special.has(pageKey.replace(/\/$/, '') || '/')) return;
|
|
407
|
+
if (pageKey === '/' || generated.has(pageKey) || redirects.has(pageKey) || folders.has(pageKey) || special.has(pageKey.replace(/\/$/, '') || '/')) return;
|
|
402
408
|
if (special.has(pathname)) return;
|
|
403
409
|
|
|
404
410
|
// Mintlify-style relative link: resolved from the file's folder
|
package/src/lib/llms-index.ts
CHANGED
|
@@ -62,7 +62,7 @@ export async function llmsIndexFiles(): Promise<Map<string, string> | null> {
|
|
|
62
62
|
// The .md route when it exists ([...slug].md.ts - unless `contextMenu` is off,
|
|
63
63
|
// and never for an OpenAPI page, which renders from the spec), else the
|
|
64
64
|
// HTML page.
|
|
65
|
-
const hasMarkdownRoute = contextMenuOptions(config.contextMenu).length > 0
|
|
65
|
+
const hasMarkdownRoute = contextMenuOptions(config.contextMenu).length > 0;
|
|
66
66
|
const href = (siteUrl ?? '') + (hasMarkdownRoute ? `/${slug}.md` : slug === 'index' ? '/' : `/${slug}/`);
|
|
67
67
|
let description = truncateDescription(entry.data.description);
|
|
68
68
|
// Mirrors Mintlify: an OpenAPI operation page's description gets its
|
package/src/lib/mcp-index.ts
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
resolveSections,
|
|
17
17
|
flattenNav,
|
|
18
18
|
} from './config';
|
|
19
|
-
import {
|
|
19
|
+
import { pageMarkdown } from './openapi-markdown';
|
|
20
20
|
import { orderByNavigation } from './llms.js';
|
|
21
21
|
import { writedocsTempDir } from './writedocs-temp-dir.js';
|
|
22
22
|
|
|
@@ -72,14 +72,9 @@ export async function mcpIndex(): Promise<McpIndex> {
|
|
|
72
72
|
.map((entry) => {
|
|
73
73
|
const slug = normalizeEntryId(entry.id);
|
|
74
74
|
const fileId = fileIdForEntry(contentDir, packageRoot, entry);
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
variables: config.variables,
|
|
79
|
-
}).trim();
|
|
80
|
-
// An OpenAPI operation page renders from the spec, so its source body
|
|
81
|
-
// is near-empty - name the operation, at least.
|
|
82
|
-
if (entry.data.openapi) content = [`Operation: ${entry.data.openapi}`, content].filter(Boolean).join('\n\n');
|
|
75
|
+
// An API page's source is a near-empty stub - its operation, written
|
|
76
|
+
// out from the spec, follows its own text (lib/openapi-markdown.ts).
|
|
77
|
+
const content = pageMarkdown(entry, { contentDir, packageRoot, variables: config.variables });
|
|
83
78
|
return {
|
|
84
79
|
slug,
|
|
85
80
|
fileId,
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// OpenAPI operations as Markdown, both ways:
|
|
2
|
+
//
|
|
3
|
+
// - descriptionHtml(): a spec's `description` fields are Markdown (CommonMark,
|
|
4
|
+
// and in practice GitHub tables too) - rendered to HTML for the API page,
|
|
5
|
+
// instead of printed as literal text with its `**`, backticks and pipes.
|
|
6
|
+
// Astro's own Markdown renderer, so a description reads like any page.
|
|
7
|
+
//
|
|
8
|
+
// - operationMarkdown() / pageMarkdown(): an operation page written out as
|
|
9
|
+
// Markdown - method and path, description, parameters, body, responses,
|
|
10
|
+
// examples. An API page's own source is a near-empty stub (it renders from
|
|
11
|
+
// the spec), so this is what its .md copy, llms-full.txt and the MCP
|
|
12
|
+
// index serve for it, and what "Copy page" copies.
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { createMarkdownProcessor } from '@astrojs/markdown-remark';
|
|
16
|
+
import {
|
|
17
|
+
schemaRows,
|
|
18
|
+
exampleFromSchema,
|
|
19
|
+
primaryContentType,
|
|
20
|
+
findOperationFile,
|
|
21
|
+
type OpenApiOperation,
|
|
22
|
+
type SchemaRow,
|
|
23
|
+
} from './openapi-render';
|
|
24
|
+
import { parseOpenApiRef } from './openapi-ref.js';
|
|
25
|
+
import { markdownForAgents } from './agent-markdown.js';
|
|
26
|
+
|
|
27
|
+
let renderer: ReturnType<typeof createMarkdownProcessor> | null = null;
|
|
28
|
+
const htmlCache = new Map<string, Promise<string>>();
|
|
29
|
+
|
|
30
|
+
/** A spec description as HTML. Empty for no description. */
|
|
31
|
+
export function descriptionHtml(markdown: string | null | undefined): Promise<string> {
|
|
32
|
+
const text = (markdown ?? '').trim();
|
|
33
|
+
if (!text) return Promise.resolve('');
|
|
34
|
+
let html = htmlCache.get(text);
|
|
35
|
+
if (!html) {
|
|
36
|
+
renderer ??= createMarkdownProcessor({ syntaxHighlight: false });
|
|
37
|
+
html = renderer.then((r) => r.render(text)).then((result) => result.code.trim());
|
|
38
|
+
htmlCache.set(text, html);
|
|
39
|
+
}
|
|
40
|
+
return html;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The operation a page's `openapi` frontmatter names, or null. */
|
|
44
|
+
export function loadOperation(ref: string, contentDir: string): OpenApiOperation | null {
|
|
45
|
+
const parsed = parseOpenApiRef(ref);
|
|
46
|
+
if (!parsed) return null;
|
|
47
|
+
const file = findOperationFile(contentDir, parsed.method, parsed.path, parsed.spec);
|
|
48
|
+
return file ? (JSON.parse(fs.readFileSync(file, 'utf-8')) as OpenApiOperation) : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A description placed under a list item: every line indented to sit inside it. */
|
|
52
|
+
function indented(text: string, spaces: number): string {
|
|
53
|
+
const pad = ' '.repeat(spaces);
|
|
54
|
+
return text
|
|
55
|
+
.trim()
|
|
56
|
+
.split('\n')
|
|
57
|
+
.map((line) => (line.trim() ? pad + line : ''))
|
|
58
|
+
.join('\n');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function fieldList(rows: SchemaRow[], depth = 0): string {
|
|
62
|
+
const pad = ' '.repeat(depth);
|
|
63
|
+
return rows
|
|
64
|
+
.map((row) => {
|
|
65
|
+
const head = `${pad}- \`${row.name}\` (${row.type}${row.required ? ', required' : ''})`;
|
|
66
|
+
const desc = row.description ? `\n${indented(row.description, pad.length + 2)}` : '';
|
|
67
|
+
const children = row.children.length ? `\n${fieldList(row.children, depth + 1)}` : '';
|
|
68
|
+
return head + desc + children;
|
|
69
|
+
})
|
|
70
|
+
.join('\n');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function jsonBlock(value: unknown): string {
|
|
74
|
+
return `\`\`\`json\n${JSON.stringify(value, null, 2)}\n\`\`\``;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The operation, written out as Markdown (no title - the page has its own). */
|
|
78
|
+
export function operationMarkdown(op: OpenApiOperation): string {
|
|
79
|
+
const out: string[] = [];
|
|
80
|
+
out.push(`**${op.method.toUpperCase()}** \`${op.path}\``);
|
|
81
|
+
if (op.servers?.[0]?.url) out.push(`Base URL: \`${op.servers[0].url}\``);
|
|
82
|
+
if (op.description?.trim()) out.push(op.description.trim());
|
|
83
|
+
else if (op.summary?.trim()) out.push(op.summary.trim());
|
|
84
|
+
|
|
85
|
+
if (op.security?.length) {
|
|
86
|
+
const auth = op.security.map((s) => (s.scheme ? `${s.name} (${s.type}, ${s.scheme})` : s.in ? `${s.name} (${s.type} in ${s.in})` : `${s.name} (${s.type})`));
|
|
87
|
+
out.push(`## Authorization\n\n${auth.map((a) => `- ${a}`).join('\n')}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const [where, title] of [['header', 'Header parameters'], ['path', 'Path parameters'], ['query', 'Query parameters']] as const) {
|
|
91
|
+
const params = op.parameters.filter((p) => p.in === where);
|
|
92
|
+
if (!params.length) continue;
|
|
93
|
+
const list = params
|
|
94
|
+
.map((p) => {
|
|
95
|
+
const head = `- \`${p.name}\` (${p.schema?.type ?? 'string'}${p.required ? ', required' : ''})`;
|
|
96
|
+
return p.description ? `${head}\n${indented(p.description, 2)}` : head;
|
|
97
|
+
})
|
|
98
|
+
.join('\n');
|
|
99
|
+
out.push(`## ${title}\n\n${list}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const bodyType = primaryContentType(op.requestBody?.content);
|
|
103
|
+
const bodySchema = bodyType ? op.requestBody?.content?.[bodyType]?.schema : undefined;
|
|
104
|
+
if (bodyType && bodySchema) {
|
|
105
|
+
const rows = schemaRows(bodySchema);
|
|
106
|
+
const parts = [`## Body\n\nContent type: \`${bodyType}\``];
|
|
107
|
+
if (rows.length) parts.push(fieldList(rows));
|
|
108
|
+
const example = exampleFromSchema(bodySchema);
|
|
109
|
+
if (example !== null && example !== undefined) parts.push(`Example:\n\n${jsonBlock(example)}`);
|
|
110
|
+
out.push(parts.join('\n\n'));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const responses = Object.entries(op.responses ?? {});
|
|
114
|
+
if (responses.length) {
|
|
115
|
+
const parts = ['## Responses'];
|
|
116
|
+
for (const [status, resp] of responses) {
|
|
117
|
+
parts.push(`### ${status}${resp.description?.trim() ? `\n\n${resp.description.trim()}` : ''}`);
|
|
118
|
+
const type = primaryContentType(resp.content);
|
|
119
|
+
const schema = type ? resp.content?.[type]?.schema : undefined;
|
|
120
|
+
if (schema) {
|
|
121
|
+
const rows = schemaRows(schema);
|
|
122
|
+
if (rows.length) parts.push(fieldList(rows));
|
|
123
|
+
const example = exampleFromSchema(schema);
|
|
124
|
+
if (example !== null && example !== undefined) parts.push(`Example:\n\n${jsonBlock(example)}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
out.push(parts.join('\n\n'));
|
|
128
|
+
}
|
|
129
|
+
return out.join('\n\n');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
interface PageEntry {
|
|
133
|
+
body?: string;
|
|
134
|
+
filePath?: string;
|
|
135
|
+
data: { openapi?: string };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A page as agents read it (its .md copy, llms-full.txt, the MCP index),
|
|
139
|
+
* without the title: its own Markdown, and for an API page the operation
|
|
140
|
+
* after it - in the order the page shows them. */
|
|
141
|
+
export function pageMarkdown(
|
|
142
|
+
entry: PageEntry,
|
|
143
|
+
{ contentDir, packageRoot, variables }: { contentDir: string; packageRoot: string; variables: Record<string, string> }
|
|
144
|
+
): string {
|
|
145
|
+
const own = markdownForAgents(entry.body ?? '', {
|
|
146
|
+
file: entry.filePath ? path.resolve(packageRoot, entry.filePath) : undefined,
|
|
147
|
+
contentDir,
|
|
148
|
+
variables,
|
|
149
|
+
}).trim();
|
|
150
|
+
if (!entry.data.openapi) return own;
|
|
151
|
+
const op = loadOperation(entry.data.openapi, contentDir);
|
|
152
|
+
const operation = op ? operationMarkdown(op) : `Operation: ${entry.data.openapi}`;
|
|
153
|
+
return [own, operation].filter(Boolean).join('\n\n');
|
|
154
|
+
}
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
// 'sidebar' the top of the sidebar: products inside a tab or a dropdown,
|
|
7
7
|
// like Mintlify's - they pick what the sidebar shows
|
|
8
8
|
// 'topbar' a switcher next to the site name (everything else)
|
|
9
|
+
// 'none' not shown: the page is inside a hidden item on this level
|
|
10
|
+
// (lib/hidden-sections.js)
|
|
9
11
|
//
|
|
10
12
|
// Without a sidebar on the page (`mode: custom` / `blank`), a 'sidebar'
|
|
11
13
|
// switcher goes to the top bar instead. The mobile menu lists every level
|
|
@@ -13,6 +15,7 @@
|
|
|
13
15
|
|
|
14
16
|
export function selectorPlacements(selectors, { sidebar = true } = {}) {
|
|
15
17
|
return selectors.map((sel, i) => {
|
|
18
|
+
if (sel.hidden) return 'none';
|
|
16
19
|
const parent = selectors[i - 1]?.kind;
|
|
17
20
|
if (sel.kind === 'tab') return 'tabs';
|
|
18
21
|
if (sel.kind === 'dropdown' && parent === 'tab') return 'tab-menu';
|
|
@@ -26,5 +29,5 @@ export function selectorPlacements(selectors, { sidebar = true } = {}) {
|
|
|
26
29
|
* top bar hides the tabs and switchers, so any page with navigation to
|
|
27
30
|
* reach needs it - with a sidebar or without (`mode: custom`). */
|
|
28
31
|
export function hasMobileMenu({ sidebar, selectors = [], globalDropdowns = [] }) {
|
|
29
|
-
return sidebar || selectors.
|
|
32
|
+
return sidebar || selectors.some((sel) => !sel.hidden) || globalDropdowns.length > 0;
|
|
30
33
|
}
|
|
@@ -71,6 +71,8 @@ import ApiPlayground from "../components/ApiPlayground.astro";
|
|
|
71
71
|
import ApiReferencePanel from "../components/ApiReferencePanel.astro";
|
|
72
72
|
import { searchScope, sectionTrail, ALL_SCOPES } from "../lib/search-scope.js";
|
|
73
73
|
import { selectorPlacements } from "../lib/selector-placement.js";
|
|
74
|
+
import { spaceOf } from "../lib/hidden-sections.js";
|
|
75
|
+
import { folderRedirects } from "../lib/folder-redirects.js";
|
|
74
76
|
|
|
75
77
|
// A page is hand-written (the `pages` collection, sourced from anywhere
|
|
76
78
|
// in the project - docs/ has no special status, see findAllPages() in
|
|
@@ -254,12 +256,34 @@ export async function getStaticPaths() {
|
|
|
254
256
|
};
|
|
255
257
|
});
|
|
256
258
|
|
|
257
|
-
|
|
259
|
+
// Folder addresses with pages under them but none of their own
|
|
260
|
+
// (`/docs/creator/`) redirect to the first page under them
|
|
261
|
+
// (lib/folder-redirects.js). Preferred in the reader's order: navigation
|
|
262
|
+
// order, public sections before hidden ones, pages outside the
|
|
263
|
+
// navigation last.
|
|
264
|
+
const slugOf = (route: { params: { slug?: string } }) => route.params.slug ?? "index";
|
|
265
|
+
const inHiddenSection = sections.map((section) => spaceOf(section.path) !== null);
|
|
266
|
+
const contentRoutes = pageRoutes.filter((route) => !("redirectTo" in route.props));
|
|
267
|
+
const folderTargets = [
|
|
268
|
+
...contentRoutes.filter((route) => !inHiddenSection[(route.props as { sectionIndex: number }).sectionIndex]).map(slugOf),
|
|
269
|
+
...contentRoutes.filter((route) => inHiddenSection[(route.props as { sectionIndex: number }).sectionIndex]).map(slugOf),
|
|
270
|
+
...hiddenRoutes.map(slugOf),
|
|
271
|
+
];
|
|
272
|
+
const takenAddresses = new Set([
|
|
273
|
+
...[...pageRoutes, ...hiddenRoutes].map(slugOf),
|
|
274
|
+
...(config.redirects ?? []).map((r) => r.source.replace(/^\/+|\/+$/g, "")),
|
|
275
|
+
]);
|
|
276
|
+
const folderRoutes = [...folderRedirects(folderTargets, takenAddresses)].map(([folder, target]) => ({
|
|
277
|
+
params: { slug: folder },
|
|
278
|
+
props: { redirectTo: `/${target}/`, temporary: true },
|
|
279
|
+
}));
|
|
280
|
+
|
|
281
|
+
return [...rootRedirect, ...pageRoutes, ...hiddenRoutes, ...folderRoutes];
|
|
258
282
|
}
|
|
259
283
|
|
|
260
284
|
interface Props {
|
|
261
285
|
redirectTo?: string;
|
|
262
|
-
// The automatic "/"
|
|
286
|
+
// The automatic "/" and folder redirects: temporary, see below.
|
|
263
287
|
temporary?: boolean;
|
|
264
288
|
entry?: DocsEntry;
|
|
265
289
|
prev?: { slug: string; group: string | null } | null;
|
|
@@ -386,6 +410,9 @@ const selectors = buildSelectors(activeSection.path, hrefForSlug, activePagePosi
|
|
|
386
410
|
// shows the reader's own first. A hidden page belongs to no section.
|
|
387
411
|
const pageSearchScope = isHidden ? { key: ALL_SCOPES, label: "" } : searchScope(activeSection.path);
|
|
388
412
|
const pageSectionTrail = isHidden ? "" : sectionTrail(activeSection.path, breadcrumbs);
|
|
413
|
+
// A page under a hidden navigation item searches its own index, not the
|
|
414
|
+
// site's (lib/hidden-sections.js, cli/run-pagefind.js).
|
|
415
|
+
const pageSearchSpace = isHidden ? null : spaceOf(activeSection.path);
|
|
389
416
|
const languageSegment = isHidden ? undefined : activeSection.path.find((segment) => segment.kind === "language");
|
|
390
417
|
const pageLang = languageSegment ? (languageSegment.items[languageSegment.index] as { language: string }).language : undefined;
|
|
391
418
|
const globalDropdowns = buildGlobalDropdowns(
|
|
@@ -443,7 +470,7 @@ const isCanvasMode = pageMode === "custom" || pageMode === "blank";
|
|
|
443
470
|
// those are excluded from the .md route this menu links to in the first
|
|
444
471
|
// place, which this mirrors on the UI side.
|
|
445
472
|
const contextMenuItems = contextMenuOptions(config.contextMenu, { mcp: config.mcp });
|
|
446
|
-
const showCopyPageMenu = contextMenuItems.length > 0 && !isCanvasMode
|
|
473
|
+
const showCopyPageMenu = contextMenuItems.length > 0 && !isCanvasMode;
|
|
447
474
|
const siteUrl = resolveSiteUrl(config);
|
|
448
475
|
|
|
449
476
|
const components = {
|
|
@@ -517,7 +544,7 @@ const components = {
|
|
|
517
544
|
{entry.data.openapi && <ApiPlayground operation={entry.data.openapi} contentDir={contentDir} />}
|
|
518
545
|
{/* After the content: a result's excerpt shows the section only when
|
|
519
546
|
that's what matched. */}
|
|
520
|
-
<div hidden data-wd-search-scope={pageSearchScope.key} data-wd-search-scope-label={pageSearchScope.label} data-pagefind-filter={`scope:${pageSearchScope.key}`}>
|
|
547
|
+
<div hidden data-wd-search-scope={pageSearchScope.key} data-wd-search-scope-label={pageSearchScope.label} data-wd-search-space={pageSearchSpace?.id} data-wd-search-public={pageSearchSpace?.searchPublic ? "true" : undefined} data-pagefind-filter={`scope:${pageSearchScope.key}`}>
|
|
521
548
|
{pageSectionTrail && <span data-pagefind-meta="section">{pageSectionTrail}</span>}
|
|
522
549
|
</div>
|
|
523
550
|
</div>
|
|
@@ -543,7 +570,7 @@ const components = {
|
|
|
543
570
|
</div>
|
|
544
571
|
<Content components={components} />
|
|
545
572
|
{entry.data.openapi && <ApiPlayground operation={entry.data.openapi} contentDir={contentDir} />}
|
|
546
|
-
<div hidden data-wd-search-scope={pageSearchScope.key} data-wd-search-scope-label={pageSearchScope.label} data-pagefind-filter={`scope:${pageSearchScope.key}`}>
|
|
573
|
+
<div hidden data-wd-search-scope={pageSearchScope.key} data-wd-search-scope-label={pageSearchScope.label} data-wd-search-space={pageSearchSpace?.id} data-wd-search-public={pageSearchSpace?.searchPublic ? "true" : undefined} data-pagefind-filter={`scope:${pageSearchScope.key}`}>
|
|
547
574
|
{pageSectionTrail && <span data-pagefind-meta="section">{pageSectionTrail}</span>}
|
|
548
575
|
</div>
|
|
549
576
|
{!entry.data.hideFooterPagination && (
|
|
@@ -4,7 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import type { APIRoute } from 'astro';
|
|
5
5
|
import { loadDocsConfig, normalizeEntryId, findAllPages, contextMenuOptions } from '../lib/config';
|
|
6
6
|
import { writedocsTempDir } from '../lib/writedocs-temp-dir.js';
|
|
7
|
-
import {
|
|
7
|
+
import { pageMarkdown } from '../lib/openapi-markdown';
|
|
8
8
|
|
|
9
9
|
// The raw-Markdown twin of [...slug].astro: every content page is also
|
|
10
10
|
// reachable at the exact same slug with a literal ".md" suffix (e.g.
|
|
@@ -46,13 +46,6 @@ export async function getStaticPaths() {
|
|
|
46
46
|
const entries: DocsEntry[] = [...pagesEntries, ...generatedDocsEntries];
|
|
47
47
|
|
|
48
48
|
return entries
|
|
49
|
-
// OpenAPI operation pages (generated stubs, or hand-written pages that
|
|
50
|
-
// set `openapi:` frontmatter) render almost entirely from the spec at
|
|
51
|
-
// request time (see ApiPlayground.astro) rather than from prose in
|
|
52
|
-
// entry.body - a raw .md dump of one would be near-empty and useless
|
|
53
|
-
// as an LLM-facing "view this page as text" export, so they're left
|
|
54
|
-
// out of this route entirely rather than serving something misleading.
|
|
55
|
-
.filter((entry) => !entry.data.openapi)
|
|
56
49
|
// A frontmatter `url` page's HTML route is a redirect to that link -
|
|
57
50
|
// there's no page content to serve as Markdown.
|
|
58
51
|
.filter((entry) => !entry.data.url)
|
|
@@ -81,11 +74,8 @@ export const GET: APIRoute = ({ props }) => {
|
|
|
81
74
|
// What the page shows rather than its raw source: Mintlify's <Visibility>
|
|
82
75
|
// for agents, imported snippets inlined, `variables` filled in - see
|
|
83
76
|
// lib/agent-markdown.js (llms-full.txt uses the same).
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
contentDir,
|
|
87
|
-
variables,
|
|
88
|
-
});
|
|
77
|
+
// An API page's operation is written out after its own text (lib/openapi-markdown.ts).
|
|
78
|
+
const body = pageMarkdown(entry, { contentDir, packageRoot, variables });
|
|
89
79
|
const markdown = `# ${entry.data.title}\n\n${body}`;
|
|
90
80
|
return new Response(markdown, {
|
|
91
81
|
headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
|
|
@@ -4,7 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import type { APIRoute } from 'astro';
|
|
5
5
|
import { loadDocsConfig, normalizeEntryId, findAllPages, resolveSiteUrl, fileIdForEntry } from '../lib/config';
|
|
6
6
|
import { writedocsTempDir } from '../lib/writedocs-temp-dir.js';
|
|
7
|
-
import {
|
|
7
|
+
import { pageMarkdown } from '../lib/openapi-markdown';
|
|
8
8
|
import { orderByNavigation } from '../lib/llms.js';
|
|
9
9
|
|
|
10
10
|
// The "everything, concatenated" half of the llms.txt pair - see
|
|
@@ -43,13 +43,6 @@ export const GET: APIRoute = async () => {
|
|
|
43
43
|
const entries: DocsEntry[] = [...pagesEntries, ...generatedDocsEntries];
|
|
44
44
|
|
|
45
45
|
const unordered = entries
|
|
46
|
-
// Same exclusion [...slug].md.ts applies to its own per-page raw
|
|
47
|
-
// Markdown route, for the same reason: an OpenAPI operation page
|
|
48
|
-
// (generated stub, or a hand-written page that opts into rendering
|
|
49
|
-
// <ApiPlayground /> via `openapi:` frontmatter) renders almost
|
|
50
|
-
// entirely from the spec at request time, so entry.body alone is
|
|
51
|
-
// near-empty and not a meaningful "full content" contribution here.
|
|
52
|
-
.filter((entry) => !entry.data.openapi)
|
|
53
46
|
// Same noindex exclusion llms.txt.ts applies to its own listing -
|
|
54
47
|
// see that file's comment.
|
|
55
48
|
.filter((entry) => !entry.data.seo?.noindex)
|
|
@@ -60,11 +53,8 @@ export const GET: APIRoute = async () => {
|
|
|
60
53
|
// What the page shows, not its raw source: <Visibility> for agents,
|
|
61
54
|
// imported snippets inlined, `variables` filled in - same as the .md
|
|
62
55
|
// route (lib/agent-markdown.js).
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
contentDir,
|
|
66
|
-
variables: config.variables,
|
|
67
|
-
});
|
|
56
|
+
// An API page's operation is written out after its own text (lib/openapi-markdown.ts).
|
|
57
|
+
const body = pageMarkdown(entry, { contentDir, packageRoot, variables: config.variables });
|
|
68
58
|
// The page's own URL, so an agent can cite it.
|
|
69
59
|
const url = `${siteUrl ?? ''}${slug === 'index' ? '/' : `/${slug}/`}`;
|
|
70
60
|
return {
|
package/src/scripts/search.ts
CHANGED
|
@@ -53,7 +53,10 @@ export function initSearch(root: ParentNode) {
|
|
|
53
53
|
let loadPromise: Promise<any> | null = null;
|
|
54
54
|
function ensurePagefind() {
|
|
55
55
|
if (!loadPromise) {
|
|
56
|
-
|
|
56
|
+
// A page under a hidden navigation item searches its own space's index
|
|
57
|
+
// (cli/run-pagefind.js, lib/hidden-sections.js); every other page, the site's.
|
|
58
|
+
const space = document.querySelector<HTMLElement>('[data-wd-search-scope]')?.dataset.wdSearchSpace;
|
|
59
|
+
const pagefindUrl = space ? `/pagefind-spaces/${encodeURIComponent(space)}/pagefind.js` : '/pagefind/pagefind.js';
|
|
57
60
|
loadPromise = import(/* @vite-ignore */ pagefindUrl)
|
|
58
61
|
.then((mod: any) => {
|
|
59
62
|
pagefind = mod;
|