Skip to content

Commit e114444

Browse files
authored
feat: refactor llms with Astro routing, MDX stripping, .md chat URLs (#493)
1 parent 91094da commit e114444

11 files changed

Lines changed: 261 additions & 191 deletions

File tree

packages/docs/docs-plugin.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createRequire } from 'node:module'
12
import path from 'node:path'
23
import url from 'node:url'
34
import type { StarlightPlugin } from '@astrojs/starlight/types'
@@ -105,13 +106,25 @@ export function docsPlugin(options: DocsPluginOptions = {}): StarlightPlugin {
105106
urlPath: getUrlPath(directory, astroConfig.base),
106107
}
107108

109+
const projectRoot = url.fileURLToPath(astroConfig.root)
110+
const _require = createRequire(path.join(projectRoot, 'package.json'))
111+
const resolvedPlugins = (options.typeDocOptions?.plugin ?? []).map(
112+
(p) => {
113+
try {
114+
return _require.resolve(p.toString())
115+
} catch {
116+
logger.warn(
117+
`Could not resolve TypeDoc plugin "${p}", using as-is`
118+
)
119+
return p
120+
}
121+
}
122+
)
123+
108124
const app = await Application.bootstrapWithPlugins({
109125
..._defaultTypeDocOptions,
110126
...options.typeDocOptions,
111-
plugin: [
112-
...(options.typeDocOptions?.plugin ?? []),
113-
'typedoc-plugin-markdown',
114-
],
127+
plugin: [...resolvedPlugins, 'typedoc-plugin-markdown'],
115128
outputs: [{ name: 'markdown', path: output.path }],
116129
readme: 'none',
117130
packageOptions: {

packages/docs/llms-plugin/PageTitle.astro

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,13 @@ interface OptionsProps {
1010
}
1111
1212
const currentPath = Astro.url.pathname.replace(/\/$/, "");
13-
const currentUrl = Astro.url.href;
13+
const mdUrl = `${Astro.site!}${currentPath.replace(/^\//, "")}.md`;
1414
const { actions, prompt: userPrompt } = config;
1515
const { pageActions } = (Astro.locals as any).starlightRoute.entry.data;
1616
1717
const prompt = userPrompt?.includes("{url}")
18-
? userPrompt?.replace("{url}", currentUrl)
19-
: `${userPrompt} ${currentUrl}`;
18+
? userPrompt?.replace("{url}", mdUrl)
19+
: `${userPrompt} ${mdUrl}`;
2020
const encodedPrompt = encodeURIComponent(prompt as string);
2121
2222
const defaultOptions: OptionsProps[] = [

packages/docs/llms-plugin/env.d.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
/// <reference types="node_modules/astro/types.d.ts" />
22

3+
declare module 'astro:config/client' {
4+
export const site: string
5+
}
6+
7+
declare module 'astro:content' {
8+
export function getCollection(
9+
collection: 'docs',
10+
filter?: (doc: { data: { draft?: boolean } }) => boolean
11+
): Promise<import('./strip').DocEntry[]>
12+
}
13+
314
declare module 'virtual:llms-plugin/config' {
415
const config: import('./index').PageActionsConfig
516
export default config

packages/docs/llms-plugin/index.ts

Lines changed: 20 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import type { StarlightPlugin } from '@astrojs/starlight/types'
22
import { AstroError } from 'astro/errors'
3-
import { normalizePath } from 'vite'
4-
import { viteStaticCopy } from 'vite-plugin-static-copy'
53

64
interface Actions {
75
chatgpt?: boolean
@@ -23,6 +21,7 @@ export interface PageActionsConfig {
2321
actions?: Actions
2422
title?: string
2523
description?: string
24+
maxDepth?: number
2625
}
2726

2827
/**
@@ -37,7 +36,7 @@ export interface PageActionsConfig {
3736
* @param {string} [userConfig.prompt] - The prompt template for AI chat services. Use `{url}` as placeholder for the markdown URL.
3837
* @param {string} [userConfig.baseUrl] - The base URL of your site, required for generating the `llms.txt` file.
3938
* @param {Actions} [userConfig.actions] - Configure which built-in actions to display and define custom actions.
40-
*
39+
* @param {number} [userConfig.maxDepth] - The maximum depth of the documentation tree to include in the `llms.txt` file. Defaults to 4.
4140
*
4241
* @example
4342
* ```javascript
@@ -52,6 +51,7 @@ export interface PageActionsConfig {
5251
* llmsPlugin({
5352
* prompt: "Read {url} and explain its main points briefly.",
5453
* baseUrl: "https://mydocs.example.com",
54+
* maxDepth: 3,
5555
* actions: {
5656
* chatgpt: false,
5757
* v0: true,
@@ -125,128 +125,45 @@ export function llmsPlugin(userConfig?: PageActionsConfig): StarlightPlugin {
125125
}
126126

127127
addIntegration({
128-
name: 'llms-plugin-integration',
128+
name: 'llms',
129129
hooks: {
130-
'astro:config:setup': ({ updateConfig }) => {
130+
'astro:config:setup'({ updateConfig, injectRoute }) {
131131
updateConfig({
132132
vite: {
133133
plugins: [
134134
{
135135
name: 'llms-plugin-config',
136-
resolveId(id) {
136+
resolveId(id: string) {
137137
if (id === 'virtual:llms-plugin/config') {
138138
return `\0${id}`
139139
}
140+
return undefined
140141
},
141-
load(id) {
142+
load(id: string) {
142143
if (id === '\0virtual:llms-plugin/config') {
143144
return `export default ${JSON.stringify(config)}`
144145
}
146+
return undefined
145147
},
146148
},
147-
viteStaticCopy({
148-
targets: [
149-
{
150-
src: 'src/content/docs/**/*.{md,mdx}',
151-
dest: '',
152-
transform: (content: string) => {
153-
const frontmatterRegex =
154-
/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/
155-
const match = content.match(frontmatterRegex)
156-
157-
let title = ''
158-
let markdownContent = content
159-
160-
if (
161-
match &&
162-
match[1] !== undefined &&
163-
match[2] !== undefined
164-
) {
165-
const frontmatter = match[1]
166-
markdownContent = match[2]
167-
168-
const titleMatch = frontmatter.match(
169-
/title:\s*["']?([^"'\n]+)["']?/
170-
)
171-
if (titleMatch && titleMatch[1] !== undefined) {
172-
title = titleMatch[1].trim()
173-
}
174-
}
175-
176-
const contentWithoutImports = markdownContent
177-
// .split('\n')
178-
// .filter(
179-
// (line) => !line.trim().startsWith('import ')
180-
// )
181-
// .join('\n')
182-
183-
let newContent = ''
184-
185-
if (title) {
186-
newContent = `# ${title}\n\n`
187-
}
188-
189-
newContent += contentWithoutImports.trim()
190-
191-
return newContent
192-
},
193-
rename: (
194-
fileName: string,
195-
fileExtension: string,
196-
fullPath: string
197-
) => {
198-
const fullPathNormalized = normalizePath(fullPath)
199-
const relativePath = (
200-
fullPathNormalized.split(
201-
'src/content/docs/'
202-
)[1] as string
203-
).replace(new RegExp(`\\.${fileExtension}$`), '')
204-
const pathSegments = relativePath.split('/')
205-
206-
if (fileName === 'index') {
207-
if (pathSegments.length === 1) {
208-
return 'index.md'
209-
} else {
210-
const directories = pathSegments
211-
.slice(0, -2)
212-
.join('/')
213-
const folderName =
214-
pathSegments[pathSegments.length - 2]
215-
216-
return directories
217-
? `${directories}/${folderName}.md`
218-
: `${folderName}.md`
219-
}
220-
}
221-
222-
const directories = pathSegments
223-
.slice(0, -1)
224-
.join('/')
225-
const finalPath = directories
226-
? `${directories}/${fileName}.md`
227-
: `${fileName}.md`
228-
229-
return finalPath.replace('@', '').toLowerCase()
230-
},
231-
},
232-
],
233-
}),
234149
],
235150
},
236151
})
237-
},
238-
},
239-
})
240152

