@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 +1 -0
- package/lib/edit/server.ts +222 -4
- package/package.json +1 -1
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)}`
|
package/lib/edit/server.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomBytes } from "crypto"
|
|
2
|
-
import { readFileSync, statSync } from "fs"
|
|
3
|
-
import {
|
|
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
|
|
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, "&") : 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, "&") : 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)
|