Lit Web ComponentsでMarkdownをレンダーする

はいさい、ちゅらデータぬオースティンやいびーん。うちなー や、すぃだく なちょーん どー！

## 概要

今日は、GoogleさんのWeb Componentライブラリ<a href="https://lit.dev/">Lit</a>を使って、Markdownの文字列をHTMLにレンダーする方法を紹介したいと思います。

実は、LitでMarkdownをレンダーするためには、いくつか工夫が必要なので、筆者は苦労したのです。

しかし、Web ComponentsでMarkdownをレンダーできると、Shadow DOMを使ったスタイリングでその部分だけに適応されるCSSを簡単に書けるのでメリットが大きいです。

# 環境構築

今回の環境構築には<a href="https://vitejs.dev/guide/">Vite</a>を使います。

```
yarn create vite
```

実行するとプロジェクト名を聞かれるので`lit-markdown`と書きましょう。

![スクリーンショット 2022-09-21 8.50.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/f3129c7e-f720-ffb7-4333-d4ffb8f0b368.png)

Viteは最近、Litのプロジェクトを新規作成でセットアップしてくれるようになったのです！Litが主流になりつつある証ですぞ！

![スクリーンショット 2022-09-21 8.51.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/637da0a6-451f-c031-4b24-df8f369e90d5.png)

もちろん、TypeScriptも使いましょう。

![スクリーンショット 2022-09-21 8.51.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/ba8c17f7-63ae-bf60-c868-e7fab2839daf.png)

終わったら、以下のコマンドを実行して準備します。

```
cd lit-markdown
yarn install
yarn dev
```

そしたら、Viteが正しく作動していることを確認しましょう。

![スクリーンショット 2022-09-21 8.53.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/cc9feabb-3853-294b-0225-50132b8b642c.png)

# Markedをインストールする

今回のプロジェクトにはLit以外にも、もう一つのnpmパッケージが必要です。`Marked`です。

https://github.com/markedjs/marked

頻繁に更新されており、使っても安全なパッケージだと筆者は考えます。

以下のコマンドを実行してインストールしましょう。

```
yarn add marked
yarn add -D @types/marked
```

これで下ごしらえはOKです！

# LitのDirectivesを使って、MarkdownをレンダーするDirectiveを作る

Markdownの文字列をLitのhtmlテンプレートの中でレンダーするためにカスタムDirectiveを作る必要があります。

### Directivesとは

まず、<a href="https://lit.dev/docs/templates/directives/">公式ドキュメント</a>の説明を引用します。

<blockquote cite="https://lit.dev/docs/templates/directives/">Directives are functions that can extend Lit by customizing the way an expression renders.</blockquote>

日本語に簡単に訳すと以下のような説明になります。

<q>Directives（指令）は、テンプレートがどのようにレンダーされるかを変えることでLitの機能を拡張させる関数です。</q>

つまり、`LitElement`の`render`関数で使われる`html`のテンプレートリテラルの中で使う関数です。

## 既存のDirective : unsafeHTML

今回のプロジェクトに必要な既存Directiveは、`unsafeHTML`です。

Litの`html`テンプレートの中で、HTMLらしき文字列を代入すると、全てがエスケープされてしまう仕様になっております。

実際、上記のViteプロジェクトの`my-element.ts`の`render`関数を以下のようにしてみると、

```typescript:src/my-element.ts
  render() {
    const rawHTMLString = "<h1>Hello World</h1>";

    return html`<div>${rawHTMLString}</div>`;
  }
```

以下のようにレンダーされます。

![スクリーンショット 2022-09-21 10.27.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/b30a258a-21be-e35e-819c-9dcea7d20358.png)

これは、<a href="https://brightsec.com/blog/code-injection-in-javascript-prevention-and-remediation/">コードインジェクションXSS攻撃</a>を防ぐためです。

基本的に、テンプレートエンジンは、出どころのわからないHTML文字列を注入してレンダーすることがセキュリティ上のリスクだという認識で設計されているので、このような安全機能が実装されています。

しかし、今回のMardownレンダーでは、MarkdownをHTML文字列に変えるので、HTML文字列をそれでも注入しないといけないのです。

こういう時は、unsafeHTML（安全じゃないHTML）の機能を使います。

### 注意点 : MarkdownのHTMLをHTMLサニタイズ化する必要がある

HTMLから不安全な要素（`<script>`, `<object>`, `<embed>`, `<link>`など）を除去することをHTMLサニタイズ化と呼びます。

ユーザーから入力されるMarkdownは全て危険でサニタイズ化が必要だと考えましょう。

今回の記事では紹介しませんが、npmパッケージでもできます。

https://www.npmjs.com/package/sanitize-html

ブラウザAPIも開発されているようで待ち遠しい！

https://developer.mozilla.org/en-US/docs/Web/API/HTML_Sanitizer_API

## unsafeHTMLの使い方

さて、`unsafeHTML`を使ってみましょう。

