@brett_lamy/docstream 0.5.5 → 0.5.7

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,7 +1,10 @@
1
1
  {
2
2
  "name": "@brett_lamy/docstream",
3
- "version": "0.5.5",
3
+ "version": "0.5.7",
4
4
  "description": "GitBook-aware readonly markdown and AI stream renderer.",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
5
8
  "type": "module",
6
9
  "scripts": {
7
10
  "typecheck": "tsc -p tsconfig.json --noEmit",
@@ -71,9 +74,8 @@
71
74
  "dependencies": {
72
75
  "@brett_lamy/viz-engine": ">=0.2.0",
73
76
  "framer-motion": "^13.1.1",
74
- "hast-util-to-html": "^9.0.5",
75
77
  "katex": "^0.18.5",
76
- "lowlight": "^3.3.0",
78
+ "gpu-lexer": "^0.0.2",
77
79
  "lucide-react": "^1.17.0",
78
80
  "mermaid": "^11.15.0",
79
81
  "rrweb": "2.0.0-alpha.20",
@@ -29,6 +29,8 @@ export interface LivePreviewProps {
29
29
  files: Record<string, string>
30
30
  entry: string
31
31
  title: string
32
+ collapsedCodeLines?: number
33
+ expandedCodeLines?: number
32
34
  }
33
35
 
34
36
  export type LivePreviewRenderer = (props: LivePreviewProps) => ReactNode
@@ -146,13 +148,23 @@ function BlockView({ block, liveRenderer, sourceRenderer }: { block: Block } & R
146
148
  )
147
149
  }
148
150
  case "code":
149
- 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
+ }
150
160
  if (block.live && isReactLanguage(block.language) && liveRenderer) {
151
161
  const entry = block.entry ?? "/src/main.jsx"
152
162
  return liveRenderer({
153
163
  files: { [entry]: block.code },
154
164
  entry,
155
165
  title: block.title ?? "Live React preview",
166
+ collapsedCodeLines: block.collapsedCodeLines,
167
+ expandedCodeLines: block.expandedCodeLines,
156
168
  })
157
169
  }
158
170
  return (
@@ -1,71 +1,58 @@
1
- import { useMemo } from "react"
2
- import { common, createLowlight } from "lowlight"
3
- import { toHtml } from "hast-util-to-html"
1
+ import { useEffect, useMemo, useState } from "react"
2
+ import { parse } from "gpu-lexer"
4
3
 
5
- const lowlight = createLowlight(common)
4
+ type Span = {
5
+ type: "plain" | "comment" | "string" | "number" | "keyword" | "type" | "function" | "constant" | "operator"
6
+ start: number
7
+ end: number
8
+ }
6
9
 
7
- const LANGUAGE_ALIASES: Record<string, string> = {
8
- js: "javascript",
9
- jsx: "javascript",
10
- sh: "bash",
11
- shell: "bash",
12
- ts: "typescript",
13
- tsx: "typescript",
14
- yml: "yaml",
10
+ const COLORS: Record<Span["type"], string | undefined> = {
11
+ plain: undefined, comment: "#6B6B78", string: "#A5D6A7", number: "#F78C6C",
12
+ keyword: "#C792EA", type: "#FFCB6B", function: "#82AAFF", constant: "#F07178", operator: "#89DDFF",
15
13
  }
16
14
 
17
- function normalizeLanguage(language: string | null) {
18
- if (!language) return null
19
- const normalized = language.toLowerCase()
20
- return LANGUAGE_ALIASES[normalized] ?? normalized
15
+ function escapeHtml(value: string) {
16
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/\"/g, "&quot;")
21
17
  }
22
18
 
23
- function highlightedHtml(code: string, language: string | null) {
24
- const normalized = normalizeLanguage(language)
25
- if (normalized && lowlight.registered(normalized)) {
26
- try {
27
- return toHtml(lowlight.highlight(normalized, code))
28
- } catch {
29
- /* fall through to plain */
30
- }
31
- }
32
- return null
19
+ function spansToHtml(code: string, spans: Span[]) {
20
+ let cursor = 0
21
+ return spans.map((span) => {
22
+ const prefix = escapeHtml(code.slice(cursor, span.start))
23
+ const text = escapeHtml(code.slice(span.start, span.end))
24
+ cursor = span.end
25
+ const color = COLORS[span.type]
26
+ return `${prefix}${color ? `<span style="color:${color}">${text}</span>` : text}`
27
+ }).join("") + escapeHtml(code.slice(cursor))
33
28
  }
34
29
 
35
- export function HighlightedCode({
36
- code,
37
- language,
38
- lineNumbers = false,
39
- }: {
30
+ export function HighlightedCode({ code, language: _language, lineNumbers = false }: {
40
31
  code: string
41
32
  language: string | null
42
33
  lineNumbers?: boolean
43
34
  }) {
44
35
  const lines = useMemo(() => code.split("\n"), [code])
45
- const html = useMemo(() => {
46
- if (lineNumbers) {
47
- return lines.map((line) => highlightedHtml(line || " ", language))
48
- }
49
- return highlightedHtml(code, language)
50
- }, [code, language, lineNumbers, lines])
36
+ const [spans, setSpans] = useState<Span[] | null>(null)
37
+
38
+ useEffect(() => {
39
+ let cancelled = false
40
+ setSpans(null)
41
+ parse(code).then((result) => { if (!cancelled) setSpans(result) }).catch(() => { if (!cancelled) setSpans([]) })
42
+ return () => { cancelled = true }
43
+ }, [code])
51
44
 
52
- if (lineNumbers) {
53
- return (
54
- <code className="hljs">
55
- {(html as Array<string | null>).map((lineHtml, index) => (
56
- <span key={index} className="docs-code-line">
57
- <span className="docs-code-lineno">{index + 1}</span>
58
- {lineHtml === null ? (
59
- <span>{lines[index]}</span>
60
- ) : (
61
- <span dangerouslySetInnerHTML={{ __html: lineHtml }} />
62
- )}
63
- </span>
64
- ))}
65
- </code>
66
- )
67
- }
45
+ const html = useMemo(() => spans && spans.length ? spansToHtml(code, spans) : null, [code, spans])
46
+ if (!lineNumbers) return html === null ? <code>{code}</code> : <code dangerouslySetInnerHTML={{ __html: html }} />
68
47
 
69
- if (html === null) return <code>{code}</code>
70
- return <code className="hljs" dangerouslySetInnerHTML={{ __html: html as string }} />
48
+ return <code>{lines.map((line, index) => {
49
+ const start = lines.slice(0, index).reduce((total, value) => total + value.length + 1, 0)
50
+ const lineSpans = spans?.filter((span) => span.start >= start && span.end <= start + line.length)
51
+ .map((span) => ({ ...span, start: span.start - start, end: span.end - start }))
52
+ const lineHtml = lineSpans?.length ? spansToHtml(line, lineSpans) : null
53
+ return <span key={index} className="docs-code-line">
54
+ <span className="docs-code-lineno">{index + 1}</span>
55
+ {lineHtml === null ? <span>{line}</span> : <span dangerouslySetInnerHTML={{ __html: lineHtml }} />}
56
+ </span>
57
+ })}</code>
71
58
  }
@@ -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
  }
@@ -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\`\`\``
@@ -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 (
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 {
@@ -549,12 +563,16 @@
549
563
  margin-top: 4px;
550
564
  }
551
565
 
566
+ .docs-mermaid-preview {
567
+ border: 1px solid var(--gb-border);
568
+ border-radius: 12px;
569
+ overflow: hidden;
570
+ }
571
+
552
572
  .docs-mermaid {
553
573
  display: flex;
554
574
  justify-content: center;
555
575
  padding: 16px;
556
- border: 1px solid var(--gb-border);
557
- border-radius: 12px;
558
576
  background: var(--background, #ffffff);
559
577
  }
560
578
 
@@ -563,14 +581,17 @@
563
581
  }
564
582
 
565
583
  .docs-mermaid-error {
584
+ margin: 0;
566
585
  font-size: 12px;
567
586
  color: var(--gb-danger);
568
- border: 1px solid var(--gb-border);
569
- border-radius: 8px;
570
587
  padding: 10px;
571
588
  white-space: pre-wrap;
572
589
  }
573
590
 
591
+ .docs-mermaid-code {
592
+ border-top: 1px solid var(--gb-border);
593
+ }
594
+
574
595
  .docs-inline-img {
575
596
  display: inline-block;
576
597
  vertical-align: middle;