Communitygithub.com

qalimero/qalim-portfolio

使用 Astro 框架建置與部署靜態網站的全面指南。

qalim-portfolio 是什麼?

此技能提供使用 Astro 框架建立和部署高效能靜態網站的全面指南。它涵蓋了 Astro 的核心功能,包括靜態網站生成 (SSG) 基礎、內容集合、Markdown/MDX 支援、部分水合 (partial hydration) 和島嶼架構 (islands architecture)。技能還深入探討了程式碼語法反白 (Shiki)、圖表整合 (Mermaid, PlantUML)、客戶端搜尋 (Pagefind, Fuse.js)、多語言 (i18n) 設定以及分頁、標籤和 RSS Feed 等常見模式。它支援部署到 Netlify、Vercel、GitHub Pages 和 GCP/Firebase 等主流平台,並包含部署前檢查清單、效能最佳化建議 (如預先擷取和關鍵 CSS) 以及使用 Vitest 和 Playwright 進行測試的指引。無論是要建立部落格、文件網站還是行銷頁面,此技能都能提供從專案初始化到上線部署的完整工作流程說明。

相容平台✓Claude Code~Codex CLI~Cursor
npx skills add https://github.com/qalimero/qalim-portfolio/tree/HEAD/.claude/skills/publishing-astro-websites

在你喜歡的 AI 中提問

開啟一個已預先載入此 Agent Skill 的新對話。

預覽

來自技能 README

qalim-portfolio preview
qalim-portfolio preview

說明文件

發布 Astro 網站

使用 Astro 的零執行時期 SSG 方法、部分水合 (partial hydration) 和廣泛的 Markdown 支援,打造快速、內容驅動的靜態網站。

目錄

快速開始

# 建立新專案 (使用 Blog 範本用於 Markdown 網站)
npm create astro@latest

# 開發
npm run dev          # 本機伺服器位於 http://localhost:4321
npm run build        # 在 dist/ 中產生靜態檔案
npm run preview      # 預覽正式環境建置

何時不該使用

此技能專注於靜態網站生成 (SSG)。對於以下情況,請考慮其他方法:

  • 即時資料應用程式 - 使用 SSR 模式並搭配資料庫連線
  • 使用者認證流程 - 需要伺服器端 session 處理
  • 具有動態庫存的電子商務 - 使用混合模式或完整的 SSR
  • 單頁應用程式 (SPA) - 直接考慮 React/Vue 框架

如需混合 SSG+SSR 模式,請參閱 Astro 的配接器 (adapter) 文件。

專案結構

src/
  components/     # Astro、React、Vue、Svelte 元件
  content/        # 內容集合 (Markdown/MDX)
    config.ts     # 集合 schema
    docs/         # 範例集合
  layouts/        # 帶有 slot 的頁面包裝器
  pages/          # 基於檔案的頁面路由 (File-based routing)
public/           # 靜態資產 (圖片、字型、favicon)
astro.config.mjs  # 框架設定

SSG vs SSR vs 混合模式

模式頁面渲染時機使用案例
SSG (預設)建置時部落格、文件、行銷網站
SSR每次請求動態資料、個人化
混合模式兩者混合靜態頁面 + 動態端點

對於純靜態網站,使用預設的 output: 'static' - 不需要配接器。

內容集合

舊有模式 (Astro 4.x)

在 src/content/config.ts 中定義 schema:

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

export const collections = {
  docs: defineCollection({
    schema: z.object({
      title: z.string(),
      description: z.string().optional(),
      tags: z.array(z.string()).optional(),
      order: z.number().optional(),
      draft: z.boolean().default(false)
    })
  })
};

內容層 API (Astro 5.0+)

使用 glob() 載入器的新模式 - 對於大型網站,建置速度最多可提升 75%:

// src/content.config.ts (注意:檔案名稱不同)
import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'astro/zod';

