워드프레스 제목(헤더) 태그에 앵커 링크 php로 삽입하기

사전 준비


개요

블로그 등 인터넷에 글을 쓰고자 할 때에는 제목을 h2, h3, h4 등의 제목 태그로 내용의 전환을 알리곤 한다. 헤더 태그로 감싸진 내용은 그 이상의 의미는 없어서, 해당 글 내부적으로 목차를 만들거나 다른 곳에서 해당 글의 특정 부분으로 링크를 걸려고 할 때에는 추가적인 작업이 필요하다. 가장 손쉽게 구현할 수 있는 방법은 헤더마다 앵커 링크를 만드는 것이다.

본 작업은 플러그인을 전혀 고려하지 않았다. 필자는 현재 Jetpack의 Markdown으로 글을 작성하고 있고, 샵 문자 (#)를 이용해서 헤더를 나누곤 하는데, 자동으로 헤더의 링크를 만들어주는 php 코드가 있으면 좋겠다 싶었다. 그리고 자바스크립트로도 구현할 수 있지만 여러가지 SEO상의 난점 때문에 서버에서 먼저 처리를 하고 싶었다.


난항

플러그인을 생각하지 않은 이유는 구현이 쉬울 것 같은 예감에서였다. 하지만 그 예감은 틀렸다.. ㅎㅎ 구현 중간에 난항을 겪은 일들은 다음과 같았다.

  • 본래 php 개발이 본 분야가 아니라서 디버깅이나 기능 테스트가 까다롭고 문법 자체도 익숙치 않아서 또 찾아보고.. 하하
  • 문자 처리가 까다로웠다. 한글같은 문자 하나에 대해 strlen을 하면 1이 아니라서 정규화를 계속 해줘야 하는 문제가 있었다(…만 사실 필요 없는 고민이었다. 문자열의 바이트 길이만 제대로 계산된다면 유니코드 여부는 중요치 않다.)

한계

이 헤더 링크 생성기(?)의 특징과 한계는 다음과 같다.

  • 헤더 정보를 별도로 저장하지 않기 때문에 헤더의 순서가 바뀌면 헤더의 id도 바뀐다. 즉 글 수정시 링크가 변경될 수 있는 가능성이 있다.
  • 매번 페이지 로딩 때마다 헤더를 탐색하기 때문에 서버에 부하가 올 수도 있다. (정확한 측정은 해보지 않았을 뿐더러 할 줄도 모른다..ㅎㅎ)
  • 이미 id 속성이 존재하는 헤더 태그에 대한 적절한 처리를.. 하지 않았다. 적절한 처리를 하려면 정규식도 살짝 바꿔야 하고, 전체적으로 조금 수정해야 할 듯 하다.

전체 코드

전체 코드를 먼저 보는 게 나는 좋더라. function.php 파일에 해당 코드를 추가한다.

// 워드프레스 시스템에 필터 추가
add_filter('the_content', 'add_header_anchor');

// 필터로서 실제로 동작하는 함수
function add_header_anchor($content)
{
  // 메인 루프 내의 독립적인 글일 때에만 적용하기.
  if (is_single() && in_the_loop() && is_main_query()) {

    // 중복 탐색을 피하기 위해 매 탐색마다 offset을 적용함.
    $offset = 0;

    // 검색 결과가 저장되는 변수
    $item = array();

    // 같은 제목이라도 다른 id를 매기기 위한 인덱스 변수
    $i = 0;

    // 목차 제작을 위해 데이터를 저장하는 변수
    $content_array = array();

    // 탐색에 실패하면 false이므로 루프가 종료됨.
    while (preg_match('/<(h[2-6])(.*?([ ]id=".+?"))?(.*?)?>(.+?)<\/h[2-6]>/', $content, $item, PREG_OFFSET_CAPTURE, $offset)) {

      // 필요한 정보를 정리하기.
      $tag = $item[1][0];
      $atts = $item[2][0];
      $id = $item[3][0];
      $atts2 = $item[4][0];
      $con_raw = $item[5][0];
      if ($id != '') {
        $link_id = urlencode($id);
      } else {
        $link_id = urlencode($con_raw);
      }
      $link_id = substr($link_id, 0, 30);
      $link_id .= '--' . $i;

      // 치환 결과를 제작함.
      $result = '<' . $tag . $atts . $atts2 . '><a class="anchor" href="#'
        . $link_id . '" id="' . $link_id . '"></a>' . $con_raw . '</' . $tag . '>';

      // replace 실시
      $content = substr_replace($content, $result, $item[0][1], strlen($item[0][0]));

      // 다음 검색을 위해 offset 계산
      $offset = strlen($result) + $item[0][1];

      // 목차 제작을 위한 데이터 생성
      $c = array();
      $c['title'] = $con_raw;
      $c['tag'] = $tag;
      $c['link'] = $link_id;
      $content_array[$i] = $c;

      // 인덱스 증감
      $i++;
    }

    // 목차 제작
    $list = '<div class="content-list"><ul>';
    foreach ($content_array as $key => $value) {
      $list .= '<li class="content-list-o ' . $value['tag'] . '"><a href="#' . $value['link'] . '">' . $value['title'] . '</a></li>';
    }
    $list .= '</ul></div>';
  }

  // 목차와 함께 변경 완료된 content를 반환.
  return $content . $list;
}

  • 다음은 각자의 입맛대로 수정할 수 있는 style.css
/* 헤더 앵커 링크 */

.entry-content h2 a.anchor,
.entry-content h3 a.anchor,
.entry-content h4 a.anchor,
.entry-content h5 a.anchor,
.entry-content h6 a.anchor {
  position: absolute;
  opacity: 0.5;
  left: -34px;
  padding: 5px;
  top: -2px;
  width: 34px;
}

.entry-content h2 a.anchor::before,
.entry-content h3 a.anchor::before,
.entry-content h4 a.anchor::before,
.entry-content h5 a.anchor::before,
.entry-content h6 a.anchor::before {
  content: url("img/link-variant.svg");
  visibility: hidden;
}

.entry-content h2 a.anchor:hover,
.entry-content h3 a.anchor:hover,
.entry-content h4 a.anchor:hover,
.entry-content h5 a.anchor:hover,
.entry-content h6 a.anchor:hover {
  opacity: 1;
}

.entry-content h2:hover a.anchor::before,
.entry-content h3:hover a.anchor::before,
.entry-content h4:hover a.anchor::before,
.entry-content h5:hover a.anchor::before,
.entry-content h6:hover a.anchor::before {
  visibility: visible;
}

/* 목차 (사이드바) */

.content-list a {
  color: #444655;
  font-size: 95%;
  text-decoration: underline solid #ccc;
}

.content-list li {font-style: normal;text-decoration: none;}
.content-list li.h3 {
  margin-left: 15px;
 list-style: circle;
}

.content-list li.h4 {
  margin-left: 30px;
  list-style: " - ";
  font-size: 95%;
}

.content-list li.h2 {
  margin-left: 0px;
  list-style: square ;
  font-weight: 700;
}

.content-list {
  display: block;
    background-color: #fff;
    padding: 30px 10px 30px 60px;
    margin: 50px 30px 10px;
    width: 300px;
    max-height: 400px;
    font-size: 93%;
    line-height: 1.6;
    overflow-y: auto;
    position: absolute;
    left: 100%;
    top: 0;
    opacity: 0.5;
}

.content-list:hover {
  opacity: 1;
}

.entry-content {
  position: relative;
}

@media all and (max-width:1200px) {
  .content-list {
    display:none;
  }
}

아이디어

워드프레스에는 필터라는 개념이 있다. 필터를 이용해 우리는 결과를 우리 입맛에 바꿀 수 있다. 워드프레스에서 제공되는 출력 관련 함수들(예: the_content 함수는 그 글의 본문을 출력한다.)은 대부분 이러한 필터를 쓸 수 있도록 해놓았다. 필터에 대한 개괄적인 설명은 워드프레스 공식 문서(영어)를 참조하라. 필터 종류는 여기(영어)에서 볼 수 있다.

그렇다면 생각이 된다. 본문이 출력되는 시점에 헤더를 검색해서 적절히 앵커 링크를 삽입하는 필터를 추가하자!

자, 그렇다면 어떤 식으로 본문 원본 데이터를 조작하여 <a> 태그를 추가할 수 있을까? 우선 헤더 태그를 검색하기 위해 정규식 을 이용해야 할 것 같다. 음. 정규식을 어떻게 이용할 수 있을까? 대략적으로 여러 방법이 고민되었다.

  • 완전히 같은 태그에 대해 다른 id를 매겨야 하는 상황이었다. 그러므로 preg_replace_all을 이용하는 방법, preg_match_all 이후 str_replace를 이용하는 방법 두 가지 모두 불가능했다.
  • preg_match_all에서 PREG_OFFSET_CAPTURE 플래그를 이용해 오프셋 값을 전부 가지고 온 뒤 substr_replace를 이용하는 방법 또한 까다로웠다. 왜냐하면 문자열을 수정할 때마다 전체 문자열의 길이가 달라져서 결국 substr_replace의 시작점을 매번 보정해줘야 했기 때문이다.

그래서 preg_matchPREG_OFFSET_CAPTURE 플래그를 계속 루프를 돌리고, 그 속에서 substr_replace를 이용하는 방법이 채택되었다. preg_match 함수는 문자열을 검색하는 데 실패하면 false를 반환하기 때문에 while 문에 조건에 넣기 알맞았다.


코드 뜯어보기

정규식

아래는 핵심 정규식이다.

/<(h[2-6])(.*?([ ]id=".+?"))?(.*?)?>(.+?)<\/h[2-6]>/

어떻게 돌아가는지에 대해서는 다음 (regexr) 링크를 참조하라. 정규식에 관한 개요는 자바스크립트 정규표현식 만들기를 참조하라.


preg_match

preg_match 함수에 PREG_OFFSET_CAPTURE 플래그를 주었을 때의 결과값은 다음과 같다. 여기서 offset이란 인덱스와 비슷한 개념이다. 해당 문자열이 시작하는 위치를 뜻한다.

array(
  0 => array (
    0 => "전체 문자열",
    1 => offset
  ),
  1 => array (
    0 => "첫 번째 그룹",
    1 => offset
  ),
  2 => array (
    0 => "두번째 그룹",
    1 => offset
  ),
  //...
)

앞서 언급했듯이 preg_match 함수는 검색에 실패하면 false를 반환하므로 손쉽게 while 조건 처리가 가능하다.

while (preg_match('/<(h[2-6])(.*?([ ]id=".+?"))?(.*?)?>(.+?)<\/h[2-6]>/',
    $content, $item, PREG_OFFSET_CAPTURE, $offset)) {
    // ...
}

치환 결과 제작

      // 필요한 정보를 정리하기.
      $tag = $item[1][0];
      $atts = $item[2][0];
      $id = $item[3][0];
      $atts2 = $item[4][0];
      $con_raw = $item[5][0];
      if ($id != '') {
        $link_id = urlencode($id);
      } else {
        $link_id = urlencode($con_raw); // 1
      }
      $link_id = substr($link_id, 0, 30); // 2
      $link_id .= '--' . $i; // 3

      // 치환 결과를 제작함. 4
      $result = '<' . $tag . $atts . $atts2 . '><a class="anchor" href="#'
        . $link_id . '" id="' . $link_id . '"></a>' . $con_raw . '</' . $tag . '>';

필요한 정보를 읽기 쉽도록 변수에 저장했다. 반드시 필요한 과정은 아니다.

  1. id 값에 한글이나 태그를 사용할 수 있는 값으로 변환하기 위해 urlencode 함수를 이용했다. id를 직접적으로 읽을 수 없는 등 이 함수가 능사는 아니지만 깊은 생각을 하지 않고 채택했다.
  2. id를 너무 길게는 만들고 싶지 않아서 substr을 이용해 30글자로 잘랐다.
  3. id 값에 i 변수를 포함시켜서 id가 페이지 내 유일한 값이 될 수 있도록 조정했다.
  4. 치환할 결과도 문자열을 주욱 이어붙여서 $result 변수에 저장했다.

목차 만들기

내친김에 목차도 만들었다.


    // 목차 제작을 위한 데이터 생성
    while(...) {
      $c = array();
      $c['title'] = $con_raw;
      $c['tag'] = $tag;
      $c['link'] = $link_id;
      $content_array[$i] = $c;
    }

    // 목차 제작
    $list = '<div class="content-list"><ul>';
    foreach ($content_array as $key => $value) {
      $list .= '<li class="content-list-o ' . $value['tag'] 
        . '"><a href="#' . $value['link'] . '">' . $value['title'] 
        . '</a></li>';
    }
    $list .= '</ul></div>';

스타일은 전체 코드 참조


스타일

스타일은 Gitlab의 것을 조금 본땄다. 헤더에 마우스를 올리면 바로 왼쪽에 불투명하게 링크 버튼이 나타나고, 링크 버튼에 마우스를 올리면 버튼이 더 선명해진다. 이 버튼을 우클릭하여 링크를 복사할 수 있다. 코드는 전체 코드 참조.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 항목은 *(으)로 표시합니다

Scroll to top