Files
sm-journal-plugin/assets/src/blogeditor/preview.ts
Jakub Zych c1f6a83c2f feat(editor): live preview with embed cards and post typography
- The preview re-renders 300 ms after the last WYSIWYG edit, Source input or external value, never while off or after detach.
- YouTube placeholders show as img.youtube.com thumbnail cards with no player; the preview copy drops active elements and on* attributes, and its stylesheet uses colours resolved from the host's --c-* values.
2026-10-10 13:56:57 +02:00

198 lines
8.2 KiB
TypeScript

import { EMBED_PROVIDERS, isEmbedProvider } from './embed'
import { inert } from './extensions'
import type { Labels } from './labels'
/**
* The preview pane's iframe sandbox attribute: an empty token list, the most
* restrictive sandbox. Pasted or imported HTML runs no script, the frame
* gets an opaque origin (it cannot read the admin's cookies, storage or DOM)
* and it cannot submit forms, open popups or navigate the top window.
*/
export const PREVIEW_SANDBOX = ''
/**
* The Content-Security-Policy the preview document declares on top of the
* admin page's policy it inherits: only inline styles and images load.
*/
export const PREVIEW_CSP = "default-src 'none'; img-src * data:; style-src 'unsafe-inline'"
/** How long the preview waits after the last change before it re-renders. */
export const PREVIEW_DEBOUNCE_MS = 300
/** The colours the preview stylesheet uses, as concrete CSS values. */
export interface PreviewColors {
surface: string
text: string
muted: string
border: string
borderStrong: string
subtle: string
primary: string
}
/** Neutral light-theme values for anything the host does not provide. */
export const PREVIEW_DEFAULTS: { colors: PreviewColors; fontFamily: string } = {
colors: {
surface: '#ffffff',
text: '#1a1a1a',
muted: '#6b7280',
border: '#e5e7eb',
borderStrong: '#9ca3af',
subtle: '#f6f7f9',
primary: '#2563eb',
},
fontFamily: 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
}
export interface PreviewOptions {
labels: Labels
lang?: string | null
colors?: PreviewColors
fontFamily?: string
}
/** The host variable each preview colour is read from. */
const COLOR_VARIABLES: Record<keyof PreviewColors, string> = {
surface: '--c-surface',
text: '--c-text',
muted: '--c-muted',
border: '--c-border',
borderStrong: '--c-border-strong',
subtle: '--c-subtle',
primary: '--c-primary',
}
/** The active elements the preview copy leaves out. */
const DROPPED_ELEMENTS = 'script, iframe, frame, frameset, object, embed, meta, base, link'
const LANG = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$/
/**
* safeCssValue returns value trimmed, or fallback when it is empty, longer
* than 200 characters or holds a character that could close the style
* element or the declaration (angle or curly brackets, a semicolon, a
* backslash or a comment opener). A host value can then never inject a rule.
*/
export function safeCssValue(value: string | null | undefined, fallback: string): string {
const v = (value ?? '').trim()
if (v === '' || v.length > 200 || /[<>{};\\]/.test(v) || v.includes('/*')) return fallback
return v
}
/**
* resolvePreviewColors reads the host's --c-* variables and font family as
* computed values. CSS variables do not cross into the srcdoc document, so
* the preview stylesheet carries the concrete values.
*/
export function resolvePreviewColors(host: Element): { colors: PreviewColors; fontFamily: string } {
const style = getComputedStyle(host)
const colors = { ...PREVIEW_DEFAULTS.colors }
for (const key of Object.keys(COLOR_VARIABLES) as (keyof PreviewColors)[]) {
colors[key] = safeCssValue(style.getPropertyValue(COLOR_VARIABLES[key]), PREVIEW_DEFAULTS.colors[key])
}
return { colors, fontFamily: safeCssValue(style.fontFamily, PREVIEW_DEFAULTS.fontFamily) }
}
/**
* youtubeThumbnail returns the thumbnail URL for a YouTube id. Call it only
* with an id that passed the provider's idPattern.
*/
export function youtubeThumbnail(id: string): string {
return `https://img.youtube.com/vi/${id}/hqdefault.jpg`
}
/**
* previewBody returns the HTML the preview shows. It works on a copy parsed
* in the inert document: active elements and on* attributes are left out
* (defense in depth on top of the sandbox) and each embed placeholder
* becomes a thumbnail card, never a player. The stored value never changes.
*/
export function previewBody(html: string, labels: Labels): string {
const doc = inert()
const template = doc.createElement('template')
template.innerHTML = html
const root = template.content
for (const el of Array.from(root.querySelectorAll(DROPPED_ELEMENTS))) el.remove()
for (const el of Array.from(root.querySelectorAll('*'))) {
for (const attr of Array.from(el.attributes)) {
if (attr.name.toLowerCase().startsWith('on')) el.removeAttribute(attr.name)
}
}
for (const figure of Array.from(root.querySelectorAll('figure[data-embed]'))) {
const provider = figure.getAttribute('data-embed') ?? ''
const id = figure.getAttribute('data-id') ?? ''
// Exactly the editor's placeholder, the same test as the Embed node.
if (figure.attributes.length !== 2 || figure.childNodes.length !== 0) continue
if (!isEmbedProvider(provider) || !EMBED_PROVIDERS[provider].idPattern.test(id)) continue
const card = doc.createElement('figure')
card.className = 'embed-card'
const img = doc.createElement('img')
img.setAttribute('src', youtubeThumbnail(id))
img.setAttribute('alt', '')
img.setAttribute('referrerpolicy', 'no-referrer')
img.setAttribute('loading', 'lazy')
img.setAttribute('width', '480')
img.setAttribute('height', '360')
const caption = doc.createElement('figcaption')
caption.textContent = `${labels.embedCard}: ${EMBED_PROVIDERS[provider].name} ${id}`
card.append(img, caption)
figure.replaceWith(card)
}
const out = doc.createElement('div')
out.append(root)
return out.innerHTML
}
/** previewStyles returns the post-body typography with concrete values. */
function previewStyles(c: PreviewColors, font: string): string {
const mono = '"DM Mono", ui-monospace, SFMono-Regular, Menlo, monospace'
return [
`body{margin:0;padding:16px 20px;background:${c.surface};color:${c.text};font-family:${font};font-size:16px;line-height:1.7;overflow-wrap:anywhere}`,
'.post-body{max-width:72ch;margin-left:auto;margin-right:auto}',
'h1,h2,h3,h4{font-weight:600;margin:1.4em 0 0.5em}',
'h1{font-size:1.875em}h2{font-size:1.5em}h3{font-size:1.25em}h4{font-size:1.1em}',
'p,ul,ol,blockquote,table,figure,pre{margin:0 0 1em}',
'ul{list-style:disc;padding-left:1.5em}ol{list-style:decimal;padding-left:1.5em}',
`a{color:${c.primary};text-decoration:underline}`,
`blockquote{border-left:3px solid ${c.borderStrong};padding-left:1em;color:${c.muted}}`,
`code{padding:0 4px;border-radius:4px;background:${c.subtle};font-family:${mono};font-size:0.9em}`,
`pre{padding:8px 12px;border-radius:8px;background:${c.subtle};font-family:${mono};overflow:auto}`,
'pre code{padding:0;background:transparent}',
`hr{border:0;border-top:1px solid ${c.borderStrong}}`,
'img{max-width:100%;height:auto}',
'table{border-collapse:collapse;width:100%}',
`td,th{border:1px solid ${c.borderStrong};padding:4px 8px;vertical-align:top}`,
`th{background:${c.subtle};font-weight:600;text-align:left}`,
`figcaption{font-size:13px;color:${c.muted};margin-top:4px}`,
`.embed-card{max-width:480px;border:1px solid ${c.border};border-radius:8px;overflow:hidden;background:${c.subtle}}`,
'.embed-card img{display:block;width:100%;height:auto;aspect-ratio:16/9;object-fit:cover}',
'.embed-card figcaption{padding:8px 12px;margin:0}',
].join('\n')
}
/**
* buildPreviewDocument returns the srcdoc for the preview frame: a full
* document whose head starts with the charset, the CSP and the no-referrer
* metas, then one stylesheet with the post-body typography, and whose body
* wraps previewBody(html) in article.post-body. CSS variables do not cross
* into the frame, so the stylesheet holds concrete colours.
*/
export function buildPreviewDocument(html: string, options: PreviewOptions): string {
const lang = options.lang && LANG.test(options.lang) ? ` lang="${options.lang}"` : ''
const colors = { ...PREVIEW_DEFAULTS.colors, ...options.colors }
const font = options.fontFamily || PREVIEW_DEFAULTS.fontFamily
return (
`<!doctype html><html${lang}><head>` +
'<meta charset="utf-8">' +
`<meta http-equiv="Content-Security-Policy" content="${PREVIEW_CSP}">` +
'<meta name="referrer" content="no-referrer">' +
`<style>${previewStyles(colors, font)}</style>` +
'</head><body>' +
`<article class="post-body">${previewBody(html, options.labels)}</article>` +
'</body></html>'
)
}