promptslide 0.3.3 → 0.3.5

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,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.5](https://github.com/prompticeu/promptslide/compare/promptslide-v0.3.4...promptslide-v0.3.5) (2026-03-08)
4
+
5
+
6
+ ### Features
7
+
8
+ * bundle shared sources with deck publish and fix deck-config parsing ([5f7fd1d](https://github.com/prompticeu/promptslide/commit/5f7fd1de6e5fc58a0568bd87830094dd40c9eca7))
9
+ * persist deck and item metadata to lockfile for publish defaults ([#68](https://github.com/prompticeu/promptslide/issues/68)) ([0dc8ca3](https://github.com/prompticeu/promptslide/commit/0dc8ca3f52b86214eb42dacd9e1ead4d05928242))
10
+
11
+ ## [0.3.4](https://github.com/prompticeu/promptslide/compare/promptslide-v0.3.3...promptslide-v0.3.4) (2026-03-07)
12
+
13
+
14
+ ### Features
15
+
16
+ * 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))
17
+
18
+
19
+ ### Performance Improvements
20
+
21
+ * 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))
22
+
3
23
  ## [0.3.3](https://github.com/prompticeu/promptslide/compare/promptslide-v0.3.2...promptslide-v0.3.3) (2026-03-07)
4
24
 
5
25
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "promptslide",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
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, readDeckMeta, updateDeckMeta, readItemMeta, updateItemMeta } 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) {
@@ -89,6 +91,57 @@ function detectRegistryDeps(content) {
89
91
  return deps
90
92
  }
91
93
 
94
+ /**
95
+ * Discover shared source files under src/ that aren't slides, layouts, or theme.
96
+ * These are files in directories like src/components/, src/lib/, src/hooks/, etc.
97
+ * Returns an array of { relativePath, fullPath, target } objects.
98
+ */
99
+ function discoverSharedSources(cwd) {
100
+ const srcDir = join(cwd, "src")
101
+ if (!existsSync(srcDir)) return []
102
+
103
+ // Top-level dirs whose top-level files are published as individual items
104
+ // (slides/*.tsx → slide items, layouts/*.tsx → layout items).
105
+ // Subdirectories within these ARE included as shared sources (e.g. slides/commercial/slide1.tsx).
106
+ const ITEM_DIRS = new Set(["slides", "layouts"])
107
+ // Top-level files already handled (theme.ts, globals.css, deck-config.ts, App.tsx)
108
+ const SKIP_FILES = new Set(["theme.ts", "globals.css", "deck-config.ts", "App.tsx", "vite-env.d.ts"])
109
+ const SOURCE_EXTS = new Set([".tsx", ".ts", ".jsx", ".js"])
110
+
111
+ const results = []
112
+
113
+ function walk(dir, relativeDir, skipTopLevelFiles) {
114
+ if (!existsSync(dir)) return
115
+ for (const entry of readdirSync(dir)) {
116
+ if (entry.startsWith(".")) continue
117
+ const full = join(dir, entry)
118
+ const s = statSync(full)
119
+ if (s.isDirectory()) {
120
+ // Enter slides/ and layouts/ but mark that top-level files are handled elsewhere
121
+ if (!relativeDir && ITEM_DIRS.has(entry)) {
122
+ walk(full, entry, true)
123
+ } else {
124
+ walk(full, relativeDir ? `${relativeDir}/${entry}` : entry, false)
125
+ }
126
+ } else if (s.isFile()) {
127
+ // Skip known top-level files in src/
128
+ if (!relativeDir && SKIP_FILES.has(entry)) continue
129
+ // Skip top-level files in slides/ and layouts/ (published as individual items)
130
+ if (skipTopLevelFiles) continue
131
+ const ext = extname(entry).toLowerCase()
132
+ if (!SOURCE_EXTS.has(ext)) continue
133
+ const relativePath = relativeDir ? `${relativeDir}/${entry}` : entry
134
+ // target mirrors the src/ directory structure for @/ import resolution
135
+ const target = relativeDir ? `src/${relativeDir}/` : "src/"
136
+ results.push({ relativePath, fullPath: full, fileName: entry, target })
137
+ }
138
+ }
139
+ }
140
+
141
+ walk(srcDir, "", false)
142
+ return results
143
+ }
144
+
92
145
  const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".webp"])
93
146
  const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2MB
94
147
 
