【Astro】またまたAstrojsで作ったサイトをカスタマイズした

公開 : 2023.03.05
titleimage

以前、自分的に理想系のブログサイトが完成した と投稿しましたが、そのころから Astro もメジャーアップデートで2.0となり、また当サイトもバージョンアップに伴う対応や、機能追加を行いましたので紹介します。

【Astro】自分的に理想系のブログサイトが完成した

【Astro】自分的に理想系のブログサイトが完成した

去年からちょくちょく改修していた当サイトですがある程度納得できる仕上がりとなったので一旦まとめます。 ブログにはどんな要素が必要なのか、また静的サイトジェネレータを使ったブログ運用についてざっくり述べていきたいと思います。

Astroの新機能 Content Collection に対応する

Content Collection は Markdown で管理されるファイルの入力規則をより厳しく管理する場合に使うことのできる機能です。例えば当サイトの場合ですとmarkdownファイルのヘッダー部に下記のような情報を入力しています。

src/content/blog/post.md
---
title: "記事のタイトル"
description: "記事の説明を入力する"
pubDate: 2023-01-01
revDate:
imgSrc: "/images/blog/***/logo.png"
imgAlt: "logo"
tags: [tag1, tag2, tag3]
draft: false
---

</div>

## 見出し2
記事の内容を書いていく

この要素が不足していないか、変数型が一致しているかなどを確認するのが本機能です。詳細は省きますが、記事の管理が以前のように src/pages/posts/blog/ で管理するのではなく専用のディレクトリ src/content/ 内で行うよう変更されました。

また、入力規則についても src/content/config.ts に記述します。

src/content/config.ts
import { z, defineCollection } from "astro:content"

const blog = defineCollection({
	schema: z.object({
		title: z.string(),
		description: z.string(),
		pubDate: z.coerce.date(),
        revDate: z.coerce.date().optional(),
		imgSrc: z.string(),
		imgAlt: z.string(),
        tags: z.array(z.string()),
		draft: z.boolean().optional()
	})
})

export const collections = {
	blog: blog
}

取り急ぎ対応する必要はなかったのですが、今後の機能追加などで未対応の場合なにかしら制約がありそうな予感がしたので対応しておきました。また Content Collectionで管理するコンテンツの呼び出し方法に変更がありましたのでその対応を行いました。

当サイトの構造は Astro Boilerplate を基本としているのですが、Githubでの更新が2022年11月で止まってしまっているので(2023年3月現在) 自力で実装しました。

Astro Boilerplate | Astro

Astro Boilerplate | Astro

A fully responsive template built with Astro, TypeScript and React styled with Tailwind CSS. The perfect boilerplate to build a blog or portfolio.

サイト内検索の実装

静的サイトの弱点の1つが、サイト内検索でしょうか? Wordpressなどのphpベースで作られたWebサイトであれば導入が簡単にできるのですが、Astroでは標準機能としては用意されていませんので自前で用意する必要があります。

今回は Algolia という全文検索サービスを利用しました。

検索レコード機能を作る

サイト内検索を有効にするためには予め、Algolia へサイト情報をレコードしておく必要があります。 サイトをデプロイする際にnodeでjavascriptを実行し、Algoliaのデータベースへサイトデータを送信します。

Astroで作る静的サイトに、超高速のAlgoliaの検索システムを導入する | Route360

前回、MeilisearchをAstroに導入しましたが、日本語の漢字語彙の検索にまだ少し難があるため、Algoliaも試しました。 Algoliaは、ドキュメント数10,000・月10,000サーチまでが無料となっています。個人や小規...

上のサイトで詳しく実装方法などについて解説されています。objectIDやslugなど若干自分好みにカスタマイズしました。

scripts/updateAlgolia.mjs
// 参考サイト
// https://route360.dev/ja/post/astro-algolia/
import * as dotenv from 'dotenv'
dotenv.config()

import algoliasearch from 'algoliasearch'
const client = algoliasearch(
  process.env.ALGOLIA_APPLICATION_ID,
  process.env.ALGOLIA_ADMIN_KEY
)

import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
import removeMd from "remove-markdown"

const filenames = fs.readdirSync(path.join('./src/content/blog'))

const data = filenames.map((filename) => {
  try {
    const markdownWithMeta = fs.readFileSync('./src/content/blog/' + filename)
    const { data: frontmatter, content } = matter(markdownWithMeta)
    return {
      objectID: filename.slice(0, -3),
      slug: "/posts/" + filename.slice(0, -3),
      title: frontmatter.title,
      content: removeMd(content).replace(/\n/g, ""),
    }
  } catch (e) {
    console.log(e.message)
    return null
  }
})

