Skip to content

Commit 81366df

Browse files
feat(docs): support custom locally hosted icons in Card and Icon components (#5423)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Catherine Deskur <catherine@buildwithfern.com> Co-authored-by: Catherine Deskur <46695336+chdeskur@users.noreply.github.com>
1 parent fe25a25 commit 81366df

File tree

6 files changed

+135
-47
lines changed

6 files changed

+135
-47
lines changed

packages/fern-docs/bundle/src/mdx/components/card/Card.tsx

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import { NoZoom } from "@fern-docs/components/contexts/NoZoom";
33
import { DisableFernAnchor } from "@fern-docs/components/FernAnchor";
44
import { FernCard } from "@fern-docs/components/FernCard";
55
import { FernImage } from "@fern-docs/components/FernImage";
6+
import { FernSvgIcon } from "@fern-docs/components/FernSvgIcon";
67
import { FaIcon } from "@fern-docs/components/fa-icon";
8+
import { processIconString } from "@fern-docs/components/util/processIconString";
79
import { isValidElement } from "react";
810
import { FernLinkCard } from "@/components/FernLinkCard";
911

@@ -79,6 +81,35 @@ export const Card: React.FC<Card.Props> = ({
7981
}
8082
}
8183

84+
const renderIcon = () => {
85+
if (typeof icon === "string") {
86+
return processIconString({
87+
icon,
88+
className: "card-icon",
89+
renderFaIcon: (faIcon) => <FaIcon className="card-icon" icon={faIcon} />,
90+
renderUrlIcon: (url, isSvg) =>
91+
isSvg ? (
92+
<NoZoom>
93+
<FernSvgIcon src={url} alt="" className="card-icon" />
94+
</NoZoom>
95+
) : (
96+
<NoZoom>
97+
<FernImage src={url} alt="" className="card-icon" />
98+
</NoZoom>
99+
),
100+
wrap: (content) => <NoZoom>{content}</NoZoom>
101+
});
102+
}
103+
if (isValidElement(icon)) {
104+
return (
105+
<span className="card-icon">
106+
<NoZoom>{icon}</NoZoom>
107+
</span>
108+
);
109+
}
110+
return null;
111+
};
112+
82113
const cardContent = (
83114
<>
84115
{badge != null && (
@@ -105,13 +136,7 @@ export const Card: React.FC<Card.Props> = ({
105136
}
106137
`}
107138
</style>
108-
{typeof icon === "string" ? (
109-
<FaIcon className="card-icon" icon={icon} />
110-
) : isValidElement(icon) ? (
111-
<span className="card-icon">
112-
<NoZoom>{icon}</NoZoom>
113-
</span>
114-
) : null}
139+
{renderIcon()}
115140
<div className="w-full space-y-1 overflow-hidden">
116141
<div className="text-body text-base font-semibold">{title}</div>
117142
{children != null && <div className="text-(color:--grayscale-a11)">{children}</div>}
Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { cn } from "@fern-docs/components/cn";
2+
import { FernImage } from "@fern-docs/components/FernImage";
3+
import { FernSvgIcon } from "@fern-docs/components/FernSvgIcon";
24
import { FaIcon } from "@fern-docs/components/fa-icon";
5+
import { processIconString } from "@fern-docs/components/util/processIconString";
36

47
export function Icon({
58
className,
@@ -10,24 +13,60 @@ export function Icon({
1013
lightModeColor
1114
}: {
1215
className?: string; // you must specify the bg-color rather than text-color because this is a mask.
13-
icon?: string; // e.g. "fas fa-home", or simply "home"
16+
icon?: string; // e.g. "fas fa-home", or simply "home", or a URL (file: references are resolved by rehype-files)
1417
color?: string; // ignored if lightModeColor and darkModeColor are set
1518
darkModeColor?: string;
1619
lightModeColor?: string;
1720
size?: number; // size in 0.25rem increments. default is 4.
1821
}) {
22+
const sizeInPixels = size * 4;
23+
24+
if (typeof icon !== "string" || !icon) {
25+
return null;
26+
}
27+
1928
return (
20-
<FaIcon
21-
className={cn(className, "fern-mdx-icon")}
22-
icon={icon ?? ""}
23-
style={
24-
{
25-
color: lightModeColor ?? color,
26-
"--fa-icon-dark": darkModeColor ?? color,
27-
width: size * 4,
28-
height: size * 4
29-
} as React.CSSProperties
30-
}
31-
/>
29+
processIconString({
30+
icon,
31+
className: cn(className, "fern-mdx-icon"),
32+
renderFaIcon: (faIcon) => (
33+
<FaIcon
34+
className={cn(className, "fern-mdx-icon")}
35+
icon={faIcon}
36+
style={
37+
{
38+
color: lightModeColor ?? color,
39+
"--fa-icon-dark": darkModeColor ?? color,
40+
width: sizeInPixels,
41+
height: sizeInPixels
42+
} as React.CSSProperties
43+
}
44+
/>
45+
),
46+
renderUrlIcon: (url, isSvg) =>
47+
isSvg ? (
48+
<span
49+
className={cn(className, "fern-mdx-icon")}
50+
style={{
51+
width: sizeInPixels,
52+
height: sizeInPixels,
53+
display: "inline-block"
54+
}}
55+
>
56+
<FernSvgIcon src={url} alt="" />
57+
</span>
58+
) : (
59+
<FernImage
60+
src={url}
61+
alt=""
62+
className={cn(className, "fern-mdx-icon")}
63+
style={{
64+
width: sizeInPixels,
65+
height: sizeInPixels
66+
}}
67+
/>
68+
),
69+
wrap: (content) => <>{content}</>
70+
}) ?? null
3271
);
3372
}

packages/fern-docs/bundle/src/mdx/plugins/rehype-files.ts

Lines changed: 33 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -56,40 +56,50 @@ export const rehypeFiles: Unified.Plugin<[RehypeFilesOptions?], Hast.Root> = ({
5656

5757
// TODO: do we need to add support for `href` and `<object data=...>`?
5858
const srcAttribute = attributes.find((attr) => attr.name === "src");
59+
const iconAttribute = attributes.find((attr) => attr.name === "icon");
5960

6061
// TODO: handle more gracefully, temporary fix for jambonz
6162
const playsInlineAttribute = attributes.find((attr) => attr.name === "playsinline");
6263
if (playsInlineAttribute) {
6364
playsInlineAttribute.name = "playsInline";
6465
}
6566

66-
if (srcAttribute == null) {
67-
return;
68-
}
67+
// Handle src attribute
68+
if (srcAttribute != null) {
69+
const src = mdxJsxAttributeToString(srcAttribute);
70+
if (!src) {
71+
console.warn(`[rehype-files]: src attribute is not parseable for ${node.name}`);
72+
} else {
73+
const { src: newSrc, height, width, blurDataURL } = replaceSrc?.(src, node.name) ?? {};
6974

70-
const src = mdxJsxAttributeToString(srcAttribute);
71-
if (!src) {
72-
console.warn(`[rehype-files]: src attribute is not parseable for ${node.name}`);
73-
return;
74-
}
75-
const { src: newSrc, height, width, blurDataURL } = replaceSrc?.(src, node.name) ?? {};
75+
if (newSrc != null) {
76+
srcAttribute.value = newSrc;
77+
}
7678

77-
if (newSrc != null) {
78-
srcAttribute.value = newSrc;
79+
setDimension(node, attributes, width, height);
80+
81+
if (
82+
blurDataURL &&
83+
node.name?.toLowerCase().startsWith("im") &&
84+
!attributes.find((attr) => attr.name === "blurDataURL")
85+
) {
86+
node.attributes.unshift({
87+
name: "blurDataURL",
88+
value: blurDataURL,
89+
type: "mdxJsxAttribute"
90+
});
91+
}
92+
}
7993
}
8094

81-
setDimension(node, attributes, width, height);
82-
83-
if (
84-
blurDataURL &&
85-
node.name?.toLowerCase().startsWith("im") &&
86-
!attributes.find((attr) => attr.name === "blurDataURL")
87-
) {
88-
node.attributes.unshift({
89-
name: "blurDataURL",
90-
value: blurDataURL,
91-
type: "mdxJsxAttribute"
92-
});
95+
if (iconAttribute != null) {
96+
const icon = mdxJsxAttributeToString(iconAttribute);
97+
if (icon?.startsWith("file:")) {
98+
const { src: newSrc } = replaceSrc?.(icon, node.name) ?? {};
99+
if (newSrc != null) {
100+
iconAttribute.value = newSrc;
101+
}
102+
}
93103
}
94104
} else if (node.type === "element") {
95105
// TODO: do we need to add support for `href` and `<object data=...>`?

packages/fern-docs/components/src/FernSvgIcon.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,5 +46,5 @@ export const FernSvgIcon: React.FC<FernSvgIconProps> = ({ src, alt, className })
4646
return <span className={className} />;
4747
}
4848

49-
return <span dangerouslySetInnerHTML={{ __html: modifiedSvgContent }} />;
49+
return <span className={className} dangerouslySetInnerHTML={{ __html: modifiedSvgContent }} />;
5050
};

packages/fern-docs/components/src/FernSvgIconServer.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ async function FernSvgIconServerInternal({ src, alt, className }: FernSvgIconSer
3535
}
3636
}
3737

38-
return <span dangerouslySetInnerHTML={{ __html: modifiedSvgContent }} />;
38+
return <span className={className} dangerouslySetInnerHTML={{ __html: modifiedSvgContent }} />;
3939
} catch (error) {
4040
console.error(`[FernSvgIconServer] Failed to fetch SVG: ${src}`, error);
4141
// Fallback to Next.js Image on error

packages/fern-docs/components/src/util/processIconString.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,31 @@ export interface ProcessIconStringOptions {
99
className?: string;
1010
renderFaIcon: (icon: string) => ReactNode;
1111
wrap?: (content: ReactNode) => ReactNode;
12+
renderUrlIcon?: (url: string, isSvg: boolean) => ReactNode;
13+
}
14+
15+
function isUrlIcon(icon: string): boolean {
16+
return icon.startsWith("http://") || icon.startsWith("https://") || icon.startsWith("/");
17+
}
18+
19+
function isSvgUrl(url: string): boolean {
20+
return url.toLowerCase().endsWith(".svg");
1221
}
1322

1423
export const processIconString = ({
1524
icon,
1625
files,
1726
className = "size-5",
1827
renderFaIcon,
19-
wrap = (content) => content
28+
wrap = (content) => content,
29+
renderUrlIcon
2030
}: ProcessIconStringOptions): ReactNode | undefined => {
2131
if (icon.startsWith("file:")) {
2232
const fileId = icon.slice(5);
2333
const fileData = files?.[fileId];
2434

2535
if (fileData) {
26-
if (fileData.src.endsWith(".svg")) {
36+
if (fileData.src.toLowerCase().endsWith(".svg")) {
2737
return wrap(<FernSvgIcon src={fileData.src} alt={fileData.alt ?? ""} className={className} />);
2838
}
2939

@@ -42,6 +52,10 @@ export const processIconString = ({
4252
return undefined;
4353
}
4454

55+
if (renderUrlIcon && isUrlIcon(icon)) {
56+
return wrap(renderUrlIcon(icon, isSvgUrl(icon)));
57+
}
58+
4559
if (icon.startsWith("<") && icon.endsWith(">")) {
4660
return wrap(<span className={className} dangerouslySetInnerHTML={{ __html: icon }} />);
4761
}

0 commit comments

Comments
 (0)