1,599 字
8 分钟阅读

从 0 到 1 搭建一个 Astro 博客

目录及当前进展#

  • 初始化项目及配置
    • Linter 及基本配置
    • 静态化网站输出
    • 添加官方集成
    • 创建第一个 Layout
  • i18n 国际化支持
    • 安装配置
    • 翻译文本示例
  • 基础布局、样式、动画
    • 页面切换动画配置
    • 语言切换组件
    • 主题 Dark 模式组件
  • 文章 Collection
    • MDX 内容增强
    • TOC、阅读进度
    • 字数、阅读时间
    • Github 卡片
    • 代码高亮
  • Search 组件
  • RSS
  • 评论系统
  • 性能优化
  • 英语切换提示
  • 统计分析、广告

喜欢的几个网站参考#

初始化项目及配置#

创建一个空白项目开始:

# 可以用其他 package manager 进行创建
 bun create astro@latest

 astro   Launch sequence initiated.

   dir   Where should we create your new project?
         ./v0.md

  tmpl   How would you like to start your new project?
         Empty

    ts   Do you plan to write TypeScript?
         Yes

   use   How strict should TypeScript be?
         Strict

  deps   Install dependencies?
         No
  No problem! Remember to install dependencies after setup.

   git   Initialize a new git repository?
         Yes

  Project initialized!
 Template copied
 TypeScript customized
 Git initialized

  next   Liftoff confirmed. Explore your project!

         Enter your project directory using cd ./v0.md
         Run bun run dev to start the dev server. CTRL+C to stop.
         Add frameworks like react or tailwind using astro add.

         Stuck? Join us at https://astro.build/chat

╭─────╮  Houston:
  Good luck out there, astronaut! 🚀
╰─────╯

Linter 及基本配置#

  • 创建 .editorconfig
  • 安装 Linter
bun add -D @biomejs/biome @willin/biome-config
  • 创建 biome.json Linter 配置文件
{
  "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json",
  "extends": ["@willin/biome-config"],
  "overrides": [
    {
      "include": ["*.svelte", "*.astro", "*.vue"],
      "linter": {
        "rules": {
          "style": {
            "useConst": "warn",
            "useImportType": "warn"
          }
        }
      }
    }
  ]
}
  • 添加 pre commit 校验
bun add -D husky lint-staged
# 创建钩子
npx husky

静态化网站输出#

部署为静态页面,并进行上链,所以首先需要配置输出为 static

// astro.config.ts
import { defineConfig } from "astro/config";

// https://astro.build/config
export default defineConfig({
	site: "https://v0.md",
	output: "static",
	build: {
		format: "directory"
	}
});

添加官方集成#

bunx astro add tailwind
bunx astro add mdx
bunx astro add sitemap

创建第一个 Layout#

创建以下两个文件。

src/styles/global.css

/* src/styles/global.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

src/layout/BaseLayout.astro

---
import '../styles/global.css';
---

<html lang="en">
	<head>
		<meta charset="utf-8" />
		<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
		<meta name="viewport" content="width=device-width" />
		<meta name="generator" content={Astro.generator} />
		<title>Astro</title>
	</head>
	<body>
		<slot />
	</body>
</html>

看看效果:

bun dev

然后访问: http://localhost:4321

i18n 国际化支持#

  • 使用 astro-i18n-aut 实现 i18n
  • 手搓一个

安装配置#

bun add dlv templite
bun add -D @types/dlv

实现 Message 翻译其实很简单,其中参考了我在 Remix i18n 的实现:

import dlv from 'dlv';
import tmpl, { type Values } from 'templite';
import { defaultLocale, locales } from '../consts';
import en from './en.json';
import zh from './zh.json';

const messages = {
  zh,
  en
};

const langs = Object.keys(locales);

export const i18n =
  (url: URL) =>
  (key: string, params: Values = {}): string => {
    const lang = getLocaleFromUrl(url);
    const val = dlv(messages?.[lang as keyof typeof locales], key, '');
    if (typeof val === 'function') return val(params);
    if (typeof val === 'string') return tmpl(val, params);
    return val;
  };

export function getLocaleFromUrl(url: URL): string {
  const parts = url.pathname.split('/').filter((el) => el !== '');
  let match = '';
  for (const part of parts) {
    if (langs.includes(part)) match = part;
  }
  if (match) return match;
  return defaultLocale;
}

翻译文本示例#

---
import { i18n } from '@/i18n';

const t = i18n(Astro.url);
---

<main>
  <h1>
    🧑‍🚀 {t('title')}
  </h1>
</main>
// ...

基础布局、样式、动画#

页面切换动画配置#

用到了 Astro 自带的 ViewTransitionsastro-loading-indicator (加载进度条)组件。可以插入到 BaseLayouthead 中。

---
import { ViewTransitions } from 'astro:transitions';
import LoadingIndicator from 'astro-loading-indicator/component';
---

<head>
  <ViewTransitions fallback="swap" />
  <LoadingIndicator color="#f06" />
</head>

// ...

语言切换组件#

为语言切换组件写了几个工具类,可以参考一下:


const langs = Object.keys(locales);
/**
 * Get Locale from URL
 * @param url
 * @returns zh | en
 */
