nuxtContent로 블로그 마이그레션

개발자 블로그 프레임워크 마이그레이션

개발자 블로그 개인 히스토리

개발자 블로그 니즈 정의

nuxt content 선택

nuxtContent 시작하기

npx nuxi@latest init . -t content

기존 댓글 컴포넌트 보존

import React from 'react';
import { useEffect, useRef } from 'react';
import { useColorMode } from '@docusaurus/theme-common';

const utterancesSelector = 'iframe.utterances-frame';

/**
 * @see https://younho9.dev/docusaurus-manage-docs-2
 * @see https://docusaurus.io/docs/next/api/themes/configuration#use-color-mode
 * 위 두 자료를 결합해서 블로그의 다크모드를 구현했습니다.
 */

function Comment() {
  const containerRef = useRef(null);
  const { colorMode: utterancesTheme } = useColorMode();

  useEffect(() => {
    const utterancesEl = containerRef.current.querySelector(utterancesSelector);

    const createUtterancesEl = () => {
      const script = document.createElement('script');
      script.src = 'https://giscus.app/client.js';
      script.setAttribute('data-repo', '유저이름/블로그_레포이름'); // 예: arch-spatula/arch-spatula.github.io
      script.setAttribute('data-repo-id', '본인레포_아이디'); // 여기
      script.setAttribute('data-category', 'General');
      script.setAttribute('data-category-id', '본인_카테고리_id'); // 여기
      script.setAttribute('data-mapping', 'pathname');
      script.setAttribute('data-strict', '0');
      script.setAttribute('data-reactions-enabled', '1');
      script.setAttribute('data-emit-metadata', '0');
      script.setAttribute('data-input-position', 'bottom');
      script.setAttribute('data-lang', 'ko');
      script.setAttribute('crossorigin', 'anonymous');
      script.setAttribute('data-theme', utterancesTheme);
      script.setAttribute('data-loading', 'lazy');

      script.async = true;
      containerRef.current.appendChild(script);
    };

    const postThemeMessage = () => {
      const message = {
        type: 'set-theme',
        theme: utterancesTheme,
      };
      utterancesEl.contentWindow.postMessage(message, 'https://utteranc.es');
    };

    utterancesEl ? postThemeMessage() : createUtterancesEl();
  }, [utterancesTheme]);

  return <div ref={containerRef} style= />;
}

export default Comment;

기존 댓글 컴포넌트 vue에 맞게 변환

<!-- comment.vue-->
<template>
  <div ref="comment"></div>
</template>

<script setup lang="ts">
const comment = useTemplateRef('comment');

const utterancesSelector = 'iframe.utterances-frame';
// ligth, dark, github-light, github-dark, dark_dimmed
const theme = 'dark_dimmed';

onMounted(() => {
  const utterancesEl = comment.value.querySelector(utterancesSelector);
  const createUtterancesEl = () => {
    const script = document.createElement('script');
    script.src = 'https://giscus.app/client.js';
    script.setAttribute('data-repo', '유저이름/블로그_레포이름'); // 예: arch-spatula/arch-spatula.github.io
    script.setAttribute('data-repo-id', '본인레포_아이디'); // 여기
    script.setAttribute('data-category', 'General');
    script.setAttribute('data-category-id', '본인_카테고리_id'); // 여기
    script.setAttribute('data-mapping', 'pathname');
    script.setAttribute('data-strict', '0');
    script.setAttribute('data-reactions-enabled', '1');
    script.setAttribute('data-emit-metadata', '0');
    script.setAttribute('data-input-position', 'bottom');
    script.setAttribute('data-lang', 'ko');
    script.setAttribute('crossorigin', 'anonymous');
    script.setAttribute('data-theme', utterancesTheme);
    script.setAttribute('data-loading', 'lazy');

    script.async = true;
    comment.value.appendChild(script);
  };

  const postThemeMessage = () => {
    const message = {
      type: 'set-theme',
      theme: theme,
    };
    utterancesEl.contentWindow.postMessage(message, 'https://utteranc.es');
  };

  utterancesEl ? postThemeMessage() : createUtterancesEl();
});
</script>

전체 설정

favicon 변경이 꽤 난해했습니다.

// index.vue
useHead({
  link: [{ rel: 'icon', type: 'image/svg+xml', href: 'favicon.svg' }],
});

nuxt css modules

https://nuxt.com/docs/getting-started/styling#css-modules

