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/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*["']([^"']+)["']/)
@@ -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
+ }
@@ -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
+ }
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"
5
5
  import tailwindcss from "@tailwindcss/postcss"
6
6
  import react from "@vitejs/plugin-react"
7
7
 
8
+ import { editorPlugin } from "../editor/plugin.mjs"
8
9
  import { promptslidePlugin } from "./plugin.mjs"
9
10
 
10
11
  const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -17,13 +18,20 @@ const tailwindPath = resolve(dirname(require.resolve("tailwindcss")), "..")
17
18
  // Resolve promptslide core from source so Vite uses TS directly without a build step
18
19
  const promptslidePath = resolve(__dirname, "../core/index.ts")
19
20
 
20
- export function createViteConfig({ cwd, mode = "development" }) {
21
+ export function createViteConfig({ cwd, mode = "development", editor = true }) {
21
22
  return {
22
23
  configFile: false,
23
24
  root: cwd,
24
25
  mode,
25
- plugins: [react(), promptslidePlugin({ root: cwd })],
26
+ plugins: [
27
+ ...(mode === "development" && editor ? [editorPlugin({ root: cwd })] : []),
28
+ react(),
29
+ promptslidePlugin({ root: cwd })
30
+ ],
26
31
  resolve: {
32
+ // The CLI's core/editor and the deck must share one React dispatcher,
33
+ // including when the CLI is installed or linked outside the deck root.
34
+ dedupe: ["react", "react-dom"],
27
35
  alias: {
28
36
  "@": resolve(cwd, "src"),
29
37
  promptslide: promptslidePath,
@@ -31,6 +39,19 @@ export function createViteConfig({ cwd, mode = "development" }) {
31
39
  tailwindcss: tailwindPath
32
40
  }
33
41
  },
42
+ // These imports live in virtual entry modules, which dependency scanning
43
+ // cannot discover before the first page loads. Optimize them together so
44
+ // the viewer and editor never start with different dependency generations.
45
+ optimizeDeps: {
46
+ include: [
47
+ "react",
48
+ "react-dom/client",
49
+ "lucide-react",
50
+ "framer-motion",
51
+ "clsx",
52
+ "tailwind-merge"
53
+ ]
54
+ },
34
55
  css: {
35
56
  postcss: {
36
57
  plugins: [tailwindcss()]