Open49

MDX をちゃんと触ってみる

Coji MizoguchiCoji Mizoguchi

最近になって Remix で MDX を試しに使ってみて超便利だったのでよいなあと思いました。
いろいろできそうなので、ちゃんとやってみます。

Coji MizoguchiCoji Mizoguchi

特徴

Markdown の中に React コンポーネントが書ける。これによって、インタラクティブなチャートや、Alert などのコンポーネントを import してコンテンツに埋め込める。

# Hello, world!

<div className="note">
  > Some notable things in a block quote!
</div>

見出しのところは markdown で、HTML タグのようなところは JSX になってる。class じゃなくて className になってるのが React JSX の特徴だ。

もう一つの特徴は、ブレース { } の中に Javascript の式が書けるのと、ESM であること (importexport)

Coji MizoguchiCoji Mizoguchi

MDX は標準で Common Mark に対応してる。
プラグインで frontmatter やシンタックスハイライトなども対応。

Coji MizoguchiCoji Mizoguchi

MDX の中で使う React コンポーネントは import することも、ローカルで定義することも、後で渡す(pass them in later)こともできる。

Coji MizoguchiCoji Mizoguchi

MDXは、JavaScriptからのインポートおよびエクスポート文もサポートしています。これらのESMの機能は、MDXの中で物事を定義するために使用することができます:

import {External} from './some/place.js'

export const Local = properties => <span style={{color: 'red'}} {...properties} />

An <External>external</External> component and a <Local>local one</Local>.

ESMは非コンポーネント(データ)にも使用できる:

import {Chart} from './chart.js'
import population from './population.js'
export const pi = 3.14

<Chart data={population} label={'Something with ' + pi} />
Coji MizoguchiCoji Mizoguchi

テキストとタグが同じ行にある場合、JSX内部でマークダウンの「インライン」は使えますが、「ブロック」は使えません:

<div># this is not a heading but *this* is emphasis</div>

1行のテキストとタグはブロックを生成しないので、<p>も生成しない。別々の行では生成されます:

<div>
  This is a `p`.
</div>
Coji MizoguchiCoji Mizoguchi

最小セットで実行してみようということで、 vite React でプロジェクト作ってみた。
https://github.com/coji/mdx-vite-example

vercel にデプロイしようとすると落ちる。

src/main.tsx(3,17): error TS2307: Cannot find module './App.mdx' or its corresponding type declarations.
 ELIFECYCLE  Command failed with exit code 2.
Error: Command "pnpm run build" exited with 1
Coji MizoguchiCoji Mizoguchi

原因これくさ。
https://github.com/brillout/vite-plugin-mdx/issues/27#issuecomment-829666479

The problem is with TypeScript, not vite-plugin-mdx. Vite only compiles away TypeScript, it doesn't do any type checking. One way to solve your problem is to add a common type definition for all your *.mdx files by creating a file named e.g. src/mdx.d.ts (the name doesn't matter):

declare module '*.mdx' {
let MDXComponent: (props: Record<string, unknown>) => JSX.Element
export default MDXComponent
}
If you need to be more specific about a particular *.mdx, add a .d.ts file with the same name.

Coji MizoguchiCoji Mizoguchi

src/mdx.d.ts を追加

src/mdx.d.ts
declare module "*.mdx" {
  let MDXComponent: (props: Record<string, unknown>) => JSX.Element;
  export default MDXComponent;
}
Coji MizoguchiCoji Mizoguchi

よくわからないまま設定してみる

vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import mdx from "@mdx-js/rollup";
import { babel } from "@rollup/plugin-babel";

export default defineConfig({
  plugins: [
    { enforce: "pre", ...mdx() },
    babel({
      extensions: [".js", ".jsx", ".ts", ".tsx", ".mdx"],
      babelHelpers: "bundled",
    }),
    react(),
  ],
});
Coji MizoguchiCoji Mizoguchi

mdx の中で modern javascript features 使いたいなら babel プラグイン入れなよってことなのかな〜

Coji MizoguchiCoji Mizoguchi

import で alias 使いたい。ちょっと試しにやってみる

tsconfig.json に baseUrl と paths 追加。

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,

    /* Bundler mode */
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx",

    /* Linting */
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
+
+    "baseUrl": ".",
+    "paths": {
+      "~/*": ["./src/*"]
    }
  },
  "include": ["src"],
  "references": [{ "path": "./tsconfig.node.json" }]
}

viteでできるように vite-tsconfig-paths 追加

$ pnpm i -D vite-tsconfig-paths
Packages: +3
+++
Progress: resolved 310, reused 267, downloaded 0, added 0, done

