fwywd チームでは新ブログを Tailwind UI の Syntax を使用して実装予定です。
記事の表示と記事作成画面のプレビューを揃えたい & 後々の Syntax 導入をスムーズにしたい目的で Tailwind UI の Syntax ベースの Markdown Viewer を実装しました!
markdoc のライブラリをインストール
yarn add @markdoc/markdoc @markdoc/next.js -D
next.config.js を書き換える
括弧の位置を間違えやすいので注意が必要です。
// next.config.js
const withMarkdoc = require('@markdoc/next.js');
/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  images: {
    deviceSizes: [340, 640, 768, 1024, 1280, 1440, 1980],
    domains: [],
  },
  pageExtensions: ['js', 'jsx', 'ts', 'tsx', 'md'],
};
module.exports = withMarkdoc()(nextConfig);
Prose.tsx
Markdoc で変換したデータを children で渡し画面表示するコンポーネントです。
Tailwind CSS の prose というクラスによって余白や文字間を調節しています。
下準備として、clsx, @tailwindcss/typography をインストールします。
yarn add clsx -D
yarn add @tailwindcss/typography -D
tailwind.config.js に plugins を追記します。
plugins: [require('@tailwindcss/typography')],
Prose.tsx の内容は以下です。
// Prose.tsx
import clsx from 'clsx';
import { ReactNode } from 'react';
interface ProseProps {
  children: ReactNode;
  className?: string;
}
export const Prose: React.FC<ProseProps> = ({ className, ...props }) => (
  <div
    className={clsx(
      className,
      'dark:text-gray-400 prose prose-gray max-w-none dark:prose-invert',
      // headings
      'prose-headings:font-display prose-headings:scroll-mt-28 prose-headings:font-normal lg:prose-headings:scroll-mt-[8.5rem]',
      // lead
      'prose-lead:text-gray-700 dark:prose-lead:text-gray-700',
      // links
      'prose-a:font-semibold dark:prose-a:text-primary-500',
      // link underline
      'prose-a:no-underline prose-a:shadow-[inset_0_-2px_0_0_var(--tw-prose-background,#fff),inset_0_calc(-1*(var(--tw-prose-underline-size,4px)+2px))_0_0_var(--tw-prose-underline,theme(colors.primary.200))] hover:prose-a:[--tw-prose-underline-size:6px] dark:[--tw-prose-background:theme(colors.gray.900)] dark:prose-a:shadow-[inset_0_calc(-1*var(--tw-prose-underline-size,2px))_0_0_var(--tw-prose-underline,theme(colors.primary.800))] dark:hover:prose-a:[--tw-prose-underline-size:6px]',
      // pre
      'prose-pre:rounded-xl prose-pre:bg-gray-900 prose-pre:shadow-lg dark:prose-pre:bg-gray-800/60 dark:prose-pre:shadow-none dark:prose-pre:ring-1 dark:prose-pre:ring-gray-700/10',
      // hr
      'dark:prose-hr:border-gray-800',
    )}
    {...props}
  />
);
Markdoc でデータを変換して Prose.tsx に渡すコンポーネントを作成します。
// Markdown.tsx
export const Markdown = () => {
  const doc = `
  # heading 1
  
  ## heading 2
  1. ol
  1. ol
  - ul
  - ul
`;
  const ast = Markdoc.parse(doc);
  const content = Markdoc.transform(ast);
  const children = Markdoc.renderers.react(content, React);
  return (
    <Prose className={`${style.md} h-[600px] w-full overflow-auto border border-primary-800 p-2`}>
      {children}
    </Prose>
  );
};
余白や文字間は Tailwind CSS の prose でいい感じになっているのでテキストの大きさや色などの装飾を CSS モジュールで適用します。
マークダウン表示できます。
Fence.tsx
マークダウンの表示はできましたがコードのハイライトはされない状態です。
Markdoc の Syntax highlighting を参考にして Tailwind UI の Syntax ベースでコードのハイライトを実装します。
少し複雑でした。大まかな流れとしたら、
- code をハイライトする Fence.tsxを作成する
- code を取り出してハイライト適用するための config ファイル node.tsを作成する
- Markdoc でデータ変換する関数に node.tsから config を渡す
といった流れです。
code をハイライトする Fence.tsx の作成
コードをハイライトするための prism-react-renderer をインストールします。
yarn add prism-react-renderer -D
Tailwind UI からそのまま持ってきて型付けをしています。
<Highlight> に渡す code の children は String(children) としないと型のエラーが発生してしまうので注意です。
// Fence.tsx
import { Fragment, ReactNode } from 'react';
import Highlight, { defaultProps, Language } from 'prism-react-renderer';
import vsDark from 'prism-react-renderer/themes/vsDark';
interface FenceProps {
  children: ReactNode;
  language: Language;
}
export const Fence: React.FC<FenceProps> = ({ children, language }) => (
  <Highlight
    {...defaultProps}
    code={children === undefined ? '' : String(children).trimEnd()}
    language={language}
    theme={vsDark}
  >
    {({ className, style, tokens, getTokenProps }) => (
      <pre className={className} style={style}>
        <code>
          {tokens.map((line, lineIndex) => (
            <Fragment key={lineIndex}>
              {line
                .filter((token) => !token.empty)
                .map((token, tokenIndex) => (
                  <span key={tokenIndex} {...getTokenProps({ token })} />
                ))}
              {'\n'}
            </Fragment>
          ))}
        </code>
      </pre>
    )}
  </Highlight>
);
ハイライトを適用させるための node.ts を作成
// markdoc/node.ts
const node = {
  fence: {
    render: 'Fence',
    attributes: {
      language: {
        type: String,
      },
    },
  },
};
export default node;
Markdoc でデータ変換する関数に config を渡す
// Markdown.tsx
const ast = Markdoc.parse(doc);
const content = Markdoc.transform(ast, { nodes: node });
const children = Markdoc.renderers.react(content, React, {
  components: { Fence },
});
code がハイライトされます。
まとめ
TypeScript の型付けと code のハイライト表示に苦戦しましたがなんとかなりました。
なにより Tailwind CSS の prose が便利でした!
少しマニアックなところですが参考になる方がいれば嬉しいです!