ちょっとしたMarkdown converterを作ってみよう

## コードはご自由にどうぞ
https://github.com/yuutakkuma/simple-markdown-converter

## 車輪の再発明は楽しい
皆さん車輪の再発明は好きですか？

好きですよね？

僕は大好きです。

なぜなら勉強になるから！！！

実務ではライブラリを使うことが多いけれど、実際にどういうロジックで動いて実装されてるのか、はたまた自分が一から考えるならどういう設計にするか等...
誰にも迷惑をかけない個人での勉強としては最高に楽しいです。

なので今回はMarkdownを変換するちょっとしたconverterを作ってみようと思います👍

## 仕様は？
とはいっても仕様次第ではめちゃくちゃ大変な作業になるので、今回は下記3つを要件として仕様を決定します。

1. 対応するHTML要素
    1. `#` -> h1
    1. `##` -> h2
    1. `###` -> h3
    1. `::section ::/section` -> section
    1. `ただの文字` -> p
    1. `改行` -> br
1. HTML要素が入力された場合
    1. 空文字に変換する
1. metadataを入力可能とする
    1. `---`で始まり`---`で終わること
    1. データは`key: 値`とすること -> 例`date: 2024年12月22日`


## さっそく作ってみよう！
でも別にそんな難しいことはしません。
流れとしては
1. 入力されたMarkdownを受け取る
1. 正規表現で検知する
1. HTMLタグにreplaceする
1. 画面に描画する

これを実現するだけです。
簡単ですね！😆


