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.
Files changed (61) hide show
  1. package/dist/index.js +312 -0
  2. package/package.json +38 -0
  3. package/template/.vscode/extensions.json +4 -0
  4. package/template/.vscode/launch.json +11 -0
  5. package/template/astro.config.mjs +216 -0
  6. package/template/ec.config.mjs +51 -0
  7. package/template/package-lock.json +12546 -0
  8. package/template/package.json +51 -0
  9. package/template/public/favicon.svg +9 -0
  10. package/template/src/assets/icons/check.svg +33 -0
  11. package/template/src/assets/icons/danger.svg +37 -0
  12. package/template/src/assets/icons/info.svg +36 -0
  13. package/template/src/assets/icons/lightbulb.svg +74 -0
  14. package/template/src/assets/icons/warning.svg +37 -0
  15. package/template/src/components/Header.astro +176 -0
  16. package/template/src/components/MdxPage.astro +49 -0
  17. package/template/src/components/OpenApiPage.astro +270 -0
  18. package/template/src/components/Search.astro +362 -0
  19. package/template/src/components/Sidebar.astro +19 -0
  20. package/template/src/components/SidebarDropdown.astro +149 -0
  21. package/template/src/components/SidebarGroup.astro +51 -0
  22. package/template/src/components/SidebarLink.astro +56 -0
  23. package/template/src/components/SidebarMenu.astro +46 -0
  24. package/template/src/components/SidebarSubgroup.astro +136 -0
  25. package/template/src/components/TableOfContents.astro +480 -0
  26. package/template/src/components/ThemeSwitcher.astro +84 -0
  27. package/template/src/components/endpoint/PlaygroundBar.astro +68 -0
  28. package/template/src/components/endpoint/PlaygroundButton.astro +44 -0
  29. package/template/src/components/endpoint/PlaygroundField.astro +54 -0
  30. package/template/src/components/endpoint/PlaygroundForm.astro +203 -0
  31. package/template/src/components/endpoint/RequestSnippets.astro +308 -0
  32. package/template/src/components/endpoint/ResponseDisplay.astro +177 -0
  33. package/template/src/components/endpoint/ResponseFields.astro +224 -0
  34. package/template/src/components/endpoint/ResponseSnippets.astro +247 -0
  35. package/template/src/components/sidebar/SidebarEndpointLink.astro +51 -0
  36. package/template/src/components/sidebar/SidebarOpenApi.astro +207 -0
  37. package/template/src/components/ui/Field.astro +69 -0
  38. package/template/src/components/ui/Tag.astro +5 -0
  39. package/template/src/components/ui/demo/CodeDemo.astro +15 -0
  40. package/template/src/components/ui/demo/Demo.astro +3 -0
  41. package/template/src/components/ui/demo/UiDisplay.astro +13 -0
  42. package/template/src/components/user/Accordian.astro +69 -0
  43. package/template/src/components/user/AccordianGroup.astro +13 -0
  44. package/template/src/components/user/Callout.astro +101 -0
  45. package/template/src/components/user/Step.astro +51 -0
  46. package/template/src/components/user/Steps.astro +9 -0
  47. package/template/src/components/user/Tab.astro +25 -0
  48. package/template/src/components/user/Tabs.astro +122 -0
  49. package/template/src/content.config.ts +11 -0
  50. package/template/src/entrypoint.ts +9 -0
  51. package/template/src/layouts/Layout.astro +92 -0
  52. package/template/src/lib/component-error.ts +163 -0
  53. package/template/src/lib/frontmatter-schema.ts +9 -0
  54. package/template/src/lib/oas.ts +24 -0
  55. package/template/src/lib/pagefind.ts +88 -0
  56. package/template/src/lib/routes.ts +316 -0
  57. package/template/src/lib/utils.ts +59 -0
  58. package/template/src/lib/validation.ts +1097 -0
  59. package/template/src/pages/[...slug].astro +77 -0
  60. package/template/src/styles/global.css +209 -0
  61. 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>