@brett_lamy/docstream 1.16.2 → 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.
Files changed (56) hide show
  1. package/README.md +46 -1
  2. package/dist/docs/DocsRenderer.d.ts.map +1 -1
  3. package/dist/docs/DocsRenderer.js +34 -3
  4. package/dist/docs/DocsRenderer.js.map +1 -1
  5. package/dist/docs/ImageThumbnail.d.ts +18 -0
  6. package/dist/docs/ImageThumbnail.d.ts.map +1 -0
  7. package/dist/docs/ImageThumbnail.js +33 -0
  8. package/dist/docs/ImageThumbnail.js.map +1 -0
  9. package/dist/gitbook/index.d.ts +1 -0
  10. package/dist/gitbook/index.d.ts.map +1 -1
  11. package/dist/gitbook/index.js +1 -0
  12. package/dist/gitbook/index.js.map +1 -1
  13. package/dist/gitbook/parse.d.ts.map +1 -1
  14. package/dist/gitbook/parse.js +3 -2
  15. package/dist/gitbook/parse.js.map +1 -1
  16. package/dist/gitbook/serialize.d.ts.map +1 -1
  17. package/dist/gitbook/serialize.js +2 -2
  18. package/dist/gitbook/serialize.js.map +1 -1
  19. package/dist/gitbook/thumbnail.d.ts +10 -0
  20. package/dist/gitbook/thumbnail.d.ts.map +1 -0
  21. package/dist/gitbook/thumbnail.js +18 -0
  22. package/dist/gitbook/thumbnail.js.map +1 -0
  23. package/dist/index.d.ts +3 -0
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +2 -0
  26. package/dist/index.js.map +1 -1
  27. package/dist/replay/ReplayFile.d.ts +14 -0
  28. package/dist/replay/ReplayFile.d.ts.map +1 -0
  29. package/dist/replay/ReplayFile.js +33 -0
  30. package/dist/replay/ReplayFile.js.map +1 -0
  31. package/dist/replay/ReplayPlayer.d.ts +5 -0
  32. package/dist/replay/ReplayPlayer.d.ts.map +1 -1
  33. package/dist/replay/ReplayPlayer.js +11 -5
  34. package/dist/replay/ReplayPlayer.js.map +1 -1
  35. package/dist/replay/index.d.ts +2 -0
  36. package/dist/replay/index.d.ts.map +1 -1
  37. package/dist/replay/index.js +1 -0
  38. package/dist/replay/index.js.map +1 -1
  39. package/dist/styles.css +152 -4
  40. package/dist/vite/index.d.ts +8 -0
  41. package/dist/vite/index.d.ts.map +1 -1
  42. package/dist/vite/index.js +22 -3
  43. package/dist/vite/index.js.map +1 -1
  44. package/package.json +1 -1
  45. package/src/docs/DocsRenderer.tsx +58 -7
  46. package/src/docs/ImageThumbnail.tsx +49 -0
  47. package/src/gitbook/index.ts +1 -0
  48. package/src/gitbook/parse.ts +2 -1
  49. package/src/gitbook/serialize.ts +2 -1
  50. package/src/gitbook/thumbnail.ts +20 -0
  51. package/src/index.ts +3 -0
  52. package/src/replay/ReplayFile.tsx +39 -0
  53. package/src/replay/ReplayPlayer.tsx +14 -7
  54. package/src/replay/index.ts +2 -0
  55. package/src/styles.css +152 -4
  56. package/src/vite/index.ts +28 -3
@@ -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
@@ -86,6 +86,9 @@ export type {
86
86
  } from "./replay/ReplayPreview"
87
87
  export type { ReplayEvent } from "./replay/types"
88
88
  export { isReplayQaUrl, normalizeReplayEmbedUrl } from "./replay/url"
89
+ export { ReplayFile, isRrwebUrl } from "./replay/ReplayFile"
90
+ export { ImageThumbnail, leadingThumbnail, thumbnailLabel } from "./docs/ImageThumbnail"
91
+ export type { ReplayFileProps } from "./replay/ReplayFile"
89
92
  export { VideoEmbed } from "./video"
90
93
  export type { VideoEmbedProps } from "./video"
91
94
  export { StlViewer, isStlUrl, parseStl } from "./stl"
