Blog搭建紀錄


History

  • 2024/03/20 First Version

本Blog的前世今生

一直想做一個Blog,最好是簡單易用且免費。傳統的技術有Wordpress/Hexo/Hugo等CMS,筆者也嘗試使用過,但覺得還是有點肥大且後期要達到一些自己想要的功能需要花費很大成本,索性自己搭一個簡易版本。一開始使用Next.js實現,基本的雛型也已經搭好了(next-blog-static),但總覺得有更簡單且更易擴充的實現方式。正好看到了使用island模式的框架Astro,讓我可以從零開始像搭積木一樣一步步建立屬於自己的Blog。本篇記錄了這個Blog的搭建過程,供想要搭建可高度客製化的朋友們參考。(只會紀錄重點部分)

前置作業

首先當然是學習Astro怎麼使用啦,筆者的話建議從官網文件 Start HereCore ConceptsTutorial看過並實作一次。Tutorial的部分會教你如何從空專案開始搭建一個簡易的Blog,藉由課程實作來讓讀者熟悉該框架的一些基本概念,在實作後可使用該專案來擴展自己的Blog。

搭建項目

pnpm create astro@latest
dir   Where should we create your new project?
        ./astro-blog

tmpl   How would you like to start your new project?
        Use blog template

    ts   Do you plan to write TypeScript?
        Yes

use   How strict should TypeScript be?
        Strict

deps   Install dependencies?
        Yes

git   Initialize a new git repository?
        Yes

這裡筆者沒有使用完成 Tutorial後的專案來製作Blog(搭建時選擇空專案),而是使用Blog Template。主要理由是在課程中使用的是Javascript且筆者想比較Template和教學專案的內容差異

