Astro.js 小ネタ集 その11 OGP画像にQRコード追加
前回に引き続きAstro.jsでの小ネタを紹介していきます。
ここでは前回生成したOGP画像にQRコードなどを追加してみたいと思います。
QRコードも追加したい
OG画像、このままでもよいのですが、せっかくなのでサイトのリンクが含まれた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
のバッファをOG画像のSVGかPNGに追加すればQRコード付きOG画像の出来上がりです。
これは直接、メモリに書けないのかな・・・?
Satori で画像埋め込み
さて、QRコード画像のデータが上記の処理で取得できたので satori
に混ぜてみましょう。
ところがちょっと問題がありまして、satori-html内のimgタグのsrcではローカルのファイルパスを指定するとうまく読み込めませんでした。 生成している最中のdistなどの中身を指定する方法がよくわからないです。
なのでデータURLの形式で直接、埋め込んでしまうのが手っ取り早いかと思います。ついでに favicon.svg
もOG画像に含めてしまいたいと思います。
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画像にすることができました🙌
今回はここまでといたします。
個人ブログでも記事をあげています。こちらもあわせてご覧ください!
Astro.js 小ネタ集 その10 OGP画像とsatori
前回に引き続きAstro.jsでの小ネタを紹介していきます。
ここではAstroの画像生成エンドポイントと、satoriでのSVG画像生成についてご紹介いたします。
Satori で OGP画像を動的に生成したい
今回は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のヘッダーにOGPのメタタグを追加
仕上げに 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メタタグのプレビューができます!
ただし当然ですが本番サイトでないとチェックできないです。。。
今回はここまでといたします。
個人ブログでも記事をあげています。こちらもあわせてご覧ください!
Astro.js 小ネタ集 その9 画像の最適化
前回に引き続きAstro.jsでの小ネタを紹介していきます。
ここではAstroのExperimental機能である画像最適化サービスと'sharp'モジュールについてご紹介いたします。 また背景画像に対しての画像最適化サービスの適応についてもご紹介いたします。
画像サービスで画像の最適化
本家のチュートリアルでは以下のページです。
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(), }, ... });
experimental: {assets: true}
と image:{ service: sharpImageService()}
を追加します。
設定は以上でOKです。
この設定を有効にした後は、public
フォルダなどにおいてある画像は、src/assets
以下に移動させてください。
content
以下の.md
ファイルなどからは ![sample image](../../assets/sample_image_001.png)
などのように相対パスで指定ができます。
これでassets
以下にある画像に対して webp形式の画像が生成され、webp形式の画像が使用できる環境では.webp
拡張子のファイルがダウンロードされます。
背景画像の最適化
上記で有効にした画像最適化サービスですが今のところ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
形式の画像を使用した最適化が適応されちゃいます💪🏽
今回はここまでといたします。
個人ブログでも記事をあげています。こちらもあわせてご覧ください!
Astro.js 小ネタ集 その8 記事の読書時間を表示
前回に引き続きAstro.jsでの小ネタを紹介していきます。
ここでは文字数から読書時間を計算してくれる 'reading-time' モジュールについてご紹介いたします。
読書時間
公式のチュートリアルに従ってブログのコンテンツに対する簡単な読書時間を表示する機能を追加してみました。
このまんまの手順ですがご紹介いたします。
1. 準備とインストール
まず以下のパッケージをインストールします。
インストールコマンドは以下です。
$ npm install reading-time mdast-util-to-string
2. remarkのプラグインを作成
続いて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
で分
の数値だけ使用しています。
3. プラグインを有効化
astro.config.mjs
にてこのプラグインを有効にいたします。
... import { remarkReadingTime } from "./src/lib/remark-reading-time"; ... export default defineConfig({ ... markdown: { remarkPlugins: [ ... remarkReadingTime ... ], ... } });
remarkPlugins
に追加すればOKです。
4. ブログ記事での利用
仕上げにブログ記事を表示するレイアウト、コンポーネントなどで以下のように使用します。
--- ... 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
の値が取得できます。
今回はここまでといたします。
個人ブログでも記事をあげています。こちらもあわせてご覧ください!
Astro.js 小ネタ集 その7 記事をタグで検索
前回に引き続きAstro.jsでの小ネタを紹介していきます。
ここでは前回紹介したJSON コレクションを使ったブログ記事のタグ検索機能についてご紹介いたします。
タグ検索機能の追加
タグのコレクションが定義できましたので、続いてタグでの検索機能を追加してみます。 これは以下の公式のチュートリアルを参考にしております。
チュートリアルの 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));
でタグが指定されているブログの一覧を作成しています。
今回はここまでといたします。
個人ブログでも記事をあげています。こちらもあわせてご覧ください!
Astro.js 小ネタ集 その6 JSONコレクション
前回に引き続きAstro.jsでの小ネタを紹介していきます。
ここでは Astro 2.5 で追加された JSON コレクションについてご紹介いたします。
JSON コレクション
Astro 2.5 からコレクションでJSON、yaml形式のファイルがサポートされました。 これでタグとカテゴリとアイコンのパス、などのようなメタデータだけの集合をコレクションとして扱えるようになりました。 個人ブログでは"タグ"の定義にJSONコレクションを使っています。 ここでは "タグ" コレクションの使用方法を解説いたします。
1. 準備
まず、"タグ" コレクションの型を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
属性を持つオブジェクトとして定義します。
2. コレクションに追加
続いて、この定義に合わせてcontent以下のようなJSONファイルを作成します。
{ "title": "Redis", "icon": "redis" }
個人ブログではURLの一部にも使用されるタグの"キーワード"に対する画面上の表示で使用されるメタ情報をこのような形で定義、管理しています。
3. ページでの利用
そして、実際の 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<T>[]
)となりますので、最初の Promise.all()
でPromiseの配列(Promise<T>[]
)を配列(T[]
)にしています。ややこしい。。。
今回はここまでといたします。
個人ブログでも記事をあげています。こちらもあわせてご覧ください!
Astro.js 小ネタ集 その5 絵文字・アイコンを簡単に扱う
前回に引き続きAstro.jsでの小ネタを紹介していきます。
ここでは astro-iconify
と Iconify サイトについてご紹介いたします。
絵文字・アイコンを簡単に扱う
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タグだけで非常に多くのアイコンが取り扱えて便利です!
今回はここまでといたします。
個人ブログでも記事をあげています。こちらもあわせてご覧ください!