- htmlGate.ts mirrors the four editor-output rules of the Go policy:
figure data-embed (^youtube$) and data-id (^[A-Za-z0-9_-]{11}$),
img alt with any text, ol start (integer)
- embeds.ts: EMBED_PROVIDERS (youtube on youtube-nocookie.com),
EMBED_PLACEHOLDER and expandEmbeds
- JournalPostBody and JournalCategoryHeader render
expandEmbeds(sanitizeJournalHtml(html)); payload transforms only sanitize
- .journal-embed styles (16 / 9 block, borderless iframe)
- tests: byte copy of the editor corpus, mirror and pipeline cases;
acme hello-acme carries a placeholder and the SSR smoke checks the iframe
- README Embeds section and decision note Policy amendment
64 lines
2.8 KiB
TypeScript
64 lines
2.8 KiB
TypeScript
// Embed expansion for Journal HTML. The Blog Editor (Journal plugin,
|
|
// assets/src/blogeditor/embed.ts) never stores an iframe: it writes the
|
|
// placeholder <figure data-embed="youtube" data-id="ID"></figure>, and the
|
|
// plugin's bluemonday policy (classes/format_html.go) keeps exactly that, with
|
|
// data-embed matching ^youtube$ and data-id matching ^[A-Za-z0-9_-]{11}$.
|
|
// htmlGate.ts mirrors those two rules, so the placeholder survives the layer's
|
|
// sanitizer too.
|
|
//
|
|
// expandEmbeds is the very last step before v-html, after sanitizeJournalHtml:
|
|
// its input is always sanitizer output, and its output is never sanitized
|
|
// again (the sanitizer strips every iframe, including the one built here). The
|
|
// regex matches the serialization sanitize-html gives the placeholder:
|
|
// attribute order kept, double quotes, empty figure. Only a known provider
|
|
// with a valid id becomes an iframe, whose src is a fixed host plus that id;
|
|
// anything else is returned unchanged and stays an inert empty figure.
|
|
|
|
/** One allowlisted embed provider. */
|
|
export type EmbedProvider = {
|
|
/** The id a placeholder must match to expand. */
|
|
id: RegExp
|
|
/** The iframe src for a validated id. */
|
|
src: (id: string) => string
|
|
/** The iframe's accessible title. */
|
|
title: string
|
|
}
|
|
|
|
/** Providers a placeholder may name. YouTube only, on its privacy-enhanced host. */
|
|
export const EMBED_PROVIDERS: Readonly<Record<string, EmbedProvider>> = Object.freeze({
|
|
youtube: {
|
|
id: /^[A-Za-z0-9_-]{11}$/,
|
|
src: (id: string) => `https://www.youtube-nocookie.com/embed/${id}`,
|
|
title: 'YouTube video',
|
|
},
|
|
})
|
|
|
|
/**
|
|
* The placeholder exactly as the editor writes it and sanitize-html
|
|
* serializes it. Group 1 is the provider, group 2 the id; neither may hold a
|
|
* quote or an angle bracket.
|
|
*/
|
|
export const EMBED_PLACEHOLDER = /<figure data-embed="([^"<>]*)" data-id="([^"<>]*)"><\/figure>/g
|
|
|
|
/**
|
|
* Replaces every placeholder whose provider is in EMBED_PROVIDERS and whose
|
|
* id passes the provider's pattern with a figure holding the provider's
|
|
* iframe. Every other match is returned unchanged. Empty input gives "".
|
|
* Call it on sanitizeJournalHtml output only, and never sanitize its result.
|
|
*/
|
|
export function expandEmbeds(html: string | null | undefined): string {
|
|
if (!html) return ''
|
|
return html.replace(EMBED_PLACEHOLDER, (match: string, provider: string, id: string) => {
|
|
if (!Object.hasOwn(EMBED_PROVIDERS, provider)) return match
|
|
const p = EMBED_PROVIDERS[provider]!
|
|
if (!p.id.test(id)) return match
|
|
return (
|
|
`<figure class="journal-embed journal-embed--${provider}">` +
|
|
`<iframe src="${p.src(id)}" title="${p.title}" loading="lazy" ` +
|
|
'referrerpolicy="strict-origin-when-cross-origin" ' +
|
|
'allow="encrypted-media; picture-in-picture; fullscreen" allowfullscreen></iframe>' +
|
|
'</figure>'
|
|
)
|
|
})
|
|
}
|