const blog = defineCollection({
  loader: glob({ pattern: '**/*.md', base: './src/data/blog' }),
  schema: ({ image }) => z.object({
    title: z.string(),
    pubDate: z.coerce.date(),
    draft: z.boolean().default(false),
    cover: image(),  // 驗證圖片存在
    author: reference('authors'),  // 跨集合參考
  })
});

export const collections = { blog };

進階 Schema 模式

schema: ({ image }) => z.object({
  cover: image(),                    // 驗證 src/ 中的圖片
  category: z.enum(['tech', 'news']),
  author: reference('authors'),      // 跨集合參考
  relatedPosts: z.array(reference('blog')).optional(),
})

自訂載入器 (遠端內容)

從外部 API (GitHub 發布、CMS 等) 擷取內容:

// src/loaders/github-releases.ts
import type { Loader } from 'astro/loaders';

export function githubReleasesLoader(repo: string): Loader {
  return {
    name: 'github-releases',
    load: async ({ store, logger }) => {
      logger.info(`Fetching releases for ${repo}`);
      const response = await fetch(`https://api.github.com/repos/${repo}/releases`);
      const releases = await response.json();

      for (const release of releases) {
        store.set({
          id: release.tag_name,
          data: {
            version: release.tag_name,
            published_at: release.published_at,
            body: release.body  // Markdown 發布說明
          }
        });
      }
    }
  };
}

在 content.config.ts 中註冊:

import { githubReleasesLoader } from './loaders/github-releases';

const releases = defineCollection({
  loader: githubReleasesLoader('owner/repo'),
  schema: z.object({
    version: z.string(),
    published_at: z.string(),
    body: z.string(),
  })
});

查詢並渲染集合:

---
import { getCollection } from "astro:content";

export async function getStaticPaths() {
  const docs = await getCollection("docs");
  return docs.map(doc => ({
    params: { slug: doc.slug },
    props: { doc }
  }));
}

const { doc } = Astro.props;
const { Content } = await doc.render();
---

<article>
  <h1>{doc.data.title}</h1>
  <Content />
</article>

語法反白

基本 Shiki 設定

import { defineConfig } from "astro/config";

export default defineConfig({
  markdown: {
    shikiConfig: {
      theme: "github-dark",
      wrap: true
    }
  }
});

雙主題 (亮色/暗色)

shikiConfig: {
  themes: {
    light: 'github-light',
    dark: 'github-dark',
  },
}

新增 CSS 以切換主題:

@media (prefers-color-scheme: dark) {
  .astro-code, .astro-code span {
    color: var(--shiki-dark) !important;
    background-color: var(--shiki-dark-bg) !important;
  }
}

行號反白與轉換器 (Transformers)

```typescript {2,4}
const a = 1;
const b = 2;  // 反白
const c = 3;
console.log(a + b + c);  // 反白
```

Shiki 轉換器 (Astro 4.14+):

import { transformerNotationFocus, transformerNotationDiff } from '@shikijs/transformers';

shikiConfig: {
  transformers: [transformerNotationFocus(), transformerNotationDiff()],
}

在程式碼中使用註解標記:

  • // [!code focus] - 聚焦此行
  • // [!code ++] - 標記為新增 (綠色)
  • // [!code --] - 標記為刪除 (紅色)

Expressive Code (推薦用於文件)

豐富的程式碼區塊,包含複製按鈕、檔案名稱、差異反白:

npm install astro-expressive-code
import expressiveCode from 'astro-expressive-code';

export default defineConfig({
  integrations: [expressiveCode()],
});

功能:複製按鈕、檔案索引標籤、行標記、終端機框架、文字標記。

圖表整合

Mermaid (推薦)

安裝 Astro 整合套件:

npm install astro-mermaid mermaid
// astro.config.mjs
import { defineConfig } from 'astro/config';
import mermaid from 'astro-mermaid';

export default defineConfig({
  integrations: [mermaid({ theme: 'default' })]
});

在 Markdown 中使用:

```mermaid
graph TD;
    A-->B;
    B-->C;
```