加入暗黑模式切換

  • global.css

    :root {
        --accent: #2337ff;
        --accent-dark: #000d8a;
        --black: 15, 18, 25;
        --white: 189, 189, 189;
        --gray: 96, 115, 159;
        --gray-light: 229, 233, 240;
        --gray-dark: 34, 41, 57;
        --gray-gradient: rgba(var(--gray-light), 50%), #fff;
        --box-shadow: 0 2px 6px rgba(var(--gray), 25%), 0 8px 24px rgba(var(--gray), 33%),
            0 16px 32px rgba(var(--gray), 33%);
        0 16px 32px rgba(var(--gray), 33%);
    }
    
    [data-theme="light"] {
        --bg-color: linear-gradient(var(--gray-gradient)) no-repeat;
        --font-color: rgba(var(--gray-dark));
        --code-color: rgba(var(--gray-dark));
        --img-bg-color: rgba(255, 255, 255);
    }
    
    [data-theme="dark"] {
        --bg-color: rgba(var(--black));
        --font-color: rgba(var(--white));
        --code-color: rgba(var(--gray-dark));
    }
    
    [data-theme="dark"] {
        --code-color: rgba(var(--gray-dark));
    }
    
    body {
        font-family: 'Atkinson', sans-serif;
        margin: 0;
        padding: 0;
        text-align: left;
        background: linear-gradient(var(--gray-gradient)) no-repeat;
        background: var(--bg-color);
        background-size: 100% 600px;
        word-wrap: break-word;
        overflow-wrap: break-word;
        color: var(--font-color);
        font-size: 20px;
        line-height: 1.7;
    }
    
    main {
        width: 720px;
        max-width: calc(100% - 2em);
        margin: auto;
        padding: 3em 1em;
    }
    
    h1,
    h2,
    h3,
    h4,
    h5,
    h6 {
        margin: 0 0 0.5rem 0;
        color: rgb(var(--font-color));
        line-height: 1.2;
    }
  • pages/index.astro

    <style>
        .title {
            margin: 0;
            color: var(--font-color);
            line-height: 1;
        }
    </style>
  • components/ThemeIcon.astro

    ---
    ---
    
    <button id="theme-toggle">
        <svg width="30px" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
            <path class="sun" fill-rule="evenodd" d="M12 17.5a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11zm0 1.5a7 7 0 1 0 0-14 7 7 0 0 0 0 14zm12-7a.8.8 0 0 1-.8.8h-2.4a.8.8 0 0 1 0-1.6h2.4a.8.8 0 0 1 .8.8zM4 12a.8.8 0 0 1-.8.8H.8a.8.8 0 0 1 0-1.6h2.5a.8.8 0 0 1 .8.8zm16.5-8.5a.8.8 0 0 1 0 1l-1.8 1.8a.8.8 0 0 1-1-1l1.7-1.8a.8.8 0 0 1 1 0zM6.3 17.7a.8.8 0 0 1 0 1l-1.7 1.8a.8.8 0 1 1-1-1l1.7-1.8a.8.8 0 0 1 1 0zM12 0a.8.8 0 0 1 .8.8v2.5a.8.8 0 0 1-1.6 0V.8A.8.8 0 0 1 12 0zm0 20a.8.8 0 0 1 .8.8v2.4a.8.8 0 0 1-1.6 0v-2.4a.8.8 0 0 1 .8-.8zM3.5 3.5a.8.8 0 0 1 1 0l1.8 1.8a.8.8 0 1 1-1 1L3.5 4.6a.8.8 0 0 1 0-1zm14.2 14.2a.8.8 0 0 1 1 0l1.8 1.7a.8.8 0 0 1-1 1l-1.8-1.7a.8.8 0 0 1 0-1z"/>
            <path class="moon" fill-rule="evenodd" d="M16.5 6A10.5 10.5 0 0 1 4.7 16.4 8.5 8.5 0 1 0 16.4 4.7l.1 1.3zm-1.7-2a9 9 0 0 1 .2 2 9 9 0 0 1-11 8.8 9.4 9.4 0 0 1-.8-.3c-.4 0-.8.3-.7.7a10 10 0 0 0 .3.8 10 10 0 0 0 9.2 6 10 10 0 0 0 4-19.2 9.7 9.7 0 0 0-.9-.3c-.3-.1-.7.3-.6.7a9 9 0 0 1 .3.8z"/>
        </svg>
    </button>
    
    <style>
        #theme-toggle {
            border: 0;
            background: none;
        }
    
        .sun { fill: black; }
        .moon { fill: transparent; }
    
        :global([data-theme="dark"]) .sun { fill: transparent; }
        :global([data-theme="dark"]) .moon { fill: white; }
    </style>
    
    <script is:inline>
        const getTheme = () => {
            if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) {
                return localStorage.getItem('theme');
            }
    
            if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
                return 'dark';
            }
    
            return 'light';
        }
    
        const theme = getTheme();
    
        if (theme === 'light') {
            document.documentElement.setAttribute('data-theme', 'light');
        } else {
            document.documentElement.setAttribute('data-theme', 'dark');
        }
    
        window.localStorage.setItem('theme', theme);
    
        const handleToggleClick = () => {
            const newTheme = getTheme() === 'light' ? 'dark' : 'light';
            const element = document.documentElement;
            element.setAttribute('data-theme', newTheme);
    
            localStorage.setItem("theme", newTheme);
        }
    
        document.getElementById("theme-toggle").addEventListener("click", handleToggleClick);
    </script>
  • components/Header.astro

    ---
    import HeaderLink from './HeaderLink.astro';
    import ThemeIcon from './ThemeIcon.astro';
    import { SITE_TITLE } from '../consts';
    ---
    
    <header>
        <nav>
            <div class="header-left">
                <h2><a href="/">{SITE_TITLE}</a></h2>
            </div>
    
            <div class="header-middle">
                <div class="internal-links">
                    <HeaderLink href="/">Home</HeaderLink>
                    <HeaderLink href="/blog">Blog</HeaderLink>
                    <HeaderLink href="/about">About</HeaderLink>
                </div>
            </div>
    
            <div class="header-right">
                <ThemeIcon />
                <div class="social-links">
                    <a href="https://github.com/wakizashi1024" target="_blank">
                        <span class="sr-only">Go to my GitHub repo</span>
                        <svg viewBox="0 0 16 16" aria-hidden="true" width="32" height="32"
                            ><path
                                fill="currentColor"
                                d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"
                            ></path></svg
                        >
                    </a>
                </div>
            </div>
        </nav>
    </header>
    <style>
        header {
            margin: 0;
            padding: 0 1em;
            background: white;
            box-shadow: 0 2px 8px rgba(var(--black), 5%);
        }
    
        :global([data-theme="dark"])
        header {
            background: var(--black);
        }
    
        .header-left {
            display: flex;
        }
    
        .header-middle {
            display: flex;
        }
    
        .header-right {
            display: flex;
        }
    
        h2 {
            margin: 0;
            font-size: 1em;
        }
    
        h2 a,
        h2 a.active {
            text-decoration: none;
        }
    
        nav {
            display: flex;
            align-items: center;
            justify-content: space-between;
        }
    
        nav a {
            padding: 1em 0.5em;
            color: var(--black);
            border-bottom: 4px solid transparent;
            text-decoration: none;
        }
    
        nav a.active {
            text-decoration: none;
            border-bottom-color: var(--accent);
        }
    
        .social-links,
        .social-links a {
            display: flex;
        }
    
        @media (max-width: 720px) {
            .social-links {
                display: none;
            }
        }
    </style>
  • components/Footer.astro

    <style>
        footer {
            padding: 2em 1em 6em 1em;
            background: linear-gradient(var(--gray-gradient)) no-repeat;
            color: rgb(var(--gray));
            background: var(--bg-color);
            color: var(--font-color);
            text-align: center;
        }
    </style>

