@cyber-dash-tech/revela 0.8.3 → 0.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/edit/open.ts CHANGED
@@ -78,6 +78,7 @@ function openEditableDeckInternal(
78
78
  const session = editServer.getOrCreateSession({
79
79
  client: options.client,
80
80
  sessionID: options.sessionID,
81
+ workspaceRoot: options.workspaceRoot,
81
82
  deck,
82
83
  })
83
84
  const url = `${editServer.baseUrl}/edit?token=${encodeURIComponent(session.token)}`
@@ -1,6 +1,7 @@
1
1
  import { randomBytes } from "crypto"
2
- import { readFileSync, statSync } from "fs"
3
- import { resolve, sep } from "path"
2
+ import { existsSync, readFileSync, statSync } from "fs"
3
+ import { fileURLToPath } from "url"
4
+ import { dirname, extname, isAbsolute, resolve, sep } from "path"
4
5
  import type { EditableDeck } from "./resolve-deck"
5
6
  import { buildEditPrompt, type EditCommentPayload } from "./prompt"
6
7
 
@@ -9,6 +10,11 @@ const SESSION_TTL_MS = 2 * 60 * 60 * 1000
9
10
  const IDLE_STOP_MS = 30 * 60 * 1000
10
11
  export const LIVE_EDITOR_IDLE_MS = 10 * 1000
11
12
 
13
+ interface EditAsset {
14
+ id: string
15
+ absoluteFile: string
16
+ }
17
+
12
18
  interface EditSession {
13
19
  token: string
14
20
  client: any
@@ -16,13 +22,17 @@ interface EditSession {
16
22
  deck: string
17
23
  file: string
18
24
  absoluteFile: string
25
+ workspaceRoot: string
26
+ assets: Map<string, EditAsset>
27
+ assetKeys: Map<string, string>
28
+ nextAssetId: number
19
29
  createdAt: number
20
30
  lastActiveAt: number
21
31
  }
22
32
 
23
33
  export interface EditServerHandle {
24
34
  baseUrl: string
25
- getOrCreateSession(input: { client: any; sessionID: string; deck: EditableDeck }): EditServerSessionResult
35
+ getOrCreateSession(input: { client: any; sessionID: string; workspaceRoot: string; deck: EditableDeck }): EditServerSessionResult
26
36
  }
27
37
 
28
38
  export interface EditServerSessionResult {
@@ -57,6 +67,7 @@ export function startEditServer(): EditServerHandle {
57
67
  existing.session.sessionID = input.sessionID
58
68
  existing.session.deck = input.deck.slug
59
69
  existing.session.file = input.deck.file
70
+ existing.session.workspaceRoot = resolve(input.workspaceRoot)
60
71
  return {
61
72
  token: existing.token,
62
73
  reused: true,
@@ -72,6 +83,10 @@ export function startEditServer(): EditServerHandle {
72
83
  deck: input.deck.slug,
73
84
  file: input.deck.file,
74
85
  absoluteFile: input.deck.absoluteFile,
86
+ workspaceRoot: resolve(input.workspaceRoot),
87
+ assets: new Map(),
88
+ assetKeys: new Map(),
89
+ nextAssetId: 1,
75
90
  createdAt: Date.now(),
76
91
  lastActiveAt: Date.now(),
77
92
  })
@@ -131,7 +146,13 @@ async function handleRequest(req: Request): Promise<Response> {
131
146
  if (url.pathname === "/deck" && req.method === "GET") {
132
147
  const session = validateSession(url.searchParams.get("token"))
133
148
  if (!session.ok) return session.response
134
- return htmlResponse(readFileSync(session.value.absoluteFile, "utf-8"))
149
+ return handleDeck(session.value)
150
+ }
151
+
152
+ if (url.pathname === "/__revela_asset" && (req.method === "GET" || req.method === "HEAD")) {
153
+ const session = validateSession(url.searchParams.get("token"))
154
+ if (!session.ok) return session.response
155
+ return handleAsset(session.value, url.searchParams.get("id"), req.method)
135
156
  }
136
157
 
137
158
  if (url.pathname === "/api/comment" && req.method === "POST") {
@@ -149,6 +170,203 @@ async function handleRequest(req: Request): Promise<Response> {
149
170
  return textResponse("Not found", 404)
150
171
  }
151
172
 
173
+ function handleDeck(session: EditSession): Response {
174
+ session.assets.clear()
175
+ session.assetKeys.clear()
176
+ session.nextAssetId = 1
177
+ const html = readFileSync(session.absoluteFile, "utf-8")
178
+ return htmlResponse(rewriteLocalAssetRefs(html, {
179
+ session,
180
+ sourceFile: session.absoluteFile,
181
+ contentType: "html",
182
+ }))
183
+ }
184
+
185
+ function handleAsset(session: EditSession, id: string | null, method: string): Response {
186
+ if (!id) return textResponse("Missing asset id", 400)
187
+ const asset = session.assets.get(id)
188
+ if (!asset) return textResponse("Asset not found", 404)
189
+ if (!existsSync(asset.absoluteFile)) return textResponse("Asset file not found", 404)
190
+ if (!statSync(asset.absoluteFile).isFile()) return textResponse("Asset is not a file", 404)
191
+
192
+ const mime = mimeTypeForPath(asset.absoluteFile)
193
+ const headers = {
194
+ "content-type": mime,
195
+ "cache-control": "no-store, max-age=0",
196
+ }
197
+ if (method === "HEAD") return new Response(null, { status: 200, headers })
198
+
199
+ if (mime === "text/css") {
200
+ const css = readFileSync(asset.absoluteFile, "utf-8")
201
+ return new Response(rewriteLocalAssetRefs(css, {
202
+ session,
203
+ sourceFile: asset.absoluteFile,
204
+ contentType: "css",
205
+ }), { status: 200, headers })
206
+ }
207
+
208
+ return new Response(new Uint8Array(readFileSync(asset.absoluteFile)), { status: 200, headers })
209
+ }
210
+
211
+ function rewriteLocalAssetRefs(content: string, input: { session: EditSession; sourceFile: string; contentType: "html" | "css" }): string {
212
+ const baseDir = dirname(input.sourceFile)
213
+ let rewritten = rewriteCssUrls(content, input.session, baseDir)
214
+ if (input.contentType === "css") return rewritten
215
+
216
+ rewritten = rewriteHtmlAssetAttributes(rewritten, input.session, baseDir)
217
+ rewritten = rewriteSrcsetAttributes(rewritten, input.session, baseDir)
218
+ return rewritten
219
+ }
220
+
221
+ function rewriteHtmlAssetAttributes(html: string, session: EditSession, baseDir: string): string {
222
+ const attrPattern = /\b(src|href|poster)\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/gi
223
+ return html.replace(attrPattern, (match, name: string, raw: string, doubleQuoted?: string, singleQuoted?: string, unquoted?: string) => {
224
+ const value = doubleQuoted ?? singleQuoted ?? unquoted ?? ""
225
+ const assetUrl = assetUrlForRef(value, session, baseDir)
226
+ if (!assetUrl) return match
227
+ const quote = doubleQuoted !== undefined ? '"' : singleQuoted !== undefined ? "'" : ""
228
+ const escaped = quote ? assetUrl.replace(/&/g, "&amp;") : assetUrl
229
+ return `${name}=${quote}${escaped}${quote}`
230
+ })
231
+ }
232
+
233
+ function rewriteSrcsetAttributes(html: string, session: EditSession, baseDir: string): string {
234
+ const srcsetPattern = /\bsrcset\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/gi
235
+ return html.replace(srcsetPattern, (match, raw: string, doubleQuoted?: string, singleQuoted?: string, unquoted?: string) => {
236
+ const value = doubleQuoted ?? singleQuoted ?? unquoted ?? ""
237
+ const rewritten = rewriteSrcset(value, session, baseDir)
238
+ if (rewritten === value) return match
239
+ const quote = doubleQuoted !== undefined ? '"' : singleQuoted !== undefined ? "'" : ""
240
+ const escaped = quote ? rewritten.replace(/&/g, "&amp;") : rewritten
241
+ return `srcset=${quote}${escaped}${quote}`
242
+ })
243
+ }
244
+
245
+ function rewriteSrcset(value: string, session: EditSession, baseDir: string): string {
246
+ return value.split(",").map((part) => {
247
+ const trimmed = part.trim()
248
+ if (!trimmed) return part
249
+ const pieces = trimmed.split(/\s+/)
250
+ const assetUrl = assetUrlForRef(pieces[0], session, baseDir)
251
+ if (!assetUrl) return part
252
+ return [assetUrl, ...pieces.slice(1)].join(" ")
253
+ }).join(", ")
254
+ }
255
+
256
+ function rewriteCssUrls(content: string, session: EditSession, baseDir: string): string {
257
+ const cssUrlPattern = /url\(\s*("([^"]*)"|'([^']*)'|([^\s)]+))\s*\)/gi
258
+ return content.replace(cssUrlPattern, (match, raw: string, doubleQuoted?: string, singleQuoted?: string, unquoted?: string) => {
259
+ const value = doubleQuoted ?? singleQuoted ?? unquoted ?? ""
260
+ const assetUrl = assetUrlForRef(value, session, baseDir)
261
+ if (!assetUrl) return match
262
+ return `url("${assetUrl.replace(/"/g, "%22")}")`
263
+ })
264
+ }
265
+
266
+ function assetUrlForRef(ref: string, session: EditSession, baseDir: string): string | null {
267
+ const absoluteFile = resolveLocalAssetRef(ref, session.workspaceRoot, baseDir)
268
+ if (!absoluteFile || !existsSync(absoluteFile) || !statSync(absoluteFile).isFile()) return null
269
+ const key = resolve(absoluteFile)
270
+ let id = session.assetKeys.get(key)
271
+ if (!id) {
272
+ id = String(session.nextAssetId++)
273
+ session.assetKeys.set(key, id)
274
+ session.assets.set(id, { id, absoluteFile: key })
275
+ }
276
+ return `/__revela_asset?token=${encodeURIComponent(session.token)}&id=${encodeURIComponent(id)}`
277
+ }
278
+
279
+ function resolveLocalAssetRef(ref: string, workspaceRoot: string, baseDir: string): string | null {
280
+ const trimmed = ref.trim()
281
+ if (!trimmed || isSkippedAssetRef(trimmed)) return null
282
+
283
+ const pathPart = stripQueryAndHash(trimmed)
284
+ if (!pathPart) return null
285
+
286
+ if (pathPart.startsWith("file://")) {
287
+ try {
288
+ return resolve(fileURLToPath(pathPart))
289
+ } catch {
290
+ return null
291
+ }
292
+ }
293
+
294
+ const decodedPath = safeDecodeUri(pathPart)
295
+ if (isWindowsAbsolutePath(decodedPath)) return resolve(decodedPath)
296
+
297
+ if (isAbsolute(decodedPath)) {
298
+ const absolute = resolve(decodedPath)
299
+ if (existsSync(absolute)) return absolute
300
+ return resolve(workspaceRoot, `.${decodedPath}`)
301
+ }
302
+
303
+ return resolve(baseDir, decodedPath)
304
+ }
305
+
306
+ function stripQueryAndHash(ref: string): string {
307
+ const hashIndex = ref.indexOf("#")
308
+ const withoutHash = hashIndex >= 0 ? ref.slice(0, hashIndex) : ref
309
+ const queryIndex = withoutHash.indexOf("?")
310
+ return queryIndex >= 0 ? withoutHash.slice(0, queryIndex) : withoutHash
311
+ }
312
+
313
+ function safeDecodeUri(value: string): string {
314
+ try {
315
+ return decodeURI(value)
316
+ } catch {
317
+ return value
318
+ }
319
+ }
320
+
321
+ function isSkippedAssetRef(ref: string): boolean {
322
+ return /^(?:https?:|data:|blob:|mailto:|tel:|javascript:|#)/i.test(ref) || ref.startsWith("//")
323
+ }
324
+
325
+ function isWindowsAbsolutePath(value: string): boolean {
326
+ return /^[a-zA-Z]:[\\/]/.test(value)
327
+ }
328
+
329
+ function mimeTypeForPath(filePath: string): string {
330
+ switch (extname(filePath).toLowerCase()) {
331
+ case ".html":
332
+ case ".htm":
333
+ return "text/html; charset=utf-8"
334
+ case ".css":
335
+ return "text/css"
336
+ case ".js":
337
+ return "application/javascript"
338
+ case ".jpg":
339
+ case ".jpeg":
340
+ return "image/jpeg"
341
+ case ".png":
342
+ return "image/png"
343
+ case ".gif":
344
+ return "image/gif"
345
+ case ".webp":
346
+ return "image/webp"
347
+ case ".svg":
348
+ return "image/svg+xml"
349
+ case ".woff":
350
+ return "font/woff"
351
+ case ".woff2":
352
+ return "font/woff2"
353
+ case ".ttf":
354
+ return "font/ttf"
355
+ case ".otf":
356
+ return "font/otf"
357
+ case ".mp4":
358
+ return "video/mp4"
359
+ case ".webm":
360
+ return "video/webm"
361
+ case ".mp3":
362
+ return "audio/mpeg"
363
+ case ".wav":
364
+ return "audio/wav"
365
+ default:
366
+ return "application/octet-stream"
367
+ }
368
+ }
369
+
152
370
  function handleDeckVersion(session: EditSession): Response {
153
371
  try {
154
372
  const version = readDeckVersion(session)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyber-dash-tech/revela",
3
- "version": "0.8.3",
3
+ "version": "0.8.4",
4
4
  "description": "OpenCode plugin that turns AI into an HTML slide deck generator",
5
5
  "type": "module",
6
6
  "main": "./index.ts",