かめ。ブログ

フロントエンドエンジニアが「Web」「Youtube」「3D」「お金」「お買い物情報」など、興味があることについて深堀っていくブログです。

見出しタグから目次を作るJavascript(Typescript)

2022年1月5日

2022年1月10日

ブログ記事を書いていると、「目次を入れたいなー」と思うことがよくあります。
自分で毎回作ってもいいのですが、見出しをとってきて自動的に作って欲しいなと思い作ってみました。
ここのブログでもこれを使って自動生成しています。


目次用のTypescript

export interface TableOfContentsSection {
  el?: HTMLHeadingElement
  items?: TableOfContentsSection[]
}

export interface TableOfContentsItem {
  title?: string
  id?: string
  items?: TableOfContentsItem[]
}

export const createChildrenItem = (
  elements: HTMLHeadingElement[],
  currentLabel: number,
  tableOfContentsItems?: TableOfContentsItem[],
  isBreak?: boolean
) => {
  const items: TableOfContentsItem[] = tableOfContentsItems || []
  for(let i = 0; i < elements.length; i++) {
    const el = elements[i]
    const label = parseInt(el?.tagName.replace('H', ''))
    const nextElement = elements[i + 1]
    const nextLabel = parseInt(nextElement?.tagName.replace('H', ''))
    const item: TableOfContentsSection = { el }
    if(nextLabel > currentLabel) {
      item.items = createChildrenItem(elements.slice(i + 1), nextLabel, item.items , true)
    }
    if(label > currentLabel) {
      continue
    }
    if(isBreak && label < currentLabel) {
      break
    }
    items.push(item)
  }
  
  return items
}

export const createTableOfContents = (hTagElements?: NodeListOf<Element>): TableOfContentsItem[] | undefined => {
  if(!hTagElements) return undefined
  return createSection(
    createChildrenItem(Array.prototype.slice.call(hTagElements), 2)
  )
}

const createSection = (section: TableOfContentsSection[], str?: string): TableOfContentsItem[] => {
  return section.map((h, i) => {
    const title = generateDescription(h.el?.innerHTML)
    const numString = `${str ? `${str}-` : ''}${i}`
    const item: TableOfContentsItem = {
      title,
      id: getId(h.el!, numString, title)
    }

    if (h.items) {
      item.items = createSection(h.items, numString)
    }

    return item
  })
}


export const generateDescription = (str?: string): string => {
  return str?.split('\n').map(value =>
    value.replace(/<("[^"]*"|'[^']*'|[^'">])*>/g, '')
  ).join('') || ''
}


const getId = (el: HTMLHeadingElement, str?: string, title?: string) => {
  const childFirstContent: Element | null = el.querySelector('*')
  const id = el.id || childFirstContent?.id || `${str ? `${str}_` : ''}${title}`
  el.setAttribute('id', id )
  return id
}


実際に上のJSを使ってHTMLを吐き出す

上のTypescripthタグをもとに階層構造のjsonを吐き出す実装になっています。
そのjsonを使って、実際にタグを生成する部分の実装です。

通常の静的なHTMLにタグを生成する場合、Typescript

import { TableOfContentsItem } from '~/utils/createTableOfContents'

const createTableContentsElement = (items: TableOfContentsItem) => {
  
  const createTableOfContent = (value: TableOfContentsItem) => {
    const ul = document.createElement('ul')
    ul.classList.add('table-of-contents')
    
    value.items?.map((item: TableOfContentsItem) => {
	   const li = document.createElement('li');
      li.classList.add('table-of-contents-value')
	   const a = document.createElement('a');
      a.textContent = item.title;
      li.appendChild(a);
      ul.appendChild(li);
      if(item.items) {
        li.appendChild(createTableOfContent(item))
      }
    })
    
    return ul
  }
  
  return createTableOfContent(items)
}

const tableContents = createTableContentsElement({ items: createTableOfContents(document.querySelectorAll('h2, h3, h4, h5, h6'))})
const h1 = document.querySelector('h1')
            
document.querySelector('body').insertBefore(tableContents, h1.nextSibling)


実際に吐き出されるHTML

下記のように階層構造のulが吐き出されます。

<ul class="table-of-contents">
  <li class="table-of-contents-value"><a>1. この文章は見出しですよ</a>
    <ul class="table-of-contents">
      <li class="table-of-contents-value"><a>1.1 この文章は見出しですよ</a></li>
    </ul>
  </li>
  <li class="table-of-contents-value"><a>2. この文章は見出しですよ</a>
    <ul class="table-of-contents">
      <li class="table-of-contents-value"><a>2.1 この文章は見出しですよ</a></li>
      <li class="table-of-contents-value"><a>2.2 この文章は見出しですよ</a>
        <ul class="table-of-contents">
          <li class="table-of-contents-value"><a>2.2.1 この文章は見出しですよ</a></li>
          <li class="table-of-contents-value"><a>2.2.2 この文章は見出しですよ</a>
            <ul class="table-of-contents">
              <li class="table-of-contents-value"><a>2.2.2.1 この文章は見出しですよ</a></li>
            </ul>
          </li>
        </ul>
      </li>
    </ul>
  </li>
</ul>


Reactの目次コンポーネント(Typescript)

import React from 'react'
import { TableOfContentsItem } from '~/utils/createTableOfContents'

export interface TableOfContentsProps {
  items?: TableOfContentsItem[]
}

const TableOfContents: React.FC<TableOfContentsProps> = props => {
  const createTableOfContents = (value: TableOfContentsItem, num: number) => {
    return (
      <ul className={`lebel${num}`}>
        {
          value.items?.map((item) => (
            <li key={item.id}>
              <a href={`#${item.id}`}>{ item.title }</a>
              {item.items && (
                createTableOfContents(item, num + 1)
              )}
            </li>
          ))
        }
      </ul>
    )
  }
  
  return <section>
    <h2>目次</h2>
    {createTableOfContents({ items: props.items! }, 2)}
  </section>
}

export default TableOfContents


まとめ

ちょっと細かい説明は今回、少し省いて書いちゃいました。
ないかなーと少し探したときに、あまり目次を自動生成するJavascriptを見つけることができなかったので
参考になれば良いかなーと言う感じで残しておきます!

npmでプラグインかしちゃっても良いかなーと思ったのですが、もうちょっと整理したりしたいところもあったので
整理したらまた改めて記事にするかもしれないです。

関連する記事

おすすめの本

Javascriptを勉強する際に読んでおくと良いなと思った本たちです。
特にリーダブルコードは綺麗なコードを書く考え方が身に付きます。いまでもその考え方が根付いています。

よくみられてる記事