Skip to content

Commit a3699c6

Browse files
feat: custom order and titles for llms.txt (#451)
Signed-off-by: David Dal Busco <david.dalbusco@outlook.com>
1 parent 6a854d0 commit a3699c6

2 files changed

Lines changed: 161 additions & 35 deletions

File tree

docusaurus.config.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,28 @@ const config: Config = {
9999
{
100100
description:
101101
"Juno is your self-contained serverless platform for building full-stack web apps without DevOps or backend boilerplate. Developers use their favorite frontend frameworks like React, SvelteKit, or Next.js, and write backend logic in Rust or TypeScript as serverless functions. Everything is bundled into a single WebAssembly (WASM) artifact that runs in a decentralized, stateful environment — under full user ownership — on the Internet Computer. Juno cannot access or modify your code, data, or infrastructure. It supports GitHub Actions for deploys and upgrades, and provides both a CLI and web Console UI for managing projects. The local development environment closely mirrors production, ensuring smooth transitions from build to deployment.",
102-
ignorePaths: ["/white-paper/"]
102+
ignorePaths: ["/white-paper/"],
103+
groups: [
104+
{
105+
matchers: [
106+
{ matcher: "intro", position: 0, title: "Introduction to Juno" },
107+
{ matcher: "start-a-new-project", position: 1 },
108+
{ matcher: "setup-the-sdk", position: 2 },
109+
{
110+
matcher: "local-development",
111+
position: 3,
112+
title: "Run your project locally"
113+
},
114+
{
115+
matcher: "create-a-satellite",
116+
position: 4,
117+
title: "Deploy with a Satellite"
118+
}
119+
],
120+
parentPath: "/docs/getting-started",
121+
title: "Getting Started"
122+
}
123+
]
103124
}
104125
]
105126
],

plugins/docusaurus.llms.plugin.ts

Lines changed: 139 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,29 @@ import TurndownService, { Options, Node as TurndownNode } from "turndown";
77

88
const { gfm } = require("@joplin/turndown-plugin-gfm");
99

10+
interface PluginOptionGroupMatcher {
11+
matcher: string;
12+
position?: number;
13+
title?: string;
14+
}
15+
16+
interface PluginOptionGroup {
17+
matchers: PluginOptionGroupMatcher[];
18+
parentPath: string;
19+
title: string;
20+
}
21+
1022
interface PluginOptions {
1123
docsDir: string;
1224
// An optional description appended after the title
1325
description?: string;
14-
// Additional path to ignore in addition to the categories
26+
// Additional paths to ignore, in addition to the categories.
1527
ignorePaths?: string[];
16-
// A title for the links that are identified at the root. By default: General
17-
generalCategoryTitle?: string;
28+
// A title for the links that are identified at the root. By default: Miscellaneous
29+
miscCategoryTitle?: string;
30+
// A custom list of groups. Matching routes will be listed under these.
31+
// Particularly useful to reorganize routes available at the root.
32+
groups?: PluginOptionGroup[];
1833
}
1934

