promptslide 0.3.17 → 0.3.19

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.
@@ -13,6 +13,7 @@ import { DEFAULT_SLIDE_TRANSITION, getSlideVariants } from "./transitions"
13
13
  // =============================================================================
14
14
 
15
15
  export interface SlideRendererProps {
16
+ playbackKey?: number
16
17
  slides: SlideConfig[]
17
18
  currentSlide: number
18
19
  animationStep: number
@@ -33,6 +34,7 @@ export interface SlideRendererProps {
33
34
  * Extracted from SlideDeck so it can be reused in SlideEmbed and other contexts.
34
35
  */
35
36
  export function SlideRenderer({
37
+ playbackKey,
36
38
  slides,
37
39
  currentSlide,
38
40
  animationStep,
@@ -58,6 +60,7 @@ export function SlideRenderer({
58
60
  <AnimatePresence initial={false}>
59
61
  <motion.div
60
62
  key={currentSlide}
63
+ data-slide-render-index={currentSlide}
61
64
  variants={slideVariants}
62
65
  initial="enter"
63
66
  animate="center"
@@ -75,11 +78,9 @@ export function SlideRenderer({
75
78
  totalSteps={totalSteps}
76
79
  showAllAnimations={showAllAnimations}
77
80
  >
78
- <SlideErrorBoundary
79
- slideIndex={currentSlide}
80
- slideTitle={slides[currentSlide]?.title}
81
- >
81
+ <SlideErrorBoundary slideIndex={currentSlide} slideTitle={slides[currentSlide]?.title}>
82
82
  <CurrentSlideComponent
83
+ key={playbackKey}
83
84
  slideNumber={currentSlide + 1}
84
85
  totalSlides={slides.length}
85
86
  />
@@ -0,0 +1,38 @@
1
+ import type { ComponentPropsWithoutRef } from "react"
2
+
3
+ import { cn } from "./utils"
4
+
5
+ type ToolbarActionProps = Omit<
6
+ ComponentPropsWithoutRef<"button">,
7
+ "aria-label" | "aria-pressed" | "className"
8
+ > & {
9
+ label: string
10
+ selected?: boolean
11
+ }
12
+
13
+ /** Shared icon action for both the viewer toolbar and the editor's toolbar portal. */
14
+ export function ToolbarAction({
15
+ label,
16
+ selected,
17
+ title = label,
18
+ children,
19
+ ...props
20
+ }: ToolbarActionProps) {
21
+ return (
22
+ <button
23
+ {...props}
24
+ type="button"
25
+ aria-label={label}
26
+ aria-pressed={selected}
27
+ title={title}
28
+ className={cn(
29
+ "relative inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-transparent transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#FF6B35] disabled:cursor-default disabled:opacity-35 [&>svg]:h-4 [&>svg]:w-4 [&>svg]:shrink-0",
30
+ selected
31
+ ? "border-[#FF6B35]/50 bg-[#FF6B35]/15 text-[#FF6B35] enabled:hover:border-[#FF6B35] enabled:hover:bg-[#FF6B35]/25"
32
+ : "text-neutral-400 enabled:hover:bg-neutral-800 enabled:hover:text-white"
33
+ )}
34
+ >
35
+ {children}
36
+ </button>
37
+ )
38
+ }
package/src/core/types.ts CHANGED
@@ -136,3 +136,17 @@ export interface LayoutBaseProps extends SlideProps {
136
136
  hideFooter?: boolean
137
137
  className?: string
138
138
  }
139
+
140
+ /** Durations in seconds, including the initial state at dwell[0]. */
141
+ export interface LoopSlideConfig {
142
+ excluded?: boolean
143
+ /** Step count when these timings were edited; used to flag changes. */
144
+ steps?: number
145
+ dwell?: (number | null)[]
146
+ }
147
+
148
+ export interface LoopConfig {
149
+ version: 1
150
+ defaultDuration: number
151
+ slides: Record<string, LoopSlideConfig>
152
+ }
@@ -67,6 +67,8 @@ export interface UseSlideNavigationReturn {
67
67
  advance: () => void
68
68
  goBack: () => void
69
69
  goToSlide: (index: number) => void
70
+ goToStep: (step: number) => void
71
+ restartAtSlide: (index: number) => void
70
72
  onTransitionComplete: () => void
71
73
  }
72
74
 
@@ -206,6 +208,29 @@ export function useSlideNavigation({
206
208
  [currentSlide, slides.length, onSlideChange]
207
209
  )
208
210
 
211
+ const goToStep = useCallback(
212
+ (step: number) => {
213
+ if (!Number.isInteger(step)) return
214
+ setQueuedAction(null)
215
+ setAnimationStep(Math.max(0, Math.min(step, slides[currentSlide]?.steps ?? 0)))
216
+ setNavState(previous => ({ ...previous, direction: 0 }))
217
+ },
218
+ [slides, currentSlide]
219
+ )
220
+
221
+ const restartAtSlide = useCallback(
222
+ (index: number) => {
223
+ if (index < 0 || index >= slides.length) return
224
+ setQueuedAction(null)
225
+ setAnimationStep(0)
226
+ setCurrentSlide(index)
227
+ // Same-slide restarts do not have a transition to complete.
228
+ setNavState({ status: index === currentSlide ? "idle" : "transitioning", direction: 1 })
229
+ onSlideChange?.(index)
230
+ },
231
+ [slides.length, currentSlide, onSlideChange]
232
+ )
233
+
209
234
  useEffect(() => {
210
235
  if (navState.status === "idle" && queuedAction !== null) {
211
236
  setQueuedAction(null)
@@ -227,6 +252,8 @@ export function useSlideNavigation({
227
252
  advance,
228
253
  goBack,
229
254
  goToSlide,
255
+ goToStep,
256
+ restartAtSlide,
230
257
  onTransitionComplete
231
258
  }
232
259
  }
@@ -0,0 +1,130 @@
1
+ import { Check, ChevronDown, Grid3X3, List, Monitor } from "lucide-react"
2
+ import { useEffect, useId, useLayoutEffect, useRef, useState } from "react"
3
+
4
+ import { cn } from "./utils"
5
+
6
+ export type ViewMode = "slide" | "list" | "grid"
7
+
8
+ const views = [
9
+ { mode: "slide", label: "Presentation view", shortcut: "V", icon: Monitor },
10
+ { mode: "list", label: "List view", shortcut: "L", icon: List },
11
+ { mode: "grid", label: "Grid view", shortcut: "G", icon: Grid3X3 }
12
+ ] as const
13
+
14
+ export function ViewMenu({
15
+ value,
16
+ onChange,
17
+ disabled
18
+ }: {
19
+ value: ViewMode
20
+ onChange: (mode: ViewMode) => void
21
+ disabled: boolean
22
+ }) {
23
+ const [open, setOpen] = useState(false)
24
+ const id = useId()
25
+ const root = useRef<HTMLDivElement>(null)
26
+ const trigger = useRef<HTMLButtonElement>(null)
27
+ const active = views.find(view => view.mode === value)!
28
+ const Icon = active.icon
29
+
30
+ useLayoutEffect(() => {
31
+ if (open) root.current?.querySelector<HTMLElement>('[aria-checked="true"]')?.focus()
32
+ }, [open])
33
+ useEffect(() => {
34
+ if (!open) return
35
+ const close = (event: PointerEvent) => {
36
+ if (!root.current?.contains(event.target as Node)) setOpen(false)
37
+ }
38
+ document.addEventListener("pointerdown", close)
39
+ return () => document.removeEventListener("pointerdown", close)
40
+ }, [open])
41
+
42
+ return (
43
+ <div
44
+ role="group"
45
+ aria-label="View selector"
46
+ ref={root}
47
+ className="relative flex"
48
+ onBlur={event => {
49
+ const next = event.relatedTarget
50
+ if (next && !event.currentTarget.contains(next)) setOpen(false)
51
+ }}
52
+ onKeyDown={event => {
53
+ // Toolbar navigation must not trigger slide advancement or deck shortcuts.
54
+ event.stopPropagation()
55
+ if (event.key === "Escape") {
56
+ setOpen(false)
57
+ trigger.current?.focus()
58
+ } else if (open && event.key === "Tab") {
59
+ trigger.current?.focus()
60
+ setOpen(false)
61
+ } else if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
62
+ event.preventDefault()
63
+ if (!open) {
64
+ setOpen(true)
65
+ return
66
+ }
67
+ const items = Array.from(
68
+ root.current!.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]')
69
+ )
70
+ const index = items.indexOf(document.activeElement as HTMLButtonElement)
71
+ const next =
72
+ event.key === "Home"
73
+ ? 0
74
+ : event.key === "End"
75
+ ? items.length - 1
76
+ : (index + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length
77
+ items[next]?.focus()
78
+ }
79
+ }}
80
+ >
81
+ <button
82
+ ref={trigger}
83
+ disabled={disabled}
84
+ aria-label="View options"
85
+ title={`View options · ${active.label}`}
86
+ aria-haspopup="menu"
87
+ aria-expanded={open}
88
+ aria-controls={open ? id : undefined}
89
+ className="inline-flex h-8 items-center gap-1.5 rounded-md bg-neutral-800 px-2 text-white hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-orange-500"
90
+ onClick={() => setOpen(previous => !previous)}
91
+ >
92
+ <Icon size={16} />
93
+ <ChevronDown size={12} />
94
+ </button>
95
+ {open && (
96
+ <div
97
+ id={id}
98
+ role="menu"
99
+ aria-label="View options"
100
+ className="absolute left-0 top-10 w-56 rounded-lg border border-neutral-700 bg-neutral-950 p-1 text-sm text-white"
101
+ >
102
+ {views.map(({ mode, label, shortcut, icon: OptionIcon }) => (
103
+ <button
104
+ key={mode}
105
+ role="menuitemradio"
106
+ aria-checked={mode === value}
107
+ tabIndex={-1}
108
+ className={cn(
109
+ "flex w-full items-center gap-2 rounded px-2 py-2 text-left text-neutral-300 hover:bg-neutral-800 focus:bg-neutral-800 focus:outline-none",
110
+ mode === value && "text-white"
111
+ )}
112
+ onClick={() => {
113
+ onChange(mode)
114
+ setOpen(false)
115
+ trigger.current?.focus()
116
+ }}
117
+ >
118
+ <OptionIcon size={16} />
119
+ <span className="flex-1">{label}</span>
120
+ <span aria-hidden="true" className="text-xs text-neutral-500">
121
+ {shortcut}
122
+ </span>
123
+ <Check size={14} className={mode === value ? "text-orange-400" : "invisible"} />
124
+ </button>
125
+ ))}
126
+ </div>
127
+ )}
128
+ </div>
129
+ )
130
+ }
@@ -0,0 +1,45 @@
1
+ # Development source editor
2
+
3
+ Run `promptslide studio` and choose **Edit** in the top-right toolbar, or **Elements** in the shared inspector. The core viewer owns the inspector's tabs, docking, and close control; the editor portals its tools into the Elements slot. The editor is injected by the development Vite plugin. Production builds, registry previews, embeds, and export pages do not load it. No editable-component migration is needed.
4
+
5
+ ## Workflow
6
+
7
+ - Double-click literal text to edit it in place. Select an existing element, or use Slide elements. Shift-click selects several elements. Double-click a selected group to reach the element under the pointer; click outside the slide to deselect.
8
+ - Edit literal text and title/subtitle/eyebrow properties, appearance, image URLs, or public asset paths in Design. Arrange contains positioning and alignment controls.
9
+ - Drag selection outlines to move. The lower handle resizes; the upper handle rotates. Arrow keys nudge by one design pixel, Shift by ten. Alt disables snapping. All geometry is measured in the 1280 × 720 design canvas.
10
+ - Align selected elements along either axis or distribute three or more with equal gaps. Bring forward / Send back set stacking order.
11
+ - For images, adjust the frame size, objectFit, objectPosition, and inset crop.
12
+ - Select a range in Text and apply bold, italic, or underline. Save existing text changes first. Range formatting creates ordinary styled JSX spans, which can subsequently be selected individually.
13
+ - Structure supports inserting text, shapes, and images into JSX containers and duplicating/deleting direct JSX children. Root components and expressions cannot be deleted. Duplicates preview immediately and support Undo and Discard; save to edit copies independently. Insertions render after Save; range formatting previews immediately.
14
+ - Switching from Elements to Slide settings or Comments uses the same Save / Discard / Keep editing guard as closing the editor. Keeping editing (or cancelling the dialog) cancels the pending panel switch. Loop timing drafts stay in the viewer across panel changes and source saves.
15
+ - Undo/redo affects the buffered draft. Save batches edits to source; Discard restores the source rendering. Close requires resolving unsaved changes. The browser tab retains its draft across hot reloads. Export draft downloads the pending operations for recovery.
16
+ - Editing uses the original viewer canvas, styling, and animation wrappers. Leave Edit to reveal steps normally; opening Edit preserves the currently visible content.
17
+
18
+ ## Source semantics
19
+
20
+ Instrumentation adds source coordinates and a SHA-256 file revision only to the transformed development module. The original TSX is unchanged until Save. Intrinsic DOM elements and `motion.*` elements are supported. Source files must resolve inside the project's `src/` directory; symlinks outside it are rejected.
21
+
22
+ A style change belongs to a JSX definition, so it affects all instances of that definition, including shared layouts. Movement uses the individual CSS `translate` property, preserving normal flex/grid flow and existing Motion transforms; rotation uses `rotate`. Resizing sets explicit frame dimensions. It does not convert the deck into an absolute-positioned document.
23
+
24
+ For direct local `array.map(item => <p>{item.title}</p>)` expressions over literal object arrays, text edits address the selected array item. Imported arrays, spreads, ambiguous bindings, computed expressions, and mixed children are intentionally read-only. Edit those expressions in code. Simple local component text props are resolved automatically when their rendered text is selected, without requiring the component to forward DOM metadata. Ambiguous or imported prop relationships remain unsupported. Shared appearance controls require opting in when multiple matching instances are present on the current slide.
25
+
26
+ Every batch checks all file revisions and validates all resulting TSX syntax before writing. Stale edits receive HTTP 409. Export or discard the draft and reselect from the current source; there is no force-overwrite button. Deleting an element discards pending edits on that element and its descendants. Other parent/child structural edits that overlap must be saved separately. This is optimistic concurrency protection, not a filesystem transaction across external processes. A save failure rolls back files still matching this operation's output.
27
+
28
+ The endpoint uses a per-server random token, checks cross-site fetches, limits payload size and operation counts, and only runs in Vite serve mode. Studio enables editing on its default loopback interface. Passing `--host` (including `--host=...`) disables the editor, source instrumentation, and editor endpoint so shared previews cannot edit source files. The client token is a request guard, not authentication for network clients.
29
+
30
+ ## Verification
31
+
32
+ - `node --test packages/cli/test/editor.test.mjs`: source edits, escaping, conflicts, batch validation, path restrictions, and array instances.
33
+ - `node packages/cli/test/editor.browser.mjs`: isolated temporary deck; text/history, save/reload, concurrent edits, scaled dragging, and production bundle exclusion. Requires the package's Playwright Chromium installation.
34
+ - `tsc -p packages/cli/tsconfig.json --noEmit`: editor and engine types.
35
+
36
+ Implementation uses TypeScript's JSX parser and original-range patches. No OpenSlide source code is vendored.
37
+
38
+ ## Inspector protocol
39
+
40
+ The viewer's inspector is part of the production core; the source editor remains development-only.
41
+ `EditorLauncher` discovers the existing toolbar, panel, canvas, and overlay slots, announces
42
+ `promptslide:editor-ready`, and handles `promptslide:editor-request` to open or request a guarded
43
+ close. `promptslide:editor-state` acknowledges actual open/closed state. The viewer commits a
44
+ pending tab change only after a closed acknowledgement; `promptslide:editor-close-cancelled`
45
+ clears that pending change. Source edits and their history stay in the editor's own React root.
@@ -0,0 +1,76 @@
1
+ export interface Rect {
2
+ x: number
3
+ y: number
4
+ width: number
5
+ height: number
6
+ }
7
+ export function translated(value: string, dx: number, dy: number) {
8
+ // Computed translate may contain percentages or calc() with spaces. Keep
9
+ // those units so movement does not remove centering or break after resizing.
10
+ const axes: string[] = []
11
+ let depth = 0,
12
+ start = 0
13
+ const input = value.trim()
14
+ for (let i = 0; i <= input.length; i++) {
15
+ const char = input[i]
16
+ if (char === "(") depth++
17
+ if (char === ")") depth--
18
+ if (i === input.length || (depth === 0 && /\s/.test(char!))) {
19
+ if (i > start) axes.push(input.slice(start, i))
20
+ start = i + 1
21
+ }
22
+ }
23
+ if (axes[0] === "none") axes.length = 0
24
+ const offset = (axis: string, delta: number) => {
25
+ if (!delta) return axis
26
+ if (/^-?(?:\d*\.)?\d+px$/.test(axis)) return `${parseFloat(axis) + delta}px`
27
+ return `calc(${axis} ${delta < 0 ? "-" : "+"} ${Math.abs(delta)}px)`
28
+ }
29
+ return [offset(axes[0] || "0px", dx), offset(axes[1] || "0px", dy), ...axes.slice(2)].join(" ")
30
+ }
31
+ export function snap(rect: Rect, others: Rect[], tolerance = 6) {
32
+ const xs = [0, 640, 1280, ...others.flatMap(r => [r.x, r.x + r.width / 2, r.x + r.width])]
33
+ const ys = [0, 360, 720, ...others.flatMap(r => [r.y, r.y + r.height / 2, r.y + r.height])]
34
+ const closest = (values: number[], targets: number[]) => {
35
+ let delta = tolerance + 1
36
+ let guide: number | undefined
37
+ for (const v of values)
38
+ for (const t of targets)
39
+ if (Math.abs(t - v) < Math.abs(delta)) {
40
+ delta = t - v
41
+ guide = t
42
+ }
43
+ return Math.abs(delta) <= tolerance ? { delta, guide } : { delta: 0, guide: undefined }
44
+ }
45
+ const x = closest([rect.x, rect.x + rect.width / 2, rect.x + rect.width], xs)
46
+ const y = closest([rect.y, rect.y + rect.height / 2, rect.y + rect.height], ys)
47
+ return { x: rect.x + x.delta, y: rect.y + y.delta, guideX: x.guide, guideY: y.guide }
48
+ }
49
+ export function aligned(
50
+ rects: Rect[],
51
+ axis: "x" | "y",
52
+ mode: "start" | "center" | "end" | "distribute"
53
+ ) {
54
+ const size = axis === "x" ? "width" : "height"
55
+ if (mode === "distribute") {
56
+ if (rects.length < 3) return rects.map(r => r[axis])
57
+ const sorted = rects.map((r, i) => ({ ...r, i })).sort((a, b) => a[axis] - b[axis])
58
+ const first = sorted[0]!,
59
+ last = sorted[sorted.length - 1]!
60
+ const gap =
61
+ (last[axis] + last[size] - first[axis] - sorted.reduce((s, r) => s + r[size], 0)) /
62
+ (rects.length - 1)
63
+ const result: number[] = []
64
+ let pos = first[axis]
65
+ for (const r of sorted) {
66
+ result[r.i] = pos
67
+ pos += r[size] + gap
68
+ }
69
+ return result
70
+ }
71
+ const min = Math.min(...rects.map(r => r[axis])),
72
+ max = Math.max(...rects.map(r => r[axis] + r[size]))
73
+ return rects.map(r =>
74
+ mode === "start" ? min : mode === "end" ? max - r[size] : (min + max - r[size]) / 2
75
+ )
76
+ }
@@ -0,0 +1,24 @@
1
+ import { createElement } from "react"
2
+ import { createRoot } from "react-dom/client"
3
+
4
+ import { EditorLauncher } from "./studio"
5
+
6
+ // Keep mounting outside the component module so React Fast Refresh can retain
7
+ // the open editor, its draft, and its history when the sidebar code changes.
8
+ export function mountEditor(
9
+ _slides: unknown,
10
+ _theme: unknown,
11
+ token: string,
12
+ initiallyOpen = false
13
+ ) {
14
+ if (document.getElementById("promptslide-editor-root")) return
15
+ const host = document.createElement("div")
16
+ host.id = "promptslide-editor-root"
17
+ document.body.appendChild(host)
18
+ const root = createRoot(host)
19
+ root.render(createElement(EditorLauncher, { token, initiallyOpen }))
20
+ return () => {
21
+ root.unmount()
22
+ host.remove()
23
+ }
24
+ }
@@ -0,0 +1,189 @@
1
+ import { randomBytes } from "node:crypto"
2
+ import {
3
+ readFileSync,
4
+ realpathSync,
5
+ writeFileSync,
6
+ renameSync,
7
+ statSync,
8
+ readdirSync
9
+ } from "node:fs"
10
+ import nodePath, { resolve, relative, sep, dirname } from "node:path"
11
+ import { fileURLToPath } from "node:url"
12
+
13
+ import { describe, editSource, instrument, revision } from "./source.mjs"
14
+
15
+ export function sourceModulePath(base, id, paths = nodePath) {
16
+ const path = paths.resolve(id.split("?")[0])
17
+ const rel = paths.relative(base, path)
18
+ if (
19
+ !rel ||
20
+ rel === ".." ||
21
+ rel.startsWith(".." + paths.sep) ||
22
+ paths.isAbsolute(rel) ||
23
+ !/\.[jt]sx$/.test(path)
24
+ )
25
+ return
26
+ return path
27
+ }
28
+
29
+ export function editorPlugin({ root }) {
30
+ root = realpathSync(root)
31
+ const token = randomBytes(24).toString("hex")
32
+ const base = resolve(root, "src")
33
+ const client = resolve(dirname(fileURLToPath(import.meta.url)), "mount.ts")
34
+ const clientUrl = "/@fs/" + client
35
+ function sourcePath(path) {
36
+ if (typeof path !== "string" || !/\.[jt]sx$/.test(path)) throw new Error("Invalid source path")
37
+ const resolved = realpathSync(resolve(root, path))
38
+ const realBase = realpathSync(base)
39
+ if (!resolved.startsWith(realBase + sep)) throw new Error("Source must be inside project src/")
40
+ return resolved
41
+ }
42
+ return {
43
+ name: "promptslide-editor",
44
+ apply: "serve",
45
+ enforce: "pre",
46
+ transform(code, id) {
47
+ const path = sourceModulePath(base, id)
48
+ if (!path) return
49
+ return { code: instrument(code, relative(root, path)), map: null }
50
+ },
51
+ resolveId(id) {
52
+ if (id === "virtual:promptslide-editor") return "\0" + id
53
+ },
54
+ load(id) {
55
+ if (id !== "\0virtual:promptslide-editor") return
56
+ return `import { mountEditor } from ${JSON.stringify(clientUrl)};
57
+ import { slides } from ${JSON.stringify(resolve(root, "src/deck-config.ts"))};
58
+ import { theme } from ${JSON.stringify(resolve(root, "src/theme.ts"))};
59
+ const dispose = mountEditor(slides, theme, ${JSON.stringify(token)}, import.meta.hot?.data.open ?? false);
60
+ if (import.meta.hot) {
61
+ import.meta.hot.accept();
62
+ import.meta.hot.dispose((data) => {
63
+ data.open = document.querySelector('[data-ps-edit-action][aria-pressed="true"]') !== null;
64
+ dispose?.();
65
+ window.dispatchEvent(new Event("promptslide:editor-refresh"));
66
+ });
67
+ }`
68
+ },
69
+ transformIndexHtml(html, ctx) {
70
+ const url = new URL(ctx.originalUrl ?? ctx.path, "http://localhost")
71
+ if (url.searchParams.get("pdf") === "true" || url.searchParams.get("export") === "true")
72
+ return
73
+ if (ctx.path !== "/" && ctx.path !== "/index.html") return
74
+ return {
75
+ html,
76
+ tags: [
77
+ {
78
+ tag: "script",
79
+ attrs: { type: "module", src: "/@id/virtual:promptslide-editor" },
80
+ injectTo: "body"
81
+ }
82
+ ]
83
+ }
84
+ },
85
+ configureServer(server) {
86
+ server.middlewares.use("/__promptslide_editor", (req, res) => {
87
+ const send = (status, data) => {
88
+ res.statusCode = status
89
+ res.setHeader("Content-Type", "application/json")
90
+ res.end(JSON.stringify(data))
91
+ }
92
+ if (
93
+ req.method !== "POST" ||
94
+ req.headers["x-promptslide-editor"] !== token ||
95
+ req.headers["sec-fetch-site"] === "cross-site"
96
+ )
97
+ return send(403, { error: "Editor access denied" })
98
+ let body = ""
99
+ req.on("data", chunk => {
100
+ body += chunk
101
+ if (body.length > 2_000_000) req.destroy()
102
+ })
103
+ req.on("end", () => {
104
+ try {
105
+ const data = JSON.parse(body)
106
+ if (data.action === "sources") {
107
+ const paths = []
108
+ function walk(directory) {
109
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
110
+ if (
111
+ entry.name.startsWith(".") ||
112
+ entry.name === "node_modules" ||
113
+ entry.isSymbolicLink()
114
+ )
115
+ continue
116
+ const path = resolve(directory, entry.name)
117
+ if (entry.isDirectory()) walk(path)
118
+ else if (entry.isFile() && /\.[jt]sx$/.test(path))
119
+ paths.push(relative(root, path))
120
+ }
121
+ }
122
+ walk(base)
123
+ return send(200, { paths: paths.sort() })
124
+ }
125
+ if (data.action === "inspect") {
126
+ const text = readFileSync(sourcePath(data.path), "utf8")
127
+ return send(200, { revision: revision(text), nodes: describe(text) })
128
+ }
129
+ if (
130
+ data.action !== "save" ||
131
+ !Array.isArray(data.files) ||
132
+ !data.files.length ||
133
+ data.files.length > 100
134
+ )
135
+ throw new Error("Invalid edit batch")
136
+ const seen = new Set()
137
+ const prepared = data.files.map(file => {
138
+ const path = sourcePath(file.path)
139
+ if (seen.has(path)) throw new Error("Duplicate file")
140
+ seen.add(path)
141
+ const before = readFileSync(path, "utf8")
142
+ if (revision(before) !== file.revision) {
143
+ const error = new Error(
144
+ `Conflict in ${file.path}. Source changed; discard or export your draft before reloading.`
145
+ )
146
+ error.status = 409
147
+ throw error
148
+ }
149
+ if (!Array.isArray(file.edits) || file.edits.length > 1000)
150
+ throw new Error("Invalid edits")
151
+ return {
152
+ path,
153
+ before,
154
+ after: editSource(before, file.edits),
155
+ mode: statSync(path).mode
156
+ }
157
+ })
158
+ // No asynchronous gap between revision checks and commits. Roll back our writes if a commit fails.
159
+ const written = []
160
+ try {
161
+ for (const file of prepared) {
162
+ if (readFileSync(file.path, "utf8") !== file.before)
163
+ throw new Error("Source changed during save")
164
+ const temp = file.path + ".promptslide-" + randomBytes(6).toString("hex")
165
+ writeFileSync(temp, file.after, { flag: "wx", mode: file.mode })
166
+ renameSync(temp, file.path)
167
+ written.push(file)
168
+ }
169
+ } catch (error) {
170
+ for (const file of written.reverse())
171
+ if (readFileSync(file.path, "utf8") === file.after)
172
+ writeFileSync(file.path, file.before)
173
+ throw error
174
+ }
175
+ // Invalidate synchronously so a reload immediately after Save cannot receive
176
+ // the old transform while the filesystem watcher is still debouncing.
177
+ for (const file of prepared) {
178
+ for (const module of server.moduleGraph?.getModulesByFile(file.path) || [])
179
+ server.moduleGraph.invalidateModule(module)
180
+ }
181
+ send(200, { saved: prepared.length })
182
+ } catch (error) {
183
+ send(error.status || 400, { error: error.message })
184
+ }
185
+ })
186
+ })
187
+ }
188
+ }
189
+ }