かもメモ

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

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


TIL: 冪等性、参照透過性、純粋関数 についてのメモ

勉強会で 読みやすいコードのガイドライン をやっていて冪等であるのがよい。と書かれていたのですが冪等についてふわふわした感覚だったので調べたメモ。
⚠ しっかり調べた訳ではないので解釈が間違っている可能性があります :pray:

冪等性 idempotency, idempotence

  • 同じ操作を何度繰り返しても、同じ結果が得られるという性質
    • f(x) = f(f(x)) が成立する (数学的な厳密な冪等性?)
      • abs(-100) = abs(abs(-100)) なので abs() 関数は冪等性がある
      • sqrt(16) === sqrt(sqrt(16)) は成立しないので sqrt() 関数は冪等性がない
      • Math.random() は実行する度に得られる結果が異なるので冪等性はない
      • 圧縮する zip を zip(zip(x)) とすると archive が二重になるので zip(x) = zip(zip(x)) は成立しないから zip() は冪等性がない
    • 何回呼び出しても同じ結果になる
      • import 'react' は何回呼び出しても同じなので import は冪等性がある
      • tsc でのコンパイルはコードと設定が同じなら何回実行しても同じ結果になるので tsc は冪等性がある
      • buttom.toggle() のような関数は実行のたびに対象の状態が true 又は false と変化するので冪等性はない
      • toggle() 関数が状態に関わらず常に void undefined を返すのなら外からみて冪等性があるのだと思うが、対象の状態が副作用的に変化して扱う側はそれを覚えておかなければならないので冪等性が無いとして扱ったほうが良さそう
  • 冪等性は外からみた性質で、内部実装に違いが合っても良い
    • 例: メールアドレスをアップデートするAPI関数で、現在のメールアドレスと同じであれば関数内では変更しないように処理が異なっても API を実行する側が内部や状態を意識しなくて住むのであれば冪等性がある

下記は API を使う側からすれば「同じ操作を何度繰り返しても、同じ結果が得られるという性質」があるので冪等性がある

// 常にアップデートする API関数
function updateEmail(user, newEmail) {
  sql.exec("UPDATE users SET email = {newEmail} WHERE id = {user.id}");
  return;
}

// 内部で処理が異なるが常に同じ結果を返す API 関数
function updateEmail(user, newEmail) {
  if (user.email == newEmail) {
    // 更新はしない
  } else {
    sql.exec("UPDATE users SET email = {newEmail} WHERE id = {user.id}")
  }
  return;
}

cf.

参照透過性 Referential transparency

  • プログラムの構成要素が同じ者同士は等しい -> A === A
    • 2 === 2 は等しい
    • x = 1 の時 x === x は等しい
  • 変数の値は最初に定義した値と常に同じ -> var, let でなく const
  • 変数に値を割り当てなおす演算である代入 (Assignment) を行う式は存在しない

変数などがどこに記録されている値を参照しているのかを考慮する必要がない = 参照透過性がある

  • abs(-100) は常に 100 で副作用も持たないので abs() 関数には参照透過性がある (冪等性もある)
  • sqrt(16) は常に 4 で副作用も持たないので sqrt() 関数には参照透過性がある (冪等性は無い)
1. 可変なデータは参照透過性を破壊する

JavaScriptarray.push() のような破壊的メソッドは参照している値を変化させるので参照透過性がない

const arr = [1];
arr.push(2);
console.log(arr); // => [1, 2];
arr.push(3);
console.log(arr); // => [1, 2, 3];
2. 代入は参照なので代入を用いると参照透過性が破壊される
let x = 0;
add = (y: number): number => {
  x = x + 1;
  return x + y;
}

add(1); // => 2
add(1); // => 3
3. 副作用(side effect) は、関数外の機能・結果に影響をもたらすので参照透過性がない

React の非同期関数でデータを取得し状態を変更するのは、入力値 (props) とは別に返却するコンポーネント (return される JSX) を変更するので副作用であり、参照透過性が無い

import { useState, useEffect, FC } from 'react';

type UserInfoProps = {
  userId: number;
};

const UserInfo: FC<UserInfoProps> = ({ userId }) => {
  const [user, setUser] = useState(null);
  useEffect(() => {
    fetch(`/api/user/{userId}`)
      .then((res) => res.json())
      .then((json) => setUser(json));
  }, [userId]);
  
  return user ? <h1>Hello, {user.name}</h1> : <div>Loading…</div>
}

最初にUserInfoコンポーネント(関数)は userId を入力に受け取り実行された時は <div>Loading...</div> 返す。しかしその後で useEffect 内の非同期処理が完了した後に <h1>Hello, {user.name}</h1> を返すので返却する値が変わってしまう

同様に head の title などコンポーネント外の DOM を書き換えるのもコンポーネント ( = 関数) の外の DOM を参照して変更しているので副作用であり、参照透過性がない

import { useEffect, FC } from 'react';

const Example: FC = () => {
  useEffect(() => {
    // Update the document title using the browser API
    document.title = `You clicked ${count} times`;
  });

  return (
    <div>Example Component</div>
  );
}

cf.

関数の参照透過性

同じ引数で何度実行されても同じ値を返す

純粋関数 Pure functional

  • 参照透過性が担保された関数
    • 同じ引数で何度実行されても同じ値を返す
  • 副作用がない
    • 変数の値は最初に定義した値と常に同じ
    • 代入を用いないので関数外の変数を変更することもない

下記の関数は引数が同じなら何度実行されても同じ値を返すので参照透過性のある純粋関数

function succ(x: number) {
  return x + 1;
}

参照透過性が成り立つ言語は式の値がプログラムのテキストから定まるという特徴から宣言型言語 (Declarative language) と呼ばれたり、関数の数学的性質が保たれるという特徴から純粋関数型言語 (Pure functional language) と呼ばれたりする。
一方変数の値の変更が認められているような参照透過的でない言語を手続き型言語と呼ぶ。
cf. 参照透過性 - Wikipedia


[参考]

ぼっち・ざ・ろっく 面白い