Skip to content

Commit 16f1583

Browse files
committed
docs: add button for copy and view
This adds 2 buttons to copy for LLM and view as Markdown Change-Id: I59fbd6cb70f0c4d4c409b4f25ebcd587211abdf7
1 parent a05db2b commit 16f1583

File tree

6 files changed

+349
-7
lines changed

6 files changed

+349
-7
lines changed
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
---
2+
import { FiCopy } from 'react-icons/fi';
3+
import { getMarkdownPath } from '../util/getMarkdownPath';
4+
5+
export interface Props {
6+
size?: 'default' | 'compact';
7+
}
8+
9+
const { size = 'default' } = Astro.props;
10+
const classes = ['docs-toolbar-button', 'copy-for-llm-button'];
11+
if (size === 'compact') {
12+
classes.push('docs-toolbar-button--compact');
13+
}
14+
const markdownPath = getMarkdownPath(Astro.url.pathname);
15+
---
16+
17+
<button
18+
class={classes.join(' ')}
19+
type="button"
20+
data-copy-for-llm
21+
data-markdown-path={markdownPath}
22+
data-state="idle"
23+
aria-label="Copy page Markdown for LLM"
24+
>
25+
<span class="docs-toolbar-button__icon" aria-hidden="true">
26+
<FiCopy size={16} focusable="false" aria-hidden="true" />
27+
</span>
28+
<span class="docs-toolbar-button__label" aria-live="polite">Copy for LLM</span>
29+
</button>
30+
31+
<style>
32+
:global(.docs-toolbar-button) {
33+
display: inline-flex;
34+
align-items: center;
35+
gap: 0.5rem;
36+
padding: 0.4rem 0.95rem;
37+
border-radius: 999px;
38+
border: 1px solid var(--chakra-colors-gray-200);
39+
background: var(--chakra-colors-white);
40+
color: inherit;
41+
font-size: 0.9rem;
42+
font-weight: 500;
43+
line-height: 1.2;
44+
cursor: pointer;
45+
transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
46+
white-space: nowrap;
47+
}
48+
49+
:global(.docs-toolbar-button:hover),
50+
:global(.docs-toolbar-button:focus-visible) {
51+
border-color: var(--chakra-colors-gray-300);
52+
background: var(--chakra-colors-gray-50);
53+
outline: none;
54+
}
55+
56+
:global(.docs-toolbar-button:active) {
57+
background: var(--chakra-colors-gray-100);
58+
}
59+
60+
:global(.docs-toolbar-button--compact) {
61+
padding-inline: 0.8rem;
62+
font-size: 0.85rem;
63+
}
64+
65+
:global(.docs-toolbar-button__icon svg) {
66+
display: block;
67+
}
68+
69+
:global(.docs-toolbar-button__label) {
70+
line-height: 1;
71+
}
72+
73+
@media (max-width: var(--docs-toolbar-mobile-breakpoint)) {
74+
:global(.docs-toolbar-button) {
75+
width: 100%;
76+
justify-content: center;
77+
}
78+
}
79+
80+
:global(.theme-dark) .docs-toolbar-button {
81+
border-color: rgba(255, 255, 255, 0.2);
82+
background: rgba(255, 255, 255, 0.05);
83+
color: var(--chakra-colors-whiteAlpha-900);
84+
}
85+
86+
:global(.theme-dark) .docs-toolbar-button:hover,
87+
:global(.theme-dark) .docs-toolbar-button:focus-visible {
88+
background: rgba(255, 255, 255, 0.12);
89+
}
90+
91+
:global(.theme-dark) .docs-toolbar-button:active {
92+
background: rgba(255, 255, 255, 0.18);
93+
}
94+
</style>
95+
96+
<script is:inline>
97+
(() => {
98+
if (typeof window === 'undefined') {
99+
return;
100+
}
101+
const globalKey = '__copyForLLMInitialized__';
102+
const win = window;
103+
if (win[globalKey]) {
104+
return;
105+
}
106+
win[globalKey] = true;
107+
108+
const LABELS = {
109+
default: 'Copy for LLM',
110+
preparing: 'Preparing…',
111+
copying: 'Copying…',
112+
success: 'Copied!',
113+
error: 'Copy failed',
114+
};
115+
const RESET_DELAY = 2000;
116+
/** @type {WeakMap<Element, number>} */
117+
const resetTimers = new WeakMap();
118+
119+
const setLabel = (button, state) => {
120+
const label = button.querySelector('.docs-toolbar-button__label');
121+
if (label) {
122+
label.textContent = LABELS[state] ?? LABELS.default;
123+
}
124+
};
125+
126+
const clearResetTimer = (button) => {
127+
const existing = resetTimers.get(button);
128+
if (existing) {
129+
window.clearTimeout(existing);
130+
resetTimers.delete(button);
131+
}
132+
};
133+
134+
// Fallback payload when raw markdown cannot be fetched: pull title, subtitle, and article body text.
135+
const buildRenderedPayload = () => {
136+
const article = document.querySelector('article#article');
137+
const title = document.querySelector('.content-title')?.textContent?.trim();
138+
const subtitle = document.querySelector('.content-subtitle')?.textContent?.trim();
139+
const mainFallback = document.querySelector('#main-content') ?? document.body;
140+
const textSource = article ?? mainFallback;
141+
const bodyText = textSource?.innerText?.trim();
142+
if (!bodyText) {
143+
return '';
144+
}
145+
return [title, subtitle, bodyText]
146+
.filter(Boolean)
147+
.join('\n\n')
148+
.trim();
149+
};
150+
151+
const fetchPageSource = async (url) => {
152+
const response = await fetch(url, {
153+
headers: {
154+
Accept: 'text/markdown, text/plain;q=0.9, */*;q=0.1',
155+
},
156+
cache: 'no-store',
157+
});
158+
if (!response.ok) {
159+
throw new Error(`Source fetch failed: ${response.status}`);
160+
}
161+
return (await response.text()).trim();
162+
};
163+
164+
const fallbackCopy = (text) => {
165+
const textarea = document.createElement('textarea');
166+
textarea.value = text;
167+
textarea.style.position = 'fixed';
168+
textarea.style.opacity = '0';
169+
textarea.style.pointerEvents = 'none';
170+
document.body.appendChild(textarea);
171+
textarea.focus();
172+
textarea.select();
173+
const success = document.execCommand('copy');
174+
textarea.remove();
175+
return success;
176+
};
177+
178+
const resetLater = (button) => {
179+
clearResetTimer(button);
180+
const timeoutId = window.setTimeout(() => {
181+
resetTimers.delete(button);
182+
if (button.dataset.state === 'idle') {
183+
setLabel(button, 'default');
184+
}
185+
}, RESET_DELAY);
186+
resetTimers.set(button, timeoutId);
187+
};
188+
189+
const attachBlurHandler = (button) => {
190+
if (button.dataset.copyForLlmBlurAttached === 'true') {
191+
return;
192+
}
193+
button.addEventListener('blur', () => {
194+
if (button.dataset.state === 'idle') {
195+
clearResetTimer(button);
196+
setLabel(button, 'default');
197+
}
198+
});
199+
button.dataset.copyForLlmBlurAttached = 'true';
200+
};
201+
202+
document.addEventListener('click', async (event) => {
203+
const target = event.target instanceof Element ? event.target.closest('[data-copy-for-llm]') : null;
204+
if (!target) {
205+
return;
206+
}
207+
const button = target;
208+
attachBlurHandler(button);
209+
if (button.dataset.state === 'busy') {
210+
return;
211+
}
212+
clearResetTimer(button);
213+
button.dataset.state = 'busy';
214+
setLabel(button, 'preparing');
215+
let payload = '';
216+
const markdownPath = button.dataset.markdownPath;
217+
try {
218+
if (!markdownPath) {
219+
throw new Error('Missing markdown path');
220+
}
221+
payload = await fetchPageSource(markdownPath);
222+
} catch (fetchError) {
223+
console.warn('Falling back to rendered content for Copy for LLM', fetchError);
224+
payload = buildRenderedPayload();
225+
}
226+
if (!payload) {
227+
setLabel(button, 'error');
228+
button.dataset.state = 'idle';
229+
resetLater(button);
230+
return;
231+
}
232+
setLabel(button, 'copying');
233+
try {
234+
if (navigator.clipboard?.writeText) {
235+
await navigator.clipboard.writeText(payload);
236+
} else if (!fallbackCopy(payload)) {
237+
throw new Error('execCommand copy failed');
238+
}
239+
setLabel(button, 'success');
240+
} catch (error) {
241+
console.error('Copy for LLM failed', error);
242+
setLabel(button, 'error');
243+
} finally {
244+
button.dataset.state = 'idle';
245+
resetLater(button);
246+
}
247+
});
248+
249+
document.querySelectorAll('[data-copy-for-llm]').forEach((button) => attachBlurHandler(button));
250+
})();
251+
</script>