2035
/**
@@ -36,7 +51,8 @@ export default function docusaurusPluginLLMs(
3651
docsDir,
3752
description,
3853
ignorePaths = [],
39-
generalCategoryTitle = "General"
54+
miscCategoryTitle = "Miscellaneous",
55+
groups = []
4056
} = {
4157
docsDir: "docs",
4258
...userOptions
@@ -51,7 +67,7 @@ export default function docusaurusPluginLLMs(
5167
) === undefined
5268
);
5369

54-
// Group
70+
// Group routes
5571
const groupedRoutes = allRoutes.reduce<GroupedRoutes>((acc, path) => {
5672
if (path.endsWith("/")) {
5773
const category = path.slice(0, -1);
@@ -64,17 +80,95 @@ export default function docusaurusPluginLLMs(
6480
};
6581
}
6682

67-
const category = dirname(path);
83+
const resolveGroup = ():
84+
| {
85+
group: PluginOptionGroup;
86+
matcher: PluginOptionGroupMatcher;
87+
}
88+
| undefined => {
89+
for (const { matchers, ...rest } of groups) {
90+
const matcher = matchers.find(({ matcher }) =>
91+
path.includes(matcher)
92+
);
93+
94+
if (matcher !== undefined) {
95+
return { group: { matchers, ...rest }, matcher };
96+
}
97+
}
98+
99+
return undefined;
100+
};
101+
102+
const matchingGroup = resolveGroup();
103+
104+
if (matchingGroup === undefined) {
105+
const groupPath = dirname(path);
106+
107+
return {
108+
...acc,
109+
[groupPath]: {
110+
...(acc[groupPath] ?? {}),
111+
children: [...(acc[groupPath]?.children ?? []), { path }]
112+
}
113+
};
114+
}
115+
116+
const {
117+
group: { parentPath, title, matchers },
118+
matcher: { title: matcherTitle }
119+
} = matchingGroup;
120+
121+
const children = [
122+
...(acc[parentPath]?.children ?? []),
123+
{ path, ...(matcherTitle !== undefined && { title: matcherTitle }) }
124+
].sort(({ path: pathA }, { path: pathB }) => {
125+
const findPosition = (childPath: string): number | undefined =>
126+
matchers.find(({ matcher }) => childPath.includes(matcher))
127+
?.position;
128+
129+
const positionPathA = findPosition(pathA);
130+
const positionPathB = findPosition(pathB);
131+
132+
return (positionPathA ?? 0) - (positionPathB ?? 0);
133+
});
68134

69135
return {
70136
...acc,
71-
[category]: {
72-
...(acc[category] ?? {}),
73-
children: [...(acc[category]?.children ?? []), path]
137+
[parentPath]: {
138+
...(acc[parentPath] ?? {}),
139+
...(title !== undefined && { title }),
140+
children
74141
}
75142
};
76143
}, {});
77144

145+
const sortedGroupedRoutes = Object.entries(groupedRoutes).sort(
146+
([keyA, _], [keyB, __]) => {
147+
// We want groups first.
148+
const isGroup = (key: string): boolean =>
149+
groups.find(({ parentPath }) => parentPath === key) !== undefined;
150+
151+
if (isGroup(keyA)) {
152+
return -1;
153+
}
154+
155+
if (isGroup(keyB)) {
156+
return 1;
157+
}
158+
159+
// We want "Misc" at the end.
160+
if (keyA === `/${docsDir}`) {
161+
return 1;
162+
}
163+
164+
if (keyB === `/${docsDir}`) {
165+
return -1;
166+
}
167+
168+
return keyA.localeCompare(keyB);
169+
}
170+
);
171+
78172
// Prepare markdown content and path
79173

80174
const { outDir, siteConfig } = context;
@@ -100,17 +194,17 @@ export default function docusaurusPluginLLMs(
100194

101195
// Create /llms.txt
102196
await generateLlmsTxt({
103-
groupedRoutes,
197+
groupedRoutes: sortedGroupedRoutes,
104198
dataRoutes,
105199
description,
106200
docsDir,
107-
generalCategoryTitle,
201+
miscCategoryTitle,
108202
...context
109203
});
110204

111205
// Create /llms-full.txt
112206
await generateLlmsTxtFull({
113-
groupedRoutes,
207+
groupedRoutes: sortedGroupedRoutes,
114208
dataRoutes,
115209
description,
116210
docsDir,
@@ -120,7 +214,19 @@ export default function docusaurusPluginLLMs(
120214
};
121215
}
122216

123-
type GroupedRoutes = Record<string, { children: string[] }>;
217+
interface GroupedRouteChild {
218+
path: string;
219+
title?: string;
220+
}
221+
222+
interface GroupedRoute {
223+
title?: string;
224+
children: GroupedRouteChild[];
225+
}
226+
227+
type GroupedRoutes = Record<string, GroupedRoute>;
228+
229+
type SortedGroupedRoutes = [string, GroupedRoute][];
124230

125231
interface RouteMarkdownData {
126232
relativePath: string;
@@ -309,15 +415,18 @@ const generateLlmsTxt = async ({
309415
outDir,
310416
description,
311417
docsDir,
312-
generalCategoryTitle
418+
miscCategoryTitle
313419
}: {
314-
groupedRoutes: GroupedRoutes;
420+
groupedRoutes: SortedGroupedRoutes;
315421
dataRoutes: RoutesData;
316422
} & Pick<LoadContext, "siteConfig" | "outDir"> &
317423
Pick<PluginOptions, "description" | "docsDir"> &
318-
Required<Pick<PluginOptions, "generalCategoryTitle">>) => {
319-
const buildLink = (route: string): string | undefined => {
320-
const data = dataRoutes.get(route);
424+
Required<Pick<PluginOptions, "miscCategoryTitle">>) => {
425+
const buildLink = ({
426+
path,
427+
title: customTitle
428+
}: GroupedRouteChild): string | undefined => {
429+
const data = dataRoutes.get(path);
321430

322431
if (data === undefined) {
323432
return undefined;
@@ -328,27 +437,24 @@ const generateLlmsTxt = async ({
328437
metadata: { title, description }
329438
} = data;
330439

331-
return `- [${title ?? ""}](${url}${relativePath})${description !== undefined && description !== "" ? `: ${description}` : ""}`;
440+
return `- [${customTitle ?? title ?? ""}](${url}${relativePath})${description !== undefined && description !== "" ? `: ${description}` : ""}`;
332441
};
333442

334-
const buildTitle = (key: string) => {
443+
const buildTitle = ({ key, title }: { key: string; title?: string }) => {
335444
if (key === `/${docsDir}`) {
336-
return `## ${generalCategoryTitle}`;
445+
return `## ${miscCategoryTitle}`;
337446
}
338447

339-
const titles = key
340-
.replaceAll(`/${docsDir}/`, "")
341-
.split("/")
342-
.map(capitalize)
343-
.join(" - ");
448+
const titles =
449+
title ??
450+
key.replaceAll(`/${docsDir}/`, "").split("/").map(capitalize).join(" - ");
344451

345452
return `## ${titles}`;
346453
};
347454

348-
const content = Object.entries(groupedRoutes)
349-
.sort(([keyA, _], [keyB, __]) => keyA.localeCompare(keyB))
455+
const content = groupedRoutes
350456
.map(
351-
([key, { children }]) => `${buildTitle(key)}
457+
([key, { children, title }]) => `${buildTitle({ key, title })}
352458
353459
${children.map(buildLink).join("\n")}`
354460
)
@@ -370,12 +476,12 @@ const generateLlmsTxtFull = async ({
370476
outDir,
371477
description
372478
}: {
373-
groupedRoutes: GroupedRoutes;
479+
groupedRoutes: SortedGroupedRoutes;
374480
dataRoutes: RoutesData;
375481
} & Pick<LoadContext, "siteConfig" | "outDir"> &
376482
Pick<PluginOptions, "description" | "docsDir">) => {
377-
const buildMarkdown = (route: string): string | undefined => {
378-
const data = dataRoutes.get(route);
483+
const buildMarkdown = ({ path }: GroupedRouteChild): string | undefined => {
484+
const data = dataRoutes.get(path);
379485

380486
if (data === undefined) {
381487
return undefined;
@@ -388,8 +494,7 @@ const generateLlmsTxtFull = async ({
388494
return markdown;
389495
};
390496

391-
const content = Object.entries(groupedRoutes)
392-
.sort(([keyA, _], [keyB, __]) => keyA.localeCompare(keyB))
497+
const content = groupedRoutes
393498
.map(([_, { children }]) => children.map(buildMarkdown).join("\n\n"))
394499
.join("\n\n");
395500

0 commit comments

Comments
 (0)