Nextraの実装を理解してドキュメントサイト以外に利用できるようになる
Nextraとは
NextraはNext.js上で、MDXによるファイルベースのCMSを実現するプラグイン(Framework)です。
VercelのプロジェクトであるNext.jsやTurbopackのドキュメントはNextraで構築されており、nextra-theme-docsと合わせることで同じようなサイトが簡単に作成できます。
本記事の目的
"Nextra = ドキュメントサイトを簡単に生成するためのツール" と捉えられがちですが、この認識は少々もったいないです。
いちばんの特徴はMDX関連のエコシステムをNext.jsにお手軽に導入できる点であり、既存テーマを使用せず、薄く利用することも可能です。
実装を追って仕組みを理解し、うまくNextraと付き合えるようになりましょう。
事前に気になっているところ
私が気になったのは以下の点。これらを理解することをゴールとします。 ➡️ 理解した結果
-
pages/*.mdx
の検出・パース - テーマの役割
-
nextra-theme-docs
の実装 (BreadcrumbやTOC) -
pages/*.tsx
の併用方法
flexsearchについてはまた今度。
エントリポイント
まずはエントリポイントから辿っていきます。
import nextra from 'nextra'
const withNextra = require('nextra')({
theme: 'nextra-theme-docs',
themeConfig: './theme.config.jsx'
})
module.exports = withNextra()
続いてnextra/src/index.js
。
nextConfig.pageExtensions
に['md','mdx']
を追加、webpackにloaderを追加しています。
nextra/loader
で*.mdx
をページとして認識できる形にパースしていると予想できます。Next.jsの作法に則ったシンプルな方法です。
return {
...nextConfig,
...(nextConfig.output !== 'export' && { rewrites }),
pageExtensions: [
...(nextConfig.pageExtensions || DEFAULT_EXTENSIONS),
...MARKDOWN_EXTENSIONS // ['md', 'mdx']
],
webpack(config, options) {
/* 省略 */
config.module.rules.push(
{
test: MARKDOWN_EXTENSION_REGEX,
issuer: request => !!request || request === null,
use: [
options.defaultLoaders.babel,
{
loader: 'nextra/loader',
options: nextraLoaderOptions
}
...
}
loaderの動作
nextra/loader.js
を経由して、nextra/src/loader.ts
を確認します。
Nextraの処理はloader.ts
でほぼ完結しています。
configの読み込み
async function loader
const {
isMetaImport = false, // _meta.jsonならtrue
isPageImport = false, // pages/*.mdxならtrue
theme,
themeConfig,
locales,
defaultLocale,
defaultShowCopyCode,
flexsearch,
latex,
staticImage,
readingTime: _readingTime,
mdxOptions,
pageMapCache,
newNextLinkBehavior,
transform,
transformPageOpts,
codeHighlight
} = context.getOptions()
pageMap: ページ構成の読み込み
collectFiles
を呼び出して、/pages配下のファイルを収集してpageMapを生成しています。
metaファイルの把握や、各ページの親子関係の付与などに利用されていきます。
(本番環境ではnextra/src/index.js
でNextraPlugin初期化時に取得、cacheされます)
const { items, fileMap } = IS_PRODUCTION
? pageMapCache.get()!
: await collectFiles({ dir: PAGES_DIR, locales })
~~~
const { route, pageMap, dynamicMetaItems } = resolvePageMap({
filePath: mdxPath,
fileMap,
defaultLocale,
items
})
compileMdx
しばらく進むとsourceをcompileMdx
しています。MDXのパース部分です。
(source = loaderが対象としているmdxの内容文字列)
const {
result,
headings,
title,
frontMatter,
structurizedData,
searchIndexKey,
hasJsxInH1,
readingTime
} = await compileMdx(
source,
{
mdxOptions: {
...mdxOptions,
jsx: true,
outputFormat: 'program',
format: 'detect'
}
...
)
パース処理
compileMdx
では、@mdx-js/mdx
を利用してremark
やrehype
の処理と、frontMatter等の付与を行っています。
細かい内容はnextra/src/compile.ts
を確認いただくとして、@mdx-js/mdx (createProcessor)
が担う3つの処理がメインです。
- Parse MDX (serialized markdown with embedded JSX, ESM, and expressions)
- Transform through remark (mdast), rehype (hast), and recma (esast)
- Serialize as JavaScript
これで無事にMDXをJSに変換できました。
MDXのパース処理をカスタマイズしたい場合、このあたりを確認しましょう。
pageOptsの生成
pageOptsとして、各mdxのメタ情報を生成しています。
gitから取得したtimestamp(最終更新日)や、readingTime(記事を読むのにかかる時間)なども付与されています。
let pageOpts: Partial<PageOpts> = {
filePath: slash(path.relative(CWD, mdxPath)),
route,
...(Object.keys(frontMatter).length > 0 && { frontMatter }),
headings,
hasJsxInH1,
timestamp,
pageMap,
...(!HAS_UNDERSCORE_APP_MDX_FILE && {
flexsearch,
newNextLinkBehavior // todo: remove in v3
}),
readingTime,
title: fallbackTitle
}
Next.jsのページとして機能させるためのimport文等の準備
const layout = isLocalTheme ? slash(path.resolve(theme)) : theme
const cssImport = OFFICIAL_THEMES.includes(theme)
? `import '${theme}/style.css'`
: ''
const finalResult = transform ? await transform(result, { route }) : result
const pageImports = `import __nextra_layout from '${layout}'
${themeConfigImport}
${katexCssImport}
${cssImport}`
nextra-theme-docsを使用した際、cssをどこでインジェクトしているのか疑問でした。ここでimportさせていますね。
transformはtheme.configで設定可能。初期値はundefined。
出力やMDXをカスタマイズしたい場合の一つの手段になります。
_app.mdxの場合
source == _app.mdx
の場合。import文などを添えて_app.tsx
として機能する状態にします。
if (pageNextRoute === '/_app') {
return `${pageImports}
${finalResult}
const __nextra_internal__ = globalThis[Symbol.for('__nextra_internal__')] ||= Object.create(null)
__nextra_internal__.Layout = __nextra_layout
__nextra_internal__.pageMap = ${JSON.stringify(pageOpts.pageMap)}
__nextra_internal__.flexsearch = ${JSON.stringify(flexsearch)}
${themeConfigImport
? '__nextra_internal__.themeConfig = __nextra_themeConfig'
: ''
}`
}
global.__nextra_internal__
global
に__nextra_internal__
を定義しています。Nextraはほぼloader.ts
で完結しているので、後続の処理やNext.jsからアクセスするために利用されます。
- Layout: nextConfig.themeで指定したmodule (
import __nextra_layout from '${layout}'
) - pageMap:
collectFiles
で収集したpages配下のデータ
Layoutについて
直感に反して、_app.mdx
の内部ではテーマレイアウトを使用していません。
pages/*.mdx
でそれぞれLayoutを読み込むパターンのようです。
(そのため、/pages/foo.tsx
を用意してもそのままではテーマが反映されません)
各ページに対する処理
続いて、_app.mdx
以外の場合の処理。
役立ちそうなnextraPageOptions
を添えてsetupNextraPage
でラップしています。
よくわからないので処理を追います。
return `import { setupNextraPage } from 'nextra/setup-page'
${HAS_UNDERSCORE_APP_MDX_FILE ? '' : pageImports}
const __nextraPageOptions = {
MDXContent,
pageOpts: ${stringifiedPageOpts},
pageNextRoute: ${JSON.stringify(pageNextRoute)},
${HAS_UNDERSCORE_APP_MDX_FILE
? ''
: 'nextraLayout: __nextra_layout,' +
(themeConfigImport && 'themeConfig: __nextra_themeConfig')
}
}
${finalResult.replace('export default MDXContent;', '')}
if (process.env.NODE_ENV !== 'production') {
__nextraPageOptions.hot = module.hot
__nextraPageOptions.pageOptsChecksum = ${stringifiedChecksum}
}
if (typeof window === 'undefined') __nextraPageOptions.dynamicMetaModules = [${dynamicMetaModules}]
export default setupNextraPage(__nextraPageOptions)`
}
setupNextraPage
setupNextraPage
は__nextra_internal__.context
にページ情報を追加して、NextraLayout
を返します。
import NextraLayout from './layout'
...
__nextra_internal__.route = pageOpts.route
__nextra_internal__.context ||= Object.create(null)
__nextra_internal__.context[pageNextRoute] = {
Content: MDXContent,
pageOpts,
themeConfig
}
return NextraLayout
NextraLayout
前処理で生成したMDXContentを__nextra_internal__.layout
で包んで、ReactElementとして返却しています。
これでpages/*.mdx
もページとして機能するようになりました。
useInternals()
は__nextra_internal__
を取得するヘルパーです。
import type { ReactElement } from 'react'
import { SSGContext } from './ssg'
import { useInternals } from './use-internals'
export default function Nextra({
__nextra_pageMap,
__nextra_dynamic_opts,
...props
}: any): ReactElement {
const { context, Layout } = useInternals()
const { Content, ...restContext } = context
if (__nextra_pageMap) {
restContext.pageOpts = {
...restContext.pageOpts,
pageMap: __nextra_pageMap
}
}
if (__nextra_dynamic_opts) {
const data = JSON.parse(__nextra_dynamic_opts)
restContext.pageOpts = {
...restContext.pageOpts,
headings: data.headings,
title: data.title || restContext.pageOpts.title,
frontMatter: data.frontMatter
}
}
return (
<Layout {...restContext} pageProps={props}>
<SSGContext.Provider value={props}>
<Content {...props} />
</SSGContext.Provider>
</Layout>
)
}
テーマの役割
ここまで処理を追って、テーマはLayoutさえ提供すればいいことがわかります。
次のようなtheme.js
でも機能します。
export default function Layout({ children }) {
return (
<>
{children}
</>
)
}
Nextraの機能にアクセスする
フローを理解したところで、Nextraの機能にアクセスする方法を確認していきます。
Guide - Nextraはデフォルトテーマを使って満足する人以外にはやさしくありません。各テーマパッケージとサンプルを見ていきます。
page構造を取得する方法
NextraをCMSとして利用するにあたり、pagesの取得ができなければお話になりません。
__nextra_internal__.pageMap
が利用できそうですが、直接覗くのは行儀が悪そうです。
残念ながらドキュメントに記載はないので、nextra-theme-docsのサイドバーをお手本にします。
export function Sidebar({
docsDirectories,
flatDirectories,
fullDirectories,
asPopover = false,
headings,
includePlaceholder
}: SideBarProps): ReactElement
引数として与えられていたので、sidebarを呼び出しているindex.tsx
を確認します。
const InnerLayout = ({
filePath,
pageMap,
frontMatter,
headings,
timestamp,
children
}: PageOpts & { children: ReactNode }): ReactElement => {
const config = useConfig()
const { locale = DEFAULT_LOCALE, defaultLocale } = useRouter()
const fsPath = useFSRoute()
const {
activeType,
activeIndex,
activeThemeContext,
activePath,
topLevelNavbarItems,
docsDirectories,
flatDirectories,
flatDocsDirectories,
directories
} = useMemo(
() =>
normalizePages({
list: pageMap,
locale,
defaultLocale,
route: fsPath
}),
[pageMap, locale, defaultLocale, fsPath]
)
export default function Layout({
children,
...context
}: NextraThemeLayoutProps): ReactElement {
return (
<ConfigProvider value={context}>
<InnerLayout {...context.pageOpts}>{children}</InnerLayout>
</ConfigProvider>
)
}
normalizePages
context.pageOpts
の内容をnextra/normalizePages
を用いて読み込んでいます。
normalizePages
は、pageMap
を利用しやすいように整形する関数で、nextra-theme-docsのNavbarとSidebarは、このデータを使っているようです。
listとして与えられたcontext.pageOpts.pageMap
に対して以下の処理をしています。
- metaページ、対象外localeページの除去
- ページ情報にmeta情報の付与
- ページの階層化
- 各ページのtype毎にhiddenしたり、topLevelNavbarItemsに突っ込んだり
テーマ内ならcontext.pageOpts.pageMap
page構造を取得したい場合、context.pageOpts.pageMap
が使えそうです。
用途によってはnormalizePages
やそれを参考にした整形を行うのも良さそうです。
ただし、context.pageOpts
が参照できるのはloaderを通している*.mdx
やテーマレイアウトだけのため不便です。
nextra/contextを利用する
そのため、__nextra_internal__.pageMap
を見る方法も用意されています。
nextra/context
にgetAllPages
という関数があります。
declare function getAllPages(): Page[];
declare function getCurrentLevelPages(): Page[];
declare function getPagesUnderRoute(route: string): Page[];
export { getAllPages, getCurrentLevelPages, getPagesUnderRoute };
import { NEXTRA_INTERNAL } from './constants'
import type {
MetaJsonFile,
NextraInternalGlobal,
Page,
PageMapItem
} from './types'
import { normalizeMeta } from './utils'
function getContext(name: string): {
pageMap: PageMapItem[]
route: string
} {
const __nextra_internal__ = (globalThis as NextraInternalGlobal)[
NEXTRA_INTERNAL
]
if (!__nextra_internal__) {
throw new Error(
`Nextra context not found. Please make sure you are using "${name}" of "nextra/context" on a Nextra page.`
)
}
return {
pageMap: __nextra_internal__.pageMap,
route: __nextra_internal__.route
}
}
~~~
export function getAllPages(): Page[] {
const { pageMap } = getContext('getAllPages')
return filter(pageMap).items
}
export function getCurrentLevelPages(): Page[] {
const { pageMap, route } = getContext('getCurrentLevelPages')
return filter(pageMap, route).activeLevelPages
}
export function getPagesUnderRoute(route: string): Page[] {
const { pageMap } = getContext('getPagesUnderRoute')
return filter(pageMap, route).activeLevelPages
}
__nextra_internal__
を直接覗くのとほぼかわりませんが、少し行儀が良いです。なにより公式から提供されているので副作用の心配が少ないです。
実は、swr-siteのサンプルでも使用されています。
まとめ
- webpack.loaderでMDX解析。Nextraのすべて
- テーマで指定したLayoutは
_app.mdx
内に埋め込まれるのではなく、各mdxページのラッパーとして使用される - ドキュメントに記載のないMDX関連の役立ち情報がpageOptsにつまってる。readingTime(記事を読むのにかかる時間)など
- ページ一覧(pageMap)が取得したい場合、
nextra/context
にgetAllPages
等がある。各mdxのfrontMatterも確認できる
tsxからのアクセス
*.tsx
の場合、nextra/loader
を経由しないため、テーマレイアウトやcontext.pageOpts
が付与されない。
ただし、_app.mdx
を対象にしたloaderは必ず通っているため、global.__nextra_internal__
に必要な情報は付与されている。
-
nextra/context
.getAllPages
が利用できる -
global.__nextra_internal__.Layout
が利用できる -
nextra-theme-docs
のテーマレイアウトはpageOptsに依存しているため、直接利用しようとするとエラーが発生する-
getAllPages
などからpageOptsを生成すれば無理やり利用できる
-
Discussion