【Astro】マークダウン製のサイトにHTMLプレビューを埋め込むremarkプラグインを実装する

マークダウンファイルでコンテンツ管理する静的サイトを作る時、コードブロックとは別にHTMLプレビューを埋め込みたい時があるかと思います。

[MDN](https://developer.mozilla.org/ja/)の記事冒頭によくあるプレビューのようなイメージです。

![スクリーンショット 2024-12-07 11.47.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3953051/bed6736b-2641-5739-eebd-db0d2ea62278.png)

[CodePen](https://codepen.io/)などのサービスを使う手もありますが記事との二重管理になってしまうため、できるだけそれは避けたいです。

そこで今回はこのプレビュー機能をAstroで実現すべく、remarkプラグインを実装してみます。

```md
```html preview
<div>...</div>

<style>...</style>
<script>...</script>
``````

目指すものとして、上記のように言語を`html`、コードブロックに`preview`というメタデータを付与した際にプレビューとして描画させるようにします。


## 1. unist-util-visitをインストール
```zsh
npm i unist-util-visit
```
`unist`のユーティリティモジュール[unist-util-visit](https://github.com/syntax-tree/unist-util-visit)をインストールします。

```typescript
import {fromMarkdown} from 'mdast-util-from-markdown'
import {visit} from 'unist-util-visit'

const tree = fromMarkdown('Some *emphasis*, **strong**, and `code`.')

visit(tree, 'text', function (node, index, parent) {
  console.log([node.value, parent ? parent.type : index])
})
```

上記は[公式README](https://github.com/syntax-tree/unist-util-visit)の例ですが、提供される`visit`を呼び出すことでhastをwalkすることができます。

## 2. コードブロック → `<iframe>`に変換
```typescript:remark-html-preview.ts
import type { Root } from 'mdast';
import { visit } from 'unist-util-visit';

export default function remarkHtmlPreview() {
  return (tree: Root) => {
    visit(tree, 'code', (node) => {
      if (node.lang !== 'html' || node.meta !== 'preview') return;

      node.data = {
        hName: 'iframe',
        hProperties: {
          title: 'HTMLのプレビュー',
          class: '',
          srcdoc: node.value,
        },
      };
    });
  };
}
```

`unist-util-visit`の`visit`を使うことで対象の要素に絞ってwalkすることができます。

コードブロックかつ`html preview`の時にのみ、`<iframe>`を挿入。`srcdoc`を指定することでコードをそのまま埋め込みます。


```diff_typescript:remark-html-preview.ts
import type { Root } from 'mdast';
import { visit } from 'unist-util-visit';

export default function remarkHtmlPreview() {
  return (tree: Root) => {
    visit(tree, 'code', (node) => {
      if (node.lang !== 'html' || node.meta !== 'preview') return;

      node.data = {
        hName: 'iframe',
        hProperties: {
           title: 'HTMLのプレビュー',
           class: '',
           srcdoc: node.value,
+          loading: 'lazy',
+          onload: "this.style.height=this.contentWindow.document.body.scrollHeight+16+'px'",
        },
      };
    });
  };
}
```

適宜プロパティを追加します。今回は遅延読み込みとコンテンツに応じた高さの調整を加えています。

## 3. Astroの設定ファイルに追記
```diff_typescript:astro.config.ts
import { defineConfig } from 'astro/config';
import remarkHtmlPreview from './remark-html-preview.ts';

export default defineConfig({
+  markdown: {
+    remarkPlugins: [remarkHtmlPreview],
+  },
});
```

実装した関数を`markdown.remarkPlugins`に指定します。

## 4. スタイリング
```html:index.astro
<style>
  iframe {
    width: 100%;
    background-color: white;
    box-sizing: border-box;
    border: none;
  }
</style>
```

`<iframe>`のデフォルトスタイルを打ち消したり、適宜調整します。これで実装完了です。

```md
```html preview
<div>
  <span></span>
  <span></span>
  <span></span>
</div>

<style>
  div {
    display: flex;
  }
  
  span {
    width: 80px;
    height: 80px;
    
    &:nth-of-type(1) {
      background-color: red;
    }

    &:nth-of-type(2) {
      background-color: green;
    }

    &:nth-of-type(3) {
      background-color: blue;
    }
  }
</style>
```
試しに上記のようにコードブロックを書いてみます。

![スクリーンショット 2024-12-07 12.36.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3953051/f9290cb2-051e-f61a-5384-3fc73958ceb5.png)

すると`<iframe>`が描画され対象のHTMLが埋め込まれました！

これで開発環境上ですぐに確認できるだけでなく管理も楽になりますね。