@raystack/chronicle 0.12.3 → 0.12.5
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/dist/cli/index.js +1763 -252
- package/package.json +8 -2
- package/src/cli/commands/build.ts +36 -7
- package/src/cli/commands/static-generate.ts +1071 -0
- package/src/cli/utils/mdx-error-report.ts +48 -0
- package/src/components/api/playground-dialog.tsx +65 -14
- package/src/components/mdx/code.tsx +8 -3
- package/src/components/mdx/image.tsx +15 -1
- package/src/components/mdx/index.tsx +10 -3
- package/src/components/ui/search.tsx +226 -64
- package/src/lib/data-urls.ts +23 -0
- package/src/lib/head.tsx +2 -1
- package/src/lib/mdx-component-names.ts +15 -0
- package/src/lib/mdx-error.test.ts +78 -0
- package/src/lib/mdx-error.ts +91 -0
- package/src/lib/openapi.ts +1 -1
- package/src/lib/page-context.tsx +29 -14
- package/src/lib/preload.ts +4 -2
- package/src/lib/remark-validate-mdx.test.ts +95 -0
- package/src/lib/remark-validate-mdx.ts +85 -0
- package/src/lib/route-resolver.ts +7 -0
- package/src/lib/static-mode.ts +3 -0
- package/src/pages/DocsPage.tsx +3 -2
- package/src/pages/NotFound.module.css +10 -0
- package/src/pages/RenderError.tsx +18 -0
- package/src/server/App.tsx +16 -12
- package/src/server/api/apis-proxy.ts +32 -7
- package/src/server/dev-error-page.ts +133 -0
- package/src/server/entry-server.tsx +6 -0
- package/src/server/entry-static.tsx +151 -0
- package/src/server/error.ts +13 -1
- package/src/server/plugins/telemetry.ts +3 -2
- package/src/server/vite-config.ts +22 -9
- package/src/themes/default/Page.module.css +2 -0
- package/src/themes/paper/Layout.module.css +0 -4
- package/src/themes/paper/Layout.tsx +0 -2
- package/src/themes/paper/Page.module.css +2 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { buildCodeFrame, parseMdxError } from '@/lib/mdx-error';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Prints a formatted report for MDX build failures (file, line:column,
|
|
8
|
+
* message, code frame). Returns false when the error is not MDX-related
|
|
9
|
+
* so the caller can rethrow it.
|
|
10
|
+
*/
|
|
11
|
+
export async function printMdxBuildError(err: unknown, packageRoot: string): Promise<boolean> {
|
|
12
|
+
const info = parseMdxError(err);
|
|
13
|
+
if (!info) return false;
|
|
14
|
+
|
|
15
|
+
const contentMirror = path.resolve(packageRoot, '.content');
|
|
16
|
+
const displayPath = info.file?.startsWith(contentMirror)
|
|
17
|
+
? path.relative(contentMirror, info.file)
|
|
18
|
+
: info.file && !path.relative(process.cwd(), info.file).startsWith('..')
|
|
19
|
+
? path.relative(process.cwd(), info.file)
|
|
20
|
+
: info.file;
|
|
21
|
+
|
|
22
|
+
console.error();
|
|
23
|
+
console.error(chalk.bgRed.white(' MDX Error '), chalk.red(info.message));
|
|
24
|
+
if (displayPath) {
|
|
25
|
+
const position = info.line ? `:${info.line}${info.column ? `:${info.column}` : ''}` : '';
|
|
26
|
+
console.error(chalk.dim(` ${displayPath}${position}`));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (info.file && info.line) {
|
|
30
|
+
try {
|
|
31
|
+
const source = await fs.readFile(info.file, 'utf-8');
|
|
32
|
+
const frame = buildCodeFrame(source, info.line, info.column);
|
|
33
|
+
const gutter = String(frame.lines[frame.lines.length - 1]?.number ?? 0).length;
|
|
34
|
+
console.error();
|
|
35
|
+
for (const { number, text, target } of frame.lines) {
|
|
36
|
+
const ln = `${target ? '>' : ' '} ${String(number).padStart(gutter)} | `;
|
|
37
|
+
console.error(target ? chalk.red(ln) + text : chalk.dim(ln + text));
|
|
38
|
+
if (target && frame.caretColumn) {
|
|
39
|
+
console.error(chalk.red(` ${' '.repeat(gutter)} | ${' '.repeat(frame.caretColumn - 1)}^`));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
// source not readable — message-only report
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
console.error();
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
@@ -8,9 +8,66 @@ import { CounterClockwiseClockIcon, CodeIcon } from '@radix-ui/react-icons'
|
|
|
8
8
|
import { MethodBadge } from '@/components/api/method-badge'
|
|
9
9
|
import { flattenSchema, generateExampleJson, toKind, type SchemaField } from '@/lib/schema'
|
|
10
10
|
import { generateCurl } from '@/lib/snippet-generators'
|
|
11
|
+
import { isStaticMode } from '@/lib/static-mode'
|
|
11
12
|
import { JsonEditor } from '@/components/api/json-editor'
|
|
12
13
|
import styles from './playground-dialog.module.css'
|
|
13
14
|
|
|
15
|
+
type ProxyResponse = {
|
|
16
|
+
status: number
|
|
17
|
+
statusText: string
|
|
18
|
+
body: unknown
|
|
19
|
+
headers?: Record<string, string>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function sendViaProxy(
|
|
23
|
+
specName: string,
|
|
24
|
+
method: string,
|
|
25
|
+
path: string,
|
|
26
|
+
headers: Record<string, string>,
|
|
27
|
+
body: unknown,
|
|
28
|
+
): Promise<ProxyResponse> {
|
|
29
|
+
const res = await fetch('/api/apis-proxy', {
|
|
30
|
+
method: 'POST',
|
|
31
|
+
headers: { 'Content-Type': 'application/json' },
|
|
32
|
+
body: JSON.stringify({ specName, method, path, headers, body }),
|
|
33
|
+
})
|
|
34
|
+
const data = await res.json()
|
|
35
|
+
if (data.status !== undefined) return data
|
|
36
|
+
return { status: res.status, statusText: res.statusText, body: data.error ?? data }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function sendDirect(
|
|
40
|
+
serverUrl: string,
|
|
41
|
+
method: string,
|
|
42
|
+
path: string,
|
|
43
|
+
headers: Record<string, string>,
|
|
44
|
+
body: unknown,
|
|
45
|
+
): Promise<ProxyResponse> {
|
|
46
|
+
const url = serverUrl + path
|
|
47
|
+
try {
|
|
48
|
+
const res = await fetch(url, {
|
|
49
|
+
method,
|
|
50
|
+
headers,
|
|
51
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
52
|
+
})
|
|
53
|
+
const contentType = res.headers.get('content-type') ?? ''
|
|
54
|
+
const responseBody = contentType.includes('application/json')
|
|
55
|
+
? await res.json()
|
|
56
|
+
: await res.text()
|
|
57
|
+
const responseHeaders: Record<string, string> = {}
|
|
58
|
+
res.headers.forEach((v, k) => { responseHeaders[k] = v })
|
|
59
|
+
return { status: res.status, statusText: res.statusText, body: responseBody, headers: responseHeaders }
|
|
60
|
+
} catch (err) {
|
|
61
|
+
if (err instanceof TypeError) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`CORS Error: The API server at ${serverUrl} does not allow requests from this origin.\n` +
|
|
64
|
+
`Ask the API server administrator to add this site's origin to their CORS allowed origins.`
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
throw err
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
14
71
|
type AuthScheme = {
|
|
15
72
|
name: string
|
|
16
73
|
type: 'apiKey' | 'bearer' | 'basic' | 'none'
|
|
@@ -193,24 +250,18 @@ export function PlaygroundDialog({
|
|
|
193
250
|
}
|
|
194
251
|
|
|
195
252
|
try {
|
|
196
|
-
const
|
|
197
|
-
method
|
|
198
|
-
|
|
199
|
-
body: JSON.stringify({ specName, method, path: fullPath, headers: reqHeaders, body: reqBody }),
|
|
200
|
-
})
|
|
201
|
-
const data = await res.json()
|
|
253
|
+
const data = isStaticMode()
|
|
254
|
+
? await sendDirect(serverUrl, method, fullPath, reqHeaders, reqBody)
|
|
255
|
+
: await sendViaProxy(specName, method, fullPath, reqHeaders, reqBody)
|
|
202
256
|
const elapsed = Math.round(performance.now() - startTime)
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
208
|
-
} catch {
|
|
209
|
-
setResponseData({ status: 0, statusText: 'Error', body: 'Failed to send request', time: 0 })
|
|
257
|
+
setResponseData({ ...data, time: elapsed })
|
|
258
|
+
} catch (err) {
|
|
259
|
+
const message = err instanceof Error ? err.message : 'Failed to send request'
|
|
260
|
+
setResponseData({ status: 0, statusText: 'Error', body: message, time: 0 })
|
|
210
261
|
} finally {
|
|
211
262
|
setLoading(false)
|
|
212
263
|
}
|
|
213
|
-
}, [specName, method, path, pathValues, queryValues, getAuthHeaders, headerValues, bodyValues, body])
|
|
264
|
+
}, [specName, method, path, serverUrl, pathValues, queryValues, getAuthHeaders, headerValues, bodyValues, body])
|
|
214
265
|
|
|
215
266
|
const responseJson = responseData
|
|
216
267
|
? (typeof responseData.body === 'string' ? responseData.body : JSON.stringify(responseData.body, null, 2))
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { type ComponentProps, isValidElement,
|
|
4
|
-
import { Mermaid } from './mermaid'
|
|
3
|
+
import { type ComponentProps, isValidElement, lazy, Suspense } from 'react'
|
|
5
4
|
import styles from './code.module.css'
|
|
6
5
|
|
|
6
|
+
const Mermaid = lazy(() => import('./mermaid').then(m => ({ default: m.Mermaid })))
|
|
7
|
+
|
|
7
8
|
type PreProps = ComponentProps<'pre'> & {
|
|
8
9
|
'data-language'?: string
|
|
9
10
|
title?: string
|
|
@@ -21,7 +22,11 @@ export function MdxPre({ children, title, className, ...props }: PreProps) {
|
|
|
21
22
|
if (isValidElement(children)) {
|
|
22
23
|
const childProps = children.props as { className?: string; children?: string }
|
|
23
24
|
if (childProps.className?.includes('language-mermaid') && typeof childProps.children === 'string') {
|
|
24
|
-
return
|
|
25
|
+
return (
|
|
26
|
+
<Suspense fallback={<pre className={styles.pre}><code>{childProps.children}</code></pre>}>
|
|
27
|
+
<Mermaid chart={childProps.children} />
|
|
28
|
+
</Suspense>
|
|
29
|
+
)
|
|
25
30
|
}
|
|
26
31
|
}
|
|
27
32
|
|
|
@@ -1,13 +1,27 @@
|
|
|
1
1
|
import type { ComponentProps } from 'react';
|
|
2
2
|
import { isLocalImage, isSvg, buildOptimizedUrl, DEFAULT_WIDTH } from '@/lib/image-utils';
|
|
3
|
+
import { isStaticMode } from '@/lib/static-mode';
|
|
3
4
|
|
|
4
5
|
type MDXImageProps = ComponentProps<'img'>;
|
|
5
6
|
|
|
7
|
+
function webpUrl(src: string): string {
|
|
8
|
+
return src.replace(/\.[^.]+$/, '.webp');
|
|
9
|
+
}
|
|
10
|
+
|
|
6
11
|
export function MDXImage({ src, alt, ...props }: MDXImageProps) {
|
|
7
12
|
if (!src) return null;
|
|
8
13
|
|
|
9
14
|
const optimize = isLocalImage(src) && !isSvg(src);
|
|
10
|
-
const imgSrc = optimize ? buildOptimizedUrl(src, DEFAULT_WIDTH) : src;
|
|
11
15
|
|
|
16
|
+
if (optimize && isStaticMode()) {
|
|
17
|
+
return (
|
|
18
|
+
<picture>
|
|
19
|
+
<source srcSet={webpUrl(src)} type="image/webp" />
|
|
20
|
+
<img src={src} alt={alt ?? ''} loading='lazy' decoding='async' {...props} />
|
|
21
|
+
</picture>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const imgSrc = optimize ? buildOptimizedUrl(src, DEFAULT_WIDTH) : src;
|
|
12
26
|
return <img src={imgSrc} alt={alt ?? ''} loading='lazy' decoding='async' {...props} />;
|
|
13
27
|
}
|
|
@@ -4,11 +4,12 @@ import { Link } from './link'
|
|
|
4
4
|
import { MdxTable, MdxThead, MdxTbody, MdxTr, MdxTh, MdxTd } from './table'
|
|
5
5
|
import { MdxPre, MdxCode } from './code'
|
|
6
6
|
import { MdxDetails, MdxSummary } from './details'
|
|
7
|
-
import { Mermaid } from './mermaid'
|
|
8
7
|
import { MdxParagraph } from './paragraph'
|
|
9
8
|
import { CalloutContainer, CalloutTitle, CalloutDescription, MdxBlockquote } from '@/components/common/callout'
|
|
10
9
|
import { Tabs } from '@raystack/apsara'
|
|
11
|
-
import { type ComponentProps, useEffect, useState } from 'react'
|
|
10
|
+
import { type ComponentProps, lazy, useEffect, useState, Suspense } from 'react'
|
|
11
|
+
|
|
12
|
+
const LazyMermaid = lazy(() => import('./mermaid').then(m => ({ default: m.Mermaid })))
|
|
12
13
|
|
|
13
14
|
function ClientOnly({ children }: { children: React.ReactNode }) {
|
|
14
15
|
const [mounted, setMounted] = useState(false)
|
|
@@ -23,6 +24,8 @@ MdxTabs.List = Tabs.List
|
|
|
23
24
|
MdxTabs.Tab = Tabs.Tab
|
|
24
25
|
MdxTabs.Content = Tabs.Content
|
|
25
26
|
|
|
27
|
+
// Capitalized keys must stay in sync with MDX_COMPONENT_NAMES in
|
|
28
|
+
// src/lib/mdx-component-names.ts (used for compile-time validation).
|
|
26
29
|
export const mdxComponents: MDXComponents = {
|
|
27
30
|
p: MdxParagraph,
|
|
28
31
|
img: MDXImage,
|
|
@@ -42,7 +45,11 @@ export const mdxComponents: MDXComponents = {
|
|
|
42
45
|
CalloutTitle,
|
|
43
46
|
CalloutDescription,
|
|
44
47
|
Tabs: MdxTabs,
|
|
45
|
-
Mermaid
|
|
48
|
+
Mermaid: (props: { chart: string }) => (
|
|
49
|
+
<Suspense fallback={<pre><code>{props.chart}</code></pre>}>
|
|
50
|
+
<LazyMermaid {...props} />
|
|
51
|
+
</Suspense>
|
|
52
|
+
),
|
|
46
53
|
}
|
|
47
54
|
|
|
48
55
|
export { MDXImage } from './image'
|
|
@@ -7,11 +7,24 @@ import {
|
|
|
7
7
|
} from '@heroicons/react/24/outline';
|
|
8
8
|
import { Command, IconButton, Text } from '@raystack/apsara';
|
|
9
9
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
|
10
|
+
import GithubSlugger from 'github-slugger';
|
|
10
11
|
import { debounce } from 'lodash-es';
|
|
11
|
-
import
|
|
12
|
+
import MiniSearch from 'minisearch';
|
|
13
|
+
import {
|
|
14
|
+
createContext,
|
|
15
|
+
useCallback,
|
|
16
|
+
useContext,
|
|
17
|
+
useEffect,
|
|
18
|
+
useMemo,
|
|
19
|
+
useState,
|
|
20
|
+
type ChangeEvent,
|
|
21
|
+
type ReactNode
|
|
22
|
+
} from 'react';
|
|
12
23
|
import { useNavigate } from 'react-router';
|
|
13
24
|
import { MethodBadge } from '@/components/api/method-badge';
|
|
14
25
|
import { usePageContext } from '@/lib/page-context';
|
|
26
|
+
import { isStaticMode } from '@/lib/static-mode';
|
|
27
|
+
import { searchIndexUrl } from '@/lib/data-urls';
|
|
15
28
|
import { SearchMatchType } from '@/types';
|
|
16
29
|
import styles from './search.module.css';
|
|
17
30
|
|
|
@@ -29,6 +42,127 @@ interface SearchProps {
|
|
|
29
42
|
classNames?: { trigger?: string };
|
|
30
43
|
}
|
|
31
44
|
|
|
45
|
+
interface SearchDocument {
|
|
46
|
+
id: string;
|
|
47
|
+
url: string;
|
|
48
|
+
title: string;
|
|
49
|
+
headings: string;
|
|
50
|
+
body: string;
|
|
51
|
+
type: string;
|
|
52
|
+
section: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let miniSearchInstance: MiniSearch<SearchDocument> | null = null;
|
|
56
|
+
let miniSearchLoading: Promise<MiniSearch<SearchDocument>> | null = null;
|
|
57
|
+
let searchDocuments: SearchDocument[] = [];
|
|
58
|
+
|
|
59
|
+
function loadSearchIndex(): Promise<MiniSearch<SearchDocument>> {
|
|
60
|
+
if (miniSearchInstance) return Promise.resolve(miniSearchInstance);
|
|
61
|
+
if (miniSearchLoading) return miniSearchLoading;
|
|
62
|
+
|
|
63
|
+
miniSearchLoading = fetch(searchIndexUrl())
|
|
64
|
+
.then(res => {
|
|
65
|
+
if (!res.ok) throw new Error(`Failed to load search index: ${res.status}`);
|
|
66
|
+
return res.json() as Promise<SearchDocument[]>;
|
|
67
|
+
})
|
|
68
|
+
.then(docs => {
|
|
69
|
+
searchDocuments = docs;
|
|
70
|
+
const ms = new MiniSearch<SearchDocument>({
|
|
71
|
+
fields: ['title', 'headings', 'body'],
|
|
72
|
+
storeFields: ['url', 'title', 'headings', 'body', 'type', 'section'],
|
|
73
|
+
searchOptions: {
|
|
74
|
+
boost: { title: 10, headings: 5, body: 1 },
|
|
75
|
+
prefix: true,
|
|
76
|
+
fuzzy: 0.2,
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
ms.addAll(docs);
|
|
80
|
+
miniSearchInstance = ms;
|
|
81
|
+
return ms;
|
|
82
|
+
})
|
|
83
|
+
.catch(err => {
|
|
84
|
+
miniSearchLoading = null;
|
|
85
|
+
throw err;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return miniSearchLoading;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function findMatch(
|
|
92
|
+
query: string,
|
|
93
|
+
title: string,
|
|
94
|
+
headings: string,
|
|
95
|
+
body: string,
|
|
96
|
+
): { match: SearchMatchType; snippet: string; slug?: string } {
|
|
97
|
+
if (title.toLowerCase().includes(query)) {
|
|
98
|
+
return { match: SearchMatchType.Title, snippet: title };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const slugger = new GithubSlugger();
|
|
102
|
+
const headingList = headings.split('\n').filter(Boolean);
|
|
103
|
+
for (const h of headingList) {
|
|
104
|
+
const slug = slugger.slug(h);
|
|
105
|
+
if (h.toLowerCase().includes(query)) {
|
|
106
|
+
return { match: SearchMatchType.Heading, snippet: h, slug };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const idx = body.toLowerCase().indexOf(query);
|
|
111
|
+
if (idx >= 0) {
|
|
112
|
+
const start = Math.max(0, idx - 40);
|
|
113
|
+
const end = Math.min(body.length, idx + query.length + 80);
|
|
114
|
+
const snippet = (start > 0 ? '...' : '') + body.slice(start, end).trim() + (end < body.length ? '...' : '');
|
|
115
|
+
return { match: SearchMatchType.Body, snippet };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { match: SearchMatchType.Title, snippet: title };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function searchStatic(query: string, tag?: string): Promise<SearchResult[]> {
|
|
122
|
+
const ms = await loadSearchIndex();
|
|
123
|
+
|
|
124
|
+
if (!query) {
|
|
125
|
+
let docs = searchDocuments.filter(d => d.type === 'page');
|
|
126
|
+
if (tag) docs = docs.filter(d => d.url === `/${tag}` || d.url.startsWith(`/${tag}/`));
|
|
127
|
+
return docs.slice(0, 8).map(d => ({
|
|
128
|
+
id: d.id,
|
|
129
|
+
url: d.url,
|
|
130
|
+
type: d.type,
|
|
131
|
+
content: d.title,
|
|
132
|
+
section: d.section || undefined,
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let results = ms.search(query);
|
|
137
|
+
if (tag) {
|
|
138
|
+
results = results.filter(r => {
|
|
139
|
+
const url = r.url as string;
|
|
140
|
+
return url === `/${tag}` || url.startsWith(`/${tag}/`);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const queryLower = query.toLowerCase();
|
|
145
|
+
return results.slice(0, 20).map(r => {
|
|
146
|
+
const { match, snippet, slug } = findMatch(
|
|
147
|
+
queryLower,
|
|
148
|
+
r.title as string,
|
|
149
|
+
r.headings as string,
|
|
150
|
+
r.body as string,
|
|
151
|
+
);
|
|
152
|
+
const id = match === SearchMatchType.Heading && slug ? `${r.id}#${slug}` : r.id as string;
|
|
153
|
+
const url = match === SearchMatchType.Heading && slug ? `${r.url}#${slug}` : r.url as string;
|
|
154
|
+
return {
|
|
155
|
+
id,
|
|
156
|
+
url,
|
|
157
|
+
type: r.type as string,
|
|
158
|
+
content: r.title as string,
|
|
159
|
+
match,
|
|
160
|
+
snippet,
|
|
161
|
+
section: (r.section as string) || undefined,
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
32
166
|
function buildSearchUrl(query: string, tag?: string): string {
|
|
33
167
|
const params = new URLSearchParams();
|
|
34
168
|
if (query) params.set('query', query);
|
|
@@ -37,8 +171,43 @@ function buildSearchUrl(query: string, tag?: string): string {
|
|
|
37
171
|
return qs ? `/api/search?${qs}` : '/api/search';
|
|
38
172
|
}
|
|
39
173
|
|
|
40
|
-
|
|
174
|
+
interface SearchContextValue {
|
|
175
|
+
open: boolean;
|
|
176
|
+
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const SearchContext = createContext<SearchContextValue | null>(null);
|
|
180
|
+
|
|
181
|
+
export function SearchProvider({ children }: { children: ReactNode }) {
|
|
41
182
|
const [open, setOpen] = useState(false);
|
|
183
|
+
const value = useMemo(() => ({ open, setOpen }), [open]);
|
|
184
|
+
return <SearchContext.Provider value={value}>{children}</SearchContext.Provider>;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function useSearch(): SearchContextValue {
|
|
188
|
+
const ctx = useContext(SearchContext);
|
|
189
|
+
if (!ctx) throw new Error('Search components must be used within <SearchProvider>');
|
|
190
|
+
return ctx;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function Search({ classNames }: SearchProps) {
|
|
194
|
+
const { setOpen } = useSearch();
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<IconButton
|
|
198
|
+
size={3}
|
|
199
|
+
aria-label='Search'
|
|
200
|
+
title='Search (Ctrl/⌘K)'
|
|
201
|
+
onClick={() => setOpen(true)}
|
|
202
|
+
className={classNames?.trigger}
|
|
203
|
+
>
|
|
204
|
+
<MagnifyingGlassIcon width={16} height={16} />
|
|
205
|
+
</IconButton>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function SearchDialog() {
|
|
210
|
+
const { open, setOpen } = useSearch();
|
|
42
211
|
const [search, setSearch] = useState('');
|
|
43
212
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
|
44
213
|
const navigate = useNavigate();
|
|
@@ -64,9 +233,14 @@ export function Search({ classNames }: SearchProps) {
|
|
|
64
233
|
}
|
|
65
234
|
}, [open, updateDebouncedSearch]);
|
|
66
235
|
|
|
236
|
+
const staticMode = isStaticMode();
|
|
237
|
+
|
|
67
238
|
const { data = [], isLoading } = useQuery<SearchResult[]>({
|
|
68
|
-
queryKey: ['search', debouncedSearch, tag],
|
|
239
|
+
queryKey: ['search', debouncedSearch, tag, staticMode],
|
|
69
240
|
queryFn: async ({ signal }) => {
|
|
241
|
+
if (staticMode) {
|
|
242
|
+
return searchStatic(debouncedSearch, tag);
|
|
243
|
+
}
|
|
70
244
|
const res = await fetch(buildSearchUrl(debouncedSearch, tag), { signal });
|
|
71
245
|
if (!res.ok) throw new Error(String(res.status));
|
|
72
246
|
return res.json();
|
|
@@ -98,67 +272,55 @@ export function Search({ classNames }: SearchProps) {
|
|
|
98
272
|
const displayResults = deduplicateByUrl(data);
|
|
99
273
|
|
|
100
274
|
return (
|
|
101
|
-
|
|
102
|
-
<
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
value={result.id}
|
|
151
|
-
onClick={() => onSelect(result.url)}
|
|
152
|
-
className={styles.item}
|
|
153
|
-
>
|
|
154
|
-
<SearchResultItem result={result} query={search} />
|
|
155
|
-
</Command.Item>
|
|
156
|
-
))}
|
|
157
|
-
</Command.Content>
|
|
158
|
-
</Command>
|
|
159
|
-
</Command.DialogContent>
|
|
160
|
-
</Command.Dialog>
|
|
161
|
-
</>
|
|
275
|
+
<Command.Dialog open={open} onOpenChange={setOpen}>
|
|
276
|
+
<Command.DialogContent className={styles.dialogContent}>
|
|
277
|
+
<Command items={displayResults}>
|
|
278
|
+
<Command.Input
|
|
279
|
+
placeholder='Search'
|
|
280
|
+
leadingIcon={<MagnifyingGlassIcon width={16} height={16} />}
|
|
281
|
+
value={search}
|
|
282
|
+
onChange={onSearchChange}
|
|
283
|
+
className={styles.input}
|
|
284
|
+
/>
|
|
285
|
+
|
|
286
|
+
<Command.Content className={styles.list}>
|
|
287
|
+
{isLoading && displayResults.length === 0 && <Command.Empty>Loading...</Command.Empty>}
|
|
288
|
+
{!isLoading &&
|
|
289
|
+
search.length > 0 &&
|
|
290
|
+
displayResults.length === 0 && (
|
|
291
|
+
<Command.Empty>No results found.</Command.Empty>
|
|
292
|
+
)}
|
|
293
|
+
{search.length === 0 &&
|
|
294
|
+
displayResults.length > 0 && (
|
|
295
|
+
<Command.Group>
|
|
296
|
+
<Command.Label>Suggestions</Command.Label>
|
|
297
|
+
{displayResults.slice(0, 8).map((result) => (
|
|
298
|
+
<Command.Item
|
|
299
|
+
key={result.id}
|
|
300
|
+
value={result.id}
|
|
301
|
+
onClick={() => onSelect(result.url)}
|
|
302
|
+
className={styles.item}
|
|
303
|
+
>
|
|
304
|
+
<SearchResultItem result={result} query="" />
|
|
305
|
+
</Command.Item>
|
|
306
|
+
))}
|
|
307
|
+
</Command.Group>
|
|
308
|
+
)}
|
|
309
|
+
{search.length > 0 &&
|
|
310
|
+
displayResults.map((result) => (
|
|
311
|
+
<Command.Item
|
|
312
|
+
key={result.id}
|
|
313
|
+
value={result.id}
|
|
314
|
+
onClick={() => onSelect(result.url)}
|
|
315
|
+
className={styles.item}
|
|
316
|
+
>
|
|
317
|
+
<SearchResultItem result={result} query={search} />
|
|
318
|
+
</Command.Item>
|
|
319
|
+
))}
|
|
320
|
+
</Command.Content>
|
|
321
|
+
</Command>
|
|
322
|
+
</Command.DialogContent>
|
|
323
|
+
</Command.Dialog>
|
|
162
324
|
);
|
|
163
325
|
}
|
|
164
326
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { isStaticMode } from './static-mode';
|
|
2
|
+
|
|
3
|
+
export function pageDataUrl(slug: string[]): string {
|
|
4
|
+
const key = slug.length === 0 ? '' : slug.map(s => encodeURIComponent(s)).join(',');
|
|
5
|
+
if (isStaticMode()) {
|
|
6
|
+
return `/data/pages/${key || 'index'}.json`;
|
|
7
|
+
}
|
|
8
|
+
return key ? `/api/page?slug=${key}` : '/api/page';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function specsUrl(versionDir: string | null): string {
|
|
12
|
+
if (isStaticMode()) {
|
|
13
|
+
const file = versionDir ? `${encodeURIComponent(versionDir)}.json` : 'latest.json';
|
|
14
|
+
return `/data/specs/${file}`;
|
|
15
|
+
}
|
|
16
|
+
return versionDir
|
|
17
|
+
? `/api/specs?version=${encodeURIComponent(versionDir)}`
|
|
18
|
+
: '/api/specs';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function searchIndexUrl(): string {
|
|
22
|
+
return '/data/search.json';
|
|
23
|
+
}
|
package/src/lib/head.tsx
CHANGED
|
@@ -9,8 +9,9 @@ export interface HeadProps {
|
|
|
9
9
|
markdownHref?: string;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
export function Head({ title, description, config, jsonLd, markdownHref }: HeadProps) {
|
|
12
|
+
export function Head({ title, description: pageDescription, config, jsonLd, markdownHref }: HeadProps) {
|
|
13
13
|
const { pathname } = useLocation();
|
|
14
|
+
const description = pageDescription || config.site.description;
|
|
14
15
|
const fullTitle = `${title} | ${config.site.title}`;
|
|
15
16
|
const ogParams = new URLSearchParams({ title });
|
|
16
17
|
if (description) ogParams.set('description', description);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { htmlTagNames } from 'html-tag-names'
|
|
2
|
+
import { svgTagNames } from 'svg-tag-names'
|
|
3
|
+
|
|
4
|
+
// Names of custom components available to MDX content.
|
|
5
|
+
// Keep in sync with the capitalized keys of `mdxComponents` in src/components/mdx/index.tsx.
|
|
6
|
+
// This lives in a React-free module so the CLI/vite-config can import it.
|
|
7
|
+
export const MDX_COMPONENT_NAMES = [
|
|
8
|
+
'Callout',
|
|
9
|
+
'CalloutTitle',
|
|
10
|
+
'CalloutDescription',
|
|
11
|
+
'Tabs',
|
|
12
|
+
'Mermaid',
|
|
13
|
+
] as const
|
|
14
|
+
|
|
15
|
+
export const KNOWN_TAGS = new Set<string>([...htmlTagNames, ...svgTagNames])
|