radiant-docs 0.1.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/dist/index.js +312 -0
- package/package.json +38 -0
- package/template/.vscode/extensions.json +4 -0
- package/template/.vscode/launch.json +11 -0
- package/template/astro.config.mjs +216 -0
- package/template/ec.config.mjs +51 -0
- package/template/package-lock.json +12546 -0
- package/template/package.json +51 -0
- package/template/public/favicon.svg +9 -0
- package/template/src/assets/icons/check.svg +33 -0
- package/template/src/assets/icons/danger.svg +37 -0
- package/template/src/assets/icons/info.svg +36 -0
- package/template/src/assets/icons/lightbulb.svg +74 -0
- package/template/src/assets/icons/warning.svg +37 -0
- package/template/src/components/Header.astro +176 -0
- package/template/src/components/MdxPage.astro +49 -0
- package/template/src/components/OpenApiPage.astro +270 -0
- package/template/src/components/Search.astro +362 -0
- package/template/src/components/Sidebar.astro +19 -0
- package/template/src/components/SidebarDropdown.astro +149 -0
- package/template/src/components/SidebarGroup.astro +51 -0
- package/template/src/components/SidebarLink.astro +56 -0
- package/template/src/components/SidebarMenu.astro +46 -0
- package/template/src/components/SidebarSubgroup.astro +136 -0
- package/template/src/components/TableOfContents.astro +480 -0
- package/template/src/components/ThemeSwitcher.astro +84 -0
- package/template/src/components/endpoint/PlaygroundBar.astro +68 -0
- package/template/src/components/endpoint/PlaygroundButton.astro +44 -0
- package/template/src/components/endpoint/PlaygroundField.astro +54 -0
- package/template/src/components/endpoint/PlaygroundForm.astro +203 -0
- package/template/src/components/endpoint/RequestSnippets.astro +308 -0
- package/template/src/components/endpoint/ResponseDisplay.astro +177 -0
- package/template/src/components/endpoint/ResponseFields.astro +224 -0
- package/template/src/components/endpoint/ResponseSnippets.astro +247 -0
- package/template/src/components/sidebar/SidebarEndpointLink.astro +51 -0
- package/template/src/components/sidebar/SidebarOpenApi.astro +207 -0
- package/template/src/components/ui/Field.astro +69 -0
- package/template/src/components/ui/Tag.astro +5 -0
- package/template/src/components/ui/demo/CodeDemo.astro +15 -0
- package/template/src/components/ui/demo/Demo.astro +3 -0
- package/template/src/components/ui/demo/UiDisplay.astro +13 -0
- package/template/src/components/user/Accordian.astro +69 -0
- package/template/src/components/user/AccordianGroup.astro +13 -0
- package/template/src/components/user/Callout.astro +101 -0
- package/template/src/components/user/Step.astro +51 -0
- package/template/src/components/user/Steps.astro +9 -0
- package/template/src/components/user/Tab.astro +25 -0
- package/template/src/components/user/Tabs.astro +122 -0
- package/template/src/content.config.ts +11 -0
- package/template/src/entrypoint.ts +9 -0
- package/template/src/layouts/Layout.astro +92 -0
- package/template/src/lib/component-error.ts +163 -0
- package/template/src/lib/frontmatter-schema.ts +9 -0
- package/template/src/lib/oas.ts +24 -0
- package/template/src/lib/pagefind.ts +88 -0
- package/template/src/lib/routes.ts +316 -0
- package/template/src/lib/utils.ts +59 -0
- package/template/src/lib/validation.ts +1097 -0
- package/template/src/pages/[...slug].astro +77 -0
- package/template/src/styles/global.css +209 -0
- package/template/tsconfig.json +5 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Layout from "../layouts/Layout.astro";
|
|
3
|
+
import type { OpenApiRoute } from "../lib/routes";
|
|
4
|
+
import { loadOpenApiSpec } from "../lib/validation";
|
|
5
|
+
import RequestSnippets from "./endpoint/RequestSnippets.astro";
|
|
6
|
+
import Oas from "oas";
|
|
7
|
+
import type { HttpMethods } from "oas/types";
|
|
8
|
+
import ResponseSnippets from "./endpoint/ResponseSnippets.astro";
|
|
9
|
+
import { renderMarkdown } from "../lib/utils";
|
|
10
|
+
import Field from "./ui/Field.astro";
|
|
11
|
+
import ResponseFields from "./endpoint/ResponseFields.astro";
|
|
12
|
+
import PlaygroundBar from "./endpoint/PlaygroundBar.astro";
|
|
13
|
+
import PlaygroundForm from "./endpoint/PlaygroundForm.astro";
|
|
14
|
+
import PlaygroundButton from "./endpoint/PlaygroundButton.astro";
|
|
15
|
+
import { getOasInstance } from "../lib/oas";
|
|
16
|
+
|
|
17
|
+
interface Props {
|
|
18
|
+
route: OpenApiRoute;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const { route } = Astro.props;
|
|
22
|
+
|
|
23
|
+
// Load and parse the OpenAPI file (handles both URLs and local files)
|
|
24
|
+
const api = await getOasInstance(route.filePath);
|
|
25
|
+
|
|
26
|
+
const definition = api.getDefinition();
|
|
27
|
+
const serverUrl = definition.servers?.[0]?.url;
|
|
28
|
+
|
|
29
|
+
const operation = api.operation(
|
|
30
|
+
route.openApiPath,
|
|
31
|
+
route.openApiMethod as HttpMethods
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const title = route.title;
|
|
35
|
+
const responses = operation.schema.responses;
|
|
36
|
+
const description = operation.getDescription();
|
|
37
|
+
const formattedDescription = description
|
|
38
|
+
? await renderMarkdown(description)
|
|
39
|
+
: null;
|
|
40
|
+
|
|
41
|
+
export const headers: { [key: string]: string } = {
|
|
42
|
+
header: "Header",
|
|
43
|
+
cookie: "Cookie",
|
|
44
|
+
path: "Path Parameters",
|
|
45
|
+
query: "Query Parameters",
|
|
46
|
+
body: "Body",
|
|
47
|
+
};
|
|
48
|
+
export interface Field {
|
|
49
|
+
name: string;
|
|
50
|
+
required: boolean;
|
|
51
|
+
type: string;
|
|
52
|
+
description: string;
|
|
53
|
+
enum?: string[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface RequestFields {
|
|
57
|
+
header: Field[];
|
|
58
|
+
cookie: Field[];
|
|
59
|
+
path: Field[];
|
|
60
|
+
query: Field[];
|
|
61
|
+
body: Field[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const requestFields: RequestFields = {
|
|
65
|
+
header: [] as Field[],
|
|
66
|
+
cookie: [] as Field[],
|
|
67
|
+
path: [] as Field[],
|
|
68
|
+
query: [] as Field[],
|
|
69
|
+
body: [] as Field[],
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Authorization
|
|
73
|
+
const securityRequirements = operation.getSecurity();
|
|
74
|
+
const schemes = definition.components?.securitySchemes || {};
|
|
75
|
+
|
|
76
|
+
if (securityRequirements && securityRequirements.length > 0) {
|
|
77
|
+
const firstOption = securityRequirements[0];
|
|
78
|
+
|
|
79
|
+
Object.keys(firstOption).forEach((key) => {
|
|
80
|
+
const scheme = schemes[key];
|
|
81
|
+
if (!scheme) return;
|
|
82
|
+
|
|
83
|
+
// Common defaults for all auth types
|
|
84
|
+
const fieldData: Field = {
|
|
85
|
+
name: "", // Will be determined below
|
|
86
|
+
required: true, // If it's in the requirement list, it is required
|
|
87
|
+
type: "string", // Auth credentials are essentially always strings
|
|
88
|
+
description: scheme.description || "", // Fallback description logic below
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// --- CASE 1: API KEY (Explicit Location) ---
|
|
92
|
+
if (scheme.type === "apiKey") {
|
|
93
|
+
fieldData.name = scheme.name; // e.g. 'x-api-key' or 'api_token'
|
|
94
|
+
|
|
95
|
+
if (!fieldData.description) {
|
|
96
|
+
fieldData.description = `API Key required in ${scheme.in}.`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Sort into buckets
|
|
100
|
+
if (scheme.in === "header") requestFields.header.push(fieldData);
|
|
101
|
+
else if (scheme.in === "query") requestFields.query.push(fieldData);
|
|
102
|
+
else if (scheme.in === "cookie") requestFields.cookie.push(fieldData);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// --- CASE 2: HTTP (Bearer / Basic) ---
|
|
106
|
+
// These are implicit headers.
|
|
107
|
+
else if (scheme.type === "http") {
|
|
108
|
+
fieldData.name = "Authorization";
|
|
109
|
+
|
|
110
|
+
if (scheme.scheme === "bearer") {
|
|
111
|
+
fieldData.description =
|
|
112
|
+
fieldData.description || "Bearer token authentication.";
|
|
113
|
+
} else if (scheme.scheme === "basic") {
|
|
114
|
+
fieldData.description =
|
|
115
|
+
fieldData.description ||
|
|
116
|
+
"Basic authentication (Base64 encoded username:password).";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
requestFields.header.push(fieldData);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// --- CASE 3: OAUTH2 / OPENID ---
|
|
123
|
+
// Standard practice is a Bearer header.
|
|
124
|
+
else if (scheme.type === "oauth2" || scheme.type === "openIdConnect") {
|
|
125
|
+
fieldData.name = "Authorization";
|
|
126
|
+
fieldData.description = fieldData.description || "OAuth2 Bearer Token.";
|
|
127
|
+
|
|
128
|
+
requestFields.header.push(fieldData);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// --- Regular Parameters (Path & Query) ---
|
|
134
|
+
const parameters = operation.getParameters();
|
|
135
|
+
parameters.forEach((param) => {
|
|
136
|
+
const schema = param.schema as any;
|
|
137
|
+
|
|
138
|
+
const field = {
|
|
139
|
+
name: param.name,
|
|
140
|
+
required: param.required || false,
|
|
141
|
+
type: schema?.enum
|
|
142
|
+
? `enum<${schema?.type || "string"}>`
|
|
143
|
+
: schema?.type || "string",
|
|
144
|
+
description: param.description || "",
|
|
145
|
+
enum: schema?.enum,
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
if (param.in === "path") requestFields.path.push(field);
|
|
149
|
+
if (param.in === "query") requestFields.query.push(field);
|
|
150
|
+
if (param.in === "header") requestFields.header.push(field);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// --- 3. Body Parameters ---
|
|
154
|
+
if (operation.hasRequestBody()) {
|
|
155
|
+
const requestBody = operation.getRequestBody("application/json");
|
|
156
|
+
const bodySchema = (requestBody as any)?.schema as any;
|
|
157
|
+
|
|
158
|
+
// A helper to extract properties (handles direct properties or allOf)
|
|
159
|
+
let properties = bodySchema?.properties || {};
|
|
160
|
+
let required: string[] = bodySchema?.required || [];
|
|
161
|
+
|
|
162
|
+
if (bodySchema?.allOf) {
|
|
163
|
+
bodySchema.allOf.forEach((s: any) => {
|
|
164
|
+
if (s.properties) properties = { ...properties, ...s.properties };
|
|
165
|
+
if (s.required && Array.isArray(s.required)) {
|
|
166
|
+
// Merge required arrays from allOf schemas
|
|
167
|
+
required = [...new Set([...required, ...s.required])];
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
Object.entries(properties)
|
|
173
|
+
.sort(([nameA], [nameB]) => {
|
|
174
|
+
const aRequired = required.includes(nameA);
|
|
175
|
+
const bRequired = required.includes(nameB);
|
|
176
|
+
// Required fields first (aRequired && !bRequired = -1, !aRequired && bRequired = 1, else 0)
|
|
177
|
+
if (aRequired && !bRequired) return -1;
|
|
178
|
+
if (!aRequired && bRequired) return 1;
|
|
179
|
+
return 0;
|
|
180
|
+
})
|
|
181
|
+
.forEach(([name, schema]: [string, any]) => {
|
|
182
|
+
requestFields.body.push({
|
|
183
|
+
name: name,
|
|
184
|
+
required: required.includes(name) || false,
|
|
185
|
+
type: schema?.enum
|
|
186
|
+
? `enum<${schema?.type || "string"}>`
|
|
187
|
+
: schema?.type || "string",
|
|
188
|
+
description: schema.description || "",
|
|
189
|
+
enum: schema?.enum,
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
<Layout>
|
|
196
|
+
<article>
|
|
197
|
+
<header class="mb-6">
|
|
198
|
+
<h1 class="text-3xl font-semibold">{title}</h1>
|
|
199
|
+
</header>
|
|
200
|
+
<div class="flex flex-row-reverse justify-between gap-6 w-full">
|
|
201
|
+
<aside class="flex-1 hidden xl:block">
|
|
202
|
+
<div class="sticky top-[68px] space-y-6 pt-16 -mt-16">
|
|
203
|
+
<RequestSnippets
|
|
204
|
+
api={api}
|
|
205
|
+
method={route.openApiMethod}
|
|
206
|
+
path={route.openApiPath}
|
|
207
|
+
/>
|
|
208
|
+
{responses && <ResponseSnippets responses={responses} />}
|
|
209
|
+
</div>
|
|
210
|
+
</aside>
|
|
211
|
+
<div class="flex-1 min-w-0">
|
|
212
|
+
<div class="mb-8">
|
|
213
|
+
<PlaygroundBar route={route} serverUrl={serverUrl}>
|
|
214
|
+
{
|
|
215
|
+
serverUrl && (
|
|
216
|
+
<PlaygroundButton>
|
|
217
|
+
<PlaygroundForm
|
|
218
|
+
serverUrl={serverUrl}
|
|
219
|
+
route={route}
|
|
220
|
+
requestFields={requestFields}
|
|
221
|
+
/>
|
|
222
|
+
</PlaygroundButton>
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
</PlaygroundBar>
|
|
226
|
+
</div>
|
|
227
|
+
{
|
|
228
|
+
formattedDescription && (
|
|
229
|
+
<div class="mb-6 prose-rules" set:html={formattedDescription} />
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
<div class="xl:hidden space-y-6 mt-6">
|
|
233
|
+
<RequestSnippets
|
|
234
|
+
api={api}
|
|
235
|
+
method={route.openApiMethod}
|
|
236
|
+
path={route.openApiPath}
|
|
237
|
+
/>
|
|
238
|
+
{responses && <ResponseSnippets responses={responses} />}
|
|
239
|
+
</div>
|
|
240
|
+
<div class="mt-10">
|
|
241
|
+
<!-- Request -->
|
|
242
|
+
{
|
|
243
|
+
Object.keys(requestFields).map(
|
|
244
|
+
(key) =>
|
|
245
|
+
requestFields[key as keyof RequestFields].length > 0 && (
|
|
246
|
+
<>
|
|
247
|
+
<h4 class="text-xl font-semibold mt-10">{headers[key]}</h4>
|
|
248
|
+
<div class="*:mt-4 *:ml-4 *:pb-4 border-b border-b-neutral-100">
|
|
249
|
+
{requestFields[key as keyof RequestFields].map((h) => (
|
|
250
|
+
<Field
|
|
251
|
+
name={h.name}
|
|
252
|
+
type={h.type}
|
|
253
|
+
required={h.required}
|
|
254
|
+
description={h.description}
|
|
255
|
+
enum={h.enum}
|
|
256
|
+
/>
|
|
257
|
+
))}
|
|
258
|
+
</div>
|
|
259
|
+
</>
|
|
260
|
+
)
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
<!-- Response -->
|
|
265
|
+
{responses && <ResponseFields responses={responses} />}
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
</div>
|
|
269
|
+
</article>
|
|
270
|
+
</Layout>
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { Icon } from "astro-icon/components";
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
<div
|
|
6
|
+
x-data="searchComponent()"
|
|
7
|
+
x-on:keydown.escape.window="close()"
|
|
8
|
+
x-on:keydown.meta.k.window.prevent="open()"
|
|
9
|
+
x-on:keydown.ctrl.k.window.prevent="open()"
|
|
10
|
+
class="relative"
|
|
11
|
+
>
|
|
12
|
+
<!-- Search Trigger Button -->
|
|
13
|
+
<button
|
|
14
|
+
x-on:click="open()"
|
|
15
|
+
class="md:bg-white dark:md:bg-neutral-800 flex items-center gap-2 h-8 md:min-w-64 px-3 py-1.5 -mr-3 xs:mr-0 text-xs text-neutral-500/80 dark:text-neutral-400/80 hover:text-neutral-500 dark:hover:text-neutral-400 md:border border-border rounded-lg cursor-pointer md:shadow-xs md:hover:shadow-sm transition"
|
|
16
|
+
>
|
|
17
|
+
<Icon name="lucide:search" class="size-5 md:size-4" />
|
|
18
|
+
<span class="hidden md:inline">Search documentation</span>
|
|
19
|
+
<kbd
|
|
20
|
+
class="font-sans tracking-wider hidden md:inline-flex ml-auto items-center gap-0.5 px-1.5 py-px text-[10px] text-neutral-500 dark:text-neutral-400 font-medium bg-white dark:bg-neutral-700 border border-border/80 rounded"
|
|
21
|
+
>
|
|
22
|
+
⌘K
|
|
23
|
+
</kbd>
|
|
24
|
+
</button>
|
|
25
|
+
|
|
26
|
+
<!-- Search Modal -->
|
|
27
|
+
<template x-teleport="body">
|
|
28
|
+
<div x-show="isOpen" x-cloak class="fixed inset-0 z-50">
|
|
29
|
+
<!-- Backdrop - NO transitions, just appears/disappears with parent -->
|
|
30
|
+
<div
|
|
31
|
+
x-on:click="close()"
|
|
32
|
+
class="absolute inset-0 bg-neutral-400/40 dark:bg-neutral-950/80 backdrop-blur-[2px]"
|
|
33
|
+
>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<!-- Modal Content - this is the only thing that animates -->
|
|
37
|
+
<div class="relative flex items-start justify-center pt-[15vh] px-4">
|
|
38
|
+
<div
|
|
39
|
+
x-show="isOpen"
|
|
40
|
+
x-transition:enter="transition ease-out duration-150"
|
|
41
|
+
x-transition:enter-start="opacity-0 scale-95"
|
|
42
|
+
x-transition:enter-end="opacity-100 scale-100"
|
|
43
|
+
x-transition:leave="transition ease-in duration-100"
|
|
44
|
+
x-transition:leave-start="opacity-100 scale-100"
|
|
45
|
+
x-transition:leave-end="opacity-0 scale-95"
|
|
46
|
+
x-on:click.outside="close()"
|
|
47
|
+
class="w-full max-w-xl bg-white dark:bg-neutral-800 rounded-xl shadow-2xl border border-neutral-200 dark:border-neutral-700/80 overflow-hidden"
|
|
48
|
+
>
|
|
49
|
+
<!-- Search Input -->
|
|
50
|
+
<div
|
|
51
|
+
class="flex items-center gap-3 px-4"
|
|
52
|
+
:class="{ 'border-b border-neutral-200/80': results.length > 0 || (query.length > 0 && !loading) }"
|
|
53
|
+
>
|
|
54
|
+
<Icon
|
|
55
|
+
name="lucide:search"
|
|
56
|
+
class="size-5 sm:size-[18px] text-neutral-400 shrink-0"
|
|
57
|
+
/>
|
|
58
|
+
<input
|
|
59
|
+
x-ref="searchInput"
|
|
60
|
+
x-model="query"
|
|
61
|
+
x-on:input="loading = query.trim().length > 0"
|
|
62
|
+
x-on:input.debounce.200ms="search()"
|
|
63
|
+
x-on:keydown.arrow-down.prevent="selectNext()"
|
|
64
|
+
x-on:keydown.arrow-up.prevent="selectPrevious()"
|
|
65
|
+
x-on:keydown.enter.prevent="goToSelected()"
|
|
66
|
+
type="text"
|
|
67
|
+
placeholder="Search documentation..."
|
|
68
|
+
class="flex-1 py-4 text-base bg-transparent outline-none placeholder:text-neutral-400 sm:text-sm"
|
|
69
|
+
/>
|
|
70
|
+
<div x-show="loading" class="shrink-0 p-1">
|
|
71
|
+
<Icon
|
|
72
|
+
name="lucide:loader"
|
|
73
|
+
class="w-4 h-4 text-neutral-400 animate-spin"
|
|
74
|
+
/>
|
|
75
|
+
</div>
|
|
76
|
+
<button
|
|
77
|
+
x-show="query.length > 0 && !loading"
|
|
78
|
+
x-on:click="clear()"
|
|
79
|
+
class="shrink-0 p-1 text-neutral-400 hover:text-neutral-600 transition-colors"
|
|
80
|
+
>
|
|
81
|
+
<Icon name="lucide:x" class="w-4 h-4" />
|
|
82
|
+
</button>
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<!-- Results -->
|
|
86
|
+
<div class="max-h-[60vh] overflow-y-auto">
|
|
87
|
+
<!-- No Results -->
|
|
88
|
+
<div
|
|
89
|
+
x-show="query.length > 0 && results.length === 0 && !loading"
|
|
90
|
+
class="px-4 py-8 text-center text-neutral-500"
|
|
91
|
+
>
|
|
92
|
+
<Icon
|
|
93
|
+
name="lucide:search-slash"
|
|
94
|
+
class="size-8 mx-auto mb-3 text-neutral-300"
|
|
95
|
+
/>
|
|
96
|
+
<p class="text-sm">
|
|
97
|
+
No results found for <strong class="text-neutral-600"
|
|
98
|
+
>"<span x-text="query"></span>"</strong
|
|
99
|
+
>.
|
|
100
|
+
</p>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<!-- Results List -->
|
|
104
|
+
<ul
|
|
105
|
+
x-show="results.length > 0"
|
|
106
|
+
class="divide-y divide-neutral-200/80"
|
|
107
|
+
>
|
|
108
|
+
<template
|
|
109
|
+
x-for="(result, index) in results"
|
|
110
|
+
:key="result.url + '-' + index"
|
|
111
|
+
>
|
|
112
|
+
<li :data-index="index">
|
|
113
|
+
<a
|
|
114
|
+
:href="result.url"
|
|
115
|
+
x-on:click="close()"
|
|
116
|
+
x-on:mouseenter="selectedIndex = index"
|
|
117
|
+
class="flex flex-col gap-1 px-4 py-3 transition-colors"
|
|
118
|
+
:class="{ 'bg-neutral-100/60': selectedIndex === index }"
|
|
119
|
+
>
|
|
120
|
+
<div class="flex items-center gap-2">
|
|
121
|
+
<!-- Page icon -->
|
|
122
|
+
<Icon
|
|
123
|
+
name="lucide:file"
|
|
124
|
+
x-show="result.type === 'page'"
|
|
125
|
+
class="w-4 h-4 text-neutral-400 shrink-0"
|
|
126
|
+
/>
|
|
127
|
+
<!-- Heading icon -->
|
|
128
|
+
<Icon
|
|
129
|
+
name="lucide:hash"
|
|
130
|
+
x-show="result.type === 'heading'"
|
|
131
|
+
class="w-4 h-4 text-neutral-400 shrink-0"
|
|
132
|
+
/>
|
|
133
|
+
<!-- Content/body text icon -->
|
|
134
|
+
<Icon
|
|
135
|
+
name="lucide:text"
|
|
136
|
+
x-show="result.type === 'content'"
|
|
137
|
+
class="w-4 h-4 text-neutral-400 shrink-0"
|
|
138
|
+
/>
|
|
139
|
+
<!-- Parent title breadcrumb -->
|
|
140
|
+
<span
|
|
141
|
+
class="text-sm text-neutral-500 truncate"
|
|
142
|
+
x-show="result.parentTitle"
|
|
143
|
+
>
|
|
144
|
+
<span x-text="result.parentTitle"></span>
|
|
145
|
+
<span class="mx-1">›</span>
|
|
146
|
+
</span>
|
|
147
|
+
<!-- Title: highlighted for page/heading, plain for content -->
|
|
148
|
+
<span
|
|
149
|
+
x-show="result.type === 'page' || result.type === 'heading'"
|
|
150
|
+
class="font-medium text-neutral-900 truncate [&_mark]:bg-neutral-200 [&_mark]:text-neutral-900 [&_mark]:rounded [&_mark]:px-0.5"
|
|
151
|
+
x-html="highlightMatch(result.title, query)"></span>
|
|
152
|
+
<span
|
|
153
|
+
x-show="result.type === 'content'"
|
|
154
|
+
class="font-medium text-neutral-900 truncate"
|
|
155
|
+
x-text="result.title"></span>
|
|
156
|
+
</div>
|
|
157
|
+
<!-- Excerpt: only for content type -->
|
|
158
|
+
<p
|
|
159
|
+
x-show="result.type === 'content'"
|
|
160
|
+
class="text-sm text-neutral-600 line-clamp-2 pl-6 [&_mark]:bg-neutral-200 [&_mark]:text-neutral-900 [&_mark]:rounded [&_mark]:px-0.5"
|
|
161
|
+
x-html="result.excerpt"
|
|
162
|
+
>
|
|
163
|
+
</p>
|
|
164
|
+
</a>
|
|
165
|
+
</li>
|
|
166
|
+
</template>
|
|
167
|
+
</ul>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
</template>
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
<script>
|
|
176
|
+
import {
|
|
177
|
+
search as pagefindSearch,
|
|
178
|
+
type PagefindResultData,
|
|
179
|
+
} from "../lib/pagefind";
|
|
180
|
+
|
|
181
|
+
// Flattened result type for display
|
|
182
|
+
interface FlatResult {
|
|
183
|
+
url: string;
|
|
184
|
+
title: string;
|
|
185
|
+
parentTitle?: string;
|
|
186
|
+
excerpt: string;
|
|
187
|
+
type: "page" | "heading" | "content";
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
document.addEventListener("alpine:init", () => {
|
|
191
|
+
// @ts-ignore
|
|
192
|
+
Alpine.data("searchComponent", () => ({
|
|
193
|
+
isOpen: false,
|
|
194
|
+
query: "",
|
|
195
|
+
results: [] as FlatResult[],
|
|
196
|
+
loading: false,
|
|
197
|
+
selectedIndex: -1,
|
|
198
|
+
|
|
199
|
+
open() {
|
|
200
|
+
this.isOpen = true;
|
|
201
|
+
document.body.style.overflow = "hidden";
|
|
202
|
+
this.$nextTick(() => {
|
|
203
|
+
(this.$refs.searchInput as HTMLInputElement)?.focus();
|
|
204
|
+
});
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
close() {
|
|
208
|
+
this.isOpen = false;
|
|
209
|
+
document.body.style.overflow = "";
|
|
210
|
+
this.query = "";
|
|
211
|
+
this.results = [];
|
|
212
|
+
this.selectedIndex = -1;
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
clear() {
|
|
216
|
+
this.query = "";
|
|
217
|
+
this.results = [];
|
|
218
|
+
this.selectedIndex = -1;
|
|
219
|
+
(this.$refs.searchInput as HTMLInputElement)?.focus();
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
selectNext() {
|
|
223
|
+
if (this.results.length === 0) return;
|
|
224
|
+
this.selectedIndex = (this.selectedIndex + 1) % this.results.length;
|
|
225
|
+
this.scrollToSelected();
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
selectPrevious() {
|
|
229
|
+
if (this.results.length === 0) return;
|
|
230
|
+
this.selectedIndex =
|
|
231
|
+
this.selectedIndex <= 0
|
|
232
|
+
? this.results.length - 1
|
|
233
|
+
: this.selectedIndex - 1;
|
|
234
|
+
this.scrollToSelected();
|
|
235
|
+
},
|
|
236
|
+
|
|
237
|
+
scrollToSelected() {
|
|
238
|
+
this.$nextTick(() => {
|
|
239
|
+
const selectedEl = document.querySelector(
|
|
240
|
+
`[data-index="${this.selectedIndex}"]`
|
|
241
|
+
);
|
|
242
|
+
selectedEl?.scrollIntoView({ block: "nearest" });
|
|
243
|
+
});
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
goToSelected() {
|
|
247
|
+
if (this.selectedIndex >= 0 && this.results[this.selectedIndex]) {
|
|
248
|
+
window.location.href = this.results[this.selectedIndex].url;
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
async search() {
|
|
253
|
+
if (!this.query.trim()) {
|
|
254
|
+
this.results = [];
|
|
255
|
+
this.selectedIndex = -1;
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
this.loading = true;
|
|
260
|
+
this.selectedIndex = -1;
|
|
261
|
+
try {
|
|
262
|
+
const rawResults = await pagefindSearch(this.query);
|
|
263
|
+
const queryLower = this.query.toLowerCase();
|
|
264
|
+
|
|
265
|
+
const flatResults: FlatResult[] = [];
|
|
266
|
+
const seenExcerpts = new Set<string>();
|
|
267
|
+
|
|
268
|
+
for (const result of rawResults) {
|
|
269
|
+
const pageTitle = result.meta?.title || "Untitled";
|
|
270
|
+
const titleLower = pageTitle.toLowerCase();
|
|
271
|
+
|
|
272
|
+
// Use startsWith instead of includes (to match Pagefind's prefix matching)
|
|
273
|
+
const titleMatchesQuery = titleLower.startsWith(queryLower);
|
|
274
|
+
const hasSubResults =
|
|
275
|
+
result.sub_results && result.sub_results.length > 0;
|
|
276
|
+
|
|
277
|
+
const matchIsInHeading =
|
|
278
|
+
hasSubResults &&
|
|
279
|
+
result.sub_results!.some((sub) =>
|
|
280
|
+
sub.title.toLowerCase().startsWith(queryLower)
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
// Collect sub-results first (they take priority)
|
|
284
|
+
const subResultsToAdd: FlatResult[] = [];
|
|
285
|
+
if (hasSubResults) {
|
|
286
|
+
for (const sub of result.sub_results!) {
|
|
287
|
+
// Don't skip same-URL sub_results anymore - they're intro content
|
|
288
|
+
// if (sub.url === result.url) continue; // REMOVE THIS LINE
|
|
289
|
+
|
|
290
|
+
const subTitleMatches = sub.title
|
|
291
|
+
.toLowerCase()
|
|
292
|
+
.startsWith(queryLower);
|
|
293
|
+
|
|
294
|
+
// Skip if this is intro (same URL) AND title matches the page title
|
|
295
|
+
// (would be duplicate of main page result)
|
|
296
|
+
const isIntroSection = sub.url === result.url;
|
|
297
|
+
if (
|
|
298
|
+
isIntroSection &&
|
|
299
|
+
sub.title === pageTitle &&
|
|
300
|
+
titleMatchesQuery
|
|
301
|
+
) {
|
|
302
|
+
continue; // Skip only if it would duplicate the page title result
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
subResultsToAdd.push({
|
|
306
|
+
url: sub.url,
|
|
307
|
+
title: sub.title,
|
|
308
|
+
parentTitle: isIntroSection ? undefined : pageTitle, // No parent for intro
|
|
309
|
+
excerpt: sub.excerpt,
|
|
310
|
+
// Intro content (same URL) or body text = 'content', heading match = 'heading'
|
|
311
|
+
type: subTitleMatches ? "heading" : "content",
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
seenExcerpts.add(sub.excerpt);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Only add main page result if:
|
|
319
|
+
// 1. Title matches OR match is not in a heading
|
|
320
|
+
// 2. AND its excerpt isn't already covered by a sub-result
|
|
321
|
+
if (titleMatchesQuery || !matchIsInHeading) {
|
|
322
|
+
const excerptAlreadyCovered = seenExcerpts.has(result.excerpt);
|
|
323
|
+
|
|
324
|
+
if (!excerptAlreadyCovered) {
|
|
325
|
+
flatResults.push({
|
|
326
|
+
url: result.url,
|
|
327
|
+
title: pageTitle,
|
|
328
|
+
parentTitle: undefined,
|
|
329
|
+
excerpt: result.excerpt,
|
|
330
|
+
type: titleMatchesQuery ? "page" : "content",
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Add the sub-results
|
|
336
|
+
flatResults.push(...subResultsToAdd);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
this.results = flatResults;
|
|
340
|
+
|
|
341
|
+
if (this.results.length > 0) {
|
|
342
|
+
this.selectedIndex = 0;
|
|
343
|
+
}
|
|
344
|
+
} catch (e) {
|
|
345
|
+
console.error("Search error:", e);
|
|
346
|
+
this.results = [];
|
|
347
|
+
}
|
|
348
|
+
this.loading = false;
|
|
349
|
+
},
|
|
350
|
+
|
|
351
|
+
// Highlight query matches in a string
|
|
352
|
+
highlightMatch(text: string, query: string): string {
|
|
353
|
+
if (!text || !query) return text;
|
|
354
|
+
|
|
355
|
+
// Match query only at the start of words (word boundary \b before the match)
|
|
356
|
+
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
357
|
+
const regex = new RegExp(`\\b(${escaped})`, "gi");
|
|
358
|
+
return text.replace(regex, "<mark>$1</mark>");
|
|
359
|
+
},
|
|
360
|
+
}));
|
|
361
|
+
});
|
|
362
|
+
</script>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { getConfig, type DocsConfig } from "../lib/validation";
|
|
3
|
+
import ThemeSwitcher from "./ThemeSwitcher.astro";
|
|
4
|
+
import SidebarMenu from "./SidebarMenu.astro";
|
|
5
|
+
|
|
6
|
+
const config: DocsConfig = await getConfig();
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<aside class="flex flex-col h-full">
|
|
10
|
+
<nav class="overflow-y-auto">
|
|
11
|
+
<SidebarMenu navigation={config.navigation} />
|
|
12
|
+
</nav>
|
|
13
|
+
<div
|
|
14
|
+
class="mt-auto bg-white z-10 p-3 border-t border-t-border-light flex gap-1.5 justify-end items-center"
|
|
15
|
+
>
|
|
16
|
+
<span class="text-neutral-400 text-xs font-light">Theme</span>
|
|
17
|
+
<ThemeSwitcher />
|
|
18
|
+
</div>
|
|
19
|
+
</aside>
|