Реалізація вбудовування контенту (Embed)
Embed — це не просто <iframe src="...">. Правильна реалізація включає безпеку (sandbox, CSP), продуктивність (lazy-loading, aspect-ratio), протокол oEmbed для автоматичної генерації та підтримку користувацьких провайдерів.
oEmbed: стандарт автоматичного вбудовування
oEmbed — це JSON-протокол, який дозволяє отримати HTML-код для вбудовування за URL. Більшість платформ його підтримують.
interface OEmbedResponse {
type: 'rich' | 'video' | 'photo' | 'link'
html?: string
width?: number
height?: number
thumbnail_url?: string
title?: string
author_name?: string
}
async function fetchOEmbed(url: string, maxWidth = 800): Promise<OEmbedResponse> {
// Більшість провайдерів приймають запит напрямку, але через CORS — через проксі
const proxyUrl = `/api/oembed?url=${encodeURIComponent(url)}&maxwidth=${maxWidth}`
const res = await fetch(proxyUrl)
if (!res.ok) throw new Error(`oEmbed failed: ${res.status}`)
return res.json()
}
// Laravel: проксі для запитів oEmbed
// app/Http/Controllers/OEmbedController.php
class OEmbedController extends Controller
{
private array $providers = [
'youtube.com' => 'https://www.youtube.com/oembed',
'youtu.be' => 'https://www.youtube.com/oembed',
'vimeo.com' => 'https://vimeo.com/api/oembed.json',
'twitter.com' => 'https://publish.twitter.com/oembed',
'x.com' => 'https://publish.twitter.com/oembed',
'instagram.com' => 'https://graph.facebook.com/v18.0/instagram_oembed',
'soundcloud.com' => 'https://soundcloud.com/oembed',
'spotify.com' => 'https://open.spotify.com/oembed',
'tiktok.com' => 'https://www.tiktok.com/oembed',
'codepen.io' => 'https://codepen.io/api/oembed',
];
public function fetch(Request $request): JsonResponse
{
$url = $request->validate(['url' => 'required|url'])['url'];
$host = parse_url($url, PHP_URL_HOST);
$host = preg_replace('/^www\./', '', $host);
$endpoint = collect($this->providers)
->first(fn($v, $k) => str_contains($host, $k));
if (!$endpoint) {
return response()->json(['error' => 'Provider not supported'], 422);
}
$response = Http::timeout(5)->get($endpoint, [
'url' => $url,
'maxwidth' => $request->integer('maxwidth', 800),
'format' => 'json',
]);
return response()->json($response->json(), $response->status());
}
}
Безпечне відтворення HTML з oEmbed
HTML із зовнішніх джерел не можна вставляти прямо через innerHTML. Використовуйте DOMPurify:
npm install dompurify @types/dompurify
import DOMPurify from 'dompurify'
// Конфіг: дозволяємо iframe тільки від довірених доменів
const ALLOWED_IFRAME_ORIGINS = [
'https://www.youtube.com',
'https://player.vimeo.com',
'https://open.spotify.com',
'https://w.soundcloud.com',
'https://www.tiktok.com',
'https://codepen.io',
]
DOMPurify.addHook('uponSanitizeElement', (node, data) => {
if (data.tagName === 'iframe') {
const src = node.getAttribute('src') || ''
const allowed = ALLOWED_IFRAME_ORIGINS.some(origin => src.startsWith(origin))
if (!allowed) {
node.remove()
}
}
})
const config = {
ADD_TAGS: ['iframe'],
ADD_ATTR: ['allowfullscreen', 'frameborder', 'scrolling', 'allow', 'referrerpolicy'],
}
function SafeEmbed({ html }: { html: string }) {
const clean = DOMPurify.sanitize(html, config)
return <div dangerouslySetInnerHTML={{ __html: clean }} className="embed-wrapper" />
}
Адаптивне вбудовування: видалення фіксованих розмірів
oEmbed часто повертає width="560" height="315" прямо в HTML. Це зламує адаптивність. Виправляємо через CSS або постобробку:
function makeResponsive(html: string): string {
const parser = new DOMParser()
const doc = parser.parseFromString(html, 'text/html')
doc.querySelectorAll('iframe').forEach(iframe => {
const w = parseInt(iframe.getAttribute('width') || '0')
const h = parseInt(iframe.getAttribute('height') || '0')
if (w && h) {
const ratio = (h / w * 100).toFixed(4)
iframe.setAttribute('width', '100%')
iframe.removeAttribute('height')
iframe.style.aspectRatio = `${w}/${h}`
}
})
return doc.body.innerHTML
}
.embed-wrapper {
width: 100%;
}
.embed-wrapper iframe {
width: 100%;
border: none;
aspect-ratio: 16/9;
}
Ледача завантаження через Intersection Observer
import { useEffect, useRef, useState } from 'react'
function LazyEmbed({ url, label }: { url: string; label: string }) {
const [loaded, setLoaded] = useState(false)
const [embedHtml, setEmbedHtml] = useState('')
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setLoaded(true)
observer.disconnect()
}
},
{ rootMargin: '300px' }
)
if (containerRef.current) observer.observe(containerRef.current)
return () => observer.disconnect()
}, [])
useEffect(() => {
if (!loaded) return
fetchOEmbed(url).then(data => {
if (data.html) setEmbedHtml(makeResponsive(data.html))
})
}, [loaded, url])
return (
<div ref={containerRef} className="embed-container" aria-label={label}>
{embedHtml
? <SafeEmbed html={embedHtml} />
: <div className="embed-placeholder">Завантаження...</div>
}
</div>
)
}
Користувацький провайдер: вбудовування Figma
// Figma не підтримує oEmbed — будуємо вручну
function buildFigmaEmbed(url: string, options = { width: 800, height: 450 }): string {
const embedUrl = `https://www.figma.com/embed?embed_host=share&url=${encodeURIComponent(url)}`
return `<iframe
src="${embedUrl}"
width="${options.width}"
height="${options.height}"
allowfullscreen
style="border: 1px solid rgba(0, 0, 0, 0.1);"
></iframe>`
}
// Визначаємо тип за URL і вибираємо стратегію
function resolveEmbed(url: string): Promise<string> {
if (url.includes('figma.com')) {
return Promise.resolve(buildFigmaEmbed(url))
}
if (url.includes('github.com') && url.includes('/blob/')) {
return buildGistEmbed(url)
}
return fetchOEmbed(url).then(d => makeResponsive(d.html ?? ''))
}
Content Security Policy та iframe sandbox
// Laravel middleware для CSP заголовків
class ContentSecurityPolicy
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$frameAncestors = [
"'self'",
'https://www.youtube.com',
'https://player.vimeo.com',
];
$response->headers->set(
'Content-Security-Policy',
implode('; ', [
"default-src 'self'",
"frame-src " . implode(' ', $frameAncestors),
"script-src 'self' 'nonce-" . $request->attributes->get('csp_nonce') . "'",
])
);
return $response;
}
}
<!-- sandbox обмежує можливості iframe -->
<iframe
src="https://www.youtube.com/embed/..."
sandbox="allow-scripts allow-same-origin allow-presentation allow-fullscreen"
loading="lazy"
referrerpolicy="no-referrer-when-downgrade"
></iframe>
Атрибут sandbox без allow-same-origin повністю ізолює iframe, але зламує більшість програвачів. Мінімальний безпечний набір — allow-scripts allow-same-origin.
Кешування oEmbed-відповідей
// Кешуємо відповідь oEmbed на 24 години — платформи рідко змінюють embed-код
public function fetch(Request $request): JsonResponse
{
$url = $request->input('url');
$cacheKey = 'oembed:' . md5($url);
$data = Cache::remember($cacheKey, now()->addDay(), function () use ($url) {
// ... запит до провайдера
});
return response()->json($data);
}
Терміни
Базовий oEmbed-проксі з 10 провайдерами — 1 день. З безпечним відтворенням, адаптивом, lazy-завантаженням та кешуванням — 2–3 дні. Повноцінний редактор з вбудовуванням WYSIWYG за URL (як у Notion) — 1 тиждень.







