- 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.
198 lines
8.2 KiB
TypeScript
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>'
|
|
)
|
|
}
|