From 1e4df0fae5645465b8f1bfc3c4946bea698a75be Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 24 Feb 2026 15:52:15 -0800 Subject: [PATCH] migrate to next/mdx --- .../mdx-components.tsx => mdx-components.tsx | 44 ++-- next.config.mjs | 39 ++- package-lock.json | 234 +++++++++--------- package.json | 6 +- src/app/docs/DocsPageContent.tsx | 2 +- .../docs/docs-mdx.module.css} | 0 src/lib/docs/page.ts | 101 +++----- src/lib/docs/rehype-highlight-all.mjs | 10 + .../docs/remark-gfm-alerts-as-callouts.mjs | 22 ++ src/lib/docs/remark-heading-ids.mjs | 39 +++ 10 files changed, 301 insertions(+), 196 deletions(-) rename src/lib/docs/mdx-components.tsx => mdx-components.tsx (71%) rename src/{components/custom-mdx/CustomMDX.module.css => lib/docs/docs-mdx.module.css} (100%) create mode 100644 src/lib/docs/rehype-highlight-all.mjs create mode 100644 src/lib/docs/remark-gfm-alerts-as-callouts.mjs create mode 100644 src/lib/docs/remark-heading-ids.mjs diff --git a/src/lib/docs/mdx-components.tsx b/mdx-components.tsx similarity index 71% rename from src/lib/docs/mdx-components.tsx rename to mdx-components.tsx index 5d0e74a8..49362ebf 100644 --- a/src/lib/docs/mdx-components.tsx +++ b/mdx-components.tsx @@ -1,3 +1,4 @@ +import type { MDXComponents } from "mdx/types"; import Blockquote from "@/components/blockquote"; import ButtonLinks from "@/components/button-links"; import Callout, { @@ -9,7 +10,6 @@ import Callout, { } from "@/components/callout"; import CardLinks from "@/components/card-links"; import CodeBlock from "@/components/codeblock"; -import s from "@/components/custom-mdx/CustomMDX.module.css"; import DonateCard from "@/components/donate-card"; import GitHub from "@/components/github"; import { processGitHubLinks } from "@/components/github/mdx"; @@ -19,7 +19,13 @@ import SponsorCard from "@/components/sponsor-card"; import { BodyParagraph, LI } from "@/components/text"; import VTSequence from "@/components/vt-sequence"; import Video from "@/components/video"; -import { isValidElement, type ReactElement } from "react"; +import s from "@/lib/docs/docs-mdx.module.css"; +import { + isValidElement, + type ComponentPropsWithoutRef, + type ImgHTMLAttributes, + type ReactElement, +} from "react"; type MermaidCodeElement = { className?: string; @@ -33,35 +39,35 @@ function isReactElement( return isValidElement(children); } -// mdxComponents defines the React component map used by docs MDX compilation. -export const mdxComponents = { - h1: (props: React.ComponentPropsWithoutRef<"h1">) => ( +// mdxComponents defines the React component map used while rendering docs MDX. +const mdxComponents: MDXComponents = { + h1: (props: ComponentPropsWithoutRef<"h1">) => ( ), - h2: (props: React.ComponentPropsWithoutRef<"h2">) => ( + h2: (props: ComponentPropsWithoutRef<"h2">) => ( ), - h3: (props: React.ComponentPropsWithoutRef<"h3">) => ( + h3: (props: ComponentPropsWithoutRef<"h3">) => ( ), - h4: (props: React.ComponentPropsWithoutRef<"h4">) => ( + h4: (props: ComponentPropsWithoutRef<"h4">) => ( ), - h5: (props: React.ComponentPropsWithoutRef<"h5">) => ( + h5: (props: ComponentPropsWithoutRef<"h5">) => ( ), - h6: (props: React.ComponentPropsWithoutRef<"h6">) => ( + h6: (props: ComponentPropsWithoutRef<"h6">) => ( ), - li: (props: React.ComponentPropsWithoutRef<"li">) => { + li: (props: ComponentPropsWithoutRef<"li">) => { const processedChildren = processGitHubLinks(props.children); return
  • {processedChildren}
  • ; }, - p: (props: React.ComponentPropsWithoutRef<"p">) => { + p: (props: ComponentPropsWithoutRef<"p">) => { const processedChildren = processGitHubLinks(props.children); return {processedChildren}; }, - code: (props: React.ComponentPropsWithoutRef<"code">) => { + code: (props: ComponentPropsWithoutRef<"code">) => { if (!props.className) { return ; } @@ -75,7 +81,7 @@ export const mdxComponents = { return ; }, - pre: (props: React.ComponentPropsWithoutRef<"pre">) => { + pre: (props: ComponentPropsWithoutRef<"pre">) => { const { children } = props; if (isReactElement(children)) { const className = children.props?.className; @@ -93,7 +99,7 @@ export const mdxComponents = { return ; }, blockquote: Blockquote, - img: (props: React.ImgHTMLAttributes) => ( + img: (props: ImgHTMLAttributes) => ( // biome-ignore lint/performance/noImgElement: Docs content deliberately uses plain img tags. {props.alt} ), @@ -115,3 +121,11 @@ export const mdxComponents = { ), }; + +// useMDXComponents returns the component map that Next.js uses while rendering MDX files. +export function useMDXComponents(components: MDXComponents): MDXComponents { + return { + ...mdxComponents, + ...components, + }; +} diff --git a/next.config.mjs b/next.config.mjs index 78e23dc6..8b43d476 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,5 +1,42 @@ +import createMDX from "@next/mdx"; +import { createRequire } from "node:module"; + +// require resolves plugin module paths for the MDX loader in ESM config files. +const require = createRequire(import.meta.url); + +// gfmAlertsAsCalloutsPlugin points at the local remark plugin that maps GFM alerts to Callout nodes. +const gfmAlertsAsCalloutsPlugin = require.resolve( + "./src/lib/docs/remark-gfm-alerts-as-callouts.mjs", +); + +// headingIdsPlugin points at the local remark plugin that applies stable heading IDs. +const headingIdsPlugin = require.resolve( + "./src/lib/docs/remark-heading-ids.mjs", +); + +// syntaxHighlightingPlugin points at the local rehype plugin that enables code highlighting. +const syntaxHighlightingPlugin = require.resolve( + "./src/lib/docs/rehype-highlight-all.mjs", +); + +// withMDX configures the Next.js MDX pipeline for docs rendering. +const withMDX = createMDX({ + extension: /\.mdx?$/, + options: { + remarkPlugins: [ + "remark-frontmatter", + "remark-gfm", + gfmAlertsAsCalloutsPlugin, + headingIdsPlugin, + ], + rehypePlugins: [syntaxHighlightingPlugin], + }, +}); + +// nextConfig contains shared website framework and header behavior. /** @type {import('next').NextConfig} */ const nextConfig = { + pageExtensions: ["ts", "tsx", "js", "jsx", "md", "mdx"], reactStrictMode: true, experimental: { // The homepage animation and docs reference pages intentionally ship @@ -28,4 +65,4 @@ const nextConfig = { }, }; -export default nextConfig; +export default withMDX(nextConfig); diff --git a/package-lock.json b/package-lock.json index 0a17791f..694b50cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "ghostty-website", "version": "0.1.0", "dependencies": { + "@mdx-js/loader": "^3.1.1", + "@next/mdx": "^16.1.6", "@r4ai/remark-callout": "^0.6.2", "classnames": "^2.5.1", "fast-xml-parser": "^5.3.7", @@ -15,12 +17,14 @@ "lucide-react": "^0.575.0", "mermaid": "^11.12.3", "next": "^16.1.6", - "next-mdx-remote": "^6.0.0", "react": "19.2.4", "react-dom": "19.2.4", "react-intersection-observer": "^10.0.3", "rehype-highlight": "^7.0.2", + "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.1", + "remark-mdx": "^3.1.1", + "remark-parse": "^11.0.0", "slugify": "^1.6.6", "zustand": "^5.0.11" }, @@ -45,29 +49,6 @@ "url": "https://github.com/sponsors/antfu" } }, - "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@biomejs/biome": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.4.tgz", @@ -769,6 +750,28 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@mdx-js/loader": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@mdx-js/loader/-/loader-3.1.1.tgz", + "integrity": "sha512-0TTacJyZ9mDmY+VefuthVshaNIyCGZHJG2fMnGaDttCt8HmjUF7SizlHJpaCDoGnN635nK1wpzfpx/Xx5S4WnQ==", + "license": "MIT", + "dependencies": { + "@mdx-js/mdx": "^3.0.0", + "source-map": "^0.7.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "webpack": ">=5" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + } + } + }, "node_modules/@mdx-js/mdx": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.1.tgz", @@ -806,23 +809,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/@mdx-js/react": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz", - "integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==", - "license": "MIT", - "dependencies": { - "@types/mdx": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - }, - "peerDependencies": { - "@types/react": ">=16", - "react": ">=16" - } - }, "node_modules/@mermaid-js/parser": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.0.0.tgz", @@ -838,6 +824,27 @@ "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==", "license": "MIT" }, + "node_modules/@next/mdx": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/mdx/-/mdx-16.1.6.tgz", + "integrity": "sha512-PT5JR4WPPYOls7WD6xEqUVVI9HDY8kY7XLQsNYB2lSZk5eJSXWu3ECtIYmfR0hZpx8Sg7BKZYKi2+u5OTSEx0w==", + "license": "MIT", + "dependencies": { + "source-map": "^0.7.0" + }, + "peerDependencies": { + "@mdx-js/loader": ">=0.15.0", + "@mdx-js/react": ">=0.15.0" + }, + "peerDependenciesMeta": { + "@mdx-js/loader": { + "optional": true + }, + "@mdx-js/react": { + "optional": true + } + } + }, "node_modules/@next/swc-darwin-arm64": { "version": "16.1.6", "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz", @@ -1315,6 +1322,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1566,6 +1574,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, "license": "MIT" }, "node_modules/cytoscape": { @@ -2343,6 +2352,27 @@ "fxparser": "src/cli/cli.js" } }, + "node_modules/fault": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fault/-/fault-2.0.1.tgz", + "integrity": "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==", + "license": "MIT", + "dependencies": { + "format": "^0.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/format": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", + "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", + "engines": { + "node": ">=0.4.x" + } + }, "node_modules/gray-matter": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", @@ -2562,12 +2592,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, "node_modules/js-yaml": { "version": "3.14.2", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", @@ -2757,6 +2781,24 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-frontmatter": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-frontmatter/-/mdast-util-frontmatter-2.0.1.tgz", + "integrity": "sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "escape-string-regexp": "^5.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-extension-frontmatter": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-gfm": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", @@ -3101,6 +3143,22 @@ "micromark-util-types": "^2.0.0" } }, + "node_modules/micromark-extension-frontmatter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-frontmatter/-/micromark-extension-frontmatter-2.0.0.tgz", + "integrity": "sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==", + "license": "MIT", + "dependencies": { + "fault": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/micromark-extension-gfm": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", @@ -3838,28 +3896,6 @@ } } }, - "node_modules/next-mdx-remote": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/next-mdx-remote/-/next-mdx-remote-6.0.0.tgz", - "integrity": "sha512-cJEpEZlgD6xGjB4jL8BnI8FaYdN9BzZM4NwadPe1YQr7pqoWjg9EBCMv3nXBkuHqMRfv2y33SzUsuyNh9LFAQQ==", - "license": "MPL-2.0", - "dependencies": { - "@babel/code-frame": "^7.23.5", - "@mdx-js/mdx": "^3.0.1", - "@mdx-js/react": "^3.0.1", - "unist-util-remove": "^4.0.0", - "unist-util-visit": "^5.1.0", - "vfile": "^6.0.1", - "vfile-matter": "^5.0.0" - }, - "engines": { - "node": ">=14", - "npm": ">=7" - }, - "peerDependencies": { - "react": ">=16" - } - }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -4109,6 +4145,22 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-frontmatter": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/remark-frontmatter/-/remark-frontmatter-5.0.0.tgz", + "integrity": "sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-frontmatter": "^2.0.0", + "micromark-extension-frontmatter": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", @@ -4564,21 +4616,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/unist-util-remove": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unist-util-remove/-/unist-util-remove-4.0.0.tgz", - "integrity": "sha512-b4gokeGId57UVRX/eVKej5gXqGlc9+trkORhFJpu9raqZkZhU0zm8Doi05+HaiBsMEIJowL+2WtQ5ItjsngPXg==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/unist-util-stringify-position": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", @@ -4648,20 +4685,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/vfile-matter": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/vfile-matter/-/vfile-matter-5.0.1.tgz", - "integrity": "sha512-o6roP82AiX0XfkyTHyRCMXgHfltUNlXSEqCIS80f+mbAyiQBE2fxtDVMtseyytGx75sihiJFo/zR6r/4LTs2Cw==", - "license": "MIT", - "dependencies": { - "vfile": "^6.0.0", - "yaml": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/vfile-message": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", @@ -4725,21 +4748,6 @@ "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", "license": "MIT" }, - "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/zustand": { "version": "5.0.11", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz", diff --git a/package.json b/package.json index 76dc8196..0164ec97 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "format": "biome format --write ." }, "dependencies": { + "@mdx-js/loader": "^3.1.1", + "@next/mdx": "^16.1.6", "@r4ai/remark-callout": "^0.6.2", "classnames": "^2.5.1", "fast-xml-parser": "^5.3.7", @@ -17,12 +19,14 @@ "lucide-react": "^0.575.0", "mermaid": "^11.12.3", "next": "^16.1.6", - "next-mdx-remote": "^6.0.0", "react": "19.2.4", "react-dom": "19.2.4", "react-intersection-observer": "^10.0.3", "rehype-highlight": "^7.0.2", + "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.1", + "remark-mdx": "^3.1.1", + "remark-parse": "^11.0.0", "slugify": "^1.6.6", "zustand": "^5.0.11" }, diff --git a/src/app/docs/DocsPageContent.tsx b/src/app/docs/DocsPageContent.tsx index 7ddcbbed..5842018f 100644 --- a/src/app/docs/DocsPageContent.tsx +++ b/src/app/docs/DocsPageContent.tsx @@ -4,10 +4,10 @@ import ScrollToTopButton from "@/components/scroll-to-top"; import Sidecar from "@/components/sidecar"; import { H1, P } from "@/components/text"; import { DOCS_PAGES_ROOT_PATH, GITHUB_REPO_URL } from "@/lib/docs/config"; +import customMdxStyles from "@/lib/docs/docs-mdx.module.css"; import type { DocsPageData } from "@/lib/docs/page"; import { Pencil } from "lucide-react"; import s from "./DocsPage.module.css"; -import customMdxStyles from "@/components/custom-mdx/CustomMDX.module.css"; interface DocsPageContentProps { navTreeData: NavTreeNode[]; diff --git a/src/components/custom-mdx/CustomMDX.module.css b/src/lib/docs/docs-mdx.module.css similarity index 100% rename from src/components/custom-mdx/CustomMDX.module.css rename to src/lib/docs/docs-mdx.module.css diff --git a/src/lib/docs/page.ts b/src/lib/docs/page.ts index dfa5b276..48407b62 100644 --- a/src/lib/docs/page.ts +++ b/src/lib/docs/page.ts @@ -1,21 +1,13 @@ -import remarkCallout, { - type Options as RemarkCalloutOptions, -} from "@r4ai/remark-callout"; import matter from "gray-matter"; -import { all } from "lowlight"; -import type { Root } from "mdast"; -import { compileMDX } from "next-mdx-remote/rsc"; import { promises as fs } from "node:fs"; -import type { ReactNode } from "react"; -import rehypeHighlight, { - type Options as RehypeHighlightOptions, -} from "rehype-highlight"; +import { createElement, type ComponentType, type ReactNode } from "react"; +import remarkMdx from "remark-mdx"; +import remarkParse from "remark-parse"; import remarkGfm from "remark-gfm"; import slugify from "slugify"; -import type { Plugin } from "unified"; +import { unified } from "unified"; import type { Node } from "unist"; import { visit } from "unist-util-visit"; -import { mdxComponents } from "./mdx-components"; const nodePath = require("node:path"); @@ -75,36 +67,8 @@ async function loadDocsPageFromRelativeFilePath( ): Promise { const mdxFileContent = matter.read(relativeFilePath); const slug = slugFromRelativeFilePath(relativeFilePath); - - const pageHeaders: PageHeader[] = []; - - const { content } = await compileMDX({ - source: mdxFileContent.content, - components: mdxComponents, - options: { - // next-mdx-remote v6 blocks JS expressions by default. Our docs use - // trusted MDX expressions, so keep JS enabled while retaining the - // dangerous-global protections introduced in v6. - blockJS: false, - blockDangerousJS: true, - mdxOptions: { - remarkPlugins: [ - remarkGfm, - gfmAlertsAsCallouts(), - parseAnchorLinks({ pageHeaders }), - ], - rehypePlugins: [ - [ - rehypeHighlight, - { - detect: false, - languages: all, - } satisfies RehypeHighlightOptions, - ], - ], - }, - }, - }); + const pageHeaders = await extractPageHeaders(mdxFileContent.content); + const MdxContent = await loadMdxComponent(relativeFilePath); return { slug, relativeFilePath, @@ -116,33 +80,40 @@ async function loadDocsPageFromRelativeFilePath( hideSidecar: Object.hasOwn(mdxFileContent.data, "hideSidecar") ? mdxFileContent.data.hideSidecar : false, - content, + content: createElement(MdxContent), pageHeaders, }; } -// gfmAlertsAsCallouts converts GFM alert blocks into typed Callout MDX nodes. -function gfmAlertsAsCallouts(): [ - Plugin<[RemarkCalloutOptions], Root>, - RemarkCalloutOptions, -] { - return [ - remarkCallout, - { - root: (callout) => ({ - tagName: "Callout", - properties: { - type: callout.type.toLowerCase(), - isFoldable: String(callout.isFoldable), - }, - }), - // We won't use title, just type. - title: () => ({ - tagName: "callout-title", - properties: {}, - }), - } satisfies RemarkCalloutOptions, - ]; +// MdxModule is the expected shape of an imported MDX module. +type MdxModule = { + default: ComponentType; +}; + +// loadMdxComponent loads the statically-compiled MDX React component for one docs file. +async function loadMdxComponent( + relativeFilePath: string, +): Promise { + const normalizedRelativePath = relativeFilePath + .replaceAll(nodePath.sep, "/") + .replace(/^\.\//, ""); + const docsRelativePath = normalizedRelativePath.replace(/^docs\//, ""); + const importPath = `../../../docs/${docsRelativePath}`; + const mdxModule = (await import(importPath)) as MdxModule; + return mdxModule.default; +} + +// extractPageHeaders parses MDX source and returns stable heading metadata. +async function extractPageHeaders(source: string): Promise { + const pageHeaders: PageHeader[] = []; + const processor = unified() + .use(remarkParse) + .use(remarkMdx) + .use(remarkGfm) + .use(parseAnchorLinks({ pageHeaders })); + const tree = processor.parse(source); + await processor.run(tree); + return pageHeaders; } // parseAnchorLinks captures headings into pageHeaders and assigns stable heading IDs. diff --git a/src/lib/docs/rehype-highlight-all.mjs b/src/lib/docs/rehype-highlight-all.mjs new file mode 100644 index 00000000..c37b4b1a --- /dev/null +++ b/src/lib/docs/rehype-highlight-all.mjs @@ -0,0 +1,10 @@ +import { all } from "lowlight"; +import rehypeHighlight from "rehype-highlight"; + +// rehypeHighlightAll enables syntax highlighting with the full lowlight language set. +export default function rehypeHighlightAll() { + return rehypeHighlight({ + detect: false, + languages: all, + }); +} diff --git a/src/lib/docs/remark-gfm-alerts-as-callouts.mjs b/src/lib/docs/remark-gfm-alerts-as-callouts.mjs new file mode 100644 index 00000000..ecbe7197 --- /dev/null +++ b/src/lib/docs/remark-gfm-alerts-as-callouts.mjs @@ -0,0 +1,22 @@ +import remarkCallout from "@r4ai/remark-callout"; + +// calloutTransform maps GFM alerts to the custom MDX callout elements used by docs pages. +const calloutTransform = remarkCallout({ + root: (callout) => ({ + tagName: "Callout", + properties: { + type: callout.type.toLowerCase(), + isFoldable: String(callout.isFoldable), + }, + }), + // We won't use title, just type. + title: () => ({ + tagName: "callout-title", + properties: {}, + }), +}); + +// remarkGfmAlertsAsCallouts exposes the callout transform as a remark plugin. +export default function remarkGfmAlertsAsCallouts() { + return calloutTransform; +} diff --git a/src/lib/docs/remark-heading-ids.mjs b/src/lib/docs/remark-heading-ids.mjs new file mode 100644 index 00000000..b4e25957 --- /dev/null +++ b/src/lib/docs/remark-heading-ids.mjs @@ -0,0 +1,39 @@ +import slugify from "slugify"; +import { visit } from "unist-util-visit"; + +// remarkHeadingIds applies stable IDs and de-duplication indices to heading nodes. +export default function remarkHeadingIds() { + // encounteredIDs tracks duplicate IDs so each heading receives a unique anchor. + const encounteredIDs = new Map(); + + return (node) => { + visit(node, "heading", (headingNode) => { + if (!Array.isArray(headingNode.children) || headingNode.children.length === 0) { + return; + } + + const text = headingNode.children + .map((child) => (typeof child.value === "string" ? child.value : "")) + .join(""); + const baseId = slugify(text.toLowerCase()); + const encounteredCount = (encounteredIDs.get(baseId) || 0) + 1; + encounteredIDs.set(baseId, encounteredCount); + const resolvedID = encounteredCount >= 2 ? `${baseId}-${encounteredCount}` : baseId; + + if (!headingNode.data) { + headingNode.data = {}; + } + headingNode.data.hProperties = { + ...headingNode.data.hProperties, + id: resolvedID, + }; + + if (encounteredCount >= 2) { + headingNode.data.hProperties = { + ...headingNode.data.hProperties, + "data-index": encounteredCount.toString(), + }; + } + }); + }; +}