@brett_lamy/docstream 1.17.0 → 1.18.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/dist/styles.css CHANGED
@@ -443,6 +443,25 @@
443
443
  margin: 4px 0 6px;
444
444
  }
445
445
 
446
+ /* A step whose heading opens with a thumbnail: the image in its own column, the title and body beside it. */
447
+ .docs-step-media {
448
+ display: flex;
449
+ align-items: flex-start;
450
+ gap: 16px;
451
+ }
452
+
453
+ .docs-step-media-body {
454
+ flex: 1;
455
+ min-width: 0;
456
+ }
457
+
458
+ @media (max-width: 560px) {
459
+ .docs-step-media {
460
+ flex-direction: column;
461
+ gap: 8px;
462
+ }
463
+ }
464
+
446
465
  .docs-embed {
447
466
  width: 100%;
448
467
  aspect-ratio: 16 / 9;
@@ -1712,6 +1731,124 @@
1712
1731
  text-decoration: none !important;
1713
1732
  }
1714
1733
 
1734
+ /* Thumbnail images (`![alt](src "thumbnail: 0:39.8")`): a small frame with a badge; hover or focus
1735
+ opens a card with the full image. First in a paragraph, the text runs beside it. */
1736
+ /* Float, not flex: the text keeps its own first line, so a list marker lines up with it. */
1737
+ .docs-media {
1738
+ display: flow-root;
1739
+ }
1740
+
1741
+ .docs-media > .docs-thumb,
1742
+ .docs-media > .docs-thumb-link {
1743
+ float: left;
1744
+ margin: 3px 14px 4px 0;
1745
+ }
1746
+
1747
+ .docs-media-body {
1748
+ display: block;
1749
+ overflow: hidden;
1750
+ }
1751
+
1752
+ .docs-thumb-link {
1753
+ text-decoration: none !important;
1754
+ }
1755
+
1756
+ .docs-thumb {
1757
+ position: relative;
1758
+ display: inline-block;
1759
+ flex: none;
1760
+ vertical-align: top;
1761
+ border-radius: 10px;
1762
+ outline: none;
1763
+ }
1764
+
1765
+ .docs-thumb-frame {
1766
+ position: relative;
1767
+ display: block;
1768
+ width: var(--docs-thumb-width, 168px);
1769
+ aspect-ratio: 16 / 10;
1770
+ overflow: hidden;
1771
+ border: 1px solid var(--gb-border);
1772
+ border-radius: 10px;
1773
+ background: var(--gb-muted, #f4f4f5);
1774
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
1775
+ cursor: zoom-in;
1776
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
1777
+ }
1778
+
1779
+ .docs-thumb-frame img {
1780
+ display: block;
1781
+ width: 100%;
1782
+ height: 100%;
1783
+ object-fit: cover;
1784
+ object-position: top left;
1785
+ }
1786
+
1787
+ .docs-thumb:hover .docs-thumb-frame,
1788
+ .docs-thumb:focus-visible .docs-thumb-frame {
1789
+ border-color: var(--gb-panel-foreground, #18181b);
1790
+ box-shadow: 0 0 0 2px var(--gb-border);
1791
+ }
1792
+
1793
+ .docs-thumb-badge {
1794
+ position: absolute;
1795
+ right: 7px;
1796
+ bottom: 7px;
1797
+ padding: 2px 7px;
1798
+ border-radius: 6px;
1799
+ color: #f4f4f5;
1800
+ background: rgba(39, 39, 42, 0.78);
1801
+ font: 500 12px/1.4 var(--gb-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
1802
+ font-variant-numeric: tabular-nums;
1803
+ pointer-events: none;
1804
+ }
1805
+
1806
+ .docs-thumb-card {
1807
+ position: absolute;
1808
+ top: calc(100% + 10px);
1809
+ left: 0;
1810
+ z-index: 60;
1811
+ display: block;
1812
+ width: min(var(--docs-thumb-card-width, 720px), calc(100vw - 32px));
1813
+ overflow: hidden;
1814
+ padding: 10px;
1815
+ border: 1px solid var(--gb-border);
1816
+ border-radius: 16px;
1817
+ background: var(--gb-panel);
1818
+ box-shadow: 0 18px 48px rgba(15, 23, 42, 0.22), 0 2px 8px rgba(15, 23, 42, 0.1);
1819
+ opacity: 0;
1820
+ visibility: hidden;
1821
+ transform: translateY(-4px);
1822
+ transition: opacity 0.14s ease, transform 0.14s ease, visibility 0.14s;
1823
+ pointer-events: none;
1824
+ }
1825
+
1826
+ .docs-thumb-card img {
1827
+ display: block;
1828
+ width: 100%;
1829
+ height: auto;
1830
+ border-radius: 8px;
1831
+ }
1832
+
1833
+ .docs-thumb-card .docs-thumb-badge {
1834
+ right: 18px;
1835
+ bottom: 18px;
1836
+ font-size: 14px;
1837
+ }
1838
+
1839
+ .docs-thumb:hover .docs-thumb-card,
1840
+ .docs-thumb:focus-visible .docs-thumb-card {
1841
+ opacity: 1;
1842
+ visibility: visible;
1843
+ transform: translateY(0);
1844
+ }
1845
+
1846
+ @media (prefers-reduced-motion: reduce) {
1847
+ .docs-thumb-card {
1848
+ transition: none;
1849
+ }
1850
+ }
1851
+
1715
1852
  .oas {
1716
1853
  border: 1px solid var(--gb-border);
1717
1854
  border-radius: 12px;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brett_lamy/docstream",
3
- "version": "1.17.0",
3
+ "version": "1.18.0",
4
4
  "description": "GitBook-aware readonly markdown and AI stream renderer.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -10,11 +10,11 @@ import {
10
10
  XCircle,
11
11
  } from "lucide-react"
12
12
 
13
- import type { Block, CodeBlockNode, CommandNode, DocumentNode, Inline, PackageManager, SourceRefNode } from "../gitbook/ast"
13
+ import type { Block, CodeBlockNode, CommandNode, DocumentNode, Inline, PackageManager, SourceRefNode, StepNode } from "../gitbook/ast"
14
14
  import { PACKAGE_MANAGERS, packageManagerCommands } from "../gitbook/package-managers"
15
15
  import { slugify } from "../gitbook/outline"
16
16
  import { resolveAsset, useAsset } from "../assets"
17
- import { parseMarkdown } from "../gitbook/parse"
17
+ import { parseBlocks, parseMarkdown } from "../gitbook/parse"
18
18
  import { serializeMarkdown } from "../gitbook/serialize"
19
19
  import { DocstreamDemoContext, DocstreamDemoRuntimeContext, DocstreamDemoVFSContext } from "../demo/context"
20
20
  import type { DemoVFS } from "../demo/vfs"
@@ -36,6 +36,7 @@ import { OpenApiOperation } from "../openapi/OpenApiOperation"
36
36
  import { Mermaid } from "./Mermaid"
37
37
  import { HighlightedCode } from "./HighlightedCode"
38
38
  import { CitationSources, InlineReference } from "./reference"
39
+ import { ImageThumbnail, leadingThumbnail, thumbnailLabel } from "./ImageThumbnail"
39
40
  import { DocumentProperties } from "./DocumentProperties"
40
41
  import { TaskCheckbox, TaskStateChangeContext, type TaskStateChangeHandler } from "./TaskCheckbox"
41
42
  import { getTaskState } from "../gitbook/tasks"
@@ -103,6 +104,8 @@ function InlineText({ nodes }: { nodes: Inline[] }) {
103
104
  if (!emphasized && !n.link) return <InlineReference key={i} node={n} />
104
105
  el = <InlineReference node={n} />
105
106
  } else if (n.type === "image") {
107
+ const label = thumbnailLabel(n)
108
+ if (label !== null) return <ImageThumbnail key={i} image={n} label={label} />
106
109
  const img = (
107
110
  <AssetImg
108
111
  url={n.src}
@@ -334,12 +337,25 @@ function Tabs({ block, liveRenderer, sourceRenderer }: { block: Extract<Block, {
334
337
  function BlockView({ block, liveRenderer, sourceRenderer }: { block: Block } & Renderers) {
335
338
  const onTaskStateChange = useContext(TaskStateChangeContext)
336
339
  switch (block.type) {
337
- case "paragraph":
340
+ case "paragraph": {
341
+ // A paragraph that opens with a thumbnail sets its text beside it.
342
+ const lead = leadingThumbnail(block.children)
343
+ if (lead) {
344
+ return (
345
+ <p className="docs-media">
346
+ <ImageThumbnail image={lead.image} label={lead.label} />
347
+ <span className="docs-media-body">
348
+ <InlineText nodes={lead.rest} />
349
+ </span>
350
+ </p>
351
+ )
352
+ }
338
353
  return (
339
354
  <p>
340
355
  <InlineText nodes={block.children} />
341
356
  </p>
342
357
  )
358
+ }
343
359
  case "heading": {
344
360
  const Tag = `h${block.level}` as const
345
361
  return (
@@ -426,10 +442,7 @@ function BlockView({ block, liveRenderer, sourceRenderer }: { block: Block } & R
426
442
  <div className="docs-step-badge">{i + 1}</div>
427
443
  {i < block.steps.length - 1 && <div className="docs-step-line" />}
428
444
  </div>
429
- <div className="docs-step-main">
430
- {s.title && <div className="docs-step-title">{s.title}</div>}
431
- <Blocks blocks={s.children} liveRenderer={liveRenderer} sourceRenderer={sourceRenderer} />
432
- </div>
445
+ <StepMain step={s} liveRenderer={liveRenderer} sourceRenderer={sourceRenderer} />
433
446
  </div>
434
447
  ))}
435
448
  </div>
@@ -601,6 +614,40 @@ function isReactLanguage(language: string | null): boolean {
601
614
  return language === "jsx" || language === "tsx" || language === "react"
602
615
  }
603
616
 
617
+ /**
618
+ * A step's title and body. A title heading that opens with a thumbnail image puts the image in a column
619
+ * of its own, with the title and the body beside it.
620
+ */
621
+ function StepMain({ step, liveRenderer, sourceRenderer }: { step: StepNode } & Renderers) {
622
+ const lead = useMemo(() => {
623
+ if (!step.heading) return null
624
+ const [heading] = parseBlocks(step.heading.split("\n").filter((line) => line.trim()))
625
+ return heading?.type === "heading" ? leadingThumbnail(heading.children) : null
626
+ }, [step.heading])
627
+ const body = <Blocks blocks={step.children} liveRenderer={liveRenderer} sourceRenderer={sourceRenderer} />
628
+ if (!lead) {
629
+ return (
630
+ <div className="docs-step-main">
631
+ {step.title && <div className="docs-step-title">{step.title}</div>}
632
+ {body}
633
+ </div>
634
+ )
635
+ }
636
+ return (
637
+ <div className="docs-step-main docs-step-media">
638
+ <ImageThumbnail image={lead.image} label={lead.label} />
639
+ <div className="docs-step-media-body">
640
+ {step.title && (
641
+ <div className="docs-step-title">
642
+ <InlineText nodes={lead.rest} />
643
+ </div>
644
+ )}
645
+ {body}
646
+ </div>
647
+ </div>
648
+ )
649
+ }
650
+
604
651
  function Blocks({ blocks, inline, liveRenderer, sourceRenderer }: { blocks: Block[]; inline?: boolean } & Renderers) {
605
652
  return (
606
653
  <div
@@ -0,0 +1,49 @@
1
+ import { useId } from "react"
2
+ import type { Inline, InlineImageNode } from "../gitbook/ast"
3
+ import { useAsset } from "../asset-resolver"
4
+ import { thumbnailLabel } from "../gitbook/thumbnail"
5
+
6
+ export { thumbnailLabel }
7
+
8
+ /** The paragraph's leading thumbnail (whitespace before it ignored) and the inlines after it. */
9
+ export function leadingThumbnail(nodes: Inline[]): { image: InlineImageNode; label: string; rest: Inline[] } | null {
10
+ const at = nodes.findIndex((n) => !(n.type === "text" && !n.text.trim()))
11
+ const first = nodes[at]
12
+ if (!first || first.type !== "image") return null
13
+ const label = thumbnailLabel(first)
14
+ if (label === null) return null
15
+ const rest = nodes.slice(at + 1)
16
+ // Drop the space that separated the image from the text.
17
+ if (rest[0]?.type === "text") rest[0] = { ...rest[0], text: rest[0].text.replace(/^\s+/, "") }
18
+ return { image: first, label, rest }
19
+ }
20
+
21
+ /**
22
+ * A small framed image with an optional badge (a timestamp, say); hovering or focusing it opens a card
23
+ * with the full image. First in a paragraph or list item, the text runs beside it.
24
+ */
25
+ export function ImageThumbnail({ image, label }: { image: InlineImageNode; label: string }) {
26
+ const src = useAsset(image.src)
27
+ const cardId = useId()
28
+ const alt = image.alt ?? ""
29
+ const badge = label ? <span className="docs-thumb-badge">{label}</span> : null
30
+ const thumb = (
31
+ <span className="docs-thumb" tabIndex={0} role="img" aria-label={alt || label || "Image"} aria-describedby={cardId}>
32
+ <span className="docs-thumb-frame">
33
+ <img src={src} alt="" loading="lazy" draggable={false} />
34
+ {badge}
35
+ </span>
36
+ <span className="docs-thumb-card" id={cardId} role="tooltip">
37
+ <img src={src} alt={alt} loading="lazy" />
38
+ {badge}
39
+ </span>
40
+ </span>
41
+ )
42
+ return image.link ? (
43
+ <a href={image.link} target="_blank" rel="noreferrer" className="docs-thumb-link">
44
+ {thumb}
45
+ </a>
46
+ ) : (
47
+ thumb
48
+ )
49
+ }
@@ -12,3 +12,4 @@ export { PACKAGE_MANAGERS, packageManagerCommands } from "./package-managers"
12
12
  export { flattenBlocks, flattenForPlainMarkdown } from "./flatten"
13
13
  export { documentOutline, slugify } from "./outline"
14
14
  export type { OutlineEntry } from "./outline"
15
+ export { stepTitleText, thumbnailLabel } from "./thumbnail"
@@ -1,3 +1,4 @@
1
+ import { stepTitleText } from "./thumbnail"
1
2
  import type {
2
3
  Block,
3
4
  CodeBlockNode,
@@ -1081,7 +1082,7 @@ function parseSteps(lines: string[]): { items: StepNode[]; end: number } {
1081
1082
  const heading = inner[0]?.type === "heading" ? inner[0] : undefined
1082
1083
  const step: StepNode = { type: "step", title, children: inner }
1083
1084
  if (heading) {
1084
- step.title = plainText(heading.children)
1085
+ step.title = stepTitleText(heading.children)
1085
1086
  inner.shift()
1086
1087
  // The title heading as written (level, formatting, blank lines before it), where not `### title`.
1087
1088
  const written = body.slice(0, (heading.gap ?? 0) + (heading.setext ? 2 : 1)).join("\n")
@@ -1,3 +1,4 @@
1
+ import { stepTitleText } from "./thumbnail"
1
2
  import type {
2
3
  Block,
3
4
  CitationDef,
@@ -94,7 +95,7 @@ function tagsFor(b: TaggedNode): [string, string] {
94
95
  function stepHeading(s: StepNode): string {
95
96
  if (s.heading !== undefined) {
96
97
  const again = parseBlocks(s.heading.split("\n"))
97
- if (again.length === 1 && again[0].type === "heading" && plainText(again[0].children) === s.title) return s.heading
98
+ if (again.length === 1 && again[0].type === "heading" && stepTitleText(again[0].children) === s.title) return s.heading
98
99
  }
99
100
  return s.title ? `### ${s.title}` : ""
100
101
  }
@@ -0,0 +1,20 @@
1
+ import type { Inline } from "./ast"
2
+ import { plainText } from "./inline"
3
+
4
+ const THUMBNAIL = /^thumbnail(?:\s*:\s*(.*))?$/i
5
+
6
+ /**
7
+ * The badge of a thumbnail image, or `null` when the image isn't one. An image is a thumbnail when its
8
+ * Markdown title starts with `thumbnail`: `![Overview](shot.jpg "thumbnail")`, or with a badge,
9
+ * `![Overview](shot.jpg "thumbnail: 0:39.8")`. Elsewhere the title is just a tooltip.
10
+ */
11
+ export function thumbnailLabel(node: Inline): string | null {
12
+ if (node.type !== "image" || !node.title) return null
13
+ const match = THUMBNAIL.exec(node.title.trim())
14
+ return match ? (match[1]?.trim() ?? "") : null
15
+ }
16
+
17
+ /** A step's title from its heading: the plain text, without thumbnail images (they show beside it). */
18
+ export function stepTitleText(nodes: Inline[]): string {
19
+ return plainText(nodes.filter((n) => thumbnailLabel(n) === null)).trim()
20
+ }
package/src/index.ts CHANGED
@@ -87,6 +87,7 @@ export type {
87
87
  export type { ReplayEvent } from "./replay/types"
88
88
  export { isReplayQaUrl, normalizeReplayEmbedUrl } from "./replay/url"
89
89
  export { ReplayFile, isRrwebUrl } from "./replay/ReplayFile"
90
+ export { ImageThumbnail, leadingThumbnail, thumbnailLabel } from "./docs/ImageThumbnail"
90
91
  export type { ReplayFileProps } from "./replay/ReplayFile"
91
92
  export { VideoEmbed } from "./video"
92
93
  export type { VideoEmbedProps } from "./video"
package/src/styles.css CHANGED
@@ -443,6 +443,25 @@
443
443
  margin: 4px 0 6px;
444
444
  }
445
445
 
446
+ /* A step whose heading opens with a thumbnail: the image in its own column, the title and body beside it. */
447
+ .docs-step-media {
448
+ display: flex;
449
+ align-items: flex-start;
450
+ gap: 16px;
451
+ }
452
+
453
+ .docs-step-media-body {
454
+ flex: 1;
455
+ min-width: 0;
456
+ }
457
+
458
+ @media (max-width: 560px) {
459
+ .docs-step-media {
460
+ flex-direction: column;
461
+ gap: 8px;
462
+ }
463
+ }
464
+
446
465
  .docs-embed {
447
466
  width: 100%;
448
467
  aspect-ratio: 16 / 9;
@@ -1712,6 +1731,124 @@
1712
1731
  text-decoration: none !important;
1713
1732
  }
1714
1733
 
1734
+ /* Thumbnail images (`![alt](src "thumbnail: 0:39.8")`): a small frame with a badge; hover or focus
1735
+ opens a card with the full image. First in a paragraph, the text runs beside it. */
1736
+ /* Float, not flex: the text keeps its own first line, so a list marker lines up with it. */
1737
+ .docs-media {
1738
+ display: flow-root;
1739
+ }
1740
+
1741
+ .docs-media > .docs-thumb,
1742
+ .docs-media > .docs-thumb-link {
1743
+ float: left;
1744
+ margin: 3px 14px 4px 0;
1745
+ }
1746
+
1747
+ .docs-media-body {
1748
+ display: block;
1749
+ overflow: hidden;
1750
+ }
1751
+
1752
+ .docs-thumb-link {
1753
+ text-decoration: none !important;
1754
+ }
1755
+
1756
+ .docs-thumb {
1757
+ position: relative;
1758
+ display: inline-block;
1759
+ flex: none;
1760
+ vertical-align: top;
1761
+ border-radius: 10px;
1762
+ outline: none;
1763
+ }
1764
+
1765
+ .docs-thumb-frame {
1766
+ position: relative;
1767
+ display: block;
1768
+ width: var(--docs-thumb-width, 168px);
1769
+ aspect-ratio: 16 / 10;
1770
+ overflow: hidden;
1771
+ border: 1px solid var(--gb-border);
1772
+ border-radius: 10px;
1773
+ background: var(--gb-muted, #f4f4f5);
1774
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
1775
+ cursor: zoom-in;
1776
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
1777
+ }
1778
+
1779
+ .docs-thumb-frame img {
1780
+ display: block;
1781
+ width: 100%;
1782
+ height: 100%;
1783
+ object-fit: cover;
1784
+ object-position: top left;
1785
+ }
1786
+
1787
+ .docs-thumb:hover .docs-thumb-frame,
1788
+ .docs-thumb:focus-visible .docs-thumb-frame {
1789
+ border-color: var(--gb-panel-foreground, #18181b);
1790
+ box-shadow: 0 0 0 2px var(--gb-border);
1791
+ }
1792
+
1793
+ .docs-thumb-badge {
1794
+ position: absolute;
1795
+ right: 7px;
1796
+ bottom: 7px;
1797
+ padding: 2px 7px;
1798
+ border-radius: 6px;
1799
+ color: #f4f4f5;
1800
+ background: rgba(39, 39, 42, 0.78);
1801
+ font: 500 12px/1.4 var(--gb-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
1802
+ font-variant-numeric: tabular-nums;
1803
+ pointer-events: none;
1804
+ }
1805
+
1806
+ .docs-thumb-card {
1807
+ position: absolute;
1808
+ top: calc(100% + 10px);
1809
+ left: 0;
1810
+ z-index: 60;
1811
+ display: block;
1812
+ width: min(var(--docs-thumb-card-width, 720px), calc(100vw - 32px));
1813
+ overflow: hidden;
1814
+ padding: 10px;
1815
+ border: 1px solid var(--gb-border);
1816
+ border-radius: 16px;
1817
+ background: var(--gb-panel);
1818
+ box-shadow: 0 18px 48px rgba(15, 23, 42, 0.22), 0 2px 8px rgba(15, 23, 42, 0.1);
1819
+ opacity: 0;
1820
+ visibility: hidden;
1821
+ transform: translateY(-4px);
1822
+ transition: opacity 0.14s ease, transform 0.14s ease, visibility 0.14s;
1823
+ pointer-events: none;
1824
+ }
1825
+
1826
+ .docs-thumb-card img {
1827
+ display: block;
1828
+ width: 100%;
1829
+ height: auto;
1830
+ border-radius: 8px;
1831
+ }
1832
+
1833
+ .docs-thumb-card .docs-thumb-badge {
1834
+ right: 18px;
1835
+ bottom: 18px;
1836
+ font-size: 14px;
1837
+ }
1838
+
1839
+ .docs-thumb:hover .docs-thumb-card,
1840
+ .docs-thumb:focus-visible .docs-thumb-card {
1841
+ opacity: 1;
1842
+ visibility: visible;
1843
+ transform: translateY(0);
1844
+ }
1845
+
1846
+ @media (prefers-reduced-motion: reduce) {
1847
+ .docs-thumb-card {
1848
+ transition: none;
1849
+ }
1850
+ }
1851
+
1715
1852
  .oas {
1716
1853
  border: 1px solid var(--gb-border);
1717
1854
  border-radius: 12px;