사전 준비
function.php
를 편집할 수 있는 툴- 링크
svg
파일을 다운받아서 테마의img/
경로에 넣어두자. (https://materialdesignicons.com/icon/link-variant) (로딩 시간이 한참 걸린다.)
개요
블로그 등 인터넷에 글을 쓰고자 할 때에는 제목을 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_match
와 PREG_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 . '>';
필요한 정보를 읽기 쉽도록 변수에 저장했다. 반드시 필요한 과정은 아니다.
id
값에 한글이나 태그를 사용할 수 있는 값으로 변환하기 위해urlencode
함수를 이용했다.id
를 직접적으로 읽을 수 없는 등 이 함수가 능사는 아니지만 깊은 생각을 하지 않고 채택했다.id
를 너무 길게는 만들고 싶지 않아서substr
을 이용해30
글자로 잘랐다.id
값에i
변수를 포함시켜서id
가 페이지 내 유일한 값이 될 수 있도록 조정했다.- 치환할 결과도 문자열을 주욱 이어붙여서
$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의 것을 조금 본땄다. 헤더에 마우스를 올리면 바로 왼쪽에 불투명하게 링크 버튼이 나타나고, 링크 버튼에 마우스를 올리면 버튼이 더 선명해진다. 이 버튼을 우클릭하여 링크를 복사할 수 있다. 코드는 전체 코드 참조.