@brett_lamy/docstream 0.3.7 → 0.5.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/package.json +3 -3
- package/src/docs/DocsRenderer.tsx +6 -1
- package/src/docs/reference.tsx +95 -0
- package/src/gitbook/ast.ts +25 -1
- package/src/gitbook/index.ts +2 -2
- package/src/gitbook/inline.ts +59 -3
- package/src/gitbook/parse.ts +38 -5
- package/src/gitbook/serialize.ts +12 -2
- package/src/index.ts +11 -2
- package/src/playground/PlaygroundStreamdown.tsx +5 -2
- package/src/playground/ReactCodePreview.tsx +69 -1
- package/src/streamdown.tsx +5 -2
- package/src/styles.css +195 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brett_lamy/docstream",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "GitBook-aware readonly markdown and AI stream renderer.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -65,14 +65,14 @@
|
|
|
65
65
|
"./styles.css": "./src/styles.css"
|
|
66
66
|
},
|
|
67
67
|
"dependencies": {
|
|
68
|
+
"@brett_lamy/viz-engine": ">=0.2.0",
|
|
68
69
|
"hast-util-to-html": "^9.0.5",
|
|
69
70
|
"lowlight": "^3.3.0",
|
|
70
71
|
"lucide-react": "^1.17.0",
|
|
71
72
|
"mermaid": "^11.15.0",
|
|
72
73
|
"rrweb": "2.0.0-alpha.20",
|
|
73
74
|
"streamdown": "^2.5.0",
|
|
74
|
-
"yaml": "^2.9.0"
|
|
75
|
-
"@brett_lamy/viz-engine": "^0.2.0"
|
|
75
|
+
"yaml": "^2.9.0"
|
|
76
76
|
},
|
|
77
77
|
"peerDependencies": {
|
|
78
78
|
"@agent-wasm/core": ">=0.4.0",
|
|
@@ -16,6 +16,7 @@ import { VideoEmbed } from "../video"
|
|
|
16
16
|
import { OpenApiOperation } from "../openapi/OpenApiOperation"
|
|
17
17
|
import { Mermaid } from "./Mermaid"
|
|
18
18
|
import { HighlightedCode } from "./HighlightedCode"
|
|
19
|
+
import { CitationSources, InlineReference } from "./reference"
|
|
19
20
|
|
|
20
21
|
export interface LivePreviewProps {
|
|
21
22
|
files: Record<string, string>
|
|
@@ -31,6 +32,7 @@ function InlineText({ nodes }: { nodes: Inline[] }) {
|
|
|
31
32
|
return (
|
|
32
33
|
<>
|
|
33
34
|
{nodes.map((n, i) => {
|
|
35
|
+
if (n.type === "reference") return <InlineReference key={i} node={n} />
|
|
34
36
|
if (n.type === "image") {
|
|
35
37
|
const img = (
|
|
36
38
|
<img
|
|
@@ -359,9 +361,11 @@ export function MarkdownContent({
|
|
|
359
361
|
sourceRenderer,
|
|
360
362
|
className,
|
|
361
363
|
}: { markdown: string; className?: string } & Renderers) {
|
|
364
|
+
const doc = parseMarkdown(markdown)
|
|
362
365
|
return (
|
|
363
366
|
<div data-docstream="" className={className}>
|
|
364
|
-
<Blocks blocks={
|
|
367
|
+
<Blocks blocks={doc.children} liveRenderer={liveRenderer} sourceRenderer={sourceRenderer} />
|
|
368
|
+
<CitationSources citations={doc.citations} />
|
|
365
369
|
</div>
|
|
366
370
|
)
|
|
367
371
|
}
|
|
@@ -370,6 +374,7 @@ export function DocsRenderer({ doc, liveRenderer, sourceRenderer }: { doc: Docum
|
|
|
370
374
|
return (
|
|
371
375
|
<article className="docs-article">
|
|
372
376
|
<Blocks blocks={doc.children} liveRenderer={liveRenderer} sourceRenderer={sourceRenderer} />
|
|
377
|
+
<CitationSources citations={doc.citations} />
|
|
373
378
|
</article>
|
|
374
379
|
)
|
|
375
380
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { createContext, useContext, useState, type ReactNode } from "react"
|
|
2
|
+
import { AtSign, Globe, Hash } from "lucide-react"
|
|
3
|
+
|
|
4
|
+
import type { CitationDef, ReferenceNode } from "../gitbook/ast"
|
|
5
|
+
|
|
6
|
+
export interface DocstreamRefContextValue {
|
|
7
|
+
/** Called when a mention/tag chip (or a citation, if it has no url) is clicked. */
|
|
8
|
+
onReferenceClick?: (ref: ReferenceNode) => void
|
|
9
|
+
/** Full override for chip rendering; return null to fall back to the default. */
|
|
10
|
+
renderReference?: (ref: ReferenceNode) => ReactNode | null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const DocstreamRefContext = createContext<DocstreamRefContextValue>({})
|
|
14
|
+
|
|
15
|
+
export function InlineReference({ node }: { node: ReferenceNode }) {
|
|
16
|
+
const { onReferenceClick, renderReference } = useContext(DocstreamRefContext)
|
|
17
|
+
const custom = renderReference?.(node)
|
|
18
|
+
if (custom != null) return <>{custom}</>
|
|
19
|
+
|
|
20
|
+
if (node.kind === "citation") {
|
|
21
|
+
// Citations default to linking their source; hosts can still observe clicks.
|
|
22
|
+
if (node.url) {
|
|
23
|
+
return (
|
|
24
|
+
<a
|
|
25
|
+
className="docs-cite"
|
|
26
|
+
href={node.url}
|
|
27
|
+
target="_blank"
|
|
28
|
+
rel="noreferrer"
|
|
29
|
+
title={node.label ?? node.url}
|
|
30
|
+
onClick={() => onReferenceClick?.(node)}
|
|
31
|
+
>
|
|
32
|
+
{node.id}
|
|
33
|
+
</a>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
// Definition missing (not yet streamed, or standalone parseInline call).
|
|
37
|
+
return (
|
|
38
|
+
<span className="docs-cite" onClick={() => onReferenceClick?.(node)}>
|
|
39
|
+
{node.id}
|
|
40
|
+
</span>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const Icon = node.kind === "mention" ? AtSign : Hash
|
|
45
|
+
return (
|
|
46
|
+
<button
|
|
47
|
+
type="button"
|
|
48
|
+
className={`docs-ref docs-ref-${node.kind}`}
|
|
49
|
+
onClick={() => onReferenceClick?.(node)}
|
|
50
|
+
>
|
|
51
|
+
<Icon className="docs-ref-icon" />
|
|
52
|
+
{node.id}
|
|
53
|
+
</button>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function hostnameOf(url: string): string | null {
|
|
58
|
+
try {
|
|
59
|
+
return new URL(url).hostname.replace(/^www\./, "")
|
|
60
|
+
} catch {
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function SourceChip({ def }: { def: CitationDef }) {
|
|
66
|
+
const [faviconFailed, setFaviconFailed] = useState(false)
|
|
67
|
+
const host = hostnameOf(def.url)
|
|
68
|
+
return (
|
|
69
|
+
<a className="docs-source-chip" href={def.url} target="_blank" rel="noreferrer" title={def.label ?? def.url}>
|
|
70
|
+
{host && !faviconFailed ? (
|
|
71
|
+
<img
|
|
72
|
+
className="docs-source-favicon"
|
|
73
|
+
src={`https://www.google.com/s2/favicons?sz=32&domain=${host}`}
|
|
74
|
+
alt=""
|
|
75
|
+
onError={() => setFaviconFailed(true)}
|
|
76
|
+
/>
|
|
77
|
+
) : (
|
|
78
|
+
<Globe className="docs-source-favicon" />
|
|
79
|
+
)}
|
|
80
|
+
{host ?? def.url}
|
|
81
|
+
</a>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Row of source-domain pills for a document's citation definitions. */
|
|
86
|
+
export function CitationSources({ citations }: { citations?: CitationDef[] }) {
|
|
87
|
+
if (!citations?.length) return null
|
|
88
|
+
return (
|
|
89
|
+
<div className="docs-sources">
|
|
90
|
+
{citations.map((def) => (
|
|
91
|
+
<SourceChip key={def.id} def={def} />
|
|
92
|
+
))}
|
|
93
|
+
</div>
|
|
94
|
+
)
|
|
95
|
+
}
|
package/src/gitbook/ast.ts
CHANGED
|
@@ -23,7 +23,29 @@ export interface InlineImageNode {
|
|
|
23
23
|
link?: string
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
export type
|
|
26
|
+
export type ReferenceKind = "mention" | "tag" | "citation"
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Inline reference chip: `@mention`, `#tag`, or footnote-style citation `[^id]`.
|
|
30
|
+
* `id` carries no sigil. Citations resolve `url`/`label` from their
|
|
31
|
+
* `[^id]: url "Label"` definition at parse time when one exists.
|
|
32
|
+
*/
|
|
33
|
+
export interface ReferenceNode {
|
|
34
|
+
type: "reference"
|
|
35
|
+
kind: ReferenceKind
|
|
36
|
+
id: string
|
|
37
|
+
url?: string
|
|
38
|
+
label?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A footnote citation definition (`[^id]: url "Label"`), collected doc-level. */
|
|
42
|
+
export interface CitationDef {
|
|
43
|
+
id: string
|
|
44
|
+
url: string
|
|
45
|
+
label?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type Inline = TextNode | InlineImageNode | ReferenceNode
|
|
27
49
|
|
|
28
50
|
export interface ParagraphNode {
|
|
29
51
|
type: "paragraph"
|
|
@@ -214,6 +236,8 @@ export type Block =
|
|
|
214
236
|
export interface DocumentNode {
|
|
215
237
|
type: "doc"
|
|
216
238
|
children: Block[]
|
|
239
|
+
/** Footnote citation definitions, in definition order. */
|
|
240
|
+
citations?: CitationDef[]
|
|
217
241
|
}
|
|
218
242
|
|
|
219
243
|
export const text = (t: string, marks: Partial<Omit<TextNode, "type" | "text">> = {}): TextNode => ({
|
package/src/gitbook/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Pure GitBook markdown engine (no React, no mermaid/streamdown). Safe to import
|
|
2
2
|
// in non-DOM environments such as a server-side AI agent or a Cloudflare Worker.
|
|
3
3
|
export type * from "./ast"
|
|
4
|
-
export { parseMarkdown, parseBlocks } from "./parse"
|
|
4
|
+
export { parseMarkdown, parseBlocks, trimPartialInlineToken } from "./parse"
|
|
5
5
|
export { serializeBlocks, serializeMarkdown } from "./serialize"
|
|
6
|
-
export { parseInline, plainText, refDefinitions, serializeInline } from "./inline"
|
|
6
|
+
export { footnoteDefinitions, parseInline, plainText, refDefinitions, serializeInline, serializeReference } from "./inline"
|
package/src/gitbook/inline.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Inline, InlineImageNode, TextNode } from "./ast"
|
|
1
|
+
import type { Inline, InlineImageNode, ReferenceNode, TextNode } from "./ast"
|
|
2
2
|
|
|
3
3
|
type Marks = Partial<Omit<TextNode, "type" | "text">>
|
|
4
4
|
|
|
@@ -6,6 +6,17 @@ type Marks = Partial<Omit<TextNode, "type" | "text">>
|
|
|
6
6
|
// and consumed here for [text][ref] / [text][] forms.
|
|
7
7
|
export const refDefinitions = new Map<string, string>()
|
|
8
8
|
|
|
9
|
+
// Footnote citation definitions ([^id]: url "Label"), populated by
|
|
10
|
+
// parseMarkdown and consumed here to resolve [^id] markers.
|
|
11
|
+
export const footnoteDefinitions = new Map<string, { url: string; label?: string }>()
|
|
12
|
+
|
|
13
|
+
// @mention / #tag body: letter/underscore start, then word chars, dots, dashes.
|
|
14
|
+
const REFERENCE_BODY_RE = /^[@#]([A-Za-z_][\w.-]*)/
|
|
15
|
+
// Chips are only recognized at start-of-input or after whitespace/open brackets,
|
|
16
|
+
// so brett@replay.io and C# stay plain text.
|
|
17
|
+
const isReferenceBoundary = (prev: string | undefined) =>
|
|
18
|
+
prev === undefined || /[\s([{]/.test(prev)
|
|
19
|
+
|
|
9
20
|
function imgAttrs(attrStr: string): Omit<InlineImageNode, "type" | "link"> {
|
|
10
21
|
const attr = (name: string) => attrStr.match(new RegExp(`${name}="([^"]*)"`, "i"))?.[1]
|
|
11
22
|
const out: Omit<InlineImageNode, "type" | "link"> = { src: attr("src") ?? "" }
|
|
@@ -110,6 +121,30 @@ export function parseInline(src: string, marks: Marks = {}): Inline[] {
|
|
|
110
121
|
}
|
|
111
122
|
if (matched) continue
|
|
112
123
|
|
|
124
|
+
// [^id] — footnote citation marker (must precede [text](url) / [text][ref])
|
|
125
|
+
const cite = rest.match(/^\[\^([^\]\s]+)\]/)
|
|
126
|
+
if (cite) {
|
|
127
|
+
flush()
|
|
128
|
+
const def = footnoteDefinitions.get(cite[1])
|
|
129
|
+
out.push({ type: "reference", kind: "citation", id: cite[1], ...def })
|
|
130
|
+
i += cite[0].length
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// @mention / #tag chips, only at a word boundary
|
|
135
|
+
if ((rest[0] === "@" || rest[0] === "#") && isReferenceBoundary(src[i - 1])) {
|
|
136
|
+
const m = rest.match(REFERENCE_BODY_RE)
|
|
137
|
+
if (m) {
|
|
138
|
+
const id = m[1].replace(/[.-]+$/, "")
|
|
139
|
+
if (id) {
|
|
140
|
+
flush()
|
|
141
|
+
out.push({ type: "reference", kind: rest[0] === "@" ? "mention" : "tag", id })
|
|
142
|
+
i += 1 + id.length
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
113
148
|
// [text](url)
|
|
114
149
|
if (rest[0] === "[") {
|
|
115
150
|
const m = rest.match(/^\[([^\]]*)\]\(([^)\s]+)\)/)
|
|
@@ -162,7 +197,18 @@ export function parseInline(src: string, marks: Marks = {}): Inline[] {
|
|
|
162
197
|
return out
|
|
163
198
|
}
|
|
164
199
|
|
|
165
|
-
const escapeText = (t: string) =>
|
|
200
|
+
const escapeText = (t: string) =>
|
|
201
|
+
t
|
|
202
|
+
.replace(/([*_~`[\]\\])/g, "\\$1")
|
|
203
|
+
// Escape @/# only at a chip boundary so emails/C# survive untouched.
|
|
204
|
+
// Note: node-local — a boundary formed across adjacent inline nodes
|
|
205
|
+
// (previous node ending in whitespace) is not caught; rare, accepted.
|
|
206
|
+
.replace(/(^|[\s([{])([@#])(?=[A-Za-z_])/g, "$1\\$2")
|
|
207
|
+
|
|
208
|
+
export function serializeReference(n: ReferenceNode): string {
|
|
209
|
+
if (n.kind === "citation") return `[^${n.id}]`
|
|
210
|
+
return `${n.kind === "mention" ? "@" : "#"}${n.id}`
|
|
211
|
+
}
|
|
166
212
|
|
|
167
213
|
function serializeImage(n: InlineImageNode): string {
|
|
168
214
|
const attrs = [
|
|
@@ -182,6 +228,7 @@ export function serializeInline(nodes: Inline[]): string {
|
|
|
182
228
|
return nodes
|
|
183
229
|
.map((n) => {
|
|
184
230
|
if (n.type === "image") return serializeImage(n)
|
|
231
|
+
if (n.type === "reference") return serializeReference(n)
|
|
185
232
|
// bare autolink: text identical to the URL, no other marks
|
|
186
233
|
if (n.link && n.text === n.link && !n.bold && !n.italic && !n.strike && !n.code) {
|
|
187
234
|
return n.link
|
|
@@ -198,5 +245,14 @@ export function serializeInline(nodes: Inline[]): string {
|
|
|
198
245
|
}
|
|
199
246
|
|
|
200
247
|
export function plainText(nodes: Inline[]): string {
|
|
201
|
-
return nodes
|
|
248
|
+
return nodes
|
|
249
|
+
.map((n) => {
|
|
250
|
+
if (n.type === "text") return n.text
|
|
251
|
+
if (n.type === "reference") {
|
|
252
|
+
// Citation superscripts are visual noise in plain text (titles etc.).
|
|
253
|
+
return n.kind === "citation" ? "" : serializeReference(n)
|
|
254
|
+
}
|
|
255
|
+
return n.alt ?? ""
|
|
256
|
+
})
|
|
257
|
+
.join("")
|
|
202
258
|
}
|
package/src/gitbook/parse.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type {
|
|
|
9
9
|
TabNode,
|
|
10
10
|
UpdateNode,
|
|
11
11
|
} from "./ast"
|
|
12
|
-
import { parseInline, plainText, refDefinitions } from "./inline"
|
|
12
|
+
import { footnoteDefinitions, parseInline, plainText, refDefinitions } from "./inline"
|
|
13
13
|
|
|
14
14
|
// Minimal HTML-inline → markdown-inline bridge for HTML table cells.
|
|
15
15
|
function htmlToInlineMd(html: string): string {
|
|
@@ -119,13 +119,46 @@ const HINT_STYLES: HintStyle[] = ["info", "success", "warning", "danger"]
|
|
|
119
119
|
export function parseMarkdown(src: string): DocumentNode {
|
|
120
120
|
const lines = src.split(/\r?\n/)
|
|
121
121
|
refDefinitions.clear()
|
|
122
|
+
footnoteDefinitions.clear()
|
|
122
123
|
const content: string[] = []
|
|
124
|
+
let inFence = false
|
|
123
125
|
for (const line of lines) {
|
|
124
|
-
|
|
125
|
-
if (
|
|
126
|
-
|
|
126
|
+
// Definition lines inside code fences are content, not definitions.
|
|
127
|
+
if (/^\s*(?:```|~~~)/.test(line)) inFence = !inFence
|
|
128
|
+
if (!inFence) {
|
|
129
|
+
// Footnote citation definitions first: [^id]: url "Optional Label"
|
|
130
|
+
const foot = line.match(/^\[\^([^\]\s]+)\]:\s*(\S+)(?:\s+"([^"]*)")?\s*$/)
|
|
131
|
+
if (foot) {
|
|
132
|
+
footnoteDefinitions.set(foot[1], { url: foot[2], ...(foot[3] ? { label: foot[3] } : {}) })
|
|
133
|
+
continue
|
|
134
|
+
}
|
|
135
|
+
const def = line.match(/^\[([^\]]+)\]:\s*(\S+)\s*$/)
|
|
136
|
+
// Keys starting with ^ are reserved for footnotes and must never
|
|
137
|
+
// shadow reference-style link definitions.
|
|
138
|
+
if (def && !def[1].startsWith("^")) {
|
|
139
|
+
refDefinitions.set(def[1].toLowerCase(), def[2])
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
content.push(line)
|
|
144
|
+
}
|
|
145
|
+
const doc: DocumentNode = { type: "doc", children: parseBlocks(content) }
|
|
146
|
+
if (footnoteDefinitions.size) {
|
|
147
|
+
doc.citations = [...footnoteDefinitions.entries()].map(([id, d]) => ({ id, ...d }))
|
|
127
148
|
}
|
|
128
|
-
return
|
|
149
|
+
return doc
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Strips an in-progress inline token (`[^1`, `@bre`, a lone `#`, a trailing
|
|
153
|
+
// `\`) from the end of a partially streamed document so chips don't flash as
|
|
154
|
+
// plain text mid-stream. No-op inside an unterminated code fence.
|
|
155
|
+
export function trimPartialInlineToken(md: string): string {
|
|
156
|
+
const fences = md.match(/^\s*(?:```|~~~)/gm)
|
|
157
|
+
if (fences && fences.length % 2 === 1) return md
|
|
158
|
+
return md
|
|
159
|
+
.replace(/\[\^[^\]]*$/, "")
|
|
160
|
+
.replace(/(^|[\s([{])[@#][\w.-]*$/, "$1")
|
|
161
|
+
.replace(/\\$/, "")
|
|
129
162
|
}
|
|
130
163
|
|
|
131
164
|
export function parseBlocks(lines: string[]): Block[] {
|
package/src/gitbook/serialize.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import type { Block, DocumentNode, Inline, ListItemNode } from "./ast"
|
|
2
|
-
import { serializeInline } from "./inline"
|
|
2
|
+
import { serializeInline, serializeReference } from "./inline"
|
|
3
3
|
|
|
4
4
|
export function serializeMarkdown(doc: DocumentNode): string {
|
|
5
|
-
|
|
5
|
+
let out = serializeBlocks(doc.children).trimEnd()
|
|
6
|
+
if (doc.citations?.length) {
|
|
7
|
+
const defs = doc.citations
|
|
8
|
+
.map((c) => `[^${c.id}]: ${c.url}${c.label ? ` "${c.label}"` : ""}`)
|
|
9
|
+
.join("\n")
|
|
10
|
+
out = out ? `${out}\n\n${defs}` : defs
|
|
11
|
+
}
|
|
12
|
+
return out + "\n"
|
|
6
13
|
}
|
|
7
14
|
|
|
8
15
|
export function serializeBlocks(blocks: Block[]): string {
|
|
@@ -156,6 +163,9 @@ function inlineToHtml(nodes: Inline[]): string {
|
|
|
156
163
|
const img = `<img src="${n.src}"${n.alt ? ` alt="${n.alt}"` : ""}>`
|
|
157
164
|
return n.link ? `<a href="${n.link}">${img}</a>` : img
|
|
158
165
|
}
|
|
166
|
+
// HTML card tables round-trip through htmlToInlineMd, which strips
|
|
167
|
+
// tags — plain markers are the only representation that survives.
|
|
168
|
+
if (n.type === "reference") return serializeReference(n)
|
|
159
169
|
let s = n.text
|
|
160
170
|
if (n.code) s = `<code>${s}</code>`
|
|
161
171
|
if (n.bold) s = `<strong>${s}</strong>`
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,8 @@ export { PlaygroundStreamdown as GitbookStreamdown } from "./playground/Playgrou
|
|
|
2
2
|
export type { PlaygroundStreamdownProps as GitbookStreamdownProps } from "./playground/PlaygroundStreamdown"
|
|
3
3
|
export { DocsRenderer, MarkdownContent } from "./docs/DocsRenderer"
|
|
4
4
|
export type { LivePreviewProps, LivePreviewRenderer, SourceReferenceRenderer } from "./docs/DocsRenderer"
|
|
5
|
+
export { CitationSources, DocstreamRefContext, InlineReference } from "./docs/reference"
|
|
6
|
+
export type { DocstreamRefContextValue } from "./docs/reference"
|
|
5
7
|
export { ReplayEmbed, ReplayPreview } from "./replay"
|
|
6
8
|
export type {
|
|
7
9
|
ReplayEventsSource,
|
|
@@ -42,9 +44,16 @@ export type {
|
|
|
42
44
|
SourceProvenance,
|
|
43
45
|
SourceReferenceClient,
|
|
44
46
|
} from "./source"
|
|
45
|
-
export { parseMarkdown, parseBlocks } from "./gitbook/parse"
|
|
47
|
+
export { parseMarkdown, parseBlocks, trimPartialInlineToken } from "./gitbook/parse"
|
|
46
48
|
export { serializeBlocks, serializeMarkdown } from "./gitbook/serialize"
|
|
47
|
-
export {
|
|
49
|
+
export {
|
|
50
|
+
footnoteDefinitions,
|
|
51
|
+
parseInline,
|
|
52
|
+
plainText,
|
|
53
|
+
refDefinitions,
|
|
54
|
+
serializeInline,
|
|
55
|
+
serializeReference,
|
|
56
|
+
} from "./gitbook/inline"
|
|
48
57
|
export { Streamdown } from "streamdown"
|
|
49
58
|
export type { StreamdownProps } from "streamdown"
|
|
50
59
|
export type * from "./gitbook/ast"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useMemo } from "react"
|
|
2
2
|
|
|
3
3
|
import { DocsRenderer, type LivePreviewRenderer } from "../docs/DocsRenderer"
|
|
4
|
-
import { parseMarkdown } from "../gitbook/parse"
|
|
4
|
+
import { parseMarkdown, trimPartialInlineToken } from "../gitbook/parse"
|
|
5
5
|
import { ReactCodePreview } from "./ReactCodePreview"
|
|
6
6
|
|
|
7
7
|
export interface PlaygroundStreamdownProps {
|
|
@@ -25,7 +25,10 @@ export function PlaygroundStreamdown({
|
|
|
25
25
|
markdown,
|
|
26
26
|
}: PlaygroundStreamdownProps) {
|
|
27
27
|
const content = markdown ?? children ?? ""
|
|
28
|
-
const doc = useMemo(
|
|
28
|
+
const doc = useMemo(
|
|
29
|
+
() => parseMarkdown(isStreaming ? trimPartialInlineToken(content) : content),
|
|
30
|
+
[content, isStreaming]
|
|
31
|
+
)
|
|
29
32
|
const liveRenderer: LivePreviewRenderer = ({ files, entry, title }) => (
|
|
30
33
|
<ReactCodePreview files={files} entry={entry} title={title} />
|
|
31
34
|
)
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import { useEffect, useState, type CSSProperties } from "react"
|
|
1
|
+
import { useEffect, useId, useMemo, useState, type CSSProperties } from "react"
|
|
2
|
+
import { HighlightedCode } from "../docs/HighlightedCode"
|
|
2
3
|
import {
|
|
3
4
|
createAlmostNodeWorkspace,
|
|
4
5
|
createReactDemoFiles,
|
|
6
|
+
normalizeAlmostNodeFiles,
|
|
7
|
+
normalizeAlmostNodePath,
|
|
5
8
|
type AlmostNodeFiles,
|
|
6
9
|
type AlmostNodeWorkspace,
|
|
7
10
|
type AlmostNodeWorkspaceOptions,
|
|
@@ -16,6 +19,12 @@ export interface ReactDemoProps {
|
|
|
16
19
|
port?: number
|
|
17
20
|
/** Height of the embedded preview. */
|
|
18
21
|
height?: number | string
|
|
22
|
+
/** Source file displayed below the preview. Defaults to the entry file. */
|
|
23
|
+
codeFile?: string
|
|
24
|
+
/** Number of source lines visible while the code preview is collapsed. Defaults to 3. */
|
|
25
|
+
collapsedCodeLines?: number
|
|
26
|
+
/** Maximum visible source lines before expanded code scrolls. Defaults to 30. */
|
|
27
|
+
expandedCodeLines?: number
|
|
19
28
|
/** Label shown above the preview. */
|
|
20
29
|
title?: string
|
|
21
30
|
/** Start the runtime on mount (default true). */
|
|
@@ -43,12 +52,33 @@ function frameHeight(height: number | string): CSSProperties {
|
|
|
43
52
|
return { height: typeof height === "number" ? `${height}px` : height }
|
|
44
53
|
}
|
|
45
54
|
|
|
55
|
+
function positiveLineCount(value: number, fallback: number): number {
|
|
56
|
+
return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function sourceLanguage(path: string): string | null {
|
|
60
|
+
const extension = path.split(".").pop()?.toLowerCase()
|
|
61
|
+
return extension && extension !== path ? extension : null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function sourceText(files: AlmostNodeFiles, requestedPath: string): string | null {
|
|
65
|
+
const normalizedPath = normalizeAlmostNodePath(requestedPath)
|
|
66
|
+
const file = normalizeAlmostNodeFiles(files).find(
|
|
67
|
+
(candidate) => normalizeAlmostNodePath(candidate.path) === normalizedPath,
|
|
68
|
+
)
|
|
69
|
+
if (!file) return null
|
|
70
|
+
return typeof file.content === "string" ? file.content : new TextDecoder().decode(file.content)
|
|
71
|
+
}
|
|
72
|
+
|
|
46
73
|
/** Render a multi-file React/JSX/TSX project through almost-node's Vite server. */
|
|
47
74
|
export function ReactDemo({
|
|
48
75
|
files,
|
|
49
76
|
entry = "/src/main.jsx",
|
|
50
77
|
port = 4173,
|
|
51
78
|
height = 360,
|
|
79
|
+
codeFile,
|
|
80
|
+
collapsedCodeLines = 3,
|
|
81
|
+
expandedCodeLines = 30,
|
|
52
82
|
title = "Live React preview",
|
|
53
83
|
autoStart = true,
|
|
54
84
|
sandbox = "allow-scripts allow-same-origin allow-forms allow-modals",
|
|
@@ -61,6 +91,18 @@ export function ReactDemo({
|
|
|
61
91
|
const [state, setState] = useState<PreviewState>(autoStart ? "starting" : "idle")
|
|
62
92
|
const [url, setUrl] = useState<string | null>(null)
|
|
63
93
|
const [error, setError] = useState<string | null>(null)
|
|
94
|
+
const [codeExpanded, setCodeExpanded] = useState(false)
|
|
95
|
+
const codePanelId = useId()
|
|
96
|
+
const displayedCodeFile = codeFile ?? entry
|
|
97
|
+
const code = useMemo(
|
|
98
|
+
() => sourceText(files, displayedCodeFile),
|
|
99
|
+
[displayedCodeFile, files],
|
|
100
|
+
)
|
|
101
|
+
const collapsedLines = positiveLineCount(collapsedCodeLines, 3)
|
|
102
|
+
const expandedLines = Math.max(
|
|
103
|
+
collapsedLines,
|
|
104
|
+
positiveLineCount(expandedCodeLines, 30),
|
|
105
|
+
)
|
|
64
106
|
|
|
65
107
|
useEffect(() => {
|
|
66
108
|
if (!run) {
|
|
@@ -158,6 +200,32 @@ export function ReactDemo({
|
|
|
158
200
|
{running ? "Starting almost-node…" : "Run the demo to open its virtual filesystem."}
|
|
159
201
|
</div>
|
|
160
202
|
)}
|
|
203
|
+
{code !== null ? (
|
|
204
|
+
<div className="docs-react-demo-code">
|
|
205
|
+
<pre
|
|
206
|
+
id={codePanelId}
|
|
207
|
+
className={codeExpanded ? "docs-react-demo-code-body docs-react-demo-code-body-expanded" : "docs-react-demo-code-body"}
|
|
208
|
+
style={{
|
|
209
|
+
"--docs-react-demo-code-lines": codeExpanded ? expandedLines : collapsedLines,
|
|
210
|
+
} as CSSProperties}
|
|
211
|
+
>
|
|
212
|
+
<HighlightedCode
|
|
213
|
+
code={code}
|
|
214
|
+
language={sourceLanguage(displayedCodeFile)}
|
|
215
|
+
lineNumbers
|
|
216
|
+
/>
|
|
217
|
+
</pre>
|
|
218
|
+
<button
|
|
219
|
+
type="button"
|
|
220
|
+
className="docs-react-demo-code-toggle"
|
|
221
|
+
aria-controls={codePanelId}
|
|
222
|
+
aria-expanded={codeExpanded}
|
|
223
|
+
onClick={() => setCodeExpanded((value) => !value)}
|
|
224
|
+
>
|
|
225
|
+
{codeExpanded ? "Hide Code" : "View Code"}
|
|
226
|
+
</button>
|
|
227
|
+
</div>
|
|
228
|
+
) : null}
|
|
161
229
|
</section>
|
|
162
230
|
)
|
|
163
231
|
}
|
package/src/streamdown.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useMemo } from "react"
|
|
2
2
|
import { DocsRenderer } from "./docs/DocsRenderer"
|
|
3
|
-
import { parseMarkdown } from "./gitbook/parse"
|
|
3
|
+
import { parseMarkdown, trimPartialInlineToken } from "./gitbook/parse"
|
|
4
4
|
|
|
5
5
|
export interface GitbookStreamdownProps {
|
|
6
6
|
children?: string
|
|
@@ -18,7 +18,10 @@ export function GitbookStreamdown({
|
|
|
18
18
|
markdown,
|
|
19
19
|
}: GitbookStreamdownProps) {
|
|
20
20
|
const content = markdown ?? children ?? ""
|
|
21
|
-
const gitbookDoc = useMemo(
|
|
21
|
+
const gitbookDoc = useMemo(
|
|
22
|
+
() => parseMarkdown(isStreaming ? trimPartialInlineToken(content) : content),
|
|
23
|
+
[content, isStreaming]
|
|
24
|
+
)
|
|
22
25
|
|
|
23
26
|
return (
|
|
24
27
|
<div
|
package/src/styles.css
CHANGED
|
@@ -775,27 +775,36 @@
|
|
|
775
775
|
.hljs-comment,
|
|
776
776
|
.hljs-quote,
|
|
777
777
|
.hljs-meta {
|
|
778
|
-
color: var(--gb-muted-foreground);
|
|
778
|
+
color: var(--gb-muted-foreground, #71717a);
|
|
779
779
|
}
|
|
780
780
|
|
|
781
781
|
.hljs-operator,
|
|
782
782
|
.hljs-punctuation {
|
|
783
|
-
color: var(--gb-code-fg);
|
|
783
|
+
color: var(--gb-code-fg, #18181b);
|
|
784
784
|
}
|
|
785
785
|
|
|
786
786
|
.hljs-deletion {
|
|
787
|
-
color: var(--gb-danger);
|
|
787
|
+
color: var(--gb-danger, #ef4444);
|
|
788
788
|
}
|
|
789
789
|
|
|
790
790
|
.hljs-addition {
|
|
791
|
-
color: var(--gb-success);
|
|
791
|
+
color: var(--gb-success, #22c55e);
|
|
792
792
|
}
|
|
793
793
|
|
|
794
794
|
.docs-react-demo {
|
|
795
|
+
--docs-react-demo-panel: var(--gb-panel, var(--card, #ffffff));
|
|
796
|
+
--docs-react-demo-foreground: var(--gb-panel-foreground, var(--card-foreground, #18181b));
|
|
797
|
+
--docs-react-demo-muted-foreground: var(--gb-muted-foreground, var(--muted-foreground, #71717a));
|
|
798
|
+
--docs-react-demo-border: var(--gb-border, var(--border, #e4e4e7));
|
|
799
|
+
--docs-react-demo-accent: var(--gb-accent, var(--accent, #f4f4f5));
|
|
800
|
+
--docs-react-demo-primary: var(--gb-primary, var(--primary, #4f46e5));
|
|
801
|
+
--docs-react-demo-code-bg: var(--gb-code-bg, rgba(128, 128, 128, 0.13));
|
|
802
|
+
--docs-react-demo-success: var(--gb-success, #22c55e);
|
|
803
|
+
--docs-react-demo-danger: var(--gb-danger, #ef4444);
|
|
795
804
|
overflow: hidden;
|
|
796
|
-
border: 1px solid var(--
|
|
805
|
+
border: 1px solid var(--docs-react-demo-border);
|
|
797
806
|
border-radius: 12px;
|
|
798
|
-
background: var(--
|
|
807
|
+
background: var(--docs-react-demo-panel);
|
|
799
808
|
}
|
|
800
809
|
|
|
801
810
|
.docs-react-demo-header {
|
|
@@ -804,33 +813,33 @@
|
|
|
804
813
|
gap: 10px;
|
|
805
814
|
min-height: 42px;
|
|
806
815
|
padding: 8px 12px;
|
|
807
|
-
border-bottom: 1px solid var(--
|
|
808
|
-
color: var(--
|
|
816
|
+
border-bottom: 1px solid var(--docs-react-demo-border);
|
|
817
|
+
color: var(--docs-react-demo-foreground);
|
|
809
818
|
font-size: 13px;
|
|
810
819
|
font-weight: 550;
|
|
811
820
|
}
|
|
812
821
|
|
|
813
822
|
.docs-react-demo-status {
|
|
814
823
|
margin-left: auto;
|
|
815
|
-
color: var(--
|
|
824
|
+
color: var(--docs-react-demo-muted-foreground);
|
|
816
825
|
font-size: 11px;
|
|
817
826
|
text-transform: uppercase;
|
|
818
827
|
letter-spacing: 0.08em;
|
|
819
828
|
}
|
|
820
829
|
|
|
821
830
|
.docs-react-demo-status-ready {
|
|
822
|
-
color: var(--
|
|
831
|
+
color: var(--docs-react-demo-success);
|
|
823
832
|
}
|
|
824
833
|
|
|
825
834
|
.docs-react-demo-status-error {
|
|
826
|
-
color: var(--
|
|
835
|
+
color: var(--docs-react-demo-danger);
|
|
827
836
|
}
|
|
828
837
|
|
|
829
838
|
.docs-react-demo-run {
|
|
830
|
-
border: 1px solid var(--
|
|
839
|
+
border: 1px solid var(--docs-react-demo-border);
|
|
831
840
|
border-radius: 6px;
|
|
832
841
|
padding: 4px 9px;
|
|
833
|
-
color: var(--
|
|
842
|
+
color: var(--docs-react-demo-foreground);
|
|
834
843
|
font-size: 12px;
|
|
835
844
|
cursor: pointer;
|
|
836
845
|
}
|
|
@@ -860,7 +869,178 @@
|
|
|
860
869
|
min-height: 100px;
|
|
861
870
|
overflow: auto;
|
|
862
871
|
padding: 14px;
|
|
863
|
-
color: var(--
|
|
864
|
-
background: var(--
|
|
872
|
+
color: var(--docs-react-demo-danger);
|
|
873
|
+
background: var(--docs-react-demo-code-bg);
|
|
865
874
|
white-space: pre-wrap;
|
|
866
875
|
}
|
|
876
|
+
|
|
877
|
+
.docs-react-demo-code {
|
|
878
|
+
position: relative;
|
|
879
|
+
border-top: 1px solid var(--docs-react-demo-border);
|
|
880
|
+
background: var(--docs-react-demo-code-bg);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
.docs-react-demo-code-body {
|
|
884
|
+
--docs-react-demo-code-line-height: 1.6rem;
|
|
885
|
+
max-height: calc(var(--docs-react-demo-code-lines) * var(--docs-react-demo-code-line-height));
|
|
886
|
+
margin: 0;
|
|
887
|
+
overflow: hidden;
|
|
888
|
+
padding: 1.15rem 1rem 2.4rem;
|
|
889
|
+
font-size: 0.875rem;
|
|
890
|
+
line-height: var(--docs-react-demo-code-line-height);
|
|
891
|
+
tab-size: 2;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
.docs-react-demo-code-body::after {
|
|
895
|
+
position: absolute;
|
|
896
|
+
right: 0;
|
|
897
|
+
bottom: 0;
|
|
898
|
+
left: 0;
|
|
899
|
+
height: 4.5rem;
|
|
900
|
+
background: linear-gradient(to bottom, transparent, var(--docs-react-demo-panel));
|
|
901
|
+
content: "";
|
|
902
|
+
pointer-events: none;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
.docs-react-demo-code-body-expanded {
|
|
906
|
+
overflow: auto;
|
|
907
|
+
padding-bottom: 3.25rem;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
.docs-react-demo-code-body-expanded::after {
|
|
911
|
+
display: none;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
.docs-react-demo-code-body > code {
|
|
915
|
+
display: block;
|
|
916
|
+
min-width: max-content;
|
|
917
|
+
padding: 0;
|
|
918
|
+
background: transparent;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
.docs-react-demo-code-body:not(.docs-react-demo-code-body-expanded) > code {
|
|
922
|
+
max-height: calc(var(--docs-react-demo-code-lines) * var(--docs-react-demo-code-line-height));
|
|
923
|
+
overflow: hidden;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
.docs-react-demo-code-body .docs-code-line {
|
|
927
|
+
min-height: var(--docs-react-demo-code-line-height);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
.docs-react-demo-code-toggle {
|
|
931
|
+
position: absolute;
|
|
932
|
+
bottom: 0.85rem;
|
|
933
|
+
left: 50%;
|
|
934
|
+
z-index: 1;
|
|
935
|
+
transform: translateX(-50%);
|
|
936
|
+
border: 1px solid var(--docs-react-demo-border);
|
|
937
|
+
border-radius: 0.65rem;
|
|
938
|
+
padding: 0.45rem 0.85rem;
|
|
939
|
+
color: var(--docs-react-demo-foreground);
|
|
940
|
+
background: var(--docs-react-demo-panel);
|
|
941
|
+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);
|
|
942
|
+
font: inherit;
|
|
943
|
+
font-size: 0.8125rem;
|
|
944
|
+
font-weight: 600;
|
|
945
|
+
cursor: pointer;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
.docs-react-demo-code-toggle:hover {
|
|
949
|
+
background: var(--docs-react-demo-accent);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
.docs-react-demo-code-toggle:focus-visible {
|
|
953
|
+
outline: 2px solid var(--docs-react-demo-primary);
|
|
954
|
+
outline-offset: 2px;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/* ---- Inline reference chips (@mention, #tag) and citations ---- */
|
|
958
|
+
|
|
959
|
+
.docs-ref {
|
|
960
|
+
display: inline-flex;
|
|
961
|
+
align-items: center;
|
|
962
|
+
gap: 4px;
|
|
963
|
+
padding: 1px 8px;
|
|
964
|
+
border-radius: 999px;
|
|
965
|
+
font-family: inherit;
|
|
966
|
+
font-size: 0.85em;
|
|
967
|
+
font-weight: 500;
|
|
968
|
+
line-height: 1.5;
|
|
969
|
+
vertical-align: baseline;
|
|
970
|
+
cursor: pointer;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
.docs-ref-icon {
|
|
974
|
+
width: 12px;
|
|
975
|
+
height: 12px;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
.docs-ref-mention {
|
|
979
|
+
border: 1px solid rgba(59, 130, 246, 0.45);
|
|
980
|
+
background: rgba(59, 130, 246, 0.1);
|
|
981
|
+
color: var(--gb-info);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
.docs-ref-mention:hover {
|
|
985
|
+
background: rgba(59, 130, 246, 0.18);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
.docs-ref-tag {
|
|
989
|
+
border: 1px solid var(--gb-border);
|
|
990
|
+
background: var(--gb-muted);
|
|
991
|
+
color: var(--gb-muted-foreground);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
.docs-ref-tag:hover {
|
|
995
|
+
background: var(--gb-accent);
|
|
996
|
+
color: var(--gb-accent-foreground);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
.docs-cite {
|
|
1000
|
+
display: inline-block;
|
|
1001
|
+
padding: 1px 5px;
|
|
1002
|
+
border-radius: 5px;
|
|
1003
|
+
background: rgba(59, 130, 246, 0.14);
|
|
1004
|
+
color: var(--gb-info) !important;
|
|
1005
|
+
font-size: 10px;
|
|
1006
|
+
font-weight: 700;
|
|
1007
|
+
line-height: 1.4;
|
|
1008
|
+
vertical-align: super;
|
|
1009
|
+
text-decoration: none !important;
|
|
1010
|
+
cursor: pointer;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
.docs-cite:hover {
|
|
1014
|
+
background: rgba(59, 130, 246, 0.22);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
.docs-sources {
|
|
1018
|
+
display: flex;
|
|
1019
|
+
flex-wrap: wrap;
|
|
1020
|
+
gap: 8px;
|
|
1021
|
+
margin-top: 16px;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
.docs-source-chip {
|
|
1025
|
+
display: inline-flex;
|
|
1026
|
+
align-items: center;
|
|
1027
|
+
gap: 6px;
|
|
1028
|
+
padding: 4px 12px;
|
|
1029
|
+
border: 1px solid var(--gb-border);
|
|
1030
|
+
border-radius: 999px;
|
|
1031
|
+
font-size: 12.5px;
|
|
1032
|
+
font-weight: 500;
|
|
1033
|
+
color: var(--gb-muted-foreground) !important;
|
|
1034
|
+
text-decoration: none !important;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
.docs-source-chip:hover {
|
|
1038
|
+
background: var(--gb-accent);
|
|
1039
|
+
color: var(--gb-accent-foreground) !important;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
.docs-source-favicon {
|
|
1043
|
+
width: 14px;
|
|
1044
|
+
height: 14px;
|
|
1045
|
+
border-radius: 4px;
|
|
1046
|
+
}
|