使用 Astro 打造速度飛快的靜態網站

在蓋「用得到的猫紋」和「Moment:看電影看劇時,聽到喜歡的音樂」專案的時候,覺得輕量級的網站,用 Markdown 語法可以滿足快速、便於更新內容的需求,找到了 Astro 和 Remix

Remix 後來發展與靜態網頁越來越不同,因此以內容和 SEO 為目標的網站,選擇使用 Astro。

Astro 目前以幾乎每天更新一次的頻率快速發展中,2022 年 8 月進入 1.0 穩定版本。這篇文章不會再講一次 Astro 文件提到的功能,而是文件上找不到參考,只好到處從社群爬文記下來的做法。

Astro 的標誌

"astro": "^3.6.1"

使用 Astro 的專案

Images

「用得到的猫紋」以 View Transition 做出目錄切換的操作體驗。

Images

「Moment:看電影看劇時,聽到喜歡的音樂」讓大部份頁面跟撰寫 HTML/CSS 無異,Lighthouse 分數輕易達到 4 顆 100。

這兩個網站都是搭配 Solid.js,用來處理表單或影片播放互動,Astro 本身幾乎是隱形,跟框架整合得不錯。


接下來的內容

Astro 2.9.0 之後遇到的問題

Astro 2.9.0 之前的心得/問題


在 API Endpoint 載入字型檔案

為什麼要載入字型檔案?

想要使用 satori,讓網站的 Open Graph 封面圖片根據標題與主要圖片動態產生。

製作過程遇到:

查詢幾篇以 Astro 使用 satori 的教學,共通點是把字型放在 /public 資料夾,

只是,在本機環境的時候,一切正常,繼續要上傳到 Vercel 時就有問題了。

❌ 按照教學的設定,只在本機環境正常

  1. API Endpoint 裡使用 satori、讀入字型
// src/pages/api/og/[id].png.ts
import { readFileSync } from 'fs';
import path from 'path';
import { html } from 'satori-html';
import satori from 'satori';

// ...

const markup = html(`
  <div>
    <!-- ... -->
  </div>
`);

const hankakuFontFile = readFileSync(
  path.resolve('./public/fonts/nunito-sans-latin-500-normal.ttf')
);

const zenkakuFontFile = readFileSync(
  path.resolve('./public/fonts/jf-openhuninn.ttf')
);

const svg: string = await satori(markup, {
  width: 1200,
  height: 630,
  fonts: [
    {
      name: 'Nunito Sans',
      data: hankakuFontFile.buffer,
      style: 'normal'
    },
    {
      name: 'jf-openhuninn-2.0',
      data: zenkakuFontFile.buffer,
      style: 'normal'
    }
  ]
});
  1. 打開 Astro 設定檔,在 Vercel Adapter 加入字型檔案。
// astro.config.mjs
import { defineConfig } from 'astro/config';
import vercel from '@astrojs/vercel/serverless';

export default defineConfig({
// ...
  adapter: vercel({
    includeFiles: [
      './public/fonts/jf-openhuninn.ttf',
      './public/fonts/nunito-sans-latin-500-normal.ttf'
    ]
  })
});
  1. Vercel Preview 網址的 API Endpoint 錯誤訊息

進行 Preview Deployment 之後,發現 API Endpoint 回傳錯誤訊息,原來是找不到 ./public/fonts/jf-openhuninn.ttf./public/fonts/nunito-sans-latin-500-normal.ttf,在 Vercel 的 Log 找出問題:

Error: ENOENT: no such file or directory, open '/var/task/public/fonts/nunito-sans-latin-500-normal.ttf'
  at Object.openSync (node:fs:596:3)
  ...

✅ 移到 /src/assets,即可正常使用

Astro 的開發成員回應:

只要把字型檔案移到 /src 資料夾就好了。

也就是:

// src/pages/api/og/[id].png.ts
// ...
const hankakuFontFile = readFileSync(
  path.resolve('./src/assets/nunito-sans-latin-500-normal.ttf')
);
// ...
// astro.config.mjs
// ...
  adapter: vercel({
    includeFiles: [
      './src/assets/jf-openhuninn.ttf',
      './src/assets/nunito-sans-latin-500-normal.ttf'
    ]
  })
// ...

⚠️ 有用,但是感覺繞一圈的做法

這是在 Vercel 支援討論區獲得的方法。

// src/pages/api/og/[id].png.ts
...
const hankakuFontFile = await fetch(
  new URL('fonts/nunito-sans-latin-500-normal.ttf', import.meta.env.SITE)
).then((res) => res.arrayBuffer());
...

const svg: string = await satori(markup, {
  width: 1200,
  height: 630,
  fonts: [
    {
      name: 'Nunito Sans',
      data: hankakuFontFile,
      style: 'normal'
    },
// ...
  ]
});

