Skip to content

Commit a08a7c5

Browse files
JPeer264claude
andcommitted
feat(js): Show which steps a selected framework changes
After a framework is selected, a line under the dropdown names the steps that changed and links to them. Each changed block gets a "For <framework>" label, a left border and a short highlight, so the change is visible and Ctrl+F finds the framework name. The API route now returns a `changed` flag, so blocks that are the same as without a framework are not marked. The default entry is now "No framework", and the dropdown uses the same arrow icon as the sidebar platform selector. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 6ba64ad commit a08a7c5

4 files changed

Lines changed: 211 additions & 20 deletions

File tree

‎app/api/platform-content/[platform]/[runtime]/[framework]/[...includePath]/route.ts‎

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,23 @@ async function collectNested(
108108
}
109109
}
110110

111+
async function buildPayload(
112+
includePath: string,
113+
resolve: (includePath: string) => Promise<SlugFile | undefined>
114+
): Promise<PlatformContentPayload> {
115+
const doc = await resolve(includePath);
116+
const payload: PlatformContentPayload = {
117+
changed: false,
118+
code: doc?.mdxSource ?? null,
119+
includes: {},
120+
platformContents: {},
121+
};
122+
if (doc) {
123+
await collectNested(doc.matter.content, resolve, payload);
124+
}
125+
return payload;
126+
}
127+
111128
export async function GET(
112129
_request: Request,
113130
{
@@ -125,16 +142,20 @@ export async function GET(
125142
const resolve = (path: string) =>
126143
resolveForFramework(path, platform, runtime, framework);
127144

128-
const doc = await resolve(includePath.join('/'));
145+
const resolveForRuntime = (path: string) =>
146+
resolvePlatformContent({
147+
includePath: path,
148+
path: ['platforms', platform, 'guides', runtime],
149+
});
129150

130-
const payload: PlatformContentPayload = {
131-
code: doc?.mdxSource ?? null,
132-
includes: {},
133-
platformContents: {},
134-
};
135-
if (doc) {
136-
await collectNested(doc.matter.content, resolve, payload);
137-
}
151+
const [payload, runtimePayload] = await Promise.all([
152+
buildPayload(includePath.join('/'), resolve),
153+
buildPayload(includePath.join('/'), resolveForRuntime),
154+
]);
155+
payload.changed =
156+
payload.code !== runtimePayload.code ||
157+
JSON.stringify(payload.platformContents) !==
158+
JSON.stringify(runtimePayload.platformContents);
138159

139160
return Response.json(payload);
140161
}

‎src/components/frameworkSelector/frameworks.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@ export const FRAMEWORK_QUERY_PARAM = 'framework';
113113

114114
/** The serialized form of a `<PlatformContent>` include, served by the platform-content API route. */
115115
export type PlatformContentPayload = {
116+
/** Whether the content differs from what the runtime's page shows without a framework. */
117+
changed: boolean;
116118
/** Compiled MDX of the resolved include, or `null` if there is no content for it. */
117119
code: string | null;
118120
/** Compiled MDX of nested `<Include name="...">` files, keyed by name. */

‎src/components/frameworkSelector/index.tsx‎

Lines changed: 139 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
11
'use client';
22

3-
import {ArrowRightIcon, ChevronDownIcon} from '@radix-ui/react-icons';
3+
import {ArrowRightIcon, CaretSortIcon} from '@radix-ui/react-icons';
44
import * as RadixSelect from '@radix-ui/react-select';
55
import {useRouter} from 'next/navigation';
6-
import {createContext, ReactNode, useContext, useEffect, useMemo, useState} from 'react';
6+
import {
7+
createContext,
8+
ReactNode,
9+
useCallback,
10+
useContext,
11+
useEffect,
12+
useMemo,
13+
useRef,
14+
useState,
15+
} from 'react';
716
import {getMDXComponent} from 'sentry-docs/getMDXComponent';
817

918
import {Alert} from '../alert';
@@ -28,14 +37,23 @@ import {
2837
platformContentUrl,
2938
RUNTIMES,
3039
} from './frameworks';
40+
import frameworkStyles from './style.module.scss';
3141

3242
const DEFAULT_VALUE = 'none';
3343
const GUIDE_VALUE_PREFIX = 'guide:';
3444
const groupLabelClassName = 'px-2 pt-2 pb-1 text-xs font-medium text-[var(--gray-11)]';
3545

46+
/** The state of a switchable block while a framework is selected. */
47+
type ContentStatus =
48+
| {state: 'loading' | 'unchanged'}
49+
/** `step` is the title of the `h2` step that contains the block, `targetId` the block's id. */
50+
| {state: 'changed'; targetId: string; step?: string};
51+
3652
type FrameworkContextValue = {
3753
framework: Framework | null;
3854
platform: string;
55+
/** Records the status of the block with `includePath`, or removes it with `null`. */
56+
reportContent: (includePath: string, status: ContentStatus | null) => void;
3957
/** The runtime guide the page belongs to, e.g. `node`. */
4058
runtime: string;
4159
};
@@ -56,6 +74,65 @@ function ItemLabel({icon, title}: {icon: string; title: string}) {
5674
);
5775
}
5876

77+
function stepTitle(el: HTMLElement): string | undefined {
78+
const step = Array.from(document.querySelectorAll<HTMLElement>('main h2[id]'))
79+
.filter(h => h.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING)
80+
.pop();
81+
return (step?.querySelector('a') ?? step)?.textContent?.trim();
82+
}
83+
84+
function FrameworkSummary({
85+
contents,
86+
framework,
87+
platform,
88+
}: {
89+
contents: Record<string, ContentStatus>;
90+
framework: Framework;
91+
platform: string;
92+
}) {
93+
const statuses = Object.values(contents);
94+
if (statuses.some(c => c.state === 'loading')) {
95+
return (
96+
<p className={frameworkStyles.summary}>Loading the steps for {framework.title}…</p>
97+
);
98+
}
99+
100+
const changed = statuses
101+
.filter(c => c.state === 'changed')
102+
.map(c => ({...c, el: document.getElementById(c.targetId)}))
103+
.filter(c => c.el)
104+
.sort((a, b) =>
105+
a.el!.compareDocumentPosition(b.el!) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1
106+
);
107+
const steps = changed.filter(
108+
(c, i) => changed.findIndex(other => other.step === c.step) === i
109+
);
110+
111+
return (
112+
<p className={frameworkStyles.summary}>
113+
<PlatformIcon
114+
platform={`${platform}-${framework.key}`}
115+
size={16}
116+
format="sm"
117+
className="!border-none !shadow-none"
118+
/>
119+
{steps.length > 0 ? (
120+
<span>
121+
Updated for {framework.title}:{' '}
122+
{steps.map((c, i) => (
123+
<span key={c.targetId}>
124+
{i > 0 && ', '}
125+
<a href={`#${c.targetId}`}>{c.step ?? 'Show the change'}</a>
126+
</span>
127+
))}
128+
</span>
129+
) : (
130+
<span>The steps for {framework.title} are the same as without a framework.</span>
131+
)}
132+
</p>
133+
);
134+
}
135+
59136
function readFrameworkFromUrl(frameworks: Framework[]): Framework | null {
60137
const key = new URLSearchParams(window.location.search).get(FRAMEWORK_QUERY_PARAM);
61138
return frameworks.find(f => f.key === key) ?? null;
@@ -90,6 +167,21 @@ export function FrameworkSelector({
90167
const frameworks = useMemo(() => runtimeConfig?.frameworks ?? [], [runtimeConfig]);
91168
const frameworkGuides = runtimeConfig?.frameworkGuides ?? [];
92169
const [framework, setFramework] = useState<Framework | null>(null);
170+
const [contents, setContents] = useState<Record<string, ContentStatus>>({});
171+
172+
const reportContent = useCallback(
173+
(includePath: string, status: ContentStatus | null) =>
174+
setContents(prev => {
175+
const next = {...prev};
176+
if (status) {
177+
next[includePath] = status;
178+
} else {
179+
delete next[includePath];
180+
}
181+
return next;
182+
}),
183+
[]
184+
);
93185

94186
// The page is statically rendered, so the query param can only be read after mount.
95187
useEffect(() => {
@@ -107,8 +199,8 @@ export function FrameworkSelector({
107199
};
108200

109201
const contextValue = useMemo(
110-
() => ({framework, platform, runtime}),
111-
[framework, platform, runtime]
202+
() => ({framework, platform, reportContent, runtime}),
203+
[framework, platform, reportContent, runtime]
112204
);
113205

114206
return (
@@ -129,7 +221,7 @@ export function FrameworkSelector({
129221
>
130222
<RadixSelect.Value />
131223
<RadixSelect.Icon>
132-
<ChevronDownIcon />
224+
<CaretSortIcon />
133225
</RadixSelect.Icon>
134226
</RadixSelect.Trigger>
135227
<RadixSelect.Content
@@ -139,10 +231,7 @@ export function FrameworkSelector({
139231
>
140232
<RadixSelect.Item value={DEFAULT_VALUE} className={styles.item}>
141233
<RadixSelect.ItemText>
142-
<ItemLabel
143-
icon={`${platform}-${runtime}`}
144-
title={`None (plain ${runtimeConfig?.title ?? runtime})`}
145-
/>
234+
<ItemLabel icon={`${platform}-${runtime}`} title="No framework" />
146235
</RadixSelect.ItemText>
147236
</RadixSelect.Item>
148237
{frameworks.length > 0 && (
@@ -182,6 +271,9 @@ export function FrameworkSelector({
182271
</RadixSelect.Root>
183272
</div>
184273
</div>
274+
{framework && (
275+
<FrameworkSummary contents={contents} framework={framework} platform={platform} />
276+
)}
185277
{children}
186278
</FrameworkContext.Provider>
187279
);
@@ -294,18 +386,40 @@ function RemoteFrameworkContent({
294386
framework,
295387
includePath,
296388
platform,
389+
reportContent,
297390
runtime,
298391
}: {
299392
fallback: ReactNode;
300393
framework: Framework;
301394
includePath: string;
302395
platform: string;
396+
reportContent: FrameworkContextValue['reportContent'];
303397
runtime: string;
304398
}) {
305399
const url = platformContentUrl(platform, runtime, framework.key, includePath);
306400
const [payload, setPayload] = useState<PlatformContentPayload | null>(null);
307401
const [error, setError] = useState(false);
308402
const codeContext = useContext(CodeContext);
403+
const changedRef = useRef<HTMLDivElement>(null);
404+
const targetId = `framework-content-${includePath.replace(/\//g, '-')}`;
405+
406+
useEffect(() => {
407+
if (error) {
408+
reportContent(includePath, {state: 'unchanged'});
409+
} else if (!payload) {
410+
reportContent(includePath, {state: 'loading'});
411+
} else if (!payload.changed || !changedRef.current) {
412+
reportContent(includePath, {state: 'unchanged'});
413+
} else {
414+
reportContent(includePath, {
415+
state: 'changed',
416+
step: stepTitle(changedRef.current),
417+
targetId,
418+
});
419+
}
420+
}, [error, includePath, payload, reportContent, targetId]);
421+
422+
useEffect(() => () => reportContent(includePath, null), [includePath, reportContent]);
309423

310424
useEffect(() => {
311425
let cancelled = false;
@@ -333,7 +447,7 @@ function RemoteFrameworkContent({
333447
}
334448
}, [payload, codeContext?.onboardingOptions]);
335449

336-
if (error) {
450+
if (error || payload?.changed === false) {
337451
return fallback;
338452
}
339453

@@ -345,7 +459,20 @@ function RemoteFrameworkContent({
345459
);
346460
}
347461

348-
return <mdx.MDX code={payload.code} />;
462+
return (
463+
<div key={url} ref={changedRef} id={targetId} className={frameworkStyles.changed}>
464+
<p className={frameworkStyles.label}>
465+
<PlatformIcon
466+
platform={`${platform}-${framework.key}`}
467+
size={16}
468+
format="sm"
469+
className="!border-none !shadow-none"
470+
/>
471+
For {framework.title}
472+
</p>
473+
<mdx.MDX code={payload.code} />
474+
</div>
475+
);
349476
}
350477

351478
/**
@@ -371,6 +498,7 @@ export function FrameworkContent({
371498
framework={context.framework}
372499
includePath={includePath}
373500
platform={context.platform}
501+
reportContent={context.reportContent}
374502
runtime={context.runtime}
375503
/>
376504
);
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
.summary {
2+
display: flex;
3+
align-items: center;
4+
gap: 0.5rem;
5+
margin-top: -0.5rem;
6+
font-size: 0.875rem;
7+
color: var(--gray-11);
8+
}
9+
10+
.changed {
11+
margin-block: 1rem;
12+
padding: 0.25rem 0 0.25rem 1rem;
13+
border-left: 3px solid var(--accent-purple);
14+
border-radius: 0 0.25rem 0.25rem 0;
15+
// Clears the sticky header and the sticky feature buttons of the quick start
16+
scroll-margin-top: calc(var(--header-height) + 5rem);
17+
animation: highlight 1.6s ease-out;
18+
}
19+
20+
.label {
21+
display: flex;
22+
align-items: center;
23+
gap: 0.5rem;
24+
margin: 0 0 0.5rem;
25+
font-size: 0.75rem;
26+
font-weight: 500;
27+
letter-spacing: 0.04em;
28+
text-transform: uppercase;
29+
color: var(--accent-purple);
30+
}
31+
32+
@keyframes highlight {
33+
from {
34+
background-color: var(--accent-purple-light);
35+
}
36+
37+
to {
38+
background-color: transparent;
39+
}
40+
}

0 commit comments

Comments
 (0)