ITごった煮 はてブ版

あそぴテック 技術ぶろぐ

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画像のSVGPNGに追加すれば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の生データとして読み込んだ faviconbtoa メソッドで埋め込みます。

これでどちらのパターンも無事に satori のSVG経由でPNG画像にすることができました🙌

今回はここまでといたします。

個人ブログでも記事をあげています。こちらもあわせてご覧ください!

Astro.js、Tailwind CSSの入門にぜひ!

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();SVGPNG画像にレンダリングします。

最後の 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、Tailwind CSSの入門にぜひ!

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、Tailwind CSSの入門にぜひ!

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、Tailwind CSSの入門にぜひ!

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].astrogetStaticPaths()では、[{ 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、Tailwind CSSの入門にぜひ!

Astro.js 小ネタ集 その6 JSONコレクション

前回に引き続きAstro.jsでの小ネタを紹介していきます。

ここでは Astro 2.5 で追加された JSON コレクションについてご紹介いたします。

JSON コレクション

Astro 2.5 からコレクションでJSONyaml形式のファイルがサポートされました。 これでタグとカテゴリとアイコンのパス、などのようなメタデータだけの集合をコレクションとして扱えるようになりました。 個人ブログでは"タグ"の定義にJSONコレクションを使っています。 ここでは "タグ" コレクションの使用方法を解説いたします。

1. 準備

まず、"タグ" コレクションの型をconfig.tsに定義します。

...
const tags = defineCollection({
  type: 'data',
  schema: z.object({
    title: z.string(),
    icon: z.string(),
  }),
});
...
export const collections = {
  tags,
  blog,
};

このようにJSONYAMLのコレクションは type: 'data'を指定します。 ここでは titleicon属性を持つオブジェクトとして定義します。

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、Tailwind CSSの入門にぜひ!

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タグの packname属性はどこから探して来ればよいのかというと、

などのサイトが利用できます。

例えば、Iconifyのリファレンスページは以下のようなページです。

それっぽいキーワードを英単語で検索してみます。例えば 'flower' とすると、

こんな感じで'flower'にタグ付けされたリストアップされます。 足りなければ'Find more'ボタンをクリックします。

このような感じでさらにアイコンが出てきます。 ここから '大変よくできましたアイコン'をクリックしてみます。

はい、ここで出てきた 'fluent-emoji-flat:white-flower' が絵文字の名前です。 これをIconタグの name 属性に指定すればOKです。

または ':' で分割して、前半の fluent-emoji-flatpack属性、後半のwhite-flowername属性に指定すればOKです。

このようにIconタグだけで非常に多くのアイコンが取り扱えて便利です!

今回はここまでといたします。

個人ブログでも記事をあげています。こちらもあわせてご覧ください!

Astro.js、Tailwind CSSの入門にぜひ!