在蓋「用得到的猫紋」和「Moment:看電影看劇時,聽到喜歡的音樂」專案的時候,覺得輕量級的網站,用 Markdown 語法可以滿足快速、便於更新內容的需求,找到了 Astro 和 Remix。
Remix 後來發展與靜態網頁越來越不同,因此以內容和 SEO 為目標的網站,選擇使用 Astro。
Astro 目前以幾乎每天更新一次的頻率快速發展中,2022 年 8 月進入 1.0 穩定版本。這篇文章不會再講一次 Astro 文件提到的功能,而是文件上找不到參考,只好到處從社群爬文記下來的做法。
"astro": "^3.6.1"
使用 Astro 的專案
「用得到的猫紋」以 View Transition
做出目錄切換的操作體驗。
「Moment:看電影看劇時,聽到喜歡的音樂」讓大部份頁面跟撰寫 HTML/CSS 無異,Lighthouse 分數輕易達到 4 顆 100。
這兩個網站都是搭配 Solid.js
,用來處理表單或影片播放互動,Astro 本身幾乎是隱形,跟框架整合得不錯。
接下來的內容
Astro 2.9.0 之後遇到的問題
Astro 2.9.0 之前的心得/問題
在 API Endpoint 載入字型檔案
為什麼要載入字型檔案?
想要使用 satori
,讓網站的 Open Graph 封面圖片根據標題與主要圖片動態產生。
製作過程遇到:
satori
的設定裡,要求必須指定字型- 如果只有加入英文字型,中文會變成豆腐(長方框)
查詢幾篇以 Astro
使用 satori
的教學,共通點是把字型放在 /public
資料夾,
只是,在本機環境的時候,一切正常,繼續要上傳到 Vercel 時就有問題了。
❌ 按照教學的設定,只在本機環境正常
- 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'
}
]
});
- 打開
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'
]
})
});
- 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 支援討論區獲得的方法。
- 字型檔案同樣放在
/public
資料夾 - 以
fetch
載入/public
對應網址的檔案 - 不需要在
Astro
的設定檔的Vercel Adapater
加入includeFiles
// 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="圖片說明" />
或 markdown
、mdx
、markdoc
的 frontmatter
資訊裡:
---
# /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.props
或 Astro.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>
樣式,會被內頁的設定覆蓋:
詢問過 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
修正。
然而,如果用了 Astro 最近新增的 Markdoc 格式,會造成編譯錯誤。
錯誤訊息是在該檔案的第 1 個空白行:
[error] Unexpected token (Note that you need plugins to import files that are not JavaScript)
這個問題在 4.0 版已經獲得解決。