diff --git a/packages/fern-docs/bundle/src/server/withInitialProps.ts b/packages/fern-docs/bundle/src/server/withInitialProps.ts index 08ed2c019d..6e7272fdf9 100644 --- a/packages/fern-docs/bundle/src/server/withInitialProps.ts +++ b/packages/fern-docs/bundle/src/server/withInitialProps.ts @@ -22,6 +22,7 @@ import { import { getMdxBundler } from "@fern-docs/ui/bundlers"; import { addLeadingSlash, + conformTrailingSlash, getRedirectForPath, isTrailingSlashEnabled, } from "@fern-docs/utils"; @@ -280,7 +281,8 @@ export async function withInitialProps({ : undefined, }; - const currentVersionId = found.currentVersion?.versionId; + const currentVersion = found.currentVersion; + const currentVersionId = currentVersion?.versionId; const versions = withVersionSwitcherInfo({ node: found.node, parents: found.parents, @@ -327,6 +329,35 @@ export async function withInitialProps({ } } + // sometimes absolute paths need to be attached to the current version prefix, or basepath + const rootSlug = root.slug; + const versionSlug = currentVersion?.slug; + const slugMap = found.collector.slugMap; + function resolveLinkHref(href: string): string | undefined { + if (href.startsWith("/")) { + const url = new URL(href, withDefaultProtocol(domain)); + if (versionSlug != null) { + const slugWithVersion = FernNavigation.slugjoin( + versionSlug, + url.pathname + ); + const found = slugMap.get(slugWithVersion); + if (found) { + return `${conformTrailingSlash(addLeadingSlash(found.slug))}${url.search}${url.hash}`; + } + } + + if (rootSlug.length > 0) { + const slugWithRoot = FernNavigation.slugjoin(rootSlug, url.pathname); + const found = slugMap.get(slugWithRoot); + if (found) { + return `${conformTrailingSlash(addLeadingSlash(found.slug))}${url.search}${url.hash}`; + } + } + } + return; + } + const content = await withResolvedDocsContent({ domain: docs.baseUrl.domain, found, @@ -343,6 +374,7 @@ export async function withInitialProps({ slug: slug, }, replaceSrc: resolveFileSrc, + replaceHref: resolveLinkHref, }); const frontmatter = extractFrontmatterFromDocsContent(found.node.id, content); diff --git a/packages/fern-docs/bundle/src/server/withResolvedDocsContent.ts b/packages/fern-docs/bundle/src/server/withResolvedDocsContent.ts index 2de0890eed..a566b96d97 100644 --- a/packages/fern-docs/bundle/src/server/withResolvedDocsContent.ts +++ b/packages/fern-docs/bundle/src/server/withResolvedDocsContent.ts @@ -20,6 +20,7 @@ interface WithResolvedDocsContentOpts { edgeFlags: EdgeFlags; scope?: Record; replaceSrc?: (src: string) => ImageData | undefined; + replaceHref?: (href: string) => string | undefined; } export async function withResolvedDocsContent({ @@ -30,6 +31,7 @@ export async function withResolvedDocsContent({ edgeFlags, scope, replaceSrc, + replaceHref, }: WithResolvedDocsContentOpts): Promise { const node = withPrunedNavigation(found.node, { visibleNodeIds: [found.node.id], @@ -81,6 +83,8 @@ export async function withResolvedDocsContent({ // inject the file url and dimensions for images and other embeddable files replaceSrc, + // resolve absolute pathed hrefs to the correct path (considers version and basepath) + replaceHref, }, serializeMdx, domain, diff --git a/packages/fern-docs/local-preview-bundle/src/utils/getDocsPageProps.ts b/packages/fern-docs/local-preview-bundle/src/utils/getDocsPageProps.ts index c5ed93fce8..624ffca019 100644 --- a/packages/fern-docs/local-preview-bundle/src/utils/getDocsPageProps.ts +++ b/packages/fern-docs/local-preview-bundle/src/utils/getDocsPageProps.ts @@ -14,7 +14,10 @@ import { type DocsV2Read, } from "@fern-api/fdr-sdk/client/types"; import * as FernNavigation from "@fern-api/fdr-sdk/navigation"; -import { visitDiscriminatedUnion } from "@fern-api/ui-core-utils"; +import { + visitDiscriminatedUnion, + withDefaultProtocol, +} from "@fern-api/ui-core-utils"; import { getFrontmatter } from "@fern-docs/mdx"; import { withSeo } from "@fern-docs/seo"; import { @@ -31,6 +34,8 @@ import { } from "@fern-docs/ui"; import { serializeMdx } from "@fern-docs/ui/bundlers/next-mdx-remote"; import { + addLeadingSlash, + conformTrailingSlash, DEFAULT_EDGE_FLAGS, EdgeFlags, getRedirectForPath, @@ -119,6 +124,35 @@ export async function getDocsPageProps( } } + // sometimes absolute paths need to be attached to the current version prefix, or basepath + const rootSlug = root.slug; + const versionSlug = node.currentVersion?.slug; + const slugMap = node.collector.slugMap; + function resolveLinkHref(href: string): string | undefined { + if (href.startsWith("/")) { + const url = new URL(href, withDefaultProtocol(docs.baseUrl.domain)); + if (versionSlug != null) { + const slugWithVersion = FernNavigation.slugjoin( + versionSlug, + url.pathname + ); + const found = slugMap.get(slugWithVersion); + if (found) { + return `${conformTrailingSlash(addLeadingSlash(found.slug))}${url.search}${url.hash}`; + } + } + + if (rootSlug.length > 0) { + const slugWithRoot = FernNavigation.slugjoin(rootSlug, url.pathname); + const found = slugMap.get(slugWithRoot); + if (found) { + return `${conformTrailingSlash(addLeadingSlash(found.slug))}${url.search}${url.hash}`; + } + } + } + return; + } + const content = await resolveDocsContent({ domain: docs.baseUrl.domain, node: node.node, @@ -154,6 +188,7 @@ export async function getDocsPageProps( // inject the file url and dimensions for images and other embeddable files replaceSrc: resolveFileSrc, + replaceHref: resolveLinkHref, }, serializeMdx, engine: "next-mdx-remote", diff --git a/packages/fern-docs/ui/src/mdx/bundlers/mdx-bundler.ts b/packages/fern-docs/ui/src/mdx/bundlers/mdx-bundler.ts index 1f46fbc19f..428d15329e 100644 --- a/packages/fern-docs/ui/src/mdx/bundlers/mdx-bundler.ts +++ b/packages/fern-docs/ui/src/mdx/bundlers/mdx-bundler.ts @@ -25,6 +25,7 @@ import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; import remarkSmartypants from "remark-smartypants"; import { rehypeFiles } from "../plugins/rehype-files"; +import { rehypeLinks } from "../plugins/rehype-links"; import { rehypeExtractAsides } from "../plugins/rehypeExtractAsides"; import { rehypeFernCode } from "../plugins/rehypeFernCode"; import { rehypeFernComponents } from "../plugins/rehypeFernComponents"; @@ -50,6 +51,7 @@ async function serializeMdxImpl( filename, scope = {}, replaceSrc, + replaceHref, }: FernSerializeMdxOptions = {} ): Promise { if (content == null) { @@ -141,6 +143,7 @@ async function serializeMdxImpl( rehypeSqueezeParagraphs, rehypeMdxClassStyle, [rehypeFiles, { replaceSrc }], + [rehypeLinks, { replaceHref }], rehypeAcornErrorBoundary, rehypeSlug, rehypeKatex, diff --git a/packages/fern-docs/ui/src/mdx/plugins/rehype-files.ts b/packages/fern-docs/ui/src/mdx/plugins/rehype-files.ts index 1c55c5f03b..573b1ba5b6 100644 --- a/packages/fern-docs/ui/src/mdx/plugins/rehype-files.ts +++ b/packages/fern-docs/ui/src/mdx/plugins/rehype-files.ts @@ -1,9 +1,11 @@ import type { Hast, + MdxExpression, MdxJsxAttribute, MdxJsxExpressionAttribute, } from "@fern-docs/mdx"; import { + isMdxExpression, isMdxJsxAttribute, isMdxJsxElementHast, mdxJsxAttributeToString, @@ -33,9 +35,12 @@ export interface RehypeFilesOptions { * @returns a function that will transform the tree */ export function rehypeFiles( - options: RehypeFilesOptions + options: RehypeFilesOptions = {} ): (tree: Hast.Root) => void { return function (tree: Hast.Root): void { + if (options == null) { + return; + } visit(tree, (node) => { if (isMdxJsxElementHast(node)) { const attributes = node.attributes.filter(isMdxJsxAttribute); @@ -136,6 +141,7 @@ export function rehypeFiles( if (estree == null) { return; } + // TODO: make this less hacky walk(estree, { enter(node) { if (node.type === "Literal" && typeof node.value === "string") { @@ -149,11 +155,27 @@ export function rehypeFiles( }); }); } + + if (isMdxExpression(node)) { + const estree = getEstree(node); + if (estree == null) { + return; + } + walk(estree, { + enter(node) { + if (node.type === "Literal" && typeof node.value === "string") { + node.value = options.replaceSrc?.(node.value)?.src ?? node.value; + } + }, + }); + } }); }; } -function getEstree(attr: MdxJsxAttribute | MdxJsxExpressionAttribute) { +function getEstree( + attr: MdxJsxAttribute | MdxJsxExpressionAttribute | MdxExpression +) { if ( attr.type === "mdxJsxAttribute" && attr.value && @@ -164,6 +186,8 @@ function getEstree(attr: MdxJsxAttribute | MdxJsxExpressionAttribute) { return attr.value.data?.estree; } else if (attr.type === "mdxJsxExpressionAttribute" && attr.data?.estree) { return attr.data?.estree; + } else if (isMdxExpression(attr) && attr.data?.estree) { + return attr.data?.estree; } return null; } diff --git a/packages/fern-docs/ui/src/mdx/plugins/rehype-links.ts b/packages/fern-docs/ui/src/mdx/plugins/rehype-links.ts new file mode 100644 index 0000000000..5a46b77812 --- /dev/null +++ b/packages/fern-docs/ui/src/mdx/plugins/rehype-links.ts @@ -0,0 +1,52 @@ +import { + isMdxJsxAttribute, + isMdxJsxElementHast, + mdxJsxAttributeToString, + visit, + type Hast, +} from "@fern-docs/mdx"; + +export interface RehypeLinksOptions { + replaceHref?(href: string): string | undefined; +} + +export function rehypeLinks({ replaceHref }: RehypeLinksOptions = {}): ( + ast: Hast.Root +) => void { + return function (ast): void { + if (replaceHref == null) { + return; + } + visit(ast, (node) => { + if (node.type === "element" && node.tagName === "a") { + const href = node.properties.href; + if (typeof href !== "string") { + return; + } + const newHref = replaceHref(href); + if (newHref == null) { + return; + } + node.properties.href = newHref; + } + + // TODO handle nested attributes and mdx expressions + if (isMdxJsxElementHast(node)) { + const attributes = node.attributes.filter(isMdxJsxAttribute); + const hrefAttribute = attributes.find((attr) => attr.name === "href"); + if (hrefAttribute == null) { + return; + } + const href = mdxJsxAttributeToString(hrefAttribute); + if (!href) { + return; + } + const newHref = replaceHref(href); + if (newHref == null) { + return; + } + hrefAttribute.value = newHref; + } + }); + }; +} diff --git a/packages/fern-docs/ui/src/mdx/types.ts b/packages/fern-docs/ui/src/mdx/types.ts index 7a002e0c93..d5aed887c4 100644 --- a/packages/fern-docs/ui/src/mdx/types.ts +++ b/packages/fern-docs/ui/src/mdx/types.ts @@ -1,6 +1,7 @@ import type * as FernDocs from "@fern-api/fdr-sdk/docs"; import type { Options } from "@mdx-js/esbuild"; import { RehypeFilesOptions } from "./plugins/rehype-files"; +import { RehypeLinksOptions } from "./plugins/rehype-links"; export type FernSerializeMdxOptions = { filename?: string; @@ -10,6 +11,7 @@ export type FernSerializeMdxOptions = { files?: Record; scope?: Record; replaceSrc?: RehypeFilesOptions["replaceSrc"]; + replaceHref?: RehypeLinksOptions["replaceHref"]; }; export type SerializeMdxFunc =