@zooid/web 0.5.1 → 0.6.0

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.
Files changed (40) hide show
  1. package/dist/assets/index-7P1i28a8.js +66 -0
  2. package/dist/assets/index-BeqmFlCW.css +1 -0
  3. package/dist/assets/json-editor-Cvlnnf1Q.css +1 -0
  4. package/dist/assets/json-editor-iJIPDKxB.js +84 -0
  5. package/dist/index.html +2 -2
  6. package/package.json +11 -5
  7. package/src/App.svelte +678 -0
  8. package/src/app.css +178 -0
  9. package/src/lib/api.ts +78 -0
  10. package/src/lib/components/admin-dropdown.svelte +89 -0
  11. package/src/lib/components/auth-modal.svelte +114 -0
  12. package/src/lib/components/avatar.svelte +29 -0
  13. package/src/lib/components/channel-header.svelte +73 -0
  14. package/src/lib/components/create-channel-modal.svelte +137 -0
  15. package/src/lib/components/edit-channel-modal.svelte +234 -0
  16. package/src/lib/components/event-card.svelte +221 -0
  17. package/src/lib/components/event-feed.svelte +50 -0
  18. package/src/lib/components/homepage.svelte +86 -0
  19. package/src/lib/components/json-editor.svelte +57 -0
  20. package/src/lib/components/keys-and-tokens-page.svelte +216 -0
  21. package/src/lib/components/keys-modal.svelte +120 -0
  22. package/src/lib/components/message-bar.svelte +290 -0
  23. package/src/lib/components/mint-token-modal.svelte +141 -0
  24. package/src/lib/components/ref-link.svelte +33 -0
  25. package/src/lib/components/ref-side-sheet.svelte +105 -0
  26. package/src/lib/components/server-config-modal.svelte +141 -0
  27. package/src/lib/components/server-config-page.svelte +130 -0
  28. package/src/lib/components/sidebar.svelte +144 -0
  29. package/src/lib/components/status-bar.svelte +40 -0
  30. package/src/lib/pretty-json.test.ts +200 -0
  31. package/src/lib/pretty-json.ts +79 -0
  32. package/src/lib/time.ts +38 -0
  33. package/src/lib/zooid-uri.test.ts +102 -0
  34. package/src/lib/zooid-uri.ts +74 -0
  35. package/src/main.ts +7 -0
  36. package/src/vite-env.d.ts +2 -0
  37. package/dist/assets/index-BNtiKsjP.js +0 -66
  38. package/dist/assets/index-C_9lWQjz.css +0 -1
  39. package/dist/assets/json-editor-De2WPcl1.js +0 -84
  40. package/dist/assets/json-editor-DfH04Znl.css +0 -1
