SvelteKit Blog 만들기 - 1일차
포스트
취소

SvelteKit Blog 만들기 - 1일차

JoyOfCode 블로거의 SvelteKit Blog 만들기를 따라한 클론 프로젝트입니다. 소스는 깃허브 에 있습니다.

참고문서

화면 캡쳐

svltk-blog-joyofcode-list 블로그 포스트 리스트 (메인)

svltk-blog-joyofcode-post1 블로그 포스트 페이지

1. 프로젝트 생성

If you’re seeing this, you’ve probably already done this step. Congrats!

1
2
3
4
5
6
7
8
$ pnpm create svelte@latest svltk-blog-joyofcode
  - Skeleton project
  - TypeScript

$ cd svltk-blog-joyofcode
$ pnpm install

$ pnpm run dev

2. Developing

라이브러리 추가

  • open-props : Open Source CSS Variables
    • 색상, 타이포그래피, 그림자 등의 CSS 변수를 제공합니다.
  • lucide-svelte : Svelte 를 위한 lucide icon library
    • fontawesome/icons 의 svelte 버전이라고 생각하면 됩니다.
  • 폰트 : @fontsource/manrope @fontsource/jetbrains-mono
1
2
3
4
5
pnpm i open-props lucide-svelte @fontsource/manrope @fontsource/jetbrains-mono

pnpm i -D mdsvex  # preprocessor for Svelte

pnpm i shiki  # Syntax Highlighter

open-props 예시 (postcss)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@import 'https://unpkg.com/open-props';

.card {
  border-radius: var(--radius-2);
  padding: var(--size-fluid-3);
  box-shadow: var(--shadow-2);

  &:hover {
    box-shadow: var(--shadow-3);
  }

  @media (--motionOK) {
    animation: var(--animation-fade-in);
  }
}

레이아웃 구성

src/routes/+layout.svelte

  • Header : Title, Menu(About, Contact, RSS), Theme Toggle
  • Main
  • Footer : CopyRight, Link

