Skip to content

feat(file-viewer): open HTML pages, SVG pictures, notebooks and more document formats - #3573

Open
LordMelkor wants to merge 13 commits into
block:mainfrom
LordMelkor:clod/file-viewer-formats
Open

LordMelkor wants to merge 13 commits into
block:mainfrom
LordMelkor:clod/file-viewer-formats

Conversation

@LordMelkor

Copy link
Copy Markdown
Contributor

Opens HTML pages, SVG pictures, notebooks and more document formats in the bundled File viewer, and draws math and plugin code blocks in files, a follow-up to #3338 (scope). It ports Media viewer v1.6. It was held back from #3338 to keep that PR reviewable (joahg/buzz-app#3), and is now rebased onto main, with Markdown parts drawn through main's PartMarkdown and shared code fence. Stacked on #3572: review that first; this PR's own changes are the last eleven commits.

What changes for people

  • HTML files show as a page, with an Outline when the page has three or more headings. Nothing on the page runs, and only images shared in the community load. Links open the way they do in messages, and # links move within the page. Source shows the HTML. A page that can't be drawn safely shows only its source.
  • SVG files show as a picture, with Source for the SVG.
  • Markdown and MDX keep the HTML written in them, with anything that could run or send data removed. MDX shows without its import, export and JSX code. Flat front matter shows as a Properties list above the document. GitHub alerts (> [!NOTE] and the rest) show as titled notes. A $$ block is a display equation.
  • Math and plugin-drawn code: a fenced block that a Buzz code-block plugin draws (Math equations, Mermaid) is drawn in a file the same way as in a message. Inline TeX from reStructuredText, AsciiDoc and LaTeX goes to the inline renderer the Math equations plugin registers. Without those plugins, the code shows.
  • Jupyter notebooks show their Markdown cells, code and saved outputs. reStructuredText, AsciiDoc and LaTeX files open as documents.
  • JSON with lines too long to read shows indented. Copy text still copies the file as saved.
  • A PDF saved under another name (for example scan.txt) opens as a PDF.
  • Word and ODT hidden text shows and hides as the author set it. Word's vanish follows ECMA-376 §17.7.3: the nearest setting in each style's basedOn chain wins, style levels combine as toggles, a document-default true stays on, and direct run formatting is absolute. In ODT, text:a uses its own style, a list item whose only paragraph is hidden leaves no empty bullet and keeps its text:start-value restart, and a visible link that holds only a bookmark is kept.
  • Links in message text to a shared file open it in the File viewer, the same as its card. Only community media addresses whose names give a known format are claimed. Other links, and Cmd/Ctrl-clicks, still go to the browser. While the relay reconnects, a linked file's tab shows Loading.
  • Outline keys held with Alt, Ctrl, Cmd or Shift are left to the browser. A key that can't move past the first or last row leaves no focus request waiting.

How

  • Data (Sigil, first four commits):

    • page.ts sanitizes an HTML file into a page with numbered links and images.
    • svg.ts checks an SVG.
    • formats.ts recognizes the new formats and their labels.
    • load.ts sniffs PDF bytes.
    • prepare-text.ts prepares JSON, MDX and front matter.
    • file-markdown.ts admits sanitized raw HTML and marks display math and alerts.
    • office/ reads notebooks, rst, AsciiDoc and LaTeX through openOffice.
    • markdown-extensions.ts is the file Markdown grammar that files and notebook cells share.
    • office/document.ts resolves Word and ODT hidden text (two commits after the UI ones).
  • UI (Clod, commits five and six, and the last three commits):

    • PageFile.tsx and page-frame.ts draw the page in an iframe with sandbox="allow-same-origin allow-scripts" and a srcdoc. Its Content Security Policy comes first in the head: script-src 'none', form-action 'none', and img-src limited to blob: and the community images the page names.
      • The viewer sets image sources and takes link clicks itself, through the DOM prototypes, so names on the page can't shadow them.
      • allow-scripts is there only so WebKit runs the viewer's own listeners; the policy still runs no script from the page.
      • The SVG picture is an img from a blob: URL.
    • host-renderers.tsx gives the viewer conversation.codeBlocks and conversation.inline through a context.
      • Markdown parts: PartMarkdown passes codeBlocks through MarkdownDocument's capabilities, so a fenced block a plugin claims is drawn by main's shared code fence, with its copy action. It adds the alert div from file-components.tsx. rehypeFileMarkdown writes the fence's language back as a lowercase language-* class, so ```Mermaid is claimed too.
      • Office, notebook and text trees: file-components.tsx draws pre through main's CodeBlock (language from the parser's data-language), inline TeX through the inline renderer, and alerts.
    • media-urls.ts takes useMediaUrls out of OfficeView, so pages and Office files share it.
    • target.ts linkedFile turns a community media link into a file target; the panel matches it beside buzz-file: targets, so the host's link click opens it.
    • Outline takes a current() callback, so documents, slides and pages share it.
  • New dependencies, pinned exactly and listed in NOTICE.md and check-icons:

    • parse5 7.3.0 and rehype-raw 7.0.0
    • remark-frontmatter 5.0.0 and remark-math 6.0.0
    • remark-rehype 11.1.2, moved from a dev dependency to a dependency

    The lockfile has no registry-specific URLs.

