포트폴리오

hELLO. 티스토리 스킨 개발 돌아보기 - 2부

1부에서는 views/Main 을 제외한 대부분의 파일들을 알아보았는데, 이번 포스트에서는 views/Main 만 대부분 알아볼 것이다. 분량이 다소 많다. 두개로 나눠도 글 읽기와 글 목록이지만 조금 더 들어가보면 포스트, 홈 커버, 리스트, 태그 클라우드, 방명록을 포함한다. 방명록, 태그 클라우드, 공지사항, 페이징에 대한 설명은 다른 부분과 겹치는 것도 있고 해서 일부 생략한다.

 

현재 기준으로 코드 하이라이팅이 이상한 것을 볼 수 있을텐데, highlight.js 에서 pug 를 지원하지 않는다는 슬픈 사실.

 

https://pronist.tistory.com/61

 

hELLO. 티스토리 스킨 개발 리뷰 - 1부

기왕 스킨을 개발했고, 꽤나 괜찮게 나가고 있으니 포트폴리오 겸 적어두면 어떨까 써보기로 했다. 본래 티스토리 스킨 과 같은 프로젝트는 안 쓰려고 했지만, 고맙게도 깃허브에서 별을 제법 ��

pronist.tistory.com

tidory.comfig.js

여기서 들어가기 전에 별칭에 대한 설정을 확인해보자면 다음과 같다. 따라서 저러한 형태로 쓰인 경로가 있다면 치환해서 사용하면 된다. 설정된 별칭은 @, ~views 이다.

/**
 * Tidory Configuration
 * https://tidory.com/docs/configuration/
 */
module.exports = {
  /**
   * Template aliases
   */
  alias: {
    '@': 'assets',
    '~views': 'views'
  }
}

views/Main.pug

include @/templates/Comment
include @/templates/Post
style
  include:stylus @/styl/styled-heading.styl

main#__main(role='main')
  #main__content
    include Main/Cover
    include Main/Guestbook
    include Main/Post
    include Main/Notice
    include Main/TagCloud
    include Main/List
    s_if_var_paging
      include Main/Paging
  #__widgets
    s_if_var_scrollspy
      include Main/Widgets/Scrollspy

여기에 속한 파일들을 전부 알아보기 전에, 위에 있는 @/templates 아래에 있는 파일들을 알아볼 필요가 있다.

 

해당 파일에서 CSS 코드를 살펴봐야 할 것이 한 가지있는데, 바로 사용자가 설정한 값에 따라 넓이를 조절할 수 있는 스킨 옵션이다. 스킨 옵션은 skin.html 에 있는 것이 아니면 해석할 수 없다. 따라서 html 사이에 끼워넣어 주어야한다는 점이 참 그렇지만, 어쩔 수 없다.

style(scoped)
  :stylus
    #tt-body-page
      #__main
        width unquote('[##_var_article-width_##]' + 'px')

    #tt-body-index,
    #tt-body-category,
    #tt-body-archive,
    #tt-body-tag,
    #tt-body-search
      #__main
        width unquote('[##_var_index-width_##]' + 'px')

@/templates/Comment.pug

해당 유형의 파일에는 댓글과 관련된 것들이 들어가는데, 댓글 폼과 댓글 목록이다. 아래의 두 파일을 덧글 관련 파일이 아닌, views/Main제일 위에다가 포함시킨 이유는 이 두개의 템플릿은 방명록에도 쓰일 것이기 때문이다. 바로 믹스인의 형태로.

include @/templates/Comment/Form
include @/templates/Comment/List

@templates/Comment/Form.pug

덧글/방명록 작성 폼을 말하며 두 개의 믹스인으로 구성된다. 티스토리 스킨을 구성하다 보면 치환자의 태그의 이름만 다르거나 하는 등 중복이 되는 경우를 여실히 볼 수 있다. form 믹스인을 보면, 중간에 개발 모드일 때랑 아닐때랑 구분해서 표현하고 있다. 특수한 경우에는 프리뷰에서 확인할 수가 없기 때문이다. 예를 들면 관리자 여부, 로그인 여부에 따라 출력이 다른 경우 말이다.

