-
Notifications
You must be signed in to change notification settings - Fork 3
feat: improve AcceptMarkdown support #209
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a6d8697
fb04fbd
e9b8fc8
aa118ff
fd47620
f4cc144
da40466
5cff604
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| import assert from "node:assert/strict"; | ||
| import { describe, test } from "node:test"; | ||
| import { unified } from "unified"; | ||
| import remarkMdx from "remark-mdx"; | ||
| import remarkParse from "remark-parse"; | ||
| import remarkMarkdownImageMap from "./remark-markdown-image-map"; | ||
|
|
||
| async function collectMarkdownImageMap(markdown: string) { | ||
| const processor = unified().use(remarkParse).use(remarkMdx).use(remarkMarkdownImageMap); | ||
| const file = { data: {} }; | ||
| const tree = processor.parse(markdown); | ||
|
|
||
| await processor.run(tree, file as any); | ||
| return file.data as { | ||
| markdownImageMap?: Record<string, string>; | ||
| "mdx-export"?: Array<{ name: string; value: unknown }>; | ||
| }; | ||
| } | ||
|
|
||
| describe("remarkMarkdownImageMap", () => { | ||
| test("exports normalized image URLs keyed by Fumadocs placeholders", async () => { | ||
| const data = await collectMarkdownImageMap(` | ||
|  | ||
|
|
||
|  | ||
| `); | ||
|
|
||
| assert.deepEqual(data.markdownImageMap, { | ||
| __img0: "/docs/images/first.png", | ||
| __img1: "/docs/images/second.png?size=large#preview", | ||
| }); | ||
| assert.deepEqual(data["mdx-export"], [ | ||
| { | ||
| name: "markdownImageMap", | ||
| value: data.markdownImageMap, | ||
| }, | ||
| ]); | ||
| }); | ||
|
|
||
| test("includes JSX image sources without shifting markdown image placeholders", async () => { | ||
| const data = await collectMarkdownImageMap(` | ||
|  | ||
|
|
||
| <Frame> | ||
| <img src="/images/frame.png" alt="Frame screenshot" /> | ||
| </Frame> | ||
|
|
||
|  | ||
| `); | ||
|
|
||
| assert.deepEqual(data.markdownImageMap, { | ||
| __img0: "/docs/images/first.png", | ||
| __img1: "/docs/images/third.png", | ||
| __img2: "/docs/images/frame.png", | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| import { visit } from "unist-util-visit"; | ||
| import { normalizeDocsAssetPath } from "../src/lib/docs-asset-path"; | ||
|
|
||
| const imagePlaceholderPattern = /^__img\d+$/; | ||
|
|
||
| function getAttributeValue(node: any, name: string): string | undefined { | ||
| for (const attribute of node.attributes ?? []) { | ||
| if (attribute.name !== name) continue; | ||
|
|
||
| const value = attribute.value; | ||
| if (typeof value === "string") return value; | ||
| if (typeof value?.value === "string") return value.value; | ||
| } | ||
| } | ||
|
|
||
| function getImageUrl(node: any): string | undefined { | ||
| if (typeof node.url === "string") return normalizeDocsAssetPath(node.url); | ||
|
|
||
| const src = getAttributeValue(node, "src"); | ||
| if (!src || imagePlaceholderPattern.test(src)) return undefined; | ||
|
|
||
| return normalizeDocsAssetPath(src); | ||
| } | ||
|
|
||
| export default function remarkMarkdownImageMap() { | ||
| return (tree: any, file: any) => { | ||
| const imageMap: Record<string, string> = {}; | ||
| let imageIndex = 0; | ||
| const jsxImageUrls: string[] = []; | ||
|
|
||
| visit(tree, "image", (node: any) => { | ||
| const url = getImageUrl(node); | ||
| if (!url) return; | ||
|
|
||
| imageMap[`__img${imageIndex}`] = url; | ||
| imageIndex += 1; | ||
| }); | ||
|
|
||
| visit(tree, ["mdxJsxFlowElement", "mdxJsxTextElement"], (node: any) => { | ||
| if (node.name !== "img") return; | ||
|
|
||
| const url = getImageUrl(node); | ||
| if (!url) return; | ||
|
|
||
| jsxImageUrls.push(url); | ||
| }); | ||
|
|
||
| for (const url of jsxImageUrls) { | ||
| imageMap[`__img${imageIndex}`] = url; | ||
| imageIndex += 1; | ||
| } | ||
|
|
||
| if (Object.keys(imageMap).length > 0) { | ||
| file.data.markdownImageMap = imageMap; | ||
| file.data["mdx-export"] ??= []; | ||
| file.data["mdx-export"].push({ | ||
| name: "markdownImageMap", | ||
| value: imageMap, | ||
| }); | ||
| } | ||
| }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,27 +8,74 @@ import remarkFollowExport from "./plugins/remark-follow-export"; | |
| import remarkDirective from "remark-directive"; | ||
| import { remarkInclude } from "fumadocs-mdx/config"; | ||
| import remarkSdkFilter from "./plugins/remark-sdk-filter"; | ||
| import remarkMarkdownImageMap from "./plugins/remark-markdown-image-map"; | ||
| import type { LLMsOptions } from "fumadocs-core/mdx-plugins"; | ||
|
|
||
| const isDevelopment = process.env.NODE_ENV === "development"; | ||
| const markdownHeadingHandler: NonNullable<LLMsOptions["handlers"]>["heading"] = ( | ||
| node, | ||
| _parent, | ||
| state, | ||
| info, | ||
| ) => { | ||
| const depth = Math.min(Math.max(node.depth, 1), 6); | ||
| return `${"#".repeat(depth)} ${state.containerPhrasing(node, info)}`; | ||
| }; | ||
|
|
||
| const processedMarkdownOptions: LLMsOptions = { | ||
| handlers: { | ||
| heading: markdownHeadingHandler, | ||
| }, | ||
| mdxAsPlaceholder: [ | ||
| "Accordion", | ||
| "AccordionGroup", | ||
| "Callout", | ||
| "Card", | ||
| "CardGroup", | ||
| "CodeGroup", | ||
| "Frame", | ||
| "Info", | ||
| "Mermaid", | ||
| "Note", | ||
| "Step", | ||
| "Steps", | ||
| "Tab", | ||
| "Tabs", | ||
| "Tip", | ||
| "Warning", | ||
| ], | ||
| }; | ||
|
|
||
| const markdownImageMapPassthroughPlugin = { | ||
| "index-file": { | ||
| serverOptions(options: any) { | ||
| options.doc ??= {}; | ||
| options.doc.passthroughs ??= []; | ||
| options.doc.passthroughs.push("markdownImageMap"); | ||
| }, | ||
| }, | ||
| }; | ||
|
|
||
| export const docs = defineDocs({ | ||
| dir: "content/docs", | ||
| docs: { | ||
| async: isDevelopment, | ||
| postprocess: { | ||
| includeProcessedMarkdown: true, | ||
| includeProcessedMarkdown: processedMarkdownOptions, | ||
| }, | ||
| }, | ||
| }); | ||
|
|
||
| export default defineConfig({ | ||
| plugins: [markdownImageMapPassthroughPlugin], | ||
| mdxOptions: { | ||
| // Shiki highlighting is one of the most expensive parts of the MDX pipeline. | ||
| // Keep it in builds, but skip it in local dev so first-page SSR doesn't have | ||
| // to highlight hundreds of code-heavy docs up front. | ||
| rehypeCodeOptions: isDevelopment ? false : undefined, | ||
| remarkPlugins: (existing) => [ | ||
| remarkImagePaths, | ||
| remarkMarkdownImageMap, | ||
|
cursor[bot] marked this conversation as resolved.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For pages whose images come from Useful? React with 👍 / 👎. |
||
| remarkLinkPaths, | ||
| remarkFollowExport, | ||
| ...existing, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| const DOCS_PREFIX = "/docs"; | ||
| const SPECIAL_URL_PATTERN = /^[a-zA-Z][a-zA-Z\d+\-.]*:/; | ||
|
|
||
| export function normalizeDocsAssetPath(url: string): string { | ||
| const trimmed = url.trim(); | ||
| if (!trimmed) return trimmed; | ||
|
|
||
| if (trimmed.startsWith("#") || trimmed.startsWith("//") || SPECIAL_URL_PATTERN.test(trimmed)) { | ||
| return trimmed; | ||
| } | ||
|
|
||
| const [, pathPart = "", suffix = ""] = trimmed.match(/^([^?#]*)([?#].*)?$/) ?? []; | ||
| const segments = pathPart.replace(/^\/+/, "").split("/").filter(Boolean); | ||
|
|
||
| const normalizedSegments: string[] = []; | ||
| for (const segment of segments) { | ||
| if (segment === "." || segment === "") continue; | ||
| if (segment === "..") { | ||
| normalizedSegments.pop(); | ||
| continue; | ||
| } | ||
| normalizedSegments.push(segment); | ||
| } | ||
|
|
||
| const normalizedPath = `/${normalizedSegments.join("/")}`; | ||
| if (normalizedPath === DOCS_PREFIX || normalizedPath.startsWith(`${DOCS_PREFIX}/`)) { | ||
| return `${normalizedPath}${suffix}`; | ||
| } | ||
|
|
||
| return `${DOCS_PREFIX}${normalizedPath === "/" ? "" : normalizedPath}${suffix}`; | ||
| } | ||
|
|
||
| export function resolveDocsAssetUrl(url: string, baseUrl?: string) { | ||
| const normalized = normalizeDocsAssetPath(url); | ||
| if (!baseUrl || normalized.startsWith("#") || SPECIAL_URL_PATTERN.test(normalized)) { | ||
| return normalized; | ||
| } | ||
|
|
||
| return new URL(normalized, baseUrl).toString(); | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.