빌드 로직
- 지금 읽고 있는 내용들은 절대 선형적으로 작성된 것이 아닙니다. 대부분의 개발은 이 글을 읽고 있는 사람도 경험해봤듯이 순환적입니다. 가장 단순하고 작은 최소한의 빌드 로직에서 점차 기능을 붙여나간 방식입니다.
- 빌드 로직에서 제일 중요한 부분이 있다면 그것은 템플릿처리 로직입니다. 템플릿처리를 위해 라이브러리를 설치할 수 있었지만 저는 극도로 단순해서 AI보고 만들어달라고 했습니다.
- 빌드와 관련 된 모든 내용은 github 레포에 build를 위해 만들어둔 스크립트에서 확인하실 수 있습니다.
- 많은 로직은 opus의 힘을 빌려 작성했습니다.
- 템플릿처리를 해야겠다는 생각은 제가 했습니다.
- 최대한 숨겨진 제어흐름이 발생하지 않게 만들기 위해 1번 실행할 함수로 만들었습니다.
build 로직
/**
* 길어서 생략
*/
const build = async () => {
// 길어서 생략
};
build();
- 최종 배포는
build.ts에서 build 함수 1개만 실행하도록 하는 것이 설계 의도입니다. 모든 것은 질렬적으로 추상화 없이 해결되어야 합니다.
{
// ... 생략
"scripts": {
"build": "tsx app/build.ts"
// ... 생략
}
}
- 단순하게 단 하나의 함수를 실행하고 모든 것을 dist 파일로 저장하게 만드는 것이 의도입니다.
템플릿 로직
- 템플릿을 어떻게 처리할지 의문이 많이 있었습니다. 예전에 ejs라는 것을 알게 되어서 express로 서버에서 html을 생성해서 응답한다는 것을 줍줍한 것이 있습니다. 하지만 너무 오래된 기술이라 거부감이 있었습니다.
handlebars.js라는 것을 알게되었습니다. 사실 AI에게 질문했습니다. 템플릿처리를 해주는 라이브러리라고 합니다.
hello {{foo}}!
{ "foo": "world" }
- 위처럼 템플릿 문자열에 json 데이터를 넣으면
hello world!가 출력되게 만드는 것이 핵심입니다. 이런 저런 다양한 표현이 있습니다.
{{#if light}}
let there be light
{{/if light}}
{ "light": false }
- 위는 공식 문서의 표현이 아닙니다. 공식 문서의 경우
{{#light}} {{/light}}으로 표현해야 합니다.
- 저는 공식문서에서 이렇게 표현하는 것이 명확하지 않다고 보고 있습니다. 단순히 문자열을 받을지 부울을 받을지 배열처럼 연속된 정보를 받을지 너무 자유롭다고 봅니다.
- handlebars.js의 경우
with로 처리하고 있습니다.
- 지금 템플릿 로직으로 처리된 결과는 빈문자열(
"")이 나와야 합니다.
{{#each adventureTime}}
{{name}} the {{ethnicity}}
{{/each}}
{
"adventureTime": [
{ "name": "Jake", "ethnicity": "Dog" },
{ "name": "Finn", "ethnicity": "Human" }
]
}
Jake the Dog
Finn The Human
- 위는 목록처럼 연속된 템플릿입니다. 참고로 중첩구조를 자질 수 있습니다.
템플릿 파일
- 실제로 1번만 읽으면 되는 파일을 초기화할 때 호출합니다. 데이터 사이즈도 별로 안 커서 메모리에서 계속 읽기만 하면 됩니다.
const appTemplate = await readFile(join(process.cwd(), 'app', 'templates', 'app.html'), 'utf8');
const postTemplate = await readFile(join(process.cwd(), 'app', 'templates', 'post.html'), 'utf8');
const mainTemplate = await readFile(join(process.cwd(), 'app', 'templates', 'main.html'), 'utf8');
const searchTemplate = await readFile(join(process.cwd(), 'app', 'templates', 'search.html'), 'utf8');
- 위 4개의 파일을 모두 읽기만 합니다.
- 루트역할을 하고 모든 내용을 받을 것은
appTemplate입니다.
appTemplate은 searchTemplate을 모두 받습니다. 또 조건부로 postTemplate 혹은 mainTemplate을 받아야 합니다.
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="/style.css" />
<title>Arch-Spatula의 개발 레시피{{title}}</title>
<meta name="description" content="{{description}}" />
<meta name="tags" content="{{tags}}" />
<meta name="authors" content="{{authors}}" />
<meta name="date" content="{{date}}" />
</head>
<body>
<nav id="nav">
<ul>
<li class="blog-logo-container">
<a class="blog-logo" href="/"
><img class="logo-img" src="/profile.png" alt="blog logo" />
<p>home</p></a
>
</li>
<li>
<button id="popup-btn">
<img class="icon" src="/search.svg" alt="search" />
<p class="popup-text">Ctrl + k</p>
</button>
</li>
<li class="github-link">
<a href="https://github.com/arch-spatula/arch-spatula.github.io" target="_blank">GitHub</a>
</li>
</ul>
</nav>
<div id="app">{{body}}</div>
<div id="search" class="hidden">{{search}}</div>
<footer class="footer"></footer>
<script src="/script.js"></script>
</body>
</html>
- 여기서 동적으로 바꿔 줄 것은
{{title}}, {{body}}, {{search}}입니다. 즉 루트 역할을 해야 합니다.
{{title}}은 블로그 글 제목에 따라 조건부로 처리되어야 합니다.
- 있으면
Arch-Spatula의 개발 레시피 - foo bar baz처럼 작성되어야 합니다.
- 없으면
Arch-Spatula의 개발 레시피으로 작성되어야 합니다.
{{body}}는 조건부로 처리됩니다.
index.html의 경우 게시글을 볼 수 있는 목록이 됩니다.
2025-11-23-new-blog.html의 경우 마크다운을 처리한 내용을 볼 수 있습니다.
<div id="search-popup">
<div id="popup-container">
<form id="search-form">
<img id="search-icon" src="/search.svg" alt="search" /><input
id="search-input"
type="search"
name="search-input"
placeholder="Search"
autocomplete="off"
/>
<div></div>
</form>
<ul id="search-tag-list">
{{#each tags}}
<li class="search-tag-item">
<a class="tag-link" href="#" data-tag="{{name}}">#{{name}} - {{count}}</a>
</li>
{{/each}}
</ul>
<ul id="search-blog-list">
{{#each posts}}
<li class="search-item">
<a href="{{filePath}}" class="search-item-link">{{title}}</a>
</li>
{{/each}}
</ul>
</div>
</div>
<div id="overlay"></div>
- 이 내용이
{{search}} 안에 위치하게 됩니다.
- 모든 태그와 게시글 목록을 확인할 수 있습니다.
- 일단 html로 모두 랜더링이 되고 css로 숨기고 있습니다. 이 부분은 다음편에 공개하겠습니다. 빌드타임에 처리할 수 없고 브라우저에서 처리되어야 하는 로직입니다.
<main id="main">
<ul class="blog-list">
{{#each posts}}
<li class="blog-item">
<div>
<a href="{{filePath}}" class="blog-link">{{title}}</a>
<p class="blog-date">{{date}}</p>
<ul class="tag-list">
{{#each tags}}
<li class="tag-item" data-id="{{this}}">
<a href="#" data-tag="{{this}}" class="tag-text">#{{this}}</a>
</li>
{{/each}}
</ul>
<p class="blog-description">{{description}}</p>
<hr class="blog-divider" />
</div>
</li>
{{/each}}
</ul>
</main>
- 이 내용은
{{body}}에 들어갑니다.
index.html에 표현될 내용입니다.
- 글목록 속에 태그가 중첩된 구조로 표현되어야 합니다.
<ul class="tag-list">
{{#each tags}}
<li class="tag-item" data-id="{{this}}">
<a href="#" data-tag="{{this}}" class="tag-text">#{{this}}</a>
</li>
{{/each}}
</ul>
<div id="content" class="markdown-body">{{content}}</div>
<aside id="toc">
<ul class="toc-list">
{{#each toc}}
<li class="toc-item toc-level-{{level}}">
<a href="#{{id}}">{{heading}}</a>
</li>
{{/each}}
</ul>
</aside>
<nav class="post-navigation">
{{#if previousPost}}
<div class="previous-post">
<span class="nav-label">← 이전 글</span>
<a href="{{previousPostFilePath}}" class="nav-link">{{previousPostTitle}}</a>
</div>
{{/if}} {{#if nextPost}}
<div class="next-post">
<span class="nav-label">다음 글 →</span>
<a href="{{nextPostFilePath}}" class="nav-link">{{nextPostTitle}}</a>
</div>
{{/if}}
</nav>
<div id="comments">
<!-- giscus 댓글 기능 생략 -->
</div>
- 이 내용은
{{body}}에 들어갑니다.
- 상세 게시글의 템플릿입니다. 핵심은
<div id="content" class="markdown-body">{{content}}</div>입니다.
- 댓글은 SPA처럼 작성하게 될 것을 걱정할 것이 없습니다. 그냥 html로 붙이되기 때문에 상당히 단순합니다.
toc와 이전 이후 글 보기도 추가를 했습니다. index로 나가서 다시 클릭하거나 검색을 누르고 이동하게 만드는 것은 액션코스트가 너무 높습니다.
render
/**
* 완전한 템플릿 렌더링
* 조건문, 반복문, 일반 플레이스홀더를 모두 처리
* @param template - 템플릿 문자열
* @param data - 렌더링할 데이터
* @returns 렌더링된 최종 문자열
*/
export const render = (template: string, data: Record<string, any>): string => {
// 1. 조건문 처리
// {{#if foo}} {{/if}}
let result = renderConditional(template, data);
// 2. 반복문 처리
// {{#each bar}} {{/each}}
result = renderEach(result, data);
// 3. 일반 플레이스홀더 처리 (모든 값을 문자열로 변환)
const stringData: Record<string, string> = {};
Object.entries(data).forEach(([key, value]) => {
if (Array.isArray(value)) {
stringData[key] = value.join(', ');
} else if (typeof value === 'object' && value !== null) {
stringData[key] = JSON.stringify(value);
} else {
stringData[key] = String(value ?? '');
}
});
// {{foo}}
result = renderTemplate(result, stringData);
return result;
};
render 함수는 평문, 조건문, 반복문 3가지를 처리합니다.
- 로직도 테스트도 단순합니다.
- 위 랜더링 로직을 갖고 문자열을 하나로 만들어서 순서대로 파일쓰기를 하는 것이 전부입니다.
/** 블로그 글 목록 템플릿 */
const MainHtml = render(mainTemplate, { posts: metaJson });
/** topbar, 배경 등 고정 요소들 */
const AppHtml = render(appTemplate, { body: MainHtml, search: SearchHtml });
/** index.html 파일 저장 */
writeFileSync(join(process.cwd(), 'dist', 'index.html'), AppHtml, 'utf8');
메타데이터 처리
- 메타데이터는 마크다운 파일을 모두 1번 읽고 순서대로 처리합니다. 먼저 모든 메타데이터를 모으고 검색 템플릿에 처리하게 만드는 것이 의도입니다.
- 이렇게 되면 마크다운 본문의 내용을 처리하면서 한번더 파일 읽기를 처리하게 됩니다. 다른 방법이 있는지 잘 모르겠습니다. 알고 있는 것이 있다면 알려주시기 바랍니다.
const metaJson: Metadata[] = [];
// ... (생략)
// /blogs의 모든 마크다운 파일 가져오기
const blogsDir = join(process.cwd(), 'blogs');
const markdownfiles = await listUpMarkdownFiles(blogsDir);
// 메타 정보와 마크다운 콘텐츠를 저장할 맵 (파일 경로 기준)
const contentMap = new Map<string, string>();
// 메타 정보 처리하기
for (const file of markdownfiles) {
const content = await readMarkdownFile(file.filePath);
const { metadata } = processMetaData(content, file.filePath, blogsDir);
if (metadata.draft) {
file.isProcessed = true;
continue;
}
// 마크다운 콘텐츠도 함께 저장
const { markdownContent } = splitMetadataAndContent(content);
contentMap.set(file.filePath, markdownContent);
metaJson.push(metadata);
// ... (생략)
}
metaJson에 메타정보들을 저장해야 합니다.
- 단순하게 파일들을 순서대로 읽고 메타정보들만 먼저 처리합니다.
- 이렇게 하는 이유는 결국에 검색 기능이 메타정보를 접근해야 하기 때문입니다.
export type Metadata = {
title?: string; // 제목
date?: string; // 발행일
tags?: string[]; // 태그들
description?: string; // 설명
authors?: string[]; // 저자
draft?: boolean; // 공개여부
filePath?: string; // 파일 경로
};
// ... (생략)
// 태그 정보 수집 (태그별 개수 포함)
const tagMap = new Map<string, number>();
metaJson.forEach((meta) => {
if (meta.tags) {
meta.tags.forEach((tag) => {
tagMap.set(tag, (tagMap.get(tag) || 0) + 1);
});
}
});
// 태그를 배열로 변환 (count 포함)
const tags = Array.from(tagMap.entries())
.map(([tag, count]) => ({ name: tag, count }))
.sort((a, b) => a.name.localeCompare(b.name)); // 알파벳 순으로 정렬
// dist/meta.json 파일로 쓰기
writeFileSync(join(process.cwd(), 'dist', 'meta.json'), JSON.stringify(metaJson.reverse(), null, 2), 'utf8');
// 검색 템플릿 렌더링
const SearchHtml = render(searchTemplate, { tags, posts: metaJson });
// ... (생략)
- 이렇게만 하면 검색 로직은 모두 처리가 된 것입니다.
- 이 검색 로직은 문자열만 만들고 직접 저장하는데 활용하지 않습니다.
마크다운 처리
const build = async () => {
// ... (생략)
// 마크다운 파일 쓰기
for (const file of markdownfiles) {
// 이런저런 경로 찾는 로직
// 이런저런 마크다운 파일 주소 알아내고 본문 파일하는 로직
const targetMeta = metaJson[targetMetaIndex];
// 이전/다음 글 정보 계산 (metaJson은 최신순으로 정렬되어 있음)
let previousPost: PostNavigation | undefined;
let nextPost: PostNavigation | undefined;
// 이전 글 (더 오래된 글 = 인덱스가 더 큼)
if (targetMetaIndex < metaJson.length - 1) {
const prevMeta = metaJson[targetMetaIndex + 1];
previousPost = {
filePath: prevMeta.filePath,
title: prevMeta.title,
};
}
// 다음 글 (더 최신 글 = 인덱스가 더 작음)
if (targetMetaIndex > 0) {
const nextMeta = metaJson[targetMetaIndex - 1];
nextPost = {
filePath: nextMeta.filePath,
title: nextMeta.title,
};
}
const htmlContent = await processMarkdownFile(
markdownContent,
targetMeta,
appTemplate,
postTemplate,
SearchHtml,
previousPost,
nextPost,
);
await writeHtmlFile(file.filePath, htmlContent, blogsDir);
file.isProcessed = true;
}
// ... (생략)
};
- 위는 각각의 게시물 파일쓰기입니다.
- 메타정보로 이전 이후 파일 순서를 알고 있습니다.
- 검색 템플릿을 문자열로 전달합니다. 물론 다른 템플릿문자열도 일단 전달만 합니다.
/**
* 마크다운 콘텐츠를 HTML로 변환하고 템플릿에 렌더링하는 함수
*
* @param markdownContent - 마크다운 콘텐츠 (frontmatter 제외)
* @param metadata - 이미 파싱된 메타데이터
* @param appTemplate - 앱 템플릿
* @param postTemplate - 포스트 템플릿
* @param searchTemplate - 검색 템플릿
* @param previousPost - 이전 글 정보
* @param nextPost - 다음 글 정보
* @returns 렌더링된 HTML 콘텐츠
*
* @example
* const markdownContent = `# Test Title\n\nThis is content.`;
* const metadata = { title: 'Test Title', date: '2021-01-01' };
* const htmlContent = await processMarkdownFile(markdownContent, metadata, appTemplate, postTemplate, searchTemplate, previousPost, nextPost);
*/
const processMarkdownFile = async (
markdownContent: string,
metadata: Metadata,
appTemplate: string,
postTemplate: string,
searchTemplate: string,
previousPost?: PostNavigation,
nextPost?: PostNavigation,
) => {
const { html: htmlContent, toc } = await convertMarkdownToHtml(markdownContent);
const bodyHtml = render(postTemplate, {
content: htmlContent,
tags: metadata.tags ?? [],
toc,
previousPost: !!previousPost,
previousPostFilePath: previousPost?.filePath ?? '',
previousPostTitle: previousPost?.title ?? '',
nextPost: !!nextPost,
nextPostFilePath: nextPost?.filePath ?? '',
nextPostTitle: nextPost?.title ?? '',
});
const appHtml = render(appTemplate, {
body: bodyHtml,
title: ` - ${metadata.title ?? ''}`,
description: metadata.description ?? '',
tags: metadata.tags?.join(', ') ?? '',
authors: metadata.authors?.join(', ') ?? '',
date: metadata.date ?? '',
search: searchTemplate,
});
return appHtml;
};
- 한 계층 내부를 보면 상당히 단순합니다. body를 먼저 만들고 app을 다음으로 만드는 것이 전부입니다.
- 실제 내용을 만드는 것은
htmlContent와 toc 뿐입니다.
/**
* 마크다운 콘텐츠를 HTML로 변환 (shiki 코드 하이라이팅 적용)
* @returns HTML 문자열과 TOC 데이터를 포함한 객체
*/
export const convertMarkdownToHtml = async (markdownSource: string): Promise<{ html: string; toc: TocItem[] }> => {
const toc: TocItem[] = [];
const htmlText = await unified()
.use(markdown)
.use(remarkGfm)
.use(remarkDirective)
.use(remarkCallout)
.use(remark2rehype)
.use(rehypeSlug)
.use(rehypeExtractToc, { toc })
.use(rehypeShiki, {
theme: 'catppuccin-mocha',
})
.use(html)
.process(markdownSource);
if (typeof htmlText.value === 'string') {
// 템플릿 문법을 이스케이프하여 템플릿 엔진에서 처리되지 않도록 함
return { html: escapeTemplateSyntax(htmlText.value), toc };
}
return { html: '', toc: [] };
};
- 의존성을 많이 사용하는 부분이 이분분입니다. 단순하게 마크다운 문자열을 받아 html 문법으로 변환하는 것이 전부입니다.
- 여기서 toc를 따로 접근하기 위해 추가 플러그인만 설치했습니다. 굳이 정규표현식으로 탐지하지 말고 AST로 접근하는 것이 더 신뢰할 수 있을 것이라고 봤습니다.
메인페이지
// ... (생략)
const MainHtml = render(mainTemplate, { posts: metaJson });
const AppHtml = render(appTemplate, { body: MainHtml, search: SearchHtml });
writeFileSync(join(process.cwd(), 'dist', 'index.html'), AppHtml, 'utf8');
// ... (생략)
index.html 파일을 저장하는 로직입니다.
- 복잡하게 처리될 로직이 없습니댜. 이미 글의 순서를 담고 있는 배열이 있습니다. 그냥 그 순서 그대로 보여주면 됩니다.
404 로직
const NotFoundHtml = render(appTemplate, {
body: '<h1 style="text-align: center; margin-top: 100px; color: #D1D7E0;">404 - Page Not Found</h1>',
search: SearchHtml,
});
writeFileSync(join(process.cwd(), 'dist', '404.html'), NotFoundHtml, 'utf8');
- 이거도 중요합니다. 없는 페이지를 접근하는 예외처리를 잘했는지 확인하는 부분입니다.
- 여전히
appTemplate에 감싸져야 합니다.
- 아주 귀찮아서 인라인으로 css를 작성했습니다.