mkdnsite 0.0.1
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/LICENSE +21 -0
- package/README.md +211 -0
- package/package.json +72 -0
- package/src/adapters/cloudflare.ts +85 -0
- package/src/adapters/fly.ts +22 -0
- package/src/adapters/local.ts +153 -0
- package/src/adapters/netlify.ts +17 -0
- package/src/adapters/types.ts +54 -0
- package/src/adapters/vercel.ts +48 -0
- package/src/cli.ts +140 -0
- package/src/client/scripts.ts +106 -0
- package/src/config/defaults.ts +68 -0
- package/src/config/schema.ts +192 -0
- package/src/content/filesystem.ts +210 -0
- package/src/content/frontmatter.ts +66 -0
- package/src/content/github.ts +211 -0
- package/src/content/types.ts +86 -0
- package/src/discovery/llmstxt.ts +70 -0
- package/src/handler.ts +188 -0
- package/src/index.ts +57 -0
- package/src/negotiate/accept.ts +72 -0
- package/src/negotiate/headers.ts +56 -0
- package/src/render/bun-native.ts +54 -0
- package/src/render/components/index.ts +149 -0
- package/src/render/page-shell.ts +121 -0
- package/src/render/portable.ts +222 -0
- package/src/render/types.ts +74 -0
- package/src/theme/prose-css.ts +377 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from 'node:fs/promises'
|
|
2
|
+
import { join, relative, extname, basename, dirname } from 'node:path'
|
|
3
|
+
import { parseFrontmatter } from './frontmatter.ts'
|
|
4
|
+
import type { ContentSource, ContentPage, NavNode } from './types.ts'
|
|
5
|
+
|
|
6
|
+
export class FilesystemSource implements ContentSource {
|
|
7
|
+
private readonly rootDir: string
|
|
8
|
+
private readonly cache = new Map<string, ContentPage>()
|
|
9
|
+
private navCache: NavNode | null = null
|
|
10
|
+
private allPagesCache: ContentPage[] | null = null
|
|
11
|
+
|
|
12
|
+
constructor (rootDir: string) {
|
|
13
|
+
this.rootDir = rootDir
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async getPage (slug: string): Promise<ContentPage | null> {
|
|
17
|
+
const stripped = slug.replace(/^\/+|\/+$/g, '')
|
|
18
|
+
const normalized = stripped !== '' ? stripped : 'index'
|
|
19
|
+
|
|
20
|
+
if (this.cache.has(normalized)) {
|
|
21
|
+
return this.cache.get(normalized) ?? null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const candidates = [
|
|
25
|
+
join(this.rootDir, `${normalized}.md`),
|
|
26
|
+
join(this.rootDir, normalized, 'index.md'),
|
|
27
|
+
join(this.rootDir, normalized, 'README.md'),
|
|
28
|
+
join(this.rootDir, normalized, 'readme.md')
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
if (normalized === 'index') {
|
|
32
|
+
candidates.unshift(
|
|
33
|
+
join(this.rootDir, 'index.md'),
|
|
34
|
+
join(this.rootDir, 'README.md'),
|
|
35
|
+
join(this.rootDir, 'readme.md')
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
for (const filePath of candidates) {
|
|
40
|
+
try {
|
|
41
|
+
const raw = await readFile(filePath, 'utf-8')
|
|
42
|
+
const parsed = parseFrontmatter(raw)
|
|
43
|
+
const fileStat = await stat(filePath)
|
|
44
|
+
|
|
45
|
+
const page: ContentPage = {
|
|
46
|
+
slug: `/${normalized === 'index' ? '' : normalized}`,
|
|
47
|
+
sourcePath: filePath,
|
|
48
|
+
meta: parsed.meta,
|
|
49
|
+
body: parsed.body,
|
|
50
|
+
raw: parsed.raw,
|
|
51
|
+
modifiedAt: fileStat.mtime
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
this.cache.set(normalized, page)
|
|
55
|
+
return page
|
|
56
|
+
} catch {
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async getNavTree (): Promise<NavNode> {
|
|
65
|
+
if (this.navCache != null) return this.navCache
|
|
66
|
+
this.navCache = await this.buildNavTree(this.rootDir, '/')
|
|
67
|
+
return this.navCache
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async listPages (): Promise<ContentPage[]> {
|
|
71
|
+
if (this.allPagesCache != null) return this.allPagesCache
|
|
72
|
+
|
|
73
|
+
const pages: ContentPage[] = []
|
|
74
|
+
await this.walkDir(this.rootDir, pages)
|
|
75
|
+
pages.sort((a, b) => a.slug.localeCompare(b.slug))
|
|
76
|
+
this.allPagesCache = pages
|
|
77
|
+
return pages
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async refresh (): Promise<void> {
|
|
81
|
+
this.cache.clear()
|
|
82
|
+
this.navCache = null
|
|
83
|
+
this.allPagesCache = null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private async walkDir (dir: string, pages: ContentPage[]): Promise<void> {
|
|
87
|
+
let entries
|
|
88
|
+
try {
|
|
89
|
+
entries = await readdir(dir, { withFileTypes: true })
|
|
90
|
+
} catch {
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for (const entry of entries) {
|
|
95
|
+
const fullPath = join(dir, entry.name)
|
|
96
|
+
|
|
97
|
+
if (entry.isDirectory()) {
|
|
98
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') {
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
await this.walkDir(fullPath, pages)
|
|
102
|
+
} else if (entry.isFile() && extname(entry.name) === '.md') {
|
|
103
|
+
const relPath = relative(this.rootDir, fullPath)
|
|
104
|
+
const slug = this.filePathToSlug(relPath)
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const raw = await readFile(fullPath, 'utf-8')
|
|
108
|
+
const parsed = parseFrontmatter(raw)
|
|
109
|
+
const fileStat = await stat(fullPath)
|
|
110
|
+
|
|
111
|
+
if (parsed.meta.draft === true) continue
|
|
112
|
+
|
|
113
|
+
pages.push({
|
|
114
|
+
slug,
|
|
115
|
+
sourcePath: fullPath,
|
|
116
|
+
meta: parsed.meta,
|
|
117
|
+
body: parsed.body,
|
|
118
|
+
raw: parsed.raw,
|
|
119
|
+
modifiedAt: fileStat.mtime
|
|
120
|
+
})
|
|
121
|
+
} catch {
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private filePathToSlug (relPath: string): string {
|
|
129
|
+
let slug = relPath.replace(/\.md$/, '')
|
|
130
|
+
|
|
131
|
+
const base = basename(slug)
|
|
132
|
+
if (base === 'index' || base === 'README' || base === 'readme') {
|
|
133
|
+
slug = dirname(slug)
|
|
134
|
+
if (slug === '.') slug = ''
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
slug = slug.replace(/\\/g, '/')
|
|
138
|
+
return `/${slug}`
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private async buildNavTree (dir: string, slugPrefix: string): Promise<NavNode> {
|
|
142
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
143
|
+
const children: NavNode[] = []
|
|
144
|
+
|
|
145
|
+
let dirTitle = basename(dir)
|
|
146
|
+
let dirOrder = 0
|
|
147
|
+
|
|
148
|
+
for (const name of ['index.md', 'README.md', 'readme.md']) {
|
|
149
|
+
try {
|
|
150
|
+
const raw = await readFile(join(dir, name), 'utf-8')
|
|
151
|
+
const parsed = parseFrontmatter(raw)
|
|
152
|
+
if (parsed.meta.title != null) dirTitle = parsed.meta.title
|
|
153
|
+
if (parsed.meta.order != null) dirOrder = parsed.meta.order
|
|
154
|
+
break
|
|
155
|
+
} catch {
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
for (const entry of entries) {
|
|
161
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue
|
|
162
|
+
|
|
163
|
+
const fullPath = join(dir, entry.name)
|
|
164
|
+
const childSlug = `${slugPrefix}${slugPrefix.endsWith('/') ? '' : '/'}${entry.name}`
|
|
165
|
+
|
|
166
|
+
if (entry.isDirectory()) {
|
|
167
|
+
const subtree = await this.buildNavTree(fullPath, childSlug)
|
|
168
|
+
children.push(subtree)
|
|
169
|
+
} else if (
|
|
170
|
+
entry.isFile() &&
|
|
171
|
+
extname(entry.name) === '.md' &&
|
|
172
|
+
entry.name !== 'index.md' &&
|
|
173
|
+
entry.name !== 'README.md' &&
|
|
174
|
+
entry.name !== 'readme.md'
|
|
175
|
+
) {
|
|
176
|
+
const raw = await readFile(fullPath, 'utf-8')
|
|
177
|
+
const parsed = parseFrontmatter(raw)
|
|
178
|
+
|
|
179
|
+
if (parsed.meta.draft === true) continue
|
|
180
|
+
|
|
181
|
+
const name = basename(entry.name, '.md')
|
|
182
|
+
const slug = `${slugPrefix}${slugPrefix.endsWith('/') ? '' : '/'}${name}`
|
|
183
|
+
|
|
184
|
+
children.push({
|
|
185
|
+
title: parsed.meta.title ?? titleCase(name),
|
|
186
|
+
slug,
|
|
187
|
+
order: parsed.meta.order ?? 999,
|
|
188
|
+
children: [],
|
|
189
|
+
isSection: false
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
children.sort((a, b) => (a.order !== 0 || b.order !== 0) ? a.order - b.order : a.title.localeCompare(b.title))
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
title: titleCase(dirTitle),
|
|
198
|
+
slug: slugPrefix,
|
|
199
|
+
order: dirOrder,
|
|
200
|
+
children,
|
|
201
|
+
isSection: true
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function titleCase (str: string): string {
|
|
207
|
+
return str
|
|
208
|
+
.replace(/[-_]/g, ' ')
|
|
209
|
+
.replace(/\b\w/g, c => c.toUpperCase())
|
|
210
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import matter from 'gray-matter'
|
|
2
|
+
import type { MarkdownMeta } from './types.ts'
|
|
3
|
+
|
|
4
|
+
export interface ParsedMarkdown {
|
|
5
|
+
meta: MarkdownMeta
|
|
6
|
+
body: string
|
|
7
|
+
raw: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Parse YAML frontmatter from a markdown string.
|
|
12
|
+
* Uses gray-matter for full YAML support, falls back to regex parser.
|
|
13
|
+
*/
|
|
14
|
+
export function parseFrontmatter (raw: string): ParsedMarkdown {
|
|
15
|
+
if (!raw.startsWith('---')) {
|
|
16
|
+
return { meta: {}, body: raw, raw }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const result = matter(raw)
|
|
21
|
+
return {
|
|
22
|
+
meta: result.data as MarkdownMeta,
|
|
23
|
+
body: result.content,
|
|
24
|
+
raw
|
|
25
|
+
}
|
|
26
|
+
} catch {
|
|
27
|
+
return parseFrontmatterSimple(raw)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Lightweight fallback parser for environments without gray-matter.
|
|
33
|
+
* Handles simple key: value pairs (no nested YAML).
|
|
34
|
+
*/
|
|
35
|
+
function parseFrontmatterSimple (raw: string): ParsedMarkdown {
|
|
36
|
+
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/)
|
|
37
|
+
if (match == null) {
|
|
38
|
+
return { meta: {}, body: raw, raw }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const [, frontmatter, body] = match
|
|
42
|
+
const meta: MarkdownMeta = {}
|
|
43
|
+
|
|
44
|
+
for (const line of frontmatter.split('\n')) {
|
|
45
|
+
const colonIdx = line.indexOf(':')
|
|
46
|
+
if (colonIdx === -1) continue
|
|
47
|
+
|
|
48
|
+
const key = line.slice(0, colonIdx).trim()
|
|
49
|
+
const rawValue = line.slice(colonIdx + 1).trim()
|
|
50
|
+
|
|
51
|
+
if (rawValue === 'true') {
|
|
52
|
+
(meta as Record<string, unknown>)[key] = true
|
|
53
|
+
} else if (rawValue === 'false') {
|
|
54
|
+
(meta as Record<string, unknown>)[key] = false
|
|
55
|
+
} else if (rawValue.startsWith('[') && rawValue.endsWith(']')) {
|
|
56
|
+
(meta as Record<string, unknown>)[key] = rawValue
|
|
57
|
+
.slice(1, -1)
|
|
58
|
+
.split(',')
|
|
59
|
+
.map(s => s.trim().replace(/^["']|["']$/g, ''))
|
|
60
|
+
} else {
|
|
61
|
+
(meta as Record<string, unknown>)[key] = rawValue
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { meta, body, raw }
|
|
66
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { parseFrontmatter } from './frontmatter.ts'
|
|
2
|
+
import type {
|
|
3
|
+
ContentSource,
|
|
4
|
+
ContentPage,
|
|
5
|
+
NavNode,
|
|
6
|
+
GitHubSourceConfig
|
|
7
|
+
} from './types.ts'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Content source that reads .md files from a public GitHub repository.
|
|
11
|
+
*
|
|
12
|
+
* Uses the GitHub Contents API (or raw.githubusercontent.com for performance).
|
|
13
|
+
* Supports caching for production use (bring your own cache backend).
|
|
14
|
+
*
|
|
15
|
+
* Rate limits:
|
|
16
|
+
* - Unauthenticated: 60 requests/hour
|
|
17
|
+
* - Authenticated (token): 5,000 requests/hour
|
|
18
|
+
*
|
|
19
|
+
* For the hosted service (mkdn.io), this source will be paired with:
|
|
20
|
+
* - Upstash Redis cache for rendered pages
|
|
21
|
+
* - GitHub webhook for cache invalidation on push
|
|
22
|
+
* - Optional R2 mirror for high-traffic sites
|
|
23
|
+
*/
|
|
24
|
+
export class GitHubSource implements ContentSource {
|
|
25
|
+
private readonly config: Required<GitHubSourceConfig>
|
|
26
|
+
private readonly cache = new Map<string, ContentPage>()
|
|
27
|
+
private navCache: NavNode | null = null
|
|
28
|
+
|
|
29
|
+
constructor (config: GitHubSourceConfig) {
|
|
30
|
+
this.config = {
|
|
31
|
+
owner: config.owner,
|
|
32
|
+
repo: config.repo,
|
|
33
|
+
ref: config.ref ?? 'main',
|
|
34
|
+
path: config.path ?? '',
|
|
35
|
+
token: config.token ?? ''
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async getPage (slug: string): Promise<ContentPage | null> {
|
|
40
|
+
const stripped = slug.replace(/^\/+|\/+$/g, '')
|
|
41
|
+
const normalized = stripped !== '' ? stripped : 'index'
|
|
42
|
+
|
|
43
|
+
if (this.cache.has(normalized)) {
|
|
44
|
+
return this.cache.get(normalized) ?? null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const candidates = [
|
|
48
|
+
`${normalized}.md`,
|
|
49
|
+
`${normalized}/index.md`,
|
|
50
|
+
`${normalized}/README.md`,
|
|
51
|
+
`${normalized}/readme.md`
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
if (normalized === 'index') {
|
|
55
|
+
candidates.unshift(
|
|
56
|
+
'index.md',
|
|
57
|
+
'README.md',
|
|
58
|
+
'readme.md'
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
for (const filePath of candidates) {
|
|
63
|
+
const raw = await this.fetchFile(filePath)
|
|
64
|
+
if (raw != null) {
|
|
65
|
+
const parsed = parseFrontmatter(raw)
|
|
66
|
+
const page: ContentPage = {
|
|
67
|
+
slug: `/${normalized === 'index' ? '' : normalized}`,
|
|
68
|
+
sourcePath: filePath,
|
|
69
|
+
meta: parsed.meta,
|
|
70
|
+
body: parsed.body,
|
|
71
|
+
raw: parsed.raw
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
this.cache.set(normalized, page)
|
|
75
|
+
return page
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async getNavTree (): Promise<NavNode> {
|
|
83
|
+
if (this.navCache != null) return this.navCache
|
|
84
|
+
|
|
85
|
+
// Fetch the repo tree via GitHub API
|
|
86
|
+
const tree = await this.fetchRepoTree()
|
|
87
|
+
this.navCache = this.buildNavFromTree(tree)
|
|
88
|
+
return this.navCache
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async listPages (): Promise<ContentPage[]> {
|
|
92
|
+
const tree = await this.fetchRepoTree()
|
|
93
|
+
const mdFiles = tree.filter(f => f.path.endsWith('.md'))
|
|
94
|
+
|
|
95
|
+
const pages: ContentPage[] = []
|
|
96
|
+
for (const file of mdFiles) {
|
|
97
|
+
const slug = file.path
|
|
98
|
+
.replace(/\.md$/, '')
|
|
99
|
+
.replace(/\/index$/, '')
|
|
100
|
+
.replace(/\/README$/, '')
|
|
101
|
+
.replace(/\/readme$/, '')
|
|
102
|
+
const page = await this.getPage(slug)
|
|
103
|
+
if (page != null) pages.push(page)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return pages.sort((a, b) => a.slug.localeCompare(b.slug))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async refresh (): Promise<void> {
|
|
110
|
+
this.cache.clear()
|
|
111
|
+
this.navCache = null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Fetch a file from the GitHub repo via raw.githubusercontent.com
|
|
116
|
+
* (faster than the API, no rate limit headers, but no metadata).
|
|
117
|
+
*/
|
|
118
|
+
private async fetchFile (filePath: string): Promise<string | null> {
|
|
119
|
+
const { owner, repo, ref, path: basePath } = this.config
|
|
120
|
+
const fullPath = basePath !== '' ? `${basePath}/${filePath}` : filePath
|
|
121
|
+
const url = `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${fullPath}`
|
|
122
|
+
|
|
123
|
+
const headers: Record<string, string> = {}
|
|
124
|
+
if (this.config.token !== '') {
|
|
125
|
+
headers.Authorization = `token ${this.config.token}`
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const response = await fetch(url, { headers })
|
|
130
|
+
if (!response.ok) return null
|
|
131
|
+
return await response.text()
|
|
132
|
+
} catch {
|
|
133
|
+
return null
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Fetch the full repo tree via the GitHub Git Trees API.
|
|
139
|
+
* Uses ?recursive=1 to get all files in one request.
|
|
140
|
+
*/
|
|
141
|
+
private async fetchRepoTree (): Promise<GitHubTreeEntry[]> {
|
|
142
|
+
const { owner, repo, ref } = this.config
|
|
143
|
+
const url = `https://api.github.com/repos/${owner}/${repo}/git/trees/${ref}?recursive=1`
|
|
144
|
+
|
|
145
|
+
const headers: Record<string, string> = {
|
|
146
|
+
Accept: 'application/vnd.github.v3+json'
|
|
147
|
+
}
|
|
148
|
+
if (this.config.token !== '') {
|
|
149
|
+
headers.Authorization = `token ${this.config.token}`
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
const response = await fetch(url, { headers })
|
|
154
|
+
if (!response.ok) return []
|
|
155
|
+
|
|
156
|
+
const data = await response.json() as { tree: GitHubTreeEntry[] }
|
|
157
|
+
const basePath = this.config.path
|
|
158
|
+
|
|
159
|
+
return data.tree
|
|
160
|
+
.filter(entry =>
|
|
161
|
+
entry.type === 'blob' &&
|
|
162
|
+
entry.path.endsWith('.md') &&
|
|
163
|
+
(basePath === '' || entry.path.startsWith(basePath + '/'))
|
|
164
|
+
)
|
|
165
|
+
.map(entry => ({
|
|
166
|
+
...entry,
|
|
167
|
+
path: basePath !== '' ? entry.path.slice(basePath.length + 1) : entry.path
|
|
168
|
+
}))
|
|
169
|
+
} catch {
|
|
170
|
+
return []
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private buildNavFromTree (entries: GitHubTreeEntry[]): NavNode {
|
|
175
|
+
// TODO: Build proper tree structure from flat file list
|
|
176
|
+
// For now, return a flat list
|
|
177
|
+
const children: NavNode[] = entries
|
|
178
|
+
.filter(e => e.path.endsWith('.md') && e.path !== 'index.md' && e.path !== 'README.md' && e.path !== 'readme.md')
|
|
179
|
+
.map(e => {
|
|
180
|
+
const name = e.path.replace(/\.md$/, '').split('/').pop() ?? e.path
|
|
181
|
+
return {
|
|
182
|
+
title: titleCase(name),
|
|
183
|
+
slug: '/' + e.path.replace(/\.md$/, '').replace(/\/index$/, ''),
|
|
184
|
+
order: 999,
|
|
185
|
+
children: [],
|
|
186
|
+
isSection: false
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
title: 'Root',
|
|
192
|
+
slug: '/',
|
|
193
|
+
order: 0,
|
|
194
|
+
children,
|
|
195
|
+
isSection: true
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
interface GitHubTreeEntry {
|
|
201
|
+
path: string
|
|
202
|
+
type: string
|
|
203
|
+
sha: string
|
|
204
|
+
size?: number
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function titleCase (str: string): string {
|
|
208
|
+
return str
|
|
209
|
+
.replace(/[-_]/g, ' ')
|
|
210
|
+
.replace(/\b\w/g, c => c.toUpperCase())
|
|
211
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parsed frontmatter metadata from a markdown file.
|
|
3
|
+
*/
|
|
4
|
+
export interface MarkdownMeta {
|
|
5
|
+
title?: string
|
|
6
|
+
description?: string
|
|
7
|
+
date?: string
|
|
8
|
+
updated?: string
|
|
9
|
+
draft?: boolean
|
|
10
|
+
order?: number
|
|
11
|
+
tags?: string[]
|
|
12
|
+
layout?: string
|
|
13
|
+
[key: string]: unknown
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A single content page derived from a .md file.
|
|
18
|
+
*/
|
|
19
|
+
export interface ContentPage {
|
|
20
|
+
/** URL path slug (e.g. /docs/getting-started) */
|
|
21
|
+
slug: string
|
|
22
|
+
/** Source path (filesystem path, R2 key, GitHub path, etc.) */
|
|
23
|
+
sourcePath: string
|
|
24
|
+
/** Parsed frontmatter metadata */
|
|
25
|
+
meta: MarkdownMeta
|
|
26
|
+
/** Raw markdown body (without frontmatter) */
|
|
27
|
+
body: string
|
|
28
|
+
/** Original raw content (with frontmatter) */
|
|
29
|
+
raw: string
|
|
30
|
+
/** Last modified timestamp */
|
|
31
|
+
modifiedAt?: Date
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A node in the site navigation tree.
|
|
36
|
+
*/
|
|
37
|
+
export interface NavNode {
|
|
38
|
+
title: string
|
|
39
|
+
slug: string
|
|
40
|
+
order: number
|
|
41
|
+
children: NavNode[]
|
|
42
|
+
isSection: boolean
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Content source interface.
|
|
47
|
+
*
|
|
48
|
+
* Implementations provide content from different backends:
|
|
49
|
+
* - FilesystemSource: local .md files (dev, self-hosted)
|
|
50
|
+
* - GitHubSource: public GitHub repo (hosted service)
|
|
51
|
+
* - R2Source: Cloudflare R2 bucket (hosted service)
|
|
52
|
+
* - S3Source: AWS S3 bucket
|
|
53
|
+
* - MemorySource: in-memory (testing)
|
|
54
|
+
*
|
|
55
|
+
* The interface is intentionally minimal so new backends
|
|
56
|
+
* are easy to implement.
|
|
57
|
+
*/
|
|
58
|
+
export interface ContentSource {
|
|
59
|
+
/** Get a single page by its URL slug. Returns null if not found. */
|
|
60
|
+
getPage: (slug: string) => Promise<ContentPage | null>
|
|
61
|
+
|
|
62
|
+
/** Get the full navigation tree. */
|
|
63
|
+
getNavTree: () => Promise<NavNode>
|
|
64
|
+
|
|
65
|
+
/** List all pages (for llms.txt, sitemap, etc.) */
|
|
66
|
+
listPages: () => Promise<ContentPage[]>
|
|
67
|
+
|
|
68
|
+
/** Refresh/invalidate any caches. */
|
|
69
|
+
refresh: () => Promise<void>
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Configuration for GitHub-based content source.
|
|
74
|
+
*/
|
|
75
|
+
export interface GitHubSourceConfig {
|
|
76
|
+
/** GitHub owner (user or org) */
|
|
77
|
+
owner: string
|
|
78
|
+
/** Repository name */
|
|
79
|
+
repo: string
|
|
80
|
+
/** Branch or tag (default: main) */
|
|
81
|
+
ref?: string
|
|
82
|
+
/** Subdirectory within the repo to treat as content root */
|
|
83
|
+
path?: string
|
|
84
|
+
/** GitHub personal access token (for private repos or higher rate limits) */
|
|
85
|
+
token?: string
|
|
86
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { ContentSource, ContentPage } from '../content/types.ts'
|
|
2
|
+
import type { MkdnSiteConfig } from '../config/schema.ts'
|
|
3
|
+
|
|
4
|
+
export async function generateLlmsTxt (
|
|
5
|
+
source: ContentSource,
|
|
6
|
+
config: MkdnSiteConfig
|
|
7
|
+
): Promise<string> {
|
|
8
|
+
const pages = await source.listPages()
|
|
9
|
+
const baseUrl = config.site.url ?? ''
|
|
10
|
+
|
|
11
|
+
const lines: string[] = []
|
|
12
|
+
|
|
13
|
+
lines.push(`# ${config.site.title}`)
|
|
14
|
+
lines.push('')
|
|
15
|
+
|
|
16
|
+
const desc = config.llmsTxt.description ?? config.site.description
|
|
17
|
+
if (desc != null) {
|
|
18
|
+
lines.push(`> ${desc}`)
|
|
19
|
+
lines.push('')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const groups = groupBySection(pages)
|
|
23
|
+
|
|
24
|
+
for (const [section, sectionPages] of Object.entries(groups)) {
|
|
25
|
+
const sectionTitle = config.llmsTxt.sections?.[section] ??
|
|
26
|
+
(section === '_root' ? 'Pages' : titleCase(section))
|
|
27
|
+
|
|
28
|
+
lines.push(`## ${sectionTitle}`)
|
|
29
|
+
lines.push('')
|
|
30
|
+
|
|
31
|
+
for (const page of sectionPages) {
|
|
32
|
+
const title = page.meta.title ?? slugToTitle(page.slug)
|
|
33
|
+
const url = `${baseUrl}${page.slug}`
|
|
34
|
+
const pageDesc = page.meta.description
|
|
35
|
+
|
|
36
|
+
lines.push(pageDesc != null
|
|
37
|
+
? `- [${title}](${url}): ${pageDesc}`
|
|
38
|
+
: `- [${title}](${url})`
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
lines.push('')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return lines.join('\n')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function groupBySection (pages: ContentPage[]): Record<string, ContentPage[]> {
|
|
49
|
+
const groups: Record<string, ContentPage[]> = {}
|
|
50
|
+
|
|
51
|
+
for (const page of pages) {
|
|
52
|
+
const segments = page.slug.replace(/^\//, '').split('/')
|
|
53
|
+
const section = segments.length > 1 ? segments[0] : '_root'
|
|
54
|
+
|
|
55
|
+
if (groups[section] == null) groups[section] = []
|
|
56
|
+
groups[section].push(page)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return groups
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function titleCase (str: string): string {
|
|
63
|
+
return str.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function slugToTitle (slug: string): string {
|
|
67
|
+
const name = slug.split('/').pop() ?? slug
|
|
68
|
+
const cleaned = name.replace(/^\//, '')
|
|
69
|
+
return titleCase(cleaned !== '' ? cleaned : 'Home')
|
|
70
|
+
}
|