Closed6

Remix+CloudflareでWebサイトを作る 13(MarkdownをHTMLに変換してスタイルを付与して表示・GitHub形式/改行/生HTML/シンタックスハイライトに対応)

saneatsusaneatsu

【2024-03-03】Markdownパーサーのライブラリを学ぶ

Remark

https://remark.js.org/

  • 曰く「Markdown processor」
  • テキストをパース(解析)し変換・操作することができるJavaScriptライブラリ(e.g. Markdown)
  • 様々なライブラリと組み合わせて目的の形式のテキストに変換可能
  • Rehype と一緒に使うことで Markdown を HTML に変換できる
    • Marked.jsもあるが、Remarkは抽象構文木(AST)に変換することができるためより柔軟に構文を改造できる

Unified

  • テキストや構文木や AST と AST を変換するための JavaScriptライブラリ
  • Remarkを含む多くのプラグインを提供しており、これらのプラグインを組み合わせてテキスト処理パイプラインを構築することが可能
  • さまざまなテキスト形式や処理ツールを統一的なAPIで扱うことができるように設計されているので、テキスト処理パイプラインをカスタマイズして拡張することが可能

remarkのドキュメントを見ると「HTMLとか別のフォーマットに変換するなら、remark()じゃなくてunified()で初期化したほうがいい。そのほうがpluginが色々使えるから」と記載されていました。一方で、remark()で初期化してremark-rehypeでHTML変換するサンプルをたくさん見かけるので、動きに大差はないのかもしれませんが

npmの巨大なエコシステム、unifiedについて調べました より

remarkのドキュメントに書かれてあるが機能ごとにライブラリを分けているため数がめちゃくちゃに多い...。

基本知識

テキストを変換する流れと基本知識は以下の記事に丸投げ

https://zenn.dev/januswel/articles/e4f979b875298e372070#unified-における用語
https://qiita.com/sankentou/items/f8eadb5722f3b39bbbf8

参考

記事

リポジトリ

saneatsusaneatsu

【2024-03-03】Markdownを変換してWebに表示してみる

はじめの一歩

なんだかいろいろな書き方ありそうだし拡張のしようがいくらでもありそうでExampleコード見ても頭こんがらがるのでまずは書いてみるか〜と思ってたらとても良い記事を見つけた。

https://zenn.dev/yoshiishunichi/articles/667120b3d0c9d2

import rehypeStringify from "rehype-stringify";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import { unified } from "unified";

export const markdownToHtml = async (markdown: string) => {
  const vfile = await unified()
    .use(remarkParse) // Markdown → mdast(Markdown文字列ASTにしたもの)
    .use(remarkRehype) // mdast → hast(HTML文字列をASTにしたもの)
    .use(rehypeStringify) // hast → HTML
    .process(markdown);

  // remark-htmlを用いると一気に以下のように書ける
  // const vfile = await remark().use(remarkHtml).process(markdown);

  return vfile.toString();
};

const markdown = `
# Head 1
## Head 2
### Head 3
普通の文字
**太字**

|key|value|
|---|---|
|キー|バリュー|

- li 1
- li 2
- li 3
`;

export async function loader({ params, context }: LoaderFunctionArgs) {
  const html = await markdownToHtml(markdown);
  console.log(html); // 適当にloaderで表示してみる

  return json({ html });
}

export default function Layout() {
  const { html } = useLoaderData<typeof loader>();

  return (
    <div dangerouslySetInnerHTML={{ __html: html }} />
  )
}

結果

loaderの出力結果

VFile {
  cwd: '<コードがあるディレクトリの絶対パス>',
  data: {},
  history: [],
  messages: [],
  value: '<h1>Head 1</h1>\n' +
    '<h2>Head 2</h2>\n' +
    '<h3>Head 3</h3>\n' +
    '<p><strong>strong</strong></p>\n' +
    '<ul>\n' +
    '<li>li 1</li>\n' +
    '<li>li 2</li>\n' +
    '<li>li 3</li>\n' +
    '</ul>'
}

画面

基本的なMarkdownは反映されてそう。

テーブルと改行は反映されないので今後対応。

VFile