devDependencies:
+ vite-tsconfig-paths 4.3.2

Done in 740ms

vite.config.ts に追加

vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import mdx from "@mdx-js/rollup";
+import tsConfigPaths from "vite-tsconfig-paths";

export default defineConfig({
-  plugins: [{ enforce: "pre", ...mdx() }, react()],
+  plugins: [{ enforce: "pre", ...mdx() }, react(), tsConfigPaths()],
});
Coji MizoguchiCoji Mizoguchi

src/App.mdx を編集して alias から import するように

src/App.mdx
# Hello!

-import { Welcome } from "./Welcome.tsx";
+import { Welcome } from "~/Welcome.tsx";

<Welcome>こんにちは〜</Welcome>
Coji MizoguchiCoji Mizoguchi

mdx が js に変換されるときにエイリアス解決されてないとだめっぽいのかな。

Coji MizoguchiCoji Mizoguchi

とりあえず相対パスで逃げることにした。うーん、嫌だなあ

Coji MizoguchiCoji Mizoguchi

vite + react だとルーティングが面倒だったので remix vite にする。
vercel のテンプレートさがすのめんどくさいから cloudflare pages で。

pnpm create remix@latest --template remix-run/remix/templates/vite-cloudflare
.../../Library/pnpm/store/v3/tmp/dlx-176 | Progress: resolved 1, reused 0, downlo.../../Library/pnpm/store/v3/tmp/dlx-176 | Progress: resolved 10, reused 7, downl.../../Library/pnpm/store/v3/tmp/dlx-176 | Progress: resolved 13, reused 13, down.../../Library/pnpm/store/v3/tmp/dlx-176 | Progress: resolved 14, reused 13, down.../../Library/pnpm/store/v3/tmp/dlx-176 | Progress: resolved 14, reused 14, down.../../Library/pnpm/store/v3/tmp/dlx-176 | Progress: resolved 23, reused 23, down.../../Library/pnpm/store/v3/tmp/dlx-176 | Progress: resolved 59, reused 58, down.../../Library/pnpm/store/v3/tmp/dlx-176 | Progress: resolved 91, reused 91, down.../../Library/pnpm/store/v3/tmp/dlx-176 | Progress: resolved 105, reused 101, do.../../Library/pnpm/store/v3/tmp/dlx-176 | Progress: resolved 145, reused 145, do.../../Library/pnpm/store/v3/tmp/dlx-176 | Progress: resolved 161, reused 161, do.../../Library/pnpm/store/v3/tmp/dlx-176 | Progress: resolved 169, reused 169, do.../../Library/pnpm/store/v3/tmp/dlx-176 | Progress: resolved 172, reused 172, do.../../Library/pnpm/store/v3/tmp/dlx-176 | Progress: resolved 177, reused 177, do.../../Library/pnpm/store/v3/tmp/dlx-176 | Progress: resolved 178, reused 177, do.../../Library/pnpm/store/v3/tmp/dlx-176 | Progress: resolved 183, reused 183, do.../../Library/pnpm/store/v3/tmp/dlx-176 | Progress: resolved 188, reused 188, do.../../Library/pnpm/store/v3/tmp/dlx-176 | Progress: resolved 196, reused 196, do.../../Library/pnpm/store/v3/tmp/dlx-176 | +200 ++++++++++++++++++++
.../../Library/pnpm/store/v3/tmp/dlx-176 | Progress: resolved 196, reused 196, do.../../Library/pnpm/store/v3/tmp/dlx-176 | Progress: resolved 200, reused 200, do.../../Library/pnpm/store/v3/tmp/dlx-176 | Progress: resolved 200, reused 200, downloaded 0, added 200, done

 remix   v2.8.1 💿 Let's build a better website...

   dir   Where should we create your new project?
         mdx-remix-vite-example

      ◼  Template: Using remix-run/remix/templates/vite-cloudflare...
      ✔  Template copied

   git   Initialize a new git repository?
         Yes

  deps   Install dependencies with pnpm?
         Yes

      ✔  Dependencies installed

      ✔  Git initialized

  done   That's it!

         Enter your project directory using cd ./mdx-remix-vite-example
         Check out README.md for development and deploy instructions.

         Join the community at https://rmx.as/discord

Coji MizoguchiCoji Mizoguchi

remix vite の場合、export const meta で適宜 export しないといけないようだ。なるほど〜
https://remix.run/docs/en/main/future/vite#map-mdx-frontmatter-to-route-exports

