@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 +5 -3
- package/src/docs/DocsRenderer.tsx +13 -1
- package/src/docs/HighlightedCode.tsx +42 -55
- package/src/docs/Mermaid.tsx +48 -13
- package/src/gitbook/ast.ts +4 -0
- package/src/gitbook/parse.ts +12 -0
- package/src/gitbook/serialize.ts +2 -0
- package/src/playground/PlaygroundStreamdown.tsx +8 -2
- package/src/styles.css +28 -7
package/package.json
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brett_lamy/docstream",
|
|
3
|
-
"version": "0.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
|
-
"
|
|
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")
|
|
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 {
|
|
3
|
-
import { toHtml } from "hast-util-to-html"
|
|
1
|
+
import { useEffect, useMemo, useState } from "react"
|
|
2
|
+
import { parse } from "gpu-lexer"
|
|
4
3
|
|
|
5
|
-
|
|
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
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
18
|
-
|
|
19
|
-
const normalized = language.toLowerCase()
|
|
20
|
-
return LANGUAGE_ALIASES[normalized] ?? normalized
|
|
15
|
+
function escapeHtml(value: string) {
|
|
16
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/\"/g, """)
|
|
21
17
|
}
|
|
22
18
|
|
|
23
|
-
function
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
}
|
package/src/docs/Mermaid.tsx
CHANGED
|
@@ -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
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
mermaid error: {error}
|
|
30
|
-
|
|
31
|
-
{
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
}
|
package/src/gitbook/ast.ts
CHANGED
|
@@ -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 {
|
package/src/gitbook/parse.ts
CHANGED
|
@@ -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
|
}
|
package/src/gitbook/serialize.ts
CHANGED
|
@@ -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
|
|
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-
|
|
126
|
+
.docs-tasklist {
|
|
127
127
|
list-style: none;
|
|
128
128
|
padding-left: 0.2em;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
.docs-
|
|
131
|
+
.docs-tasklist li {
|
|
132
132
|
display: flex;
|
|
133
133
|
gap: 8px;
|
|
134
|
-
align-items:
|
|
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;
|