블로그를 운영하다 보면 내 마음에 쏙 드는 테마(스킨)를 직접 만들거나 수정하고 싶어질 때가 있다. 나 역시 텀블러(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>) 유무를 분석해 동일한 조작 패널을 생성한다. 실제 블로그 관리자의 '스킨 편집' 화면에서 옵션을 바꿔보는 경험을 로컬에 그대로 옮겨온 셈이다.
- 텀블러 테마 HTML 상단에
⑤ 풍성한 기본 Mock 데이터와 커스텀 오버라이드
처음 실행했을 때 빈 화면이 나오면 테스트할 맛이 나지 않는다. 그래서 실행 즉시 확인해 볼 수 있도록 다양한 포스트 타입(텀블러의 텍스트·사진·인용구·채팅·비디오·Q&A, 티스토리의 본문·코드블록·댓글과 대댓글·홈 커버·태그 클라우드 등)이 담긴 기본 Mock 데이터를 내장했다. 또한 텀블러에서 무한 스크롤을 구현할 때 자주 쓰는 /page/:num 엔드포인트도 가상 API로 지원한다.
만약 내 실제 블로그의 글 제목이나 카테고리 구조로 테스트해 보고 싶다면, 프로젝트 폴더에 tumblr-mock.json이나 tistory-mock.json 파일 하나만 만들어 원하는 항목을 덮어쓰면 된다.
마치며
직접 텀블러와 티스토리 테마를 고치면서 느꼈던 작은 불편함을 해결하려고 시작한 프로젝트였는데, 만들어 두고 나니 이제는 테마를 수정할 때 없어서는 안 될 도구가 되었다. 예전에는 색상 하나, 여백 몇 픽셀 고치는 데도 스킨을 압축해서 올리고 확인하느라 진이 빠졌다면, 이제는 로컬에서 뷰포트와 스킨 옵션을 이리저리 바꿔가며 훨씬 즐겁게 작업하고 있다.
티스토리 스킨이나 텀블러 커스텀 테마를 직접 만들고 다듬는다면 한 번쯤 가볍게 사용해 보길 추천한다.