🌈

Nextraの実装を理解してドキュメントサイト以外に利用できるようになる

2023/06/23に公開

Nextraとは

https://nextra.site

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についてはまた今度。

エントリポイント

まずはエントリポイントから辿っていきます。

next.config.js
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の作法に則ったシンプルな方法です。

nextra/src/index.js L66
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の読み込み

nextra/src/loader.ts L72
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されます)

nextra/src/loader.ts
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の内容文字列)

nextra/src/loader.ts L165
const {
  result,
  headings,
  title,
  frontMatter,
  structurizedData,
  searchIndexKey,
  hasJsxInH1,
  readingTime
} = await compileMdx(
  source,
  {
    mdxOptions: {
      ...mdxOptions,
      jsx: true,
      outputFormat: 'program',
      format: 'detect'
    }
    
    ...
)

パース処理

compileMdxでは、@mdx-js/mdxを利用してremarkrehypeの処理と、frontMatter等の付与を行っています。

細かい内容はnextra/src/compile.tsを確認いただくとして、@mdx-js/mdx (createProcessor)が担う3つの処理がメインです。

  1. Parse MDX (serialized markdown with embedded JSX, ESM, and expressions)
  2. Transform through remark (mdast), rehype (hast), and recma (esast)
  3. Serialize as JavaScript

これで無事にMDXをJSに変換できました。
MDXのパース処理をカスタマイズしたい場合、このあたりを確認しましょう。

pageOptsの生成

pageOptsとして、各mdxのメタ情報を生成しています。
gitから取得したtimestamp(最終更新日)や、readingTime(記事を読むのにかかる時間)なども付与されています。

nextra/src/loader.ts L244 ~
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文等の準備

nextra/src/loader.ts L242 ~
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として機能する状態にします。

nextra/src/loader.ts
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でラップしています。
よくわからないので処理を追います。

nextra/src/loader.ts L311
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を返します。

nextra/src/setup-page.ts
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__を取得するヘルパーです。

nextra/src/layout.tsx
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でも機能します。

theme.js
export default function Layout({ children }) {
  return (
    <>
      {children}
    </>
  )
}

Nextraの機能にアクセスする

フローを理解したところで、Nextraの機能にアクセスする方法を確認していきます。
Guide - Nextraはデフォルトテーマを使って満足する人以外にはやさしくありません。各テーマパッケージとサンプルを見ていきます。

page構造を取得する方法

NextraをCMSとして利用するにあたり、pagesの取得ができなければお話になりません。
__nextra_internal__.pageMapが利用できそうですが、直接覗くのは行儀が悪そうです。

残念ながらドキュメントに記載はないので、nextra-theme-docsのサイドバーをお手本にします。

nextra-theme-docs/src/components/sidebar.tsx
export function Sidebar({
  docsDirectories,
  flatDirectories,
  fullDirectories,
  asPopover = false,
  headings,
  includePlaceholder
}: SideBarProps): ReactElement 

引数として与えられていたので、sidebarを呼び出しているindex.tsxを確認します。

nextra-theme-docs/src/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]
  )
nextra-theme-docs/src/index.tsx
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/contextgetAllPagesという関数があります。

nextra/context
declare function getAllPages(): Page[];
declare function getCurrentLevelPages(): Page[];
declare function getPagesUnderRoute(route: string): Page[];

export { getAllPages, getCurrentLevelPages, getPagesUnderRoute };
nextra/src/context.ts
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/contextgetAllPages等がある。各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