跳转到内容

给 AstroPaper 加一个项目页,再把 Decap CMS 部署到 Vercel

Published: at 23:00

个人博客写久以后,文章通常不会是唯一内容。我还想放相册、个人介绍,也想给自己做的软件留一个长期入口。

这次我给 AstroPaper 增加了一个项目页,用来展示 iOS 软件 LinkSet 和 macOS 软件闪电右键。页面完成以后,我又接入了 Decap CMS,让文章可以在浏览器里编辑,再由 GitHub 提交触发 Vercel 部署。

页面本身不难,OAuth 才是最容易卡住的地方。Netlify 托管时可以直接使用平台提供的认证服务,Vercel 上没有这层现成代理,需要自己补上授权入口和回调函数。中间还遇到了一次很典型的中文文件名问题,一篇标题带有 100% 的文章让后台直接崩溃。

下面按实际改造顺序写一遍。最终会得到这些东西。

先看一下项目结构

这次新增和修改的文件集中在几个位置。

api/
└── decap/
    ├── _shared.ts
    ├── auth.ts
    └── callback.ts

public/
├── admin/
│   └── config.yml
└── images/
    └── projects/
        ├── linkset-icon.jpg
        ├── linkset-preview.jpg
        ├── quickright-icon.png
        └── quickright-preview.jpg

src/
├── components/
│   └── Header.astro
└── pages/
    ├── admin/
    │   └── index.html
    └── projects/
        └── index.astro

博客继续保持静态输出。只有 OAuth 两个地址运行在 Vercel Functions 中,这样不用为了一个后台登录把整站改成 SSR。

项目页先从内容数据开始

项目页只有两款软件,直接把数据写在 src/pages/projects/index.astro 里已经够用。这里没有急着引入新的内容集合,因为产品名称、平台、卖点和 App Store 地址并不会频繁变化。

const projects = [
  {
    name: "LinkSet",
    chineseName: "链接管理器",
    platform: "iPhone · iPad · Mac",
    description: "把散落在不同平台的重要链接收进一个清爽的空间。",
    features: ["AI 智能分类", "iCloud 多端同步", "重复与失效链接清理"],
    icon: "/images/projects/linkset-icon.jpg",
    preview: "/images/projects/linkset-preview.jpg",
    href: "https://apps.apple.com/cn/app/...",
    theme: "linkset",
  },
  {
    name: "闪电右键",
    chineseName: "QuickRight",
    platform: "macOS",
    description: "为 Finder 补上真正顺手的右键菜单。",
    features: ["快捷新建文件", "常用 Finder 工具", "轻量且原生"],
    icon: "/images/projects/quickright-icon.png",
    preview: "/images/projects/quickright-preview.jpg",
    href: "https://apps.apple.com/cn/app/...",
    theme: "quickright",
  },
];

页面再用一次 map 渲染卡片。以后增加新软件,只要补一个对象,不必复制整段 HTML。

<div class="project-list">
  {
    projects.map(project => (
      <article class={`project-card project-card--${project.theme}`}>
        <div class="project-copy">
          <img src={project.icon} alt={`${project.name} 应用图标`} />
          <h2>{project.name}</h2>
          <p>{project.description}</p>

          <ul aria-label={`${project.name} 主要功能`}>
            {project.features.map(feature => (
              <li>{feature}</li>
            ))}
          </ul>

          <a href={project.href} target="_blank" rel="noopener noreferrer">
            在 App Store 查看
          </a>
        </div>

        <div class="project-visual">
          <img src={project.preview} alt={project.previewAlt} />
        </div>
      </article>
    ))
  }
</div>

卡片在桌面端分成文案和预览两栏,窄屏时改成上下排列。LinkSet 的截图接近手机长图,适合居中展示并留出顶部空间。闪电右键是横向的 macOS 窗口截图,直接铺满右侧更自然。两种图片共用同一套卡片结构,只通过 theme 增加少量差异样式。

.project-card {
  @apply grid overflow-hidden rounded-2xl border border-skin-line;
  @apply sm:grid-cols-[minmax(0,1fr)_minmax(240px,0.86fr)];
}

.project-card--linkset .preview {
  @apply left-1/2 top-6 h-[112%] w-auto -translate-x-1/2;
  @apply rounded-t-[2rem] object-contain shadow-2xl;
}

@media (max-width: 639px) {
  .project-card--linkset .project-visual {
    min-height: 23rem;
  }
}

这页沿用了 AstroPaper 的 LayoutHeaderBreadcrumbsFooter。颜色使用主题已有的 skin 变量,深色模式不用另外维护一套。按钮的位移动画还增加了 prefers-reduced-motion 处理,系统要求减少动态效果时会关闭过渡。

导航栏需要同步认识新页面。先给 Header.astroactiveNav 联合类型加入 projects,随后增加入口。

<li>
  <a href="/projects/" class={activeNav === "projects" ? "active" : ""}>
    项目
  </a>
</li>

项目页调用 <Header activeNav="projects" /> 后,现有的波浪下划线就会自动落在“项目”上。桌面导航和移动菜单使用同一份列表,也不用再补第二个入口。

给静态站放进一个内容后台