src/routes/[slug]/*

  • +page.svelte : Post 페이지 (article)
    • svelte:head : title, seo(og 태그)
    • hgroup : title, date
    • tags : tag list
    • content : svelte:component 로 md 파일을 렌더링
  • +page.ts : slug 이름의 md 파일을 읽어와 content, meta 를 전달
    • meta : title, date, tags(categories)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import type { PageLoad } from './$types';
import { error } from '@sveltejs/kit';

export const load: PageLoad = async ({ params }) => {
  try {
    const post = await import(`../../posts/${params.slug}.md`);

    return {
      content: post.default,
      meta: post.metadata,
    };
  } catch (e) {
    throw error(404, `Could not find post with slug "${params.slug}"`);
  }
};

src/posts/*.md

  • first-post.md, second-post.md, example.md (Counter 버튼 예시)
  • counter.svelte : 예시용 Svelte 컴포넌트

svltk-blog-joyofcode-post3

src/app.html

src/routes/error.html

  • 사용자 정의 템플릿으로 error 메시지 출력

코드 작성 (TS)

lib 폴더

$lib/index.ts

  • title, description 정의
  • dev 모드인지 확인하여 url 을 다르게 설정
1
2
import { dev } from '$app/environment';
export const url = dev ? 'http://localhost:5173/' : 'https://joyofcode.xyz/';

$lib/theme.ts

  • writable store 를 사용하여 theme 을 저장하고 변경
    • localStorage.setItem
    • documentElement.setAttribute

$lib/types.ts

  • Post 타입 정의
  • Category 타입 정의

$lib/utils.ts

  • 문자열 날짜값을 medium date format 으로 출력

API handlers

src/routes/api/posts/+server.ts

  • GET handler : /api/posts
  • getPosts() 함수
    • import.meta.glob() : glob 패턴으로 md 파일들을 읽어온다.
    • 파일명에서 slug 를 추출
    • file.metadata 에 slug 를 더해 Post 타입으로 변환
    • published 가 true 인 경우만 posts 에 추가
    • posts 를 date 기준으로 정렬
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { json } from '@sveltejs/kit';
import type { Post } from '$lib/types';
import type { RequestHandler } from './$types';

async function getPosts() {
  let posts: Post[] = [];

  const paths = import.meta.glob('/src/posts/*.md', { eager: true });

  for (const path in paths) {
    const file = paths[path];
    const slug = path.split('/').at(-1)?.replace('.md', '');

    if (file && typeof file === 'object' && 'metadata' in file && slug) {
      const metadata = file.metadata as Omit<Post, 'slug'>;
      const post = { ...metadata, slug } satisfies Post;
      post.published && posts.push(post);
    }
  }

  posts = posts.sort((first, second) => new Date(second.date).getTime() - new Date(first.date).getTime());

  return posts;
}

export const GET: RequestHandler = async () => {
  const posts = await getPosts();
  return json(posts);
};

src/routes/api/rss.xml.ts

  • Prerendering : 빌드 시점에 미리 렌더링하여 정적 파일로 생성
  • GET handler : /rss.xml
  • api/posts 에서 posts 를 읽어와 rss.xml 을 생성
    • xml 이라서 json() 대신 new Response() 사용
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
export const prerender = true;

import * as config from '$lib/index';
import type { Post } from '$lib/types';
import type { RequestHandler } from './$types';

export const GET: RequestHandler = async ({ fetch }) => {
  const response = await fetch('api/posts');
  const posts: Post[] = await response.json();

  const headers = { 'Content-Type': 'application/xml' };

  const xml = `
    <rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
    <channel>
      ...(생략)
    </channel>
    </rss>
  `.trim();

  return new Response(xml, { headers });
};

Theme Toggle

src/routes/toggle.svelte : Theme Toggle 버튼 컴포넌트

  • $theme 값에 따라 if 블럭으로 Sun/Moon 아이콘 출력
  • Theme 에 따라 Sun/Moon 아이콘을 fly transition 으로 전환
  • click 이벤트에 toggleTheme 함수를 바인딩

src/app.html

  • localStorage 로부터 color-schema 를 읽어 theme 설정
    • localStorage.getItem('color-scheme');
    • document.documentElement.setAttribute('color-scheme', theme)

app.css : CSS color-scheme 설정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
:root {
  color-scheme: dark;

  --brand: var(--brand-dark);
  --text-1: var(--text-1-dark);
  --text-2: var(--text-2-dark);
  --surface-1: var(--surface-1-dark);
  --surface-2: var(--surface-2-dark);
  --surface-3: var(--surface-3-dark);
  --surface-4: var(--surface-4-dark);
  --background: var(--background-dark);
  --border: var(--border-dark);
}

@media (prefers-color-scheme: light) {
  :root {
    color-scheme: light;

    --brand: var(--brand-light);
    --text-1: var(--text-1-light);
    --text-2: var(--text-2-light);
    --surface-1: var(--surface-1-light);
    --surface-2: var(--surface-2-light);
    --surface-3: var(--surface-3-light);
    --surface-4: var(--surface-4-light);
    --background: var(--background-light);
    --border: var(--border-light);
  }
}

Mdsvex 설정

Mdsvex 는 MarkDown 문서를 Svelte 컴포넌트로 변환해 주는 preprocessor 이기 때문에, svelte.config.js 에 preprocess 에 추가해 줍니다.

  • Syntax Highlighter 로 shiki 를 설정
  • Img 컴포넌트를 사용하기 위해 layout 을 설정합니다.
    • img 태그를 Image 컴포넌트로 overlay 합니다.

$lib/custom/img.svelte : Image 컴포넌트

  • lazy 로딩
1
2
3
4
5
6
<script lang="ts">
  export let src: string;
  export let alt: string;
</script>

<img {src} {alt} loading="lazy" />

svelte.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { mdsvex, escapeSvelte } from 'mdsvex';
import shiki from 'shiki';

/** @type {import('mdsvex').MdsvexOptions} */
const mdsvexOptions = {
  extensions: ['.md'],
  highlight: {
    highlighter: async (code, lang = 'text') => {
      const highlighter = await shiki.getHighlighter({ theme: 'poimandres' });
      const html = escapeSvelte(highlighter.codeToHtml(code, { lang }));
      return `{@html \`${html}\`}`;
    },
  },
  layout: {
    _: './src/mdsvex.svelte',
  },
  remarkPlugins: [remarkUnwrapImages, [remarkToc, { tight: true }]],
  rehypePlugins: [rehypeSlug],
};

/** @type {import('@sveltejs/kit').Config} */
const config = {
  extensions: ['.svelte', '.md'],
  preprocess: [vitePreprocess(), mdsvex(mdsvexOptions)],
  kit: {
    adapter: adapter(),
  },
};

export default config;

마크다운 플러그인

잘 모르겠다. 그냥 따라했다.

1
2
pnpm i remark-unwrap-images remark-toc rehype-slug
pnpm i rehype-slug rehype-autolink-headings
  • rehype-slug : 제목에 ID를 자동으로 추가
  • rehype-autolink-headings : 자동으로 제목에 링크를 추가

3. Building

1
2
3
pnpm run build  # 빌드

pnpm run preview  # 빌드된 정적 파일을 로컬에서 확인

Vercel Adapter 적용하기

  • svelte.config.js 에 auto 대신에 vercel adapter 설정 추가
1
2
3
4
5
# remove the default adapter
pnpm remove @sveltejs/adapter-auto

# add Vercel adapter
pnpm i -D @sveltejs/adapter-vercel

Vercel 배포하기

  • github 에 push
  • Vercel 에 git 연결

9. Review

  • Mdsvex 관련 플러그인들이 많은데 무슨 기능들인지 제대로 보지 못했다.
  • 한글로 작성된 블로그 문서가 있는데 tailwindcss 를 적용했다는 점만 다르고 동일한 내용이다.
  • 다른 블로그 프로젝트들도 찾아서 따라해 보아야겠다. 재밋다.
    • Josh Collinsworth 블로그 에는 category 리스트도 있다.
    • blog/1/+page.md 방식으로 구성할 수도 있다.
      • cf. 본 글에서는 blog/post-title.md 방식으로 구성

참고문서

 
 

끝!   읽어주셔서 감사합니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.