ろぼいんブログ
更新:

ブログをAstroに移行しました

サムネイル

今までこのブログでは独自に開発したCMSと静的サイトジェネレーターを使っていましたが、Astroに移行しました。この記事では、Astroに移行した理由と、つまづいたところを紹介します。

Astroとは?

Astro は、Markdownを用いて記事を執筆できる 静的サイトジェネレーター です。Astroデフォルトでスクリプトが不要なので、高速なWebサイトを構築できます。詳しくは公式ドキュメントをご覧ください。

独自システムを辞めた理由

今までこのブログでは、独自に開発したCMSと静的サイトジェネレーターを使っていました。この システム は2021年6月に実施した 全面リニューアル で導入し、2022年2月に 再構築 したものです。

Markdownで書いた記事を、テンプレートを使ってHTMLに変換するというシステムです。その過程で、HTMLや画像の最適化をしていました。

しかし、このシステムにはいくつか問題がありました。

まず、主に画像の最適化処理において多重forループがあり、ソースコードが複雑になっていました。また、新しい記事のデータベースへの登録はコマンドラインから可能ですが、既存の記事に関するデータの変更や削除には、手動でデータベースを編集する必要がありました。

さらに、そもそも独自開発したものなので、信頼性とパフォーマンスの面で不安がありました。

以上の理由から、独自システムを辞めることにしました。

Astroを選んだ理由

では、数あるCMSや静的サイトジェネレーターの中から、なぜAstroを選んだのでしょうか。

まず、システムを選定する上で必須の条件として、次のようなものがありました。

  1. 静的サイトジェネレーターであること
  2. Markdownで記事を執筆できること
  3. 特定のライブラリーに依存しないこと
  4. 拡張性やカスタマイズ性が高いこと

「静的サイトジェネレーターであること」については、このブログで利用している GitHub Pages が静的サイトにのみ対応しているためです。

「Markdownで記事を執筆できること」については、理由が2つあります。1つ目は、既存の記事がMarkdownで作成されているためです。2つ目は、Markdownだとさまざまな環境で気軽に執筆できるためです。

「特定のライブラリーに依存しないこと」については、これによってJavaScriptのコードの量を減らせ、サイトのパフォーマンスを向上できるためです。ライブラリーを使用すると、その分だけページの読み込みが遅くなります。

主要な静的サイトジェネレーターとしては、 Next.js Nuxt.js などがあります。しかし、Next.jsはReactベースですし、Nuxt.jsはVue.jsベースです。この時点で「特定のライブラリーに依存しないこと」に当てはまらないため、選択肢から外しました。

静的サイトジェネレーターを選定する上で参考になったのが、Jamstackのサイトです。Jamstackのサイトでは、主要な静的サイトジェネレーターを条件に合わせて絞り込めるようになっています。

このページで[Templates]としてMarkdownを指定すると、次のようになりました。

Markdownを使った静的サイトジェネレーターの一覧。

GitHubのスター順で並べると、「Docusaurus」「Slate」「Astro」「Docsify」「Eleventy」「mdBook」といったものが上位になりました。この中で、上から順番に調査しました。

まず、DocusaurusはReactベースなので除外しました。

Slateについては、ブログというよりもドキュメント用ですし、Rubyで作られているので除外しました。Ruby自体は悪くないのですが、ライブラリーのインストールなどに使っている Node.js で統一できた方が便利だからです。

最終的に、条件に合致しておりGitHubのスター数も多いためAstroを選びました。

Astroへの移行でつまづいたところ

Astroはとても使いやすく、ブログの基礎部分の移行作業はたったの30分で完了しました。他の細かい部分の調整や移行などを含めると、最終的に6日間で移行できました。

基礎部分が30分で終わったのに最終的に6日間かかっていることからも分かるとおり、いくつかつまづいた点があるので紹介します。

画像の相対パス

Astroはv3.0で正式サポートされた assets 機能により、Markdownファイル内で画像を相対パスで指定できます。

しかし、相対パスを記述する際、./は省略できないようです。つまり、次の例では、上は正しく動作しますが、下は正しく動作しません。

