@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brett_lamy/docstream",
3
- "version": "0.3.7",
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={parseMarkdown(markdown).children} liveRenderer={liveRenderer} sourceRenderer={sourceRenderer} />
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
+ }
@@ -23,7 +23,29 @@ export interface InlineImageNode {
23
23
  link?: string
24
24
  }
25
25
 
26
- export type Inline = TextNode | InlineImageNode
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 => ({
@@ -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"
@@ -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) => t.replace(/([*_~`[\]\\])/g, "\\$1")
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.map((n) => (n.type === "text" ? n.text : (n.alt ?? ""))).join("")
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
  }
@@ -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
- const def = line.match(/^\[([^\]]+)\]:\s*(\S+)\s*$/)
125
- if (def) refDefinitions.set(def[1].toLowerCase(), def[2])
126
- else content.push(line)
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 { type: "doc", children: parseBlocks(content) }
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[] {
@@ -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
- return serializeBlocks(doc.children).trimEnd() + "\n"
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 { parseInline, plainText, refDefinitions, serializeInline } from "./gitbook/inline"
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(() => parseMarkdown(content), [content])
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
  }
@@ -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(() => parseMarkdown(content), [content])
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(--gb-border);
805
+ border: 1px solid var(--docs-react-demo-border);
797
806
  border-radius: 12px;
798
- background: var(--gb-panel);
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(--gb-border);
808
- color: var(--gb-panel-foreground);
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(--gb-muted-foreground);
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(--gb-success);
831
+ color: var(--docs-react-demo-success);
823
832
  }
824
833
 
825
834
  .docs-react-demo-status-error {
826
- color: var(--gb-danger);
835
+ color: var(--docs-react-demo-danger);
827
836
  }
828
837
 
829
838
  .docs-react-demo-run {
830
- border: 1px solid var(--gb-border);
839
+ border: 1px solid var(--docs-react-demo-border);
831
840
  border-radius: 6px;
832
841
  padding: 4px 9px;
833
- color: var(--gb-panel-foreground);
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(--gb-danger);
864
- background: var(--gb-code-bg);
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
+ }