mixin formGuestControl(type)
  .form__guest
    .name
      label(for='name') 이름
      input(type='text' name=`[##_${type}_input_name_##]` value='[##_guest_name_##]')
    .password
      label(for='password') 암호
      input(type='password' maxlength='8' name=`[##_${type}_input_password_##]` value='[##_guest_password_##]')

mixin form(type)
  div(class=`${type}-form content__form`)
    #{`s_${type}_input_form`}
      .form__shadow
        textarea(name=`${type === 'guest' ? '[##_guest_textarea_body_##]' : '[##_rp_input_comment_##]'}`)
        div(class=`${type}-form-control form-control`)
          if process.env.NODE_ENV === 'development'
            .form__control__inner
              +formGuestControl(type)
          else
            #{`s_${type}_member`}
              .form__control__inner
                #{type === 'guest' ? 's_guest_form' : 's_rp_guest'}
                  +formGuestControl(type)
          .form__submit
            input#secret(type='checkbox' name=`[##_${type}_input_is_secret_##]`)
            label#secret-label(for='secret')
            a(href='#' onclick=`[##_${type}_onclick_submit_##]`)

파라매터로 type 을 받고 있는데, 이는 방명록일 때와 덧글일 때를 구분하기 위함이다. 그리고, CSS 에서 스킨 옵션이 쓰인 부분이 있어서 이것도 한 번 보자. 해당 부분은 덧글 폼의 가로사이즈를 본문 크기에 따라 맞춘다.

style(scoped)
  :stylus
    #tt-body-page
      .content__form .form__shadow textarea
        width unquote('[##_var_article-width_##]' + 'px')
    #tt-body-guestbook
      .content__form .form__shadow textarea
        width unquote('[##_var_guestbook-width_##]' + 'px')

@/templates/Comment/List.pug

해당 파일은 덧글 목록과 방명록 목록을 표현한다. 댓글과 대댓글은 코드의 중복이 있기 때문에 믹스인으로 별도로 빼놓았으며, 대댓글에는 댓글 쓰기가 없으므로 isReplyable 을 파라매터로 받고있다. 아래의 믹스인들이 실제로 어떻게 사용되는지는 이 포스트의 후반부에 나올 것이다.

mixin comment(type, isReplyable = true)
  div(class=`[##_${type}_rep_class_##]`)
    .header
      .user
        .pic: img(src=`[##_${type}_rep_logo_##]`)
        .metainfo
          .name #{`[##_${type}_rep_name_##]`}
          time.date #{`[##_${type}_rep_date_##]`}
    .body #{`[##_${type}_rep_desc_##]`}
    .control
      a(href='#' onclick=`[##_${type}_rep_onclick_delete_##]`) 수정/삭제
      if isReplyable
        a(href='#' onclick=`[##_${type}_rep_onclick_reply_##]`) 댓글쓰기

mixin list(type, rType)
  div(class=`${type}-list content__list`)
    #{`s_${type}_container`}
      ol
        #{`s_${type}_rep`}
          li(id=`[##_${type}_rep_id_##]`)
            +comment(type)
            #{`s_${rType}_container`}
              ol
                #{`s_${rType}_rep`}
                  li(id=`[##_${type}_rep_id_##]`)
                    +comment(type, false)

@/templates/Post.pug

여기에는 글 목록과 글 읽기에 대한 것들이 들어가 있으며 사용처는 포스트와 공지사항, 페이지다.

include @/templates/Post/Index
include @/templates/Post/Permalink

@/templates/Post/Index.pug

이 친구도 믹스인이지만, 짧다. 이 믹스인은 커버가 아닌 최신 글일 때의 글 목록을 표현하는 마크업을 가지고 있다.

mixin index(type, hasCategory=true)
  #{`s_${type === 'list' ? 'list' : 'index_article'}_rep`}
    include @/templates/Post/Index/Post