241-
addIntegration({
242-
name: 'llms',
243-
hooks: {
244-
'astro:config:setup'({ injectRoute }) {
245-
const entrypoint = new URL('llms.txt.ts', import.meta.url)
246153
injectRoute({
247-
entrypoint,
154+
entrypoint: '@hugomrdias/docs/llms.txt',
248155
pattern: '/llms.txt',
249156
})
157+
158+
injectRoute({
159+
entrypoint: '@hugomrdias/docs/llms-full.txt',
160+
pattern: '/llms-full.txt',
161+
})
162+
163+
injectRoute({
164+
entrypoint: '@hugomrdias/docs/markdown',
165+
pattern: '/[...slug].md',
166+
})
250167
},
251168
},
252169
})
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { site } from 'astro:config/client'
2+
import config from 'virtual:llms-plugin/config'
3+
import type { APIRoute } from 'astro'
4+
import { generateLlmsIndex } from './utils'
5+
6+
export const GET: APIRoute = () => {
7+
return generateLlmsIndex(site, config, Number.POSITIVE_INFINITY)
8+
}
Lines changed: 3 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,83 +1,8 @@
1-
// @ts-expect-error - TODO: fix this
21
import { site } from 'astro:config/client'
3-
// @ts-expect-error - TODO: fix this
4-
import { getCollection, type InferEntrySchema } from 'astro:content'
52
import config from 'virtual:llms-plugin/config'
63
import type { APIRoute } from 'astro'
4+
import { generateLlmsIndex } from './utils'
75