## 入力されたMarkdownを受け取る
ユーザーにMarkdownを入力させるMarkdownWriteSpaceコンポーネントを作成します。
また、今回は状態管理に[Jotai](https://jotai.org/)を使用しています。

ここではシンプルにonChangeが発火するたびに状態管理を更新する処理を記述しています。

```tsx
export function MarkdownWriteSpace() {
  const setMarkdown = useSetAtom(markdownAtom);

  const markdownChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
    setMarkdown(event.target.value);
  };

  return (
    <textarea
      className="md-write-space"
      placeholder="markdownで書いてください"
      autoComplete="off"
      autoCapitalize="off"
      rows={30}
      onChange={markdownChange}
    />
  );
}
```

## 正規表現で検知してreplaceする
入力されたmarkdownを正規表現で検知して、仕様通りにreplaceします！
replaceする時にclassを付けることでスタイルも反映させるようにし、`<` or `>`が入力されたら空文字に変換するようにもしてます。

```ts
function convertMarkdownToHtml(markdown: string) {
  // 空文字（改行対応）
  if (markdown === "") {
    return markdown.replace("", "<br>");
  }

  // h1
  const regexpH1 = /^#(?=\s)/;
  if (markdown.match(regexpH1)) {
    return markdown
      .replace(regexpH1, "<h1  class='md md-h-one'>")
      .replace(/$/, "</h1>")
      .replace(/\s/, "");
  }

  // h2
  const regexpH2 = /^##(?=\s)/;
  if (markdown.match(regexpH2)) {
    return markdown
      .replace(regexpH2, "<h2  class='md md-h-two'>")
      .replace(/$/, "</h2>")
      .replace(/\s/, "");
  }

  // h3
  const regexpH3 = /^###(?=\s)/;
  if (markdown.match(regexpH3)) {
    return markdown
      .replace(regexpH3, "<h3  class='md'>")
      .replace(/$/, "</h3>")
      .replace(/\s/, "");
  }

  // section
  const regexpSectionStart = /^::section/;
  if (markdown.match(regexpSectionStart)) {
    return markdown.replace(regexpSectionStart, "<section  class='md'>");
  }
  const regexpSectionEnd = /^::\/section/;
  if (markdown.match(regexpSectionEnd)) {
    return markdown.replace(regexpSectionEnd, "</section>");
  }

  // htmlタグ検知
  const regexpHtml = /[<>]/g;
  if (markdown.match(regexpHtml)) {
    console.warn("< or > はNGです", markdown);

    return "";
  }

  if (markdown) {
    // p
    return markdown.replace(/^/, "<p  class='md'>").replace(/$/, "</p>");
  }
}

export function contentConvert(mdContent: string) {
  let splited = mdContent.split("\n");
  const html = "";

  for (let i = 0; i < splited.length; i += 1) {
    const content = convertMarkdownToHtml(splited[i]);

    if (content || typeof content === "string") {
      splited[i] = content;
    }
  }

  return html.concat(...splited);
}
```
---
また、metadataの抽出とオブジェクト形式に変換する関数も作成します。

```ts
export function extractMetadata(md: string) {
  const target = "---";
  const splited = md.split("\n");
  const indexOfFirst = splited.indexOf(target);
  const indexOfSecond = splited.indexOf(target, indexOfFirst + 1);

  // metadataが無い場合の対応
  if (indexOfFirst < 0 || indexOfSecond < 0) {
    return;
  }

  const metaData = splited.slice(indexOfFirst + 1, indexOfSecond);

  return metaData;
}

export function convertMetadataToObjectFormat(metadata: string[]) {
  // メタデータをオブジェクト形式に変換する
  let metadataObject: { [key: string]: string | string[] } = {};
  for (let i = 0; i < metadata.length; ++i) {
    const colon = metadata[i].indexOf(":");
    const key = metadata[i].slice(0, colon);
    const value = metadata[i].slice(colon + 1).trim();

    // メタデータに配列が存在した場合、文字列から配列に変換する
    if (value.match(/^(?=.*\[)(?=.*\])/)) {
      const array = value
        .replace(/\[/, "")
        .replace(/\]/, "")
        .replaceAll(",", "")
        .split(" ");
      metadataObject[key] = array;
    } else {
      // 配列以外のデータは文字列で保存
      metadataObject[key] = value;
    }
  }

  return metadataObject;
}

```

---

そしてmetadataとcontentに変換するconverter関数を作成します。

```ts
import { contentConvert } from "./content";
import { convertMetadataToObjectFormat, extractMetadata } from "./metadata";

function getMarkdownContentData(md: string) {
  const target = "---";
  const metaStart = md.indexOf(target);
  const metaEnd = md.indexOf(target, metaStart + 1);

  // metadataがなかった場合の対策で条件分岐する
  const sliceStart = metaStart >= 0 ? metaEnd + target.length : 0;
  const content = md.slice(sliceStart);

  const html = contentConvert(content);

  return html;
}

function getMarkdownMetadata(md: string) {
  const extractedMetadata = extractMetadata(md);
  if (!extractedMetadata) {
    return;
  }

  const convertedMetadata = convertMetadataToObjectFormat(extractedMetadata);
  return convertedMetadata;
}

/**
 * @description
 * マークダウン記法をHTMLに変換する。
 *
 * @summary
 * **対応済み記法一覧**
 * ```
 * metadata
 * ---
 * test: hogehoge
 * ---
 *
 * # => h1
 * ## => h2
 * ### => h3
 *
 * ::section => <section>
 * ::/section => </section>
 *
 * text => p
 *
 * 改行 => br
 * ```
 *
 */
export function markdownConverter(md: string) {
  const html = getMarkdownContentData(md);
  const metadata = getMarkdownMetadata(md);

  return { html, metadata };
}

```

## 画面に描画する
あとは作成したconverterを呼び出すpreviewコンポーネントを作成して完成です！

```tsx
import "./MarkdownPreview.css";
import { useAtomValue } from "jotai";
// atoms
import { markdownAtom } from "../atoms/markdownAtom";
// converter
import { markdownConverter } from "../converter";

export function MarkdownPreview() {
  const markdown = useAtomValue(markdownAtom);

  const { metadata, html } = markdownConverter(markdown);

  return (
    <div className="md-preview-container">
      {metadata && (
        <div className="md-preview-metadata">
          <b>metadata</b>
          <div>{JSON.stringify(metadata, null, 2)}</div>
        </div>
      )}
      <div
        className="md-preview-el"
        dangerouslySetInnerHTML={{ __html: html }}
      />
    </div>
  );
}

```

## 実際に動かしてみた
こんな感じになります！！！
metadataもオブジェクトで抽出できてるし、HTML要素に変換できて且つスタイルも反映されてます！
成功です😎
![スクリーンショット 2024-12-23 0.20.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/344650/202b204e-aebf-52ba-b9e5-8d3bffb24631.png)

## Next.jsと相性がいいかも？
SSRやSSGする際に相性がいいと思います。
そもそもNext.jsでmarkdownをconvertするライブラリが紹介されてますし。
でも自分で作ってしまえばカスタムは自由で、色々遊べるので個人でやる分にはとても楽しいのが車輪の再発明です！

## まとめ

皆んなも車輪の再発明をやろう