@brett_lamy/docstream 0.5.4 → 0.5.6

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 CHANGED
@@ -236,6 +236,21 @@ plugin validates the mount boundary, only edits existing files, and notifies
236
236
  Vite after a save so the preview reloads through Vite's normal module pipeline.
237
237
  Set `writable: false` on a mount when a documentation site should only render.
238
238
 
239
+ The Vite endpoints exist only during development. For a deployed static site,
240
+ bundle the referenced modules and use the read-only production client:
241
+
242
+ ```tsx
243
+ import { createBundledSourceClient } from "@brett_lamy/docstream"
244
+ import * as ButtonModule from "./components/Button"
245
+
246
+ const client = createBundledSourceClient({
247
+ "ui:Button.tsx": ButtonModule,
248
+ })
249
+ ```
250
+
251
+ Entries may also be lazy import functions. Keys can be `mount:path` (recommended)
252
+ or just `path` when names cannot collide.
253
+
239
254
  ## Assets and OpenAPI Specs
240
255
 
241
256
  Relative image and OpenAPI spec paths can be resolved against an asset base:
@@ -258,6 +273,7 @@ You can also resolve paths yourself with `resolveAsset`.
258
273
  - `OpenApiOperation`: Renders a parsed OpenAPI operation block.
259
274
  - `SourcePreview`: Imports and renders a mounted component or CSF story export.
260
275
  - `createViteSourceClient`: Reads, writes, and imports files exposed by the Vite plugin.
276
+ - `createBundledSourceClient`: Imports bundled source modules in read-only production builds.
261
277
 
262
278
  ### Parser and Serializer
263
279
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brett_lamy/docstream",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
4
4
  "description": "GitBook-aware readonly markdown and AI stream renderer.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -72,6 +72,7 @@
72
72
  "@brett_lamy/viz-engine": ">=0.2.0",
73
73
  "framer-motion": "^13.1.1",
74
74
  "hast-util-to-html": "^9.0.5",
75
+ "katex": "^0.18.5",
75
76
  "lowlight": "^3.3.0",
76
77
  "lucide-react": "^1.17.0",
77
78
  "mermaid": "^11.15.0",
@@ -1,5 +1,6 @@
1
1
  import { useState, type ReactNode } from "react"
2
2
  import { LayoutGroup, motion } from "framer-motion"
3
+ import "katex/dist/katex.min.css"
3
4
  import {
4
5
  AlertTriangle,
5
6
  CheckCircle2,
@@ -18,11 +19,18 @@ import { OpenApiOperation } from "../openapi/OpenApiOperation"
18
19
  import { Mermaid } from "./Mermaid"
19
20
  import { HighlightedCode } from "./HighlightedCode"
20
21
  import { CitationSources, InlineReference } from "./reference"
22
+ import { renderMathToHtml } from "./math"
23
+
24
+ function MathBlock({ formula }: { formula: string }) {
25
+ return <div className="docs-math" dangerouslySetInnerHTML={{ __html: renderMathToHtml(formula) }} />
26
+ }
21
27
 
22
28
  export interface LivePreviewProps {
23
29
  files: Record<string, string>
24
30
  entry: string
25
31
  title: string
32
+ collapsedCodeLines?: number
33
+ expandedCodeLines?: number
26
34
  }
27
35
 
28
36
  export type LivePreviewRenderer = (props: LivePreviewProps) => ReactNode
@@ -140,13 +148,23 @@ function BlockView({ block, liveRenderer, sourceRenderer }: { block: Block } & R
140
148
  )
141
149
  }
142
150
  case "code":
143
- if (block.language === "mermaid") return <Mermaid code={block.code} />
151
+ if (block.language === "mermaid") {
152
+ return (
153
+ <Mermaid
154
+ code={block.code}
155
+ collapsedCodeLines={block.collapsedCodeLines}
156
+ expandedCodeLines={block.expandedCodeLines}
157
+ />
158
+ )
159
+ }
144
160
  if (block.live && isReactLanguage(block.language) && liveRenderer) {
145
161
  const entry = block.entry ?? "/src/main.jsx"
146
162
  return liveRenderer({
147
163
  files: { [entry]: block.code },
148
164
  entry,
149
165
  title: block.title ?? "Live React preview",
166
+ collapsedCodeLines: block.collapsedCodeLines,
167
+ expandedCodeLines: block.expandedCodeLines,
150
168
  })
151
169
  }
