promptslide 0.3.2 → 0.3.4

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 CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.4](https://github.com/prompticeu/promptslide/compare/promptslide-v0.3.3...promptslide-v0.3.4) (2026-03-07)
4
+
5
+
6
+ ### Features
7
+
8
+ * unify deckPrefix and deckSlug into single deck slug concept ([#66](https://github.com/prompticeu/promptslide/issues/66)) ([f091c7d](https://github.com/prompticeu/promptslide/commit/f091c7dcd93738670fde4b48ff1020a358bce128))
9
+
10
+
11
+ ### Performance Improvements
12
+
13
+ * optimize deck publish with parallel phases and reusable capture session ([#63](https://github.com/prompticeu/promptslide/issues/63)) ([62fb985](https://github.com/prompticeu/promptslide/commit/62fb9853f7d220be8bf5ba07e5cc55744b658229))
14
+
15
+ ## [0.3.3](https://github.com/prompticeu/promptslide/compare/promptslide-v0.3.2...promptslide-v0.3.3) (2026-03-07)
16
+
17
+
18
+ ### Features
19
+
20
+ * forward browser ESM import errors to terminal logs ([#61](https://github.com/prompticeu/promptslide/issues/61)) ([4925013](https://github.com/prompticeu/promptslide/commit/49250131469061b4ca102644e8a7cbf6f63ac2c9))
21
+
3
22
  ## [0.3.2](https://github.com/prompticeu/promptslide/compare/promptslide-v0.3.1...promptslide-v0.3.2) (2026-03-07)
4
23
 
5
24
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "promptslide",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "CLI and slide engine for PromptSlide presentations",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -149,8 +149,7 @@ export async function add(args) {
149
149
 
150
150
  // Persist deck slug for future pull/publish if this is a deck
151
151
  if (item.type === "deck") {
152
- const prefix = item.name.split("/")[0]
153
- updateLockfilePublishConfig(cwd, { deckSlug: item.name, deckPrefix: prefix })
152
+ updateLockfilePublishConfig(cwd, { deckSlug: item.name })
154
153
  }
155
154
 
156
155
  // Auto-update deck-config.ts
@@ -236,8 +236,7 @@ export async function create(args) {
236
236
  }
237
237
 
238
238
  // Persist deck slug for future pull/publish in the new project
239
- const prefix = fromSlug.split("/")[0]
240
- updateLockfilePublishConfig(targetDir, { deckSlug: fromSlug, deckPrefix: prefix })
239
+ updateLockfilePublishConfig(targetDir, { deckSlug: fromSlug })
241
240
 
242
241
  console.log()
243
242
  }
@@ -4,41 +4,43 @@ import { fileURLToPath } from "node:url"
4
4
 
5
5
  import { bold, green, cyan, red, dim } from "../utils/ansi.mjs"
6
6
  import { requireAuth } from "../utils/auth.mjs"
7
- import { captureSlideAsDataUri, isPlaywrightAvailable } from "../utils/export.mjs"
8
- import { publishToRegistry, registryItemExists, searchRegistry, updateLockfileItem, updateLockfilePublishConfig, readLockfile, hashContent, detectPackageManager, requestUploadTokens, uploadBinaryToBlob, assetFileToSlug, detectAssetDepsInContent } from "../utils/registry.mjs"
7
+ import { captureSlideAsDataUri, isPlaywrightAvailable, createCaptureSession } from "../utils/export.mjs"
8
+ import { publishToRegistry, registryItemExists, searchRegistry, updateLockfileItem, updateLockfilePublishConfig, readLockfile, writeLockfile, hashContent, detectPackageManager, requestUploadTokens, uploadBinaryToBlob, assetFileToSlug, detectAssetDepsInContent } from "../utils/registry.mjs"
9
9
  import { prompt, confirm, select, closePrompts } from "../utils/prompts.mjs"
10
10
  import { parseDeckConfig } from "../utils/deck-config.mjs"
11
11
 
12
12
  const __dirname = dirname(fileURLToPath(import.meta.url))
13
13
  const CLI_VERSION = JSON.parse(readFileSync(join(__dirname, "..", "..", "package.json"), "utf-8")).version
14
14
 
15
- function readDeckPrefix(cwd) {
16
- // Prefer stored prefix from lockfile (user's previous choice)
15
+ function readDeckSlug(cwd) {
17
16
  const lock = readLockfile(cwd)
17
+ // Prefer stored deck slug — migrate old two-part format (e.g. "my-deck/name" → "my-deck")
18
+ if (lock.deckSlug) return lock.deckSlug.split("/")[0]
19
+ // Migrate legacy deckPrefix (old lockfiles stored prefix separately)
18
20
  if (lock.deckPrefix) return lock.deckPrefix
21
+ return ""
22
+ }
19
23
 
20
- // Fall back to package.json name
21
- try {
22
- const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf-8"))
23
- return (pkg.name || "").toLowerCase()
24
- } catch {
25
- return ""
26
- }
24
+ function defaultDeckSlug(cwd) {
25
+ const dirName = basename(cwd)
26
+ return dirName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")
27
27
  }
28
28
 
29
- async function promptDeckPrefix(cwd, interactive) {
30
- const defaultPrefix = readDeckPrefix(cwd)
29
+ async function promptDeckSlug(cwd, interactive) {
30
+ const stored = readDeckSlug(cwd)
31
+ const fallback = defaultDeckSlug(cwd)
32
+ const defaultValue = stored || fallback
31
33
  if (!interactive) {
32
- if (!defaultPrefix) throw new Error("Deck prefix is required. Set a name in package.json or publish interactively.")
33
- return defaultPrefix
34
+ if (!defaultValue) throw new Error("Deck slug is required. Publish interactively to set it.")
35
+ return defaultValue
34
36
  }
35
- let prefix
37
+ let slug
36
38
  while (true) {
37
- prefix = await prompt("Deck prefix:", defaultPrefix)
38
- if (prefix && /^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(prefix)) break
39
- console.log(` ${red("Error:")} Deck prefix is required (lowercase alphanumeric with hyphens, min 2 chars)`)
39
+ slug = await prompt("Deck slug:", defaultValue)
40
+ if (slug && /^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(slug)) break
41
+ console.log(` ${red("Error:")} Deck slug is required (lowercase alphanumeric with hyphens, min 2 chars)`)
40
42
  }
41
- return prefix
43
+ return slug
42
44
  }
43
45
 
44
46
  function titleCase(slug) {
@@ -228,10 +230,10 @@ function scanForFiles(cwd) {
228
230
 
229
231
  /**
230
232
  * Publish a single item to the registry.
231
- * @param {{ filePath: string, cwd: string, auth: object, typeOverride?: string, interactive?: boolean, deckPrefix?: string }} opts
233
+ * @param {{ filePath: string, cwd: string, auth: object, typeOverride?: string, interactive?: boolean, deckSlug?: string }} opts
232
234
  * @returns {Promise<{ slug: string, status: string }>}
233
235
  */
234
- async function publishItem({ filePath, cwd, auth, typeOverride, interactive = true, deckPrefix }) {
236
+ async function publishItem({ filePath, cwd, auth, typeOverride, interactive = true, deckSlug: deckSlugOverride }) {
235
237
  const fullPath = join(cwd, filePath)
236
238
  if (!existsSync(fullPath)) {
237
239
  throw new Error(`File not found: ${filePath}`)
@@ -240,8 +242,8 @@ async function publishItem({ filePath, cwd, auth, typeOverride, interactive = tr
240
242
  const content = readFileSync(fullPath, "utf-8")
241
243
  const fileName = basename(fullPath)
242
244
  const baseSlug = fileName.replace(/\.tsx?$/, "")
243
- const prefix = deckPrefix || await promptDeckPrefix(cwd, interactive)
244
- const slug = `${prefix}/${baseSlug}`
245
+ const deck = deckSlugOverride || await promptDeckSlug(cwd, interactive)
246
+ const slug = `${deck}/${baseSlug}`
245
247
 
246
248
  const type = typeOverride || detectType(filePath) || "slide"
247
249
  const steps = detectSteps(content)
@@ -283,7 +285,7 @@ async function publishItem({ filePath, cwd, auth, typeOverride, interactive = tr
283
285
  previewImage = await captureSlideAsDataUri({ cwd, slidePath: filePath }).catch(() => null)
284
286
  }
285
287
 
286
- const prefixedDeps = registryDeps.map(d => `${prefix}/${d}`)
288
+ const prefixedDeps = registryDeps.map(d => `${deck}/${d}`)
287
289
  const payload = {
288
290
  type,
289
291
  slug,
@@ -378,33 +380,11 @@ export async function publish(args) {
378
380
  process.exit(1)
379
381
  }
380
382
 
381
- // Check if a deck slug already exists in the lockfile
382
- const existingLock = readLockfile(cwd)
383
- const existingSlug = existingLock.deckSlug
384
- let deckPrefix, deckSlug
383
+ // Resolve deck slug (= deck identity, used as namespace for all items)
384
+ const deckSlug = await promptDeckSlug(cwd, true)
385
385
 
386
- const dirName = basename(cwd)
387
- const deckBaseSlug = dirName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")
388
-
389
- if (existingSlug) {
390
- console.log(` ${dim("Previously published as")} ${cyan(existingSlug)}`)
391
- console.log()
392
- const reuse = await confirm(` Publish to ${bold(existingSlug)}?`)
393
-
394
- if (reuse) {
395
- deckSlug = existingSlug
396
- deckPrefix = existingSlug.split("/")[0]
397
- } else {
398
- deckPrefix = await promptDeckPrefix(cwd, true)
399
- deckSlug = `${deckPrefix}/${deckBaseSlug}`
400
- }
401
- } else {
402
- deckPrefix = await promptDeckPrefix(cwd, true)
403
- deckSlug = `${deckPrefix}/${deckBaseSlug}`
404
- }
405
-
406
- // Persist prefix for next time (slug is saved after successful publish)
407
- updateLockfilePublishConfig(cwd, { deckPrefix })
386
+ // Persist slug early so it's available even if publish fails partway
387
+ updateLockfilePublishConfig(cwd, { deckSlug })
408
388
 
409
389
  // Walk public/ to collect assets and build reference set
410
390
  const publicDir = join(cwd, "public")
@@ -458,7 +438,7 @@ export async function publish(args) {
458
438
  console.log()
459
439
 
460
440
  // Collect deck metadata
461
- const title = await prompt("Title:", titleCase(deckBaseSlug))
441
+ const title = await prompt("Title:", titleCase(deckSlug))
462
442
  const description = await prompt("Description:", "")
463
443
  const tagsInput = await prompt("Tags (comma-separated):", "")
464
444
  const tags = tagsInput ? tagsInput.split(",").map(t => t.trim()).filter(Boolean) : []
@@ -500,10 +480,10 @@ export async function publish(args) {
500
480
 
501
481
  // Read lockfile for skip-if-unchanged
502
482
  const lock = readLockfile(cwd)
503
- let itemIndex = 0
504
483
  let published = 0
505
484
  let skipped = 0
506
485
  let failed = 0
486
+ const lockfileUpdates = []
507
487
 
508
488
  function isUnchanged(slug, fileHashes) {
509
489
  const entry = lock.items[slug]
@@ -514,51 +494,66 @@ export async function publish(args) {
514
494
  return currentKeys.every(k => entry.files[k] === fileHashes[k])
515
495
  }
516
496
 
517
- // ── Phase 1: Assets ──
518
- for (const asset of publicAssets) {
519
- itemIndex++
520
- const assetSlug = assetFileToSlug(deckPrefix, asset.relativePath)
521
- const fileName = basename(asset.fullPath)
522
- const dirPart = asset.relativePath.includes("/")
523
- ? "public/" + asset.relativePath.substring(0, asset.relativePath.lastIndexOf("/") + 1)
524
- : "public/"
525
-
526
- const fileData = readFileForRegistry(asset.fullPath)
527
- const fileHash = fileData.binary
528
- ? hashContent(fileData.buffer.toString("base64"))
529
- : hashContent(fileData.content)
530
- const fileHashes = { [dirPart + fileName]: fileHash }
531
-
532
- if (isUnchanged(assetSlug, fileHashes)) {
533
- console.log(` [${itemIndex}/${totalItems}] ${dim("—")} asset ${dim(assetSlug)} (unchanged)`)
534
- skipped++
535
- continue
536
- }
497
+ const CONCURRENCY = 6
537
498
 
538
- let files
539
- if (fileData.binary) {
540
- files = await uploadBinaryFiles(assetSlug, [
541
- { path: fileName, target: dirPart, binary: true, buffer: fileData.buffer, contentType: fileData.contentType, size: fileData.size }
542
- ], auth)
543
- } else {
544
- files = [{ path: fileName, target: dirPart, content: fileData.content }]
499
+ async function runParallel(tasks) {
500
+ const results = []
501
+ for (let i = 0; i < tasks.length; i += CONCURRENCY) {
502
+ const batch = tasks.slice(i, i + CONCURRENCY)
503
+ results.push(...await Promise.allSettled(batch.map(fn => fn())))
545
504
  }
505
+ return results
506
+ }
546
507
 
547
- try {
548
- const result = await publishToRegistry({ type: "asset", slug: assetSlug, title: fileName, files }, auth)
549
- console.log(` [${itemIndex}/${totalItems}] ${green("✓")} asset ${cyan(assetSlug)} ${dim(`v${result.version}`)}`)
550
- updateLockfileItem(cwd, assetSlug, result.version ?? 0, fileHashes)
551
- published++
552
- } catch (err) {
553
- console.log(` [${itemIndex}/${totalItems}] ${red("✗")} asset ${dim(assetSlug)}: ${err.message}`)
554
- failed++
508
+ // ── Phase 1: Assets ──
509
+ let itemIndex = 0
510
+ const assetTasks = publicAssets.map((asset) => {
511
+ const myIndex = ++itemIndex
512
+ return async () => {
513
+ const assetSlug = assetFileToSlug(deckSlug, asset.relativePath)
514
+ const fileName = basename(asset.fullPath)
515
+ const dirPart = asset.relativePath.includes("/")
516
+ ? "public/" + asset.relativePath.substring(0, asset.relativePath.lastIndexOf("/") + 1)
517
+ : "public/"
518
+
519
+ const fileData = readFileForRegistry(asset.fullPath)
520
+ const fileHash = fileData.binary
521
+ ? hashContent(fileData.buffer.toString("base64"))
522
+ : hashContent(fileData.content)
523
+ const fileHashes = { [dirPart + fileName]: fileHash }
524
+
525
+ if (isUnchanged(assetSlug, fileHashes)) {
526
+ console.log(` [${myIndex}/${totalItems}] ${dim("—")} asset ${dim(assetSlug)} (unchanged)`)
527
+ skipped++
528
+ return
529
+ }
530
+
531
+ let files
532
+ if (fileData.binary) {
533
+ files = await uploadBinaryFiles(assetSlug, [
534
+ { path: fileName, target: dirPart, binary: true, buffer: fileData.buffer, contentType: fileData.contentType, size: fileData.size }
535
+ ], auth)
536
+ } else {
537
+ files = [{ path: fileName, target: dirPart, content: fileData.content }]
538
+ }
539
+
540
+ try {
541
+ const result = await publishToRegistry({ type: "asset", slug: assetSlug, title: fileName, files }, auth)
542
+ console.log(` [${myIndex}/${totalItems}] ${green("✓")} asset ${cyan(assetSlug)} ${dim(`v${result.version}`)}`)
543
+ lockfileUpdates.push({ slug: assetSlug, version: result.version ?? 0, fileHashes })
544
+ published++
545
+ } catch (err) {
546
+ console.log(` [${myIndex}/${totalItems}] ${red("✗")} asset ${dim(assetSlug)}: ${err.message}`)
547
+ failed++
548
+ }
555
549
  }
556
- }
550
+ })
551
+ await runParallel(assetTasks)
557
552
 
558
553
  // ── Phase 2: Theme ──
559
554
  if (hasTheme) {
560
555
  itemIndex++
561
- const themeSlug = `${deckPrefix}/theme`
556
+ const themeSlug = `${deckSlug}/theme`
562
557
  const themePayloadFiles = []
563
558
  const themeFileHashes = {}
564
559
  let themeContent = ""
@@ -574,7 +569,7 @@ export async function publish(args) {
574
569
  console.log(` [${itemIndex}/${totalItems}] ${dim("—")} theme ${dim(themeSlug)} (unchanged)`)
575
570
  skipped++
576
571
  } else {
577
- const assetDeps = detectAssetDepsInContent(themeContent, deckPrefix, publicFileSet)
572
+ const assetDeps = detectAssetDepsInContent(themeContent, deckSlug, publicFileSet)
578
573
  const npmDeps = detectNpmDeps(themeContent)
579
574
 
580
575
  try {
@@ -588,7 +583,7 @@ export async function publish(args) {
588
583
  promptslideVersion: CLI_VERSION
589
584
  }, auth)
590
585
  console.log(` [${itemIndex}/${totalItems}] ${green("✓")} theme ${cyan(themeSlug)} ${dim(`v${result.version}`)}`)
591
- updateLockfileItem(cwd, themeSlug, result.version ?? 0, themeFileHashes)
586
+ lockfileUpdates.push({ slug: themeSlug, version: result.version ?? 0, fileHashes: themeFileHashes })
592
587
  published++
593
588
  } catch (err) {
594
589
  console.log(` [${itemIndex}/${totalItems}] ${red("✗")} theme ${dim(themeSlug)}: ${err.message}`)
@@ -597,100 +592,110 @@ export async function publish(args) {
597
592
  }
598
593
  }
599
594
 
600
- // ── Phase 3: Layouts ──
601
- for (const layoutFile of layoutEntries) {
602
- itemIndex++
603
- const layoutName = layoutFile.replace(/\.tsx?$/, "")
604
- const layoutSlug = `${deckPrefix}/${layoutName}`
605
- const content = readFileSync(join(cwd, "src", "layouts", layoutFile), "utf-8")
606
- const fileHashes = { [`src/layouts/${layoutFile}`]: hashContent(content) }
595
+ // ── Phase 3: Layouts (parallel) ──
596
+ const layoutTasks = layoutEntries.map((layoutFile) => {
597
+ const myIndex = ++itemIndex
598
+ return async () => {
599
+ const layoutName = layoutFile.replace(/\.tsx?$/, "")
600
+ const layoutSlug = `${deckSlug}/${layoutName}`
601
+ const content = readFileSync(join(cwd, "src", "layouts", layoutFile), "utf-8")
602
+ const fileHashes = { [`src/layouts/${layoutFile}`]: hashContent(content) }
603
+
604
+ if (isUnchanged(layoutSlug, fileHashes)) {
605
+ console.log(` [${myIndex}/${totalItems}] ${dim("—")} layout ${dim(layoutSlug)} (unchanged)`)
606
+ skipped++
607
+ return
608
+ }
607
609
 
608
- if (isUnchanged(layoutSlug, fileHashes)) {
609
- console.log(` [${itemIndex}/${totalItems}] ${dim("—")} layout ${dim(layoutSlug)} (unchanged)`)
610
- skipped++
611
- continue
612
- }
610
+ const assetDeps = detectAssetDepsInContent(content, deckSlug, publicFileSet)
611
+ const npmDeps = detectNpmDeps(content)
612
+ const regDeps = hasTheme ? [`${deckSlug}/theme`] : []
613
+ regDeps.push(...assetDeps)
613
614
 
614
- const assetDeps = detectAssetDepsInContent(content, deckPrefix, publicFileSet)
615
- const npmDeps = detectNpmDeps(content)
616
- const regDeps = hasTheme ? [`${deckPrefix}/theme`] : []
617
- regDeps.push(...assetDeps)
618
-
619
- try {
620
- const result = await publishToRegistry({
621
- type: "layout",
622
- slug: layoutSlug,
623
- title: titleCase(layoutName),
624
- files: [{ path: layoutFile, target: "src/layouts/", content }],
625
- registryDependencies: regDeps.length ? regDeps : undefined,
626
- npmDependencies: Object.keys(npmDeps).length ? npmDeps : undefined,
627
- promptslideVersion: CLI_VERSION
628
- }, auth)
629
- console.log(` [${itemIndex}/${totalItems}] ${green("✓")} layout ${cyan(layoutSlug)} ${dim(`v${result.version}`)}`)
630
- updateLockfileItem(cwd, layoutSlug, result.version ?? 0, fileHashes)
631
- published++
632
- } catch (err) {
633
- console.log(` [${itemIndex}/${totalItems}] ${red("✗")} layout ${dim(layoutSlug)}: ${err.message}`)
634
- failed++
615
+ try {
616
+ const result = await publishToRegistry({
617
+ type: "layout",
618
+ slug: layoutSlug,
619
+ title: titleCase(layoutName),
620
+ files: [{ path: layoutFile, target: "src/layouts/", content }],
621
+ registryDependencies: regDeps.length ? regDeps : undefined,
622
+ npmDependencies: Object.keys(npmDeps).length ? npmDeps : undefined,
623
+ promptslideVersion: CLI_VERSION
624
+ }, auth)
625
+ console.log(` [${myIndex}/${totalItems}] ${green("")} layout ${cyan(layoutSlug)} ${dim(`v${result.version}`)}`)
626
+ lockfileUpdates.push({ slug: layoutSlug, version: result.version ?? 0, fileHashes })
627
+ published++
628
+ } catch (err) {
629
+ console.log(` [${myIndex}/${totalItems}] ${red("✗")} layout ${dim(layoutSlug)}: ${err.message}`)
630
+ failed++
631
+ }
635
632
  }
636
- }
633
+ })
634
+ await runParallel(layoutTasks)
635
+
636
+ // ── Phase 4: Slides (parallel, reuse single capture session) ──
637
+ const captureSession = canCapture ? await createCaptureSession({ cwd }) : null
638
+
639
+ const slideTasks = slideEntries.map((slideFile) => {
640
+ const myIndex = ++itemIndex
641
+ return async () => {
642
+ const slideName = slideFile.replace(/\.tsx?$/, "")
643
+ const slideSlug = `${deckSlug}/${slideName}`
644
+ const content = readFileSync(join(cwd, "src", "slides", slideFile), "utf-8")
645
+ const fileHashes = { [`src/slides/${slideFile}`]: hashContent(content) }
646
+
647
+ if (isUnchanged(slideSlug, fileHashes)) {
648
+ console.log(` [${myIndex}/${totalItems}] ${dim("—")} slide ${dim(slideSlug)} (unchanged)`)
649
+ skipped++
650
+ return
651
+ }
637
652
 
638
- // ── Phase 4: Slides ──
639
- for (const slideFile of slideEntries) {
640
- itemIndex++
641
- const slideName = slideFile.replace(/\.tsx?$/, "")
642
- const slideSlug = `${deckPrefix}/${slideName}`
643
- const content = readFileSync(join(cwd, "src", "slides", slideFile), "utf-8")
644
- const fileHashes = { [`src/slides/${slideFile}`]: hashContent(content) }
653
+ const assetDeps = detectAssetDepsInContent(content, deckSlug, publicFileSet)
654
+ const layoutDeps = detectRegistryDeps(content).map(d => `${deckSlug}/${d}`)
655
+ const npmDeps = detectNpmDeps(content)
656
+ const steps = detectSteps(content)
657
+ const slideConfig = deckConfig.slides.find(s => s.slug === slideName)
658
+ const section = slideConfig?.section || undefined
645
659
 
646
- if (isUnchanged(slideSlug, fileHashes)) {
647
- console.log(` [${itemIndex}/${totalItems}] ${dim("—")} slide ${dim(slideSlug)} (unchanged)`)
648
- skipped++
649
- continue
650
- }
660
+ const regDeps = hasTheme ? [`${deckSlug}/theme`] : []
661
+ regDeps.push(...layoutDeps, ...assetDeps)
651
662
 
652
- const assetDeps = detectAssetDepsInContent(content, deckPrefix, publicFileSet)
653
- const layoutDeps = detectRegistryDeps(content).map(d => `${deckPrefix}/${d}`)
654
- const npmDeps = detectNpmDeps(content)
655
- const steps = detectSteps(content)
656
- const slideConfig = deckConfig.slides.find(s => s.slug === slideName)
657
- const section = slideConfig?.section || undefined
658
-
659
- const regDeps = hasTheme ? [`${deckPrefix}/theme`] : []
660
- regDeps.push(...layoutDeps, ...assetDeps)
663
+ // Generate preview image reusing shared session
664
+ let slidePreview = null
665
+ if (captureSession) {
666
+ slidePreview = await captureSession.capture(`src/slides/${slideFile}`).catch(() => null)
667
+ }
661
668
 
662
- // Generate preview image for this slide
663
- let slidePreview = null
664
- if (canCapture) {
665
- slidePreview = await captureSlideAsDataUri({ cwd, slidePath: `src/slides/${slideFile}` }).catch(() => null)
669
+ try {
670
+ const result = await publishToRegistry({
671
+ type: "slide",
672
+ slug: slideSlug,
673
+ title: titleCase(slideName),
674
+ files: [{ path: slideFile, target: "src/slides/", content }],
675
+ steps,
676
+ section,
677
+ registryDependencies: regDeps.length ? regDeps : undefined,
678
+ npmDependencies: Object.keys(npmDeps).length ? npmDeps : undefined,
679
+ previewImage: slidePreview || undefined,
680
+ promptslideVersion: CLI_VERSION
681
+ }, auth)
682
+ console.log(` [${myIndex}/${totalItems}] ${green("✓")} slide ${cyan(slideSlug)} ${dim(`v${result.version}`)}`)
683
+ lockfileUpdates.push({ slug: slideSlug, version: result.version ?? 0, fileHashes })
684
+ published++
685
+ } catch (err) {
686
+ console.log(` [${myIndex}/${totalItems}] ${red("✗")} slide ${dim(slideSlug)}: ${err.message}`)
687
+ failed++
688
+ }
666
689
  }
690
+ })
691
+ await runParallel(slideTasks)
667
692
 
668
- try {
669
- const result = await publishToRegistry({
670
- type: "slide",
671
- slug: slideSlug,
672
- title: titleCase(slideName),
673
- files: [{ path: slideFile, target: "src/slides/", content }],
674
- steps,
675
- section,
676
- registryDependencies: regDeps.length ? regDeps : undefined,
677
- npmDependencies: Object.keys(npmDeps).length ? npmDeps : undefined,
678
- previewImage: slidePreview || undefined,
679
- promptslideVersion: CLI_VERSION
680
- }, auth)
681
- console.log(` [${itemIndex}/${totalItems}] ${green("✓")} slide ${cyan(slideSlug)} ${dim(`v${result.version}`)}`)
682
- updateLockfileItem(cwd, slideSlug, result.version ?? 0, fileHashes)
683
- published++
684
- } catch (err) {
685
- console.log(` [${itemIndex}/${totalItems}] ${red("✗")} slide ${dim(slideSlug)}: ${err.message}`)
686
- failed++
687
- }
688
- }
693
+ if (captureSession) await captureSession.close()
689
694
 
690
695
  // ── Phase 5: Deck manifest ──
691
696
  itemIndex++
692
- const slideSlugs = slideEntries.map(f => `${deckPrefix}/${f.replace(/\.tsx?$/, "")}`)
693
- const assetSlugs = publicAssets.map(a => assetFileToSlug(deckPrefix, a.relativePath))
697
+ const slideSlugs = slideEntries.map(f => `${deckSlug}/${f.replace(/\.tsx?$/, "")}`)
698
+ const assetSlugs = publicAssets.map(a => assetFileToSlug(deckSlug, a.relativePath))
694
699
  const allDeckDeps = [...slideSlugs, ...assetSlugs]
695
700
 
696
701
  let deckItemId = null
@@ -709,8 +714,7 @@ export async function publish(args) {
709
714
  promptslideVersion: CLI_VERSION
710
715
  }, auth)
711
716
  console.log(` [${itemIndex}/${totalItems}] ${green("✓")} deck ${cyan(deckSlug)} ${dim(`v${result.version}`)}`)
712
- updateLockfileItem(cwd, deckSlug, result.version ?? 0, {})
713
- updateLockfilePublishConfig(cwd, { deckSlug })
717
+ lockfileUpdates.push({ slug: deckSlug, version: result.version ?? 0, fileHashes: {} })
714
718
  deckItemId = result.id
715
719
  published++
716
720
  } catch (err) {
@@ -718,6 +722,18 @@ export async function publish(args) {
718
722
  failed++
719
723
  }
720
724
 
725
+ // Batch-write all lockfile updates at once
726
+ const finalLock = readLockfile(cwd)
727
+ for (const update of lockfileUpdates) {
728
+ finalLock.items[update.slug] = {
729
+ version: update.version,
730
+ installedAt: new Date().toISOString().split("T")[0],
731
+ files: update.fileHashes
732
+ }
733
+ }
734
+ if (deckItemId) finalLock.deckSlug = deckSlug
735
+ writeLockfile(cwd, finalLock)
736
+
721
737
  console.log()
722
738
  console.log(` ${bold("Done:")} ${green(`${published} published`)}${skipped ? `, ${skipped} unchanged` : ""}${failed ? `, ${red(`${failed} failed`)}` : ""}`)
723
739
  if (auth.organizationSlug && deckItemId) {
@@ -758,12 +774,12 @@ export async function publish(args) {
758
774
  }
759
775
  console.log()
760
776
 
761
- // Prompt for deck prefix (required)
762
- const deckPrefix = await promptDeckPrefix(cwd, true)
763
- const slug = `${deckPrefix}/${baseSlug}`
777
+ // Prompt for deck slug (required — items are namespaced under the deck)
778
+ const deck = await promptDeckSlug(cwd, true)
779
+ const slug = `${deck}/${baseSlug}`
764
780
 
765
- // Persist prefix for next time
766
- updateLockfilePublishConfig(cwd, { deckPrefix })
781
+ // Persist deck slug for next time
782
+ updateLockfilePublishConfig(cwd, { deckSlug: deck })
767
783
 
768
784
  console.log(` Slug: ${cyan(slug)}`)
769
785
  console.log()
@@ -788,7 +804,7 @@ export async function publish(args) {
788
804
  const missing = []
789
805
 
790
806
  for (const depSlug of registryDeps) {
791
- const prefixedDepSlug = `${deckPrefix}/${depSlug}`
807
+ const prefixedDepSlug = `${deck}/${depSlug}`
792
808
  const exists = await registryItemExists(prefixedDepSlug, auth)
793
809
  if (!exists) {
794
810
  missing.push(depSlug)
@@ -826,7 +842,7 @@ export async function publish(args) {
826
842
  auth,
827
843
  typeOverride: depType,
828
844
  interactive: true,
829
- deckPrefix
845
+ deckSlug: deck
830
846
  })
831
847
  console.log()
832
848
  const depVer = result.version ? ` ${dim(`v${result.version}`)}` : ""
@@ -881,7 +897,7 @@ export async function publish(args) {
881
897
  console.log()
882
898
 
883
899
  // Publish main item
884
- const prefixedRegistryDeps = registryDeps.map(d => `${deckPrefix}/${d}`)
900
+ const prefixedRegistryDeps = registryDeps.map(d => `${deck}/${d}`)
885
901
  const payload = {
886
902
  type,
887
903
  slug,
@@ -97,6 +97,74 @@ export async function captureSlideScreenshot({ cwd, slidePath, width = 1280, hei
97
97
  }
98
98
  }
99
99
 
100
+ /**
101
+ * Create a reusable capture session that shares a single Vite server and browser
102
+ * instance across multiple screenshot captures.
103
+ *
104
+ * @param {{ cwd: string, width?: number, height?: number }} opts
105
+ * @returns {Promise<{ capture: (slidePath: string) => Promise<string | null>, close: () => Promise<void> } | null>}
106
+ * null if Playwright is not available
107
+ */
108
+ export async function createCaptureSession({ cwd, width = 1280, height = 720 }) {
109
+ let chromium
110
+ try {
111
+ const pw = await import("playwright")
112
+ chromium = pw.chromium
113
+ } catch {
114
+ return null
115
+ }
116
+
117
+ 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
+
131
+ const browser = await chromium.launch({ headless: true })
132
+
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 } })
136
+ 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)
148
+
149
+ const element = await page.$("[data-export-ready='true']")
150
+ const screenshot = await element.screenshot({ type: "png" })
151
+ return `data:image/png;base64,${screenshot.toString("base64")}`
152
+ } catch (err) {
153
+ console.error(` Screenshot error: ${err.message}`)
154
+ return null
155
+ } finally {
156
+ await page.close().catch(() => {})
157
+ }
158
+ }
159
+
160
+ async function close() {
161
+ await browser.close().catch(() => {})
162
+ await server.close()
163
+ }
164
+
165
+ return { capture, close }
166
+ }
167
+
100
168
  /**
101
169
  * Capture a slide and return as base64 data URI.
102
170
  * Returns null if Playwright is not available or capture fails.
@@ -80,14 +80,15 @@ export function updateLockfileItem(cwd, slug, version, files) {
80
80
  }
81
81
 
82
82
  /**
83
- * Store publish configuration (deckPrefix, deckSlug) in the lockfile.
83
+ * Store publish configuration (deckSlug) in the lockfile.
84
+ * Removes the legacy deckPrefix key if present.
84
85
  * @param {string} cwd
85
- * @param {{ deckPrefix?: string, deckSlug?: string }} config
86
+ * @param {{ deckSlug?: string }} config
86
87
  */
87
88
  export function updateLockfilePublishConfig(cwd, config) {
88
89
  const lock = readLockfile(cwd)
89
- if (config.deckPrefix !== undefined) lock.deckPrefix = config.deckPrefix
90
90
  if (config.deckSlug !== undefined) lock.deckSlug = config.deckSlug
91
+ delete lock.deckPrefix
91
92
  writeLockfile(cwd, lock)
92
93
  }
93
94
 
@@ -1,3 +1,5 @@
1
+ import { bold, dim } from "../utils/ansi.mjs"
2
+
1
3
  const VIRTUAL_ENTRY_ID = "virtual:promptslide-entry"
2
4
  const RESOLVED_VIRTUAL_ENTRY_ID = "\0" + VIRTUAL_ENTRY_ID
3
5
  const VIRTUAL_EXPORT_ID = "virtual:promptslide-export"
@@ -5,6 +7,21 @@ const RESOLVED_VIRTUAL_EXPORT_ID = "\0" + VIRTUAL_EXPORT_ID
5
7
  const VIRTUAL_EMBED_ID = "virtual:promptslide-embed"
6
8
  const RESOLVED_VIRTUAL_EMBED_ID = "\0" + VIRTUAL_EMBED_ID
7
9
 
10
+ // Inline script that catches module load errors (e.g. missing named exports)
11
+ // and forwards them to the Vite dev server so they appear in terminal logs.
12
+ // Must be a regular script (not type="module") to run before module evaluation.
13
+ const ERROR_FORWARD_SCRIPT = `<script>
14
+ window.addEventListener("error", function(e) {
15
+ if (e.message) {
16
+ fetch("/__promptslide_error", {
17
+ method: "POST",
18
+ headers: { "Content-Type": "application/json" },
19
+ body: JSON.stringify({ message: e.message, filename: e.filename || "" })
20
+ }).catch(function() {});
21
+ }
22
+ });
23
+ </script>`
24
+
8
25
  function getHtmlTemplate() {
9
26
  return `<!doctype html>
10
27
  <html lang="en">
@@ -15,6 +32,7 @@ function getHtmlTemplate() {
15
32
  </head>
16
33
  <body>
17
34
  <div id="root"></div>
35
+ ${ERROR_FORWARD_SCRIPT}
18
36
  <script type="module" src="/@id/${VIRTUAL_ENTRY_ID}"></script>
19
37
  </body>
20
38
  </html>`
@@ -155,6 +173,23 @@ export function promptslidePlugin({ root: initialRoot } = {}) {
155
173
  },
156
174
 
157
175
  configureServer(server) {
176
+ // Pre-middleware: receive browser errors and log them to the terminal
177
+ server.middlewares.use((req, res, next) => {
178
+ if (req.method !== "POST" || req.url !== "/__promptslide_error") return next()
179
+
180
+ let body = ""
181
+ req.on("data", chunk => { body += chunk })
182
+ req.on("end", () => {
183
+ try {
184
+ const { message, filename } = JSON.parse(body)
185
+ const location = filename ? ` ${dim(`(${filename})`)}` : ""
186
+ server.config.logger.error(`${bold("Browser error:")} ${message}${location}`, { timestamp: true })
187
+ } catch {}
188
+ res.statusCode = 204
189
+ res.end()
190
+ })
191
+ })
192
+
158
193
  // Pre-middleware: serve /embed route
159
194
  server.middlewares.use(async (req, res, next) => {
160
195
  const url = new URL(req.url, "http://localhost")