theme preview 제작기 (티스토리, 텀블러)

블로그를 운영하다 보면 내 마음에 쏙 드는 테마(스킨)를 직접 만들거나 수정하고 싶어질 때가 있다. 나 역시 텀블러(Tumblr)와 티스토리(Tistory) 블로그 테마를 직접 입맛에 맞게 고쳐 쓰곤 하는데, 막상 테마를 수정하려고 하면 '코드를 고치고 눈으로 확인하는 과정'이 생각보다 꽤 번거롭다.

  • 텀블러는 테마 편집기 화면에 HTML을 통째로 붙여넣고 프리뷰가 뜰 때까지 기다려야 한다. 외부 CSS/JS 파일이라도 분리해 두면 캐시나 CSP(Content Security Policy) 문제로 Resource Override 같은 브라우저 확장 프로그램과 씨름해야 한다.
  • 티스토리도 마찬가지로 스킨 편집 화면에서 일일이 코드를 고쳐 적용해 보거나, 로컬에서 수정한 skin.html, style.css 파일들을 매번 관리자 페이지에 업로드하며 확인해야 한다.

*"그냥 프론트엔드 개발할 때처럼 로컬에서 코드를 저장하면 브라우저에 바로 반영되게 할 수는 없을까?"*

이 단순한 갈증에서 출발해 로컬 테마 프리뷰 도구인 tumblr-theme-preview와 tistory-theme-preview를 만들게 되었다. 이번 글에서는 두 도구의 핵심인 테마 문법 파서를 어떻게 만들었는지, 그리고 테마 수정을 편하게 해주는 프리뷰 앱의 구조와 기능을 실제 코드와 함께 가볍게 정리해 본다.


1. 테마 문법 파서 만들기: 공식 문서를 코드로 옮기는 과정

텀블러와 티스토리의 테마 파일은 일반 HTML처럼 보이지만, 실제로는 각 플랫폼 고유의 템플릿 치환자 문법이 가득 들어 있다. 로컬에서 테마를 제대로 미리 보려면 브라우저에 HTML을 내려주기 전에 이 치환자들을 실제 데이터로 바꿔주는 템플릿 엔진(파서)이 필요했다.

처음에는 단순히 정규식(Regex)과 String.replace()로 치환하면 되지 않을까 생각했지만, 곧 한계에 부딪혔다. 블록 안에 또 다른 블록이 중첩되기 때문이다. 예를 들어 '포스트 반복 블록' 안에 '태그 반복 블록'이 있고, 그 안에 또 '조건문 블록'이 들어가는 식이다.

그래서 두 프로젝트 모두 가벼운 3단계 컴파일 파이프라인(tokenize → buildAST → evaluateAST) 구조로 파서를 설계했다.

function compile(template, context = {}) {
  const tokens = tokenize(template);   // 1. 문자열을 토큰 단위로 분리
  const ast = buildAST(tokens);        // 2. 중첩 블록을 트리(AST) 구조로 변환
  return evaluateAST(ast, context);    // 3. Mock 데이터를 대입해 최종 HTML 렌더링
}

1-1. Tumblr 테마 문법 파싱

텀블러 커스텀 테마 공식 문서를 보면 텀블러의 문법은 중괄호({ }) 하나로 모든 것을 표현한다.

  • 블록 문법: {block:Posts} ... {/block:Posts}
  • 변수 치환자: {Title}, {Body}, {Permalink}
  • 커스텀 메타 변수: {text:Theme Version}, {color:Background}, {select:Layout Width}, {lang:Search results for SearchQuery}

먼저 토크나이저(tokenize)에서는 정규식으로 블록의 시작({block:...}), 블록의 끝({/block:...}), 그리고 일반 변수({...})를 추출해 토큰 배열로 만든다.

