@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.
@@ -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 `![logo](images/logo.svg)` 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;