功能:客戶端渲染、自動主題切換、可離線使用、無需 Playwright。

替代方案 (建置時靜態 SVG): 使用 rehype-mermaid 搭配 Playwright 來預渲染圖表 (需要執行 npx playwright install --with-deps)。

暗色模式主題策略:

  1. CSS 變數 - 讓瀏覽器在執行時期解析顏色:
// mermaid 設定
mermaid.initialize({
  theme: 'base',
  themeVariables: {
    primaryColor: 'var(--diagram-primary)',
    lineColor: 'var(--diagram-line)'
  }
});
  1. Picture 元素 - 產生兩種主題,使用媒體查詢切換:
<picture>
  <source srcset="/diagrams/flow-dark.svg" media="(prefers-color-scheme: dark)">
  <img src="/diagrams/flow-light.svg" alt="流程圖">
</picture>
  1. 行內 SVG - 使用 CSS 定位 SVG 類別 (風險:樣式衝突) - 加入無障礙標準,可考慮使用 role='image' 或 role="presentation":
.dark .mermaid-svg .node rect {
  fill: var(--bg-dark);
}

PlantUML

npx astro add plantuml

在 Markdown 中使用:

```plantuml
@startuml
Alice -> Bob: Hello
Bob --> Alice: Hi!
@enduml
```

客戶端搜尋

Pagefind (推薦用於大型網站)

零設定靜態搜尋,在建置時建立索引:

npm install pagefind

新增至 package.json:

{
  "scripts": {
    "build": "astro build && npx pagefind --site dist",
    "postbuild": "pagefind --site dist"
  }
}

在元件中使用:

<link href="/pagefind/pagefind-ui.css" rel="stylesheet" />
<script src="/pagefind/pagefind-ui.js" type="text/javascript"></script>

<div id="search"></div>
<script>
  window.addEventListener('DOMContentLoaded', () => {
    new PagefindUI({ element: '#search', showSubResults: true });
  });
</script>

功能:無需外部服務、可離線使用、自動索引、小體積 (~10KB)。

細粒度索引控制:

<!-- 僅索引主要內容,不含頁首/側邊欄 -->
<main data-pagefind-body>
  <h1 data-pagefind-meta="title">頁面標題</h1>
  <p data-pagefind-weight="10">重要的介紹文字</p>

  <!-- 從搜尋片段中排除 -->
  <nav data-pagefind-ignore>
    <a href="/related">相關文章</a>
  </nav>
</main>
屬性用途
data-pagefind-body將索引範圍限制在此元素內
data-pagefind-ignore從索引中排除此元素
data-pagefind-meta="key"定義後設資料欄位
data-pagefind-weight="10"提升相關性 (預設值:1)

Pagefind vs Fuse.js

功能PagefindFuse.js
架構預先建置的二進位區塊執行時期記憶體中
頻寬低 (僅載入所需的區塊)高 (下載完整索引)
可擴展性10,000+ 頁面< 500 頁面
多語言原生詞幹處理需手動設定
使用案例全域網站搜尋小型列表過濾

Fuse.js (輕量級替代方案)

適用於需要自訂 UI 的較小網站:

---
import { getCollection } from "astro:content";
const posts = await getCollection('blog');
const searchIndex = JSON.stringify(posts.map(post => ({
  title: post.data.title,
  slug: post.slug,
  body: post.body.slice(0, 500)
})));
---

<input type="search" id="search" placeholder="搜尋..." />
<ul id="results"></ul>

<script define:vars={{ searchIndex }}>
  import Fuse from 'fuse.js';

  const fuse = new Fuse(JSON.parse(searchIndex), {
    keys: ['title', 'body'],
    threshold: 0.3
  });

  document.getElementById('search').addEventListener('input', (e) => {
    const results = fuse.search(e.target.value);
    const resultsEl = document.getElementById('results');

    // 安全地清除之前的結果
    resultsEl.replaceChildren();

    // 使用安全的 DOM 方法建構結果
    results.forEach(r => {
      const li = document.createElement('li');
      const link = document.createElement('a');
      link.href = `/blog/${r.item.slug}`;
      link.textContent = r.item.title;
      li.appendChild(link);
      resultsEl.appendChild(li);
    });
  });