// tumblr-theme-preview/lib/parser.js
function tokenize(template) {
  const tokens = [];
  let lastIndex = 0;
  let match;

  // {block:Name}, {/block:Name}, {Variable} 등 텀블러 예약어 토큰 추출 정규식
  const regex = /\{(\/?block:[A-Za-z0-9_:\-]+|(?:text|select|color|font|image|if|lang):[A-Za-z0-9_:\- ]+|[A-Za-z0-9_:\-]+)\}/g;

  while ((match = regex.exec(template)) !== null) {
    const textBefore = template.substring(lastIndex, match.index);
    if (textBefore) tokens.push({ type: 'text', content: textBefore });

    const tokenVal = match[1];
    if (tokenVal.startsWith('block:')) {
      tokens.push({ type: 'block_start', name: tokenVal.substring(6) });
    } else if (tokenVal.startsWith('/block:')) {
      tokens.push({ type: 'block_end', name: tokenVal.substring(7) });
    } else {
      tokens.push({ type: 'variable', name: tokenVal });
    }
    lastIndex = regex.lastIndex;
  }
  // ... 남은 텍스트 처리
  return tokens;
}

이렇게 뽑아낸 토큰들은 스택(Stack)을 이용해 부모-자식 관계를 갖는 트리(AST)로 조립한다. block_start를 만나면 새 블록 노드를 만들어 스택에 push하고, 짝이 맞는 block_end를 만나면 pop하는 식이다.

function buildAST(tokens) {
  const root = { type: 'root', children: [] };
  const stack = [root];

  for (const token of tokens) {
    const currentParent = stack[stack.length - 1];

    if (token.type === 'text' || token.type === 'variable') {
      currentParent.children.push(token);
    } else if (token.type === 'block_start') {
      const newBlock = { type: 'block', name: token.name, children: [] };
      currentParent.children.push(newBlock);
      stack.push(newBlock);
    } else if (token.type === 'block_end') {
      if (stack.length > 1 && stack[stack.length - 1].name === token.name) {
        stack.pop();
      }
    }
  }
  return root;
}

이후 평가기(evaluateAST)가 트리를 순회하며 HTML을 만들어낸다. 텀블러 공식 문서를 보며 파서에 신경 써서 반영한 포인트는 다음과 같다.

  • 조건형 블록과 반복형 블록 구분: 같은 {block:Name} 형태라도 어떤 것은 조건문({block:IndexPage}, {block:IfEndlessScroll}, {block:Text})이고, 어떤 것은 배열을 순회하는 반복문({block:Posts}, {block:Tags})이다. 예를 들어 {block:Posts}를 만나면 Mock 데이터의 posts 배열을 순회하며 자식 컨텍스트에 currentPost를 주입해 렌더링한다.
  • 유연한 커스텀 변수 매칭: 텀블러에서는 <meta name="if:Show Header" content="1">처럼 띄어쓰기가 포함된 변수를 본문에서 {block:IfShowHeader}나 {block:IfNotShowHeader}로 공백 없이 붙여 쓴다. 이를 위해 대소문자와 공백을 제거해 비교하는 헬퍼 함수를 두어 어떤 형태로 작성하든 매칭되도록 했다.
  • 20여 가지 날짜·시간 변수의 동적 생성: 텀블러 문서에는 {DayOfMonth}, {DayOfWeekShort}, {TimeAgo}, {Timestamp} 등 날짜 관련 치환자가 무척 많다. Mock 데이터에 이를 일일이 적는 대신, 기본 날짜(Year, ShortMonth, DayOfMonth)만 있으면 나머지 20여 개 변수를 자동으로 계산해 반환하도록 구현했다.

1-2. Tistory 스킨 문법 파싱

