@abraca/nuxt 2.0.0 → 2.0.1
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/dist/module.json +1 -1
- package/dist/runtime/components/AEditor.vue +5 -0
- package/dist/runtime/components/ASubPageList.d.vue.ts +66 -0
- package/dist/runtime/components/ASubPageList.vue +147 -0
- package/dist/runtime/components/ASubPageList.vue.d.ts +66 -0
- package/dist/runtime/components/aware/AMedia.d.vue.ts +1 -1
- package/dist/runtime/components/aware/AMedia.vue.d.ts +1 -1
- package/dist/runtime/components/renderers/calendar/ACalendarToolbar.d.vue.ts +4 -4
- package/dist/runtime/components/renderers/calendar/ACalendarToolbar.vue.d.ts +4 -4
- package/dist/runtime/components/renderers/media/MediaTransportBar.d.vue.ts +2 -2
- package/dist/runtime/components/renderers/media/MediaTransportBar.vue.d.ts +2 -2
- package/dist/runtime/composables/useEditorSuggestions.js +2 -1
- package/dist/runtime/extensions/svg-embed.d.ts +23 -0
- package/dist/runtime/extensions/svg-embed.js +33 -0
- package/dist/runtime/extensions/views/SvgEmbedView.d.vue.ts +4 -0
- package/dist/runtime/extensions/views/SvgEmbedView.vue +120 -0
- package/dist/runtime/extensions/views/SvgEmbedView.vue.d.ts +4 -0
- package/dist/runtime/plugin-abracadabra.client.js +1 -1
- package/dist/runtime/plugins/core.plugin.js +8 -4
- package/dist/runtime/server/plugins/abracadabra-service.js +83 -5
- package/dist/runtime/utils/sanitizeSvg.d.ts +19 -0
- package/dist/runtime/utils/sanitizeSvg.js +87 -0
- package/package.json +2 -3
package/dist/module.json
CHANGED
|
@@ -323,6 +323,11 @@ const editorHandlers = {
|
|
|
323
323
|
language: "js"
|
|
324
324
|
}),
|
|
325
325
|
isActive: (editor) => editor.isActive("diff")
|
|
326
|
+
},
|
|
327
|
+
svgEmbed: {
|
|
328
|
+
canExecute: () => true,
|
|
329
|
+
execute: (editor) => insertNode(editor, "svgEmbed", { svg: "", title: "", width: null, height: null }),
|
|
330
|
+
isActive: (editor) => editor.isActive("svgEmbed")
|
|
326
331
|
}
|
|
327
332
|
};
|
|
328
333
|
const _mentionItems = computed(
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export interface SubPageListEntry {
|
|
2
|
+
id: string;
|
|
3
|
+
label: string;
|
|
4
|
+
type?: string;
|
|
5
|
+
meta?: {
|
|
6
|
+
icon?: string;
|
|
7
|
+
color?: string;
|
|
8
|
+
[k: string]: unknown;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
type __VLS_Props = {
|
|
12
|
+
/** Parent document whose direct children are listed. */
|
|
13
|
+
parentDocId: string;
|
|
14
|
+
/** Layout. `list` = vertical rows, `grid` = card grid, `compact` = minimal pills. Default: 'list'. */
|
|
15
|
+
layout?: 'list' | 'grid' | 'compact';
|
|
16
|
+
/** Maximum number of items shown. Omit for all. */
|
|
17
|
+
limit?: number;
|
|
18
|
+
/** Empty-state text. */
|
|
19
|
+
emptyText?: string;
|
|
20
|
+
/** Show a "New …" button at the bottom of the list. Emits `create`. */
|
|
21
|
+
showCreate?: boolean;
|
|
22
|
+
/** Label for the create button. */
|
|
23
|
+
createLabel?: string;
|
|
24
|
+
};
|
|
25
|
+
declare var __VLS_1: {}, __VLS_8: {
|
|
26
|
+
entry: SubPageListEntry;
|
|
27
|
+
}, __VLS_15: {
|
|
28
|
+
entry: SubPageListEntry;
|
|
29
|
+
}, __VLS_17: {
|
|
30
|
+
key: string;
|
|
31
|
+
entry: SubPageListEntry;
|
|
32
|
+
}, __VLS_24: {
|
|
33
|
+
key: string;
|
|
34
|
+
entry: SubPageListEntry;
|
|
35
|
+
};
|
|
36
|
+
type __VLS_Slots = {} & {
|
|
37
|
+
empty?: (props: typeof __VLS_1) => any;
|
|
38
|
+
} & {
|
|
39
|
+
item?: (props: typeof __VLS_8) => any;
|
|
40
|
+
} & {
|
|
41
|
+
'item-trailing'?: (props: typeof __VLS_15) => any;
|
|
42
|
+
} & {
|
|
43
|
+
item?: (props: typeof __VLS_17) => any;
|
|
44
|
+
} & {
|
|
45
|
+
item?: (props: typeof __VLS_24) => any;
|
|
46
|
+
};
|
|
47
|
+
declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
48
|
+
open: (id: string, label: string) => any;
|
|
49
|
+
create: () => any;
|
|
50
|
+
}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
51
|
+
onOpen?: ((id: string, label: string) => any) | undefined;
|
|
52
|
+
onCreate?: (() => any) | undefined;
|
|
53
|
+
}>, {
|
|
54
|
+
layout: "list" | "grid" | "compact";
|
|
55
|
+
emptyText: string;
|
|
56
|
+
showCreate: boolean;
|
|
57
|
+
createLabel: string;
|
|
58
|
+
}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
59
|
+
declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
|
|
60
|
+
declare const _default: typeof __VLS_export;
|
|
61
|
+
export default _default;
|
|
62
|
+
type __VLS_WithSlots<T, S> = T & {
|
|
63
|
+
new (): {
|
|
64
|
+
$slots: S;
|
|
65
|
+
};
|
|
66
|
+
};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed } from "vue";
|
|
3
|
+
import { useAbracadabra } from "../composables/useAbracadabra";
|
|
4
|
+
import { useChildTree } from "../composables/useChildTree";
|
|
5
|
+
const props = defineProps({
|
|
6
|
+
parentDocId: { type: String, required: true },
|
|
7
|
+
layout: { type: String, required: false, default: "list" },
|
|
8
|
+
limit: { type: Number, required: false },
|
|
9
|
+
emptyText: { type: String, required: false, default: "No child documents yet" },
|
|
10
|
+
showCreate: { type: Boolean, required: false, default: false },
|
|
11
|
+
createLabel: { type: String, required: false, default: "New page" }
|
|
12
|
+
});
|
|
13
|
+
const emit = defineEmits(["open", "create"]);
|
|
14
|
+
const { doc: rootDoc } = useAbracadabra();
|
|
15
|
+
const tree = useChildTree(rootDoc, props.parentDocId);
|
|
16
|
+
const entries = computed(() => {
|
|
17
|
+
const all = tree.childrenOf(null);
|
|
18
|
+
return props.limit !== void 0 ? all.slice(0, props.limit) : all;
|
|
19
|
+
});
|
|
20
|
+
function defaultIcon(entry) {
|
|
21
|
+
return entry.meta?.icon ?? "i-lucide-file-text";
|
|
22
|
+
}
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<template>
|
|
26
|
+
<div class="a-subpage-list">
|
|
27
|
+
<!-- Empty state -->
|
|
28
|
+
<div
|
|
29
|
+
v-if="!entries.length"
|
|
30
|
+
class="a-subpage-list__empty"
|
|
31
|
+
>
|
|
32
|
+
<slot name="empty">
|
|
33
|
+
<UIcon
|
|
34
|
+
name="i-lucide-folder-open"
|
|
35
|
+
class="size-6 text-(--ui-text-dimmed) mb-2"
|
|
36
|
+
/>
|
|
37
|
+
<p class="text-sm text-(--ui-text-muted)">
|
|
38
|
+
{{ emptyText }}
|
|
39
|
+
</p>
|
|
40
|
+
</slot>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<!-- list -->
|
|
44
|
+
<ul
|
|
45
|
+
v-else-if="layout === 'list'"
|
|
46
|
+
class="flex flex-col gap-0.5"
|
|
47
|
+
>
|
|
48
|
+
<li
|
|
49
|
+
v-for="entry in entries"
|
|
50
|
+
:key="entry.id"
|
|
51
|
+
>
|
|
52
|
+
<slot
|
|
53
|
+
name="item"
|
|
54
|
+
:entry="entry"
|
|
55
|
+
>
|
|
56
|
+
<button
|
|
57
|
+
class="w-full flex items-center gap-2 px-2.5 py-1.5 rounded-md text-sm text-(--ui-text) hover:bg-(--ui-bg-elevated) transition-colors text-left"
|
|
58
|
+
type="button"
|
|
59
|
+
@click="emit('open', entry.id, entry.label)"
|
|
60
|
+
>
|
|
61
|
+
<UIcon
|
|
62
|
+
:name="defaultIcon(entry)"
|
|
63
|
+
class="size-4 shrink-0"
|
|
64
|
+
:style="entry.meta?.color ? `color: ${entry.meta.color}` : ''"
|
|
65
|
+
:class="entry.meta?.color ? '' : 'text-(--ui-text-muted)'"
|
|
66
|
+
/>
|
|
67
|
+
<span class="flex-1 truncate">{{ entry.label || "Untitled" }}</span>
|
|
68
|
+
<slot
|
|
69
|
+
name="item-trailing"
|
|
70
|
+
:entry="entry"
|
|
71
|
+
/>
|
|
72
|
+
</button>
|
|
73
|
+
</slot>
|
|
74
|
+
</li>
|
|
75
|
+
</ul>
|
|
76
|
+
|
|
77
|
+
<!-- grid -->
|
|
78
|
+
<div
|
|
79
|
+
v-else-if="layout === 'grid'"
|
|
80
|
+
class="grid grid-cols-2 sm:grid-cols-3 gap-2"
|
|
81
|
+
>
|
|
82
|
+
<slot
|
|
83
|
+
v-for="entry in entries"
|
|
84
|
+
:key="entry.id"
|
|
85
|
+
name="item"
|
|
86
|
+
:entry="entry"
|
|
87
|
+
>
|
|
88
|
+
<button
|
|
89
|
+
class="flex flex-col gap-2 p-3 rounded-md border border-(--ui-border) bg-(--ui-bg) hover:border-(--ui-border-accented) hover:bg-(--ui-bg-elevated) transition-colors text-left"
|
|
90
|
+
type="button"
|
|
91
|
+
@click="emit('open', entry.id, entry.label)"
|
|
92
|
+
>
|
|
93
|
+
<UIcon
|
|
94
|
+
:name="defaultIcon(entry)"
|
|
95
|
+
class="size-5"
|
|
96
|
+
:style="entry.meta?.color ? `color: ${entry.meta.color}` : ''"
|
|
97
|
+
:class="entry.meta?.color ? '' : 'text-(--ui-text-muted)'"
|
|
98
|
+
/>
|
|
99
|
+
<span class="text-sm font-medium text-(--ui-text-highlighted) line-clamp-2">{{ entry.label || "Untitled" }}</span>
|
|
100
|
+
</button>
|
|
101
|
+
</slot>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<!-- compact -->
|
|
105
|
+
<div
|
|
106
|
+
v-else-if="layout === 'compact'"
|
|
107
|
+
class="flex flex-wrap gap-1.5"
|
|
108
|
+
>
|
|
109
|
+
<slot
|
|
110
|
+
v-for="entry in entries"
|
|
111
|
+
:key="entry.id"
|
|
112
|
+
name="item"
|
|
113
|
+
:entry="entry"
|
|
114
|
+
>
|
|
115
|
+
<button
|
|
116
|
+
class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full border border-(--ui-border) bg-(--ui-bg) text-xs hover:border-(--ui-border-accented) hover:bg-(--ui-bg-elevated) transition-colors"
|
|
117
|
+
type="button"
|
|
118
|
+
@click="emit('open', entry.id, entry.label)"
|
|
119
|
+
>
|
|
120
|
+
<UIcon
|
|
121
|
+
:name="defaultIcon(entry)"
|
|
122
|
+
class="size-3"
|
|
123
|
+
:style="entry.meta?.color ? `color: ${entry.meta.color}` : ''"
|
|
124
|
+
:class="entry.meta?.color ? '' : 'text-(--ui-text-muted)'"
|
|
125
|
+
/>
|
|
126
|
+
<span class="text-(--ui-text)">{{ entry.label || "Untitled" }}</span>
|
|
127
|
+
</button>
|
|
128
|
+
</slot>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
<!-- create -->
|
|
132
|
+
<div
|
|
133
|
+
v-if="showCreate"
|
|
134
|
+
class="mt-2"
|
|
135
|
+
>
|
|
136
|
+
<UButton
|
|
137
|
+
:icon="layout === 'grid' ? 'i-lucide-plus-square' : 'i-lucide-plus'"
|
|
138
|
+
:label="createLabel"
|
|
139
|
+
size="sm"
|
|
140
|
+
variant="ghost"
|
|
141
|
+
color="neutral"
|
|
142
|
+
:block="layout === 'grid'"
|
|
143
|
+
@click="emit('create')"
|
|
144
|
+
/>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
</template>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export interface SubPageListEntry {
|
|
2
|
+
id: string;
|
|
3
|
+
label: string;
|
|
4
|
+
type?: string;
|
|
5
|
+
meta?: {
|
|
6
|
+
icon?: string;
|
|
7
|
+
color?: string;
|
|
8
|
+
[k: string]: unknown;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
type __VLS_Props = {
|
|
12
|
+
/** Parent document whose direct children are listed. */
|
|
13
|
+
parentDocId: string;
|
|
14
|
+
/** Layout. `list` = vertical rows, `grid` = card grid, `compact` = minimal pills. Default: 'list'. */
|
|
15
|
+
layout?: 'list' | 'grid' | 'compact';
|
|
16
|
+
/** Maximum number of items shown. Omit for all. */
|
|
17
|
+
limit?: number;
|
|
18
|
+
/** Empty-state text. */
|
|
19
|
+
emptyText?: string;
|
|
20
|
+
/** Show a "New …" button at the bottom of the list. Emits `create`. */
|
|
21
|
+
showCreate?: boolean;
|
|
22
|
+
/** Label for the create button. */
|
|
23
|
+
createLabel?: string;
|
|
24
|
+
};
|
|
25
|
+
declare var __VLS_1: {}, __VLS_8: {
|
|
26
|
+
entry: SubPageListEntry;
|
|
27
|
+
}, __VLS_15: {
|
|
28
|
+
entry: SubPageListEntry;
|
|
29
|
+
}, __VLS_17: {
|
|
30
|
+
key: string;
|
|
31
|
+
entry: SubPageListEntry;
|
|
32
|
+
}, __VLS_24: {
|
|
33
|
+
key: string;
|
|
34
|
+
entry: SubPageListEntry;
|
|
35
|
+
};
|
|
36
|
+
type __VLS_Slots = {} & {
|
|
37
|
+
empty?: (props: typeof __VLS_1) => any;
|
|
38
|
+
} & {
|
|
39
|
+
item?: (props: typeof __VLS_8) => any;
|
|
40
|
+
} & {
|
|
41
|
+
'item-trailing'?: (props: typeof __VLS_15) => any;
|
|
42
|
+
} & {
|
|
43
|
+
item?: (props: typeof __VLS_17) => any;
|
|
44
|
+
} & {
|
|
45
|
+
item?: (props: typeof __VLS_24) => any;
|
|
46
|
+
};
|
|
47
|
+
declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
48
|
+
open: (id: string, label: string) => any;
|
|
49
|
+
create: () => any;
|
|
50
|
+
}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
51
|
+
onOpen?: ((id: string, label: string) => any) | undefined;
|
|
52
|
+
onCreate?: (() => any) | undefined;
|
|
53
|
+
}>, {
|
|
54
|
+
layout: "list" | "grid" | "compact";
|
|
55
|
+
emptyText: string;
|
|
56
|
+
showCreate: boolean;
|
|
57
|
+
createLabel: string;
|
|
58
|
+
}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
59
|
+
declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
|
|
60
|
+
declare const _default: typeof __VLS_export;
|
|
61
|
+
export default _default;
|
|
62
|
+
type __VLS_WithSlots<T, S> = T & {
|
|
63
|
+
new (): {
|
|
64
|
+
$slots: S;
|
|
65
|
+
};
|
|
66
|
+
};
|
|
@@ -12,8 +12,8 @@ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {
|
|
|
12
12
|
awareness: boolean;
|
|
13
13
|
tag: "video" | "audio";
|
|
14
14
|
live: boolean;
|
|
15
|
-
controls: boolean;
|
|
16
15
|
total: boolean;
|
|
16
|
+
controls: boolean;
|
|
17
17
|
}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
18
18
|
declare const _default: typeof __VLS_export;
|
|
19
19
|
export default _default;
|
|
@@ -12,8 +12,8 @@ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {
|
|
|
12
12
|
awareness: boolean;
|
|
13
13
|
tag: "video" | "audio";
|
|
14
14
|
live: boolean;
|
|
15
|
-
controls: boolean;
|
|
16
15
|
total: boolean;
|
|
16
|
+
controls: boolean;
|
|
17
17
|
}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
18
18
|
declare const _default: typeof __VLS_export;
|
|
19
19
|
export default _default;
|
|
@@ -20,16 +20,16 @@ type __VLS_Props = {
|
|
|
20
20
|
};
|
|
21
21
|
};
|
|
22
22
|
declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
23
|
-
next: () => any;
|
|
24
|
-
prev: () => any;
|
|
25
23
|
"update:viewMode": (mode: CalendarViewMode) => any;
|
|
24
|
+
prev: () => any;
|
|
25
|
+
next: () => any;
|
|
26
26
|
today: () => any;
|
|
27
27
|
"add-event": () => any;
|
|
28
28
|
"navigate-to-month": (year: number, month: number) => any;
|
|
29
29
|
}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
30
|
-
onNext?: (() => any) | undefined;
|
|
31
|
-
onPrev?: (() => any) | undefined;
|
|
32
30
|
"onUpdate:viewMode"?: ((mode: CalendarViewMode) => any) | undefined;
|
|
31
|
+
onPrev?: (() => any) | undefined;
|
|
32
|
+
onNext?: (() => any) | undefined;
|
|
33
33
|
onToday?: (() => any) | undefined;
|
|
34
34
|
"onAdd-event"?: (() => any) | undefined;
|
|
35
35
|
"onNavigate-to-month"?: ((year: number, month: number) => any) | undefined;
|
|
@@ -20,16 +20,16 @@ type __VLS_Props = {
|
|
|
20
20
|
};
|
|
21
21
|
};
|
|
22
22
|
declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
23
|
-
next: () => any;
|
|
24
|
-
prev: () => any;
|
|
25
23
|
"update:viewMode": (mode: CalendarViewMode) => any;
|
|
24
|
+
prev: () => any;
|
|
25
|
+
next: () => any;
|
|
26
26
|
today: () => any;
|
|
27
27
|
"add-event": () => any;
|
|
28
28
|
"navigate-to-month": (year: number, month: number) => any;
|
|
29
29
|
}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
30
|
-
onNext?: (() => any) | undefined;
|
|
31
|
-
onPrev?: (() => any) | undefined;
|
|
32
30
|
"onUpdate:viewMode"?: ((mode: CalendarViewMode) => any) | undefined;
|
|
31
|
+
onPrev?: (() => any) | undefined;
|
|
32
|
+
onNext?: (() => any) | undefined;
|
|
33
33
|
onToday?: (() => any) | undefined;
|
|
34
34
|
"onAdd-event"?: (() => any) | undefined;
|
|
35
35
|
"onNavigate-to-month"?: ((year: number, month: number) => any) | undefined;
|
|
@@ -8,13 +8,13 @@ type __VLS_Props = {
|
|
|
8
8
|
isLoading: boolean;
|
|
9
9
|
};
|
|
10
10
|
declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
11
|
-
next: () => any;
|
|
12
11
|
prev: () => any;
|
|
12
|
+
next: () => any;
|
|
13
13
|
togglePlay: () => any;
|
|
14
14
|
seek: (position: number) => any;
|
|
15
15
|
}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
16
|
-
onNext?: (() => any) | undefined;
|
|
17
16
|
onPrev?: (() => any) | undefined;
|
|
17
|
+
onNext?: (() => any) | undefined;
|
|
18
18
|
onTogglePlay?: (() => any) | undefined;
|
|
19
19
|
onSeek?: ((position: number) => any) | undefined;
|
|
20
20
|
}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
@@ -8,13 +8,13 @@ type __VLS_Props = {
|
|
|
8
8
|
isLoading: boolean;
|
|
9
9
|
};
|
|
10
10
|
declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
11
|
-
next: () => any;
|
|
12
11
|
prev: () => any;
|
|
12
|
+
next: () => any;
|
|
13
13
|
togglePlay: () => any;
|
|
14
14
|
seek: (position: number) => any;
|
|
15
15
|
}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
16
|
-
onNext?: (() => any) | undefined;
|
|
17
16
|
onPrev?: (() => any) | undefined;
|
|
17
|
+
onNext?: (() => any) | undefined;
|
|
18
18
|
onTogglePlay?: (() => any) | undefined;
|
|
19
19
|
onSeek?: ((position: number) => any) | undefined;
|
|
20
20
|
}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
@@ -72,7 +72,8 @@ export function useEditorSuggestions(options = {}) {
|
|
|
72
72
|
...extEnabled("colorSwatch") ? [{ kind: "colorSwatch", label: "Color Swatch", icon: "i-lucide-palette", description: "Inline color chip with picker", keywords: ["color", "swatch", "token", "palette"] }] : [],
|
|
73
73
|
...extEnabled("mathBlock") ? [{ kind: "mathBlock", label: "Math (block)", icon: "i-lucide-square-sigma", description: "Display-mode LaTeX equation", keywords: ["katex", "latex", "equation", "formula", "math"] }] : [],
|
|
74
74
|
...extEnabled("mathInline") ? [{ kind: "mathInline", label: "Math (inline)", icon: "i-lucide-sigma", description: "Inline LaTeX expression", keywords: ["katex", "latex", "inline math"] }] : [],
|
|
75
|
-
...extEnabled("diff") ? [{ kind: "diff", label: "Diff", icon: "i-lucide-git-compare", description: "Side-by-side text diff", keywords: ["compare", "changes", "git"] }] : []
|
|
75
|
+
...extEnabled("diff") ? [{ kind: "diff", label: "Diff", icon: "i-lucide-git-compare", description: "Side-by-side text diff", keywords: ["compare", "changes", "git"] }] : [],
|
|
76
|
+
...extEnabled("svgEmbed") ? [{ kind: "svgEmbed", label: "SVG embed", icon: "i-lucide-image", description: "Inline SVG (sanitized)", keywords: ["svg", "vector", "icon", "diagram"] }] : []
|
|
76
77
|
]
|
|
77
78
|
];
|
|
78
79
|
try {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Node } from '@tiptap/core';
|
|
2
|
+
declare module '@tiptap/core' {
|
|
3
|
+
interface Commands<ReturnType> {
|
|
4
|
+
svgEmbed: {
|
|
5
|
+
insertSvgEmbed: (attrs: {
|
|
6
|
+
svg?: string;
|
|
7
|
+
title?: string;
|
|
8
|
+
width?: string | null;
|
|
9
|
+
height?: string | null;
|
|
10
|
+
}) => ReturnType;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* SVG embed extension.
|
|
16
|
+
*
|
|
17
|
+
* The extension itself has no peer dep. Sanitization happens inside the
|
|
18
|
+
* NodeView via `runtime/utils/sanitizeSvg.ts`, which try-imports DOMPurify
|
|
19
|
+
* and falls back to a strict built-in allowlist when DOMPurify isn't
|
|
20
|
+
* installed. Apps that need richer SVG features (animations, CSS) should
|
|
21
|
+
* `pnpm add dompurify`.
|
|
22
|
+
*/
|
|
23
|
+
export declare const SvgEmbed: Node<any, any>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Node, mergeAttributes } from "@tiptap/core";
|
|
2
|
+
import { VueNodeViewRenderer } from "@tiptap/vue-3";
|
|
3
|
+
import SvgEmbedView from "./views/SvgEmbedView.vue";
|
|
4
|
+
export const SvgEmbed = Node.create({
|
|
5
|
+
name: "svgEmbed",
|
|
6
|
+
group: "block",
|
|
7
|
+
atom: true,
|
|
8
|
+
draggable: true,
|
|
9
|
+
addAttributes() {
|
|
10
|
+
return {
|
|
11
|
+
svg: { default: "" },
|
|
12
|
+
title: { default: "" },
|
|
13
|
+
width: { default: null },
|
|
14
|
+
height: { default: null }
|
|
15
|
+
};
|
|
16
|
+
},
|
|
17
|
+
parseHTML() {
|
|
18
|
+
return [{ tag: 'div[data-type="svg-embed"]' }];
|
|
19
|
+
},
|
|
20
|
+
renderHTML({ HTMLAttributes }) {
|
|
21
|
+
return ["div", mergeAttributes(HTMLAttributes, { "data-type": "svg-embed" })];
|
|
22
|
+
},
|
|
23
|
+
addNodeView() {
|
|
24
|
+
return VueNodeViewRenderer(SvgEmbedView);
|
|
25
|
+
},
|
|
26
|
+
addCommands() {
|
|
27
|
+
return {
|
|
28
|
+
insertSvgEmbed: (attrs) => ({ commands }) => {
|
|
29
|
+
return commands.insertContent({ type: this.name, attrs });
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
});
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { NodeViewProps } from '@tiptap/vue-3';
|
|
2
|
+
declare const __VLS_export: import("vue").DefineComponent<NodeViewProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<NodeViewProps> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
3
|
+
declare const _default: typeof __VLS_export;
|
|
4
|
+
export default _default;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, computed, watch, watchEffect } from "vue";
|
|
3
|
+
import { NodeViewWrapper } from "@tiptap/vue-3";
|
|
4
|
+
import { sanitizeSvg } from "../../utils/sanitizeSvg";
|
|
5
|
+
const props = defineProps({
|
|
6
|
+
decorations: { type: Array, required: true },
|
|
7
|
+
selected: { type: Boolean, required: true },
|
|
8
|
+
updateAttributes: { type: Function, required: true },
|
|
9
|
+
deleteNode: { type: Function, required: true },
|
|
10
|
+
node: { type: null, required: true },
|
|
11
|
+
view: { type: null, required: true },
|
|
12
|
+
getPos: { type: null, required: true },
|
|
13
|
+
innerDecorations: { type: null, required: true },
|
|
14
|
+
editor: { type: Object, required: true },
|
|
15
|
+
extension: { type: Object, required: true },
|
|
16
|
+
HTMLAttributes: { type: Object, required: true }
|
|
17
|
+
});
|
|
18
|
+
const rawSvg = computed(() => props.node.attrs.svg || "");
|
|
19
|
+
const title = computed(() => props.node.attrs.title || "");
|
|
20
|
+
const hasSvg = computed(() => !!rawSvg.value);
|
|
21
|
+
const sanitized = ref("");
|
|
22
|
+
const isDragOver = ref(false);
|
|
23
|
+
watchEffect(async () => {
|
|
24
|
+
sanitized.value = await sanitizeSvg(rawSvg.value);
|
|
25
|
+
});
|
|
26
|
+
const containerStyle = computed(() => {
|
|
27
|
+
const s = {};
|
|
28
|
+
if (props.node.attrs.width) s.width = String(props.node.attrs.width);
|
|
29
|
+
if (props.node.attrs.height) s.height = String(props.node.attrs.height);
|
|
30
|
+
return s;
|
|
31
|
+
});
|
|
32
|
+
function isSvgFile(file) {
|
|
33
|
+
return file.type === "image/svg+xml" || file.name.toLowerCase().endsWith(".svg");
|
|
34
|
+
}
|
|
35
|
+
async function loadSvgFile(file) {
|
|
36
|
+
const text = await file.text();
|
|
37
|
+
if (!text.includes("<svg")) return;
|
|
38
|
+
props.updateAttributes({
|
|
39
|
+
svg: text,
|
|
40
|
+
title: props.node.attrs.title || file.name.replace(/\.svg$/i, "")
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
function pickSvgFile() {
|
|
44
|
+
const input = document.createElement("input");
|
|
45
|
+
input.type = "file";
|
|
46
|
+
input.accept = ".svg,image/svg+xml";
|
|
47
|
+
input.onchange = () => {
|
|
48
|
+
const file = input.files?.[0];
|
|
49
|
+
if (file) loadSvgFile(file);
|
|
50
|
+
};
|
|
51
|
+
input.click();
|
|
52
|
+
}
|
|
53
|
+
function onDrop(e) {
|
|
54
|
+
e.preventDefault();
|
|
55
|
+
e.stopPropagation();
|
|
56
|
+
isDragOver.value = false;
|
|
57
|
+
const file = Array.from(e.dataTransfer?.files ?? []).find(isSvgFile);
|
|
58
|
+
if (file) loadSvgFile(file);
|
|
59
|
+
}
|
|
60
|
+
function onDragOver(e) {
|
|
61
|
+
e.preventDefault();
|
|
62
|
+
e.stopPropagation();
|
|
63
|
+
isDragOver.value = true;
|
|
64
|
+
}
|
|
65
|
+
function onDragLeave() {
|
|
66
|
+
isDragOver.value = false;
|
|
67
|
+
}
|
|
68
|
+
const placeholderText = computed(() => {
|
|
69
|
+
if (isDragOver.value) return "Drop SVG file here";
|
|
70
|
+
if (hasSvg.value && !sanitized.value) return "SVG removed by sanitizer";
|
|
71
|
+
return "Click or drop an SVG file";
|
|
72
|
+
});
|
|
73
|
+
</script>
|
|
74
|
+
|
|
75
|
+
<template>
|
|
76
|
+
<NodeViewWrapper
|
|
77
|
+
class="svg-embed-wrapper my-3"
|
|
78
|
+
data-type="svg-embed"
|
|
79
|
+
>
|
|
80
|
+
<div
|
|
81
|
+
contenteditable="false"
|
|
82
|
+
data-drag-handle
|
|
83
|
+
class="border border-(--ui-border) rounded-md overflow-hidden transition-colors"
|
|
84
|
+
:class="{
|
|
85
|
+
'border-(--ui-primary) bg-(--ui-primary)/5': isDragOver,
|
|
86
|
+
'border-(--ui-primary)': props.selected && !isDragOver
|
|
87
|
+
}"
|
|
88
|
+
@drop="onDrop"
|
|
89
|
+
@dragover="onDragOver"
|
|
90
|
+
@dragleave="onDragLeave"
|
|
91
|
+
>
|
|
92
|
+
<div
|
|
93
|
+
v-if="title && sanitized"
|
|
94
|
+
class="px-3 py-1.5 text-xs font-medium text-(--ui-text-dimmed) border-b border-(--ui-border) bg-(--ui-bg-elevated)"
|
|
95
|
+
>
|
|
96
|
+
{{ title }}
|
|
97
|
+
</div>
|
|
98
|
+
<div
|
|
99
|
+
v-if="sanitized"
|
|
100
|
+
class="flex items-center justify-center p-2 [&_svg]:max-w-full [&_svg]:h-auto"
|
|
101
|
+
:style="containerStyle"
|
|
102
|
+
v-html="sanitized"
|
|
103
|
+
/>
|
|
104
|
+
<div
|
|
105
|
+
v-else
|
|
106
|
+
class="flex flex-col items-center justify-center gap-2 px-6 py-10 cursor-pointer text-(--ui-text-dimmed) hover:bg-(--ui-bg-elevated)/40 transition-colors"
|
|
107
|
+
role="button"
|
|
108
|
+
tabindex="0"
|
|
109
|
+
@click="pickSvgFile"
|
|
110
|
+
@keydown.enter="pickSvgFile"
|
|
111
|
+
>
|
|
112
|
+
<UIcon
|
|
113
|
+
name="i-lucide-image"
|
|
114
|
+
class="size-6"
|
|
115
|
+
/>
|
|
116
|
+
<span class="text-sm">{{ placeholderText }}</span>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
</NodeViewWrapper>
|
|
120
|
+
</template>
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { NodeViewProps } from '@tiptap/vue-3';
|
|
2
|
+
declare const __VLS_export: import("vue").DefineComponent<NodeViewProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<NodeViewProps> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
3
|
+
declare const _default: typeof __VLS_export;
|
|
4
|
+
export default _default;
|
|
@@ -860,7 +860,7 @@ export default defineNuxtPlugin({
|
|
|
860
860
|
}
|
|
861
861
|
}
|
|
862
862
|
const server = savedServers.value.find((s) => s.url === serverUrl);
|
|
863
|
-
const docId = configEntryDocId ?? server?.entryDocId ?? server?.cachedSpaces?.[0]?.id ?? spacesInfo?.spaces?.[0]?.id ?? void 0;
|
|
863
|
+
const docId = configEntryDocId ?? server?.entryDocId ?? server?.cachedSpaces?.[0]?.id ?? spacesInfo?.spaces?.[0]?.id ?? info?.root_doc_id ?? void 0;
|
|
864
864
|
if (!docId) {
|
|
865
865
|
connectionError.value = "No entry document found. Configure entryDocId or ensure the server has spaces.";
|
|
866
866
|
addLog("No entry document \u2014 cannot connect", "system");
|
|
@@ -15,7 +15,8 @@ const OPTIONAL_BUILTIN_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
|
15
15
|
"colorSwatch",
|
|
16
16
|
"mathBlock",
|
|
17
17
|
"mathInline",
|
|
18
|
-
"diff"
|
|
18
|
+
"diff",
|
|
19
|
+
"svgEmbed"
|
|
19
20
|
]);
|
|
20
21
|
async function loadClientExtensions() {
|
|
21
22
|
const [
|
|
@@ -71,7 +72,8 @@ async function loadClientExtensions() {
|
|
|
71
72
|
{ Spoiler },
|
|
72
73
|
{ ColorSwatch },
|
|
73
74
|
{ MathBlock, MathInline },
|
|
74
|
-
{ Diff }
|
|
75
|
+
{ Diff },
|
|
76
|
+
{ SvgEmbed }
|
|
75
77
|
] = await Promise.all([
|
|
76
78
|
import("@tiptap/extension-task-list"),
|
|
77
79
|
import("@tiptap/extension-task-item"),
|
|
@@ -125,7 +127,8 @@ async function loadClientExtensions() {
|
|
|
125
127
|
import("../extensions/spoiler.js"),
|
|
126
128
|
import("../extensions/color-swatch.js"),
|
|
127
129
|
import("../extensions/math.js"),
|
|
128
|
-
import("../extensions/diff.js")
|
|
130
|
+
import("../extensions/diff.js"),
|
|
131
|
+
import("../extensions/svg-embed.js")
|
|
129
132
|
]);
|
|
130
133
|
const lowlight = createLowlight(common);
|
|
131
134
|
const extensions = [
|
|
@@ -198,7 +201,8 @@ async function loadClientExtensions() {
|
|
|
198
201
|
ColorSwatch,
|
|
199
202
|
MathBlock,
|
|
200
203
|
MathInline,
|
|
201
|
-
Diff
|
|
204
|
+
Diff,
|
|
205
|
+
SvgEmbed
|
|
202
206
|
];
|
|
203
207
|
try {
|
|
204
208
|
const emojiPkg = "@tiptap/extension-emoji";
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { defineNitroPlugin } from "nitropack/runtime/plugin";
|
|
2
2
|
import { useRuntimeConfig } from "nitropack/runtime/config";
|
|
3
3
|
import { useStorage } from "nitropack/runtime/storage";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
4
6
|
import { registerServerPlugin, bootRunners, shutdownAllRunners } from "../utils/serverRunner.js";
|
|
5
7
|
import { createDocCacheAPI } from "../utils/docCache.js";
|
|
6
8
|
import { docTreeCacheRunner } from "../runners/doc-tree-cache.js";
|
|
@@ -16,17 +18,83 @@ function toBase64Url(bytes) {
|
|
|
16
18
|
for (const byte of bytes) b += String.fromCharCode(byte);
|
|
17
19
|
return btoa(b).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
18
20
|
}
|
|
21
|
+
async function loadOrCreateAutoIdentity(opts) {
|
|
22
|
+
if (existsSync(opts.cachePath)) {
|
|
23
|
+
try {
|
|
24
|
+
const cached = JSON.parse(readFileSync(opts.cachePath, "utf-8"));
|
|
25
|
+
if (cached?.publicKey && cached?.privateKey && cached?.serverUrl === opts.serverUrl) {
|
|
26
|
+
console.log("[abracadabra-service] using cached auto-bootstrap identity:", cached.username);
|
|
27
|
+
return cached;
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
let info = null;
|
|
33
|
+
try {
|
|
34
|
+
const r = await fetch(`${opts.serverUrl}/info`);
|
|
35
|
+
if (r.ok) info = await r.json();
|
|
36
|
+
} catch {
|
|
37
|
+
}
|
|
38
|
+
if (!info) {
|
|
39
|
+
console.warn(`[abracadabra-service] auto-bootstrap aborted: ${opts.serverUrl}/info unreachable`);
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
if (!info.registration_allowed) {
|
|
43
|
+
console.warn(
|
|
44
|
+
"[abracadabra-service] auto-bootstrap aborted: server has registration_allowed=false. Set abracadabra.service.{publicKey,privateKey} module options to use a pre-registered service account."
|
|
45
|
+
);
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
const sk = opts.ed.utils.randomPrivateKey();
|
|
49
|
+
const pk = await opts.ed.getPublicKey(sk);
|
|
50
|
+
const publicKey = toBase64Url(pk);
|
|
51
|
+
const privateKey = toBase64Url(sk);
|
|
52
|
+
const username = `runner-${publicKey.replace(/[^a-zA-Z0-9]/g, "").slice(0, 16).toLowerCase()}`;
|
|
53
|
+
console.log(`[abracadabra-service] auto-registering ${username} on ${opts.serverUrl}\u2026`);
|
|
54
|
+
const res = await fetch(`${opts.serverUrl}/auth/register`, {
|
|
55
|
+
method: "POST",
|
|
56
|
+
headers: { "Content-Type": "application/json" },
|
|
57
|
+
body: JSON.stringify({
|
|
58
|
+
username,
|
|
59
|
+
identityPublicKey: publicKey,
|
|
60
|
+
deviceName: "nuxt-runner",
|
|
61
|
+
displayName: "Nuxt Runner"
|
|
62
|
+
})
|
|
63
|
+
});
|
|
64
|
+
if (!res.ok && res.status !== 409) {
|
|
65
|
+
console.error(`[abracadabra-service] auto-register failed: ${res.status} ${await res.text()}`);
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const identity = {
|
|
69
|
+
username,
|
|
70
|
+
publicKey,
|
|
71
|
+
privateKey,
|
|
72
|
+
serverUrl: opts.serverUrl,
|
|
73
|
+
createdAt: Date.now()
|
|
74
|
+
};
|
|
75
|
+
try {
|
|
76
|
+
mkdirSync(dirname(opts.cachePath), { recursive: true });
|
|
77
|
+
writeFileSync(opts.cachePath, JSON.stringify(identity, null, 2), "utf-8");
|
|
78
|
+
console.log(`[abracadabra-service] cached identity at ${opts.cachePath}`);
|
|
79
|
+
} catch (e) {
|
|
80
|
+
console.warn("[abracadabra-service] failed to cache identity (will re-register on next boot):", e instanceof Error ? e.message : e);
|
|
81
|
+
}
|
|
82
|
+
return identity;
|
|
83
|
+
}
|
|
19
84
|
export default defineNitroPlugin(async (nitroApp) => {
|
|
20
85
|
const config = useRuntimeConfig();
|
|
21
86
|
const abraConfig = config.abracadabra;
|
|
22
87
|
const storage = useStorage();
|
|
23
88
|
initSlugMap(storage);
|
|
24
89
|
await loadPersistedSlugMap();
|
|
25
|
-
const
|
|
26
|
-
const
|
|
90
|
+
const explicitPubKey = abraConfig?.servicePublicKey ?? "";
|
|
91
|
+
const explicitPrivKey = abraConfig?.servicePrivateKey ?? "";
|
|
27
92
|
const rootDocIdOverride = abraConfig?.serviceRootDocId ?? "";
|
|
28
93
|
const disabled = abraConfig?.serviceDisabled ?? false;
|
|
29
|
-
|
|
94
|
+
const serverUrl = config.public?.abracadabra?.url;
|
|
95
|
+
if (disabled) return;
|
|
96
|
+
if (!serverUrl) {
|
|
97
|
+
console.warn("[abracadabra-service] no abracadabra.url configured \u2014 service plugin disabled");
|
|
30
98
|
return;
|
|
31
99
|
}
|
|
32
100
|
let wsp = null;
|
|
@@ -45,10 +113,20 @@ export default defineNitroPlugin(async (nitroApp) => {
|
|
|
45
113
|
const edEtc = ed.etc;
|
|
46
114
|
edEtc.sha512Sync = (...m) => sha512(edEtc.concatBytes(...m));
|
|
47
115
|
edEtc.sha512Async = (...m) => Promise.resolve(edEtc.sha512Sync(...m));
|
|
116
|
+
let pubKeyB64 = explicitPubKey;
|
|
117
|
+
let privKeyB64 = explicitPrivKey;
|
|
118
|
+
if (!pubKeyB64 || !privKeyB64) {
|
|
119
|
+
const cachePath = join(process.cwd(), ".data", "abracadabra-runner-identity.json");
|
|
120
|
+
const id = await loadOrCreateAutoIdentity({ serverUrl, cachePath, ed });
|
|
121
|
+
if (!id) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
pubKeyB64 = id.publicKey;
|
|
125
|
+
privKeyB64 = id.privateKey;
|
|
126
|
+
}
|
|
48
127
|
const privKey = fromBase64Url(privKeyB64);
|
|
49
128
|
const client = new AbracadabraClient({
|
|
50
|
-
|
|
51
|
-
url: config.public?.abracadabra?.url,
|
|
129
|
+
url: serverUrl,
|
|
52
130
|
persistAuth: false
|
|
53
131
|
});
|
|
54
132
|
await client.loginWithKey(pubKeyB64, async (challenge) => {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sanitizeSvg — modular SVG sanitizer.
|
|
3
|
+
*
|
|
4
|
+
* Tries to use DOMPurify (if the consumer installed it) for industry-grade
|
|
5
|
+
* sanitization. Falls back to a strict built-in allowlist that strips:
|
|
6
|
+
* - <script>, <foreignObject>, <iframe>, <embed>, <object>
|
|
7
|
+
* - `on*` event-handler attributes
|
|
8
|
+
* - `href` / `xlink:href` values that aren't `#anchor` (blocks `javascript:`)
|
|
9
|
+
*
|
|
10
|
+
* The built-in is intentionally conservative — it errs on the side of
|
|
11
|
+
* removing benign content rather than allowing risky content through.
|
|
12
|
+
* Apps that need more permissive sanitization (CSS classes, animations,
|
|
13
|
+
* external refs they own) should install DOMPurify and configure it.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Sanitize an SVG string. Async because DOMPurify is loaded lazily.
|
|
17
|
+
* Returns the safe SVG markup, or empty string if nothing safe remained.
|
|
18
|
+
*/
|
|
19
|
+
export declare function sanitizeSvg(svg: string): Promise<string>;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
let domPurifyCache = null;
|
|
2
|
+
let loadingPromise = null;
|
|
3
|
+
let warnedMissing = false;
|
|
4
|
+
async function tryLoadDomPurify() {
|
|
5
|
+
if (domPurifyCache) return domPurifyCache;
|
|
6
|
+
if (loadingPromise) return loadingPromise;
|
|
7
|
+
loadingPromise = (async () => {
|
|
8
|
+
try {
|
|
9
|
+
const pkg = "dompurify";
|
|
10
|
+
const mod = await import(
|
|
11
|
+
/* @vite-ignore */
|
|
12
|
+
pkg
|
|
13
|
+
);
|
|
14
|
+
domPurifyCache = mod?.default ?? mod;
|
|
15
|
+
return domPurifyCache;
|
|
16
|
+
} catch {
|
|
17
|
+
if (import.meta.dev && !warnedMissing) {
|
|
18
|
+
warnedMissing = true;
|
|
19
|
+
console.warn(
|
|
20
|
+
"[abracadabra] svg-embed: `dompurify` peer dependency not installed. Falling back to a strict built-in SVG sanitizer. Install with `pnpm add dompurify` for richer SVG support (CSS, animations, etc.)."
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
} finally {
|
|
25
|
+
loadingPromise = null;
|
|
26
|
+
}
|
|
27
|
+
})();
|
|
28
|
+
return loadingPromise;
|
|
29
|
+
}
|
|
30
|
+
const DANGEROUS_TAGS = /* @__PURE__ */ new Set([
|
|
31
|
+
"script",
|
|
32
|
+
"foreignobject",
|
|
33
|
+
"iframe",
|
|
34
|
+
"embed",
|
|
35
|
+
"object",
|
|
36
|
+
"meta",
|
|
37
|
+
"link"
|
|
38
|
+
]);
|
|
39
|
+
const ALLOWED_PROTOCOL_PREFIXES = ["#", "data:image/"];
|
|
40
|
+
function strictBuiltinSanitize(svg) {
|
|
41
|
+
const stripped = svg.replace(/<\?xml[^?]*\?>/gi, "").replace(/<!DOCTYPE[^>]*>/gi, "").trim();
|
|
42
|
+
if (!stripped) return "";
|
|
43
|
+
if (typeof DOMParser === "undefined") {
|
|
44
|
+
return "";
|
|
45
|
+
}
|
|
46
|
+
const doc = new DOMParser().parseFromString(stripped, "image/svg+xml");
|
|
47
|
+
if (doc.querySelector("parsererror")) return "";
|
|
48
|
+
const walker = doc.createTreeWalker(doc.documentElement, NodeFilter.SHOW_ELEMENT);
|
|
49
|
+
const toRemove = [];
|
|
50
|
+
let node = doc.documentElement;
|
|
51
|
+
while (node) {
|
|
52
|
+
if (DANGEROUS_TAGS.has(node.tagName.toLowerCase())) {
|
|
53
|
+
toRemove.push(node);
|
|
54
|
+
} else {
|
|
55
|
+
for (const attr of Array.from(node.attributes)) {
|
|
56
|
+
const name = attr.name.toLowerCase();
|
|
57
|
+
if (name.startsWith("on")) {
|
|
58
|
+
node.removeAttribute(attr.name);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (name === "href" || name === "xlink:href") {
|
|
62
|
+
const v = attr.value.trim().toLowerCase();
|
|
63
|
+
if (!ALLOWED_PROTOCOL_PREFIXES.some((p) => v.startsWith(p))) {
|
|
64
|
+
node.removeAttribute(attr.name);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
node = walker.nextNode();
|
|
70
|
+
}
|
|
71
|
+
for (const el of toRemove) el.remove();
|
|
72
|
+
return doc.documentElement.outerHTML;
|
|
73
|
+
}
|
|
74
|
+
export async function sanitizeSvg(svg) {
|
|
75
|
+
if (!svg) return "";
|
|
76
|
+
const purify = await tryLoadDomPurify();
|
|
77
|
+
if (purify) {
|
|
78
|
+
const stripped = svg.replace(/<\?xml[^?]*\?>/gi, "").replace(/<!DOCTYPE[^>]*>/gi, "").trim();
|
|
79
|
+
return purify.sanitize(stripped, {
|
|
80
|
+
USE_PROFILES: { svg: true, svgFilters: true },
|
|
81
|
+
ADD_TAGS: ["use", "style"],
|
|
82
|
+
FORBID_TAGS: ["script", "foreignObject", "iframe", "embed", "object"],
|
|
83
|
+
FORBID_ATTR: ["onload", "onerror", "onclick", "onmouseover", "onfocus", "onblur"]
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return strictBuiltinSanitize(svg);
|
|
87
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abraca/nuxt",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.1",
|
|
4
4
|
"description": "First-class Nuxt module for the Abracadabra CRDT collaboration platform",
|
|
5
5
|
"repository": "abracadabra/abracadabra-nuxt",
|
|
6
6
|
"license": "MIT",
|
|
@@ -26,7 +26,6 @@
|
|
|
26
26
|
"access": "public"
|
|
27
27
|
},
|
|
28
28
|
"scripts": {
|
|
29
|
-
"seed": "node --experimental-strip-types scripts/seed-playground.ts",
|
|
30
29
|
"prepack": "nuxt-module-build build",
|
|
31
30
|
"dev": "npm run dev:prepare && nuxt dev playground",
|
|
32
31
|
"dev:build": "nuxt build playground",
|
|
@@ -97,7 +96,7 @@
|
|
|
97
96
|
}
|
|
98
97
|
},
|
|
99
98
|
"devDependencies": {
|
|
100
|
-
"@abraca/dabra": "^2.0.
|
|
99
|
+
"@abraca/dabra": "^2.0.1",
|
|
101
100
|
"@iconify-json/lucide": "^1.2.105",
|
|
102
101
|
"@noble/ed25519": "~2.3.0",
|
|
103
102
|
"@noble/hashes": "^1.8.0",
|