빌드 로직

build 로직

/**
 * 길어서 생략
 */
const build = async () => {
  // 길어서 생략
};

build();
{
  // ... 생략
  "scripts": {
    "build": "tsx app/build.ts"
    // ... 생략
  }
}

템플릿 로직

hello {{foo}}!
{ "foo": "world" }
{{#if light}}
  let there be light
{{/if light}}
{ "light": false }
{{#each adventureTime}}
  {{name}} the {{ethnicity}}
{{/each}}
{
  "adventureTime": [
    { "name": "Jake", "ethnicity": "Dog" },
    { "name": "Finn", "ethnicity": "Human" }
  ]
}
Jake the Dog
Finn The Human

템플릿 파일

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');
<!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>
<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>
<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>
<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>

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;
};
/** 블로그 글 목록 템플릿 */
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');

메타데이터 처리

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);

  // ... (생략)
}
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;
};
/**
 * 마크다운 콘텐츠를 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: [] };
};

메인페이지

// ... (생략)
const MainHtml = render(mainTemplate, { posts: metaJson });
const AppHtml = render(appTemplate, { body: MainHtml, search: SearchHtml });
writeFileSync(join(process.cwd(), 'dist', 'index.html'), AppHtml, 'utf8');
// ... (생략)

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');