@aravindc26/velu 0.11.0 → 0.11.3
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/package.json +15 -6
- package/schema/velu.schema.json +1251 -115
- package/src/build.ts +1121 -304
- package/src/cli.ts +90 -26
- package/src/engine/_server.mjs +1684 -277
- package/src/engine/app/(docs)/[...slug]/layout.tsx +371 -0
- package/src/engine/app/(docs)/[...slug]/page.tsx +926 -0
- package/src/engine/app/api/proxy/route.ts +23 -0
- package/src/engine/app/copy-page.css +59 -1
- package/src/engine/app/global.css +3157 -3
- package/src/engine/app/layout.tsx +56 -1
- package/src/engine/app/llms-file/route.ts +87 -0
- package/src/engine/app/llms-full-file/route.ts +62 -0
- package/src/engine/app/md-file/[...slug]/route.ts +409 -0
- package/src/engine/app/page.tsx +45 -0
- package/src/engine/app/robots.txt/route.ts +63 -0
- package/src/engine/app/rss-file/[...slug]/route.ts +169 -0
- package/src/engine/app/sitemap.xml/route.ts +82 -0
- package/src/engine/components/assistant.tsx +16 -5
- package/src/engine/components/changelog-filters.tsx +114 -0
- package/src/engine/components/code-group.tsx +383 -0
- package/src/engine/components/color.tsx +118 -0
- package/src/engine/components/expandable.tsx +77 -0
- package/src/engine/components/icon.tsx +136 -0
- package/src/engine/components/image-zoom-fallback.tsx +147 -0
- package/src/engine/components/image.tsx +111 -0
- package/src/engine/components/manual-api-playground.tsx +154 -0
- package/src/engine/components/mermaid.tsx +142 -0
- package/src/engine/components/openapi-toc-sync.tsx +59 -0
- package/src/engine/components/openapi.tsx +1682 -0
- package/src/engine/components/page-feedback.tsx +153 -0
- package/src/engine/components/product-switcher.tsx +27 -3
- package/src/engine/components/prompt.tsx +90 -0
- package/src/engine/components/providers.tsx +1 -6
- package/src/engine/components/search.tsx +4 -0
- package/src/engine/components/sidebar-links.tsx +13 -15
- package/src/engine/components/synced-tabs.tsx +57 -0
- package/src/engine/components/toc-examples.tsx +110 -0
- package/src/engine/components/view.tsx +344 -0
- package/src/engine/generated/redirects.ts +3 -0
- package/src/engine/lib/changelog.ts +246 -0
- package/src/engine/lib/layout.shared.ts +30 -2
- package/src/engine/lib/llms.ts +444 -0
- package/src/engine/lib/navigation-normalize.mjs +481 -412
- package/src/engine/lib/navigation-normalize.ts +261 -54
- package/src/engine/lib/redirects.ts +194 -0
- package/src/engine/lib/source.ts +107 -4
- package/src/engine/lib/velu.ts +368 -2
- package/src/engine/mdx-components.tsx +648 -0
- package/src/engine/middleware.ts +66 -0
- package/src/engine/public/icons/cursor-dark.svg +12 -0
- package/src/engine/public/icons/cursor-light.svg +12 -0
- package/src/engine/source.config.ts +98 -1
- package/src/engine/src/components/PageTitle.astro +16 -5
- package/src/engine/src/lib/velu.ts +11 -3
- package/src/navigation-normalize.ts +252 -54
- package/src/themes.ts +6 -6
- package/src/validate.ts +119 -6
- package/src/engine/app/(docs)/[[...slug]]/layout.tsx +0 -87
- package/src/engine/app/(docs)/[[...slug]]/page.tsx +0 -146
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
1
2
|
import type { ReactNode } from 'react';
|
|
2
|
-
import { getAppearance } from '@/lib/velu';
|
|
3
|
+
import { getAppearance, getSeoConfig, getSiteFavicon, getSiteName, getSiteOrigin, getSitePrimaryColor } from '@/lib/velu';
|
|
3
4
|
import { Providers } from '@/components/providers';
|
|
4
5
|
import { VeluAssistant } from '@/components/assistant';
|
|
5
6
|
import './global.css';
|
|
@@ -7,6 +8,57 @@ import './search.css';
|
|
|
7
8
|
import './assistant.css';
|
|
8
9
|
import './copy-page.css';
|
|
9
10
|
|
|
11
|
+
function toAbsoluteUrl(origin: string, value: string): string {
|
|
12
|
+
const trimmed = value.trim();
|
|
13
|
+
if (!trimmed) return trimmed;
|
|
14
|
+
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
|
15
|
+
const path = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
|
16
|
+
return `${origin}${path}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const siteName = getSiteName();
|
|
20
|
+
const siteOrigin = getSiteOrigin();
|
|
21
|
+
const seo = getSeoConfig();
|
|
22
|
+
const favicon = getSiteFavicon();
|
|
23
|
+
const primaryColor = getSitePrimaryColor();
|
|
24
|
+
const generatedDefaultSocialImage = '/og/index.svg';
|
|
25
|
+
const defaultSocialImage = seo.metatags['og:image'] ?? seo.metatags['twitter:image'] ?? generatedDefaultSocialImage;
|
|
26
|
+
const absoluteDefaultSocialImage = defaultSocialImage ? toAbsoluteUrl(siteOrigin, defaultSocialImage) : undefined;
|
|
27
|
+
|
|
28
|
+
export const metadata: Metadata = {
|
|
29
|
+
metadataBase: new URL(siteOrigin),
|
|
30
|
+
title: {
|
|
31
|
+
default: siteName,
|
|
32
|
+
template: `%s - ${siteName}`,
|
|
33
|
+
},
|
|
34
|
+
applicationName: siteName,
|
|
35
|
+
generator: seo.metatags.generator || 'Mintlify',
|
|
36
|
+
appleWebApp: {
|
|
37
|
+
title: siteName,
|
|
38
|
+
},
|
|
39
|
+
openGraph: {
|
|
40
|
+
type: 'website',
|
|
41
|
+
siteName,
|
|
42
|
+
...(absoluteDefaultSocialImage
|
|
43
|
+
? { images: [{ url: absoluteDefaultSocialImage, width: 1200, height: 630 }] }
|
|
44
|
+
: {}),
|
|
45
|
+
},
|
|
46
|
+
twitter: {
|
|
47
|
+
card: 'summary_large_image',
|
|
48
|
+
...(absoluteDefaultSocialImage ? { images: [absoluteDefaultSocialImage] } : {}),
|
|
49
|
+
},
|
|
50
|
+
...(favicon
|
|
51
|
+
? {
|
|
52
|
+
icons: {
|
|
53
|
+
icon: [{ url: favicon }],
|
|
54
|
+
shortcut: [{ url: favicon }],
|
|
55
|
+
apple: [{ url: favicon }],
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
: {}),
|
|
59
|
+
...(primaryColor ? { other: { 'msapplication-TileColor': primaryColor } } : {}),
|
|
60
|
+
};
|
|
61
|
+
|
|
10
62
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
|
11
63
|
const appearance = getAppearance();
|
|
12
64
|
const theme =
|
|
@@ -19,6 +71,9 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
|
|
19
71
|
|
|
20
72
|
return (
|
|
21
73
|
<html lang="en" suppressHydrationWarning>
|
|
74
|
+
<head>
|
|
75
|
+
<link rel="sitemap" type="application/xml" href={`${siteOrigin}/sitemap.xml`} />
|
|
76
|
+
</head>
|
|
22
77
|
<body className="min-h-screen" suppressHydrationWarning>
|
|
23
78
|
<Providers theme={theme}>
|
|
24
79
|
{children}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import {
|
|
2
|
+
collectLlmsPages,
|
|
3
|
+
getSiteTitle,
|
|
4
|
+
normalizePath,
|
|
5
|
+
readCustomLlmsFile,
|
|
6
|
+
} from '@/lib/llms';
|
|
7
|
+
import { getSiteOrigin } from '@/lib/velu';
|
|
8
|
+
|
|
9
|
+
export const dynamic = 'force-static';
|
|
10
|
+
|
|
11
|
+
function cleanInlineText(value: string): string {
|
|
12
|
+
return value.replace(/\s+/g, ' ').trim();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function toMarkdownPath(path: string): string {
|
|
16
|
+
const normalized = normalizePath(path);
|
|
17
|
+
if (normalized.endsWith('.md')) return normalized;
|
|
18
|
+
return `${normalized}.md`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function toSpecUrl(origin: string, spec: string): string {
|
|
22
|
+
const trimmed = spec.trim();
|
|
23
|
+
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
|
24
|
+
return `${origin}${normalizePath(trimmed)}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function GET() {
|
|
28
|
+
const custom = await readCustomLlmsFile('llms.txt');
|
|
29
|
+
if (custom !== null) {
|
|
30
|
+
return new Response(custom, {
|
|
31
|
+
status: 200,
|
|
32
|
+
headers: {
|
|
33
|
+
'content-type': 'text/plain; charset=utf-8',
|
|
34
|
+
'cache-control': 'public, max-age=300',
|
|
35
|
+
'x-content-type-options': 'nosniff',
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const siteTitle = getSiteTitle();
|
|
41
|
+
const origin = getSiteOrigin();
|
|
42
|
+
const pages = await collectLlmsPages();
|
|
43
|
+
const docsPages = pages.filter((page) => !page.noindex && !(page.sourceKind === 'generated' && page.isOpenApiOperation));
|
|
44
|
+
const openApiSpecs = Array.from(
|
|
45
|
+
new Set(
|
|
46
|
+
pages
|
|
47
|
+
.filter((page) => !page.noindex && page.sourceKind === 'generated' && page.isOpenApiOperation && typeof page.openapiSpec === 'string')
|
|
48
|
+
.map((page) => page.openapiSpec!.trim())
|
|
49
|
+
.filter((spec) => spec.length > 0),
|
|
50
|
+
),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const lines: string[] = [];
|
|
54
|
+
lines.push(`# ${siteTitle}`);
|
|
55
|
+
lines.push('');
|
|
56
|
+
lines.push('## Docs');
|
|
57
|
+
lines.push('');
|
|
58
|
+
for (const page of docsPages) {
|
|
59
|
+
const url = `${origin}${toMarkdownPath(page.path)}`;
|
|
60
|
+
const description = page.description ? cleanInlineText(page.description) : '';
|
|
61
|
+
if (description) {
|
|
62
|
+
lines.push(`- [${page.title}](${url}): ${description}`);
|
|
63
|
+
} else {
|
|
64
|
+
lines.push(`- [${page.title}](${url})`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (openApiSpecs.length > 0) {
|
|
68
|
+
lines.push('');
|
|
69
|
+
lines.push('## OpenAPI Specs');
|
|
70
|
+
lines.push('');
|
|
71
|
+
for (const spec of openApiSpecs) {
|
|
72
|
+
const url = toSpecUrl(origin, spec);
|
|
73
|
+
lines.push(`- [${spec}](${url})`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
lines.push('');
|
|
77
|
+
|
|
78
|
+
const body = `${lines.join('\n').trimEnd()}\n`;
|
|
79
|
+
return new Response(body, {
|
|
80
|
+
status: 200,
|
|
81
|
+
headers: {
|
|
82
|
+
'content-type': 'text/plain; charset=utf-8',
|
|
83
|
+
'cache-control': 'public, max-age=300',
|
|
84
|
+
'x-content-type-options': 'nosniff',
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import {
|
|
2
|
+
collectLlmsPages,
|
|
3
|
+
getSiteTitle,
|
|
4
|
+
normalizePath,
|
|
5
|
+
readCustomLlmsFile,
|
|
6
|
+
} from '@/lib/llms';
|
|
7
|
+
import { getSiteOrigin } from '@/lib/velu';
|
|
8
|
+
|
|
9
|
+
export const dynamic = 'force-static';
|
|
10
|
+
|
|
11
|
+
export async function GET() {
|
|
12
|
+
const custom = await readCustomLlmsFile('llms-full.txt');
|
|
13
|
+
if (custom !== null) {
|
|
14
|
+
return new Response(custom, {
|
|
15
|
+
status: 200,
|
|
16
|
+
headers: {
|
|
17
|
+
'content-type': 'text/plain; charset=utf-8',
|
|
18
|
+
'cache-control': 'public, max-age=300',
|
|
19
|
+
'x-content-type-options': 'nosniff',
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const siteTitle = getSiteTitle();
|
|
25
|
+
const origin = getSiteOrigin();
|
|
26
|
+
const pages = await collectLlmsPages({ includeMarkdown: true });
|
|
27
|
+
const includedPages = pages.filter((page) => {
|
|
28
|
+
if (page.noindex) return false;
|
|
29
|
+
const hasBodyContent = typeof page.markdown === 'string' && page.markdown.trim().length > 0;
|
|
30
|
+
if (page.isOpenApiOperation && !hasBodyContent) return false;
|
|
31
|
+
if (page.sourceKind === 'generated' && page.isOpenApiOperation) return false;
|
|
32
|
+
return true;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const lines: string[] = [];
|
|
36
|
+
lines.push(`# ${siteTitle}`);
|
|
37
|
+
lines.push('');
|
|
38
|
+
|
|
39
|
+
for (const page of includedPages) {
|
|
40
|
+
const url = `${origin}${normalizePath(page.path)}`;
|
|
41
|
+
lines.push(`## ${page.title}`);
|
|
42
|
+
lines.push('');
|
|
43
|
+
lines.push(`Source: ${url}`);
|
|
44
|
+
lines.push('');
|
|
45
|
+
if (page.description) {
|
|
46
|
+
lines.push(`> ${page.description.replace(/\s+/g, ' ').trim()}`);
|
|
47
|
+
lines.push('');
|
|
48
|
+
}
|
|
49
|
+
lines.push(page.markdown && page.markdown.length > 0 ? page.markdown : '_No content._');
|
|
50
|
+
lines.push('');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const body = `${lines.join('\n').trimEnd()}\n`;
|
|
54
|
+
return new Response(body, {
|
|
55
|
+
status: 200,
|
|
56
|
+
headers: {
|
|
57
|
+
'content-type': 'text/plain; charset=utf-8',
|
|
58
|
+
'cache-control': 'public, max-age=300',
|
|
59
|
+
'x-content-type-options': 'nosniff',
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
import { source } from '@/lib/source';
|
|
4
|
+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
5
|
+
import { getApiConfig, getLanguages } from '@/lib/velu';
|
|
6
|
+
|
|
7
|
+
interface RouteParams {
|
|
8
|
+
slug?: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface ParsedOpenApiFrontmatter {
|
|
12
|
+
spec?: string;
|
|
13
|
+
method: string;
|
|
14
|
+
endpoint: string;
|
|
15
|
+
kind: 'path' | 'webhook';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const OPENAPI_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace'] as const;
|
|
19
|
+
|
|
20
|
+
export const dynamic = 'force-static';
|
|
21
|
+
|
|
22
|
+
export async function generateStaticParams() {
|
|
23
|
+
const generated = source.generateParams('slug') as Array<{ slug?: string[] }>;
|
|
24
|
+
const seen = new Set<string>();
|
|
25
|
+
const out: Array<{ slug: string[] }> = [];
|
|
26
|
+
|
|
27
|
+
for (const entry of generated) {
|
|
28
|
+
const slug = entry.slug ?? [];
|
|
29
|
+
if (slug.length === 0) continue;
|
|
30
|
+
const mdSlug = [...slug];
|
|
31
|
+
mdSlug[mdSlug.length - 1] = `${mdSlug[mdSlug.length - 1]}.md`;
|
|
32
|
+
const key = mdSlug.join('/');
|
|
33
|
+
if (seen.has(key)) continue;
|
|
34
|
+
seen.add(key);
|
|
35
|
+
out.push({ slug: mdSlug });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function resolveLocaleSlug(slugInput: string[] | undefined) {
|
|
42
|
+
const languages = getLanguages();
|
|
43
|
+
const defaultLanguage = languages[0] ?? 'en';
|
|
44
|
+
const slug = slugInput ?? [];
|
|
45
|
+
const firstSeg = slug[0];
|
|
46
|
+
const hasLocalePrefix = languages.includes(firstSeg ?? '');
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
locale: hasLocalePrefix ? firstSeg! : defaultLanguage,
|
|
50
|
+
pageSlug: hasLocalePrefix ? slug.slice(1) : slug,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function loadMarkdownForSlug(slug: string[], locale: string, hasI18n: boolean): Promise<string | undefined> {
|
|
55
|
+
const rel = slug.join('/');
|
|
56
|
+
const docsRoots = [
|
|
57
|
+
join(process.cwd(), 'content', 'docs'),
|
|
58
|
+
join(process.cwd(), '.velu-out', 'content', 'docs'),
|
|
59
|
+
];
|
|
60
|
+
const roots = hasI18n
|
|
61
|
+
? docsRoots.flatMap((root) => [join(root, locale), root])
|
|
62
|
+
: docsRoots;
|
|
63
|
+
const paths = roots.flatMap((root) => [join(root, `${rel}.md`), join(root, `${rel}.mdx`)]);
|
|
64
|
+
|
|
65
|
+
for (const filePath of paths) {
|
|
66
|
+
try {
|
|
67
|
+
return await readFile(filePath, 'utf-8');
|
|
68
|
+
} catch {
|
|
69
|
+
// ignore and continue
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseFrontmatterMap(markdown?: string): Record<string, string> {
|
|
76
|
+
if (!markdown) return {};
|
|
77
|
+
const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
78
|
+
if (!match) return {};
|
|
79
|
+
|
|
80
|
+
const output: Record<string, string> = {};
|
|
81
|
+
const lines = match[1].split(/\r?\n/);
|
|
82
|
+
for (const rawLine of lines) {
|
|
83
|
+
const line = rawLine.trim();
|
|
84
|
+
if (!line || line.startsWith('#')) continue;
|
|
85
|
+
const entry = line.match(/^([A-Za-z0-9_-]+)\s*:\s*(.+)$/);
|
|
86
|
+
if (!entry) continue;
|
|
87
|
+
const key = entry[1];
|
|
88
|
+
const rawValue = entry[2].trim();
|
|
89
|
+
const value = rawValue.replace(/^['"]|['"]$/g, '').trim();
|
|
90
|
+
output[key] = value;
|
|
91
|
+
}
|
|
92
|
+
return output;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function stripFrontmatter(markdown: string): string {
|
|
96
|
+
return markdown.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function parseOpenApiFrontmatter(rawValue: string | undefined, defaultSpec?: string): ParsedOpenApiFrontmatter | null {
|
|
100
|
+
if (!rawValue) return null;
|
|
101
|
+
const trimmed = rawValue.trim();
|
|
102
|
+
if (!trimmed) return null;
|
|
103
|
+
|
|
104
|
+
const withInlineSpec = trimmed.match(/^(\S+)\s+([A-Za-z]+)\s+(.+)$/);
|
|
105
|
+
if (withInlineSpec) {
|
|
106
|
+
const method = withInlineSpec[2].toUpperCase();
|
|
107
|
+
const endpoint = withInlineSpec[3].trim();
|
|
108
|
+
if (!endpoint) return null;
|
|
109
|
+
const kind = method === 'WEBHOOK' ? 'webhook' : 'path';
|
|
110
|
+
if (kind === 'path' && !endpoint.startsWith('/')) return null;
|
|
111
|
+
return {
|
|
112
|
+
spec: withInlineSpec[1].trim(),
|
|
113
|
+
method,
|
|
114
|
+
endpoint,
|
|
115
|
+
kind,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const withDefaultSpec = trimmed.match(/^([A-Za-z]+)\s+(.+)$/);
|
|
120
|
+
if (withDefaultSpec) {
|
|
121
|
+
const method = withDefaultSpec[1].toUpperCase();
|
|
122
|
+
const endpoint = withDefaultSpec[2].trim();
|
|
123
|
+
if (!endpoint) return null;
|
|
124
|
+
const kind = method === 'WEBHOOK' ? 'webhook' : 'path';
|
|
125
|
+
if (kind === 'path' && !endpoint.startsWith('/')) return null;
|
|
126
|
+
return {
|
|
127
|
+
spec: defaultSpec,
|
|
128
|
+
method,
|
|
129
|
+
endpoint,
|
|
130
|
+
kind,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
138
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
139
|
+
? (value as Record<string, unknown>)
|
|
140
|
+
: undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function parseOpenApiDocument(rawSource: string): Record<string, unknown> | null {
|
|
144
|
+
const sourceText = rawSource.trim();
|
|
145
|
+
if (!sourceText) return null;
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const parsed = JSON.parse(sourceText);
|
|
149
|
+
if (parsed && typeof parsed === 'object') return parsed as Record<string, unknown>;
|
|
150
|
+
} catch {
|
|
151
|
+
// Fall through to YAML parse.
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
const parsed = parseYaml(sourceText);
|
|
156
|
+
if (parsed && typeof parsed === 'object') return parsed as Record<string, unknown>;
|
|
157
|
+
} catch {
|
|
158
|
+
// ignore
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function loadOpenApiDocument(specSource: string): Promise<Record<string, unknown> | null> {
|
|
165
|
+
const sourceText = specSource.trim();
|
|
166
|
+
if (!sourceText) return null;
|
|
167
|
+
|
|
168
|
+
if (/^https?:\/\//i.test(sourceText)) {
|
|
169
|
+
try {
|
|
170
|
+
const response = await fetch(sourceText, { cache: 'force-cache' });
|
|
171
|
+
if (!response.ok) return null;
|
|
172
|
+
const text = await response.text();
|
|
173
|
+
return parseOpenApiDocument(text);
|
|
174
|
+
} catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const relative = sourceText.replace(/^\/+/, '');
|
|
180
|
+
const candidates = sourceText.startsWith('/')
|
|
181
|
+
? [
|
|
182
|
+
join(process.cwd(), 'public', relative),
|
|
183
|
+
join(process.cwd(), relative),
|
|
184
|
+
join(process.cwd(), 'content', 'docs', relative),
|
|
185
|
+
]
|
|
186
|
+
: [
|
|
187
|
+
join(process.cwd(), sourceText),
|
|
188
|
+
join(process.cwd(), 'public', sourceText),
|
|
189
|
+
join(process.cwd(), 'content', 'docs', sourceText),
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
for (const candidate of candidates) {
|
|
193
|
+
try {
|
|
194
|
+
const text = await readFile(candidate, 'utf-8');
|
|
195
|
+
const parsed = parseOpenApiDocument(text);
|
|
196
|
+
if (parsed) return parsed;
|
|
197
|
+
} catch {
|
|
198
|
+
// ignore and continue
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function pickOperationMethod(pathItem: Record<string, unknown>, preferred?: string): string | undefined {
|
|
206
|
+
const preferredLower = preferred?.toLowerCase();
|
|
207
|
+
if (preferredLower && OPENAPI_METHODS.includes(preferredLower as (typeof OPENAPI_METHODS)[number])) {
|
|
208
|
+
const selected = asRecord(pathItem[preferredLower]);
|
|
209
|
+
if (selected) return preferredLower;
|
|
210
|
+
}
|
|
211
|
+
for (const method of OPENAPI_METHODS) {
|
|
212
|
+
if (asRecord(pathItem[method])) return method;
|
|
213
|
+
}
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function resolvePathOperation(
|
|
218
|
+
document: Record<string, unknown>,
|
|
219
|
+
endpoint: string,
|
|
220
|
+
method: string,
|
|
221
|
+
): { endpoint: string; method: string; operation: Record<string, unknown> } | null {
|
|
222
|
+
const paths = asRecord(document.paths);
|
|
223
|
+
if (!paths) return null;
|
|
224
|
+
|
|
225
|
+
const candidates = endpoint.startsWith('/')
|
|
226
|
+
? [endpoint, endpoint.replace(/^\/+/, '')]
|
|
227
|
+
: [`/${endpoint}`, endpoint];
|
|
228
|
+
const methodLower = method.toLowerCase();
|
|
229
|
+
|
|
230
|
+
for (const candidate of candidates) {
|
|
231
|
+
const pathItem = asRecord(paths[candidate]);
|
|
232
|
+
if (!pathItem) continue;
|
|
233
|
+
const resolvedMethod = pickOperationMethod(pathItem, methodLower);
|
|
234
|
+
if (!resolvedMethod) continue;
|
|
235
|
+
const operation = asRecord(pathItem[resolvedMethod]);
|
|
236
|
+
if (!operation) continue;
|
|
237
|
+
const resolvedEndpoint = candidate.startsWith('/') ? candidate : `/${candidate}`;
|
|
238
|
+
return { endpoint: resolvedEndpoint, method: resolvedMethod, operation };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function resolveWebhookOperation(
|
|
245
|
+
document: Record<string, unknown>,
|
|
246
|
+
endpoint: string,
|
|
247
|
+
): { endpoint: string; method: string; operation: Record<string, unknown> } | null {
|
|
248
|
+
const webhooks = asRecord(document.webhooks);
|
|
249
|
+
if (!webhooks) return null;
|
|
250
|
+
|
|
251
|
+
const normalized = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
|
|
252
|
+
const candidates = [endpoint, normalized, endpoint.replace(/^\/+/, ''), normalized.replace(/^\/+/, '')];
|
|
253
|
+
|
|
254
|
+
for (const candidate of candidates) {
|
|
255
|
+
const pathItem = asRecord(webhooks[candidate]);
|
|
256
|
+
if (!pathItem) continue;
|
|
257
|
+
const resolvedMethod = pickOperationMethod(pathItem);
|
|
258
|
+
if (!resolvedMethod) continue;
|
|
259
|
+
const operation = asRecord(pathItem[resolvedMethod]);
|
|
260
|
+
if (!operation) continue;
|
|
261
|
+
return { endpoint: candidate, method: resolvedMethod, operation };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function displaySpecName(specSource: string | undefined): string {
|
|
268
|
+
if (!specSource || !specSource.trim()) return 'openapi.json';
|
|
269
|
+
const trimmed = specSource.trim();
|
|
270
|
+
if (/^https?:\/\//i.test(trimmed)) {
|
|
271
|
+
try {
|
|
272
|
+
const url = new URL(trimmed);
|
|
273
|
+
const name = basename(url.pathname || '');
|
|
274
|
+
return name || 'openapi.json';
|
|
275
|
+
} catch {
|
|
276
|
+
return 'openapi.json';
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return basename(trimmed.replace(/\\/g, '/')) || 'openapi.json';
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function buildMintlifyStyleApiMarkdown(input: {
|
|
283
|
+
title: string;
|
|
284
|
+
description?: string;
|
|
285
|
+
body?: string;
|
|
286
|
+
specLabel: string;
|
|
287
|
+
endpoint: string;
|
|
288
|
+
method: string;
|
|
289
|
+
kind: 'path' | 'webhook';
|
|
290
|
+
snippet: Record<string, unknown>;
|
|
291
|
+
}): string {
|
|
292
|
+
const lines: string[] = [];
|
|
293
|
+
lines.push(`# ${input.title}`);
|
|
294
|
+
lines.push('');
|
|
295
|
+
if (input.description) {
|
|
296
|
+
lines.push(`> ${input.description}`);
|
|
297
|
+
lines.push('');
|
|
298
|
+
}
|
|
299
|
+
if (input.body) {
|
|
300
|
+
lines.push(input.body.trim());
|
|
301
|
+
lines.push('');
|
|
302
|
+
}
|
|
303
|
+
lines.push('## OpenAPI');
|
|
304
|
+
lines.push('');
|
|
305
|
+
|
|
306
|
+
const opLabel = input.kind === 'webhook'
|
|
307
|
+
? `webhook ${input.endpoint}`
|
|
308
|
+
: `${input.method.toLowerCase()} ${input.endpoint}`;
|
|
309
|
+
const yaml = stringifyYaml(input.snippet).trimEnd();
|
|
310
|
+
lines.push(`\`\`\`\`yaml ${input.specLabel} ${opLabel}`);
|
|
311
|
+
lines.push(yaml);
|
|
312
|
+
lines.push('````');
|
|
313
|
+
lines.push('');
|
|
314
|
+
return lines.join('\n');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export async function GET(_request: Request, { params }: { params: Promise<RouteParams> }) {
|
|
318
|
+
const resolvedParams = await params;
|
|
319
|
+
const fullSlug = resolvedParams.slug ?? [];
|
|
320
|
+
if (fullSlug.length === 0) {
|
|
321
|
+
return new Response('Not Found', {
|
|
322
|
+
status: 404,
|
|
323
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const normalized = [...fullSlug];
|
|
328
|
+
const lastIndex = normalized.length - 1;
|
|
329
|
+
const last = normalized[lastIndex] ?? '';
|
|
330
|
+
if (last.toLowerCase().endsWith('.md')) {
|
|
331
|
+
const stripped = last.slice(0, -3);
|
|
332
|
+
if (stripped) normalized[lastIndex] = stripped;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const { locale, pageSlug } = resolveLocaleSlug(normalized);
|
|
336
|
+
const hasI18n = getLanguages().length > 1;
|
|
337
|
+
const page = hasI18n ? source.getPage(pageSlug, locale) : source.getPage(pageSlug);
|
|
338
|
+
|
|
339
|
+
if (!page) {
|
|
340
|
+
return new Response('Not Found', {
|
|
341
|
+
status: 404,
|
|
342
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const fromFile = await loadMarkdownForSlug(pageSlug, locale, hasI18n);
|
|
347
|
+
const fromData = ((page.data as unknown) as Record<string, unknown>).processedMarkdown;
|
|
348
|
+
const markdown = typeof fromFile === 'string' && fromFile.trim().length > 0
|
|
349
|
+
? fromFile
|
|
350
|
+
: (typeof fromData === 'string' ? fromData : `# ${page.data.title}\n`);
|
|
351
|
+
|
|
352
|
+
const frontmatter = parseFrontmatterMap(markdown);
|
|
353
|
+
const markdownBody = stripFrontmatter(markdown).trim();
|
|
354
|
+
const apiConfig = getApiConfig();
|
|
355
|
+
const parsedOpenApi = parseOpenApiFrontmatter(frontmatter.openapi, apiConfig.defaultOpenApiSpec);
|
|
356
|
+
if (parsedOpenApi?.spec) {
|
|
357
|
+
const document = await loadOpenApiDocument(parsedOpenApi.spec);
|
|
358
|
+
if (document) {
|
|
359
|
+
const resolved = parsedOpenApi.kind === 'webhook'
|
|
360
|
+
? resolveWebhookOperation(document, parsedOpenApi.endpoint)
|
|
361
|
+
: resolvePathOperation(document, parsedOpenApi.endpoint, parsedOpenApi.method);
|
|
362
|
+
|
|
363
|
+
if (resolved) {
|
|
364
|
+
const title = frontmatter.title
|
|
365
|
+
|| (typeof resolved.operation.summary === 'string' ? resolved.operation.summary : '')
|
|
366
|
+
|| `${resolved.method.toUpperCase()} ${resolved.endpoint}`;
|
|
367
|
+
const description = frontmatter.description
|
|
368
|
+
|| (typeof resolved.operation.description === 'string' ? resolved.operation.description : undefined);
|
|
369
|
+
const snippet: Record<string, unknown> = {
|
|
370
|
+
openapi: typeof document.openapi === 'string' ? document.openapi : '3.0.0',
|
|
371
|
+
info: asRecord(document.info) ?? { title: 'API', version: '1.0.0' },
|
|
372
|
+
...(Array.isArray(document.servers) ? { servers: document.servers } : {}),
|
|
373
|
+
...(Array.isArray(document.security) ? { security: document.security } : {}),
|
|
374
|
+
...(parsedOpenApi.kind === 'webhook'
|
|
375
|
+
? { webhooks: { [resolved.endpoint]: { [resolved.method]: resolved.operation } } }
|
|
376
|
+
: { paths: { [resolved.endpoint]: { [resolved.method]: resolved.operation } } }),
|
|
377
|
+
...(asRecord(document.components) ? { components: document.components } : {}),
|
|
378
|
+
};
|
|
379
|
+
const output = buildMintlifyStyleApiMarkdown({
|
|
380
|
+
title,
|
|
381
|
+
description,
|
|
382
|
+
body: markdownBody || undefined,
|
|
383
|
+
specLabel: displaySpecName(parsedOpenApi.spec),
|
|
384
|
+
endpoint: resolved.endpoint,
|
|
385
|
+
method: resolved.method,
|
|
386
|
+
kind: parsedOpenApi.kind,
|
|
387
|
+
snippet,
|
|
388
|
+
});
|
|
389
|
+
return new Response(output, {
|
|
390
|
+
status: 200,
|
|
391
|
+
headers: {
|
|
392
|
+
'content-type': 'text/markdown; charset=utf-8',
|
|
393
|
+
'content-disposition': 'inline',
|
|
394
|
+
'x-content-type-options': 'nosniff',
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return new Response(markdown, {
|
|
402
|
+
status: 200,
|
|
403
|
+
headers: {
|
|
404
|
+
'content-type': 'text/markdown; charset=utf-8',
|
|
405
|
+
'content-disposition': 'inline',
|
|
406
|
+
'x-content-type-options': 'nosniff',
|
|
407
|
+
},
|
|
408
|
+
});
|
|
409
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { redirect } from 'next/navigation';
|
|
2
|
+
import { source } from '@/lib/source';
|
|
3
|
+
import { getLanguages } from '@/lib/velu';
|
|
4
|
+
|
|
5
|
+
interface PageTreeNode {
|
|
6
|
+
type?: string;
|
|
7
|
+
url?: string;
|
|
8
|
+
external?: boolean;
|
|
9
|
+
index?: { url?: string };
|
|
10
|
+
children?: unknown[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function findFirstPageUrl(node: unknown): string | undefined {
|
|
14
|
+
if (!node || typeof node !== 'object') return undefined;
|
|
15
|
+
const entry = node as PageTreeNode;
|
|
16
|
+
|
|
17
|
+
if (entry.type === 'page' && !entry.external && typeof entry.url === 'string' && entry.url.length > 0) {
|
|
18
|
+
return entry.url;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (entry.type === 'folder') {
|
|
22
|
+
if (typeof entry.index?.url === 'string' && entry.index.url.length > 0) {
|
|
23
|
+
return entry.index.url;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const children = Array.isArray(entry.children) ? entry.children : [];
|
|
28
|
+
for (const child of children) {
|
|
29
|
+
const nested = findFirstPageUrl(child);
|
|
30
|
+
if (nested) return nested;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function resolveDefaultDocsHref(): string {
|
|
37
|
+
const defaultLanguage = getLanguages()[0] ?? 'en';
|
|
38
|
+
const tree = source.getPageTree(defaultLanguage);
|
|
39
|
+
const first = findFirstPageUrl(tree);
|
|
40
|
+
return first || '/';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default function HomePage() {
|
|
44
|
+
redirect(resolveDefaultDocsHref());
|
|
45
|
+
}
|