전역 CSS 적용

export default defineNuxtConfig({
  css: ['~/assets/css/main.css'],
});

nvchad에 스타일링 참고

<ContentRenderer class="markdown-body" :value="doc" />

nuxt content에 syntax highlight

content: {
		highlight: {
			// Theme used in all color schemes.
			theme: "github-dark",
			langs: ["c", "cpp", "java", "lua"],
			// OR
			//theme: {
			//// Default theme (same as single string)
			//default: "github-light",
			//// Theme used if `html.dark`
			//dark: "github-dark",
			//// Theme used if `html.sepia`
			//sepia: "monokai",
			//},
		},

		experimental: {
			search: {},
		},
	},

메인 페이지

const search = ref('');

/**
 * NOTE: 없으면 전체 선택
 * 클릭하면 로직 실행
 * 없으면 추가하고 있으면 제거하기
 * 0개면 true하고 다음 로직들을 생략
 * 태그가 있으면 블로그 태그 목록 중에 있는 목록만 보여줌
 */
const selectedTags = ref<string[]>([]);

필터 로직 추가

<template>
  <main>
    <input :class="$style.input" v-model="search" />
    <ContentList path="/blogs" v-slot="{ list }">
      <div v-for="blog in list" :key="blog._path">
        <div
          v-if="
            (blog.title?.includes(search) || blog.description?.includes(search)) &&
            (!selectedTags.length || selectedTags?.some((elem) => blog?.tags?.includes(elem)))
          "
        >
          <NuxtLink :to="blog._path">
            <h2></h2>
            <p></p>
          </NuxtLink>
          <div v-for="tag in blog.tags">
            <button
              @click="
                () => {
                  const idx = selectedTags.findIndex((val) => val === tag);
                  if (idx === -1) {
                    selectedTags.push(tag);
                  } else {
                    selectedTags.splice(idx, 1);
                  }
                }
              "
            >
              
            </button>
          </div>
        </div>
      </div>
    </ContentList>
  </main>
</template>
import type { QueryBuilderParams } from "@nuxt/content";

const query: QueryBuilderParams = {
  sort: [{ date: -1 }],
};

<ContentList :query="query" path="/blogs" v-slot="{ list }" ></ContentList>

codeblock 복사 버튼 만들기

export default (id) => {
  const docContent = document.getElementById(id);
  const preElements = docContent?.querySelectorAll('pre');

  preElements?.forEach(function (preElement) {
    const childDiv = preElement.querySelector('div');
    if (childDiv) return;

    const button = document.createElement('div');
    button.classList = 'copyBtn';
    button.ariaLabel = 'copy button';

    button.addEventListener('click', function () {
      button.classList = 'clickedCopyBtn';

      const content = preElement.textContent;
      navigator.clipboard.writeText(content);

      // reset to old copyIcon after 1s
      setTimeout(() => (button.classList = 'copyBtn'), 2000);
    });

    preElement.appendChild(button);
  });
};
<template>
  <template v-slot="{ doc }">
    <article>
      <!-- <h1></h1> -->
      
      
      <ContentRenderer id="DocContent" class="markdown-body dark_dimmed" :value="doc" />
    </article>
  </template>
</template>

<script setup lang="ts">
/**
 * 기능 자체는 동작함
 * TODO: 아이콘이 붙게 만들어야 함.
 */
const addBtn = (id: string) => {
  const docContent = document.getElementById(id);
  const preElements = docContent?.querySelectorAll('pre');

  preElements?.forEach(function (preElement) {
    const childDiv = preElement.querySelector('div');
    if (childDiv) return;

    // const button = document.createElement("div");
    const button = document.createElement('button');
    button.classList.add('copyBtn');
    button.ariaLabel = 'copy button';

    button.addEventListener('click', function () {
      button.classList.replace('copyBtn', 'clickedCopyBtn');

      const content = preElement.textContent ?? '';
      navigator.clipboard.writeText(content);

      // reset to old copyIcon after 1s
      setTimeout(() => button.classList.replace('clickedCopyBtn', 'copyBtn'), 2000);
    });

    preElement.appendChild(button);
  });
};
</script>

css로 아이콘 붙이기

.copyBtn {
  @apply !bg-slate !hover:bg-green3;
  @apply i-uil:clipboard cursor-pointer;
}

.clickedCopyBtn {
  @apply !bg-green-3;
  @apply i-line-md:confirm-circle;
}