とは

  • Virtualized File の略で、ファイルを仮想化して扱えるようにしたもの
  • Unified では VFile を用いてデータをやりとりする
    • ファイルのパス情報を抽象化して管理している
saneatsusaneatsu

【2022-03-03】リンク・改行・テーブル等に対応する

色々導入

  • remark-html
    • 1つ前にも書いてあるがこれによって一気通貫で変換を行う
    • unifiedremarkmdasthastrehype とか調べてたけどなんも考えなくて良くなったな...
  • remark-gfm
    • GFM(github flavored markdown)のこと
    • GitHubのマークダウンに変換することでリンクに対応
    • これによって こういう打ち消し線 やテーブルにも対応できるようになった
  • remark-breaks
    • 改行することで <br /> タグを挿入できるようになった

コード

変換

import { remark } from "remark";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import remarkHtml from "remark-html";

export const markdownToHtml = async (markdown: string) => {
  const vfile = await remark()
    .use(remarkGfm)
    .use(remarkBreaks)
    .use(remarkHtml)
    .process(markdown);

  return vfile.toString();
};  

Markdown

const markdown = `
# Head 1
## Head 2
### Head 3

普通の文字

**太字**

ここと
ここは改行しておいて欲しい
[Google](https://www.google.co.jp/)のリンクです。
~~打ち消し線~~に対応した

|key|value|
|---|---|
|キー|バリュー|

- li 1
- li 2
- li 3
`;

結果

次はCSSを書いてスタイルを付与する。

saneatsusaneatsu

【2024-03-03】CSSを適用

Sass導入

npm install -D sass

CSS書く

色はJoy UIの Theme colors - Joy UI から取ってきた。

