diff --git a/packages/ui/app/package.json b/packages/ui/app/package.json index 57792d8e80..2caac6ba4f 100644 --- a/packages/ui/app/package.json +++ b/packages/ui/app/package.json @@ -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", @@ -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", @@ -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", diff --git a/packages/ui/app/src/__test__/setup.ts b/packages/ui/app/src/__test__/setup.ts new file mode 100644 index 0000000000..04491f43d2 --- /dev/null +++ b/packages/ui/app/src/__test__/setup.ts @@ -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(); +}); diff --git a/packages/ui/app/src/mdx/__test__/mdx.test.ts b/packages/ui/app/src/mdx/__test__/mdx.test.ts new file mode 100644 index 0000000000..40b2e4d9e7 --- /dev/null +++ b/packages/ui/app/src/mdx/__test__/mdx.test.ts @@ -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 { + 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("
Hello world!
"); + expect(result.type).toBe("div"); + expect(result.children).toEqual(["Hello world!"]); + }); + + it("should render custom html with JSX", async () => { + const result = await renderMdxContent('
Hello world!
'); + expect(result.type).toBe("div"); + expect(result.props.style).toEqual({ display: "none" }); + }); + + it("should render custom html with className", async () => { + const result = await renderMdxContent('
Hello world!
'); + expect(result.type).toBe("div"); + expect(result.props.className).toEqual("testing"); + }); + + it("should render custom html with className 2", async () => { + const result = await renderMdxContent('
Hello world!
'); + expect(result.type).toBe("div"); + expect(result.props.className).toEqual("testing"); + }); + + it("should render custom html with CSS styles", async () => { + const result = await renderMdxContent('
Hello world!
'); + 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"); + }); + }); +}); diff --git a/packages/ui/app/src/mdx/base-components.tsx b/packages/ui/app/src/mdx/base-components.tsx index 3e089fea62..97a4e3f9d8 100644 --- a/packages/ui/app/src/mdx/base-components.tsx +++ b/packages/ui/app/src/mdx/base-components.tsx @@ -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"; @@ -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"), }, - , + , {props.children}, ); }; diff --git a/packages/ui/app/src/mdx/mdx.ts b/packages/ui/app/src/mdx/mdx.ts index bc7c40c52c..88dbfc76fa 100644 --- a/packages/ui/app/src/mdx/mdx.ts +++ b/packages/ui/app/src/mdx/mdx.ts @@ -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; @@ -31,14 +31,20 @@ export type SerializedMdxContent = MDXRemoteSerializeResult[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", }; /** diff --git a/packages/ui/app/src/mdx/plugins/rehypeSanitizeJSX.ts b/packages/ui/app/src/mdx/plugins/rehypeSanitizeJSX.ts index 1875a087bd..410c379a32 100644 --- a/packages/ui/app/src/mdx/plugins/rehypeSanitizeJSX.ts +++ b/packages/ui/app/src/mdx/plugins/rehypeSanitizeJSX.ts @@ -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"; @@ -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; + }); + } + }); }; } diff --git a/packages/ui/app/src/mdx/plugins/remarkRehypeHandlers.ts b/packages/ui/app/src/mdx/plugins/remarkRehypeHandlers.ts new file mode 100644 index 0000000000..cc21677a2f --- /dev/null +++ b/packages/ui/app/src/mdx/plugins/remarkRehypeHandlers.ts @@ -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); +} diff --git a/packages/ui/app/src/syntax-highlighting/FernSyntaxHighlighterTokens.tsx b/packages/ui/app/src/syntax-highlighting/FernSyntaxHighlighterTokens.tsx index b5f7cf9f70..b44d12e54e 100644 --- a/packages/ui/app/src/syntax-highlighting/FernSyntaxHighlighterTokens.tsx +++ b/packages/ui/app/src/syntax-highlighting/FernSyntaxHighlighterTokens.tsx @@ -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"; @@ -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; @@ -179,42 +178,3 @@ function flattenHighlightLines(highlightLines: HighlightLine[]): number[] { return [lineNumber - 1]; }); } - -function parseStyle(value: unknown): Record | undefined { - if (typeof value !== "string") { - return undefined; - } - - const result: Record = {}; - - 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; -} diff --git a/packages/ui/app/src/util/parseStringStyle.ts b/packages/ui/app/src/util/parseStringStyle.ts new file mode 100644 index 0000000000..548c4215ed --- /dev/null +++ b/packages/ui/app/src/util/parseStringStyle.ts @@ -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 | undefined { + if (typeof value !== "string") { + return undefined; + } + + const result: Record = {}; + + 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; +} diff --git a/packages/ui/app/vitest.config.ts b/packages/ui/app/vitest.config.ts index 1e11c218f0..4fdcbdcfc0 100644 --- a/packages/ui/app/vitest.config.ts +++ b/packages/ui/app/vitest.config.ts @@ -5,5 +5,7 @@ export default defineConfig({ plugins: [react()], test: { globals: true, + environment: "jsdom", + setupFiles: "./src/__test__/setup.ts", }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3136c79369..fcd03643ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -780,9 +780,15 @@ importers: lodash-es: specifier: ^4.17.21 version: 4.17.21 + mdast: + specifier: ^3.0.0 + version: 3.0.0 mdast-util-mdx-jsx: specifier: ^3.1.0 version: 3.1.2 + mdast-util-to-hast: + specifier: ^13.1.0 + version: 13.1.0 moment: specifier: ^2.30.1 version: 2.30.1 @@ -831,6 +837,9 @@ importers: react-medium-image-zoom: specifier: ^5.1.10 version: 5.1.10(react-dom@18.2.0)(react@18.2.0) + rehype-slug: + specifier: ^6.0.0 + version: 6.0.0 remark-gemoji: specifier: ^8.0.0 version: 8.0.0 @@ -907,6 +916,9 @@ importers: jsdom: specifier: ^24.0.0 version: 24.0.0 + next-router-mock: + specifier: ^0.9.13 + version: 0.9.13(next@14.1.4)(react@18.2.0) organize-imports-cli: specifier: ^0.10.0 version: 0.10.0 @@ -2371,6 +2383,7 @@ packages: engines: {node: '>=6.9.0'} dependencies: '@babel/types': 7.24.0 + dev: true /@babel/helper-module-transforms@7.23.3(@babel/core@7.24.0): resolution: {integrity: sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==} @@ -2393,7 +2406,7 @@ packages: dependencies: '@babel/core': 7.24.3 '@babel/helper-environment-visitor': 7.22.20 - '@babel/helper-module-imports': 7.24.3 + '@babel/helper-module-imports': 7.22.15 '@babel/helper-simple-access': 7.22.5 '@babel/helper-split-export-declaration': 7.22.6 '@babel/helper-validator-identifier': 7.22.20 @@ -9759,6 +9772,10 @@ packages: resolve-pkg-maps: 1.0.0 dev: true + /github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + dev: false + /glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -9987,6 +10004,12 @@ packages: dependencies: function-bind: 1.1.2 + /hast-util-heading-rank@3.0.0: + resolution: {integrity: sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==} + dependencies: + '@types/hast': 3.0.4 + dev: false + /hast-util-parse-selector@4.0.0: resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} dependencies: @@ -10036,6 +10059,12 @@ packages: transitivePeerDependencies: - supports-color + /hast-util-to-string@3.0.0: + resolution: {integrity: sha512-OGkAxX1Ua3cbcW6EJ5pT/tslVb90uViVkcJ4ZZIMW/R33DX/AkcJcRrPebPwJkHYwlDHXz4aIwvAAaAdtrACFA==} + dependencies: + '@types/hast': 3.0.4 + dev: false + /hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} dependencies: @@ -11215,6 +11244,11 @@ packages: dependencies: '@types/mdast': 4.0.3 + /mdast@3.0.0: + resolution: {integrity: sha512-xySmf8g4fPKMeC07jXGz971EkLbWAJ83s4US2Tj9lEdnZ142UP5grN73H1Xd3HzrdbU5o9GYYP/y8F9ZSwLE9g==} + deprecated: '`mdast` was renamed to `remark`' + dev: false + /mdn-data@2.0.28: resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} dev: false @@ -11781,6 +11815,16 @@ packages: - supports-color dev: true + /next-router-mock@0.9.13(next@14.1.4)(react@18.2.0): + resolution: {integrity: sha512-906n2RRaE6Y28PfYJbaz5XZeJ6Tw8Xz1S6E31GGwZ0sXB6/XjldD1/2azn1ZmBmRk5PQRkzjg+n+RHZe5xQzWA==} + peerDependencies: + next: '>=10.0.0' + react: '>=17.0.0' + dependencies: + next: 14.1.4(@babel/core@7.24.3)(react-dom@18.2.0)(react@18.2.0) + react: 18.2.0 + dev: true + /next-themes@0.2.1(next@14.1.4)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-B+AKNfYNIzh0vqQQKqQItTS8evEouKD7H5Hj3kmuPERwddR2TxvDSFZuTj6T7Jfn1oyeUyJMydPl1Bkxkh0W7A==} peerDependencies: @@ -11869,7 +11913,6 @@ packages: transitivePeerDependencies: - '@babel/core' - babel-plugin-macros - dev: false /node-cache@5.1.2: resolution: {integrity: sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==} @@ -13275,6 +13318,16 @@ packages: jsesc: 0.5.0 dev: true + /rehype-slug@6.0.0: + resolution: {integrity: sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==} + dependencies: + '@types/hast': 3.0.4 + github-slugger: 2.0.0 + hast-util-heading-rank: 3.0.0 + hast-util-to-string: 3.0.0 + unist-util-visit: 5.0.0 + dev: false + /remark-gemoji@8.0.0: resolution: {integrity: sha512-/fL9rc72FYwFGtOKcT+QeQdx9Q9t5v4N6KLXSDOTEgaedzK85I9judBqB2eqz+g4b0ERMejlwSOuPK+wket6aA==} dependencies: @@ -13998,7 +14051,6 @@ packages: '@babel/core': 7.24.3 client-only: 0.0.1 react: 18.2.0 - dev: false /styled-jsx@5.1.2(@babel/core@7.24.0)(react@18.2.0): resolution: {integrity: sha512-FI5r0a5ED2/+DSdG2ZRz3a4FtNQnKPLadauU5v76a9QsscwZrWggQKOmyxGGP5EWKbyY3bsuWAJYzyKaDAVAcw==}