/* copy button */
#DocContent pre button {
  @apply rounded-full w-fit h-fit p-0;
  @apply text-slate-5 bg-transparent;
}

#DocContent pre button :hover {
  @apply text-red;
}
body {
  mask: url(public/clipboard.svg) no-repeat center !important;
}
/* copy button */
#DocContent pre {
  position: relative;
}
#DocContent pre div {
  width: 36px;
  height: 36px;
  position: absolute;
  border-radius: 4px;
  top: 0;
  right: 0;
  margin: 8px;
}
.copy-warrper {
  width: 100%;
  aspect-ratio: 1 / 1;
}
.copy-warrper:hover {
  background-color: #22272e;
}
#DocContent pre div button {
  all: unset;
  width: 100%;
  aspect-ratio: 1 / 1;
  cursor: pointer;
  background-repeat: no-repeat;
  background-position: center center;
}

.copyBtn {
  background-color: #adbac7 !important;
  mask: url(public/clipboard.svg) no-repeat center !important;
}

.clickedCopyBtn {
  background-color: #10b981 !important;
  mask: url(public/clipboard-check.svg) no-repeat center !important;
}

hover하는 동안만 아이콘 보이기

div {
  display: none;
}

a:hover + div {
  display: block;
}
#DocContent pre:hover > div {
  display: block;
}

#DocContent pre div {
  width: 36px;
  height: 36px;
  position: absolute;
  border-radius: 4px;
  top: 0;
  right: 0;
  margin: 8px;
  display: none;
}

가로스크롤 엣지 케이스 처리

/**
 * @see https://stackoverflow.com/questions/6838104/pure-javascript-method-to-wrap-content-in-a-div
 */
function wrap(el: Element, wrapper: Element) {
  if (el.parentNode) el.parentNode.insertBefore(wrapper, el);
  wrapper.appendChild(el);
}

기타 기능

<!-- app.vue -->
<template>
  <nav>
    <ul>
      <li>
        <NuxtLink to="/">home</NuxtLink>
      </li>
    </ul>
  </nav>
  <div>
    <NuxtPage />
  </div>
</template>

전체 tag 목록과 개수 보여주기

<script lang="ts" setup>
const tags = ref<Map<string, number>>(new Map());

await queryContent('blogs')
  .find()
  .then((res) => res.map((elem) => elem?.tags))
  .then((res: string[][]) => {
    res.forEach((elems) => {
      elems.forEach((elem) => {
        if (tags.value.get(elem)) {
          tags.value.set(elem, tags.value.get(elem) + 1);
        } else {
          tags.value.set(elem, 1);
        }
      });
    });
  });
</script>

<template>
  <div :class="$style['tag-warpper']">
    <button :class="$style['button-tag']" v-for="tag in tags"> </button>
  </div>
</template>

이전글 다음글 이동

const route = useRoute();
const { navPageFromPath, navDirFromPath } = useContentHelpers();
const { data: navigation } = await useAsyncData('blogs', () => fetchContentNavigation());

type PageItem = {
  title: string;
  _path: string;
  draft?: boolean;
};

const prevPage = ref<{ title: string; _path: string } | null>(null);
const nextPage = ref<{ title: string; _path: string } | null>(null);

const page = navPageFromPath(route.path, navigation.value);

for (let idx = 0; idx < navigation.value[0]?.children.length; idx++) {
  const elem = navigation.value[0]?.children[idx] as PageItem;
  let prev: null | PageItem = null;

  if (idx === 0) prev = null;
  else prev = navigation.value[0]?.children[idx - 1] as PageItem;

  let next: null | PageItem = null;
  if (idx === navigation.value[0]?.children.length - 1) next = null;
  else next = navigation.value[0]?.children[idx + 1] as PageItem;

  if (elem?._path === page?._path) {
    if (prev) prevPage.value = { title: prev.title, _path: prev._path };
    else prevPage.value = null;
    if (next) nextPage.value = { title: next.title, _path: next._path };
    else nextPage.value = null;
    break;
  }
}
<NuxtLink v-if="prevPage?._path" :to="prevPage?._path">
  <div></div>
</NuxtLink>
<NuxtLink v-if="nextPage?._path" :to="nextPage?._path">
  <div></div>
</NuxtLink>

DIY로 만드는 TOC

const route = useRoute();

