Communitygithub.com

qalimero/qalim-portfolio

使用 Astro 框架构建和部署静态网站的全面指南。

qalim-portfolio 是什么?

该技能提供了一套完整的 Astro 框架指南,专注于静态网站生成。它涵盖了从快速启动、项目结构到内容集合(包括 Astro 5.0 中的新 Content Layer API)和 Markdown/MDX 支持的方方面面。此外,它还深入介绍了代码语法高亮(Shiki、Expressive Code)、图表集成(Mermaid、PlantUML)、客户端搜索(Pagefind、Fuse.js)以及国际化等高级功能。该技能还详细解释了部署到 Netlify、Vercel、GitHub Pages 或 GCP/Firebase 的配置,并提供了全面的部署前检查清单和测试建议。总的来说,它为开发者提供了构建生产级、高性能、静态 Astro 网站所需的一切知识。

兼容平台✓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

文档

发布 Astro 网站

使用 Astro 的零运行时 SSG 方法、部分水合和广泛的 Markdown 支持,构建快速、内容驱动的静态网站。

目录

快速入门

# 创建新项目 (Markdown 站点使用 Blog 模板)
npm create astro@latest

# 开发
npm run dev          # 本地服务器在 http://localhost:4321
npm run build        # 在 dist/ 目录生成静态文件
npm run preview      # 预览生产构建

何时不使用

此技能专注于静态网站生成 (SSG)。对于其他场景,请考虑其他方法:

  • 实时数据应用 - 使用 SSR 模式配合数据库连接
  • 用户认证流程 - 需要服务器端会话处理
  • 具有动态库存的电子商务 - 使用混合模式或完整 SSR
  • 单页应用 (SPA) - 直接考虑使用 React/Vue 框架

如需了解 SSG+SSR 混合模式,请参阅 Astro 的适配器文档。

项目结构

src/
  components/     # Astro, React, Vue, Svelte 组件
  content/        # 内容集合 (Markdown/MDX)
    config.ts     # 集合架构
    docs/         # 示例集合
  layouts/        # 带有插槽的页面包装器
  pages/          # 基于文件的路由
public/           # 静态资源 (图片, 字体, 网站图标)
astro.config.mjs  # 框架配置

SSG vs SSR vs Hybrid

模式页面渲染时机使用场景
SSG (默认)构建时博客, 文档, 营销网站
SSR每次请求动态数据, 个性化
混合两者混合静态页面 + 动态端点

对于纯静态站点,使用默认的 output: 'static' — 无需适配器。

内容集合

传统模式 (Astro 4.x)

在 src/content/config.ts 中定义架构:

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)
    })
  })
};

Content Layer 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: ({ image }) => z.object({
  cover: image(),                    // 验证 src/ 中的图片
  category: z.enum(['tech', 'news']),
  author: reference('authors'),      // 跨集合引用
  relatedPosts: z.array(reference('blog')).optional(),
})

自定义加载器 (远程内容)

从外部 API (GitHub releases, 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;
  }
}

行高亮和转换器

