your personal website on atproto - mirror blento.app

blog embeds

Florian 469ce4fc 522440a0

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