Skip to content

Commit 34ea45d

Browse files
fix(portal): show when Copy Markdown fails (#419)
1 parent 53e7e7d commit 34ea45d

4 files changed

Lines changed: 163 additions & 9 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { useEffect, useState } from 'react';
2+
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
3+
import { Check, Copy, X } from 'lucide-react';
4+
import { copyMarkdown } from '@/lib/copy-markdown';
5+
6+
type CopyState = 'idle' | 'copying' | 'copied' | 'failed';
7+
8+
const LABELS: Record<CopyState, string> = {
9+
idle: 'Copy Markdown',
10+
copying: 'Copy Markdown',
11+
copied: 'Copied Markdown',
12+
failed: 'Copy failed'
13+
};
14+
15+
export function MarkdownCopyButton({ markdownUrl, base }: Readonly<{ markdownUrl: string; base: string }>) {
16+
const [state, setState] = useState<CopyState>('idle');
17+
18+
useEffect(() => {
19+
if (state !== 'copied' && state !== 'failed') return;
20+
const timeout = window.setTimeout(() => setState('idle'), 1500);
21+
return () => window.clearTimeout(timeout);
22+
}, [state]);
23+
24+
const copy = () => {
25+
setState('copying');
26+
copyMarkdown(markdownUrl, base).then(
27+
() => setState('copied'),
28+
(error: unknown) => {
29+
console.error(error);
30+
setState('failed');
31+
}
32+
);
33+
};
34+
35+
return (
36+
<>
37+
<button
38+
type="button"
39+
disabled={state === 'copying'}
40+
onClick={copy}
41+
className={buttonVariants({
42+
color: 'secondary',
43+
size: 'sm',
44+
className: 'gap-2 [&_svg]:size-3.5 [&_svg]:text-fd-muted-foreground'
45+
})}
46+
>
47+
<StateIcon state={state} />
48+
{LABELS[state]}
49+
</button>
50+
{/* A focused button's own text change isn't reliably announced. */}
51+
<output className="sr-only">{state === 'copied' || state === 'failed' ? LABELS[state] : ''}</output>
52+
</>
53+
);
54+
}
55+
56+
function StateIcon({ state }: Readonly<{ state: CopyState }>) {
57+
if (state === 'copied') return <Check />;
58+
if (state === 'failed') return <X />;
59+
return <Copy />;
60+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export async function copyMarkdown(rootRelativeUrl: string, base: string): Promise<void> {
2+
const url = `${base.replace(/\/$/, '')}${rootRelativeUrl}`;
3+
// Handed over unresolved, so Safari still counts the write as part of the click.
4+
const markdown = fetchMarkdown(url);
5+
await Promise.all([navigator.clipboard.write([new ClipboardItem({ 'text/plain': markdown })]), markdown]);
6+
}
7+
8+
async function fetchMarkdown(url: string): Promise<string> {
9+
const response = await fetch(url);
10+
if (!response.ok) throw new Error(`Failed to fetch ${url}: ${response.status}`);
11+
return response.text();
12+
}

‎portal-template/src/routes/$.tsx‎

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,12 @@ import { portal } from '@/lib/portal';
88
import { absoluteUrl, canonicalLink } from '@/lib/seo';
99
import { useFumadocsLoader } from 'fumadocs-core/source/client';
1010
import { usePathname } from 'fumadocs-core/framework';
11-
import {
12-
DocsBody,
13-
DocsDescription,
14-
DocsPage,
15-
DocsTitle,
16-
MarkdownCopyButton,
17-
ViewOptionsPopover
18-
} from 'fumadocs-ui/layouts/notebook/page';
11+
import { DocsBody, DocsDescription, DocsPage, DocsTitle, ViewOptionsPopover } from 'fumadocs-ui/layouts/notebook/page';
1912
import { staticFunctionMiddleware } from '@tanstack/start-static-server-functions';
2013
import { Suspense, use, type ReactNode } from 'react';
2114
import { useMDXComponents } from '@/components/mdx';
2215
import { OpenAPIPage } from '@/components/api-page';
16+
import { MarkdownCopyButton } from '@/components/markdown-copy-button';
2317
import { slimOpenAPIPageProps } from '@/lib/openapi-slim';
2418

2519
const rootRoute = getRouteApi('__root__');
@@ -142,7 +136,8 @@ function Content({
142136
<DocsTitle>{page.title}</DocsTitle>
143137
<DocsDescription>{page.description}</DocsDescription>
144138
<div className="flex flex-row gap-2 items-center border-b -mt-4 pb-6">
145-
<MarkdownCopyButton markdownUrl={markdownUrl} />
139+
{/* Keyed so a new page starts at "Copy Markdown", not at the last page's result. */}
140+
<MarkdownCopyButton key={markdownUrl} markdownUrl={markdownUrl} base={import.meta.env.BASE_URL} />
146141
{/* Sends the reader to an external AI vendor, so a portal published under someone
147142
else's brand can turn it off. */}
148143
{portal.pageActions ? <ViewOptionsPopover markdownUrl={markdownUrl} pageUrl={pageUrl} /> : null}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { expect } from 'chai';
2+
import sinon from 'sinon';
3+
import { setImmediate } from 'node:timers/promises';
4+
import { copyMarkdown } from '../../portal-template/src/lib/copy-markdown';
5+
6+
class FakeClipboardItem {
7+
constructor(readonly items: Record<string, Promise<string>>) {}
8+
}
9+
10+
describe('copying a page as Markdown', () => {
11+
let clipboard: string;
12+
13+
beforeEach(() => {
14+
clipboard = 'before';
15+
sinon.define(globalThis, 'ClipboardItem', FakeClipboardItem);
16+
sinon.define(globalThis.navigator, 'clipboard', {
17+
write: async ([item]: FakeClipboardItem[]) => {
18+
clipboard = await item.items['text/plain'];
19+
}
20+
});
21+
});
22+
23+
afterEach(() => sinon.restore());
24+
25+
it('puts the Markdown fetched under the base path on the clipboard', async () => {
26+
const fetch = sinon.stub(globalThis, 'fetch').resolves(new globalThis.Response('# Intro'));
27+
28+
await copyMarkdown('/guides/intro.md', '/docs/');
29+
30+
expect(fetch.firstCall.args[0]).to.equal('/docs/guides/intro.md');
31+
expect(clipboard).to.equal('# Intro');
32+
});
33+
34+
it('fetches from the root when the site is hosted there', async () => {
35+
const fetch = sinon.stub(globalThis, 'fetch').resolves(new globalThis.Response('# Intro'));
36+
37+
await copyMarkdown('/guides/intro.md', '/');
38+
39+
expect(fetch.firstCall.args[0]).to.equal('/guides/intro.md');
40+
});
41+
42+
it('rejects on an error response and leaves the clipboard as it was', async () => {
43+
sinon.stub(globalThis, 'fetch').resolves(new globalThis.Response('<html>Not Found</html>', { status: 404 }));
44+
45+
const error = await copyMarkdown('/guides/missing.md', '/').catch((reason: unknown) => reason);
46+
47+
expect(error).to.be.an('error').with.property('message', 'Failed to fetch /guides/missing.md: 404');
48+
expect(clipboard).to.equal('before');
49+
});
50+
51+
it('rejects when the request gets no response and leaves the clipboard as it was', async () => {
52+
const offline = new TypeError('Failed to fetch');
53+
sinon.stub(globalThis, 'fetch').rejects(offline);
54+
55+
const error = await copyMarkdown('/guides/offline.md', '/').catch((reason: unknown) => reason);
56+
57+
expect(error).to.equal(offline);
58+
expect(clipboard).to.equal('before');
59+
});
60+
61+
it('rejects when the clipboard refuses the write', async () => {
62+
sinon.stub(globalThis, 'fetch').resolves(new globalThis.Response('# Denied'));
63+
const denied = new globalThis.DOMException('Write permission denied.', 'NotAllowedError');
64+
sinon.stub(globalThis.navigator.clipboard, 'write').rejects(denied);
65+
66+
const error = await copyMarkdown('/guides/denied.md', '/').catch((reason: unknown) => reason);
67+
68+
expect(error).to.equal(denied);
69+
expect(clipboard).to.equal('before');
70+
});
71+
72+
it('rejects with the refusal, and leaves no rejection unhandled, when the clipboard refuses before the fetch fails', async () => {
73+
sinon.stub(globalThis, 'fetch').resolves(new globalThis.Response('<html>Not Found</html>', { status: 404 }));
74+
const blocked = new globalThis.DOMException('Blocked by a permissions policy.', 'NotAllowedError');
75+
sinon.stub(globalThis.navigator.clipboard, 'write').rejects(blocked);
76+
const unhandled: unknown[] = [];
77+
const record = (reason: unknown) => unhandled.push(reason);
78+
process.prependListener('unhandledRejection', record);
79+
80+
const error = await copyMarkdown('/guides/blocked.md', '/').catch((reason: unknown) => reason);
81+
await setImmediate();
82+
process.off('unhandledRejection', record);
83+
84+
expect(error).to.equal(blocked);
85+
expect(unhandled).to.be.empty;
86+
});
87+
});

0 commit comments

Comments
 (0)