上記の`my-element.ts`で書いた`rawHTMLString`をもう一度レンダーしてみましょう。

```typescript:src/my-element.ts
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";

import { homeStyles } from "./styles";

/**
 * An example element.
 */
@customElement("my-element")
export class MyElement extends LitElement {
  render() {
    const rawHTMLString = "<h1>Hello World</h1>";

    return html`<div>${unsafeHTML(rawHTMLString)}</div>`;
  }

  static styles = [homeStyles];
}
```

こうして使うと、以下のように、ちゃんとHTMLとしてレンダーされます。

![スクリーンショット 2022-09-21 10.32.11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/52e6bf2c-2afb-0d8f-1790-3d6fc8f31abd.png)

これでよしと！

# カスタムDirective

既存Directivesの他に、自作で作れる`カスタムDirective`があります。

既存Directiveにない機能を追加したい時はこちらを使います。

以下のように簡単なDirectiveを作ります。文字列を`<p>`要素で包むものです。

```typescript:src/directives/simple-directive.ts
import { Directive, directive } from "lit/directive.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";

// クラス継承でDirectiveを定義する
class SimpleDirective extends Directive {
  render(text: string) {
    return unsafeHTML(`<p>${text}</p>`);
  }
}

// html``の中で使うDirective関数
export const simpleDirective = directive(SimpleDirective);
```

`my-element.ts`では以下のような使い方をします。

```typescript:src/my-element.ts
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { simpleDirective } from "./directives/simple-directive";

import { homeStyles } from "./styles";

@customElement("my-element")
export class MyElement extends LitElement {
  render() {
    const rawHTMLString = "<h1>Hello World</h1>";

    return html`<div>
      ${unsafeHTML(rawHTMLString)}
      ${simpleDirective("My name is Austin.")}
    </div>`;
  }

  static styles = [homeStyles];
}

declare global {
  interface HTMLElementTagNameMap {
    "my-element": MyElement;
  }
}
```

レンダーされた結果は以下のようになります。

![スクリーンショット 2022-09-21 11.18.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/74bd4769-674a-4cf7-ed7b-536458401495.png)

Directiveの中でも文字列を返すと、HTMLだとエスケープされてしまうことに留意しましょう！

## 非同期Directives

カスタムDirectiveの中にも、非同期処理が必要な場合に使う<a href="https://lit.dev/docs/templates/custom-directives/#async-directives">`AsyncDirective`</a>というDirectiveクラスを継承する必要があります。

今回のMarkdownのレンダー処理は、非同期処理になるので、この`AsyncDirective`を使います。

非同期Directiveの基本的な書き方は以下の通りです。上記の`SimpleDirective`を非同期処理風に書き直してみましょう。

```typescript:src/directive/simple-async-directive
import { directive } from "lit/directive.js";
import { AsyncDirective } from "lit/async-directive.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";

// クラス継承でAsyncDirectiveを定義する
class SimpleAsyncDirective extends AsyncDirective {
  render(promise: Promise<string>) {
    Promise.resolve(promise).then((result) =>
      this.setValue(unsafeHTML(`<p>${result}</p>`))
    );

    return "Loading...";
  }
}

// html``の中で使うDirective関数
export const loadParagragh = directive(
  SimpleAsyncDirective
);

```

`AsyncDirective`で変わったことをいえば、まず、`render`では、`Loading...`という仮の値を返していることと、Promiseの処理が終わった後に、`this.setValue`で非同期処理の結果を使って、レンダーされる値を代入していることです。

一旦、`Loading...`が表示されてから、Promiseが解決され、`<p>Hoge Hoge</p>`がHTMLとして代入される流れです。

実際、`my-element.ts`で使ってみましょう。

```typescript:src/my-element.ts
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { loadParagragh } from "./directives/simple-async-directive";

import { homeStyles } from "./styles";

@customElement("my-element")
export class MyElement extends LitElement {
  render() {
    const rawHTMLString = "<h1>Hello World</h1>";
    const textPromise = new Promise<string>(
      (resolve) =>
        setTimeout(
          () => resolve("My name is Austin."),
          2000
        )
    );

    return html`<div>
      ${unsafeHTML(rawHTMLString)}
      ${loadParagragh(textPromise)}
    </div>`;
  }

  static styles = [homeStyles];
}
```

結果:

![ezgif.com-gif-maker (12).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/3c4c53bf-9577-2a93-b07b-f84f191f6c72.gif)

このように、`AsyncDirective`を使って非同期処理のレンダーを簡単にできます！

# MarkdownをレンダーするDirectiveを作成する

上記の知識を活かして、MarkdownをレンダーするDirectiveを書きましょう！

## Markedの使い方

まずMarkedの使い方を説明します。

今回は、Markedの`parse`関数を使います。`marked.parse`は、引数には、Markdownの文字列と、処理が終わった時のコールバック関数を渡します。

```typescript
import { marked } from "marked";

const markdownRaw = `
# Hello World

