Skip to content
This repository was archived by the owner on Mar 18, 2026. It is now read-only.

Commit 069f6f2

Browse files
javdlclaude
andcommitted
Add Copy Page dropdown and markdown API endpoint (developer-8lb epic)
- Add PageTitle.astro with Copy Page dropdown (copy as markdown, view as markdown) - Add [...slug].md.ts SSR endpoint serving raw markdown for any docs page - Add constants.ts with shared PAGE_TITLE_ID constant - Register PageTitle override in astro.config.mjs - Add E2E tests for markdown endpoint Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 768c309 commit 069f6f2

7 files changed

Lines changed: 305 additions & 5 deletions

File tree

.beads/sync-state.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
2-
"last_failure": "2026-02-18T12:23:33.254530947Z",
3-
"failure_count": 1,
4-
"backoff_until": "2026-02-18T12:24:03.254531488Z",
2+
"last_failure": "2026-02-18T13:20:49.935960302Z",
3+
"failure_count": 2,
4+
"backoff_until": "2026-02-18T13:21:49.935960763Z",
55
"needs_manual_sync": false,
66
"failure_reason": "failed to get current branch: exit status 128"
77
}

astro.config.mjs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ import react from "@astrojs/react";
1010

1111
// https://astro.build/config
1212
export default defineConfig({
13+
vite: {
14+
server: {
15+
allowedHosts: ["loom"],
16+
},
17+
},
1318
image: {
1419
domains: ["fashionunited.com", "storage.cloud.google.com"],
1520
remotePatterns: [{
@@ -19,7 +24,8 @@ export default defineConfig({
1924
site: 'https://developer.fashionunited.com',
2025
integrations: [starlight({
2126
components: {
22-
Head: "./src/components/starlight/Head.astro"
27+
Head: "./src/components/starlight/Head.astro",
28+
PageTitle: "./src/components/starlight/PageTitle.astro",
2329
},
2430
title: 'FashionUnited Docs',
2531
customCss: ['./src/styles/custom.css', '@fontsource/ibm-plex-mono/400.css', '@fontsource/ibm-plex-mono/600.css', '@fontsource-variable/inter', '@fontsource-variable/lora'],

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"type": "module",
44
"version": "0.0.1",
55
"scripts": {
6-
"dev": "astro dev",
6+
"dev": "astro dev --host 0.0.0.0",
77
"start": "bun ./dist/server/entry.mjs",
88
"build": "astro check && astro build",
99
"preview": "astro preview",

src/components/constants.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// N.B. THIS FILE IS IMPORTED IN BOTH SERVER- AND CLIENT-SIDE CODE.
2+
// THINK TWICE BEFORE ADDING STUFF AS IT WILL GET SHIPPED TO THE CLIENT.
3+
4+
export const PAGE_TITLE_ID = '_top';
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
---
2+
import { PAGE_TITLE_ID } from '../constants.ts';
3+
const title = Astro.locals.starlightRoute.entry.data.title;
4+
---
5+
6+
<div class="page-title-wrapper">
7+
<h1 id={PAGE_TITLE_ID}>{title}</h1>
8+
9+
<div class="copy-page-container">
10+
<button
11+
class="copy-page-button"
12+
aria-label="Copy page options"
13+
aria-expanded="false"
14+
aria-haspopup="menu"
15+
>
16+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
17+
<path d="M5.75 4.75H10.25M5.75 7.75H10.25M5.75 10.75H8.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
18+
<path d="M3.25 2.75H12.75V13.25H3.25V2.75Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
19+
</svg>
20+
<span>Copy page</span>
21+
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg" class="chevron" aria-hidden="true">
22+
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
23+
</svg>
24+
</button>
25+
26+
<div class="dropdown-menu" role="menu" aria-label="Page copy options" hidden>
27+
<button class="dropdown-item copy-as-markdown" data-action="copy-markdown" role="menuitem">
28+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
29+
<path d="M5.75 4.75H10.25M5.75 7.75H10.25M5.75 10.75H8.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
30+
<path d="M3.25 2.75H12.75V13.25H3.25V2.75Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
31+
</svg>
32+
<div>
33+
<div class="item-title">Copy page</div>
34+
<div class="item-description">Copy page as Markdown for LLMs</div>
35+
</div>
36+
</button>
37+
38+
<button class="dropdown-item view-as-markdown" data-action="view-markdown" role="menuitem">
39+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
40+
<path d="M2 8C2 8 4 4 8 4C12 4 14 8 14 8C14 8 12 12 8 12C4 12 2 8 2 8Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
41+
<circle cx="8" cy="8" r="2" stroke="currentColor" stroke-width="1.5"/>
42+
</svg>
43+
<div>
44+
<div class="item-title">View as Markdown</div>
45+
<div class="item-description">View this page as plain text</div>
46+
</div>
47+
</button>
48+
</div>
49+
</div>
50+
</div>
51+
52+
<script>
53+
function setupCopyPageDropdown() {
54+
const button = document.querySelector('.copy-page-button');
55+
const dropdown = document.querySelector('.dropdown-menu');
56+
57+
if (!button || !dropdown) return;
58+
59+
// Remove any existing listeners by cloning (prevents duplicates on view transitions)
60+
const newButton = button.cloneNode(true);
61+
button.parentNode?.replaceChild(newButton, button);
62+
63+
newButton.addEventListener('click', (e) => {
64+
e.stopPropagation();
65+
const currentDropdown = document.querySelector('.dropdown-menu');
66+
const currentButton = document.querySelector('.copy-page-button');
67+
if (!currentDropdown) return;
68+
const isHidden = currentDropdown.hasAttribute('hidden');
69+
currentDropdown.toggleAttribute('hidden', !isHidden);
70+
currentButton?.setAttribute('aria-expanded', String(!isHidden));
71+
});
72+
73+
const closeDropdown = () => {
74+
const currentDropdown = document.querySelector('.dropdown-menu');
75+
const currentButton = document.querySelector('.copy-page-button');
76+
if (currentDropdown) {
77+
currentDropdown.setAttribute('hidden', '');
78+
currentButton?.setAttribute('aria-expanded', 'false');
79+
}
80+
};
81+
document.addEventListener('click', closeDropdown, { once: false });
82+
83+
const newCopyBtn = document.querySelector('[data-action="copy-markdown"]');
84+
newCopyBtn?.addEventListener('click', async () => {
85+
try {
86+
const response = await fetch(window.location.pathname.replace(/\/$/, '') + '.md');
87+
if (response.ok) {
88+
const markdown = await response.text();
89+
await navigator.clipboard.writeText(markdown);
90+
91+
const titleEl = newCopyBtn.querySelector('.item-title');
92+
const originalText = titleEl?.textContent;
93+
if (titleEl) {
94+
titleEl.textContent = 'Copied!';
95+
setTimeout(() => {
96+
if (titleEl) {
97+
titleEl.textContent = originalText || 'Copy page';
98+
}
99+
}, 2000);
100+
}
101+
}
102+
} catch (error) {
103+
console.error('Failed to copy:', error);
104+
}
105+
closeDropdown();
106+
});
107+
108+
const newViewBtn = document.querySelector('[data-action="view-markdown"]');
109+
newViewBtn?.addEventListener('click', () => {
110+
const markdownUrl = window.location.pathname.replace(/\/$/, '') + '.md';
111+
window.open(markdownUrl, '_blank');
112+
closeDropdown();
113+
});
114+
}
115+
116+
document.addEventListener('DOMContentLoaded', setupCopyPageDropdown);
117+
document.addEventListener('astro:page-load', setupCopyPageDropdown);
118+
</script>
119+
120+
<style>
121+
.page-title-wrapper {
122+
display: flex;
123+
align-items: center;
124+
justify-content: space-between;
125+
gap: 1rem;
126+
margin-top: 1rem;
127+
}
128+
129+
h1 {
130+
margin: 0;
131+
font-size: var(--sl-text-h1);
132+
line-height: var(--sl-line-height-headings);
133+
font-weight: 600;
134+
color: var(--sl-color-white);
135+
flex: 1;
136+
}
137+
138+
.copy-page-container {
139+
position: relative;
140+
}
141+
142+
.copy-page-button {
143+
display: flex;
144+
align-items: center;
145+
gap: 0.5rem;
146+
padding: 0.5rem 0.75rem;
147+
background: var(--sl-color-bg-nav);
148+
border: 1px solid var(--sl-color-gray-5);
149+
border-radius: 0.5rem;
150+
color: var(--sl-color-white);
151+
font-size: 0.875rem;
152+
font-weight: 500;
153+
cursor: pointer;
154+
transition: all 0.2s;
155+
white-space: nowrap;
156+
}
157+
158+
.copy-page-button:hover {
159+
background: var(--sl-color-gray-6);
160+
border-color: var(--sl-color-gray-4);
161+
}
162+
163+
.copy-page-button .chevron {
164+
transition: transform 0.2s;
165+
}
166+
167+
.copy-page-button:hover .chevron {
168+
transform: translateY(1px);
169+
}
170+
171+
.dropdown-menu {
172+
position: absolute;
173+
top: calc(100% + 0.5rem);
174+
right: 0;
175+
min-width: 280px;
176+
background: var(--sl-color-bg-nav);
177+
border: 1px solid var(--sl-color-gray-5);
178+
border-radius: 0.5rem;
179+
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
180+
z-index: 100;
181+
padding: 0.25rem;
182+
}
183+
184+
.dropdown-menu[hidden] {
185+
display: none;
186+
}
187+
188+
.dropdown-item {
189+
display: flex;
190+
align-items: flex-start;
191+
gap: 0.75rem;
192+
width: 100%;
193+
padding: 0.75rem;
194+
background: transparent;
195+
border: none;
196+
border-radius: 0.375rem;
197+
color: var(--sl-color-white);
198+
text-align: left;
199+
cursor: pointer;
200+
transition: background 0.2s;
201+
}
202+
203+
.dropdown-item:hover {
204+
background: var(--sl-color-gray-6);
205+
}
206+
207+
.dropdown-item svg {
208+
flex-shrink: 0;
209+
margin-top: 0.125rem;
210+
color: var(--sl-color-gray-3);
211+
}
212+
213+
.item-title {
214+
font-size: 0.875rem;
215+
font-weight: 500;
216+
line-height: 1.25;
217+
}
218+
219+
.item-description {
220+
font-size: 0.75rem;
221+
color: var(--sl-color-gray-3);
222+
line-height: 1.25;
223+
margin-top: 0.125rem;
224+
}
225+
226+
@media (max-width: 768px) {
227+
.page-title-wrapper {
228+
flex-direction: column;
229+
align-items: flex-start;
230+
}
231+
232+
.copy-page-button span {
233+
display: none;
234+
}
235+
236+
.dropdown-menu {
237+
right: auto;
238+
left: 0;
239+
}
240+
}
241+
</style>

src/pages/[...slug].md.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { APIRoute } from 'astro';
2+
import { getCollection } from 'astro:content';
3+
4+
export const prerender = false;
5+
6+
export const GET: APIRoute = async ({ params }) => {
7+
const slug = params.slug || '';
8+
const docs = await getCollection('docs');
9+
const doc = docs.find(entry => entry.slug === slug);
10+
11+
if (!doc) {
12+
return new Response('Not found', { status: 404 });
13+
}
14+
15+
return new Response(doc.body, {
16+
status: 200,
17+
headers: {
18+
'Content-Type': 'text/plain; charset=utf-8',
19+
},
20+
});
21+
};
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
test.describe('Markdown API endpoint ([...slug].md.ts)', () => {
4+
test('GET /docs/introduction.md returns 200 with text/plain content type', async ({ request }) => {
5+
const response = await request.get('/docs/introduction.md');
6+
expect(response.status()).toBe(200);
7+
expect(response.headers()['content-type']).toContain('text/plain');
8+
});
9+
10+
test('GET /docs/introduction.md response body contains markdown content', async ({ request }) => {
11+
const response = await request.get('/docs/introduction.md');
12+
const body = await response.text();
13+
expect(body.length).toBeGreaterThan(0);
14+
expect(body).toMatch(/#|\w+/);
15+
});
16+
17+
test('GET /nonexistent-page.md returns 404', async ({ request }) => {
18+
const response = await request.get('/nonexistent-page.md');
19+
expect(response.status()).toBe(404);
20+
});
21+
22+
test('GET /docs/marketplace/graphql-api.md returns 200', async ({ request }) => {
23+
const response = await request.get('/docs/marketplace/graphql-api.md');
24+
expect(response.status()).toBe(200);
25+
const body = await response.text();
26+
expect(body.length).toBeGreaterThan(0);
27+
});
28+
});

0 commit comments

Comments
 (0)