mkdnsite 0.0.1 → 1.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.
@@ -125,6 +125,18 @@ export class FilesystemSource implements ContentSource {
125
125
  }
126
126
  }
127
127
 
128
+ private async dirHasIndex (dir: string): Promise<boolean> {
129
+ for (const name of ['index.md', 'README.md', 'readme.md']) {
130
+ try {
131
+ await stat(join(dir, name))
132
+ return true
133
+ } catch {
134
+ continue
135
+ }
136
+ }
137
+ return false
138
+ }
139
+
128
140
  private filePathToSlug (relPath: string): string {
129
141
  let slug = relPath.replace(/\.md$/, '')
130
142
 
@@ -165,7 +177,11 @@ export class FilesystemSource implements ContentSource {
165
177
 
166
178
  if (entry.isDirectory()) {
167
179
  const subtree = await this.buildNavTree(fullPath, childSlug)
168
- children.push(subtree)
180
+ // Only include directories that have navigable content:
181
+ // either at least one child page/section, or their own index.md
182
+ if (subtree.children.length > 0 || await this.dirHasIndex(fullPath)) {
183
+ children.push(subtree)
184
+ }
169
185
  } else if (
170
186
  entry.isFile() &&
171
187
  extname(entry.name) === '.md' &&
@@ -5,26 +5,43 @@ import type {
5
5
  NavNode,
6
6
  GitHubSourceConfig
7
7
  } from './types.ts'
8
+ import { buildNavTree as sharedBuildNavTree } from './nav-builder.ts'
9
+
10
+ const TTL_MS = 5 * 60 * 1000 // 5 minutes
11
+
12
+ interface CacheEntry<T> {
13
+ value: T
14
+ expiresAt: number
15
+ }
16
+
17
+ interface ParsedFile {
18
+ path: string
19
+ meta: Record<string, unknown>
20
+ body: string
21
+ raw: string
22
+ }
8
23
 
9
24
  /**
10
25
  * Content source that reads .md files from a public GitHub repository.
11
26
  *
12
- * Uses the GitHub Contents API (or raw.githubusercontent.com for performance).
13
- * Supports caching for production use (bring your own cache backend).
27
+ * Uses raw.githubusercontent.com for file content (fast, no API rate limits)
28
+ * and the GitHub Git Trees API for file listing (one call, recursive).
29
+ *
30
+ * On first getNavTree() or listPages() call, fetches all .md file contents
31
+ * in parallel, parses frontmatter, pre-populates the page cache, and builds
32
+ * the nav tree. Subsequent calls use the TTL cache (default: 5 minutes).
14
33
  *
15
34
  * Rate limits:
16
- * - Unauthenticated: 60 requests/hour
35
+ * - Unauthenticated: 60 GitHub API requests/hour (tree fetch only)
17
36
  * - 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
37
+ * - raw.githubusercontent.com: no documented rate limit (generous)
23
38
  */
24
39
  export class GitHubSource implements ContentSource {
25
40
  private readonly config: Required<GitHubSourceConfig>
26
- private readonly cache = new Map<string, ContentPage>()
27
- private navCache: NavNode | null = null
41
+ private readonly pageCache = new Map<string, CacheEntry<ContentPage | null>>()
42
+ private navCache: CacheEntry<NavNode> | null = null
43
+ private treeCache: CacheEntry<GitHubTreeEntry[]> | null = null
44
+ private prefetchPromise: Promise<void> | null = null
28
45
 
29
46
  constructor (config: GitHubSourceConfig) {
30
47
  this.config = {
@@ -37,84 +54,134 @@ export class GitHubSource implements ContentSource {
37
54
  }
38
55
 
39
56
  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
57
+ const key = slugToKey(slug)
58
+ const cached = this.pageCache.get(key)
59
+ if (cached != null && cached.expiresAt > Date.now()) {
60
+ return cached.value
45
61
  }
46
62
 
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
- }
63
+ // Pre-populate cache via bulk prefetch
64
+ await this.ensurePrefetched()
61
65
 
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
- }
66
+ const afterPrefetch = this.pageCache.get(key)
67
+ if (afterPrefetch != null && afterPrefetch.expiresAt > Date.now()) {
68
+ return afterPrefetch.value
77
69
  }
78
70
 
79
- return null
71
+ // Fallback: individual file fetch (handles slugs not in tree, e.g. after refresh)
72
+ const result = await this.fetchPageByKey(key)
73
+ this.setPage(key, result)
74
+ return result
80
75
  }
81
76
 
82
77
  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
78
+ if (this.navCache != null && this.navCache.expiresAt > Date.now()) {
79
+ return this.navCache.value
80
+ }
81
+ await this.ensurePrefetched()
82
+ if (this.navCache != null && this.navCache.expiresAt > Date.now()) return this.navCache.value
83
+ return this.emptyRoot()
89
84
  }
90
85
 
91
86
  async listPages (): Promise<ContentPage[]> {
92
- const tree = await this.fetchRepoTree()
93
- const mdFiles = tree.filter(f => f.path.endsWith('.md'))
94
-
87
+ await this.ensurePrefetched()
95
88
  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)
89
+ for (const [, entry] of this.pageCache) {
90
+ if (entry.expiresAt > Date.now() && entry.value != null && entry.value.meta.draft !== true) {
91
+ pages.push(entry.value)
92
+ }
104
93
  }
105
-
106
94
  return pages.sort((a, b) => a.slug.localeCompare(b.slug))
107
95
  }
108
96
 
109
97
  async refresh (): Promise<void> {
110
- this.cache.clear()
98
+ this.pageCache.clear()
111
99
  this.navCache = null
100
+ this.treeCache = null
101
+ this.prefetchPromise = null
112
102
  }
113
103
 
104
+ // ─── Private: prefetch ──────────────────────────────────────────────────────
105
+
114
106
  /**
115
- * Fetch a file from the GitHub repo via raw.githubusercontent.com
116
- * (faster than the API, no rate limit headers, but no metadata).
107
+ * Ensures all .md files have been fetched and cached. Only runs once per
108
+ * TTL window concurrent callers share the same promise.
117
109
  */
110
+ private async ensurePrefetched (): Promise<void> {
111
+ const stale = this.navCache == null || this.navCache.expiresAt <= Date.now()
112
+ if (this.prefetchPromise != null && !stale) {
113
+ await this.prefetchPromise
114
+ return
115
+ }
116
+ this.prefetchPromise = this.prefetchAll()
117
+ await this.prefetchPromise
118
+ }
119
+
120
+ private async prefetchAll (): Promise<void> {
121
+ const tree = await this.fetchRepoTree()
122
+ const mdFiles = tree.filter(e => e.type === 'blob' && e.path.endsWith('.md'))
123
+
124
+ // Fetch all file contents in parallel
125
+ const fetched = await Promise.all(
126
+ mdFiles.map(async (entry) => {
127
+ const raw = await this.fetchFile(entry.path)
128
+ if (raw == null) return null
129
+ const { meta, body } = parseFrontmatter(raw)
130
+ const parsed: ParsedFile = { path: entry.path, meta: meta as Record<string, unknown>, body, raw }
131
+ return parsed
132
+ })
133
+ )
134
+
135
+ const parsed = fetched.filter((f): f is ParsedFile => f != null)
136
+
137
+ // Populate page cache
138
+ for (const file of parsed) {
139
+ const key = filePathToKey(file.path)
140
+ const slug = keyToSlug(key)
141
+ const page: ContentPage = {
142
+ slug,
143
+ sourcePath: file.path,
144
+ meta: file.meta,
145
+ body: file.body,
146
+ raw: file.raw
147
+ }
148
+ this.setPage(key, page)
149
+ }
150
+
151
+ // Build nav tree
152
+ const nav = sharedBuildNavTree(parsed)
153
+ this.navCache = { value: nav, expiresAt: Date.now() + TTL_MS }
154
+ }
155
+
156
+ // ─── Private: fetching ──────────────────────────────────────────────────────
157
+
158
+ private async fetchPageByKey (key: string): Promise<ContentPage | null> {
159
+ const candidates =
160
+ key === 'index'
161
+ ? ['index.md', 'README.md', 'readme.md']
162
+ : [
163
+ `${key}.md`,
164
+ `${key}/index.md`,
165
+ `${key}/README.md`,
166
+ `${key}/readme.md`
167
+ ]
168
+
169
+ for (const filePath of candidates) {
170
+ const raw = await this.fetchFile(filePath)
171
+ if (raw != null) {
172
+ const { meta, body } = parseFrontmatter(raw)
173
+ return {
174
+ slug: keyToSlug(key),
175
+ sourcePath: filePath,
176
+ meta: meta as Record<string, unknown>,
177
+ body,
178
+ raw
179
+ }
180
+ }
181
+ }
182
+ return null
183
+ }
184
+
118
185
  private async fetchFile (filePath: string): Promise<string | null> {
119
186
  const { owner, repo, ref, path: basePath } = this.config
120
187
  const fullPath = basePath !== '' ? `${basePath}/${filePath}` : filePath
@@ -134,12 +201,12 @@ export class GitHubSource implements ContentSource {
134
201
  }
135
202
  }
136
203
 
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
204
  private async fetchRepoTree (): Promise<GitHubTreeEntry[]> {
142
- const { owner, repo, ref } = this.config
205
+ if (this.treeCache != null && this.treeCache.expiresAt > Date.now()) {
206
+ return this.treeCache.value
207
+ }
208
+
209
+ const { owner, repo, ref, path: basePath } = this.config
143
210
  const url = `https://api.github.com/repos/${owner}/${repo}/git/trees/${ref}?recursive=1`
144
211
 
145
212
  const headers: Record<string, string> = {
@@ -154,9 +221,7 @@ export class GitHubSource implements ContentSource {
154
221
  if (!response.ok) return []
155
222
 
156
223
  const data = await response.json() as { tree: GitHubTreeEntry[] }
157
- const basePath = this.config.path
158
-
159
- return data.tree
224
+ const entries = data.tree
160
225
  .filter(entry =>
161
226
  entry.type === 'blob' &&
162
227
  entry.path.endsWith('.md') &&
@@ -166,46 +231,48 @@ export class GitHubSource implements ContentSource {
166
231
  ...entry,
167
232
  path: basePath !== '' ? entry.path.slice(basePath.length + 1) : entry.path
168
233
  }))
234
+
235
+ this.treeCache = { value: entries, expiresAt: Date.now() + TTL_MS }
236
+ return entries
169
237
  } catch {
170
238
  return []
171
239
  }
172
240
  }
173
241
 
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
- })
242
+ // ─── Private: helpers ───────────────────────────────────────────────────────
189
243
 
