Skip to content

Commit 1ec5b25

Browse files
feat(docs): add highlight prop with range syntax to snippet components (#6106)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Catherine Deskur <[email protected]> Co-authored-by: Catherine Deskur <[email protected]> Co-authored-by: chdeskur <[email protected]>
1 parent 43acb66 commit 1ec5b25

File tree

14 files changed

+252
-18
lines changed

14 files changed

+252
-18
lines changed

packages/fern-docs/bundle/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@
119119
"motion": "^12.4.7",
120120
"next": "catalog:",
121121
"next-themes": "^0.4.4",
122+
"parse-numeric-range": "^1.3.0",
122123
"posthog-js": "^1.298.0",
123124
"pretty-bytes": "^6.1.1",
124125
"qs": "catalog:",

packages/fern-docs/bundle/src/mdx/components/snippets/EndpointRequestSnippet.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ export function EndpointRequestSnippet({
1515
endpointDefinition,
1616
slugs,
1717
lang,
18-
className
18+
className,
19+
highlight
1920
}: {
2021
/**
2122
* The endpoint locator to use for the request snippet.
@@ -35,6 +36,10 @@ export function EndpointRequestSnippet({
3536
slugs?: string[];
3637
lang?: string;
3738
className?: string;
39+
/**
40+
* Sets the lines to highlight
41+
*/
42+
highlight?: number | number[];
3843
}) {
3944
if (endpointDefinition == null) {
4045
return null;
@@ -47,6 +52,7 @@ export function EndpointRequestSnippet({
4752
example={example}
4853
className={className}
4954
lang={lang ?? "en"}
55+
highlight={highlight}
5056
/>
5157
);
5258
}
@@ -56,13 +62,15 @@ function EndpointRequestSnippetInternal({
5662
example,
5763
slugs,
5864
className,
59-
lang
65+
lang,
66+
highlight
6067
}: {
6168
endpoint: ApiDefinition.EndpointDefinition;
6269
example: string | undefined;
6370
slugs: string[];
6471
lang: string;
6572
className?: string;
73+
highlight?: number | number[];
6674
}): ReactElement<any> | null {
6775
const slug = useCurrentSlug(slugs);
6876
const { selectedExample, selectedExampleKey, availableLanguages, setSelectedExampleKey } = useExampleSelection(
@@ -114,6 +122,7 @@ function EndpointRequestSnippetInternal({
114122
language={selectedExampleKey.language}
115123
json={EMPTY_OBJECT}
116124
scrollAreaStyle={{ maxHeight: "500px" }}
125+
highlight={highlight}
117126
/>
118127
</div>
119128
);

packages/fern-docs/bundle/src/mdx/components/snippets/EndpointResponseSnippet.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ export function EndpointResponseSnippet({
1010
endpointDefinition,
1111
slug,
1212
className,
13-
lang
13+
lang,
14+
highlight
1415
}: {
1516
/**
1617
* The endpoint locator to use for the request snippet.
@@ -30,6 +31,10 @@ export function EndpointResponseSnippet({
3031
slug: string;
3132
className?: string;
3233
lang?: string;
34+
/**
35+
* Sets the lines to highlight
36+
*/
37+
highlight?: number | number[];
3338
}) {
3439
if (endpointDefinition == null) {
3540
return null;
@@ -42,6 +47,7 @@ export function EndpointResponseSnippet({
4247
slug={slug}
4348
className={className}
4449
lang={lang ?? "en"}
50+
highlight={highlight}
4551
/>
4652
);
4753
}
@@ -51,13 +57,15 @@ function EndpointResponseSnippetInternal({
5157
example,
5258
slug,
5359
className,
54-
lang
60+
lang,
61+
highlight
5562
}: {
5663
slug: string;
5764
endpoint: EndpointDefinition;
5865
example: string | undefined;
5966
className?: string;
6067
lang: string;
68+
highlight?: number | number[];
6169
}) {
6270
const { selectedExample } = useExampleSelection(endpoint, example);
6371

@@ -81,6 +89,7 @@ function EndpointResponseSnippetInternal({
8189
slug={slug}
8290
isResponse
8391
lang={lang}
92+
highlight={highlight}
8493
/>
8594
</div>
8695
);

packages/fern-docs/bundle/src/mdx/components/snippets/SchemaSnippet.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,13 @@ type SchemaSnippetProps = {
2323
*/
2424
title?: string;
2525
className?: string;
26+
/**
27+
* Line numbers to highlight. Supports range syntax like `highlight={[1-5, 7, 9]}`.
28+
*/
29+
highlight?: number | number[];
2630
};
2731

28-
export function SchemaSnippet({ typeDefinition, types, lang, title, className }: SchemaSnippetProps) {
32+
export function SchemaSnippet({ typeDefinition, types, lang, title, className, highlight }: SchemaSnippetProps) {
2933
const language = lang ?? "en";
3034

3135
const example = useMemo(() => {
@@ -50,6 +54,7 @@ export function SchemaSnippet({ typeDefinition, types, lang, title, className }:
5054
json={example}
5155
scrollAreaStyle={{ maxHeight: "500px" }}
5256
lang={language}
57+
highlight={highlight}
5358
/>
5459
</div>
5560
);

packages/fern-docs/bundle/src/mdx/components/snippets/WebhookPayloadSnippet.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ export function WebhookPayloadSnippet({
77
webhookDefinition,
88
slug,
99
className,
10-
lang
10+
lang,
11+
highlight
1112
}: {
1213
/**
1314
* The webhook locator to use for the payload snippet.
@@ -24,6 +25,10 @@ export function WebhookPayloadSnippet({
2425
slug: string | undefined;
2526
className?: string;
2627
lang?: string;
28+
/**
29+
* Sets the lines to highlight
30+
*/
31+
highlight?: number | number[];
2732
}) {
2833
if (webhookDefinition == null) {
2934
return null;
@@ -35,6 +40,7 @@ export function WebhookPayloadSnippet({
3540
slug={slug}
3641
className={className}
3742
lang={lang ?? "en"}
43+
highlight={highlight}
3844
/>
3945
);
4046
}
@@ -43,12 +49,14 @@ function WebhookPayloadSnippetInternal({
4349
webhook,
4450
slug,
4551
className,
46-
lang
52+
lang,
53+
highlight
4754
}: {
4855
slug: string | undefined;
4956
webhook: ApiDefinition.WebhookDefinition;
5057
className?: string;
5158
lang: string;
59+
highlight?: number | number[];
5260
}) {
5361
const example = webhook.examples?.[0];
5462

@@ -74,6 +82,7 @@ function WebhookPayloadSnippetInternal({
7482
scrollAreaStyle={{ maxHeight: "500px" }}
7583
slug={slug}
7684
lang={lang}
85+
highlight={highlight}
7786
/>
7887
</div>
7988
);
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import type { Hast, MdxJsxAttribute } from "@fern-docs/mdx";
2+
import { expandHighlightRanges } from "./expand-highlight-ranges";
3+
4+
function createMockNode(highlightValue: string): Hast.MdxJsxElement {
5+
return {
6+
type: "mdxJsxFlowElement",
7+
name: "TestComponent",
8+
attributes: [
9+
{
10+
type: "mdxJsxAttribute",
11+
name: "highlight",
12+
value: {
13+
type: "mdxJsxAttributeValueExpression",
14+
value: highlightValue,
15+
data: {}
16+
}
17+
}
18+
],
19+
children: []
20+
};
21+
}
22+
23+
function getHighlightValue(node: Hast.MdxJsxElement): string | undefined {
24+
const attr = node.attributes.find(
25+
(a): a is MdxJsxAttribute => a.type === "mdxJsxAttribute" && a.name === "highlight"
26+
);
27+
if (attr?.value && typeof attr.value === "object" && attr.value.type === "mdxJsxAttributeValueExpression") {
28+
return attr.value.value;
29+
}
30+
return undefined;
31+
}
32+
33+
describe("expandHighlightRanges", () => {
34+
it("should expand a simple range", () => {
35+
const node = createMockNode("[1-5]");
36+
expandHighlightRanges(node);
37+
expect(getHighlightValue(node)).toBe("[1, 2, 3, 4, 5]");
38+
});
39+
40+
it("should expand a range with individual numbers", () => {
41+
const node = createMockNode("[1-3, 7, 9]");
42+
expandHighlightRanges(node);
43+
expect(getHighlightValue(node)).toBe("[1, 2, 3, 7, 9]");
44+
});
45+
46+
it("should expand multiple ranges", () => {
47+
const node = createMockNode("[1-3, 5-7]");
48+
expandHighlightRanges(node);
49+
expect(getHighlightValue(node)).toBe("[1, 2, 3, 5, 6, 7]");
50+
});
51+
52+
it("should not modify arrays without ranges", () => {
53+
const node = createMockNode("[1, 2, 3]");
54+
expandHighlightRanges(node);
55+
expect(getHighlightValue(node)).toBe("[1, 2, 3]");
56+
});
57+
58+
it("should handle whitespace in the expression", () => {
59+
const node = createMockNode("[ 1-3 , 5 ]");
60+
expandHighlightRanges(node);
61+
expect(getHighlightValue(node)).toBe("[1, 2, 3, 5]");
62+
});
63+
64+
it("should handle out-of-order list", () => {
65+
const node = createMockNode("[ 5, 1-3 ]");
66+
expandHighlightRanges(node);
67+
expect(getHighlightValue(node)).toBe("[5, 1, 2, 3]");
68+
});
69+
70+
it("should not modify non-highlight attributes", () => {
71+
const node: Hast.MdxJsxElement = {
72+
type: "mdxJsxFlowElement",
73+
name: "TestComponent",
74+
attributes: [
75+
{
76+
type: "mdxJsxAttribute",
77+
name: "otherProp",
78+
value: {
79+
type: "mdxJsxAttributeValueExpression",
80+
value: "[1-5]",
81+
data: {}
82+
}
83+
}
84+
],
85+
children: []
86+
};
87+
expandHighlightRanges(node);
88+
const attr = node.attributes.find(
89+
(a): a is MdxJsxAttribute => a.type === "mdxJsxAttribute" && a.name === "otherProp"
90+
);
91+
if (attr?.value && typeof attr.value === "object" && attr.value.type === "mdxJsxAttributeValueExpression") {
92+
expect(attr.value.value).toBe("[1-5]");
93+
}
94+
});
95+
96+
it("should handle string attribute values", () => {
97+
const node: Hast.MdxJsxElement = {
98+
type: "mdxJsxFlowElement",
99+
name: "TestComponent",
100+
attributes: [
101+
{
102+
type: "mdxJsxAttribute",
103+
name: "highlight",
104+
value: "some-string"
105+
}
106+
],
107+
children: []
108+
};
109+
expandHighlightRanges(node);
110+
const attr = node.attributes.find(
111+
(a): a is MdxJsxAttribute => a.type === "mdxJsxAttribute" && a.name === "highlight"
112+
);
113+
expect(attr?.value).toBe("some-string");
114+
});
115+
116+
it("should create valid estree for the expanded array", () => {
117+
const node = createMockNode("[1-3]");
118+
expandHighlightRanges(node);
119+
const attr = node.attributes.find(
120+
(a): a is MdxJsxAttribute => a.type === "mdxJsxAttribute" && a.name === "highlight"
121+
);
122+
if (attr?.value && typeof attr.value === "object" && attr.value.type === "mdxJsxAttributeValueExpression") {
123+
const estree = attr.value.data?.estree;
124+
expect(estree).toBeDefined();
125+
expect(estree?.type).toBe("Program");
126+
expect(estree?.body[0]?.type).toBe("ExpressionStatement");
127+
}
128+
});
129+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { Hast } from "@fern-docs/mdx";
2+
import parseNumericRange from "parse-numeric-range";
3+
4+
/**
5+
* Expands range syntax in the `highlight` attribute of MDX JSX elements.
6+
* For example, `highlight={[1-5, 7, 9]}` becomes `highlight={[1, 2, 3, 4, 5, 7, 9]}`.
7+
*
8+
* This is necessary because JSX expressions like `[1-5, 7, 9]` would normally be
9+
* evaluated as JavaScript where `1-5` becomes `-4`. This function intercepts the
10+
* raw expression string before JavaScript evaluation and rewrites it.
11+
*/
12+
export function expandHighlightRanges(node: Hast.MdxJsxElement): void {
13+
for (const attr of node.attributes) {
14+
if (attr.type !== "mdxJsxAttribute" || attr.name !== "highlight") {
15+
continue;
16+
}
17+
18+
if (typeof attr.value === "object" && attr.value?.type === "mdxJsxAttributeValueExpression") {
19+
const rawExpr = attr.value.value;
20+
if (/\[\s*[\d\s,-]+\s*\]/.test(rawExpr) && rawExpr.includes("-")) {
21+
const expanded = parseNumericRange(rawExpr.replace(/[[\]]/g, ""));
22+
attr.value = {
23+
type: "mdxJsxAttributeValueExpression",
24+
value: `[${expanded.join(", ")}]`,
25+
data: {
26+
estree: {
27+
type: "Program",
28+
body: [
29+
{
30+
type: "ExpressionStatement",
31+
expression: {
32+
type: "ArrayExpression",
33+
elements: expanded.map((n) => ({
34+
type: "Literal",
35+
value: n,
36+
raw: String(n)
37+
}))
38+
}
39+
}
40+
],
41+
sourceType: "module"
42+
}
43+
}
44+
};
45+
}
46+
}
47+
}
48+
}

packages/fern-docs/bundle/src/mdx/plugins/rehype-endpoint-example-snippets.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
unknownToMdxJsxAttribute,
1212
visit
1313
} from "@fern-docs/mdx";
14+
import { expandHighlightRanges } from "./expand-highlight-ranges";
1415

1516
/**
1617
* The code below copies the `example` prop of an
@@ -49,6 +50,7 @@ export const rehypeEndpointExampleSnippets: Unified.Plugin<[{ loader: DocsLoader
4950

5051
// check that the current node is a request or response snippet
5152
if (isRequestSnippet || isResponseSnippet) {
53+
expandHighlightRanges(node);
5254
const { props } = hastMdxJsxElementHastToProps(node);
5355

5456
// cannot parse non-string endpoint prop

0 commit comments

Comments
 (0)