diff --git a/www/apps/book/app/learn/fundamentals/plugins/create/page.mdx b/www/apps/book/app/learn/fundamentals/plugins/create/page.mdx index b405c74d7c681..e6e51ca63ea37 100644 --- a/www/apps/book/app/learn/fundamentals/plugins/create/page.mdx +++ b/www/apps/book/app/learn/fundamentals/plugins/create/page.mdx @@ -205,40 +205,40 @@ You can now build your plugin's customizations. The following guide explains how +} + +export async function GET(req: NextRequest, { params }: Params) { + const { slug } = await params + + // keep this so that Vercel keeps the files in deployment + const basePath = path.join(process.cwd(), "app") + const filePath = path.join(basePath, ...slug, "page.mdx") + + if (!existsSync(filePath)) { + return notFound() + } + + const cleanMdContent = await getCleanMd_(filePath, { + before: [ + [ + crossProjectLinksPlugin, + { + baseUrl: process.env.NEXT_PUBLIC_BASE_URL, + projectUrls: { + resources: { + url: process.env.NEXT_PUBLIC_RESOURCES_URL, + }, + "user-guide": { + url: process.env.NEXT_PUBLIC_RESOURCES_URL, + }, + ui: { + url: process.env.NEXT_PUBLIC_RESOURCES_URL, + }, + api: { + url: process.env.NEXT_PUBLIC_RESOURCES_URL, + }, + }, + useBaseUrl: + process.env.NODE_ENV === "production" || + process.env.VERCEL_ENV === "production", + }, + ], + [localLinksRehypePlugin], + ] as unknown as Plugin[], + after: [ + [addUrlToRelativeLink, { url: process.env.NEXT_PUBLIC_BASE_URL }], + ] as unknown as Plugin[], + }) + + return new NextResponse(cleanMdContent, { + headers: { + "Content-Type": "text/markdown", + }, + }) +} + +const getCleanMd_ = unstable_cache( + async (filePath: string, plugins?: { before?: Plugin[]; after?: Plugin[] }) => + getCleanMd({ filePath, plugins }), + ["clean-md"], + { + revalidate: 3600, + } +) diff --git a/www/apps/book/middleware.ts b/www/apps/book/middleware.ts new file mode 100644 index 0000000000000..2dcad296b91cc --- /dev/null +++ b/www/apps/book/middleware.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server" +import type { NextRequest } from "next/server" + +export function middleware(request: NextRequest) { + return NextResponse.rewrite( + new URL( + `/md-content${request.nextUrl.pathname.replace("/index.html.md", "")}`, + request.url + ) + ) +} + +export const config = { + matcher: "/:path*/index.html.md", +} diff --git a/www/apps/book/package.json b/www/apps/book/package.json index 1e4f84b4ea9d1..970baa5a386c4 100644 --- a/www/apps/book/package.json +++ b/www/apps/book/package.json @@ -20,6 +20,7 @@ "@next/mdx": "15.0.4", "clsx": "^2.1.0", "docs-ui": "*", + "docs-utils": "*", "next": "15.0.4", "react": "rc", "react-dom": "rc", diff --git a/www/apps/resources/app/md-content/[[...slug]]/route.ts b/www/apps/resources/app/md-content/[[...slug]]/route.ts new file mode 100644 index 0000000000000..d20fcd40f4962 --- /dev/null +++ b/www/apps/resources/app/md-content/[[...slug]]/route.ts @@ -0,0 +1,98 @@ +import { getCleanMd } from "docs-utils" +import { existsSync } from "fs" +import { unstable_cache } from "next/cache" +import { notFound } from "next/navigation" +import { NextRequest, NextResponse } from "next/server" +import path from "path" +import { + addUrlToRelativeLink, + crossProjectLinksPlugin, + localLinksRehypePlugin, +} from "remark-rehype-plugins" +import type { Plugin } from "unified" +import { filesMap } from "../../../generated/files-map.mjs" +import { slugChanges } from "../../../generated/slug-changes.mjs" + +type Params = { + params: Promise<{ slug: string[] }> +} + +export async function GET(req: NextRequest, { params }: Params) { + const { slug = ["/"] } = await params + + // keep this so that Vercel keeps the files in deployment + path.join(process.cwd(), "app") + path.join(process.cwd(), "references") + + const filePathFromMap = await getFileFromMaps(`/${slug.join("/")}`) + if (!filePathFromMap) { + return notFound() + } + + const filePath = path.join(path.resolve("..", "..", ".."), filePathFromMap) + + if (!existsSync(filePath)) { + return notFound() + } + + const cleanMdContent = await getCleanMd_(filePath, { + before: [ + [ + crossProjectLinksPlugin, + { + baseUrl: process.env.NEXT_PUBLIC_BASE_URL, + projectUrls: { + docs: { + url: process.env.NEXT_PUBLIC_DOCS_URL, + path: "", + }, + "user-guide": { + url: process.env.NEXT_PUBLIC_USER_GUIDE_URL, + }, + ui: { + url: process.env.NEXT_PUBLIC_UI_URL, + }, + api: { + url: process.env.NEXT_PUBLIC_API_URL, + }, + }, + useBaseUrl: + process.env.NODE_ENV === "production" || + process.env.VERCEL_ENV === "production", + }, + ], + [localLinksRehypePlugin], + ] as unknown as Plugin[], + after: [ + [addUrlToRelativeLink, { url: process.env.NEXT_PUBLIC_BASE_URL }], + ] as unknown as Plugin[], + }) + + return new NextResponse(cleanMdContent, { + headers: { + "Content-Type": "text/markdown", + }, + }) +} + +const getCleanMd_ = unstable_cache( + async (filePath: string, plugins?: { before?: Plugin[]; after?: Plugin[] }) => + getCleanMd({ filePath, plugins }), + ["clean-md"], + { + revalidate: 3600, + } +) + +const getFileFromMaps = unstable_cache( + async (path: string) => { + return ( + slugChanges.find((slugChange) => slugChange.newSlug === path)?.filePath || + filesMap.find((file) => file.pathname === path)?.filePath + ) + }, + ["file-map"], + { + revalidate: 3600, + } +) diff --git a/www/apps/resources/middleware.ts b/www/apps/resources/middleware.ts new file mode 100644 index 0000000000000..45470c2329eff --- /dev/null +++ b/www/apps/resources/middleware.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server" +import type { NextRequest } from "next/server" + +export function middleware(request: NextRequest) { + return NextResponse.rewrite( + new URL( + `${request.nextUrl.basePath}/md-content${request.nextUrl.pathname.replace("/index.html.md", "")}`, + request.url + ) + ) +} + +export const config = { + matcher: "/:path*/index.html.md", +} diff --git a/www/apps/ui/contentlayer.config.ts b/www/apps/ui/contentlayer.config.ts index 808c558412863..d3c6ad5043643 100644 --- a/www/apps/ui/contentlayer.config.ts +++ b/www/apps/ui/contentlayer.config.ts @@ -1,8 +1,9 @@ import "dotenv/config" import { defineDocumentType, makeSource } from "contentlayer/source-files" -import { rehypeComponent } from "./src/lib/rehype-component" import rehypeSlug from "rehype-slug" +import { uiRehypePlugin } from "../../packages/remark-rehype-plugins/src" +import { ExampleRegistry } from "./src/registries/example-registry" export const Doc = defineDocumentType(() => ({ name: "Doc", @@ -29,7 +30,15 @@ export default makeSource({ contentDirPath: "./src/content", documentTypes: [Doc], mdx: { - rehypePlugins: [[rehypeComponent], [rehypeSlug]], + rehypePlugins: [ + [ + uiRehypePlugin, + { + exampleRegistry: ExampleRegistry, + }, + ], + [rehypeSlug], + ], mdxOptions: (options) => { return { ...options, diff --git a/www/apps/ui/package.json b/www/apps/ui/package.json index ef2f18956a496..bc63d8213d785 100644 --- a/www/apps/ui/package.json +++ b/www/apps/ui/package.json @@ -24,6 +24,7 @@ "contentlayer": "^0.3.4", "date-fns": "^3.3.1", "docs-ui": "*", + "docs-utils": "*", "mdast-util-toc": "^7.0.0", "next": "15.0.4", "next-contentlayer": "^0.3.4", @@ -47,6 +48,7 @@ "eslint-plugin-prettier": "^5.2.1", "eslint-plugin-react-hooks": "^5.0.0", "react-docgen": "^7.1.0", + "remark-rehype-plugins": "*", "ts-node": "^10.9.1", "types": "*" }, diff --git a/www/apps/ui/src/app/md-content/[[...slug]]/route.ts b/www/apps/ui/src/app/md-content/[[...slug]]/route.ts new file mode 100644 index 0000000000000..e912dca2c8a22 --- /dev/null +++ b/www/apps/ui/src/app/md-content/[[...slug]]/route.ts @@ -0,0 +1,86 @@ +import { getCleanMd } from "docs-utils" +import { existsSync } from "fs" +import { unstable_cache } from "next/cache" +import { notFound } from "next/navigation" +import { NextRequest, NextResponse } from "next/server" +import path from "path" +import { addUrlToRelativeLink } from "remark-rehype-plugins" +import type { Plugin } from "unified" +import * as Icons from "@medusajs/icons" +import * as HookValues from "@/registries/hook-values" +import { colors as allColors } from "@/config/colors" + +type Params = { + params: Promise<{ slug: string[] }> +} + +export async function GET(req: NextRequest, { params }: Params) { + const { slug = ["/"] } = await params + + // keep this so that Vercel keeps the files in deployment + const basePath = path.join(process.cwd(), "src", "content", "docs") + const examplesPath = path.join(process.cwd(), "src", "examples") + const specsPath = path.join(process.cwd(), "src", "specs") + const fileName = slug.length === 1 ? "index" : slug.pop() || "index" + + const filePath = path.join(basePath, ...slug, `${fileName}.mdx`) + + if (!existsSync(filePath)) { + return notFound() + } + + const cleanMdContent = await getCleanMd_( + filePath, + { examplesPath, specsPath }, + { + after: [ + [addUrlToRelativeLink, { url: process.env.NEXT_PUBLIC_BASE_URL }], + ] as unknown as Plugin[], + } + ) + + return new NextResponse(cleanMdContent, { + headers: { + "Content-Type": "text/markdown", + }, + }) +} + +const getCleanMd_ = unstable_cache( + async ( + filePath: string, + parserOptions: { + examplesPath: string + specsPath: string + }, + plugins?: { before?: Plugin[]; after?: Plugin[] } + ) => { + const iconNames = Object.keys(Icons).filter((name) => name !== "default") + + return getCleanMd({ + filePath, + plugins, + parserOptions: { + ComponentExample: { + examplesBasePath: parserOptions.examplesPath, + }, + ComponentReference: { + specsPath: parserOptions.specsPath, + }, + IconSearch: { + iconNames, + }, + HookValues: { + hooksData: HookValues, + }, + Colors: { + colors: allColors, + }, + }, + }) + }, + ["clean-md"], + { + revalidate: 3600, + } +) diff --git a/www/apps/ui/src/middleware.ts b/www/apps/ui/src/middleware.ts new file mode 100644 index 0000000000000..45470c2329eff --- /dev/null +++ b/www/apps/ui/src/middleware.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server" +import type { NextRequest } from "next/server" + +export function middleware(request: NextRequest) { + return NextResponse.rewrite( + new URL( + `${request.nextUrl.basePath}/md-content${request.nextUrl.pathname.replace("/index.html.md", "")}`, + request.url + ) + ) +} + +export const config = { + matcher: "/:path*/index.html.md", +} diff --git a/www/apps/ui/src/props/hooks/usePrompt.tsx b/www/apps/ui/src/props/hooks/usePrompt.tsx index 58a5ecb1d3cd2..3fd1190ca5d67 100644 --- a/www/apps/ui/src/props/hooks/usePrompt.tsx +++ b/www/apps/ui/src/props/hooks/usePrompt.tsx @@ -1,19 +1,8 @@ import { HookTable } from "@/components/hook-table" -import { HookDataMap } from "@/types/hooks" - -const useToastValues: HookDataMap = [ - { - value: "dialog", - type: { - type: "function", - signature: `async (props: PromptProps): Promise`, - }, - description: "Async function used to display a new confirmation dialog.", - }, -] +import { usePrompt } from "../../registries/hook-values" const Props = () => { - return + return } export default Props diff --git a/www/apps/ui/src/props/hooks/useToggleState.tsx b/www/apps/ui/src/props/hooks/useToggleState.tsx index 03266fb9e991a..467c27a7316f8 100644 --- a/www/apps/ui/src/props/hooks/useToggleState.tsx +++ b/www/apps/ui/src/props/hooks/useToggleState.tsx @@ -1,20 +1,8 @@ import { HookTable } from "@/components/hook-table" -import { HookDataMap } from "@/types/hooks" - -const useToggleStateValuesArray: HookDataMap = [ - { - value: "state", - type: { - type: "object", - name: "StateData", - shape: - "[\n state: boolean,\n open: () => void,\n close: () => void,\n toggle: () => void\n]", - }, - }, -] +import { useToggleState } from "../../registries/hook-values" const Props = () => { - return + return } export default Props diff --git a/www/apps/ui/src/registries/example-registry.tsx b/www/apps/ui/src/registries/example-registry.tsx index e0878ca2ced33..811e35d5a3ef7 100644 --- a/www/apps/ui/src/registries/example-registry.tsx +++ b/www/apps/ui/src/registries/example-registry.tsx @@ -1,12 +1,7 @@ import * as React from "react" +import { ExampleRegistry as ExampleRegistryType } from "types" -type ExampleType = { - name: string - component: React.LazyExoticComponent<() => React.JSX.Element> - file: string -} - -export const ExampleRegistry: Record = { +export const ExampleRegistry: ExampleRegistryType = { "alert-demo": { name: "alert-demo", component: React.lazy(async () => import("@/examples/alert-demo")), diff --git a/www/apps/ui/src/registries/hook-registry.tsx b/www/apps/ui/src/registries/hook-registry.tsx index 55096fb03cd11..8ed40fbac882d 100644 --- a/www/apps/ui/src/registries/hook-registry.tsx +++ b/www/apps/ui/src/registries/hook-registry.tsx @@ -1,6 +1,9 @@ -import { HookRegistryItem } from "@/types/hooks" import * as React from "react" +export type HookRegistryItem = { + table: React.LazyExoticComponent +} + export const HookRegistry: Record = { usePrompt: { table: React.lazy(async () => import("../props/hooks/usePrompt")), diff --git a/www/apps/ui/src/registries/hook-values.ts b/www/apps/ui/src/registries/hook-values.ts new file mode 100644 index 0000000000000..6315390ea3c2a --- /dev/null +++ b/www/apps/ui/src/registries/hook-values.ts @@ -0,0 +1,24 @@ +import { HookDataMap } from "../types/hooks" + +export const useToggleState: HookDataMap = [ + { + value: "state", + type: { + type: "object", + name: "StateData", + shape: + "[\n state: boolean,\n open: () => void,\n close: () => void,\n toggle: () => void\n]", + }, + }, +] + +export const usePrompt: HookDataMap = [ + { + value: "dialog", + type: { + type: "function", + signature: `async (props: PromptProps): Promise`, + }, + description: "Async function used to display a new confirmation dialog.", + }, +] diff --git a/www/apps/ui/src/types/hooks.ts b/www/apps/ui/src/types/hooks.ts index eeff02487ac99..85fc6ebfff5b8 100644 --- a/www/apps/ui/src/types/hooks.ts +++ b/www/apps/ui/src/types/hooks.ts @@ -1,4 +1,3 @@ -import { ComponentType, LazyExoticComponent } from "react" import { PropType } from "./props" export type HookData = { @@ -8,7 +7,3 @@ export type HookData = { } export type HookDataMap = HookData[] - -export type HookRegistryItem = { - table: LazyExoticComponent -} diff --git a/www/apps/user-guide/app/md-content/[...slug]/route.ts b/www/apps/user-guide/app/md-content/[...slug]/route.ts new file mode 100644 index 0000000000000..5914149b9515e --- /dev/null +++ b/www/apps/user-guide/app/md-content/[...slug]/route.ts @@ -0,0 +1,76 @@ +import { getCleanMd } from "docs-utils" +import { existsSync } from "fs" +import { unstable_cache } from "next/cache" +import { notFound } from "next/navigation" +import { NextRequest, NextResponse } from "next/server" +import path from "path" +import { + addUrlToRelativeLink, + crossProjectLinksPlugin, + localLinksRehypePlugin, +} from "remark-rehype-plugins" +import type { Plugin } from "unified" + +type Params = { + params: Promise<{ slug: string[] }> +} + +export async function GET(req: NextRequest, { params }: Params) { + const { slug } = await params + + // keep this so that Vercel keeps the files in deployment + const basePath = path.join(process.cwd(), "app") + const filePath = path.join(basePath, ...slug, "page.mdx") + + if (!existsSync(filePath)) { + return notFound() + } + + const cleanMdContent = await getCleanMd_(filePath, { + before: [ + [ + crossProjectLinksPlugin, + { + baseUrl: process.env.NEXT_PUBLIC_BASE_URL, + projectUrls: { + docs: { + url: process.env.NEXT_PUBLIC_DOCS_URL, + path: "", + }, + resources: { + url: process.env.NEXT_PUBLIC_RESOURCES_URL, + }, + ui: { + url: process.env.NEXT_PUBLIC_UI_URL, + }, + api: { + url: process.env.NEXT_PUBLIC_API_URL, + }, + }, + useBaseUrl: + process.env.NODE_ENV === "production" || + process.env.VERCEL_ENV === "production", + }, + ], + [localLinksRehypePlugin], + ] as unknown as Plugin[], + after: [ + [addUrlToRelativeLink, { url: process.env.NEXT_PUBLIC_BASE_URL }], + ] as unknown as Plugin[], + }) + + return new NextResponse(cleanMdContent, { + headers: { + "Content-Type": "text/markdown", + }, + }) +} + +const getCleanMd_ = unstable_cache( + async (filePath: string, plugins?: { before?: Plugin[]; after?: Plugin[] }) => + getCleanMd({ filePath, plugins }), + ["clean-md"], + { + revalidate: 3600, + } +) diff --git a/www/apps/user-guide/middleware.ts b/www/apps/user-guide/middleware.ts new file mode 100644 index 0000000000000..45470c2329eff --- /dev/null +++ b/www/apps/user-guide/middleware.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server" +import type { NextRequest } from "next/server" + +export function middleware(request: NextRequest) { + return NextResponse.rewrite( + new URL( + `${request.nextUrl.basePath}/md-content${request.nextUrl.pathname.replace("/index.html.md", "")}`, + request.url + ) + ) +} + +export const config = { + matcher: "/:path*/index.html.md", +} diff --git a/www/packages/docs-utils/package.json b/www/packages/docs-utils/package.json index b12cd17325e53..2784a99221c87 100644 --- a/www/packages/docs-utils/package.json +++ b/www/packages/docs-utils/package.json @@ -29,6 +29,7 @@ }, "dependencies": { "@mdx-js/mdx": "^3.1.0", + "react-docgen": "^7.1.0", "remark-frontmatter": "^5.0.0", "remark-mdx": "^3.1.0", "remark-parse": "^11.0.0", diff --git a/www/packages/docs-utils/src/get-clean-md.ts b/www/packages/docs-utils/src/get-clean-md.ts index bb8af46259a65..bced578dff0fa 100644 --- a/www/packages/docs-utils/src/get-clean-md.ts +++ b/www/packages/docs-utils/src/get-clean-md.ts @@ -2,25 +2,62 @@ import remarkMdx from "remark-mdx" import remarkParse from "remark-parse" import remarkStringify from "remark-stringify" import { read } from "to-vfile" -import { UnistNode, UnistNodeWithData, UnistTree } from "types" +import { FrontMatter, UnistNode, UnistNodeWithData, UnistTree } from "types" import { Plugin, Transformer, unified } from "unified" import { SKIP } from "unist-util-visit" import type { VFile } from "vfile" import { + ComponentParser, parseCard, parseCardList, parseCodeTabs, + parseColors, + parseComponentExample, + parseComponentReference, parseDetails, + parseHookValues, + parseIconSearch, parseNote, + parsePackageInstall, parsePrerequisites, parseSourceCodeLink, parseTable, parseTabs, parseTypeList, parseWorkflowDiagram, -} from "./utils/parse-elms.js" +} from "./utils/parsers.js" +import remarkFrontmatter from "remark-frontmatter" +import { matter } from "vfile-matter" -const parseComponentsPlugin = (): Transformer => { +const parsers: Record = { + Card: parseCard, + CardList: parseCardList, + CodeTabs: parseCodeTabs, + Details: parseDetails, + Note: parseNote, + Prerequisites: parsePrerequisites, + SourceCodeLink: parseSourceCodeLink, + Table: parseTable, + Tabs: parseTabs, + TypeList: parseTypeList, + WorkflowDiagram: parseWorkflowDiagram, + ComponentExample: parseComponentExample, + ComponentReference: parseComponentReference, + PackageInstall: parsePackageInstall, + IconSearch: parseIconSearch, + HookValues: parseHookValues, + Colors: parseColors, +} + +const isComponentAllowed = (nodeName: string): boolean => { + return Object.keys(parsers).includes(nodeName) +} + +type ParserPluginOptions = { + [key: string]: unknown +} + +const parseComponentsPlugin = (options: ParserPluginOptions): Transformer => { return async (tree) => { const { visit } = await import("unist-util-visit") @@ -50,81 +87,110 @@ const parseComponentsPlugin = (): Transformer => { } } if (node.type === "heading") { - if ( - node.depth === 1 && - node.children?.length && - node.children[0].value === "metadata.title" - ) { - node.children[0] = { - type: "text", - value: pageTitle, + if (node.depth === 1 && node.children?.length) { + if (node.children[0].value === "metadata.title") { + node.children[0] = { + type: "text", + value: pageTitle, + } + } else { + node.children = node.children + .filter((child) => child.type === "text") + .map((child) => ({ + ...child, + value: child.value?.trim(), + })) } } return } if ( node.type === "mdxjsEsm" || - node.name === "Feedback" || - node.name === "ChildDocs" || - node.name === "DetailsList" + !isComponentAllowed(node.name as string) ) { parent?.children.splice(index, 1) return [SKIP, index] } - switch (node.name) { - case "Card": - return parseCard(node, index, parent) - case "CardList": - return parseCardList(node as UnistNodeWithData, index, parent) - case "CodeTabs": - return parseCodeTabs(node as UnistNodeWithData, index, parent) - case "Details": - return parseDetails(node as UnistNodeWithData, index, parent) - case "Note": - return parseNote(node, index, parent) - case "Prerequisites": - return parsePrerequisites(node as UnistNodeWithData, index, parent) - case "SourceCodeLink": - return parseSourceCodeLink(node as UnistNodeWithData, index, parent) - case "Table": - return parseTable(node as UnistNodeWithData, index, parent) - case "Tabs": - return parseTabs(node as UnistNodeWithData, index, parent) - case "TypeList": - return parseTypeList(node as UnistNodeWithData, index, parent) - case "WorkflowDiagram": - return parseWorkflowDiagram( - node as UnistNodeWithData, - index, - parent - ) + + if (!node.name) { + return + } + + const parser = parsers[node.name] + if (parser) { + const parserOptions = options[node.name] || {} + return parser(node as UnistNodeWithData, index, parent, parserOptions) + } + } + ) + } +} + +const removeFrontmatterPlugin = (): Transformer => { + return async (tree) => { + const { visit } = await import("unist-util-visit") + + visit( + tree as UnistTree, + ["yaml", "toml"], + (node: UnistNode, index, parent) => { + if (typeof index !== "number" || parent?.type !== "root") { + return } + + parent.children.splice(index, 1) + return [SKIP, index] } ) } } const getParsedAsString = (file: VFile): string => { - return file.toString().replaceAll(/^([\s]*)\* /gm, "$1- ") + let content = file.toString().replaceAll(/^([\s]*)\* /gm, "$1- ") + const frontmatter = file.data.matter as FrontMatter | undefined + + if (frontmatter?.title) { + content = `# ${frontmatter.title}\n\n${frontmatter.description ? `${frontmatter.description}\n\n` : ""}${content}` + } + + return content } -export const getCleanMd = async ( - filePath: string, +type Options = { + filePath: string plugins?: { before?: Plugin[] after?: Plugin[] } -): Promise => { + parserOptions?: ParserPluginOptions +} + +export const getCleanMd = async ({ + filePath, + plugins, + parserOptions, +}: Options): Promise => { if (!filePath.endsWith(".md") && !filePath.endsWith(".mdx")) { return "" } - const unifier = unified().use(remarkParse).use(remarkMdx).use(remarkStringify) + const unifier = unified() + .use(remarkParse) + .use(remarkMdx) + .use(remarkStringify) + .use(remarkFrontmatter, ["yaml"]) + .use(() => { + return (tree, file) => { + matter(file) + } + }) plugins?.before?.forEach((plugin) => { unifier.use(...(Array.isArray(plugin) ? plugin : [plugin])) }) - unifier.use(parseComponentsPlugin) + unifier + .use(parseComponentsPlugin, parserOptions || {}) + .use(removeFrontmatterPlugin) plugins?.after?.forEach((plugin) => { unifier.use(...(Array.isArray(plugin) ? plugin : [plugin])) diff --git a/www/packages/docs-utils/src/utils/parse-elms.ts b/www/packages/docs-utils/src/utils/parsers.ts similarity index 58% rename from www/packages/docs-utils/src/utils/parse-elms.ts rename to www/packages/docs-utils/src/utils/parsers.ts index c67779c8a3a42..7e6ae4d8127af 100644 --- a/www/packages/docs-utils/src/utils/parse-elms.ts +++ b/www/packages/docs-utils/src/utils/parsers.ts @@ -5,9 +5,19 @@ import { isExpressionJsVarLiteral, isExpressionJsVarObj, } from "../expression-is-utils.js" +import path from "path" +import { readFileSync } from "fs" +import type { Documentation } from "react-docgen" -export const parseCard = ( - node: UnistNode, +export type ComponentParser = ( + node: UnistNodeWithData, + index: number, + parent: UnistTree, + options?: TOptions +) => VisitorResult + +export const parseCard: ComponentParser = ( + node: UnistNodeWithData, index: number, parent: UnistTree ): VisitorResult => { @@ -52,7 +62,7 @@ export const parseCard = ( return [SKIP, index] } -export const parseCardList = ( +export const parseCardList: ComponentParser = ( node: UnistNodeWithData, index: number, parent: UnistTree @@ -72,30 +82,40 @@ export const parseCardList = ( .map((item) => { if ( !isExpressionJsVarObj(item) || - !("text" in item) || - !("link" in item) || - !isExpressionJsVarLiteral(item.text) || - !isExpressionJsVarLiteral(item.link) + !("title" in item) || + !("href" in item) || + !isExpressionJsVarLiteral(item.title) || + !isExpressionJsVarLiteral(item.href) ) { return null } + const description = isExpressionJsVarLiteral(item.text) + ? (item.text.data as string) + : "" + const children: UnistNode[] = [ + { + type: "link", + url: `${item.href.data}`, + children: [ + { + type: "text", + value: item.title.data as string, + }, + ], + }, + ] + if (description.length) { + children.push({ + type: "text", + value: `: ${description}`, + }) + } return { type: "listItem", children: [ { type: "paragraph", - children: [ - { - type: "link", - url: `#${item.link.data}`, - children: [ - { - type: "text", - value: item.text.data, - }, - ], - }, - ], + children, }, ], } @@ -111,7 +131,7 @@ export const parseCardList = ( return [SKIP, index] } -export const parseCodeTabs = ( +export const parseCodeTabs: ComponentParser = ( node: UnistNodeWithData, index: number, parent: UnistTree @@ -131,30 +151,26 @@ export const parseCodeTabs = ( return } - children.push({ - type: "mdxJsxFlowElement", - name: "details", - children: [ - { - type: "mdxJsxFlowElement", - name: "summary", - children: [ - { - type: "text", - value: (label.value as string) || "summary", - }, - ], - }, - code, - ], - }) + children.push( + { + type: "heading", + depth: 3, + children: [ + { + type: "text", + value: label.value as string, + }, + ], + }, + code + ) }) parent?.children.splice(index, 1, ...children) return [SKIP, index] } -export const parseDetails = ( +export const parseDetails: ComponentParser = ( node: UnistNodeWithData, index: number, parent: UnistTree @@ -163,28 +179,29 @@ export const parseDetails = ( (attr) => attr.name === "summaryContent" ) - parent?.children.splice(index, 1, { - type: "mdxJsxFlowElement", - name: "details", - children: [ - { - type: "mdxJsxFlowElement", - name: "summary", - children: [ - { - type: "text", - value: (summary?.value as string) || "Details", - }, - ], - }, - ...(node.children || []), - ], - }) + const children: UnistNode[] = [] + + if (summary?.value) { + children.push({ + type: "heading", + depth: 3, + children: [ + { + type: "text", + value: (summary?.value as string) || "Details", + }, + ], + }) + } + + children.push(...(node.children || [])) + + parent?.children.splice(index, 1, ...children) return [SKIP, index] } -export const parseNote = ( - node: UnistNode, +export const parseNote: ComponentParser = ( + node: UnistNodeWithData, index: number, parent: UnistTree ): VisitorResult => { @@ -192,7 +209,7 @@ export const parseNote = ( return [SKIP, index] } -export const parsePrerequisites = ( +export const parsePrerequisites: ComponentParser = ( node: UnistNodeWithData, index: number, parent: UnistTree @@ -265,7 +282,7 @@ export const parsePrerequisites = ( return [SKIP, index] } -export const parseSourceCodeLink = ( +export const parseSourceCodeLink: ComponentParser = ( node: UnistNodeWithData, index: number, parent: UnistTree @@ -293,7 +310,7 @@ export const parseSourceCodeLink = ( return [SKIP, index] } -export const parseTable = ( +export const parseTable: ComponentParser = ( node: UnistNodeWithData, index: number, parent: UnistTree @@ -348,7 +365,7 @@ export const parseTable = ( }) } -export const parseTabs = ( +export const parseTabs: ComponentParser = ( node: UnistNodeWithData, index: number, parent: UnistTree @@ -368,23 +385,19 @@ export const parseTabs = ( return } - tabs.push({ - type: "mdxJsxFlowElement", - name: "details", - children: [ - { - type: "mdxJsxFlowElement", - name: "summary", - children: [ - { - type: "text", - value: tabLabel, - }, - ], - }, - ...tabContent, - ], - }) + tabs.push( + { + type: "heading", + depth: 3, + children: [ + { + type: "text", + value: tabLabel, + }, + ], + }, + ...tabContent + ) }) }) @@ -392,7 +405,7 @@ export const parseTabs = ( return [SKIP, index] } -export const parseTypeList = ( +export const parseTypeList: ComponentParser = ( node: UnistNodeWithData, index: number, parent: UnistTree @@ -435,7 +448,7 @@ export const parseTypeList = ( children: [ { type: "text", - value: `${typeName}: (${itemType}) ${itemDescription}`, + value: `${typeName}: (${itemType}) ${itemDescription}`.trim(), }, ], }, @@ -465,7 +478,7 @@ export const parseTypeList = ( return [SKIP, index] } -export const parseWorkflowDiagram = ( +export const parseWorkflowDiagram: ComponentParser = ( node: UnistNodeWithData, index: number, parent: UnistTree @@ -558,6 +571,321 @@ export const parseWorkflowDiagram = ( return [SKIP, index] } +export const parseComponentExample: ComponentParser<{ + examplesBasePath: string +}> = ( + node: UnistNodeWithData, + index: number, + parent: UnistTree, + options +): VisitorResult => { + if (!options?.examplesBasePath) { + return + } + + const exampleName = node.attributes?.find((attr) => attr.name === "name") + if (!exampleName) { + return + } + + const fileContent = readFileSync( + path.join(options.examplesBasePath, `${exampleName.value as string}.tsx`), + "utf-8" + ) + + parent.children?.splice(index, 1, { + type: "code", + lang: "tsx", + value: fileContent, + }) + return [SKIP, index] +} + +export const parseComponentReference: ComponentParser<{ specsPath: string }> = ( + node: UnistNodeWithData, + index: number, + parent: UnistTree, + options +): VisitorResult => { + if (!options?.specsPath) { + return + } + + const mainComponent = node.attributes?.find( + (attr) => attr.name === "mainComponent" + )?.value as string + if (!mainComponent) { + return + } + + const componentNames: string[] = [] + + const componentsToShowAttr = node.attributes?.find( + (attr) => attr.name === "componentsToShow" + ) + + if ( + componentsToShowAttr && + typeof componentsToShowAttr.value !== "string" && + componentsToShowAttr.value.data?.estree + ) { + const componentsToShowJsVar = estreeToJs( + componentsToShowAttr.value.data.estree + ) + + if (componentsToShowAttr && Array.isArray(componentsToShowJsVar)) { + componentNames.push( + ...componentsToShowJsVar + .map((item) => { + return isExpressionJsVarLiteral(item) ? (item.data as string) : "" + }) + .filter((name) => name.length > 0) + ) + } + } + + if (!componentNames.length) { + componentNames.push(mainComponent) + } + + const getComponentNodes = (componentName: string): UnistNode[] => { + const componentSpecsFile = path.join( + options.specsPath, + mainComponent, + `${componentName}.json` + ) + + const componentSpecs: Documentation = JSON.parse( + readFileSync(componentSpecsFile, "utf-8") + ) + + const componentNodes: UnistNode[] = [ + { + type: "heading", + depth: 3, + children: [ + { + type: "text", + value: `${componentName} Props`, + }, + ], + }, + ] + + if (componentSpecs.description) { + componentNodes.push({ + type: "paragraph", + children: [ + { + type: "text", + value: componentSpecs.description, + }, + ], + }) + } + + if (componentSpecs.props) { + const listNode: UnistNode = { + type: "list", + ordered: false, + spread: false, + children: [], + } + + Object.entries(componentSpecs.props).forEach(([propName, propData]) => { + listNode.children?.push({ + type: "listItem", + children: [ + { + type: "paragraph", + children: [ + { + type: "text", + value: + `${propName}: (${propData.type?.name || propData.tsType?.name}) ${propData.description || ""}${propData.defaultValue ? ` Default: ${propData.defaultValue.value}` : ""}`.trim(), + }, + ], + }, + ], + }) + }) + + componentNodes.push(listNode) + } + + return componentNodes + } + + parent.children?.splice( + index, + 1, + ...componentNames.flatMap(getComponentNodes) + ) +} + +export const parsePackageInstall: ComponentParser = ( + node: UnistNodeWithData, + index: number, + parent: UnistTree +): VisitorResult => { + const packageName = node.attributes?.find( + (attr) => attr.name === "packageName" + ) + if (!packageName) { + return + } + + parent.children?.splice(index, 1, { + type: "code", + lang: "bash", + value: `npm install ${packageName.value}`, + }) + return [SKIP, index] +} + +export const parseIconSearch: ComponentParser<{ iconNames: string[] }> = ( + node: UnistNodeWithData, + index: number, + parent: UnistTree, + options +): VisitorResult => { + if (!options?.iconNames) { + return + } + + parent.children?.splice(index, 1, { + type: "list", + ordered: false, + spread: false, + children: options.iconNames.map((iconName) => ({ + type: "listItem", + children: [ + { + type: "paragraph", + children: [ + { + type: "text", + value: iconName, + }, + ], + }, + ], + })), + }) + return [SKIP, index] +} + +export const parseHookValues: ComponentParser<{ + hooksData: { + [k: string]: { + value: string + type?: { + type: string + } + description?: string + }[] + } +}> = ( + node: UnistNodeWithData, + index: number, + parent: UnistTree, + options +): VisitorResult => { + if (!options?.hooksData) { + return + } + + const hookName = node.attributes?.find((attr) => attr.name === "hook") + + if ( + !hookName || + !hookName.value || + typeof hookName.value !== "string" || + !options.hooksData[hookName.value] + ) { + return + } + + const hookData = options.hooksData[hookName.value] + + const listItems = hookData.map((item) => { + return { + type: "listItem", + children: [ + { + type: "paragraph", + children: [ + { + type: "text", + value: + `${item.value}: (${item.type?.type}) ${item.description || ""}`.trim(), + }, + ], + }, + ], + } + }) + + parent.children?.splice(index, 1, { + type: "list", + ordered: false, + spread: false, + children: listItems, + }) + return [SKIP, index] +} + +export const parseColors: ComponentParser<{ + colors: { + [k: string]: Record + } +}> = ( + node: UnistNodeWithData, + index: number, + parent: UnistTree, + options +): VisitorResult => { + if (!options?.colors) { + return + } + + parent.children?.splice(index, 1, { + type: "list", + ordered: false, + spread: false, + children: Object.entries(options.colors).flatMap(([section, colors]) => [ + { + type: "heading", + depth: 3, + children: [ + { + type: "text", + value: section, + }, + ], + }, + ...Object.entries(colors).map(([name, value]) => ({ + type: "listItem", + children: [ + { + type: "paragraph", + children: [ + { + type: "text", + value: name, + }, + { + type: "text", + value: `: ${value}`, + }, + ], + }, + ], + })), + ]), + }) +} + /** * Helpers */ diff --git a/www/packages/remark-rehype-plugins/src/index.ts b/www/packages/remark-rehype-plugins/src/index.ts index 904bddd1181ac..ef61c72f11559 100644 --- a/www/packages/remark-rehype-plugins/src/index.ts +++ b/www/packages/remark-rehype-plugins/src/index.ts @@ -7,6 +7,7 @@ export * from "./page-number.js" export * from "./prerequisites-link-fixer.js" export * from "./resolve-admonitions.js" export * from "./type-list-link-fixer.js" +export * from "./ui-rehype-plugin.js" export * from "./workflow-diagram-link-fixer.js" export * from "./utils/fix-link.js" diff --git a/www/packages/remark-rehype-plugins/src/ui-rehype-plugin.ts b/www/packages/remark-rehype-plugins/src/ui-rehype-plugin.ts new file mode 100644 index 0000000000000..161f1dc4ebfd1 --- /dev/null +++ b/www/packages/remark-rehype-plugins/src/ui-rehype-plugin.ts @@ -0,0 +1,82 @@ +import fs from "fs" +import path from "path" +import { u } from "unist-builder" +import { visit } from "unist-util-visit" +import { Documentation } from "react-docgen" +import { ExampleRegistry, UnistNode, UnistTree } from "types" + +type Options = { + exampleRegistry: ExampleRegistry +} + +export function uiRehypePlugin({ exampleRegistry }: Options) { + return async (tree: UnistTree) => { + visit(tree, (node: UnistNode) => { + if (node.name === "ComponentExample") { + const name = getNodeAttributeByName(node, "name")?.value as string + + if (!name) { + return null + } + + try { + const component = exampleRegistry[name] + const src = component.file + + const filePath = path.join(process.cwd(), src) + let source = fs.readFileSync(filePath, "utf8") + + source = source.replaceAll("export default", "export") + + // Trim newline at the end of file. It's correct, but it makes source display look off + if (source.endsWith("\n")) { + source = source.substring(0, source.length - 1) + } + + node.children?.push( + u("element", { + tagName: "span", + properties: { + __src__: src, + code: source, + }, + }) + ) + } catch (error) { + console.error(error) + } + } else if (node.name === "ComponentReference") { + const mainComponent = getNodeAttributeByName(node, "mainComponent") + ?.value as string + + if (!mainComponent) { + return null + } + + const mainSpecsDir = path.join(process.cwd(), "src/specs") + const componentSpecsDir = path.join(mainSpecsDir, mainComponent) + const specs: Documentation[] = [] + + const specFiles = fs.readdirSync(componentSpecsDir) + specFiles.map((specFileName) => { + // read spec file + const specFile = fs.readFileSync( + path.join(componentSpecsDir, specFileName), + "utf-8" + ) + specs.push(JSON.parse(specFile) as Documentation) + }) + + node.attributes?.push({ + name: "specsSrc", + value: JSON.stringify(specs), + type: "mdxJsxAttribute", + }) + } + }) + } +} + +function getNodeAttributeByName(node: UnistNode, name: string) { + return node.attributes?.find((attribute) => attribute.name === name) +} diff --git a/www/packages/types/src/frontmatter.ts b/www/packages/types/src/frontmatter.ts index b525be266263b..cc369825ee7bd 100644 --- a/www/packages/types/src/frontmatter.ts +++ b/www/packages/types/src/frontmatter.ts @@ -7,4 +7,6 @@ export declare type FrontMatter = { sidebar_autogenerate_exclude?: boolean sidebar_description?: string tags?: string[] + title?: string + description?: string } diff --git a/www/packages/types/src/index.ts b/www/packages/types/src/index.ts index 9717696f3f931..b9984408b273e 100644 --- a/www/packages/types/src/index.ts +++ b/www/packages/types/src/index.ts @@ -10,4 +10,5 @@ export * from "./navigation-dropdown.js" export * from "./sidebar.js" export * from "./tags.js" export * from "./toc.js" +export * from "./ui.js" export * from "./workflow.js" diff --git a/www/packages/types/src/remark-rehype.ts b/www/packages/types/src/remark-rehype.ts index 752db4b2980c1..80153d286804d 100644 --- a/www/packages/types/src/remark-rehype.ts +++ b/www/packages/types/src/remark-rehype.ts @@ -18,6 +18,7 @@ export interface UnistNode extends Node { url?: string spread?: boolean depth?: number + lang?: string } export type ArrayExpression = { diff --git a/www/packages/types/src/ui.ts b/www/packages/types/src/ui.ts new file mode 100644 index 0000000000000..87183ff7dccd6 --- /dev/null +++ b/www/packages/types/src/ui.ts @@ -0,0 +1,7 @@ +export type ExampleType = { + name: string + component: React.LazyExoticComponent<() => React.JSX.Element> + file: string +} + +export type ExampleRegistry = Record diff --git a/www/yarn.lock b/www/yarn.lock index fabe4043e4cef..09adeec3d013a 100644 --- a/www/yarn.lock +++ b/www/yarn.lock @@ -5868,6 +5868,7 @@ __metadata: build-scripts: "*" clsx: ^2.1.0 docs-ui: "*" + docs-utils: "*" eslint: ^9.13.0 eslint-plugin-prettier: ^5.2.1 eslint-plugin-react-hooks: ^5.0.0 @@ -7198,6 +7199,7 @@ __metadata: dependencies: "@mdx-js/mdx": ^3.1.0 "@types/node": ^20.11.20 + react-docgen: ^7.1.0 remark-frontmatter: ^5.0.0 remark-mdx: ^3.1.0 remark-parse: ^11.0.0 @@ -15044,6 +15046,7 @@ turbo@latest: contentlayer: ^0.3.4 date-fns: ^3.3.1 docs-ui: "*" + docs-utils: "*" eslint: ^9.13.0 eslint-plugin-prettier: ^5.2.1 eslint-plugin-react-hooks: ^5.0.0 @@ -15057,6 +15060,7 @@ turbo@latest: react-dom: rc rehype-slug: ^6.0.0 remark: ^14.0.3 + remark-rehype-plugins: "*" tailwind: "*" tailwindcss: 3.3.3 ts-node: ^10.9.1