8-
/**
9-
* Route that generates a single plaintext Markdown document from the full website content.
10-
*/
11-
export const GET: APIRoute = async () => {
12-
type SectionNode = {
13-
docs: { id: string; data: InferEntrySchema<'docs'> }[]
14-
children: Map<string, SectionNode>
15-
}
16-
17-
function toTitleCase(str: string): string {
18-
return str
19-
.split('-')
20-
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
21-
.join(' ')
22-
}
23-
24-
const root: SectionNode = { docs: [], children: new Map() }
25-
// @ts-expect-error - TODO: fix this
26-
const docs = await getCollection('docs', (doc) => !doc.data.draft)
27-
let body = `# ${config.title}\n\n${config.description}\n\n`
28-
29-
for (const doc of docs) {
30-
const segments = doc.id.split('/')
31-
let current = root
32-
for (let i = 0; i < segments.length - 1; i++) {
33-
const segment = segments[i]
34-
if (!current.children.has(segment)) {
35-
current.children.set(segment, { docs: [], children: new Map() })
36-
}
37-
const next = current.children.get(segment)
38-
if (next) {
39-
current = next
40-
}
41-
}
42-
current.docs.push({ id: doc.id, data: doc.data })
43-
}
44-
45-
function renderSection(
46-
node: SectionNode,
47-
level: number,
48-
path: string[]
49-
): string {
50-
let output = ''
51-
const headerLevel = '#'.repeat(level + 1)
52-
if (node.docs.length > 0 && path.length === 0) {
53-
for (const doc of node.docs) {
54-
const description = doc.data.description
55-
? `: ${doc.data.description}`
56-
: ''
57-
output += `- [${doc.data.title}](${site}/${doc.id}.md)${description}\n`
58-
}
59-
output += '\n'
60-
}
61-
62-
for (const [segment, childNode] of node.children.entries()) {
63-
const currentPath = [...path, segment]
64-
output += `${headerLevel} ${toTitleCase(segment)}\n\n`
65-
if (childNode.docs.length > 0) {
66-
for (const doc of childNode.docs) {
67-
const description = doc.data.description
68-
? `: ${doc.data.description}`
69-
: ''
70-
output += `- [${doc.data.title}](${site}/${doc.id}.md)${description}\n`
71-
}
72-
output += '\n'
73-
}
74-
output += renderSection(childNode, level + 1, currentPath)
75-
}
76-
77-
return output
78-
}
79-
80-
body += renderSection(root, 1, [])
81-
82-
return new Response(body)
6+
export const GET: APIRoute = () => {
7+
return generateLlmsIndex(site, config, config.maxDepth ?? 4)
838
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { getCollection } from 'astro:content'
2+
import type { APIRoute, GetStaticPaths } from 'astro'
3+
import { type DocEntry, getMarkdownPath, stripMdxSyntax } from './strip'
4+
5+
export const getStaticPaths: GetStaticPaths = async () => {
6+
const docs = await getCollection(
7+
'docs',
8+
(doc: { data: { draft?: boolean } }) => !doc.data.draft
9+
)
10+
11+
return docs.map((doc) => ({
12+
params: { slug: getMarkdownPath(doc.id) },
13+
props: { doc },
14+
}))
15+
}
16+
17+
export const GET: APIRoute = ({ props }) => {
18+
const { doc } = props as { doc: DocEntry }
19+
const body = `# ${doc.data.title}\n\n${stripMdxSyntax(doc.body).trim()}`
20+
21+
return new Response(body, {
22+
headers: {
23+
'content-type': 'text/markdown; charset=utf-8',
24+
},
25+
})
26+
}

0 commit comments

Comments
 (0)