@/templates/post/Index/Post.pug

div(class=`${type} content__index`)
  .img
    #{`s_${type}_rep_thumbnail`}
      img.thumbnail(src=`[##_${type}_rep_thumbnail${type === 'list' ? '' : '_url'}_##]`)
  .description
    if hasCategory
      .category: a(href=`[##_${type}_rep_category_link_##]`) #{`[##_${type}_rep_category_##]`}
    h1.title: a(href=`[##_${type}_rep_link_##]`) #{`[##_${type}_rep_title_##]`}
    p.summary #{`[##_${type}_rep_summary_##]`}
    time.date
      | →
      span #{`[##_${type}_rep_${type === 'list' ? 'regdate' : 'date'}_##]`}

@/templates/Post/Permalink.pug

퍼머링크는 공지사항과 포스트, 페이지 글 읽기에 해당하며, 내용이 가장 많은 편에 속한다. 아래의 믹스인을 통해 글 읽기를 해결한다.

mixin permalink(pageType, type, hasCategory = true)
  #{`s_${pageType}_rep`}
    div(id=`__${pageType}`)
      include @/templates/Post/Permalink/Post
      if hasCategory
        s_if_var_comment
          include @/templates/Post/Permalink/Comment
        s_if_var_notify
          include ~views/Main/Post/Permalink/Notification

퍼머링크를 위한 스크립트는 다음과 같다. 코드를 하이라이팅 하고, 이미지를 정렬하고, h2, h3, h4 태그에 링크를 부여하고, 그리고 제일 중요한 역할인 글 모드를 설정한다.

script(scoped).
  /**
   * Set Styled Heading
   */
  $(document).ready(() => h.setStyledHeading('.content__permalink', '[##_var_article-mode_##]', {
    controllable: true,
    modeHansControlElement: '.content__permalink > .content div.h[data-mode]'
  }))

script.
  /**
   * Media contents alignment
   */
  $(document).ready(() => {
    const alignImage = function (width) {
      width = width > 1100 ? 1100 : width
      $(this).css({ width, 'max-width': 'none', 'margin-left': `calc((100% - ${width}px) / 2)`})
    }

    $('figure.imageblock.alignCenter').each(function () {
      alignImage.call(this, $(this).data('origin-width') || $(this).attr('width') || $(this).width())
    })
    $('figure.imagegridblock').each(function () {
      alignImage.call(this, 1100)
    })
    $('figure.imageslideblock.alignCenter').each(function () {
      alignImage.call(this, $(this).find('.image-wrap.selected > img').data('origin-width'))
    })
  })

script.
  /**
   * Headings
   */
  $(document).ready(() => {
    $('.content__permalink').find('h2, h3, h4').each(function () {
      const $heading = $(this)
      const anchor = encodeURIComponent($heading.text())
      const $a = $('<a></a>').attr('href', '#' + anchor).text($heading.text())
      const htmlContent = $heading.html()

      $a.html(htmlContent)
      $heading.attr('id', anchor).empty().append($a)
    })
  })

script.
  /**
   * Code Highlighting
   */
  $(document).ready(() => hljs.initHighlighting())

h.setStyledHeading()

 

여기서 h.setStyledHeading() 메서드의 구현은 아래와 같은데, 글 모드를 설정하는 코드이므로 참고하면 좋다. 이미지가 없으면 자동으로 default 로 바꿔준다거나 하는 것을 잘 살펴보거나, 수동으로 설정할 때 모드를 잘못 입력하는 경우 기본으로 셋팅한다는 점도 있다.

  /**
   * Set Styled Heading
   *
   * @param {string} container
   * @param {string} mode
   * @param {object} options
   */
  static setStyledHeading (container, mode, options = {}) {
    const $container = $(container)

    options = {
      controllable: options.controllable || false,
      modeHansControlElement: options.modeHansControlElement || '',
      attr: options.attr || 'data-mode'
    }

    const supports = ['default', 'tape', 'screen']

    /**
    * Mode to 'default' if not exist thumbnail
    */
    if (!$container.find('header > .img').length) {
      mode = 'default'
    }

    /**
     * Mode hands control
     */
    if (options.controllable) {
      const $modeHansControl = $(options.modeHansControlElement)
      if ($modeHansControl.length) {
        mode = $modeHansControl.attr(options.attr)
        mode = supports.includes(mode) ? mode : 'default'
      }
    }

    $container.attr(options.attr, mode)
  }