加入RWD選單折疊(Hambuger)

  • components/Hamburger.astro

    ---
    ---
    <style>
        .hamburger {
            padding: 12px 12px;
            cursor: pointer;
        }
    
        .hamburger .line {
            display: block;
            width: 40px;
            height: 5px;
            margin: 10px 0;
            background-color: var(--font-color);
        }
    </style>
    
    <div class="hamburger">
        <span class="line"></span>
        <span class="line"></span>
        <span class="line"></span>
    </div>
  • components/Header.astro

    ---
    import ThemeIcon from './ThemeIcon.astro';
    import { SITE_TITLE } from '../consts';
    import Hamburger from './Hamburger.astro';
    import Navigation from './Navigation.astro';
    ---
    
    <header>
        <div class="header-top">
            <Hamburger />
    
            <h2 class="logo"><a href="/">{SITE_TITLE}</a></h2>
    
            <div class="nav-desktop">
                <Navigation />
            </div>
    
            <div class="actions">
                <ThemeIcon />
                <div class="social-links">
                    <a href="https://github.com/wakizashi1024" target="_blank">
                        <span class="sr-only">Go to my GitHub repo</span>
                        <svg viewBox="0 0 16 16" aria-hidden="true" width="32" height="32"
                            ><path
                                fill="currentColor"
                                d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"
                            ></path></svg
                        >
                    </a>
                </div>
            </div>
        </div>
    
        <div class="nav-mobile">
            <Navigation />
        </div>
    </header>
    
    <script>
        import "../scripts/menu.js";
    </script>
    
    <style>
        header {
            margin: 0;
            padding: 0 1em;
            background: white;
            box-shadow: 0 2px 8px rgba(var(--black), 5%);
        }
    
        :global([data-theme="dark"])
        header {
            background: var(--black);
        }
    
        .logo, .actions {
            width: 250px;
        }
    
        .header-top .actions {
            display: flex;
            justify-content: flex-end;
        }
    
        h2 {
            margin: 0;
            font-size: 1em;
        }
    
        h2 a,
        h2 a.active {
            text-decoration: none;
        }
    
        .header-top {
            display: flex;
            align-items: center;
            justify-content: space-between;
        }
    
        .header-top a {
            padding: 1em 0.5em;
            color: var(--black);
            border-bottom: 4px solid transparent;
            text-decoration: none;
        }
    
        .header-top a.active {
            text-decoration: none;
            border-bottom-color: var(--accent);
        }
    
        .social-links,
        .social-links a {
            display: flex;
        }
    </style>
  • components/Navigation.astro

    ---
    import HeaderLink from './HeaderLink.astro';
    ---
    
    <style>
        a {
            padding: 1em 0.5em;
            color: var(--black);
            border-bottom: 4px solid transparent;
            text-decoration: none;
        }
    
        a.active {
            text-decoration: none;
            border-bottom-color: var(--accent);
        }
    
        @media (max-width: 720px) {
            a.active {
                border-bottom-color: transparent;
            }
        }
    </style>
    <nav>
        <div class="nav-links">
            <HeaderLink href="/">Home</HeaderLink>
            <HeaderLink href="/blog">Blog</HeaderLink>
            <HeaderLink href="/about">About</HeaderLink>
        </div>
    </nav>
  • scripts/menu.js

    document.querySelector('.hamburger').addEventListener('click', () => {
        console.log(document.querySelector('.nav-links'))
        document.querySelector('.nav-mobile .nav-links').classList.toggle('expanded');
    });
  • global.css

    @media (max-width: 720px) {	
        .nav-desktop {
            display: none;
        }
    
        .logo {
            flex: 1;
            text-align: center;
        }
    
        .actions {
            width: auto;
        }
    
        nav {
            display: block;
            position: static;
            width: auto;
            background: none;
        }
    
        .nav-links {
            display: none;
            flex-direction: column;
            text-align: center;
        }
    
        .nav-links a {
            display: inline-block;
            padding: 15px 20px;
        }
    
        .social-links {
            display: none;
        }
    }
    
    @media (min-width: 720px) {
        .hamburger {
            display: none;
        }
    
        .nav-mobile {
            display: none;
        }
    }
    
    .expanded {
        display: flex;
    }

