@brett_lamy/docstream 1.18.1 → 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 +1 -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 +1 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// A GitBook-style docs site from a folder of Markdown files. Root-entry safe: no almost-node.
|
|
2
|
+
export { DocsSite } from "./DocsSite"
|
|
3
|
+
export type { DocsSiteLink, DocsSiteProps, DocsSiteRouting, DocsSiteTheme } from "./DocsSite"
|
|
4
|
+
export {
|
|
5
|
+
SITE_FRONTMATTER_KEYS,
|
|
6
|
+
buildDocsTree,
|
|
7
|
+
findLinkedPage,
|
|
8
|
+
humanize,
|
|
9
|
+
isMarkdownPath,
|
|
10
|
+
normalizePath,
|
|
11
|
+
readFrontmatter,
|
|
12
|
+
resolveRelativePath,
|
|
13
|
+
slugForPath,
|
|
14
|
+
stripOrderPrefix,
|
|
15
|
+
} from "./tree"
|
|
16
|
+
export type { BuildDocsTreeOptions, DocsFiles, DocsNavItem, DocsPage, DocsTree } from "./tree"
|
|
17
|
+
export { createDocsSearchIndex, searchableText } from "./search"
|
|
18
|
+
export type { DocsSearchIndex, DocsSearchResult, DocsSearchSection } from "./search"
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/* Client-side full-text search over a docs tree: each page is split into sections at its
|
|
2
|
+
headings, so a hit can jump straight to the heading it was found under. */
|
|
3
|
+
import { slugify } from "../gitbook/outline"
|
|
4
|
+
import { plainTitle, type DocsPage, type DocsTree } from "./tree"
|
|
5
|
+
|
|
6
|
+
export interface DocsSearchSection {
|
|
7
|
+
page: DocsPage
|
|
8
|
+
/** The heading this section starts at; undefined for the text before the first heading. */
|
|
9
|
+
heading?: string
|
|
10
|
+
/** The heading's anchor id. */
|
|
11
|
+
anchor?: string
|
|
12
|
+
text: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface DocsSearchResult {
|
|
16
|
+
page: DocsPage
|
|
17
|
+
heading?: string
|
|
18
|
+
anchor?: string
|
|
19
|
+
/** A short excerpt around the first match, or the section's opening words. */
|
|
20
|
+
snippet: string
|
|
21
|
+
/** [start, end) of the match inside `snippet`, when the match is in the text. */
|
|
22
|
+
highlight?: [number, number]
|
|
23
|
+
score: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface DocsSearchIndex {
|
|
27
|
+
sections: DocsSearchSection[]
|
|
28
|
+
search(query: string, limit?: number): DocsSearchResult[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const HEADING = /^(#{1,6})[ \t]+(.+?)[ \t]*#*[ \t]*$/
|
|
32
|
+
const TABS_TITLE = /^\{%\s*tabs\b[^%]*\btitle="([^"]+)"/
|
|
33
|
+
|
|
34
|
+
/** Markdown → searchable plain text (tags, HTML and punctuation removed, code kept). */
|
|
35
|
+
export function searchableText(markdown: string): string {
|
|
36
|
+
return markdown
|
|
37
|
+
.replace(/^(`{3,}|~{3,}).*$/gm, " ")
|
|
38
|
+
.replace(/\{%[^%]*%\}/g, " ")
|
|
39
|
+
.replace(/<[^>]+>/g, " ")
|
|
40
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
41
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
42
|
+
.replace(/^\[\^[^\]]+\]:.*$/gm, " ")
|
|
43
|
+
.replace(/\[\^[^\]]+\]/g, "")
|
|
44
|
+
.replace(/[*~`>|#]+/g, " ")
|
|
45
|
+
// `_` only as emphasis at a word's edge, so `@brett_lamy` and `snake_case` stay whole.
|
|
46
|
+
.replace(/(^|[\s(])_+|_+(?=[\s).,;:!?]|$)/g, "$1 ")
|
|
47
|
+
.replace(/^\s*[-+]\s+(\[.\]\s+)?/gm, " ")
|
|
48
|
+
.replace(/\$\$/g, " ")
|
|
49
|
+
.replace(/\s+/g, " ")
|
|
50
|
+
.trim()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function sectionsOf(page: DocsPage): DocsSearchSection[] {
|
|
54
|
+
const out: DocsSearchSection[] = []
|
|
55
|
+
let heading: string | undefined
|
|
56
|
+
let buf: string[] = []
|
|
57
|
+
let fence: string | null = null
|
|
58
|
+
const flush = () => {
|
|
59
|
+
const text = searchableText(buf.join("\n"))
|
|
60
|
+
if (text || heading) out.push({ page, heading, anchor: heading ? slugify(heading) : undefined, text })
|
|
61
|
+
buf = []
|
|
62
|
+
}
|
|
63
|
+
for (const line of page.body.split("\n")) {
|
|
64
|
+
const trimmed = line.trim()
|
|
65
|
+
const f = /^(`{3,}|~{3,})/.exec(trimmed)
|
|
66
|
+
if (f) {
|
|
67
|
+
if (!fence) fence = f[1]![0]!
|
|
68
|
+
else if (f[1]![0] === fence) fence = null
|
|
69
|
+
buf.push(line)
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
const h = fence ? null : HEADING.exec(line) ?? TABS_TITLE.exec(trimmed)
|
|
73
|
+
if (h) {
|
|
74
|
+
flush()
|
|
75
|
+
heading = plainTitle(h.length === 3 ? h[2]! : h[1]!)
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
buf.push(line)
|
|
79
|
+
}
|
|
80
|
+
flush()
|
|
81
|
+
return out
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function snippetAround(text: string, at: number, length: number): { snippet: string; highlight?: [number, number] } {
|
|
85
|
+
if (at < 0) return { snippet: text.length > 140 ? `${text.slice(0, 140).trimEnd()}…` : text }
|
|
86
|
+
const start = Math.max(0, at - 50)
|
|
87
|
+
const end = Math.min(text.length, at + length + 90)
|
|
88
|
+
const lead = start > 0 ? "…" : ""
|
|
89
|
+
const snippet = `${lead}${text.slice(start, end).trim()}${end < text.length ? "…" : ""}`
|
|
90
|
+
const offset = lead.length + (at - start) - (text.slice(start, end).length - text.slice(start, end).trimStart().length)
|
|
91
|
+
return { snippet, highlight: [offset, offset + length] }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** An in-memory index of every page; `search` ranks title, then heading, then body matches. */
|
|
95
|
+
export function createDocsSearchIndex(tree: DocsTree): DocsSearchIndex {
|
|
96
|
+
const sections = tree.pages.flatMap(sectionsOf)
|
|
97
|
+
return {
|
|
98
|
+
sections,
|
|
99
|
+
search(query, limit = 20) {
|
|
100
|
+
const q = query.trim().toLowerCase()
|
|
101
|
+
if (!q) return []
|
|
102
|
+
const terms = q.split(/\s+/).filter(Boolean)
|
|
103
|
+
const results: DocsSearchResult[] = []
|
|
104
|
+
for (const s of sections) {
|
|
105
|
+
const title = s.page.title.toLowerCase()
|
|
106
|
+
const heading = s.heading?.toLowerCase() ?? ""
|
|
107
|
+
const text = s.text.toLowerCase()
|
|
108
|
+
const haystack = `${title} ${heading} ${text}`
|
|
109
|
+
if (!terms.every((t) => haystack.includes(t))) continue
|
|
110
|
+
let score = 0
|
|
111
|
+
for (const t of terms) {
|
|
112
|
+
if (title.startsWith(t)) score += 12
|
|
113
|
+
else if (title.includes(t)) score += 8
|
|
114
|
+
if (heading.startsWith(t)) score += 6
|
|
115
|
+
else if (heading.includes(t)) score += 4
|
|
116
|
+
if (text.includes(t)) score += 1
|
|
117
|
+
}
|
|
118
|
+
if (title === q) score += 20
|
|
119
|
+
if (heading === q) score += 10
|
|
120
|
+
// A page's opening section carries its title match; deeper sections rank by their heading.
|
|
121
|
+
if (s.heading) score -= title.includes(terms[0]!) ? 6 : 0
|
|
122
|
+
const at = text.indexOf(terms[0]!)
|
|
123
|
+
results.push({ page: s.page, heading: s.heading, anchor: s.anchor, score, ...snippetAround(s.text, at, at >= 0 ? terms[0]!.length : 0) })
|
|
124
|
+
}
|
|
125
|
+
results.sort((a, b) => b.score - a.score)
|
|
126
|
+
const seen = new Set<string>()
|
|
127
|
+
const out: DocsSearchResult[] = []
|
|
128
|
+
for (const r of results) {
|
|
129
|
+
const key = `${r.page.slug}#${r.anchor ?? ""}`
|
|
130
|
+
if (seen.has(key)) continue
|
|
131
|
+
seen.add(key)
|
|
132
|
+
out.push(r)
|
|
133
|
+
if (out.length >= limit) break
|
|
134
|
+
}
|
|
135
|
+
return out
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
}
|