Skip to content

Commit

Permalink
Merge pull request #986 from tszhong0411/pack-39-fix-mdx
Browse files Browse the repository at this point in the history
Fix MDX
  • Loading branch information
tszhong0411 authored Jan 18, 2025
2 parents 427bda6 + 77e497d commit 752cf87
Show file tree
Hide file tree
Showing 16 changed files with 268 additions and 235 deletions.
29 changes: 29 additions & 0 deletions apps/docs/src/components/mdx/heading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Adapted from: https://github.com/fuma-nama/fumadocs/blob/82c273917280f63da95687852135f89a08593e71/packages/ui/src/components/heading.tsx
*/
import { cn } from '@tszhong0411/utils'
import { LinkIcon } from 'lucide-react'

type Types = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
type HeadingProps<T extends Types> = Omit<React.ComponentProps<T>, 'as'> & {
as?: T
}

const Heading = <T extends Types = 'h1'>(props: HeadingProps<T>) => {
const { as, className, children, id, ...rest } = props
const Component = as ?? 'h1'

return (
<Component className={cn('scroll-m-32', className)} id={id} {...rest}>
<a href={`#${id}`} className='not-prose group'>
{children}
<LinkIcon
aria-label='Link to section'
className='text-muted-foreground ml-2 inline size-4 opacity-0 transition-opacity group-hover:opacity-100'
/>
</a>
</Component>
)
}

export default Heading
8 changes: 8 additions & 0 deletions apps/docs/src/components/mdx/mdx.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,20 @@ import { cn } from '@tszhong0411/utils'

import ComponentPreview from './component-preview'
import EmbedComponentPreview from './embed-component-preview'
import Heading from './heading'

type MdxProps = {
code: string
} & React.ComponentProps<'div'>

