Files
vue-journal-layer/app/utils/embeds.ts
Jakub Zych a6ee443885 feat: keep Blog Editor markup in the sanitizer and expand Journal embed placeholders into privacy-enhanced iframes
- 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
2026-10-10 12:19:00 +02:00

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>'
)
})
}