tangled
alpha
login
or
join now
flo-bit.dev
/
blento
22
fork
atom
your personal website on atproto - mirror
blento.app
22
fork
atom
overview
issues
1
pulls
pipelines
blog embeds
Florian
2 weeks ago
469ce4fc
522440a0
+401
-6
9 changed files
expand all
collapse all
unified
split
src
lib
components
rich-text-editor
RichTextEditor.svelte
embed
EmbedNode.ts
EmbedNodeComponent.svelte
embeds
BlogContent.svelte
YouTubeEmbed.svelte
index.ts
types.ts
youtube.ts
routes
[[actor=actor]]
blog
[rkey]
+page.svelte
+12
-2
src/lib/components/rich-text-editor/RichTextEditor.svelte
···
19
19
import './code.css';
20
20
import { cn } from '@foxui/core';
21
21
import { ImageUploadNode } from './image-upload/ImageUploadNode';
22
22
+
import { EmbedNode } from './embed/EmbedNode';
22
23
import { Transaction } from '@tiptap/pm/state';
23
24
24
25
let {
···
129
130
!editor.view.state.selection.empty &&
130
131
!editor.isActive('codeBlock') &&
131
132
!editor.isActive('link') &&
132
132
-
!editor.isActive('imageUpload')
133
133
+
!editor.isActive('imageUpload') &&
134
134
+
!editor.isActive('embed')
133
135
);
134
136
},
135
137
pluginKey: 'bubble-menu-marks'
···
158
160
}),
159
161
Typography.configure(),
160
162
Markdown.configure(),
161
161
-
ImageUploadNode.configure({})
163
163
+
ImageUploadNode.configure({}),
164
164
+
EmbedNode.configure({})
162
165
];
163
166
164
167
editor = new Editor({
···
389
392
:global(div[data-type='image-upload']) {
390
393
&.ProseMirror-selectednode {
391
394
outline: 3px solid var(--color-accent-500);
395
395
+
}
396
396
+
}
397
397
+
398
398
+
:global(div[data-type='embed']) {
399
399
+
&.ProseMirror-selectednode {
400
400
+
outline: 3px solid var(--color-accent-500);
401
401
+
border-radius: 0.75rem;
392
402
}
393
403
}
394
404
+155
src/lib/components/rich-text-editor/embed/EmbedNode.ts
···
1
1
+
import { Node, mergeAttributes } from '@tiptap/core';
2
2
+
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
3
3
+
import { matchEmbed } from '$lib/embeds';
4
4
+
import EmbedNodeComponent from './EmbedNodeComponent.svelte';
5
5
+
import { Plugin, PluginKey } from '@tiptap/pm/state';
6
6
+
7
7
+
declare module '@tiptap/core' {
8
8
+
interface Commands<ReturnType> {
9
9
+
embed: {
10
10
+
setEmbed: (options: { url: string }) => ReturnType;
11
11
+
};
12
12
+
}
13
13
+
}
14
14
+
15
15
+
export const EmbedNode = Node.create({
16
16
+
name: 'embed',
17
17
+
group: 'block',
18
18
+
atom: true,
19
19
+
draggable: true,
20
20
+
selectable: true,
21
21
+
inline: false,
22
22
+
23
23
+
addAttributes() {
24
24
+
return {
25
25
+
url: { default: null },
26
26
+
embedType: { default: null },
27
27
+
embedData: { default: null }
28
28
+
};
29
29
+
},
30
30
+
31
31
+
addCommands() {
32
32
+
return {
33
33
+
setEmbed:
34
34
+
({ url }) =>
35
35
+
({ commands }) => {
36
36
+
const match = matchEmbed(url);
37
37
+
if (!match) return false;
38
38
+
return commands.insertContent({
39
39
+
type: this.name,
40
40
+
attrs: {
41
41
+
url,
42
42
+
embedType: match.type,
43
43
+
embedData: JSON.stringify(match)
44
44
+
}
45
45
+
});
46
46
+
}
47
47
+
};
48
48
+
},
49
49
+
50
50
+
parseHTML() {
51
51
+
return [{ tag: 'div[data-type="embed"]' }];
52
52
+
},
53
53
+
54
54
+
renderHTML({ HTMLAttributes }) {
55
55
+
return ['div', mergeAttributes({ 'data-type': 'embed' }, HTMLAttributes)];
56
56
+
},
57
57
+
58
58
+
addNodeView() {
59
59
+
return SvelteNodeViewRenderer(EmbedNodeComponent);
60
60
+
},
61
61
+
62
62
+
// Markdown integration for @tiptap/markdown
63
63
+
// These fields are read by the Markdown extension via getExtensionField()
64
64
+
65
65
+
markdownTokenName: 'embedUrl',
66
66
+
67
67
+
markdownTokenizer: {
68
68
+
name: 'embedUrl',
69
69
+
level: 'block' as const,
70
70
+
tokenize(src: string) {
71
71
+
const match = src.match(/^(https?:\/\/\S+)\s*(?:\n|$)/);
72
72
+
if (!match) return undefined;
73
73
+
74
74
+
const url = match[1];
75
75
+
const embedMatch = matchEmbed(url);
76
76
+
if (!embedMatch) return undefined;
77
77
+
78
78
+
return {
79
79
+
type: 'embedUrl',
80
80
+
raw: match[0],
81
81
+
url,
82
82
+
embedType: embedMatch.type,
83
83
+
embedData: embedMatch
84
84
+
};
85
85
+
}
86
86
+
},
87
87
+
88
88
+
parseMarkdown(
89
89
+
token: { url: string; embedType: string; embedData: Record<string, unknown> },
90
90
+
helpers: { createNode: (type: string, attrs: Record<string, unknown>) => unknown }
91
91
+
) {
92
92
+
return helpers.createNode('embed', {
93
93
+
url: token.url,
94
94
+
embedType: token.embedType,
95
95
+
embedData: JSON.stringify(token.embedData)
96
96
+
});
97
97
+
},
98
98
+
99
99
+
renderMarkdown(node: { attrs?: { url?: string } }) {
100
100
+
return (node.attrs?.url ?? '') + '\n';
101
101
+
},
102
102
+
103
103
+
addProseMirrorPlugins() {
104
104
+
const nodeType = this.type;
105
105
+
106
106
+
return [
107
107
+
new Plugin({
108
108
+
key: new PluginKey('embed-auto-detect'),
109
109
+
appendTransaction(transactions, _oldState, newState) {
110
110
+
const docChanged = transactions.some((tr) => tr.docChanged);
111
111
+
if (!docChanged) return null;
112
112
+
113
113
+
const tr = newState.tr;
114
114
+
let modified = false;
115
115
+
116
116
+
newState.doc.descendants((node, pos) => {
117
117
+
if (modified) return false;
118
118
+
if (node.type.name !== 'paragraph') return;
119
119
+
if (node.childCount !== 1) return;
120
120
+
121
121
+
const child = node.firstChild;
122
122
+
if (!child || !child.isText) return;
123
123
+
124
124
+
const text = child.text?.trim();
125
125
+
if (!text) return;
126
126
+
127
127
+
// Check if the text (possibly with a link mark) is a bare URL
128
128
+
const urlMatch = text.match(/^(https?:\/\/\S+)$/);
129
129
+
if (!urlMatch) return;
130
130
+
131
131
+
const url = urlMatch[1];
132
132
+
const embed = matchEmbed(url);
133
133
+
if (!embed) return;
134
134
+
135
135
+
// Only convert when cursor is NOT inside this paragraph
136
136
+
const sel = newState.selection;
137
137
+
const nodeEnd = pos + node.nodeSize;
138
138
+
if (sel.from >= pos && sel.from <= nodeEnd) return;
139
139
+
140
140
+
const embedNode = nodeType.create({
141
141
+
url,
142
142
+
embedType: embed.type,
143
143
+
embedData: JSON.stringify(embed)
144
144
+
});
145
145
+
146
146
+
tr.replaceWith(pos, nodeEnd, embedNode);
147
147
+
modified = true;
148
148
+
});
149
149
+
150
150
+
return modified ? tr : null;
151
151
+
}
152
152
+
})
153
153
+
];
154
154
+
}
155
155
+
});
+55
src/lib/components/rich-text-editor/embed/EmbedNodeComponent.svelte
···
1
1
+
<script lang="ts">
2
2
+
import type { NodeViewProps } from '@tiptap/core';
3
3
+
import { NodeViewWrapper } from 'svelte-tiptap';
4
4
+
import { getEmbedDefinition } from '$lib/embeds';
5
5
+
6
6
+
let props: NodeViewProps = $props();
7
7
+
8
8
+
let url = $derived(props.node.attrs.url as string);
9
9
+
let embedType = $derived(props.node.attrs.embedType as string);
10
10
+
let embedData = $derived.by(() => {
11
11
+
try {
12
12
+
return JSON.parse(props.node.attrs.embedData || '{}');
13
13
+
} catch {
14
14
+
return {};
15
15
+
}
16
16
+
});
17
17
+
18
18
+
let definition = $derived(getEmbedDefinition(embedType));
19
19
+
</script>
20
20
+
21
21
+
<NodeViewWrapper data-type="embed" class="my-4">
22
22
+
{#if definition}
23
23
+
<div class="not-prose group relative">
24
24
+
<definition.component {url} data={embedData} />
25
25
+
{#if props.editor.isEditable}
26
26
+
<div class="absolute top-2 right-2 opacity-0 transition-opacity group-hover:opacity-100">
27
27
+
<button
28
28
+
onclick={() => props.deleteNode()}
29
29
+
class="rounded-lg bg-black/60 p-1.5 text-white transition-colors hover:bg-black/80"
30
30
+
title="Remove embed"
31
31
+
>
32
32
+
<svg
33
33
+
xmlns="http://www.w3.org/2000/svg"
34
34
+
viewBox="0 0 24 24"
35
35
+
fill="currentColor"
36
36
+
class="size-4"
37
37
+
>
38
38
+
<path
39
39
+
fill-rule="evenodd"
40
40
+
d="M5.47 5.47a.75.75 0 0 1 1.06 0L12 10.94l5.47-5.47a.75.75 0 1 1 1.06 1.06L13.06 12l5.47 5.47a.75.75 0 1 1-1.06 1.06L12 13.06l-5.47 5.47a.75.75 0 0 1-1.06-1.06L10.94 12 5.47 6.53a.75.75 0 0 1 0-1.06Z"
41
41
+
clip-rule="evenodd"
42
42
+
/>
43
43
+
</svg>
44
44
+
</button>
45
45
+
</div>
46
46
+
{/if}
47
47
+
</div>
48
48
+
{:else}
49
49
+
<div class="bg-base-100 dark:bg-base-800 text-base-500 rounded-xl p-4 text-sm">
50
50
+
Unsupported embed: <a href={url} target="_blank" rel="noopener noreferrer" class="underline"
51
51
+
>{url}</a
52
52
+
>
53
53
+
</div>
54
54
+
{/if}
55
55
+
</NodeViewWrapper>
+30
src/lib/embeds/BlogContent.svelte
···
1
1
+
<script lang="ts">
2
2
+
import { marked, type Renderer } from 'marked';
3
3
+
import { sanitize } from '$lib/sanitize';
4
4
+
import { parseContentSegments, getEmbedDefinition } from '$lib/embeds';
5
5
+
6
6
+
let {
7
7
+
content,
8
8
+
renderer
9
9
+
}: {
10
10
+
content: string;
11
11
+
renderer: Renderer;
12
12
+
} = $props();
13
13
+
14
14
+
let segments = $derived(parseContentSegments(content));
15
15
+
</script>
16
16
+
17
17
+
{#each segments as segment, i (segment.kind === 'embed' ? `embed-${i}` : `md-${i}`)}
18
18
+
{#if segment.kind === 'markdown'}
19
19
+
{@html sanitize(marked.parse(segment.text, { renderer }) as string, {
20
20
+
ADD_ATTR: ['target']
21
21
+
})}
22
22
+
{:else if segment.kind === 'embed'}
23
23
+
{@const definition = getEmbedDefinition(segment.type)}
24
24
+
{#if definition}
25
25
+
<div class="not-prose my-6">
26
26
+
<definition.component url={segment.url} data={segment.data} />
27
27
+
</div>
28
28
+
{/if}
29
29
+
{/if}
30
30
+
{/each}
+42
src/lib/embeds/YouTubeEmbed.svelte
···
1
1
+
<script lang="ts">
2
2
+
import type { EmbedComponentProps } from './types';
3
3
+
4
4
+
let { data }: EmbedComponentProps = $props();
5
5
+
6
6
+
let videoId = $derived(data.videoId as string);
7
7
+
let poster = $derived(data.poster as string);
8
8
+
let isPlaying = $state(false);
9
9
+
</script>
10
10
+
11
11
+
{#if isPlaying}
12
12
+
<div class="relative aspect-video w-full overflow-hidden rounded-xl">
13
13
+
<iframe
14
14
+
class="absolute inset-0 h-full w-full"
15
15
+
src="https://www.youtube.com/embed/{videoId}?autoplay=1"
16
16
+
title="YouTube video player"
17
17
+
frameborder="0"
18
18
+
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
19
19
+
allowfullscreen
20
20
+
></iframe>
21
21
+
</div>
22
22
+
{:else}
23
23
+
<button
24
24
+
onclick={() => (isPlaying = true)}
25
25
+
class="group relative aspect-video w-full cursor-pointer overflow-hidden rounded-xl"
26
26
+
>
27
27
+
<img
28
28
+
class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-102"
29
29
+
src={poster}
30
30
+
alt="YouTube video thumbnail"
31
31
+
/>
32
32
+
<div class="absolute inset-0 flex items-center justify-center">
33
33
+
<svg xmlns="http://www.w3.org/2000/svg" class="w-16 drop-shadow-lg" viewBox="0 0 256 180">
34
34
+
<path
35
35
+
fill="#f00"
36
36
+
d="M250.346 28.075A32.18 32.18 0 0 0 227.69 5.418C207.824 0 127.87 0 127.87 0S47.912.164 28.046 5.582A32.18 32.18 0 0 0 5.39 28.24c-6.009 35.298-8.34 89.084.165 122.97a32.18 32.18 0 0 0 22.656 22.657c19.866 5.418 99.822 5.418 99.822 5.418s79.955 0 99.82-5.418a32.18 32.18 0 0 0 22.657-22.657c6.338-35.348 8.291-89.1-.164-123.134"
37
37
+
/>
38
38
+
<path fill="#fff" d="m102.421 128.06l66.328-38.418l-66.328-38.418z" />
39
39
+
</svg>
40
40
+
</div>
41
41
+
</button>
42
42
+
{/if}
+74
src/lib/embeds/index.ts
···
1
1
+
import type { EmbedDefinition } from './types';
2
2
+
import { youtubeEmbed } from './youtube';
3
3
+
4
4
+
/**
5
5
+
* Registry of all embed definitions. To add a new embed type,
6
6
+
* import its definition and add it to this array.
7
7
+
*/
8
8
+
export const embedDefinitions: EmbedDefinition[] = [youtubeEmbed];
9
9
+
10
10
+
/**
11
11
+
* Try to match a URL against all registered embed definitions.
12
12
+
* Returns the first match with { type, url, ...data }, or null.
13
13
+
*/
14
14
+
export function matchEmbed(
15
15
+
url: string
16
16
+
): ({ type: string; url: string } & Record<string, unknown>) | null {
17
17
+
for (const def of embedDefinitions) {
18
18
+
const data = def.match(url);
19
19
+
if (data) {
20
20
+
return { type: def.type, url, ...data };
21
21
+
}
22
22
+
}
23
23
+
return null;
24
24
+
}
25
25
+
26
26
+
/**
27
27
+
* Get the embed definition for a given type string.
28
28
+
*/
29
29
+
export function getEmbedDefinition(type: string): EmbedDefinition | undefined {
30
30
+
return embedDefinitions.find((d) => d.type === type);
31
31
+
}
32
32
+
33
33
+
export type ContentSegment =
34
34
+
| { kind: 'markdown'; text: string }
35
35
+
| { kind: 'embed'; type: string; url: string; data: Record<string, unknown> };
36
36
+
37
37
+
const BARE_URL_LINE = /^\s*(https?:\/\/\S+)\s*$/;
38
38
+
39
39
+
/**
40
40
+
* Parse a markdown string into segments, splitting out embeddable URLs.
41
41
+
* Lines that are just a bare URL matching an embed definition become embed segments.
42
42
+
* Consecutive non-embed lines are grouped into markdown segments.
43
43
+
*/
44
44
+
export function parseContentSegments(markdown: string): ContentSegment[] {
45
45
+
const lines = markdown.split('\n');
46
46
+
const segments: ContentSegment[] = [];
47
47
+
let buffer: string[] = [];
48
48
+
49
49
+
function flush() {
50
50
+
if (buffer.length > 0) {
51
51
+
segments.push({ kind: 'markdown', text: buffer.join('\n') });
52
52
+
buffer = [];
53
53
+
}
54
54
+
}
55
55
+
56
56
+
for (const line of lines) {
57
57
+
const urlMatch = line.match(BARE_URL_LINE);
58
58
+
if (urlMatch) {
59
59
+
const url = urlMatch[1];
60
60
+
const embed = matchEmbed(url);
61
61
+
if (embed) {
62
62
+
flush();
63
63
+
segments.push({ kind: 'embed', type: embed.type, url: embed.url, data: embed });
64
64
+
continue;
65
65
+
}
66
66
+
}
67
67
+
buffer.push(line);
68
68
+
}
69
69
+
70
70
+
flush();
71
71
+
return segments;
72
72
+
}
73
73
+
74
74
+
export type { EmbedDefinition, EmbedComponentProps } from './types';
+15
src/lib/embeds/types.ts
···
1
1
+
import type { Component } from 'svelte';
2
2
+
3
3
+
export type EmbedComponentProps = {
4
4
+
url: string;
5
5
+
data: Record<string, unknown>;
6
6
+
};
7
7
+
8
8
+
export type EmbedDefinition = {
9
9
+
/** Unique identifier, e.g. 'youtube', 'spotify' */
10
10
+
type: string;
11
11
+
/** Attempt to match a URL. Return provider-specific data on success, or null on failure. */
12
12
+
match: (url: string) => Record<string, unknown> | null;
13
13
+
/** Svelte component used to render the embed. Receives EmbedComponentProps. */
14
14
+
component: Component<EmbedComponentProps>;
15
15
+
};
+16
src/lib/embeds/youtube.ts
···
1
1
+
import { matcher } from '$lib/cards/media/YoutubeVideoCard/index';
2
2
+
import YouTubeEmbed from './YouTubeEmbed.svelte';
3
3
+
import type { EmbedDefinition } from './types';
4
4
+
5
5
+
export const youtubeEmbed: EmbedDefinition = {
6
6
+
type: 'youtube',
7
7
+
match: (url: string) => {
8
8
+
const id = matcher(url);
9
9
+
if (!id) return null;
10
10
+
return {
11
11
+
videoId: id,
12
12
+
poster: `https://i.ytimg.com/vi/${id}/hqdefault.jpg`
13
13
+
};
14
14
+
},
15
15
+
component: YouTubeEmbed
16
16
+
};
+2
-4
src/routes/[[actor=actor]]/blog/[rkey]/+page.svelte
···
2
2
import { getCDNImageBlobUrl } from '$lib/atproto';
3
3
import { Avatar as FoxAvatar } from '@foxui/core';
4
4
import { marked } from 'marked';
5
5
-
import { sanitize } from '$lib/sanitize';
6
5
import { all, createLowlight } from 'lowlight';
6
6
+
import BlogContent from '$lib/embeds/BlogContent.svelte';
7
7
8
8
const lowlight = createLowlight(all);
9
9
···
157
157
<article
158
158
class="prose dark:prose-invert prose-base prose-neutral prose-a:text-accent-600 dark:prose-a:text-accent-400 prose-img:rounded-xl max-w-none"
159
159
>
160
160
-
{@html sanitize(marked.parse(content.value, { renderer }) as string, {
161
161
-
ADD_ATTR: ['target']
162
162
-
})}
160
160
+
<BlogContent content={content.value} {renderer} />
163
161
</article>
164
162
{:else}
165
163
<div class="py-4">