State machines

Audited with the state-machines skill:

  • Page view: drawn | source | undrawn. A page that can't be drawn starts in undrawn. Leaving the page also moves to undrawn, which shows the source with a note and no switch back.
  • Page frame: wired only when the loaded srcdoc matches the current one.
    • about:srcdoc means the page loaded.
    • about:blank is the empty frame before the page, so it's ignored.
    • Any other document means the frame navigated or can't be read, so it goes to undrawn.
    • The wiring's cleanup removes its listeners.
  • SVG picture: one object URL per text, revoked on cleanup.

Efficiency

GPT Astra (High) reviewed Sigil's data layer as an efficiency oracle; its last recheck scored 9/10 with no open finding.

Evidence

At the head of this PR:

  • tsc, Biome, pnpm lint (including check-icons) and pnpm run design:check are clean.
  • Vitest src/bundled/file-viewer and src/features/markdown: 56 files, 1,944 tests passed. The new UI tests include:
    • the frame policy and the not-drawn cases
    • image sources and link clicks, including names that shadow DOM properties
    • community, embedded and remote images
    • #, Buzz and external links
    • the outline jump and Source switching
    • a frame that navigates away or can't be read
    • SVG pictures, with their object URLs revoked
    • math and a claimed fence drawn by plugins, including a capitalized fence language, and the code without them (LaTeX and Markdown)
    • Properties and alerts
    • JSON indentation and a renamed PDF
    • outline keys held with a modifier, and a linked .md while the relay reconnects
  • Playwright file-viewer.spec.mjs and message-files-polish.spec.mjs, Chromium and WebKit: 46 passed (before the capitalized-fence commit, which Vitest covers). New cases:
    • HTML page: the author's styles apply, the community image loads, the remote image is never requested, a # link scrolls in the frame, and Source and Page switch.
    • SVG: drawn as a picture, with Source.
    • Notebook: shows its saved output.
    • scan.txt holding a PDF: opens as a PDF.
  • An agent review of the port onto main found one regression (capitalized fence languages), fixed in the last commit, and one dead override, removed.
  • The pre-push hook (tsc, the full Vitest suite, design-system, clippy) passed at this head.

Known limitations

  • Pages draw only images shared in the community, so the File viewer loads nothing from outside it.
  • A linked file's tab is titled with its media address's name (the file hash and extension), because a link carries no file name.
  • Inline $…$ in Markdown stays text, as on GitHub. Inline math comes from rst, AsciiDoc and LaTeX.

🤖 Generated with Claude Code

LordMelkor and others added 13 commits November 15, 2026 08:40
Read Office, OpenDocument, RTF and iWork files through bounded format
admission, XML and archive parsing. Keep the generated document, sheet,
slide and preview models independent of the viewer UI.

Co-authored-by: Sigil <06114f072cf21e50fcd4133ab21346cdb1f74ee16d853a9729995fa6c03a251c@buzz.block.builderlab.xyz>
Signed-off-by: LordMelkor <kray@squareup.com>
Documents show with their pictures, tables and charts, and a Word file
with three or more headings has an Outline. Spreadsheets show a sheet at
a time with a tab for each, and Copy as TSV copies the open sheet.
Presentations show each slide fitted to the tab, with speaker notes.
Numbers files open as sheets too. Older .doc and .ppt files show their
text only, and Pages and Keynote files the preview picture saved in
them. Nothing in a file runs.

