MDX をちゃんと触ってみる
最近になって Remix で MDX を試しに使ってみて超便利だったのでよいなあと思いました。
いろいろできそうなので、ちゃんとやってみます。
公式のやつ。
What's MDX から。
特徴
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 であること (import
と export
)
MDX は標準で Common Mark に対応してる。
プラグインで frontmatter やシンタックスハイライトなども対応。
MDX の中で使う React コンポーネントは import することも、ローカルで定義することも、後で渡す(pass them in later)こともできる。
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} />
完全に jsx ですけど。
テキストとタグが同じ行にある場合、JSX内部でマークダウンの「インライン」は使えますが、「ブロック」は使えません:
<div># this is not a heading but *this* is emphasis</div>
1行のテキストとタグはブロックを生成しないので、<p>も生成しない。別々の行では生成されます:
<div>
This is a `p`.
</div>
最小セットで実行してみようということで、 vite React でプロジェクト作ってみた。
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
原因これくさ。
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.
src/mdx.d.ts を追加
declare module "*.mdx" {
let MDXComponent: (props: Record<string, unknown>) => JSX.Element;
export default MDXComponent;
}
デプロイできた。
ソース
Welcome コンポーネントががうごかないよ。
特に console とかにもエラーは出ていない。なんでや
babel いるのかな〜
よくわからないまま設定してみる
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(),
],
});
main.tsx でエラー。
babel 通しちゃうからか。。
でも Welcome.tsx でやりたいんだよな
ああ、なんかちょっと違うのかな。
mdx の中で modern javascript features 使いたいなら babel プラグイン入れなよってことなのかな〜
mdx から tsx を import したいのとは別な気がしてきた
MDX のドキュメントちゃんと読んでみる
うわあ、Welcome.tsx で return 抜けてた
普通にうごきました。
import で alias 使いたい。ちょっと試しにやってみる
tsconfig.json に baseUrl と paths 追加。
{
"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 に追加
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()],
});
src/App.mdx を編集して alias から import するように
# Hello!
-import { Welcome } from "./Welcome.tsx";
+import { Welcome } from "~/Welcome.tsx";
<Welcome>こんにちは〜</Welcome>
だめです
mdx が js に変換されるときにエイリアス解決されてないとだめっぽいのかな。
いろいろ検索してたら Remix Vite で作られた mdx ベースのブログ、という面白いサイトみつけた
mdx ファイルと、routes を実行時にリストアップしてポスト一覧を作ってる。
へ〜へ〜インスパイア元の同様のコンセプトのサイト
とソースコード
こっちは framer-motion で動きもつけててかわいい
remix-blog-mdx のほうで mdx から別ディレクトリの component を import してるの見つけたけど、相対パスになってる。うーん
とりあえず相対パスで逃げることにした。うーん、嫌だなあ
動くようにはなったのでこれをやっていく
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
prettier / biome / tailwindcss / mdx / frontmatter 入れて、eslint 消して。
一旦うごかすようにした。
リポジトリ
Hello
ソース
---
title: Hello!
---
export const Thing = () => {
return <div className="text-4xl">World</div>
}
# here is <Thing />
frontmatter の title が効いてない
remix vite の場合、export const meta で適宜 export しないといけないようだ。なるほど〜
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
headers 設定できるのいいね
というわけでこんな感じに。
---
-title: Hello!
+meta:
+ - title: Hello!
---
+export const meta = frontmatter.meta
export const Thing = () => {
return <div className="text-4xl">World</div>
}
ちゃんと変わった。
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>
大雑把にすると以下のように変換されると思っとくといいよ、とのこと
export function Thing() {
return <>World</>
}
# Hello <Thing />
/* @jsxRuntime automatic */
/* @jsxImportSource react */
export function Thing() {
return <>World</>
}
export default function MDXContent() {
return <h1>Hello <Thing /></h1>
}
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)がエクスポートされる。
実際の出力はこうだ:
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)
}
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ランタイムの両方がサポートされています。
試しにやってみる。
export function Thing() {
return <>World</>
}
# Hello <Thing />
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>
)
}
export ないとダメなのかな〜?
試しに Things から export 消してみる
function Thing() {
return <>World</>
}
# Hello <Thing />
ダメみたい。なんでかはよくわからない。
export 使ってないと pretitier によって勝手にインデント消されちゃう
次 Props を親から mdx コンポーネントに渡して表示する。
mdx
# Hello {props.name.toUpperCase()}
The current year is {props.year}
route コンポーネント(親)
import Example from './example.mdx'
export default function PropsDemoPage() {
return (
<div>
<Example name="coji" year="2024" />
</div>
)
}
うごいた
mdx を DB に入れて SSR したいなあと思ってたんですが、
この gist の通りにやったらできそうでした。
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 が何入れればいいのかな〜