@brett_lamy/docstream 0.5.1 → 0.5.3

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@brett_lamy/docstream",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "GitBook-aware readonly markdown and AI stream renderer.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -70,6 +70,7 @@
70
70
  },
71
71
  "dependencies": {
72
72
  "@brett_lamy/viz-engine": ">=0.2.0",
73
+ "framer-motion": "^13.1.1",
73
74
  "hast-util-to-html": "^9.0.5",
74
75
  "lowlight": "^3.3.0",
75
76
  "lucide-react": "^1.17.0",
@@ -1,4 +1,5 @@
1
1
  import { useState, type ReactNode } from "react"
2
+ import { LayoutGroup, motion } from "framer-motion"
2
3
  import {
3
4
  AlertTriangle,
4
5
  CheckCircle2,
@@ -92,18 +93,30 @@ function Tabs({ block, liveRenderer, sourceRenderer }: { block: Extract<Block, {
92
93
  const [active, setActive] = useState(0)
93
94
  return (
94
95
  <div className="docs-tabs">
95
- <div className="docs-tabs-header">
96
- {block.tabs.map((t, i) => (
97
- <button
98
- key={i}
99
- className={i === active ? "docs-tab-active" : ""}
100
- onClick={() => setActive(i)}
101
- >
102
- {t.title}
103
- </button>
104
- ))}
105
- </div>
106
- <div className="docs-tabs-body">
96
+ <LayoutGroup>
97
+ <div className="docs-tabs-header" role="tablist" aria-label="Content tabs">
98
+ {block.tabs.map((t, i) => (
99
+ <button
100
+ key={i}
101
+ type="button"
102
+ role="tab"
103
+ aria-selected={i === active}
104
+ className={i === active ? "docs-tab-active" : ""}
105
+ onClick={() => setActive(i)}
106
+ >
107
+ {i === active ? (
108
+ <motion.span
109
+ className="docs-tab-indicator"
110
+ layoutId="docs-tab-indicator"
111
+ transition={{ type: "spring", bounce: 0.2, duration: 0.6 }}
112
+ />
113
+ ) : null}
114
+ <span className="docs-tab-label">{t.title}</span>
115
+ </button>
116
+ ))}
117
+ </div>
118
+ </LayoutGroup>
119
+ <div className="docs-tabs-body" role="tabpanel">
107
120
  <Blocks blocks={block.tabs[active]?.children ?? []} liveRenderer={liveRenderer} sourceRenderer={sourceRenderer} />
108
121
  </div>
109
122
  </div>
@@ -1,17 +1,99 @@
1
- import { createContext, useContext, useState, type ReactNode } from "react"
2
- import { AtSign, Globe, Hash } from "lucide-react"
1
+ import { createContext, useContext, useId, useState, type ReactNode } from "react"
2
+ import { ArrowUpRight, AtSign, Globe, Hash } from "lucide-react"
3
3
 
4
4
  import type { CitationDef, ReferenceNode } from "../gitbook/ast"
5
5
 
6
+ export interface CitationPreview {
7
+ title?: string
8
+ description?: string
9
+ image?: string
10
+ siteName?: string
11
+ favicon?: string
12
+ }
13
+
14
+ export type CitationPreviewResolver = (citation: ReferenceNode) => CitationPreview | null | Promise<CitationPreview | null>
15
+
6
16
  export interface DocstreamRefContextValue {
7
17
  /** Called when a mention/tag chip (or a citation, if it has no url) is clicked. */
8
18
  onReferenceClick?: (ref: ReferenceNode) => void
9
19
  /** Full override for chip rendering; return null to fall back to the default. */
10
20
  renderReference?: (ref: ReferenceNode) => ReactNode | null
21
+ /** Resolves Open Graph-style metadata when a linked citation is hovered or focused. */
22
+ resolveCitationPreview?: CitationPreviewResolver
11
23
  }
12
24
 
13
25
  export const DocstreamRefContext = createContext<DocstreamRefContextValue>({})
14
26
 
27
+ const previewCaches = new WeakMap<CitationPreviewResolver, Map<string, Promise<CitationPreview | null>>>()
28
+
29
+ function resolvePreview(resolver: CitationPreviewResolver, citation: ReferenceNode) {
30
+ let cache = previewCaches.get(resolver)
31
+ if (!cache) {
32
+ cache = new Map()
33
+ previewCaches.set(resolver, cache)
34
+ }
35
+ const key = citation.url ?? citation.id
36
+ let request = cache.get(key)
37
+ if (!request) {
38
+ request = Promise.resolve(resolver(citation)).catch(() => null)
39
+ cache.set(key, request)
40
+ }
41
+ return request
42
+ }
43
+
44
+ function CitationHoverCard({ node }: { node: ReferenceNode }) {
45
+ const { onReferenceClick, resolveCitationPreview } = useContext(DocstreamRefContext)
46
+ const [preview, setPreview] = useState<CitationPreview | null>(null)
47
+ const [imageFailed, setImageFailed] = useState(false)
48
+ const [requested, setRequested] = useState(false)
49
+ const cardId = useId()
50
+ const host = node.url ? hostnameOf(node.url) : null
51
+
52
+ const requestPreview = () => {
53
+ if (!resolveCitationPreview || requested) return
54
+ setRequested(true)
55
+ void resolvePreview(resolveCitationPreview, node).then((value) => setPreview(value))
56
+ }
57
+
58
+ const title = preview?.title ?? node.label ?? host ?? node.url
59
+ const siteName = preview?.siteName ?? host
60
+ const favicon = preview?.favicon ?? (host ? `https://www.google.com/s2/favicons?sz=32&domain=${host}` : undefined)
61
+
62
+ return (
63
+ <span className="docs-cite-wrap" onMouseEnter={requestPreview} onFocus={requestPreview}>
64
+ <a
65
+ className="docs-cite"
66
+ href={node.url}
67
+ target="_blank"
68
+ rel="noreferrer"
69
+ aria-describedby={cardId}
70
+ onClick={() => onReferenceClick?.(node)}
71
+ >
72
+ {node.id}
73
+ </a>
74
+ <span className="docs-cite-card" id={cardId} role="tooltip">
75
+ {preview?.image && !imageFailed ? (
76
+ <span className="docs-cite-card-image-wrap">
77
+ <img className="docs-cite-card-image" src={preview.image} alt="" onError={() => setImageFailed(true)} />
78
+ </span>
79
+ ) : null}
80
+ <span className="docs-cite-card-body">
81
+ <span className="docs-cite-card-source">
82
+ {favicon ? <img className="docs-cite-card-favicon" src={favicon} alt="" /> : <Globe className="docs-cite-card-favicon" />}
83
+ <span>{siteName}</span>
84
+ </span>
85
+ <span className="docs-cite-card-title">{title}</span>
86
+ {preview?.description ? <span className="docs-cite-card-description">{preview.description}</span> : null}
87
+ <span className="docs-cite-card-url">
88
+ {node.url}
89
+ <ArrowUpRight aria-hidden="true" />
90
+ </span>
91
+ </span>
92
+ </span>
93
+ </span>
94
+ )
95
+ }
96
+
15
97
  export function InlineReference({ node }: { node: ReferenceNode }) {
16
98
  const { onReferenceClick, renderReference } = useContext(DocstreamRefContext)
17
99
  const custom = renderReference?.(node)
@@ -20,18 +102,7 @@ export function InlineReference({ node }: { node: ReferenceNode }) {
20
102
  if (node.kind === "citation") {
21
103
  // Citations default to linking their source; hosts can still observe clicks.
22
104
  if (node.url) {
23
- return (
24
- <a
25
- className="docs-cite"
26
- href={node.url}
27
- target="_blank"
28
- rel="noreferrer"
29
- title={node.label ?? node.url}
30
- onClick={() => onReferenceClick?.(node)}
31
- >
32
- {node.id}
33
- </a>
34
- )
105
+ return <CitationHoverCard node={node} />
35
106
  }
36
107
  // Definition missing (not yet streamed, or standalone parseInline call).
37
108
  return (
@@ -225,7 +225,7 @@ export function parseBlocks(lines: string[]): Block[] {
225
225
  const code = inner.find((b) => b.type === "code")
226
226
  if (code && code.type === "code") {
227
227
  code.title = tag.attrs.title ?? null
228
- code.lineNumbers = tag.attrs.lineNumbers === "true"
228
+ code.lineNumbers = booleanAttr(tag.attrs, "lineNumbers") ?? booleanAttr(tag.attrs, "showLineNumbers") ?? true
229
229
  code.live = tag.attrs.live === "true"
230
230
  code.entry = tag.attrs.entry ?? null
231
231
  blocks.push(code)
@@ -428,13 +428,14 @@ export function parseBlocks(lines: string[]): Block[] {
428
428
  }
429
429
 
430
430
  // fenced code (``` or ~~~)
431
- const fence = trimmed.match(/^(`{3,}|~{3,})(\S*)\s*$/)
431
+ const fence = trimmed.match(/^(`{3,}|~{3,})([^\s`]*)?(?:\s+(.*?))?\s*$/)
432
432
  if (fence) {
433
433
  flushParagraph()
434
434
  const marker = fence[1][0]
435
+ const attrs = parseAttrs(fence[3])
435
436
  const code: string[] = []
436
437
  i++
437
- while (i < lines.length && !lines[i].trim().startsWith(marker.repeat(3))) {
438
+ while (i < lines.length && !lines[i].trim().startsWith(fence[1])) {
438
439
  code.push(lines[i])
439
440
  i++
440
441
  }
@@ -442,9 +443,11 @@ export function parseBlocks(lines: string[]): Block[] {
442
443
  blocks.push({
443
444
  type: "code",
444
445
  language: fence[2] || null,
445
- title: null,
446
- lineNumbers: false,
446
+ title: attrs.title ?? null,
447
+ lineNumbers: booleanAttr(attrs, "lineNumbers") ?? booleanAttr(attrs, "showLineNumbers") ?? true,
447
448
  code: code.join("\n"),
449
+ ...(booleanAttr(attrs, "live") === undefined ? {} : { live: booleanAttr(attrs, "live") }),
450
+ ...(attrs.entry ? { entry: attrs.entry } : {}),
448
451
  })
449
452
  continue
450
453
  }
@@ -459,7 +462,7 @@ export function parseBlocks(lines: string[]): Block[] {
459
462
  code.push(lines[i].slice(4))
460
463
  i++
461
464
  }
462
- blocks.push({ type: "code", language: null, title: null, lineNumbers: false, code: code.join("\n") })
465
+ blocks.push({ type: "code", language: null, title: null, lineNumbers: true, code: code.join("\n") })
463
466
  continue
464
467
  }
465
468
 
@@ -34,18 +34,14 @@ function serializeBlock(b: Block): string {
34
34
  }
35
35
 
36
36
  case "code": {
37
- const fence = "```" + (b.language ?? "")
38
- const body = `${fence}\n${b.code}\n\`\`\``
39
- if (b.title || b.lineNumbers || b.live || b.entry) {
40
- const attrs = [
41
- b.title ? ` title="${b.title}"` : "",
42
- b.lineNumbers ? ` lineNumbers="true"` : "",
43
- b.live ? ` live="true"` : "",
44
- b.entry ? ` entry="${b.entry}"` : "",
45
- ].join("")
46
- return `{% code${attrs} %}\n${body}\n{% endcode %}`
47
- }
48
- return body
37
+ const attrs = [
38
+ b.title ? `title="${b.title}"` : "",
39
+ !b.lineNumbers ? `lineNumbers="false"` : "",
40
+ b.live ? `live="true"` : "",
41
+ b.entry ? `entry="${b.entry}"` : "",
42
+ ].filter(Boolean)
43
+ const info = [b.language ?? "", ...attrs].filter(Boolean).join(" ")
44
+ return `\`\`\`${info}\n${b.code}\n\`\`\``
49
45
  }
50
46
 
51
47
  case "hint":
package/src/index.ts CHANGED
@@ -3,7 +3,7 @@ export type { PlaygroundStreamdownProps as GitbookStreamdownProps } from "./play
3
3
  export { DocsRenderer, MarkdownContent } from "./docs/DocsRenderer"
4
4
  export type { LivePreviewProps, LivePreviewRenderer, SourceReferenceRenderer } from "./docs/DocsRenderer"
5
5
  export { CitationSources, DocstreamRefContext, InlineReference } from "./docs/reference"
6
- export type { DocstreamRefContextValue } from "./docs/reference"
6
+ export type { CitationPreview, CitationPreviewResolver, DocstreamRefContextValue } from "./docs/reference"
7
7
  export { ReplayEmbed, ReplayPreview } from "./replay"
8
8
  export type {
9
9
  ReplayEventsSource,
@@ -5,7 +5,7 @@ import { parseMarkdown, trimPartialInlineToken } from "./gitbook/parse"
5
5
  // Re-exported so markdown-only consumers of ./streamdown can wire chip
6
6
  // click handling without importing the heavy root entry.
7
7
  export { CitationSources, DocstreamRefContext, InlineReference } from "./docs/reference"
8
- export type { DocstreamRefContextValue } from "./docs/reference"
8
+ export type { CitationPreview, CitationPreviewResolver, DocstreamRefContextValue } from "./docs/reference"
9
9
 
10
10
  export interface GitbookStreamdownProps {
11
11
  children?: string
package/src/styles.css CHANGED
@@ -176,6 +176,7 @@
176
176
 
177
177
  .docs-hint {
178
178
  display: flex;
179
+ align-items: center;
179
180
  gap: 12px;
180
181
  padding: 14px 16px;
181
182
  border-radius: 12px;
@@ -186,7 +187,6 @@
186
187
  width: 18px;
187
188
  height: 18px;
188
189
  flex-shrink: 0;
189
- margin-top: 3px;
190
190
  }
191
191
 
192
192
  .docs-hint > div {
@@ -194,6 +194,14 @@
194
194
  min-width: 0;
195
195
  }
196
196
 
197
+ .docs-hint > div > :first-child {
198
+ margin-top: 0;
199
+ }
200
+
201
+ .docs-hint > div > :last-child {
202
+ margin-bottom: 0;
203
+ }
204
+
197
205
  .docs-hint-info {
198
206
  border-color: var(--gb-info-border);
199
207
  background: var(--gb-info-bg);
@@ -234,31 +242,77 @@
234
242
  border: 1px solid var(--gb-border);
235
243
  border-radius: 12px;
236
244
  overflow: hidden;
245
+ background: var(--gb-panel);
237
246
  }
238
247
 
239
248
  .docs-tabs-header {
240
249
  display: flex;
250
+ width: fit-content;
251
+ max-width: calc(100% - 24px);
241
252
  gap: 2px;
242
- padding: 6px 8px 0;
253
+ margin: 10px auto 0;
254
+ padding: 4px;
255
+ overflow-x: auto;
256
+ border: 1px solid var(--gb-border);
257
+ border-radius: 999px;
243
258
  background: var(--gb-muted);
244
- border-bottom: 1px solid var(--gb-border);
245
259
  }
246
260
 
247
261
  .docs-tabs-header button {
262
+ position: relative;
263
+ z-index: 0;
264
+ flex: 0 0 auto;
265
+ border: 0;
266
+ border-radius: 999px;
248
267
  padding: 7px 14px;
268
+ background: transparent;
269
+ font: inherit;
249
270
  font-size: 13.5px;
250
- border-radius: 8px 8px 0 0;
271
+ line-height: 1.25;
251
272
  color: var(--gb-muted-foreground);
273
+ cursor: pointer;
274
+ -webkit-tap-highlight-color: transparent;
275
+ transition: color 160ms ease;
252
276
  }
253
277
 
254
278
  .docs-tabs-header .docs-tab-active {
255
- background: var(--background, #ffffff);
256
- color: var(--foreground, #18181b);
257
- font-weight: 500;
279
+ color: var(--gb-panel-foreground);
280
+ font-weight: 600;
281
+ }
282
+
283
+ .docs-tabs-header button:hover:not(.docs-tab-active) {
284
+ color: var(--gb-panel-foreground);
285
+ }
286
+
287
+ .docs-tabs-header button:focus-visible {
288
+ outline: 2px solid var(--gb-primary);
289
+ outline-offset: 2px;
290
+ }
291
+
292
+ .docs-tab-indicator {
293
+ position: absolute;
294
+ inset: 0;
295
+ z-index: -1;
296
+ border: 1px solid var(--gb-border);
297
+ border-radius: inherit;
298
+ background: var(--gb-panel);
299
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08), 0 1px 6px rgba(15, 23, 42, 0.06);
300
+ }
301
+
302
+ .docs-tab-label {
303
+ position: relative;
258
304
  }
259
305
 
260
306
  .docs-tabs-body {
307
+ margin-top: 10px;
261
308
  padding: 14px 16px;
309
+ border-top: 1px solid var(--gb-border);
310
+ }
311
+
312
+ @media (prefers-reduced-motion: reduce) {
313
+ .docs-tabs-header button {
314
+ transition: none;
315
+ }
262
316
  }
263
317
 
264
318
  .docs-expandable {
@@ -996,6 +1050,12 @@
996
1050
  color: var(--gb-accent-foreground);
997
1051
  }
998
1052
 
1053
+ .docs-cite-wrap {
1054
+ position: relative;
1055
+ display: inline-block;
1056
+ vertical-align: super;
1057
+ }
1058
+
999
1059
  .docs-cite {
1000
1060
  display: inline-block;
1001
1061
  padding: 1px 5px;
@@ -1005,7 +1065,7 @@
1005
1065
  font-size: 10px;
1006
1066
  font-weight: 700;
1007
1067
  line-height: 1.4;
1008
- vertical-align: super;
1068
+ vertical-align: baseline;
1009
1069
  text-decoration: none !important;
1010
1070
  cursor: pointer;
1011
1071
  }
@@ -1014,6 +1074,121 @@
1014
1074
  background: rgba(59, 130, 246, 0.22);
1015
1075
  }
1016
1076
 
1077
+ .docs-cite:focus-visible {
1078
+ outline: 2px solid var(--gb-info);
1079
+ outline-offset: 2px;
1080
+ }
1081
+
1082
+ .docs-cite-card {
1083
+ position: absolute;
1084
+ top: calc(100% + 10px);
1085
+ left: 50%;
1086
+ z-index: 50;
1087
+ display: block;
1088
+ width: min(320px, calc(100vw - 32px));
1089
+ overflow: hidden;
1090
+ border: 1px solid var(--gb-border);
1091
+ border-radius: 12px;
1092
+ color: var(--gb-panel-foreground);
1093
+ background: var(--gb-panel);
1094
+ box-shadow: 0 12px 32px rgba(15, 23, 42, 0.16), 0 2px 8px rgba(15, 23, 42, 0.08);
1095
+ opacity: 0;
1096
+ visibility: hidden;
1097
+ transform: translate(-50%, -4px);
1098
+ transition: opacity 120ms ease, transform 120ms ease, visibility 120ms;
1099
+ pointer-events: none;
1100
+ text-align: left;
1101
+ white-space: normal;
1102
+ }
1103
+
1104
+ .docs-cite-wrap:hover .docs-cite-card,
1105
+ .docs-cite-wrap:focus-within .docs-cite-card {
1106
+ opacity: 1;
1107
+ visibility: visible;
1108
+ transform: translate(-50%, 0);
1109
+ }
1110
+
1111
+ .docs-cite-card-image-wrap {
1112
+ display: block;
1113
+ aspect-ratio: 1.91 / 1;
1114
+ overflow: hidden;
1115
+ border-bottom: 1px solid var(--gb-border);
1116
+ background: var(--gb-muted);
1117
+ }
1118
+
1119
+ .docs-cite-card-image {
1120
+ width: 100%;
1121
+ height: 100%;
1122
+ object-fit: cover;
1123
+ }
1124
+
1125
+ .docs-cite-card-body {
1126
+ display: flex;
1127
+ flex-direction: column;
1128
+ gap: 6px;
1129
+ padding: 12px 14px 13px;
1130
+ }
1131
+
1132
+ .docs-cite-card-source,
1133
+ .docs-cite-card-url {
1134
+ display: flex;
1135
+ align-items: center;
1136
+ gap: 6px;
1137
+ min-width: 0;
1138
+ color: var(--gb-muted-foreground);
1139
+ font-size: 11px;
1140
+ font-weight: 500;
1141
+ line-height: 1.35;
1142
+ }
1143
+
1144
+ .docs-cite-card-favicon {
1145
+ width: 14px;
1146
+ height: 14px;
1147
+ flex: 0 0 auto;
1148
+ border-radius: 3px;
1149
+ }
1150
+
1151
+ .docs-cite-card-title {
1152
+ display: -webkit-box;
1153
+ overflow: hidden;
1154
+ color: var(--gb-panel-foreground);
1155
+ font-size: 14px;
1156
+ font-weight: 650;
1157
+ line-height: 1.35;
1158
+ -webkit-box-orient: vertical;
1159
+ -webkit-line-clamp: 2;
1160
+ }
1161
+
1162
+ .docs-cite-card-description {
1163
+ display: -webkit-box;
1164
+ overflow: hidden;
1165
+ color: var(--gb-muted-foreground);
1166
+ font-size: 12px;
1167
+ font-weight: 400;
1168
+ line-height: 1.45;
1169
+ -webkit-box-orient: vertical;
1170
+ -webkit-line-clamp: 3;
1171
+ }
1172
+
1173
+ .docs-cite-card-url {
1174
+ margin-top: 2px;
1175
+ overflow: hidden;
1176
+ text-overflow: ellipsis;
1177
+ white-space: nowrap;
1178
+ }
1179
+
1180
+ .docs-cite-card-url svg {
1181
+ width: 12px;
1182
+ height: 12px;
1183
+ flex: 0 0 auto;
1184
+ }
1185
+
1186
+ @media (prefers-reduced-motion: reduce) {
1187
+ .docs-cite-card {
1188
+ transition: none;
1189
+ }
1190
+ }
1191
+
1017
1192
  .docs-sources {
1018
1193
  display: flex;
1019
1194
  flex-wrap: wrap;