@autumnsgrove/groveengine 0.4.7 → 0.4.9
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.
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
let showPreview = $state(true);
|
|
40
40
|
let cursorLine = $state(1);
|
|
41
41
|
let cursorCol = $state(1);
|
|
42
|
+
let isUpdating = $state(false);
|
|
42
43
|
|
|
43
44
|
// Image upload state
|
|
44
45
|
let isDragging = $state(false);
|
|
@@ -326,7 +327,8 @@
|
|
|
326
327
|
|
|
327
328
|
// Text manipulation helpers
|
|
328
329
|
function wrapSelection(before, after) {
|
|
329
|
-
if (!textareaRef) return;
|
|
330
|
+
if (!textareaRef || isUpdating) return;
|
|
331
|
+
isUpdating = true;
|
|
330
332
|
const start = textareaRef.selectionStart;
|
|
331
333
|
const end = textareaRef.selectionEnd;
|
|
332
334
|
const selectedText = content.substring(start, end);
|
|
@@ -335,16 +337,19 @@
|
|
|
335
337
|
textareaRef.selectionStart = start + before.length;
|
|
336
338
|
textareaRef.selectionEnd = end + before.length;
|
|
337
339
|
textareaRef.focus();
|
|
340
|
+
isUpdating = false;
|
|
338
341
|
}, 0);
|
|
339
342
|
}
|
|
340
343
|
|
|
341
344
|
function insertAtCursor(text) {
|
|
342
|
-
if (!textareaRef) return;
|
|
345
|
+
if (!textareaRef || isUpdating) return;
|
|
346
|
+
isUpdating = true;
|
|
343
347
|
const start = textareaRef.selectionStart;
|
|
344
348
|
content = content.substring(0, start) + text + content.substring(start);
|
|
345
349
|
setTimeout(() => {
|
|
346
350
|
textareaRef.selectionStart = textareaRef.selectionEnd = start + text.length;
|
|
347
351
|
textareaRef.focus();
|
|
352
|
+
isUpdating = false;
|
|
348
353
|
}, 0);
|
|
349
354
|
}
|
|
350
355
|
|
|
@@ -362,10 +367,18 @@
|
|
|
362
367
|
}
|
|
363
368
|
|
|
364
369
|
function insertCodeBlock() {
|
|
370
|
+
if (!textareaRef || isUpdating) return;
|
|
371
|
+
isUpdating = true;
|
|
365
372
|
const start = textareaRef.selectionStart;
|
|
366
|
-
const
|
|
373
|
+
const end = textareaRef.selectionEnd;
|
|
374
|
+
const selectedText = content.substring(start, end);
|
|
367
375
|
const codeBlock = "```\n" + (selectedText || "code here") + "\n```";
|
|
368
|
-
content = content.substring(0, start) + codeBlock + content.substring(
|
|
376
|
+
content = content.substring(0, start) + codeBlock + content.substring(end);
|
|
377
|
+
setTimeout(() => {
|
|
378
|
+
textareaRef.selectionStart = textareaRef.selectionEnd = start + codeBlock.length;
|
|
379
|
+
textareaRef.focus();
|
|
380
|
+
isUpdating = false;
|
|
381
|
+
}, 0);
|
|
369
382
|
}
|
|
370
383
|
|
|
371
384
|
function insertList() {
|
|
@@ -691,7 +704,9 @@
|
|
|
691
704
|
</div>
|
|
692
705
|
<div class="preview-content" bind:this={previewRef}>
|
|
693
706
|
{#if previewHtml}
|
|
694
|
-
{
|
|
707
|
+
{#key previewHtml}
|
|
708
|
+
<div>{@html previewHtml}</div>
|
|
709
|
+
{/key}
|
|
695
710
|
{:else}
|
|
696
711
|
<p class="preview-placeholder">
|
|
697
712
|
Your rendered markdown will appear here...
|
|
@@ -1006,7 +1021,9 @@
|
|
|
1006
1021
|
|
|
1007
1022
|
<div class="content-body">
|
|
1008
1023
|
{#if previewHtml}
|
|
1009
|
-
{
|
|
1024
|
+
{#key previewHtml}
|
|
1025
|
+
<div>{@html previewHtml}</div>
|
|
1026
|
+
{/key}
|
|
1010
1027
|
{:else}
|
|
1011
1028
|
<p class="preview-placeholder">Start writing to see your content here...</p>
|
|
1012
1029
|
{/if}
|
|
@@ -1845,8 +1862,9 @@
|
|
|
1845
1862
|
border: 1px solid var(--light-border-primary);
|
|
1846
1863
|
border-radius: 12px;
|
|
1847
1864
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
|
1848
|
-
z-index:
|
|
1865
|
+
z-index: 1003; /* above modals and gutters */
|
|
1849
1866
|
animation: slide-up 0.2s ease;
|
|
1867
|
+
overflow: hidden;
|
|
1850
1868
|
}
|
|
1851
1869
|
@keyframes slide-up {
|
|
1852
1870
|
from { opacity: 0; transform: translateY(10px); }
|
|
@@ -401,10 +401,10 @@
|
|
|
401
401
|
// Sanitize HTML content to prevent XSS attacks (browser-only for SSR compatibility)
|
|
402
402
|
let DOMPurify = $state(null);
|
|
403
403
|
|
|
404
|
-
// Load DOMPurify only in browser
|
|
404
|
+
// Load DOMPurify only in browser (avoids jsdom dependency for SSR)
|
|
405
405
|
onMount(async () => {
|
|
406
406
|
if (browser) {
|
|
407
|
-
const module = await import('
|
|
407
|
+
const module = await import('dompurify');
|
|
408
408
|
DOMPurify = module.default;
|
|
409
409
|
}
|
|
410
410
|
});
|
package/dist/utils/api.js
CHANGED
|
@@ -11,99 +11,114 @@
|
|
|
11
11
|
* @throws {Error} If request fails
|
|
12
12
|
*/
|
|
13
13
|
export async function apiRequest(url, options = {}) {
|
|
14
|
-
|
|
14
|
+
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
// Debug logging
|
|
17
|
+
if (typeof console !== "undefined" && process.env.NODE_ENV !== "production") {
|
|
18
|
+
console.debug("[apiRequest]", {
|
|
19
|
+
url,
|
|
20
|
+
csrfToken: csrfToken ? "present" : "missing",
|
|
21
|
+
});
|
|
22
|
+
}
|
|
21
23
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
// Build headers - don't set Content-Type for FormData (browser sets it with boundary)
|
|
25
|
+
const headers = {
|
|
26
|
+
...(csrfToken && { "X-CSRF-Token": csrfToken }),
|
|
27
|
+
...options.headers,
|
|
28
|
+
};
|
|
26
29
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
// Only add Content-Type if not FormData
|
|
31
|
+
if (!(options.body instanceof FormData)) {
|
|
32
|
+
headers["Content-Type"] = "application/json";
|
|
33
|
+
}
|
|
31
34
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
errorMessage = error.message || errorMessage;
|
|
37
|
-
} catch {
|
|
38
|
-
errorMessage = `${response.status} ${response.statusText}`;
|
|
39
|
-
}
|
|
40
|
-
throw new Error(errorMessage);
|
|
41
|
-
}
|
|
35
|
+
const response = await fetch(url, {
|
|
36
|
+
...options,
|
|
37
|
+
headers,
|
|
38
|
+
});
|
|
42
39
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
40
|
+
if (!response.ok) {
|
|
41
|
+
let errorMessage = "Request failed";
|
|
42
|
+
try {
|
|
43
|
+
const error = await response.json();
|
|
44
|
+
errorMessage = error.message || errorMessage;
|
|
45
|
+
} catch {
|
|
46
|
+
errorMessage = `${response.status} ${response.statusText}`;
|
|
47
|
+
}
|
|
48
|
+
// Include CSRF debug info
|
|
49
|
+
if (response.status === 403 && errorMessage.includes("CSRF")) {
|
|
50
|
+
console.error("[apiRequest] CSRF token validation failed", {
|
|
51
|
+
csrfToken,
|
|
52
|
+
headers: Object.fromEntries(response.headers.entries()),
|
|
53
|
+
url,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
throw new Error(errorMessage);
|
|
57
|
+
}
|
|
47
58
|
|
|
48
|
-
|
|
59
|
+
// Handle empty responses (204 No Content)
|
|
60
|
+
if (response.status === 204) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return response.json();
|
|
49
65
|
}
|
|
50
66
|
|
|
51
67
|
/**
|
|
52
68
|
* Convenience methods for common HTTP verbs
|
|
53
69
|
*/
|
|
54
70
|
export const api = {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
apiRequest(url, { ...options, method: 'GET' }),
|
|
71
|
+
/**
|
|
72
|
+
* GET request
|
|
73
|
+
* @param {string} url - API endpoint
|
|
74
|
+
* @param {RequestInit} options - Additional fetch options
|
|
75
|
+
*/
|
|
76
|
+
get: (url, options = {}) => apiRequest(url, { ...options, method: "GET" }),
|
|
62
77
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
78
|
+
/**
|
|
79
|
+
* POST request
|
|
80
|
+
* @param {string} url - API endpoint
|
|
81
|
+
* @param {any} body - Request body (will be JSON stringified)
|
|
82
|
+
* @param {RequestInit} options - Additional fetch options
|
|
83
|
+
*/
|
|
84
|
+
post: (url, body, options = {}) =>
|
|
85
|
+
apiRequest(url, {
|
|
86
|
+
...options,
|
|
87
|
+
method: "POST",
|
|
88
|
+
body: JSON.stringify(body),
|
|
89
|
+
}),
|
|
75
90
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
91
|
+
/**
|
|
92
|
+
* PUT request
|
|
93
|
+
* @param {string} url - API endpoint
|
|
94
|
+
* @param {any} body - Request body (will be JSON stringified)
|
|
95
|
+
* @param {RequestInit} options - Additional fetch options
|
|
96
|
+
*/
|
|
97
|
+
put: (url, body, options = {}) =>
|
|
98
|
+
apiRequest(url, {
|
|
99
|
+
...options,
|
|
100
|
+
method: "PUT",
|
|
101
|
+
body: JSON.stringify(body),
|
|
102
|
+
}),
|
|
88
103
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
104
|
+
/**
|
|
105
|
+
* DELETE request
|
|
106
|
+
* @param {string} url - API endpoint
|
|
107
|
+
* @param {RequestInit} options - Additional fetch options
|
|
108
|
+
*/
|
|
109
|
+
delete: (url, options = {}) =>
|
|
110
|
+
apiRequest(url, { ...options, method: "DELETE" }),
|
|
96
111
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
112
|
+
/**
|
|
113
|
+
* PATCH request
|
|
114
|
+
* @param {string} url - API endpoint
|
|
115
|
+
* @param {any} body - Request body (will be JSON stringified)
|
|
116
|
+
* @param {RequestInit} options - Additional fetch options
|
|
117
|
+
*/
|
|
118
|
+
patch: (url, body, options = {}) =>
|
|
119
|
+
apiRequest(url, {
|
|
120
|
+
...options,
|
|
121
|
+
method: "PATCH",
|
|
122
|
+
body: JSON.stringify(body),
|
|
123
|
+
}),
|
|
109
124
|
};
|
package/dist/utils/sanitize.js
CHANGED
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Centralized sanitization utilities for XSS prevention
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
|
+
* Uses DOMPurify for client-side sanitization. On the server (SSR),
|
|
5
|
+
* content is passed through unsanitized since it will be sanitized
|
|
6
|
+
* when the page hydrates on the client.
|
|
7
|
+
*
|
|
8
|
+
* This approach avoids bundling jsdom (required by isomorphic-dompurify)
|
|
9
|
+
* which doesn't work in Cloudflare Workers.
|
|
4
10
|
*/
|
|
5
11
|
|
|
6
|
-
import
|
|
12
|
+
import { browser } from "$app/environment";
|
|
13
|
+
|
|
14
|
+
// Dynamically import DOMPurify only in browser
|
|
15
|
+
let DOMPurify = null;
|
|
16
|
+
if (browser) {
|
|
17
|
+
import("dompurify").then((module) => {
|
|
18
|
+
DOMPurify = module.default;
|
|
19
|
+
});
|
|
20
|
+
}
|
|
7
21
|
|
|
8
22
|
/**
|
|
9
23
|
* Sanitize HTML content to prevent XSS attacks
|
|
@@ -15,6 +29,11 @@ export function sanitizeHTML(html) {
|
|
|
15
29
|
return "";
|
|
16
30
|
}
|
|
17
31
|
|
|
32
|
+
// On server, pass through - will be sanitized on client hydration
|
|
33
|
+
if (!browser || !DOMPurify) {
|
|
34
|
+
return html;
|
|
35
|
+
}
|
|
36
|
+
|
|
18
37
|
const config = {
|
|
19
38
|
FORBID_TAGS: [
|
|
20
39
|
"script",
|
|
@@ -61,6 +80,11 @@ export function sanitizeSVG(svg) {
|
|
|
61
80
|
return "";
|
|
62
81
|
}
|
|
63
82
|
|
|
83
|
+
// On server, pass through - will be sanitized on client hydration
|
|
84
|
+
if (!browser || !DOMPurify) {
|
|
85
|
+
return svg;
|
|
86
|
+
}
|
|
87
|
+
|
|
64
88
|
return DOMPurify.sanitize(svg, {
|
|
65
89
|
USE_PROFILES: { svg: true, svgFilters: true },
|
|
66
90
|
ALLOWED_TAGS: [
|
|
@@ -156,6 +180,11 @@ export function sanitizeMarkdown(markdownHTML) {
|
|
|
156
180
|
return "";
|
|
157
181
|
}
|
|
158
182
|
|
|
183
|
+
// On server, pass through - will be sanitized on client hydration
|
|
184
|
+
if (!browser || !DOMPurify) {
|
|
185
|
+
return markdownHTML;
|
|
186
|
+
}
|
|
187
|
+
|
|
159
188
|
// For markdown, we allow a broader set of tags but still sanitize
|
|
160
189
|
return DOMPurify.sanitize(markdownHTML, {
|
|
161
190
|
ALLOWED_TAGS: [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@autumnsgrove/groveengine",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.9",
|
|
4
4
|
"description": "Multi-tenant blog engine for Grove Platform. Features gutter annotations, markdown editing, magic code auth, and Cloudflare Workers deployment.",
|
|
5
5
|
"author": "AutumnsGrove",
|
|
6
6
|
"license": "MIT",
|
|
@@ -181,7 +181,7 @@
|
|
|
181
181
|
"@types/dompurify": "^3.0.5",
|
|
182
182
|
"chart.js": "^4.5.1",
|
|
183
183
|
"clsx": "^2.1.1",
|
|
184
|
-
"
|
|
184
|
+
"dompurify": "^3.3.0",
|
|
185
185
|
"gray-matter": "^4.0.3",
|
|
186
186
|
"lucide-svelte": "^0.554.0",
|
|
187
187
|
"marked": "^17.0.1",
|