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.
- package/package.json +8 -3
- package/src/adapters/cloudflare.ts +202 -15
- package/src/adapters/local.ts +38 -17
- package/src/analytics/classify.ts +65 -0
- package/src/analytics/console.ts +39 -0
- package/src/analytics/noop.ts +15 -0
- package/src/analytics/types.ts +49 -0
- package/src/cache/kv.ts +81 -0
- package/src/cache/memory.ts +46 -0
- package/src/cache/response.ts +24 -0
- package/src/cli.ts +301 -51
- package/src/client/scripts.ts +379 -3
- package/src/config/defaults.ts +66 -5
- package/src/config/schema.ts +200 -2
- package/src/content/assets.ts +202 -0
- package/src/content/cache.ts +232 -0
- package/src/content/filesystem.ts +17 -1
- package/src/content/github.ts +169 -102
- package/src/content/nav-builder.ts +120 -0
- package/src/content/r2.ts +214 -0
- package/src/handler.ts +341 -21
- package/src/index.ts +49 -1
- package/src/mcp/server.ts +164 -0
- package/src/mcp/stdio.ts +29 -0
- package/src/mcp/transport.ts +29 -0
- package/src/negotiate/headers.ts +37 -9
- package/src/render/page-shell.ts +249 -8
- package/src/search/index.ts +342 -0
- package/src/security/csp.ts +92 -0
- package/src/theme/{prose-css.ts → base-css.ts} +251 -11
- package/src/theme/build-css.ts +74 -0
|
@@ -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
|
-
|
|
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' &&
|
package/src/content/github.ts
CHANGED
|
@@ -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
|
|
13
|
-
*
|
|
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
|
|
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
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
this.navCache
|
|
88
|
-
return this.
|
|
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
|
-
|
|
93
|
-
const mdFiles = tree.filter(f => f.path.endsWith('.md'))
|
|
94
|
-
|
|
87
|
+
await this.ensurePrefetched()
|
|
95
88
|
const pages: ContentPage[] = []
|
|
96
|
-
for (const
|
|
97
|
-
|
|
98
|
-
.
|
|
99
|
-
|
|
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.
|
|
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
|
-
*
|
|
116
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
+
}
|