Map MDX frontmatter to route exports
The Remix compiler allowed you to define headers, meta and handle route exports in your frontmatter. This Remix-specific feature is obviously not supported by the remark-mdx-frontmatter plugin. If you were using this feature, you should manually map frontmatter to route exports yourself:

日本語訳

MDXフロントマターとルートエクスポートのマッピング
Remixコンパイラでは、ヘッダやメタを定義し、ルートエクスポートを扱うことができます。この Remix 固有の機能は remark-mdx-frontmatter プラグインではサポートされていません。もしこの機能を使うのであれば、手作業でfrontmatterをrouteにマッピングしてください。

👉 Map frontmatter to route exports for MDX routes

---
meta:
  - title: My First Post
  - name: description
    content: Isn't this awesome?
headers:
  Cache-Control: no-cache
---

+export const meta = frontmatter.meta;
+export const headers = frontmatter.headers;

# Hello World
Coji MizoguchiCoji Mizoguchi

ちゃんと変わった。
https://mdx-remix-vite-example.pages.dev/hello

SSR でちゃんと HTML として <title>Hello!</title> が入ってる。素敵

<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charSet="utf-8"/>
        <meta name="viewport" content="width=device-width, initial-scale=1"/>
        <title>Hello!</title>
        <link rel="stylesheet" href="/assets/tailwind-D938hx_-.css"/>
    </head>
    <body>
        <h1>
            here is <div class="text-4xl">World</div>
        </h1>
        <script>
            ((STORAGE_KEY2,restoreKey)=>{
                if (!window.history.state || !window.history.state.key) {
                    let key2 = Math.random().toString(32).slice(2);
                    window.history.replaceState({
                        key: key2
                    }, "");
                }
                try {
                    let positions = JSON.parse(sessionStorage.getItem(STORAGE_KEY2) || "{}");
                    let storedY = positions[restoreKey || window.history.state.key];
                    if (typeof storedY === "number") {
                        window.scrollTo(0, storedY);
                    }
                } catch (error) {
                    console.error(error);
                    sessionStorage.removeItem(STORAGE_KEY2);
                }
            }
            )("positions", null)
        </script>
        <link rel="modulepreload" href="/assets/manifest-b493e017.js"/>
        <link rel="modulepreload" href="/assets/entry.client-Cnfe980b.js"/>
        <link rel="modulepreload" href="/assets/jsx-runtime-BlSqMCxk.js"/>
        <link rel="modulepreload" href="/assets/components-uA9UXrr3.js"/>
        <link rel="modulepreload" href="/assets/root-BdXEYQVy.js"/>
        <link rel="modulepreload" href="/assets/route-X9fKwQn1.js"/>
        <script>
            window.__remixContext = {
                "url": "/hello",
                "basename": "/",
                "state": {
                    "loaderData": {
                        "root": null,
                        "routes/hello": null
                    },
                    "actionData": null,
                    "errors": null
                },
                "future": {
                    "v3_fetcherPersist": false,
                    "v3_relativeSplatPath": false,
                    "v3_throwAbortReason": false
                },
                "isSpaMode": false
            };
        </script>
        <script type="module" async="">
            import "/assets/manifest-b493e017.js";
            import*as route0 from "/assets/root-BdXEYQVy.js";
            import*as route1 from "/assets/route-X9fKwQn1.js";
            window.__remixRouteModules = {
                "root": route0,
                "routes/hello": route1
            };

            import("/assets/entry.client-Cnfe980b.js");
        </script>
    </body>
</html>
Coji MizoguchiCoji Mizoguchi

大雑把にすると以下のように変換されると思っとくといいよ、とのこと

input.mdx
export function Thing() {
  return <>World</>
}

# Hello <Thing />
output.jsx
/* @jsxRuntime automatic */
/* @jsxImportSource react */

export function Thing() {
  return <>World</>
}

export default function MDXContent() {
  return <h1>Hello <Thing /></h1>
}
Coji MizoguchiCoji Mizoguchi

Some observations:
The output is serialized JavaScript that still needs to be evaluated

  • A comment is injected to configure how JSX is handled
  • It’s a complete file with import/exports
  • A component (MDXContent) is exported
    The actual output is:

日本語訳

いくつか気づいたことがある:
出力はまだ評価される必要があるシリアライズされたJavaScriptです。

  • JSXがどのように処理されるかを設定するためのコメントが注入されている。
  • インポート/エクスポートを含む完全なファイルである。
  • コンポーネント(MDXContent)がエクスポートされる。
    実際の出力はこうだ:
output-actual.js
import {Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs} from 'react/jsx-runtime'

