docs: add clean markdown version of all documentation pages (#11308)

* added route to book

* added to resources

* added route to ui

* added to user guide
This commit is contained in:
Shahed Nasser
2025-02-05 11:23:13 +02:00
committed by GitHub
parent 87db3f0c45
commit 98236c8262
30 changed files with 1086 additions and 192 deletions

View File

@@ -205,40 +205,40 @@ You can now build your plugin's customizations. The following guide explains how
<CardList
items={[
{
text: "Create a module",
link: "/learn/fundamentals/modules",
title: "Create a module",
href: "/learn/fundamentals/modules",
},
{
text: "Create a module link",
link: "/learn/fundamentals/module-links",
title: "Create a module link",
href: "/learn/fundamentals/module-links",
},
{
text: "Create a workflow",
link: "/learn/fundamentals/workflows",
title: "Create a workflow",
href: "/learn/fundamentals/workflows",
},
{
text: "Add a workflow hook",
link: "/learn/fundamentals/workflows/add-workflow-hook",
title: "Add a workflow hook",
href: "/learn/fundamentals/workflows/add-workflow-hook",
},
{
text: "Create an API route",
link: "/learn/fundamentals/api-routes",
title: "Create an API route",
href: "/learn/fundamentals/api-routes",
},
{
text: "Add a subscriber",
link: "/learn/fundamentals/events-and-subscribers",
title: "Add a subscriber",
href: "/learn/fundamentals/events-and-subscribers",
},
{
text: "Add a scheduled job",
link: "/learn/fundamentals/scheduled-jobs",
title: "Add a scheduled job",
href: "/learn/fundamentals/scheduled-jobs",
},
{
text: "Add an admin widget",
link: "/learn/fundamentals/admin/widgets",
title: "Add an admin widget",
href: "/learn/fundamentals/admin/widgets",
},
{
text: "Add an admin UI route",
link: "/learn/fundamentals/admin/ui-routes",
title: "Add an admin UI route",
href: "/learn/fundamentals/admin/ui-routes",
}
]}
className="mb-1.5"

View File

@@ -72,7 +72,7 @@ Below are some stories from companies that use Medusa:
This documentation introduces you to Medusa's concepts and how they help you build your business use case. The documentation is structured to gradually introduce Medusa's concepts, with easy-to-follow examples along the way.
By following this documentation, youll be able to create custom commerce experiences that would otherwise take large engineering teams months to build.
By following this documentation, you'll be able to create custom commerce experiences that would otherwise take large engineering teams months to build.
### How to use the documentation

View File

@@ -13,11 +13,11 @@ You can build your storefront from scratch with your preferred tech stack, or st
<CardList
items={[
{
text: "Install Next.js Starter Storefront",
title: "Install Next.js Starter Storefront",
href: "!resources!/nextjs-starter"
},
{
text: "Build Custom Storefront",
title: "Build Custom Storefront",
href: "!resources!/storefront-development"
}
]}

View File

@@ -0,0 +1,75 @@
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: {
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,
}
)

View File

@@ -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",
}

View File

@@ -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",

View File

@@ -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,
}
)

View File

@@ -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",
}

View File

@@ -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,

View File

@@ -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": "*"
},

View File

@@ -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,
}
)

View File

@@ -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",
}

View File

@@ -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<boolean>`,
},
description: "Async function used to display a new confirmation dialog.",
},
]
import { usePrompt } from "../../registries/hook-values"
const Props = () => {
return <HookTable props={useToastValues} />
return <HookTable props={usePrompt} />
}
export default Props

View File

@@ -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 <HookTable props={useToggleStateValuesArray} />
return <HookTable props={useToggleState} />
}
export default Props

View File

@@ -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<string, ExampleType> = {
export const ExampleRegistry: ExampleRegistryType = {
"alert-demo": {
name: "alert-demo",
component: React.lazy(async () => import("@/examples/alert-demo")),

View File

@@ -1,6 +1,9 @@
import { HookRegistryItem } from "@/types/hooks"
import * as React from "react"
export type HookRegistryItem = {
table: React.LazyExoticComponent<React.ComponentType>
}
export const HookRegistry: Record<string, HookRegistryItem> = {
usePrompt: {
table: React.lazy(async () => import("../props/hooks/usePrompt")),

View File

@@ -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<boolean>`,
},
description: "Async function used to display a new confirmation dialog.",
},
]

View File

@@ -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<ComponentType>
}

View File

@@ -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,
}
)

View File

@@ -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",
}

View File

@@ -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",

View File

@@ -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<string, ComponentParser> = {
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 getParsedAsString = (file: VFile): string => {
return file.toString().replaceAll(/^([\s]*)\* /gm, "$1- ")
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]
}
)
}
}
export const getCleanMd = async (
filePath: string,
const getParsedAsString = (file: VFile): string => {
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
}
type Options = {
filePath: string
plugins?: {
before?: Plugin[]
after?: Plugin[]
}
): Promise<string> => {
parserOptions?: ParserPluginOptions
}
export const getCleanMd = async ({
filePath,
plugins,
parserOptions,
}: Options): Promise<string> => {
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]))

View File

@@ -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<TOptions = any> = (
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<string, string>
}
}> = (
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
*/

View File

@@ -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"

View File

@@ -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)
}

View File

@@ -7,4 +7,6 @@ export declare type FrontMatter = {
sidebar_autogenerate_exclude?: boolean
sidebar_description?: string
tags?: string[]
title?: string
description?: string
}

View File

@@ -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"

View File

@@ -18,6 +18,7 @@ export interface UnistNode extends Node {
url?: string
spread?: boolean
depth?: number
lang?: string
}
export type ArrayExpression = {

View File

@@ -0,0 +1,7 @@
export type ExampleType = {
name: string
component: React.LazyExoticComponent<() => React.JSX.Element>
file: string
}
export type ExampleRegistry = Record<string, ExampleType>

View File

@@ -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