promptslide 0.2.0 → 0.2.2

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.
@@ -0,0 +1,115 @@
1
+ import { execSync } from "node:child_process"
2
+ import { dirname, join } from "node:path"
3
+ import { fileURLToPath } from "node:url"
4
+
5
+ import { createServer } from "vite"
6
+
7
+ import { ensureTsConfig } from "./tsconfig.mjs"
8
+ import { createViteConfig } from "../vite/config.mjs"
9
+
10
+ /**
11
+ * Check if Playwright is available.
12
+ * @returns {Promise<boolean>}
13
+ */
14
+ export async function isPlaywrightAvailable() {
15
+ try {
16
+ await import("playwright")
17
+ return true
18
+ } catch {
19
+ return false
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Ensure the Chromium browser binary is installed.
25
+ * Attempts a launch and auto-installs if the binary is missing.
26
+ * @param {import("playwright").BrowserType} chromium
27
+ */
28
+ async function ensureChromium(chromium) {
29
+ try {
30
+ const browser = await chromium.launch({ headless: true })
31
+ await browser.close()
32
+ } catch (err) {
33
+ if (err.message && err.message.includes("Executable doesn't exist")) {
34
+ const pwIndex = fileURLToPath(import.meta.resolve("playwright"))
35
+ const cliPath = join(dirname(pwIndex), "cli.js")
36
+ execSync(`node "${cliPath}" install chromium`, { stdio: "inherit" })
37
+ } else {
38
+ throw err
39
+ }
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Capture a screenshot of a specific slide.
45
+ * @param {{ cwd: string, slidePath: string, width?: number, height?: number }} opts
46
+ * @returns {Promise<Buffer | null>} PNG buffer, or null if Playwright is not installed
47
+ */
48
+ export async function captureSlideScreenshot({ cwd, slidePath, width = 1280, height = 720 }) {
49
+ let chromium
50
+ try {
51
+ const pw = await import("playwright")
52
+ chromium = pw.chromium
53
+ } catch {
54
+ return null
55
+ }
56
+
57
+ await ensureChromium(chromium)
58
+
59
+ ensureTsConfig(cwd)
60
+
61
+ const config = createViteConfig({ cwd, mode: "development" })
62
+ const server = await createServer({
63
+ ...config,
64
+ server: { port: 0, strictPort: false },
65
+ logLevel: "silent"
66
+ })
67
+ await server.listen()
68
+
69
+ const address = server.httpServer.address()
70
+ const port = typeof address === "object" ? address.port : 0
71
+ const url = `http://localhost:${port}/?export=true&slidePath=${encodeURIComponent(slidePath)}`
72
+
73
+ let browser
74
+ try {
75
+ browser = await chromium.launch({ headless: true })
76
+ const page = await browser.newPage({ viewport: { width, height } })
77
+
78
+ const errors = []
79
+ page.on("pageerror", err => errors.push(err.message))
80
+
81
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 15000 })
82
+ await page.waitForSelector("[data-export-ready='true']", { timeout: 15000 }).catch(err => {
83
+ if (errors.length) {
84
+ throw new Error(`${err.message}\n Browser errors:\n ${errors.join("\n ")}`)
85
+ }
86
+ throw err
87
+ })
88
+ await page.waitForTimeout(200)
89
+
90
+ const element = await page.$("[data-export-ready='true']")
91
+ const screenshot = await element.screenshot({ type: "png" })
92
+
93
+ return screenshot
94
+ } finally {
95
+ if (browser) await browser.close().catch(() => {})
96
+ await server.close()
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Capture a slide and return as base64 data URI.
102
+ * Returns null if Playwright is not available or capture fails.
103
+ * @param {{ cwd: string, slidePath: string }} opts
104
+ * @returns {Promise<string | null>}
105
+ */
106
+ export async function captureSlideAsDataUri({ cwd, slidePath }) {
107
+ try {
108
+ const buffer = await captureSlideScreenshot({ cwd, slidePath })
109
+ if (!buffer) return null
110
+ return `data:image/png;base64,${buffer.toString("base64")}`
111
+ } catch (err) {
112
+ console.error(` Screenshot error: ${err.message}`)
113
+ return null
114
+ }
115
+ }
@@ -0,0 +1,319 @@
1
+ import { createHash } from "node:crypto"
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs"
3
+ import { join } from "node:path"
4
+
5
+ const LOCKFILE = ".promptslide-lock.json"
6
+
7
+ /**
8
+ * Read the lockfile from a project directory.
9
+ * @param {string} cwd
10
+ * @returns {{ items: Record<string, { version: number, installedAt: string }> }}
11
+ */
12
+ export function readLockfile(cwd) {
13
+ const path = join(cwd, LOCKFILE)
14
+ if (!existsSync(path)) return { items: {} }
15
+ try {
16
+ return JSON.parse(readFileSync(path, "utf-8"))
17
+ } catch {
18
+ console.warn(` Warning: ${LOCKFILE} is corrupt or unreadable. Starting fresh.`)
19
+ return { items: {} }
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Write the lockfile to a project directory.
25
+ * @param {string} cwd
26
+ * @param {{ items: Record<string, { version: number, installedAt: string }> }} data
27
+ */
28
+ export function writeLockfile(cwd, data) {
29
+ writeFileSync(join(cwd, LOCKFILE), JSON.stringify(data, null, 2) + "\n", "utf-8")
30
+ }
31
+
32
+ /**
33
+ * Hash a string with SHA-256 and return the hex digest.
34
+ * @param {string} content
35
+ * @returns {string}
36
+ */
37
+ export function hashContent(content) {
38
+ return createHash("sha256").update(content, "utf-8").digest("hex")
39
+ }
40
+
41
+ /**
42
+ * Hash a file on disk. Returns null if the file doesn't exist.
43
+ * @param {string} filePath - Absolute path
44
+ * @returns {string | null}
45
+ */
46
+ export function hashFile(filePath) {
47
+ if (!existsSync(filePath)) return null
48
+ return hashContent(readFileSync(filePath, "utf-8"))
49
+ }
50
+
51
+ /**
52
+ * Check if a file on disk differs from its stored hash.
53
+ * Returns true if the file has been modified or doesn't exist.
54
+ * @param {string} cwd
55
+ * @param {string} relativePath - e.g. "src/slides/hero.tsx"
56
+ * @param {string} storedHash
57
+ * @returns {boolean}
58
+ */
59
+ export function isFileDirty(cwd, relativePath, storedHash) {
60
+ const currentHash = hashFile(join(cwd, relativePath))
61
+ if (!currentHash) return true
62
+ return currentHash !== storedHash
63
+ }
64
+
65
+ /**
66
+ * Add or update a single item in the lockfile.
67
+ * @param {string} cwd
68
+ * @param {string} slug
69
+ * @param {number} version
70
+ * @param {Record<string, string>} files - Map of relative paths to content hashes
71
+ */
72
+ export function updateLockfileItem(cwd, slug, version, files) {
73
+ const lock = readLockfile(cwd)
74
+ lock.items[slug] = {
75
+ version,
76
+ installedAt: new Date().toISOString().split("T")[0],
77
+ files
78
+ }
79
+ writeLockfile(cwd, lock)
80
+ }
81
+
82
+ /**
83
+ * Remove a single item from the lockfile.
84
+ * @param {string} cwd
85
+ * @param {string} slug
86
+ */
87
+ export function removeLockfileItem(cwd, slug) {
88
+ const lock = readLockfile(cwd)
89
+ delete lock.items[slug]
90
+ writeLockfile(cwd, lock)
91
+ }
92
+
93
+ /**
94
+ * Build common auth headers for registry API requests.
95
+ * @param {{ token: string, organizationId?: string }} auth
96
+ * @returns {Record<string, string>}
97
+ */
98
+ function authHeaders(auth) {
99
+ const headers = { Authorization: `Bearer ${auth.token}` }
100
+ if (auth.organizationId) {
101
+ headers["X-Organization-Id"] = auth.organizationId
102
+ }
103
+ return headers
104
+ }
105
+
106
+ /**
107
+ * Fetch the user's organizations from the registry.
108
+ * @param {{ registry: string, token: string }} auth
109
+ * @returns {Promise<{ id: string, name: string, slug: string, role: string }[]>}
110
+ */
111
+ export async function fetchOrganizations(auth) {
112
+ const res = await fetch(`${auth.registry}/api/organizations`, {
113
+ headers: authHeaders(auth)
114
+ })
115
+
116
+ if (res.status === 401) {
117
+ throw new Error("Authentication failed. Run `promptslide login` to re-authenticate.")
118
+ }
119
+ if (!res.ok) {
120
+ throw new Error(`Failed to fetch organizations (${res.status}): ${await res.text()}`)
121
+ }
122
+
123
+ const data = await res.json()
124
+ return data.organizations || []
125
+ }
126
+
127
+ /**
128
+ * Fetch a registry item JSON from the registry API.
129
+ * @param {string} nameOrUrl - Item name (e.g. "slide-hero-gradient") or full URL
130
+ * @param {{ registry: string, apiKey: string }} auth - Auth credentials
131
+ * @returns {Promise<object>} Registry item JSON
132
+ */
133
+ export async function fetchRegistryItem(nameOrUrl, auth) {
134
+ const url = nameOrUrl.startsWith("http")
135
+ ? nameOrUrl
136
+ : `${auth.registry}/api/r/${nameOrUrl}.json`
137
+
138
+ const res = await fetch(url, {
139
+ headers: authHeaders(auth)
140
+ })
141
+
142
+ if (res.status === 401) {
143
+ throw new Error("Authentication failed. Run `promptslide login` to re-authenticate.")
144
+ }
145
+ if (res.status === 403) {
146
+ const body = await res.json().catch(() => ({}))
147
+ if (body.status === "pending_review") {
148
+ throw new Error(`Item "${nameOrUrl}" is pending review. An admin must approve it first.`)
149
+ }
150
+ if (body.status === "rejected") {
151
+ throw new Error(`Item "${nameOrUrl}" was rejected by an admin.`)
152
+ }
153
+ throw new Error("Access denied. This item belongs to a different organization.")
154
+ }
155
+ if (res.status === 404) {
156
+ throw new Error(`Item not found: ${nameOrUrl}`)
157
+ }
158
+ if (!res.ok) {
159
+ throw new Error(`Registry error (${res.status}): ${await res.text()}`)
160
+ }
161
+
162
+ return res.json()
163
+ }
164
+
165
+ /**
166
+ * Resolve all registry dependencies recursively.
167
+ * Returns a flat list of all items to install (including the root item).
168
+ * Skips items whose target files already exist locally.
169
+ *
170
+ * @param {object} item - Root registry item JSON
171
+ * @param {{ registry: string, apiKey: string }} auth
172
+ * @param {string} cwd - Project root directory
173
+ * @returns {Promise<{ items: object[], npmDeps: Record<string, string> }>}
174
+ */
175
+ export async function resolveRegistryDependencies(item, auth, cwd) {
176
+ const seen = new Set()
177
+ const items = []
178
+ const npmDeps = {}
179
+
180
+ async function resolve(current) {
181
+ if (seen.has(current.name)) return
182
+ seen.add(current.name)
183
+
184
+ // Collect npm dependencies
185
+ if (current.dependencies) {
186
+ Object.assign(npmDeps, current.dependencies)
187
+ }
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
+ }
198
+
199
+ // Recurse into registry dependencies
200
+ 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
+ }
209
+ }
210
+ }
211
+
212
+ await resolve(item)
213
+ return { items, npmDeps }
214
+ }
215
+
216
+ /**
217
+ * Check if a registry item exists (by slug).
218
+ * @param {string} name - Item slug
219
+ * @param {{ registry: string, token: string }} auth
220
+ * @returns {Promise<boolean>}
221
+ */
222
+ export async function registryItemExists(name, auth) {
223
+ try {
224
+ await fetchRegistryItem(name, auth)
225
+ return true
226
+ } catch {
227
+ return false
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Search/list registry items.
233
+ * @param {{ search?: string, type?: string }} params
234
+ * @param {{ registry: string, apiKey: string }} auth
235
+ * @returns {Promise<object[]>}
236
+ */
237
+ export async function searchRegistry(params, auth) {
238
+ const url = new URL(`${auth.registry}/api/r`)
239
+ if (params.search) url.searchParams.set("search", params.search)
240
+ if (params.type) url.searchParams.set("type", params.type)
241
+
242
+ const res = await fetch(url.toString(), {
243
+ headers: authHeaders(auth)
244
+ })
245
+
246
+ if (res.status === 401) {
247
+ throw new Error("Authentication failed. Run `promptslide login` to re-authenticate.")
248
+ }
249
+ if (!res.ok) {
250
+ throw new Error(`Registry error (${res.status}): ${await res.text()}`)
251
+ }
252
+
253
+ return res.json()
254
+ }
255
+
256
+ /**
257
+ * Publish an item to the registry.
258
+ * @param {object} payload - Publish payload
259
+ * @param {{ registry: string, apiKey: string }} auth
260
+ * @returns {Promise<object>} Server response
261
+ */
262
+ export async function publishToRegistry(payload, auth) {
263
+ const res = await fetch(`${auth.registry}/api/publish`, {
264
+ method: "POST",
265
+ headers: {
266
+ ...authHeaders(auth),
267
+ "Content-Type": "application/json"
268
+ },
269
+ body: JSON.stringify(payload)
270
+ })
271
+
272
+ if (res.status === 401) {
273
+ throw new Error("Authentication failed. Run `promptslide login` to re-authenticate.")
274
+ }
275
+ if (!res.ok) {
276
+ const body = await res.text()
277
+ throw new Error(`Publish failed (${res.status}): ${body}`)
278
+ }
279
+
280
+ return res.json()
281
+ }
282
+
283
+ /**
284
+ * Detect the package manager in use.
285
+ * @param {string} cwd
286
+ * @returns {"bun" | "yarn" | "pnpm" | "npm"}
287
+ */
288
+ export function detectPackageManager(cwd) {
289
+ if (existsSync(join(cwd, "bun.lock")) || existsSync(join(cwd, "bun.lockb"))) return "bun"
290
+ if (existsSync(join(cwd, "yarn.lock"))) return "yarn"
291
+ if (existsSync(join(cwd, "pnpm-lock.yaml"))) return "pnpm"
292
+ return "npm"
293
+ }
294
+
295
+ /**
296
+ * Get the install command and args for adding dependencies.
297
+ * Returns { cmd, args, display } for safe use with execFileSync.
298
+ * @param {"bun" | "yarn" | "pnpm" | "npm"} pm
299
+ * @param {string[]} packages
300
+ * @returns {{ cmd: string, args: string[], display: string }}
301
+ */
302
+ export function getInstallCommand(pm, packages) {
303
+ // Validate package names to prevent injection
304
+ for (const pkg of packages) {
305
+ if (!/^@?[a-zA-Z0-9][-a-zA-Z0-9/._]*@[^\s]+$/.test(pkg)) {
306
+ throw new Error(`Invalid package specifier: ${pkg}`)
307
+ }
308
+ }
309
+ switch (pm) {
310
+ case "bun":
311
+ return { cmd: "bun", args: ["add", ...packages], display: `bun add ${packages.join(" ")}` }
312
+ case "yarn":
313
+ return { cmd: "yarn", args: ["add", ...packages], display: `yarn add ${packages.join(" ")}` }
314
+ case "pnpm":
315
+ return { cmd: "pnpm", args: ["add", ...packages], display: `pnpm add ${packages.join(" ")}` }
316
+ default:
317
+ return { cmd: "npm", args: ["install", ...packages], display: `npm install ${packages.join(" ")}` }
318
+ }
319
+ }
@@ -22,7 +22,7 @@ export function createViteConfig({ cwd, mode = "development" }) {
22
22
  configFile: false,
23
23
  root: cwd,
24
24
  mode,
25
- plugins: [react(), promptslidePlugin()],
25
+ plugins: [react(), promptslidePlugin({ root: cwd })],
26
26
  resolve: {
27
27
  alias: {
28
28
  "@": resolve(cwd, "src"),
@@ -1,9 +1,11 @@
1
1
  const VIRTUAL_ENTRY_ID = "virtual:promptslide-entry"
2
2
  const RESOLVED_VIRTUAL_ENTRY_ID = "\0" + VIRTUAL_ENTRY_ID
3
+ const VIRTUAL_EXPORT_ID = "virtual:promptslide-export"
4
+ const RESOLVED_VIRTUAL_EXPORT_ID = "\0" + VIRTUAL_EXPORT_ID
3
5
 
4
6
  function getHtmlTemplate() {
5
7
  return `<!doctype html>
6
- <html lang="en" class="dark">
8
+ <html lang="en">
7
9
  <head>
8
10
  <meta charset="UTF-8" />
9
11
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -16,12 +18,27 @@ function getHtmlTemplate() {
16
18
  </html>`
17
19
  }
18
20
 
19
- function getEntryModule() {
21
+ function getExportHtmlTemplate() {
22
+ return `<!doctype html>
23
+ <html lang="en">
24
+ <head>
25
+ <meta charset="UTF-8" />
26
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
27
+ <title>PromptSlide Export</title>
28
+ </head>
29
+ <body>
30
+ <div id="root"></div>
31
+ <script type="module" src="/@id/${VIRTUAL_EXPORT_ID}"></script>
32
+ </body>
33
+ </html>`
34
+ }
35
+
36
+ function getEntryModule(root) {
20
37
  return `
21
38
  import { StrictMode, createElement } from "react"
22
39
  import { createRoot } from "react-dom/client"
23
- import "./src/globals.css"
24
- import App from "./src/App"
40
+ import "${root}/src/globals.css"
41
+ import App from "${root}/src/App"
25
42
 
26
43
  createRoot(document.getElementById("root")).render(
27
44
  createElement(StrictMode, null, createElement(App))
@@ -29,24 +46,88 @@ createRoot(document.getElementById("root")).render(
29
46
  `
30
47
  }
31
48
 
32
- export function promptslidePlugin() {
49
+ function getExportEntryModule(root, slidePath) {
50
+ return `
51
+ import { StrictMode, createElement, useState, useEffect } from "react"
52
+ import { createRoot } from "react-dom/client"
53
+ import { AnimationProvider, SlideErrorBoundary, SlideThemeProvider } from "promptslide"
54
+ import "${root}/src/globals.css"
55
+ import * as slideMod from "${root}/${slidePath}"
56
+
57
+ let theme = {}
58
+ try {
59
+ const themeMod = await import("${root}/src/theme")
60
+ theme = themeMod.theme || themeMod.default || {}
61
+ } catch {}
62
+
63
+ const SlideComponent = slideMod.default || Object.values(slideMod).find(v => typeof v === "function")
64
+
65
+ function ExportView() {
66
+ const [ready, setReady] = useState(false)
67
+ useEffect(() => { setReady(true) }, [])
68
+ return createElement("div", {
69
+ "data-export-ready": ready ? "true" : undefined,
70
+ style: { width: 1280, height: 720, overflow: "hidden", position: "relative", background: "black" }
71
+ },
72
+ createElement(AnimationProvider, { currentStep: 0, totalSteps: 0, showAllAnimations: true },
73
+ createElement(SlideErrorBoundary, { slideIndex: 0 },
74
+ SlideComponent
75
+ ? createElement(SlideComponent, { slideNumber: 1, totalSlides: 1 })
76
+ : null
77
+ )
78
+ )
79
+ )
80
+ }
81
+
82
+ createRoot(document.getElementById("root")).render(
83
+ createElement(StrictMode, null,
84
+ createElement(SlideThemeProvider, { theme },
85
+ createElement(ExportView)
86
+ )
87
+ )
88
+ )
89
+ `
90
+ }
91
+
92
+ export function promptslidePlugin({ root: initialRoot } = {}) {
93
+ let root = initialRoot
94
+ let exportSlidePath = null
95
+
33
96
  return {
34
97
  name: "promptslide",
35
98
  enforce: "pre",
36
99
 
100
+ configResolved(config) {
101
+ if (!root) root = config.root
102
+ },
103
+
37
104
  resolveId(id) {
38
- if (id === VIRTUAL_ENTRY_ID) {
39
- return RESOLVED_VIRTUAL_ENTRY_ID
40
- }
105
+ if (id === VIRTUAL_ENTRY_ID) return RESOLVED_VIRTUAL_ENTRY_ID
106
+ if (id === VIRTUAL_EXPORT_ID) return RESOLVED_VIRTUAL_EXPORT_ID
41
107
  },
42
108
 
43
109
  load(id) {
44
- if (id === RESOLVED_VIRTUAL_ENTRY_ID) {
45
- return getEntryModule()
46
- }
110
+ if (id === RESOLVED_VIRTUAL_ENTRY_ID) return getEntryModule(root)
111
+ if (id === RESOLVED_VIRTUAL_EXPORT_ID) return getExportEntryModule(root, exportSlidePath || "src/slides/slide-title.tsx")
47
112
  },
48
113
 
49
114
  configureServer(server) {
115
+ // Pre-middleware: intercept export URLs before Vite's SPA fallback rewrites them
116
+ server.middlewares.use(async (req, res, next) => {
117
+ const url = new URL(req.url, "http://localhost")
118
+ if (url.searchParams.get("export") !== "true") return next()
119
+
120
+ exportSlidePath = url.searchParams.get("slidePath") || "src/slides/slide-title.tsx"
121
+ // Invalidate cached export module so it regenerates with the new slidePath
122
+ const mod = server.moduleGraph.getModuleById(RESOLVED_VIRTUAL_EXPORT_ID)
123
+ if (mod) server.moduleGraph.invalidateModule(mod)
124
+ const html = await server.transformIndexHtml("/index.html", getExportHtmlTemplate())
125
+ res.setHeader("Content-Type", "text/html")
126
+ res.statusCode = 200
127
+ res.end(html)
128
+ })
129
+
130
+ // Post-middleware: serve the regular app after Vite's built-in middleware
50
131
  return () => {
51
132
  server.middlewares.use(async (req, res, next) => {
52
133
  if (req.url === "/" || req.url === "/index.html") {