【2023年6月版】
Astro.js 小ネタ集 その②
公開日: 2023/06/21
読了時間: 約 21分
はじめに
Astroを使ってサイト構築する小ネタ集の第2段でございます。 Astro 2.5 がリリースされまして、それによっていくつか機能も増えておりますのでその辺りも取り込みまして、より一層の機能強化を行っております。
それでは行ってみましょう。
絵文字・アイコンを簡単に扱う
Astroで絵文字やアイコンを簡単に扱うには astro-iconify
が便利です。
インストール方法&使い方は README.md にある通りですが、
$ npm i astro-iconify
でインストール、利用するのは、
---
import { Icon } from 'astro-iconify'
---
<!-- Automatically fetches and inlines Material Design Icon's "account" SVG -->
<Icon pack="mdi" name="account" />
<!-- Equivalent shorthand -->
<Icon name="mdi:account" />
でOKです。
ここで Icon
タグの pack
とname
属性はどこから探して来ればよいのかというと、
- Iconify official Icon Sets reference :https://icon-sets.iconify.design/
- Icônes :https://icones.js.org/
などのサイトが利用できます。
例えば、Iconifyのリファレンスページは以下のようなページです。
それっぽいキーワードを英単語で検索してみます。例えば ‘flower’ とすると、
こんな感じで’flower’にタグ付けされたリストアップされます。 足りなければ’Find more’ボタンをクリックします。
このような感じでさらにアイコンが出てきます。 ここから ‘大変よくできましたアイコン’をクリックしてみます。
はい、ここで出てきた ‘fluent-emoji-flat:white-flower’ が絵文字の名前です。
これをIcon
タグの name
属性に指定すればOKです。
または ’:’ で分割して、前半の fluent-emoji-flat
がpack
属性、後半のwhite-flower
をname
属性に指定すればOKです。
このようにIconタグだけで非常に多くのアイコンが取り扱えて便利です!
JSON コレクション
Astro 2.5 からコレクションでJSON、yaml形式のファイルがサポートされました。 これでタグとカテゴリとアイコンのパス、などのようなメタデータだけの集合をコレクションとして扱えるようになりました。 本ブログでは”タグ”の定義にJSONコレクションを使っています。 ここでは “タグ” コレクションの使用方法を解説いたします。
まず、“タグ” コレクションの型をconfig.ts
に定義します。
...
const tags = defineCollection({
type: 'data',
schema: z.object({
title: z.string(),
icon: z.string(),
}),
});
...
export const collections = {
tags,
blog,
};
このようにJSONやYAMLのコレクションは type: 'data'
を指定します。
ここでは title
とicon
属性を持つオブジェクトとして定義します。
続いて、この定義に合わせてcontent以下のようなJSONファイルを作成します。
{
"title": "Redis",
"icon": "redis"
}
本ブログではURLの一部にも使用されるタグの”キーワード”に対する画面上の表示で使用されるメタ情報をこのような形で定義、管理しています。
そして、実際の astro ファイル上では以下のようにブログのメタ情報と併せてJSONコレクションのエンティティを取得しています。
import { getCollection, getEntry } from 'astro:content';
...
const tags = await Promise.all([...(await getCollection('blog'))
.flatMap((blog) => blog.data.tags)
.reduce((uniq, tag) => uniq.add(tag), new Set<string>())]
.map(async (tag) => {
const e = await getEntry('tags', tag);
return e ?? {
id : tag,
collection: 'tags',
data: {
title : tag,
icon : ``,
}
};
}));
(await getCollection('blog')).flatMap((blog) => blog.data.tags)
でブログ記事に含まれるタグのリストを取得しています。
ブログにタグのリストがついているので、ブログのリスト(=タグのリストのリスト)からただのタグのリストに直すために flatMap
を使用しています。
続いて .reduce((uniq, tag) => uniq.add(tag), new Set<string>())
でリストをSet
にすることでタグの重複を除去します。
さらにSet
はこのままでは .map
などのメソッドが使えないので、[... タグのSet]
の形式でリストに展開します。
そして、await getEntry('tags', tag)
で タグ名.json
を読み込みます。ファイルがなければ、{id:tag, collction: 'tag', ...}
というコレクションのエンティティをその場で生成してタグのメタ情報リストを作成しています。
後はブログのコレクションと同じように tags[0].data.title
など値をHTMLに埋め込めばOKです。
ちなみに、.map(async ...)
を使用すると、この戻り値はPromiseの配列(Promise<>[]
)となりますので、最初の Promise.all()
でPromiseの配列(Promise<T>[]
)を配列のPromise(Promise<T[]>
)にしています。ややこしい。。。
タグ検索機能の追加
タグのコレクションが定義できましたので続いてタグでの検索機能を追加してみます。 これは以下の公式のチュートリアルを参考にしております。
チュートリアルの src/pages/tags/[tag].astro
を以下に再掲します。
import BaseLayout from '../../layouts/BaseLayout.astro';
export async function getStaticPaths() {
return [
{ params: { tag: "astro" } },
{ params: { tag: "successes" } },
{ params: { tag: "community" } },
{ params: { tag: "blogging" } },
{ params: { tag: "setbacks" } },
{ params: { tag: "learning in public" } },
];
}
const { tag } = Astro.params;
---
<BaseLayout pageTitle={tag}>
<p>Posts tagged with {tag}</p>
</BaseLayout>
これの第一のミソはファイル名が [tag].astro
となっている点です。これがgetStaticPaths()
の戻り値の[{ params: { tag: 'astro' } }, ...]
に対応しています。
ファイル名を [id].astro
とした場合には [{ params: { id: ... } },]
としなければなりません。
このようにparams
はファイルのパスのプレースホルダと対応しています。
例えばsrc/pages/[content]/[id].astro
のgetStaticPaths()
では、[{ params: { content: 'sample' , id: '1' } }, ...]
のようなデータを返す必要があります。
本ブログでは src/pages/tags/[id].astro
としていますので、以下のようになっております。
...
export async function getStaticPaths() {
return Promise.all([...(await getCollection('blog'))
...
].map(async (tag) => {
const e = await getEntry('tags', tag);
return e ? {
params: {
id: e.id,
},
props: {
tag: e.id,
tagName: e.data.title,
icon: e.data.icon,
}
} : {
params: {
id: tag,
},
props: {
tag,
tagName: tag,
icon: '',
}
}
})
);
}
interface Props {
tag: string;
tagName: string;
icon: string;
};
const { tag, tagName, icon } = Astro.props as Props;
const blog = await getCollection('blog', (blog) => blog.data.tags.includes(tag));
のような感じで使用しています。
getStaticPaths()
でユニークなタグのリストを生成するところは先ほどと同じで、続いて読み取ったJSONから { params:{id: e.id,}, props: {tag: e.id, tagName: e.data.title, icon: e.data.icon,}}
形式のオブジェクトに変換しています。
このオブジェクトの後半の props
の箇所が、interface Props {...}; const { tag, tagName, icon } = Astro.props as Props;
で渡されてきます。
そして最後のconst blog = await getCollection('blog', (blog) => blog.data.tags.includes(tag));
でタグが指定されているブログの一覧を作成しています。
読書時間
公式のチュートリアルに従ってブログのコンテンツに対する簡単な読書時間を表示する機能を追加してみました。
このまんまの手順ですがご紹介いたします。
まず以下のパッケージをインストールします。
インストールコマンドは以下です。
$ npm install reading-time mdast-util-to-string
続いてremarkのプラグインを自作いたします。
import type { Plugin } from "unified";
import readingTime from 'reading-time';
import { toString } from 'mdast-util-to-string';
export function remarkReadingTime() :Plugin {
return function (tree, { data }) {
const textOnPage = toString(tree);
const stat = readingTime(textOnPage);
data.astro.frontmatter.minutesRead = stat.minutes;
};
}
ここでは stat.minutes
で分
の数値だけ使用しています。
astro.config.mjs
にてこのプラグインを有効にいたします。
...
import { remarkReadingTime } from "./src/lib/remark-reading-time";
...
export default defineConfig({
...
markdown: {
remarkPlugins: [
...
remarkReadingTime
...
],
...
}
});
仕上げにブログ記事を表示するレイアウト、コンポーネントなどで以下のように使用します。
---
...
interface Props {
entry: CollectionEntry<'blog'>;
nextEntry?: CollectionEntry<'blog'>;
prevEntry?: CollectionEntry<'blog'>;
}
const { entry, nextEntry, prevEntry } = Astro.props;
const { Content, headings, remarkPluginFrontmatter} = await entry.render();
---
<Blog entry={entry} minutesRead={remarkPluginFrontmatter["minutesRead"]} ... >
<Content/>
</Blog>
entry.render()
の戻り値でMarkdown処理後のコンテンツやメタ情報が取得できます。
const {remarkPluginFrontmatter} = await entry.render()
で先ほどの data.astro.frontmatter
オブジェクトが取得できます。
remarkPluginFrontmatter["minutesRead"]
で frontmatter.minutesRead
の値が取得できます。
画像サービスで画像の最適化
本家のチュートリアルでは以下のページです。
Exprerimental な機能ですが、@astrojs/image を使用しない組み込みの画像最適化サービスを有効にします。
さらにsharpを使って画像の最適化を行ってみます。
まずは使用するパッケージをインストールします。
$ npm install sharp
続いて astro.config.mjs
で最適化サービスを有効にします。
import { defineConfig, sharpImageService } from 'astro/config';
...
export default defineConfig({
...
experimental: {
assets: true
},
image: {
service: sharpImageService(),
},
...
});
設定は以上でOKです。
この設定を有効にした後は、public
フォルダなどにおいてある画像は、src/assets
以下に移動させてください。
content
以下の.md
ファイルなどからは ![sample image](../../assets/sample_image_001.png)
などのように相対パスで指定ができます。
これでassets
以下にある画像に対して webp形式の画像が生成され、webp形式の画像が使用できる環境では.webp
拡張子のファイルがダウンロードされます。
CSSの背景画像の最適化
上記で有効にした画像最適化サービスですが今のところCSS の background-image では対応できていないので、このままでは結局public
の画像ファイルを使用することになってしまいます。。。
このままではイケてないので、以下のパッケージを使用し、背景画像についても画像最適化サービスに対応させます。
リファレンスは以下です。
では、パッケージをインストールしてみましょう。
$ npm install astro-imagetools
astroファイルでは、背景画像を指定したいブロックを <BackgroundPicture>
タグで囲みます。
だいたい以下のような感じです。
...
<BackgroundPicture
src={`src/assets/cover/${entry.data.cover}`}
saturation={0.5}
>
<h2>{p.data.title}</h2>
</BackgroundPicture>
...
これで背景画像についても webp
形式の画像を使用した最適化が適応されちゃいます💪🏽
Satori で HTMLから画像生成
TwitterやZennなどで埋め込まれるOGPメタタグ用に画像エンドポイントを作成します。 さらに、そこでブログのタイトルなど文字列を含む画像を生成するため、satoriを使用してHTMLでレイアウトを定義したSVG画像の生成にチャレンジしてみます。
以下のパッケージを使用します。
まずはパッケージのインストールから。
$ npm i satori satori-html @resvg/resvg-js
また画像内で日本語フォントをきれいにレンダリングするためにお気に入りのフォントファイルをダウンロードしてきます。
今回は さわらびゴシック を使用しています。
フォントファイルをダウンロードしたら、src/lib/sawarabi-gothic-medium.ttf
などのようなフォルダに入れておきます。
続いてOGP画像用エンドポイントを作成します。ざっくりと載せてしまいます。
import satori from 'satori';
import { html } from 'satori-html';
import Sawarabi from '../../lib/sawarabi-gothic-medium.ttf'
export async function getStaticPaths() {
return (await getCollection('blog')).map((post) => ({
params: { slug: post.slug },
props: { collection: 'blog' }
}) as any);
}
const height = 630;
const width = 1200;
export async function get({ url, params, props }: APIContext) {
const { slug } = params;
const { collection } = props as { collection: 'blog' };
let post: CollectionEntry<'blog'> | undefined;
let qrCode : Buffer | undefined;
post = await getEntryBySlug(collection, slug);
const out = html`<div tw="flex w-full flex-col bg-red-400">
<div tw="h-93 mx-6 px-4 flex flex-col bg-white">
<h1 tw="text-4xl">${post?.data.title}</h1>
<p tw="text-[1.8rem] w-[56rem] bottom-0">${(post?.data as any).description ?? ''}</p>
</div>
<div tw="flex flex-row mx-6 bg-white rounded-b-2xl mb-8">
<p tw="text-[1.5rem] text-zinc-600">あそぴテックのごった煮ブログ</p>
</div>
</div>`
let svg = await satori(out, {
fonts: [
{
name: 'Open Sans',
data: Buffer.from(Sawarabi),
style: 'normal'
}
],
height,
width
});
const resvg = new Resvg(svg, {
font: {
loadSystemFonts: false
},
fitTo: {
mode: 'width',
value: width
}
});
const image = resvg.render();
return {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=31536000, immutable'
},
body: image.asPng()
}
}
まず、getStaticPaths()
で blog
コレクションからすべての slug
に対する [slug].png
のファイル名を生成してます。
続いて、export async function get({ url, params, props }: APIContext)
で GET
に対するエンドポイントを定義しています。
post = await getEntryBySlug(collection, slug);
はその通り、slug
からブログのエントリを読み込んでいます。
“html`“で始まる部分でレイアウトを定義するHTMLを記載しています。文字列で指定したHTMLは satori-html のおかげで仮想DOMに変換されております。
そういえば、tw=
で tailwindcss のクラス名が効いてます。これはありがたいです。
let svg = await satori(out, {...
でフォントと画像サイズを指定してSVGの生成を行います。
const resvg = new Resvg(svg, {... .... resvg.render();
でSVGをPNG画像にレンダリングします。
最後の return {headers: {'Content-Type': 'image/png','Cache-Control': 'public, max-age=31536000, immutable'},body: image.asPng()}
でPNG画像形式でデータを返却します。
仕上げに HTMLのヘッダーでMeta
タグを設定します。
---
...
export interface Props {
title?: string;
description?: string;
thumbnail?: {
src: string;
};
}
const { title, description, thumbnail} = Astro.props;
---
<!DOCTYPE html>
<html lang="en">
<head>
....
<meta property="og:title" content={title} />
<meta property="og:type" content="website" />
<meta property="og:description" content={description} />
<meta property="og:image" content={thumbnail.src} />
<meta property="og:url" content={Astro.request.url} />
<meta name="twitter:card" content="summary_large_image" />
....
このような感じでだいたいOKです。
ヘッダータグに組み込んだOGPのメタ情報は以下のサイトでチェックできます。
ここでサイトのURLを入れれば Twitterなどで埋め込まれるOGメタタグのプレビューができます!
ただし当然ですが本番サイトでないとチェックできないです。。。
QRコード作成
OGP画像、このままでもよいのですが、せっかくなのでサイトのリンクが含まれたQRコード画像を追加してみたいと思います。 使用するパッケージは以下です。
パッケージのインストールは以下です。
$ npm i qrcode
処理を記述するのは上記と同じsrc/pages/images/[slug].png.ts
でとりあえずQRコード画像の生成のみ行ってみます。
処理は非常にシンプルです。
import * as QRCode from 'qrcode';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
...
export async function get({ url, params, props }: APIContext) {
...
const appPrefix = 'my-blog';
const linkUrl = `${SITE_URL}/blog/${slug}`;
let tmpDir;
try {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), appPrefix));
const file_path = tmpDir + Date.now() +".png";
await QRCode.toFile(file_path, linkUrl, { width , margin });
qrCode = fs.readFileSync(file_path);
}
finally {
try {
if (tmpDir) {
fs.rmSync(tmpDir, { recursive: true });
}
}
catch (e) {
console.error(`An error has occurred while removing the temp folder at ${tmpDir}. Please remove it manually. Error: ${e}`);
}
}
...
ここの処理で最も気を付ける必要があるのは一時ファイルの後始末
です。
fs.mkdtempSync(path.join(os.tmpdir(), appPrefix))
で適当なプレフィックスを持つ一時フォルダを作成します。
続いて await QRCode.toFile(...)
にて今作った一時フォルダに、リンクURLをQRコード画像に変換して保存します。
書き込み終わったら fs.readFileSync(file_path);
でファイルを読み込みます。
最後に finally
ブロックで fs.rmSync(tmpDir, { recursive: true })
を行い、一時フォルダをリカーシブに削除いたします。
あとは変数に持っている qrCode
のバッファをOGP画像のSVGかPNGに追加すればQRコード付きOGP画像の出来上がりです。
これは直接、メモリに書けないのかな・・・?
Satori で画像埋め込み
さて、QRコード画像のデータが上記の処理で取得できたので satori
に混ぜてみましょう。
ところがちょっと問題がありまして、satoriのimgタグのsrcではローカルのファイルパスを指定するとうまく読み込めませんでした。 生成している最中のdistなどの中身を指定する方法がよくわからないです。
なのでデータURLの形式で直接、埋め込んでしまうのが手っ取り早いかと思います。ついでに favicon.svg
もOGP画像に含めてしまいたいと思います。
const favicon = import.meta.glob('../../../public/favicon.svg', {eager:true, as: 'raw'})["../../../public/favicon.svg"];
...
const out = html`<div tw="flex w-full flex-col bg-red-400">
<div tw="h-93 mx-6 px-4 flex flex-col bg-white">
<h1 tw="text-4xl">${post?.data.title}</h1>
<p tw="text-[1.8rem] w-[56rem] bottom-0">${(post?.data as any).description ?? ''}</p>
</div>
<div tw="flex flex-row mx-6 bg-white rounded-b-2xl mb-8">
<img tw="w-30 h-30 ml-10 mb-2 inline-block grow-0" src="data:image/png;base64,${qrCode ? qrCode.toString('base64'):''}" />
<div tw="flex flex-row-reverse w-230 mt-15">
<p tw="text-[1.5rem] text-zinc-600">あそぴテックのごった煮ブログ</p>
<img tw="w-12 h-12 rounded-full inline-block" src="data:image/svg+xml;base64,${btoa(favicon)}" />
</div>
</div>
</div>`
public
にあるfavicon.svg
をそのまま生データとして読み込むために、import.meta.glob(... {as: raw})[ファイル名]
を使っています。
imgタグのsrc属性は data:image/png;base64,
や data:image/svg+xml;base64,
のような”dataプロトコル:ファイルタイプ”の形式ではじめます。
で実際に埋め込むデータですが、qrCode
のバッファは toString('base64')
として埋め込みます。
SVGの生データとして読み込んだ favicon
は btoa
メソッドで埋め込みます。
これでどちらのパターンも無事に satori のSVG経由でPNG画像にすることができました🙌
駆け足でしたが本日は以上といたします。
お疲れさまでした!