src/components/PageContent/PageContent.astro

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,35 @@ const suppressTitle = content.suppressTitle;
9292
.content > section {
9393
margin-bottom: 4rem;
9494
}
95+
:global(.page-heading-bar) {
96+
display: grid;
97+
grid-template-columns: minmax(0, 1fr) auto;
98+
gap: 0.75rem;
99+
align-items: center;
100+
margin-bottom: 0.75rem;
101+
}
102+
:global(.page-heading-bar__breadcrumbs) {
103+
min-width: 0;
104+
}
105+
:global(.page-heading-bar__breadcrumbs .breadcrumbs) {
106+
margin: 0;
107+
display: inline-flex;
108+
align-items: center;
109+
}
110+
:global(.page-heading-bar__breadcrumbs .breadcrumbs ol) {
111+
flex-wrap: nowrap;
112+
gap: 0.25rem;
113+
}
114+
:global(.page-heading-bar__breadcrumbs .breadcrumbs li) {
115+
max-width: none;
116+
}
117+
:global(.docs-toolbar-actions--heading) {
118+
display: inline-flex;
119+
gap: 0.5rem;
120+
flex-wrap: wrap;
121+
justify-content: flex-end;
122+
flex: 0 0 auto;
123+
}
95124
.next-previous-nav {
96125
display: flex;
97126
flex-wrap: wrap;
@@ -118,4 +147,14 @@ const suppressTitle = content.suppressTitle;
118147
height: 0;
119148
}
120149
}
150+
151+
@media (max-width: var(--docs-toolbar-mobile-breakpoint)) {
152+
:global(.page-heading-bar) {
153+
grid-template-columns: 1fr;
154+
}
155+
:global(.docs-toolbar-actions--heading) {
156+
width: 100%;
157+
justify-content: flex-start;
158+
}
159+
}
121160
</style>
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
import { FiFileText } from 'react-icons/fi';
3+
import { getMarkdownPath } from '../util/getMarkdownPath';
4+
5+
export interface Props {
6+
size?: 'default' | 'compact';
7+
}
8+
9+
const { size = 'default' } = Astro.props;
10+
const classes = ['docs-toolbar-button', 'view-markdown-button'];
11+
if (size === 'compact') {
12+
classes.push('docs-toolbar-button--compact');
13+
}
14+
15+
const markdownPath = getMarkdownPath(Astro.url.pathname);
16+
---
17+
18+
<a class={classes.join(' ')} href={markdownPath} target="_blank" rel="noopener noreferrer" aria-label="View current page as Markdown source">
19+
<span class="docs-toolbar-button__icon" aria-hidden="true">
20+
<FiFileText size={16} focusable="false" aria-hidden="true" />
21+
</span>
22+
<span class="docs-toolbar-button__label">View as Markdown</span>
23+
</a>
24+
25+
<style>
26+
.view-markdown-button {
27+
text-decoration: none;
28+
color: inherit;
29+
}
30+
31+
.view-markdown-button:hover,
32+
.view-markdown-button:focus-visible,
33+
.view-markdown-button:active {
34+
text-decoration: none;
35+
color: inherit;
36+
}
37+
</style>