Decap CMS 的后台可以从一个很薄的 HTML 页面启动。文件放在 src/pages/admin/index.html,Astro 构建后会得到 /admin/index.html

<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width" />
    <meta name="robots" content="noindex, nofollow" />
    <link href="/admin/config.yml" type="text/yaml" rel="cms-config-url" />
    <title>内容管理 | HuaTing的博客</title>
  </head>
  <body>
    <noscript>请启用 JavaScript 后使用内容管理后台。</noscript>
    <script src="https://cdn.jsdelivr.net/npm/decap-cms@3.1.2/dist/decap-cms.js"></script>
  </body>
</html>

我把版本号固定下来,没有直接引用浮动的最新版。这样一次依赖更新不会在毫无提示的情况下改变后台行为。准备升级时,可以先改成新的明确版本,在预览部署里检查登录、文章列表、图片和保存流程。

public/admin/config.yml 决定后台怎样读取和保存内容。它需要同时匹配 GitHub 仓库和 Astro Content Collection。

backend:
  name: github
  repo: sillyaboy/astro-paper-blog
  branch: main
  use_graphql: true
  base_url: https://www.huating.me
  auth_endpoint: api/decap/auth

local_backend: true

site_url: https://www.huating.me
display_url: https://www.huating.me
locale: zh_Hans

media_folder: src/assets/images
public_folder: "@assets/images"

base_url 指向 OAuth 函数所在域名。auth_endpoint 只写认证入口,回调地址由认证函数交给 GitHub。媒体文件继续进入 src/assets/images,正文则保存 Astro 已经使用的别名路径。

本地编辑时可以在 Astro 开发服务之外启动 Decap 的本地代理。

npx decap-server
npm run dev

local_backend: true 只开放本地开发能力,线上仍然走 GitHub OAuth。

让字段符合 Astro 内容模型

原博客的文章 frontmatter 已经有固定结构。Decap 配置中的字段名必须与它一致,否则后台保存一次,就可能写出 Astro 无法通过校验的数据。

下面是一组主要字段。

collections:
  - name: blog
    label: 文章
    folder: src/content/blog
    create: true
    extension: md
    format: frontmatter
    identifier_field: title
    slug: "{{slug}}"
    fields:
      - label: 标题
        name: title
        widget: string
      - label: 自定义网址标识
        name: slug
        widget: string
        required: false
      - label: 作者
        name: author
        widget: string
        default: Hua Ting
      - label: 发布时间
        name: pubDatetime
        widget: datetime
        format: "YYYY-MM-DDTHH:mm:ss.SSSZ"
      - label: 描述
        name: description
        widget: text
      - label: 标签
        name: tags
        widget: list
        required: false
      - label: 正文
        name: body
        widget: markdown

日期格式值得单独检查。Astro 的 schema 使用 z.date(),Decap 保存的时间需要包含完整时区信息。YYYY-MM-DDTHH:mm:ss.SSSZ 能把浏览器选择的日期和时间写成明确的 ISO 8601 值。

slug 字段也保留了下来。已有文章继续使用原来的公开网址,编辑标题不会顺手改掉链接。这个小设置能省掉重定向和搜索引擎收录变化带来的麻烦。

GitHub OAuth 要补一段服务端流程

Decap CMS 在浏览器里运行,GitHub OAuth 的 Client Secret 不能放进 config.yml 或前端脚本。Vercel Functions 正好可以承担这段工作。

完整过程很短。

  1. 后台打开 /api/decap/auth
  2. 函数生成随机 state 并写入安全 Cookie
  3. 浏览器跳到 GitHub 授权页
  4. GitHub 把临时 code 送回 /api/decap/callback
  5. 回调函数用 Client Secret 换取 access token
  6. token 通过 window.postMessage 交还给 Decap CMS

GitHub OAuth App 中填写下面两个地址。

Homepage URL
https://www.huating.me

Authorization callback URL
https://www.huating.me/api/decap/callback

回调地址要和代码生成的 redirect_uri 完全一致。正式域名带 www 时,这三个地方都使用同一个版本,避免在授权中途依赖重定向。

Vercel 的 Production 环境至少需要两个 Secret。

GITHUB_OAUTH_CLIENT_ID
GITHUB_OAUTH_CLIENT_SECRET

代码还预留了这些可选项。

OAUTH_ALLOWED_ORIGIN=https://www.huating.me
OAUTH_REDIRECT_URI=https://www.huating.me/api/decap/callback
GITHUB_OAUTH_SCOPE=repo

Client Secret 只能保存在 Vercel 环境变量中。不要把它写进 .env 后提交,也不要使用 PUBLIC_VITE_ 前缀。

repo scope 可以读写账号有权访问的公开与私有仓库,授权范围很宽。如果 CMS 只管理公开仓库,可以评估 public_repo。GitHub OAuth App 不能把传统 OAuth scope 精确限制到单个仓库,后台使用者仍然应该控制在确实有仓库写权限的人之内。

认证入口怎样生成跳转

api/decap/auth.ts 会检查请求方法和 provider,随后生成 32 字节随机值。

const state = createState();
const authorizationUrl = new URL("https://github.com/login/oauth/authorize");

