promptslide 0.2.2 → 0.2.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "promptslide",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "CLI and slide engine for PromptSlide presentations",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,4 +1,4 @@
1
- import { existsSync, writeFileSync, mkdirSync } from "node:fs"
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"
2
2
  import { execFileSync } from "node:child_process"
3
3
  import { join, dirname, resolve, sep } from "node:path"
4
4
 
@@ -85,8 +85,17 @@ export async function add(args) {
85
85
  const newHash = hashContent(file.content)
86
86
 
87
87
  if (existsSync(targetPath)) {
88
- const existingHash = hashFile(targetPath)
89
- if (existingHash === newHash) {
88
+ // For binary files (data URIs), compare decoded bytes directly
89
+ const dataUriMatch = file.content.match(/^data:[^;]+;base64,/)
90
+ let identical
91
+ if (dataUriMatch) {
92
+ const newBuf = Buffer.from(file.content.slice(dataUriMatch[0].length), "base64")
93
+ const existingBuf = readFileSync(targetPath)
94
+ identical = newBuf.equals(existingBuf)
95
+ } else {
96
+ identical = hashFile(targetPath) === newHash
97
+ }
98
+ if (identical) {
90
99
  console.log(` ${dim("Skipped")} ${relativePath} ${dim("(identical)")}`)
91
100
  fileHashes[relativePath] = newHash
92
101
  continue
@@ -99,7 +108,12 @@ export async function add(args) {
99
108
  }
100
109
 
101
110
  mkdirSync(targetDir, { recursive: true })
102
- writeFileSync(targetPath, file.content, "utf-8")
111
+ const dataUriPrefix = file.content.match(/^data:[^;]+;base64,/)
112
+ if (dataUriPrefix) {
113
+ writeFileSync(targetPath, Buffer.from(file.content.slice(dataUriPrefix[0].length), "base64"))
114
+ } else {
115
+ writeFileSync(targetPath, file.content, "utf-8")
116
+ }
103
117
  fileHashes[relativePath] = newHash
104
118
  written.push({ item: regItem, file })
105
119
  console.log(` ${green("✓")} Added ${cyan(relativePath)}${regItem !== item ? dim(" (dependency)") : ""}`)
@@ -1,11 +1,14 @@
1
1
  import { execSync } from "node:child_process"
2
- import { existsSync, cpSync, readFileSync, writeFileSync } from "node:fs"
2
+ import { existsSync, cpSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"
3
3
  import { join, resolve, dirname } from "node:path"
4
4
  import { fileURLToPath } from "node:url"
5
5
 
6
6
  import { bold, green, cyan, red, dim } from "../utils/ansi.mjs"
7
+ import { requireAuth } from "../utils/auth.mjs"
7
8
  import { hexToOklch, isValidHex } from "../utils/colors.mjs"
8
9
  import { prompt, confirm, closePrompts } from "../utils/prompts.mjs"
10
+ import { fetchRegistryItem, resolveRegistryDependencies } from "../utils/registry.mjs"
11
+ import { toPascalCase, replaceDeckConfig } from "../utils/deck-config.mjs"
9
12
  import { ensureTsConfig } from "../utils/tsconfig.mjs"
10
13
 
11
14
  const __filename = fileURLToPath(import.meta.url)
@@ -58,7 +61,9 @@ export async function create(args) {
58
61
 
59
62
  // Parse flags
60
63
  const useDefaults = args.includes("--yes") || args.includes("-y")
61
- const filteredArgs = args.filter(a => a !== "--yes" && a !== "-y")
64
+ const fromIdx = args.findIndex(a => a === "--from")
65
+ const fromSlug = fromIdx >= 0 ? args[fromIdx + 1] : null
66
+ const filteredArgs = args.filter((a, i) => a !== "--yes" && a !== "-y" && a !== "--from" && i !== fromIdx + 1)
62
67
 
63
68
  // 1. Parse directory name from args or prompt
64
69
  let dirName = filteredArgs[0]
@@ -69,11 +74,13 @@ export async function create(args) {
69
74
  console.log(` Scaffolds a new PromptSlide slide deck project.`)
70
75
  console.log()
71
76
  console.log(` ${bold("Options:")}`)
72
- console.log(` -y, --yes Skip prompts and use defaults`)
77
+ console.log(` -y, --yes Skip prompts and use defaults`)
78
+ console.log(` --from <deck-slug> Start from a published deck`)
73
79
  console.log()
74
- console.log(` ${bold("Example:")}`)
80
+ console.log(` ${bold("Examples:")}`)
75
81
  console.log(` promptslide create my-pitch-deck`)
76
82
  console.log(` promptslide create my-pitch-deck --yes`)
83
+ console.log(` promptslide create my-deck --from promptic-pitch-deck`)
77
84
  console.log()
78
85
  process.exit(0)
79
86
  }
@@ -109,9 +116,9 @@ export async function create(args) {
109
116
  const defaultName = titleCase(dirName)
110
117
  const projectName = useDefaults ? defaultName : await prompt("Project name:", defaultName)
111
118
 
112
- // 3. Ask for primary brand color (optional)
119
+ // 3. Ask for primary brand color (optional, skipped when using --from since deck provides its own)
113
120
  let primaryHex = "#3B82F6"
114
- if (!useDefaults) {
121
+ if (!useDefaults && !fromSlug) {
115
122
  primaryHex = await prompt("Primary brand color (hex):", "#3B82F6")
116
123
  if (!isValidHex(primaryHex)) {
117
124
  console.log(` ${dim("Invalid hex color, using default #3B82F6")}`)
@@ -161,7 +168,87 @@ export async function create(args) {
161
168
  replaceInFile(path, values)
162
169
  }
163
170
 
164
- // 6. If running from local dev, rewrite deps to use file: paths
171
+ // 6. Overlay deck files if --from was specified
172
+ if (fromSlug) {
173
+ const auth = requireAuth()
174
+
175
+ let item
176
+ try {
177
+ item = await fetchRegistryItem(fromSlug, auth)
178
+ } catch (err) {
179
+ console.error(` ${red("Error:")} ${err.message}`)
180
+ closePrompts()
181
+ process.exit(1)
182
+ }
183
+
184
+ if (item.type !== "deck") {
185
+ console.error(` ${red("Error:")} "${fromSlug}" is a ${item.type}, not a deck. Use --from with a published deck.`)
186
+ closePrompts()
187
+ process.exit(1)
188
+ }
189
+
190
+ const versionTag = item.version ? ` ${dim(`v${item.version}`)}` : ""
191
+ console.log(` Using deck ${bold(item.title || item.name)}${versionTag}`)
192
+
193
+ let resolved
194
+ try {
195
+ resolved = await resolveRegistryDependencies(item, auth, targetDir)
196
+ } catch (err) {
197
+ console.error(` ${red("Error:")} ${err.message}`)
198
+ closePrompts()
199
+ process.exit(1)
200
+ }
201
+
202
+ // Write all deck files (no conflict prompts — fresh project)
203
+ for (const regItem of resolved.items) {
204
+ if (!regItem.files?.length) continue
205
+ for (const file of regItem.files) {
206
+ const targetPath = join(targetDir, file.target, file.path)
207
+ const targetFileDir = dirname(targetPath)
208
+ mkdirSync(targetFileDir, { recursive: true })
209
+
210
+ const dataUriPrefix = file.content.match(/^data:[^;]+;base64,/)
211
+ if (dataUriPrefix) {
212
+ writeFileSync(targetPath, Buffer.from(file.content.slice(dataUriPrefix[0].length), "base64"))
213
+ } else {
214
+ writeFileSync(targetPath, file.content, "utf-8")
215
+ }
216
+ console.log(` ${green("✓")} Added ${cyan(file.target + file.path)}`)
217
+ }
218
+ }
219
+
220
+ // Reconstruct deck-config.ts from deckConfig metadata
221
+ if (item.meta?.slides) {
222
+ const slides = item.meta.slides.map(s => ({
223
+ componentName: toPascalCase(s.slug),
224
+ importPath: `@/slides/${s.slug}`,
225
+ steps: s.steps,
226
+ section: s.section
227
+ }))
228
+ replaceDeckConfig(targetDir, slides, {
229
+ transition: item.meta.transition,
230
+ directionalTransition: item.meta.directionalTransition
231
+ })
232
+ console.log(` ${green("✓")} Generated ${cyan("deck-config.ts")} ${dim(`(${slides.length} slides)`)}`)
233
+ }
234
+
235
+ // Add npm dependencies from the deck to package.json
236
+ if (Object.keys(resolved.npmDeps).length > 0) {
237
+ const pkgList = Object.entries(resolved.npmDeps).map(([name, ver]) => `${name}@${ver}`)
238
+ const pkgPath = join(targetDir, "package.json")
239
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"))
240
+ for (const [name, ver] of Object.entries(resolved.npmDeps)) {
241
+ pkg.dependencies = pkg.dependencies || {}
242
+ pkg.dependencies[name] = ver
243
+ }
244
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf-8")
245
+ console.log(` ${green("✓")} Added ${dim(pkgList.join(", "))} to package.json`)
246
+ }
247
+
248
+ console.log()
249
+ }
250
+
251
+ // 7. If running from local dev, rewrite deps to use file: paths (must run after --from to not overwrite)
165
252
  const localPaths = getLocalPackagePaths()
166
253
  if (localPaths) {
167
254
  const pkgPath = join(targetDir, "package.json")
@@ -171,10 +258,10 @@ export async function create(args) {
171
258
  console.log(` ${dim("Local dev detected — using file: paths for packages")}`)
172
259
  }
173
260
 
174
- // 7. Generate tsconfig.json for editor support
261
+ // 8. Generate tsconfig.json for editor support
175
262
  ensureTsConfig(targetDir)
176
263
 
177
- // 8. Install PromptSlide agent skill (defaults to yes; skipped with --yes since skills CLI is interactive)
264
+ // 9. Install PromptSlide agent skill (defaults to yes; skipped with --yes since skills CLI is interactive)
178
265
  if (useDefaults) {
179
266
  console.log(` ${dim("Tip: Run")} npx skills add prompticeu/promptslide ${dim("to install the agent skill")}`)
180
267
  } else {
@@ -196,7 +283,7 @@ export async function create(args) {
196
283
  }
197
284
  }
198
285
 
199
- // 9. Success output
286
+ // 10. Success output
200
287
  console.log()
201
288
  console.log(` ${green("✓")} Created ${bold(projectName)} in ${cyan(dirName)}/`)
202
289
  console.log()
@@ -4,8 +4,33 @@ import { join, basename, relative, extname } from "node:path"
4
4
  import { bold, green, cyan, red, dim } from "../utils/ansi.mjs"
5
5
  import { requireAuth } from "../utils/auth.mjs"
6
6
  import { captureSlideAsDataUri, isPlaywrightAvailable } from "../utils/export.mjs"
7
- import { publishToRegistry, registryItemExists, updateLockfileItem, hashContent, detectPackageManager } from "../utils/registry.mjs"
7
+ import { publishToRegistry, registryItemExists, searchRegistry, updateLockfileItem, readLockfile, hashContent, detectPackageManager, requestUploadTokens, uploadBinaryToBlob, assetFileToSlug, detectAssetDepsInContent } from "../utils/registry.mjs"
8
8
  import { prompt, confirm, closePrompts } from "../utils/prompts.mjs"
9
+ import { parseDeckConfig } from "../utils/deck-config.mjs"
10
+
11
+ function readDeckPrefix(cwd) {
12
+ try {
13
+ const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf-8"))
14
+ return (pkg.name || "").toLowerCase()
15
+ } catch {
16
+ return ""
17
+ }
18
+ }
19
+
20
+ async function promptDeckPrefix(cwd, interactive) {
21
+ const defaultPrefix = readDeckPrefix(cwd)
22
+ if (!interactive) {
23
+ if (!defaultPrefix) throw new Error("Deck prefix is required. Set a name in package.json or publish interactively.")
24
+ return defaultPrefix
25
+ }
26
+ let prefix
27
+ while (true) {
28
+ prefix = await prompt("Deck prefix:", defaultPrefix)
29
+ if (prefix && /^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(prefix)) break
30
+ console.log(` ${red("Error:")} Deck prefix is required (lowercase alphanumeric with hyphens, min 2 chars)`)
31
+ }
32
+ return prefix
33
+ }
9
34
 
10
35
  function titleCase(slug) {
11
36
  return slug
@@ -69,6 +94,171 @@ function readPreviewImage(imagePath) {
69
94
  return `data:${mime};base64,${buf.toString("base64")}`
70
95
  }
71
96
 
97
+ const BINARY_EXTS = new Set([".png", ".jpg", ".jpeg", ".webp", ".gif", ".ico", ".woff", ".woff2"])
98
+ const MAX_INLINE_SIZE = 512_000 // 512KB for inline text content
99
+ const MAX_DECK_FILES = 50 // registry limit
100
+
101
+ const MIME_MAP = {
102
+ ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
103
+ ".webp": "image/webp", ".gif": "image/gif", ".ico": "image/x-icon",
104
+ ".woff": "font/woff", ".woff2": "font/woff2"
105
+ }
106
+
107
+ /**
108
+ * Read a file for registry publishing.
109
+ * Returns { binary: true, buffer, contentType, size } for binary files,
110
+ * or { binary: false, content } for text files.
111
+ */
112
+ function readFileForRegistry(fullPath) {
113
+ const ext = extname(fullPath).toLowerCase()
114
+ if (BINARY_EXTS.has(ext)) {
115
+ const buffer = readFileSync(fullPath)
116
+ const contentType = MIME_MAP[ext] || "application/octet-stream"
117
+ return { binary: true, buffer, contentType, size: buffer.length }
118
+ }
119
+ return { binary: false, content: readFileSync(fullPath, "utf-8") }
120
+ }
121
+
122
+ /**
123
+ * Collect all files for deck publishing.
124
+ * Returns files with structured format: text files have { path, target, content },
125
+ * binary files have { path, target, binary: true, buffer, contentType, size }.
126
+ */
127
+ function collectDeckFiles(cwd) {
128
+ const files = []
129
+ const warnings = []
130
+
131
+ function addDir(dirPath, target, filter) {
132
+ if (!existsSync(dirPath)) return
133
+ for (const entry of readdirSync(dirPath)) {
134
+ if (entry === ".gitkeep") continue
135
+ const fullPath = join(dirPath, entry)
136
+ const stat = statSync(fullPath)
137
+ if (stat.isDirectory()) {
138
+ addDir(fullPath, `${target}${entry}/`, filter)
139
+ continue
140
+ }
141
+ if (!stat.isFile()) continue
142
+ if (filter && !filter(entry)) continue
143
+ const fileData = readFileForRegistry(fullPath)
144
+ if (fileData.binary) {
145
+ files.push({ path: entry, target, binary: true, buffer: fileData.buffer, contentType: fileData.contentType, size: fileData.size })
146
+ } else {
147
+ if (fileData.content.length > MAX_INLINE_SIZE) {
148
+ warnings.push(`${target}${entry}: exceeds 512KB inline limit (${(fileData.content.length / 1024).toFixed(0)}KB), skipped`)
149
+ continue
150
+ }
151
+ files.push({ path: entry, target, content: fileData.content })
152
+ }
153
+ }
154
+ }
155
+
156
+ addDir(join(cwd, "src", "slides"), "src/slides/", f => f.endsWith(".tsx") || f.endsWith(".ts"))
157
+ addDir(join(cwd, "src", "layouts"), "src/layouts/", f => f.endsWith(".tsx") || f.endsWith(".ts"))
158
+
159
+ const themePath = join(cwd, "src", "theme.ts")
160
+ if (existsSync(themePath)) {
161
+ files.push({ path: "theme.ts", target: "src/", content: readFileSync(themePath, "utf-8") })
162
+ }
163
+
164
+ const globalsPath = join(cwd, "src", "globals.css")
165
+ if (existsSync(globalsPath)) {
166
+ files.push({ path: "globals.css", target: "src/", content: readFileSync(globalsPath, "utf-8") })
167
+ }
168
+
169
+ addDir(join(cwd, "public"), "public/")
170
+
171
+ if (files.length > MAX_DECK_FILES) {
172
+ warnings.push(`Deck has ${files.length} files but registry limit is ${MAX_DECK_FILES}. Only the first ${MAX_DECK_FILES} will be included.`)
173
+ files.length = MAX_DECK_FILES
174
+ }
175
+
176
+ return { files, warnings }
177
+ }
178
+
179
+ /**
180
+ * Upload binary files directly to Vercel Blob via pre-signed tokens,
181
+ * then return a unified file array ready for the publish payload.
182
+ *
183
+ * Text files get { path, target, content }.
184
+ * Binary files get { path, target, storageUrl, contentType } (or fall back to inline data URIs).
185
+ *
186
+ * @param {string} slug - Item slug
187
+ * @param {object[]} files - Mixed array from collectDeckFiles
188
+ * @param {{ registry: string, token: string }} auth
189
+ * @returns {Promise<object[]>} Files ready for publish payload
190
+ */
191
+ async function uploadBinaryFiles(slug, files, auth) {
192
+ const binaryFiles = files.filter(f => f.binary)
193
+ const textFiles = files.filter(f => !f.binary)
194
+
195
+ if (binaryFiles.length === 0) {
196
+ return textFiles.map(f => ({ path: f.path, target: f.target, content: f.content }))
197
+ }
198
+
199
+ // Request upload tokens from registry
200
+ let tokens = []
201
+ try {
202
+ tokens = await requestUploadTokens(
203
+ slug,
204
+ binaryFiles.map(f => ({ path: f.path, contentType: f.contentType, size: f.size })),
205
+ auth
206
+ )
207
+ } catch (err) {
208
+ console.log(` ${dim("⚠ Could not get upload tokens, falling back to inline encoding")}`)
209
+ }
210
+
211
+ const result = textFiles.map(f => ({ path: f.path, target: f.target, content: f.content }))
212
+
213
+ if (tokens.length === 0) {
214
+ // Fallback: encode binary files as base64 data URIs inline
215
+ for (const f of binaryFiles) {
216
+ result.push({
217
+ path: f.path,
218
+ target: f.target,
219
+ content: `data:${f.contentType};base64,${f.buffer.toString("base64")}`
220
+ })
221
+ }
222
+ return result
223
+ }
224
+
225
+ // Upload each binary file directly to Vercel Blob
226
+ const tokenMap = Object.fromEntries(tokens.map(t => [t.path, t]))
227
+
228
+ for (const f of binaryFiles) {
229
+ const token = tokenMap[f.path]
230
+ if (!token) {
231
+ // No token for this file — fall back to inline
232
+ result.push({
233
+ path: f.path,
234
+ target: f.target,
235
+ content: `data:${f.contentType};base64,${f.buffer.toString("base64")}`
236
+ })
237
+ continue
238
+ }
239
+
240
+ try {
241
+ const blobUrl = await uploadBinaryToBlob(f.buffer, token.pathname, f.contentType, token.clientToken)
242
+ result.push({
243
+ path: f.path,
244
+ target: f.target,
245
+ storageUrl: blobUrl,
246
+ contentType: f.contentType
247
+ })
248
+ } catch (err) {
249
+ console.log(` ${dim(`⚠ Direct upload failed for ${f.path}: ${err.message}`)}`)
250
+ // Fall back to inline
251
+ result.push({
252
+ path: f.path,
253
+ target: f.target,
254
+ content: `data:${f.contentType};base64,${f.buffer.toString("base64")}`
255
+ })
256
+ }
257
+ }
258
+
259
+ return result
260
+ }
261
+
72
262
  function scanForFiles(cwd) {
73
263
  const files = []
74
264
  const dirs = [
@@ -88,10 +278,10 @@ function scanForFiles(cwd) {
88
278
 
89
279
  /**
90
280
  * Publish a single item to the registry.
91
- * @param {{ filePath: string, cwd: string, auth: object, typeOverride?: string, interactive?: boolean }} opts
281
+ * @param {{ filePath: string, cwd: string, auth: object, typeOverride?: string, interactive?: boolean, deckPrefix?: string }} opts
92
282
  * @returns {Promise<{ slug: string, status: string }>}
93
283
  */
94
- async function publishItem({ filePath, cwd, auth, typeOverride, interactive = true }) {
284
+ async function publishItem({ filePath, cwd, auth, typeOverride, interactive = true, deckPrefix }) {
95
285
  const fullPath = join(cwd, filePath)
96
286
  if (!existsSync(fullPath)) {
97
287
  throw new Error(`File not found: ${filePath}`)
@@ -99,7 +289,9 @@ async function publishItem({ filePath, cwd, auth, typeOverride, interactive = tr
99
289
 
100
290
  const content = readFileSync(fullPath, "utf-8")
101
291
  const fileName = basename(fullPath)
102
- const slug = fileName.replace(/\.tsx?$/, "")
292
+ const baseSlug = fileName.replace(/\.tsx?$/, "")
293
+ const prefix = deckPrefix || await promptDeckPrefix(cwd, interactive)
294
+ const slug = `${prefix}/${baseSlug}`
103
295
 
104
296
  const type = typeOverride || detectType(filePath) || "slide"
105
297
  const steps = detectSteps(content)
@@ -110,7 +302,7 @@ async function publishItem({ filePath, cwd, auth, typeOverride, interactive = tr
110
302
  let title, description, tags, section, releaseNotes, previewImage
111
303
 
112
304
  if (interactive) {
113
- title = await prompt("Title:", titleCase(slug))
305
+ title = await prompt("Title:", titleCase(baseSlug))
114
306
  description = await prompt("Description:", "")
115
307
  const tagsInput = await prompt("Tags (comma-separated):", "")
116
308
  tags = tagsInput ? tagsInput.split(",").map(t => t.trim()).filter(Boolean) : []
@@ -133,7 +325,7 @@ async function publishItem({ filePath, cwd, auth, typeOverride, interactive = tr
133
325
  }
134
326
  }
135
327
  } else {
136
- title = titleCase(slug)
328
+ title = titleCase(baseSlug)
137
329
  description = ""
138
330
  tags = []
139
331
  section = ""
@@ -141,6 +333,7 @@ async function publishItem({ filePath, cwd, auth, typeOverride, interactive = tr
141
333
  previewImage = await captureSlideAsDataUri({ cwd, slidePath: filePath }).catch(() => null)
142
334
  }
143
335
 
336
+ const prefixedDeps = registryDeps.map(d => `${prefix}/${d}`)
144
337
  const payload = {
145
338
  type,
146
339
  slug,
@@ -151,7 +344,7 @@ async function publishItem({ filePath, cwd, auth, typeOverride, interactive = tr
151
344
  section: section || undefined,
152
345
  files: [{ path: fileName, target, content }],
153
346
  npmDependencies: Object.keys(npmDeps).length ? npmDeps : undefined,
154
- registryDependencies: registryDeps.length ? registryDeps : undefined,
347
+ registryDependencies: prefixedDeps.length ? prefixedDeps : undefined,
155
348
  releaseNotes: releaseNotes || undefined,
156
349
  previewImage: previewImage || undefined
157
350
  }
@@ -175,11 +368,12 @@ export async function publish(args) {
175
368
  if (args[0] === "--help" || args[0] === "-h") {
176
369
  console.log(` ${bold("Usage:")} promptslide publish ${dim("[file] [--type slide|layout|deck|theme]")}`)
177
370
  console.log()
178
- console.log(` Publish a slide or layout to the registry.`)
371
+ console.log(` Publish a slide, layout, or entire deck to the registry.`)
179
372
  console.log()
180
373
  console.log(` ${bold("Examples:")}`)
181
374
  console.log(` promptslide publish src/slides/slide-hero.tsx`)
182
375
  console.log(` promptslide publish --type layout`)
376
+ console.log(` promptslide publish --type deck`)
183
377
  console.log()
184
378
  process.exit(0)
185
379
  }
@@ -200,7 +394,7 @@ export async function publish(args) {
200
394
  }
201
395
  let filePath = args.find((a, i) => !a.startsWith("--") && !flagIndices.has(i))
202
396
 
203
- if (!filePath) {
397
+ if (!filePath && typeOverride !== "deck") {
204
398
  // Interactive mode: scan for files
205
399
  const available = scanForFiles(cwd)
206
400
  if (available.length === 0) {
@@ -212,16 +406,348 @@ export async function publish(args) {
212
406
  available.forEach((f, i) => {
213
407
  console.log(` ${dim(`${i + 1}.`)} ${f.target}${f.path}`)
214
408
  })
409
+ console.log(` ${dim("─".repeat(30))}`)
410
+ console.log(` ${dim(`${available.length + 1}.`)} ${bold("Entire deck")}`)
215
411
  console.log()
216
412
 
217
413
  const choice = await prompt("Select file number:", "1")
218
414
  const idx = parseInt(choice, 10) - 1
219
- if (idx < 0 || idx >= available.length) {
415
+
416
+ if (idx === available.length) {
417
+ typeOverride = "deck"
418
+ } else if (idx < 0 || idx >= available.length) {
220
419
  console.error(` ${red("Error:")} Invalid selection.`)
221
420
  process.exit(1)
421
+ } else {
422
+ filePath = relative(cwd, available[idx].fullPath)
423
+ }
424
+ }
425
+
426
+ // ── Deck publish flow (decomposed) ───────────────────────────────
427
+ if (typeOverride === "deck") {
428
+ const deckConfig = parseDeckConfig(cwd)
429
+ if (!deckConfig) {
430
+ console.error(` ${red("Error:")} Could not parse src/deck-config.ts.`)
431
+ console.error(` ${dim("Ensure it has a slides array with component/steps entries.")}`)
432
+ process.exit(1)
222
433
  }
223
434
 
224
- filePath = relative(cwd, available[idx].fullPath)
435
+ const deckPrefix = await promptDeckPrefix(cwd, true)
436
+ const dirName = basename(cwd)
437
+ const deckBaseSlug = dirName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")
438
+ const deckSlug = `${deckPrefix}/${deckBaseSlug}`
439
+
440
+ // Walk public/ to collect assets and build reference set
441
+ const publicDir = join(cwd, "public")
442
+ const publicAssets = []
443
+ const publicFileSet = new Set()
444
+ function walkPublic(dir, prefix) {
445
+ if (!existsSync(dir)) return
446
+ for (const entry of readdirSync(dir)) {
447
+ if (entry.startsWith(".")) continue
448
+ const full = join(dir, entry)
449
+ const s = statSync(full)
450
+ if (s.isDirectory()) {
451
+ walkPublic(full, prefix ? `${prefix}/${entry}` : entry)
452
+ } else if (s.isFile()) {
453
+ const relPath = prefix ? `${prefix}/${entry}` : entry
454
+ publicAssets.push({ relativePath: relPath, fullPath: full })
455
+ publicFileSet.add(relPath)
456
+ }
457
+ }
458
+ }
459
+ walkPublic(publicDir, "")
460
+
461
+ // Discover theme, layout, and slide files
462
+ const themeFilePaths = []
463
+ if (existsSync(join(cwd, "src", "theme.ts"))) themeFilePaths.push("theme.ts")
464
+ if (existsSync(join(cwd, "src", "globals.css"))) themeFilePaths.push("globals.css")
465
+ const hasTheme = themeFilePaths.length > 0
466
+
467
+ const layoutsDir = join(cwd, "src", "layouts")
468
+ const layoutEntries = existsSync(layoutsDir)
469
+ ? readdirSync(layoutsDir).filter(f => f.endsWith(".tsx") || f.endsWith(".ts"))
470
+ : []
471
+
472
+ const slidesDir = join(cwd, "src", "slides")
473
+ const slideEntries = existsSync(slidesDir)
474
+ ? readdirSync(slidesDir).filter(f => f.endsWith(".tsx") || f.endsWith(".ts"))
475
+ : []
476
+
477
+ const totalItems = publicAssets.length + (hasTheme ? 1 : 0) + layoutEntries.length + slideEntries.length + 1
478
+
479
+ // Display summary
480
+ console.log(` ${bold("Decomposed deck publish:")} ${cyan(deckSlug)}`)
481
+ if (publicAssets.length) console.log(` Assets: ${publicAssets.length}`)
482
+ if (hasTheme) console.log(` Theme: 1`)
483
+ if (layoutEntries.length) console.log(` Layouts: ${layoutEntries.length}`)
484
+ console.log(` Slides: ${slideEntries.length}`)
485
+ console.log(` Total: ${totalItems} items`)
486
+ if (deckConfig.transition) {
487
+ console.log(` Transition: ${deckConfig.transition}${deckConfig.directionalTransition ? " (directional)" : ""}`)
488
+ }
489
+ console.log()
490
+
491
+ // Collect deck metadata
492
+ const title = await prompt("Title:", titleCase(deckBaseSlug))
493
+ const description = await prompt("Description:", "")
494
+ const tagsInput = await prompt("Tags (comma-separated):", "")
495
+ const tags = tagsInput ? tagsInput.split(",").map(t => t.trim()).filter(Boolean) : []
496
+ const releaseNotes = await prompt("Release notes:", "")
497
+
498
+ // Check Playwright availability once for preview images
499
+ const canCapture = await isPlaywrightAvailable()
500
+
501
+ // Preview image (attached to the deck manifest only)
502
+ let previewImage = null
503
+ const previewImagePath = await prompt("Preview image path (leave empty to auto-generate):", "")
504
+ if (previewImagePath) {
505
+ const resolved = join(cwd, previewImagePath)
506
+ previewImage = readPreviewImage(resolved)
507
+ if (!previewImage) {
508
+ console.log(` ${dim("⚠ Skipping image: file not found, unsupported format, or > 2MB")}`)
509
+ }
510
+ } else if (canCapture) {
511
+ const firstSlide = deckConfig.slides[0]
512
+ if (firstSlide) {
513
+ console.log(` ${dim("Generating deck preview image...")}`)
514
+ previewImage = await captureSlideAsDataUri({ cwd, slidePath: `src/slides/${firstSlide.slug}.tsx` })
515
+ if (previewImage) {
516
+ console.log(` ${green("✓")} Deck preview image generated`)
517
+ } else {
518
+ console.log(` ${dim("⚠ Could not generate deck preview image")}`)
519
+ }
520
+ }
521
+ } else {
522
+ const pm = detectPackageManager(cwd)
523
+ const installCmd = pm === "bun" ? "bun add -d playwright" : pm === "pnpm" ? "pnpm add -D playwright" : pm === "yarn" ? "yarn add -D playwright" : "npm install -D playwright"
524
+ console.log(` ${dim("Tip: Install playwright for auto-generated preview images")}`)
525
+ console.log(` ${dim(` ${installCmd} && npx playwright install chromium`)}`)
526
+ }
527
+
528
+ console.log()
529
+ console.log(` Publishing ${totalItems} items...`)
530
+ console.log()
531
+
532
+ // Read lockfile for skip-if-unchanged
533
+ const lock = readLockfile(cwd)
534
+ let itemIndex = 0
535
+ let published = 0
536
+ let skipped = 0
537
+ let failed = 0
538
+
539
+ function isUnchanged(slug, fileHashes) {
540
+ const entry = lock.items[slug]
541
+ if (!entry?.files) return false
542
+ const storedKeys = Object.keys(entry.files)
543
+ const currentKeys = Object.keys(fileHashes)
544
+ if (storedKeys.length !== currentKeys.length) return false
545
+ return currentKeys.every(k => entry.files[k] === fileHashes[k])
546
+ }
547
+
548
+ // ── Phase 1: Assets ──
549
+ for (const asset of publicAssets) {
550
+ itemIndex++
551
+ const assetSlug = assetFileToSlug(deckPrefix, asset.relativePath)
552
+ const fileName = basename(asset.fullPath)
553
+ const dirPart = asset.relativePath.includes("/")
554
+ ? "public/" + asset.relativePath.substring(0, asset.relativePath.lastIndexOf("/") + 1)
555
+ : "public/"
556
+
557
+ const fileData = readFileForRegistry(asset.fullPath)
558
+ const fileHash = fileData.binary
559
+ ? hashContent(fileData.buffer.toString("base64"))
560
+ : hashContent(fileData.content)
561
+ const fileHashes = { [dirPart + fileName]: fileHash }
562
+
563
+ if (isUnchanged(assetSlug, fileHashes)) {
564
+ console.log(` [${itemIndex}/${totalItems}] ${dim("—")} asset ${dim(assetSlug)} (unchanged)`)
565
+ skipped++
566
+ continue
567
+ }
568
+
569
+ let files
570
+ if (fileData.binary) {
571
+ files = await uploadBinaryFiles(assetSlug, [
572
+ { path: fileName, target: dirPart, binary: true, buffer: fileData.buffer, contentType: fileData.contentType, size: fileData.size }
573
+ ], auth)
574
+ } else {
575
+ files = [{ path: fileName, target: dirPart, content: fileData.content }]
576
+ }
577
+
578
+ try {
579
+ const result = await publishToRegistry({ type: "asset", slug: assetSlug, title: fileName, files }, auth)
580
+ console.log(` [${itemIndex}/${totalItems}] ${green("✓")} asset ${cyan(assetSlug)} ${dim(`v${result.version}`)}`)
581
+ updateLockfileItem(cwd, assetSlug, result.version ?? 0, fileHashes)
582
+ published++
583
+ } catch (err) {
584
+ console.log(` [${itemIndex}/${totalItems}] ${red("✗")} asset ${dim(assetSlug)}: ${err.message}`)
585
+ failed++
586
+ }
587
+ }
588
+
589
+ // ── Phase 2: Theme ──
590
+ if (hasTheme) {
591
+ itemIndex++
592
+ const themeSlug = `${deckPrefix}/theme`
593
+ const themePayloadFiles = []
594
+ const themeFileHashes = {}
595
+ let themeContent = ""
596
+
597
+ for (const tf of themeFilePaths) {
598
+ const content = readFileSync(join(cwd, "src", tf), "utf-8")
599
+ themePayloadFiles.push({ path: tf, target: "src/", content })
600
+ themeFileHashes[`src/${tf}`] = hashContent(content)
601
+ themeContent += content
602
+ }
603
+
604
+ if (isUnchanged(themeSlug, themeFileHashes)) {
605
+ console.log(` [${itemIndex}/${totalItems}] ${dim("—")} theme ${dim(themeSlug)} (unchanged)`)
606
+ skipped++
607
+ } else {
608
+ const assetDeps = detectAssetDepsInContent(themeContent, deckPrefix, publicFileSet)
609
+ const npmDeps = detectNpmDeps(themeContent)
610
+
611
+ try {
612
+ const result = await publishToRegistry({
613
+ type: "theme",
614
+ slug: themeSlug,
615
+ title: "Theme",
616
+ files: themePayloadFiles,
617
+ registryDependencies: assetDeps.length ? assetDeps : undefined,
618
+ npmDependencies: Object.keys(npmDeps).length ? npmDeps : undefined
619
+ }, auth)
620
+ console.log(` [${itemIndex}/${totalItems}] ${green("✓")} theme ${cyan(themeSlug)} ${dim(`v${result.version}`)}`)
621
+ updateLockfileItem(cwd, themeSlug, result.version ?? 0, themeFileHashes)
622
+ published++
623
+ } catch (err) {
624
+ console.log(` [${itemIndex}/${totalItems}] ${red("✗")} theme ${dim(themeSlug)}: ${err.message}`)
625
+ failed++
626
+ }
627
+ }
628
+ }
629
+
630
+ // ── Phase 3: Layouts ──
631
+ for (const layoutFile of layoutEntries) {
632
+ itemIndex++
633
+ const layoutName = layoutFile.replace(/\.tsx?$/, "")
634
+ const layoutSlug = `${deckPrefix}/${layoutName}`
635
+ const content = readFileSync(join(cwd, "src", "layouts", layoutFile), "utf-8")
636
+ const fileHashes = { [`src/layouts/${layoutFile}`]: hashContent(content) }
637
+
638
+ if (isUnchanged(layoutSlug, fileHashes)) {
639
+ console.log(` [${itemIndex}/${totalItems}] ${dim("—")} layout ${dim(layoutSlug)} (unchanged)`)
640
+ skipped++
641
+ continue
642
+ }
643
+
644
+ const assetDeps = detectAssetDepsInContent(content, deckPrefix, publicFileSet)
645
+ const npmDeps = detectNpmDeps(content)
646
+ const regDeps = hasTheme ? [`${deckPrefix}/theme`] : []
647
+ regDeps.push(...assetDeps)
648
+
649
+ try {
650
+ const result = await publishToRegistry({
651
+ type: "layout",
652
+ slug: layoutSlug,
653
+ title: titleCase(layoutName),
654
+ files: [{ path: layoutFile, target: "src/layouts/", content }],
655
+ registryDependencies: regDeps.length ? regDeps : undefined,
656
+ npmDependencies: Object.keys(npmDeps).length ? npmDeps : undefined
657
+ }, auth)
658
+ console.log(` [${itemIndex}/${totalItems}] ${green("✓")} layout ${cyan(layoutSlug)} ${dim(`v${result.version}`)}`)
659
+ updateLockfileItem(cwd, layoutSlug, result.version ?? 0, fileHashes)
660
+ published++
661
+ } catch (err) {
662
+ console.log(` [${itemIndex}/${totalItems}] ${red("✗")} layout ${dim(layoutSlug)}: ${err.message}`)
663
+ failed++
664
+ }
665
+ }
666
+
667
+ // ── Phase 4: Slides ──
668
+ for (const slideFile of slideEntries) {
669
+ itemIndex++
670
+ const slideName = slideFile.replace(/\.tsx?$/, "")
671
+ const slideSlug = `${deckPrefix}/${slideName}`
672
+ const content = readFileSync(join(cwd, "src", "slides", slideFile), "utf-8")
673
+ const fileHashes = { [`src/slides/${slideFile}`]: hashContent(content) }
674
+
675
+ if (isUnchanged(slideSlug, fileHashes)) {
676
+ console.log(` [${itemIndex}/${totalItems}] ${dim("—")} slide ${dim(slideSlug)} (unchanged)`)
677
+ skipped++
678
+ continue
679
+ }
680
+
681
+ const assetDeps = detectAssetDepsInContent(content, deckPrefix, publicFileSet)
682
+ const layoutDeps = detectRegistryDeps(content).map(d => `${deckPrefix}/${d}`)
683
+ const npmDeps = detectNpmDeps(content)
684
+ const steps = detectSteps(content)
685
+ const slideConfig = deckConfig.slides.find(s => s.slug === slideName)
686
+ const section = slideConfig?.section || undefined
687
+
688
+ const regDeps = hasTheme ? [`${deckPrefix}/theme`] : []
689
+ regDeps.push(...layoutDeps, ...assetDeps)
690
+
691
+ // Generate preview image for this slide
692
+ let slidePreview = null
693
+ if (canCapture) {
694
+ slidePreview = await captureSlideAsDataUri({ cwd, slidePath: `src/slides/${slideFile}` }).catch(() => null)
695
+ }
696
+
697
+ try {
698
+ const result = await publishToRegistry({
699
+ type: "slide",
700
+ slug: slideSlug,
701
+ title: titleCase(slideName),
702
+ files: [{ path: slideFile, target: "src/slides/", content }],
703
+ steps,
704
+ section,
705
+ registryDependencies: regDeps.length ? regDeps : undefined,
706
+ npmDependencies: Object.keys(npmDeps).length ? npmDeps : undefined,
707
+ previewImage: slidePreview || undefined
708
+ }, auth)
709
+ console.log(` [${itemIndex}/${totalItems}] ${green("✓")} slide ${cyan(slideSlug)} ${dim(`v${result.version}`)}`)
710
+ updateLockfileItem(cwd, slideSlug, result.version ?? 0, fileHashes)
711
+ published++
712
+ } catch (err) {
713
+ console.log(` [${itemIndex}/${totalItems}] ${red("✗")} slide ${dim(slideSlug)}: ${err.message}`)
714
+ failed++
715
+ }
716
+ }
717
+
718
+ // ── Phase 5: Deck manifest ──
719
+ itemIndex++
720
+ const slideSlugs = slideEntries.map(f => `${deckPrefix}/${f.replace(/\.tsx?$/, "")}`)
721
+ const assetSlugs = publicAssets.map(a => assetFileToSlug(deckPrefix, a.relativePath))
722
+ const allDeckDeps = [...slideSlugs, ...assetSlugs]
723
+
724
+ try {
725
+ const result = await publishToRegistry({
726
+ type: "deck",
727
+ slug: deckSlug,
728
+ title,
729
+ description: description || undefined,
730
+ tags,
731
+ deckConfig,
732
+ files: [],
733
+ registryDependencies: allDeckDeps.length ? allDeckDeps : undefined,
734
+ releaseNotes: releaseNotes || undefined,
735
+ previewImage: previewImage || undefined
736
+ }, auth)
737
+ console.log(` [${itemIndex}/${totalItems}] ${green("✓")} deck ${cyan(deckSlug)} ${dim(`v${result.version}`)}`)
738
+ updateLockfileItem(cwd, deckSlug, result.version ?? 0, {})
739
+ published++
740
+ } catch (err) {
741
+ console.log(` [${itemIndex}/${totalItems}] ${red("✗")} deck ${dim(deckSlug)}: ${err.message}`)
742
+ failed++
743
+ }
744
+
745
+ console.log()
746
+ console.log(` ${bold("Done:")} ${green(`${published} published`)}${skipped ? `, ${skipped} unchanged` : ""}${failed ? `, ${red(`${failed} failed`)}` : ""}`)
747
+ console.log(` Install: ${cyan(`promptslide add ${deckSlug}`)}`)
748
+ console.log()
749
+ closePrompts()
750
+ return
225
751
  }
226
752
 
227
753
  // Resolve full path and read content
@@ -233,7 +759,7 @@ export async function publish(args) {
233
759
 
234
760
  const content = readFileSync(fullPath, "utf-8")
235
761
  const fileName = basename(fullPath)
236
- const slug = fileName.replace(/\.tsx?$/, "")
762
+ const baseSlug = fileName.replace(/\.tsx?$/, "")
237
763
 
238
764
  // Detect metadata
239
765
  const type = typeOverride || detectType(filePath) || "slide"
@@ -253,12 +779,34 @@ export async function publish(args) {
253
779
  }
254
780
  console.log()
255
781
 
782
+ // Prompt for deck prefix (required)
783
+ const deckPrefix = await promptDeckPrefix(cwd, true)
784
+ const slug = `${deckPrefix}/${baseSlug}`
785
+ console.log(` Slug: ${cyan(slug)}`)
786
+ console.log()
787
+
788
+ // Warn if same base slug exists under a different prefix
789
+ try {
790
+ const { items: results } = await searchRegistry({ search: baseSlug, type }, auth)
791
+ const conflicts = (results || []).filter(r => r.name !== slug && r.name.endsWith(`/${baseSlug}`))
792
+ if (conflicts.length) {
793
+ console.log(` ${bold("Note:")} ${baseSlug} also exists as:`)
794
+ for (const c of conflicts) {
795
+ console.log(` ${dim("·")} ${c.name} ${dim(`(${c.title || "untitled"})`)}`)
796
+ }
797
+ console.log()
798
+ }
799
+ } catch {
800
+ // Search failure is non-blocking
801
+ }
802
+
256
803
  // Check if registry dependencies exist and offer to publish missing ones
257
804
  if (registryDeps.length) {
258
805
  const missing = []
259
806
 
260
807
  for (const depSlug of registryDeps) {
261
- const exists = await registryItemExists(depSlug, auth)
808
+ const prefixedDepSlug = `${deckPrefix}/${depSlug}`
809
+ const exists = await registryItemExists(prefixedDepSlug, auth)
262
810
  if (!exists) {
263
811
  missing.push(depSlug)
264
812
  }
@@ -294,7 +842,8 @@ export async function publish(args) {
294
842
  cwd,
295
843
  auth,
296
844
  typeOverride: depType,
297
- interactive: true
845
+ interactive: true,
846
+ deckPrefix
298
847
  })
299
848
  console.log()
300
849
  const depVer = result.version ? ` ${dim(`v${result.version}`)}` : ""
@@ -311,13 +860,13 @@ export async function publish(args) {
311
860
  }
312
861
  }
313
862
 
314
- console.log(` ${dim("─── Main item:")} ${bold(slug)} ${dim("───")}`)
863
+ console.log(` ${dim("─── Main item:")} ${bold(baseSlug)} ${dim("───")}`)
315
864
  console.log()
316
865
  }
317
866
  }
318
867
 
319
868
  // Collect metadata for main item
320
- const title = await prompt("Title:", titleCase(slug))
869
+ const title = await prompt("Title:", titleCase(baseSlug))
321
870
  const description = await prompt("Description:", "")
322
871
  const tagsInput = await prompt("Tags (comma-separated):", "")
323
872
  const tags = tagsInput ? tagsInput.split(",").map(t => t.trim()).filter(Boolean) : []
@@ -349,6 +898,7 @@ export async function publish(args) {
349
898
  console.log()
350
899
 
351
900
  // Publish main item
901
+ const prefixedRegistryDeps = registryDeps.map(d => `${deckPrefix}/${d}`)
352
902
  const payload = {
353
903
  type,
354
904
  slug,
@@ -359,7 +909,7 @@ export async function publish(args) {
359
909
  section: section || undefined,
360
910
  files: [{ path: fileName, target, content }],
361
911
  npmDependencies: Object.keys(npmDeps).length ? npmDeps : undefined,
362
- registryDependencies: registryDeps.length ? registryDeps : undefined,
912
+ registryDependencies: prefixedRegistryDeps.length ? prefixedRegistryDeps : undefined,
363
913
  releaseNotes: releaseNotes || undefined,
364
914
  previewImage: previewImage || undefined
365
915
  }
@@ -5,7 +5,7 @@ import { homedir } from "node:os"
5
5
  const AUTH_DIR = join(homedir(), ".promptslide")
6
6
  const AUTH_FILE = join(AUTH_DIR, "auth.json")
7
7
 
8
- const DEFAULT_REGISTRY = "https://registry.promptslide.dev"
8
+ const DEFAULT_REGISTRY = "https://promptslide.eu"
9
9
 
10
10
  /**
11
11
  * Load stored auth credentials.
@@ -206,3 +206,58 @@ export function replaceDeckConfig(cwd, slides, opts = {}) {
206
206
  writeFileSync(configPath, content, "utf-8")
207
207
  return true
208
208
  }
209
+
210
+ /**
211
+ * Parse deck-config.ts and extract the deckConfig structure for publishing.
212
+ * Returns { transition?, directionalTransition?, slides } where slides is
213
+ * an array of { slug, steps, section? }.
214
+ *
215
+ * @param {string} cwd - Project root directory
216
+ * @returns {{ transition?: string, directionalTransition?: boolean, slides: { slug: string, steps: number, section?: string }[] } | null}
217
+ */
218
+ export function parseDeckConfig(cwd) {
219
+ const configPath = join(cwd, DECK_CONFIG_PATH)
220
+ if (!existsSync(configPath)) return null
221
+
222
+ const content = readFileSync(configPath, "utf-8")
223
+
224
+ // 1. Parse imports to build componentName -> slug map
225
+ // Pattern: import { SlideTitle } from "@/slides/slide-title"
226
+ const componentToSlug = {}
227
+ const importRegex = /import\s*\{\s*(\w+)\s*\}\s*from\s*["']@\/slides\/([^"']+)["']/g
228
+ for (const match of content.matchAll(importRegex)) {
229
+ componentToSlug[match[1]] = match[2].replace(/\.tsx?$/, "")
230
+ }
231
+
232
+ // 2. Parse transition exports
233
+ let transition
234
+ const transitionMatch = content.match(/export\s+const\s+transition\s*=\s*["']([^"']+)["']/)
235
+ if (transitionMatch) transition = transitionMatch[1]
236
+
237
+ let directionalTransition
238
+ const dirMatch = content.match(/export\s+const\s+directionalTransition\s*=\s*(true|false)/)
239
+ if (dirMatch) directionalTransition = dirMatch[1] === "true"
240
+
241
+ // 3. Parse slides array entries (order-independent matching)
242
+ 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
+ }
256
+
257
+ if (slides.length === 0) return null
258
+
259
+ const result = { slides }
260
+ if (transition) result.transition = transition
261
+ if (directionalTransition !== undefined) result.directionalTransition = directionalTransition
262
+ return result
263
+ }
@@ -28,10 +28,12 @@ export function prompt(question, defaultValue) {
28
28
  if (!rl) return Promise.resolve(defaultValue || "")
29
29
  return new Promise(resolve => {
30
30
  const suffix = defaultValue ? ` ${dim(`(${defaultValue})`)} ` : " "
31
+ const onClose = () => resolve(defaultValue || "")
32
+ rl.once("close", onClose)
31
33
  rl.question(` ${question}${suffix}`, answer => {
34
+ rl.removeListener("close", onClose)
32
35
  resolve(answer.trim() || defaultValue || "")
33
36
  })
34
- rl.once("close", () => resolve(defaultValue || ""))
35
37
  })
36
38
  }
37
39
 
@@ -40,11 +42,13 @@ export function confirm(question, defaultYes = true) {
40
42
  if (!rl) return Promise.resolve(defaultYes)
41
43
  return new Promise(resolve => {
42
44
  const hint = defaultYes ? "Y/n" : "y/N"
45
+ const onClose = () => resolve(defaultYes)
46
+ rl.once("close", onClose)
43
47
  rl.question(` ${question} ${dim(`(${hint})`)} `, answer => {
48
+ rl.removeListener("close", onClose)
44
49
  const a = answer.trim().toLowerCase()
45
50
  if (!a) return resolve(defaultYes)
46
51
  resolve(a === "y" || a === "yes")
47
52
  })
48
- rl.once("close", () => resolve(defaultYes))
49
53
  })
50
54
  }
@@ -186,26 +186,23 @@ export async function resolveRegistryDependencies(item, auth, cwd) {
186
186
  Object.assign(npmDeps, current.dependencies)
187
187
  }
188
188
 
189
- // Check if any files need writing (skip if all exist)
190
- const needsInstall = current.files?.some(f => {
191
- const targetPath = join(cwd, f.target, f.path)
192
- return !existsSync(targetPath)
193
- })
194
-
195
- if (needsInstall || current === item) {
196
- items.push(current)
197
- }
189
+ // Always include items add.mjs handles identical/overwrite logic per file
190
+ items.push(current)
198
191
 
199
- // Recurse into registry dependencies
192
+ // Resolve registry dependencies in parallel
200
193
  if (current.registryDependencies?.length) {
201
- for (const depName of current.registryDependencies) {
202
- try {
203
- const depItem = await fetchRegistryItem(depName, auth)
204
- await resolve(depItem)
205
- } catch (err) {
206
- console.warn(` Warning: Could not resolve dependency "${depName}": ${err.message}`)
207
- }
208
- }
194
+ const fetches = current.registryDependencies
195
+ .filter(depName => !seen.has(depName))
196
+ .map(async (depName) => {
197
+ try {
198
+ return await fetchRegistryItem(depName, auth)
199
+ } catch (err) {
200
+ console.warn(` Warning: Could not resolve dependency "${depName}": ${err.message}`)
201
+ return null
202
+ }
203
+ })
204
+ const depItems = (await Promise.all(fetches)).filter(Boolean)
205
+ await Promise.all(depItems.map(dep => resolve(dep)))
209
206
  }
210
207
  }
211
208
 
@@ -317,3 +314,116 @@ export function getInstallCommand(pm, packages) {
317
314
  return { cmd: "npm", args: ["install", ...packages], display: `npm install ${packages.join(" ")}` }
318
315
  }
319
316
  }
317
+
318
+ /**
319
+ * Request pre-signed upload tokens for binary files.
320
+ * Returns empty array if registry doesn't support direct upload (local dev).
321
+ *
322
+ * @param {string} slug - Item slug
323
+ * @param {{ path: string, contentType: string, size: number }[]} files
324
+ * @param {{ registry: string, token: string }} auth
325
+ * @returns {Promise<{ path: string, clientToken: string, pathname: string }[]>}
326
+ */
327
+ export async function requestUploadTokens(slug, files, auth) {
328
+ const res = await fetch(`${auth.registry}/api/publish/upload-tokens`, {
329
+ method: "POST",
330
+ headers: {
331
+ ...authHeaders(auth),
332
+ "Content-Type": "application/json"
333
+ },
334
+ body: JSON.stringify({ slug, files })
335
+ })
336
+
337
+ if (res.status === 404) {
338
+ // Registry doesn't have this endpoint (older version) — fall back to inline
339
+ return []
340
+ }
341
+ if (res.status === 401) {
342
+ throw new Error("Authentication failed. Run `promptslide login` to re-authenticate.")
343
+ }
344
+ if (!res.ok) {
345
+ throw new Error(`Failed to get upload tokens (${res.status}): ${await res.text()}`)
346
+ }
347
+
348
+ const data = await res.json()
349
+ return data.tokens || []
350
+ }
351
+
352
+ /**
353
+ * Upload a binary file directly to Vercel Blob using a scoped client token.
354
+ * Uses raw fetch — no @vercel/blob dependency needed.
355
+ *
356
+ * @param {Buffer} buffer - Raw file bytes
357
+ * @param {string} pathname - Target pathname in blob storage
358
+ * @param {string} contentType - MIME type
359
+ * @param {string} clientToken - Scoped Vercel Blob client token
360
+ * @returns {Promise<string>} The permanent blob URL
361
+ */
362
+ export async function uploadBinaryToBlob(buffer, pathname, contentType, clientToken) {
363
+ const res = await fetch(`https://blob.vercel-storage.com/${pathname}`, {
364
+ method: "PUT",
365
+ headers: {
366
+ Authorization: `Bearer ${clientToken}`,
367
+ "x-content-type": contentType,
368
+ "x-api-version": "7",
369
+ "x-vercel-blob-access": "private"
370
+ },
371
+ body: buffer
372
+ })
373
+
374
+ if (!res.ok) {
375
+ const text = await res.text()
376
+ throw new Error(`Blob upload failed (${res.status}): ${text}`)
377
+ }
378
+
379
+ const result = await res.json()
380
+ return result.url
381
+ }
382
+
383
+ /**
384
+ * Convert a public/ file path to a registry slug.
385
+ * Deterministic mapping used both during publish (to create the slug)
386
+ * and during asset reference detection (to find the right dependency slug).
387
+ *
388
+ * "logo.png" → "{prefix}/logo-png"
389
+ * "images/hero.jpg" → "{prefix}/images-hero-jpg"
390
+ *
391
+ * @param {string} prefix - Deck prefix (e.g. "my-deck")
392
+ * @param {string} relativePath - Path relative to public/ (e.g. "images/hero.jpg")
393
+ * @returns {string} Full registry slug
394
+ */
395
+ export function assetFileToSlug(prefix, relativePath) {
396
+ const segment = relativePath
397
+ .replace(/[/\\]/g, "-")
398
+ .replace(/[^a-z0-9-]/gi, "-")
399
+ .toLowerCase()
400
+ .replace(/-+/g, "-")
401
+ .replace(/^-|-$/g, "")
402
+ return `${prefix}/${segment}`
403
+ }
404
+
405
+ /**
406
+ * Scan component source for references to public/ assets.
407
+ * Detects patterns like src="/file.png", href="/file.woff2", url('/file.jpg').
408
+ *
409
+ * @param {string} content - File source code (.tsx, .ts, .css)
410
+ * @param {string} prefix - Registry prefix (e.g. "my-deck")
411
+ * @param {Set<string>} publicFiles - Set of known public/ file paths (relative to public/)
412
+ * @returns {string[]} Array of registry dependency slugs
413
+ */
414
+ export function detectAssetDepsInContent(content, prefix, publicFiles) {
415
+ const deps = new Set()
416
+ const patterns = [
417
+ /(?:src|href|poster|data-src)\s*=\s*\{?\s*["']\/([^"']+)["']/g,
418
+ /url\(\s*["']?\/([^"')]+)["']?\s*\)/g,
419
+ ]
420
+ for (const pattern of patterns) {
421
+ for (const match of content.matchAll(pattern)) {
422
+ const filePath = match[1]
423
+ if (publicFiles.has(filePath)) {
424
+ deps.add(assetFileToSlug(prefix, filePath))
425
+ }
426
+ }
427
+ }
428
+ return Array.from(deps)
429
+ }
@@ -4,6 +4,12 @@ Slide deck framework: Vite + React 19 + Tailwind v4 + Framer Motion. Each slide
4
4
 
5
5
  > **Recommended**: Install the [PromptSlide skill](https://github.com/prompticeu/promptslide) for guided slide authoring, style presets, design recipes, and best practices: `npx skills add prompticeu/promptslide`
6
6
 
7
+ ## Before You Start
8
+
9
+ Do not jump straight to writing slides. First confirm the visual direction with the user — theme colors, fonts, and overall style. Then, for each slide, think about what design approach fits the content before coding. Not everything needs cards or grids — let the content shape the layout.
10
+
11
+ If the PromptSlide skill is installed, follow its workflow — it includes design planning steps that should happen before writing any slides.
12
+
7
13
  ---
8
14
 
9
15
  ## Architecture
@@ -83,5 +89,5 @@ Layouts in `src/layouts/` and theme colors in `src/globals.css` are yours to cus
83
89
  - **Semantic colors**: Use `text-foreground`, `text-muted-foreground`, `text-primary`, `bg-background`, `bg-card`, `border-border`.
84
90
  - **Icons**: Import from `lucide-react` (e.g., `import { ArrowRight } from "lucide-react"`).
85
91
  - **Animations**: Use `<Animated step={n}>` for click-to-reveal. The `steps` value in `deck-config.ts` must equal the highest step number used. Available: `fade`, `slide-up`, `slide-down`, `slide-left`, `slide-right`, `scale`.
86
- - **PDF compatibility**: No `blur()` or `backdrop-filter` (dropped by Chromium). No gradients (use solid colors with opacity). Keep colored shadows at `/5` max.
92
+ - **PDF compatibility**: No `blur()` or `backdrop-filter` (dropped by Chromium). No gradients (use solid colors with opacity). No `box-shadow` (doesn't export correctly) — use borders or background tints instead.
87
93
  - **Brand color**: Edit `--primary` in `src/globals.css`. Configure logo and fonts in `src/theme.ts`.
File without changes
@@ -1,8 +1,5 @@
1
- import type { ThemeConfig } from "@promptslide/core";
1
+ import type { ThemeConfig } from "promptslide";
2
2
 
3
3
  export const theme: ThemeConfig = {
4
4
  name: "{{PROJECT_NAME}}",
5
- logo: {
6
- full: "/logo.svg",
7
- },
8
5
  };
@@ -1,7 +0,0 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
2
- <rect width="32" height="32" rx="6" fill="currentColor" opacity="0.1"/>
3
- <rect x="6" y="8" width="20" height="14" rx="2" stroke="currentColor" stroke-width="2" fill="none"/>
4
- <line x1="6" y1="25" x2="26" y2="25" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
5
- <rect x="10" y="12" width="8" height="2" rx="1" fill="currentColor" opacity="0.6"/>
6
- <rect x="10" y="16" width="12" height="1.5" rx="0.75" fill="currentColor" opacity="0.3"/>
7
- </svg>