</script>

對於企業需求,請考慮 Algolia (託管搜尋 API)。

版本化文件

多版本文件模式

使用基於資料夾的結構:

src/content/docs/
  v1/
    getting-started.md
    api-reference.md
  v2/
    getting-started.md
    api-reference.md
    new-feature.md

國際化 (i18n)

在 astro.config.mjs 中設定:

export default defineConfig({
  i18n: {
    defaultLocale: "en",
    locales: ["en", "fr", "es"],
    routing: {
      prefixDefaultLocale: false
    }
  }
});

按語言環境組織內容:

src/content/docs/
  en/
    getting-started.md
  fr/
    getting-started.md

在元件中偵測語言環境:

---
const locale = Astro.currentLocale || 'en';
---

缺少翻譯時的回退機制

當翻譯不存在時,顯示帶有提示橫幅的英文內容:

---
// src/pages/[lang]/[...slug].astro
import { getCollection, getEntry } from "astro:content";

const languages = ['en', 'es', 'fr'];
const defaultLang = 'en';

export async function getStaticPaths() {
  const englishDocs = await getCollection('docs', ({ id }) => id.startsWith('en/'));
  const paths = [];

  for (const doc of englishDocs) {
    const slug = doc.id.replace(/^en\//, '');

    for (const lang of languages) {
      const localizedId = `${lang}/${slug}`;
      const localizedDoc = await getEntry('docs', localizedId);

      paths.push({
        params: { lang, slug },
        props: {
          entry: localizedDoc || doc,  // 回退至英文
          isFallback: !localizedDoc
        }
      });
    }
  }
  return paths;
}

const { entry, isFallback } = Astro.props;
const { Content } = await entry.render();
---

{isFallback && (
  <div class="translation-notice">
    此頁面尚無您的語系版本。
  </div>
)}
<Content />

常見模式

分頁列表

對於超過 50 篇文章的網站,將其分割成多個頁面:

---
// src/pages/blog/page/[page].astro
import { getCollection } from "astro:content";

const POSTS_PER_PAGE = 10;

export async function getStaticPaths() {
  const allPosts = await getCollection("blog");
  const totalPages = Math.ceil(allPosts.length / POSTS_PER_PAGE);
  const paths = [];

  for (let page = 1; page <= totalPages; page++) {
    paths.push({
      params: { page: String(page) },
      props: { page, totalPages }
    });
  }
  return paths;
}

const { page, totalPages } = Astro.props;
const allPosts = await getCollection("blog");
const paginatedPosts = allPosts.slice(
  (page - 1) * POSTS_PER_PAGE,
  page * POSTS_PER_PAGE
);
---

<h1>部落格 - 第 {page} 頁</h1>

<ul>
{allPosts.map(post => <li><a href={`/blog/${post.slug}`}>{post.data.title}</a></li>)}
</ul>

<nav>
  {page > 1 && <a href={`/blog/page/${page - 1}`}>上一頁</a>}
  {page < totalPages && <a href={`/blog/page/${page + 1}`}>下一頁</a>}
</nav>

多標籤篩選

使用 URL 搜尋參數實作 AND/OR 邏輯:

---
// src/pages/blog/[...slug].astro
import { getCollection } from 'astro:content';
import { parseSlug } from './_helpers';

export async function getStaticPaths() {
  const posts = await getCollection('blog');

  const paths = [];

  // 根據標籤結構產生病態路由
  for (const post of posts) {
    const tags = post.data.tags || [];

    // 以多種方式串接
    if (tags.length === 1) {
      paths.push({ params: { slug: [tags[0]] }, props: { post } });
    } else if (tags.length >= 2) {
      paths.push({ params: { slug: tags }, props: { post } });
    }
  }

  // 包含不含標籤的文章
  paths.push({ params: { slug: undefined }, props: { post: null } });

  return paths;
}

const { post } = Astro.props;
---

{post && (
  <article>
    <h2>{post.data.title}</h2>
    <p>{post.data.description}</p>
    <ul>
      {post.data.tags?.map(tag => <li>{tag}</li>)}
    </ul>
  </article>
)}
// src/pages/blog/_helpers.ts
export function parseSlug(slug: string[] | undefined): string[] {
  if (!slug) return [];
  return Array.isArray(slug) ? slug : [slug];
}

標籤索引頁面

從集合動態產生標籤頁面:

---
// src/pages/tags/[tag].astro
export async function getStaticPaths() {
  const posts = await getCollection('blog');
  const tags = [...new Set(posts.flatMap(p => p.data.tags || []))];

  return tags.map(tag => ({
    params: { tag },
    props: { tag, posts: posts.filter(p => p.data.tags?.includes(tag)) }
  }));
}

const { tag, posts } = Astro.props;
---

<h1>標籤:{tag}</h1>
<ul>
  {posts.map(post => (
    <li><a href={`/blog/${post.slug}`}>{post.data.title}</a></li>
  ))}
</ul>

RSS Feed

使用 Astro 內建的 RSS 支援:

// astro.config.mjs
import { defineConfig } from 'astro/config';

// 自訂 RSS 產生器函式
async function generateRSS() {
  const posts = await getCollection('blog');
  const site = 'https://example.com';

  const items = posts
    .filter(p => !p.data.draft)
    .sort((a, b) => b.data.pubDate - a.data.pubDate)
    .slice(0, 20)
    .map(post => ({
      title: post.data.title,
      link: `${site}/blog/${post.slug}`,
      pubDate: post.data.pubDate,
      description: post.data.description,
    }));

  return new Response(
    `
    <rss version="2.0">
      <channel>
        <title>我的部落格</title>
        <link>${site}</link>
        <description>最近的文章</description>
        ${items.map(item => `
          <item>
            <title>${escapeXML(item.title)}</title>
            <link>${escapeXML(item.link)}</link>
            <pubDate>${item.pubDate.toUTCString()}</pubDate>
            <description>${escapeXML(item.description)}</description>
          </item>
        `).join('')}
      </channel>
    </rss>
    `,
    { headers: { 'Content-Type': 'application/rss+xml' } }
  );
}

function escapeXML(str: string): string {
  return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

表單處理

靜態網站通常需要外部表單服務。推薦的選項:

服務優點限制
Formspree簡單的 API,免費方案每月提交次數有限
Netlify Forms原生 Netlify 整合,零伺服器僅限 Netlify 託管
Google Forms免費,可擴展自訂選項較少
Getform檔案上傳,電子郵件通知付費方案才有進階功能

Netlify Forms 範例:

<form name="contact" netlify>
  <input type="text" name="name" placeholder="您的姓名" required />
  <input type="email" name="email" placeholder="您的電子郵件" required />
  <textarea name="message" placeholder="您的訊息"></textarea>
  <button type="submit">傳送</button>
</form>

Formspree 範例:

<form action="https://formspree.io/f/{your-form-id}" method="POST">
  <input type="text" name="name" placeholder="您的姓名" required />
  <input type="email" name="email" placeholder="您的電子郵件" required />
  <textarea name="message" placeholder="您的訊息"></textarea>
  <button type="submit">傳送</button>
</form>

效能最佳實務

預先擷取 (Prefetching)

import { defineConfig } from 'astro/config';
import prefetch from '@astrojs/prefetch';

export default defineConfig({
  integrations: [prefetch({
    selector: 'a[href^="/"]',
    throttle: 3
  })]
});

策略:

  • 使用 intent 進行觸控預先擷取 (懸停時載入)
  • 使用 hover 立即載入
  • 對首頁可見的導覽連結使用 viewport

關鍵 CSS 提取

啟用自動關鍵 CSS 內嵌 (適用於 Netlify、Vercel、Cloudflare):

import { defineConfig } from 'astro/config';

export default defineConfig({
  build: {
    inlineStylesheets: 'auto'
  }
});

圖片最佳化

import { defineConfig } from 'astro/config';
import image from '@astrojs/image';

export default defineConfig({
  integrations: [image({
    serviceEntryPoint: '@astrojs/image/sharp'
  })]
});

HTML 壓縮

import { defineConfig } from 'astro/config';
import compress from 'astro-compress';

export default defineConfig({
  integrations: [compress()]
});

移除未使用的 CSS

import { defineConfig } from 'astro/config';
import purgecss from 'astro-purgecss';

export default defineConfig({
  integrations: [purgecss()]
});

部署

Netlify

# 安裝 Netlify 配接器
npx astro add netlify
# netlify.toml
[build]
  command = "npm run build"
  publish = "dist"

[[redirects]]
  from = "/blog/*"
  to = "/blog/:splat"
  status = 200

Vercel

npx astro add vercel

GitHub Pages

npx astro add node
name: Deploy to GitHub Pages

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npm run build
      - uses: peaceiris/actions-gh-pages@v3
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: ./dist

GCP / Firebase

npx astro add node
# 在 astro.config.mjs 中將 output 設定為 'static'
# 並在 firebase.json 中將 public 設定為 'dist'

部署至 Firebase Hosting:

npm install -g firebase-tools
firebase init
firebase deploy --only hosting

部署前檢查清單

  • npm run build 無錯誤完成
  • 所有內部連結有效 (考慮使用 hyperlink)
  • 圖片路徑使用 ESM 匯入 (import logo from "./logo.png")
  • 環境變數在 astro.config.mjs 和 public/env.js 中均正確設定
  • 自訂 404 頁面 (src/pages/404.astro)
  • SEO 中繼標籤 (使用 @astrojs/seo)
  • 啟用 Sitemap 生成 (@astrojs/sitemap)
  • 殘缺檔案處理 (public/_redirects 或 astro.config.mjs 中的 trailingSlash)
  • 自訂字型已預載 (使用 <link rel="preload">)
  • 測試 Lighthouse 效能分數 (目標:> 90)
  • 無障礙檢查 (使用 axe 工具)

測試與品質

Vitest (單元測試)

npm install -D vitest
// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'node',
    include: ['src/**/*.test.ts']
  }
});