try{(()=>{function a(e){if(!e)return;let t=e.getAttribute("tabindex")!==null,n=e.scrollWidth>e.clientWidth;n&&!t?e.setAttribute("tabindex","0"):!n&&t&&e.removeAttribute("tabindex")}var u=window.requestIdleCallback||(e=>setTimeout(e,1)),i=window.cancelIdleCallback||clearTimeout;function l(e){let t=new Set,n,r;return new ResizeObserver(c=>{c.forEach(o=>t.add(o.target)),n&&clearTimeout(n),r&&i(r),n=setTimeout(()=>{r&&i(r),r=u(()=>{t.forEach(o=>e(o)),t.clear()})},250)})}function d(e,t){e.querySelectorAll?.(".expressive-code pre > code").forEach(n=>{let r=n.parentElement;r&&t.observe(r)})}var s=l(a);d(document,s);var b=new MutationObserver(e=>e.forEach(t=>t.addedNodes.forEach(n=>{d(n,s)})));b.observe(document.body,{childList:!0,subtree:!0});document.addEventListener("astro:page-load",()=>{d(document,s)});})();}catch(e){console.error("[EC] tabindex-js-module failed:",e)}try{(()=>{function i(o){let e=document.createElement("pre");Object.assign(e.style,{opacity:"0",pointerEvents:"none",position:"absolute",overflow:"hidden",left:"0",top:"0",width:"20px",height:"20px",webkitUserSelect:"auto",userSelect:"all"}),e.ariaHidden="true",e.textContent=o,document.body.appendChild(e);let a=document.createRange();a.selectNode(e);let n=getSelection();if(!n)return!1;n.removeAllRanges(),n.addRange(a);let r=!1;try{r=document.execCommand("copy")}finally{n.removeAllRanges(),document.body.removeChild(e)}return r}async function l(o){let e=o.currentTarget,a=e.dataset,n=!1,r=a.code.replace(/\u007f/g,` `);try{await navigator.clipboard.writeText(r),n=!0}catch{n=i(r)}if(!n||e.parentNode?.querySelector(".feedback"))return;let t=document.createElement("div");t.classList.add("feedback"),t.append(a.copied),e.before(t),t.offsetWidth,requestAnimationFrame(()=>t?.classList.add("show"));let c=()=>!t||t.classList.remove("show"),d=()=>{!t||parseFloat(getComputedStyle(t).opacity)>0||(t.remove(),t=void 0)};setTimeout(c,1500),setTimeout(d,2500),e.addEventListener("blur",c),t.addEventListener("transitioncancel",d),t.addEventListener("transitionend",d)}function s(o){o.querySelectorAll?.(".expressive-code .copy button").forEach(e=>e.addEventListener("click",l))}s(document);var u=new MutationObserver(o=>o.forEach(e=>e.addedNodes.forEach(a=>{s(a)})));u.observe(document.body,{childList:!0,subtree:!0});document.addEventListener("astro:page-load",()=>{s(document)});})();}catch(e){console.error("[EC] copy-js-module failed:",e)}
<!-- 正しく動く -->
![画像](./image.png)
<!-- 正しく動かない -->
![画像](image.png)

相対パスの./を省略すると、「Octal literal in strict mode (Note that you need plugins to import files that are not JavaScript)」というエラーが発生します。

Astroのソースコードを確認したところ、これはWindows環境で発生する問題のようです。軽くしか調査していませんが、おおむね次のような理由で発生しているようです。

  • AstroはVite用のコードをテンプレートリテラルで生成している
  • ./を省略すると、パスの正規化の関係で絶対パスへの変換時に区切り文字が\になる
  • テンプレートリテラル内で\を使用すると、エスケープシーケンスとして扱われ、\の後に続く文字が8進数として解釈される

この問題を回避するため、既存の記事で\が省略されている相対パスに\を追加するスクリプトを作って修正しました。

Note記法の対応

今までこのブログでは、Markdownパーサーとして Marked を採用していました。また、Qiitaの Note記法 を使うために、 qnote-parser というプラグインを開発して使っていました。

しかし、AstroではMarkdownパーサーとして remark を採用しており、このプラグインを利用できません。

そこで、 qiita-to-md というパッケージの内部で使われているプラグインを利用することにしました。astro.config.mjsに次のように記述することで、Note記法を利用できるようになります。

import { defineConfig } from 'astro/config';
import { RemarkNotePlugin } from "@masatomakino/qiita-to-md/bin/plugin/RemarkNotePlugin";
export default defineConfig({
site: "<サイトのURL>",
markdown: {
remarkPlugins: [RemarkNotePlugin.plugin],
remarkRehype: {
handlers: {
note: RemarkNotePlugin.rehypeNoteHandler
}
}
}
});


注意点として、記事執筆時点でこのプラグインには改段落やリストを含むNote記法を正しく変換できない バグ があります。

また、このプラグインはQiitaの記事をダウンロードしてMarkdownとHTMLとして保存するためのものなので、単体で使うことが想定されていません。そのため、改行コードとして\r\nではなく\nを使用する必要があります。これについても、スクリプトでまとめて置き換えました。