const { data } = await useAsyncData(`${route.path}`, queryContent(`${route.path}`).findOne);
data.value.body.children.forEach((element: { tag: string; props: { id: string } }) => {
  switch (element.tag) {
    case 'h1':
    case 'h2':
    case 'h3':
    case 'h4':
    case 'h5':
    case 'h6':
      console.log(element.props.id);
      break;
    default:
      break;
  }
});
const toc = ref<{ heading: string; depth: 1 | 2 | 3 | 4 | 5 | 6 }[]>([]);

data.value.body.children.forEach((element: { tag: string; props: { id: string } }) => {
  switch (element.tag) {
    case 'h1':
      toc.value.push({ heading: element.props.id, depth: 1 });
      break;
    case 'h2':
      toc.value.push({ heading: element.props.id, depth: 2 });
      break;
    case 'h3':
      toc.value.push({ heading: element.props.id, depth: 3 });
      break;
    case 'h4':
      toc.value.push({ heading: element.props.id, depth: 4 });
      break;
    case 'h5':
      toc.value.push({ heading: element.props.id, depth: 5 });
      break;
    case 'h6':
      toc.value.push({ heading: element.props.id, depth: 6 });
      break;
    default:
      break;
  }
});
<template>
  <div :class="$style['toc-warpper']">
    <div v-for="item in toc">
      <NuxtLink
        :class="$style['heading-link']"
        :to="`#${item.heading}`"
        :style="{ padding: `0 0 0 ${(item.depth - 1) * 24}px` }"
        ></NuxtLink
      >
    </div>
  </div>
</template>
<style module>
.toc-warpper {
  position: fixed;
  top: 96px;
  left: calc(50vw + 464px);
  z-index: 0;
}

.heading-link {
  color: #444c56b3;
  text-decoration: underline;
  font-size: 16px;
  line-height: 1.5;

  overflow: hidden;
  white-space: normal;
  text-overflow: ellipsis;
}
.heading-link:hover {
  color: #478be6;
}
</style>

개발자 블로그 빌드 시도

draft 숨기기 기능 미동작

This is a known issue, open for about a year now: https://github.com/nuxt/content/issues/1523

개발자 블로그 빌드 실패

pnpm run generate

github actions

공식 문서 확인

name: Deploy to GitHub Pages
on:
  workflow_dispatch:
  push:
    branches:
      - main
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - run: corepack enable
      - uses: actions/setup-node@v3
        with:
          node-version: '20'
      # Pick your own package manager and build script
      - run: npm install
      - run: npx nuxt build --preset github_pages
      - name: Upload artifact
        uses: actions/upload-pages-artifact@v3
        with:
          path: ./.output/public

  # Deployment job
  deploy:
    # Add a dependency to the build job
    needs: build
    # Grant GITHUB_TOKEN the permissions required to make a Pages deployment
    permissions:
      pages: write # to deploy to Pages
      id-token: write # to verify the deployment originates from an appropriate source
    # Deploy to the github_pages environment
    environment:
      name: github_pages
      url: $
    # Specify runner + deployment step
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v4
npx nuxt build --preset github_pages

결국 성공한 방법

name: Test deployment

on:
  pull_request:
    branches:
      - main
    # 트리거, 경로 등을 추가로 정의하려면 gh 액션 문서를 참고하세요.
    # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#on

jobs:
  test-deploy:
    name: Test deployment
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - run: corepack enable
      - uses: actions/setup-node@v3
        with:
          node-version: '20'
      # Pick your own package manager and build script
      - run: pnpm install
      - run: pnpm generate
# https://github.com/actions/deploy-pages#usage
name: Deploy to GitHub Pages
on:
  workflow_dispatch:
  push:
    branches:
      - main
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: corepack enable
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      # Pick your own package manager and build script
      - run: pnpm install
      - run: pnpm generate --preset github_pages
      - run: touch .output/public/.nojekyll
      - name: Upload artifact
        uses: actions/upload-pages-artifact@v3
        with:
          path: ./.output/public
  # Deployment job
  deploy:
    # Add a dependency to the build job
    needs: build
    # Grant GITHUB_TOKEN the permissions required to make a Pages deployment
    permissions:
      pages: write # to deploy to Pages
      id-token: write # to verify the deployment originates from an appropriate source
    # Deploy to the github_pages environment
    environment:
      name: github_pages
      url: $
    # Specify runner + deployment step
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v4

결론