티스토리 스킨 가이드 공식 문서를 참고해 만든 티스토리 파서는 텀블러와는 또 다른 문법 구조를 다룬다. 티스토리는 두 가지 문법을 조합해서 사용한다.

  • 그룹 치환자 (블록): <s_t3> ... </s_t3>, <s_article_rep> ... </s_article_rep>
  • 값 치환자 (변수): [##_title_## ], [##_article_rep_title_## ]

티스토리 토크나이저는 <s_태그명 속성>, </s_태그명>, [##_변수명_## ] 세 가지 패턴을 한 번에 파싱한다. 특히 홈 커버에서 쓰이는 <s_cover name='featured'>처럼 그룹 치환자에 HTML 속성(Attribute)이 들어가는 경우까지 처리해야 했다.

// tistory-theme-preview/lib/parser.js
function tokenize(template) {
  const tokens = [];
  let lastIndex = 0;

  // 1. 그룹 시작: <s_TAG attrs> | 2. 그룹 종료: </s_TAG> | 3. 값 치환자: [##_VAR_## ]
  const regex = /<s_([a-zA-Z0-9_\-]+)((?:\s+[^>]*)?)>|<\/s_([a-zA-Z0-9_\-]+)>|\[##_([a-zA-Z0-9_\-]+)_##\]/gi;

  let match;
  while ((match = regex.exec(template)) !== null) {
    // ... 이전 텍스트 추가
    if (match[1] !== undefined) {
      const name = match[1].toLowerCase();
      const rawAttrs = match[2] || '';
      const attrs = {};
      const attrRegex = /([a-zA-Z0-9_\-]+)=["']([^"']*)["']/g;
      let attrMatch;
      while ((attrMatch = attrRegex.exec(rawAttrs)) !== null) {
        attrs[attrMatch[1].toLowerCase()] = attrMatch[2];
      }
      tokens.push({ type: 'block_start', name, attrs });
    } else if (match[3] !== undefined) {
      tokens.push({ type: 'block_end', name: match[3].toLowerCase() });
    } else if (match[4] !== undefined) {
      tokens.push({ type: 'variable', name: match[4] });
    }
    lastIndex = regex.lastIndex;
  }
  return tokens;
}

티스토리 파서를 구현하면서 챙긴 핵심 포인트는 다음과 같다.

  • 홈 커버와 아티클 영역의 상호배타적 렌더링: 티스토리에서는 홈 화면(index)에 홈 커버(<s_cover_group>)가 활성화되어 있으면 기본 글 목록(<s_article_rep>) 대신 커버 아이템들이 렌더링된다. 평가기에서 현재 viewType과 enableCovers 옵션을 확인해 실제 티스토리와 똑같이 분기되도록 맞췄다.
// 평가기(evaluateAST) 내부의 그룹 치환자 처리 예시
if (name === 'cover_rep') {
  if (context.viewType !== 'index' || !context.enableCovers) return '';
  return (context.covers || []).map(cov =>
    renderChildren(node, { ...context, currentCover: cov })
  ).join('');
}

if (name === 'cover') {
  // <s_cover name="featured"> 속성과 현재 커버 데이터의 name이 일치할 때만 렌더링
  const coverNameAttr = node.attrs && node.attrs.name;
  if (context.currentCover && coverNameAttr === context.currentCover.name) {
    return renderChildren(node, context);
  }
  return '';
}
  • index.xml 파서 연동: 티스토리 스킨은 skin.html 혼자 동작하지 않고 스킨 설정 파일인 index.xml과 짝을 이룬다. 별도의 XML 파서(xml_parser.js)를 만들어 index.xml에 정의된 스킨 옵션(<variables>), 홈 커버 정의(<cover>), 리스트 스타일 등을 읽어오고, 이를 스킨의 <s_if_var_...> / <s_not_var_...> 조건문이나 [##_var_..._## ] 치환자와 연결했다.
  • 최신 위젯과 레거시 문법 동시 지원: 예전 티스토리 스킨들은 <s_rp_rep> 그룹 치환자로 댓글 HTML 구조를 직접 짰지만, 최근 스킨들은 [##_comment_group_## ]이나 [##_guestbook_group_## ] 치환자 하나로 React 기반의 최신 댓글·방명록 위젯을 통째로 불러온다. 어떤 스킨을 테스트하든 어색하지 않도록 두 방식 모두 실제 티스토리 화면과 유사하게 렌더링되도록 구현했다.

2. 프리뷰 앱 구조: 테마 수정을 쾌적하게 만드는 기능들

파서가 엔진이라면, 프리뷰 앱은 테마 제작자가 실제로 다루는 환경이다. 테마를 수정할 때 불편했던 점들을 하나씩 해결하다 보니 자연스럽게 아래와 같은 구조가 만들어졌다.

① 설정이 필요 없는 자동 탐색 (Zero-Config CLI)

복잡한 설정 파일을 만들 필요 없이, 작업 중인 테마 폴더에서 터미널을 열고 명령어 한 줄만 입력하면 바로 실행되도록 만들었다.

# 텀블러 테마 폴더에서
npx tumblr-theme-preview

# 티스토리 스킨 폴더에서
npx tistory-theme-preview

CLI가 실행되면 현재 폴더 안의 파일들을 스캔해 {block:Posts}나 <s_t3>, [##_ 같은 고유 치환자가 들어 있는 HTML 파일을 알아서 찾아내고, 함께 있는 style.css, index.xml, images/ 폴더를 자동으로 매핑해 로컬 서버를 띄운다.

② 원격 CDN 주소를 로컬 파일로 바꿔치기 (Smart Asset Replacer)

테마를 만들다 보면 HTML 안에 <link rel="stylesheet" href="https://cdn.jsdelivr.net/.../main.css">처럼 이미 배포된 원격 CDN 주소를 적어두는 경우가 많다. 로컬에서 수정할 때마다 이 주소를 ./css/main.css로 바꿨다가 배포할 때 다시 원복하는 건 꽤 번거롭고 실수하기도 쉽다.

그래서 렌더링 직전에 HTML 안의 원격 http://, https:// 에셋 경로를 스캔한 뒤, 파일 이름이 내 로컬 폴더(css/, js/, images/)에 있는 파일과 같다면 자동으로 로컬 파일을 바라보도록 우회(Rewrite)하는 기능을 넣었다.

// server.js - 원격 CDN 에셋을 로컬 파일로 자동 우회
function applySmartAssetReplacements(html) {
  const assetRegex = /(href|src)=["'](https?:\/\/[^"']+\.(css|js|png|jpg|jpeg|gif|svg|webp))["']/g;

  return html.replace(assetRegex, (match, attr, url, ext) => {
    const filename = path.basename(url);
    const checkPaths = [
      path.join(imagesDir, filename),
      path.join(skinDir, filename),
      path.join(skinDir, ext === 'css' ? 'css' : 'js', filename)
    ];

    for (const checkPath of checkPaths) {
      if (fs.existsSync(checkPath)) {
        // 로컬에 동일한 파일명이 존재하면 로컬 경로로 즉시 치환
        return checkPath.startsWith(imagesDir)
          ? `${attr}="/images/${filename}"`
          : `${attr}="/${filename}"`;
      }
    }
    return match;
  });
}

덕분에 HTML 코드는 한 줄도 건드리지 않고 로컬 CSS와 JS를 수정하며 바로 결과를 확인할 수 있다.

③ 저장하면 바로 반영되는 라이브 리로드 (Live Reload)

코드를 수정하고 저장했을 때 브라우저를 일일이 새로고침하는 수고를 덜기 위해, Node.js의 fs.watch와 SSE(Server-Sent Events)를 활용한 라이브 리로드를 적용했다.

// 파일 변경 감지 시 연결된 브라우저로 reload 이벤트 전송
fs.watch(targetPath, { recursive: true }, (eventType, filename) => {
  clearTimeout(reloadTimeout);
  reloadTimeout = setTimeout(() => {
    liveReloadClients.forEach(client => client.res.write('data: reload\n\n'));
  }, 150);
});

렌더링되는 프리뷰 HTML 하단에는 EventSource('/api/live-reload') 스크립트가 자동으로 주입되어, 에디터에서 skin.html이나 style.css를 수정하고 저장하면 0.15초 만에 화면이 자동 갱신된다.

④ 모든 화면과 옵션을 한눈에 테스트하는 대시보드

http://localhost:3000에 접속하면 단순히 렌더링된 결과물만 뜨는 것이 아니라, 테마의 구석구석을 테스트할 수 있는 컨트롤 대시보드가 함께 열린다.

  • 다양한 뷰 모드 전환: 홈(인덱스) 화면뿐만 아니라 글 본문 상세, 카테고리 목록, 태그 페이지, 검색 결과, '검색 결과가 없을 때'의 화면, 공지사항, 보호글(비밀번호 입력 화면), 방명록까지 버튼 클릭 한 번으로 전환하며 레이아웃이 깨지는 곳이 없는지 확인할 수 있다.
  • 반응형 뷰포트 프리셋: 데스크톱, 랩톱(1280px), 태블릿, 모바일 화면 크기를 즉시 오가며 반응형 CSS를 점검할 수 있고, 가로/세로 회전 기능도 지원한다.
  • 테마·스킨 옵션의 동적 UI 생성: 가장 공을 들인 기능 중 하나다.
    • 텀블러 테마 HTML 상단에 <meta name="color:Accent" ...>, <meta name="if:Endless Scroll" ...>, <meta name="select:Layout Width" ...>를 선언해 두면, 서버가 이를 추출해 대시보드 왼쪽에 컬러 피커, 토글 스위치, 드롭다운 메뉴를 자동으로 만들어 준다.
    • 티스토리 역시 index.xml에 정의된 <variables>(COLOR, BOOL, SELECT, STRING 등)와 홈 커버(<s_cover_group>) 유무를 분석해 동일한 조작 패널을 생성한다. 실제 블로그 관리자의 '스킨 편집' 화면에서 옵션을 바꿔보는 경험을 로컬에 그대로 옮겨온 셈이다.

⑤ 풍성한 기본 Mock 데이터와 커스텀 오버라이드

처음 실행했을 때 빈 화면이 나오면 테스트할 맛이 나지 않는다. 그래서 실행 즉시 확인해 볼 수 있도록 다양한 포스트 타입(텀블러의 텍스트·사진·인용구·채팅·비디오·Q&A, 티스토리의 본문·코드블록·댓글과 대댓글·홈 커버·태그 클라우드 등)이 담긴 기본 Mock 데이터를 내장했다. 또한 텀블러에서 무한 스크롤을 구현할 때 자주 쓰는 /page/:num 엔드포인트도 가상 API로 지원한다.

만약 내 실제 블로그의 글 제목이나 카테고리 구조로 테스트해 보고 싶다면, 프로젝트 폴더에 tumblr-mock.json이나 tistory-mock.json 파일 하나만 만들어 원하는 항목을 덮어쓰면 된다.


마치며

직접 텀블러와 티스토리 테마를 고치면서 느꼈던 작은 불편함을 해결하려고 시작한 프로젝트였는데, 만들어 두고 나니 이제는 테마를 수정할 때 없어서는 안 될 도구가 되었다. 예전에는 색상 하나, 여백 몇 픽셀 고치는 데도 스킨을 압축해서 올리고 확인하느라 진이 빠졌다면, 이제는 로컬에서 뷰포트와 스킨 옵션을 이리저리 바꿔가며 훨씬 즐겁게 작업하고 있다.

티스토리 스킨이나 텀블러 커스텀 테마를 직접 만들고 다듬는다면 한 번쯤 가볍게 사용해 보길 추천한다.

  • Tumblr 테마 프리뷰어: npx tumblr-theme-preview (GitHub)
  • Tistory 스킨 프리뷰어: npx tistory-theme-preview (GitHub)
반응형