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.
@@ -1,18 +1,34 @@
1
1
  import { readFileSync, writeFileSync, existsSync } from "node:fs"
2
- import { join } from "node:path"
2
+ import { join, resolve, sep, extname } from "node:path"
3
+
3
4
  import { bold, dim } from "../utils/ansi.mjs"
5
+ import { projectIdentity } from "../utils/studio-discovery.mjs"
4
6
 
5
7
  /** Normalize a path to forward slashes so it can be used in ES module import strings on Windows. */
6
8
  function toImportPath(p) {
7
9
  return p.split("\\").join("/")
8
10
  }
9
11
 
12
+ function validateSlidePath(root, slidePath) {
13
+ const absolute = resolve(root, slidePath)
14
+ if (
15
+ !absolute.startsWith(resolve(root) + sep) ||
16
+ ![".tsx", ".jsx", ".ts", ".js"].includes(extname(absolute)) ||
17
+ !existsSync(absolute)
18
+ ) {
19
+ throw new Error(`Invalid slide path: ${slidePath}`)
20
+ }
21
+ return absolute
22
+ }
23
+
10
24
  const VIRTUAL_ENTRY_ID = "virtual:promptslide-entry"
11
25
  const RESOLVED_VIRTUAL_ENTRY_ID = "\0" + VIRTUAL_ENTRY_ID
12
26
  const VIRTUAL_EXPORT_ID = "virtual:promptslide-export"
13
27
  const RESOLVED_VIRTUAL_EXPORT_ID = "\0" + VIRTUAL_EXPORT_ID
14
28
  const VIRTUAL_EMBED_ID = "virtual:promptslide-embed"
15
29
  const RESOLVED_VIRTUAL_EMBED_ID = "\0" + VIRTUAL_EMBED_ID
30
+ const VIRTUAL_PDF_ID = "virtual:promptslide-pdf"
31
+ const RESOLVED_VIRTUAL_PDF_ID = "\0" + VIRTUAL_PDF_ID
16
32
 
17
33
  // Inline script that catches module load errors (e.g. missing named exports)
18
34
  // and forwards them to the Vite dev server so they appear in terminal logs.
