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.
@@ -42,9 +42,11 @@ export interface SlideTransitionConfig {
42
42
  // =============================================================================
43
43
 
44
44
  const fadeVariants: Variants = {
45
- enter: { opacity: 0 },
46
- center: { opacity: 1 },
47
- exit: { opacity: 0 }
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(0)
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 slideEntry = ` { component: ${componentName}, steps: ${steps} },`
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 = lastSlideImportIdx >= 0
74
- ? lastSlideImportIdx + 1
75
- : lastAnyImportIdx >= 0
76
- ? lastAnyImportIdx + 1
77
- : 0
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 parts = [`component: ${s.componentName}`, `steps: ${s.steps}`]
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
+ }
@@ -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({ cwd, slidePath, width = 1280, height = 720 }) {
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
- ensureTsConfig(cwd)
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
- await page.waitForSelector("[data-export-ready='true']", { timeout: 15000 }).catch(err => {
83
- if (errors.length) {
84
- throw new Error(`${err.message}\n Browser errors:\n ${errors.join("\n ")}`)
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
- ensureTsConfig(cwd)
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 capture(slidePath) {
134
- const url = `http://localhost:${port}/?export=true&slidePath=${encodeURIComponent(slidePath)}`
135
- const page = await browser.newPage({ viewport: { width, height } })
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 errors = []
138
- page.on("pageerror", err => errors.push(err.message))
139
-
140
- await page.goto(url, { waitUntil: "domcontentloaded", timeout: 15000 })
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
- await page.close().catch(() => {})
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
+ }
@@ -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
+ }