Co-authored-by: Clod <c7b21872e9ddb79ca62967d0e21e6a4518a669eb04648e963cdfb2426e62fafb@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: LordMelkor <kray@squareup.com>
Rebuild uploaded HTML as page data and validate SVG documents for picture and source views. Reuse the document parser and image admission helpers with exact parser dependency versions.

Co-authored-by: Sigil <06114f072cf21e50fcd4133ab21346cdb1f74ee16d853a9729995fa6c03a251c@buzz.block.builderlab.xyz>
Signed-off-by: LordMelkor <kray@squareup.com>
Route HTML, SVG and text document formats by name or MIME. Honor media MIME types for text names and decide renamed PDF limits after the first five bytes.

Co-authored-by: Sigil <06114f072cf21e50fcd4133ab21346cdb1f74ee16d853a9729995fa6c03a251c@buzz.block.builderlab.xyz>
Signed-off-by: LordMelkor <kray@squareup.com>
Add saved notebook outputs and reStructuredText, AsciiDoc and LaTeX documents behind the lazy Office reader. Prepare JSON and MDX once per file and preserve raw HTML structures and anchors across bounded Markdown parts.

Co-authored-by: Sigil <06114f072cf21e50fcd4133ab21346cdb1f74ee16d853a9729995fa6c03a251c@buzz.block.builderlab.xyz>
Signed-off-by: LordMelkor <kray@squareup.com>
Prepare flat YAML properties before indexing and use the same Markdown grammar for files and notebook cells. Keep display equations intact within the existing budget, preserve alert types across part boundaries and handle every supported line ending.

Co-authored-by: Sigil <06114f072cf21e50fcd4133ab21346cdb1f74ee16d853a9729995fa6c03a251c@buzz.block.builderlab.xyz>
Signed-off-by: LordMelkor <kray@squareup.com>
… viewer

HTML files draw as a page in a sandboxed srcdoc frame whose policy runs no
script and loads only community images; links open as they do in messages,
and Source switches to the HTML. SVG files draw as a picture from a blob URL.
JSON with overlong lines shows indented, MDX without its code, and Markdown
keeps its sanitized HTML. Fenced code and inline TeX go to Buzz's code-block
and inline renderers (Math equations, Mermaid), with the code as fallback.
A renamed PDF opens as a PDF.

Co-authored-by: Clod <c7b21872e9ddb79ca62967d0e21e6a4518a669eb04648e963cdfb2426e62fafb@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: LordMelkor <kray@squareup.com>
A Markdown file's flat front matter shows as a Properties list above the
document, and GitHub alerts in files and notebook cells show as titled
notes. Markdown files use the shared file grammar, so a `$$` block reaches
Buzz's math renderer.

Co-authored-by: Clod <c7b21872e9ddb79ca62967d0e21e6a4518a669eb04648e963cdfb2426e62fafb@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: LordMelkor <kray@squareup.com>
Compose Word vanish as a style toggle while retaining absolute direct
formatting. Apply ODT hyperlink styles, omit hidden-only list items, and
retain visible bookmark-only links without restoring hidden targets.

Co-authored-by: Sigil <06114f072cf21e50fcd4133ab21346cdb1f74ee16d853a9729995fa6c03a251c@buzz.block.builderlab.xyz>
Signed-off-by: LordMelkor <kray@squareup.com>
Resolve basedOn chains by override before combining Word style levels.
Keep document-default hidden text and absolute direct formatting, and
carry hidden ODT item restarts into following visible numbering.

Co-authored-by: Sigil <06114f072cf21e50fcd4133ab21346cdb1f74ee16d853a9729995fa6c03a251c@buzz.block.builderlab.xyz>
Signed-off-by: LordMelkor <kray@squareup.com>
A key held with Alt, Ctrl, Cmd or Shift no longer moves outline focus,
and a key at either end of the outline no longer leaves a focus request
waiting that would take focus back when its row is drawn again.

Co-authored-by: Clod <c7b21872e9ddb79ca62967d0e21e6a4518a669eb04648e963cdfb2426e62fafb@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: LordMelkor <kray@squareup.com>
A link in message text to a file shared in this community now opens in
the File viewer, the same as the file's card. Only community media
addresses whose names give a known format are claimed; other links and
modified clicks stay with the browser.

