1,599 字
8 分钟阅读
从 0 到 1 搭建一个 Astro 博客
2024-09-01
目录及当前进展
- 初始化项目及配置
- Linter 及基本配置
- 静态化网站输出
- 添加官方集成
- 创建第一个 Layout
- i18n 国际化支持
- 安装配置
- 翻译文本示例
- 基础布局、样式、动画
- 页面切换动画配置
- 语言切换组件
- 主题 Dark 模式组件
- 文章 Collection
- MDX 内容增强
- TOC、阅读进度
- 字数、阅读时间
- Github 卡片
- 代码高亮
- Search 组件
- RSS
- 评论系统
- 性能优化
- 英语切换提示
- 统计分析、广告
喜欢的几个网站参考
- https://innei.in/ | Repo: https://github.com/Innei/Shiro
- https://fuwari.vercel.app/ | Repo: https://github.com/saicaca/fuwari
- https://yfi.moe | Repo: https://github.com/yy4382/yfi.moe
- https://kai.bi/ | Repo: https://github.com/ccbikai/astro-aria
初始化项目及配置
创建一个空白项目开始:
# 可以用其他 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 自带的 ViewTransitions
和 astro-loading-indicator
(加载进度条)组件。可以插入到 BaseLayout
的 head
中。
---
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 模式组件
主要包含两部分功能:
- 切换明暗主题:
light
、dark
、auto
三种模式 - 调整主题色调:
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 增强及卡片
Waiting for api.github.com...
::github{repo="willin/v0.md"}