@@ -45,7 +61,8 @@ function getHtmlTemplate() {
45
61
  </html>`
46
62
  }
47
63
 
48
- function getExportHtmlTemplate() {
64
+ function getExportHtmlTemplate(slidePath) {
65
+ const entry = `${VIRTUAL_EXPORT_ID}?slidePath=${encodeURIComponent(slidePath)}`
49
66
  return `<!doctype html>
50
67
  <html lang="en">
51
68
  <head>
@@ -55,7 +72,7 @@ function getExportHtmlTemplate() {
55
72
  </head>
56
73
  <body>
57
74
  <div id="root"></div>
58
- <script type="module" src="/@id/${VIRTUAL_EXPORT_ID}"></script>
75
+ <script type="module" src="/@id/${entry}"></script>
59
76
  </body>
60
77
  </html>`
61
78
  }
@@ -76,12 +93,13 @@ createRoot(document.getElementById("root")).render(
76
93
 
77
94
  function getExportEntryModule(root, slidePath) {
78
95
  const r = toImportPath(root)
96
+ const slideImport = JSON.stringify(toImportPath(validateSlidePath(root, slidePath)))
79
97
  return `
80
98
  import { StrictMode, createElement, useState, useEffect } from "react"
81
99
  import { createRoot } from "react-dom/client"
82
100
  import { AnimationProvider, SlideErrorBoundary, SlideThemeProvider } from "promptslide"
83
101
  import "${r}/src/globals.css"
84
- import * as slideMod from "${r}/${slidePath}"
102
+ import * as slideMod from ${slideImport}
85
103
 
86
104
  let theme = {}
87
105
  try {
@@ -93,7 +111,18 @@ const SlideComponent = slideMod.default || Object.values(slideMod).find(v => typ
93
111
 
94
112
  function ExportView() {
95
113
  const [ready, setReady] = useState(false)
96
- useEffect(() => { setReady(true) }, [])
114
+ useEffect(() => {
115
+ let cancelled = false
116
+ const markReady = async () => {
117
+ await document.fonts.ready
118
+ await Promise.all(Array.from(document.images, image => image.decode().catch(() => {})))
119
+ requestAnimationFrame(() => requestAnimationFrame(() => {
120
+ if (!cancelled) setReady(true)
121
+ }))
122
+ }
123
+ void markReady()
124
+ return () => { cancelled = true }
125
+ }, [])
97
126
  return createElement("div", {
98
127
  "data-export-ready": ready ? "true" : undefined,
99
128
  style: { width: 1280, height: 720, overflow: "hidden", position: "relative", background: "black" }
@@ -134,6 +163,63 @@ function getEmbedHtmlTemplate() {
134
163
  </html>`
135
164
  }
136
165
 
166
+ function getPdfHtmlTemplate() {
167
+ return `<!doctype html><html><head><meta charset="UTF-8" /><title>PromptSlide PDF</title></head>
168
+ <body><div id="root"></div><script type="module" src="/@id/${VIRTUAL_PDF_ID}"></script></body></html>`
169
+ }
170
+
171
+ function getPdfEntryModule(root) {
172
+ const r = toImportPath(root)
173
+ return `
174
+ import { StrictMode, createElement, useEffect, useState } from "react"
175
+ import { createRoot } from "react-dom/client"
176
+ import { AnimationProvider, SlideErrorBoundary, SlideThemeProvider } from "promptslide"
177
+ import "${r}/src/globals.css"
178
+ import { slides } from "${r}/src/deck-config"
179
+
180
+ let theme = {}
181
+ try {
182
+ const themeMod = await import("${r}/src/theme")
183
+ theme = themeMod.theme || themeMod.default || {}
184
+ } catch {}
185
+
186
+ const style = document.createElement("style")
187
+ style.textContent = "@page{size:1280px 720px;margin:0}html,body,#root{margin:0;padding:0;width:1280px;background:#000}body{print-color-adjust:exact;-webkit-print-color-adjust:exact}.pdf-slide{width:1280px;height:720px;overflow:hidden;break-after:page;page-break-after:always}.pdf-slide:last-child{break-after:auto}"
188
+ document.head.appendChild(style)
189
+
190
+ function PdfView() {
191
+ const [ready, setReady] = useState(false)
192
+ useEffect(() => {
193
+ let cancelled = false
194
+ const markReady = async () => {
195
+ await document.fonts.ready
196
+ await Promise.all(Array.from(document.images, image => image.decode().catch(() => {})))
197
+ requestAnimationFrame(() => requestAnimationFrame(() => {
198
+ if (!cancelled) setReady(true)
199
+ }))
200
+ }
201
+ void markReady()
202
+ return () => { cancelled = true }
203
+ }, [])
204
+ return createElement("div", { "data-pdf-ready": ready ? "true" : undefined },
205
+ slides.map((slide, index) => createElement("div", { className: "pdf-slide", key: slide.id || index },
206
+ createElement(AnimationProvider, { currentStep: slide.steps, totalSteps: slide.steps, showAllAnimations: true },
207
+ createElement(SlideErrorBoundary, { slideIndex: index, slideTitle: slide.title },
208
+ createElement(slide.component, { slideNumber: index + 1, totalSlides: slides.length })
209
+ )
210
+ )
211
+ ))
212
+ )
213
+ }
214
+
215
+ createRoot(document.getElementById("root")).render(
216
+ createElement(StrictMode, null,
217
+ createElement(SlideThemeProvider, { theme }, createElement(PdfView))
218
+ )
219
+ )
220
+ `
221
+ }
222
+
137
223
  function getEmbedEntryModule(root) {
138
224
  const r = toImportPath(root)
139
225
  return `
@@ -161,7 +247,6 @@ createRoot(document.getElementById("root")).render(
161
247
 
162
248
  export function promptslidePlugin({ root: initialRoot } = {}) {
163
249
  let root = initialRoot
164
- let exportSlidePath = null
165
250
 
166
251
  return {
167
252
  name: "promptslide",
@@ -183,28 +268,57 @@ export function promptslidePlugin({ root: initialRoot } = {}) {
183
268
 
184
269
  resolveId(id) {
185
270
  if (id === VIRTUAL_ENTRY_ID) return RESOLVED_VIRTUAL_ENTRY_ID
186
- if (id === VIRTUAL_EXPORT_ID) return RESOLVED_VIRTUAL_EXPORT_ID
271
+ if (id.startsWith(`${VIRTUAL_EXPORT_ID}?`)) return "\0" + id
187
272
  if (id === VIRTUAL_EMBED_ID) return RESOLVED_VIRTUAL_EMBED_ID
273
+ if (id === VIRTUAL_PDF_ID) return RESOLVED_VIRTUAL_PDF_ID
188
274
  },
189
275
 
190
276
  load(id) {
191
277
  if (id === RESOLVED_VIRTUAL_ENTRY_ID) return getEntryModule(root)
192
- if (id === RESOLVED_VIRTUAL_EXPORT_ID) return getExportEntryModule(root, exportSlidePath || "src/slides/slide-title.tsx")
278
+ if (id.startsWith(`${RESOLVED_VIRTUAL_EXPORT_ID}?`)) {
279
+ const params = new URLSearchParams(id.slice(id.indexOf("?") + 1))
280
+ return getExportEntryModule(root, params.get("slidePath") || "src/slides/slide-title.tsx")
281
+ }
193
282
  if (id === RESOLVED_VIRTUAL_EMBED_ID) return getEmbedEntryModule(root)
283
+ if (id === RESOLVED_VIRTUAL_PDF_ID) return getPdfEntryModule(root)
194
284
  },
195
285
 
196
286
  configureServer(server) {
287
+ server.middlewares.use(async (req, res, next) => {
288
+ if (req.method !== "GET" || req.url !== "/__promptslide_pdf") return next()
289
+ try {
290
+ const { captureDeckPdfFromServer } = await import("../utils/export.mjs")
291
+ const address = server.httpServer.address()
292
+ const port = typeof address === "object" ? address.port : 0
293
+ const pdf = await captureDeckPdfFromServer(`http://127.0.0.1:${port}`)
294
+ res.setHeader("Content-Type", "application/pdf")
295
+ res.setHeader("Content-Disposition", 'attachment; filename="slides.pdf"')
296
+ res.end(pdf)
297
+ } catch (error) {
298
+ res.statusCode = 500
299
+ res.end(String(error.message || error))
300
+ }
301
+ })
302
+ server.middlewares.use((req, res, next) => {
303
+ if (req.method !== "GET" || req.url !== "/__promptslide_info") return next()
304
+ res.setHeader("Content-Type", "application/json")
305
+ res.end(JSON.stringify({ projectId: projectIdentity(root) }))
306
+ })
197
307
  // Pre-middleware: receive browser errors and log them to the terminal
198
308
  server.middlewares.use((req, res, next) => {
199
309
  if (req.method !== "POST" || req.url !== "/__promptslide_error") return next()
200
310
 
201
311
  let body = ""
202
- req.on("data", chunk => { body += chunk })
312
+ req.on("data", chunk => {
313
+ body += chunk
314
+ })
203
315
  req.on("end", () => {
204
316
  try {
205
317
  const { message, filename } = JSON.parse(body)
206
318
  const location = filename ? ` ${dim(`(${filename})`)}` : ""
207
- server.config.logger.error(`${bold("Browser error:")} ${message}${location}`, { timestamp: true })
319
+ server.config.logger.error(`${bold("Browser error:")} ${message}${location}`, {
320
+ timestamp: true
321
+ })
208
322
  } catch {}
209
323
  res.statusCode = 204
210
324
  res.end()
@@ -238,7 +352,9 @@ export function promptslidePlugin({ root: initialRoot } = {}) {
238
352
  if (req.method !== "POST" || req.url !== "/__promptslide_annotations") return next()
239
353
 
240
354
  let body = ""
241
- req.on("data", chunk => { body += chunk })
355
+ req.on("data", chunk => {
356
+ body += chunk
357
+ })
242
358
  req.on("end", () => {
243
359
  try {
244
360
  const data = JSON.parse(body)
@@ -267,13 +383,27 @@ export function promptslidePlugin({ root: initialRoot } = {}) {
267
383
  // Pre-middleware: intercept export URLs before Vite's SPA fallback rewrites them
268
384
  server.middlewares.use(async (req, res, next) => {
269
385
  const url = new URL(req.url, "http://localhost")
386
+ if (url.searchParams.get("pdf") === "true") {
387
+ const html = await server.transformIndexHtml("/index.html", getPdfHtmlTemplate())
388
+ res.setHeader("Content-Type", "text/html")
389
+ res.statusCode = 200
390
+ res.end(html)
391
+ return
392
+ }
270
393
  if (url.searchParams.get("export") !== "true") return next()
271
394
 
272
- exportSlidePath = url.searchParams.get("slidePath") || "src/slides/slide-title.tsx"
273
- // Invalidate cached export module so it regenerates with the new slidePath
274
- const mod = server.moduleGraph.getModuleById(RESOLVED_VIRTUAL_EXPORT_ID)
275
- if (mod) server.moduleGraph.invalidateModule(mod)
276
- const html = await server.transformIndexHtml("/index.html", getExportHtmlTemplate())
395
+ const slidePath = url.searchParams.get("slidePath") || "src/slides/slide-title.tsx"
396
+ try {
397
+ validateSlidePath(root, slidePath)
398
+ } catch (error) {
399
+ res.statusCode = 400
400
+ res.end(error.message)
401
+ return
402
+ }
403
+ const html = await server.transformIndexHtml(
404
+ "/index.html",
405
+ getExportHtmlTemplate(slidePath)
406
+ )
277
407
  res.setHeader("Content-Type", "text/html")
278
408
  res.statusCode = 200
279
409
  res.end(html)
@@ -73,8 +73,8 @@ import { SlideTitle } from "@/slides/slide-title";
73
73
  import { SlideMarket } from "@/slides/slide-market";
74
74
 
75
75
  export const slides: SlideConfig[] = [
76
- { component: SlideTitle, steps: 0 },
77
- { component: SlideMarket, steps: 0 },
76
+ { id: "title", component: SlideTitle, steps: 0 },
77
+ { id: "market", component: SlideMarket, steps: 0 },
78
78
  ];
79
79
  ```
80
80
 
@@ -85,6 +85,7 @@ Layouts in `src/layouts/` and theme colors in `src/globals.css` are yours to cus
85
85
  ## Key Constraints
86
86
 
87
87
  - **Slide dimensions**: 1280×720 (16:9). Content scales automatically in presentation mode.
88
+ - **Slide IDs**: Give each slide a unique, stable `id` in `deck-config.ts` so links and annotations survive reordering. Existing decks without IDs remain supported.
88
89
  - **Semantic colors**: Use `text-foreground`, `text-muted-foreground`, `text-primary`, `bg-background`, `bg-card`, `border-border`.
89
90
  - **Icons**: Import from `lucide-react` (e.g., `import { ArrowRight } from "lucide-react"`).
90
91
  - **Animations**: Use `<Animated step={n}>` for click-to-reveal. The `steps` value in `deck-config.ts` must equal the highest step number used. Available: `fade`, `slide-up`, `slide-down`, `slide-left`, `slide-right`, `scale`. **Important:** `<Animated>` renders a wrapper div — when inside a grid/flex container, pass layout classes (`h-full`, `col-span-*`, etc.) via `className` on the `<Animated>`, not only on the inner child.
@@ -3,6 +3,6 @@ import { SlideTitle } from "@/slides/slide-title";
3
3
  import { SlideExample } from "@/slides/slide-example";
4
4
 
5
5
  export const slides: SlideConfig[] = [
6
- { component: SlideTitle, steps: 0 },
7
- { component: SlideExample, steps: 2 },
6
+ { id: "title", component: SlideTitle, steps: 0 },
7
+ { id: "example", component: SlideExample, steps: 2 },
8
8
  ];