Co-authored-by: Clod <c7b21872e9ddb79ca62967d0e21e6a4518a669eb04648e963cdfb2426e62fafb@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: LordMelkor <kray@squareup.com>
Markdown parts now use the shared code fence, which reads the fence's
`language-*` class. The sanitizer keeps only lowercase classes, so a
```Mermaid or ```Math fence lost its plugin. The file grammar now writes
the lowercase language back to the class. The inline-math override on
Markdown parts is removed, since only Office trees mark inline math.

Co-authored-by: Clod <c7b21872e9ddb79ca62967d0e21e6a4518a669eb04648e963cdfb2426e62fafb@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: LordMelkor <kray@squareup.com>

@joahg joahg left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 This account isn't approving UI changes. This PR adds new File viewer views (HTML pages, SVG, notebooks, more document formats), so it needs a design or core-team reviewer. Because HTML pages from untrusted attachments render in a same-origin frame, I focused on security and reviewed only this PR's eleven commits on top of #3572.

I found no way to run script. Both the frame's meta policy (default-src 'none', script-src 'none', base-uri 'none', form-action 'none') and the app's own CSP would have to be bypassed. On top of that:

  • PAGE_PREFIX pins what can come before the policy.
  • markerAt refuses <noscript> and stray <meta>.
  • page.ts drops base, meta, link, script, iframe/object/embed, foreignObject, animate/set, on*, srcset, target, action/formaction, and javascript:/vbscript:/data: values.
  • stablePage requires a stable re-serialization.
  • Navigating away sends the frame to undrawn.
  • The prototype-based listeners can't be shadowed by page names.
  • The Markdown, MDX and notebook paths run rehype-sanitize after rehype-raw, with an explicit allowlist. Notebook application/javascript outputs aren't rendered.

Blocking finding (P1): the frame's img-src allows the whole buzz-media: scheme. cspSource (page-frame.ts) returns the bare scheme for a native media URL, and page-frame.test.ts pins img-src blob: buzz-media:. On macOS and Linux, any page that shows one community image therefore lets its own CSS (background: url(buzz-media://localhost/…), <input type=image>, etc.) load any buzz-media:// URL, not just the images the page was approved to show. The native media handler doesn't keep its own community list; it accepts any HTTPS origin with a /media/<sha256> path and sends signed media auth with the request. So opening a page, with no click, can make the app send an authenticated request to a host outside the user's communities. That leaks the user's IP and pubkey, and it contradicts "only images shared in the community load". Suggested fix: have the viewer fetch each approved image and hand the frame blob: URLs only, then drop buzz-media: (and the web proxy path, whose ?url= the policy also can't restrict) from img-src. Separately, binding the native media handler to saved communities would be worthwhile defense in depth.

P2:

  • isSvg (svg.ts) only checks well-formedness. Drawing SVG through <img src=blob:> is inert, but an "open image in new window" or drag-out of that blob URL would load it as a same-origin document with its scripts. Stripping script, on* and foreignObject (or rasterizing) would close that.
  • page.ts keeps href on non-<a> elements, e.g. <math href>, which WebKit and Firefox follow on click. The frame then navigates (it correctly goes undrawn), but the click still reaches the remote host outside the viewer's link handling. Dropping href/src except on a/area/img and in-document SVG # refs would fix it.

P3:

  • The page-link fallback in followLink (PageFile.tsx) opens a raw target=_blank link instead of going through MessageLink.
  • markdown-schema.ts also sets clobber: [] / clobberPrefix: "", the same unprefixed-id issue I noted on #3572.
  • font-src data: admits attacker fonts, which is a small extra parser surface.

Not verified: I reviewed the code only and didn't run the app or test the WebKit and Chromium context menus for the SVG case. I didn't check how the web build's media proxy validates its url parameter.

@kazemek21

Copy link
Copy Markdown
Contributor

Instructions to get the build to run:
buzz://message?channel=5909b3f1-fbe6-4f63-9cd2-06440e58b4a1&id=41538c84fc77a64cc0d124407b31805e3d485d331654a91b5185d62dc9245b05&thread=71a8d710754540d5c562226840389021badb3398752eb76d821d2a5b6c2b16ba

Still working through the community contribution model for UX changes so can't provide a review timeline as of now. Additional context can be found here:
buzz://message?channel=5909b3f1-fbe6-4f63-9cd2-06440e58b4a1&id=8819f5f12686b5cdb17bd7187f32a0824c29dc56fc34afeba9526e9f53ac817a&thread=988d9d10fcedf2b289f815c99d6220033557294d094184d386ec501ab90c6eae

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants