@brett_lamy/docstream 1.0.0 → 1.1.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/README.md +102 -0
- package/package.json +5 -1
- package/src/demo/DemoViewer.tsx +705 -0
- package/src/demo/context.ts +13 -0
- package/src/demo/glob.ts +188 -0
- package/src/demo/index.ts +15 -0
- package/src/demo/markdown.ts +102 -0
- package/src/demo/types.ts +53 -0
- package/src/docs/CollapsibleCode.tsx +54 -0
- package/src/docs/DocsRenderer.tsx +147 -36
- package/src/docs/PageActions.tsx +219 -0
- package/src/docs/controls.tsx +138 -0
- package/src/docs/copy.tsx +82 -0
- package/src/docs/tabs-sync.ts +61 -0
- package/src/gitbook/ast.ts +35 -0
- package/src/gitbook/index.ts +1 -1
- package/src/gitbook/parse.ts +48 -1
- package/src/gitbook/serialize.ts +16 -1
- package/src/index.ts +35 -2
- package/src/playground/PlaygroundStreamdown.tsx +14 -3
- package/src/playground/ReactCodePreview.tsx +8 -37
- package/src/streamdown.tsx +21 -3
- package/src/styles.css +917 -1
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/* "Copy page ▾": copy the page as Markdown (with {% demo %} blocks resolved into their real files),
|
|
2
|
+
view it as Markdown, or open it in ChatGPT / Claude. */
|
|
3
|
+
import { useEffect, useId, useRef, useState, type KeyboardEvent, type ReactNode } from "react"
|
|
4
|
+
import { AnimatePresence, motion, useReducedMotion } from "framer-motion"
|
|
5
|
+
import { ArrowUpRight, ChevronDown, FileText } from "lucide-react"
|
|
6
|
+
|
|
7
|
+
import { useDemoResolver } from "../demo/context"
|
|
8
|
+
import { resolveDemosToMarkdown, type ResolveDemosOptions } from "../demo/markdown"
|
|
9
|
+
import type { DemoResolver } from "../demo/types"
|
|
10
|
+
import { CopyIcon, useCopied } from "./copy"
|
|
11
|
+
|
|
12
|
+
export type DocPageAction = "copy" | "view" | "chatgpt" | "claude"
|
|
13
|
+
|
|
14
|
+
export interface DocPageActionsOptions {
|
|
15
|
+
/**
|
|
16
|
+
* Public URL of this page as Markdown (e.g. `/md/button.md`). "View as Markdown" opens it and the
|
|
17
|
+
* ChatGPT / Claude prompts reference it. Without it, "View" opens the resolved Markdown as a
|
|
18
|
+
* blob and the prompts reference `pageUrl` (default: the current location).
|
|
19
|
+
*/
|
|
20
|
+
markdownUrl?: string
|
|
21
|
+
pageUrl?: string
|
|
22
|
+
/** The question handed to ChatGPT / Claude. */
|
|
23
|
+
prompt?: (url: string) => string
|
|
24
|
+
/** Which menu entries to show, in order. Defaults to all four. */
|
|
25
|
+
actions?: DocPageAction[]
|
|
26
|
+
/** Final touch on the copied Markdown (e.g. append a "Source:" footer). */
|
|
27
|
+
transform?: (markdown: string) => string
|
|
28
|
+
/** Options for resolving `{% demo %}` blocks. */
|
|
29
|
+
demoOptions?: ResolveDemosOptions
|
|
30
|
+
/** Overrides the resolver from context. */
|
|
31
|
+
resolver?: DemoResolver
|
|
32
|
+
className?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface DocPageActionsProps extends DocPageActionsOptions {
|
|
36
|
+
/** The page's Markdown source (or a function producing it on demand). */
|
|
37
|
+
markdown: string | (() => string | Promise<string>)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const OpenAIGlyph = () => (
|
|
41
|
+
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" aria-hidden="true">
|
|
42
|
+
{[0, 60, 120].map((a) => (
|
|
43
|
+
<rect key={a} x="8.6" y="2.6" width="6.8" height="18.8" rx="3.4" transform={`rotate(${a} 12 12)`} />
|
|
44
|
+
))}
|
|
45
|
+
</svg>
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
const AnthropicGlyph = () => (
|
|
49
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
50
|
+
<path d="M17.3 3.54h-3.67l6.7 16.92H24zM6.7 3.54 0 20.46h3.74l1.37-3.55h7.01l1.37 3.55h3.74L10.54 3.54zm-.37 10.22 2.29-5.94 2.29 5.94z" />
|
|
51
|
+
</svg>
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
const defaultPrompt = (url: string) => `Read ${url}, I want to ask questions about it.`
|
|
55
|
+
|
|
56
|
+
interface MenuItem {
|
|
57
|
+
id: DocPageAction
|
|
58
|
+
icon: ReactNode
|
|
59
|
+
label: string
|
|
60
|
+
hint: string
|
|
61
|
+
href?: string
|
|
62
|
+
run?: () => void
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Resolves the page Markdown (demos inlined) for copying or exporting. */
|
|
66
|
+
async function pageMarkdown(props: DocPageActionsProps, resolver: DemoResolver | undefined) {
|
|
67
|
+
const source = typeof props.markdown === "function" ? await props.markdown() : props.markdown
|
|
68
|
+
const resolved = await resolveDemosToMarkdown(source, resolver, props.demoOptions)
|
|
69
|
+
return props.transform ? props.transform(resolved) : resolved
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A shadcn-style split button: "Copy page" plus a menu of Markdown / AI actions. */
|
|
73
|
+
export function DocPageActions(props: DocPageActionsProps) {
|
|
74
|
+
const resolver = useDemoResolver(props.resolver)
|
|
75
|
+
const { copied, copy } = useCopied()
|
|
76
|
+
const [open, setOpen] = useState(false)
|
|
77
|
+
const reduced = useReducedMotion()
|
|
78
|
+
const root = useRef<HTMLDivElement | null>(null)
|
|
79
|
+
const trigger = useRef<HTMLButtonElement | null>(null)
|
|
80
|
+
const menuId = useId()
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (!open) return
|
|
84
|
+
const onDown = (event: PointerEvent) => {
|
|
85
|
+
if (!root.current?.contains(event.target as Node)) setOpen(false)
|
|
86
|
+
}
|
|
87
|
+
document.addEventListener("pointerdown", onDown)
|
|
88
|
+
// Focus the first item once the menu is in the DOM.
|
|
89
|
+
const frame = requestAnimationFrame(() => root.current?.querySelector<HTMLElement>("[role=menuitem]")?.focus())
|
|
90
|
+
return () => {
|
|
91
|
+
document.removeEventListener("pointerdown", onDown)
|
|
92
|
+
cancelAnimationFrame(frame)
|
|
93
|
+
}
|
|
94
|
+
}, [open])
|
|
95
|
+
|
|
96
|
+
const copyPage = () => copy(() => pageMarkdown(props, resolver))
|
|
97
|
+
const here = typeof location === "undefined" ? "" : location.href
|
|
98
|
+
const target = props.markdownUrl ? absolute(props.markdownUrl) : props.pageUrl ?? here
|
|
99
|
+
const ask = (props.prompt ?? defaultPrompt)(target)
|
|
100
|
+
|
|
101
|
+
const viewMarkdown = async () => {
|
|
102
|
+
const text = await pageMarkdown(props, resolver)
|
|
103
|
+
const url = URL.createObjectURL(new Blob([text], { type: "text/plain;charset=utf-8" }))
|
|
104
|
+
window.open(url, "_blank", "noopener")
|
|
105
|
+
setTimeout(() => URL.revokeObjectURL(url), 60_000)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const all: MenuItem[] = [
|
|
109
|
+
{ id: "copy", icon: <CopyIcon copied={false} />, label: "Copy as Markdown", hint: "Copy this page for LLMs", run: () => void copyPage() },
|
|
110
|
+
props.markdownUrl
|
|
111
|
+
? { id: "view", icon: <FileText width={15} height={15} aria-hidden="true" />, label: "View as Markdown", hint: "Open the page as plain text", href: props.markdownUrl }
|
|
112
|
+
: { id: "view", icon: <FileText width={15} height={15} aria-hidden="true" />, label: "View as Markdown", hint: "Open the page as plain text", run: () => void viewMarkdown() },
|
|
113
|
+
{ id: "chatgpt", icon: <OpenAIGlyph />, label: "Open in ChatGPT", hint: "Ask questions about this page", href: `https://chatgpt.com/?hints=search&q=${encodeURIComponent(ask)}` },
|
|
114
|
+
{ id: "claude", icon: <AnthropicGlyph />, label: "Open in Claude", hint: "Ask questions about this page", href: `https://claude.ai/new?q=${encodeURIComponent(ask)}` },
|
|
115
|
+
]
|
|
116
|
+
const wanted = props.actions ?? ["copy", "view", "chatgpt", "claude"]
|
|
117
|
+
const items = wanted.map((id) => all.find((item) => item.id === id)).filter((item): item is MenuItem => !!item)
|
|
118
|
+
|
|
119
|
+
const close = (refocus = true) => {
|
|
120
|
+
setOpen(false)
|
|
121
|
+
if (refocus) trigger.current?.focus()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const onMenuKey = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
125
|
+
const entries = [...event.currentTarget.querySelectorAll<HTMLElement>("[role=menuitem]")]
|
|
126
|
+
const index = entries.indexOf(document.activeElement as HTMLElement)
|
|
127
|
+
let next = -1
|
|
128
|
+
if (event.key === "ArrowDown") next = (index + 1) % entries.length
|
|
129
|
+
else if (event.key === "ArrowUp") next = (index - 1 + entries.length) % entries.length
|
|
130
|
+
else if (event.key === "Home") next = 0
|
|
131
|
+
else if (event.key === "End") next = entries.length - 1
|
|
132
|
+
else if (event.key === "Escape") {
|
|
133
|
+
event.preventDefault()
|
|
134
|
+
close()
|
|
135
|
+
return
|
|
136
|
+
} else if (event.key === "Tab") {
|
|
137
|
+
close(false)
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
if (next < 0) return
|
|
141
|
+
event.preventDefault()
|
|
142
|
+
entries[next]?.focus()
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<div ref={root} className={props.className ? `docs-page-actions ${props.className}` : "docs-page-actions"}>
|
|
147
|
+
<button type="button" className="docs-page-actions-main" onClick={() => void copyPage()} aria-label="Copy page as Markdown">
|
|
148
|
+
<CopyIcon copied={copied} size={13} />
|
|
149
|
+
<span aria-live="polite">{copied ? "Copied" : "Copy page"}</span>
|
|
150
|
+
</button>
|
|
151
|
+
<button
|
|
152
|
+
ref={trigger}
|
|
153
|
+
type="button"
|
|
154
|
+
className="docs-page-actions-more"
|
|
155
|
+
aria-label="More page actions"
|
|
156
|
+
aria-haspopup="menu"
|
|
157
|
+
aria-expanded={open}
|
|
158
|
+
aria-controls={open ? menuId : undefined}
|
|
159
|
+
onClick={() => setOpen((value) => !value)}
|
|
160
|
+
onKeyDown={(event) => {
|
|
161
|
+
if (event.key === "ArrowDown" && !open) {
|
|
162
|
+
event.preventDefault()
|
|
163
|
+
setOpen(true)
|
|
164
|
+
}
|
|
165
|
+
}}
|
|
166
|
+
>
|
|
167
|
+
<ChevronDown width={13} height={13} strokeWidth={2.4} aria-hidden="true" className={open ? "docs-page-actions-caret-open" : undefined} />
|
|
168
|
+
</button>
|
|
169
|
+
<AnimatePresence>
|
|
170
|
+
{open ? (
|
|
171
|
+
<motion.div
|
|
172
|
+
id={menuId}
|
|
173
|
+
role="menu"
|
|
174
|
+
aria-label="Page actions"
|
|
175
|
+
className="docs-page-actions-menu"
|
|
176
|
+
onKeyDown={onMenuKey}
|
|
177
|
+
initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.95, y: -4 }}
|
|
178
|
+
animate={{ opacity: 1, scale: 1, y: 0, transition: reduced ? { duration: 0.1 } : { type: "spring", bounce: 0.2, duration: 0.32 } }}
|
|
179
|
+
exit={{ opacity: 0, scale: reduced ? 1 : 0.97, transition: { duration: 0.12 } }}
|
|
180
|
+
style={{ transformOrigin: "top right" }}
|
|
181
|
+
>
|
|
182
|
+
{items.map((item) => {
|
|
183
|
+
const body = (
|
|
184
|
+
<>
|
|
185
|
+
<span className="docs-page-actions-icon">{item.icon}</span>
|
|
186
|
+
<span className="docs-page-actions-text">
|
|
187
|
+
<span className="docs-page-actions-label">
|
|
188
|
+
{item.label}
|
|
189
|
+
{item.href ? <ArrowUpRight width={12} height={12} aria-hidden="true" /> : null}
|
|
190
|
+
</span>
|
|
191
|
+
<span className="docs-page-actions-hint">{item.hint}</span>
|
|
192
|
+
</span>
|
|
193
|
+
</>
|
|
194
|
+
)
|
|
195
|
+
return item.href ? (
|
|
196
|
+
<a key={item.id} role="menuitem" tabIndex={-1} className="docs-page-actions-item" href={item.href} target="_blank" rel="noreferrer" onClick={() => close(false)}>
|
|
197
|
+
{body}
|
|
198
|
+
</a>
|
|
199
|
+
) : (
|
|
200
|
+
<button key={item.id} type="button" role="menuitem" tabIndex={-1} className="docs-page-actions-item" onClick={() => { item.run?.(); close() }}>
|
|
201
|
+
{body}
|
|
202
|
+
</button>
|
|
203
|
+
)
|
|
204
|
+
})}
|
|
205
|
+
</motion.div>
|
|
206
|
+
) : null}
|
|
207
|
+
</AnimatePresence>
|
|
208
|
+
</div>
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function absolute(url: string): string {
|
|
213
|
+
if (typeof location === "undefined") return url
|
|
214
|
+
try {
|
|
215
|
+
return new URL(url, location.href).toString()
|
|
216
|
+
} catch {
|
|
217
|
+
return url
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/* Small accessible controls shared by the demo viewer, tabs and page actions: a pill tab strip
|
|
2
|
+
(tabs semantics, roving focus) and a segmented radio group. */
|
|
3
|
+
import { useId, type KeyboardEvent, type ReactNode } from "react"
|
|
4
|
+
import { motion, useReducedMotion } from "framer-motion"
|
|
5
|
+
|
|
6
|
+
export interface PillOption<T extends string> {
|
|
7
|
+
id: T
|
|
8
|
+
label: ReactNode
|
|
9
|
+
/** Accessible name when `label` is not plain text. */
|
|
10
|
+
ariaLabel?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const SPRING = { type: "spring", bounce: 0.18, duration: 0.45 } as const
|
|
14
|
+
|
|
15
|
+
/** Arrow/Home/End move focus (and selection) across a row of buttons. */
|
|
16
|
+
export function rovingKeyDown<T extends string>(
|
|
17
|
+
event: KeyboardEvent<HTMLElement>,
|
|
18
|
+
ids: readonly T[],
|
|
19
|
+
current: T,
|
|
20
|
+
select: (id: T) => void,
|
|
21
|
+
orientation: "horizontal" | "vertical" = "horizontal",
|
|
22
|
+
) {
|
|
23
|
+
const prev = orientation === "horizontal" ? "ArrowLeft" : "ArrowUp"
|
|
24
|
+
const next = orientation === "horizontal" ? "ArrowRight" : "ArrowDown"
|
|
25
|
+
const index = ids.indexOf(current)
|
|
26
|
+
let target = -1
|
|
27
|
+
if (event.key === prev) target = (index - 1 + ids.length) % ids.length
|
|
28
|
+
else if (event.key === next) target = (index + 1) % ids.length
|
|
29
|
+
else if (event.key === "Home") target = 0
|
|
30
|
+
else if (event.key === "End") target = ids.length - 1
|
|
31
|
+
if (target < 0) return
|
|
32
|
+
event.preventDefault()
|
|
33
|
+
select(ids[target])
|
|
34
|
+
const buttons = event.currentTarget.querySelectorAll<HTMLElement>("[data-roving]")
|
|
35
|
+
buttons[target]?.focus()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A tab strip with a sliding pill. `panelId(id)` wires aria-controls when the panels exist. */
|
|
39
|
+
export function PillTabs<T extends string>({
|
|
40
|
+
options,
|
|
41
|
+
value,
|
|
42
|
+
onChange,
|
|
43
|
+
label,
|
|
44
|
+
panelId,
|
|
45
|
+
tabId,
|
|
46
|
+
className,
|
|
47
|
+
}: {
|
|
48
|
+
options: readonly PillOption<T>[]
|
|
49
|
+
value: T
|
|
50
|
+
onChange: (id: T) => void
|
|
51
|
+
label: string
|
|
52
|
+
panelId?: (id: T) => string
|
|
53
|
+
tabId?: (id: T) => string
|
|
54
|
+
className?: string
|
|
55
|
+
}) {
|
|
56
|
+
const layoutId = useId()
|
|
57
|
+
const reduced = useReducedMotion()
|
|
58
|
+
return (
|
|
59
|
+
<div
|
|
60
|
+
className={className ? `docs-pills ${className}` : "docs-pills"}
|
|
61
|
+
role="tablist"
|
|
62
|
+
aria-label={label}
|
|
63
|
+
onKeyDown={(event) => rovingKeyDown(event, options.map((o) => o.id), value, onChange)}
|
|
64
|
+
>
|
|
65
|
+
{options.map((option) => {
|
|
66
|
+
const selected = option.id === value
|
|
67
|
+
return (
|
|
68
|
+
<button
|
|
69
|
+
key={option.id}
|
|
70
|
+
id={tabId?.(option.id)}
|
|
71
|
+
type="button"
|
|
72
|
+
role="tab"
|
|
73
|
+
data-roving=""
|
|
74
|
+
aria-selected={selected}
|
|
75
|
+
aria-controls={panelId?.(option.id)}
|
|
76
|
+
aria-label={option.ariaLabel}
|
|
77
|
+
tabIndex={selected ? 0 : -1}
|
|
78
|
+
className="docs-pill"
|
|
79
|
+
onClick={() => onChange(option.id)}
|
|
80
|
+
>
|
|
81
|
+
{selected ? (
|
|
82
|
+
<motion.span layoutId={`pill-${layoutId}`} className="docs-pill-indicator" transition={reduced ? { duration: 0 } : SPRING} />
|
|
83
|
+
) : null}
|
|
84
|
+
<span className="docs-pill-label">{option.label}</span>
|
|
85
|
+
</button>
|
|
86
|
+
)
|
|
87
|
+
})}
|
|
88
|
+
</div>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A segmented single choice (radio semantics), styled like the pill tabs. */
|
|
93
|
+
export function Segmented<T extends string>({
|
|
94
|
+
options,
|
|
95
|
+
value,
|
|
96
|
+
onChange,
|
|
97
|
+
label,
|
|
98
|
+
className,
|
|
99
|
+
}: {
|
|
100
|
+
options: readonly PillOption<T>[]
|
|
101
|
+
value: T
|
|
102
|
+
onChange: (id: T) => void
|
|
103
|
+
label: string
|
|
104
|
+
className?: string
|
|
105
|
+
}) {
|
|
106
|
+
const layoutId = useId()
|
|
107
|
+
const reduced = useReducedMotion()
|
|
108
|
+
return (
|
|
109
|
+
<div
|
|
110
|
+
className={className ? `docs-pills docs-segmented ${className}` : "docs-pills docs-segmented"}
|
|
111
|
+
role="radiogroup"
|
|
112
|
+
aria-label={label}
|
|
113
|
+
onKeyDown={(event) => rovingKeyDown(event, options.map((o) => o.id), value, onChange)}
|
|
114
|
+
>
|
|
115
|
+
{options.map((option) => {
|
|
116
|
+
const selected = option.id === value
|
|
117
|
+
return (
|
|
118
|
+
<button
|
|
119
|
+
key={option.id}
|
|
120
|
+
type="button"
|
|
121
|
+
role="radio"
|
|
122
|
+
data-roving=""
|
|
123
|
+
aria-checked={selected}
|
|
124
|
+
aria-label={option.ariaLabel}
|
|
125
|
+
tabIndex={selected ? 0 : -1}
|
|
126
|
+
className="docs-pill"
|
|
127
|
+
onClick={() => onChange(option.id)}
|
|
128
|
+
>
|
|
129
|
+
{selected ? (
|
|
130
|
+
<motion.span layoutId={`seg-${layoutId}`} className="docs-pill-indicator" transition={reduced ? { duration: 0 } : SPRING} />
|
|
131
|
+
) : null}
|
|
132
|
+
<span className="docs-pill-label">{option.label}</span>
|
|
133
|
+
</button>
|
|
134
|
+
)
|
|
135
|
+
})}
|
|
136
|
+
</div>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react"
|
|
2
|
+
import { Check, Copy } from "lucide-react"
|
|
3
|
+
|
|
4
|
+
/** Copies text, falling back to a selection copy where the Clipboard API is unavailable. */
|
|
5
|
+
export async function copyText(text: string): Promise<void> {
|
|
6
|
+
try {
|
|
7
|
+
await navigator.clipboard.writeText(text)
|
|
8
|
+
} catch {
|
|
9
|
+
const area = document.createElement("textarea")
|
|
10
|
+
area.value = text
|
|
11
|
+
area.setAttribute("readonly", "")
|
|
12
|
+
area.style.cssText = "position:fixed;top:0;left:0;opacity:0"
|
|
13
|
+
document.body.appendChild(area)
|
|
14
|
+
area.select()
|
|
15
|
+
document.execCommand("copy")
|
|
16
|
+
area.remove()
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Copies text that is still being produced. Hands the clipboard a pending
|
|
22
|
+
* ClipboardItem synchronously so Safari keeps the click's user activation.
|
|
23
|
+
*/
|
|
24
|
+
export async function copyLazyText(produce: () => string | Promise<string>): Promise<void> {
|
|
25
|
+
const pending = Promise.resolve().then(produce)
|
|
26
|
+
if (typeof ClipboardItem !== "undefined" && navigator.clipboard?.write) {
|
|
27
|
+
try {
|
|
28
|
+
const blob = pending.then((text) => new Blob([text], { type: "text/plain" }))
|
|
29
|
+
await navigator.clipboard.write([new ClipboardItem({ "text/plain": blob })])
|
|
30
|
+
return
|
|
31
|
+
} catch {
|
|
32
|
+
// fall through to writeText / selection copy
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
await copyText(await pending)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** `copied` flips true for a moment after `copy()`. */
|
|
39
|
+
export function useCopied(ms = 1600) {
|
|
40
|
+
const [copied, setCopied] = useState(false)
|
|
41
|
+
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
42
|
+
useEffect(() => () => clearTimeout(timer.current), [])
|
|
43
|
+
const copy = async (text: string | (() => string | Promise<string>)) => {
|
|
44
|
+
if (typeof text === "function") await copyLazyText(text)
|
|
45
|
+
else await copyText(text)
|
|
46
|
+
setCopied(true)
|
|
47
|
+
clearTimeout(timer.current)
|
|
48
|
+
timer.current = setTimeout(() => setCopied(false), ms)
|
|
49
|
+
}
|
|
50
|
+
return { copied, copy }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function CopyIcon({ copied, size = 14 }: { copied: boolean; size?: number }) {
|
|
54
|
+
return copied ? (
|
|
55
|
+
<Check className="docs-copy-icon docs-copy-icon-done" width={size} height={size} strokeWidth={2.4} aria-hidden="true" />
|
|
56
|
+
) : (
|
|
57
|
+
<Copy className="docs-copy-icon" width={size} height={size} strokeWidth={2} aria-hidden="true" />
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface CopyButtonProps {
|
|
62
|
+
text: string | (() => string | Promise<string>)
|
|
63
|
+
label?: string
|
|
64
|
+
className?: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Icon button that copies `text` and swaps to a tick. */
|
|
68
|
+
export function CopyButton({ text, label = "Copy code", className }: CopyButtonProps) {
|
|
69
|
+
const { copied, copy } = useCopied()
|
|
70
|
+
return (
|
|
71
|
+
<button
|
|
72
|
+
type="button"
|
|
73
|
+
className={className ? `docs-copy ${className}` : "docs-copy"}
|
|
74
|
+
onClick={() => void copy(text)}
|
|
75
|
+
aria-label={copied ? "Copied" : label}
|
|
76
|
+
title={copied ? "Copied" : label}
|
|
77
|
+
data-copied={copied || undefined}
|
|
78
|
+
>
|
|
79
|
+
<CopyIcon copied={copied} />
|
|
80
|
+
</button>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { useCallback, useSyncExternalStore } from "react"
|
|
2
|
+
|
|
3
|
+
/* Page-wide (and cross-page) memory for `{% tabs sync="key" %}`: the last tab title a reader chose
|
|
4
|
+
for a key, shared by every synced tab set and persisted in localStorage. */
|
|
5
|
+
|
|
6
|
+
const PREFIX = "docstream:tabs:"
|
|
7
|
+
const memory = new Map<string, string>()
|
|
8
|
+
const listeners = new Set<() => void>()
|
|
9
|
+
|
|
10
|
+
function storage(): Storage | null {
|
|
11
|
+
try {
|
|
12
|
+
return typeof window === "undefined" ? null : window.localStorage
|
|
13
|
+
} catch {
|
|
14
|
+
return null
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getSyncedTab(key: string): string | null {
|
|
19
|
+
if (memory.has(key)) return memory.get(key)!
|
|
20
|
+
const saved = storage()?.getItem(PREFIX + key) ?? null
|
|
21
|
+
if (saved !== null) memory.set(key, saved)
|
|
22
|
+
return saved
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function setSyncedTab(key: string, title: string): void {
|
|
26
|
+
memory.set(key, title)
|
|
27
|
+
try {
|
|
28
|
+
storage()?.setItem(PREFIX + key, title)
|
|
29
|
+
} catch {
|
|
30
|
+
// storage full or disabled: the in-memory choice still syncs this page
|
|
31
|
+
}
|
|
32
|
+
listeners.forEach((listener) => listener())
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function subscribe(listener: () => void) {
|
|
36
|
+
listeners.add(listener)
|
|
37
|
+
const onStorage = (event: StorageEvent) => {
|
|
38
|
+
if (event.key?.startsWith(PREFIX)) {
|
|
39
|
+
memory.delete(event.key.slice(PREFIX.length))
|
|
40
|
+
listener()
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (typeof window !== "undefined") window.addEventListener("storage", onStorage)
|
|
44
|
+
return () => {
|
|
45
|
+
listeners.delete(listener)
|
|
46
|
+
if (typeof window !== "undefined") window.removeEventListener("storage", onStorage)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `[title, setTitle]` for a sync key; both are inert when `key` is undefined. */
|
|
51
|
+
export function useSyncedTab(key: string | undefined): [string | null, (title: string) => void] {
|
|
52
|
+
const title = useSyncExternalStore(
|
|
53
|
+
subscribe,
|
|
54
|
+
() => (key ? getSyncedTab(key) : null),
|
|
55
|
+
() => null,
|
|
56
|
+
)
|
|
57
|
+
const set = useCallback((next: string) => {
|
|
58
|
+
if (key) setSyncedTab(key, next)
|
|
59
|
+
}, [key])
|
|
60
|
+
return [title, set]
|
|
61
|
+
}
|
package/src/gitbook/ast.ts
CHANGED
|
@@ -89,6 +89,11 @@ export interface TabNode {
|
|
|
89
89
|
export interface TabsNode {
|
|
90
90
|
type: "tabs"
|
|
91
91
|
tabs: TabNode[]
|
|
92
|
+
/**
|
|
93
|
+
* Sync group (`{% tabs sync="pm" %}`). Every tab set with the same key follows
|
|
94
|
+
* the reader's last choice (matched by tab title), remembered across pages.
|
|
95
|
+
*/
|
|
96
|
+
sync?: string
|
|
92
97
|
}
|
|
93
98
|
|
|
94
99
|
export interface ExpandableNode {
|
|
@@ -143,6 +148,35 @@ export interface SourceRefNode {
|
|
|
143
148
|
title?: string
|
|
144
149
|
}
|
|
145
150
|
|
|
151
|
+
export type DemoLayout = "auto" | "single" | "multi"
|
|
152
|
+
export type DemoViewport = "desktop" | "tablet" | "phone"
|
|
153
|
+
|
|
154
|
+
export interface DemoVariantOption {
|
|
155
|
+
id: string
|
|
156
|
+
label: string
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A file-backed live demo: `{% demo src="<page>/<example>" %}`. The example
|
|
161
|
+
* folder (resolved by the host's `DemoResolver`) is the authority for the
|
|
162
|
+
* component, its source files, and its defaults; attributes here override the
|
|
163
|
+
* folder's `meta.json`.
|
|
164
|
+
*/
|
|
165
|
+
export interface DemoNode {
|
|
166
|
+
type: "demo"
|
|
167
|
+
/** `<page>/<example>` folder id understood by the host's resolver. */
|
|
168
|
+
src: string
|
|
169
|
+
title?: string
|
|
170
|
+
description?: string
|
|
171
|
+
/** Preview height, kept verbatim (`"420"`, `"60vh"`, `"auto"`). */
|
|
172
|
+
height?: string
|
|
173
|
+
layout?: DemoLayout
|
|
174
|
+
/** `variants="full:Full,compact:Compact"`. */
|
|
175
|
+
variants?: DemoVariantOption[]
|
|
176
|
+
/** Initial preview viewport for the multi-file viewer. */
|
|
177
|
+
viewport?: DemoViewport
|
|
178
|
+
}
|
|
179
|
+
|
|
146
180
|
export interface ColumnNode {
|
|
147
181
|
type: "column"
|
|
148
182
|
children: Block[]
|
|
@@ -227,6 +261,7 @@ export type Block =
|
|
|
227
261
|
| EmbedNode
|
|
228
262
|
| ContentRefNode
|
|
229
263
|
| SourceRefNode
|
|
264
|
+
| DemoNode
|
|
230
265
|
| ColumnsNode
|
|
231
266
|
| FigureNode
|
|
232
267
|
| ListNode
|
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, trimPartialInlineToken } from "./parse"
|
|
4
|
+
export { parseDemoVariants, parseMarkdown, parseBlocks, trimPartialInlineToken } from "./parse"
|
|
5
5
|
export { serializeBlocks, serializeMarkdown } from "./serialize"
|
|
6
6
|
export { footnoteDefinitions, parseInline, plainText, refDefinitions, serializeInline, serializeReference } from "./inline"
|
package/src/gitbook/parse.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
Block,
|
|
3
3
|
ColumnNode,
|
|
4
|
+
DemoLayout,
|
|
5
|
+
DemoNode,
|
|
6
|
+
DemoVariantOption,
|
|
7
|
+
DemoViewport,
|
|
4
8
|
DocumentNode,
|
|
5
9
|
HintStyle,
|
|
6
10
|
Inline,
|
|
@@ -155,6 +159,39 @@ export function parseMarkdown(src: string): DocumentNode {
|
|
|
155
159
|
return doc
|
|
156
160
|
}
|
|
157
161
|
|
|
162
|
+
const DEMO_LAYOUTS: DemoLayout[] = ["auto", "single", "multi"]
|
|
163
|
+
const DEMO_VIEWPORTS: DemoViewport[] = ["desktop", "tablet", "phone"]
|
|
164
|
+
|
|
165
|
+
/** `"full:Full,compact:Compact"` → `[{ id: "full", label: "Full" }, …]`. A bare id is its own label. */
|
|
166
|
+
export function parseDemoVariants(raw: string): DemoVariantOption[] {
|
|
167
|
+
return raw
|
|
168
|
+
.split(",")
|
|
169
|
+
.map((part) => part.trim())
|
|
170
|
+
.filter(Boolean)
|
|
171
|
+
.map((part) => {
|
|
172
|
+
const colon = part.indexOf(":")
|
|
173
|
+
if (colon < 0) return { id: part, label: part }
|
|
174
|
+
const id = part.slice(0, colon).trim()
|
|
175
|
+
const label = part.slice(colon + 1).trim()
|
|
176
|
+
return { id, label: label || id }
|
|
177
|
+
})
|
|
178
|
+
.filter((v) => v.id)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function parseDemoTag(attrs: Record<string, string>): DemoNode {
|
|
182
|
+
const variants = attrs.variants ? parseDemoVariants(attrs.variants) : []
|
|
183
|
+
return {
|
|
184
|
+
type: "demo",
|
|
185
|
+
src: (attrs.src ?? "").trim(),
|
|
186
|
+
...(attrs.title ? { title: attrs.title } : {}),
|
|
187
|
+
...(attrs.description ? { description: attrs.description } : {}),
|
|
188
|
+
...(attrs.height ? { height: attrs.height } : {}),
|
|
189
|
+
...((DEMO_LAYOUTS as string[]).includes(attrs.layout) ? { layout: attrs.layout as DemoLayout } : {}),
|
|
190
|
+
...(variants.length ? { variants } : {}),
|
|
191
|
+
...((DEMO_VIEWPORTS as string[]).includes(attrs.viewport) ? { viewport: attrs.viewport as DemoViewport } : {}),
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
158
195
|
// Strips an in-progress inline token (`[^1`, `@bre`, a lone `#`, a trailing
|
|
159
196
|
// `\`) from the end of a partially streamed document so chips don't flash as
|
|
160
197
|
// plain text mid-stream. No-op inside an unterminated code fence.
|
|
@@ -162,6 +199,8 @@ export function trimPartialInlineToken(md: string): string {
|
|
|
162
199
|
const fences = md.match(/^\s*(?:```|~~~)/gm)
|
|
163
200
|
if (fences && fences.length % 2 === 1) return md
|
|
164
201
|
return md
|
|
202
|
+
// A block tag still arriving (`{% demo src="butt`) would flash as a paragraph.
|
|
203
|
+
.replace(/(^|\n)[ \t]*\{(?:%(?:(?!%\})[^\n])*)?$/, "$1")
|
|
165
204
|
.replace(/\[\^[^\]]*$/, "")
|
|
166
205
|
.replace(/(^|[\s([{])[@#][\w.-]*$/, "$1")
|
|
167
206
|
.replace(/\\$/, "")
|
|
@@ -206,7 +245,7 @@ export function parseBlocks(lines: string[]): Block[] {
|
|
|
206
245
|
|
|
207
246
|
if (tag.name === "tabs") {
|
|
208
247
|
const { body, next } = collectUntil(lines, i + 1, "tabs")
|
|
209
|
-
blocks.push({ type: "tabs", tabs: parseTabs(body) })
|
|
248
|
+
blocks.push({ type: "tabs", tabs: parseTabs(body), ...(tag.attrs.sync ? { sync: tag.attrs.sync } : {}) })
|
|
210
249
|
i = next
|
|
211
250
|
continue
|
|
212
251
|
}
|
|
@@ -288,6 +327,14 @@ export function parseBlocks(lines: string[]): Block[] {
|
|
|
288
327
|
continue
|
|
289
328
|
}
|
|
290
329
|
|
|
330
|
+
if (tag.name === "demo") {
|
|
331
|
+
blocks.push(parseDemoTag(tag.attrs))
|
|
332
|
+
i++
|
|
333
|
+
// tolerate an optional {% enddemo %}
|
|
334
|
+
if (i < lines.length && templateTag(lines[i])?.name === "enddemo") i++
|
|
335
|
+
continue
|
|
336
|
+
}
|
|
337
|
+
|
|
291
338
|
if (tag.name === "updates") {
|
|
292
339
|
const { body, next } = collectUntil(lines, i + 1, "updates")
|
|
293
340
|
blocks.push({
|
package/src/gitbook/serialize.ts
CHANGED
|
@@ -50,7 +50,7 @@ function serializeBlock(b: Block): string {
|
|
|
50
50
|
return `{% hint style="${b.style}" %}\n${serializeBlocks(b.children)}\n{% endhint %}`
|
|
51
51
|
|
|
52
52
|
case "tabs":
|
|
53
|
-
return `{% tabs %}\n${b.tabs
|
|
53
|
+
return `{% tabs${b.sync ? ` sync="${b.sync}"` : ""} %}\n${b.tabs
|
|
54
54
|
.map((t) => `{% tab title="${t.title}" %}\n${serializeBlocks(t.children)}\n{% endtab %}`)
|
|
55
55
|
.join("\n\n")}\n{% endtabs %}`
|
|
56
56
|
|
|
@@ -92,6 +92,21 @@ function serializeBlock(b: Block): string {
|
|
|
92
92
|
return `{% source-ref${attrs} %}`
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
case "demo": {
|
|
96
|
+
const attrs = [
|
|
97
|
+
` src="${b.src}"`,
|
|
98
|
+
b.title ? ` title="${b.title}"` : "",
|
|
99
|
+
b.description ? ` description="${b.description}"` : "",
|
|
100
|
+
b.height ? ` height="${b.height}"` : "",
|
|
101
|
+
b.layout ? ` layout="${b.layout}"` : "",
|
|
102
|
+
b.variants?.length
|
|
103
|
+
? ` variants="${b.variants.map((v) => (v.label === v.id ? v.id : `${v.id}:${v.label}`)).join(",")}"`
|
|
104
|
+
: "",
|
|
105
|
+
b.viewport ? ` viewport="${b.viewport}"` : "",
|
|
106
|
+
].join("")
|
|
107
|
+
return `{% demo${attrs} %}`
|
|
108
|
+
}
|
|
109
|
+
|
|
95
110
|
case "columns":
|
|
96
111
|
return `{% columns %}\n${b.columns
|
|
97
112
|
.map((c) => `{% column %}\n${serializeBlocks(c.children)}\n{% endcolumn %}`)
|