加入標籤分類

  • content/config.ts

    const blog = defineCollection({
        type: 'content',
        // Type-check frontmatter using a schema
        schema: z.object({
            title: z.string(),
            description: z.string(),
            // Transform string to Date object
            pubDate: z.coerce.date(),
            updatedDate: z.coerce.date().optional(),
            heroImage: z.string().optional(),
            tags: z.optional(z.array(z.string())),
        }),
    });
  • layouts/BlogPost.astro

    ---
    const { title, description, pubDate, updatedDate, heroImage, tags } = Astro.props;
    ---
    
    <div class="tags">
        {tags.map((tag) => <a href={`/tags/${tag}`}>{tag}</a>)}
    </div>
  • pages/tags/[tag].astro

    ---
    import { getCollection } from 'astro:content';
    import BaseLayout from '../../layouts/BaseLayout.astro';
    export async function getStaticPaths() {
        const allPosts = await getCollection("blog");
        const uniqueTags = [...new Set(allPosts.map((post) => post.data.tags).flat())] as string[];
        return uniqueTags.map((tag: string) => {
            const filteredPosts = allPosts.filter((post) => post.data.tags?.includes(tag))
            filteredPosts.sort((a, b) => a.data.pubDate - b.data.pubDate);
            // console.log(filteredPosts)
            return {
                params: { tag },
                props: { posts: filteredPosts },
            };
        });
    }
    const { tag } = Astro.params;
    const { posts } = Astro.props;
    ---
    
    <style>
    main h2:first-child {
        text-align: center;
    }
    </style>
    
    <BaseLayout title={tag}>
    <main>
        <h2>Posts tagged with {tag}</h2>
        <ul>
        {posts.map((post) => (
            <li><a href={`/blog/${post.slug}/`}>{post.data.title}</a></li>
        ))}
        </ul>
    </main>
    
    </BaseLayout>
  • pages/tags/index.astro

    ---
    import BaseLayout from "../../layouts/BaseLayout.astro";
    import { getCollection } from "astro:content";
    const allPosts = await getCollection('blog');
    const tags = [...new Set(allPosts.map((post) => post.data.tags).flat())];
    const pageTitle = "Tag Index";
    ---
    <style>
        a {
            color: #00539f;
        }
    
        .tags {
            display: flex;
            flex-wrap: wrap;
            justify-content: center;
        }
    
        .tag {
            margin: 0.25em;
            border: dotted 1px #a1a1a1;
            border-radius: .5em;
            padding: .5em 1em;
            font-size: 1.15em;
            background-color: #f8fcfd;
        }
    </style>
    
    <BaseLayout title={pageTitle}>
        <main>
            <div class="tags">
                {tags.map((tag) => <p class="tag"><a href={`/tags/${tag}`}>{tag}</a></p>)}
            </div>
        </main>
    </BaseLayout>

參考內容