radiant-docs 0.1.6 → 0.1.8

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 (78) hide show
  1. package/dist/index.js +32 -6
  2. package/package.json +3 -3
  3. package/template/astro.config.mjs +76 -3
  4. package/template/package-lock.json +924 -737
  5. package/template/package.json +7 -5
  6. package/template/scripts/generate-og-images.mjs +335 -0
  7. package/template/scripts/generate-og-metadata.mjs +173 -0
  8. package/template/scripts/rewrite-static-asset-host.mjs +408 -0
  9. package/template/scripts/stamp-image-versions.mjs +277 -0
  10. package/template/scripts/stamp-og-image-versions.mjs +199 -0
  11. package/template/scripts/stamp-pagefind-runtime-version.mjs +140 -0
  12. package/template/src/assets/fonts/geist-mono/cyrillic.woff2 +0 -0
  13. package/template/src/assets/fonts/geist-mono/latin-ext.woff2 +0 -0
  14. package/template/src/assets/fonts/geist-mono/latin.woff2 +0 -0
  15. package/template/src/assets/fonts/google-sans-flex/canadian-aboriginal.woff2 +0 -0
  16. package/template/src/assets/fonts/google-sans-flex/cherokee.woff2 +0 -0
  17. package/template/src/assets/fonts/google-sans-flex/latin-ext.woff2 +0 -0
  18. package/template/src/assets/fonts/google-sans-flex/latin.woff2 +0 -0
  19. package/template/src/assets/fonts/google-sans-flex/math.woff2 +0 -0
  20. package/template/src/assets/fonts/google-sans-flex/nushu.woff2 +0 -0
  21. package/template/src/assets/fonts/google-sans-flex/symbols.woff2 +0 -0
  22. package/template/src/assets/fonts/google-sans-flex/syriac.woff2 +0 -0
  23. package/template/src/assets/fonts/google-sans-flex/tifinagh.woff2 +0 -0
  24. package/template/src/assets/fonts/google-sans-flex/vietnamese.woff2 +0 -0
  25. package/template/src/components/Footer.astro +94 -0
  26. package/template/src/components/Header.astro +11 -66
  27. package/template/src/components/LogoLink.astro +103 -0
  28. package/template/src/components/MdxPage.astro +126 -11
  29. package/template/src/components/OpenApiPage.astro +1036 -69
  30. package/template/src/components/Search.astro +0 -2
  31. package/template/src/components/SidebarDropdown.astro +34 -14
  32. package/template/src/components/SidebarGroup.astro +3 -6
  33. package/template/src/components/SidebarLink.astro +22 -12
  34. package/template/src/components/SidebarMenu.astro +19 -16
  35. package/template/src/components/SidebarSegmented.astro +99 -0
  36. package/template/src/components/SidebarSubgroup.astro +12 -12
  37. package/template/src/components/ThemeSwitcher.astro +30 -7
  38. package/template/src/components/endpoint/PlaygroundBar.astro +32 -36
  39. package/template/src/components/endpoint/PlaygroundButton.astro +40 -4
  40. package/template/src/components/endpoint/PlaygroundField.astro +1068 -22
  41. package/template/src/components/endpoint/PlaygroundForm.astro +559 -61
  42. package/template/src/components/endpoint/RequestSnippets.astro +342 -193
  43. package/template/src/components/endpoint/ResponseDisplay.astro +161 -147
  44. package/template/src/components/endpoint/ResponseFieldTree.astro +134 -0
  45. package/template/src/components/endpoint/ResponseFields.astro +711 -68
  46. package/template/src/components/endpoint/ResponseSnippets.astro +299 -173
  47. package/template/src/components/sidebar/SidebarEndpointLink.astro +1 -1
  48. package/template/src/components/ui/CodeLanguageIcon.astro +19 -0
  49. package/template/src/components/ui/CodeTabEdge.astro +79 -0
  50. package/template/src/components/ui/Field.astro +103 -20
  51. package/template/src/components/ui/Icon.astro +32 -0
  52. package/template/src/components/ui/ListChevronsToggle.astro +31 -0
  53. package/template/src/components/ui/Tag.astro +1 -1
  54. package/template/src/components/user/{Accordian.astro → Accordion.astro} +6 -6
  55. package/template/src/components/user/Callout.astro +5 -9
  56. package/template/src/components/user/CodeBlock.astro +400 -0
  57. package/template/src/components/user/CodeGroup.astro +225 -0
  58. package/template/src/components/user/ComponentPreview.astro +1 -0
  59. package/template/src/components/user/ComponentPreviewBlock.astro +181 -0
  60. package/template/src/components/user/Image.astro +132 -0
  61. package/template/src/components/user/Steps.astro +1 -3
  62. package/template/src/components/user/Tabs.astro +2 -2
  63. package/template/src/content.config.ts +1 -0
  64. package/template/src/layouts/Layout.astro +109 -8
  65. package/template/src/lib/code/code-block.ts +546 -0
  66. package/template/src/lib/frontmatter-schema.ts +8 -7
  67. package/template/src/lib/mdx/remark-code-block-component.ts +342 -0
  68. package/template/src/lib/mdx/remark-demote-h1.ts +16 -0
  69. package/template/src/lib/pagefind.ts +19 -5
  70. package/template/src/lib/routes.ts +49 -31
  71. package/template/src/lib/utils.ts +20 -0
  72. package/template/src/lib/validation.ts +638 -200
  73. package/template/src/pages/[...slug].astro +18 -5
  74. package/template/src/styles/geist-mono.css +33 -0
  75. package/template/src/styles/global.css +89 -84
  76. package/template/src/styles/google-sans-flex.css +143 -0
  77. package/template/ec.config.mjs +0 -51
  78. /package/template/src/components/user/{AccordianGroup.astro → AccordionGroup.astro} +0 -0
