@levino/shipyard-docs 0.5.1 → 0.5.2
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/README.md +1 -1
- package/astro/Layout.astro +19 -1
- package/astro/LlmsTxtSidebarLabel.astro +63 -0
- package/package.json +2 -2
- package/src/index.ts +285 -1
- package/src/llmsTxt.ts +183 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @levino/shipyard-docs
|
|
2
2
|
|
|
3
|
-
Documentation plugin for [
|
|
3
|
+
Documentation plugin for [shipyard](https://shipyard.levinkeller.de), a general-purpose page builder for Astro.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
package/astro/Layout.astro
CHANGED
|
@@ -5,12 +5,14 @@ import { docsConfigs } from 'virtual:shipyard-docs-configs'
|
|
|
5
5
|
import type { NavigationTree } from '@levino/shipyard-base'
|
|
6
6
|
import { Breadcrumbs, TableOfContents } from '@levino/shipyard-base/components'
|
|
7
7
|
import BaseLayout from '@levino/shipyard-base/layouts/Page.astro'
|
|
8
|
+
import { experimental_AstroContainer as AstroContainer } from 'astro/container'
|
|
8
9
|
import { Array as EffectArray, Option } from 'effect'
|
|
9
10
|
import { getPaginationInfo } from '../src/pagination'
|
|
10
11
|
import type { DocsData } from '../src/sidebarEntries'
|
|
11
12
|
import { toSidebarEntries } from '../src/sidebarEntries'
|
|
12
13
|
import DocMetadata from './DocMetadata.astro'
|
|
13
14
|
import DocPagination from './DocPagination.astro'
|
|
15
|
+
import LlmsTxtSidebarLabel from './LlmsTxtSidebarLabel.astro'
|
|
14
16
|
|
|
15
17
|
interface Props {
|
|
16
18
|
headings?: { depth: number; text: string; slug: string }[]
|
|
@@ -122,8 +124,24 @@ const entries: NavigationTree =
|
|
|
122
124
|
? (fullTree[Astro.currentLocale]?.subEntry ?? {})
|
|
123
125
|
: fullTree
|
|
124
126
|
|
|
125
|
-
// Compute pagination info for the current page
|
|
127
|
+
// Compute pagination info for the current page BEFORE adding llms.txt
|
|
128
|
+
// (llms.txt should not be part of pagination navigation)
|
|
126
129
|
const pagination = getPaginationInfo(Astro.url.pathname, entries, docs)
|
|
130
|
+
|
|
131
|
+
// Add llms.txt link to sidebar if enabled for this docs instance
|
|
132
|
+
// This is added AFTER pagination computation so it doesn't affect prev/next navigation
|
|
133
|
+
const docsConfig = docsConfigs[normalizedBasePath]
|
|
134
|
+
const llmsTxtHref = `/${normalizedBasePath}/llms.txt`
|
|
135
|
+
// Render the LlmsTxtSidebarLabel component to HTML using Astro Container
|
|
136
|
+
if (docsConfig?.llmsTxtEnabled) {
|
|
137
|
+
const container = await AstroContainer.create()
|
|
138
|
+
const llmsTxtLabelHtml = await container.renderToString(LlmsTxtSidebarLabel, {
|
|
139
|
+
props: { href: llmsTxtHref },
|
|
140
|
+
})
|
|
141
|
+
entries['llms.txt'] = {
|
|
142
|
+
labelHtml: llmsTxtLabelHtml,
|
|
143
|
+
}
|
|
144
|
+
}
|
|
127
145
|
---
|
|
128
146
|
|
|
129
147
|
<BaseLayout sidebarNavigation={entries}>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props {
|
|
3
|
+
href: string
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const { href } = Astro.props
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<llms-txt-label data-href={href}>
|
|
10
|
+
<span class="flex items-center justify-between gap-1">
|
|
11
|
+
<a href={href}>llms.txt</a>
|
|
12
|
+
<button
|
|
13
|
+
type="button"
|
|
14
|
+
class="btn btn-ghost btn-xs p-0.5"
|
|
15
|
+
title="Copy URL to clipboard"
|
|
16
|
+
aria-label="Copy URL to clipboard"
|
|
17
|
+
data-copy-url={href}
|
|
18
|
+
>
|
|
19
|
+
<svg
|
|
20
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
21
|
+
fill="none"
|
|
22
|
+
viewBox="0 0 24 24"
|
|
23
|
+
stroke-width="1.5"
|
|
24
|
+
stroke="currentColor"
|
|
25
|
+
class="w-4 h-4"
|
|
26
|
+
>
|
|
27
|
+
<path
|
|
28
|
+
stroke-linecap="round"
|
|
29
|
+
stroke-linejoin="round"
|
|
30
|
+
d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75"
|
|
31
|
+
></path>
|
|
32
|
+
</svg>
|
|
33
|
+
</button>
|
|
34
|
+
</span>
|
|
35
|
+
</llms-txt-label>
|
|
36
|
+
|
|
37
|
+
<script>
|
|
38
|
+
class LlmsTxtLabel extends HTMLElement {
|
|
39
|
+
connectedCallback() {
|
|
40
|
+
const href = this.dataset.href
|
|
41
|
+
if (!href) return
|
|
42
|
+
|
|
43
|
+
const button = this.querySelector('button')
|
|
44
|
+
if (!button) return
|
|
45
|
+
|
|
46
|
+
button.addEventListener('click', async (event) => {
|
|
47
|
+
event.preventDefault()
|
|
48
|
+
event.stopPropagation()
|
|
49
|
+
|
|
50
|
+
const fullUrl = new URL(href, window.location.origin).toString()
|
|
51
|
+
try {
|
|
52
|
+
await navigator.clipboard.writeText(fullUrl)
|
|
53
|
+
button.classList.add('text-success')
|
|
54
|
+
setTimeout(() => button.classList.remove('text-success'), 1500)
|
|
55
|
+
} catch (error) {
|
|
56
|
+
console.error('Failed to copy URL:', error)
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
customElements.define('llms-txt-label', LlmsTxtLabel)
|
|
63
|
+
</script>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@levino/shipyard-docs",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"effect": "^3.12.5",
|
|
18
18
|
"ramda": "^0.31",
|
|
19
|
-
"@levino/shipyard-base": "^0.5.
|
|
19
|
+
"@levino/shipyard-base": "^0.5.11"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@tailwindcss/typography": "^0.5.16",
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,9 @@ import { z } from 'astro/zod'
|
|
|
7
7
|
// Re-export git metadata utilities
|
|
8
8
|
export type { GitMetadata } from './gitMetadata'
|
|
9
9
|
export { getEditUrl, getGitMetadata } from './gitMetadata'
|
|
10
|
+
// Re-export llms.txt utilities
|
|
11
|
+
export type { LlmsDocEntry, LlmsTxtConfig } from './llmsTxt'
|
|
12
|
+
export { generateLlmsFullTxt, generateLlmsTxt } from './llmsTxt'
|
|
10
13
|
// Re-export pagination types and utilities
|
|
11
14
|
export type { PaginationInfo, PaginationLink } from './pagination'
|
|
12
15
|
export { getPaginationInfo } from './pagination'
|
|
@@ -49,6 +52,39 @@ export const docsSchema = z.object({
|
|
|
49
52
|
custom_edit_url: z.string().nullable().optional(),
|
|
50
53
|
})
|
|
51
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Configuration for llms.txt generation.
|
|
57
|
+
* When enabled, generates llms.txt and llms-full.txt files following
|
|
58
|
+
* the specification at https://llmstxt.org/
|
|
59
|
+
*/
|
|
60
|
+
export interface LlmsTxtDocsConfig {
|
|
61
|
+
/**
|
|
62
|
+
* Whether to enable llms.txt generation.
|
|
63
|
+
* @default false
|
|
64
|
+
*/
|
|
65
|
+
enabled?: boolean
|
|
66
|
+
/**
|
|
67
|
+
* The project name to use as the H1 heading in llms.txt.
|
|
68
|
+
* If not provided, will need to be set for the file to be useful.
|
|
69
|
+
*/
|
|
70
|
+
projectName?: string
|
|
71
|
+
/**
|
|
72
|
+
* A concise summary of the project displayed as a blockquote.
|
|
73
|
+
* This should help LLMs quickly understand what the project is about.
|
|
74
|
+
*/
|
|
75
|
+
summary?: string
|
|
76
|
+
/**
|
|
77
|
+
* Optional additional description paragraphs.
|
|
78
|
+
* Displayed after the summary blockquote.
|
|
79
|
+
*/
|
|
80
|
+
description?: string
|
|
81
|
+
/**
|
|
82
|
+
* Custom section title for the documentation links.
|
|
83
|
+
* @default 'Documentation'
|
|
84
|
+
*/
|
|
85
|
+
sectionTitle?: string
|
|
86
|
+
}
|
|
87
|
+
|
|
52
88
|
/**
|
|
53
89
|
* Configuration for a docs instance.
|
|
54
90
|
*/
|
|
@@ -83,6 +119,23 @@ export interface DocsConfig {
|
|
|
83
119
|
* @default false
|
|
84
120
|
*/
|
|
85
121
|
showLastUpdateAuthor?: boolean
|
|
122
|
+
/**
|
|
123
|
+
* Configuration for llms.txt generation.
|
|
124
|
+
* Enables automatic generation of llms.txt and llms-full.txt files
|
|
125
|
+
* that help LLMs understand and index your documentation.
|
|
126
|
+
*
|
|
127
|
+
* @example
|
|
128
|
+
* ```ts
|
|
129
|
+
* shipyardDocs({
|
|
130
|
+
* llmsTxt: {
|
|
131
|
+
* enabled: true,
|
|
132
|
+
* projectName: 'My Project',
|
|
133
|
+
* summary: 'A framework for building amazing apps',
|
|
134
|
+
* }
|
|
135
|
+
* })
|
|
136
|
+
* ```
|
|
137
|
+
*/
|
|
138
|
+
llmsTxt?: LlmsTxtDocsConfig
|
|
86
139
|
}
|
|
87
140
|
|
|
88
141
|
/**
|
|
@@ -113,7 +166,7 @@ export const createDocsCollection = (
|
|
|
113
166
|
})
|
|
114
167
|
|
|
115
168
|
/**
|
|
116
|
-
*
|
|
169
|
+
* shipyard Docs integration for Astro.
|
|
117
170
|
*
|
|
118
171
|
* Supports multiple documentation instances with configurable route mounting.
|
|
119
172
|
*
|
|
@@ -147,6 +200,7 @@ const docsConfigs: Record<
|
|
|
147
200
|
showLastUpdateAuthor: boolean
|
|
148
201
|
routeBasePath: string
|
|
149
202
|
collectionName: string
|
|
203
|
+
llmsTxtEnabled: boolean
|
|
150
204
|
}
|
|
151
205
|
> = {}
|
|
152
206
|
|
|
@@ -157,6 +211,7 @@ export default (config: DocsConfig = {}): AstroIntegration => {
|
|
|
157
211
|
editUrl,
|
|
158
212
|
showLastUpdateTime = false,
|
|
159
213
|
showLastUpdateAuthor = false,
|
|
214
|
+
llmsTxt,
|
|
160
215
|
} = config
|
|
161
216
|
|
|
162
217
|
// Normalize the route base path (remove leading/trailing slashes safely)
|
|
@@ -178,6 +233,7 @@ export default (config: DocsConfig = {}): AstroIntegration => {
|
|
|
178
233
|
showLastUpdateAuthor,
|
|
179
234
|
routeBasePath: normalizedBasePath,
|
|
180
235
|
collectionName: resolvedCollectionName,
|
|
236
|
+
llmsTxtEnabled: !!llmsTxt?.enabled,
|
|
181
237
|
}
|
|
182
238
|
|
|
183
239
|
// Virtual module for this specific route's config
|
|
@@ -342,6 +398,234 @@ if (
|
|
|
342
398
|
prerender: true,
|
|
343
399
|
})
|
|
344
400
|
}
|
|
401
|
+
|
|
402
|
+
// Generate llms.txt routes if enabled
|
|
403
|
+
if (llmsTxt?.enabled) {
|
|
404
|
+
const llmsTxtConfig = {
|
|
405
|
+
projectName: llmsTxt.projectName ?? 'Documentation',
|
|
406
|
+
summary: llmsTxt.summary,
|
|
407
|
+
description: llmsTxt.description,
|
|
408
|
+
sectionTitle: llmsTxt.sectionTitle ?? 'Documentation',
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Generate individual plain text endpoints for each doc page
|
|
412
|
+
// These are mounted at /_llms-txt/[slug].txt
|
|
413
|
+
const llmsTxtPagesFileName = `llms-txt-pages-${normalizedBasePath}.ts`
|
|
414
|
+
const llmsTxtPagesFilePath = join(generatedDir, llmsTxtPagesFileName)
|
|
415
|
+
|
|
416
|
+
const llmsTxtPagesFileContent = `import type { APIRoute, GetStaticPaths } from 'astro'
|
|
417
|
+
import { i18n } from 'astro:config/server'
|
|
418
|
+
import { getCollection, render } from 'astro:content'
|
|
419
|
+
|
|
420
|
+
const collectionName = ${JSON.stringify(resolvedCollectionName)}
|
|
421
|
+
|
|
422
|
+
export const getStaticPaths: GetStaticPaths = async () => {
|
|
423
|
+
const allDocs = await getCollection(collectionName)
|
|
424
|
+
|
|
425
|
+
// When i18n is enabled, only include docs from the default locale
|
|
426
|
+
const defaultLocale = i18n?.defaultLocale
|
|
427
|
+
const docs = defaultLocale
|
|
428
|
+
? allDocs.filter((doc) => doc.id.startsWith(defaultLocale + '/') || doc.id === defaultLocale)
|
|
429
|
+
: allDocs
|
|
430
|
+
|
|
431
|
+
return docs.map((doc) => {
|
|
432
|
+
const cleanId = doc.id.replace(/\\.md$/, '')
|
|
433
|
+
// For i18n, strip the locale prefix from the slug
|
|
434
|
+
let slug = cleanId
|
|
435
|
+
if (i18n && defaultLocale) {
|
|
436
|
+
const [locale, ...rest] = cleanId.split('/')
|
|
437
|
+
slug = rest.length ? rest.join('/') : locale
|
|
438
|
+
}
|
|
439
|
+
// Handle index pages - use special suffix
|
|
440
|
+
if (slug.endsWith('/index')) {
|
|
441
|
+
slug = slug.slice(0, -6) + '/_index'
|
|
442
|
+
} else if (slug === 'index') {
|
|
443
|
+
slug = '_index'
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return {
|
|
447
|
+
// For [...slug], the param should be the full path string (Astro handles the split)
|
|
448
|
+
params: { slug },
|
|
449
|
+
props: { doc },
|
|
450
|
+
}
|
|
451
|
+
})
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export const GET: APIRoute = async ({ props }) => {
|
|
455
|
+
const { doc } = props as { doc: Awaited<ReturnType<typeof getCollection>>[number] }
|
|
456
|
+
const { headings } = await render(doc)
|
|
457
|
+
const h1 = headings.find((h) => h.depth === 1)
|
|
458
|
+
|
|
459
|
+
// Build the plain text content with title and raw markdown body
|
|
460
|
+
const title = doc.data.title ?? h1?.text ?? doc.id
|
|
461
|
+
const description = doc.data.description ? doc.data.description + '\\n\\n' : ''
|
|
462
|
+
const body = doc.body ?? ''
|
|
463
|
+
|
|
464
|
+
const content = '# ' + title + '\\n\\n' + description + body
|
|
465
|
+
|
|
466
|
+
return new Response(content, {
|
|
467
|
+
headers: {
|
|
468
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
469
|
+
},
|
|
470
|
+
})
|
|
471
|
+
}
|
|
472
|
+
`
|
|
473
|
+
writeFileSync(llmsTxtPagesFilePath, llmsTxtPagesFileContent)
|
|
474
|
+
|
|
475
|
+
// Generate llms.txt endpoint file
|
|
476
|
+
const llmsTxtFileName = `llms-txt-${normalizedBasePath}.ts`
|
|
477
|
+
const llmsTxtFilePath = join(generatedDir, llmsTxtFileName)
|
|
478
|
+
|
|
479
|
+
const llmsTxtFileContent = `import type { APIRoute } from 'astro'
|
|
480
|
+
import { i18n } from 'astro:config/server'
|
|
481
|
+
import { getCollection, render } from 'astro:content'
|
|
482
|
+
import { generateLlmsTxt } from '@levino/shipyard-docs'
|
|
483
|
+
|
|
484
|
+
const llmsTxtConfig = ${JSON.stringify(llmsTxtConfig)}
|
|
485
|
+
const collectionName = ${JSON.stringify(resolvedCollectionName)}
|
|
486
|
+
const routeBasePath = ${JSON.stringify(normalizedBasePath)}
|
|
487
|
+
|
|
488
|
+
export const GET: APIRoute = async ({ site }) => {
|
|
489
|
+
const baseUrl = site?.toString() ?? 'https://example.com'
|
|
490
|
+
const allDocs = await getCollection(collectionName)
|
|
491
|
+
|
|
492
|
+
// When i18n is enabled, only include docs from the default locale
|
|
493
|
+
const defaultLocale = i18n?.defaultLocale
|
|
494
|
+
const docs = defaultLocale
|
|
495
|
+
? allDocs.filter((doc) => doc.id.startsWith(defaultLocale + '/') || doc.id === defaultLocale)
|
|
496
|
+
: allDocs
|
|
497
|
+
|
|
498
|
+
const entries = await Promise.all(
|
|
499
|
+
docs.map(async (doc) => {
|
|
500
|
+
const { headings } = await render(doc)
|
|
501
|
+
const h1 = headings.find((h) => h.depth === 1)
|
|
502
|
+
const cleanId = doc.id.replace(/\\.md$/, '')
|
|
503
|
+
|
|
504
|
+
// Generate slug for the _llms-txt path
|
|
505
|
+
let slug = cleanId
|
|
506
|
+
if (i18n && defaultLocale) {
|
|
507
|
+
const [locale, ...rest] = cleanId.split('/')
|
|
508
|
+
slug = rest.length ? rest.join('/') : locale
|
|
509
|
+
}
|
|
510
|
+
// Handle index pages - use special suffix
|
|
511
|
+
if (slug.endsWith('/index')) {
|
|
512
|
+
slug = slug.slice(0, -6) + '/_index'
|
|
513
|
+
} else if (slug === 'index') {
|
|
514
|
+
slug = '_index'
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Path points to the plain text file
|
|
518
|
+
const path = '/' + routeBasePath + '/_llms-txt/' + slug + '.txt'
|
|
519
|
+
|
|
520
|
+
return {
|
|
521
|
+
path,
|
|
522
|
+
title: doc.data.title ?? h1?.text ?? doc.id,
|
|
523
|
+
description: doc.data.description,
|
|
524
|
+
position: doc.data.sidebar_position,
|
|
525
|
+
}
|
|
526
|
+
})
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
const content = generateLlmsTxt(entries, llmsTxtConfig, baseUrl)
|
|
530
|
+
|
|
531
|
+
return new Response(content, {
|
|
532
|
+
headers: {
|
|
533
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
534
|
+
},
|
|
535
|
+
})
|
|
536
|
+
}
|
|
537
|
+
`
|
|
538
|
+
writeFileSync(llmsTxtFilePath, llmsTxtFileContent)
|
|
539
|
+
|
|
540
|
+
// Generate llms-full.txt endpoint file
|
|
541
|
+
const llmsFullTxtFileName = `llms-full-txt-${normalizedBasePath}.ts`
|
|
542
|
+
const llmsFullTxtFilePath = join(generatedDir, llmsFullTxtFileName)
|
|
543
|
+
|
|
544
|
+
const llmsFullTxtFileContent = `import type { APIRoute } from 'astro'
|
|
545
|
+
import { i18n } from 'astro:config/server'
|
|
546
|
+
import { getCollection, render } from 'astro:content'
|
|
547
|
+
import { generateLlmsFullTxt } from '@levino/shipyard-docs'
|
|
548
|
+
|
|
549
|
+
const llmsTxtConfig = ${JSON.stringify(llmsTxtConfig)}
|
|
550
|
+
const collectionName = ${JSON.stringify(resolvedCollectionName)}
|
|
551
|
+
const routeBasePath = ${JSON.stringify(normalizedBasePath)}
|
|
552
|
+
|
|
553
|
+
export const GET: APIRoute = async ({ site }) => {
|
|
554
|
+
const baseUrl = site?.toString() ?? 'https://example.com'
|
|
555
|
+
const allDocs = await getCollection(collectionName)
|
|
556
|
+
|
|
557
|
+
// When i18n is enabled, only include docs from the default locale
|
|
558
|
+
const defaultLocale = i18n?.defaultLocale
|
|
559
|
+
const docs = defaultLocale
|
|
560
|
+
? allDocs.filter((doc) => doc.id.startsWith(defaultLocale + '/') || doc.id === defaultLocale)
|
|
561
|
+
: allDocs
|
|
562
|
+
|
|
563
|
+
const entries = await Promise.all(
|
|
564
|
+
docs.map(async (doc) => {
|
|
565
|
+
const { headings } = await render(doc)
|
|
566
|
+
const h1 = headings.find((h) => h.depth === 1)
|
|
567
|
+
const cleanId = doc.id.replace(/\\.md$/, '')
|
|
568
|
+
|
|
569
|
+
// Generate slug for the _llms-txt path
|
|
570
|
+
let slug = cleanId
|
|
571
|
+
if (i18n && defaultLocale) {
|
|
572
|
+
const [locale, ...rest] = cleanId.split('/')
|
|
573
|
+
slug = rest.length ? rest.join('/') : locale
|
|
574
|
+
}
|
|
575
|
+
// Handle index pages - use special suffix
|
|
576
|
+
if (slug.endsWith('/index')) {
|
|
577
|
+
slug = slug.slice(0, -6) + '/_index'
|
|
578
|
+
} else if (slug === 'index') {
|
|
579
|
+
slug = '_index'
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Path points to the plain text file
|
|
583
|
+
const path = '/' + routeBasePath + '/_llms-txt/' + slug + '.txt'
|
|
584
|
+
|
|
585
|
+
// Read the raw markdown content from the file
|
|
586
|
+
const rawContent = doc.body ?? ''
|
|
587
|
+
|
|
588
|
+
return {
|
|
589
|
+
path,
|
|
590
|
+
title: doc.data.title ?? h1?.text ?? doc.id,
|
|
591
|
+
description: doc.data.description,
|
|
592
|
+
position: doc.data.sidebar_position,
|
|
593
|
+
content: rawContent,
|
|
594
|
+
}
|
|
595
|
+
})
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
const content = generateLlmsFullTxt(entries, llmsTxtConfig, baseUrl)
|
|
599
|
+
|
|
600
|
+
return new Response(content, {
|
|
601
|
+
headers: {
|
|
602
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
603
|
+
},
|
|
604
|
+
})
|
|
605
|
+
}
|
|
606
|
+
`
|
|
607
|
+
writeFileSync(llmsFullTxtFilePath, llmsFullTxtFileContent)
|
|
608
|
+
|
|
609
|
+
// Inject route for individual plain text pages (catch-all for nested paths)
|
|
610
|
+
injectRoute({
|
|
611
|
+
pattern: `/${normalizedBasePath}/_llms-txt/[...slug].txt`,
|
|
612
|
+
entrypoint: llmsTxtPagesFilePath,
|
|
613
|
+
prerender: true,
|
|
614
|
+
})
|
|
615
|
+
|
|
616
|
+
// Inject routes for llms.txt and llms-full.txt under the docs path
|
|
617
|
+
injectRoute({
|
|
618
|
+
pattern: `/${normalizedBasePath}/llms.txt`,
|
|
619
|
+
entrypoint: llmsTxtFilePath,
|
|
620
|
+
prerender: true,
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
injectRoute({
|
|
624
|
+
pattern: `/${normalizedBasePath}/llms-full.txt`,
|
|
625
|
+
entrypoint: llmsFullTxtFilePath,
|
|
626
|
+
prerender: true,
|
|
627
|
+
})
|
|
628
|
+
}
|
|
345
629
|
},
|
|
346
630
|
},
|
|
347
631
|
}
|
package/src/llmsTxt.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* llms.txt generation utilities for shipyard-docs.
|
|
3
|
+
*
|
|
4
|
+
* This module provides utilities to generate llms.txt and llms-full.txt files
|
|
5
|
+
* following the specification at https://llmstxt.org/
|
|
6
|
+
*
|
|
7
|
+
* The llms.txt format helps LLMs efficiently parse and understand documentation
|
|
8
|
+
* by providing a structured markdown index of all documentation pages.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Configuration for llms.txt generation.
|
|
13
|
+
*/
|
|
14
|
+
export interface LlmsTxtConfig {
|
|
15
|
+
/**
|
|
16
|
+
* Whether to enable llms.txt generation.
|
|
17
|
+
* @default false
|
|
18
|
+
*/
|
|
19
|
+
enabled?: boolean
|
|
20
|
+
/**
|
|
21
|
+
* The project name to use as the H1 heading.
|
|
22
|
+
* If not provided, the title from the shipyard-base configuration will be used.
|
|
23
|
+
*/
|
|
24
|
+
projectName?: string
|
|
25
|
+
/**
|
|
26
|
+
* A concise summary of the project displayed as a blockquote.
|
|
27
|
+
* This should help LLMs quickly understand what the project is about.
|
|
28
|
+
*/
|
|
29
|
+
summary?: string
|
|
30
|
+
/**
|
|
31
|
+
* Optional additional description paragraphs.
|
|
32
|
+
* Displayed after the summary blockquote.
|
|
33
|
+
*/
|
|
34
|
+
description?: string
|
|
35
|
+
/**
|
|
36
|
+
* Custom section title for the documentation links.
|
|
37
|
+
* @default 'Documentation'
|
|
38
|
+
*/
|
|
39
|
+
sectionTitle?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Represents a documentation page for llms.txt generation.
|
|
44
|
+
*/
|
|
45
|
+
export interface LlmsDocEntry {
|
|
46
|
+
/**
|
|
47
|
+
* The URL path to the documentation page.
|
|
48
|
+
*/
|
|
49
|
+
path: string
|
|
50
|
+
/**
|
|
51
|
+
* The title of the documentation page.
|
|
52
|
+
*/
|
|
53
|
+
title: string
|
|
54
|
+
/**
|
|
55
|
+
* Optional description of what the page covers.
|
|
56
|
+
*/
|
|
57
|
+
description?: string
|
|
58
|
+
/**
|
|
59
|
+
* The full rendered content of the page (used for llms-full.txt).
|
|
60
|
+
*/
|
|
61
|
+
content?: string
|
|
62
|
+
/**
|
|
63
|
+
* Position for sorting (lower numbers come first).
|
|
64
|
+
*/
|
|
65
|
+
position?: number
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Generates the content for llms.txt following the specification.
|
|
70
|
+
*
|
|
71
|
+
* @param entries - Array of documentation entries to include
|
|
72
|
+
* @param config - Configuration options
|
|
73
|
+
* @param baseUrl - Base URL of the site (e.g., 'https://docs.example.com')
|
|
74
|
+
* @returns The generated llms.txt content as a string
|
|
75
|
+
*/
|
|
76
|
+
export function generateLlmsTxt(
|
|
77
|
+
entries: LlmsDocEntry[],
|
|
78
|
+
config: LlmsTxtConfig,
|
|
79
|
+
baseUrl: string,
|
|
80
|
+
): string {
|
|
81
|
+
const lines: string[] = []
|
|
82
|
+
|
|
83
|
+
// H1 title (required)
|
|
84
|
+
const projectName = config.projectName ?? 'Documentation'
|
|
85
|
+
lines.push(`# ${projectName}`)
|
|
86
|
+
lines.push('')
|
|
87
|
+
|
|
88
|
+
// Blockquote summary (recommended)
|
|
89
|
+
if (config.summary) {
|
|
90
|
+
lines.push(`> ${config.summary}`)
|
|
91
|
+
lines.push('')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Additional description
|
|
95
|
+
if (config.description) {
|
|
96
|
+
lines.push(config.description)
|
|
97
|
+
lines.push('')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Documentation section
|
|
101
|
+
const sectionTitle = config.sectionTitle ?? 'Documentation'
|
|
102
|
+
lines.push(`## ${sectionTitle}`)
|
|
103
|
+
lines.push('')
|
|
104
|
+
|
|
105
|
+
// Sort entries by position, then by title
|
|
106
|
+
const sortedEntries = [...entries].sort((a, b) => {
|
|
107
|
+
const posA = a.position ?? Number.POSITIVE_INFINITY
|
|
108
|
+
const posB = b.position ?? Number.POSITIVE_INFINITY
|
|
109
|
+
if (posA !== posB) return posA - posB
|
|
110
|
+
return a.title.localeCompare(b.title)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
// Generate links
|
|
114
|
+
for (const entry of sortedEntries) {
|
|
115
|
+
const url = new URL(entry.path, baseUrl).toString()
|
|
116
|
+
const description = entry.description ? `: ${entry.description}` : ''
|
|
117
|
+
lines.push(`- [${entry.title}](${url})${description}`)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
lines.push('')
|
|
121
|
+
return lines.join('\n')
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Generates the content for llms-full.txt with complete documentation content.
|
|
126
|
+
*
|
|
127
|
+
* @param entries - Array of documentation entries with content
|
|
128
|
+
* @param config - Configuration options
|
|
129
|
+
* @param baseUrl - Base URL of the site
|
|
130
|
+
* @returns The generated llms-full.txt content as a string
|
|
131
|
+
*/
|
|
132
|
+
export function generateLlmsFullTxt(
|
|
133
|
+
entries: LlmsDocEntry[],
|
|
134
|
+
config: LlmsTxtConfig,
|
|
135
|
+
baseUrl: string,
|
|
136
|
+
): string {
|
|
137
|
+
const lines: string[] = []
|
|
138
|
+
|
|
139
|
+
// H1 title (required)
|
|
140
|
+
const projectName = config.projectName ?? 'Documentation'
|
|
141
|
+
lines.push(`# ${projectName}`)
|
|
142
|
+
lines.push('')
|
|
143
|
+
|
|
144
|
+
// Blockquote summary (recommended)
|
|
145
|
+
if (config.summary) {
|
|
146
|
+
lines.push(`> ${config.summary}`)
|
|
147
|
+
lines.push('')
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Additional description
|
|
151
|
+
if (config.description) {
|
|
152
|
+
lines.push(config.description)
|
|
153
|
+
lines.push('')
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
lines.push('---')
|
|
157
|
+
lines.push('')
|
|
158
|
+
|
|
159
|
+
// Sort entries by position, then by title
|
|
160
|
+
const sortedEntries = [...entries].sort((a, b) => {
|
|
161
|
+
const posA = a.position ?? Number.POSITIVE_INFINITY
|
|
162
|
+
const posB = b.position ?? Number.POSITIVE_INFINITY
|
|
163
|
+
if (posA !== posB) return posA - posB
|
|
164
|
+
return a.title.localeCompare(b.title)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
// Include full content of each document
|
|
168
|
+
for (const entry of sortedEntries) {
|
|
169
|
+
const url = new URL(entry.path, baseUrl).toString()
|
|
170
|
+
lines.push(`## ${entry.title}`)
|
|
171
|
+
lines.push('')
|
|
172
|
+
lines.push(`URL: ${url}`)
|
|
173
|
+
lines.push('')
|
|
174
|
+
if (entry.content) {
|
|
175
|
+
lines.push(entry.content.trim())
|
|
176
|
+
lines.push('')
|
|
177
|
+
}
|
|
178
|
+
lines.push('---')
|
|
179
|
+
lines.push('')
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return lines.join('\n')
|
|
183
|
+
}
|