My Name is Austin
`;

marked.parse(markdownRaw, (error, result) => {
  console.log(result); // "<h1>Hello world</h1><p>My...
});
```

これをPromise化すれば、以下のような書き方ができます。

```typescript
   new Promise<string>((resolve, reject) => {
      marked.parse(raw, (error, result) => {
        if (error) return reject(error);
        resolve(result);
      });
    })
```

## 非同期Directive化する

次は、`AsyncDirective`クラスを上記のロジックをDirective化します。

```typescript:src/directives/markdown-directive.ts
import { directive } from "lit/directive.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { AsyncDirective } from "lit/async-directive.js";
import { marked } from "marked";

class MarkdownDirective extends AsyncDirective {
  render(rawMarkdown: string) {
    new Promise<string>((resolve, reject) => {
      marked.parse(rawMarkdown, (error, result) => {
        if (error) return reject(error);
        resolve(result);
      });
    }).then((rawHTML) => {
      const renderedUnsafeHTML = unsafeHTML(rawHTML);
      this.setValue(renderedUnsafeHTML);
    });

    return "Loading...";
  }
}

export const resolveMarkdown = directive(MarkdownDirective);

```

本来だったら、こちらの`rawHTML`はまたサニタイズ化するべきところなのですが、本投稿ではしません。

これでMarkdownの文字列をレンダーするDirectiveができたので、`my-element.ts`で使ってみましょう！

# \<textarea>のMarkdownをレンダーする

最後に、`<textarea>`の入力を上記の`MarkdownDirective`を使ってレンダーするようにします。

## Litのバグ - \<textarea>にEventListenerを付けられない

バグなのか、僕の使い方が間違っているのかわかりませんが、通常の`html <textarea @input=${() => console.log("callback")}></textarea>`の書き方では、`input`の`EventListener`を追加することがどうもできないようです。

解決策として、LitElementのライフサイクルの`firstUpdated`のコールバックで手動で`addEventListener`を実行することができます。

LitElementのライフサイクルについては、下記のリンクで確認していただけます。

https://lit.dev/docs/components/lifecycle/#firstupdated


```typescript:src/my-element.ts
import { LitElement, html, PropertyValueMap } from "lit";
import { customElement, query } from "lit/decorators.js";

import { homeStyles } from "./styles";

@customElement("my-element")
export class MyElement extends LitElement {
  @query("textarea")
  private textarea!: HTMLTextAreaElement;

  firstUpdated(
    _changedProperties: PropertyValueMap<unknown> | Map<PropertyKey, unknown>
  ) {
    super.firstUpdated(_changedProperties);
    this.textarea.addEventListener("input", this.handleTextareaInput);
  }

  private handleTextareaInput: EventListener = () => {
    console.log("input");
  };

  render() {
    return html`<textarea name="markdown" id="markdown"></textarea>`;
  }

  static styles = [homeStyles];
}
```

## \<textarea>の入力をレンダーする

ここで魔法が始まります。ここまで培ったものが実るのです。

まず、Litの`state`装飾関数を使い、`raw`という文字列のクラス変数を追加します。そして上記で付けた`handleTextareaInput`の`EventListener`では、`<textarea>`の中身を`this.raw`に保存させます。

最後に、`<article>`要素の中に`resolveMarkdown`のdirective関数を入れて、引数に`this.raw`を渡します。

```typescript:src/my-element.ts
import { LitElement, html, PropertyValueMap } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { resolveMarkdown } from "./directives/markdown-directive";

import { homeStyles } from "./styles";

@customElement("my-element")
export class MyElement extends LitElement {
  @query("textarea")
  private textarea!: HTMLTextAreaElement;
  @state()
  private raw = "";

  firstUpdated(
    _changedProperties: PropertyValueMap<unknown> | Map<PropertyKey, unknown>
  ) {
    super.firstUpdated(_changedProperties);
    this.textarea.addEventListener("input", this.handleTextareaInput);
  }

  private handleTextareaInput: EventListener = () => {
    const { value } = this.textarea;
    if (!value) return;
    this.raw = value.trim();
  };

  render() {
    return html`<label for="markdown">Input</label
      ><textarea name="markdown" id="markdown"></textarea>
      <p>Output</p>
      <article>${resolveMarkdown(this.raw)}</article>`;
  }

  static styles = [homeStyles];
}
```

## 結果

綺麗にレンダーできています！

![ezgif.com-gif-maker (13).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2344956/9b166d07-e247-81dd-2827-18e7e2971678.gif)

# まとめ

ここまで、LitのWeb Componentで綺麗にMarkdownをレンダーする方法を紹介してきましたが、いかがでしょうか？

Markdown入力を可能にすると、一気にユーザーの表現力を高めることができます。

その反面、レンダーするロジックも必要です。

また、レンダーすることによるパフォーマンスの問題もありますので、こういうプレビューなら問題ないでしょうが、大きなブログだと、SSR（サーバーでレンダーしておく)ことも良いかと思います。

本投稿で、Litの力もまた実感していただけたら嬉しいなと思います。