@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 CHANGED
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": ">=4.0.0"
6
6
  },
7
- "version": "2.0.0",
7
+ "version": "2.0.1",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.2",
10
10
  "unbuild": "3.6.1"
@@ -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 pubKeyB64 = abraConfig?.servicePublicKey ?? "";
26
- const privKeyB64 = abraConfig?.servicePrivateKey ?? "";
90
+ const explicitPubKey = abraConfig?.servicePublicKey ?? "";
91
+ const explicitPrivKey = abraConfig?.servicePrivateKey ?? "";
27
92
  const rootDocIdOverride = abraConfig?.serviceRootDocId ?? "";
28
93
  const disabled = abraConfig?.serviceDisabled ?? false;
29
- if (disabled || !pubKeyB64 || !privKeyB64) {
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
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Nuxt runtime config augmentation not resolved in Nitro
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.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.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",