190
- return {
191
- title: 'Root',
192
- slug: '/',
193
- order: 0,
194
- children,
195
- isSection: true
196
- }
244
+ private setPage (key: string, value: ContentPage | null): void {
245
+ this.pageCache.set(key, { value, expiresAt: Date.now() + TTL_MS })
246
+ }
247
+
248
+ private emptyRoot (): NavNode {
249
+ return { title: 'Root', slug: '/', order: 0, children: [], isSection: true }
197
250
  }
198
251
  }
199
252
 
253
+ // ─── Slug/key helpers ────────────────────────────────────────────────────────
254
+
255
+ function slugToKey (slug: string): string {
256
+ const stripped = slug.replace(/^\/+|\.md$/g, '').replace(/\/+$/, '')
257
+ return stripped === '' ? 'index' : stripped
258
+ }
259
+
260
+ function filePathToKey (filePath: string): string {
261
+ const noExt = filePath.replace(/\.md$/, '')
262
+ if (noExt === 'index' || noExt === 'README' || noExt === 'readme') return 'index'
263
+ if (/\/(?:index|README|readme)$/.test(noExt)) return noExt.replace(/\/(?:index|README|readme)$/, '')
264
+ return noExt
265
+ }
266
+
267
+ function keyToSlug (key: string): string {
268
+ return key === 'index' ? '/' : `/${key}`
269
+ }
270
+
271
+ // ─── Types ───────────────────────────────────────────────────────────────────
272
+
200
273
  interface GitHubTreeEntry {
201
274
  path: string
202
275
  type: string
203
276
  sha: string
204
277
  size?: number
205
278
  }
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,120 @@
1
+ import type { NavNode } from './types.ts'
2
+
3
+ /**
4
+ * Minimal file entry needed to build a nav tree.
5
+ * Consumers (GitHubSource, R2ContentSource, etc.) map their own types to this.
6
+ */
7
+ export interface FileEntry {
8
+ /** Relative path from content root, e.g. 'docs/getting-started.md' */
9
+ path: string
10
+ /** Parsed frontmatter metadata */
11
+ meta: Record<string, unknown>
12
+ }
13
+
14
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
15
+
16
+ function titleCase (str: string): string {
17
+ return str.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
18
+ }
19
+
20
+ function getOrCreateSection (
21
+ sections: Map<string, NavNode>,
22
+ path: string,
23
+ root: NavNode
24
+ ): NavNode {
25
+ if (sections.has(path)) return sections.get(path) as NavNode
26
+ if (path === '') return root
27
+
28
+ const parts = path.split('/')
29
+ const parentPath = parts.slice(0, -1).join('/')
30
+ const name = parts[parts.length - 1]
31
+ const parent = getOrCreateSection(sections, parentPath, root)
32
+
33
+ const section: NavNode = {
34
+ title: titleCase(name),
35
+ slug: '/' + path,
36
+ order: 999,
37
+ children: [],
38
+ isSection: true
39
+ }
40
+ sections.set(path, section)
41
+ parent.children.push(section)
42
+ return section
43
+ }
44
+
45
+ function pruneEmpty (node: NavNode): boolean {
46
+ node.children = node.children.filter(child => {
47
+ if (!child.isSection) return true
48
+ return pruneEmpty(child)
49
+ })
50
+ return node.children.length > 0
51
+ }
52
+
53
+ function sortNode (node: NavNode): void {
54
+ node.children.sort((a, b) => {
55
+ if (a.order !== b.order) return a.order - b.order
56
+ return a.title.localeCompare(b.title)
57
+ })
58
+ for (const child of node.children) {
59
+ if (child.isSection) sortNode(child)
60
+ }
61
+ }
62
+
63
+ // ─── Public API ───────────────────────────────────────────────────────────────
64
+
65
+ /**
66
+ * Build a NavNode tree from a flat list of file entries.
67
+ *
68
+ * - Files named index.md / README.md provide section metadata
69
+ * - Files with draft: true in meta are excluded from navigation
70
+ * - Sections with no navigable children are pruned
71
+ */
72
+ export function buildNavTree (files: FileEntry[], rootTitle = 'Root'): NavNode {
73
+ const root: NavNode = { title: rootTitle, slug: '/', order: 0, children: [], isSection: true }
74
+ const sections = new Map<string, NavNode>()
75
+ sections.set('', root)
76
+
77
+ const isIndex = (p: string): boolean =>
78
+ /(?:^|\/)(?:index|README|readme)\.md$/.test(p)
79
+
80
+ const indexFiles = files.filter(f => isIndex(f.path))
81
+ const leafFiles = files.filter(f => !isIndex(f.path))
82
+
83
+ // Apply index file metadata to section nodes
84
+ for (const file of indexFiles) {
85
+ const parts = file.path.split('/')
86
+ const dirParts = parts.slice(0, -1)
87
+ if (dirParts.length === 0) continue // root index — not a section node
88
+
89
+ const dirPath = dirParts.join('/')
90
+ const section = getOrCreateSection(sections, dirPath, root)
91
+ if (file.meta.title != null) section.title = file.meta.title as string
92
+ if (file.meta.order != null) section.order = file.meta.order as number
93
+ }
94
+
95
+ // Add leaf nodes (non-index .md files)
96
+ for (const file of leafFiles) {
97
+ if (file.meta.draft === true) continue
98
+
99
+ const parts = file.path.split('/')
100
+ const fileName = parts[parts.length - 1]
101
+ const dirParts = parts.slice(0, -1)
102
+ const dirPath = dirParts.join('/')
103
+
104
+ const parent = getOrCreateSection(sections, dirPath, root)
105
+ const name = fileName.replace(/\.md$/, '')
106
+ const slugPath = file.path.replace(/\.md$/, '')
107
+ const node: NavNode = {
108
+ title: file.meta.title != null ? file.meta.title as string : titleCase(name),
109
+ slug: '/' + slugPath,
110
+ order: file.meta.order != null ? file.meta.order as number : 999,
111
+ children: [],
112
+ isSection: false
113
+ }
114
+ parent.children.push(node)
115
+ }
116
+
117
+ pruneEmpty(root)
118
+ sortNode(root)
119
+ return root
120
+ }