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.
@@ -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
+ }