authorizationUrl.searchParams.set(
  "client_id",
  getRequiredEnv("GITHUB_OAUTH_CLIENT_ID")
);
authorizationUrl.searchParams.set("redirect_uri", getRedirectUri());
authorizationUrl.searchParams.set("scope", "repo");
authorizationUrl.searchParams.set("state", state);

return new Response(null, {
  status: 302,
  headers: {
    Location: authorizationUrl.toString(),
    "Set-Cookie": stateCookie(state),
    "Cache-Control": "no-store, max-age=0",
  },
});

Cookie 使用这些属性。

Path=/api/decap
HttpOnly
Secure
SameSite=Lax
Max-Age=600

浏览器脚本读不到 HttpOnly Cookie,HTTPS 之外也不会发送它。十分钟有效期足够完成一次授权,时间到了就失效。

回调函数怎样把 token 送回后台

api/decap/callback.ts 先比较查询参数中的 state 和 Cookie。缺少 code、Cookie 已过期或两边不同,都会停止换取 token。

const code = requestUrl.searchParams.get("code");
const returnedState = requestUrl.searchParams.get("state");
const expectedState = readStateCookie(request);

if (!code || !returnedState || returnedState !== expectedState) {
  return htmlResponse("error", {
    error: "GitHub OAuth state validation failed. Please try again.",
  });
}

校验通过以后,函数在服务端向 GitHub 交换 token。

const tokenResponse = await fetch(
  "https://github.com/login/oauth/access_token",
  {
    method: "POST",
    headers: {
      Accept: "application/json",
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: new URLSearchParams({
      client_id: getRequiredEnv("GITHUB_OAUTH_CLIENT_ID"),
      client_secret: getRequiredEnv("GITHUB_OAUTH_CLIENT_SECRET"),
      code,
      redirect_uri: getRedirectUri(),
    }),
  }
);

Decap CMS 通过弹窗完成授权。回调页收到 token 后,要按它约定的消息格式通知原窗口。

window.opener.postMessage(
  `authorization:github:success:${JSON.stringify({
    token,
    provider: "github",
  })}`,
  allowedOrigin
);

这里没有把目标来源写成 *。回调页只接受 https://www.huating.me 发来的消息,也只把结果发回这个来源。响应同时带上 no-store、CSP、Referrer-PolicyX-Content-Type-Options,用完后清除 state Cookie。

Vercel 不需要额外写重定向规则

Vercel 会把根目录 api 下的 TypeScript 文件识别成 Functions。

api/decap/auth.ts      -> /api/decap/auth
api/decap/callback.ts  -> /api/decap/callback

这次没有增加 vercel.json。推送到 main 后,GitHub 集成自动创建 Production Deployment,环境变量也在这次新部署中生效。

部署完成后,我按下面的顺序检查。

GET /api/decap/auth
期望结果 302,并跳向 github.com/login/oauth/authorize

GET /api/decap/callback
期望结果 200,并带 no-store 与 CSP 响应头

GET /admin/config.yml
期望结果能看到 base_url 和 auth_endpoint

打开 /admin/
期望结果能登录、读取文章列表并打开编辑器

只看到登录页还不算接入完成。授权弹窗能够回来、文章能打开,保存后 GitHub 也出现提交,整个流程才算走通。

一个百分号让后台崩了

第一次登录后,文章列表刚打开,Decap CMS 3.1.2 抛出了下面的错误。

URIError: Pathname "/collections/blog/entries/Wordpress 负载100% 解决思路"
could not be decoded. This is likely caused by an invalid percent-encoding.

对应的 Markdown 文件名正好是下面这样。

Wordpress 负载100% 解决思路.md

路由解析器把文件名中的裸 % 当成百分号编码开头,后面又没有两个合法的十六进制字符,decodeURIComponent 因此报错。标题可以保留,问题出在文件名。

这篇文章已经有独立的 frontmatter slug。

slug: WordPress-100-Load-Solution

所以我只把源文件改成下面的名字。

wordpress-100-load-solution.md

改完后重新部署,从 /admin/ 首页进入文章列表。后台生成的编辑地址变成了安全路径。

/admin/#/collections/blog/entries/wordpress-100-load-solution

文章公开地址仍然由 frontmatter 中的 slug 决定。

/posts/WordPress-100-Load-Solution/

这个问题给后续内容留下一条很实用的规则。标题可以使用中文和常见标点,文件名尽量使用小写字母、数字与连字符。公开地址交给明确的 slug 管理。这样既照顾阅读,也能避开不同工具对文件路径的解析差异。

最后做一次完整检查

这次改造完成后,我保留了一份短检查表。

项目页给软件留了一个稳定入口,Decap CMS 则把写文章这件事从本地编辑器搬到了浏览器里。两部分都没有改变 AstroPaper 原来的内容组织方式,博客依旧是静态站,GitHub 继续保存全部历史,Vercel 只多运行两个很小的授权函数。

这套结构很适合个人博客。平时几乎没有服务端维护成本,想改页面时仍然可以直接写 Astro,临时在别的电脑上更新文章时也有一个够用的后台。

参考资料