diff --git a/Sources/termio/Editor/MarkdownReaderRenderer.swift b/Sources/termio/Editor/MarkdownReaderRenderer.swift index 3bddfac8..bdaead04 100644 --- a/Sources/termio/Editor/MarkdownReaderRenderer.swift +++ b/Sources/termio/Editor/MarkdownReaderRenderer.swift @@ -293,7 +293,10 @@ enum MarkdownReaderRenderer { .reader pre code { background: none; padding: 0; font-size: 13.5px; line-height: 1.6; } /* The hljs theme ships its own background, padding and base color for `.hljs`; the block's look belongs to this stylesheet, so only the token colors survive. */ - .reader img { max-width: 100%; margin: 0.6em 0; border-radius: 6px; } + /* `height: auto` against the pixel height GitHub writes onto a pasted ``; a + clamped width with that height still set stretches the picture vertically. */ + .reader img, .reader video { max-width: 100%; height: auto; margin: 0.6em 0; + border-radius: 6px; } /* `` is on the raw-HTML whitelist and READMEs use it for shortcuts; without a key cap it reads as ordinary text. */ .reader kbd { font: 0.78em var(--font-mono); background: var(--soft); diff --git a/Sources/termio/Info/MarkdownHTML.swift b/Sources/termio/Info/MarkdownHTML.swift index 5f3e402b..fa30d1a0 100644 --- a/Sources/termio/Info/MarkdownHTML.swift +++ b/Sources/termio/Info/MarkdownHTML.swift @@ -800,16 +800,18 @@ enum HTMLSanitizer { "thead", "tbody", "tfoot", "tr", "td", "th", "caption", "blockquote", "dl", "dt", "dd", "kbd", "q", "samp", "var", "hr", "s", "summary", "details", "figure", "figcaption", "abbr", "cite", "dfn", "mark", "small", "span", "time", "wbr", - "picture", "source", + "picture", "source", "video", ] /// The useful subset of GitHub's `:all` attribute list plus its per-element ones. /// `style`, `class`, `id`, and event handlers are intentionally absent — GitHub - /// strips those too. + /// strips those too. `autoplay` is absent by choice: a conversation that starts + /// playing the moment it opens is hostile in a pane the user only glanced at. private static let allowedAttributes: Set = [ "href", "src", "srcset", "media", "alt", "title", "align", "valign", "width", "height", "border", "colspan", "rowspan", "open", "dir", "lang", "start", "type", "checked", "disabled", "datetime", "cite", "cellpadding", "cellspacing", + "controls", "poster", "muted", "loop", "playsinline", "preload", ] /// `http`/`https`/`mailto`/relative, GitHub's protocol whitelist for href/src. diff --git a/Sources/termio/Issues/IssuesView.swift b/Sources/termio/Issues/IssuesView.swift index 28dee0b3..661fa631 100644 --- a/Sources/termio/Issues/IssuesView.swift +++ b/Sources/termio/Issues/IssuesView.swift @@ -823,7 +823,7 @@ private struct CapsuleSwitch: View { /// `documentMode`, so raw HTML (bot comments, ``/``/tables) renders /// through the GitHub-mirroring `HTMLSanitizer` whitelist the way GitHub itself /// does, colored from the live `TraceTheme`. -private enum IssueDetailHTML { +enum IssueDetailHTML { static func page(_ detail: IssueDetail, theme: TraceTheme) -> String { let s = detail.summary let labels = s.labels.map { @@ -850,12 +850,49 @@ private enum IssueDetailHTML {
\(body)
\(comments) + """ - return routeImages(page) + return routeImages(embedAttachments(page)) } - /// Point every ``/`` at the token-authenticating loader so a private + /// GitHub uploads a video as a bare attachment URL on its own line and turns it into a + /// player when it renders; the markdown only ever carries the link, so the pane makes the + /// same substitution. Images never take this shape — the composer writes them as `` + /// or `![…]()` — so a paragraph that is nothing but an attachment link is a video. + static func embedAttachments(_ html: String) -> String { + let link = #/

[^<]*

/# + return html.replacing(link) { match in + let url = String(match.1) + guard isAttachment(url) else { return String(match.0) } + return "" + } + } + + /// An uploaded attachment: today's `github.com/user-attachments/assets/` (typeless + /// in the URL) and the older `*.githubusercontent.com` files, which name their format. + private static func isAttachment(_ url: String) -> Bool { + guard let parsed = URL(string: url), let host = parsed.host else { return false } + let path = parsed.path.lowercased() + if host == "github.com" { return path.hasPrefix("/user-attachments/assets/") } + guard host.hasSuffix("githubusercontent.com") else { return false } + return [".mp4", ".mov", ".webm", ".m4v"].contains { path.hasSuffix($0) } + } + + /// The attachment URL carries no type, so a bare link that turns out to be an image + /// would render as a dead player. Media that fails to load is one, so swap in the + /// image rather than leaving the black box. + private static let attachmentFallbackScript = """ + for (const video of document.querySelectorAll("video.attachment")) { + video.addEventListener("error", () => { + const image = document.createElement("img"); + image.src = video.src; + video.replaceWith(image); + }); + } + """ + + /// Point every ``/`