// ...


根據文件路徑載入圖片

Astro 2.9.0 起的實驗功能 Assets (3.0.0 之後成為正式功能),光是看文件,提到載入圖片的方法,會發現很多得不斷複製貼上的地方,不論是在 .astro

---
// /src/pages/[categories]/[...slug].astro
import { Image } from 'astro:assets';
import ovenMittImage from "../assets/utensils/oven-mitt/cover.jpg";
---

<Image src={ovenMitt} alt="圖片說明" />

markdownmdxmarkdocfrontmatter 資訊裡:

---
# /src/content/utensils/oven-mitt.mdx
title: "標題名稱"
cover: "../../assets/utensils/oven-mitt/cover.jpg" # 對應到檔案 "src/assets//utensils/oven-mitt/cover.jpg"
coverAlt: "圖片說明"
---

文章內容

以上都無法從路徑與檔案名稱獲得對應圖片檔案,要不然就是得設定好一大串 import。這使得在加入資料的時候,非常不方便。

還有,若把圖片放在 /public,用 <Image /> 帶入路徑:

---
// ❌ 在 <Image /> 載入 /public 資料夾裡的圖片
import { Image } from 'astro:assets';
---

<Image src={`/${categoryName}/${slug}/cover.jpg`} alt="圖片說明">

得到的是一般 <img> 語法,沒有 Astro 針對圖片載入效能的效果。

<!-- ❌ <Image /> 不會對來自 /public 的圖片進行最佳化 -->
<img src="/utensils/oven-mitt/cover.jpg" alt="圖片說明">

如果想要自動帶入路徑或檔名,又有 Astro 的最佳化效果,要從 Vite 4 的功能來下手:

---
// /src/pages/[categories]/[...slug].astro
import { Image } from 'astro:assets';
const { categories: categoryName, slug } = Astro.params;
---

<!-- ✅ 在 src 直接使用 import -->
<Image
  src={import(`../../assets/${categoryName}/${slug}/cover.jpg`)}
  alt="圖片說明"
/>

要注意的是,結尾必須是附檔名,不能放在變數裡面。

資料來源

Using Dynamic Filename Props in Astro Images


以變數載入 Content Collection 的型別設定

Content Collection 製作比較複雜的導覽介面時,遇到須以 Astro.propsAstro.params 來取得特定的文章列表或內容。

---
// /src/components/navigation/Slug.astro
import { getCollection } from 'astro:content';

const { categoryName } = Astro.props;

const slugItems = await getCollection(categoryName);
---

getCollection() 只能傳入已經設定好的 collection 名稱:

// /src/content/config.ts
import { defineCollection } from 'astro:content';
import { itemSchema } from '../schemas';

const tidyCollection = defineCollection({
  type: 'content',
  schema: itemSchema
});
const utensilsCollection = defineCollection({
  type: 'content',
  schema: itemSchema
});

export const collections = {
  'tidy': tidyCollection,
  'utensils': utensilsCollection
};

所以,在指定此處 prop 型別的時候,必須設定為 string literal types

以最偷懶的方法,是手動列出可能的 string

---
// /src/components/navigation/Slug.astro
import { getCollection } from 'astro:content';

type Props = {
  categoryName: 'tidy' | 'utensils';
};
const { categoryName } = Astro.props;

const slugItems = await getCollection(categoryName);
---

只不過,如果 categoryName 有內容變動,就要記得回來這個檔案跟上,很不方便。

因此想要用一組陣列,既產生靜態網址,也可以定義 string literal types

使用的方法是把陣列指定為 const assertion

// constants.ts
export const categories = <const>
[
  {
    name: 'tidy',
    label: '收納'
  },
  {
    name: 'utensils',
    label: '廚房清潔'
  }
];

這可以用在靜態網址的 getStaticPaths()

---
// /src/pages/[categories].astro
import { categories } from '../../constants';

export async function getStaticPaths() {
  return categories.map(({ name, label }) => ({
    params: { categories: name },
    props: { name, label }
  }));
};
---

也能符合要傳入 getCollection()props 型別設定:

---
// /src/layouts/Slug.astro
import SlugNavigation from '@components/navigation/Slugs.astro';

type Params = { categories: (typeof categories)[number]['name'] };
const { categories: categoryName } = Astro.params as Params;
---
<!-- ... -->
<SlugNavigation categoryName={categoryName} />
<!-- ... -->
---
// /src/components/navigation/Slugs.astro

type Props = { categoryName: (typeof categories)[number]['name'] };
const { categoryName } = Astro.props as Props;

const slugItems = await getCollection(categoryName);
---
<!-- ... -->
{
  slugItems.map(({ slug, data }) => (
    // ...
  ))
}
<!-- ... -->

Markdoc 設定檔的 build 型別錯誤