@/templates/Post/Permalink/Post.pug

 

퍼머링크 포스트의 마크업은 다음과 같다. 아래에 관리자 메뉴, 태그, 카테고리 더 보기, 글 작성자 등 또 다른 여러 파일이 포함되어 있지만, 다른 것보다 중요도가 낮아서 살펴볼 필요는 없다.

div(class=`${type} content__permalink` data-mode='[##_var_article-mode_##]')
  header.header(role='header')
    #{`s_${type}_rep_thumbnail`}
      .img
        .mask
        img.thumbnail(src=`[##_${type}_rep_thumbnail_raw_url_##]`)
    .heading
      if hasCategory
        a.category(href='[##_article_rep_category_link_##]') [##_article_rep_category_##]
      h1.title #{`[##_${type}_rep_title_##]`}
      .metainfo
        time.date #{`[##_${type}_rep_date_##]`}
        if hasCategory
          include @/templates/Post/Permalink/Metainfo/Admin
  article.content #{`[##_${type}_rep_desc_##]`}
  include @/templates/Post/Permalink/PostBtn
  if hasCategory
    include ~views/Main/Post/Permalink/Category
    include ~views/Main/Post/Permalink/Tag
  s_if_var_author
    include @/templates/Post/Permalink/Author

@/templates/Post/Permalink/Comment.pug

 

해당 파일을 언급하는 이유는, 여기서 우리가 작성한 믹스인을 처음 사용하기 때문이다. form, list 믹스인을 다음과 같이 사용할 수 있다. 본래 댓글 마크업은 길지만, 이미 믹스인에 작성해두었기 때문에 호출만 해주면 된다. 방명록도 비슷하게 처리하기 때문에 생략이다.

.permalink__comment
  s_rp
    +form('rp')
    +list('rp', 'rp2')

~views/Main/Post/Permalink/Notification.pug

 

이 친구는 글 읽기에서 이전 글/다음 글 팝업 창을 띄운다. 스킨의 특유 기능 중 하나이므로 살펴보기로 하자. 이전 글/다음 글이 있으므로 일단 두 코드가 어느정도 중복이므로 믹스인으로 구성하며, h.notify() 메서드에서는 UIkit.notification() 을 실행하는 것이 주요 코드이므로 생략한다.

mixin notify(type, label, pos)
  #{`s_article_${type}`}
    a.permalink__notify(href=`[##_article_${type}_link_##]` class='uk-box-shadow-medium' id=type)
      #{`s_article_${type}_thumbnail`}
        .thumbnail
          img(src=`[##_article_${type}_thumbnail_link_##]`)
          //- img(src='https://t1.daumcdn.net/tistory_admin/static/mobile/m640/img_relation.png')
      .metainfo
        .description #{label}
        .title #{`[##_article_${type}_title_##]`}

  script.
    /**
     * Set timer for Notification
     */
    $(document).ready(() => setTimeout(() => h.notify('#' + '#{type}', `#{pos}`, 15000), 3000))

+notify('next', '다음 글', 'bottom-right')
+notify('prev', '이전 글', 'bottom-left')

주석 처리해놓은 것은 테스트를 위한 것이다. 해당 치환자는 다른 치환자와는 다르게 섬네일이 없다고 해도 대체 이미지로 표시를 해주기 때문에 저렇게 테스트를 통해 처리하고 h.notify() 에서 해당 대체 이미지를 없애는 코드를 추가해주어야 한다. 어떻게 생각해보면 티스토리 치환자의 동작 방식이 일관적이지 않기 때문에 개발이 조금 힘들었던 점도 있다.

views/Main/Post.pug

드디어 글 읽기 부분을 우리가 작성한 믹스인으로 통해 써보기로 구현해보기로 하자. 이 친구는 덧글처럼 아주 심플한 모양을 가진다. 아래의 믹스인 호출은 각각 포스트 글 읽기와 페이지를 나타내며 페이지의 경우에는 카테고리가 없기 때문에 두 번째 파라매터에 false 를 준다. Post/Protected 는 보호글인데, 이 친구는 생략한다.

s_article_rep
  +permalink('permalink_article', 'article')
  +permalink('page', 'article', false)
  include Post/Protected

views/Main/List.pug

글 목록을 총괄한다. 태그, 검색, 카테고리와 같은 것들을 말이다. 단 공지사항은 예외다. 아래의 마크업에서 script 태그가 부자연스럽게 끼워져있는 것을 볼 수 있는데, [##_list_conform_##] 치환자는 부득이하게도 s_list 치환자의 아래에서만 동작한다. 그래서 어쩔 수 없다. 문자열을 하드 코딩으로 비교하기 때문에 사실 그다지 중요한 코드는 아니다. 주목해봐야 하는 것은 index 믹스인이 쓰였다는 점이다.

s_list
  section.__list.main__list(data-mode='[##_list_style_##]' data-image-mode='[##_var_list-image-mode_##]')
    header.list__header
      s_list_image
        .img
          .mask
          img.thumbnail(src='[##_list_image_##]')
      .heading
        h1.title [##_list_conform_##]
    script(scoped).
      /**
      * set List Title
      */
      $(document).ready(() => $('.__list > .list__header .title').text(
        '[##_list_conform_##]' === '전체 글'
          ? '[##_title_##]'
          : '[##_list_conform_##]'
        )
      )
    ul
      +index('list')

여기서 글 스타일data-mode 에서, 카테고리 이미지 스타일data-image-mode 에서 설정한다는 점을 살펴보자. 해당 스타일에 따라 CSS 를 다르게 처리할 뿐이고 마크업은 똑같다.

views/Main/Cover.pug

이번에 살펴볼 부분은 홈 커버다. 커버에는 믹스인이 많으며, 스타일이 여러 개가 존재하므로 좀 길다. 본래는 스타일마다 파일을 쪼개는 것도 생각해보았으나, 중복이 너무 많아서 그렇게 하지는 않고 글 목록 스타일처럼 처리해보기로 했다. 이것을 일반 티스토리 스킨 만들듯 skin.html 에 하드 코딩했다면 매우 피곤했을 것으로 생각된다. 도대체 중복되는 코드만 몇개이며 길이는 얼마나 길어질까.

mixin coverItem()
  s_cover_item
    li.content__index
      s_cover_item_article_info
        block

mixin coverImage()
  a.img(href='[##_cover_item_url_##]')
    s_cover_item_thumbnail
      img.thumbnail(src='[##_cover_item_thumbnail_##]')&attributes(attributes)

mixin coverDescription(isFeatured=false)
  .description&attributes(attributes)
    .category: a(href='[##_cover_item_category_url_##]') [##_cover_item_category_##]
    h1.title: a(href='[##_cover_item_url_##]') [##_cover_item_title_##]
    unless isFeatured
      p.summary [##_cover_item_summary_##]
      time.date
        | →
        span [##_cover_item_simple_date_##]

mixin cover(mode, isFeatured=false)
  case mode
    when 'default'
    when 'list'
    when 'grid'
    when 'gallery'
      section.__list.main__cover(data-mode=mode)
        h1.cover__title [##_cover_title_##]
        ul&attributes(attributes)
          +coverItem()
            +coverImage()
            +coverDescription(isFeatured)
    when 'tape'
    when 'screen'
      section.__slide.main__cover(data-mode=mode class='uk-position-relative uk-visible-toggle uk-light' tabindex='-1')&attributes(attributes)
        h1.cover__title [##_cover_title_##]
        ul(class='uk-slideshow-items')
          +coverItem()
            +coverImage()(uk-cover)
            div(class='uk-overlay-primary uk-position-cover')
            +coverDescription(true)(class='uk-position-center uk-position-small')
        a(class='uk-position-center-left uk-position-small uk-hidden-hover' href='#' uk-slidenav-previous uk-slideshow-item='previous')
        a(class='uk-position-center-right uk-position-small uk-hidden-hover' href='#' uk-slidenav-next uk-slideshow-item='next')

s_cover_group
  s_cover_rep
    s_cover(name='default')
      +cover('default')
    s_cover(name='list')
      +cover('list')
    s_cover(name='grid')
      +cover('grid')(class='uk-child-width-1-2@s uk-child-width-1-[##_var_grid-column-count_##]@m' uk-grid)
    s_cover(name='gallery')
      +cover('gallery', true)(class='uk-child-width-1-2@s uk-child-width-1-[##_var_gallery-column-count_##]@m' uk-grid)
    s_cover(name='tape')
      +cover('tape')(uk-slideshow='min-height: 280; max-height: [##_var_tape-height_##]; autoplay: true; animation: fade')
    s_cover(name='screen')
      +cover('screen')(uk-slideshow='autoplay: true; animation: fade')

views/Main/Widgets/scrollspy.pug

이 녀석은 TOC(Table Of Contents)를 표시해준다. 위젯이기 때문에 별도로 분리를 해놓았다. 사실 기존에는 Vue.js 컴포넌트로 구성했었으나 꼭 그럴 필요는 없는 것같아서 일반적인 템플릿으로 바꾸었다. 그렇게 생각하면 밖에 있을 필요없이 @/templates/Post/Permalink/Post.pug 내부에 속해도 상관 없을 것 같긴 하다. 언젠간 옮길지도.

 

마크업 자체는 짧지만, 자바스크립트에서 본문에 속한 헤더를 추출하고, 메뉴를 구성하는 역할을 해준다. 또한 position: sticky 를 사용했기 때문에 높이를 맞춰주는 것도 빼먹어서는 안 된다.

#__spy
  ul(class='uk-nav uk-nav-default' uk-scrollspy-nav='closest: li')

script.
  /**
   * Create a scrollspy menu
   */
  $(document).ready(() => {
    const $permalink = $('.content__permalink')

    // Extract Headings
    const spies = $permalink.find('h2, h3').get().reduce((spies, heading) => {
      const $heading = $(heading)
      const anchor = encodeURIComponent($heading.text())
      spies.push({ href: '#' + anchor, label: $heading.text(), tag: $heading.prop('tagName').toLowerCase() })
      return spies
    }, [])

    // Build
    const $spy = $('#__spy ul')
    spies.forEach(spy => {
      const $a = $('<a></a>').text(spy.label).attr({
        'href': spy.href,
        'data-tag': spy.tag
      })
      $spy.append($('<li></li>').append($a))
    })
  })

script.
  /**
   * Set scrollspy position
   */
  $(document).ready(() => {
    const $spy = $('#__spy')
    const $content = $('.content__permalink').children('.content')

    if ($content.length) {
      const { top } = $content.position()
      $spy.css({ top: top + 50, height: $content.height() })
    }
  })

마치며

hELLO. 스킨 개발 리뷰 2부가 끝났다. 해당 스킨을 만드는데에는 꽤나 많은 시간을 들였고, 코드의 중복을 줄이거나 버그를 줄이기 위해 여러모로 애썼다. 이 기세로 봐서는 다음 티스토리 스킨은 안 만들것 같다. 신경 쓸 것이 많은데다가 이 보다 더 좋은 품질의 스킨을 만들 수 있을지도 의문이다. 디자인적으로 획기적인 것이 있다면 고려는 해볼 것이지만, 사실 지금은 새로운 언어를 익히는데 더 시간을 쓰고 있어서 신규 스킨을 만드는 것은 계획에 없다.