Skip to content

Add share menu with copy-as-markdown export - #257

Merged
benvinegar merged 2 commits into
mainfrom
claude/card-share-copy-menu-yxc7qk
Aug 18, 2026
Merged

Add share menu with copy-as-markdown export#257
benvinegar merged 2 commits into
mainfrom
claude/card-share-copy-menu-yxc7qk

Conversation

@benvinegar

Copy link
Copy Markdown
Member

Consolidates the card's three separate export actions (copy link, open in new tab, open as PNG) into a single Share menu, and adds Copy as markdown to export the entire post as portable markdown.

Changes

Server-side markdown flattening (server/postMarkdown.ts):

  • New module that converts posts to portable markdown, runtime-agnostic (no node: imports) so it runs on both Node and Cloudflare Workers
  • Each surface kind flattens honestly: text kinds become fenced blocks, images become image links, html/unknown kinds degrade to links back to the surface rather than dumping markup
  • Includes a unified diff generator for diff surfaces sent as before/after file pairs, with proper handling of end-of-file newlines so patches apply cleanly with git apply
  • Exports postToMarkdown() and surfaceToMarkdown() for converting posts/surfaces, plus stripAnsi() for cleaning terminal output and unifiedDiff() for generating patches

Viewer share menu (viewer/src/ShareMenu.tsx):

  • New component replacing three separate footer icons with one labeled Share button that opens a fixed-position menu
  • Menu rows: Copy link, Copy as markdown, Open in new tab, Open as image
  • Markdown fetch is prefetched when menu opens so it's usually ready by the time the row is clicked
  • Handles clipboard writes including the async case (promise-valued ClipboardItem for Safari's gesture-tied permissions)
  • Full keyboard navigation (arrow keys, Home/End, Escape to close)
  • Menu dismisses on scroll/resize/outside click and refocuses the button on close

Clipboard utility (viewer/src/clipboard.ts):

  • New writeClipboard() function that handles both ready strings and pending promises
  • Uses promise-valued ClipboardItem where supported (Safari) to claim permission synchronously inside the gesture, falling back to awaiting for older Chromium

API route (server/app.ts):

  • New GET /api/posts/:id/markdown endpoint that serves the post as portable markdown with absolute links (so it survives pasting elsewhere)

Tests:

  • Comprehensive test suite (test/postMarkdown.test.ts) covering all surface kinds, edge cases like backticks in code blocks, line number tracking for excerpts, and unified diff correctness validated against git apply
  • Clipboard tests (viewer/test/clipboard.test.ts) for both ready and pending text, promise-valued ClipboardItem fallback behavior
  • E2E test (e2e/viewer.spec.ts) verifying the menu appears, markdown copies correctly, and keyboard/click dismissal works
  • API test (test/api.test.ts) confirming the markdown endpoint returns proper content-type and absolute links

UI updates:

  • Removed three separate icon buttons from card footer (copy link, open, screenshot)
  • Added Share button with new ShareIcon and MarkdownIcon
  • Styled share menu with proper z-index layering, hover states, and disabled state for unavailable actions (e.g., screenshots on servers without Browser Rendering)

Notable details

  • Markdown is fetched on-demand but prefetched when the menu opens, so the copy action usually completes instantly
  • The flattener is runtime-agnostic so it can run on the Worker DO as well as Node, enabling the CLI/HTTP tiers to access markdown exports
  • Diff surfaces without a pre-computed patch are reconstructed from before/after file pairs using an LCS-based algorithm that stays bounded for large files
  • Terminal output has ANSI escapes stripped so pasted output reads as plain text
  • HTML surfaces never dump their markup into the markdown document—they degrade to a link back to the surface instead, maintaining the security boundary

https://claude.ai/code/session_01EnBQ4vuJfaVNmQsZLYZQRT

…-markdown

The card footer carried three icons that all meant "take this elsewhere" —
copy link, open in a new tab, open as a PNG — with no room for a fourth. They
become rows in one labelled Share menu, joined by Copy as markdown.

The flattening is served rather than derived in the browser: a hydrated post
omits sandboxed surface bodies (apiViews.ts), so only the server sees the whole
post. GET /api/posts/:id/markdown returns the same text for curl and the CLI.
Prose stays prose, code/diff/terminal/json/mermaid become fenced blocks, images
become image links, and an html surface links back rather than dumping markup.

The menu is position: fixed off the button's rect because .card is
overflow: hidden, so scroll and resize dismiss it rather than let it drift.
The markdown is prefetched when the menu opens and handed to the clipboard as
a promise, so a slow fetch still copies inside the user gesture (Safari).
…pply

Review of the previous commit found the hand-rolled unified diff (the fallback
for a diff surface sent as before/after file pairs) never emitted
`\ No newline at end of file`. Fuzzing it against `git apply` failed 368 of 600
random pairs: a file with no trailing newline produced a patch git rejects, and
the asymmetric case applied but silently reintroduced a newline the "after"
text never had.

Three fixes, one root cause — the end-of-file newline was not modelled:
- The flag is now part of a line's identity, so a last line with no newline
  after it cannot match one with. That also fixes a trailing-newline-only
  change reading as an empty diff and degrading to a link.
- splitLines() treats an empty file as zero lines rather than one blank one,
  so an insertion into an empty file is `@@ -0,0 +1,N @@`, not `@@ -1,1 @@`.
- codeHeading() reuses splitLines() instead of a raw split, so an excerpt whose
  code ends in a newline no longer overstates its line range by one.

Tests now apply each patch with `git apply` and compare the result byte for
byte, instead of regex-matching hunk text — the old assertions passed against
patches git refuses. Same fuzz run over the fixed code: 482 applied, 0 failures.
@benvinegar
benvinegar merged commit 14a8ea3 into main Aug 18, 2026
13 of 14 checks passed
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.

1 participant