promptslide 0.3.15 → 0.3.17
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 +987 -211
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/commands/add.mjs +9 -2
- package/src/commands/clone.mjs +62 -22
- package/src/commands/create.mjs +8 -1
- package/src/commands/publish.mjs +34 -8
- package/src/commands/pull.mjs +66 -29
- package/src/commands/studio.mjs +3 -0
- 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 +657 -115
- 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/index.mjs +6 -0
- package/src/utils/deck-config.mjs +30 -11
- package/src/utils/deck-sync.mjs +78 -0
- package/src/utils/export.mjs +151 -52
- package/src/utils/registry.mjs +23 -0
- package/src/utils/studio-discovery.mjs +37 -0
- package/src/vite/plugin.mjs +146 -16
- package/templates/default/AGENTS.md +3 -2
- package/templates/default/src/deck-config.ts +2 -2
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") {
|
package/src/index.mjs
CHANGED
|
@@ -40,6 +40,7 @@ function printHelp() {
|
|
|
40
40
|
console.log()
|
|
41
41
|
console.log(` ${bold("Tools:")}`)
|
|
42
42
|
console.log(` to-image ${dim("<slide>")} Export a slide as a PNG image`)
|
|
43
|
+
console.log(` to-pdf Export the full deck as a PDF`)
|
|
43
44
|
console.log()
|
|
44
45
|
console.log(` ${bold("Options:")}`)
|
|
45
46
|
console.log(` --help, -h Show this help message`)
|
|
@@ -136,6 +137,11 @@ switch (command) {
|
|
|
136
137
|
await toImage(args)
|
|
137
138
|
break
|
|
138
139
|
}
|
|
140
|
+
case "to-pdf": {
|
|
141
|
+
const { toPdf } = await import("./commands/to-pdf.mjs")
|
|
142
|
+
await toPdf(args)
|
|
143
|
+
break
|
|
144
|
+
}
|
|
139
145
|
case "--help":
|
|
140
146
|
case "-h":
|
|
141
147
|
case undefined:
|
|
@@ -33,9 +33,9 @@ export function deriveImportPath(target, filePath) {
|
|
|
33
33
|
* Uses string manipulation to preserve existing content.
|
|
34
34
|
*
|
|
35
35
|
* @param {string} cwd - Project root directory
|
|
36
|
-
* @param {{ componentName: string, importPath: string, steps: number }} opts
|
|
36
|
+
* @param {{ componentName: string, importPath: string, steps: number, id?: string }} opts
|
|
37
37
|
*/
|
|
38
|
-
export function addSlideToDeckConfig(cwd, { componentName, importPath, steps }) {
|
|
38
|
+
export function addSlideToDeckConfig(cwd, { componentName, importPath, steps, id }) {
|
|
39
39
|
const configPath = join(cwd, DECK_CONFIG_PATH)
|
|
40
40
|
if (!existsSync(configPath)) {
|
|
41
41
|
console.warn(` Warning: ${DECK_CONFIG_PATH} not found. Skipping deck-config update.`)
|
|
@@ -51,7 +51,13 @@ export function addSlideToDeckConfig(cwd, { componentName, importPath, steps })
|
|
|
51
51
|
|
|
52
52
|
// Build import and slide entry strings
|
|
53
53
|
const importLine = `import { ${componentName} } from "${importPath}"`
|
|
54
|
-
const
|
|
54
|
+
const slideId =
|
|
55
|
+
id ||
|
|
56
|
+
importPath
|
|
57
|
+
.split("/")
|
|
58
|
+
.pop()
|
|
59
|
+
.replace(/^slide-/, "")
|
|
60
|
+
const slideEntry = ` { id: ${JSON.stringify(slideId)}, component: ${componentName}, steps: ${steps} },`
|
|
55
61
|
|
|
56
62
|
// Find insertion point for import:
|
|
57
63
|
// Look for the last import from "@/slides/" or "@/layouts/"
|
|
@@ -70,11 +76,12 @@ export function addSlideToDeckConfig(cwd, { componentName, importPath, steps })
|
|
|
70
76
|
}
|
|
71
77
|
|
|
72
78
|
// Insert import after last slide import, or after last import, or at top
|
|
73
|
-
const importInsertIdx =
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
79
|
+
const importInsertIdx =
|
|
80
|
+
lastSlideImportIdx >= 0
|
|
81
|
+
? lastSlideImportIdx + 1
|
|
82
|
+
: lastAnyImportIdx >= 0
|
|
83
|
+
? lastAnyImportIdx + 1
|
|
84
|
+
: 0
|
|
78
85
|
|
|
79
86
|
importLines.splice(importInsertIdx, 0, importLine)
|
|
80
87
|
content = importLines.join("\n")
|
|
@@ -169,7 +176,7 @@ export function removeSlideFromDeckConfig(cwd, componentName) {
|
|
|
169
176
|
* Replace the entire deck-config.ts with a new slide manifest (for deck type).
|
|
170
177
|
*
|
|
171
178
|
* @param {string} cwd - Project root directory
|
|
172
|
-
* @param {{ componentName: string, importPath: string, steps: number, section?: string }[]} slides
|
|
179
|
+
* @param {{ componentName: string, importPath: string, steps: number, id?: string, section?: string }[]} slides
|
|
173
180
|
* @param {{ transition?: string, directionalTransition?: boolean }} opts
|
|
174
181
|
*/
|
|
175
182
|
export function replaceDeckConfig(cwd, slides, opts = {}) {
|
|
@@ -182,7 +189,17 @@ export function replaceDeckConfig(cwd, slides, opts = {}) {
|
|
|
182
189
|
|
|
183
190
|
const slideEntries = slides
|
|
184
191
|
.map(s => {
|
|
185
|
-
const
|
|
192
|
+
const id =
|
|
193
|
+
s.id ||
|
|
194
|
+
s.importPath
|
|
195
|
+
.split("/")
|
|
196
|
+
.pop()
|
|
197
|
+
.replace(/^slide-/, "")
|
|
198
|
+
const parts = [
|
|
199
|
+
`id: ${JSON.stringify(id)}`,
|
|
200
|
+
`component: ${s.componentName}`,
|
|
201
|
+
`steps: ${s.steps}`
|
|
202
|
+
]
|
|
186
203
|
if (s.section) parts.push(`section: "${s.section}"`)
|
|
187
204
|
return ` { ${parts.join(", ")} },`
|
|
188
205
|
})
|
|
@@ -213,7 +230,7 @@ export function replaceDeckConfig(cwd, slides, opts = {}) {
|
|
|
213
230
|
/**
|
|
214
231
|
* Parse deck-config.ts and extract the deckConfig structure for publishing.
|
|
215
232
|
* Returns { transition?, directionalTransition?, slides } where slides is
|
|
216
|
-
* an array of { slug, steps, section? }.
|
|
233
|
+
* an array of { slug, id?, steps, section? }.
|
|
217
234
|
*
|
|
218
235
|
* @param {string} cwd - Project root directory
|
|
219
236
|
* @returns {{ transition?: string, directionalTransition?: boolean, slides: { slug: string, steps: number, section?: string }[] } | null}
|
|
@@ -280,6 +297,8 @@ export function parseDeckConfig(cwd) {
|
|
|
280
297
|
if (!slug) continue // layout or unknown import — skip
|
|
281
298
|
const stepsMatch = body.match(/steps:\s*(\d+)/)
|
|
282
299
|
const entry = { slug, steps: stepsMatch ? parseInt(stepsMatch[1], 10) : 0 }
|
|
300
|
+
const idMatch = body.match(/\bid:\s*["']([^"']+)["']/)
|
|
301
|
+
if (idMatch) entry.id = idMatch[1]
|
|
283
302
|
// Preserve original component name so pull can reconstruct the correct export
|
|
284
303
|
if (slugToComponent[slug]) entry.componentName = slugToComponent[slug]
|
|
285
304
|
const sectionMatch = body.match(/section:\s*["']([^"']+)["']/)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Return the slide slugs backed by files in a resolved registry dependency set.
|
|
3
|
+
* Only registry-provided files count; scaffold files already present in a target
|
|
4
|
+
* directory must not make stale deck metadata look valid.
|
|
5
|
+
*
|
|
6
|
+
* @param {object[]} items
|
|
7
|
+
* @returns {Set<string>}
|
|
8
|
+
*/
|
|
9
|
+
export function collectRegistrySlideSlugs(items) {
|
|
10
|
+
const slugs = new Set()
|
|
11
|
+
|
|
12
|
+
for (const item of items ?? []) {
|
|
13
|
+
for (const file of item.files ?? []) {
|
|
14
|
+
const target = String(file.target ?? "")
|
|
15
|
+
.replaceAll("\\", "/")
|
|
16
|
+
.replace(/^\.\//, "")
|
|
17
|
+
const path = String(file.path ?? "")
|
|
18
|
+
.replaceAll("\\", "/")
|
|
19
|
+
.replace(/^\.\//, "")
|
|
20
|
+
const fullPath = `${target.replace(/\/?$/, "/")}${path}`.replace(/\/+/g, "/")
|
|
21
|
+
const match = fullPath.match(/^src\/slides\/(.+)\.tsx?$/)
|
|
22
|
+
if (match) slugs.add(match[1])
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return slugs
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Split deck metadata into entries backed by registry files and stale entries.
|
|
31
|
+
* Array order is preserved so generated deck configs match the published order.
|
|
32
|
+
*
|
|
33
|
+
* @param {object[]} slides
|
|
34
|
+
* @param {Set<string>} availableSlugs
|
|
35
|
+
* @returns {{ available: object[], missing: object[] }}
|
|
36
|
+
*/
|
|
37
|
+
export function partitionDeckSlides(slides, availableSlugs) {
|
|
38
|
+
const available = []
|
|
39
|
+
const missing = []
|
|
40
|
+
|
|
41
|
+
for (const slide of slides ?? []) {
|
|
42
|
+
if (availableSlugs.has(slide.slug)) available.push(slide)
|
|
43
|
+
else missing.push(slide)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return { available, missing }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Select slide files for deck publishing. By default, only files referenced by
|
|
51
|
+
* deck-config.ts are included. The opt-in flag retains the legacy publish-all
|
|
52
|
+
* behavior for users who intentionally publish scratch or library slides.
|
|
53
|
+
*
|
|
54
|
+
* @param {string[]} slideEntries
|
|
55
|
+
* @param {{ slug: string }[]} configuredSlides
|
|
56
|
+
* @param {boolean} includeUnreferenced
|
|
57
|
+
* @returns {{ selected: string[], unreferenced: string[], missing: string[] }}
|
|
58
|
+
*/
|
|
59
|
+
export function selectSlidesForPublish(
|
|
60
|
+
slideEntries,
|
|
61
|
+
configuredSlides,
|
|
62
|
+
includeUnreferenced = false
|
|
63
|
+
) {
|
|
64
|
+
const filesBySlug = new Map(slideEntries.map(file => [file.replace(/\.tsx?$/, ""), file]))
|
|
65
|
+
const configuredSlugs = new Set(configuredSlides.map(slide => slide.slug))
|
|
66
|
+
const unreferenced = slideEntries.filter(
|
|
67
|
+
file => !configuredSlugs.has(file.replace(/\.tsx?$/, ""))
|
|
68
|
+
)
|
|
69
|
+
const missing = configuredSlides.map(slide => slide.slug).filter(slug => !filesBySlug.has(slug))
|
|
70
|
+
const selected = includeUnreferenced
|
|
71
|
+
? [...slideEntries]
|
|
72
|
+
: configuredSlides.flatMap(slide => {
|
|
73
|
+
const file = filesBySlug.get(slide.slug)
|
|
74
|
+
return file ? [file] : []
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
return { selected, unreferenced, missing }
|
|
78
|
+
}
|
package/src/utils/export.mjs
CHANGED
|
@@ -4,8 +4,9 @@ import { fileURLToPath } from "node:url"
|
|
|
4
4
|
|
|
5
5
|
import { createServer } from "vite"
|
|
6
6
|
|
|
7
|
-
import { ensureTsConfig } from "./tsconfig.mjs"
|
|
8
7
|
import { createViteConfig } from "../vite/config.mjs"
|
|
8
|
+
import { projectIdentity, readStudioServer } from "./studio-discovery.mjs"
|
|
9
|
+
import { ensureTsConfig } from "./tsconfig.mjs"
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Check if Playwright is available.
|
|
@@ -40,12 +41,88 @@ async function ensureChromium(chromium) {
|
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
/** Find a Studio process serving this project; other projects must not be reused. */
|
|
45
|
+
async function findStudioServer(cwd, preferredPort) {
|
|
46
|
+
const candidates = preferredPort
|
|
47
|
+
? [`http://127.0.0.1:${preferredPort}`, `http://localhost:${preferredPort}`]
|
|
48
|
+
: [
|
|
49
|
+
readStudioServer(cwd),
|
|
50
|
+
...Array.from({ length: 11 }, (_, i) => `http://127.0.0.1:${5173 + i}`)
|
|
51
|
+
]
|
|
52
|
+
const results = await Promise.all(
|
|
53
|
+
candidates.filter(Boolean).map(async baseUrl => {
|
|
54
|
+
try {
|
|
55
|
+
const response = await fetch(`${baseUrl}/__promptslide_info`, {
|
|
56
|
+
signal: AbortSignal.timeout(350)
|
|
57
|
+
})
|
|
58
|
+
if (!response.ok) return null
|
|
59
|
+
const info = await response.json()
|
|
60
|
+
return info.projectId === projectIdentity(cwd) ? baseUrl : null
|
|
61
|
+
} catch {
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
)
|
|
66
|
+
return results.find(Boolean) || null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function getCaptureServer(cwd, preferredPort) {
|
|
70
|
+
const studio = await findStudioServer(cwd, preferredPort)
|
|
71
|
+
if (studio) return { baseUrl: studio, close: async () => {} }
|
|
72
|
+
|
|
73
|
+
ensureTsConfig(cwd)
|
|
74
|
+
const config = createViteConfig({ cwd, mode: "development" })
|
|
75
|
+
const server = await createServer({
|
|
76
|
+
...config,
|
|
77
|
+
server: { port: 0, strictPort: true },
|
|
78
|
+
logLevel: "silent"
|
|
79
|
+
})
|
|
80
|
+
await server.listen()
|
|
81
|
+
const address = server.httpServer.address()
|
|
82
|
+
const port = typeof address === "object" ? address.port : 0
|
|
83
|
+
return { baseUrl: `http://127.0.0.1:${port}`, close: () => server.close() }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function waitForExport(page, errors) {
|
|
87
|
+
try {
|
|
88
|
+
const handle = await page.waitForFunction(
|
|
89
|
+
() => {
|
|
90
|
+
const overlay = document.querySelector("vite-error-overlay")
|
|
91
|
+
if (overlay) {
|
|
92
|
+
const root = overlay.shadowRoot
|
|
93
|
+
return {
|
|
94
|
+
error:
|
|
95
|
+
root?.querySelector(".message-body")?.textContent ||
|
|
96
|
+
root?.textContent ||
|
|
97
|
+
"Vite compilation failed"
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (document.querySelector("[data-export-ready='true']")) return { ready: true }
|
|
101
|
+
return false
|
|
102
|
+
},
|
|
103
|
+
null,
|
|
104
|
+
{ timeout: 15000 }
|
|
105
|
+
)
|
|
106
|
+
const result = await handle.jsonValue()
|
|
107
|
+
if (result.error) throw new Error(`Slide compile error: ${result.error.trim()}`)
|
|
108
|
+
} catch (err) {
|
|
109
|
+
if (errors.length) throw new Error(`${err.message}\nBrowser errors:\n ${errors.join("\n ")}`)
|
|
110
|
+
throw err
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
43
114
|
/**
|
|
44
115
|
* Capture a screenshot of a specific slide.
|
|
45
116
|
* @param {{ cwd: string, slidePath: string, width?: number, height?: number }} opts
|
|
46
117
|
* @returns {Promise<Buffer | null>} PNG buffer, or null if Playwright is not installed
|
|
47
118
|
*/
|
|
48
|
-
export async function captureSlideScreenshot({
|
|
119
|
+
export async function captureSlideScreenshot({
|
|
120
|
+
cwd,
|
|
121
|
+
slidePath,
|
|
122
|
+
width = 1280,
|
|
123
|
+
height = 720,
|
|
124
|
+
studioPort
|
|
125
|
+
}) {
|
|
49
126
|
let chromium
|
|
50
127
|
try {
|
|
51
128
|
const pw = await import("playwright")
|
|
@@ -56,19 +133,8 @@ export async function captureSlideScreenshot({ cwd, slidePath, width = 1280, hei
|
|
|
56
133
|
|
|
57
134
|
await ensureChromium(chromium)
|
|
58
135
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const config = createViteConfig({ cwd, mode: "development" })
|
|
62
|
-
const server = await createServer({
|
|
63
|
-
...config,
|
|
64
|
-
server: { port: 0, strictPort: false },
|
|
65
|
-
logLevel: "silent"
|
|
66
|
-
})
|
|
67
|
-
await server.listen()
|
|
68
|
-
|
|
69
|
-
const address = server.httpServer.address()
|
|
70
|
-
const port = typeof address === "object" ? address.port : 0
|
|
71
|
-
const url = `http://localhost:${port}/?export=true&slidePath=${encodeURIComponent(slidePath)}`
|
|
136
|
+
const server = await getCaptureServer(cwd, studioPort)
|
|
137
|
+
const url = `${server.baseUrl}/?export=true&slidePath=${encodeURIComponent(slidePath)}`
|
|
72
138
|
|
|
73
139
|
let browser
|
|
74
140
|
try {
|
|
@@ -78,14 +144,10 @@ export async function captureSlideScreenshot({ cwd, slidePath, width = 1280, hei
|
|
|
78
144
|
const errors = []
|
|
79
145
|
page.on("pageerror", err => errors.push(err.message))
|
|
80
146
|
|
|
81
|
-
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 15000 })
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
throw err
|
|
87
|
-
})
|
|
88
|
-
await page.waitForTimeout(200)
|
|
147
|
+
const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: 15000 })
|
|
148
|
+
if (!response?.ok())
|
|
149
|
+
throw new Error(`Slide export failed: ${response?.status()} ${await response?.text()}`)
|
|
150
|
+
await waitForExport(page, errors)
|
|
89
151
|
|
|
90
152
|
const element = await page.$("[data-export-ready='true']")
|
|
91
153
|
const screenshot = await element.screenshot({ type: "png" })
|
|
@@ -105,7 +167,7 @@ export async function captureSlideScreenshot({ cwd, slidePath, width = 1280, hei
|
|
|
105
167
|
* @returns {Promise<{ capture: (slidePath: string) => Promise<string | null>, close: () => Promise<void> } | null>}
|
|
106
168
|
* null if Playwright is not available
|
|
107
169
|
*/
|
|
108
|
-
export async function createCaptureSession({ cwd, width = 1280, height = 720 }) {
|
|
170
|
+
export async function createCaptureSession({ cwd, width = 1280, height = 720, studioPort }) {
|
|
109
171
|
let chromium
|
|
110
172
|
try {
|
|
111
173
|
const pw = await import("playwright")
|
|
@@ -115,36 +177,21 @@ export async function createCaptureSession({ cwd, width = 1280, height = 720 })
|
|
|
115
177
|
}
|
|
116
178
|
|
|
117
179
|
await ensureChromium(chromium)
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const config = createViteConfig({ cwd, mode: "development" })
|
|
121
|
-
const server = await createServer({
|
|
122
|
-
...config,
|
|
123
|
-
server: { port: 0, strictPort: false },
|
|
124
|
-
logLevel: "silent"
|
|
125
|
-
})
|
|
126
|
-
await server.listen()
|
|
127
|
-
|
|
128
|
-
const address = server.httpServer.address()
|
|
129
|
-
const port = typeof address === "object" ? address.port : 0
|
|
130
|
-
|
|
180
|
+
const server = await getCaptureServer(cwd, studioPort)
|
|
131
181
|
const browser = await chromium.launch({ headless: true })
|
|
182
|
+
const page = await browser.newPage({ viewport: { width, height } })
|
|
183
|
+
let pending = Promise.resolve()
|
|
132
184
|
|
|
133
|
-
async function
|
|
134
|
-
const url =
|
|
135
|
-
const
|
|
185
|
+
async function captureOne(slidePath) {
|
|
186
|
+
const url = `${server.baseUrl}/?export=true&slidePath=${encodeURIComponent(slidePath)}`
|
|
187
|
+
const errors = []
|
|
188
|
+
const onPageError = err => errors.push(err.message)
|
|
189
|
+
page.on("pageerror", onPageError)
|
|
136
190
|
try {
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
await page
|
|
141
|
-
await page.waitForSelector("[data-export-ready='true']", { timeout: 15000 }).catch(err => {
|
|
142
|
-
if (errors.length) {
|
|
143
|
-
throw new Error(`${err.message}\n Browser errors:\n ${errors.join("\n ")}`)
|
|
144
|
-
}
|
|
145
|
-
throw err
|
|
146
|
-
})
|
|
147
|
-
await page.waitForTimeout(200)
|
|
191
|
+
const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout: 15000 })
|
|
192
|
+
if (!response?.ok())
|
|
193
|
+
throw new Error(`Slide export failed: ${response?.status()} ${await response?.text()}`)
|
|
194
|
+
await waitForExport(page, errors)
|
|
148
195
|
|
|
149
196
|
const element = await page.$("[data-export-ready='true']")
|
|
150
197
|
const screenshot = await element.screenshot({ type: "png" })
|
|
@@ -153,11 +200,22 @@ export async function createCaptureSession({ cwd, width = 1280, height = 720 })
|
|
|
153
200
|
console.error(` Screenshot error: ${err.message}`)
|
|
154
201
|
return null
|
|
155
202
|
} finally {
|
|
156
|
-
|
|
203
|
+
page.off("pageerror", onPageError)
|
|
157
204
|
}
|
|
158
205
|
}
|
|
159
206
|
|
|
207
|
+
function capture(slidePath) {
|
|
208
|
+
const result = pending.then(() => captureOne(slidePath))
|
|
209
|
+
pending = result.then(
|
|
210
|
+
() => {},
|
|
211
|
+
() => {}
|
|
212
|
+
)
|
|
213
|
+
return result
|
|
214
|
+
}
|
|
215
|
+
|
|
160
216
|
async function close() {
|
|
217
|
+
await pending
|
|
218
|
+
await page.close().catch(() => {})
|
|
161
219
|
await browser.close().catch(() => {})
|
|
162
220
|
await server.close()
|
|
163
221
|
}
|
|
@@ -181,3 +239,44 @@ export async function captureSlideAsDataUri({ cwd, slidePath }) {
|
|
|
181
239
|
return null
|
|
182
240
|
}
|
|
183
241
|
}
|
|
242
|
+
|
|
243
|
+
/** Render the whole deck at its design size through an existing Studio server. */
|
|
244
|
+
export async function captureDeckPdfFromServer(baseUrl) {
|
|
245
|
+
const { chromium } = await import("playwright")
|
|
246
|
+
await ensureChromium(chromium)
|
|
247
|
+
const browser = await chromium.launch({ headless: true })
|
|
248
|
+
try {
|
|
249
|
+
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } })
|
|
250
|
+
const errors = []
|
|
251
|
+
page.on("pageerror", error => errors.push(error.message))
|
|
252
|
+
const response = await page.goto(`${baseUrl}/?pdf=true`, {
|
|
253
|
+
waitUntil: "domcontentloaded",
|
|
254
|
+
timeout: 15000
|
|
255
|
+
})
|
|
256
|
+
if (!response?.ok()) throw new Error(`Deck PDF export failed: ${response?.status()}`)
|
|
257
|
+
try {
|
|
258
|
+
await page.waitForSelector("[data-pdf-ready='true']", { timeout: 15000 })
|
|
259
|
+
} catch (error) {
|
|
260
|
+
if (errors.length)
|
|
261
|
+
throw new Error(`${error.message}\nBrowser errors:\n ${errors.join("\n ")}`)
|
|
262
|
+
throw error
|
|
263
|
+
}
|
|
264
|
+
return await page.pdf({
|
|
265
|
+
width: "1280px",
|
|
266
|
+
height: "720px",
|
|
267
|
+
printBackground: true,
|
|
268
|
+
preferCSSPageSize: true
|
|
269
|
+
})
|
|
270
|
+
} finally {
|
|
271
|
+
await browser.close()
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export async function captureDeckPdf({ cwd, studioPort }) {
|
|
276
|
+
const server = await getCaptureServer(cwd, studioPort)
|
|
277
|
+
try {
|
|
278
|
+
return await captureDeckPdfFromServer(server.baseUrl)
|
|
279
|
+
} finally {
|
|
280
|
+
await server.close()
|
|
281
|
+
}
|
|
282
|
+
}
|
package/src/utils/registry.mjs
CHANGED
|
@@ -150,6 +150,29 @@ export function removeLockfileItem(cwd, slug) {
|
|
|
150
150
|
writeLockfile(cwd, lock)
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Remove lockfile entries whose tracked files have all disappeared locally.
|
|
155
|
+
* Entries with no files (such as the deck manifest) are retained.
|
|
156
|
+
*
|
|
157
|
+
* @param {string} cwd
|
|
158
|
+
* @param {{ items?: Record<string, { files?: Record<string, string> }> }} lock
|
|
159
|
+
* @returns {string[]} Removed item slugs
|
|
160
|
+
*/
|
|
161
|
+
export function pruneMissingLockfileItems(cwd, lock) {
|
|
162
|
+
const removed = []
|
|
163
|
+
lock.items ||= {}
|
|
164
|
+
|
|
165
|
+
for (const [slug, item] of Object.entries(lock.items)) {
|
|
166
|
+
const paths = Object.keys(item.files ?? {})
|
|
167
|
+
if (paths.length > 0 && paths.every(path => !existsSync(join(cwd, path)))) {
|
|
168
|
+
delete lock.items[slug]
|
|
169
|
+
removed.push(slug)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return removed
|
|
174
|
+
}
|
|
175
|
+
|
|
153
176
|
/**
|
|
154
177
|
* Build common auth headers for registry API requests.
|
|
155
178
|
* @param {{ token: string, organizationId?: string }} auth
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
import { readFileSync, writeFileSync, unlinkSync } from "node:fs"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import { join, resolve } from "node:path"
|
|
5
|
+
|
|
6
|
+
export function projectIdentity(cwd) {
|
|
7
|
+
return createHash("sha256").update(resolve(cwd)).digest("hex").slice(0, 16)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function discoveryPath(cwd) {
|
|
11
|
+
return join(tmpdir(), `promptslide-studio-${projectIdentity(cwd)}.json`)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function registerStudioServer(cwd, baseUrl) {
|
|
15
|
+
const path = discoveryPath(cwd)
|
|
16
|
+
writeFileSync(
|
|
17
|
+
path,
|
|
18
|
+
JSON.stringify({ root: resolve(cwd), baseUrl: baseUrl.replace(/\/$/, ""), pid: process.pid })
|
|
19
|
+
)
|
|
20
|
+
process.on("exit", () => {
|
|
21
|
+
try {
|
|
22
|
+
const current = JSON.parse(readFileSync(path, "utf8"))
|
|
23
|
+
if (current.pid === process.pid) unlinkSync(path)
|
|
24
|
+
} catch {
|
|
25
|
+
/* Another process may have removed the file. */
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function readStudioServer(cwd) {
|
|
31
|
+
try {
|
|
32
|
+
const info = JSON.parse(readFileSync(discoveryPath(cwd), "utf8"))
|
|
33
|
+
return info.root === resolve(cwd) ? info.baseUrl : null
|
|
34
|
+
} catch {
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
}
|