frontmatterの画像のパス

Astroの コンテンツコレクションと画像 についてのドキュメントで、「ブログ記事のカバー画像など、コンテンツコレクションのエントリに関連付けられた画像を、現在のフォルダからの相対パスを使ってフロントマターに宣言できます」と書かれています。

ドキュメントでは例として、次のコードが掲載されています。

---
import { Image } from "astro:assets";
import { getCollection } from "astro:content";
const allBlogPosts = await getCollection("blog");
---
{
allBlogPosts.map((post) => (
<div>
<Image src={post.data.cover} alt={post.data.coverAlt} />
<h2>
<a href={"/blog/" + post.slug}>{post.data.title}</a>
</h2>
</div>
))
}

このことから、てっきり<Image />コンポーネントを<img>タグに置き換えても同様に動作すると思っていました。しかし、実際には次のように記述する必要があります。

---
import { Image } from "astro:assets";
import { getCollection } from "astro:content";
const allBlogPosts = await getCollection("blog");
---
{
allBlogPosts.map((post) => (
<div>
<img src={post.data.cover.src} alt={post.data.coverAlt} />
<h2>
<a href={"/blog/" + post.slug}>{post.data.title}</a>
</h2>
</div>
))
}

<img>タグに渡すときにはpost.data.coverではなくpost.data.cover.srcとする必要があるようです。

これは、Astroが単純にfrontmatterの画像のパスを解決しているだけでなく、画像の最適化もしており、post.data.coverにパス以外の情報も含まれているためです。

View Transitions機能が画像へのリンクを破壊する

Astroでは、たった 2行のコードを追加 するだけで、ブラウザーの View Transitions API を利用できるようになります。また、この機能をサポートしていないブラウザーのためのフォールバックも提供されています。

View Transitionsを使うと、MPAでSPAのようなシームレスなページ遷移を実現できます。

しかし、この機能は画像やPDFなど、非ページへのリンクを 破壊 します。非ページへのリンクをクリックすると、画像やPDFの代わりに画面に奇妙な文字列が表示されます。

これを回避するには、非ページへのリンクにdata-astro-reload属性を付与します。この属性が付与されたリンクでは、View Transitionsが無効になります。

このブログでは、フロントエンドのJavaScriptを用いて、href属性の末尾が/で終わっていないリンクに対してdata-astro-reload属性を付与するようにしています。

この解決策は、ページへのリンクがhttps://example.com/page-title/という形式になっているため動作します。https://example.com/page-titleという形式やhttps://example.com/page-title.htmlという形式の場合は別の対応が必要です。

.astroファイル内での条件分岐

.astroファイル内で特定の変数の値に応じて要素を追加したりしなかったりするには、たとえば次のようにします。

条件分岐に使う変数 && (追加する要素)という形式で記述します。

{
Astro.props.frontmatter.showThumbnail && Astro.props.frontmatter.thumbnail && (
<img
src={Astro.props.frontmatter.thumbnail.src}
alt="サムネイル"
width={Astro.props.frontmatter.thumbnail.width}
height={Astro.props.frontmatter.thumbnail.height}
/>
)
}

まとめ

この記事では、ブログをAstroに移行した理由と、つまづいたところを紹介しました。

いくつかつまづいた点はありましたが、Astroは使いやすく高速です。また、今まで手動でコマンドを実行したり編集したりする必要があった、記事一覧や、トップページの新着記事と新着動画などを自動で更新できるようになりました。

カスタマイズ性も高いので、ぜひAstroを使ってみてください。

おすすめアイテム

※このリンクを経由して商品を購入すると、当サイトの運営者が報酬を得ることがあります。詳細はこちら

このサイトを支援する

Buy Me a CoffeeまたはGitHub Sponsorsで支援していただけると、サイトの運営やコンテンツ制作の励みになります。定期的な支援と一度限りの支援がありますので、お間違いのないようにお願いします。

Buy me a coffee

著者のアイコン画像

生まれた時から、母国語よりも先にJavaScriptを使っていました。ネットの海のどこにもいなくてどこにでもいます。

Webフロントエンドプログラマーで、テクノロジーに関する話題を追いかけています。動画編集やプログラミングが趣味で、たまにデザインなどもやっています。主にTypeScriptを使用したWebフロントエンド開発を専門とし、便利で実用的なブラウザー拡張機能を作成しています。また、個人ブログを通じて、IT関連のニュースやハウツー、技術的なプログラミング情報を発信しています。

最新記事