@lukoweb/apitogo 0.1.19 → 0.1.21
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/cli.js +218 -84
- package/package.json +1 -1
- package/src/config/validators/NavigationSchema.ts +352 -352
- package/src/lib/shiki.ts +179 -179
- package/src/vite/config.ts +20 -0
- package/src/vite/pagefind-dev-stub.ts +17 -17
- package/src/vite/plugin-search.ts +2 -8
|
@@ -1,352 +1,352 @@
|
|
|
1
|
-
import path from "node:path";
|
|
2
|
-
import { glob } from "glob";
|
|
3
|
-
import type { RootContent } from "hast";
|
|
4
|
-
import type { LucideIcon } from "lucide-react";
|
|
5
|
-
import type { Heading, PhrasingContent } from "mdast";
|
|
6
|
-
import { fromMarkdown } from "mdast-util-from-markdown";
|
|
7
|
-
import { mdxFromMarkdown } from "mdast-util-mdx";
|
|
8
|
-
import type { MdxJsxTextElement } from "mdast-util-mdx-jsx";
|
|
9
|
-
import { mdxjs } from "micromark-extension-mdxjs";
|
|
10
|
-
import type { SortableType } from "../../lib/navigation/applyRules.js";
|
|
11
|
-
import { readFrontmatter } from "../../lib/util/readFrontmatter.js";
|
|
12
|
-
import type { ConfigWithMeta } from "../loader.js";
|
|
13
|
-
import type {
|
|
14
|
-
InputNavigationCategory,
|
|
15
|
-
InputNavigationCategoryLinkDoc,
|
|
16
|
-
InputNavigationCustomPage,
|
|
17
|
-
InputNavigationDoc,
|
|
18
|
-
InputNavigationFilter,
|
|
19
|
-
InputNavigationItem,
|
|
20
|
-
InputNavigationLink,
|
|
21
|
-
InputNavigationSection,
|
|
22
|
-
InputNavigationSeparator,
|
|
23
|
-
NavigationInsertRule,
|
|
24
|
-
NavigationModifyRule,
|
|
25
|
-
NavigationMoveRule,
|
|
26
|
-
NavigationRemoveRule,
|
|
27
|
-
NavigationRule,
|
|
28
|
-
NavigationSortRule,
|
|
29
|
-
} from "./InputNavigationSchema.js";
|
|
30
|
-
import { DocsConfigSchema } from "./ZudokuConfig.js";
|
|
31
|
-
|
|
32
|
-
type ReplaceFields<Base, Overrides> = Omit<Base, keyof Overrides> & Overrides;
|
|
33
|
-
// string icons will be transformed to `LucideIcon` in `vite/plugin-navigation.ts`
|
|
34
|
-
type ResolvedIcon = { icon?: LucideIcon | string };
|
|
35
|
-
|
|
36
|
-
// `doc` items can have string shorthands, but this resolver will resolve them to the full type
|
|
37
|
-
type FinalNavigationDoc = Extract<InputNavigationDoc, { type: "doc" }>;
|
|
38
|
-
type FinalNavigationCategoryLinkDoc = Extract<
|
|
39
|
-
InputNavigationCategoryLinkDoc,
|
|
40
|
-
{ type: "doc" }
|
|
41
|
-
>;
|
|
42
|
-
|
|
43
|
-
export type NavigationDoc = ReplaceFields<
|
|
44
|
-
FinalNavigationDoc,
|
|
45
|
-
{
|
|
46
|
-
label: string;
|
|
47
|
-
categoryLabel?: string;
|
|
48
|
-
path: string;
|
|
49
|
-
rich?: RootContent[];
|
|
50
|
-
} & ResolvedIcon
|
|
51
|
-
>;
|
|
52
|
-
|
|
53
|
-
export type NavigationLink = ReplaceFields<InputNavigationLink, ResolvedIcon>;
|
|
54
|
-
|
|
55
|
-
export type NavigationCategoryLinkDoc = ReplaceFields<
|
|
56
|
-
FinalNavigationCategoryLinkDoc,
|
|
57
|
-
{ label: string; path: string } & ResolvedIcon
|
|
58
|
-
>;
|
|
59
|
-
|
|
60
|
-
export type NavigationCategory = ReplaceFields<
|
|
61
|
-
InputNavigationCategory,
|
|
62
|
-
{
|
|
63
|
-
items: NavigationItem[];
|
|
64
|
-
link?: NavigationCategoryLinkDoc;
|
|
65
|
-
} & ResolvedIcon
|
|
66
|
-
>;
|
|
67
|
-
export type NavigationCustomPage = ReplaceFields<
|
|
68
|
-
InputNavigationCustomPage,
|
|
69
|
-
ResolvedIcon
|
|
70
|
-
>;
|
|
71
|
-
|
|
72
|
-
export type NavigationSeparator = InputNavigationSeparator & { label: string };
|
|
73
|
-
|
|
74
|
-
export type NavigationSection = InputNavigationSection;
|
|
75
|
-
|
|
76
|
-
export type NavigationFilter = InputNavigationFilter & { label: string };
|
|
77
|
-
|
|
78
|
-
export type NavigationItem =
|
|
79
|
-
| NavigationDoc
|
|
80
|
-
| NavigationLink
|
|
81
|
-
| NavigationCategory
|
|
82
|
-
| NavigationCustomPage
|
|
83
|
-
| NavigationSeparator
|
|
84
|
-
| NavigationSection
|
|
85
|
-
| NavigationFilter;
|
|
86
|
-
|
|
87
|
-
export type SortableNavigationItem = Extract<
|
|
88
|
-
NavigationItem,
|
|
89
|
-
{ type: SortableType }
|
|
90
|
-
>;
|
|
91
|
-
|
|
92
|
-
export type Navigation = NavigationItem[];
|
|
93
|
-
|
|
94
|
-
const extractTitleFromContent = (content: string): string | undefined =>
|
|
95
|
-
content.match(/^\s*#\s(.*)$/m)?.at(1);
|
|
96
|
-
|
|
97
|
-
type MdxPhrasingContent = PhrasingContent | MdxJsxTextElement;
|
|
98
|
-
|
|
99
|
-
const isMdxJsxElement = (node: MdxPhrasingContent): node is MdxJsxTextElement =>
|
|
100
|
-
node.type === "mdxJsxTextElement";
|
|
101
|
-
|
|
102
|
-
const mdastToString = (node: MdxPhrasingContent | Heading): string => {
|
|
103
|
-
if ("value" in node && typeof node.value === "string") return node.value;
|
|
104
|
-
if ("children" in node && Array.isArray(node.children)) {
|
|
105
|
-
return node.children
|
|
106
|
-
.map((c) => mdastToString(c as MdxPhrasingContent))
|
|
107
|
-
.join("");
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
return "";
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
// Extract rich H1 heading content from MDX. Returns AST nodes only when H1 contains JSX elements.
|
|
114
|
-
const extractRichH1 = (content: string) => {
|
|
115
|
-
try {
|
|
116
|
-
const mdast = fromMarkdown(content, {
|
|
117
|
-
extensions: [mdxjs()],
|
|
118
|
-
mdastExtensions: [mdxFromMarkdown()],
|
|
119
|
-
// biome-ignore lint/suspicious/noExplicitAny: mdast-util-from-markdown has type incompatibilities between versions
|
|
120
|
-
} as any);
|
|
121
|
-
|
|
122
|
-
const h1 = mdast.children.find(
|
|
123
|
-
(node): node is Heading => node.type === "heading" && node.depth === 1,
|
|
124
|
-
);
|
|
125
|
-
|
|
126
|
-
if (!h1) return undefined;
|
|
127
|
-
|
|
128
|
-
const children = h1.children as MdxPhrasingContent[];
|
|
129
|
-
const hasJsx = children.some(isMdxJsxElement);
|
|
130
|
-
|
|
131
|
-
// Extract all text content including from emphasis/strong/links
|
|
132
|
-
const label = mdastToString(h1).trim();
|
|
133
|
-
|
|
134
|
-
// Note: rich only contains MDAST nodes. RichText handles text and mdxJsxTextElement,
|
|
135
|
-
// but markdown formatting (strong/emphasis/link) in H1 won't render styled.
|
|
136
|
-
return hasJsx ? { label, rich: children as RootContent[] } : { label };
|
|
137
|
-
} catch {
|
|
138
|
-
return undefined;
|
|
139
|
-
}
|
|
140
|
-
};
|
|
141
|
-
|
|
142
|
-
const isNavigationItem = (item: unknown): item is NavigationItem =>
|
|
143
|
-
item !== undefined;
|
|
144
|
-
|
|
145
|
-
const toPosixPath = (filePath: string) =>
|
|
146
|
-
filePath.split(path.win32.sep).join(path.posix.sep);
|
|
147
|
-
|
|
148
|
-
export type ResolvedNavigationInsertRule = Omit<
|
|
149
|
-
NavigationInsertRule,
|
|
150
|
-
"items"
|
|
151
|
-
> & { items: NavigationItem[] };
|
|
152
|
-
|
|
153
|
-
export type ResolvedNavigationRule =
|
|
154
|
-
| NavigationModifyRule
|
|
155
|
-
| NavigationRemoveRule
|
|
156
|
-
| ResolvedNavigationInsertRule
|
|
157
|
-
| NavigationSortRule
|
|
158
|
-
| NavigationMoveRule;
|
|
159
|
-
|
|
160
|
-
export class NavigationResolver {
|
|
161
|
-
private rootDir: string;
|
|
162
|
-
private globPatterns: string[];
|
|
163
|
-
private globFiles: string[] = [];
|
|
164
|
-
private items: InputNavigationItem[] = [];
|
|
165
|
-
private itemIndex = 0;
|
|
166
|
-
|
|
167
|
-
constructor(config: ConfigWithMeta) {
|
|
168
|
-
this.rootDir = config.__meta.rootDir;
|
|
169
|
-
this.globPatterns = DocsConfigSchema.parse(config.docs ?? {}).files;
|
|
170
|
-
this.items = config.navigation ?? [];
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
async initialize() {
|
|
174
|
-
if (this.globFiles.length > 0) return;
|
|
175
|
-
|
|
176
|
-
this.globFiles = await glob(this.globPatterns, {
|
|
177
|
-
root: this.rootDir,
|
|
178
|
-
ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**"],
|
|
179
|
-
}).then((files) => files.map(toPosixPath));
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
async resolve() {
|
|
183
|
-
await this.initialize();
|
|
184
|
-
|
|
185
|
-
const resolvedItems = await Promise.all(
|
|
186
|
-
this.items.map((item) => this.resolveItem(item)),
|
|
187
|
-
);
|
|
188
|
-
|
|
189
|
-
return resolvedItems.filter(isNavigationItem);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
async resolveRules(
|
|
193
|
-
rules: NavigationRule[],
|
|
194
|
-
): Promise<ResolvedNavigationRule[]> {
|
|
195
|
-
await this.initialize();
|
|
196
|
-
|
|
197
|
-
return Promise.all(
|
|
198
|
-
rules.map(async (rule) => {
|
|
199
|
-
if (rule.type === "insert") {
|
|
200
|
-
const items = await Promise.all(
|
|
201
|
-
rule.items.map((item) => this.resolveItem(item)),
|
|
202
|
-
).then((items) => items.filter(isNavigationItem));
|
|
203
|
-
|
|
204
|
-
return { ...rule, items };
|
|
205
|
-
}
|
|
206
|
-
return rule;
|
|
207
|
-
}),
|
|
208
|
-
);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
private async resolveDoc(
|
|
212
|
-
filePath: string,
|
|
213
|
-
categoryLabel?: string,
|
|
214
|
-
): Promise<NavigationDoc | undefined> {
|
|
215
|
-
const fileNoExt = toPosixPath(filePath).replace(/\.mdx?$/, "");
|
|
216
|
-
|
|
217
|
-
const foundMatches = this.globFiles.find(
|
|
218
|
-
(file) =>
|
|
219
|
-
file.endsWith(`${fileNoExt}.md`) || file.endsWith(`${fileNoExt}.mdx`),
|
|
220
|
-
);
|
|
221
|
-
|
|
222
|
-
if (!foundMatches) {
|
|
223
|
-
throw new Error(
|
|
224
|
-
`File not found for document '${filePath}'. Navigation items of type 'doc' must point to a valid .md or .mdx file. Do you mean 'link' or 'custom-page'? Check navigation configuration documentation for more information: https://docs.apitogo.com/docs/configuration/navigation`,
|
|
225
|
-
);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
const { data, content } = await readFrontmatter(foundMatches);
|
|
229
|
-
|
|
230
|
-
// Skip draft documents in production mode
|
|
231
|
-
if (process.env.NODE_ENV !== "development" && data.draft === true) {
|
|
232
|
-
return undefined;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
const richH1 = extractRichH1(content);
|
|
236
|
-
|
|
237
|
-
const label =
|
|
238
|
-
data.navigation_label ??
|
|
239
|
-
data.sidebar_label ??
|
|
240
|
-
data.title ??
|
|
241
|
-
richH1?.label ??
|
|
242
|
-
extractTitleFromContent(content) ??
|
|
243
|
-
filePath;
|
|
244
|
-
|
|
245
|
-
const icon = data.navigation_icon ?? data.sidebar_icon;
|
|
246
|
-
|
|
247
|
-
const doc = {
|
|
248
|
-
type: "doc",
|
|
249
|
-
file: filePath,
|
|
250
|
-
label,
|
|
251
|
-
icon,
|
|
252
|
-
display: data.navigation_display,
|
|
253
|
-
categoryLabel,
|
|
254
|
-
path: fileNoExt,
|
|
255
|
-
rich: richH1?.rich,
|
|
256
|
-
} satisfies NavigationDoc;
|
|
257
|
-
|
|
258
|
-
return doc;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
private async resolveLink(
|
|
262
|
-
file: string,
|
|
263
|
-
): Promise<NavigationCategoryLinkDoc | undefined> {
|
|
264
|
-
const doc = await this.resolveDoc(file);
|
|
265
|
-
|
|
266
|
-
return doc
|
|
267
|
-
? { type: "doc", file, label: doc.label, icon: doc.icon, path: doc.path }
|
|
268
|
-
: undefined;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
private async resolveItemCategoryLinkDoc(
|
|
272
|
-
item: string | InputNavigationCategoryLinkDoc,
|
|
273
|
-
): Promise<NavigationCategoryLinkDoc | undefined> {
|
|
274
|
-
if (typeof item === "string") {
|
|
275
|
-
return this.resolveLink(item);
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
const doc = await this.resolveDoc(item.file);
|
|
279
|
-
return doc
|
|
280
|
-
? {
|
|
281
|
-
...item,
|
|
282
|
-
label: doc.label,
|
|
283
|
-
icon: doc.icon,
|
|
284
|
-
path: item.path ?? doc.path,
|
|
285
|
-
}
|
|
286
|
-
: undefined;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
private async resolveNavigationItemDoc(
|
|
290
|
-
item: string | InputNavigationDoc,
|
|
291
|
-
categoryLabel?: string,
|
|
292
|
-
): Promise<NavigationDoc | undefined> {
|
|
293
|
-
if (typeof item === "string") {
|
|
294
|
-
return this.resolveDoc(item, categoryLabel);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
const doc = await this.resolveDoc(item.file, categoryLabel);
|
|
298
|
-
return doc ? { ...doc, ...item, path: item.path ?? doc.path } : undefined;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
private async resolveItem(
|
|
302
|
-
item: InputNavigationItem,
|
|
303
|
-
categoryLabel?: string,
|
|
304
|
-
): Promise<NavigationItem | undefined> {
|
|
305
|
-
if (typeof item === "string") {
|
|
306
|
-
return this.resolveDoc(item, categoryLabel);
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
switch (item.type) {
|
|
310
|
-
case "doc":
|
|
311
|
-
return this.resolveNavigationItemDoc(item, categoryLabel);
|
|
312
|
-
case "link":
|
|
313
|
-
case "custom-page":
|
|
314
|
-
case "section":
|
|
315
|
-
return item;
|
|
316
|
-
case "separator":
|
|
317
|
-
return { ...item, label: `separator-${this.itemIndex++}` };
|
|
318
|
-
case "filter":
|
|
319
|
-
return { ...item, label: `filter-${this.itemIndex++}` };
|
|
320
|
-
case "category": {
|
|
321
|
-
const categoryItem = item;
|
|
322
|
-
|
|
323
|
-
const items = (
|
|
324
|
-
await Promise.all(
|
|
325
|
-
(categoryItem.items as InputNavigationItem[]).map((subItem) =>
|
|
326
|
-
this.resolveItem(subItem, categoryItem.label),
|
|
327
|
-
),
|
|
328
|
-
)
|
|
329
|
-
).filter(isNavigationItem);
|
|
330
|
-
|
|
331
|
-
const resolvedLink = categoryItem.link
|
|
332
|
-
? await this.resolveItemCategoryLinkDoc(categoryItem.link)
|
|
333
|
-
: undefined;
|
|
334
|
-
|
|
335
|
-
// Filter out empty categories (no items and no link) in production
|
|
336
|
-
if (
|
|
337
|
-
process.env.NODE_ENV !== "development" &&
|
|
338
|
-
items.length === 0 &&
|
|
339
|
-
!resolvedLink
|
|
340
|
-
) {
|
|
341
|
-
return undefined;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
return {
|
|
345
|
-
...categoryItem,
|
|
346
|
-
items,
|
|
347
|
-
link: resolvedLink,
|
|
348
|
-
};
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
}
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { glob } from "glob";
|
|
3
|
+
import type { RootContent } from "hast";
|
|
4
|
+
import type { LucideIcon } from "lucide-react";
|
|
5
|
+
import type { Heading, PhrasingContent } from "mdast";
|
|
6
|
+
import { fromMarkdown } from "mdast-util-from-markdown";
|
|
7
|
+
import { mdxFromMarkdown } from "mdast-util-mdx";
|
|
8
|
+
import type { MdxJsxTextElement } from "mdast-util-mdx-jsx";
|
|
9
|
+
import { mdxjs } from "micromark-extension-mdxjs";
|
|
10
|
+
import type { SortableType } from "../../lib/navigation/applyRules.js";
|
|
11
|
+
import { readFrontmatter } from "../../lib/util/readFrontmatter.js";
|
|
12
|
+
import type { ConfigWithMeta } from "../loader.js";
|
|
13
|
+
import type {
|
|
14
|
+
InputNavigationCategory,
|
|
15
|
+
InputNavigationCategoryLinkDoc,
|
|
16
|
+
InputNavigationCustomPage,
|
|
17
|
+
InputNavigationDoc,
|
|
18
|
+
InputNavigationFilter,
|
|
19
|
+
InputNavigationItem,
|
|
20
|
+
InputNavigationLink,
|
|
21
|
+
InputNavigationSection,
|
|
22
|
+
InputNavigationSeparator,
|
|
23
|
+
NavigationInsertRule,
|
|
24
|
+
NavigationModifyRule,
|
|
25
|
+
NavigationMoveRule,
|
|
26
|
+
NavigationRemoveRule,
|
|
27
|
+
NavigationRule,
|
|
28
|
+
NavigationSortRule,
|
|
29
|
+
} from "./InputNavigationSchema.js";
|
|
30
|
+
import { DocsConfigSchema } from "./ZudokuConfig.js";
|
|
31
|
+
|
|
32
|
+
type ReplaceFields<Base, Overrides> = Omit<Base, keyof Overrides> & Overrides;
|
|
33
|
+
// string icons will be transformed to `LucideIcon` in `vite/plugin-navigation.ts`
|
|
34
|
+
type ResolvedIcon = { icon?: LucideIcon | string };
|
|
35
|
+
|
|
36
|
+
// `doc` items can have string shorthands, but this resolver will resolve them to the full type
|
|
37
|
+
type FinalNavigationDoc = Extract<InputNavigationDoc, { type: "doc" }>;
|
|
38
|
+
type FinalNavigationCategoryLinkDoc = Extract<
|
|
39
|
+
InputNavigationCategoryLinkDoc,
|
|
40
|
+
{ type: "doc" }
|
|
41
|
+
>;
|
|
42
|
+
|
|
43
|
+
export type NavigationDoc = ReplaceFields<
|
|
44
|
+
FinalNavigationDoc,
|
|
45
|
+
{
|
|
46
|
+
label: string;
|
|
47
|
+
categoryLabel?: string;
|
|
48
|
+
path: string;
|
|
49
|
+
rich?: RootContent[];
|
|
50
|
+
} & ResolvedIcon
|
|
51
|
+
>;
|
|
52
|
+
|
|
53
|
+
export type NavigationLink = ReplaceFields<InputNavigationLink, ResolvedIcon>;
|
|
54
|
+
|
|
55
|
+
export type NavigationCategoryLinkDoc = ReplaceFields<
|
|
56
|
+
FinalNavigationCategoryLinkDoc,
|
|
57
|
+
{ label: string; path: string } & ResolvedIcon
|
|
58
|
+
>;
|
|
59
|
+
|
|
60
|
+
export type NavigationCategory = ReplaceFields<
|
|
61
|
+
InputNavigationCategory,
|
|
62
|
+
{
|
|
63
|
+
items: NavigationItem[];
|
|
64
|
+
link?: NavigationCategoryLinkDoc;
|
|
65
|
+
} & ResolvedIcon
|
|
66
|
+
>;
|
|
67
|
+
export type NavigationCustomPage = ReplaceFields<
|
|
68
|
+
InputNavigationCustomPage,
|
|
69
|
+
ResolvedIcon
|
|
70
|
+
>;
|
|
71
|
+
|
|
72
|
+
export type NavigationSeparator = InputNavigationSeparator & { label: string };
|
|
73
|
+
|
|
74
|
+
export type NavigationSection = InputNavigationSection;
|
|
75
|
+
|
|
76
|
+
export type NavigationFilter = InputNavigationFilter & { label: string };
|
|
77
|
+
|
|
78
|
+
export type NavigationItem =
|
|
79
|
+
| NavigationDoc
|
|
80
|
+
| NavigationLink
|
|
81
|
+
| NavigationCategory
|
|
82
|
+
| NavigationCustomPage
|
|
83
|
+
| NavigationSeparator
|
|
84
|
+
| NavigationSection
|
|
85
|
+
| NavigationFilter;
|
|
86
|
+
|
|
87
|
+
export type SortableNavigationItem = Extract<
|
|
88
|
+
NavigationItem,
|
|
89
|
+
{ type: SortableType }
|
|
90
|
+
>;
|
|
91
|
+
|
|
92
|
+
export type Navigation = NavigationItem[];
|
|
93
|
+
|
|
94
|
+
const extractTitleFromContent = (content: string): string | undefined =>
|
|
95
|
+
content.match(/^\s*#\s(.*)$/m)?.at(1);
|
|
96
|
+
|
|
97
|
+
type MdxPhrasingContent = PhrasingContent | MdxJsxTextElement;
|
|
98
|
+
|
|
99
|
+
const isMdxJsxElement = (node: MdxPhrasingContent): node is MdxJsxTextElement =>
|
|
100
|
+
node.type === "mdxJsxTextElement";
|
|
101
|
+
|
|
102
|
+
const mdastToString = (node: MdxPhrasingContent | Heading): string => {
|
|
103
|
+
if ("value" in node && typeof node.value === "string") return node.value;
|
|
104
|
+
if ("children" in node && Array.isArray(node.children)) {
|
|
105
|
+
return node.children
|
|
106
|
+
.map((c) => mdastToString(c as MdxPhrasingContent))
|
|
107
|
+
.join("");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return "";
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// Extract rich H1 heading content from MDX. Returns AST nodes only when H1 contains JSX elements.
|
|
114
|
+
const extractRichH1 = (content: string) => {
|
|
115
|
+
try {
|
|
116
|
+
const mdast = fromMarkdown(content, {
|
|
117
|
+
extensions: [mdxjs()],
|
|
118
|
+
mdastExtensions: [mdxFromMarkdown()],
|
|
119
|
+
// biome-ignore lint/suspicious/noExplicitAny: mdast-util-from-markdown has type incompatibilities between versions
|
|
120
|
+
} as any);
|
|
121
|
+
|
|
122
|
+
const h1 = mdast.children.find(
|
|
123
|
+
(node): node is Heading => node.type === "heading" && node.depth === 1,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
if (!h1) return undefined;
|
|
127
|
+
|
|
128
|
+
const children = h1.children as MdxPhrasingContent[];
|
|
129
|
+
const hasJsx = children.some(isMdxJsxElement);
|
|
130
|
+
|
|
131
|
+
// Extract all text content including from emphasis/strong/links
|
|
132
|
+
const label = mdastToString(h1).trim();
|
|
133
|
+
|
|
134
|
+
// Note: rich only contains MDAST nodes. RichText handles text and mdxJsxTextElement,
|
|
135
|
+
// but markdown formatting (strong/emphasis/link) in H1 won't render styled.
|
|
136
|
+
return hasJsx ? { label, rich: children as RootContent[] } : { label };
|
|
137
|
+
} catch {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const isNavigationItem = (item: unknown): item is NavigationItem =>
|
|
143
|
+
item !== undefined;
|
|
144
|
+
|
|
145
|
+
const toPosixPath = (filePath: string) =>
|
|
146
|
+
filePath.split(path.win32.sep).join(path.posix.sep);
|
|
147
|
+
|
|
148
|
+
export type ResolvedNavigationInsertRule = Omit<
|
|
149
|
+
NavigationInsertRule,
|
|
150
|
+
"items"
|
|
151
|
+
> & { items: NavigationItem[] };
|
|
152
|
+
|
|
153
|
+
export type ResolvedNavigationRule =
|
|
154
|
+
| NavigationModifyRule
|
|
155
|
+
| NavigationRemoveRule
|
|
156
|
+
| ResolvedNavigationInsertRule
|
|
157
|
+
| NavigationSortRule
|
|
158
|
+
| NavigationMoveRule;
|
|
159
|
+
|
|
160
|
+
export class NavigationResolver {
|
|
161
|
+
private rootDir: string;
|
|
162
|
+
private globPatterns: string[];
|
|
163
|
+
private globFiles: string[] = [];
|
|
164
|
+
private items: InputNavigationItem[] = [];
|
|
165
|
+
private itemIndex = 0;
|
|
166
|
+
|
|
167
|
+
constructor(config: ConfigWithMeta) {
|
|
168
|
+
this.rootDir = config.__meta.rootDir;
|
|
169
|
+
this.globPatterns = DocsConfigSchema.parse(config.docs ?? {}).files;
|
|
170
|
+
this.items = config.navigation ?? [];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async initialize() {
|
|
174
|
+
if (this.globFiles.length > 0) return;
|
|
175
|
+
|
|
176
|
+
this.globFiles = await glob(this.globPatterns, {
|
|
177
|
+
root: this.rootDir,
|
|
178
|
+
ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**"],
|
|
179
|
+
}).then((files) => files.map(toPosixPath));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async resolve() {
|
|
183
|
+
await this.initialize();
|
|
184
|
+
|
|
185
|
+
const resolvedItems = await Promise.all(
|
|
186
|
+
this.items.map((item) => this.resolveItem(item)),
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
return resolvedItems.filter(isNavigationItem);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async resolveRules(
|
|
193
|
+
rules: NavigationRule[],
|
|
194
|
+
): Promise<ResolvedNavigationRule[]> {
|
|
195
|
+
await this.initialize();
|
|
196
|
+
|
|
197
|
+
return Promise.all(
|
|
198
|
+
rules.map(async (rule) => {
|
|
199
|
+
if (rule.type === "insert") {
|
|
200
|
+
const items = await Promise.all(
|
|
201
|
+
rule.items.map((item) => this.resolveItem(item)),
|
|
202
|
+
).then((items) => items.filter(isNavigationItem));
|
|
203
|
+
|
|
204
|
+
return { ...rule, items };
|
|
205
|
+
}
|
|
206
|
+
return rule;
|
|
207
|
+
}),
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private async resolveDoc(
|
|
212
|
+
filePath: string,
|
|
213
|
+
categoryLabel?: string,
|
|
214
|
+
): Promise<NavigationDoc | undefined> {
|
|
215
|
+
const fileNoExt = toPosixPath(filePath).replace(/\.mdx?$/, "");
|
|
216
|
+
|
|
217
|
+
const foundMatches = this.globFiles.find(
|
|
218
|
+
(file) =>
|
|
219
|
+
file.endsWith(`${fileNoExt}.md`) || file.endsWith(`${fileNoExt}.mdx`),
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
if (!foundMatches) {
|
|
223
|
+
throw new Error(
|
|
224
|
+
`File not found for document '${filePath}'. Navigation items of type 'doc' must point to a valid .md or .mdx file. Do you mean 'link' or 'custom-page'? Check navigation configuration documentation for more information: https://docs.apitogo.com/docs/configuration/navigation`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const { data, content } = await readFrontmatter(foundMatches);
|
|
229
|
+
|
|
230
|
+
// Skip draft documents in production mode
|
|
231
|
+
if (process.env.NODE_ENV !== "development" && data.draft === true) {
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const richH1 = extractRichH1(content);
|
|
236
|
+
|
|
237
|
+
const label =
|
|
238
|
+
data.navigation_label ??
|
|
239
|
+
data.sidebar_label ??
|
|
240
|
+
data.title ??
|
|
241
|
+
richH1?.label ??
|
|
242
|
+
extractTitleFromContent(content) ??
|
|
243
|
+
filePath;
|
|
244
|
+
|
|
245
|
+
const icon = data.navigation_icon ?? data.sidebar_icon;
|
|
246
|
+
|
|
247
|
+
const doc = {
|
|
248
|
+
type: "doc",
|
|
249
|
+
file: filePath,
|
|
250
|
+
label,
|
|
251
|
+
icon,
|
|
252
|
+
display: data.navigation_display,
|
|
253
|
+
categoryLabel,
|
|
254
|
+
path: fileNoExt,
|
|
255
|
+
rich: richH1?.rich,
|
|
256
|
+
} satisfies NavigationDoc;
|
|
257
|
+
|
|
258
|
+
return doc;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private async resolveLink(
|
|
262
|
+
file: string,
|
|
263
|
+
): Promise<NavigationCategoryLinkDoc | undefined> {
|
|
264
|
+
const doc = await this.resolveDoc(file);
|
|
265
|
+
|
|
266
|
+
return doc
|
|
267
|
+
? { type: "doc", file, label: doc.label, icon: doc.icon, path: doc.path }
|
|
268
|
+
: undefined;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private async resolveItemCategoryLinkDoc(
|
|
272
|
+
item: string | InputNavigationCategoryLinkDoc,
|
|
273
|
+
): Promise<NavigationCategoryLinkDoc | undefined> {
|
|
274
|
+
if (typeof item === "string") {
|
|
275
|
+
return this.resolveLink(item);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const doc = await this.resolveDoc(item.file);
|
|
279
|
+
return doc
|
|
280
|
+
? {
|
|
281
|
+
...item,
|
|
282
|
+
label: doc.label,
|
|
283
|
+
icon: doc.icon,
|
|
284
|
+
path: item.path ?? doc.path,
|
|
285
|
+
}
|
|
286
|
+
: undefined;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private async resolveNavigationItemDoc(
|
|
290
|
+
item: string | InputNavigationDoc,
|
|
291
|
+
categoryLabel?: string,
|
|
292
|
+
): Promise<NavigationDoc | undefined> {
|
|
293
|
+
if (typeof item === "string") {
|
|
294
|
+
return this.resolveDoc(item, categoryLabel);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const doc = await this.resolveDoc(item.file, categoryLabel);
|
|
298
|
+
return doc ? { ...doc, ...item, path: item.path ?? doc.path } : undefined;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private async resolveItem(
|
|
302
|
+
item: InputNavigationItem,
|
|
303
|
+
categoryLabel?: string,
|
|
304
|
+
): Promise<NavigationItem | undefined> {
|
|
305
|
+
if (typeof item === "string") {
|
|
306
|
+
return this.resolveDoc(item, categoryLabel);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
switch (item.type) {
|
|
310
|
+
case "doc":
|
|
311
|
+
return this.resolveNavigationItemDoc(item, categoryLabel);
|
|
312
|
+
case "link":
|
|
313
|
+
case "custom-page":
|
|
314
|
+
case "section":
|
|
315
|
+
return item;
|
|
316
|
+
case "separator":
|
|
317
|
+
return { ...item, label: `separator-${this.itemIndex++}` };
|
|
318
|
+
case "filter":
|
|
319
|
+
return { ...item, label: `filter-${this.itemIndex++}` };
|
|
320
|
+
case "category": {
|
|
321
|
+
const categoryItem = item;
|
|
322
|
+
|
|
323
|
+
const items = (
|
|
324
|
+
await Promise.all(
|
|
325
|
+
(categoryItem.items as InputNavigationItem[]).map((subItem) =>
|
|
326
|
+
this.resolveItem(subItem, categoryItem.label),
|
|
327
|
+
),
|
|
328
|
+
)
|
|
329
|
+
).filter(isNavigationItem);
|
|
330
|
+
|
|
331
|
+
const resolvedLink = categoryItem.link
|
|
332
|
+
? await this.resolveItemCategoryLinkDoc(categoryItem.link)
|
|
333
|
+
: undefined;
|
|
334
|
+
|
|
335
|
+
// Filter out empty categories (no items and no link) in production
|
|
336
|
+
if (
|
|
337
|
+
process.env.NODE_ENV !== "development" &&
|
|
338
|
+
items.length === 0 &&
|
|
339
|
+
!resolvedLink
|
|
340
|
+
) {
|
|
341
|
+
return undefined;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
...categoryItem,
|
|
346
|
+
items,
|
|
347
|
+
link: resolvedLink,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|