Skip to content

Commit

Permalink
feat: enable custom anchor links (#590)
Browse files Browse the repository at this point in the history
  • Loading branch information
abvthecity authored Mar 28, 2024
1 parent f9b4b9c commit 725707a
Show file tree
Hide file tree
Showing 11 changed files with 258 additions and 56 deletions.
4 changes: 4 additions & 0 deletions packages/ui/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,9 @@
"jotai": "^2.7.0",
"jsonpath": "^1.1.1",
"lodash-es": "^4.17.21",
"mdast": "^3.0.0",
"mdast-util-mdx-jsx": "^3.1.0",
"mdast-util-to-hast": "^13.1.0",
"moment": "^2.30.1",
"next": "^14.1.4",
"next-mdx-remote": "^4.4.1",
Expand All @@ -79,6 +81,7 @@
"react-instantsearch": "^7.7.0",
"react-intersection-observer": "^9.4.3",
"react-medium-image-zoom": "^5.1.10",
"rehype-slug": "^6.0.0",
"remark-gemoji": "^8.0.0",
"remark-gfm": "^4.0.0",
"remark-parse": "^11.0.0",
Expand Down Expand Up @@ -106,6 +109,7 @@
"depcheck": "^1.4.3",
"eslint": "^8.56.0",
"jsdom": "^24.0.0",
"next-router-mock": "^0.9.13",
"organize-imports-cli": "^0.10.0",
"prettier": "^3.2.4",
"react-test-renderer": "^18.2.0",
Expand Down
13 changes: 13 additions & 0 deletions packages/ui/app/src/__test__/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import * as matchers from "@testing-library/jest-dom/matchers";
import { cleanup } from "@testing-library/react";
import { afterEach, beforeAll, expect, vi } from "vitest";

expect.extend(matchers);

beforeAll(() => {
vi.mock("next/router", () => require("next-router-mock"));
});

afterEach(() => {
cleanup();
});
71 changes: 71 additions & 0 deletions packages/ui/app/src/mdx/__test__/mdx.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { createElement } from "react";
import renderer from "react-test-renderer";
import { MdxContent } from "../MdxContent";
import { serializeMdxContent } from "../mdx";

async function renderMdxContent(content: string): Promise<renderer.ReactTestRendererJSON> {
const serializedContent = await serializeMdxContent(content, true);
const result = renderer.create(createElement(MdxContent, { mdx: serializedContent })).toJSON();

assert(result != null);
assert(!Array.isArray(result));

return result;
}

describe("serializeMdxContent", () => {
describe("custom html", () => {
it("should render custom html", async () => {
const result = await renderMdxContent("<div>Hello world!</div>");
expect(result.type).toBe("div");
expect(result.children).toEqual(["Hello world!"]);
});

it("should render custom html with JSX", async () => {
const result = await renderMdxContent('<div style={{ display: "none" }}>Hello world!</div>');
expect(result.type).toBe("div");
expect(result.props.style).toEqual({ display: "none" });
});

it("should render custom html with className", async () => {
const result = await renderMdxContent('<div className="testing">Hello world!</div>');
expect(result.type).toBe("div");
expect(result.props.className).toEqual("testing");
});

it("should render custom html with className 2", async () => {
const result = await renderMdxContent('<div class="testing">Hello world!</div>');
expect(result.type).toBe("div");
expect(result.props.className).toEqual("testing");
});

it("should render custom html with CSS styles", async () => {
const result = await renderMdxContent('<div style="display: none">Hello world!</div>');
expect(result.type).toBe("div");
expect(result.props.style).toEqual({ display: "none" });
});
});

describe("headings", () => {
it("should generate automatic anchor link", async () => {
const result = await renderMdxContent("### Hello world!");
expect(result.type).toBe("h3");
expect(result.props.id).toBe("hello-world");
});

it("should generate with automatic anchor link with special letters", async () => {
const result = await renderMdxContent("### Hello world ✅");
expect(result.type).toBe("h3");
expect(result.props.id).toBe("hello-world-");
});

it("should generate with a custom anchor link", async () => {
const mdxContent = "### Hello world! [#custom-anchor]";

const result = await renderMdxContent(mdxContent);

expect(result.type).toBe("h3");
expect(result.props.id).toBe("custom-anchor");
});
});
});
8 changes: 3 additions & 5 deletions packages/ui/app/src/mdx/base-components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import { AbsolutelyPositionedAnchor } from "../commons/AbsolutelyPositionedAncho
import { FernCard } from "../components/FernCard";
import { FernLink } from "../components/FernLink";
import { useNavigationContext } from "../contexts/navigation-context";
import { getSlugFromChildren } from "../util/getSlugFromText";
import { onlyText } from "../util/onlyText";
import "./base-components.scss";

Expand Down Expand Up @@ -73,16 +72,15 @@ export function useCurrentPathname(): string {
}

export const HeadingRenderer = (level: number, props: ComponentProps<"h1">): ReactElement => {
const slug = getSlugFromChildren(props.children);
return createElement(
`h${level}`,
{
id: slug,
"data-anchor": slug,
id: props.id,
"data-anchor": props.id,
...props,
className: cn(props.className, "flex items-center relative group/anchor-container mb-3"),
},
<AbsolutelyPositionedAnchor href={{ hash: slug, pathname: useCurrentPathname() }} />,
<AbsolutelyPositionedAnchor href={{ hash: props.id, pathname: useCurrentPathname() }} />,
<span>{props.children}</span>,
);
};
Expand Down
16 changes: 11 additions & 5 deletions packages/ui/app/src/mdx/mdx.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import type { MDXRemoteSerializeResult } from "next-mdx-remote";
import { serialize } from "next-mdx-remote/serialize";
import rehypeSlug from "rehype-slug";
import remarkGemoji from "remark-gemoji";
import remarkGfm from "remark-gfm";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import { emitDatadogError } from "../analytics/datadogRum";
import { stringHasMarkdown } from "./common/util";
import { rehypeFernCode } from "./plugins/rehypeFernCode";
import { rehypeFernComponents } from "./plugins/rehypeFernComponents";
import { rehypeSanitizeJSX } from "./plugins/rehypeSanitizeJSX";
import { customHeadingHandler } from "./plugins/remarkRehypeHandlers";

export interface FernDocsFrontmatter {
title?: string;
Expand All @@ -31,14 +31,20 @@ export type SerializedMdxContent = MDXRemoteSerializeResult<Record<string, unkno
type SerializeOptions = NonNullable<Parameters<typeof serialize>[1]>;

const MDX_OPTIONS: SerializeOptions["mdxOptions"] = {
remarkPlugins: [remarkParse, remarkRehype, remarkGfm, remarkGemoji],
rehypePlugins: [rehypeFernCode, rehypeFernComponents, rehypeSanitizeJSX],
remarkRehypeOptions: {
handlers: {
heading: customHeadingHandler,
},
},

remarkPlugins: [remarkGfm, remarkGemoji],
rehypePlugins: [rehypeSlug, rehypeFernCode, rehypeFernComponents, rehypeSanitizeJSX],
format: "detect",
/**
* development=true is required to render MdxRemote from the client-side.
* https://github.com/hashicorp/next-mdx-remote/issues/350
*/
development: process.env.NODE_ENV !== "production",
// development: process.env.NODE_ENV !== "production",
};

/**
Expand Down
23 changes: 23 additions & 0 deletions packages/ui/app/src/mdx/plugins/rehypeSanitizeJSX.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Root } from "hast";
import { visit } from "unist-util-visit";
import { parseStringStyle } from "../../util/parseStringStyle";
import { INTRINSIC_JSX_TAGS } from "../common/intrinsict-elements";
import { JSX_COMPONENTS } from "../mdx-components";
import { valueToEstree } from "./to-estree";
Expand Down Expand Up @@ -28,5 +29,27 @@ export function rehypeSanitizeJSX(): (tree: Root) => void {
});
}
});

visit(tree, (node) => {
if (isMdxJsxFlowElement(node)) {
node.attributes = node.attributes.map((attr) => {
if (attr.type === "mdxJsxAttribute") {
// convert class to className
if (attr.name === "class") {
return { ...attr, name: "className" };
}

// if the style attribute is a string, convert it to an object
if (attr.name === "style") {
if (typeof attr.value === "string") {
return toAttribute("style", attr.value, valueToEstree(parseStringStyle(attr.value)));
}
}
}

return attr;
});
}
});
};
}
31 changes: 31 additions & 0 deletions packages/ui/app/src/mdx/plugins/remarkRehypeHandlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Element } from "hast";
import { Heading } from "mdast";
import { State } from "mdast-util-to-hast";

export function customHeadingHandler(state: State, node: Heading): Element {
let id: string | undefined;
const children = state.all(node).map((child) => {
if (child.type === "text") {
// extract [#anchor] from heading text
const match = child.value.match(/^(.*?)(?:\s*\[#([^\]]+)\])?$/);
const [, text, anchor] = match || [];
if (text != null && anchor != null) {
id = anchor;
return {
...child,
value: text,
};
}
}

return child;
});
const result: Element = {
type: "element",
tagName: "h" + node.depth,
properties: { id },
children,
};
state.patch(node, result);
return state.applyData(node, result);
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import cn from "clsx";
import { Element } from "hast";
import { camelCase, isEqual } from "lodash-es";
import { isEqual } from "lodash-es";
import { ReactNode, forwardRef, memo, useMemo } from "react";
import StyleToObject from "style-to-object";
import { visit } from "unist-util-visit";
import { emitDatadogError } from "../analytics/datadogRum";
import { FernScrollArea } from "../components/FernScrollArea";
import { HastToJSX } from "../mdx/common/HastToJsx";
import { parseStringStyle } from "../util/parseStringStyle";
import "./FernSyntaxHighlighter.css";
import { HighlightedTokens } from "./fernShiki";

Expand Down Expand Up @@ -117,7 +116,7 @@ export const FernSyntaxHighlighterTokens = memo(

visit(tokens.hast, "element", (node) => {
if (node.tagName === "pre") {
preStyle = parseStyle(node.properties.style) ?? {};
preStyle = parseStringStyle(node.properties.style) ?? {};
}
});
return preStyle;
Expand Down Expand Up @@ -179,42 +178,3 @@ function flattenHighlightLines(highlightLines: HighlightLine[]): number[] {
return [lineNumber - 1];
});
}

function parseStyle(value: unknown): Record<string, string> | undefined {
if (typeof value !== "string") {
return undefined;
}

const result: Record<string, string> = {};

try {
StyleToObject(value, replacer);
} catch (e) {
// eslint-disable-next-line no-console
console.error("Failed to parse style", e);

emitDatadogError(e, {
context: "FernSyntaxHighlighter",
errorSource: "parseStyle",
errorDescription: "Failed to parse style originating from shiki",
data: { value },
});

return undefined;
}

function replacer(name: string, value: string) {
let key = name;

if (key.slice(0, 2) !== "--") {
if (key.slice(0, 4) === "-ms-") {
key = "ms-" + key.slice(4);
}
key = camelCase(key);
}

result[key] = value;
}

return result;
}
42 changes: 42 additions & 0 deletions packages/ui/app/src/util/parseStringStyle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { camelCase } from "lodash-es";
import StyleToObject from "style-to-object";
import { emitDatadogError } from "../analytics/datadogRum";

export function parseStringStyle(value: unknown): Record<string, string> | undefined {
if (typeof value !== "string") {
return undefined;
}

const result: Record<string, string> = {};

try {
StyleToObject(value, replacer);
} catch (e) {
// eslint-disable-next-line no-console
console.error("Failed to parse style", e);

emitDatadogError(e, {
context: "FernSyntaxHighlighter",
errorSource: "parseStyle",
errorDescription: "Failed to parse style originating from shiki",
data: { value },
});

return undefined;
}

function replacer(name: string, value: string) {
let key = name;

if (key.slice(0, 2) !== "--") {
if (key.slice(0, 4) === "-ms-") {
key = "ms-" + key.slice(4);
}
key = camelCase(key);
}

result[key] = value;
}

return result;
}
2 changes: 2 additions & 0 deletions packages/ui/app/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,7 @@ export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: "jsdom",
setupFiles: "./src/__test__/setup.ts",
},
});
Loading

0 comments on commit 725707a

Please sign in to comment.