Skip to content

Commit 66c4768

Browse files
authored
Add CopyLLMTxtMenu component for enhanced markdown copying functionality (#647)
* Add CopyLLMTxtMenu component for enhanced markdown copying functionality - Introduced a new Svelte component, CopyLLMTxtMenu, to facilitate copying markdown content for LLMs. - Updated conversion scripts to include the new component in both MD and RST to MDX transformations. - Adjusted related test cases to ensure proper integration of the new component. * Add functionality to insert CopyLLMTxtMenu before the first H1 in markdown conversion - Implemented a new function to add a CopyLLMTxtMenu component before the first H1 heading in the converted markdown. - Updated the conversion logic to ensure the menu is only added if it is not already present. - Enhanced tests to verify the correct insertion of the menu and to check for existing instances. * foramt
1 parent 2e40b00 commit 66c4768

11 files changed

+462
-4
lines changed

kit/src/lib/CopyLLMTxtMenu.svelte

Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
<script lang="ts">
2+
import { onDestroy, tick } from "svelte";
3+
import { copyToClipboard } from "./copyToClipboard";
4+
import IconCopy from "./IconCopy.svelte";
5+
import IconCaret from "./IconCaret.svelte";
6+
import IconCode from "./IconCode.svelte";
7+
import IconArrowUpRight from "./IconArrowUpRight.svelte";
8+
import IconOpenAI from "./IconOpenAI.svelte";
9+
import IconAnthropic from "./IconAnthropic.svelte";
10+
11+
export let label = "Copy page";
12+
export let markdownDescription = "Copy page as Markdown for LLMs";
13+
export let containerClass = "";
14+
export let containerStyle = "";
15+
16+
const isClient = typeof window !== "undefined";
17+
const hasDocument = typeof document !== "undefined";
18+
19+
function resolveSourceUrl() {
20+
if (!isClient || !window.location) return;
21+
const current = window.location.href.replace(/#.*$/, "");
22+
return current.endsWith(".md") ? current : `${current}.md`;
23+
}
24+
25+
const SOURCE_URL = resolveSourceUrl();
26+
let encodedPrompt: string | null = null;
27+
28+
let open = false;
29+
let copied = false;
30+
let triggerEl: HTMLDivElement | null = null;
31+
let menuEl: HTMLDivElement | null = null;
32+
let menuStyle = "";
33+
let closeTimeout: ReturnType<typeof setTimeout> | null = null;
34+
let sourceMarkdown: string | null = null;
35+
let sourceFetchPromise: Promise<string> | null = null;
36+
37+
type ExternalOption = {
38+
label: string;
39+
description: string;
40+
icon: "chatgpt" | "claude";
41+
buildUrl: () => string;
42+
};
43+
44+
const externalOptions: ExternalOption[] = [
45+
{
46+
label: "Open in ChatGPT",
47+
description: "Ask questions about this page",
48+
icon: "chatgpt",
49+
buildUrl: () =>
50+
encodedPrompt
51+
? `https://chatgpt.com/?hints=search&q=${encodedPrompt}`
52+
: "https://chatgpt.com",
53+
},
54+
{
55+
label: "Open in Claude",
56+
description: "Ask questions about this page",
57+
icon: "claude",
58+
buildUrl: () =>
59+
encodedPrompt ? `https://claude.ai/new?q=${encodedPrompt}` : "https://claude.ai/new",
60+
},
61+
];
62+
63+
const baseMenuItemClass =
64+
"cursor-pointer text-sm group relative w-full select-none outline-none flex items-center gap-1 px-1.5 py-1.5 rounded-xl text-left transition border-gray-200 bg-white hover:shadow-inner dark:border-gray-850 dark:bg-gray-950 dark:text-gray-200 dark:hover:bg-gray-800";
65+
66+
function ensurePromptAndUrl() {
67+
if (encodedPrompt || !isClient) return;
68+
encodedPrompt = encodeURIComponent(`Read from ${SOURCE_URL} so I can ask questions about it.`);
69+
}
70+
71+
async function fetchSourceMarkdown(): Promise<string> {
72+
if (!isClient || typeof fetch !== "function" || !SOURCE_URL) return "";
73+
ensurePromptAndUrl();
74+
if (sourceMarkdown) return sourceMarkdown;
75+
if (!sourceFetchPromise) {
76+
sourceFetchPromise = fetch(SOURCE_URL, { headers: { Accept: "text/plain" } })
77+
.then((response) => {
78+
if (!response.ok) {
79+
throw new Error(`Failed to fetch source content: ${response.status}`);
80+
}
81+
return response.text();
82+
})
83+
.then((text) => {
84+
sourceMarkdown = text;
85+
return text;
86+
})
87+
.catch((error) => {
88+
console.error("Unable to fetch remote markdown", error);
89+
sourceMarkdown = "";
90+
return "";
91+
});
92+
}
93+
return sourceFetchPromise;
94+
}
95+
96+
async function copyMarkdown() {
97+
if (!isClient) {
98+
console.warn("Clipboard API unavailable");
99+
return;
100+
}
101+
102+
try {
103+
const markdown = await fetchSourceMarkdown();
104+
if (!markdown) {
105+
console.warn("Nothing to copy");
106+
return;
107+
}
108+
109+
const hasNavigatorClipboard =
110+
typeof navigator !== "undefined" &&
111+
!!navigator.clipboard &&
112+
typeof navigator.clipboard.writeText === "function";
113+
114+
if (hasNavigatorClipboard) {
115+
await navigator.clipboard.writeText(markdown);
116+
} else if (hasDocument) {
117+
copyToClipboard(markdown);
118+
} else {
119+
console.warn("Clipboard API unavailable");
120+
return;
121+
}
122+
123+
copied = true;
124+
await tick();
125+
if (closeTimeout) clearTimeout(closeTimeout);
126+
closeTimeout = setTimeout(() => {
127+
copied = false;
128+
}, 2000);
129+
} catch (error) {
130+
console.error("Failed to write to clipboard", error);
131+
}
132+
}
133+
134+
function openMenu() {
135+
open = true;
136+
if (isClient && triggerEl) {
137+
void tick().then(() => {
138+
if (!triggerEl) return;
139+
const rect = triggerEl.getBoundingClientRect();
140+
const gutter = 10;
141+
const minWidth = Math.max(rect.width + 80, 220);
142+
const right = Math.max(window.innerWidth - rect.right, gutter);
143+
menuStyle = `top:${rect.bottom + gutter}px;right:${right}px;min-width:${minWidth}px;`;
144+
});
145+
}
146+
}
147+
148+
function closeMenu() {
149+
open = false;
150+
}
151+
152+
function toggleMenu() {
153+
open ? closeMenu() : openMenu();
154+
}
155+
156+
function openMarkdownPreview() {
157+
if (!isClient) return;
158+
window.open(SOURCE_URL, "_blank", "noopener,noreferrer");
159+
closeMenu();
160+
}
161+
162+
function launchExternal(option: ExternalOption) {
163+
ensurePromptAndUrl();
164+
if (isClient) {
165+
window.open(option.buildUrl(), "_blank", "noopener,noreferrer");
166+
}
167+
closeMenu();
168+
}
169+
170+
function handleWindowPointer(event: MouseEvent) {
171+
if (!open || !isClient) return;
172+
const targetNode = event.target as Node;
173+
if (menuEl?.contains(targetNode) || triggerEl?.contains(targetNode)) {
174+
return;
175+
}
176+
closeMenu();
177+
}
178+
179+
function handleWindowKeydown(event: KeyboardEvent) {
180+
if (event.key === "Escape" && open) {
181+
closeMenu();
182+
}
183+
}
184+
185+
function handleWindowResize() {
186+
if (open) closeMenu();
187+
}
188+
189+
function handleWindowScroll() {
190+
if (open) closeMenu();
191+
}
192+
193+
onDestroy(() => {
194+
if (closeTimeout) clearTimeout(closeTimeout);
195+
});
196+
</script>
197+
198+
<svelte:window
199+
on:mousedown={handleWindowPointer}
200+
on:keydown={handleWindowKeydown}
201+
on:resize={handleWindowResize}
202+
on:scroll={handleWindowScroll}
203+
/>
204+
205+
<div
206+
class={`items-center shrink-0 min-w-[156px] justify-end ml-auto flex${
207+
containerClass ? ` ${containerClass}` : ""
208+
}`}
209+
style={containerStyle}
210+
>
211+
<div bind:this={triggerEl} class="inline-flex rounded-xl">
212+
<button
213+
on:click={copyMarkdown}
214+
class="inline-flex items-center gap-1.5 h-9 px-3.5 text-sm font-medium text-gray-800 border border-r-0 rounded-l-xl border-gray-200 bg-white hover:shadow-inner dark:border-gray-850 dark:bg-gray-950 dark:text-gray-200 dark:hover:bg-gray-800"
215+
aria-live="polite"
216+
>
217+
<span class="inline-flex items-center justify-center rounded-md p-1">
218+
<IconCopy classNames="w-4 h-4" />
219+
</span>
220+
<span>{copied ? "Copied" : label}</span>
221+
</button>
222+
<button
223+
on:click={toggleMenu}
224+
class="inline-flex items-center justify-center w-9 h-9 disabled:pointer-events-none text-sm text-gray-500 hover:text-gray-700 dark:hover:text-white rounded-r-xl border border-l transition border-gray-200 bg-white hover:shadow-inner dark:border-gray-850 dark:bg-gray-950 dark:text-gray-200 dark:hover:bg-gray-800"
225+
aria-haspopup="menu"
226+
aria-expanded={open}
227+
aria-label={open ? "Close copy menu" : "Open copy menu"}
228+
>
229+
<IconCaret
230+
classNames={`transition-transform text-gray-400 overflow-visible ${
231+
open ? "rotate-180" : "rotate-0"
232+
}`}
233+
/>
234+
</button>
235+
</div>
236+
237+
{#if open}
238+
<div
239+
class="fixed inset-0 z-40"
240+
aria-hidden="true"
241+
style="background: transparent;"
242+
on:click={closeMenu}
243+
></div>
244+
<div
245+
bind:this={menuEl}
246+
role="menu"
247+
class="fixed z-50 backdrop-blur-xl rounded-xl max-h-[420px] overflow-y-auto p-1 border flex flex-col border-gray-200 bg-white dark:border-gray-850 dark:bg-gray-950 dark:text-gray-200"
248+
style={menuStyle}
249+
aria-label="Copy menu"
250+
>
251+
<button
252+
role="menuitem"
253+
on:click={() => {
254+
copyMarkdown();
255+
closeMenu();
256+
}}
257+
class={baseMenuItemClass}
258+
>
259+
<div class="border border-gray-200 dark:border-gray-850 rounded-lg p-1.5">
260+
<IconCopy classNames="w-4 h-4 shrink-0" />
261+
</div>
262+
<div class="flex flex-col px-1">
263+
<div class="text-sm font-medium text-gray-800 dark:text-gray-300 flex items-center gap-1">
264+
{label}
265+
</div>
266+
<div class="text-xs text-gray-600 dark:text-gray-400">
267+
{markdownDescription}
268+
</div>
269+
</div>
270+
</button>
271+
272+
<button
273+
role="menuitem"
274+
on:click={() => {
275+
openMarkdownPreview();
276+
}}
277+
class={baseMenuItemClass}
278+
>
279+
<div class="border border-gray-200 dark:border-gray-850 rounded-lg p-1.5">
280+
<IconCode classNames="w-4 h-4 shrink-0" />
281+
</div>
282+
<div class="flex flex-col px-1">
283+
<div class="text-sm font-medium text-gray-800 dark:text-gray-300 flex items-center gap-1">
284+
View as Markdown
285+
<IconArrowUpRight classNames="w-4 h-4 text-gray-500 dark:text-gray-300 shrink-0" />
286+
</div>
287+
<div class="text-xs text-gray-600 dark:text-gray-400">View this page as plain text</div>
288+
</div>
289+
</button>
290+
291+
{#each externalOptions as option}
292+
<button role="menuitem" on:click={() => launchExternal(option)} class={baseMenuItemClass}>
293+
<div class="border border-gray-200 dark:border-gray-850 rounded-lg p-1.5">
294+
{#if option.icon === "chatgpt"}
295+
<IconOpenAI classNames="w-4 h-4 shrink-0" />
296+
{:else}
297+
<IconAnthropic classNames="w-4 h-4 shrink-0" />
298+
{/if}
299+
</div>
300+
<div class="flex flex-col px-1">
301+
<div
302+
class="text-sm font-medium text-gray-800 dark:text-gray-200 flex items-center gap-1"
303+
>
304+
{option.label}
305+
</div>
306+
<div class="text-xs text-gray-600 dark:text-gray-400">
307+
{option.description}
308+
</div>
309+
</div>
310+
<IconArrowUpRight classNames="w-4 h-4 text-gray-500 dark:text-gray-300" />
311+
</button>
312+
{/each}
313+
</div>
314+
{/if}
315+
</div>

kit/src/lib/EditOnGithub.svelte

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
<script lang="ts">
2+
import IconCode from "./IconCode.svelte";
23
export let source: string = "";
34
</script>
45

@@ -7,7 +8,6 @@
78
href={source}
89
target="_blank"
910
>
10-
<span>&lt;</span>
11-
<span>&gt;</span>
12-
<span><span class="underline ml-1.5">Update</span> on GitHub</span>
11+
<IconCode classNames="mr-1" />
12+
<span><span class="underline">Update</span> on GitHub</span>
1313
</a>

kit/src/lib/IconAnthropic.svelte

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<script lang="ts">
2+
export let classNames = "";
3+
</script>
4+
5+
<svg
6+
class={classNames}
7+
fill="currentColor"
8+
height="1em"
9+
viewBox="0 0 24 24"
10+
width="1em"
11+
xmlns="http://www.w3.org/2000/svg"
12+
>
13+
<title>Anthropic</title>
14+
<path
15+
d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z"
16+
></path>
17+
</svg>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<script lang="ts">
2+
export let classNames = "";
3+
</script>
4+
5+
<svg
6+
class={classNames}
7+
xmlns="http://www.w3.org/2000/svg"
8+
width="24"
9+
height="24"
10+
viewBox="0 0 24 24"
11+
fill="none"
12+
stroke="currentColor"
13+
stroke-width="1.75"
14+
stroke-linecap="round"
15+
stroke-linejoin="round"
16+
>
17+
<path d="M7 7h10v10"></path>
18+
<path d="M7 17 17 7"></path>
19+
</svg>

kit/src/lib/IconCaret.svelte

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<script lang="ts">
2+
export let classNames = "";
3+
</script>
4+
5+
<svg
6+
class={classNames}
7+
width="1em"
8+
height="1em"
9+
viewBox="0 0 12 7"
10+
fill="none"
11+
xmlns="http://www.w3.org/2000/svg"
12+
>
13+
<path d="M1 1L6 6L11 1" stroke="currentColor" />
14+
</svg>

0 commit comments

Comments
 (0)