@@ -0,0 +1,342 @@
1
+ import type { Root } from "mdast";
2
+ import { fromMarkdown } from "mdast-util-from-markdown";
3
+ import { mdxFromMarkdown } from "mdast-util-mdx";
4
+ import { mdxjs } from "micromark-extension-mdxjs";
5
+ import type { Plugin } from "unified";
6
+ import { visitParents } from "unist-util-visit-parents";
7
+
8
+ type CodeNode = {
9
+ type: "code";
10
+ lang?: string | null;
11
+ meta?: string | null;
12
+ value?: string | null;
13
+ };
14
+
15
+ type ParentNode = {
16
+ children?: unknown[];
17
+ };
18
+
19
+ type ParsedCodeMeta = {
20
+ filename: string;
21
+ showLineNumbers: boolean;
22
+ hideLanguageIcon: boolean;
23
+ highlightedLines: string;
24
+ collapsedLines: string;
25
+ };
26
+
27
+ type MdxJsxAttributeNode = {
28
+ type: "mdxJsxAttribute";
29
+ name: string;
30
+ value: string;
31
+ };
32
+
33
+ type MdxJsxFlowElementNode = {
34
+ type: "mdxJsxFlowElement";
35
+ name: string;
36
+ attributes: MdxJsxAttributeNode[];
37
+ children: unknown[];
38
+ };
39
+
40
+ type ParagraphNode = {
41
+ type: "paragraph";
42
+ children?: unknown[];
43
+ };
44
+
45
+ type MdxJsxTextElementNode = {
46
+ type: "mdxJsxTextElement";
47
+ name?: string | null;
48
+ attributes?: unknown[];
49
+ children?: unknown[];
50
+ };
51
+
52
+ const COMPONENT_PREVIEW_NAME = "ComponentPreview";
53
+ const COMPONENT_PREVIEW_BLOCK_NAME = "ComponentPreviewBlock";
54
+ const COMPONENT_PREVIEW_LANGUAGES = new Set(["jsx", "tsx", "mdx"]);
55
+ const INTERNAL_CODE_BLOCK_NAME = "CodeBlockInternal";
56
+
57
+ const FILE_EXTENSION_BY_LANGUAGE: Record<string, string> = {
58
+ bash: "sh",
59
+ shell: "sh",
60
+ c: "c",
61
+ cpp: "cpp",
62
+ csharp: "cs",
63
+ css: "css",
64
+ diff: "diff",
65
+ go: "go",
66
+ graphql: "graphql",
67
+ html: "html",
68
+ java: "java",
69
+ javascript: "js",
70
+ jsx: "jsx",
71
+ json: "json",
72
+ kotlin: "kt",
73
+ markdown: "md",
74
+ php: "php",
75
+ plaintext: "txt",
76
+ python: "py",
77
+ ruby: "rb",
78
+ rust: "rs",
79
+ sql: "sql",
80
+ swift: "swift",
81
+ toml: "toml",
82
+ typescript: "ts",
83
+ tsx: "tsx",
84
+ xml: "xml",
85
+ yaml: "yml",
86
+ };
87
+
88
+ function readMetaString(meta: string | null | undefined): string {
89
+ return typeof meta === "string" ? meta.trim() : "";
90
+ }
91
+
92
+ function parseMetaValue(meta: string, key: string): string {
93
+ const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
94
+ const regex = new RegExp(
95
+ `(?:^|\\s)${escapedKey}=(?:\\{([^}]*)\\}|"([^"]*)"|'([^']*)'|\\\`([^\\\`]*)\\\`|([^\\s]+))`,
96
+ );
97
+ const match = meta.match(regex);
98
+ return (
99
+ match?.[1] ??
100
+ match?.[2] ??
101
+ match?.[3] ??
102
+ match?.[4] ??
103
+ match?.[5] ??
104
+ ""
105
+ ).trim();
106
+ }
107
+
108
+ function parseBooleanFlag(meta: string, key: string): boolean {
109
+ const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
110
+ const bareFlagRegex = new RegExp(`(?:^|\\s)${escapedKey}(?:\\s|$)`);
111
+ if (bareFlagRegex.test(meta)) return true;
112
+
113
+ const explicitTrueRegex = new RegExp(
114
+ `(?:^|\\s)${escapedKey}=\\{?["']?true["']?\\}?`,
115
+ "i",
116
+ );
117
+ return explicitTrueRegex.test(meta);
118
+ }
119
+
120
+ function parseHighlightedLines(meta: string): string {
121
+ const match = meta.match(/(?:^|\s)\{([^}]+)\}/);
122
+ return (match?.[1] ?? "").trim();
123
+ }
124
+
125
+ function parseParsedCodeMeta(meta: string): ParsedCodeMeta {
126
+ const filename = parseMetaValue(meta, "title");
127
+ const showLineNumbers = parseBooleanFlag(meta, "showLineNumbers");
128
+ const collapsedLines = parseMetaValue(meta, "collapse");
129
+ const highlightedLines = parseHighlightedLines(meta);
130
+ const hideLanguageIcon = parseBooleanFlag(meta, "hideLanguageIcon");
131
+
132
+ return {
133
+ filename,
134
+ showLineNumbers,
135
+ hideLanguageIcon,
136
+ highlightedLines,
137
+ collapsedLines,
138
+ };
139
+ }
140
+
141
+ function createAttribute(name: string, value: string): MdxJsxAttributeNode {
142
+ return {
143
+ type: "mdxJsxAttribute",
144
+ name,
145
+ value,
146
+ };
147
+ }
148
+
149
+ function parseComponentPreviewChildren(rawCode: string): unknown[] {
150
+ const parsedTree = fromMarkdown(rawCode, {
151
+ extensions: [mdxjs()],
152
+ mdastExtensions: [mdxFromMarkdown()],
153
+ }) as Root;
154
+
155
+ const children = Array.isArray(parsedTree.children) ? parsedTree.children : [];
156
+
157
+ // Promote a single inline MDX JSX node into flow-level JSX so the preview
158
+ // renders block components (e.g. <Callout />) as expected.
159
+ return children.map((node) => {
160
+ const paragraphNode = node as ParagraphNode;
161
+ if (
162
+ paragraphNode.type !== "paragraph" ||
163
+ !Array.isArray(paragraphNode.children) ||
164
+ paragraphNode.children.length !== 1
165
+ ) {
166
+ return node;
167
+ }
168
+
169
+ const onlyChild = paragraphNode.children[0] as MdxJsxTextElementNode;
170
+ if (
171
+ onlyChild.type !== "mdxJsxTextElement" ||
172
+ typeof onlyChild.name !== "string" ||
173
+ onlyChild.name.trim().length === 0
174
+ ) {
175
+ return node;
176
+ }
177
+
178
+ return {
179
+ type: "mdxJsxFlowElement",
180
+ name: onlyChild.name,
181
+ attributes: Array.isArray(onlyChild.attributes)
182
+ ? onlyChild.attributes
183
+ : [],
184
+ children: Array.isArray(onlyChild.children) ? onlyChild.children : [],
185
+ };
186
+ });
187
+ }
188
+
189
+ function getNearestMdxJsxFlowElementName(
190
+ ancestors: unknown[],
191
+ ): string | null {
192
+ for (let index = ancestors.length - 1; index >= 0; index -= 1) {
193
+ const ancestor = ancestors[index] as {
194
+ type?: string;
195
+ name?: string | null;
196
+ };
197
+ if (ancestor?.type !== "mdxJsxFlowElement") continue;
198
+ return typeof ancestor.name === "string" ? ancestor.name : null;
199
+ }
200
+
201
+ return null;
202
+ }
203
+
204
+ function isInsideMdxJsxTextElement(ancestors: unknown[]): boolean {
205
+ return ancestors.some((ancestor) => {
206
+ const node = ancestor as { type?: string };
207
+ return node?.type === "mdxJsxTextElement";
208
+ });
209
+ }
210
+
211
+ function buildDefaultCodeGroupFileName(
212
+ language: string,
213
+ codeIndexInGroup: number,
214
+ ): string {
215
+ const normalizedLanguage = language.trim().toLowerCase() || "plaintext";
216
+ const extension = FILE_EXTENSION_BY_LANGUAGE[normalizedLanguage] ?? "txt";
217
+ return `file-name-${codeIndexInGroup + 1}.${extension}`;
218
+ }
219
+
220
+ export const remarkCodeBlockComponent: Plugin<[], Root> = () => {
221
+ return (tree) => {
222
+ visitParents(tree, "code", (node, ancestors) => {
223
+ const codeNode = node as CodeNode;
224
+ const parent = ancestors[ancestors.length - 1] as ParentNode | undefined;
225
+ const siblings = parent?.children;
226
+ if (!siblings) return;
227
+
228
+ const currentIndex = siblings.indexOf(node);
229
+ if (currentIndex < 0) return;
230
+
231
+ const nearestMdxFlowElementName = getNearestMdxJsxFlowElementName(
232
+ ancestors,
233
+ );
234
+ const isInsideCodeGroup = nearestMdxFlowElementName === "CodeGroup";
235
+ const isInsideComponentPreview =
236
+ nearestMdxFlowElementName === COMPONENT_PREVIEW_NAME;
237
+ const isInsideInlineMdx = isInsideMdxJsxTextElement(ancestors);
238
+ if (isInsideInlineMdx) return;
239
+
240
+ const meta = readMetaString(codeNode.meta);
241
+ const parsedMeta = parseParsedCodeMeta(meta);
242
+ const language =
243
+ typeof codeNode.lang === "string" && codeNode.lang.trim().length > 0
244
+ ? codeNode.lang.trim()
245
+ : "plaintext";
246
+ const normalizedLanguage = language.trim().toLowerCase();
247
+ const rawCode = typeof codeNode.value === "string" ? codeNode.value : "";
248
+ const codeIndexInGroup = isInsideCodeGroup
249
+ ? siblings
250
+ .slice(0, currentIndex)
251
+ .filter(
252
+ (sibling) => (sibling as { type?: string }).type === "code",
253
+ ).length
254
+ : 0;
255
+ const fileName =
256
+ parsedMeta.filename.length > 0
257
+ ? parsedMeta.filename
258
+ : isInsideCodeGroup
259
+ ? buildDefaultCodeGroupFileName(language, codeIndexInGroup)
260
+ : "";
261
+ const showFilename = isInsideCodeGroup || fileName.length > 0;
262
+
263
+ const attributes: MdxJsxAttributeNode[] = [
264
+ createAttribute("language", language),
265
+ createAttribute("raw", rawCode),
266
+ createAttribute("showFilename", showFilename ? "true" : "false"),
267
+ createAttribute(
268
+ "showLineNumbers",
269
+ parsedMeta.showLineNumbers ? "true" : "false",
270
+ ),
271
+ ];
272
+
273
+ if (parsedMeta.hideLanguageIcon) {
274
+ attributes.push(createAttribute("hideLanguageIcon", "true"));
275
+ }
276
+
277
+ if (showFilename) {
278
+ attributes.push(createAttribute("filename", fileName));
279
+ }
280
+
281
+ if (isInsideCodeGroup) {
282
+ attributes.push(createAttribute("inCodeGroup", "true"));
283
+ }
284
+
285
+ if (parsedMeta.highlightedLines.length > 0) {
286
+ attributes.push(
287
+ createAttribute("highlightedLines", parsedMeta.highlightedLines),
288
+ );
289
+ }
290
+
291
+ if (parsedMeta.collapsedLines.length > 0) {
292
+ attributes.push(
293
+ createAttribute("collapsedLines", parsedMeta.collapsedLines),
294
+ );
295
+ }
296
+
297
+ if (isInsideComponentPreview) {
298
+ if (!COMPONENT_PREVIEW_LANGUAGES.has(normalizedLanguage)) {
299
+ throw new Error(
300
+ `[USER_ERROR]: <${COMPONENT_PREVIEW_NAME}>: Fenced code blocks must use jsx, tsx, or mdx language (received "${language}")`,
301
+ );
302
+ }
303
+
304
+ let previewChildren: unknown[] = [];
305
+ try {
306
+ previewChildren = parseComponentPreviewChildren(rawCode);
307
+ } catch (error) {
308
+ const reason =
309
+ error instanceof Error && error.message.trim().length > 0
310
+ ? ` -> ${error.message}`
311
+ : "";
312
+ throw new Error(
313
+ `[USER_ERROR]: <${COMPONENT_PREVIEW_NAME}>: Failed to parse fenced code block as MDX content${reason}`,
314
+ );
315
+ }
316
+
317
+ const previewAttributes = attributes.filter(
318
+ (attribute) => attribute.name !== "inCodeGroup",
319
+ );
320
+ const previewNode: MdxJsxFlowElementNode = {
321
+ type: "mdxJsxFlowElement",
322
+ name: COMPONENT_PREVIEW_BLOCK_NAME,
323
+ attributes: previewAttributes,
324
+ children: previewChildren,
325
+ };
326
+ siblings[currentIndex] = previewNode;
327
+ return;
328
+ }
329
+
330
+ const replacementNode: MdxJsxFlowElementNode = {
331
+ type: "mdxJsxFlowElement",
332
+ name: INTERNAL_CODE_BLOCK_NAME,
333
+ attributes,
334
+ children: [],
335
+ };
336
+
337
+ siblings[currentIndex] = replacementNode;
338
+ });
339
+ };
340
+ };
341
+
342
+ export default remarkCodeBlockComponent;
@@ -0,0 +1,16 @@
1
+ import type { Heading, Root } from "mdast";
2
+ import type { Plugin } from "unified";
3
+ import { visitParents } from "unist-util-visit-parents";
4
+
5
+ export const remarkDemoteH1: Plugin<[], Root> = () => {
6
+ return (tree) => {
7
+ visitParents(tree, "heading", (node) => {
8
+ const heading = node as Heading;
9
+ if (heading.depth === 1) {
10
+ heading.depth = 2;
11
+ }
12
+ });
13
+ };
14
+ };
15
+
16
+ export default remarkDemoteH1;
@@ -37,6 +37,7 @@ export interface PagefindSearchResponse {
37
37
  }
