refactor(content): render user content through one extensible pipeline
Every text a user wrote went through the same chain repeated at ten call sites — sanitize, embedMedia, smilies — and each step parsed and serialized the whole text again, on top of the parse of the sanitizer. The list of steps was hard-coded in MediaEmbed, so a module could not add one.
ContentRendererInterface replaces that chain. The text is parsed once and every step works on that tree: render(), renderOrNull() for a source that can have nothing to show, and toPlainText() for previews and titles. ContentContext carries the HTML policy and whether the smilies of the staff render.
Two extension points, tagged by PSRContainerFactory so a module only has to implement the interface: ContentTransformerInterface is a step of the pipeline (priority orders them), EmbedProviderInterface turns the URL of a media site into a player. A provider names a Twig template rather than building markup, so a theme overrides the look of a player by mirroring its path.
Drops imangazaliev/didom and simba77/ckeditor-media-embed: PHP 8.4 ships an HTML5 parser in ext-dom, and HtmlFragment parses a post as the contents of body, which is where a fragment belongs. The wrapper-cutting hacks around libxml go with them.
Fixed along the way: the smilies no longer render inside attributes or inside code and pre, since the step walks text nodes; the YouTube player is sized with the Bootstrap 5 ratio classes instead of the Bootstrap 4 ones the theme has not carried for years; the video id is matched against what YouTube accepts before it reaches the address of an iframe; the preview link wraps the image alone rather than the caption with it; a JSON payload of news comments carries the markup as a string.
New guide: .agents/content-pipeline.md.