Astro 的 Markdoc 說明文件提到:設定檔名稱可以是 markdoc.config.mjs|ts。如果用 .ts 附檔名,且跟著說明,把<article> 標籤省略。進行 build 的時候會出現錯誤:

pnpm build

> astro check && tsc --noEmit && astro build

09:06:48 PM [content] Types generated 257ms
...
markdoc.config.ts:15:7 - error TS2322: Type 'null' is not assignable to type 'Render | undefined'.

15       render: null
         ~~~~~~

Found 1 error in markdoc.config.ts:15

這錯誤只要檔名改成 markdoc.config.mjs 即可通過。


區分 <body> 樣式

Astro 的 CSS 是 scoped,不同頁的樣式不會混在一起。我想要首頁跟內文頁的外觀、版面不同。因此在各自頁面的 <body> 設定:

// pages/index.astro
body {
  background-color: lavender;
}

// layouts/BlogPost.astro
body {
  background-color: thistle;
}

發現首頁的 <body> 樣式,會被內頁的設定覆蓋:

首頁的 body 樣式被內文頁覆蓋

詢問過 Astro 製作團隊,是他們刻意這樣設計,如果也有不同 <body> 樣式需求,有以下 3 個步驟:

<body> 加上屬性來區別頁面

<!-- pages/index.astro -->
<body data-body-style="home">
...
</body>

<!-- layouts/BlogPost.astro -->
<body data-body-style="post">
...
</body>

import 匯入全域 CSS 檔案

// Head.astro
import '~/styles/global.css';

設定個別樣式

// ~/styles/global.css
[data-body-style="home"] {
  background-color: lavender;
}

[data-body-style="post"] {
  background-color: thistle;
}

內容頁,或者說 /layouts 裡的樣式,就不會蓋掉首頁的。


設定 Markdown 語法的樣式

在 Astro 的 pages/ 下,無論是使用 .md.mdx 檔案,都可以有等同於 MDX 的功能,將元件輸入至內文。

// LyricSection.astro
<section class="lyricSection">
  <slot />
</section>
---
# /pages/bohemian-rhapsody.mdx

layout: LyricSection from '../../components/LyricSection.astro';
---

<LyricSection>

Is this the real life?

Is this just fantasy?

Caught in a landslide

No escape from reality

</LyricSection>

這一段會產生 HTML 語法:

<!-- /bohemian-rhapsody -->
<section class="lyricSection astro-SNPOEJMT">
  <p>Is this the real life?</p>
  <p>Is this just fantasy?</p>
  <p>Caught in a landslide</p>
  <p>No escape from reality</p>
</section>

我想要設定 <section> 下面的 <p> 樣式,會發現也是因為 scoped 的緣故,沒辦法直接指定:

// LyricSection.astro
...
<style lang="scss">
.lyricSection {
  display: grid;
  row-gap: 16px;

  p {
    margin: 0;
    color: hsl(var(--color-midnight1600));
    font-size: 1.6rem;
    line-height: 24px;
  }
}
</style>

必須加上 :global() 才有效果:

// LyricSection.astro

.lyricSection {
  ...

  :global(p) {
    ...
  }
}

加入結構化資料 JSON LD 語法

按照直覺把結構化資料放在 <head> ... </head> 裡面時,會發現無法如預期輸出 JSON LD 語法:

// BaseHead.astro
---
const schema = JSON.stringify({
  '@context': 'https://schema.org',
  '@graph': [{
    '@type': 'WebPage',
    url: `${import.meta.env.PUBLIC_HOSTNAME}${canonicalPath}`,
    name: title,
    datePublished: published,
    dateModified: modified
  }]
});

// 要把 īmport 改為 import
---

<script type="application/ld+json">{schema}</script>

0.23.0 版開始,在 <script> 得要讓 HTML 有 Escape 過,才會出現有效的 JSON LD 語法:

// BaseHead.astro
<script type="application/ld+json" set:html={schema} />

遇到的 Bug

在程式碼區塊使用環境變數

在上一段內容,程式碼區塊裡有提到 Astro 的環境變數

上傳到正式環境才發現:Markdown 區塊裡應該要直接顯示環境變數原始碼的地方,卻把它轉換了,或是出現令人不解的訊息,在使用 SSR 的情況下,甚至會無法 Build 成功。

如果是使用 MDX,已經在 @astrojs/mdx@0.11.2 修正。

把環境變數顯示出來的 Markdown 程式碼區塊

然而,如果用了 Astro 最近新增的 Markdoc 格式,會造成編譯錯誤。

錯誤訊息是在該檔案的第 1 個空白行:

[error] Unexpected token (Note that you need plugins to import files that are not JavaScript)

這個問題在 4.0 版已經獲得解決。


💬 討論

新增討論 👆連至 Github 的 Discussions 參與