client
  .initIndex('yuki-meguro.com')
  .saveObjects(JSON.parse(JSON.stringify(data)))
  .then((res) => console.log(res))
  .catch((err) => console.error(err))

また、サイト情報を出力する際には以下とします。

node scripts/updateAlgolia.mjs

nodeの実行はGithub Actionsなどと組み合わせることでプッシュ時など特定の条件で自動化することも可能です。 Github Actions の .yml 一例

.github/workflows/update.yml
name: filename
on:
  push:
  workflow_dispatch:

jobs:
  update-index:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: "16"
          cache: "npm"
      - run: npm i
      - run: node scripts/filename.mjs

検索結果を取得する

上で一通りAlgolia用のサイト情報を構築したら、今度はサイト上で検索結果を取得します。ユーザの入力に応じて検索結果を表示します。 今回は以下のオープンソースを参考にさせていただきました。

hiroppy/site: 🏡

hiroppy/site: 🏡

🏡. Contribute to hiroppy/site development by creating an account on GitHub.

コード全文を表示 src/components/Search.astro
---
import SearchIcons from "./icons/Search.astro";
import AlgoliaLogo from "./icons/Algolia_logo.astro";

const ALGOLIA_APPLICATION_ID = "○○○○○○○○";
const ALGOLIA_SEARCH_ONLY_KEY = "○○○○○○○○○○○○○○";

if (!ALGOLIA_APPLICATION_ID || !ALGOLIA_SEARCH_ONLY_KEY) {
  throw new Error(
    "ALGOLIA_APPLICATION_ID and ALGOLIA_SEARCH_ONLY_KEY are reuired"
  );
}
---

<button id="search-button" class="search-button" aria-label="Search articles">
  <svg
    class="h-5 w-5 hover:!text-blue-300 text-gray-800 dark:text-gray-200"
    width="20"
    height="20"
    viewBox="0 0 20 20"
    stroke-width="1.5"
    stroke="currentColor"
    fill="none"
  >
    <SearchIcons />
  </svg>
</button>
<dialog id="search-dialog" class="search-dialog bg-slate-200 dark:bg-slate-800">
  <div id="search-box" class="search-box">
    <div class="search-input-box">
      <svg
        class="h-5 w-5 hover:text-blue-300 text-gray-300"
        width="24"
        height="24"
        viewBox="0 0 24 24"
        stroke-width="1.5"
        stroke="currentColor"
        fill="none"
      >
        <SearchIcons />
      </svg>
      <input
        id="search-input"
        class="search-input text-gray-800 dark:text-gray-200 font-normal"
        autofocus
        placeholder="記事を検索する"
        aria-label="Search"
      />
    </div>
    <AlgoliaLogo />
    <ul id="search-result" class="search-result"></ul>
  </div>
</dialog>

<script define:vars={{ ALGOLIA_APPLICATION_ID, ALGOLIA_SEARCH_ONLY_KEY }}>
  window.algolia = {
    applicationId: ALGOLIA_APPLICATION_ID,
    searchOnlyKey: ALGOLIA_SEARCH_ONLY_KEY,
  };
</script>

<script>
  import algoliasearch from "algoliasearch/lite";
  import instantsearch from "instantsearch.js/es";
  import { connectAutocomplete } from "instantsearch.js/es/connectors";
  import type { AutocompleteRenderState } from "instantsearch.js/es/connectors/autocomplete/connectAutocomplete";

  const button = document.querySelector<HTMLButtonElement>("#search-button");
  const dialog = document.querySelector<HTMLDialogElement>("#search-dialog");
  const container = document.querySelector<HTMLDivElement>("#search-box");
  const input = document.querySelector<HTMLInputElement>("#search-input");
  const ul = document.querySelector<HTMLUListElement>("#search-result");

  const searchClient = algoliasearch(
    window.algolia.applicationId,
    window.algolia.searchOnlyKey
  );
  const search = instantsearch({
    indexName: "yuki-meguro.com",
    searchClient,
    searchFunction(helper) {
      if (helper.state.query) {
        helper.search();
      } else if (ul) {
        ul.textContent = "";
      }
    },
  });
  const customAutocomplete = connectAutocomplete(renderAutocomplete);

  search.addWidgets([
    customAutocomplete({
      // https://www.algolia.com/doc/api-reference/widgets/autocomplete/js/
      // @ts-expect-error type mismatch
      container,
    }),
  ]);

  button?.addEventListener("click", () => {
    if (!search.started) {
      search.start();
    }
    dialog?.showModal();
  });

  dialog?.addEventListener("click", (event) => {
    if (event.target === dialog) {
      dialog?.close();
    }
  });

  function renderAutocomplete(
    renderOptions: AutocompleteRenderState,
    isFirstRender: Boolean
  ) {
    const { indices, refine } = renderOptions;
    const [blog] = indices;

    if (isFirstRender && input) {
      input.addEventListener("input", ({ currentTarget }) => {
        if (currentTarget instanceof HTMLInputElement) {
          refine(currentTarget.value);
        }
      });
      refine(input.value);
    }

    if (blog) {
      const fragment = document.createDocumentFragment();

      blog.hits.forEach((item) => {
        const li = document.createElement("li");
        const a = document.createElement("a");

        a.setAttribute("href", new URL(item.slug, location.origin).href);
        a.textContent = item.title;
        li.classList.add("search-result-item");
        li.appendChild(a);
        fragment.appendChild(li);
      });

      ul?.replaceChildren();
      ul?.appendChild(fragment);
    }
  }