@@ -0,0 +1,39 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { useAsset } from '../asset-resolver';
3
+ import { ReplayPreview } from './ReplayPreview';
4
+ import type { ReplayPlayerOptions } from './ReplayPlayer';
5
+
6
+ export interface ReplayFileProps extends Omit<ReplayPlayerOptions, 'url'> {
7
+ /** The `.rrweb` file as written in the document; the host's asset resolver maps it to a loadable URL. */
8
+ url: string;
9
+ }
10
+
11
+ /**
12
+ * An rrweb recording file (a JSON event array, or `{ events }`), played in the replay browser frame.
13
+ * The URL goes through `AssetResolverProvider`, and the file is fetched only once the frame nears the
14
+ * viewport, so a page of recordings doesn't download them all up front.
15
+ */
16
+ export function ReplayFile({ url, ...options }: ReplayFileProps) {
17
+ const src = useAsset(url);
18
+ const ref = useRef<HTMLDivElement>(null);
19
+ const [near, setNear] = useState(false);
20
+
21
+ useEffect(() => {
22
+ if (near) return;
23
+ const el = ref.current;
24
+ if (!el || typeof IntersectionObserver === 'undefined') return setNear(true);
25
+ const io = new IntersectionObserver((entries) => entries.some((e) => e.isIntersecting) && setNear(true), { rootMargin: '240px' });
26
+ io.observe(el);
27
+ return () => io.disconnect();
28
+ }, [near]);
29
+
30
+ if (!near || src === undefined) return <div ref={ref} className="docstream-asset-pending" data-slot="replay-file" aria-busy="true" />;
31
+ return <ReplayPreview source={{ kind: 'events-url', url: src }} chrome="browser" {...options} />;
32
+ }
33
+
34
+ const RRWEB = /\.rrweb(?:[?#]|$)/i;
35
+
36
+ /** True for an rrweb recording file: a URL whose path ends in `.rrweb`. */
37
+ export function isRrwebUrl(url: string): boolean {
38
+ return RRWEB.test(url.trim());
39
+ }
@@ -294,11 +294,18 @@ export function ReplayPlayer({
294
294
 
295
295
  /* ── parts ───────────────────────────────────────────────────────────────────────── */
296
296
 
297
- export function BrowserBar({ href, trafficLights, loading }: { href: string; trafficLights: boolean; loading: boolean }) {
297
+ /** Splits an address for the URL bar: the host, and the path + query + hash after it. */
298
+ export function splitReplayAddress(href: string): { host: string; path: string } {
298
299
  const shown = href.replace(/^https?:\/\//, '')
299
- const slash = shown.indexOf('/')
300
- const host = slash < 0 ? shown : shown.slice(0, slash)
301
- const path = slash < 0 ? '' : shown.slice(slash)
300
+ const cut = shown.search(/[/?#]/)
301
+ return cut < 0 ? { host: shown, path: '' } : { host: shown.slice(0, cut), path: shown.slice(cut) }
302
+ }
303
+
304
+ export function BrowserBar({ href, trafficLights, loading }: { href: string; trafficLights: boolean; loading: boolean }) {
305
+ const { host, path } = splitReplayAddress(href)
306
+ // Once the recording is past the site root, where it is (path, query, hash) is what matters:
307
+ // it leads and keeps its room, and the host dims and gives way first.
308
+ const primary = path && path !== '/' ? 'path' : 'host'
302
309
  return (
303
310
  <div data-slot="replay-preview-bar">
304
311
  {trafficLights ? (
@@ -310,9 +317,9 @@ export function BrowserBar({ href, trafficLights, loading }: { href: string; tra
310
317
  <span data-slot="replay-preview-lock" aria-hidden="true">
311
318
  {loading ? <span data-slot="replay-preview-spinner" /> : <Lock size={10} strokeWidth={2.5} />}
312
319
  </span>
313
- <span data-slot="replay-preview-url" title={href}>
314
- <span>{host}</span>
315
- <span>{path}</span>
320
+ <span data-slot="replay-preview-url" data-primary={primary} title={href}>
321
+ <span data-part="host">{host}</span>
322
+ {path ? <span data-part="path">{path}</span> : null}
316
323
  </span>
317
324
  </div>
318
325
  {trafficLights ? <span aria-hidden="true" data-slot="replay-preview-lights-spacer" /> : null}
@@ -18,4 +18,6 @@ export type { ReplayMarker, ReplayMarkerKind, ReplayMeta, ReplayPointerPoint, Re
18
18
  export { ReplayEventType, ReplayIncrementalSource, ReplayMouseInteraction } from './types';
19
19
  export type { ReplayEvent } from './types';
20
20
  export { isReplayQaUrl, normalizeReplayEmbedUrl } from './url';
21
+ export { ReplayFile, isRrwebUrl } from './ReplayFile';
22
+ export type { ReplayFileProps } from './ReplayFile';
21
23
  export { sanitizeReplayEvent } from './sanitize';
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;
@@ -3718,18 +3855,29 @@
3718
3855
  }
3719
3856
 
3720
3857
  :where([data-slot="replay-preview-url"]) {
3858
+ display: flex;
3721
3859
  min-width: 0;
3722
3860
  overflow: hidden;
3723
- text-overflow: ellipsis;
3724
3861
  white-space: nowrap;
3725
3862
  }
3726
3863
 
3727
- :where([data-slot="replay-preview-url"] > :first-child) {
3864
+ :where([data-slot="replay-preview-url"] > span) {
3865
+ min-width: 0;
3866
+ overflow: hidden;
3867
+ text-overflow: ellipsis;
3868
+ color: var(--muted-foreground, #71717a);
3869
+ }
3870
+
3871
+ /* The primary part is full strength; when space runs out the other one gives way first. */
3872
+ :where([data-slot="replay-preview-url"][data-primary="host"] > [data-part="host"]),
3873
+ :where([data-slot="replay-preview-url"][data-primary="path"] > [data-part="path"]) {
3874
+ flex-shrink: 1;
3728
3875
  color: var(--foreground, #18181b);
3729
3876
  }
3730
3877
 
3731
- :where([data-slot="replay-preview-url"] > :last-child) {
3732
- color: var(--muted-foreground, #71717a);
3878
+ :where([data-slot="replay-preview-url"][data-primary="host"] > [data-part="path"]),
3879
+ :where([data-slot="replay-preview-url"][data-primary="path"] > [data-part="host"]) {
3880
+ flex-shrink: 10000;
3733
3881
  }
3734
3882
 
3735
3883
  :where([data-slot="replay-preview-spinner"]) {
package/src/vite/index.ts CHANGED
@@ -10,6 +10,11 @@ export interface DocstreamSourceMount {
10
10
  root: string
11
11
  /** Allow PUT requests to edit files in this mount. Defaults to true. */
12
12
  writable?: boolean
13
+ /**
14
+ * Reload the page after a PUT. Defaults to true. Turn it off for content files whose importers
15
+ * hot-update themselves (an editor autosaving Markdown), so a save doesn't reload the editor.
16
+ */
17
+ reloadOnWrite?: boolean
13
18
  }
14
19
 
15
20
  export interface DocstreamSourcesOptions {
@@ -22,6 +27,7 @@ interface ResolvedMount {
22
27
  name: string
23
28
  root: string
24
29
  writable: boolean
30
+ reloadOnWrite: boolean
25
31
  }
26
32
 
27
33
  const VIRTUAL_ID = "virtual:docstream-sources"
@@ -38,10 +44,14 @@ function requestUrl(request: IncomingMessage): URL {
38
44
  return new URL(request.url ?? "/", "http://docstream.local")
39
45
  }
40
46
 
41
- async function requestBody(request: IncomingMessage): Promise<string> {
47
+ async function requestBytes(request: IncomingMessage): Promise<Buffer> {
42
48
  const chunks: Buffer[] = []
43
49
  for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
44
- return Buffer.concat(chunks).toString("utf8")
50
+ return Buffer.concat(chunks)
51
+ }
52
+
53
+ async function requestBody(request: IncomingMessage): Promise<string> {
54
+ return (await requestBytes(request)).toString("utf8")
45
55
  }
46
56
 
47
57
  function normalizeRelativePath(value: string): string {
@@ -113,10 +123,21 @@ function installMiddleware(server: ViteDevServer, apiBase: string, viteBase: str
113
123
  await fs.writeFile(absolutePath, parsed.content, "utf8")
114
124
  const stat = await fs.stat(absolutePath)
115
125
  server.watcher.add(absolutePath)
116
- server.ws.send({ type: "full-reload", path: "*" })
126
+ if (mount.reloadOnWrite) server.ws.send({ type: "full-reload", path: "*" })
117
127
  return send(response, 200, JSON.stringify({ content: parsed.content, provenance: provenance(url, mount, relativePath, absolutePath, stat.mtimeMs) }))
118
128
  }
119
129
 
130
+ if (request.method === "POST") {
131
+ // Upload: create (or replace) a file with the raw request body, e.g. a dropped .rrweb recording.
132
+ if (!mount.writable) return send(response, 403, `Source mount ${mount.name} is read-only`, "text/plain; charset=utf-8")
133
+ const bytes = await requestBytes(request)
134
+ await fs.mkdir(path.dirname(absolutePath), { recursive: true })
135
+ await fs.writeFile(absolutePath, bytes)
136
+ const stat = await fs.stat(absolutePath)
137
+ server.watcher.add(absolutePath)
138
+ return send(response, 201, JSON.stringify({ size: stat.size, provenance: provenance(url, mount, relativePath, absolutePath, stat.mtimeMs) }))
139
+ }
140
+
120
141
  return send(response, 405, "Method not allowed", "text/plain; charset=utf-8")
121
142
  } catch (error) {
122
143
  const status = error && typeof error === "object" && "code" in error && (error as { code?: string }).code === "ENOENT" ? 404 : 400
@@ -129,6 +150,9 @@ function installMiddleware(server: ViteDevServer, apiBase: string, viteBase: str
129
150
  * Mount real source directories behind safe Vite dev-server endpoints.
130
151
  * Markdown references import live modules from these files, and editor writes
131
152
  * go back to disk so Vite's normal watcher/HMR pipeline remains authoritative.
153
+ *
154
+ * `GET {apiBase}/source?mount=&path=` reads a file, `PUT` (JSON `{ content }`) edits an existing one,
155
+ * and `POST` (raw body) creates or replaces one — uploads such as `.rrweb` recordings.
132
156
  */
133
157
  export function docstreamSources(options: DocstreamSourcesOptions): Plugin {
134
158
  const apiBase = `/${(options.apiBase ?? "/@docstream").replace(/^\/+|\/+$/g, "")}`
@@ -148,6 +172,7 @@ export function docstreamSources(options: DocstreamSourcesOptions): Plugin {
148
172
  name: mount.name,
149
173
  root: path.resolve(config.root, mount.root),
150
174
  writable: mount.writable !== false,
175
+ reloadOnWrite: mount.reloadOnWrite !== false,
151
176
  }
152
177
  })
153
178
  },