export function getLocaleFromUrl(url: URL | string): string {
  const parts = (typeof url === 'string' ? url : url.pathname)
    .split('/')
    .filter((el) => el !== '');
  let match = '';
  for (const part of parts) {
    if (langs.includes(part)) match = part;
  }
  if (match) return match;
  return defaultLocale;
}

/**
 * Get Relative URL without Locale
 * @param url sth like: /zh/about/
 * @returns /about/
 */
export function getRelativeUrlWithoutLocale(url: URL | string): string {
  const parts = (typeof url === 'string' ? url : url.pathname)
    .split('/')
    .filter((el) => el !== '');
  const newParts = parts.filter((el) => !langs.includes(el));
  const u = `/${newParts.join('/')}`;
  return u.endsWith('/') ? u : `${u}/`;
}

/**
 * Get Locale URL
 * @param url /about/
 * @param locale zh | en
 * @returns /en/about/
 */
export function getLocaleUrl(
  url: URL | string,
  locale: keyof typeof locales
): string {
  const u = getRelativeUrlWithoutLocale(url);
  return locale === defaultLocale ? u : `/${locale}${u}`;
}

然后创建了一个 LocleLink 组件:

---
import { defaultLocale, type locales } from '@/consts';
import { getLocaleFromUrl, getLocaleUrl, i18n } from '@/i18n';

type Props = {
  href: string;
  class?: string;
  locale?: keyof typeof locales;
};

const { href, class:className, locale } = Astro.props;

const currentLocale = locale || getLocaleFromUrl(Astro.url);
const link = getLocaleUrl(href, currentLocale);
---

<a href={link} class:list={[className]}>
  <slot />
</a>
// ...

切换语言的时候,下拉菜单中加入 LoacleLink

---
import { languages } from '@/consts';
import { getLocaleFromUrl } from '@/i18n';
import { Icon } from 'astro-icon/components';
import Dropdown from '../ui/Dropdown.astro';
import LocaleLink from '../ui/LocaleLink.astro';

const url = Astro.url;
const locale = getLocaleFromUrl(Astro.url);
---

<Dropdown class="w-32">
  <Icon name="i18n" class="size-5" />
  <ul slot="dropdown">
    {Object.entries(languages).map(([code,{name, flag, unicode}]) => (
      <li>
        <LocaleLink href={url} locale={code} class={locale === code ? 'active flex' : 'flex'}>
          <img
            loading="lazy"
            width="20"
            height="20"
            alt={flag}
            src={`https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/svg/${unicode}.svg`}
          />
          <span class="flex flex-1 justify-between pl-2">{name}</span>
        </LocaleLink>
      </li>
    ))}
  </ul>
</Dropdown>
// ...

主题 Dark 模式组件#

主要包含两部分功能:

  • 切换明暗主题: lightdarkauto 三种模式
  • 调整主题色调: hue 0 - 360
