Skip to content

Commit e7f0b44

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 e7f0b44

File tree

5 files changed

+340
-7
lines changed

5 files changed

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