Skip to content

Commit bb349fa

Browse files
bloveclaude
andauthored
feat(website): docs LLM markdown export (raw-markdown route + PageActions) (#575)
* docs(spec): docs LLM markdown export (raw-markdown route + PageActions) Mirror dawn's per-page LLM experience: a raw-MDX route and a PageActions dropdown (Copy page as Markdown, Open in ChatGPT, Edit on GitHub) mounted in the page-header actions slot reserved by Spec 1. Second of two docs specs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(plan): docs LLM markdown export implementation plan Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(website): raw-markdown route for docs pages * feat(website): PageActions dropdown (copy markdown, ChatGPT, GitHub) * feat(website): mount PageActions in the docs page header --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bf70c3e commit bb349fa

8 files changed

Lines changed: 825 additions & 1 deletion

File tree

apps/website/e2e/docs.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ test.describe('Docs slug page', () => {
6464
await expect(page.getByText(/LangGraph\s+·\s+Getting Started/i).first()).toBeVisible();
6565
// Prev/Next: introduction is the first page, so a "Next →" card is present
6666
await expect(page.getByText('Next →').first()).toBeVisible();
67+
// Per-page LLM actions trigger
68+
await expect(page.locator('main button[aria-label="Page actions"]').first()).toBeVisible();
6769
});
6870

6971
test('breadcrumb shows the library + page title', async ({ page }) => {
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// @vitest-environment node
2+
import { describe, expect, it } from 'vitest';
3+
import { GET } from './route';
4+
5+
function ctx(params: { library: string; section: string; slug: string }) {
6+
return { params: Promise.resolve(params) };
7+
}
8+
9+
describe('GET /api/markdown/[library]/[section]/[slug]', () => {
10+
it('returns the raw mdx for a known page as text/markdown', async () => {
11+
const res = await GET(
12+
new Request('http://localhost/api/markdown/langgraph/getting-started/introduction'),
13+
ctx({ library: 'langgraph', section: 'getting-started', slug: 'introduction' }),
14+
);
15+
expect(res.status).toBe(200);
16+
expect(res.headers.get('content-type')).toContain('text/markdown');
17+
const body = await res.text();
18+
expect(body).toContain('# Introduction');
19+
});
20+
21+
it('404s for an unknown page', async () => {
22+
const res = await GET(
23+
new Request('http://localhost/api/markdown/langgraph/getting-started/does-not-exist'),
24+
ctx({ library: 'langgraph', section: 'getting-started', slug: 'does-not-exist' }),
25+
);
26+
expect(res.status).toBe(404);
27+
});
28+
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { NextResponse } from 'next/server';
2+
import { getDocBySlug, getAllDocSlugs } from '../../../../../../lib/docs';
3+
4+
export function generateStaticParams() {
5+
return getAllDocSlugs();
6+
}
7+
8+
interface RouteContext {
9+
params: Promise<{ library: string; section: string; slug: string }>;
10+
}
11+
12+
export async function GET(_req: Request, context: RouteContext): Promise<Response> {
13+
const { library, section, slug } = await context.params;
14+
const doc = getDocBySlug(library, section, slug);
15+
16+
if (!doc) {
17+
return new NextResponse('Not found', {
18+
status: 404,
19+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
20+
});
21+
}
22+
23+
return new NextResponse(doc.content, {
24+
status: 200,
25+
headers: {
26+
'Content-Type': 'text/markdown; charset=utf-8',
27+
'Cache-Control': 'public, max-age=60, must-revalidate',
28+
},
29+
});
30+
}

apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { MdxRenderer } from '../../../../../components/docs/MdxRenderer';
66
import { DocsSearch } from '../../../../../components/docs/DocsSearch';
77
import { DocsBreadcrumb } from '../../../../../components/docs/DocsBreadcrumb';
88
import { DocsPageHeader } from '../../../../../components/docs/DocsPageHeader';
9+
import { PageActions } from '../../../../../components/docs/PageActions';
910
import { DocsPrevNext } from '../../../../../components/docs/DocsPrevNext';
1011
import { getDocBySlug, getAllDocSlugs, getDocMetadata } from '../../../../../lib/docs';
1112
import { ApiDocRenderer, type ApiDocEntry } from '../../../../../components/docs/ApiDocRenderer';
@@ -74,7 +75,11 @@ export default async function DocsPage({ params }: DocsRouteProps) {
7475
<div className="flex-1 min-w-0">
7576
<div className="px-6 md:px-12 pt-6">
7677
<DocsBreadcrumb library={library as LibraryId} section={section} slug={slug} title={doc.title} />
77-
<DocsPageHeader library={library as LibraryId} section={section} />
78+
<DocsPageHeader
79+
library={library as LibraryId}
80+
section={section}
81+
actions={<PageActions library={library} section={section} slug={slug} />}
82+
/>
7883
</div>
7984
<article className="flex-1 py-8 px-4 sm:px-6 md:px-12 md:max-w-3xl overflow-x-hidden">
8085
<MdxRenderer
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// SPDX-License-Identifier: MIT
2+
// @vitest-environment jsdom
3+
import React from 'react';
4+
import { describe, expect, it, vi, beforeEach } from 'vitest';
5+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
6+
7+
const trackMock = vi.hoisted(() => vi.fn());
8+
const writeTextMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
9+
const fetchMock = vi.hoisted(() => vi.fn());
10+
11+
vi.mock('../../lib/analytics/client', () => ({ track: trackMock }));
12+
13+
beforeEach(() => {
14+
trackMock.mockClear();
15+
writeTextMock.mockClear();
16+
fetchMock.mockReset();
17+
fetchMock.mockResolvedValue({ ok: true, text: () => Promise.resolve('# Streaming\n\nbody') });
18+
Object.assign(navigator, { clipboard: { writeText: writeTextMock } });
19+
Object.assign(globalThis, { fetch: fetchMock });
20+
});
21+
22+
async function open() {
23+
const { PageActions } = await import('./PageActions');
24+
render(<PageActions library="langgraph" section="guides" slug="streaming" />);
25+
fireEvent.click(screen.getByRole('button', { name: /page actions/i }));
26+
}
27+
28+
describe('PageActions', () => {
29+
it('copies the raw markdown from the route and fires analytics', async () => {
30+
await open();
31+
fireEvent.click(screen.getByRole('menuitem', { name: /copy page as markdown/i }));
32+
await waitFor(() => expect(writeTextMock).toHaveBeenCalledWith('# Streaming\n\nbody'));
33+
expect(fetchMock).toHaveBeenCalledWith('/api/markdown/langgraph/guides/streaming');
34+
expect(trackMock).toHaveBeenCalledWith(
35+
'docs:copy_code_click',
36+
expect.objectContaining({ surface: 'docs', cta_id: 'copy_page_markdown' }),
37+
);
38+
});
39+
40+
it('links to ChatGPT with the page URL and to GitHub edit', async () => {
41+
await open();
42+
const chatgpt = screen.getByRole('menuitem', { name: /open in chatgpt/i }) as HTMLAnchorElement;
43+
expect(chatgpt.getAttribute('href')).toContain('https://chatgpt.com/?hints=search&q=');
44+
expect(chatgpt.getAttribute('href')).toContain(encodeURIComponent('https://threadplane.ai/docs/langgraph/guides/streaming'));
45+
const github = screen.getByRole('menuitem', { name: /edit on github/i }) as HTMLAnchorElement;
46+
expect(github.getAttribute('href')).toBe('https://github.com/cacheplane/angular-agent-framework/edit/main/apps/website/content/docs/langgraph/guides/streaming.mdx');
47+
});
48+
});
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
'use client';
2+
import { useEffect, useRef, useState } from 'react';
3+
import { tokens } from '@threadplane/design-tokens';
4+
import { analyticsEvents } from '../../lib/analytics/events';
5+
import { track } from '../../lib/analytics/client';
6+
7+
// NOTE: SITE_ORIGIN is duplicated from lib/site-metadata rather than imported,
8+
// because site-metadata transitively pulls in lib/blog (Node `fs`), which a
9+
// 'use client' component cannot bundle. Keep in sync with site-metadata.ts.
10+
const SITE_ORIGIN = 'https://threadplane.ai';
11+
12+
const GITHUB_EDIT_BASE =
13+
'https://github.com/cacheplane/angular-agent-framework/edit/main/apps/website/content/docs';
14+
15+
interface Props {
16+
library: string;
17+
section: string;
18+
slug: string;
19+
}
20+
21+
export function PageActions({ library, section, slug }: Props) {
22+
const [open, setOpen] = useState(false);
23+
const [copied, setCopied] = useState(false);
24+
const ref = useRef<HTMLDivElement>(null);
25+
26+
useEffect(() => {
27+
if (!open) return;
28+
const onDown = (e: MouseEvent) => {
29+
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
30+
};
31+
const onKey = (e: KeyboardEvent) => {
32+
if (e.key === 'Escape') setOpen(false);
33+
};
34+
document.addEventListener('mousedown', onDown);
35+
document.addEventListener('keydown', onKey);
36+
return () => {
37+
document.removeEventListener('mousedown', onDown);
38+
document.removeEventListener('keydown', onKey);
39+
};
40+
}, [open]);
41+
42+
const path = `${library}/${section}/${slug}`;
43+
const pageUrl = `${SITE_ORIGIN}/docs/${path}`;
44+
const chatgptUrl = `https://chatgpt.com/?hints=search&q=${encodeURIComponent(
45+
`Read this Threadplane docs page and help me apply it to my project: ${pageUrl}`,
46+
)}`;
47+
const githubUrl = `${GITHUB_EDIT_BASE}/${path}.mdx`;
48+
49+
const copyMarkdown = async () => {
50+
try {
51+
const res = await fetch(`/api/markdown/${path}`);
52+
if (!res.ok) throw new Error(String(res.status));
53+
const text = await res.text();
54+
await navigator.clipboard.writeText(text);
55+
track(analyticsEvents.docsCopyCodeClick, { surface: 'docs', cta_id: 'copy_page_markdown' });
56+
setCopied(true);
57+
setTimeout(() => setCopied(false), 2000);
58+
} catch {
59+
// network/clipboard failure — silently ignore
60+
}
61+
setOpen(false);
62+
};
63+
64+
const itemStyle: React.CSSProperties = {
65+
display: 'block',
66+
width: '100%',
67+
textAlign: 'left',
68+
padding: '8px 12px',
69+
fontFamily: tokens.typography.fontSans,
70+
fontSize: 13,
71+
color: tokens.colors.textPrimary,
72+
background: 'transparent',
73+
border: 'none',
74+
textDecoration: 'none',
75+
cursor: 'pointer',
76+
whiteSpace: 'nowrap',
77+
};
78+
79+
return (
80+
<div ref={ref} style={{ position: 'relative' }}>
81+
<button
82+
type="button"
83+
aria-label="Page actions"
84+
aria-haspopup="menu"
85+
aria-expanded={open}
86+
onClick={() => setOpen((o) => !o)}
87+
style={{
88+
display: 'inline-flex',
89+
alignItems: 'center',
90+
justifyContent: 'center',
91+
width: 32,
92+
height: 32,
93+
padding: 0,
94+
border: `1px solid ${tokens.surfaces.border}`,
95+
borderRadius: tokens.radius.md,
96+
background: tokens.surfaces.surface,
97+
color: tokens.colors.textSecondary,
98+
cursor: 'pointer',
99+
fontSize: 18,
100+
lineHeight: 1,
101+
}}
102+
>
103+
<span aria-hidden="true"></span>
104+
</button>
105+
{open ? (
106+
<div
107+
role="menu"
108+
style={{
109+
position: 'absolute',
110+
top: 'calc(100% + 6px)',
111+
right: 0,
112+
minWidth: 200,
113+
background: tokens.surfaces.surface,
114+
border: `1px solid ${tokens.surfaces.border}`,
115+
borderRadius: tokens.radius.md,
116+
boxShadow: tokens.shadows.md,
117+
padding: 4,
118+
zIndex: 20,
119+
}}
120+
>
121+
<button type="button" role="menuitem" onClick={copyMarkdown} style={itemStyle}>
122+
{copied ? 'Copied' : 'Copy page as Markdown'}
123+
</button>
124+
<a
125+
role="menuitem"
126+
href={chatgptUrl}
127+
target="_blank"
128+
rel="noopener noreferrer"
129+
onClick={() => setOpen(false)}
130+
style={itemStyle}
131+
>
132+
Open in ChatGPT
133+
</a>
134+
<a
135+
role="menuitem"
136+
href={githubUrl}
137+
target="_blank"
138+
rel="noopener noreferrer"
139+
onClick={() => setOpen(false)}
140+
style={itemStyle}
141+
>
142+
Edit on GitHub
143+
</a>
144+
</div>
145+
) : null}
146+
</div>
147+
);
148+
}

0 commit comments

Comments
 (0)