@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @levino/shipyard-docs
2
2
 
3
- Documentation plugin for [Shipyard](https://shipyard.levinkeller.de), a general-purpose page builder for Astro.
3
+ Documentation plugin for [shipyard](https://shipyard.levinkeller.de), a general-purpose page builder for Astro.
4
4
 
5
5
  ## Installation
6
6
 
@@ -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.1",
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.9"
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
- * Shipyard Docs integration for Astro.
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
+ }