@@ -228,10 +281,10 @@ function scanForFiles(cwd) {
228
281
 
229
282
  /**
230
283
  * Publish a single item to the registry.
231
- * @param {{ filePath: string, cwd: string, auth: object, typeOverride?: string, interactive?: boolean, deckPrefix?: string }} opts
284
+ * @param {{ filePath: string, cwd: string, auth: object, typeOverride?: string, interactive?: boolean, deckSlug?: string }} opts
232
285
  * @returns {Promise<{ slug: string, status: string }>}
233
286
  */
234
- async function publishItem({ filePath, cwd, auth, typeOverride, interactive = true, deckPrefix }) {
287
+ async function publishItem({ filePath, cwd, auth, typeOverride, interactive = true, deckSlug: deckSlugOverride }) {
235
288
  const fullPath = join(cwd, filePath)
236
289
  if (!existsSync(fullPath)) {
237
290
  throw new Error(`File not found: ${filePath}`)
@@ -240,8 +293,8 @@ async function publishItem({ filePath, cwd, auth, typeOverride, interactive = tr
240
293
  const content = readFileSync(fullPath, "utf-8")
241
294
  const fileName = basename(fullPath)
242
295
  const baseSlug = fileName.replace(/\.tsx?$/, "")
243
- const prefix = deckPrefix || await promptDeckPrefix(cwd, interactive)
244
- const slug = `${prefix}/${baseSlug}`
296
+ const deck = deckSlugOverride || await promptDeckSlug(cwd, interactive)
297
+ const slug = `${deck}/${baseSlug}`
245
298
 
246
299
  const type = typeOverride || detectType(filePath) || "slide"
247
300
  const steps = detectSteps(content)
@@ -252,11 +305,13 @@ async function publishItem({ filePath, cwd, auth, typeOverride, interactive = tr
252
305
  let title, description, tags, section, releaseNotes, previewImage
253
306
 
254
307
  if (interactive) {
255
- title = await prompt("Title:", titleCase(baseSlug))
256
- description = await prompt("Description:", "")
257
- const tagsInput = await prompt("Tags (comma-separated):", "")
308
+ const storedMeta = readItemMeta(cwd, slug)
309
+ title = await prompt("Title:", storedMeta.title || titleCase(baseSlug))
310
+ description = await prompt("Description:", storedMeta.description || "")
311
+ const storedTagsDefault = storedMeta.tags?.length ? storedMeta.tags.join(", ") : ""
312
+ const tagsInput = await prompt("Tags (comma-separated):", storedTagsDefault)
258
313
  tags = tagsInput ? tagsInput.split(",").map(t => t.trim()).filter(Boolean) : []
259
- section = await prompt("Section:", "")
314
+ section = await prompt("Section:", storedMeta.section || "")
260
315
  releaseNotes = await prompt("Release notes:", "")
261
316
  const imagePath = await prompt("Preview image path (leave empty to auto-generate):", "")
262
317
  if (imagePath) {
@@ -283,7 +338,7 @@ async function publishItem({ filePath, cwd, auth, typeOverride, interactive = tr
283
338
  previewImage = await captureSlideAsDataUri({ cwd, slidePath: filePath }).catch(() => null)
284
339
  }
285
340
 
286
- const prefixedDeps = registryDeps.map(d => `${prefix}/${d}`)
341
+ const prefixedDeps = registryDeps.map(d => `${deck}/${d}`)
287
342
  const payload = {
288
343
  type,
289
344
  slug,
@@ -305,6 +360,13 @@ async function publishItem({ filePath, cwd, auth, typeOverride, interactive = tr
305
360
  // Track in lockfile
306
361
  const fileHashes = { [target + fileName]: hashContent(content) }
307
362
  updateLockfileItem(cwd, slug, result.version ?? 0, fileHashes)
363
+ // Persist item metadata for next publish
364
+ updateItemMeta(cwd, slug, {
365
+ title,
366
+ description: description || undefined,
367
+ tags,
368
+ section: section || undefined
369
+ })
308
370
 
309
371
  return { slug, status: result.status || "published", version: result.version }
310
372
  }
@@ -378,33 +440,11 @@ export async function publish(args) {
378
440
  process.exit(1)
379
441
  }
380
442
 
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
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
- }
443
+ // Resolve deck slug (= deck identity, used as namespace for all items)
444
+ const deckSlug = await promptDeckSlug(cwd, true)
405
445
 
406
- // Persist prefix for next time (slug is saved after successful publish)
407
- updateLockfilePublishConfig(cwd, { deckPrefix })
446
+ // Persist slug early so it's available even if publish fails partway
447
+ updateLockfilePublishConfig(cwd, { deckSlug })
408
448
 
409
449
  // Walk public/ to collect assets and build reference set
410
450
  const publicDir = join(cwd, "public")
@@ -443,6 +483,9 @@ export async function publish(args) {
443
483
  ? readdirSync(slidesDir).filter(f => f.endsWith(".tsx") || f.endsWith(".ts"))
444
484
  : []
445
485
 
486
+ // Discover shared source files (src/components/, src/lib/, etc.)
487
+ const sharedSources = discoverSharedSources(cwd)
488
+
446
489
  const totalItems = publicAssets.length + (hasTheme ? 1 : 0) + layoutEntries.length + slideEntries.length + 1
447
490
 
448
491
  // Display summary
@@ -451,16 +494,26 @@ export async function publish(args) {
451
494
  if (hasTheme) console.log(` Theme: 1`)
452
495
  if (layoutEntries.length) console.log(` Layouts: ${layoutEntries.length}`)
453
496
  console.log(` Slides: ${slideEntries.length}`)
497
+ if (sharedSources.length) console.log(` Shared: ${sharedSources.length} ${dim("(bundled with deck)")}`)
454
498
  console.log(` Total: ${totalItems} items`)
499
+ // Info if there are slide files on disk not referenced in deck-config.ts
500
+ const deckConfigSlideCount = deckConfig.slides.length
501
+ if (deckConfigSlideCount !== slideEntries.length) {
502
+ console.log()
503
+ console.log(` ${dim("ℹ")} deck-config.ts has ${deckConfigSlideCount} slides, ${slideEntries.length} slide files on disk`)
504
+ console.log(` ${dim("All slides are published. Only deck-config slides appear in the deck preview.")}`)
505
+ }
455
506
  if (deckConfig.transition) {
456
507
  console.log(` Transition: ${deckConfig.transition}${deckConfig.directionalTransition ? " (directional)" : ""}`)
457
508
  }
458
509
  console.log()
459
510
 
460
- // Collect deck metadata
461
- const title = await prompt("Title:", titleCase(deckBaseSlug))
462
- const description = await prompt("Description:", "")
463
- const tagsInput = await prompt("Tags (comma-separated):", "")
511
+ // Collect deck metadata (use stored values as defaults)
512
+ const storedDeckMeta = readDeckMeta(cwd)
513
+ const title = await prompt("Title:", storedDeckMeta.title || titleCase(deckSlug))
514
+ const description = await prompt("Description:", storedDeckMeta.description || "")
515
+ const storedTagsDefault = storedDeckMeta.tags?.length ? storedDeckMeta.tags.join(", ") : ""
516
+ const tagsInput = await prompt("Tags (comma-separated):", storedTagsDefault)
464
517
  const tags = tagsInput ? tagsInput.split(",").map(t => t.trim()).filter(Boolean) : []
465
518
  const releaseNotes = await prompt("Release notes:", "")
466
519
 
@@ -500,10 +553,10 @@ export async function publish(args) {
500
553
 
501
554
  // Read lockfile for skip-if-unchanged
502
555
  const lock = readLockfile(cwd)
503
- let itemIndex = 0
504
556
  let published = 0
505
557
  let skipped = 0
506
558
  let failed = 0
559
+ const lockfileUpdates = []
507
560
 
508
561
  function isUnchanged(slug, fileHashes) {
509
562
  const entry = lock.items[slug]
@@ -514,51 +567,66 @@ export async function publish(args) {
514
567
  return currentKeys.every(k => entry.files[k] === fileHashes[k])
515
568
  }
516
569
 
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
- }
570
+ const CONCURRENCY = 6
537
571
 
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 }]
572
+ async function runParallel(tasks) {
573
+ const results = []
574
+ for (let i = 0; i < tasks.length; i += CONCURRENCY) {
575
+ const batch = tasks.slice(i, i + CONCURRENCY)
576
+ results.push(...await Promise.allSettled(batch.map(fn => fn())))
545
577
  }
578
+ return results
579
+ }
580
+
581
+ // ── Phase 1: Assets ──
582
+ let itemIndex = 0
583
+ const assetTasks = publicAssets.map((asset) => {
584
+ const myIndex = ++itemIndex
585
+ return async () => {
586
+ const assetSlug = assetFileToSlug(deckSlug, asset.relativePath)
587
+ const fileName = basename(asset.fullPath)
588
+ const dirPart = asset.relativePath.includes("/")
589
+ ? "public/" + asset.relativePath.substring(0, asset.relativePath.lastIndexOf("/") + 1)
590
+ : "public/"
591
+
592
+ const fileData = readFileForRegistry(asset.fullPath)
593
+ const fileHash = fileData.binary
594
+ ? hashContent(fileData.buffer.toString("base64"))
595
+ : hashContent(fileData.content)
596
+ const fileHashes = { [dirPart + fileName]: fileHash }
597
+
598
+ if (isUnchanged(assetSlug, fileHashes)) {
599
+ console.log(` [${myIndex}/${totalItems}] ${dim("—")} asset ${dim(assetSlug)} (unchanged)`)
600
+ skipped++
601
+ return
602
+ }
603
+
604
+ let files
605
+ if (fileData.binary) {
606
+ files = await uploadBinaryFiles(assetSlug, [
607
+ { path: fileName, target: dirPart, binary: true, buffer: fileData.buffer, contentType: fileData.contentType, size: fileData.size }
608
+ ], auth)
609
+ } else {
610
+ files = [{ path: fileName, target: dirPart, content: fileData.content }]
611
+ }
546
612
 
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++
613
+ try {
614
+ const result = await publishToRegistry({ type: "asset", slug: assetSlug, title: fileName, files }, auth)
615
+ console.log(` [${myIndex}/${totalItems}] ${green("✓")} asset ${cyan(assetSlug)} ${dim(`v${result.version}`)}`)
616
+ lockfileUpdates.push({ slug: assetSlug, version: result.version ?? 0, fileHashes })
617
+ published++
618
+ } catch (err) {
619
+ console.log(` [${myIndex}/${totalItems}] ${red("✗")} asset ${dim(assetSlug)}: ${err.message}`)
620
+ failed++
621
+ }
555
622
  }
556
- }
623
+ })
624
+ await runParallel(assetTasks)
557
625
 
558
626
  // ── Phase 2: Theme ──
559
627
  if (hasTheme) {
560
628
  itemIndex++
561
- const themeSlug = `${deckPrefix}/theme`
629
+ const themeSlug = `${deckSlug}/theme`
562
630
  const themePayloadFiles = []
563
631
  const themeFileHashes = {}
564
632
  let themeContent = ""
@@ -574,7 +642,7 @@ export async function publish(args) {
574
642
  console.log(` [${itemIndex}/${totalItems}] ${dim("—")} theme ${dim(themeSlug)} (unchanged)`)
575
643
  skipped++
576
644
  } else {
577
- const assetDeps = detectAssetDepsInContent(themeContent, deckPrefix, publicFileSet)
645
+ const assetDeps = detectAssetDepsInContent(themeContent, deckSlug, publicFileSet)
578
646
  const npmDeps = detectNpmDeps(themeContent)
579
647
 
580
648
  try {
@@ -588,7 +656,7 @@ export async function publish(args) {
588
656
  promptslideVersion: CLI_VERSION
589
657
  }, auth)
590
658
  console.log(` [${itemIndex}/${totalItems}] ${green("✓")} theme ${cyan(themeSlug)} ${dim(`v${result.version}`)}`)
591
- updateLockfileItem(cwd, themeSlug, result.version ?? 0, themeFileHashes)
659
+ lockfileUpdates.push({ slug: themeSlug, version: result.version ?? 0, fileHashes: themeFileHashes })
592
660
  published++
593
661
  } catch (err) {
594
662
  console.log(` [${itemIndex}/${totalItems}] ${red("✗")} theme ${dim(themeSlug)}: ${err.message}`)
@@ -597,102 +665,128 @@ export async function publish(args) {
597
665
  }
598
666
  }
599
667
 
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) }
668
+ // ── Phase 3: Layouts (parallel) ──
669
+ const layoutTasks = layoutEntries.map((layoutFile) => {
670
+ const myIndex = ++itemIndex
671
+ return async () => {
672
+ const layoutName = layoutFile.replace(/\.tsx?$/, "")
673
+ const layoutSlug = `${deckSlug}/${layoutName}`
674
+ const content = readFileSync(join(cwd, "src", "layouts", layoutFile), "utf-8")
675
+ const fileHashes = { [`src/layouts/${layoutFile}`]: hashContent(content) }
676
+
677
+ if (isUnchanged(layoutSlug, fileHashes)) {
678
+ console.log(` [${myIndex}/${totalItems}] ${dim("—")} layout ${dim(layoutSlug)} (unchanged)`)
679
+ skipped++
680
+ return
681
+ }
607
682
 
608
- if (isUnchanged(layoutSlug, fileHashes)) {
609
- console.log(` [${itemIndex}/${totalItems}] ${dim("—")} layout ${dim(layoutSlug)} (unchanged)`)
610
- skipped++
611
- continue
612
- }
683
+ const assetDeps = detectAssetDepsInContent(content, deckSlug, publicFileSet)
684
+ const npmDeps = detectNpmDeps(content)
685
+ const regDeps = hasTheme ? [`${deckSlug}/theme`] : []
686
+ regDeps.push(...assetDeps)
613
687
 
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++
688
+ try {
689
+ const result = await publishToRegistry({
690
+ type: "layout",
691
+ slug: layoutSlug,
692
+ title: titleCase(layoutName),
693
+ files: [{ path: layoutFile, target: "src/layouts/", content }],
694
+ registryDependencies: regDeps.length ? regDeps : undefined,
695
+ npmDependencies: Object.keys(npmDeps).length ? npmDeps : undefined,
696
+ promptslideVersion: CLI_VERSION
697
+ }, auth)
698
+ console.log(` [${myIndex}/${totalItems}] ${green("")} layout ${cyan(layoutSlug)} ${dim(`v${result.version}`)}`)
699
+ lockfileUpdates.push({ slug: layoutSlug, version: result.version ?? 0, fileHashes })
700
+ published++
701
+ } catch (err) {
702
+ console.log(` [${myIndex}/${totalItems}] ${red("✗")} layout ${dim(layoutSlug)}: ${err.message}`)
703
+ failed++
704
+ }
635
705
  }
636
- }
637
-
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) }
706
+ })
707
+ await runParallel(layoutTasks)
708
+
709
+ // ── Phase 4: Slides (parallel, reuse single capture session) ──
710
+ const captureSession = canCapture ? await createCaptureSession({ cwd }) : null
711
+
712
+ const slideTasks = slideEntries.map((slideFile) => {
713
+ const myIndex = ++itemIndex
714
+ return async () => {
715
+ const slideName = slideFile.replace(/\.tsx?$/, "")
716
+ const slideSlug = `${deckSlug}/${slideName}`
717
+ const content = readFileSync(join(cwd, "src", "slides", slideFile), "utf-8")
718
+ const fileHashes = { [`src/slides/${slideFile}`]: hashContent(content) }
719
+
720
+ if (isUnchanged(slideSlug, fileHashes)) {
721
+ console.log(` [${myIndex}/${totalItems}] ${dim("—")} slide ${dim(slideSlug)} (unchanged)`)
722
+ skipped++
723
+ return
724
+ }
645
725
 
646
- if (isUnchanged(slideSlug, fileHashes)) {
647
- console.log(` [${itemIndex}/${totalItems}] ${dim("—")} slide ${dim(slideSlug)} (unchanged)`)
648
- skipped++
649
- continue
650
- }
726
+ const assetDeps = detectAssetDepsInContent(content, deckSlug, publicFileSet)
727
+ const layoutDeps = detectRegistryDeps(content).map(d => `${deckSlug}/${d}`)
728
+ const npmDeps = detectNpmDeps(content)
729
+ const steps = detectSteps(content)
730
+ const slideConfig = deckConfig.slides.find(s => s.slug === slideName)
731
+ const section = slideConfig?.section || undefined
651
732
 
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
733
+ const regDeps = hasTheme ? [`${deckSlug}/theme`] : []
734
+ regDeps.push(...layoutDeps, ...assetDeps)
658
735
 
659
- const regDeps = hasTheme ? [`${deckPrefix}/theme`] : []
660
- regDeps.push(...layoutDeps, ...assetDeps)
736
+ // Generate preview image reusing shared session
737
+ let slidePreview = null
738
+ if (captureSession) {
739
+ slidePreview = await captureSession.capture(`src/slides/${slideFile}`).catch(() => null)
740
+ }
661
741
 
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)
742
+ try {
743
+ const result = await publishToRegistry({
744
+ type: "slide",
745
+ slug: slideSlug,
746
+ title: titleCase(slideName),
747
+ files: [{ path: slideFile, target: "src/slides/", content }],
748
+ steps,
749
+ section,
750
+ registryDependencies: regDeps.length ? regDeps : undefined,
751
+ npmDependencies: Object.keys(npmDeps).length ? npmDeps : undefined,
752
+ previewImage: slidePreview || undefined,
753
+ promptslideVersion: CLI_VERSION
754
+ }, auth)
755
+ console.log(` [${myIndex}/${totalItems}] ${green("✓")} slide ${cyan(slideSlug)} ${dim(`v${result.version}`)}`)
756
+ lockfileUpdates.push({ slug: slideSlug, version: result.version ?? 0, fileHashes })
757
+ published++
758
+ } catch (err) {
759
+ console.log(` [${myIndex}/${totalItems}] ${red("✗")} slide ${dim(slideSlug)}: ${err.message}`)
760
+ failed++
761
+ }
666
762
  }
763
+ })
764
+ await runParallel(slideTasks)
667
765
 
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
- }
766
+ if (captureSession) await captureSession.close()
689
767
 
690
- // ── Phase 5: Deck manifest ──
768
+ // ── Phase 5: Deck manifest (includes shared source modules) ──
691
769
  itemIndex++
692
- const slideSlugs = slideEntries.map(f => `${deckPrefix}/${f.replace(/\.tsx?$/, "")}`)
693
- const assetSlugs = publicAssets.map(a => assetFileToSlug(deckPrefix, a.relativePath))
770
+ const slideSlugs = slideEntries.map(f => `${deckSlug}/${f.replace(/\.tsx?$/, "")}`)
771
+ const assetSlugs = publicAssets.map(a => assetFileToSlug(deckSlug, a.relativePath))
694
772
  const allDeckDeps = [...slideSlugs, ...assetSlugs]
695
773
 
774
+ // Bundle shared source files with the deck item so preview/install can resolve @/ imports
775
+ const sharedFiles = sharedSources.map(s => ({
776
+ path: s.fileName,
777
+ target: s.target,
778
+ content: readFileSync(s.fullPath, "utf-8")
779
+ }))
780
+ if (sharedFiles.length) {
781
+ console.log(` ${dim(`Bundling ${sharedFiles.length} shared source file(s) with deck`)}`)
782
+ }
783
+
784
+ // Collect npm deps from shared source files
785
+ const sharedNpmDeps = {}
786
+ for (const f of sharedFiles) {
787
+ Object.assign(sharedNpmDeps, detectNpmDeps(f.content))
788
+ }
789
+
696
790
  let deckItemId = null
697
791
  try {
698
792
  const result = await publishToRegistry({
@@ -702,22 +796,38 @@ export async function publish(args) {
702
796
  description: description || undefined,
703
797
  tags,
704
798
  deckConfig,
705
- files: [],
799
+ files: sharedFiles,
800
+ npmDependencies: Object.keys(sharedNpmDeps).length ? sharedNpmDeps : undefined,
706
801
  registryDependencies: allDeckDeps.length ? allDeckDeps : undefined,
707
802
  releaseNotes: releaseNotes || undefined,
708
803
  previewImage: previewImage || undefined,
709
804
  promptslideVersion: CLI_VERSION
710
805
  }, auth)
711
806
  console.log(` [${itemIndex}/${totalItems}] ${green("✓")} deck ${cyan(deckSlug)} ${dim(`v${result.version}`)}`)
712
- updateLockfileItem(cwd, deckSlug, result.version ?? 0, {})
713
- updateLockfilePublishConfig(cwd, { deckSlug })
807
+ lockfileUpdates.push({ slug: deckSlug, version: result.version ?? 0, fileHashes: {} })
714
808
  deckItemId = result.id
715
809
  published++
810
+ // Persist deck metadata for next publish
811
+ updateDeckMeta(cwd, { title, description: description || undefined, tags })
716
812
  } catch (err) {
717
813
  console.log(` [${itemIndex}/${totalItems}] ${red("✗")} deck ${dim(deckSlug)}: ${err.message}`)
718
814
  failed++
719
815
  }
720
816
 
817
+ // Batch-write all lockfile updates at once
818
+ const finalLock = readLockfile(cwd)
819
+ for (const update of lockfileUpdates) {
820
+ const existing = finalLock.items[update.slug] || {}
821
+ finalLock.items[update.slug] = {
822
+ ...existing,
823
+ version: update.version,
824
+ installedAt: new Date().toISOString().split("T")[0],
825
+ files: update.fileHashes
826
+ }
827
+ }
828
+ if (deckItemId) finalLock.deckSlug = deckSlug
829
+ writeLockfile(cwd, finalLock)
830
+
721
831
  console.log()
722
832
  console.log(` ${bold("Done:")} ${green(`${published} published`)}${skipped ? `, ${skipped} unchanged` : ""}${failed ? `, ${red(`${failed} failed`)}` : ""}`)
723
833
  if (auth.organizationSlug && deckItemId) {
@@ -758,12 +868,12 @@ export async function publish(args) {
758
868
  }
759
869
  console.log()
760
870
 
761
- // Prompt for deck prefix (required)
762
- const deckPrefix = await promptDeckPrefix(cwd, true)
763
- const slug = `${deckPrefix}/${baseSlug}`
871
+ // Prompt for deck slug (required — items are namespaced under the deck)
872
+ const deck = await promptDeckSlug(cwd, true)
873
+ const slug = `${deck}/${baseSlug}`
764
874
 
765
- // Persist prefix for next time
766
- updateLockfilePublishConfig(cwd, { deckPrefix })
875
+ // Persist deck slug for next time
876
+ updateLockfilePublishConfig(cwd, { deckSlug: deck })
767
877
 
768
878
  console.log(` Slug: ${cyan(slug)}`)
769
879
  console.log()
@@ -788,7 +898,7 @@ export async function publish(args) {
788
898
  const missing = []
789
899
 
790
900
  for (const depSlug of registryDeps) {
791
- const prefixedDepSlug = `${deckPrefix}/${depSlug}`
901
+ const prefixedDepSlug = `${deck}/${depSlug}`
792
902
  const exists = await registryItemExists(prefixedDepSlug, auth)
793
903
  if (!exists) {
794
904
  missing.push(depSlug)
@@ -826,7 +936,7 @@ export async function publish(args) {
826
936
  auth,
827
937
  typeOverride: depType,
828
938
  interactive: true,
829
- deckPrefix
939
+ deckSlug: deck
830
940
  })
831
941
  console.log()
832
942
  const depVer = result.version ? ` ${dim(`v${result.version}`)}` : ""
@@ -848,12 +958,14 @@ export async function publish(args) {
848
958
  }
849
959
  }
850
960
 
851
- // Collect metadata for main item
852
- const title = await prompt("Title:", titleCase(baseSlug))
853
- const description = await prompt("Description:", "")
854
- const tagsInput = await prompt("Tags (comma-separated):", "")
961
+ // Collect metadata for main item (use stored values as defaults)
962
+ const storedMeta = readItemMeta(cwd, slug)
963
+ const title = await prompt("Title:", storedMeta.title || titleCase(baseSlug))
964
+ const description = await prompt("Description:", storedMeta.description || "")
965
+ const storedTagsDefault = storedMeta.tags?.length ? storedMeta.tags.join(", ") : ""
966
+ const tagsInput = await prompt("Tags (comma-separated):", storedTagsDefault)
855
967
  const tags = tagsInput ? tagsInput.split(",").map(t => t.trim()).filter(Boolean) : []
856
- const section = await prompt("Section:", "")
968
+ const section = await prompt("Section:", storedMeta.section || "")
857
969
  const releaseNotes = await prompt("Release notes:", "")
858
970
  const previewImagePath = await prompt("Preview image path (leave empty to auto-generate):", "")
859
971
  let previewImage = null
@@ -881,7 +993,7 @@ export async function publish(args) {
881
993
  console.log()
882
994
 
883
995
  // Publish main item
884
- const prefixedRegistryDeps = registryDeps.map(d => `${deckPrefix}/${d}`)
996
+ const prefixedRegistryDeps = registryDeps.map(d => `${deck}/${d}`)
885
997
  const payload = {
886
998
  type,
887
999
  slug,
@@ -911,6 +1023,13 @@ export async function publish(args) {
911
1023
  // Track in lockfile
912
1024
  const fileHashes = { [target + fileName]: hashContent(content) }
913
1025
  updateLockfileItem(cwd, slug, result.version ?? 0, fileHashes)
1026
+ // Persist item metadata for next publish
1027
+ updateItemMeta(cwd, slug, {
1028
+ title,
1029
+ description: description || undefined,
1030
+ tags,
1031
+ section: section || undefined
1032
+ })
914
1033
  } catch (err) {
915
1034
  console.error(` ${red("Error:")} ${err.message}`)
916
1035
  process.exit(1)
@@ -6,13 +6,16 @@ const DECK_CONFIG_PATH = "src/deck-config.ts"
6
6
  /**
7
7
  * Convert a kebab-case filename to PascalCase component name.
8
8
  * e.g. "slide-hero-gradient" → "SlideHeroGradient"
9
+ * e.g. "01-title" → "Slide01Title" (prefixed to ensure valid JS identifier)
9
10
  */
10
11
  export function toPascalCase(kebab) {
11
- return kebab
12
+ const raw = kebab
12
13
  .replace(/\.tsx?$/, "")
13
14
  .split("-")
14
15
  .map(part => part.charAt(0).toUpperCase() + part.slice(1))
15
16
  .join("")
17
+ // JS identifiers cannot start with a digit — prefix with "Slide"
18
+ return /^\d/.test(raw) ? `Slide${raw}` : raw
16
19
  }
17
20
 
18
21
  /**
@@ -222,11 +225,20 @@ export function parseDeckConfig(cwd) {
222
225
  const content = readFileSync(configPath, "utf-8")
223
226
 
224
227
  // 1. Parse imports to build componentName -> slug map
225
- // Pattern: import { SlideTitle } from "@/slides/slide-title"
228
+ // Handles: import { SlideTitle } from "@/slides/slide-title"
229
+ // Handles: import { default as SlideTitle } from "@/slides/slide-title"
230
+ // Handles: import { SlideTitle as Alias } from "@/slides/slide-title"
226
231
  const componentToSlug = {}
227
- const importRegex = /import\s*\{\s*(\w+)\s*\}\s*from\s*["']@\/slides\/([^"']+)["']/g
232
+ const importRegex = /import\s*\{([^}]+)\}\s*from\s*["']@\/slides\/([^"']+)["']/g
228
233
  for (const match of content.matchAll(importRegex)) {
229
- componentToSlug[match[1]] = match[2].replace(/\.tsx?$/, "")
234
+ const importClause = match[1].trim()
235
+ const slug = match[2].replace(/\.tsx?$/, "")
236
+ // Handle "X", "X as Y" (use Y), "default as Y" (use Y)
237
+ const asMatch = importClause.match(/(?:\w+\s+)?as\s+(\w+)/)
238
+ const componentName = asMatch ? asMatch[1] : importClause.match(/(\w+)/)?.[1]
239
+ if (componentName) {
240
+ componentToSlug[componentName] = slug
241
+ }
230
242
  }
231
243
 
232
244
  // 2. Parse transition exports
@@ -238,20 +250,38 @@ export function parseDeckConfig(cwd) {
238
250
  const dirMatch = content.match(/export\s+const\s+directionalTransition\s*=\s*(true|false)/)
239
251
  if (dirMatch) directionalTransition = dirMatch[1] === "true"
240
252
 
241
- // 3. Parse slides array entries (order-independent matching)
253
+ // 3. Extract slides array content, then parse entries within it
242
254
  const slides = []
243
- const entryRegex = /\{([^}]+)\}/g
244
- for (const match of content.matchAll(entryRegex)) {
245
- const body = match[1]
246
- const componentMatch = body.match(/component:\s*(\w+)/)
247
- const stepsMatch = body.match(/steps:\s*(\d+)/)
248
- if (!componentMatch || !stepsMatch) continue
249
- const slug = componentToSlug[componentMatch[1]]
250
- if (!slug) continue // layout or unknown import — skip
251
- const entry = { slug, steps: parseInt(stepsMatch[1], 10) }
252
- const sectionMatch = body.match(/section:\s*["']([^"']+)["']/)
253
- if (sectionMatch) entry.section = sectionMatch[1]
254
- slides.push(entry)
255
+ const slidesArrayMatch = content.match(/export\s+const\s+slides\s*:\s*SlideConfig\[\]\s*=\s*\[/)
256
+ if (slidesArrayMatch) {
257
+ const arrayStartIdx = content.indexOf(slidesArrayMatch[0]) + slidesArrayMatch[0].length
258
+ // Find matching closing bracket
259
+ let bracketDepth = 1
260
+ let arrayEndIdx = arrayStartIdx
261
+ for (let i = arrayStartIdx; i < content.length; i++) {
262
+ if (content[i] === "[") bracketDepth++
263
+ if (content[i] === "]") bracketDepth--
264
+ if (bracketDepth === 0) {
265
+ arrayEndIdx = i
266
+ break
267
+ }
268
+ }
269
+ const arrayContent = content.slice(arrayStartIdx, arrayEndIdx)
270
+
271
+ // Match entries within the slides array only
272
+ const entryRegex = /\{([^}]+)\}/g
273
+ for (const match of arrayContent.matchAll(entryRegex)) {
274
+ const body = match[1]
275
+ const componentMatch = body.match(/component:\s*(\w+)/)
276
+ if (!componentMatch) continue
277
+ const slug = componentToSlug[componentMatch[1]]
278
+ if (!slug) continue // layout or unknown import — skip
279
+ const stepsMatch = body.match(/steps:\s*(\d+)/)
280
+ const entry = { slug, steps: stepsMatch ? parseInt(stepsMatch[1], 10) : 0 }
281
+ const sectionMatch = body.match(/section:\s*["']([^"']+)["']/)
282
+ if (sectionMatch) entry.section = sectionMatch[1]
283
+ slides.push(entry)
284
+ }
255
285
  }
256
286
 
257
287
  if (slides.length === 0) return null
@@ -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.
@@ -71,7 +71,9 @@ export function isFileDirty(cwd, relativePath, storedHash) {
71
71
  */
72
72
  export function updateLockfileItem(cwd, slug, version, files) {
73
73
  const lock = readLockfile(cwd)
74
+ const existing = lock.items[slug] || {}
74
75
  lock.items[slug] = {
76
+ ...existing,
75
77
  version,
76
78
  installedAt: new Date().toISOString().split("T")[0],
77
79
  files
@@ -80,14 +82,60 @@ export function updateLockfileItem(cwd, slug, version, files) {
80
82
  }
81
83
 
82
84
  /**
83
- * Store publish configuration (deckPrefix, deckSlug) in the lockfile.
85
+ * Store publish configuration (deckSlug) in the lockfile.
86
+ * Removes the legacy deckPrefix key if present.
84
87
  * @param {string} cwd
85
- * @param {{ deckPrefix?: string, deckSlug?: string }} config
88
+ * @param {{ deckSlug?: string }} config
86
89
  */
87
90
  export function updateLockfilePublishConfig(cwd, config) {
88
91
  const lock = readLockfile(cwd)
89
- if (config.deckPrefix !== undefined) lock.deckPrefix = config.deckPrefix
90
92
  if (config.deckSlug !== undefined) lock.deckSlug = config.deckSlug
93
+ delete lock.deckPrefix
94
+ writeLockfile(cwd, lock)
95
+ }
96
+
97
+ /**
98
+ * Read stored deck-level publish metadata (title, description, tags).
99
+ * @param {string} cwd
100
+ * @returns {{ title?: string, description?: string, tags?: string[] }}
101
+ */
102
+ export function readDeckMeta(cwd) {
103
+ const lock = readLockfile(cwd)
104
+ return lock.deckMeta || {}
105
+ }
106
+
107
+ /**
108
+ * Persist deck-level publish metadata so it can be reused as defaults.
109
+ * @param {string} cwd
110
+ * @param {{ title?: string, description?: string, tags?: string[] }} meta
111
+ */
112
+ export function updateDeckMeta(cwd, meta) {
113
+ const lock = readLockfile(cwd)
114
+ lock.deckMeta = { ...lock.deckMeta, ...meta }
115
+ writeLockfile(cwd, lock)
116
+ }
117
+
118
+ /**
119
+ * Read stored per-item publish metadata (title, description, tags, section).
120
+ * @param {string} cwd
121
+ * @param {string} slug
122
+ * @returns {{ title?: string, description?: string, tags?: string[], section?: string }}
123
+ */
124
+ export function readItemMeta(cwd, slug) {
125
+ const lock = readLockfile(cwd)
126
+ return lock.items[slug]?.meta || {}
127
+ }
128
+
129
+ /**
130
+ * Persist per-item publish metadata alongside the version/files entry.
131
+ * @param {string} cwd
132
+ * @param {string} slug
133
+ * @param {{ title?: string, description?: string, tags?: string[], section?: string }} meta
134
+ */
135
+ export function updateItemMeta(cwd, slug, meta) {
136
+ const lock = readLockfile(cwd)
137
+ if (!lock.items[slug]) lock.items[slug] = {}
138
+ lock.items[slug].meta = meta
91
139
  writeLockfile(cwd, lock)
92
140
  }
93
141