Computer >> 컴퓨터 >  >> 프로그래밍 >> Ruby

Jekyll로 다단계 하위 내비게이션 구현하기

지난 포스트에서는 Jekyll 페이지의 각 H2 제목에 대한 하위 내비게이션 링크를 생성하는 방법을 살펴보았습니다. 이번 포스트에서는 그 기반을 한 단계 더 발전시켜, H3, H4 등 원하는 만큼 깊은 수준의 하위 내비게이션을 추가하는 방법을 알아보겠습니다.

개요

이 프로젝트는 크게 두 단계로 나누어 진행합니다.

  • 첫 번째 단계: Nokogiri를 사용하여 H2 태그 "내부"에 속한 H3 태그로 정의된 섹션들을 추출합니다.
  • 두 번째 단계: 재귀적 템플릿(recursive template)이라는 멋진 기법을 활용해 임의의 깊이를 가진 하위 내비게이션을 렌더링합니다.

본격적으로 시작하기 전에 한 가지 짚고 넘어갈 점이 있습니다. 여기서 H3 태그가 H2 "내부"에 있다고 말할 때, 이는 DOM 구조상 실제로 중첩되어 있다는 의미가 아닙니다. 대신 아래와 같은 상황을 가리킵니다.

<h2>Animals</h2>
<p>Here are some kinds of animals.</p>
<h3>Giraffe</h3>
<p>This section about giraffes logically belongs inside of the section about animals, even though the structure of the Dom doesn't define it as being nested</p>
<h3>Zebra</h3>
<p>Another section that logically belongs under "Animals"</p>

기린(Giraffe)과 얼룩말(Zebra) 섹션은 DOM 구조상으로는 동물(Animals) 섹션에 중첩되어 있지 않지만, 논리적으로는 그 안에 속한다는 것이죠.

문서를 섹션으로 나누기

위와 같은 HTML 문서를 섹션 단위로 나누려 할 때 직면하는 명백한 문제는, 아무것도 중첩되어 있지 않다는 점입니다. 그런데 대부분의 HTML 파싱 도구는 중첩 구조를 전제로 설계되어 있습니다.

이것이 작업 자체를 불가능하게 만드는 것은 아니지만, 약간의 추가 작업이 필요하다는 뜻입니다. 아래 예제에서는 먼저 각 H2 태그를 찾은 다음, 형제(sibling) 노드들을 직접 스캔하면서 H3 태그를 찾아냅니다.

여기서는 커스텀 열거자(enumerator)를 활용했습니다. 열거자에 대해 궁금한 점이 있다면 관련 블로그 포스트를 참고하세요.

require "nokogiri"

class MySubnavGenerator < Jekyll::Generator
  def generate(site)
    parser = Jekyll::Converters::Markdown.new(site.config)

    site.pages.each do |page|
      if page.ext == ".md"
        doc = Nokogiri::HTML(parser.convert(page['content']))

        page.data["subnav"] = doc.css('h2').map do |h2|
          to_nav_item(page, h2).tap do |item|
            item["children"] = subheadings(h2).map { |h3| to_nav_item(page, h3) }
          end
        end
      end
    end
  end

  # Converts a heading into a hash of the info for a link
  def to_nav_item(page, heading)
    {
      "title" => heading.text,
      "url" => [page.url, heading['id']].join("#")
    }
  end

  # Returns an enumerator of all H3s "belonging" to an H2
  def subheadings(el)
    Enumerator.new do |y|
      next_el = el.next_sibling
      while next_el && next_el.name != "h2"
        if next_el.name == "h3"
          y << next_el
        end
        next_el = next_el.next_sibling
      end
    end
  end
end

코드가 다소 길지만, 지난 포스트에서 다룬 내용을 기반으로 확장한 것입니다. Jekyll 플러그인의 구조나 Nokogiri 사용법에 대한 자세한 설명이 필요하다면 해당 글을 확인해 주세요.

핵심 로직을 간단히 설명하면 다음과 같습니다.

  • to_nav_item 메서드: 제목 요소를 링크 정보 해시(title과 url)로 변환합니다.
  • subheadings 메서드: 특정 H2의 형제 노드를 순회하면서 다음 H2를 만나기 전까지의 모든 H3를 열거자로 반환합니다.

이 코드를 문서 사이트에 적용하면 다음과 같은 해시 구조가 생성됩니다.

[{"title"=>"Getting Started",
  "url"=>"/lib/java.html#getting-started",
  "sub_subnav"=>
   [{"title"=>"Download / Maven", "url"=>"/lib/java.html#download-maven"},
    {"title"=>"Stand Alone Usage", "url"=>"/lib/java.html#stand-alone-usage"},
    {"title"=>"Servlet Usage", "url"=>"/lib/java.html#servlet-usage"},
    {"title"=>"Play Usage", "url"=>"/lib/java.html#play-usage"},
    {"title"=>"API Usage", "url"=>"/lib/java.html#api-usage"}]},
    ...

이제 남은 일은 이 데이터를 Liquid 템플릿으로 렌더링하는 방법을 찾는 것뿐입니다.

하위 내비게이션 렌더링하기

Liquid 템플릿으로 임의의 깊이를 가진 하위 내비게이션을 렌더링하는 것은 생각보다 어렵지 않습니다. 핵심 비결은 자기 자신을 렌더링하는 partial을 사용하는 것입니다.

레이아웃에서는 partial을 호출하면서 내비게이션 항목 컬렉션을 함께 넘겨줍니다.

{% include navigation_item.html collection=page.subnav level=0 %}

partial은 현재 레벨의 링크들을 생성한 뒤, 자식 목록을 인자로 전달하며 자기 자신을 다시 호출합니다. 재귀 함수처럼 이론상 무한히 깊은 구조도 처리할 수 있습니다. 재미로 각 하위 내비게이션 레벨에 level-1, level-2 같은 클래스를 부여하는 코드도 추가했는데, 스타일링 시 매우 유용합니다.

{% if include.collection.size > 0 %}
<ul class="nav nav-list level-{{ include.level }}">
    {% for item in include.collection %}
      {% if item.url == page.url %}
      <li class="active">
      {% else %}
      <li>
      {% endif %}
        {% if item.subnav.size > 0 %}
          <a class="has-subnav" href="{{ item.url }}">
          <span class="glyphicon glyphicon-plus"></span>
          <span class="glyphicon glyphicon-minus"></span>
        {% else %}
          <a href="{{ item.url }}">
        {% endif %}
          {{ item.title }}
        </a>
        {% assign next_level = include.level | plus: 1 %}
        {% include navigation_item.html collection=item.children level=next_level %}
      </li>
    {% endfor %}
  </ul>
{% endif %}

동작 방식을 정리하면 다음과 같습니다.

  1. 컬렉션이 비어 있지 않으면 <ul> 목록을 생성하고, 현재 레벨 값을 클래스로 지정합니다.
  2. 각 항목의 URL이 현재 페이지 URL과 일치하면 active 클래스를 부여해 현재 위치를 표시합니다.
  3. 하위 항목이 존재하는 경우 플러스/마이너스 아이콘을 추가하고, level 값을 1 증가시킨 후 자기 자신을 재귀적으로 include합니다.

마무리

지금까지 Jekyll의 매력적인 세계를 잠시 탐험해 보았습니다. 곧 Ruby 내부 동작(Ruby internals)에 관한 연재 글도 준비되어 있으니 많은 기대 부탁드립니다!