```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: 你好
Bob --> Alice: 嗨!
@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 i = 1; i <= totalPages; i++) {
    paths.push({
      params: { page: String(i) },
      props: {
        page: i,
        totalPages,
        posts: allPosts.slice((i - 1) * POSTS_PER_PAGE, i * POSTS_PER_PAGE)
      }
    });
  }
  return paths;
}

const { page, totalPages, posts } = Astro.props;
---

<div class="pagination">
  {page > 1 && <a href={`/blog/page/${page - 1}`}>上一页</a>}
  <span>第 {page} 页,共 {totalPages} 页</span>
  {page < totalPages && <a href={`/blog/page/${page + 1}`}>下一页</a>}
</div>

标签系统

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

export async function getStaticPaths() {
  const allPosts = await getCollection("blog");
  const tags = [...new Set(allPosts.flatMap(p => p.data.tags || []))];

  return tags.map(tag => ({
    params: { tag },
    props: {
      tag,
      posts: allPosts.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

import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';

export async function GET(context) {
  const posts = await getCollection('blog');
  return rss({
    title: '我的博客',
    description: '我的 Astro 博客的 RSS Feed',
    site: context.site,
    items: posts.map(post => ({
      title: post.data.title,
      pubDate: post.data.pubDate,
      description: post.data.description,
      link: `/blog/${post.slug}/`,
    })),
    customData: `<language>zh-cn</language>`,
  });
}

表单处理

对于静态站点,使用无服务器函数提交表单:

---
// src/pages/contact.astro
---

<form action="/api/contact" 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>

使用 Netlify Forms (零后端):

<form netlify name="contact">
  <input type="hidden" name="form-name" value="contact" />
  <!-- 表单字段 -->
</form>

性能最佳实践

预取

内置的客户端路由器支持预取:

export default defineConfig({
  prefetch: true
});

或使用指令手动控制:

<a href="/about" data-astro-prefetch>关于我们</a>

关键 CSS

自动内联关键 CSS (默认启用)。对于自定义,使用 criticalCSS:

export default defineConfig({
  build: {
    inlineStylesheets: 'auto'  // 自动内联关键 CSS
  }
});

图片优化

使用 @astrojs/image 进行优化、响应式图片:

---
import { Image, Picture } from '@astrojs/image/components';
import heroImage from '../assets/hero.jpg';
---

<Image src={heroImage} alt="英雄图" width={800} height={400} />
<Picture src={heroImage} formats={['avif', 'webp']} widths={[400, 800, 1200]} />

分析工具选择

服务特点
Google Analytics全面但体积大
Plausible轻量、隐私优先、开源
Fathom极简、GDPR 合规
Cloudflare Web Analytics免费、无 JS、隐私优先
Umami自托管、开源、轻量

部署

Netlify

npm install @astrojs/netlify
export default defineConfig({
  output: 'static',
  adapter: netlify()
});

重定向: 创建 public/_redirects:

/blog/* /blog/:splat 200
/* /index.html 404

功能: 零配置部署、自动 HTTPS、无服务器函数、拆分测试、表单处理。

Vercel

npm install @astrojs/vercel
export default defineConfig({
  output: 'static',
  adapter: vercel()
});

GitHub Pages

npm install @astrojs/github-pages
export default defineConfig({
  site: 'https://<username>.github.io',
  base: '/<repository>',
  adapter: githubPages()
});

GCP / Firebase

npm install @astrojs/cloudflare  # 与 GCP 上的 Cloudflare Workers 类似

对于 Firebase Hosting:

// firebase.json
{
  "hosting": {
    "public": "dist",
    "ignore": ["firebase.json", "**/.*"],
    "rewrites": [{
      "source": "**",
      "destination": "/index.html"
    }]
  }
}

部署前检查清单

  • 运行 astro check 并修复类型错误
  • 运行 astro build (无错误)
  • 预览生产构建:astro preview
  • 测试所有内部链接和导航
  • 验证表单功能
  • 检查分析是否加载
  • 测试暗色模式
  • 验证社交媒体元数据 (Open Graph)
  • 测试使用键盘导航的辅助功能
  • 检查 SEO 设置:robots.txt, sitemap.xml, rss.xml
  • 验证构建大小 (dist/ 目录)

测试与质量

Vitest (单元测试)

npm install -D vitest
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
  test: {
    include: ['src/**/*.test.{js,ts}']
  }
});

示例测试:

import { describe, it, expect } from 'vitest';

describe('我的组件', () => {
  it('应该正确渲染', () => {
    // 测试逻辑
    expect(true).toBe(true);
  });
});

Playwright (E2E 测试)

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',
    url: 'http://localhost:4321',
    reuseExistingServer: true
  }
});

示例 E2E 测试:

import { test, expect } from '@playwright/test';

test('主页应该加载', async ({ page }) => {
  await page.goto('http://localhost:4321');
  await expect(page).toHaveTitle(/我的网站/);
});

test('导航应该有效', async ({ page }) => {
  await page.goto('http://localhost:4321');
  await page.click('text=关于');
  await expect(page).toHaveURL(/\/about/);
});

链接检查

npm install -D broken-link-checker
npx broken-link-checker http://localhost:4321 --recursive

故障排除

问题解决方案
构建错误 "cannot find module '@astrojs/...'"安装适配器:npm install @astrojs/netlify
页面返回 404检查文件名是否匹配路由规则 (例如 src/pages/about.md 映射到 /about)
Content layer 内容未显示验证 src/content.config.ts 中的路径模式
Mermaid 图表未渲染确保已安装 astro-mermaid 并添加了集成
搜索不工作检查 pagefind --site dist 是否在构建后运行
i18n 路由失败验证 astro.config.mjs 中的语言配置
暗色模式未应用如果使用 Shiki 主题,检查 CSS 媒体查询。验证集成是否支持主题切换

相关技能