かもメモ

自分の落ちた落とし穴に何度も落ちる人のメモ帳

React マークダウンを HTML 変換するライブラリ試してみた

マークダウンテキストを HTML 表示するライブラリ試してみたのでメモ

結論

React で使うなら React-markdown がシンプルで良さそう

Markdown ライブラリ比較

調査経緯

markdown-it vs remark vs marked

  • ライブラリとしては marked >>> markdown-it > remark の順で使われている
  • remark の内部で使われている remark-parse が圧倒的に多く使われている
  • 最終更新は markdown-it > remark (remark-parse) > marked の順

marked は利用数は多いのですが最終アップデートが 11 年前なので、Markdown-it と Remark で検討することにしました

Markdown-it Remark
Project JavaScript TypeScript
Star 14.5k 5.8k
Last publish 7 months ago a year ago

Star 数は Markdown-it の方が多いですが、Remark の方は unified というプロジェクトに属していて TypeScript 製です。
GitHubリポジトリをみた感じ両方とも活発に開発されているように感じました。Remark の方は Next.js の Vercel がスポンサーをしているらしく個人的にはポイントが高いです。

Markdown-it

ライブラリのインストール

$ npm i markdown-it
$ npm i -D @types/markdown-it

使い方

import markdownit from 'markdown-it';

const markdownString = `# headind`;

const Content = () => {
  return (
    <div
      dangerouslySetInnerHTML={{ __html: markdownit().render(markdownString) }}
    />
  );
};
  • markdown-it は HTML な文字列に変換するので React で出力するには dangerouslySetInnerHTML を使う必要がある
  • プラグインなど導入しなくても table も変換される
  • GitHub にあるような checkbox はサポートされてない
  • <script> 含む HTML はそのまま文字列として出力される

Remark

If you just want to turn markdown into HTML (with maybe a few extensions), we recommend micromark instead. remark can also do that but it focusses on ASTs and providing an interface for plugins to transform them.

Depending on the input you have and output you want, you can use different parts of remark. If the input is markdown, you can use remark-parse with unified. If the output is markdown, you can use remark-stringify with unified If both the input and output are markdown, you can use remark on its own. When you want to inspect and format markdown files in a project, you can use remark-cli.
cf. When should I use this?

今回はあえて Remark の含まれる unified を使った方法を試しました。

ライブラリのインストール

$ npm i unified remark-parse remark-rehype rehype-sanitize rehype-stringify
# Support GFM (tables, autolinks, tasklists, strikethrough)
$ npm i remark-gfm

使い方
Remark は非同期で変換を行うので使い方に注意が必要

import { useEffect, useState } from 'react';
import { unified } from 'unified';
// markdown をパースする
import remarkParse from 'remark-parse';
// Support GFM (tables, autolinks, tasklists, strikethrough)
import remarkGfm from 'remark-gfm';
// HTML に変換する
import remarkRehype from 'remark-rehype';
// サニタイズ
import rehypeSanitize from 'rehype-sanitize';
// HTML にシリアライズ
import rehypeStringify from 'rehype-stringify';

const markdownString = `# headind`;

const parseMarkdown = async (text: string): Promise<string> => {
  const file = await unified()
    .use(remarkParse)
    .use(remarkGfm)
    .use(remarkRehype)
    .use(rehypeSanitize)
    .use(rehypeStringify)
    .process(text);
  // VText が返されるので String にして返す
  return String(file);
};

const Content = () => {
  const [content, setContent] = useState('');
  useEffect(() => {
    const getContent = async () => {
      const htmlString = await parseMarkdown(markdownString);
      setContent(htmlString);
    };
    getContent();
  }, []);

  return (
    <>
      {content ? (
        <div dangerouslySetInnerHTML={{ __html: content }} />
      ) : (
        <p>Loading...</p>
      )}
    </>
  );
};

react-markdown

Remark ベースで React Component としてマークダウンを扱えるようになるライブラリ。Remark ベースなのでプラグインがそのまま利用できる

パッケージのインストール

$ npm i react-markdown
# Support GFM (tables, autolinks, tasklists, strikethrough)
$ npm i remark-gfm

使い方

import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';

const markdownString = `# headind`;

const Content = () => {
  return (
    <ReactMarkdown remarkPlugins={[remarkGfm]}>
      {markdownString}
    </ReactMarkdown>
  );
};
  • <ReactMarkdown> の children に マークダウン形式の文字列を渡すだけでOK
  • remark-gfm プラグインを使えば Table, checkbox も変換される
  • <script> 含む HTML はそのまま文字列として出力される

HTML タグを使えるようにする

  • rehype-raw プラグインを使えばマークダウンの中に直接書かれた HTML も出力できるようになる
  • rehype-sanitize を合わせて使うことで <script> タグを除去できる

プラグインのインストール

$ npm i rehype-raw rehype-sanitize

使い方

import ReactMarkdown from 'react-markdown';
import rehypeRaw from "rehype-raw";
import rehypeSanitize from "rehype-sanitize";
import remarkGfm from "remark-gfm";

