@raystack/chronicle 0.11.1 → 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/dist/cli/index.js +12 -11
- package/package.json +1 -1
- package/src/components/ui/search.module.css +39 -23
- package/src/components/ui/search.tsx +46 -39
- package/src/lib/image-utils.test.ts +8 -0
- package/src/lib/remark-resolve-images.ts +7 -6
- package/src/server/api/image.ts +4 -2
- package/src/server/api/search.ts +11 -10
- package/src/server/entry-server.tsx +17 -14
- package/src/server/utils/api-markdown.ts +5 -0
- package/src/server/utils/safe-path.test.ts +34 -0
- package/src/server/utils/safe-path.ts +6 -1
- package/src/themes/default/Layout.module.css +5 -1
- package/src/themes/default/Layout.tsx +5 -2
- package/src/themes/default/Page.module.css +22 -4
- package/src/themes/default/Page.tsx +1 -1
- package/src/themes/paper/Page.module.css +7 -2
- package/src/types/content.ts +15 -0
package/dist/cli/index.js
CHANGED
|
@@ -72,17 +72,18 @@ var init_image_utils = () => {};
|
|
|
72
72
|
import path4 from "node:path";
|
|
73
73
|
import { visit } from "unist-util-visit";
|
|
74
74
|
function resolveUrl(src, dir) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
75
|
+
const normalized = src.replace(/\\/g, "/");
|
|
76
|
+
if (/^[a-z][a-z0-9+\-.]*:/i.test(normalized))
|
|
77
|
+
return normalized;
|
|
78
|
+
if (normalized.startsWith("//"))
|
|
79
|
+
return normalized;
|
|
80
|
+
if (normalized.startsWith("#"))
|
|
81
|
+
return normalized;
|
|
82
|
+
if (normalized.startsWith("/_content/"))
|
|
83
|
+
return normalized;
|
|
84
|
+
if (normalized.startsWith("/"))
|
|
85
|
+
return `/_content${normalized}`;
|
|
86
|
+
return `/_content/${path4.posix.normalize(path4.posix.join(dir, normalized))}`;
|
|
86
87
|
}
|
|
87
88
|
function optimizeUrl(url, optimize) {
|
|
88
89
|
if (optimize && isLocalImage(url) && !isSvg(url))
|
package/package.json
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
.dialogContent {
|
|
2
|
-
border-radius: var(--rs-radius-
|
|
2
|
+
border-radius: var(--rs-radius-5);
|
|
3
3
|
padding: 0px;
|
|
4
4
|
width: 80%;
|
|
5
5
|
max-width: 600px;
|
|
6
6
|
position: fixed;
|
|
7
7
|
top: 20%;
|
|
8
|
+
overflow: clip;
|
|
9
|
+
box-shadow: var(--rs-shadow-floating);
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
.input {
|
|
@@ -13,7 +15,8 @@
|
|
|
13
15
|
|
|
14
16
|
.list {
|
|
15
17
|
max-height: 400px;
|
|
16
|
-
|
|
18
|
+
padding: 0 var(--rs-space-2);
|
|
19
|
+
gap: var(--rs-space-2);
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
.list :global([cmdk-group-heading]) {
|
|
@@ -25,35 +28,50 @@
|
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
.item {
|
|
28
|
-
|
|
29
|
-
padding: var(--rs-space-3);
|
|
31
|
+
padding: var(--rs-space-5) var(--rs-space-4);
|
|
30
32
|
gap: var(--rs-space-3);
|
|
31
|
-
border-radius: var(--rs-radius-
|
|
33
|
+
border-radius: var(--rs-radius-3);
|
|
32
34
|
cursor: pointer;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
|
-
|
|
36
37
|
.item[data-selected="true"] {
|
|
37
38
|
background: var(--rs-color-background-base-primary-hover);
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
.itemContent {
|
|
41
42
|
display: flex;
|
|
42
|
-
align-items:
|
|
43
|
-
gap:
|
|
43
|
+
align-items: flex-start;
|
|
44
|
+
gap: var(--rs-space-3);
|
|
44
45
|
flex: 1;
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
.sectionBadge {
|
|
48
|
-
margin-left: auto;
|
|
49
|
-
flex-shrink: 0;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
48
|
.resultText {
|
|
53
49
|
display: flex;
|
|
54
50
|
flex-direction: column;
|
|
55
|
-
gap:
|
|
51
|
+
gap: var(--rs-space-2);
|
|
56
52
|
min-width: 0;
|
|
53
|
+
flex: 1;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.breadcrumb {
|
|
57
|
+
display: flex;
|
|
58
|
+
align-items: center;
|
|
59
|
+
gap: var(--rs-space-2);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.breadcrumbText {
|
|
63
|
+
font-family: var(--rs-font-body);
|
|
64
|
+
font-size: var(--rs-font-size-small);
|
|
65
|
+
font-weight: var(--rs-font-weight-medium);
|
|
66
|
+
line-height: var(--rs-line-height-small);
|
|
67
|
+
letter-spacing: var(--rs-letter-spacing-small);
|
|
68
|
+
color: var(--rs-color-foreground-base-primary);
|
|
69
|
+
white-space: nowrap;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.breadcrumbSeparator {
|
|
73
|
+
color: var(--rs-color-foreground-base-tertiary);
|
|
74
|
+
font-size: var(--rs-font-size-small);
|
|
57
75
|
}
|
|
58
76
|
|
|
59
77
|
.headingText {
|
|
@@ -64,10 +82,6 @@
|
|
|
64
82
|
color: var(--rs-color-foreground-accent-primary-hover);
|
|
65
83
|
}
|
|
66
84
|
|
|
67
|
-
.separator {
|
|
68
|
-
color: var(--rs-color-foreground-base-secondary);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
85
|
.pageText {
|
|
72
86
|
color: var(--rs-color-foreground-base-primary);
|
|
73
87
|
}
|
|
@@ -77,14 +91,15 @@
|
|
|
77
91
|
}
|
|
78
92
|
|
|
79
93
|
.icon {
|
|
80
|
-
width:
|
|
81
|
-
height:
|
|
94
|
+
width: 16px;
|
|
95
|
+
height: 16px;
|
|
82
96
|
color: var(--rs-color-foreground-base-secondary);
|
|
83
97
|
flex-shrink: 0;
|
|
98
|
+
margin-top: 1px;
|
|
84
99
|
}
|
|
85
100
|
|
|
86
101
|
.itemContent :global([class*="badge-module"]) {
|
|
87
|
-
min-width:
|
|
102
|
+
min-width: auto;
|
|
88
103
|
justify-content: center;
|
|
89
104
|
}
|
|
90
105
|
|
|
@@ -95,14 +110,15 @@
|
|
|
95
110
|
.snippetText {
|
|
96
111
|
font-size: var(--rs-font-size-mini);
|
|
97
112
|
line-height: var(--rs-line-height-mini);
|
|
98
|
-
|
|
113
|
+
letter-spacing: var(--rs-letter-spacing-mini);
|
|
114
|
+
color: var(--rs-color-foreground-base-secondary);
|
|
99
115
|
overflow: hidden;
|
|
100
116
|
text-overflow: ellipsis;
|
|
101
117
|
white-space: nowrap;
|
|
102
118
|
}
|
|
103
119
|
|
|
104
120
|
.matchHighlight {
|
|
105
|
-
color: var(--rs-color-foreground-
|
|
121
|
+
color: var(--rs-color-foreground-base-primary);
|
|
106
122
|
font-weight: var(--rs-font-weight-medium);
|
|
107
123
|
}
|
|
108
124
|
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import {
|
|
2
2
|
DocumentIcon,
|
|
3
3
|
HashtagIcon,
|
|
4
|
-
MagnifyingGlassIcon
|
|
4
|
+
MagnifyingGlassIcon,
|
|
5
|
+
CodeBracketIcon,
|
|
6
|
+
ChevronRightIcon
|
|
5
7
|
} from '@heroicons/react/24/outline';
|
|
6
|
-
import {
|
|
8
|
+
import { Command, IconButton, Text } from '@raystack/apsara';
|
|
7
9
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
|
8
10
|
import { debounce } from 'lodash-es';
|
|
9
11
|
import { useCallback, useEffect, useMemo, useState, type ChangeEvent } from 'react';
|
|
10
12
|
import { useNavigate } from 'react-router';
|
|
11
13
|
import { MethodBadge } from '@/components/api/method-badge';
|
|
12
14
|
import { usePageContext } from '@/lib/page-context';
|
|
15
|
+
import { SearchMatchType } from '@/types';
|
|
13
16
|
import styles from './search.module.css';
|
|
14
17
|
|
|
15
18
|
interface SearchResult {
|
|
@@ -17,7 +20,7 @@ interface SearchResult {
|
|
|
17
20
|
url: string;
|
|
18
21
|
type: string;
|
|
19
22
|
content: string;
|
|
20
|
-
match?:
|
|
23
|
+
match?: SearchMatchType;
|
|
21
24
|
snippet?: string;
|
|
22
25
|
section?: string;
|
|
23
26
|
}
|
|
@@ -135,15 +138,7 @@ export function Search({ classNames }: SearchProps) {
|
|
|
135
138
|
onClick={() => onSelect(result.url)}
|
|
136
139
|
className={styles.item}
|
|
137
140
|
>
|
|
138
|
-
<
|
|
139
|
-
{getResultIcon(result)}
|
|
140
|
-
<Text className={styles.pageText}>
|
|
141
|
-
<HighlightedText
|
|
142
|
-
html={stripMethod(result.content)}
|
|
143
|
-
/>
|
|
144
|
-
</Text>
|
|
145
|
-
{result.section && <Badge size="small" className={styles.sectionBadge}>{result.section}</Badge>}
|
|
146
|
-
</div>
|
|
141
|
+
<SearchResultItem result={result} query="" />
|
|
147
142
|
</Command.Item>
|
|
148
143
|
))}
|
|
149
144
|
</Command.Group>
|
|
@@ -156,25 +151,7 @@ export function Search({ classNames }: SearchProps) {
|
|
|
156
151
|
onClick={() => onSelect(result.url)}
|
|
157
152
|
className={styles.item}
|
|
158
153
|
>
|
|
159
|
-
<
|
|
160
|
-
{getResultIcon(result)}
|
|
161
|
-
<div className={styles.resultText}>
|
|
162
|
-
<Text className={styles.pageText}>
|
|
163
|
-
<HighlightQuery text={stripMethod(result.content)} query={search} />
|
|
164
|
-
</Text>
|
|
165
|
-
{result.snippet && result.match === 'heading' && (
|
|
166
|
-
<Text className={styles.snippetText}>
|
|
167
|
-
# <HighlightQuery text={result.snippet} query={search} />
|
|
168
|
-
</Text>
|
|
169
|
-
)}
|
|
170
|
-
{result.snippet && result.match === 'body' && (
|
|
171
|
-
<Text className={styles.snippetText}>
|
|
172
|
-
<HighlightQuery text={result.snippet} query={search} />
|
|
173
|
-
</Text>
|
|
174
|
-
)}
|
|
175
|
-
</div>
|
|
176
|
-
{result.section && <Badge size="small" className={styles.sectionBadge}>{result.section}</Badge>}
|
|
177
|
-
</div>
|
|
154
|
+
<SearchResultItem result={result} query={search} />
|
|
178
155
|
</Command.Item>
|
|
179
156
|
))}
|
|
180
157
|
</Command.Content>
|
|
@@ -185,6 +162,38 @@ export function Search({ classNames }: SearchProps) {
|
|
|
185
162
|
);
|
|
186
163
|
}
|
|
187
164
|
|
|
165
|
+
function SearchResultItem({ result, query }: { result: SearchResult; query: string }) {
|
|
166
|
+
const method = extractMethod(result.content);
|
|
167
|
+
const title = stripMethod(result.content);
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<div className={styles.itemContent}>
|
|
171
|
+
{getResultIcon(result)}
|
|
172
|
+
<div className={styles.resultText}>
|
|
173
|
+
<div className={styles.breadcrumb}>
|
|
174
|
+
{result.section && (
|
|
175
|
+
<>
|
|
176
|
+
<span className={styles.breadcrumbText}>{result.section}</span>
|
|
177
|
+
<ChevronRightIcon width={12} height={12} className={styles.breadcrumbSeparator} />
|
|
178
|
+
</>
|
|
179
|
+
)}
|
|
180
|
+
{method && <MethodBadge method={method} size='micro' />}
|
|
181
|
+
<Text className={styles.breadcrumbText}>
|
|
182
|
+
{query ? <HighlightQuery text={title} query={query} /> : <HighlightedText html={title} />}
|
|
183
|
+
</Text>
|
|
184
|
+
</div>
|
|
185
|
+
{result.snippet && (
|
|
186
|
+
<Text className={styles.snippetText}>
|
|
187
|
+
{query
|
|
188
|
+
? <HighlightQuery text={result.snippet} query={query} />
|
|
189
|
+
: result.snippet}
|
|
190
|
+
</Text>
|
|
191
|
+
)}
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
188
197
|
function deduplicateByUrl(results: SearchResult[]): SearchResult[] {
|
|
189
198
|
const seen = new Set<string>();
|
|
190
199
|
return results.filter(r => {
|
|
@@ -233,15 +242,13 @@ function HighlightQuery({ text, query }: { text: string; query: string }) {
|
|
|
233
242
|
}
|
|
234
243
|
|
|
235
244
|
function getResultIcon(result: SearchResult): React.ReactNode {
|
|
236
|
-
if (
|
|
237
|
-
return
|
|
238
|
-
<DocumentIcon className={styles.icon} />
|
|
239
|
-
) : (
|
|
240
|
-
<HashtagIcon className={styles.icon} />
|
|
241
|
-
);
|
|
245
|
+
if (result.url.startsWith('/apis/')) {
|
|
246
|
+
return <CodeBracketIcon className={styles.icon} />;
|
|
242
247
|
}
|
|
243
|
-
|
|
244
|
-
|
|
248
|
+
if (result.match === SearchMatchType.Heading) {
|
|
249
|
+
return <HashtagIcon className={styles.icon} />;
|
|
250
|
+
}
|
|
251
|
+
return <DocumentIcon className={styles.icon} />;
|
|
245
252
|
}
|
|
246
253
|
|
|
247
254
|
function getPageTitle(url: string): string {
|
|
@@ -53,6 +53,14 @@ describe('buildOptimizedUrl', () => {
|
|
|
53
53
|
});
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
+
describe('buildOptimizedUrl with backslashes', () => {
|
|
57
|
+
test('backslashes in input are not double-encoded', () => {
|
|
58
|
+
const url = buildOptimizedUrl('/_content/docs/imgs\\screenshot.png', 640);
|
|
59
|
+
expect(url).toContain('imgs%5Cscreenshot.png');
|
|
60
|
+
expect(url).not.toContain('%255C');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
56
64
|
describe('constants', () => {
|
|
57
65
|
test('ALLOWED_WIDTHS is sorted ascending', () => {
|
|
58
66
|
for (let i = 1; i < ALLOWED_WIDTHS.length; i++) {
|
|
@@ -8,13 +8,14 @@ import { MdxNodeType } from './mdx-utils'
|
|
|
8
8
|
import { isLocalImage, isSvg, buildOptimizedUrl, DEFAULT_WIDTH } from './image-utils'
|
|
9
9
|
|
|
10
10
|
function resolveUrl(src: string, dir: string): string {
|
|
11
|
-
|
|
12
|
-
if (
|
|
13
|
-
if (
|
|
14
|
-
if (
|
|
11
|
+
const normalized = src.replace(/\\/g, '/')
|
|
12
|
+
if (/^[a-z][a-z0-9+\-.]*:/i.test(normalized)) return normalized
|
|
13
|
+
if (normalized.startsWith('//')) return normalized
|
|
14
|
+
if (normalized.startsWith('#')) return normalized
|
|
15
|
+
if (normalized.startsWith('/_content/')) return normalized
|
|
15
16
|
|
|
16
|
-
if (
|
|
17
|
-
return `/_content/${path.posix.normalize(path.posix.join(dir,
|
|
17
|
+
if (normalized.startsWith('/')) return `/_content${normalized}`
|
|
18
|
+
return `/_content/${path.posix.normalize(path.posix.join(dir, normalized))}`
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
interface RemarkResolveImagesOptions {
|
package/src/server/api/image.ts
CHANGED
|
@@ -52,14 +52,16 @@ async function evictIfNeeded(storage: ReturnType<typeof useStorage>) {
|
|
|
52
52
|
export default defineHandler(async event => {
|
|
53
53
|
const storage = useStorage(STORAGE_KEY)
|
|
54
54
|
|
|
55
|
-
const
|
|
55
|
+
const rawUrl = event.url.searchParams.get('url')
|
|
56
56
|
const wParam = event.url.searchParams.get('w')
|
|
57
57
|
const qParam = event.url.searchParams.get('q')
|
|
58
58
|
|
|
59
|
-
if (!
|
|
59
|
+
if (!rawUrl || !wParam) {
|
|
60
60
|
throw new HTTPError({ status: StatusCodes.BAD_REQUEST, message: 'Missing url or w parameter' })
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
const url = rawUrl.replace(/\\/g, '/')
|
|
64
|
+
|
|
63
65
|
if (!url.startsWith('/_content/')) {
|
|
64
66
|
throw new HTTPError({ status: StatusCodes.BAD_REQUEST, message: 'Only local content images allowed' })
|
|
65
67
|
}
|
package/src/server/api/search.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { getApiConfigsForVersion, loadConfig } from '@/lib/config';
|
|
|
7
7
|
import { loadApiSpecs } from '@/lib/openapi';
|
|
8
8
|
import { extractFrontmatter, getPageSearchContent, getPagesForVersion } from '@/lib/source';
|
|
9
9
|
import { LATEST_CONTEXT, type VersionContext } from '@/lib/version-source';
|
|
10
|
+
import { SearchResultType, SearchMatchType } from '@/types';
|
|
10
11
|
|
|
11
12
|
interface SearchDocument {
|
|
12
13
|
id: string;
|
|
@@ -14,7 +15,7 @@ interface SearchDocument {
|
|
|
14
15
|
title: string;
|
|
15
16
|
headings: string;
|
|
16
17
|
body: string;
|
|
17
|
-
type:
|
|
18
|
+
type: SearchResultType;
|
|
18
19
|
section: string;
|
|
19
20
|
}
|
|
20
21
|
|
|
@@ -104,7 +105,7 @@ async function buildDocs(ctx: VersionContext): Promise<SearchDocument[]> {
|
|
|
104
105
|
title: fm.title,
|
|
105
106
|
headings,
|
|
106
107
|
body: [fm.description ?? '', body].join(' '),
|
|
107
|
-
type:
|
|
108
|
+
type: SearchResultType.Page,
|
|
108
109
|
section: entry?.label ?? dir ?? '',
|
|
109
110
|
});
|
|
110
111
|
}
|
|
@@ -128,7 +129,7 @@ async function buildDocs(ctx: VersionContext): Promise<SearchDocument[]> {
|
|
|
128
129
|
title: `${method.toUpperCase()} ${op.summary ?? opId}`,
|
|
129
130
|
headings: op.summary ?? opId,
|
|
130
131
|
body: [op.description ?? '', pathStr, method.toUpperCase()].join(' '),
|
|
131
|
-
type:
|
|
132
|
+
type: SearchResultType.Api,
|
|
132
133
|
section: spec.name,
|
|
133
134
|
});
|
|
134
135
|
}
|
|
@@ -144,9 +145,9 @@ function findMatch(
|
|
|
144
145
|
title: string,
|
|
145
146
|
headings: string,
|
|
146
147
|
body: string,
|
|
147
|
-
): { match:
|
|
148
|
+
): { match: SearchMatchType; snippet: string; slug?: string } {
|
|
148
149
|
if (title.toLowerCase().includes(query)) {
|
|
149
|
-
return { match:
|
|
150
|
+
return { match: SearchMatchType.Title, snippet: title };
|
|
150
151
|
}
|
|
151
152
|
|
|
152
153
|
const slugger = new GithubSlugger();
|
|
@@ -154,7 +155,7 @@ function findMatch(
|
|
|
154
155
|
for (const h of headingList) {
|
|
155
156
|
const slug = slugger.slug(h);
|
|
156
157
|
if (h.toLowerCase().includes(query)) {
|
|
157
|
-
return { match:
|
|
158
|
+
return { match: SearchMatchType.Heading, snippet: h, slug };
|
|
158
159
|
}
|
|
159
160
|
}
|
|
160
161
|
|
|
@@ -163,10 +164,10 @@ function findMatch(
|
|
|
163
164
|
const start = Math.max(0, idx - 40);
|
|
164
165
|
const end = Math.min(body.length, idx + query.length + 80);
|
|
165
166
|
const snippet = (start > 0 ? '...' : '') + body.slice(start, end).trim() + (end < body.length ? '...' : '');
|
|
166
|
-
return { match:
|
|
167
|
+
return { match: SearchMatchType.Body, snippet };
|
|
167
168
|
}
|
|
168
169
|
|
|
169
|
-
return { match:
|
|
170
|
+
return { match: SearchMatchType.Title, snippet: title };
|
|
170
171
|
}
|
|
171
172
|
|
|
172
173
|
function resolveCtx(tag: string | null): VersionContext {
|
|
@@ -218,8 +219,8 @@ export default defineHandler(async event => {
|
|
|
218
219
|
const queryLower = query.toLowerCase();
|
|
219
220
|
return Response.json((result.rows ?? []).map(r => {
|
|
220
221
|
const { match, snippet, slug } = findMatch(queryLower, r.title as string, r.headings as string, r.body as string);
|
|
221
|
-
const id = match ===
|
|
222
|
-
const url = match ===
|
|
222
|
+
const id = match === SearchMatchType.Heading && slug ? `${r.id}#${slug}` : r.id as string;
|
|
223
|
+
const url = match === SearchMatchType.Heading && slug ? `${r.url}#${slug}` : r.url as string;
|
|
223
224
|
return {
|
|
224
225
|
id,
|
|
225
226
|
url,
|
|
@@ -14,6 +14,7 @@ import { getFirstApiUrl } from '@/lib/api-routes';
|
|
|
14
14
|
import { StatusCodes } from 'http-status-codes';
|
|
15
15
|
import { resolveDocsRedirect } from '@/lib/tree-utils';
|
|
16
16
|
import { isLocalImage, isSvg, buildOptimizedUrl, DEFAULT_WIDTH } from '@/lib/image-utils';
|
|
17
|
+
import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
|
|
17
18
|
import { useNitroApp } from 'nitro/app';
|
|
18
19
|
import { App } from './App';
|
|
19
20
|
|
|
@@ -136,20 +137,22 @@ export default {
|
|
|
136
137
|
</head>
|
|
137
138
|
<body>
|
|
138
139
|
<div id="root">
|
|
139
|
-
<
|
|
140
|
-
<
|
|
141
|
-
<
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
140
|
+
<QueryClientProvider client={new QueryClient()}>
|
|
141
|
+
<StaticRouter location={pathname}>
|
|
142
|
+
<ReactRouterProvider>
|
|
143
|
+
<PageProvider
|
|
144
|
+
initialConfig={config}
|
|
145
|
+
initialTree={tree}
|
|
146
|
+
initialPage={pageData}
|
|
147
|
+
initialApiSpecs={apiSpecs}
|
|
148
|
+
initialVersion={route.version}
|
|
149
|
+
loadMdx={async () => ({ content: null, toc: [] })}
|
|
150
|
+
>
|
|
151
|
+
<App />
|
|
152
|
+
</PageProvider>
|
|
153
|
+
</ReactRouterProvider>
|
|
154
|
+
</StaticRouter>
|
|
155
|
+
</QueryClientProvider>
|
|
153
156
|
</div>
|
|
154
157
|
</body>
|
|
155
158
|
</html>,
|
|
@@ -41,6 +41,11 @@ function generateApiMarkdown(
|
|
|
41
41
|
lines.push(operation.description)
|
|
42
42
|
lines.push('')
|
|
43
43
|
}
|
|
44
|
+
if (operation.externalDocs?.url) {
|
|
45
|
+
const label = operation.externalDocs.description || 'external documentation'
|
|
46
|
+
lines.push(`Read more about this operation in the [${label}](${operation.externalDocs.url}).`)
|
|
47
|
+
lines.push('')
|
|
48
|
+
}
|
|
44
49
|
lines.push(`\`${method}\` \`${path}\``)
|
|
45
50
|
lines.push('')
|
|
46
51
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { safePath } from '@/server/utils/safe-path';
|
|
4
|
+
|
|
5
|
+
describe('safePath', () => {
|
|
6
|
+
const base = '/app/content';
|
|
7
|
+
|
|
8
|
+
test('resolves valid path within base', () => {
|
|
9
|
+
expect(safePath(base, '/docs/intro.mdx')).toBe(path.resolve(base, 'docs/intro.mdx'));
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('returns null for path traversal', () => {
|
|
13
|
+
expect(safePath(base, '/../etc/passwd')).toBeNull();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('normalizes backslashes to forward slashes', () => {
|
|
17
|
+
const result = safePath(base, '/docs\\imgs\\screenshot.png');
|
|
18
|
+
expect(result).toBe(path.resolve(base, 'docs/imgs/screenshot.png'));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('decodes URI-encoded characters', () => {
|
|
22
|
+
const result = safePath(base, '/docs/my%20image.png');
|
|
23
|
+
expect(result).toBe(path.resolve(base, 'docs/my image.png'));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('strips query string before resolving', () => {
|
|
27
|
+
const result = safePath(base, '/docs/img.png?v=1');
|
|
28
|
+
expect(result).toBe(path.resolve(base, 'docs/img.png'));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('returns null for malformed percent-encoding', () => {
|
|
32
|
+
expect(safePath(base, '/docs/%E0%A4%')).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -5,7 +5,12 @@ import path from 'node:path';
|
|
|
5
5
|
* Returns null if the resolved path escapes the base directory.
|
|
6
6
|
*/
|
|
7
7
|
export function safePath(baseDir: string, urlPath: string): string | null {
|
|
8
|
-
|
|
8
|
+
let decoded: string;
|
|
9
|
+
try {
|
|
10
|
+
decoded = decodeURIComponent(urlPath.split('?')[0]).replace(/\\/g, '/');
|
|
11
|
+
} catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
9
14
|
const resolved = path.resolve(baseDir, '.' + decoded);
|
|
10
15
|
if (
|
|
11
16
|
!resolved.startsWith(path.resolve(baseDir) + path.sep) &&
|
|
@@ -171,11 +171,15 @@
|
|
|
171
171
|
}
|
|
172
172
|
|
|
173
173
|
.groupItems {
|
|
174
|
-
padding-left:
|
|
174
|
+
padding-left: 0;
|
|
175
175
|
padding-bottom: var(--rs-space-3);
|
|
176
176
|
gap: 0;
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
.navGroup:not([data-depth='0']) .groupItems {
|
|
180
|
+
padding-left: var(--rs-space-4);
|
|
181
|
+
}
|
|
182
|
+
|
|
179
183
|
.navGroup {
|
|
180
184
|
margin-top: 0;
|
|
181
185
|
}
|
|
@@ -28,8 +28,11 @@ import type { ThemeLayoutProps } from '@/types';
|
|
|
28
28
|
import styles from './Layout.module.css';
|
|
29
29
|
import { OpenInAI } from './OpenInAI';
|
|
30
30
|
import { SidebarLogo } from './SidebarLogo';
|
|
31
|
+
|
|
31
32
|
import { VersionSwitcher } from './VersionSwitcher';
|
|
32
33
|
|
|
34
|
+
const MAX_SIDEBAR_DEPTH = 3;
|
|
35
|
+
|
|
33
36
|
const iconMap: Record<string, React.ReactNode> = {
|
|
34
37
|
'rectangle-stack': <RectangleStackIcon width={16} height={16} />,
|
|
35
38
|
'method-get': <MethodBadge method='GET' size='micro' />,
|
|
@@ -249,7 +252,7 @@ function SidebarNode({
|
|
|
249
252
|
}
|
|
250
253
|
|
|
251
254
|
if (item.type === 'folder') {
|
|
252
|
-
if (depth >
|
|
255
|
+
if (depth > MAX_SIDEBAR_DEPTH) return null;
|
|
253
256
|
const icon = typeof item.icon === 'string' ? iconMap[item.icon] : item.icon;
|
|
254
257
|
const hasActiveChild = hasActiveDescendant(item, pathname);
|
|
255
258
|
return (
|
|
@@ -258,7 +261,7 @@ function SidebarNode({
|
|
|
258
261
|
data-depth={depth}
|
|
259
262
|
label={item.name?.toString() ?? ''}
|
|
260
263
|
leadingIcon={icon ?? undefined}
|
|
261
|
-
collapsible={depth
|
|
264
|
+
collapsible={depth >= 1}
|
|
262
265
|
defaultOpen={hasActiveChild}
|
|
263
266
|
classNames={{
|
|
264
267
|
items: styles.groupItems,
|
|
@@ -39,15 +39,22 @@
|
|
|
39
39
|
.content h1 {
|
|
40
40
|
font-size: var(--rs-font-size-t4);
|
|
41
41
|
line-height: var(--rs-line-height-t4);
|
|
42
|
-
margin
|
|
43
|
-
margin-bottom: var(--rs-space-10);
|
|
42
|
+
margin: var(--rs-space-10) 0;
|
|
44
43
|
}
|
|
45
44
|
|
|
46
45
|
.content h2 {
|
|
47
46
|
font-size: var(--rs-font-size-t3);
|
|
48
47
|
line-height: var(--rs-line-height-t3);
|
|
49
|
-
margin-top: var(--rs-space-
|
|
50
|
-
margin-bottom: var(--rs-space-
|
|
48
|
+
margin-top: var(--rs-space-10);
|
|
49
|
+
margin-bottom: var(--rs-space-7);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.content p + h2,
|
|
53
|
+
.content ul + h2,
|
|
54
|
+
.content ol + h2,
|
|
55
|
+
.content div + h2,
|
|
56
|
+
.content table + h2 {
|
|
57
|
+
margin-top: var(--rs-space-13);
|
|
51
58
|
}
|
|
52
59
|
|
|
53
60
|
.content h3 {
|
|
@@ -77,6 +84,7 @@
|
|
|
77
84
|
font-style: normal;
|
|
78
85
|
font-weight: var(--rs-font-weight-regular);
|
|
79
86
|
line-height: 171.429%;
|
|
87
|
+
margin-bottom: var(--rs-space-7);
|
|
80
88
|
}
|
|
81
89
|
|
|
82
90
|
.content ul,
|
|
@@ -90,6 +98,15 @@
|
|
|
90
98
|
margin: var(--rs-space-2) 0;
|
|
91
99
|
}
|
|
92
100
|
|
|
101
|
+
.content table td {
|
|
102
|
+
font-size: var(--rs-font-size-regular);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.content table th {
|
|
106
|
+
font-size: var(--rs-font-size-regular);
|
|
107
|
+
font-weight: var(--rs-font-weight-medium);
|
|
108
|
+
}
|
|
109
|
+
|
|
93
110
|
.content a {
|
|
94
111
|
font-size: inherit;
|
|
95
112
|
}
|
|
@@ -101,6 +118,7 @@
|
|
|
101
118
|
.content img {
|
|
102
119
|
max-width: 100%;
|
|
103
120
|
height: auto;
|
|
121
|
+
margin: var(--rs-space-7) 0;
|
|
104
122
|
}
|
|
105
123
|
|
|
106
124
|
.content table {
|
|
@@ -12,7 +12,7 @@ export function Page({ page }: ThemePageProps) {
|
|
|
12
12
|
<Flex className={styles.page}>
|
|
13
13
|
<article className={styles.article} data-article-content>
|
|
14
14
|
{page.frontmatter.title && (
|
|
15
|
-
<Headline size="
|
|
15
|
+
<Headline size="t4" render={<h1 />} className={styles.title}>
|
|
16
16
|
{page.frontmatter.title}
|
|
17
17
|
</Headline>
|
|
18
18
|
)}
|
|
@@ -94,9 +94,9 @@
|
|
|
94
94
|
|
|
95
95
|
.articleTitle {
|
|
96
96
|
font-family: var(--paper-font-body);
|
|
97
|
-
font-size: var(--rs-
|
|
97
|
+
font-size: var(--rs-font-size-t4);
|
|
98
98
|
font-weight: var(--rs-font-weight-medium);
|
|
99
|
-
line-height: var(--rs-
|
|
99
|
+
line-height: var(--rs-line-height-t4);
|
|
100
100
|
letter-spacing: var(--rs-letter-spacing-t1);
|
|
101
101
|
text-align: center;
|
|
102
102
|
color: var(--rs-color-foreground-base-primary);
|
|
@@ -213,6 +213,11 @@
|
|
|
213
213
|
text-overflow: unset;
|
|
214
214
|
word-wrap: break-word;
|
|
215
215
|
vertical-align: top;
|
|
216
|
+
font-size: var(--rs-font-size-regular);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.content table th {
|
|
220
|
+
font-weight: var(--rs-font-weight-medium);
|
|
216
221
|
}
|
|
217
222
|
|
|
218
223
|
.content a {
|
package/src/types/content.ts
CHANGED
|
@@ -24,6 +24,21 @@ export interface PageNav {
|
|
|
24
24
|
next: PageNavLink | null
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
export const SearchResultType = {
|
|
28
|
+
Page: 'page',
|
|
29
|
+
Api: 'api',
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
export type SearchResultType = (typeof SearchResultType)[keyof typeof SearchResultType];
|
|
33
|
+
|
|
34
|
+
export const SearchMatchType = {
|
|
35
|
+
Title: 'title',
|
|
36
|
+
Heading: 'heading',
|
|
37
|
+
Body: 'body',
|
|
38
|
+
} as const;
|
|
39
|
+
|
|
40
|
+
export type SearchMatchType = (typeof SearchMatchType)[keyof typeof SearchMatchType];
|
|
41
|
+
|
|
27
42
|
export interface Page extends PageNav {
|
|
28
43
|
slug: string[]
|
|
29
44
|
frontmatter: Frontmatter
|