@@ -0,0 +1,144 @@
1
+ <script lang="ts">
2
+ import type { ChannelInfo } from '../api';
3
+ import AdminDropdown from './admin-dropdown.svelte';
4
+
5
+ import type { Component } from 'svelte';
6
+
7
+ interface SettingsPageExtension {
8
+ slug: string;
9
+ label: string;
10
+ icon?: Component;
11
+ }
12
+
13
+ let {
14
+ channels,
15
+ selectedId,
16
+ serverName,
17
+ hasAuth,
18
+ isAdmin,
19
+ status,
20
+ pollInterval,
21
+ onSelect,
22
+ onAuthClick,
23
+ onClose,
24
+ onServerConfig,
25
+ onKeysAndTokens,
26
+ onCreateChannel,
27
+ extensionSettingsPages = [],
28
+ onExtensionSettings,
29
+ }: {
30
+ channels: ChannelInfo[];
31
+ selectedId: string | null;
32
+ serverName: string;
33
+ hasAuth: boolean;
34
+ isAdmin: boolean;
35
+ status: 'connected' | 'polling' | 'reconnecting' | 'error' | 'idle' | 'loading';
36
+ pollInterval: number;
37
+ onSelect: (id: string) => void;
38
+ onAuthClick: () => void;
39
+ onClose?: () => void;
40
+ onServerConfig: () => void;
41
+ onKeysAndTokens: () => void;
42
+ onCreateChannel: () => void;
43
+ extensionSettingsPages?: SettingsPageExtension[];
44
+ onExtensionSettings?: (slug: string) => void;
45
+ } = $props();
46
+
47
+ const statusColor: Record<string, string> = {
48
+ connected: 'bg-primary',
49
+ polling: 'bg-primary',
50
+ reconnecting: 'bg-yellow-500',
51
+ error: 'bg-destructive',
52
+ idle: 'bg-muted-foreground',
53
+ loading: 'bg-muted-foreground',
54
+ };
55
+
56
+ const statusLabel: Record<string, string> = {
57
+ connected: 'Connected',
58
+ polling: 'Connected',
59
+ reconnecting: 'Reconnecting...',
60
+ error: 'Error',
61
+ idle: 'Idle',
62
+ loading: 'Loading...',
63
+ };
64
+ </script>
65
+
66
+ <div class="flex flex-col h-full w-full bg-[oklch(0.13_0_0)] border-r border-border">
67
+ <!-- Server header -->
68
+ <div class="flex items-center justify-between px-3 h-12 border-b border-border shrink-0">
69
+ {#if isAdmin}
70
+ <AdminDropdown {serverName} {onServerConfig} {onKeysAndTokens} {extensionSettingsPages} {onExtensionSettings} />
71
+ {:else}
72
+ <span class="font-semibold text-sm truncate">{serverName}</span>
73
+ {/if}
74
+ {#if onClose}
75
+ <button
76
+ onclick={onClose}
77
+ class="p-1.5 rounded hover:bg-secondary transition-colors md:hidden"
78
+ aria-label="Close sidebar"
79
+ >
80
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
81
+ </button>
82
+ {/if}
83
+ </div>
84
+
85
+ <!-- Channel list -->
86
+ <div class="flex-1 overflow-y-auto py-2">
87
+ <div class="px-3 mb-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Channels</div>
88
+ {#each channels as ch (ch.id)}
89
+ <button
90
+ class="w-full text-left px-3 py-1.5 flex items-center gap-2 text-sm transition-colors
91
+ {selectedId === ch.id ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'}"
92
+ onclick={() => onSelect(ch.id)}
93
+ >
94
+ <span class="text-muted-foreground/60 shrink-0">#</span>
95
+ <span class="truncate flex-1">{ch.name}</span>
96
+ {#if !ch.is_public}
97
+ <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground/40 shrink-0"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
98
+ {/if}
99
+ </button>
100
+ {/each}
101
+
102
+ {#if channels.length === 0 && !isAdmin}
103
+ <div class="px-3 py-4 text-xs text-muted-foreground/60 text-center">
104
+ No channels yet
105
+ </div>
106
+ {/if}
107
+
108
+ {#if isAdmin}
109
+ <button
110
+ class="w-full text-left px-3 py-1.5 flex items-center gap-2 text-sm text-muted-foreground/50 hover:text-muted-foreground hover:bg-secondary/50 transition-colors"
111
+ onclick={onCreateChannel}
112
+ >
113
+ <span class="shrink-0">+</span>
114
+ <span>Create channel</span>
115
+ </button>
116
+ {/if}
117
+ </div>
118
+
119
+ <!-- Profile -->
120
+ <button
121
+ onclick={onAuthClick}
122
+ class="border-t border-border px-3 h-12 flex items-center gap-2 w-full hover:bg-secondary/50 transition-colors shrink-0"
123
+ title={hasAuth ? 'Authenticated' : 'Sign in'}
124
+ >
125
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={hasAuth ? 'text-primary' : 'text-muted-foreground'}>
126
+ <circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 0 0-16 0"/>
127
+ </svg>
128
+ <span class="text-xs {hasAuth ? 'text-foreground' : 'text-muted-foreground'}">{hasAuth ? 'Signed in' : 'Sign in'}</span>
129
+ </button>
130
+
131
+ <!-- Status + powered by -->
132
+ <div class="border-t border-border px-3 py-2 flex items-center justify-between text-[10px] text-muted-foreground/50">
133
+ <div class="flex items-center gap-1.5">
134
+ <span class={`inline-block w-1.5 h-1.5 rounded-full ${statusColor[status]}`}></span>
135
+ <span>{statusLabel[status]}</span>
136
+ {#if status === 'connected'}
137
+ <span class="text-muted-foreground/30">WS</span>
138
+ {:else if status === 'polling'}
139
+ <span class="text-muted-foreground/30">{pollInterval}s</span>
140
+ {/if}
141
+ </div>
142
+ <a href="https://zooid.dev" class="underline hover:text-muted-foreground/70">Zooid</a>
143
+ </div>
144
+ </div>
@@ -0,0 +1,40 @@
1
+ <script lang="ts">
2
+ let {
3
+ status,
4
+ pollInterval,
5
+ }: {
6
+ status: 'connected' | 'polling' | 'reconnecting' | 'error' | 'idle' | 'loading';
7
+ pollInterval: number;
8
+ } = $props();
9
+
10
+ const statusText: Record<string, string> = {
11
+ connected: 'Connected',
12
+ polling: 'Connected',
13
+ reconnecting: 'Reconnecting...',
14
+ error: 'Error',
15
+ idle: 'Idle',
16
+ loading: 'Loading...',
17
+ };
18
+
19
+ const statusColor: Record<string, string> = {
20
+ connected: 'bg-primary',
21
+ polling: 'bg-primary',
22
+ reconnecting: 'bg-yellow-500',
23
+ error: 'bg-destructive',
24
+ idle: 'bg-muted-foreground',
25
+ loading: 'bg-muted-foreground',
26
+ };
27
+ </script>
28
+
29
+ <div class="flex items-center justify-between px-4 py-2 pb-[calc(0.5rem+env(safe-area-inset-bottom))] text-xs text-muted-foreground border-t border-border">
30
+ <div class="flex items-center gap-2">
31
+ <span class={`inline-block w-1.5 h-1.5 rounded-full ${statusColor[status]}`}></span>
32
+ <span>{statusText[status]}</span>
33
+ {#if status === 'connected'}
34
+ <span class="text-muted-foreground/60">WebSocket</span>
35
+ {:else if status === 'polling'}
36
+ <span class="text-muted-foreground/60">poll every {pollInterval}s</span>
37
+ {/if}
38
+ </div>
39
+ <span class="text-muted-foreground/40"><a href="https://zooid.dev" class="underline hover:text-muted-foreground">Powered by Zooid</a></span>
40
+ </div>
@@ -0,0 +1,200 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { looksLikeMarkdown, parsePretty, renderMarkdown } from './pretty-json';
3
+
4
+ describe('parsePretty', () => {
5
+ it('parses flat object into text nodes', () => {
6
+ const raw = JSON.stringify({ title: 'Hello', count: 42 });
7
+ const nodes = parsePretty(raw);
8
+ expect(nodes).toEqual([
9
+ {
10
+ kind: 'text',
11
+ key: 'title',
12
+ value: 'Hello',
13
+ markdown: false,
14
+ multiline: false,
15
+ },
16
+ {
17
+ kind: 'text',
18
+ key: 'count',
19
+ value: '42',
20
+ markdown: false,
21
+ multiline: false,
22
+ },
23
+ ]);
24
+ });
25
+
26
+ it('preserves newlines in string values and marks as multiline', () => {
27
+ const raw = JSON.stringify({ body: 'line one\nline two\nline three' });
28
+ const nodes = parsePretty(raw)!;
29
+ expect(nodes[0].kind).toBe('text');
30
+ const text = nodes[0] as Extract<(typeof nodes)[0], { kind: 'text' }>;
31
+ expect(text.value).toBe('line one\nline two\nline three');
32
+ expect(text.multiline).toBe(true);
33
+ expect(text.value.split('\n')).toEqual([
34
+ 'line one',
35
+ 'line two',
36
+ 'line three',
37
+ ]);
38
+ });
39
+
40
+ it('marks single-line strings as not multiline', () => {
41
+ const raw = JSON.stringify({ title: 'no newlines here' });
42
+ const nodes = parsePretty(raw)!;
43
+ const text = nodes[0] as Extract<(typeof nodes)[0], { kind: 'text' }>;
44
+ expect(text.multiline).toBe(false);
45
+ });
46
+
47
+ it('parses nested object as group node', () => {
48
+ const raw = JSON.stringify({ meta: { author: 'bot', version: 1 } });
49
+ const nodes = parsePretty(raw)!;
50
+ expect(nodes).toHaveLength(1);
51
+ expect(nodes[0].kind).toBe('group');
52
+ const group = nodes[0] as Extract<(typeof nodes)[0], { kind: 'group' }>;
53
+ expect(group.key).toBe('meta');
54
+ expect(group.children).toEqual([
55
+ {
56
+ kind: 'text',
57
+ key: 'author',
58
+ value: 'bot',
59
+ markdown: false,
60
+ multiline: false,
61
+ },
62
+ {
63
+ kind: 'text',
64
+ key: 'version',
65
+ value: '1',
66
+ markdown: false,
67
+ multiline: false,
68
+ },
69
+ ]);
70
+ });
71
+
72
+ it('parses array of objects as list node', () => {
73
+ const raw = JSON.stringify({
74
+ posts: [
75
+ { title: 'A', score: 10 },
76
+ { title: 'B', score: 20 },
77
+ ],
78
+ });
79
+ const nodes = parsePretty(raw)!;
80
+ expect(nodes).toHaveLength(1);
81
+ expect(nodes[0].kind).toBe('list');
82
+ const list = nodes[0] as Extract<(typeof nodes)[0], { kind: 'list' }>;
83
+ expect(list.items).toHaveLength(2);
84
+ expect(list.items[0][0]).toEqual({
85
+ kind: 'text',
86
+ key: 'title',
87
+ value: 'A',
88
+ markdown: false,
89
+ multiline: false,
90
+ });
91
+ });
92
+
93
+ it('detects markdown in string values', () => {
94
+ const raw = JSON.stringify({ summary: '**bold** and *italic*' });
95
+ const nodes = parsePretty(raw)!;
96
+ const text = nodes[0] as Extract<(typeof nodes)[0], { kind: 'text' }>;
97
+ expect(text.markdown).toBe(true);
98
+ });
99
+
100
+ it('returns null for non-object JSON', () => {
101
+ expect(parsePretty('"hello"')).toBeNull();
102
+ expect(parsePretty('[1,2,3]')).toBeNull();
103
+ expect(parsePretty('42')).toBeNull();
104
+ });
105
+
106
+ it('returns null for invalid JSON', () => {
107
+ expect(parsePretty('not json')).toBeNull();
108
+ });
109
+ });
110
+
111
+ describe('ref convention', () => {
112
+ it('should mark ref field with kind "ref"', () => {
113
+ const nodes = parsePretty(
114
+ JSON.stringify({
115
+ body: 'Trade executed',
116
+ ref: 'zooid:signals/01HWXYZ123456789012345A',
117
+ }),
118
+ );
119
+
120
+ const refNode = nodes!.find((n) => n.key === 'ref');
121
+ expect(refNode).toBeDefined();
122
+ expect(refNode!.kind).toBe('ref');
123
+ expect((refNode as { value: string }).value).toBe(
124
+ 'zooid:signals/01HWXYZ123456789012345A',
125
+ );
126
+ });
127
+
128
+ it('should mark ref with https: scheme', () => {
129
+ const nodes = parsePretty(
130
+ JSON.stringify({
131
+ ref: 'https://example.com/resource',
132
+ }),
133
+ );
134
+
135
+ const refNode = nodes!.find((n) => n.key === 'ref');
136
+ expect(refNode!.kind).toBe('ref');
137
+ });
138
+
139
+ it('should not mark non-ref string fields as ref', () => {
140
+ const nodes = parsePretty(
141
+ JSON.stringify({
142
+ body: 'zooid:signals/01HWXYZ123456789012345A',
143
+ symbol: 'AAPL',
144
+ }),
145
+ );
146
+
147
+ const bodyNode = nodes!.find((n) => n.key === 'body');
148
+ expect(bodyNode!.kind).toBe('text');
149
+ });
150
+ });
151
+
152
+ describe('looksLikeMarkdown', () => {
153
+ it('detects bold syntax', () => {
154
+ expect(looksLikeMarkdown('some **bold** text')).toBe(true);
155
+ });
156
+
157
+ it('detects italic syntax', () => {
158
+ expect(looksLikeMarkdown('some *italic* text')).toBe(true);
159
+ });
160
+
161
+ it('detects heading syntax', () => {
162
+ expect(looksLikeMarkdown('# Heading')).toBe(true);
163
+ });
164
+
165
+ it('detects link syntax', () => {
166
+ expect(looksLikeMarkdown('see [link](url)')).toBe(true);
167
+ });
168
+
169
+ it('detects code backticks', () => {
170
+ expect(looksLikeMarkdown('use `code` here')).toBe(true);
171
+ });
172
+
173
+ it('does not flag plain text', () => {
174
+ expect(looksLikeMarkdown('just a normal sentence')).toBe(false);
175
+ });
176
+
177
+ it('does not flag URLs without markdown', () => {
178
+ expect(looksLikeMarkdown('https://example.com/path/to/page')).toBe(false);
179
+ });
180
+ });
181
+
182
+ describe('renderMarkdown', () => {
183
+ it('renders bold text', () => {
184
+ const html = renderMarkdown('**bold**');
185
+ expect(html).toContain('<strong>bold</strong>');
186
+ });
187
+
188
+ it('renders bullet lists', () => {
189
+ const html = renderMarkdown('* item one\n* item two');
190
+ expect(html).toContain('<li>');
191
+ expect(html).toContain('item one');
192
+ expect(html).toContain('item two');
193
+ });
194
+
195
+ it('renders links', () => {
196
+ const html = renderMarkdown('[click](https://example.com)');
197
+ expect(html).toContain('<a');
198
+ expect(html).toContain('https://example.com');
199
+ });
200
+ });
@@ -0,0 +1,79 @@
1
+ import { marked } from 'marked';
2
+
3
+ marked.setOptions({ breaks: true, gfm: true });
4
+
5
+ export type PrettyNode =
6
+ | {
7
+ kind: 'text';
8
+ key: string;
9
+ value: string;
10
+ markdown: boolean;
11
+ multiline: boolean;
12
+ }
13
+ | { kind: 'ref'; key: string; value: string }
14
+ | { kind: 'group'; key: string; children: PrettyNode[] }
15
+ | { kind: 'list'; key: string; items: PrettyNode[][] };
16
+
17
+ const MD_HINT = /[*_#\[`~>]|\n[-*] |\n\d+\. /;
18
+
19
+ export function looksLikeMarkdown(s: string): boolean {
20
+ return MD_HINT.test(s);
21
+ }
22
+
23
+ export function renderMarkdown(s: string): string {
24
+ return marked.parse(s, { async: false }) as string;
25
+ }
26
+
27
+ export function parsePretty(raw: string): PrettyNode[] | null {
28
+ try {
29
+ const obj = JSON.parse(raw);
30
+ if (typeof obj !== 'object' || obj === null || Array.isArray(obj))
31
+ return null;
32
+ return objectToNodes(obj);
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ export function objectToNodes(obj: Record<string, unknown>): PrettyNode[] {
39
+ return Object.entries(obj).map(([key, value]) => {
40
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
41
+ return {
42
+ kind: 'group' as const,
43
+ key,
44
+ children: objectToNodes(value as Record<string, unknown>),
45
+ };
46
+ }
47
+ if (Array.isArray(value) && value.length > 0) {
48
+ return {
49
+ kind: 'list' as const,
50
+ key,
51
+ items: value.map((item) =>
52
+ typeof item === 'object' && item !== null && !Array.isArray(item)
53
+ ? objectToNodes(item as Record<string, unknown>)
54
+ : [
55
+ {
56
+ kind: 'text' as const,
57
+ key: '',
58
+ value: String(item),
59
+ markdown: false,
60
+ multiline: false,
61
+ },
62
+ ],
63
+ ),
64
+ };
65
+ }
66
+ const str = typeof value === 'string' ? value : JSON.stringify(value);
67
+ if (key === 'ref' && typeof value === 'string') {
68
+ return { kind: 'ref' as const, key, value: str };
69
+ }
70
+ const multiline = str.includes('\n');
71
+ return {
72
+ kind: 'text' as const,
73
+ key,
74
+ value: str,
75
+ markdown: looksLikeMarkdown(str),
76
+ multiline,
77
+ };
78
+ });
79
+ }
@@ -0,0 +1,38 @@
1
+ export function formatFull(iso: string): string {
2
+ const hasOffset = /Z|[+-]\d{2}:?\d{2}$/.test(iso);
3
+ const ts = hasOffset ? iso : iso + 'Z';
4
+ return new Date(ts).toLocaleString();
5
+ }
6
+
7
+ const ULID_CHARS = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
8
+
9
+ /** Extract millisecond timestamp from a ULID (first 10 chars encode 48-bit time). */
10
+ export function ulidTimestamp(ulid: string): number {
11
+ let time = 0;
12
+ for (let i = 0; i < 10; i++) {
13
+ time = time * 32 + ULID_CHARS.indexOf(ulid[i].toUpperCase());
14
+ }
15
+ return time;
16
+ }
17
+
18
+ export function formatRelative(iso: string): string {
19
+ const hasOffset = /Z|[+-]\d{2}:?\d{2}$/.test(iso);
20
+ const ts = hasOffset ? iso : iso + 'Z';
21
+ return formatRelativeMs(new Date(ts).getTime());
22
+ }
23
+
24
+ export function formatRelativeUlid(ulid: string): string {
25
+ return formatRelativeMs(ulidTimestamp(ulid));
26
+ }
27
+
28
+ function formatRelativeMs(ms: number): string {
29
+ const diff = Date.now() - ms;
30
+ const seconds = Math.floor(diff / 1000);
31
+ if (seconds < 60) return `${seconds}s ago`;
32
+ const minutes = Math.floor(seconds / 60);
33
+ if (minutes < 60) return `${minutes}m ago`;
34
+ const hours = Math.floor(minutes / 60);
35
+ if (hours < 24) return `${hours}h ago`;
36
+ const days = Math.floor(hours / 24);
37
+ return `${days}d ago`;
38
+ }
@@ -0,0 +1,102 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseZooidUri, resolveRef } from './zooid-uri';
3
+
4
+ describe('parseZooidUri', () => {
5
+ it('should parse same-server URI', () => {
6
+ const result = parseZooidUri('zooid:signals/01HWXYZ123456789012345A');
7
+ expect(result).toEqual({
8
+ host: null,
9
+ channel: 'signals',
10
+ eventId: '01HWXYZ123456789012345A',
11
+ });
12
+ });
13
+
14
+ it('should parse cross-server URI', () => {
15
+ const result = parseZooidUri(
16
+ 'zooid:ori.zoon.eco/signals/01HWXYZ123456789012345A',
17
+ );
18
+ expect(result).toEqual({
19
+ host: 'ori.zoon.eco',
20
+ channel: 'signals',
21
+ eventId: '01HWXYZ123456789012345A',
22
+ });
23
+ });
24
+
25
+ it('should distinguish host from channel by dot presence', () => {
26
+ const withDot = parseZooidUri(
27
+ 'zooid:my.server/chan/01HWXYZ123456789012345A',
28
+ );
29
+ expect(withDot?.host).toBe('my.server');
30
+
31
+ const withoutDot = parseZooidUri(
32
+ 'zooid:my-channel/01HWXYZ123456789012345A',
33
+ );
34
+ expect(withoutDot?.host).toBeNull();
35
+ expect(withoutDot?.channel).toBe('my-channel');
36
+ });
37
+
38
+ it('should return null for invalid URIs', () => {
39
+ expect(parseZooidUri('not-a-uri')).toBeNull();
40
+ expect(parseZooidUri('zooid:')).toBeNull();
41
+ expect(parseZooidUri('zooid:only-one-part')).toBeNull();
42
+ expect(parseZooidUri('https://example.com')).toBeNull();
43
+ });
44
+ });
45
+
46
+ describe('resolveRef', () => {
47
+ const currentServer = 'https://demo.zoon.eco';
48
+
49
+ it('should resolve zooid: same-server to in-app link', () => {
50
+ const result = resolveRef(
51
+ 'zooid:signals/01HWXYZ123456789012345A',
52
+ currentServer,
53
+ );
54
+ expect(result).toEqual({
55
+ type: 'zooid',
56
+ label: 'signals/01HWXYZ123456789012345A',
57
+ channel: 'signals',
58
+ eventId: '01HWXYZ123456789012345A',
59
+ href: null,
60
+ });
61
+ });
62
+
63
+ it('should resolve zooid: cross-server to external link', () => {
64
+ const result = resolveRef(
65
+ 'zooid:ori.zoon.eco/signals/01HWXYZ123456789012345A',
66
+ currentServer,
67
+ );
68
+ expect(result).toEqual({
69
+ type: 'zooid-external',
70
+ label: 'ori.zoon.eco/signals/01HWXYZ123456789012345A',
71
+ channel: 'signals',
72
+ eventId: '01HWXYZ123456789012345A',
73
+ href: 'https://ori.zoon.eco/api/v1/channels/signals/events/01HWXYZ123456789012345A',
74
+ });
75
+ });
76
+
77
+ it('should resolve https: as external link', () => {
78
+ const result = resolveRef('https://example.com/docs', currentServer);
79
+ expect(result).toEqual({
80
+ type: 'external',
81
+ label: 'https://example.com/docs',
82
+ href: 'https://example.com/docs',
83
+ });
84
+ });
85
+
86
+ it('should resolve http: as external link', () => {
87
+ const result = resolveRef('http://localhost:8787/test', currentServer);
88
+ expect(result).toEqual({
89
+ type: 'external',
90
+ label: 'http://localhost:8787/test',
91
+ href: 'http://localhost:8787/test',
92
+ });
93
+ });
94
+
95
+ it('should resolve unknown schemes as plain text', () => {
96
+ const result = resolveRef('ftp://files.example.com', currentServer);
97
+ expect(result).toEqual({
98
+ type: 'text',
99
+ label: 'ftp://files.example.com',
100
+ });
101
+ });
102
+ });
@@ -0,0 +1,74 @@
1
+ export interface ZooidUri {
2
+ host: string | null;
3
+ channel: string;
4
+ eventId: string;
5
+ }
6
+
7
+ export type ResolvedRef =
8
+ | {
9
+ type: 'zooid';
10
+ label: string;
11
+ channel: string;
12
+ eventId: string;
13
+ href: null;
14
+ }
15
+ | {
16
+ type: 'zooid-external';
17
+ label: string;
18
+ channel: string;
19
+ eventId: string;
20
+ href: string;
21
+ }
22
+ | { type: 'external'; label: string; href: string }
23
+ | { type: 'text'; label: string };
24
+
25
+ export function parseZooidUri(uri: string): ZooidUri | null {
26
+ if (!uri.startsWith('zooid:')) return null;
27
+
28
+ const path = uri.slice('zooid:'.length);
29
+ if (!path) return null;
30
+ const segments = path.split('/');
31
+
32
+ if (segments.length === 2) {
33
+ const [channel, eventId] = segments;
34
+ if (!channel || !eventId) return null;
35
+ return { host: null, channel, eventId };
36
+ }
37
+
38
+ if (segments.length === 3) {
39
+ const [host, channel, eventId] = segments;
40
+ if (!host || !channel || !eventId) return null;
41
+ if (!host.includes('.')) return null;
42
+ return { host, channel, eventId };
43
+ }
44
+
45
+ return null;
46
+ }
47
+
48
+ export function resolveRef(ref: string, currentServer: string): ResolvedRef {
49
+ const zooid = parseZooidUri(ref);
50
+ if (zooid) {
51
+ if (zooid.host) {
52
+ return {
53
+ type: 'zooid-external',
54
+ label: `${zooid.host}/${zooid.channel}/${zooid.eventId}`,
55
+ channel: zooid.channel,
56
+ eventId: zooid.eventId,
57
+ href: `https://${zooid.host}/api/v1/channels/${zooid.channel}/events/${zooid.eventId}`,
58
+ };
59
+ }
60
+ return {
61
+ type: 'zooid',
62
+ label: `${zooid.channel}/${zooid.eventId}`,
63
+ channel: zooid.channel,
64
+ eventId: zooid.eventId,
65
+ href: null,
66
+ };
67
+ }
68
+
69
+ if (ref.startsWith('https://') || ref.startsWith('http://')) {
70
+ return { type: 'external', label: ref, href: ref };
71
+ }
72
+
73
+ return { type: 'text', label: ref };
74
+ }
package/src/main.ts ADDED
@@ -0,0 +1,7 @@
1
+ import './app.css';
2
+ import App from './App.svelte';
3
+ import { mount } from 'svelte';
4
+
5
+ const app = mount(App, { target: document.getElementById('app')! });
6
+
7
+ export default app;
@@ -0,0 +1,2 @@
1
+ /// <reference types="svelte" />
2
+ /// <reference types="vite/client" />