152
170
  return (
@@ -327,7 +345,7 @@ function BlockView({ block, liveRenderer, sourceRenderer }: { block: Block } & R
327
345
  </table>
328
346
  )
329
347
  case "math":
330
- return <pre className="docs-math">{block.formula}</pre>
348
+ return <MathBlock formula={block.formula} />
331
349
  case "updates":
332
350
  return (
333
351
  <div className="docs-updates">
@@ -1,11 +1,26 @@
1
- import { useEffect, useRef, useState } from "react"
1
+ import { useEffect, useId, useRef, useState, type CSSProperties } from "react"
2
+ import { HighlightedCode } from "./HighlightedCode"
2
3
 
3
4
  let seq = 0
4
5
 
5
- export function Mermaid({ code }: { code: string }) {
6
+ export interface MermaidProps {
7
+ code: string
8
+ collapsedCodeLines?: number
9
+ expandedCodeLines?: number
10
+ }
11
+
12
+ function positiveLineCount(value: number | undefined, fallback: number): number {
13
+ return value !== undefined && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback
14
+ }
15
+
16
+ export function Mermaid({ code, collapsedCodeLines = 3, expandedCodeLines = 30 }: MermaidProps) {
6
17
  const [svg, setSvg] = useState<string | null>(null)
7
18
  const [error, setError] = useState<string | null>(null)
19
+ const [codeExpanded, setCodeExpanded] = useState(false)
8
20
  const idRef = useRef(`mermaid-${++seq}`)
21
+ const codePanelId = useId()
22
+ const collapsedLines = positiveLineCount(collapsedCodeLines, 3)
23
+ const expandedLines = Math.max(collapsedLines, positiveLineCount(expandedCodeLines, 30))
9
24
 
10
25
  useEffect(() => {
11
26
  let cancelled = false
@@ -23,15 +38,35 @@ export function Mermaid({ code }: { code: string }) {
23
38
  }
24
39
  }, [code])
25
40
 
26
- if (error) {
27
- return (
28
- <pre className="docs-mermaid-error">
29
- mermaid error: {error}
30
- {"\n"}
31
- {code}
32
- </pre>
33
- )
34
- }
35
- if (!svg) return <div className="docs-mermaid">Rendering diagram…</div>
36
- return <div className="docs-mermaid" dangerouslySetInnerHTML={{ __html: svg }} />
41
+ return (
42
+ <section className="docs-mermaid-preview" data-docstream-mermaid="">
43
+ {error ? (
44
+ <pre className="docs-mermaid-error">mermaid error: {error}</pre>
45
+ ) : svg ? (
46
+ <div className="docs-mermaid" dangerouslySetInnerHTML={{ __html: svg }} />
47
+ ) : (
48
+ <div className="docs-mermaid">Rendering diagram…</div>
49
+ )}
50
+ <div className="docs-react-demo-code docs-mermaid-code">
51
+ <pre
52
+ id={codePanelId}
53
+ className={codeExpanded ? "docs-react-demo-code-body docs-react-demo-code-body-expanded" : "docs-react-demo-code-body"}
54
+ style={{
55
+ "--docs-react-demo-code-lines": codeExpanded ? expandedLines : collapsedLines,
56
+ } as CSSProperties}
57
+ >
58
+ <HighlightedCode code={code} language="mermaid" lineNumbers />
59
+ </pre>
60
+ <button
61
+ type="button"
62
+ className="docs-react-demo-code-toggle"
63
+ aria-controls={codePanelId}
64
+ aria-expanded={codeExpanded}
65
+ onClick={() => setCodeExpanded((value) => !value)}
66
+ >
67
+ {codeExpanded ? "Hide Code" : "View Code"}
68
+ </button>
69
+ </div>
70
+ </section>
71
+ )
37
72
  }
@@ -0,0 +1,9 @@
1
+ import katex from "katex"
2
+
3
+ export function renderMathToHtml(formula: string): string {
4
+ return katex.renderToString(formula, {
5
+ displayMode: true,
6
+ throwOnError: false,
7
+ output: "htmlAndMathml",
8
+ })
9
+ }
@@ -68,6 +68,10 @@ export interface CodeBlockNode {
68
68
  live?: boolean
69
69
  /** Project-relative entry file used by a live preview. */
70
70
  entry?: string | null
71
+ /** Source lines visible before an expandable code preview is opened. */
72
+ collapsedCodeLines?: number
73
+ /** Maximum source lines visible before expanded code scrolls. */
74
+ expandedCodeLines?: number
71
75
  }
72
76
 
73
77
  export interface HintNode {
@@ -71,6 +71,12 @@ function booleanAttr(attrs: Record<string, string>, key: string): boolean | unde
71
71
  return attrs[key] !== "false"
72
72
  }
73
73
 
74
+ function positiveNumberAttr(attrs: Record<string, string>, key: string): number | undefined {
75
+ if (!(key in attrs)) return undefined
76
+ const value = Number(attrs[key])
77
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : undefined
78
+ }
79
+
74
80
  interface TemplateTag {
75
81
  name: string
76
82
  attrs: Record<string, string>
@@ -447,6 +453,12 @@ export function parseBlocks(lines: string[]): Block[] {
447
453
  code: code.join("\n"),
448
454
  ...(booleanAttr(attrs, "live") === undefined ? {} : { live: booleanAttr(attrs, "live") }),
449
455
  ...(attrs.entry ? { entry: attrs.entry } : {}),
456
+ ...(positiveNumberAttr(attrs, "collapsedCodeLines") === undefined
457
+ ? {}
458
+ : { collapsedCodeLines: positiveNumberAttr(attrs, "collapsedCodeLines") }),
459
+ ...(positiveNumberAttr(attrs, "expandedCodeLines") === undefined
460
+ ? {}
461
+ : { expandedCodeLines: positiveNumberAttr(attrs, "expandedCodeLines") }),
450
462
  })
451
463
  continue
452
464
  }
@@ -39,6 +39,8 @@ function serializeBlock(b: Block): string {
39
39
  !b.lineNumbers ? `lineNumbers="false"` : "",
40
40
  b.live ? `live="true"` : "",
41
41
  b.entry ? `entry="${b.entry}"` : "",
42
+ b.collapsedCodeLines ? `collapsedCodeLines="${b.collapsedCodeLines}"` : "",
43
+ b.expandedCodeLines ? `expandedCodeLines="${b.expandedCodeLines}"` : "",
42
44
  ].filter(Boolean)
43
45
  const info = [b.language ?? "", ...attrs].filter(Boolean).join(" ")
44
46
  return `\`\`\`${info}\n${b.code}\n\`\`\``
package/src/index.ts CHANGED
@@ -36,8 +36,9 @@ export type {
36
36
  ReactDemoProps,
37
37
  } from "./playground"
38
38
  export { resolveAsset, setAssetBase } from "./assets"
39
- export { createViteSourceClient, SourcePreview } from "./source"
39
+ export { createBundledSourceClient, createViteSourceClient, SourcePreview } from "./source"
40
40
  export type {
41
+ BundledSourceModule,
41
42
  SourceFileSnapshot,
42
43
  SourceModule,
43
44
  SourcePreviewProps,
@@ -29,8 +29,14 @@ export function PlaygroundStreamdown({
29
29
  () => parseMarkdown(isStreaming ? trimPartialInlineToken(content) : content),
30
30
  [content, isStreaming]
31
31
  )
32
- const liveRenderer: LivePreviewRenderer = ({ files, entry, title }) => (
33
- <ReactCodePreview files={files} entry={entry} title={title} />
32
+ const liveRenderer: LivePreviewRenderer = ({ files, entry, title, collapsedCodeLines, expandedCodeLines }) => (
33
+ <ReactCodePreview
34
+ files={files}
35
+ entry={entry}
36
+ title={title}
37
+ collapsedCodeLines={collapsedCodeLines}
38
+ expandedCodeLines={expandedCodeLines}
39
+ />
34
40
  )
35
41
 
36
42
  return (
@@ -1,6 +1,8 @@
1
1
  import type { SourceRefNode } from "../gitbook/ast"
2
2
  import type { SourceFileSnapshot, SourceReferenceClient, SourceModule } from "./types"
3
3
 
4
+ export type BundledSourceModule = SourceModule | (() => SourceModule | Promise<SourceModule>)
5
+
4
6
  function params(reference: SourceRefNode): URLSearchParams {
5
7
  return new URLSearchParams({
6
8
  mount: reference.mount,
@@ -38,3 +40,20 @@ export function createViteSourceClient(apiBase = "/@docstream"): SourceReference
38
40
  },
39
41
  }
40
42
  }
43
+
44
+ /** Create a read-only source client for modules included in a production bundle. */
45
+ export function createBundledSourceClient(modules: Record<string, BundledSourceModule>): SourceReferenceClient {
46
+ return {
47
+ async read() {
48
+ throw new Error("Source text is unavailable in a bundled source client.")
49
+ },
50
+ async write() {
51
+ throw new Error("Bundled source previews are read-only.")
52
+ },
53
+ async importModule(reference) {
54
+ const entry = modules[`${reference.mount}:${reference.path}`] ?? modules[reference.path]
55
+ if (!entry) throw new Error(`No bundled source module found for ${reference.mount}:${reference.path}`)
56
+ return typeof entry === "function" ? entry() : entry
57
+ },
58
+ }
59
+ }
@@ -1,4 +1,5 @@
1
- export { createViteSourceClient } from "./client"
1
+ export { createBundledSourceClient, createViteSourceClient } from "./client"
2
+ export type { BundledSourceModule } from "./client"
2
3
  export { SourcePreview } from "./SourcePreview"
3
4
  export type { SourcePreviewProps } from "./SourcePreview"
4
5
  export type {
package/src/styles.css CHANGED
@@ -123,15 +123,29 @@
123
123
  padding-left: 1.5em;
124
124
  }
125
125
 
126
- .docs-article .docs-tasklist {
126
+ .docs-tasklist {
127
127
  list-style: none;
128
128
  padding-left: 0.2em;
129
129
  }
130
130
 
131
- .docs-article .docs-tasklist li {
131
+ .docs-tasklist li {
132
132
  display: flex;
133
133
  gap: 8px;
134
- align-items: baseline;
134
+ align-items: flex-start;
135
+ }
136
+
137
+ .docs-tasklist li > input {
138
+ flex: 0 0 auto;
139
+ margin-top: 0.45em;
140
+ }
141
+
142
+ .docs-tasklist .docs-blocks-inline {
143
+ flex: 1;
144
+ min-width: 0;
145
+ }
146
+
147
+ .docs-tasklist .docs-blocks-inline > p:first-child {
148
+ margin: 0;
135
149
  }
136
150
 
137
151
  .docs-article blockquote {
@@ -489,10 +503,12 @@
489
503
  border-radius: 12px;
490
504
  padding: 14px 16px;
491
505
  background: var(--gb-muted);
492
- font-family: ui-monospace, Menlo, monospace;
493
- font-size: 13.5px;
494
506
  text-align: center;
495
- white-space: pre-wrap;
507
+ overflow-x: auto;
508
+ }
509
+
510
+ .docs-math .katex-display {
511
+ margin: 0;
496
512
  }
497
513
 
498
514
  .docs-updates {
@@ -547,12 +563,16 @@
547
563
  margin-top: 4px;
548
564
  }
549
565
 
566
+ .docs-mermaid-preview {
567
+ border: 1px solid var(--gb-border);
568
+ border-radius: 12px;
569
+ overflow: hidden;
570
+ }
571
+
550
572
  .docs-mermaid {
551
573
  display: flex;
552
574
  justify-content: center;
553
575
  padding: 16px;
554
- border: 1px solid var(--gb-border);
555
- border-radius: 12px;
556
576
  background: var(--background, #ffffff);
557
577
  }
558
578
 
@@ -561,14 +581,17 @@
561
581
  }
562
582
 
563
583
  .docs-mermaid-error {
584
+ margin: 0;
564
585
  font-size: 12px;
565
586
  color: var(--gb-danger);
566
- border: 1px solid var(--gb-border);
567
- border-radius: 8px;
568
587
  padding: 10px;
569
588
  white-space: pre-wrap;
570
589
  }
571
590
 
591
+ .docs-mermaid-code {
592
+ border-top: 1px solid var(--gb-border);
593
+ }
594
+
572
595
  .docs-inline-img {
573
596
  display: inline-block;
574
597
  vertical-align: middle;