function getHue() {
  return +localStorage.getItem('hue') || primaryHue;
}
function setHue(hue) {
  localStorage.setItem('hue', hue);
  document.documentElement.style.setProperty('--primary-hue', hue);
}

function loadDarkMode() {
  let darkMode = localStorage.getItem("theme");
  if (darkMode === null) {
    localStorage.setItem("theme", "auto");
    darkMode = "auto";
  }
  if (darkMode === "light" || darkMode === "auto") {
    document.documentElement.classList.remove("dark");
  }
  if (darkMode === "dark") {
    document.documentElement.classList.add("dark");
  }
  if (
    darkMode === "auto" &&
    window.matchMedia("(prefers-color-scheme: dark)").matches
  ) {
    document.documentElement.classList.add("dark");
  }
}

function dispatchClick() {
  const darkMode = localStorage.getItem("theme");
  const buttons = document.querySelectorAll('button.theme-button');

  buttons.forEach((button) => {
    if (button.dataset.id === darkMode) {
      button.classList.add('active');
      document.querySelector('span.current-theme')?.setHTMLUnsafe(button.querySelector('span').innerHTML);
    } else {
      button.classList.remove('active');
    }
    const click = () => {
      localStorage.setItem('theme', button.dataset.id);
      buttons.forEach((b)=> b.classList.remove('active'));
      button.classList.add('active');
      document.querySelector('span.current-theme')?.setHTMLUnsafe(button.innerText);
      loadDarkMode();
    };
    button.removeEventListener('click', click);
    button.addEventListener('click', click);
  });
}

function init() {
  setHue(getHue());
  loadDarkMode();
  dispatchClick();
}
init();
document.addEventListener("astro:after-swap", init);

设置页面的主色调(通过 hue 调节):

  • 主字体颜色
  • 主背景颜色
  • 次字体颜色
  • 次背景色

文章 Collection#

配置字段:

import { defineCollection, z } from 'astro:content';

const posts = defineCollection({
  type: 'content',
  // Type-check frontmatter using a schema
  schema: z.object({
    title: z.string(),
    description: z.string(),
    tags: z.array(z.string()).optional().default([]),
    category: z.string().optional().default(''),
    image: z.string().optional().default(''),
    // Transform string to Date object
    published: z.coerce.date(),
    updated: z.coerce.date().optional()
  })
});

export const collections = { posts };

按照语言来获取 Collection 内容:

import { type CollectionEntry, getCollection } from 'astro:content';
import { getLocaleFromUrl } from '@/i18n';

export async function getLocaleCollection(
  locale: string,
  collection = 'posts',
  sort = 'reverseChronological'
): Promise<CollectionEntry<'posts'>[]> {
  const entries = await getCollection(collection);

  const result =
    locale === ''
      ? entries
      : entries.filter((entry) => getLocaleFromUrl(entry.slug) === locale);

  if (sort === 'reverseChronological') {
    return result.sort(
      (a: CollectionEntry<'posts'>, b: CollectionEntry<'posts'>) =>
        a.data.published.valueOf() < b.data.published.valueOf() ? 1 : -1
    );
  }
  return result;
}

页面中获取 Collection 内容:

import { getLocaleFromUrl, getRelativeUrlWithoutLocale } from '@/i18n';
import { getLocaleCollection } from '@/lib/collection';

export async function getStaticPaths() {
  const posts = await getLocaleCollection('', 'posts', '');
  return posts.map((post) => {
    return {
      params: {
        lang: getLocaleFromUrl(post.slug),
        slug: getRelativeUrlWithoutLocale(post.slug)
      },
      props: { entry: post }
    };
  });
}

MDX 内容增强#

修改配置 astro.config.ts,加入:

markdown: {
  remarkPlugins: [],
  rehypePlugins: []
}
TIP

测试一下 Markdown 增强及卡片

willin
/
v0.md
Waiting for api.github.com...
00K
0K
None
Waiting...
::github{repo="willin/v0.md"}

Search 组件#

RSS#

评论系统#

性能优化#

英语切换提示#

统计分析、广告#

2024-2024 Willin Wang. | Source Code RSS | Site Map Powered by Astro.