Repository navigation
fix(portal): show when Copy Markdown fails - #419
Conversation
fumadocs-ui's MarkdownCopyButton rejects on an error response, but its useCopyButton has no rejection path, so a failed copy shows nothing. Upstream won't change it (fuma-nama/fumadocs#3634), so the portal owns the button: a failed fetch or clipboard write shows "Failed. Try Again." and leaves the button enabled for the retry. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ESLint's no-undef rejected the bare globals, failing posttest. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A focused button's own text change isn't reliably announced, so the result goes to a visually hidden status region instead of aria-live on the button. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Matches fumadocs' button: later clicks copy the cached Markdown without fetching it again. A failed fetch isn't cached, so the next click retries. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A refused write rejects with the browser's error, for both a fresh and a cached copy, and Markdown fetched before the refusal is kept, so the next click copies it without fetching again. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The portal's button hard-coded its labels in English. It now reads them through @fuma-translate/react under fumadocs' own keys and note, so translations given to fumadocs reach it too. The package is pinned to the version fumadocs-ui resolves, so both share one copy and one translation context, and it is linked into the build project like the template's other dependencies. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
SonarCloud (S6819) prefers the element to the status role; <output> is a live region with that role, so screen readers announce it the same way. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
It is appended to the base path as is, so a relative URL would build a broken one. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
mrafnadeem-apimatic
left a comment
There was a problem hiding this comment.
The fix works: checked in Chrome against portal serve with a copy of test/resources/portal-inputs/default. Success, failure, the reset on moving to another page, and the screen-reader status all behave as the description says. Requesting changes for the following, each with an inline suggestion:
- A failed fetch can still be an unhandled rejection when the clipboard refuses the write before reading it (
copy-markdown.ts), with a test for it. - Reset "Copy failed" back to "Copy Markdown" after 1.5 s, like "Copied Markdown", so the reader can see they can try again. The screen-reader status clears with it (
markdown-copy-button.tsx). - Drop
@fuma-translate/react. The portal supplies no translations, so it only adds a dependency and a version rule nothing checks (markdown-copy-button.tsx). - Comments: three are longer than the one line of why that
.ai/instructions.mdallows.
The description needs to follow:
- The table's failure row ("button stays enabled for a retry") should say the button returns to Copy Markdown after 1.5 s.
- How to test: step 6 says "click again" after step 5 has moved to another page, so it copies a page that never failed. Retrying on the page that failed is never exercised by hand. Suggested steps 4–6:
4. On a page you haven't copied yet, click Copy Markdown. Expected: Copy failed, the console logsFailed to fetch …: 404, the clipboard is unchanged, and 1.5 s later the button reads Copy Markdown again.
5. Runwindow.fetch = realFetchand click again on the same page. Expected: Copied Markdown, and pasting gives that page's Markdown.
6. Run the step 3 snippet again, click Copy Markdown on another page you haven't copied, and pick a different page from the sidebar before the 1.5 s pass. Expected: the new page's button reads Copy Markdown, not Copy failed. - If (3) is taken, remove the "New dependency:
@fuma-translate/react" section.
With all the suggestions applied: the copy-markdown and packaging tests pass (28 passing), the new test fails on the current code, and in Chrome a failure resets to Copy Markdown after 1.5 s, with no unhandled rejection in the blocked-clipboard case.
…te first When the clipboard refused the write without reading the item, nothing listened to the fetch, so a failed fetch was an unhandled rejection. Awaiting both rejects with whichever fails first. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…@fuma-translate/react The portal supplies no translations, so the package only added a dependency, a linked build dependency and a version rule nothing checked. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e "Copied Markdown" Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
@mrafnadeem-apimatic thanks for the review. All of it is addressed:
The description follows: the failure row says the button returns to Copy Markdown after 1.5 s, the Labels row says they're written in English, the "New dependency" section is gone, and How to test uses your steps 4–6.
|
| } | ||
|
|
||
| async function fetchMarkdown(url: string): Promise<string> { | ||
| const response = await fetch(url); |
There was a problem hiding this comment.
Raw fetch can throw and should be wrapped in a try-catch block. The catch case indicates some network error.
There was a problem hiding this comment.
A failed `fetch` is already handled: it rejects `fetchMarkdown`, `Promise.all` passes that rejection on to `copyMarkdown`, and the button's rejection handler logs it and shows Copy failed. The `ClipboardItem` gets the same rejected promise, so the clipboard stays as it was. A try/catch here could only rethrow, since the rejection is what cancels the write.
There was no test for a request that gets no response, so I've added one: `rejects when the request gets no response and leaves the clipboard as it was`.
| async function fetchMarkdown(url: string): Promise<string> { | ||
| const response = await fetch(url); | ||
| if (!response.ok) throw new Error(`Failed to fetch ${url}: ${response.status}`); | ||
| const markdown = await response.text(); |
There was a problem hiding this comment.
This can throw with exceptions and should have a try-catch block to handle the edge cases.
There was a problem hiding this comment.
Same path as `fetch` above: if `response.text()` rejects, so do `fetchMarkdown` and `copyMarkdown`, and the button logs the error and shows Copy failed with the clipboard unchanged. A try/catch would have to rethrow to keep that behaviour, so it would add nothing.
|
|
||
| async function fetchMarkdown(url: string): Promise<string> { | ||
| const response = await fetch(url); | ||
| if (!response.ok) throw new Error(`Failed to fetch ${url}: ${response.status}`); |
There was a problem hiding this comment.
Why throw? This is one of the scenarios where we need to return a value and show a Failed message/state.
There was a problem hiding this comment.
The throw is what shows the failed state. This promise is handed to ClipboardItem, and whatever it resolves with is written to the clipboard, so returning a value would replace the reader's clipboard with it. Rejecting is the only way to cancel the write, which is why fumadocs-ui started throwing here in 16.15.16. The button turns the rejection into Copy failed (markdown-copy-button.tsx).
copyMarkdown could catch it and resolve with a success flag instead, but the button would behave exactly the same, so I've kept the rejection.
…r portal serve A page edit under portal serve hot-updates the route without a full reload, so the cache kept copying the pre-edit Markdown. The browser's HTTP cache already serves a repeat click. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
#419's copyMarkdown put the base in front of the page's path by hand, a second copy of the rule withBasePath holds, and its button took the base as a prop read from import.meta.env. It now goes through withBasePath, which reads Vite's base itself, so the button takes the page's path alone. The optional base stays for the tests, which check the address under /docs/. The plan records the copy button as the template's own, with only the popover given a full address, and the order agreed for #419 and #424. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
🎉 This PR is included in version 2.0.0-beta.4 🎉 The release is available on: Your semantic-release bot 📦🚀 |



Follow-up to #413 (review comment on Copy Markdown failures).
The bug
Since fumadocs-ui 16.15.16,
MarkdownCopyButtonthrows when the page's.mdrequest fails, which keeps an error page off the clipboard. But itsuseCopyButtonhook has no rejection path, so a failed copy is an unhandled rejection: no check mark, no error, and the clipboard keeps what it had. A denied clipboard write fails the same way.Upstream closed fuma-nama/fumadocs#3634 as not planned, and suggested owning the button instead.
The change
The content route now renders the portal's own
MarkdownCopyButton(src/components/markdown-copy-button.tsx), with the same look as fumadocs':aria-liveon the button<output>, since a focused button's own text change isn't reliably announceduseTranslations, notepage actionswithBasePathreads Vite'sBASE_URLBASE_URLin as a prop, so the button doesn't read it itselfportal servecopies the Markdown as it is after an editThe fetch and the clipboard write live in
src/lib/copy-markdown.ts. The fetch is handed toClipboardItemunresolved, as fumadocs does, so Safari still treats the write as part of the click.ViewOptionsPopoverstill comes from fumadocs-ui.Tests
test/portal-template/copy-markdown.test.tscheckscopyMarkdownwithfetch,ClipboardItemandnavigator.clipboardstubbed:/docs/and/) and writes the Markdown;How to test
pnpm installandpnpm build, thennode bin/run.js portal serve --input <copy of test/resources/portal-inputs/default>./guides/intro, click Copy Markdown and paste. You should get the page's Markdown, and the button should show Copied Markdown..mdrequests return 404:Failed to fetch …: 404, the clipboard is unchanged, and 1.5 s later the button reads Copy Markdown again.window.fetch = realFetchand click again on the same page. Expected: Copied Markdown, and pasting gives that page's Markdown.Checked
pnpm test(tsc, mocha, ESLint): 1570 passing, 46 pending.APIMATIC_E2E=1portal build suite: 46 passing, including "type-checks against the packages it is built with".<output>torole="status") is addressed.🤖 Generated with Claude Code