const markdownString = `# headind
<strong>Strong</strong>
<script>console.log('danger!')</script>
`;

const Content = () => {
  return (
    <ReactMarkdown
      rehypePlugins={[rehypeRaw, rehypeSanitize]}
      remarkPlugins={[remarkGfm]}
    >
      {markdownString}
    </ReactMarkdown>
  );
};

プラグインの順番を [rehypeSanitize, rehypeRaw] にすると <script> 以外の HTML タグも除去されてしまう

結論

Markdown と一言で言ってもフォーマットや形式がふわっとしているので、自分の頭の中にある Markdown がすべてサポートされているのようなことはない。
Remark はプラグインを使うことで使えるフォーマットをカスタマイズできる仕様になっているのが個人的に良いなと感じた。その上で React で使うなら内部的に Remark が使われている React-markdown を使うのがシンプルに書けて良さそうに思った。

これらのライブラリを使えば外部の markdown を fetch して表示する blog のようなものは割と簡単に作れそうだな〜と思いました。ライブラリありがたい!

調べてたら結局長くなってしまった…
おわり


[参考]

React vanilla-extract 使ってみたのメモ

今関わっているプロジェクトで vanilla-extract という CSS Modules っぽく書ける CSS in JS が使われており初めて触ってみたの感想的なメモ

vanilla-extract の特徴

  • *.css.ts という TypeScript ファイルにオブジェクト形式で CSS を作成して CSS Modude っぽく使える
  • TypeScript なので型の恩恵が受けられる (存在しないプロパティなどがコンパイルエラーになる)
  • CSS のネストが制限される

CSS Modules っぽい

*.css.ts ファイルに書いたクラス定義を import して className に割り当て、出力されるクラスは hash 化されてスコープが作られる。

/src
  |- /components
      |-/header
          |- index.tsx
          |- header.css.ts
CSS の定義

/header/header.css.ts

import { style } from '@vanilla-extract/css';

export const header = style({
  background: "#fff",
  width: '100%',
});

export const wrapper = style({
  display: 'flex',
  justifyContent: 'space-between',
  alignItems: 'center',
  padding: '0.5rem 1rem',
});

export const title = style({
  fontSize: '2rem',
});

export const link = style({
  color: '#2ea4de',
  textDecoration: 'none',
  transition: 'color .3s',
  ':hover': {
    color: '#fbcb62'
  }
});
コンポーネント側での使い方

/header/index.ts

import { FC } from "react";
import { header, wrapper, title, link } from './header.css';

export const Header: FC = () => {
  return (
    <header className={header}>
      <div className={wrapper}>
        <h1 className={title}>Title</h1>
        <nav>
          <a className={link} href="#">Link</a>
        </nav>
      </div>
    </header>
  );
};

ほぼ CSS Modules と同じな印象でした。
CSS のスタイルを文字列で囲わないとなのが少し面倒ですが、プロパティ・値共に TypeScript での補完が効くので書きやすい印象でした。オブジェクト形式なので途中で追加変更した際に , を書き忘れるとビルドエラーになってしまうのが少し注意ポイントですが formatter などでどうにでもなりそうです。

Global なスタイルと CSS 変数 (カスタムプロパティ)

Global なスタイル

a タグなど共通してスタイルは globalStyle として定義する

import { globalStyle } from '@vanilla-extract/css';

globalStyle('html, body', {
  margin: 0
});
⚠ globalStyle は :hover などの疑似セレクタを一緒に定義できない
globalStyle('.link', {
  color: '#2ea4de',
  textDecoration: 'underline',
  transition: 'color .3s',
  ':hover': {
    color: '#fbcb62',
  },
});

これはコンパイルエラーになる
=> Object literal may only specify known properties, and '':hover'' does not exist in type 'GlobalStyleRule'.

疑似セレクタは現状別々に定義しなければならない

globalStyle での issue が出ているのでこの先変更されるかもしれないが、現状 .link.link:hover の用に別々に定義しなければならない

globalStyle('.link', {
  color: '#2ea4de',
  textDecoration: 'underline',
  transition: 'color .3s',
});
globalStyle('.link:hover', {
  color: '#fbcb62'
});

CSS 変数 (カスタムプロパティ)

CSS 変数は createGlobalTheme を使って定義できる

import { createGlobalTheme } from '@vanilla-extract/css';

const vars = createGlobalTheme(':root', {
  color: {
    primary: 'red',
    secondary: 'blue'
  },
});

👇 CSS 変数もハッシュが追加されたものが出力される

:root {
  --color-primary__1cfdkwm0: red;
  --color-secondary__1cfdkwm1: blue;
}

createGlobalTheme で作成した CSS 変数を使う方法

createGlobalTheme で定義した変数を import してプロパティ指定すればOK

import { style } from '@vanilla-extract/css';
import { vars } from './styles/theme.css';

const container = styles({
  color: vars.color.primary,
});

cf. createGlobalTheme — vanilla-extract