Playwright (端對端測試)

npm install -D @playwright/test
npx playwright install
// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  webServer: {
    command: 'npm run preview',
    port: 4321,
    reuseExistingServer: true
  }
});

連結檢查

npm install -D hyperlink
{
  "scripts": {
    "check:links": "hyperlink dist/ --recursive --sources src/"
  }
}

疑難排解

問題解決方案
getCollection 回傳空值確認路徑在 src/content/ 下且 schema 有效。嘗試簡化為不帶選擇器的 getCollection('blog')。
建置時圖片載入器錯誤使用 ESM 匯入路徑 (import logo from "./logo.png"),而非字串 (<img src="/logo.png" />)。
水合 (Hydration) 錯誤確認僅用戶端元件使用 JS;靜態 HTML 無需水合。使用 client:load 或 client:idle。
Mermaid 主題變數未套用使用建置時 SVG 方法 (圖片元素);CSS 變數無法在 Shadow DOM 內運作。
i18n 路由問題檢查特定 API 文件中的 prefixDefaultLocale: false。使用 Astro.currentLocale 進行語言環境偵測。
部署時 404 錯誤檢查 SPA 模式的伺服器重新導向設定。對於 Netlify/Vercel,使用 [[redirects]] 或 @astrojs/vercel 的 isr 設定。
元件未找到確認元件路徑相對於使用點正確。使用絕對路徑 (從 src/ 開始) 以避免混淆。

相關技能