38
38
 
39
39
  export interface PagefindInstance {
40
+ options?: (options: { baseUrl?: string; basePath?: string }) => Promise<void>;
40
41
  init: () => Promise<void>;
41
42
  search: (
42
43
  query: string,
@@ -48,17 +49,30 @@ export interface PagefindInstance {
48
49
 
49
50
  let pagefindInstance: PagefindInstance | null = null;
50
51
 
52
+ function getPagefindScriptUrl(): string {
53
+ return "/pagefind/pagefind.js";
54
+ }
55
+
51
56
  export async function getPagefind(): Promise<PagefindInstance | null> {
52
57
  if (pagefindInstance) return pagefindInstance;
53
58
 
54
59
  try {
55
- // Completely bypass Vite's module resolution by constructing the import dynamically
56
- // This uses Function constructor to create a truly runtime import
60
+ // Completely bypass Vite's module resolution by constructing the import dynamically.
61
+ // This keeps Pagefind loading purely runtime and allows versioned query params.
57
62
  const importPagefind = new Function(
58
- 'return import("/pagefind/pagefind.js")'
59
- ) as () => Promise<PagefindInstance>;
63
+ "moduleUrl",
64
+ "return import(moduleUrl)"
65
+ ) as (moduleUrl: string) => Promise<PagefindInstance>;
60
66
 
61
- const pagefind = await importPagefind();
67
+ const pagefind = await importPagefind(getPagefindScriptUrl());
68
+ // Pagefind uses the script import location to derive result URL base.
69
+ // Since we import from the static host, force result links back to the
70
+ // current docs site origin while leaving asset fetches on static host.
71
+ if (typeof pagefind.options === "function") {
72
+ const baseUrl =
73
+ typeof window !== "undefined" ? window.location.origin : "/";
74
+ await pagefind.options({ baseUrl });
75
+ }
62
76
  await pagefind.init();
63
77
  pagefindInstance = pagefind;
64
78
  return pagefindInstance;
@@ -37,10 +37,27 @@ export interface OpenApiRoute extends BaseRoute {
37
37
  // Discriminated union
38
38
  export type Route = MdxRoute | OpenApiRoute;
39
39
 
40
+ function normalizeTitle(value: unknown): string | undefined {
41
+ if (typeof value !== "string") return undefined;
42
+ const trimmed = value.trim();
43
+ return trimmed.length ? trimmed : undefined;
44
+ }
45
+
46
+ export function resolveMdxPageTitle(args: {
47
+ entry: any;
48
+ docsTitle?: unknown;
49
+ filePath: string;
50
+ }): string {
51
+ const frontmatterTitle = normalizeTitle(args.entry?.data?.title);
52
+ const docsJsonTitle = normalizeTitle(args.docsTitle);
53
+
54
+ return frontmatterTitle || docsJsonTitle || deriveTitleFromEntryId(args.filePath);
55
+ }
56
+
40
57
  function processPageItem(
41
58
  item: NavPageItem,
42
59
  parentSlug: string = "",
43
- docs: any[]
60
+ docs: any[],
44
61
  ): MdxRoute {
45
62
  const filePath = typeof item === "string" ? item : item.page;
46
63
  const filename = filePath.split("/").pop() || filePath;
@@ -55,12 +72,17 @@ function processPageItem(
55
72
 
56
73
  if (!entry) {
57
74
  throw new Error(
58
- `Could not find content collection entry for path: ${filePath}`
75
+ `Could not find content collection entry for path: ${filePath}`,
59
76
  );
60
77
  }
61
78
 
62
- // Get title from frontmatter
63
- const title = entry.data.title || deriveTitleFromEntryId(entry.filePath);
79
+ // Get title from docs.json NavPage object first, then fallback to filename
80
+ const configTitle = typeof item === "object" ? item.title : undefined;
81
+ const title = resolveMdxPageTitle({
82
+ entry,
83
+ docsTitle: configTitle,
84
+ filePath,
85
+ });
64
86
 
65
87
  return {
66
88
  type: "mdx",
@@ -73,7 +95,7 @@ function processPageItem(
73
95
  function processGroup(
74
96
  group: NavGroup,
75
97
  parentSlug: string = "",
76
- docs: any[]
98
+ docs: any[],
77
99
  ): Route[] {
78
100
  let routes: Route[] = [];
79
101
  const groupSlug = slugify(group.group);
@@ -86,7 +108,7 @@ function processGroup(
86
108
  } else if ("group" in item) {
87
109
  // Nested group
88
110
  routes = routes.concat(
89
- processGroup(item as NavGroup, currentPrefix, docs)
111
+ processGroup(item as NavGroup, currentPrefix, docs),
90
112
  );
91
113
  }
92
114
  });
@@ -96,7 +118,7 @@ function processGroup(
96
118
 
97
119
  // Helper function to parse endpoint string (same as in validation.ts)
98
120
  function parseEndpointString(
99
- endpointStr: string
121
+ endpointStr: string,
100
122
  ): { method: string; path: string } | null {
101
123
  const trimmed = endpointStr.trim();
102
124
  const parts = trimmed.split(/\s+/);
@@ -124,7 +146,7 @@ function shouldIncludeEndpoint(
124
146
  method: string,
125
147
  pathStr: string,
126
148
  include?: string[],
127
- exclude?: string[]
149
+ exclude?: string[],
128
150
  ): boolean {
129
151
  // Normalize for comparison
130
152
  const normalizedMethod = method.toUpperCase();
@@ -155,7 +177,7 @@ function shouldIncludeEndpoint(
155
177
 
156
178
  async function processOpenApiFile(
157
179
  openApiPathOrConfig: string | NavOpenApi,
158
- parentSlug: string = ""
180
+ parentSlug: string = "",
159
181
  ): Promise<OpenApiRoute[]> {
160
182
  const routes: OpenApiRoute[] = [];
161
183
  const CWD = process.cwd();
@@ -243,7 +265,7 @@ async function processOpenApiFile(
243
265
  async function processMenuItem(
244
266
  menuItem: NavMenuItem,
245
267
  parentSlug: string = "",
246
- docs: any[]
268
+ docs: any[],
247
269
  ): Promise<Route[]> {
248
270
  let routes: Route[] = [];
249
271
  const menuItemSlug = slugify(menuItem.label);
@@ -253,17 +275,16 @@ async function processMenuItem(
253
275
 
254
276
  const submenu = menuItem.submenu;
255
277
 
256
- // Process pages if they exist
278
+ // Process pages if they exist (pages array can contain pages and groups)
257
279
  if (submenu.pages) {
258
- submenu.pages.forEach((page) => {
259
- routes.push(processPageItem(page, currentPrefix, docs));
260
- });
261
- }
262
-
263
- // Process groups if they exist
264
- if (submenu.groups) {
265
- submenu.groups.forEach((group) => {
266
- routes = routes.concat(processGroup(group, currentPrefix, docs));
280
+ submenu.pages.forEach((item) => {
281
+ if (typeof item === "string" || "page" in item) {
282
+ routes.push(processPageItem(item as NavPageItem, currentPrefix, docs));
283
+ } else if ("group" in item) {
284
+ routes = routes.concat(
285
+ processGroup(item as NavGroup, currentPrefix, docs),
286
+ );
287
+ }
267
288
  });
268
289
  }
269
290
 
@@ -271,7 +292,7 @@ async function processMenuItem(
271
292
  if (submenu.openapi) {
272
293
  const openApiRoutes = await processOpenApiFile(
273
294
  submenu.openapi,
274
- currentPrefix
295
+ currentPrefix,
275
296
  );
276
297
  routes = routes.concat(openApiRoutes);
277
298
  }
@@ -289,19 +310,16 @@ export async function getAllRoutes(): Promise<Route[]> {
289
310
 
290
311
  let allRoutes: Route[] = [];
291
312
 
292
- // 3. Identify the active navigation container (pages, groups, or menu)
313
+ // 3. Identify the active navigation container (pages or menu)
293
314
  // We use the XOR guarantee from validateNavigation (only one key exists)
294
315
  if (navigation.pages) {
295
- // Case 1: Simple pages array at the top level
316
+ // Case 1: Pages array at the top level (can contain pages and groups)
296
317
  navigation.pages.forEach((item) => {
297
- // items are guaranteed to be string | NavPage
298
- allRoutes.push(processPageItem(item, "", docs));
299
- });
300
- } else if (navigation.groups) {
301
- // Case 2: Groups array at the top level
302
- navigation.groups.forEach((group) => {
303
- // items are guaranteed to be NavGroup
304
- allRoutes = allRoutes.concat(processGroup(group, "", docs));
318
+ if (typeof item === "string" || "page" in item) {
319
+ allRoutes.push(processPageItem(item as NavPageItem, "", docs));
320
+ } else if ("group" in item) {
321
+ allRoutes = allRoutes.concat(processGroup(item as NavGroup, "", docs));
322
+ }
305
323
  });
306
324
  } else if (navigation.menu) {
307
325
  // Case 3: Menu object at the top level
@@ -57,3 +57,23 @@ export function deriveTitleFromEntryId(filePath: string): string {
57
57
  .trim() || "Untitled"
58
58
  );
59
59
  }
60
+
61
+ export function buildMdxPageHref(args: {
62
+ filePath: string;
63
+ groupSlug?: string;
64
+ homePath?: string;
65
+ }): string {
66
+ if (args.homePath && args.filePath === args.homePath) {
67
+ return "/";
68
+ }
69
+
70
+ const filename = path.basename(args.filePath);
71
+ const pageSlug = slugify(filename);
72
+ const normalizedGroupSlug = (args.groupSlug || "")
73
+ .replace(/^\/+/, "")
74
+ .replace(/\/+$/, "");
75
+
76
+ return normalizedGroupSlug
77
+ ? `/${normalizedGroupSlug}/${pageSlug}`
78
+ : `/${pageSlug}`;
79
+ }