src/layouts/MainLayout.astro

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ import type { CollectionEntry } from 'astro:content';
33
import type { MarkdownHeading } from 'astro';
44
import generateToc from '~/util/generateToc';
55
import Breadcrumbs from '../components/Breadcrumbs.astro';
6+
import CopyForLLMButton from '../components/CopyForLLMButton.astro';
67
import PageContent from '../components/PageContent/PageContent.astro';
78
import RightSidebar from '../components/RightSidebar/RightSidebar.astro';
89
import TableOfContents from '../components/RightSidebar/TableOfContents';
10+
import ViewMarkdownButton from '../components/ViewMarkdownButton.astro';
911
import BaseLayout from './BaseLayout.astro';
1012
1113
export interface Props {
@@ -23,13 +25,21 @@ const { content, headings, breadcrumbTitle } = Astro.props;
2325
{
2426
Astro.url.pathname !== '/' && (
2527
<Fragment slot="before-title">
26-
<Breadcrumbs breadcrumbTitle={breadcrumbTitle} />
28+
<div class="page-heading-bar">
29+
<div class="page-heading-bar__breadcrumbs">
30+
<Breadcrumbs breadcrumbTitle={breadcrumbTitle} />
31+
</div>
32+
<div class="docs-toolbar-actions docs-toolbar-actions--heading">
33+
<CopyForLLMButton size="compact" />
34+
<ViewMarkdownButton size="compact" />
35+
</div>
36+
</div>
2737
</Fragment>
2838
)
2939
}
30-
{
31-
headings && (
32-
<Fragment slot="before-article">
40+
<Fragment slot="before-article">
41+
{
42+
headings && (
3343
<nav>
3444
<TableOfContents
3545
client:media="(max-width: 82em)"
@@ -40,9 +50,9 @@ const { content, headings, breadcrumbTitle } = Astro.props;
4050
isMobile={true}
4151
/>
4252
</nav>
43-
</Fragment>
44-
)
45-
}
53+
)
54+
}
55+
</Fragment>
4656
<Fragment slot="after-title"><slot name="header" /></Fragment>
4757
<slot />
4858
</PageContent>

src/styles/theme.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
--theme-text-md: 1rem;
2222
--theme-text-sm: 0.9375rem;
2323
--theme-text-xs: 0.875rem;
24+
--docs-toolbar-mobile-breakpoint: 40em;
2425
/* Animation helpers */
2526
--theme-ease-bounce: cubic-bezier(0.4, 2.5, 0.6, 1);
2627
}

0 commit comments

Comments
 (0)