@brett_lamy/docstream 0.7.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.
@@ -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
+ }
@@ -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
@@ -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"
@@ -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({
@@ -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 %}`)