疑似セレクタ・ネストしたプロパティ

疑似セレクタ

疑似セレクタはキーを:始まりの文字列として定義できる

import { style } from '@vanilla-extract/css';

const link = style({
  opacity: 1,
  ':hover': {
      opacity: 0.8,
  },
  ':before': {
    content: '>',
    marginRight: '.5rem',
  },
};

ネストしたプロパティ

  • selectors プロパティの中に定義する
  • 自身を示す & が必須
  • 文字列展開 (テンプレートリテラル) を使う場合は、セレクタ[] で囲う
import { style } from '@vanilla-extract/css';

const link = style({
  selectors: {
    '&:hover:not(:active)': {
      border: '2px solid aquamarine'
    },
    'nav li > &': {
      textDecoration: 'underline'
    },
  },
});

👇 ビルド結果

.styles_link__1hiof570:hover:not(:active) {
  border: 2px solid aquamarine;
}
nav li > .styles_link__1hiof570 {
  text-decoration: underline;
}

🙅 自身の子・自身からの隣接セレクタは指定できない

オブジェクトの中はあくまで自身が最終的なスタイルの対象でなければならない

import { style } from '@vanilla-extract/css';

const section = style({
  marginBottom: '6rem',
});

const container = style({
  // some style…
  selectors: {
     // ❌ ERROR
    `& > *:first-child`: {
      marginTop: '0px',
    },
    // ❌ ERROR
    [`& > ${section}`]: {
      marginBottom: '3rem',
    }
  },
});

自身のスタイルでないものはコンパイルエラーになる

👇

  • 🙆 あくまで自身のスタイル (自身が子) でなければならない
  • 🙆 最終的に適応したいセレクタa など global なセレクタの場合は globalStyle を使う
import { globalStyle, style } from '@vanilla-extract/css';

const container = style({
  // some style…
});

const section = style({
  marginBottom: '6rem',
  selectors: {
    [`${container} > &`]: {
      marginBottom: '3rem',
    }
  },
});

globalStyle(`${container} > *:first-child`, {
  backgroundColor: '#F00',
});

🤔 .section + .section のような自身との隣接セレクタの指定方法がわからない

.section + .section { margin-top: 3rem; }

みたいなスタイルを結構使うのですが vanilla-extract での指定方法がわかりませんでした

import { style } from '@vanilla-extract/css';

const section = style({
  selectors: {
    // ❌ ERROR: Top-level "this" will be replaced with undefined since this file is an ECMAScript module
    [`${this} + &`]: {
      marginTop: '3rem',
    },
    // エラーにはならないが無視される
    // Block-scoped variable 'section' used before its declaration.
    [`${section} + &`]: {
      marginTop: '3rem',
    },
  }
});

globalStyle で定義するほか無いのかもしれません…

cf. Styling — vanilla-extract


📝 Vite React のプロジェクトに vanilla-extract を導入する

vite 用のプラグインを導入する必要がある

$ npm i -D @vanilla-extract/vite-plugin

vite.config.js

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
+ import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin'

// https://vitejs.dev/config/
export default defineConfig({
- plugins: [react()]
+ plugins: [react(), vanillaExtractPlugin()]
})

cf. Vite — vanilla-extract

📝 CSS Modules の将来性に関してのメモ

cf. CSS Modulesの歴史、現在、これから - Hatena Developer Blog


Vite PostCSS で CSS をネストして書けるようにしたい

PostCSS はデフォルトでは Sass のようなネストでスタイルを書くことができません。
Global 用のスタイルを書くときなどにネストできないとちょっと不便なので PostCSS を使ってネストして CSS を書けるようにしたメモ

Vite (react) のプロジェクトに PostCSS-nesting を導入する

$ npm i -D postcss-nesting

PostCSS-nested v.s. PostCSS-nesting

PostCSS Nesting lets you nest style rules inside each other, following the CSS Nesting specification. If you want nested rules the same way Sass works you might want to use PostCSS Nested instead.

SCSS っぽく書きたいなら postcss-nested を選択すれば良さそう

vite.config.jscss のセクションを追加して postcss-nesting の設定を追加すれば OK

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";
import postcssNesting from "postcss-nesting";

export default defineConfig({
  plugins: [react(), tsconfigPaths(), svgr()],
  css: {
    postcss: {
      plugins: [postcssNesting],
    },
  },
  server: { port: 3000 },
});

📝 PostCSS はセレクタをネストさせる時に & が必要

🙅 Scss と同じ書き方をしても意図したとおりにコンパイルされない

.foo {
  h1 { color: red; }
}

🙆 PostCSS-nesting ではネストさせるセレクタの前に & が必要

.foo {
  & h1 { color: red; }
}

PostCSS を使ってネストした CSS が書けるようになりました。ただネストは詳細度が上がりメンテナンス性を損なう場合があるので使いすぎには注意しましょう!

おわり ₍ ᐢ. ̫ .ᐢ ₎


[参考]

最近作業中にずっとヨルシカ聴いてる