const components: MDXComponents = {
h2: (props: React.ComponentProps<'h2'>) => <Heading as='h2' {...props} />,
h3: (props: React.ComponentProps<'h3'>) => <Heading as='h3' {...props} />,
h4: (props: React.ComponentProps<'h4'>) => <Heading as='h4' {...props} />,
h5: (props: React.ComponentProps<'h5'>) => <Heading as='h5' {...props} />,
h6: (props: React.ComponentProps<'h6'>) => <Heading as='h6' {...props} />,

// Custom components
...uiComponents,
Callout: (props) => <uiComponents.Callout className='[&_p]:m-0' {...props} />,
ComponentPreview,
Expand Down
2 changes: 1 addition & 1 deletion packages/mdx/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@
"cosmiconfig": "^9.0.0",
"fast-glob": "^3.3.2",
"github-slugger": "^2.0.0",
"gray-matter": "^4.0.3",
"hast": "^1.0.0",
"jiti": "^2.4.2",
"mdx-bundler": "^10.0.3",
Expand All @@ -60,6 +59,7 @@
"shiki": "^1.24.0"
},
"devDependencies": {
"@mdx-js/esbuild": "^3.1.0",
"@tszhong0411/eslint-config": "workspace:*",
"@tszhong0411/tsconfig": "workspace:*",
"@tszhong0411/utils": "workspace:*",
Expand Down
99 changes: 5 additions & 94 deletions packages/mdx/src/build.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,14 @@
import { getErrorMessage } from '@tszhong0411/utils'
import chokidar from 'chokidar'
import matter from 'gray-matter'
import fs from 'node:fs/promises'
import path from 'node:path'
import pluralize from 'pluralize'

import { generateData } from '@/generation'
import type { Collection, Config, Fields } from '@/types'
import type { Config } from '@/types'
import { getConfig } from '@/utils/get-config'
import { getDocumentsCount } from '@/utils/get-documents-count'
import { getEntries } from '@/utils/get-entries'
import { logger } from '@/utils/logger'
import { writeJSON } from '@/utils/write-json'

type MissingField = {
name: string
type: string
}

type Error = {
file: string
type: string
missingFields: MissingField[]
}

type Options = {
watch?: boolean
}
Expand All @@ -50,6 +35,7 @@ const watchImpl = (config: Config) => {
if (p === 'mdx.config.ts') {
logger.warn('Config file changed, restarting...')
watcher.close()
cache.clear()
build({ watch: true })
return
}
Expand All @@ -66,23 +52,13 @@ const watchImpl = (config: Config) => {
export const build = async (options: Options = {}) => {
const { watch = false } = options
const { config } = await getConfig(process.cwd())
const { contentDirPath, collections } = config

try {
const begin = performance.now()
await ensureDirectoryExists(contentDirPath)
await createGeneratedDirectory()
await createPackageJson()

const errors = await findErrors(collections, contentDirPath)

if (errors.length > 0) {
throw new Error(formatErrorMessage(errors))
}

await generateData(config)

const count = await getDocumentsCount(contentDirPath)
await fs.mkdir('.mdx/generated', { recursive: true })
await createPackageJson()
const count = await generateData(config)

logger.info(`Generated ${pluralize('document', count, true)} in .mdx`, begin)

Expand All @@ -92,18 +68,6 @@ export const build = async (options: Options = {}) => {
}
}

const ensureDirectoryExists = async (dirPath: string) => {
try {
await fs.access(dirPath)
} catch {
throw new Error(`Directory ${dirPath} does not exist. Please check your configuration.`)
}
}

const createGeneratedDirectory = async () => {
await fs.mkdir('.mdx/generated', { recursive: true })
}

const createPackageJson = async () => {
const packageJsonContent = {
name: 'mdx-generated',
Expand All @@ -123,56 +87,3 @@ const createPackageJson = async () => {

await writeJSON('.mdx/package.json', packageJsonContent)
}

const findErrors = async (collections: Collection[], contentDirPath: string): Promise<Error[]> => {
const errors: Error[] = []

for (const collection of collections) {
const entries = await getEntries(collection.filePathPattern, contentDirPath)

for (const entry of entries) {
const fullPath = path.join(process.cwd(), contentDirPath, entry)

if (collection.fields) {
const missingFields = await validateRequiredFields(collection.fields, fullPath)
if (missingFields.length > 0) {
errors.push({
file: entry,
type: collection.name,
missingFields
})
}
}
}
}

return errors
}

const validateRequiredFields = async (
fields: Fields,
fullPath: string
): Promise<MissingField[]> => {
const requiredFields = fields.filter((field) => field.required)

const fileContent = await fs.readFile(fullPath, 'utf8')
const parsedContent = matter(fileContent)

return requiredFields.filter((field) => !parsedContent.data[field.name])
}

const formatErrorMessage = (errors: Error[]): string => {
let errorMessage = 'Generation Failed\n\n'
errorMessage += `└── Missing required fields for ${errors.length} documents.\n\n`

for (const { file, type, missingFields } of errors) {
errorMessage += ` • "${file}" (of type "${type}") is missing the following required fields:\n`

for (const [i, field] of missingFields.entries()) {
const isLastField = i === missingFields.length - 1
errorMessage += ` • ${field.name}: ${field.type}${isLastField ? '\n\n' : '\n'}`
}
}

return errorMessage
}
118 changes: 77 additions & 41 deletions packages/mdx/src/generation/generate-data.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,79 @@
import type { Options } from '@mdx-js/esbuild'
import { bundleMDX } from 'mdx-bundler'
import fs from 'node:fs/promises'
import path from 'node:path'

import { BASE_FOLDER_PATH } from '@/constants'
import { defaultRehypePlugins, defaultRemarkPlugins } from '@/plugins'
import type { Config } from '@/types'
import type { Collection, Config } from '@/types'
import { getEntries } from '@/utils/get-entries'
import { getTOC } from '@/utils/get-toc'
import { logger } from '@/utils/logger'
import { validateFrontmatter } from '@/utils/validate-frontmatter'
import { writeJSON } from '@/utils/write-json'

import { generateIndexDts } from './generate-index-d-ts'
import { generateIndexMjs } from './generate-index-mjs'
import { generateTypesDts } from './generate-types-d-ts'

export const generateData = async (config: Config) => {
const { contentDirPath, collections, remarkPlugins = [], rehypePlugins = [], cache } = config
const processMDXContent = (source: string, config: Config) => {
const { remarkPlugins = [], rehypePlugins = [] } = config

return bundleMDX({
source,
mdxOptions: (options: Options) => {
options.remarkPlugins = [
...(options.remarkPlugins ?? []),
...defaultRemarkPlugins,
...remarkPlugins
]
options.rehypePlugins = [
...(options.rehypePlugins ?? []),
...defaultRehypePlugins,
...rehypePlugins
]

return options
}
})
}

const processFields = async (
entry: string,
content: string,
data: Record<string, unknown>,
code: string,
collection: Collection
) => {
const fileName = path.basename(entry, '.mdx')
const staticFields = {
...data,
code,
raw: content,
fileName: fileName,
filePath: entry,
toc: await getTOC(content)
}

const computedFields: Record<string, unknown> = {}

if (collection.computedFields) {
for (const computedField of collection.computedFields) {
computedFields[computedField.name] = await computedField.resolve({
...staticFields
})
}
}

return {
...staticFields,
...computedFields
}
}

export const generateData = async (config: Config): Promise<number> => {
const { contentDirPath, collections, cache } = config
let documentGenerationCount = 0

for (const collection of collections) {
const entries = await getEntries(collection.filePathPattern, contentDirPath)
Expand All @@ -27,7 +86,6 @@ export const generateData = async (config: Config) => {

for (const entry of entries) {
const fullPath = path.join(contentDirPath, entry)
const fileName = path.basename(entry, '.mdx')
const fileContent = await fs.readFile(fullPath, 'utf8')

const cached = cache.get(fullPath)
Expand All @@ -37,52 +95,28 @@ export const generateData = async (config: Config) => {
continue
}

const { code, matter } = await bundleMDX({
source: fileContent,
mdxOptions: (options) => {
options.remarkPlugins = [
...(options.remarkPlugins ?? []),
...defaultRemarkPlugins,
...remarkPlugins
]
options.rehypePlugins = [
...(options.rehypePlugins ?? []),
...defaultRehypePlugins,
...rehypePlugins
]

// eslint-disable-next-line @typescript-eslint/no-unsafe-return -- not sure why, but the type is correct
return options
}
})
const { data, content } = matter
const { code, matter } = await processMDXContent(fileContent, config)

const staticFields = {
...data,
code,
raw: content,
fileName: fileName,
filePath: entry,
toc: await getTOC(content)
}
const { data, content } = matter

const computedFields: Record<string, unknown> = {}
if (collection.fields) {
const errors = validateFrontmatter(data, collection.fields)

if (collection.computedFields) {
for (const computedField of collection.computedFields) {
computedFields[computedField.name] = computedField.resolve({
...staticFields
})
if (errors.length > 0) {
logger.warn(
`Invalid frontmatter in ${entry}:\n${errors
.map((e) => ` ${e.field}: expected ${e.expected}, received ${e.received}`)
.join('\n')}`
)
continue
}
}

const fields = {
...staticFields,
...computedFields
}
const fields = await processFields(entry, content, data, code, collection)

indexJson.push(fields)
cache.set(fullPath, fields)
documentGenerationCount++
}

await writeJSON(`${collectionFolderPath}/index.json`, indexJson)
Expand All @@ -91,4 +125,6 @@ export const generateData = async (config: Config) => {
await generateIndexDts(collections)
await generateTypesDts(collections)
await generateIndexMjs(collections)

return documentGenerationCount
}
Loading

0 comments on commit 752cf87

Please sign in to comment.