@brett_lamy/docstream 1.18.0 → 1.19.0
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 +65 -19
- package/dist/docs/DocsRenderer.d.ts.map +1 -1
- package/dist/docs/DocsRenderer.js +14 -3
- package/dist/docs/DocsRenderer.js.map +1 -1
- package/dist/gitbook/parse.js +10 -2
- package/dist/gitbook/parse.js.map +1 -1
- package/dist/openapi/OpenApiOperation.d.ts.map +1 -1
- package/dist/openapi/OpenApiOperation.js +13 -5
- package/dist/openapi/OpenApiOperation.js.map +1 -1
- package/dist/site/DocsSite.d.ts +74 -0
- package/dist/site/DocsSite.d.ts.map +1 -0
- package/dist/site/DocsSite.js +479 -0
- package/dist/site/DocsSite.js.map +1 -0
- package/dist/site/index.d.ts +7 -0
- package/dist/site/index.d.ts.map +1 -0
- package/dist/site/index.js +5 -0
- package/dist/site/index.js.map +1 -0
- package/dist/site/search.d.ts +28 -0
- package/dist/site/search.d.ts.map +1 -0
- package/dist/site/search.js +125 -0
- package/dist/site/search.js.map +1 -0
- package/dist/site/site.css +816 -0
- package/dist/site/tree.d.ts +105 -0
- package/dist/site/tree.d.ts.map +1 -0
- package/dist/site/tree.js +411 -0
- package/dist/site/tree.js.map +1 -0
- package/dist/styles.css +10 -0
- package/package.json +8 -1
- package/src/docs/DocsRenderer.tsx +14 -3
- package/src/gitbook/parse.ts +10 -2
- package/src/openapi/OpenApiOperation.tsx +13 -5
- package/src/site/DocsSite.tsx +958 -0
- package/src/site/index.ts +18 -0
- package/src/site/search.ts +138 -0
- package/src/site/site.css +816 -0
- package/src/site/tree.ts +482 -0
- package/src/styles.css +10 -0
package/src/site/tree.ts
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
/* A docs site from a virtual file system: every Markdown file becomes a page, and the
|
|
2
|
+
folder structure (or a GitBook `SUMMARY.md`) becomes the sidebar.
|
|
3
|
+
|
|
4
|
+
Conventions, GitBook's where it has one:
|
|
5
|
+
- `README.md` (or `index.md`) is its folder's page; the top-level one is the home page.
|
|
6
|
+
- `SUMMARY.md` at the root, when present, *is* the sidebar: `## Group` headings and
|
|
7
|
+
nested `* [Title](path.md)` lists. Pages it leaves out stay reachable by URL.
|
|
8
|
+
- Without one, top-level folders become sidebar groups and deeper folders nest.
|
|
9
|
+
- Numeric prefixes (`01-intro.md`, `2_guides/`) order entries and are dropped from URLs
|
|
10
|
+
and titles. Frontmatter `order` overrides the prefix.
|
|
11
|
+
- A page's title is, in order: its SUMMARY.md label, frontmatter `title`, its first
|
|
12
|
+
`# heading`, then the file name (`quick-start.md` → "Quick start"). Frontmatter
|
|
13
|
+
`description` is its lede and `hidden: true` keeps it out of the sidebar.
|
|
14
|
+
- Every other file in the docs root is an asset (`tree.assets`): DocsSite serves it to the
|
|
15
|
+
pages, so `` or an OpenAPI block's `[api](specs/api.json)` loads
|
|
16
|
+
the file from the VFS.
|
|
17
|
+
Everything here is pure (no React, no DOM), so a static build or a test can use it. */
|
|
18
|
+
import { parseDocument, isMap } from "yaml"
|
|
19
|
+
|
|
20
|
+
/** A file's contents: text, or a `Blob` for binary assets (images, models). Pages must be text. */
|
|
21
|
+
export type DocsFileContent = string | Blob
|
|
22
|
+
|
|
23
|
+
export type DocsFiles = Record<string, DocsFileContent> | ReadonlyArray<{ path: string; content: DocsFileContent }>
|
|
24
|
+
|
|
25
|
+
export interface DocsPage {
|
|
26
|
+
/** Source path relative to the docs root, e.g. `01-guides/02-install.md`. */
|
|
27
|
+
path: string
|
|
28
|
+
/** URL path without leading slash: `guides/install`, `""` for the home page. */
|
|
29
|
+
slug: string
|
|
30
|
+
title: string
|
|
31
|
+
description?: string
|
|
32
|
+
/** The file as written (frontmatter included). */
|
|
33
|
+
markdown: string
|
|
34
|
+
/** The page body as the site renders it: no site-only frontmatter, no leading `# title`. */
|
|
35
|
+
body: string
|
|
36
|
+
/** Frontmatter (all keys), `{}` when there is none. */
|
|
37
|
+
frontmatter: Record<string, unknown>
|
|
38
|
+
hidden: boolean
|
|
39
|
+
/** Titles of the sidebar groups/folders above the page, outermost first. */
|
|
40
|
+
breadcrumbs: string[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type DocsNavItem =
|
|
44
|
+
| { kind: "group"; title: string; children: DocsNavItem[] }
|
|
45
|
+
| { kind: "page"; title: string; slug: string; children: DocsNavItem[] }
|
|
46
|
+
| { kind: "folder"; title: string; children: DocsNavItem[] }
|
|
47
|
+
| { kind: "link"; title: string; href: string }
|
|
48
|
+
|
|
49
|
+
export interface DocsTree {
|
|
50
|
+
/** Every page, sidebar order first, then pages the sidebar leaves out. */
|
|
51
|
+
pages: DocsPage[]
|
|
52
|
+
/** Pages in reading order (what Previous / Next walk): listed, not hidden. */
|
|
53
|
+
order: DocsPage[]
|
|
54
|
+
nav: DocsNavItem[]
|
|
55
|
+
bySlug: Map<string, DocsPage>
|
|
56
|
+
byPath: Map<string, DocsPage>
|
|
57
|
+
/** The common folder stripped from every input path (`"docs/"`), or `""`. */
|
|
58
|
+
root: string
|
|
59
|
+
/** Every non-Markdown file under the root, by path relative to it (`specs/api.json`). */
|
|
60
|
+
assets: Map<string, DocsFileContent>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface BuildDocsTreeOptions {
|
|
64
|
+
/** Folder to treat as the docs root; default: the deepest folder shared by every Markdown file. */
|
|
65
|
+
root?: string
|
|
66
|
+
/** Extra file filter, applied after the Markdown filter. */
|
|
67
|
+
include?: (path: string) => boolean
|
|
68
|
+
/** Use `SUMMARY.md` when there is one (default true). */
|
|
69
|
+
summary?: boolean
|
|
70
|
+
/** Sidebar title for a folder without a README, given its name with the prefix removed. */
|
|
71
|
+
folderTitle?: (name: string, path: string) => string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Frontmatter keys that describe the page to the site rather than the reader. */
|
|
75
|
+
export const SITE_FRONTMATTER_KEYS = ["title", "description", "order", "hidden", "icon"] as const
|
|
76
|
+
|
|
77
|
+
const MARKDOWN = /\.(md|markdown|mdx)$/i
|
|
78
|
+
const INDEX = /^(readme|index)$/i
|
|
79
|
+
const PREFIX = /^(\d+)[-_. ]+(?=.)/
|
|
80
|
+
|
|
81
|
+
const isBlob = (v: unknown): v is Blob => typeof Blob !== "undefined" && v instanceof Blob
|
|
82
|
+
|
|
83
|
+
export function isMarkdownPath(path: string): boolean {
|
|
84
|
+
return MARKDOWN.test(path)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** `./docs//a/../b.md` → `docs/b.md`. Leading slashes and `.` segments are dropped. */
|
|
88
|
+
export function normalizePath(path: string): string {
|
|
89
|
+
const out: string[] = []
|
|
90
|
+
for (const part of path.replace(/\\/g, "/").split("/")) {
|
|
91
|
+
if (!part || part === ".") continue
|
|
92
|
+
if (part === "..") out.pop()
|
|
93
|
+
else out.push(part)
|
|
94
|
+
}
|
|
95
|
+
return out.join("/")
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** `"03-quick-start"` → `{ name: "quick-start", order: 3 }`. */
|
|
99
|
+
export function stripOrderPrefix(name: string): { name: string; order?: number } {
|
|
100
|
+
const m = PREFIX.exec(name)
|
|
101
|
+
return m ? { name: name.slice(m[0].length), order: Number(m[1]) } : { name }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** `"quick-start"` → `"Quick start"`; `"api_reference"` → `"Api reference"`. */
|
|
105
|
+
export function humanize(name: string): string {
|
|
106
|
+
const words = name.replace(/[-_]+/g, " ").replace(/\s+/g, " ").trim()
|
|
107
|
+
return words ? words[0]!.toUpperCase() + words.slice(1) : name
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const segmentSlug = (segment: string) =>
|
|
111
|
+
encodeURIComponent(stripOrderPrefix(segment).name.replace(/\s+/g, "-")).replace(/%2F/g, "/")
|
|
112
|
+
|
|
113
|
+
/** The URL slug for a source path: `01-guides/README.md` → `guides`, `README.md` → `""`. */
|
|
114
|
+
export function slugForPath(path: string): string {
|
|
115
|
+
const parts = normalizePath(path).replace(MARKDOWN, "").split("/")
|
|
116
|
+
if (INDEX.test(parts[parts.length - 1] ?? "")) parts.pop()
|
|
117
|
+
return parts.map(segmentSlug).join("/")
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Resolve `href` (as written in `fromPath`'s Markdown) against the docs root; null when external. */
|
|
121
|
+
export function resolveRelativePath(fromPath: string, href: string): { path: string; hash: string } | null {
|
|
122
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("//")) return null
|
|
123
|
+
const hashAt = href.indexOf("#")
|
|
124
|
+
const raw = hashAt >= 0 ? href.slice(0, hashAt) : href
|
|
125
|
+
const hash = hashAt >= 0 ? href.slice(hashAt + 1) : ""
|
|
126
|
+
const clean = raw.split("?")[0]!
|
|
127
|
+
let decoded = clean
|
|
128
|
+
try {
|
|
129
|
+
decoded = decodeURI(clean)
|
|
130
|
+
} catch {
|
|
131
|
+
// keep as written
|
|
132
|
+
}
|
|
133
|
+
if (!decoded) return { path: normalizePath(fromPath), hash }
|
|
134
|
+
const dir = fromPath.includes("/") ? fromPath.slice(0, fromPath.lastIndexOf("/")) : ""
|
|
135
|
+
const path = decoded.startsWith("/") ? normalizePath(decoded) : normalizePath(`${dir}/${decoded}`)
|
|
136
|
+
return { path, hash }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
interface Frontmatter {
|
|
140
|
+
values: Record<string, unknown>
|
|
141
|
+
/** Byte length of the `---` block (and the blank lines after it), 0 when absent. */
|
|
142
|
+
length: number
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const FRONTMATTER = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/
|
|
146
|
+
|
|
147
|
+
export function readFrontmatter(markdown: string): Frontmatter {
|
|
148
|
+
const m = FRONTMATTER.exec(markdown)
|
|
149
|
+
if (!m) return { values: {}, length: 0 }
|
|
150
|
+
let values: Record<string, unknown> = {}
|
|
151
|
+
try {
|
|
152
|
+
const doc = parseDocument(m[1]!)
|
|
153
|
+
if (!doc.errors.length && isMap(doc.contents)) values = (doc.toJS() ?? {}) as Record<string, unknown>
|
|
154
|
+
} catch {
|
|
155
|
+
// Unreadable YAML stays in the body, where the property sheet reports it.
|
|
156
|
+
return { values: {}, length: 0 }
|
|
157
|
+
}
|
|
158
|
+
const rest = markdown.slice(m[0].length)
|
|
159
|
+
return { values, length: m[0].length + (rest.length - rest.replace(/^(?:[ \t]*\r?\n)+/, "").length) }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const H1 = /^#[ \t]+(.+?)[ \t]*#*[ \t]*$/
|
|
163
|
+
const FENCE = /^(`{3,}|~{3,})/
|
|
164
|
+
|
|
165
|
+
/** The first line that is a `# heading` before any other content, with its offsets. */
|
|
166
|
+
function leadingH1(body: string): { text: string; end: number } | null {
|
|
167
|
+
let offset = 0
|
|
168
|
+
for (const line of body.split("\n")) {
|
|
169
|
+
const t = line.replace(/\r$/, "")
|
|
170
|
+
if (t.trim() === "") {
|
|
171
|
+
offset += line.length + 1
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
174
|
+
const m = H1.exec(t)
|
|
175
|
+
if (!m) return null
|
|
176
|
+
const after = body.slice(offset + line.length + 1)
|
|
177
|
+
return { text: m[1]!, end: offset + line.length + 1 + (after.length - after.replace(/^(?:[ \t]*\r?\n)+/, "").length) }
|
|
178
|
+
}
|
|
179
|
+
return null
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The first `# heading` anywhere outside code fences (for titles only). */
|
|
183
|
+
function firstH1(body: string): string | null {
|
|
184
|
+
let fence: string | null = null
|
|
185
|
+
for (const line of body.split("\n")) {
|
|
186
|
+
const f = FENCE.exec(line.trim())
|
|
187
|
+
if (f) {
|
|
188
|
+
if (!fence) fence = f[1]![0]!
|
|
189
|
+
else if (f[1]![0] === fence) fence = null
|
|
190
|
+
continue
|
|
191
|
+
}
|
|
192
|
+
if (fence) continue
|
|
193
|
+
const m = H1.exec(line.replace(/\r$/, ""))
|
|
194
|
+
if (m) return m[1]!
|
|
195
|
+
}
|
|
196
|
+
return null
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Inline Markdown in a heading → plain text for the sidebar and `<title>`. */
|
|
200
|
+
export function plainTitle(text: string): string {
|
|
201
|
+
return text
|
|
202
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
203
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
204
|
+
.replace(/`([^`]*)`/g, "$1")
|
|
205
|
+
.replace(/(\*\*|__|\*|_|~~)(.+?)\1/g, "$2")
|
|
206
|
+
.replace(/\\([\\`*_{}[\]()#+\-.!@])/g, "$1")
|
|
207
|
+
.trim()
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function toEntries(files: DocsFiles): Array<{ path: string; content: DocsFileContent }> {
|
|
211
|
+
return Array.isArray(files)
|
|
212
|
+
? files.map((f) => ({ path: f.path, content: f.content }))
|
|
213
|
+
: Object.entries(files as Record<string, DocsFileContent>).map(([path, content]) => ({ path, content }))
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function commonRoot(paths: string[]): string {
|
|
217
|
+
if (!paths.length) return ""
|
|
218
|
+
const dirs = paths.map((p) => p.split("/").slice(0, -1))
|
|
219
|
+
const first = dirs[0]!
|
|
220
|
+
let n = first.length
|
|
221
|
+
for (const d of dirs) {
|
|
222
|
+
let i = 0
|
|
223
|
+
while (i < n && i < d.length && d[i] === first[i]) i++
|
|
224
|
+
n = i
|
|
225
|
+
}
|
|
226
|
+
return n ? `${first.slice(0, n).join("/")}/` : ""
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
interface Draft extends DocsPage {
|
|
230
|
+
order?: number
|
|
231
|
+
titleFromFile: boolean
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function draftPage(path: string, markdown: string): Draft {
|
|
235
|
+
const fm = readFrontmatter(markdown)
|
|
236
|
+
const fmKeys = Object.keys(fm.values)
|
|
237
|
+
const siteOnly = fmKeys.every((k) => (SITE_FRONTMATTER_KEYS as readonly string[]).includes(k))
|
|
238
|
+
let body = siteOnly ? markdown.slice(fm.length) : markdown
|
|
239
|
+
const fmTitle = typeof fm.values.title === "string" ? fm.values.title : undefined
|
|
240
|
+
let title: string | undefined = fmTitle
|
|
241
|
+
// The site renders the title itself, so a leading `# Title` leaves the body.
|
|
242
|
+
if (siteOnly) {
|
|
243
|
+
const h1 = leadingH1(body)
|
|
244
|
+
if (h1) {
|
|
245
|
+
title ??= plainTitle(h1.text)
|
|
246
|
+
body = body.slice(h1.end)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
title ??= (() => {
|
|
250
|
+
const h = firstH1(markdown.slice(fm.length))
|
|
251
|
+
return h ? plainTitle(h) : undefined
|
|
252
|
+
})()
|
|
253
|
+
const parts = path.replace(MARKDOWN, "").split("/")
|
|
254
|
+
const file = parts[parts.length - 1]!
|
|
255
|
+
const named = INDEX.test(file) ? parts[parts.length - 2] : file
|
|
256
|
+
const fromFile = !title
|
|
257
|
+
title ??= named ? humanize(stripOrderPrefix(named).name) : "Home"
|
|
258
|
+
const order = typeof fm.values.order === "number" ? fm.values.order : stripOrderPrefix(INDEX.test(file) ? (parts[parts.length - 2] ?? "") : file).order
|
|
259
|
+
return {
|
|
260
|
+
path,
|
|
261
|
+
slug: slugForPath(path),
|
|
262
|
+
title,
|
|
263
|
+
description: typeof fm.values.description === "string" ? fm.values.description : undefined,
|
|
264
|
+
markdown,
|
|
265
|
+
body,
|
|
266
|
+
frontmatter: fm.values,
|
|
267
|
+
hidden: fm.values.hidden === true,
|
|
268
|
+
breadcrumbs: [],
|
|
269
|
+
order,
|
|
270
|
+
titleFromFile: fromFile,
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
interface FolderNode {
|
|
275
|
+
name: string
|
|
276
|
+
path: string
|
|
277
|
+
index?: Draft
|
|
278
|
+
pages: Draft[]
|
|
279
|
+
folders: Map<string, FolderNode>
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const byOrder = <T extends { order?: number; title: string; isIndex?: boolean }>(a: T, b: T) =>
|
|
283
|
+
(a.order ?? Number.POSITIVE_INFINITY) - (b.order ?? Number.POSITIVE_INFINITY) || a.title.localeCompare(b.title)
|
|
284
|
+
|
|
285
|
+
function folderTree(drafts: Draft[]): FolderNode {
|
|
286
|
+
const root: FolderNode = { name: "", path: "", pages: [], folders: new Map() }
|
|
287
|
+
for (const d of drafts) {
|
|
288
|
+
const parts = d.path.split("/")
|
|
289
|
+
const file = parts.pop()!
|
|
290
|
+
let node = root
|
|
291
|
+
for (const part of parts) {
|
|
292
|
+
let next = node.folders.get(part)
|
|
293
|
+
if (!next) {
|
|
294
|
+
next = { name: part, path: node.path ? `${node.path}/${part}` : part, pages: [], folders: new Map() }
|
|
295
|
+
node.folders.set(part, next)
|
|
296
|
+
}
|
|
297
|
+
node = next
|
|
298
|
+
}
|
|
299
|
+
if (INDEX.test(file.replace(MARKDOWN, "")) && !node.index) node.index = d
|
|
300
|
+
else node.pages.push(d)
|
|
301
|
+
}
|
|
302
|
+
return root
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function autoNav(root: FolderNode, folderTitle: (name: string, path: string) => string): DocsNavItem[] {
|
|
306
|
+
const folderItem = (f: FolderNode, crumbs: string[]): { item: DocsNavItem; order?: number; title: string } => {
|
|
307
|
+
const title = f.index && !f.index.titleFromFile ? f.index.title : folderTitle(stripOrderPrefix(f.name).name, f.path)
|
|
308
|
+
const children = entries(f, [...crumbs, title])
|
|
309
|
+
if (f.index) f.index.breadcrumbs = crumbs
|
|
310
|
+
const order = (f.index?.frontmatter.order as number | undefined) ?? stripOrderPrefix(f.name).order
|
|
311
|
+
return {
|
|
312
|
+
item: f.index && !f.index.hidden ? { kind: "page", title, slug: f.index.slug, children } : { kind: "folder", title, children },
|
|
313
|
+
order,
|
|
314
|
+
title,
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
const entries = (f: FolderNode, crumbs: string[]): DocsNavItem[] => {
|
|
318
|
+
const list: Array<{ item: DocsNavItem; order?: number; title: string }> = []
|
|
319
|
+
for (const p of f.pages) {
|
|
320
|
+
p.breadcrumbs = crumbs
|
|
321
|
+
if (!p.hidden) list.push({ item: { kind: "page", title: p.title, slug: p.slug, children: [] }, order: p.order, title: p.title })
|
|
322
|
+
}
|
|
323
|
+
for (const sub of f.folders.values()) {
|
|
324
|
+
const e = folderItem(sub, crumbs)
|
|
325
|
+
if (e.item.kind === "page" || (e.item.kind === "folder" && e.item.children.length)) list.push(e)
|
|
326
|
+
}
|
|
327
|
+
return list.sort(byOrder).map((e) => e.item)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const top: DocsNavItem[] = []
|
|
331
|
+
if (root.index) {
|
|
332
|
+
root.index.breadcrumbs = []
|
|
333
|
+
if (!root.index.hidden) top.push({ kind: "page", title: root.index.title, slug: root.index.slug, children: [] })
|
|
334
|
+
}
|
|
335
|
+
// Loose top-level pages come first, then one group per top-level folder.
|
|
336
|
+
const loose: Array<{ item: DocsNavItem; order?: number; title: string }> = []
|
|
337
|
+
for (const p of root.pages) {
|
|
338
|
+
p.breadcrumbs = []
|
|
339
|
+
if (!p.hidden) loose.push({ item: { kind: "page", title: p.title, slug: p.slug, children: [] }, order: p.order, title: p.title })
|
|
340
|
+
}
|
|
341
|
+
top.push(...loose.sort(byOrder).map((e) => e.item))
|
|
342
|
+
const groups = [...root.folders.values()]
|
|
343
|
+
.map((f) => {
|
|
344
|
+
const title = f.index && !f.index.titleFromFile ? f.index.title : folderTitle(stripOrderPrefix(f.name).name, f.path)
|
|
345
|
+
const children = entries(f, [title])
|
|
346
|
+
if (f.index) {
|
|
347
|
+
f.index.breadcrumbs = [title]
|
|
348
|
+
if (!f.index.hidden) children.unshift({ kind: "page", title: "Overview", slug: f.index.slug, children: [] })
|
|
349
|
+
}
|
|
350
|
+
return { item: { kind: "group", title, children } as DocsNavItem, order: (f.index?.frontmatter.order as number | undefined) ?? stripOrderPrefix(f.name).order, title }
|
|
351
|
+
})
|
|
352
|
+
.filter((g) => g.item.kind === "group" && g.item.children.length)
|
|
353
|
+
.sort(byOrder)
|
|
354
|
+
top.push(...groups.map((g) => g.item))
|
|
355
|
+
return top
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const LIST_ITEM = /^(\s*)[*+-][ \t]+\[([^\]]*)\]\(([^)\s]*)(?:\s+"[^"]*")?\)\s*$/
|
|
359
|
+
const GROUP = /^#{1,6}[ \t]+(.+?)[ \t]*#*[ \t]*$/
|
|
360
|
+
|
|
361
|
+
/** GitBook's SUMMARY.md: `## Group` headings, nested `* [Title](path.md)` lists, `---` ignored. */
|
|
362
|
+
function summaryNav(summary: string, summaryPath: string, byPath: Map<string, Draft>): DocsNavItem[] {
|
|
363
|
+
const top: DocsNavItem[] = []
|
|
364
|
+
let group: { kind: "group"; title: string; children: DocsNavItem[] } | null = null
|
|
365
|
+
const stack: Array<{ indent: number; children: DocsNavItem[]; crumbs: string[] }> = []
|
|
366
|
+
let firstHeading = true
|
|
367
|
+
for (const line of summary.split("\n")) {
|
|
368
|
+
const t = line.replace(/\r$/, "")
|
|
369
|
+
const g = GROUP.exec(t)
|
|
370
|
+
if (g) {
|
|
371
|
+
// The first `# Table of contents` heading is the file's own title, not a group.
|
|
372
|
+
if (firstHeading && /^#\s/.test(t) && /contents|summary/i.test(g[1]!)) {
|
|
373
|
+
firstHeading = false
|
|
374
|
+
continue
|
|
375
|
+
}
|
|
376
|
+
firstHeading = false
|
|
377
|
+
group = { kind: "group", title: plainTitle(g[1]!), children: [] }
|
|
378
|
+
top.push(group)
|
|
379
|
+
stack.length = 0
|
|
380
|
+
continue
|
|
381
|
+
}
|
|
382
|
+
const m = LIST_ITEM.exec(t)
|
|
383
|
+
if (!m) continue
|
|
384
|
+
firstHeading = false
|
|
385
|
+
const indent = m[1]!.replace(/\t/g, " ").length
|
|
386
|
+
const label = plainTitle(m[2]!)
|
|
387
|
+
const href = m[3]!
|
|
388
|
+
while (stack.length && stack[stack.length - 1]!.indent >= indent) stack.pop()
|
|
389
|
+
const parent = stack[stack.length - 1]
|
|
390
|
+
const siblings = parent ? parent.children : group ? group.children : top
|
|
391
|
+
const crumbs = parent ? parent.crumbs : group ? [group.title] : []
|
|
392
|
+
const resolved = resolveRelativePath(summaryPath, href)
|
|
393
|
+
const page = resolved ? byPath.get(resolved.path) : undefined
|
|
394
|
+
let item: DocsNavItem
|
|
395
|
+
if (page) {
|
|
396
|
+
page.title = label || page.title
|
|
397
|
+
page.breadcrumbs = crumbs
|
|
398
|
+
item = { kind: "page", title: page.title, slug: page.slug, children: [] }
|
|
399
|
+
} else if (!href || !resolved) {
|
|
400
|
+
item = href ? { kind: "link", title: label, href } : { kind: "folder", title: label, children: [] }
|
|
401
|
+
} else {
|
|
402
|
+
continue
|
|
403
|
+
}
|
|
404
|
+
siblings.push(item)
|
|
405
|
+
if (item.kind === "page" || item.kind === "folder") stack.push({ indent, children: item.children, crumbs: [...crumbs, label] })
|
|
406
|
+
}
|
|
407
|
+
return top
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function walkPages(nav: DocsNavItem[], bySlug: Map<string, DocsPage>, out: DocsPage[] = []): DocsPage[] {
|
|
411
|
+
for (const item of nav) {
|
|
412
|
+
if (item.kind === "page") {
|
|
413
|
+
const p = bySlug.get(item.slug)
|
|
414
|
+
if (p && !out.includes(p)) out.push(p)
|
|
415
|
+
}
|
|
416
|
+
if (item.kind !== "link") walkPages(item.children, bySlug, out)
|
|
417
|
+
}
|
|
418
|
+
return out
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Build pages and the sidebar from a set of files. Non-Markdown files are ignored. */
|
|
422
|
+
export function buildDocsTree(files: DocsFiles, options: BuildDocsTreeOptions = {}): DocsTree {
|
|
423
|
+
const all = toEntries(files)
|
|
424
|
+
.map((f) => ({ path: normalizePath(f.path), content: f.content }))
|
|
425
|
+
.filter((f) => !options.include || options.include(f.path))
|
|
426
|
+
const entries = all.filter((f): f is { path: string; content: string } => isMarkdownPath(f.path) && typeof f.content === "string")
|
|
427
|
+
const root = options.root !== undefined ? (normalizePath(options.root) ? `${normalizePath(options.root)}/` : "") : commonRoot(entries.map((f) => f.path))
|
|
428
|
+
const inRoot = entries.filter((f) => f.path.startsWith(root)).map((f) => ({ path: f.path.slice(root.length), content: f.content }))
|
|
429
|
+
const assets = new Map<string, DocsFileContent>()
|
|
430
|
+
for (const f of all) {
|
|
431
|
+
if (!isMarkdownPath(f.path) && f.path.startsWith(root) && (typeof f.content === "string" || isBlob(f.content))) {
|
|
432
|
+
assets.set(f.path.slice(root.length), f.content)
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const summaryFile = options.summary === false ? undefined : inRoot.find((f) => /^summary\.md$/i.test(f.path))
|
|
437
|
+
const drafts = inRoot.filter((f) => f !== summaryFile).map((f) => draftPage(f.path, f.content))
|
|
438
|
+
const byPath = new Map(drafts.map((d) => [d.path, d]))
|
|
439
|
+
const folderTitle = options.folderTitle ?? ((name: string) => humanize(name))
|
|
440
|
+
|
|
441
|
+
const nav = summaryFile ? summaryNav(summaryFile.content, summaryFile.path, byPath) : autoNav(folderTree(drafts), folderTitle)
|
|
442
|
+
|
|
443
|
+
const bySlug = new Map<string, Draft>()
|
|
444
|
+
for (const d of drafts) if (!bySlug.has(d.slug)) bySlug.set(d.slug, d)
|
|
445
|
+
const listed = walkPages(nav, bySlug) as Draft[]
|
|
446
|
+
const pages: DocsPage[] = [...listed, ...drafts.filter((d) => !listed.includes(d))].map(
|
|
447
|
+
({ order: _order, titleFromFile: _fromFile, ...page }) => page,
|
|
448
|
+
)
|
|
449
|
+
// Keep the maps pointing at the cleaned objects.
|
|
450
|
+
const finalBySlug = new Map<string, DocsPage>()
|
|
451
|
+
const finalByPath = new Map<string, DocsPage>()
|
|
452
|
+
for (const p of pages) {
|
|
453
|
+
if (!finalBySlug.has(p.slug)) finalBySlug.set(p.slug, p)
|
|
454
|
+
finalByPath.set(p.path, p)
|
|
455
|
+
}
|
|
456
|
+
const order = listed.map((p) => finalBySlug.get(p.slug)!).filter((p) => !p.hidden)
|
|
457
|
+
return { pages, order, nav, bySlug: finalBySlug, byPath: finalByPath, root, assets }
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** The asset (non-Markdown file) `href` names from `from`, with its root-relative path; null when none. */
|
|
461
|
+
export function findLinkedAsset(tree: DocsTree, from: DocsPage, href: string): { path: string; content: DocsFileContent } | null {
|
|
462
|
+
const resolved = resolveRelativePath(from.path, href)
|
|
463
|
+
if (!resolved) return null
|
|
464
|
+
const content = tree.assets.get(resolved.path)
|
|
465
|
+
return content === undefined ? null : { path: resolved.path, content }
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** The page `href` points at from `from` — a relative `.md` path, a folder, or a slug. */
|
|
469
|
+
export function findLinkedPage(tree: DocsTree, from: DocsPage, href: string): { page: DocsPage; hash: string } | null {
|
|
470
|
+
const resolved = resolveRelativePath(from.path, href)
|
|
471
|
+
if (!resolved) return null
|
|
472
|
+
const { path, hash } = resolved
|
|
473
|
+
if (!href.split("#")[0]) return { page: from, hash }
|
|
474
|
+
const direct =
|
|
475
|
+
tree.byPath.get(path) ??
|
|
476
|
+
tree.byPath.get(`${path}/README.md`) ??
|
|
477
|
+
tree.byPath.get(`${path}/readme.md`) ??
|
|
478
|
+
tree.byPath.get(`${path}/index.md`) ??
|
|
479
|
+
tree.bySlug.get(path) ??
|
|
480
|
+
tree.bySlug.get(slugForPath(path))
|
|
481
|
+
return direct ? { page: direct, hash } : null
|
|
482
|
+
}
|
package/src/styles.css
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
@import "./docs/properties.css";
|
|
2
2
|
@import "./docs/tasks.css";
|
|
3
|
+
@import "./site/site.css";
|
|
3
4
|
|
|
4
5
|
[data-docstream],
|
|
5
6
|
.docs-article {
|
|
@@ -443,6 +444,15 @@
|
|
|
443
444
|
margin: 4px 0 6px;
|
|
444
445
|
}
|
|
445
446
|
|
|
447
|
+
/* A step's body starts at the top, so a step without a title has its first line beside its number. */
|
|
448
|
+
:is(.docs-step-main, .docs-step-media-body) > [data-docstream-blocks] > :first-child {
|
|
449
|
+
margin-top: 0;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
.docs-step-main > [data-docstream-blocks]:first-child {
|
|
453
|
+
padding-top: 2px;
|
|
454
|
+
}
|
|
455
|
+
|
|
446
456
|
/* A step whose heading opens with a thumbnail: the image in its own column, the title and body beside it. */
|
|
447
457
|
.docs-step-media {
|
|
448
458
|
display: flex;
|