markdown.scss
.markdown {
  h1 {
    padding-bottom: 4px;
    margin-bottom: 16px;
    font-size: 24px;
    font-weight: bold;
    border-bottom: solid 2px var(--joy-palette-neutral-500, #636b74);
  }

  h2 {
    padding-bottom: 4px;
    margin: 16px 0;
    font-size: 22px;
    font-weight: bold;
    border-bottom: solid 1px var(--joy-palette-neutral-500, #636b74);
  }

  h3 {
    margin: 16px 0;
    font-size: 18px;
    font-weight: bold;
  }

  p {
    margin: 6px 0;
  }

  a {
    color: var(--joy-palette-primary-500, #0b6bcb);
    transition: opacity 0.1s linear;

    &:hover {
      color: var(--joy-palette-primary-600, #185ea5);
    }
  }

  table {
    border-collapse: collapse;

    th,
    td {
      border: solid 1px black;
    }

    th {
      font-weight: bold;
      color: var(--joy-palette-neutral-800, #171a1c);
      background: var(--joy-palette-neutral-100, #f0f4f8);
      padding: 10px;
    }

    td {
      padding: 4px 10px;
    }
  }
}

適用

import styles from "~/styles/markdown.scss";

export const links = () => [{ rel: "stylesheet", href: styles }];

// loaderとかは変わっていないので省略

export default function Layout() {
  const { html } = useLoaderData<typeof loader>();

  return (
    <div className="markdown" dangerouslySetInnerHTML={{ __html: html }} />
  )
}

結果

ビルドエラー

エラー内容

Error [RollupError]: "default" is not exported by "app/styles/markdown.scss", imported by "app/routes/articles.$id.tsx".

修正

- import styles from "~/styles/markdown.scss";
+ import "~/styles/markdown.scss";

- export const links = () => [{ rel: "stylesheet", href: styles }];
+ export const links = () => [
+   { rel: "stylesheet", href: "~/styles/markdown.scss" },
+ ];

次したいこと

  • YoutubeとTwitterなどの埋め込み対応
  • 独自記法を作成(例:Zennの :::message みたいなやつ)
saneatsusaneatsu

【2024-03-03】remark-html を使わない形式に戻す

Markdownに直接HTMLを書いても有効にするためにrehype-raw を導入したい。
が、remark-htmlを使っているとうまくいかなかった。

というのも、unifiedでuseを記載するときにはその順番が重要になってくるのでremark-htmlを使って一気に変換を行っていると間に処理を挟むことができず自由度が減って不都合だった。

import rehypeRaw from "rehype-raw";
import rehypeStringify from "rehype-stringify";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import { unified } from "unified";

/**
 * 適用順序
 * 1. remarkParseでMarkdownをmdastに変換
 * 2. remark-xxx のプラグインを適用
 * 3. remarkRehypeでmdastをhastに変換
 * 4. rehype-xxx のプラグインを適用
 * 5. hastをReactElementに変換
 */
export const markdownToHtml = async (markdown: string) => {
  const vfile = await unified()
    .use(remarkParse, { fragment: true }) // Markdown → mdast(Markdown文字列ASTにしたもの)
    .use(remarkGfm) // GitHubのMarkdown記法を使えるようにする
    .use(remarkBreaks) // 改行を<br>にする
    .use(remarkRehype, {
      allowDangerousHtml: true, // rehype-rawのために直接記載されたタグを許可する
    })
    .use(remarkRehype) // mdast → hast(HTML文字列をASTにしたもの)
    .use(rehypeRaw) // Markdown内でHTMLを使えるようにする
    .use(rehypeStringify) // hast → HTML
    .process(markdown);

  return vfile.toString();
};
saneatsusaneatsu

【2024-03-03】シンタックスハイライトをきれいにしよう

https://osgsm.io/posts/introducing-rehype-pretty-code

ここを参考に。

実装

$ npm i rehype-pretty-code
// シンタックスハイライト部分を追記
const markdown = `
\`\`\`sh
$ npm i hoge
\`\`\`

\`\`\`ts
export const markdownToHtml = async (markdown: string) => {
  const vfile = await unified()
    .use(remarkParse, { fragment: true }) // Markdown → mdast(Markdown文字列ASTにしたもの)
    .use(remarkGfm) // GitHubのMarkdown記法を使えるようにする
    .use(remarkBreaks) // 改行を<br>にする
    .use(remarkRehype, {
      allowDangerousHtml: true, // rehype-rawのために直接記載されたタグを許可する
    })
    .use(remarkRehype) // mdast → hast(HTML文字列をASTにしたもの)
    .use(rehypeRaw) // Markdown内でHTMLを使えるようにする
    .use(rehypeStringify) // hast → HTML
    .process(markdown);

  return vfile.toString();
};
\`\`\`
`;
+ import rehypePrettyCode from "rehype-pretty-code";
import rehypeRaw from "rehype-raw";
import rehypeStringify from "rehype-stringify";
import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import { unified } from "unified";

/**
 * 適用順序
 * 1. remarkParseでMarkdownをmdastに変換
 * 2. remark-xxx のプラグインを適用
 * 3. remarkRehypeでmdastをhastに変換
 * 4. rehype-xxx のプラグインを適用
 * 5. hastをReactElementに変換
 */
export const markdownToHtml = async (markdown: string) => {
  const vfile = await unified()
    .use(remarkParse, { fragment: true }) // Markdown → mdast(Markdown文字列ASTにしたもの)
    .use(remarkGfm) // GitHubのMarkdown記法を使えるようにする
    .use(remarkBreaks) // 改行を<br>にする
    .use(remarkRehype, {
      allowDangerousHtml: true, // rehype-rawのために直接記載されたタグを許可する
    })
    .use(remarkRehype) // mdast → hast(HTML文字列をASTにしたもの)
    .use(rehypeRaw) // Markdown内でHTMLを使えるようにする
+   .use(rehypePrettyCode, {
+     theme: "one-dark-pro",
+      keepBackground: true, // テーマの背景色をそのまま使用
+    }) // シンタックスハイライト
    .use(rehypeStringify) // hast → HTML
    .process(markdown);

  return vfile.toString();
};

その他のテーマは以下を参照
https://github.com/shikijs/textmate-grammars-themes/tree/main/packages/tm-themes

markdown.scss
.markdown {
  ...

+ figure {
+   margin: 0;
+   pre {
+     border-radius: 4px;
+     font-size: 0.9rem;
+     padding: 12px;
+   }
+ }
}

結果

Before

After

このスクラップは2ヶ月前にクローズされました