Astro 博客搭建与部署
基于 astro.build 搭建博客。可部署在服务器、www.netlify.com、github pages。
在网上还看到有自动化工具:DevNow,基于 astro 一键式搭建博客,但主题无法自选。
本文还是以常规的方式,选择主题,按照主题的要求修改源码配置,最后通过 github 的 action 自动部署到 github pages。
本文在 Linux 下完成。
目标
- 搭建过程尽可能简单,不需要太复杂的技术,太绚丽的界面。
- 无需购买服务器,托管在 github,但开启自定义域名,通过自己的域名访问。
- 博客功能:有阅读目录TOC、清晰的代码块展示、归档和搜索的功能。
- 更新内容要方便。
主题选择
自己设计主题和功能需要花费较多的时间,学习前端知识,学习astro。本文本着将博客当作一个工具来使用,而不是一个折腾、学习的项目的态度,尽可能的提供简洁的方案。
本文选择主题 Astro Theme Yi,足够简洁,但不简单,需要的功能都有,能看得出来作者是按照自己的使用场景打造,作者自己的博客也确实就是基于这个主题建立的。相比之下,我之前基于 hugo 的 stack 主题功能上有一些简单,SEO不友好,还需要解决一些问题才能使用。
感谢作者的努力!
域名注册
我希望拥有一个自己的域名,单独用作博客,或者是自己的一些服务。如果不想购买域名,则可以直接使用 github 的 page 服务,或者部署在 netlify 上。
我在 namesilo 上购买 top 域名,相比于 com 域名,会便宜很多。首年 1.88 美元,续费 4.88 美元。
购买后,按照 cloudflare 的域名DNS解析指南,在 cloudflare 托管域名,将 namesilo 的域名的域名服务器,替换为 cloudflare 的。这样才能在 cloudflare 中管理自己的域名。
使用模板
可以在 astro 官方网站中找自己喜欢的模板。下面以 Astro-yi 为例。
下载和配置
1git clone https://github.com/cirry/astro-yi.git
参考:Astro-Yi主题配置和写作技巧 原作者提供的主题配置,对模板进行修改。
本地小修改
由于之前的 markdown 书写习惯是从二级标题开始,所以将 TOC 的标题改为从 H2 开始显示。
修改文件 src/components/Toc.astro
1 tocbot.init({2 tocSelector: ".toc-container",3 contentSelector: ".markdown-body",4 headingSelector: "h2,h3,h4",5 hasInnerContainers: true,6 headingsOffset: 80,7 scrollSmoothOffset: -80,8 scrollSmoothDuration: 200,9 collapseDepth: 4,10 onClick: function (){11 return false12 },13 });
构建
参考主题仓库的 readme,在终端中,运行下列命令。
通过 npm 安装需要的组件,并且预览网站。
1npm install -g pnpm2pnpm i3npm run dev # preview
成功后,可在本地浏览器输入 http://localhost:4321/
预览
将博客文章写在 src/content/blog
文件夹中,在 src/content/feed
文件夹中编写想发布的动态内容。通过下列命令编译打包内容。
1npm run build # build
打包完成后,在根目录中会生成一个 dist 文件夹。将 ‘dist’ 文件夹上传到 Web 服务器目录中,即可完成部署。对于 github 来说,可以使用 action 自动化实现打包部署的过程。
Action自动化部署
参考:部署你的 Astro 站点至 GitHub Pages
官方的教程是在源码仓库部署,而我的需求是将源码仓库设置为私有仓库,将 build 后的网站部署到另一个公开仓库。需要对官方的 action 脚本进行修改。
生成 PAT 和 Actions secrets
由于是部署到另一个仓库,需要先获取 PAT,通过 PAT 提供另一个仓库的访问权限。可以参考:Hugo搭建 中的使用 git 模板
部分,生成 经典 Tokens
。需要注意,如果用细粒度令牌(Fine-grained Tokens)生成 PAT ,需要开启 Contents 的权限,否则在部署时会遇到权限错误。
然后在仓库设置中,把 PAT 添加到 Actions secrets。
编写 deploy.yml
在私有仓库,也就是源码仓库,新建 .github/workflows/deploy.yml ,并按照自己的仓库、分支名称,填写如下,其中 secrets.ASTRO_DEPLOY 就是生成的 PAT。
1name: Deploy to GitHub Pages2
3on:4 # 每次推送到 `main` 分支时触发这个"工作流程"5 # 如果你使用了别的分支名,请按需将 `main` 替换成你的分支名6 push:7 branches: [ main ]8 # 允许你在 GitHub 上的 Actions 标签中手动触发此"工作流程"9 workflow_dispatch:10
11# 允许 job 克隆 repo 并创建一个 page deployment12permissions:13 contents: read14 pages: write15 id-token: write24 collapsed lines
16
17jobs:18 build:19 runs-on: ubuntu-latest20 steps:21 - name: Checkout your repository using git22 uses: actions/checkout@v423 - name: Install, build, and upload your site24 uses: withastro/action@v325 with:26 # path: . # 存储库中 Astro 项目的根位置。(可选)27 # node-version: 20 # 用于构建站点的特定 Node.js 版本,默认为 20。(可选)28 package-manager: pnpm@latest # 指定使用 pnpm 8.x 版本29 # package-manager: pnpm@latest # 或者使用最新版本的 pnpm30 - name: Deploy to GitHub Pages31 id: deployment32 uses: JamesIves/github-pages-deploy-action@v4 # 一个自动发布github pages的action33 with:34 repository-name: 用户名/公开仓库名。例如:codegithub/codegithub.github.io35 token: ${{ secrets.ASTRO_DEPLOY }}36 branch: main37 folder: dist38 clean: true39 single-commit: true
跳过 github 的 Jekyll 流程
在 GitHub Pages 部署 Astro 網站要注意的事情、Bypassing Jekyll on GitHub Pages
astro 编译后,会将样式文件放在 _astro
文件夹下,但 github 如果在仓库中选择基于分支构建 Pages,则会自动采取 Jekyll 的方式构建 Pages。而 Jekyll 会跳过前缀是下划线的文件夹的处理,在访问博客时,该文件夹下的内容就无法访问。如果采取 action 作为构建 Pages 的方式,就不受影响,astro 官方就是采用 action 作为构建 Pages 的方式。
解决方式是在作为 pages 的仓库的根目录下创建一个名为 .nojekyll
空文件,这样会绕过 Jekyll 构建过程并直接部署内容。所以,在本文的场景中,需要在源码 astro 仓库的 public 文件夹创建 .nojekyll
空文件。这样部署到公开仓库后,公开仓库的根目录下就会有 .nojekyll
空文件。
github添加自己域名
由于不想购买服务器,不想让个人使用的服务和代理 VPS 混合在一起使用,可以将博客部署到 github pages,然后为 github pages 配置自己的域名,就可以通过自己的域名访问了。
为 github pages 启用自己的域名
参考:【官方】验证 GitHub Pages 的自定义域、【教程】Github Page 添加自定义域名、多项目部署为同一个GitHub Pages
官方教程有些晦涩,中文翻译不完整,例如 apex 域名(顶点域名),标题里面没翻译,正文里面又翻译了,导致我以为 apex 是 github 的配置。
验证
先在 github 的设置中,添加自己的域名。
点击自己的头像,进入设置界面,选择 Pages,添加自己的域名。
然后会显示如下内容,意思是,你现在需要去自己的域名托管网站,为自己的域名添加一条包含 固定的名称和内容 的 TXT 类型的 DNS 解析记录。
比如在 cloudflare 中,添加一条 TXT 的 DNS 解析记录
添加完成后,稍等一会,点击 Verify,github 就会自动去验证。
映射
先配置顶点域名,将顶点域名映射到 github pages 的站点 ip 地址。
官方文档中给出了 github pages 的 ipv4 和 ipv6 的所有 ip 地址,需要将顶点域名(也就是购买时获得的域名,没有任何子域名的。例如 your.top 和 www.your.top,前者是顶点域名,后者是子域名)解析到给出的几个 ip 地址。一般来说,名称填入 @ 就表示顶点域名。
然后配置子域名,配置子域名时,就不用解析到 ip 地址了,直接选择 CNAME 类型,将自己的子域名映射到 your_github_useranme_.github.io 即可。
配置完毕后如下(注意,如果是 cloudflare 配置,需要取消代理,即不点亮橙色云,否则无法强制开启 https):
最后还需要在启用 github pages 的仓库(如果想在源码仓库开启 pages 也可以,需要将仓库权限改为 Public。否则,就需要新建一个 Public 仓库)的设置中,配置自己的域名。
强制 HTTPS
参考:Github pages keep saying it cant enforce https、Unable to enforce https on my site.
如果需要让自定义域名强制支持 https,则需要等 github 自动设置证书生效后,勾选 Enforce HTTPS。
注意,在 cloudflare 中,不能点亮小橙云,否则 github 不会自动申请证书。下图为关闭小橙云后,github 自动申请证书时的截图。
撰写文章并更新
思源笔记导出转换
之前我的博客使用 hugo 搭建的,先在思源笔记上写文章,然后导出 md 压缩包,手动解压然后移动到对应的仓库中,并修改部分内容适配 hugo。
现在换到 astro,由于 astro 不支持相对路径引用图片,写了一个自动化转换思源的 md 压缩包到 astro 博客框架的脚本。这个脚本会自动解压缩、替换图片路径、将图片移动到 public/images 下、修改文件命名等。
脚本内容如下:
1#!/usr/bin/env bash2set -eo pipefail3
4# 颜色配置5RED='\033[0;31m'6GREEN='\033[0;32m'7YELLOW='\033[1;33m'8NC='\033[0m'9
10# 基础路径配置11BLOG_PATH="/home/ling/Desktop/blog/astro-yi"12CONTENT_PATH="$BLOG_PATH/src/content/blog"13PUBLIC_IMAGES_PATH="$BLOG_PATH/public/images"14
15# 命令配置:格式「命令名="描述::处理函数::选项声明"」353 collapsed lines
16declare -A COMMAND_MAP=(17 ["unzip"]="解压思源笔记导出的md压缩包::handle_unzip::-f,--file:指定压缩包文件路径(必须)::-o,--output:输出目录(默认:与压缩包同名)"18 ["move-images"]="移动图片文件到公共目录::handle_move_images::-d,--dir:指定包含assets的目录(必须)"19 ["update-references"]="更新MD文件中的图片引用::handle_update_references::-f,--file:指定MD文件路径(必须)::-n,--name:文件夹名称(必须)"20 ["move-md"]="移动MD文件到博客根目录::handle_move_md::-f,--file:指定MD文件路径(必须)"21 ["process-all"]="执行完整处理流程::handle_process_all::-f,--file:指定压缩包文件路径(必须)::-d,--delete:处理完成后删除源压缩包(可选)"22)23
24# 错误处理函数25panic() {26 echo -e "${RED}[错误]${NC} $1" >&227 exit 128}29
30# 日志输出函数31log_info() {32 echo -e "${GREEN}[信息]${NC} $1" >&233}34
35log_warning() {36 echo -e "${YELLOW}[警告]${NC} $1" >&237}38
39log_success() {40 echo -e "${GREEN}[成功]${NC} $1" >&241}42
43##############################################44# 通用的帮助信息生成函数 #45##############################################46
47show_command_help() {48 local cmd="$1"49 local info="${COMMAND_MAP[$cmd]}"50
51 # 1) 把所有 "::" 替换成一个不会在脚本中出现的分隔符,比如 '|'52 info="${info//::/|}"53
54 # 2) 这时才能安全地用 IFS='|' 来做分割55 local desc handler options_str56 IFS="|" read -r desc handler options_str <<< "$info"57
58 echo -e "${YELLOW}命令: ${GREEN}$cmd${NC}"59 echo -e "${YELLOW}描述: ${NC}$desc"60 echo -e "${YELLOW}选项:${NC}"61
62 # 3) 选项声明里可能有多段「::」分隔,继续替换并拆分63 options_str="${options_str//::/|}"64 IFS="|" read -ra opt_lines <<< "$options_str"65
66 for opt_line in "${opt_lines[@]}"; do67 [[ -z "$opt_line" ]] && continue68
69 # opt_line 类似 "-p,--package:指定包名(必须)"70 # 用 ":" 分割出(短+长选项)和描述71 IFS=":" read -r flags desc <<< "$opt_line"72 printf " ${GREEN}%-20s${NC} %s\n" "$flags" "$desc"73 done74}75
76# 主帮助信息77show_help() {78 echo -e "${YELLOW}使用方法:${NC}"79 echo " $0 [命令] [选项]"80 echo81 echo -e "${YELLOW}可用命令:${NC}"82 for cmd in "${!COMMAND_MAP[@]}"; do83 local desc=${COMMAND_MAP[$cmd]%%::*}84 printf " ${GREEN}%-15s${NC} %s\n" "$cmd" "$desc"85 done86 echo -e "\n使用 ${YELLOW}$0 命令 --help${NC} 查看具体命令帮助"87}88
89##############################################90# 命令处理函数 #91##############################################92
93handle_unzip() {94 local file="" output=""95 while [[ $# -gt 0 ]]; do96 case $1 in97 -f|--file)98 file="$2"99 shift 2100 ;;101 -o|--output)102 output="$2"103 shift 2104 ;;105 -h|--help)106 show_command_help "unzip"107 exit 0108 ;;109 *)110 panic "未知选项: $1"111 ;;112 esac113 done114
115 [[ -z "$file" ]] && panic "必须指定压缩包文件路径"116 [[ -f "$file" ]] || panic "文件不存在: $file"117
118 # 如果输出目录未指定,则使用压缩包文件名作为目录名119 if [[ -z "$output" ]]; then120 # 去掉.md.zip后缀,获取文件名121 output=$(basename "$file" .md.zip)122 # 创建目标目录123 mkdir -p "$CONTENT_PATH/$output"124 output="$CONTENT_PATH/$output"125 else126 mkdir -p "$output"127 fi128
129 log_info "正在解压 ${YELLOW}$file${NC} 到 ${YELLOW}$output${NC}"130 unzip -o "$file" -d "$output" >&2131 log_success "解压完成"132
133 # 只返回解压目录路径,不输出其他信息134 echo "$output"135}136
137handle_move_images() {138 local dir=""139 while [[ $# -gt 0 ]]; do140 case $1 in141 -d|--dir)142 dir="$2"143 shift 2144 ;;145 -h|--help)146 show_command_help "move-images"147 exit 0148 ;;149 *)150 panic "未知选项: $1"151 ;;152 esac153 done154
155 [[ -z "$dir" ]] && panic "必须指定目录路径"156 [[ -d "$dir" ]] || panic "目录不存在: $dir"157
158 # 获取子文件夹名称159 subfolder=$(basename "$dir")160
161 # 检查是否存在 assets 文件夹162 if [ -d "${dir}/assets" ]; then163 # 创建目标文件夹(如果不存在)164 target_dir="$PUBLIC_IMAGES_PATH/$subfolder"165 mkdir -p "$target_dir"166
167 # 移动所有图片文件168 # 支持的图片格式:jpg, jpeg, png, gif, webp, svg169 find "${dir}/assets" -type f \( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" -o -iname "*.gif" -o -iname "*.webp" -o -iname "*.svg" \) -exec mv {} "$target_dir/" \;170
171 log_success "已移动 ${YELLOW}${dir}/assets${NC} 中的图片到 ${YELLOW}$target_dir${NC}"172
173 # 删除空的 assets 文件夹(可选)174 rmdir "${dir}/assets" 2>/dev/null && log_info "已删除空的 assets 文件夹"175 else176 log_warning "目录 ${YELLOW}$dir${NC} 中不存在 assets 文件夹"177 fi178}179
180handle_update_references() {181 local file="" name=""182 while [[ $# -gt 0 ]]; do183 case $1 in184 -f|--file)185 file="$2"186 shift 2187 ;;188 -n|--name)189 name="$2"190 shift 2191 ;;192 -h|--help)193 show_command_help "update-references"194 exit 0195 ;;196 *)197 panic "未知选项: $1"198 ;;199 esac200 done201
202 [[ -z "$file" ]] && panic "必须指定MD文件路径"203 [[ -f "$file" ]] || panic "文件不存在: $file"204 [[ -z "$name" ]] && panic "必须指定文件夹名称"205
206 log_info "正在更新 ${YELLOW}$file${NC} 中的图片引用"207
208 # 步骤1: 使用 perl 将 assets/ 替换为 /images/文件夹名/209 perl -i -pe 's/(\s*)!\[(.*?)\]\(assets\//\1,然后处理路径部分的空格213 perl -i -pe 's/(\!\[.*?\]\()([^)]*)( )([^)]*)(\))/$1$2%20$4$5/g' "$file"214
215 # 上面的替换可能无法处理多个空格的情况,所以我们多执行几次216 # 理论上重复执行直到没有空格为止,但为了简单起见,我们重复执行几次217 for i in {1..5}; do218 perl -i -pe 's/(\!\[.*?\]\()([^)]*)( )([^)]*)(\))/$1$2%20$4$5/g' "$file"219 done220
221 log_success "图片引用路径更新完成(包括空格处理)"222}223
224handle_move_md() {225 local file=""226 while [[ $# -gt 0 ]]; do227 case $1 in228 -f|--file)229 file="$2"230 shift 2231 ;;232 -h|--help)233 show_command_help "move-md"234 exit 0235 ;;236 *)237 panic "未知选项: $1"238 ;;239 esac240 done241
242 [[ -z "$file" ]] && panic "必须指定MD文件路径"243 [[ -f "$file" ]] || panic "文件不存在: $file"244
245 # 获取文件名和所在文件夹246 file_name=$(basename "$file")247 folder_path=$(dirname "$file")248 folder_name=$(basename "$folder_path")249
250 # 检查目标位置是否已经存在同名文件251 if [ -f "$CONTENT_PATH/$file_name" ]; then252 log_warning "根目录已存在同名文件 ${YELLOW}$file_name${NC},将重命名后移动"253 # 生成新文件名(添加文件夹名称作为前缀)254 new_file_name="${folder_name}-${file_name}"255 mv "$file" "$CONTENT_PATH/$new_file_name"256 log_success "已将 ${YELLOW}$file${NC} 移动并重命名为 ${YELLOW}$CONTENT_PATH/$new_file_name${NC}"257 else258 # 移动文件到根目录259 mv "$file" "$CONTENT_PATH/"260 log_success "已将 ${YELLOW}$file${NC} 移动到 ${YELLOW}$CONTENT_PATH/$file_name${NC}"261 fi262
263 # 检查子文件夹是否为空264 if [ -z "$(ls -A "$folder_path" 2>/dev/null)" ]; then265 # 文件夹为空,可以删除266 rmdir "$folder_path"267 log_info "已删除空文件夹 ${YELLOW}$folder_path${NC}"268 else269 log_info "文件夹 ${YELLOW}$folder_path${NC} 不为空,保留"270 fi271}272
273handle_process_all() {274 local file="" delete_zip=0275 while [[ $# -gt 0 ]]; do276 case $1 in277 -f|--file)278 file="$2"279 shift 2280 ;;281 -d|--delete)282 delete_zip=1283 shift284 ;;285 -h|--help)286 show_command_help "process-all"287 exit 0288 ;;289 *)290 panic "未知选项: $1"291 ;;292 esac293 done294
295 [[ -z "$file" ]] && panic "必须指定压缩包文件路径"296 [[ -f "$file" ]] || panic "文件不存在: $file"297
298 log_info "开始处理文件 ${YELLOW}$file${NC}"299
300 # 1. 解压文件 - 捕获返回的目录路径而不是所有输出301 dir=$(handle_unzip --file "$file")302 log_info "文件已解压到 ${YELLOW}$dir${NC}"303
304 # 获取文件夹名称305 folder_name=$(basename "$dir")306
307 # 2. 移动图片308 handle_move_images --dir "$dir"309 log_info "图片已移动到公共目录"310
311 # 3. 查找MD文件 - 使用更安全的方式处理包含空格的文件名312 # 不使用命令替换和for循环,而是使用 while read 循环313 log_info "搜索MD文件..."314 local found_files=0315
316 while IFS= read -r -d '' md_file; do317 # 4. 更新图片引用318 found_files=1319 log_info "找到MD文件: ${YELLOW}$md_file${NC}"320 handle_update_references --file "$md_file" --name "$folder_name"321 log_info "MD文件中的图片引用已更新"322
323 # 5. 移动MD文件324 handle_move_md --file "$md_file"325 log_info "MD文件已移动到博客根目录"326 done < <(find "$dir" -maxdepth 1 -type f -name "*.md" -print0)327
328 if [[ $found_files -eq 0 ]]; then329 log_warning "未找到MD文件"330 fi331
332 # 6. 删除源压缩包(如果选择了--delete选项)333 if [[ $delete_zip -eq 1 ]]; then334 if rm "$file"; then335 log_success "已删除源压缩包 ${YELLOW}$file${NC}"336 else337 log_warning "删除源压缩包 ${YELLOW}$file${NC} 失败"338 fi339 fi340
341 log_success "文件 ${YELLOW}$file${NC} 处理完成"342}343
344##############################################345# 主逻辑 #346##############################################347
348main() {349 [[ $# -eq 0 ]] || [[ "$1" == "--help" ]] && show_help && exit 0350
351 local cmd="$1"352 shift353
354 [[ -n "${COMMAND_MAP[$cmd]}" ]] || panic "未知命令: $cmd"355
356 # 同样地,这里也要避免直接 IFS="::" 分割357 # 先用 '|' 替换 "::" ,再做一次分割即可358 local info="${COMMAND_MAP[$cmd]}"359 info="${info//::/|}"360
361 local desc handler options_str362 IFS="|" read -r desc handler options_str <<< "$info"363
364 # 调用对应的处理函数365 $handler "$@"366}367
368main "$@"
脚本将每一个功能都独立为一个命令选项,并提供整合选项 process-all
自动执行所有功能。
例如
1./siyuan_export_processor.sh process-all -d -f "年轻人的第一台 VPS 代理服务器.md.zip"
调整 frontmatter
Astro 主题有一些额外的 frontmatter,思源默认的 frontmatter 可能没有这么多。需要手动调整一下,比如 分类和标签。
VSCODE 编写
除了思源笔记以外,还可以通过 vscode 编写,虽然目前 vscode 已经自带了 markdown 的图片粘贴自动引用,但在 cursor 里没法用。需要安装扩展 Markdown Paste。
可以在设置中的 Markdown Paste: Path
中修改自定义存放目录。例如在 Astro 的通常的文件结构中,存放在 /public/images 中。
1${workspaceFolder}/public/images/${fileBasenameNoExtension}/
直接使用 vscode 编写 md 文件,可以更好的利用主题提供的额外的 markdown 语法。
git 提交
写完后,用 git 工具提交到仓库,会自动触发编译,并推动到公开仓库部署。