</script>

<style>
  .search-dialog {
    @apply m-auto h-4/5 w-4/5 md:w-3/4 lg:w-2/4 rounded-lg backdrop:bg-gray-900 backdrop:bg-opacity-60;
  }
  .search-dialog::-webkit-scrollbar{
    display:none;
  }

  .search-box {
    @apply text-gray-700 h-full;
  }
  .search-input-box {
    @apply border border-blue-300 rounded-md w-full p-1 flex items-center gap-1;
  }
  .search-input {
    @apply bg-transparent flex-1 outline-0 max-sm:text-lg;
  }
  .search-result {
    @apply my-4 overflow-y-auto h-[calc(100%-44px)];
  }
</style>

<style is:global>
  html:has(dialog[open]) {
    overflow: hidden;
  }
  
  .search-result-item {
    @apply py-2 text-gray-800 dark:!text-gray-200 hover:text-blue-300;
  }

  .search-result-item > a {
    @apply block;
  }
</style>

また注意事項として、Algolia を無料版で利用する場合は必ずロゴ(クレジット)表示を行いましょう。

ロゴ

ヘッダーにバーガーメニューを作る

検索ボタンやテーマ切り替えボタンなど配置して、ヘッダー部がスマホ表示だと狭くなってしまったのでバーガーメニューを用意しました。バーガーメニューに関しては調べると実装例が無数に出て来ると思うので好みのデザインを選べばよいかと思います。

burger

スマホ表示がスッキリしました!

お知らせ機能とデザイン

お知らせ

blogやprojects というコンテンツに加えて、お知らせのデザインも整えてみました。基本的にはブログカードのように画像表示は不要と思いましたのでシンプルなデザインとし1行で収まりやすいデザインとしました。

コード挿入を少しだけカスタマイズ

当サイトではコード挿入を行うとデフォルトでこんな感じに表示されます。

print("hoge")

ファイル名も表示したいなぁ~と思いQiitaなどでは受け付ける入力規則を試してみましたが受け付けず…

```python:test.py

markdown内はHTMLタグを受け付けるので、CSSをカスタマイズすることで無理やり対応してみました。以下のようにコード上部にファイル名が表示されました。

src/content/blog/post.md
<div>
    <span class="codename">src/content/blog/post.md</span>
    /*
    <code>
    この間にコードを挿入する
    */
    </code>
</div>

ファイル名がコード上にあるとやっぱりわかりやすいですね。

またコードが長文になったときに折りたたみできるようにも対応してみました。これに関しては detailsとsummaryタグ を使いCSSで表示のみカスタマイズしてるだけです。

コード全文を表示 src/components/Search.astro
<details>
    <summary>コード全文を表示</summary>
    <span class="codename">src/content/blog/post.md</span>
    /*
    <code>
    この間にコードを挿入する
    */
    </code>
</details>

長いコードを挿入するときには便利ですね!

おわりに

Astro 2.0 への対応が終わり、検索機能やコード挿入など細かいカスタマイズが終わりました。個人的にあったらいいな~と思う機能をちょくちょく追加してきたのですが、これで満足できそうです。 静的サイトでありながら、Wordpressブログなどとくらべても機能的に遜色ないレベルではないでしょうか?

細かいCSSのデザイン修正は今後も行うと思いますが、これでしばらく使っていこうと思います!