mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-01 12:02:05 +00:00
feat(desktop): lazy embed renderers + fenced diagrams/alerts
Per-kind renderers, each a lazy split chunk: plain-iframe video/maps (wheel chains to the transcript; maps gate scroll behind ⌘), the in-document blockquote-script path for X/Instagram, the dark Spotify player, and the YouTube iframe. Adds Mermaid and DOMPurify-sanitised SVG fences and GFM alert callouts, all sized to 33dvh and theme-matched to avoid white color-scheme artifacts. Main-process stamps a Referer on YouTube embed requests.
This commit is contained in:
parent
81ac562bf0
commit
0c190083cd
13 changed files with 757 additions and 0 deletions
48
apps/desktop/electron/embed-referer.cjs
Normal file
48
apps/desktop/electron/embed-referer.cjs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
'use strict'
|
||||
|
||||
const { session } = require('electron')
|
||||
|
||||
const EMBED_SESSION_PARTITION = 'persist:hermes-embed'
|
||||
const EMBED_REFERER = 'https://www.youtube.com/'
|
||||
const YOUTUBE_REFERER_HOST_RE =
|
||||
/(^|\.)(youtube\.com|youtube-nocookie\.com|googlevideo\.com|ytimg\.com|youtubei\.googleapis\.com)$/i
|
||||
|
||||
function installEmbedRefererForSession(embedSession) {
|
||||
if (!embedSession) {
|
||||
return
|
||||
}
|
||||
|
||||
embedSession.webRequest.onBeforeSendHeaders((details, callback) => {
|
||||
let host = ''
|
||||
|
||||
try {
|
||||
host = new URL(details.url).hostname
|
||||
} catch {
|
||||
host = ''
|
||||
}
|
||||
|
||||
if (!YOUTUBE_REFERER_HOST_RE.test(host)) {
|
||||
callback({ requestHeaders: details.requestHeaders })
|
||||
return
|
||||
}
|
||||
|
||||
const headers = { ...details.requestHeaders }
|
||||
|
||||
if (!headers.Referer && !headers.referer) {
|
||||
headers.Referer = EMBED_REFERER
|
||||
}
|
||||
|
||||
callback({ requestHeaders: headers })
|
||||
})
|
||||
}
|
||||
|
||||
/** Stamp Referer on YouTube requests in the embed webview partition only. */
|
||||
function installEmbedReferer() {
|
||||
try {
|
||||
installEmbedRefererForSession(session.fromPartition(EMBED_SESSION_PARTITION))
|
||||
} catch {
|
||||
// Non-fatal: embeds still render; YouTube may show referer errors.
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { installEmbedReferer }
|
||||
|
|
@ -24,6 +24,7 @@ const https = require('node:https')
|
|||
const path = require('node:path')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
const { execFileSync, spawn } = require('node:child_process')
|
||||
const { installEmbedReferer } = require('./embed-referer.cjs')
|
||||
const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
|
||||
const { runBootstrap } = require('./bootstrap-runner.cjs')
|
||||
const {
|
||||
|
|
@ -7447,6 +7448,7 @@ app.whenReady().then(() => {
|
|||
}
|
||||
installMediaPermissions()
|
||||
registerMediaProtocol()
|
||||
installEmbedReferer()
|
||||
registerDeepLinkProtocol()
|
||||
ensureWslWindowsFonts()
|
||||
configureSpellChecker()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
import { createElement } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { extractAlert } from './alert'
|
||||
|
||||
describe('extractAlert', () => {
|
||||
it('detects each GFM alert kind from the leading marker', () => {
|
||||
for (const [marker, type] of [
|
||||
['[!NOTE]', 'note'],
|
||||
['[!TIP]', 'tip'],
|
||||
['[!IMPORTANT]', 'important'],
|
||||
['[!WARNING]', 'warning'],
|
||||
['[!CAUTION]', 'caution']
|
||||
] as const) {
|
||||
const node = createElement('p', null, `${marker}\nBody text`)
|
||||
const result = extractAlert(node)
|
||||
|
||||
expect(result?.type).toBe(type)
|
||||
}
|
||||
})
|
||||
|
||||
it('is case-insensitive on the marker', () => {
|
||||
expect(extractAlert(createElement('p', null, '[!note] hi'))?.type).toBe('note')
|
||||
})
|
||||
|
||||
it('returns null for a plain blockquote', () => {
|
||||
expect(extractAlert(createElement('p', null, 'just a quote'))).toBeNull()
|
||||
expect(extractAlert('no marker here')).toBeNull()
|
||||
})
|
||||
|
||||
it('strips the marker token from the body', () => {
|
||||
const result = extractAlert(createElement('p', null, '[!WARNING]\nDanger ahead'))
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
// The marker must not survive into the rendered body.
|
||||
expect(JSON.stringify(result?.body)).not.toContain('[!WARNING]')
|
||||
expect(JSON.stringify(result?.body)).toContain('Danger ahead')
|
||||
})
|
||||
})
|
||||
125
apps/desktop/src/components/assistant-ui/embeds/alert.tsx
Normal file
125
apps/desktop/src/components/assistant-ui/embeds/alert.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { cloneElement, isValidElement, type ReactNode } from 'react'
|
||||
|
||||
import { AlertCircle, AlertTriangle, type IconComponent, Info, Zap } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type AlertType = 'caution' | 'important' | 'note' | 'tip' | 'warning'
|
||||
|
||||
interface AlertStyle {
|
||||
accent: string
|
||||
icon: IconComponent
|
||||
label: string
|
||||
}
|
||||
|
||||
// GitHub's five alert kinds, mapped to our icon set + a tinted accent.
|
||||
const ALERT_STYLES: Record<AlertType, AlertStyle> = {
|
||||
caution: { accent: 'text-rose-600 dark:text-rose-400', icon: AlertTriangle, label: 'Caution' },
|
||||
important: { accent: 'text-violet-600 dark:text-violet-400', icon: AlertCircle, label: 'Important' },
|
||||
note: { accent: 'text-blue-600 dark:text-blue-400', icon: Info, label: 'Note' },
|
||||
tip: { accent: 'text-emerald-600 dark:text-emerald-400', icon: Zap, label: 'Tip' },
|
||||
warning: { accent: 'text-amber-600 dark:text-amber-400', icon: AlertTriangle, label: 'Warning' }
|
||||
}
|
||||
|
||||
const MARKER_RE = /^\s*\[!(note|tip|important|warning|caution)\]\s*\n?/i
|
||||
|
||||
function firstText(node: ReactNode): string {
|
||||
if (typeof node === 'string') {
|
||||
return node
|
||||
}
|
||||
|
||||
if (typeof node === 'number') {
|
||||
return String(node)
|
||||
}
|
||||
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) {
|
||||
const text = firstText(child)
|
||||
|
||||
if (text.trim()) {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
if (isValidElement(node)) {
|
||||
return firstText((node.props as { children?: ReactNode }).children)
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
// Remove the leading `[!TYPE]` token from the first text node that carries it,
|
||||
// leaving the rest of the blockquote body intact. One-shot via the `state` flag.
|
||||
function stripMarker(node: ReactNode, state: { done: boolean }): ReactNode {
|
||||
if (state.done) {
|
||||
return node
|
||||
}
|
||||
|
||||
if (typeof node === 'string') {
|
||||
const replaced = node.replace(MARKER_RE, '')
|
||||
|
||||
if (replaced !== node) {
|
||||
state.done = true
|
||||
|
||||
return replaced
|
||||
}
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
if (Array.isArray(node)) {
|
||||
return node.map((child, index) => <Fragmentless key={index} node={stripMarker(child, state)} />)
|
||||
}
|
||||
|
||||
if (isValidElement(node)) {
|
||||
const children = (node.props as { children?: ReactNode }).children
|
||||
|
||||
if (children == null) {
|
||||
return node
|
||||
}
|
||||
|
||||
return cloneElement(node, undefined, stripMarker(children, state))
|
||||
}
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
// Tiny helper so the array branch can return keyed nodes without wrapping
|
||||
// strings in extra elements (React renders the raw node).
|
||||
function Fragmentless({ node }: { node: ReactNode }) {
|
||||
return <>{node}</>
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a GitHub-style alert blockquote (`> [!NOTE]`). Returns the alert kind
|
||||
* and the body with the marker stripped, or null for a plain blockquote.
|
||||
*/
|
||||
export function extractAlert(children: ReactNode): { body: ReactNode; type: AlertType } | null {
|
||||
const match = firstText(children).match(MARKER_RE)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { body: stripMarker(children, { done: false }), type: match[1].toLowerCase() as AlertType }
|
||||
}
|
||||
|
||||
export function MarkdownAlert({ children, type }: { children: ReactNode; type: AlertType }) {
|
||||
const style = ALERT_STYLES[type]
|
||||
const Icon = style.icon
|
||||
|
||||
return (
|
||||
<div
|
||||
className="my-2 rounded-lg border border-border bg-muted/25 px-3 py-2 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"
|
||||
data-slot="aui_markdown-alert"
|
||||
>
|
||||
<div className={cn('mb-1 flex items-center gap-1.5 text-[0.8125rem] font-semibold', style.accent)}>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
{style.label}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
'use client'
|
||||
|
||||
import { type CSSProperties } from 'react'
|
||||
|
||||
import type { FrameEmbed } from './providers/types'
|
||||
import { ScrollGate } from './scroll-gate'
|
||||
import { useIsDark } from './use-is-dark'
|
||||
|
||||
const ALLOW = 'autoplay; encrypted-media; picture-in-picture; clipboard-write; fullscreen'
|
||||
|
||||
// Plain iframes (not webviews): a non-scrollable cross-origin iframe lets the
|
||||
// wheel chain to the transcript instead of capturing it. Maps are the one
|
||||
// exception — they're interactive, so a ScrollGate blocks them until ⌘ is held.
|
||||
export default function FrameEmbedRenderer({ descriptor }: { descriptor: FrameEmbed }) {
|
||||
const isDark = useIsDark()
|
||||
const isMap = descriptor.provider === 'googlemaps' || descriptor.provider === 'openstreetmap'
|
||||
// color-scheme makes the iframe's default (unpainted) backdrop follow the
|
||||
// theme instead of flashing white at the corners / during load.
|
||||
const colorScheme = isDark ? 'dark' : 'light'
|
||||
|
||||
const style: CSSProperties = descriptor.aspectRatio
|
||||
? { aspectRatio: descriptor.aspectRatio, colorScheme }
|
||||
: { colorScheme, height: descriptor.height }
|
||||
|
||||
if (isMap) {
|
||||
return (
|
||||
<div className="relative w-full overflow-hidden" style={style}>
|
||||
<iframe
|
||||
allow={ALLOW}
|
||||
className="absolute inset-0 size-full border-0 bg-transparent"
|
||||
loading="lazy"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
src={descriptor.embedUrl}
|
||||
style={{ colorScheme }}
|
||||
title={`${descriptor.label} embed`}
|
||||
/>
|
||||
<ScrollGate />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<iframe
|
||||
allow={ALLOW}
|
||||
allowFullScreen
|
||||
className="block w-full border-0 bg-transparent"
|
||||
loading="lazy"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
scrolling="no"
|
||||
src={descriptor.embedUrl}
|
||||
style={style}
|
||||
title={`${descriptor.label} embed`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
'use client'
|
||||
|
||||
import mermaid from 'mermaid'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { RichFenceProps } from './types'
|
||||
import { useIsDark } from './use-is-dark'
|
||||
|
||||
let lastTheme: 'dark' | 'default' | null = null
|
||||
|
||||
// Re-initialise only on first use / theme flip. `securityLevel: 'strict'` makes
|
||||
// mermaid sanitise label HTML and drop click handlers, so the rendered SVG is
|
||||
// safe to inject.
|
||||
function ensureInit(dark: boolean) {
|
||||
const theme = dark ? 'dark' : 'default'
|
||||
|
||||
if (theme === lastTheme) {
|
||||
return
|
||||
}
|
||||
|
||||
mermaid.initialize({ fontFamily: 'inherit', securityLevel: 'strict', startOnLoad: false, theme })
|
||||
lastTheme = theme
|
||||
}
|
||||
|
||||
function SourcePreview({ code, muted }: { code: string; muted?: boolean }) {
|
||||
return (
|
||||
<pre
|
||||
className={cn(
|
||||
'overflow-auto p-3 font-mono text-[0.7rem] leading-relaxed whitespace-pre-wrap wrap-anywhere',
|
||||
muted ? 'text-muted-foreground/70' : 'text-foreground/90'
|
||||
)}
|
||||
>
|
||||
{code}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
// Lazy chunk (pulls in mermaid). Renders ```mermaid fences as diagrams; shows
|
||||
// the source while the message streams (partial syntax throws) and falls back
|
||||
// to source on parse failure.
|
||||
export default function MermaidRenderer({ code, streaming }: RichFenceProps) {
|
||||
const isDark = useIsDark()
|
||||
const [svg, setSvg] = useState('')
|
||||
const [failed, setFailed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (streaming) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
setFailed(false)
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
ensureInit(isDark)
|
||||
const id = `mmd-${Math.random().toString(36).slice(2)}`
|
||||
const result = await mermaid.render(id, code)
|
||||
|
||||
if (!cancelled) {
|
||||
setSvg(result.svg)
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setFailed(true)
|
||||
setSvg('')
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [code, isDark, streaming])
|
||||
|
||||
if (streaming) {
|
||||
return <SourcePreview code={code} muted />
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
return <SourcePreview code={code} />
|
||||
}
|
||||
|
||||
if (!svg) {
|
||||
return <SourcePreview code={code} muted />
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="overflow-auto p-3 [&_svg]:mx-auto [&_svg]:h-auto [&_svg]:max-h-[33dvh] [&_svg]:max-w-full"
|
||||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
39
apps/desktop/src/components/assistant-ui/embeds/registry.tsx
Normal file
39
apps/desktop/src/components/assistant-ui/embeds/registry.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
'use client'
|
||||
|
||||
import { type ComponentType, lazy, type LazyExoticComponent, type ReactNode, Suspense } from 'react'
|
||||
|
||||
import { RichBoundary } from './rich-boundary'
|
||||
import type { RichFenceProps } from './types'
|
||||
|
||||
// Root renderer for fenced code blocks: a language → lazy-renderer table. Each
|
||||
// renderer is its own split chunk (mermaid pulls in the mermaid lib, svg pulls
|
||||
// in DOMPurify), loaded only when a block of that language actually appears.
|
||||
const LAZY_FENCE: Record<string, LazyExoticComponent<ComponentType<RichFenceProps>>> = {
|
||||
mermaid: lazy(() => import('./mermaid-embed')),
|
||||
svg: lazy(() => import('./svg-embed'))
|
||||
}
|
||||
|
||||
export const RICH_FENCE_LANGUAGES: ReadonlySet<string> = new Set(Object.keys(LAZY_FENCE))
|
||||
|
||||
interface RichCodeBlockProps extends RichFenceProps {
|
||||
/** Rendered for unhandled languages, while the chunk loads, and on failure
|
||||
* (typically the normal syntax-highlighted code block). */
|
||||
fallback: ReactNode
|
||||
language?: string
|
||||
}
|
||||
|
||||
export function RichCodeBlock({ code, fallback, language, streaming }: RichCodeBlockProps) {
|
||||
const Renderer = language ? LAZY_FENCE[language.toLowerCase()] : undefined
|
||||
|
||||
if (!Renderer) {
|
||||
return <>{fallback}</>
|
||||
}
|
||||
|
||||
return (
|
||||
<RichBoundary fallback={fallback} resetKey={code}>
|
||||
<Suspense fallback={fallback}>
|
||||
<Renderer code={code} streaming={streaming} />
|
||||
</Suspense>
|
||||
</RichBoundary>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/** Block wheel until ⌘/Ctrl so map embeds don't hijack transcript scroll. */
|
||||
export function ScrollGate() {
|
||||
const [active, setActive] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const sync = (event: KeyboardEvent) => setActive(event.metaKey || event.ctrlKey)
|
||||
const clear = () => setActive(false)
|
||||
|
||||
window.addEventListener('keydown', sync)
|
||||
window.addEventListener('keyup', sync)
|
||||
window.addEventListener('blur', clear)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', sync)
|
||||
window.removeEventListener('keyup', sync)
|
||||
window.removeEventListener('blur', clear)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={cn('group/gate absolute inset-0', active ? 'pointer-events-none' : 'pointer-events-auto')}>
|
||||
<span className="pointer-events-none absolute bottom-2 left-2 rounded-md bg-black/55 px-1.5 py-0.5 text-[0.625rem] font-medium text-white opacity-0 transition-opacity group-hover/embed:opacity-100">
|
||||
Hold ⌘ to zoom
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
131
apps/desktop/src/components/assistant-ui/embeds/social-embed.tsx
Normal file
131
apps/desktop/src/components/assistant-ui/embeds/social-embed.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { escapeHtml } from './escape-html'
|
||||
import type { EmbedDescriptor } from './providers/types'
|
||||
import { useIsDark } from './use-is-dark'
|
||||
|
||||
// The provider embed scripts need a REAL origin to run (they touch
|
||||
// cookies/storage/postMessage), so — exactly like react-social-media-embed — we
|
||||
// render the official blockquote in this document and let the script swap it for
|
||||
// a correctly-sized iframe. A sandboxed srcDoc iframe gives a null origin and
|
||||
// the scripts silently bail (white / 2px). The container is height:auto, so it
|
||||
// grows to whatever the provider renders. No measuring, no forced height.
|
||||
type EmbedWindow = Window &
|
||||
typeof globalThis & {
|
||||
instgrm?: { Embeds?: { process?: () => void } }
|
||||
twttr?: { widgets?: { load?: (el?: HTMLElement) => void } }
|
||||
}
|
||||
|
||||
const SCRIPT: Record<string, { id: string; src: string }> = {
|
||||
instagram: { id: 'hermes-ig-embed', src: 'https://www.instagram.com/embed.js' },
|
||||
tiktok: { id: 'hermes-tt-embed', src: 'https://www.tiktok.com/embed.js' },
|
||||
twitter: { id: 'hermes-tw-embed', src: 'https://platform.twitter.com/widgets.js' }
|
||||
}
|
||||
|
||||
const PROCESS_DELAYS_MS = [0, 300, 800, 1600, 3000]
|
||||
|
||||
function markup(descriptor: EmbedDescriptor, theme: 'dark' | 'light'): string {
|
||||
const url = escapeHtml(descriptor.sourceUrl)
|
||||
|
||||
switch (descriptor.provider) {
|
||||
case 'instagram':
|
||||
return `<blockquote class="instagram-media" data-instgrm-permalink="${url}" data-instgrm-version="14" style="margin:0;width:100%;min-width:0;max-width:100%"></blockquote>`
|
||||
case 'tiktok': {
|
||||
const id = escapeHtml(descriptor.id.replace(/^tiktok:/, ''))
|
||||
|
||||
return `<blockquote class="tiktok-embed" cite="${url}" data-video-id="${id}" style="margin:0;max-width:100%"><section></section></blockquote>`
|
||||
}
|
||||
|
||||
case 'twitter':
|
||||
// data-chrome="transparent" drops the card background so the themed page
|
||||
// shows through instead of a white box.
|
||||
return `<blockquote class="twitter-tweet" data-dnt="true" data-theme="${theme}" data-chrome="transparent"><a href="${url}"></a></blockquote>`
|
||||
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function loadScript(provider: string): Promise<void> {
|
||||
const { id, src } = SCRIPT[provider]
|
||||
|
||||
// TikTok exposes no re-process API; its script rescans the document each time
|
||||
// it runs, so we re-inject it. The others are loaded once and reused.
|
||||
if (provider === 'tiktok') {
|
||||
document.getElementById(id)?.remove()
|
||||
} else if (document.getElementById(id)) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
const script = document.createElement('script')
|
||||
|
||||
script.async = true
|
||||
script.id = id
|
||||
script.onload = () => resolve()
|
||||
script.onerror = () => resolve()
|
||||
script.src = src
|
||||
document.body.appendChild(script)
|
||||
})
|
||||
}
|
||||
|
||||
function processEmbed(provider: string, container: HTMLElement): void {
|
||||
const win = window as EmbedWindow
|
||||
|
||||
if (provider === 'instagram') {
|
||||
win.instgrm?.Embeds?.process?.()
|
||||
} else if (provider === 'twitter') {
|
||||
win.twttr?.widgets?.load?.(container)
|
||||
}
|
||||
// TikTok auto-scans on (re)injection — no manual process call.
|
||||
}
|
||||
|
||||
export default function SocialEmbedRenderer({ descriptor }: { descriptor: EmbedDescriptor }) {
|
||||
const isDark = useIsDark()
|
||||
const ref = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const container = ref.current
|
||||
|
||||
if (!container) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
const timers: number[] = []
|
||||
|
||||
container.innerHTML = markup(descriptor, isDark ? 'dark' : 'light')
|
||||
|
||||
void loadScript(descriptor.provider).then(() => {
|
||||
// The script renders asynchronously; nudge a few times so the embed
|
||||
// settles whether the script was cached or freshly fetched.
|
||||
for (const delay of PROCESS_DELAYS_MS) {
|
||||
timers.push(window.setTimeout(() => !cancelled && processEmbed(descriptor.provider, container), delay))
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
|
||||
for (const timer of timers) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
container.innerHTML = ''
|
||||
}
|
||||
}, [descriptor, isDark])
|
||||
|
||||
// The white corner/box on tweets is a color-scheme MISMATCH: when the iframe's
|
||||
// resolved scheme differs from ours, the browser paints an opaque (white)
|
||||
// Canvas behind it. Twitter's embed resolves to `light`, so we force the iframe
|
||||
// to `light` to match — no mismatch, no Canvas — and data-chrome=transparent
|
||||
// then lets the dark page show through. (Confirmed: mkdocs-material #6889.)
|
||||
return (
|
||||
<div
|
||||
className="w-full [&_.instagram-media]:!min-w-0 [&_iframe]:!m-0 [&_iframe]:!max-w-full [&_iframe]:[color-scheme:light]"
|
||||
ref={ref}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
'use client'
|
||||
|
||||
import { type CSSProperties, useMemo } from 'react'
|
||||
|
||||
import type { FrameEmbed } from './providers/types'
|
||||
import { useIsDark } from './use-is-dark'
|
||||
|
||||
const ALLOW = 'autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture'
|
||||
|
||||
// Spotify paints a white backdrop behind its card; theme=0 gives the dark
|
||||
// player and the card wrapper's overflow-hidden clips the corners.
|
||||
function spotifySrc(embedUrl: string, isDark: boolean): string {
|
||||
const url = new URL(embedUrl)
|
||||
|
||||
url.searchParams.set('utm_source', 'generator')
|
||||
|
||||
if (isDark) {
|
||||
url.searchParams.set('theme', '0')
|
||||
}
|
||||
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
export default function SpotifyEmbedRenderer({ descriptor }: { descriptor: FrameEmbed }) {
|
||||
const isDark = useIsDark()
|
||||
const src = useMemo(() => spotifySrc(descriptor.embedUrl, isDark), [descriptor.embedUrl, isDark])
|
||||
|
||||
// Match the iframe's own (light) scheme — a `dark` mismatch makes the browser
|
||||
// paint an opaque white Canvas behind it. theme=0 still gives the dark player.
|
||||
const style: CSSProperties = {
|
||||
colorScheme: 'light',
|
||||
height: descriptor.height
|
||||
}
|
||||
|
||||
return (
|
||||
<iframe
|
||||
allow={ALLOW}
|
||||
className="block w-full border-0 bg-transparent"
|
||||
loading="lazy"
|
||||
src={src}
|
||||
style={style}
|
||||
title="Spotify embed"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
'use client'
|
||||
|
||||
import DOMPurify from 'dompurify'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import type { RichFenceProps } from './types'
|
||||
|
||||
// Lazy chunk (pulls in DOMPurify). Renders a ```svg fence as an image after
|
||||
// hard-sanitising it: the svg profile strips scripts, event handlers, and
|
||||
// foreignObject, so untrusted model output can't execute.
|
||||
export default function SvgRenderer({ code }: RichFenceProps) {
|
||||
const clean = useMemo(
|
||||
() =>
|
||||
DOMPurify.sanitize(code, {
|
||||
USE_PROFILES: { svg: true, svgFilters: true }
|
||||
}),
|
||||
[code]
|
||||
)
|
||||
|
||||
if (!clean.trim()) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Left-aligned, capped on both axes so a large intrinsic SVG scales down
|
||||
// (preserving ratio) instead of filling the column or centering.
|
||||
return (
|
||||
<div
|
||||
className="my-2 [&_svg]:block [&_svg]:h-auto [&_svg]:w-auto [&_svg]:max-h-[33dvh] [&_svg]:max-w-full"
|
||||
dangerouslySetInnerHTML={{ __html: clean }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
'use client'
|
||||
|
||||
import { type CSSProperties, lazy, Suspense } from 'react'
|
||||
|
||||
import { EMBED_MAX_H } from './embed-size'
|
||||
import { EmbedFail } from './fail'
|
||||
import type { EmbedDescriptor } from './providers/types'
|
||||
import { RichBoundary } from './rich-boundary'
|
||||
|
||||
const FrameEmbedRenderer = lazy(() => import('./frame-embed'))
|
||||
const SocialEmbedRenderer = lazy(() => import('./social-embed'))
|
||||
const SpotifyEmbedRenderer = lazy(() => import('./spotify-embed'))
|
||||
const YouTubeEmbedRenderer = lazy(() => import('./youtube-embed'))
|
||||
|
||||
function intrinsicHeight(descriptor: EmbedDescriptor): number {
|
||||
if (descriptor.aspectRatio) {
|
||||
return Math.round((descriptor.maxWidth ?? 640) / descriptor.aspectRatio)
|
||||
}
|
||||
|
||||
return descriptor.height ?? 320
|
||||
}
|
||||
|
||||
function LazyRenderer({ descriptor }: { descriptor: EmbedDescriptor }) {
|
||||
// X and Instagram load their official blockquote script in-document. The tweet
|
||||
// check also narrows the union to FrameEmbed for the iframe renderers below.
|
||||
if (descriptor.renderer === 'tweet' || descriptor.provider === 'instagram') {
|
||||
return <SocialEmbedRenderer descriptor={descriptor} />
|
||||
}
|
||||
|
||||
if (descriptor.provider === 'youtube') {
|
||||
return <YouTubeEmbedRenderer descriptor={descriptor} />
|
||||
}
|
||||
|
||||
if (descriptor.provider === 'spotify') {
|
||||
return <SpotifyEmbedRenderer descriptor={descriptor} />
|
||||
}
|
||||
|
||||
return <FrameEmbedRenderer descriptor={descriptor} />
|
||||
}
|
||||
|
||||
export function UrlEmbed({ descriptor }: { descriptor: EmbedDescriptor }) {
|
||||
const aspect = descriptor.aspectRatio
|
||||
|
||||
// Ratio embeds cap WIDTH off the ratio so height tops out at the cap while
|
||||
// scaling. Non-ratio embeds own their own height (scale-to-fit / measured /
|
||||
// fixed) — no maxHeight here or it would clip what the renderer just sized.
|
||||
const style: CSSProperties = {
|
||||
containIntrinsicSize: `auto ${intrinsicHeight(descriptor)}px`,
|
||||
contentVisibility: 'auto',
|
||||
...(aspect
|
||||
? { width: `min(${descriptor.maxWidth ?? 640}px, 100%, calc(${EMBED_MAX_H} * ${aspect}))` }
|
||||
: { width: descriptor.maxWidth ? `min(${descriptor.maxWidth}px, 100%)` : '100%' })
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="group/embed my-2 block overflow-hidden rounded-lg" data-slot="aui_embed-card" style={style}>
|
||||
<RichBoundary fallback={<EmbedFail label={descriptor.label} />} resetKey={descriptor.id}>
|
||||
<Suspense fallback={null}>
|
||||
<LazyRenderer descriptor={descriptor} />
|
||||
</Suspense>
|
||||
</RichBoundary>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import type { FrameEmbed } from './providers/types'
|
||||
import { useIsDark } from './use-is-dark'
|
||||
|
||||
const YOUTUBE_ALLOW =
|
||||
'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen'
|
||||
|
||||
function youtubeSrc(embedUrl: string): string {
|
||||
const url = new URL(embedUrl)
|
||||
|
||||
// Only pass origin when it is an HTTP(S) origin; custom schemes (app://,
|
||||
// file://) can make the player reject otherwise embeddable videos.
|
||||
if (
|
||||
typeof window !== 'undefined' &&
|
||||
(window.location.protocol === 'http:' || window.location.protocol === 'https:') &&
|
||||
window.location.origin &&
|
||||
window.location.origin !== 'null'
|
||||
) {
|
||||
url.searchParams.set('origin', window.location.origin)
|
||||
}
|
||||
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
// Keep this as a plain iframe and let YouTube render its native player/error UI.
|
||||
export default function YouTubeEmbedRenderer({ descriptor }: { descriptor: FrameEmbed }) {
|
||||
const isDark = useIsDark()
|
||||
const src = useMemo(() => youtubeSrc(descriptor.embedUrl), [descriptor.embedUrl])
|
||||
|
||||
// Width is capped to the ratio by UrlEmbed, so aspect-video sizes height ≤ cap.
|
||||
return (
|
||||
<iframe
|
||||
allow={YOUTUBE_ALLOW}
|
||||
allowFullScreen
|
||||
className="block aspect-video w-full border-0 bg-transparent"
|
||||
loading="lazy"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
scrolling="no"
|
||||
src={src}
|
||||
style={{ colorScheme: isDark ? 'dark' : 'light' }}
|
||||
title="YouTube embed"
|
||||
/>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue