Slack で真の Markdown を使う

この記事は [Speee Advent Calendar 2019](https://qiita.com/advent-calendar/2019/speee) 11日目の記事です。
Slack で "真の" Markdown を使う方法について解説します。

## 前段

### あんた誰？

**[jsx-slack]** という JavaScript 用オープンソースライブラリを開発している人です。

- **開発の取り組みに関する紹介**
  - 入門編: **[Slack Block Kit のメッセージをよりメンテナブルにする jsx-slack のご紹介](https://tech.speee.jp/entry/2019/04/23/094015)**
  - 実践編: **[実践 jsx-slack: jsx-slack + Bolt で Slack のモーダルを自在に操ろう](https://tech.speee.jp/entry/2019/10/16/100022)**

#### [jsx-slack] の概説

[jsx-slack] は、 Slack が提供する UI フレームワーク **[Block Kit]** を、 JSX を使い React ライクに記述することができる JavaScript ライブラリです。

[<img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yuki-hattori/20190422/20190422104901.png" alt="@speee-js/jsx-slack" width="400" />][jsx-slack]

:warning: この記事でも jsx-slack を使用した事例を紹介しますが、**一般的な使い方とは異なる使い方** なので、応用編的な扱いです。jsx-slack の普通の使い方については、**[入門編](https://tech.speee.jp/entry/2019/04/23/094015)** から順にご覧ください。

[jsx-slack]: https://github.com/speee/jsx-slack
[block kit]: https://api.slack.com/block-kit

## "真の" Markdown？

「Slack って Markdown 記法でしょ？」という認識の人もそれなりにいると思いますので、さらっと解説します。

### Slack は Markdown 不採用

『Slack の Markdown 記法』という記述は、最近になって国内外問わず多く見受けられるようになりましたが、 *厳密に言えばこの表記は正しくありません。*

それどころか、Slack の方針として 「Markdown 記法のニーズは一部のユーザのみ」 ということで **『Markdown 記法は対応していない』** と明記しています。これは昔から一貫している方針です。

> Slack メッセージの書式設定は、大多数のユーザーのニーズに対応するよう設計されています。マークダウン記法を使用されるユーザーは少数のため、対応は行っていません。
>
> *— [メッセージの書式設定 | Slack](https://web.archive.org/web/20191121103150/https://slack.com/intl/ja-jp/help/articles/202288908) (2019-11-21 時点)*

実際の Markdown 記法と Slack で採用されている記法との差異については、先行する Qiita 記事 *([Slack のメッセージ記法と Markdown を比較してみる](https://qiita.com/tk0miya/items/63ae78cede0eb57f6d07))* が詳しいですが、一般的な Markdown 記法と使用キャラクタが類似しているので、「Markdown 記法だ」と誤認されるのも無理はないのかもしれません。

当該記事ではエンドユーザー向けの記法のみ紹介されていますが、 [Slack API のみで使用できる追加のシンタックス](https://api.slack.com/messaging/composing/formatting#advanced) もあります（ハイパーリンク、チャンネルリンク、メンション、時刻のローカライズ）。

### 正式名称

Slack で使われるフォーマットの正式名称は **"[mrkdwn](https://api.slack.com/messaging/composing/formatting#basics)"** です。ちょうど Markdown から母音を抜いた表記で、無理矢理日本語に訳せば *『マクダン記法』* といったところでしょうか。

> The `mrkdwn` attribute is missing vowels because our markup language is not quite markdown but something quite like it.
>
> *— [Formatting messages | Slack](https://api.slack.com/docs/message-formatting#disabling_markup_processing)*

Slack API を使ったことがある開発者なら、度々 "mrkdwn" 表記を見ることと思います。

### なぜ真の Markdown を使いたいのか

mrkdwn ではなく、わざわざマイノリティなニーズである Markdown を使いたいユースケースとしては、以下が挙げられます。

- Markdown を使用した外部サービスと連携する Slack アプリで、Markdown の内容を表示したい
- Slack 以外にもメッセージの利用用途があるアプリで、メッセージのポータビリティを確保したい（例: Web ページでも同内容を表示したい）
- 仕様がブラックボックスではないフォーマットを使いたい (例: [CommonMark](https://commonmark.org/)) [^blackbox]

[^blackbox]: そうはいっても、仮に CommonMark 準拠でもさらに細かくフレーバーがあるケースも多いので、詳細な仕様を突き詰めるとやはりブラックボックスになったりすることもありますが…

こうして見ると、エンドユーザー向けのニーズというよりは、開発者向けの絞られたニーズという感じがしますね。Markdown に対応しない理由もある程度納得できます。

## Markdown を mrkdwn に変換する

さて、実際に "真の" Markdown を使わなければならないケースにぶち当たったら、どうすれば良いでしょうか？

Markdown と mrkdwn は似ているように見えて実はかなり差異が多いため、そのまま Slack で使うと間違いなく表記が崩れます（例: Markdown の斜体が mrkdwn で太字になる、など）。

### 変換ライブラリ (JavaScript)

幸い、JavaScript の場合、Markdown を mrkdwn に変換するライブラリが npm にいくつか存在します。

#### [slackify-markdown]

[remark](https://github.com/remarkjs/remark) でパースした Markdown を、Slack の mrkdwn に変換してくれる JavaScript ライブラリです。「関数に Markdown を渡すだけ」と、使い方が非常にシンプルなのが最大のウリです。

[slackify-markdown]: https://github.com/jsarafajr/slackify-markdown

#### [html-to-mrkdwn]

こちらは HTML を mrkdwn に変換するライブラリです。GitHub が開発しており、[GitHub の Slack アプリ](https://slack.github.com/) でも Issue や PR の Markdown を展開するのに使われています。

HTML から変換するので、「Markdown パーサーを開発者側で自由に選択できる」というメリットがあります。[GFM (GitHub Flavored Markdown) の Task list](https://github.github.com/gfm/#task-list-items-extension-) にも対応しているのが GitHub 製らしいですね。

[html-to-mrkdwn]: https://github.com/integrations/html-to-mrkdwn

### レンダリングの比較

試しに、こんな感じの Markdown をそれぞれのライブラリを通して Slack に表示させてみましょう。

    # Heading 1
    ## Heading 2
    ### Heading 3
    #### Heading 4
    ##### Heading 5
    ###### Heading 6

    Hello, ~~Markdown~~ **mrkdwn**!

    `mrkdwn` is text formatting markup style in [Slack](https://slack.com/).

    ---

    - First
    - Second
      - Sub item 1
      - Sub item 2
        - A
        - B
      - C
    - Third
      1. Ordered list 1
      1. Ordered list 2
      1. Ordered list 3

    > *This is blockquote.*

    ```
    console.log('Hello, mrkdwn!')
    ```

参考までに GitHub で表示させると、以下のような見た目になります。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70467/ed28adba-3aa2-d200-3fa1-e51ae2ce75a4.png)

#### [slackify-markdown]

    *Heading 1*

    *Heading 2*

    *Heading 3*

    *Heading 4*

    *Heading 5*

    *Heading 6*

    Hello, ​~Markdown~​ ​*mrkdwn*​!

    `mrkdwn` is text formatting markup style in <https://slack.com/|Slack>.

    * * *

    •   First
    •   Second
        •   Sub item 1
        •   Sub item 2
        •   A
        •   B
        •   C
    •   Third
        1.  Ordered list 1
        2.  Ordered list 2
        3.  Ordered list 3

    > ​_This is blockquote._​

    ```
    console.log('Hello, mrkdwn!')
    ```

![slackify-markdown](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70467/33282789-9c1d-d818-e204-356688d446bc.png)

mrkdwn で表現できる基本的な要素は、概ね再現できています。

- **slackify-markdown 由来の特徴**
    - ヘッダーはすべて単純な太字 (mrkdwn では文字の大きさを変えられないため)
    - 実は `*` `_` `~` などのキャラクタの横に **ゼロ幅スペース (`0x200b`) が挟まっている** [^zero-width-space]
        - mrkdwn のフォーマットは単語境界でしか効かないため
        - 表示の再現性が高いが、コピペする時には余計な文字に注意する必要がある

* **remark 由来の特徴**
    * 水平線は `* * *` に変換される ([remark-stringify](https://github.com/remarkjs/remark/tree/master/packages/remark-stringify) のデフォルト)
    * リストのレベルインデントが2スペースの場合、3段階目のリストのレベルが認識されない (4スペースだと OK)

[^zero-width-space]: ちなみに拙作の [jsx-slack] でも、同じゼロ幅スペースを使った対策をするモードを [`JSXSlack.exactMode(true)`](https://github.com/speee/jsx-slack/blob/master/docs/about-escape-and-exact-mode.md#exact-mode) を事前に呼び出すことで使用できます。

#### [html-to-mrkdwn]

html-to-mrkdwn は、先に Markdown を HTML に変換する必要があります。変換には slackify-markdown と同様 remark (CommonMark モード) を使用しました。

```javascript:HTML→Markdown→mrkdwn変換コード
const unified = require('unified')
const remarkParse = require('remark-parse')
const html = require('remark-html')
const htmlToMrkdwn = require('html-to-mrkdwn')

const markdown = /* サンプル Markdown の内容 */

console.log(
  htmlToMrkdwn(
    unified()
      .use(remarkParse, { commonmark: true })
      .use(html)
      .processSync(markdown)
      .toString()
  ).text
)
```

    *Heading 1*
    *Heading 2*
    *Heading 3*
    *Heading 4*
    *Heading 5*
    *Heading 6*

    Hello, ~Markdown~ *mrkdwn*!

    `mrkdwn` is text formatting markup style in <https://slack.com/|Slack>.

    * * *

    • First
    • Second
        • Sub item 1
        • Sub item 2
            • A
            • B
        • C
    • Third
        1. Ordered list 1
        2. Ordered list 2
        3. Ordered list 3

    > _This is blockquote._

    ```
    console.log('Hello, mrkdwn!')

    ```

![html-to-mrkdwn](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70467/3fd31308-ef01-7d6e-2efc-2d851974adf1.png)

結果は概ね同じなのですが、こちらはリストのインデントを再現できている代わりに、ヘッダーの間に空行が無かったり、コードブロックに余計な空行が入っているなど、それぞれに一長一短な部分があります。

html-to-mrkdwn はこれ以外にも、Slack の [*(Outdated な)* Attachments](https://api.slack.com/docs/message-attachments) 向けのオブジェクトを返し、HTML の中で最初に出てきた画像の URL を抽出する機能などを備えています。

## Markdown を Block Kit に変換する

さて、mrkdwn への変換はできましたが、より一歩進めて、**[Block Kit](https://api.slack.com/block-kit)** に対応する術を考えてみましょう。Block Kit は Slack が提供する、メッセージ他で使用できる UI フレームワークで、より表現力の高いメッセージを作成することを可能にします。 *(参考: [Slack API 新機能の Block Kit を使ってより情報的なレストラン検索コマンドを作ろう](https://qiita.com/girlie_mac/items/2254183edf9277455c51))*

Block Kit に変換すれば、よりオリジナルのレンダリング結果に近い Markdown を Slack でも表現できるはずです。（例: 文中に差し込まれた画像を表示するなど）

### Markdown → jsx-slack → Block Kit

ここで出てくるのが、拙作の **[jsx-slack]** です。

これは本来、JSX を使って React 的に Block Kit の JSON を生成するためのライブラリで、元の用途からは外れています。しかし、以下 2 点の特徴を踏まえると、Markdown の変換にも使えるのでは？と考えました。

- **HTML 要素を使ったフォーマットができる**

jsx-slack は、「既存の知識を使ってテンプレートを書けるようにする」ことが目的でもあるため、mrkdwn の代わりに (React や Vue と同じように) [**HTML 要素を使ってメッセージをフォーマット** することもできます](https://github.com/speee/jsx-slack/blob/master/docs/html-like-formatting.md)。

一部のプリミティブなタグのみですが、対応している HTML を JSX で定義してあげると、それに応じた mrkdwn が出力されます。

```jsx
import JSXSlack, { Blocks } from '@speee-js/jsx-slack'

console.log(
  JSXSlack(
    <Blocks>
      <section>
        <p><b>Hello, world!</b></p>
        <p>
          foobar<br />
          <i><a href="https://example.com/">Go to website</a></i>
        </p>
      </section>
    </Blocks>
  )
)
```

```mrkdwn:&lt;section&gt;&nbsp;の出力結果
*Hello, world!*

foobar
_<https://example.com/|Go to website>_
```

つまり、HTML (を含む JSX) → mrkdwn 変換のためのツールとしても使用可能ということになります。

- **文字列表現の JSX から Block Kit JSON に変換できる**

jsx-slack は、テンプレートリテラルタグを使った文字列によるテンプレートから Block Kit JSON を生成できる機能があります。

```javascript
import { jsxslack } from '@speee-js/jsx-slack'

console.log(jsxslack`
  <Blocks>
    <section>
      <p><b>Hello, world!</b></p>
      <p>
        foobar<br />
        <i><a href="https://example.com/">Go to website</a></i>
      </p>
    </section>
  </Blocks>
`)
```

これは [JSX トランスパイルのセットアップが面倒な人のために用意した機能](https://github.com/speee/jsx-slack/issues/6) ですが、**「文字列表現の JSX をランタイムで直接パースできる」**という思わぬ嬉しい副作用をもたらしました。jsx-slack のデモとして用意している [**オンライン REPL** (https://speee-jsx-slack.netlify.com/)](https://speee-jsx-slack.netlify.com/) は、実際にこの恩恵を受けています。

この2つの機能を踏まえると、**『Markdown パーサーが jsx-slack 向けの出力をアウトプットしてくれれば、テンプレートリテラルタグを通して Markdown → Block Kit 変換ができる』** ということになります。

```javascript
import { jsxslack } from '@speee-js/jsx-slack'

// Markdown を jsx-slack でパースできる形に変換
const jsx = renderMarkdown('# Hello, world!')

// 変換結果を jsx-slack のテンプレートリテラルタグに渡す
console.log(jsxslack([jsx]))
```

※ `jsxslack` をタグとして使っていないのでトリッキーですが、 `` jsxslack`abc` `` は `jsxslack(['abc'])` と同義です。

### デモ

論より証拠、実際に Markdown → Block Kit JSON の変換を行うデモを [**https://markdown-to-slack-block-kit.now.sh/** *(Markdown to Slack converter)*](https://markdown-to-slack-block-kit.now.sh/) に用意しました。このデモでは、Markdown を喰わせると、GitHub の Markdown レンダリングに近い見た目を持つ Block Kit JSON を出力します。

[<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70467/197f6dad-5d6b-a284-7a95-1e8dedab5bf1.png" width="500" />](https://markdown-to-slack-block-kit.now.sh/)

右下のボタンから [Block Kit Builder](https://api.slack.com/tools/block-kit-builder) に JSON を転送して、メッセージの Slack 上でのレンダリングを確認することもできます。

#### レンダリング例

先の mrkdwn の例で挙げたサンプル Markdown のレンダリングは以下のようになります。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70467/5301be12-1815-f33c-6d00-a6bd04f283b4.png)

水平線がレンダリングされるようになったほか、`<h1>` `<h2>` が GitHub 同様の水平線付きに変換されました。`<h6>` も灰色の小さなフォントになってるほか、リストの頭のマーカーもレベルによって異なるものになっていることが分かります。

### コード

[**CodeSandbox** (https://codesandbox.io/s/markdown-to-slack-converter-iphgn)](https://codesandbox.io/s/markdown-to-slack-converter-iphgn?fontsize=12&hidenavigation=1&module=%2Fsrc%2Fmarkdown.js&theme=dark) にて、このデモのコードを公開しています。

[![Edit Markdown to Slack converter](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/markdown-to-slack-converter-iphgn?fontsize=12&hidenavigation=1&module=%2Fsrc%2Fmarkdown.js&theme=dark)

#### 解説

`markdown.js` が Markdown → jsx-slack 用 JSX の出力を担う部分です。このデモでは、カスタマイズが容易な [markdown-it](https://github.com/markdown-it/markdown-it) をパーサーに使用した CommonMark に基づく変換を行っています。

```javascript
import MarkdownIt from 'markdown-it'

const md = new MarkdownIt('commonmark', {
  breaks: true,
  html: false,
  xhtmlOut: true,
  linkify: true,
})
```

jsx-slack は全ての HTML に対応しているわけではないので、任意の HTML は `html: false` で使用できないようにしておきます。。

##### ヘッダー

通常 Markdown におけるヘッダーは `<h1>` ～ `<h6>` に変換されますが、このデモではその変換をオーバーライドし、**`<Section><b>xxxxx</b></Section>`** というタグに変換されるようにしています。

`<Section>` は Block Kit における [**Section ブロック**](https://api.slack.com/reference/block-kit/blocks#section) であり、ヘッダーのテキストを表現する部分が1つのブロックになるように変換しています。

```javascript
const blockHeadingOpen = (tokens, idx) => {
  const token = tokens[idx]
  return `<${token.tag === 'h6' ? 'Context' : 'Section'}><b>`
}

const blockHeadingClose = (tokens, idx) => {
  const token = tokens[idx]
  const close = `</b></${token.tag === 'h6' ? 'Context' : 'Section'}>`

  // GitHub style headings
  if (token.tag === 'h1' || token.tag === 'h2') return `${close}<Divider />`

  return close
}

md.renderer.rules.heading_open = blockHeadingOpen
md.renderer.rules.heading_close = blockHeadingClose
```

`<h1>` `<h2>` は、閉じタグのレンダリング時に `<Divider />` による水平線 ([**Divider ブロック**](https://api.slack.com/reference/block-kit/blocks#divider)) を追加することで、GitHub スタイルを再現します。また、`<h6>` の灰色・小さなフォントは、`<Section>` の代わりに [**Context ブロック**](https://api.slack.com/reference/block-kit/blocks#context) (`<Context>`) を使用することで実現しています。

##### 水平線

水平線はもうそのまま `<Divider />` に変換します。読んで字の如く。

```javascript
md.renderer.rules.hr = () => '<Divider />'
```

##### 文中の画像

Block Kit の [**Image ブロック**](https://api.slack.com/reference/block-kit/blocks#image) を使えば、ブロック間に画像を入れることが可能です。jsx-slack では `<Image>` を使用します。

通常の Markdown の画像は、ブロック要素ではなくインライン要素なので、`foo![](xxx.jpg)bar` などのように、文字の間に突然挟まれたりすると Block Kit 的には厄介です。そのため、[markdown-it-block-image](https://github.com/rotorz/markdown-it-block-image) プラグインを使い、ブロックとして使われている画像のみレンダリングするように設定します。

```javascript
// Image (block level only)
md.use(markdownItBlockImage, {
  outputContainer: 'div',
  containerClassName: null,
})

md.renderer.rules.image = () => ''

md.renderer.rules['block-image_open'] = () => {
  md.renderer.rules.image = (tokens, idx, options, env, slf) => {
    const token = tokens[idx]
    const { renderToken } = slf

    token.tag = 'Image'

    try {
      slf.renderToken = (tokens, idx, opts) => {
        const token = tokens[idx]
        token.attrSet(
          'alt',
          token.attrGet('alt') || token.attrGet('title') || 'Image'
        )

        return renderToken.call(slf, tokens, idx, opts)
      }

      return image(tokens, idx, options, env, slf)
    } finally {
      slf.renderToken = renderToken
    }
  }
  return ''
}

md.renderer.rules['block-image_close'] = () => {
  md.renderer.rules.image = () => ''
  return ''
}
```

「画像ブロックを認識したら、動的に画像レンダラーを変える」という結構アグレッシブな方法ですが、ちゃんと動いてくれます。

例えば、以下は jsx-slack のドキュメントから抜粋した、画像を使った Markdown の例です。

```markdown
# jsx-slack

Build JSON object for [Slack] [Block Kit] surfaces from readable [JSX].

![](https://raw.githubusercontent.com/speee/jsx-slack/master/docs/jsx.png)

![](https://raw.githubusercontent.com/speee/jsx-slack/master/docs/slack-notification.png)

​[slack]: https://slack.com
​[jsx]: https://reactjs.org/docs/introducing-jsx.html
​[block kit]: https://api.slack.com/block-kit
```

変換した JSON は以下。

```json
[
  {
    "type": "section",
    "text": {
      "text": "*jsx-slack*",
      "type": "mrkdwn",
      "verbatim": true
    }
  },
  {
    "type": "divider"
  },
  {
    "type": "section",
    "text": {
      "text": "Build JSON object for <https://slack.com|Slack> <https://api.slack.com/block-kit|Block Kit> surfaces from readable <https://reactjs.org/docs/introducing-jsx.html|JSX>.",
      "type": "mrkdwn",
      "verbatim": true
    }
  },
  {
    "type": "image",
    "alt_text": "Image",
    "image_url": "https://raw.githubusercontent.com/speee/jsx-slack/master/docs/jsx.png"
  },
  {
    "type": "image",
    "alt_text": "Image",
    "image_url": "https://raw.githubusercontent.com/speee/jsx-slack/master/docs/slack-notification.png"
  }
]
```

Slack で表示すると、以下のような感じに表示されます（1メッセージの中で複数の画像を扱うことができます）。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70467/765d4f7d-c521-f9cf-afeb-72fcf7b1638e.png)

## おわりに

Slack で "真の" Markdown を Block Kit でレンダリングするために、[jsx-slack] を応用した事例をご紹介しました。
このデモは元をたどると、社内のアプリでの利用と、拙作の jsx-slack の活用例の模索を目的に実験していたものです。

ひょっとしたら slackify-markdown などと同じように簡単に使えるライブラリの形にするかもしれませんが、需要があるか微妙なので、万が一声が集まったら検討してみたいと思います。 🔈

### :warning: 注意

jsx-slack の *"本来の"* 使い方は以下をご覧ください :sweat_smile: 

- 入門編: **[Slack Block Kit のメッセージをよりメンテナブルにする jsx-slack のご紹介](https://tech.speee.jp/entry/2019/04/23/094015)**
- 実践編: **[実践 jsx-slack: jsx-slack + Bolt で Slack のモーダルを自在に操ろう](https://tech.speee.jp/entry/2019/10/16/100022)**