kanachi-blog

notionでの公開記事をastro-notion-blogを使って公開するよ

slackのmkdwn記法をhtmlに変換するフォーマッター

ソースコード
import { Space, Text, useMantineTheme } from '@mantine/core'
import parse from 'html-react-parser'
import React from 'react'

interface SlackMarkdownToHtmlProps {
  markdown: string
  keyword?: string
}

// リスト項目のタイプを判断する正規表現
const listTypesRegex = {
  ordered: /^\s*([a-z]|\d+)\.\s+(.*)/,
  unordered: /^\s*(•|◦|▪︎)\s+(.*)/,
}
// インデントのレベルを計算するための関数
function getIndentLevel(line: any) {
  const leadingWhitespace = line.match(/^(\s*)/)[0]
  return leadingWhitespace.length / 4
}

function parseList(inputString: string) {
  const lines = inputString.split('\n')
  let previousIndent = 0
  let listTypeStack = []
  let previousListType = ''
  let parsedHTML = ''

  for (let line of lines) {
    const indent = getIndentLevel(line)
    const orderedListMatch = line.match(listTypesRegex.ordered)
    const unorderedListMatch = line.match(listTypesRegex.unordered)
    let itemContent, listType

    //今回の行のindentまでタグを閉じる
    while (indent < previousIndent) {
      parsedHTML += `</li></${listTypeStack.pop()}>`
      previousIndent--
    }

    //lineの行が順序つきリストか順序なしリストかそれ以外かを判定
    if (orderedListMatch) {
      listType = 'ol'
      itemContent = orderedListMatch[2]
    } else if (unorderedListMatch) {
      listType = 'ul'
      itemContent = unorderedListMatch[2]
    } else {
      //タグを全て閉じる
      while (listTypeStack.length) {
        parsedHTML += `</li></${listTypeStack.pop()}>`
        previousIndent--
      }
      parsedHTML += parsedHTML ? `\n${line}` : `${line}`
      continue
    }

    //既存のリストにネストされる場合
    //同じ種類のリストを検知
    if (previousListType === listType && indent === previousIndent) {
      parsedHTML += `<li>${itemContent}`
      previousListType = listType
      continue
    } //違う種類のリストを検知
    else if (previousListType != listType && indent === previousIndent) {
      while (listTypeStack.length) {
        parsedHTML += `</li></${listTypeStack.pop()}>`
        previousIndent--
      }
    }
    //新しいリストか既存のネストのリストになる場合
    parsedHTML += `<${listType}>`
    listTypeStack.push(listType)
    previousIndent = indent
    parsedHTML += `<li>${itemContent}`
    previousListType = listType
  }
  return parsedHTML
}

export const SlackMarkdownToHtml: React.FC<SlackMarkdownToHtmlProps> = ({ markdown, keyword = '' }) => {
  const { colors } = useMantineTheme()

  // User mention
  let html = markdown.replace(/\n?<@(.*?)>/g, '@[ユーザ名]')

  // Channel name
  const channelIdNameList = html.match(/<#.+?\|.*?>/g)
  if (channelIdNameList !== null && channelIdNameList.length > 0) {
    for (const channelIdName of channelIdNameList) {
      const channelName = channelIdName.match(/<#.+\|(.*)>/)
      if (channelName == null) continue
      if (channelName[1] === '') {
        html = html.replace(channelIdName, '#[チャンネル名]')
      } else {
        html = html.replace(channelIdName, `#${channelName[1]}`)
      }
    }
  }

  // Default emoji
  html = html.replace(/\n?:(.*?):/g, '<em-emoji id="$1" set="twitter" size="1em"></em-emoji>')

  // Link with text
  html = html.replace(/<(.+?)%7C(.+?)>/g, '<a href="$1" style="color:#3575ad; word-wrap: break-word;">$2</a>')

  // Plain URL to link
  html = html.replace(
    /(?<!<a href=")<?(http[s]?:\/\/[^<>\s]+)(?!">)>?/g,
    '<a href="$1" style="color:#3575ad; word-wrap: break-word;">$1</a>',
  )

  // Bold
  html = html.replace(/\*(.+?)\*/g, '<b>$1</b>')

  // Italic
  html = html.replace(/\_(.+?)\_/g, '<i>$1</i>')

  // Strikethrough
  html = html.replace(/\~(.+?)\~/g, '<s>$1</s>')

  //list
  html = parseList(html)

  // Quote
  html = html.replace(
    /\n?&gt; (.+)\n?/gm,
    '<blockquote style="padding: 0 1em; border-left: 4px solid #d6dde3;color: #333333;  overflow: scroll;">$1</blockquote>',
  )

  // Code block
  html = html.replace(
    /\n?```(.*?)```\n?/gs,
    '<pre style="background-color:#eee;border-radius: 3px; overflow: scroll; "><code style="display: inline-block;font-family: courier, monospace;padding: 0 3px; font-size:12px;">$1</code></pre>',
  )

  // Inline code
  html = html.replace(
    /\n?\`(.*?)\`\n?/g,
    '<code style="background-color:#eee;border-radius: 3px;font-family: courier, monospace;padding: 0 3px; color: #E84D7E; font-size:12px;  overflow: scroll;">$1</code>',
  )

  // const regex = new RegExp(keyword, 'gi')
  // html = html.replace(regex, `<span style="background-color: ${colors.mainSecondary[0]};">$&</span>`)

  // New line
  html = html.replace(/\n/g, '<br />')

  // if (keyword) {
  //   const regex = new RegExp(keyword, 'gi')
  //   html = html.replace(regex, `<span style="background-color: ${colors.mainSecondary[0]};">$&</span>`)
  // }
  return (
    <Text fz='sm'>
      {parse(html)}
      <Space h={10} />
    </Text>
  )
}
入力例
markdown : string = 'ボールド\n_イタリック_\n~取り消し戦~\n~ボールドイタリック取り消し戦~\nhttps://github.dena.jp/eng-training/23-b1-aripos|リンク\nhttps://github.dena.jp/eng-training/23-b1-aripos\n1. 順番リスト1\n a. 入れ子\n i. 入れ子2\n 1. 入れ子\n2. 順番リスト2\n• リスト\n ◦ 入れ子\n ▪︎ 入れ子2\n • 入れ子3\n> 引用区\nhogehoge\nこれはコードブロックです。\\nやあやあやあ \n:manabi_kb: :kami2:\n<@U0505FYFXJ9>'
フロントでの表示