@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.
- package/README.md +46 -1
- package/dist/docs/DocsRenderer.d.ts.map +1 -1
- package/dist/docs/DocsRenderer.js +34 -3
- package/dist/docs/DocsRenderer.js.map +1 -1
- package/dist/docs/ImageThumbnail.d.ts +18 -0
- package/dist/docs/ImageThumbnail.d.ts.map +1 -0
- package/dist/docs/ImageThumbnail.js +33 -0
- package/dist/docs/ImageThumbnail.js.map +1 -0
- package/dist/gitbook/index.d.ts +1 -0
- package/dist/gitbook/index.d.ts.map +1 -1
- package/dist/gitbook/index.js +1 -0
- package/dist/gitbook/index.js.map +1 -1
- package/dist/gitbook/parse.d.ts.map +1 -1
- package/dist/gitbook/parse.js +3 -2
- package/dist/gitbook/parse.js.map +1 -1
- package/dist/gitbook/serialize.d.ts.map +1 -1
- package/dist/gitbook/serialize.js +2 -2
- package/dist/gitbook/serialize.js.map +1 -1
- package/dist/gitbook/thumbnail.d.ts +10 -0
- package/dist/gitbook/thumbnail.d.ts.map +1 -0
- package/dist/gitbook/thumbnail.js +18 -0
- package/dist/gitbook/thumbnail.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/replay/ReplayFile.d.ts +14 -0
- package/dist/replay/ReplayFile.d.ts.map +1 -0
- package/dist/replay/ReplayFile.js +33 -0
- package/dist/replay/ReplayFile.js.map +1 -0
- package/dist/replay/ReplayPlayer.d.ts +5 -0
- package/dist/replay/ReplayPlayer.d.ts.map +1 -1
- package/dist/replay/ReplayPlayer.js +11 -5
- package/dist/replay/ReplayPlayer.js.map +1 -1
- package/dist/replay/index.d.ts +2 -0
- package/dist/replay/index.d.ts.map +1 -1
- package/dist/replay/index.js +1 -0
- package/dist/replay/index.js.map +1 -1
- package/dist/styles.css +152 -4
- package/dist/vite/index.d.ts +8 -0
- package/dist/vite/index.d.ts.map +1 -1
- package/dist/vite/index.js +22 -3
- package/dist/vite/index.js.map +1 -1
- package/package.json +1 -1
- package/src/docs/DocsRenderer.tsx +58 -7
- package/src/docs/ImageThumbnail.tsx +49 -0
- package/src/gitbook/index.ts +1 -0
- package/src/gitbook/parse.ts +2 -1
- package/src/gitbook/serialize.ts +2 -1
- package/src/gitbook/thumbnail.ts +20 -0
- package/src/index.ts +3 -0
- package/src/replay/ReplayFile.tsx +39 -0
- package/src/replay/ReplayPlayer.tsx +14 -7
- package/src/replay/index.ts +2 -0
- package/src/styles.css +152 -4
- package/src/vite/index.ts +28 -3
package/src/gitbook/parse.ts
CHANGED
|
@@ -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 =
|
|
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")
|
package/src/gitbook/serialize.ts
CHANGED
|
@@ -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" &&
|
|
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`: ``, or with a badge,
|
|
9
|
+
* ``. 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
|
-
|
|
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
|
|
300
|
-
|
|
301
|
-
|
|
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}
|
package/src/replay/index.ts
CHANGED
|
@@ -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 (``): 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"] >
|
|
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"] >
|
|
3732
|
-
|
|
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
|
|
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)
|
|
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
|
},
|