export function Thing() {
  return _jsx(_Fragment, {children: 'World'})
}

function _createMdxContent(props) {
  const _components = {h1: 'h1', ...props.components}
  return _jsxs(_components.h1, {children: ['Hello ', _jsx(Thing, {})]})
}

export default function MDXContent(props = {}) {
  const {wrapper: MDXLayout} = props.components || {}
  return MDXLayout
    ? _jsx(MDXLayout, {...props, children: _jsx(_createMdxContent, {...props})})
    : _createMdxContent(props)
}
Coji MizoguchiCoji Mizoguchi

Some more observations:

  • JSX is compiled away to function calls and an import of React†
  • The content component can be given {components: {wrapper: MyLayout}} to wrap all content
  • The content component can be given {components: {h1: MyComponent}} to use something else for the heading

† MDX is not coupled to React. You can also use it with Preact, Vue, Emotion, Theme UI, etc. Both the classic and automatic JSX runtimes are supported.

日本語訳

もう少し観察してみよう:

  • JSXは関数呼び出しとReact†のインポートにコンパイルされる。
  • コンテンツ・コンポーネントは、{components: {を与えることができる: MyLayout}}を与えることができます。
  • コンテンツ・コンポーネントには{components: {h1: MyComponent}} を与えることができます。

MDXはReactに結合されていません。Preact、Vue、Emotion、Theme UIなどでも使えます。古典的なJSXランタイムと自動JSXランタイムの両方がサポートされています。

Coji MizoguchiCoji Mizoguchi

試しにやってみる。

app/routes/mdx_content/Example.mdx
export function Thing() {
  return <>World</>
}

# Hello <Thing />
app/routes/mdx_content/route.tsx
import * as Example from './example.mdx'

export default function MdxContentPage() {
  return (
    <div>
      mdx content
      <div>Example: {JSON.stringify(Example, null, 2)}</div>
      <Example.default />
    </div>
  )
}

https://mdx-remix-vite-example.pages.dev/mdx_content

Coji MizoguchiCoji Mizoguchi

試しに Things から export 消してみる

function Thing() {
return <>World</>
}

# Hello <Thing />

ダメみたい。なんでかはよくわからない。

Coji MizoguchiCoji Mizoguchi

export 使ってないと pretitier によって勝手にインデント消されちゃう

Coji MizoguchiCoji Mizoguchi

次 Props を親から mdx コンポーネントに渡して表示する。

mdx

app/routes/props/example.mdx
# Hello {props.name.toUpperCase()}

The current year is {props.year}

route コンポーネント(親)

app/routes/props/route.tsx
import Example from './example.mdx'

export default function PropsDemoPage() {
  return (
    <div>
      <Example name="coji" year="2024" />
    </div>
  )
}

うごいた

Coji MizoguchiCoji Mizoguchi

mdx を DB に入れて SSR したいなあと思ってたんですが、
この gist の通りにやったらできそうでした。

https://gist.github.com/johno/77a7f142b847ed130f767eadb7f6b790?permalink_comment_id=4695348#gistcomment-4695348

komponent.tsx
import { KModules } from '..'; // object with my custom components
import { EvaluateOptions, evaluateSync } from '@mdx-js/mdx';
import { MDXProvider, useMDXComponents } from '@mdx-js/react';
import { FC, useMemo } from 'react';
import * as runtime from 'react/jsx-runtime';

export interface KomponentProps {
  children?: React.ReactNode;

  /**
   * Markdown string to be rendered. If this is set, `children` will be ignored.
   */
  markdown?: string;

  /**
   * Replace the default components with your own.
   */
  components?: Parameters<typeof MDXProvider>[0]['components'];

  /**
   * Add extra components to the default ones.
   */
  extra?: Parameters<typeof MDXProvider>[0]['components'];
}

export const Komponent: FC<KomponentProps> = ({
  children,
  markdown,
  components,
  extra,
}) => {
  const modules =
    components ??
    Object.entries(KModules).reduce(
      (ms, [key, value]) => ({ ...ms, [key]: value }),
      extra,
    );

  const MDXContent = useMemo(() => {
    try {
      return markdown
        ? evaluateSync(markdown, {
            ...runtime,
            useMDXComponents,
          } as EvaluateOptions).default
        : null;
    } catch (e) {
      return () => (e as Error).message;
    }
  }, [markdown]);

  return (
    <MDXProvider components={modules}>
      {MDXContent ? <MDXContent /> : children}
    </MDXProvider>
  );
};

KModules が何入れればいいのかな〜