promptslide 0.3.16 → 0.3.18
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/CHANGELOG.md +14 -0
- package/README.md +16 -12
- package/dist/index.d.ts +17 -7
- package/dist/index.js +1072 -225
- package/dist/index.js.map +1 -1
- package/package.json +31 -31
- package/src/commands/clone.mjs +54 -21
- package/src/commands/pull.mjs +57 -27
- package/src/commands/studio.mjs +7 -3
- package/src/commands/to-image.mjs +18 -5
- package/src/commands/to-pdf.mjs +27 -0
- package/src/core/animated.tsx +3 -3
- package/src/core/annotations/adapters/http.ts +8 -0
- package/src/core/annotations/annotation-overlay.tsx +77 -32
- package/src/core/annotations/annotation-panel.tsx +184 -51
- package/src/core/annotations/types.ts +5 -0
- package/src/core/annotations/use-annotations.ts +57 -7
- package/src/core/slide-deck.tsx +740 -116
- package/src/core/slide-renderer.tsx +3 -8
- package/src/core/transitions.ts +5 -3
- package/src/core/types.ts +2 -0
- package/src/core/use-slide-navigation.ts +93 -3
- package/src/editor/README.md +35 -0
- package/src/editor/geometry.ts +76 -0
- package/src/editor/mount.ts +24 -0
- package/src/editor/plugin.mjs +189 -0
- package/src/editor/source.mjs +363 -0
- package/src/editor/studio.css +1004 -0
- package/src/editor/studio.tsx +1948 -0
- package/src/index.mjs +6 -0
- package/src/utils/deck-config.mjs +30 -11
- package/src/utils/export.mjs +151 -52
- package/src/utils/studio-discovery.mjs +37 -0
- package/src/vite/config.mjs +23 -2
- package/src/vite/plugin.mjs +147 -16
- package/templates/default/AGENTS.md +3 -2
- package/templates/default/src/deck-config.ts +2 -2
|
@@ -58,6 +58,7 @@ export function SlideRenderer({
|
|
|
58
58
|
<AnimatePresence initial={false}>
|
|
59
59
|
<motion.div
|
|
60
60
|
key={currentSlide}
|
|
61
|
+
data-slide-render-index={currentSlide}
|
|
61
62
|
variants={slideVariants}
|
|
62
63
|
initial="enter"
|
|
63
64
|
animate="center"
|
|
@@ -75,14 +76,8 @@ export function SlideRenderer({
|
|
|
75
76
|
totalSteps={totalSteps}
|
|
76
77
|
showAllAnimations={showAllAnimations}
|
|
77
78
|
>
|
|
78
|
-
<SlideErrorBoundary
|
|
79
|
-
|
|
80
|
-
slideTitle={slides[currentSlide]?.title}
|
|
81
|
-
>
|
|
82
|
-
<CurrentSlideComponent
|
|
83
|
-
slideNumber={currentSlide + 1}
|
|
84
|
-
totalSlides={slides.length}
|
|
85
|
-
/>
|
|
79
|
+
<SlideErrorBoundary slideIndex={currentSlide} slideTitle={slides[currentSlide]?.title}>
|
|
80
|
+
<CurrentSlideComponent slideNumber={currentSlide + 1} totalSlides={slides.length} />
|
|
86
81
|
</SlideErrorBoundary>
|
|
87
82
|
</AnimationProvider>
|
|
88
83
|
</motion.div>
|
package/src/core/transitions.ts
CHANGED
|
@@ -42,9 +42,11 @@ export interface SlideTransitionConfig {
|
|
|
42
42
|
// =============================================================================
|
|
43
43
|
|
|
44
44
|
const fadeVariants: Variants = {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
// Render the next slide fully beneath the current one, then fade the
|
|
46
|
+
// outgoing slide away. This keeps the frame opaque throughout the change.
|
|
47
|
+
enter: { opacity: 1, zIndex: 0 },
|
|
48
|
+
center: { opacity: 1, zIndex: 0 },
|
|
49
|
+
exit: { opacity: 0, zIndex: 1 }
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
const slideLeftVariants: Variants = {
|
package/src/core/types.ts
CHANGED
|
@@ -27,6 +27,8 @@ export type SlideComponent = React.ComponentType<SlideProps>
|
|
|
27
27
|
* eliminating the need for runtime step discovery.
|
|
28
28
|
*/
|
|
29
29
|
export interface SlideConfig {
|
|
30
|
+
/** Stable identifier for links and annotations. Keep unchanged when reordering slides. */
|
|
31
|
+
id?: string
|
|
30
32
|
/** The slide component to render */
|
|
31
33
|
component: SlideComponent
|
|
32
34
|
/** Number of animation steps (0 = no animations). This is the max step number used in <Animated step={N}> */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useEffect, useState } from "react"
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react"
|
|
2
2
|
|
|
3
3
|
import type { NavigationDirection, SlideConfig } from "./types"
|
|
4
4
|
|
|
@@ -15,6 +15,42 @@ interface NavigationState {
|
|
|
15
15
|
direction: NavigationDirection
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
const POSITION_KEY = "promptslide:position"
|
|
19
|
+
|
|
20
|
+
function slideKey(slides: SlideConfig[], index: number): string {
|
|
21
|
+
return slides[index]?.id || String(index + 1)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function slideFromHash(slides: SlideConfig[]): number | null {
|
|
25
|
+
if (typeof window === "undefined" || !window.location.hash) return null
|
|
26
|
+
let hash: string
|
|
27
|
+
try {
|
|
28
|
+
hash = decodeURIComponent(window.location.hash.slice(1))
|
|
29
|
+
} catch {
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
const byId = slides.findIndex(slide => slide.id === hash)
|
|
33
|
+
if (byId >= 0) return byId
|
|
34
|
+
if (/^[1-9]\d*$/.test(hash)) {
|
|
35
|
+
const index = Number(hash) - 1
|
|
36
|
+
if (index < slides.length) return index
|
|
37
|
+
}
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function savedStep(slides: SlideConfig[], index: number): number {
|
|
42
|
+
if (typeof window === "undefined") return 0
|
|
43
|
+
try {
|
|
44
|
+
const saved = JSON.parse(window.sessionStorage.getItem(POSITION_KEY) || "null")
|
|
45
|
+
if (saved?.slide === slideKey(slides, index) && Number.isInteger(saved.step)) {
|
|
46
|
+
return Math.max(0, Math.min(saved.step, slides[index]?.steps ?? 0))
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
/* Storage may be unavailable. */
|
|
50
|
+
}
|
|
51
|
+
return 0
|
|
52
|
+
}
|
|
53
|
+
|
|
18
54
|
export interface UseSlideNavigationOptions {
|
|
19
55
|
slides: SlideConfig[]
|
|
20
56
|
initialSlide?: number
|
|
@@ -43,8 +79,12 @@ export function useSlideNavigation({
|
|
|
43
79
|
initialSlide = 0,
|
|
44
80
|
onSlideChange
|
|
45
81
|
}: UseSlideNavigationOptions): UseSlideNavigationReturn {
|
|
46
|
-
const [currentSlide, setCurrentSlide] = useState(initialSlide)
|
|
47
|
-
const [animationStep, setAnimationStep] = useState(
|
|
82
|
+
const [currentSlide, setCurrentSlide] = useState(() => slideFromHash(slides) ?? initialSlide)
|
|
83
|
+
const [animationStep, setAnimationStep] = useState(() =>
|
|
84
|
+
savedStep(slides, slideFromHash(slides) ?? initialSlide)
|
|
85
|
+
)
|
|
86
|
+
const slidesRef = useRef(slides)
|
|
87
|
+
const activeKeyRef = useRef(slideKey(slides, slideFromHash(slides) ?? initialSlide))
|
|
48
88
|
|
|
49
89
|
const [navState, setNavState] = useState<NavigationState>({
|
|
50
90
|
status: "idle",
|
|
@@ -55,6 +95,56 @@ export function useSlideNavigation({
|
|
|
55
95
|
|
|
56
96
|
const totalSteps = slides[currentSlide]?.steps ?? 0
|
|
57
97
|
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
if (typeof window === "undefined" || slides.length === 0) return
|
|
100
|
+
if (slidesRef.current !== slides) {
|
|
101
|
+
slidesRef.current = slides
|
|
102
|
+
const previousKey = activeKeyRef.current
|
|
103
|
+
const movedIndex = slides.findIndex(slide => slide.id && slide.id === previousKey)
|
|
104
|
+
if (movedIndex >= 0 && movedIndex !== currentSlide) {
|
|
105
|
+
setCurrentSlide(movedIndex)
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
if (movedIndex < 0 && !/^[1-9]\d*$/.test(previousKey) && currentSlide !== 0) {
|
|
109
|
+
setAnimationStep(0)
|
|
110
|
+
setCurrentSlide(0)
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (!slides[currentSlide]) {
|
|
115
|
+
setCurrentSlide(0)
|
|
116
|
+
setAnimationStep(0)
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
const key = slideKey(slides, currentSlide)
|
|
120
|
+
activeKeyRef.current = key
|
|
121
|
+
const hash = `#${encodeURIComponent(key)}`
|
|
122
|
+
if (window.location.hash !== hash) {
|
|
123
|
+
window.history.replaceState(window.history.state, "", hash)
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
window.sessionStorage.setItem(
|
|
127
|
+
POSITION_KEY,
|
|
128
|
+
JSON.stringify({ slide: key, step: animationStep })
|
|
129
|
+
)
|
|
130
|
+
} catch {
|
|
131
|
+
/* Storage may be unavailable. */
|
|
132
|
+
}
|
|
133
|
+
}, [slides, currentSlide, animationStep])
|
|
134
|
+
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
const handleHashChange = () => {
|
|
137
|
+
const index = slideFromHash(slides) ?? 0
|
|
138
|
+
if (index === currentSlide) return
|
|
139
|
+
setNavState({ status: "transitioning", direction: index > currentSlide ? 1 : -1 })
|
|
140
|
+
setAnimationStep(0)
|
|
141
|
+
setCurrentSlide(index)
|
|
142
|
+
onSlideChange?.(index)
|
|
143
|
+
}
|
|
144
|
+
window.addEventListener("hashchange", handleHashChange)
|
|
145
|
+
return () => window.removeEventListener("hashchange", handleHashChange)
|
|
146
|
+
}, [slides, currentSlide, onSlideChange])
|
|
147
|
+
|
|
58
148
|
const onTransitionComplete = useCallback(() => {
|
|
59
149
|
setNavState(prev => {
|
|
60
150
|
if (prev.status === "transitioning") {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Development source editor
|
|
2
|
+
|
|
3
|
+
Run `promptslide studio` and choose **Edit** in the existing top-right toolbar. 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
|
+
- 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.
|
|
15
|
+
- Editing uses the original viewer canvas, styling, and animation wrappers. Leave Edit to reveal steps normally; opening Edit preserves the currently visible content.
|
|
16
|
+
|
|
17
|
+
## Source semantics
|
|
18
|
+
|
|
19
|
+
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.
|
|
20
|
+
|
|
21
|
+
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.
|
|
22
|
+
|
|
23
|
+
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.
|
|
24
|
+
|
|
25
|
+
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.
|
|
26
|
+
|
|
27
|
+
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.
|
|
28
|
+
|
|
29
|
+
## Verification
|
|
30
|
+
|
|
31
|
+
- `node --test packages/cli/test/editor.test.mjs`: source edits, escaping, conflicts, batch validation, path restrictions, and array instances.
|
|
32
|
+
- `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.
|
|
33
|
+
- `tsc -p packages/cli/tsconfig.json --noEmit`: editor and engine types.
|
|
34
|
+
|
|
35
|
+
Implementation uses TypeScript's JSX parser and original-range patches. No OpenSlide source code is vendored.
|
|
@@ -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('.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
|
+
}
|