たくさんの自由帳

Next.js で出来たこのブログの改修をした

投稿日 : | 0 日前

文字数(だいたい) : 13732

どうもこんにちわ。
コイバナ恋愛 ミニファンディスク アフターフェスティバル 攻略しました。

Imgur

FD で FD 言及するのねw

かわい~

Imgur

Imgur

おもろい!!!!!!!!
いちいちおもろいのすごい

Imgur

Imgur

サブカプも報われててよかったよかった。

Imgur

まぶしすぎるので(?)ぜひ
みじかい!!!

Imgur

本題

修正したい点がまた増えてきた、、いい加減直します!
優先度高め順で

  • 静的(意味深)書き出しGitHub Actionsが遅すぎる
    • Markdown → HTMLが何回も何回も呼ばれているのが原因ぽい
    • Markdown → HTMLの変換結果をキャッシュする方法が必要
  • 静的サイトでも使える全文検索があるらしいので使ってみたい→pagefind
  • OGP 画像を作って共有したときにおしゃれにしたい
  • シンタックスハイライトをrehype-pretty-codeにしたい
    • VSCodeも使ってるやつらしい
  • Google Analytics 4のみ。UAの方を消したい
    • ついでにページ遷移イベントを自前で送るのもやめたい
  • コードブロックにコピーボタンほしい
  • スマホでも目次
  • ダークモード切り替えをlocalStorage + useSyncExternalStoreにしたい
  • 環境変数じゃなくてベタ書きのが残ってる
  • Mastodon / Misskey の認証マーク付けれるように

やったこと

シンタックスハイライト

highlight.jsからShiki(を使っている rehype-pretty-code )にしました。
JSXとTypeScriptの色つけが良くなった気がする。やった~~~

どうやらVSCodeのシンタックスハイライトがこのShikiみたい?

依存関係の更新

Next.jsとかUnifiedとかの更新もした。
rehype-pretty-codeとかは11系にしないといけないので

Google Analytics の UA を消す

GA4 / UAの両方書いてたんですけど、いつからか、なんか重複されてる気がする?記録されるようになったので消しました。
ついでに、Next.js側でGoogle アナリティクスの使い方が言及されたので、そっちに乗り換えました。
ブラウザの履歴を検知する設定を有効にする必要があります。

私の環境ではjsdomの型がおかしくなってしまったので、next/third-partiesは使わず、引き続き<script>を仕込むことにしました。

Imgur

ただ、hooks(useEffect)でページ切り替えイベントを送るのはもうやめようと思いました。
GA4にブラウザの履歴イベントに基づくページの変更ってやつがあるので、それにページ遷移イベントを送るのをお任せしようと思います。

Imgur

今まではuseEffectで送ってましたが、GA4におまかせできるならお任せしようと思う。

Mastodon / Misskey のチェックマークが付くように rel に me を入れる

自分のWebページ(静的サイトでいい)に、自分のMastodon / MisskeyのURLを<a>タグで追加して、
hrefと共にrel="me"を付与します。
そのあと、Mastodon / Misskeyの補足情報にサイトのURLを入れると、チェックマークがつく機能があります。

そのrel="me"を付与できるように修正しました。

これ、SPAだと無理でSSGとかSSRみたいにHTMLをプリレンダリングした状態でホスティングしないといけないハズなので、ちょっと注意です。

Nuxtの静的サイトジェネレートはモードをuniversalにしよう - たくさんの自由帳

https://takusan.negitoro.dev/posts/nuxt_universal/

<a class="no-underline text-inherit" rel="me" href="https://diary.negitoro.dev/@takusan_23">
    @takusan_23
</a>

Markdown から HTML の変換が何度も走って遅い

改修したかった一番の理由これ。
SSGするGitHub Actionsが遅い。2024年になってから?GitHub Actionsのマシンスペックが向上したらしく、ずいぶん早くなりましたが。それでも15分くらいかかってる。

Imgur

(ちなみにスペックが上る前は30~40分くらいかかってた。小声。やばい)

Imgur

理由はわかっていて、MarkdownからHTMLの変換が何度も何度も走っているから。
変換したらメモリに乗せておくとか、あると思うんですけど、ファイル更新の際にメモリに乗せた変換結果を破棄するのもめんどいなあ思ってやらなかった。

でもやります。やっぱ遅いんで。

シングルトン案

最初に考えたのはシングルトンにしてパース結果を使い回す案。
Next.jsのホットリードにシングルトンにしたインスタンスまで巻き込まれて、インスタンスが作り直されてしまうらしい。調べたらglobalThisとかいうのに入れるといいらしい。

Comprehensive Guide to Using Prisma ORM with Next.js (Prisma ORM v7)

Learn best practices, monorepo strategies, and dynamic usage techniques for Prisma ORM in Next.js applications. Applies to Prisma ORM v7.

https://www.prisma.io/docs/orm/v7/more/troubleshooting/nextjs

これ合ってる?、これでいいのか怪しくなってきた。暫定対応感あるけど。
ちなみにこの技を使っても、constructor()でconsole.logしてみると何故か2回出力される。シングルトンってなんだ?

class ContentFolderManager {

    /** マークダウンパース結果を共用するため、シングルトンにする */
    private static _instance: ContentFolderManager

    /** マークダウンのパース結果 */
    private markdownParseList: Map<string, MarkdownData> = new Map()

    /**
     * ContentFolderManager を返します。
     * マークダウンのパース結果を共用するため、シングルトンになってます。
     * 
     * @returns ContentFolderManager
     */
    static getInstance() {
        // Next.js はシングルトンでインスタンスを共有しようとしても、ホットリードでインスタンスが再生成されるらしい
        if (process.env.NODE_ENV === 'development') {
            globalThis['_ContentFolderManager'] = globalThis['_ContentFolderManager'] ?? new ContentFolderManager()
            return globalThis['_ContentFolderManager'] as ContentFolderManager
        } else {
            ContentFolderManager._instance = ContentFolderManager._instance ?? new ContentFolderManager()
            return ContentFolderManager._instance
        }
    }
}

Next.js のキャッシュ案

これやるならNext.jsのcache()(どっちかと言うとunstable_cache)とかを使うべきな気がしてきた...
この辺で言及しているやつ。。

unstable_cacheの方だと、revalidateTag()を使うことでunstable_cacheのキャッシュを消すことが出来るみたい。
cache()の方はちょっと消し方見つからなかった。。。

というわけで仕込んでみました!
まずはキャッシュを保持しておくストアです。キャッシュがあれば返すやつ(なければパースする)と、消すやつ。
あんまりNext.jsに依存したコードを増やしたくなかったので今回はインターフェースを切りました。剥がしやすいようにしてみた。

export class NextJsCacheStore<T> implements CacheStore<T> {

    async getCache(key: string, notExists: (key: string) => Promise<T>): Promise<T> {
        // キャッシュがあればそれ、なければ notExists を呼び出す
        const cacheOrCreate = unstable_cache(
            async () => notExists(key),
            [key],
            { tags: [key] }
        )
        // 待って返す
        const result = await cacheOrCreate()
        return result
    }

    deleteCache(key: string): void {
        revalidateTag(key)
    }

}

interface CacheStore<T> {

    getCache(key: string, notExists: (key: string) => Promise<T>): Promise<T>

    deleteCache(key: string): void
}

それを仕込みました。
parseMarkdownでマークダウンをパースする前にキャッシュが存在するか問い合わせて、あれば返す。無いならパースする。
また、ファイルの変更を監視して、変化があったらキャッシュから消すようにした。これで変更があった際には再度パースする様になっているはず。
キャッシュのキーはファイルパス。


/** マークダウンパース結果をキャッシュして使い回す。中身は Next.js の cache() です。 */
private static cacheStore = new NextJsCacheStore<MarkdownData>()

static {
    // マークダウンファイルの変更を追跡して、キャッシュしているマークダウンパース結果を削除する
    // 複数回呼ばれるらしいので、その都度パースするではなく、消すだけ消して必要になったらパースする方向で
    this.watchFolder(ContentFolderManager.POSTS_FOLDER_PATH, (filePath) => {
        console.log(`[change] ${filePath}`)
        this.cacheStore.deleteCache(filePath)
    })
}

/**
 * Markdown をパースして返す。
 * キャッシュがあればキャッシュを返します。
 * 
 * @param filePath ファイルパス
 * @param baseUrl /posts /pages など
 * @returns MarkdownData
 */
private static async parseMarkdown(filePath: string, baseUrl: string) {
    // キャッシュがあるか問い合わせる
    const markdownData = await this.cacheStore.getCache(filePath, (_) => MarkdownParser.parse(filePath, baseUrl))
    return markdownData
}

/**
 * fs.watch を使ったフォルダの監視。複数回呼ばれるらしい。
 * マークダウンに変更があった際に通知されてほしいので。
 * 
 * @param folderPath フォルダパス
 * @param onChange 変更があった際に呼ばれます。引数はファイルパス。
 */
private static watchFolder(
    folderPath: string,
    onChange: (filePath: string) => void
) {
    (async () => {
        const pagesWatcher = fs.watch(folderPath)
        for await (const result of pagesWatcher) {
            if (result.eventType === 'change' && result.filename) {
                onChange(path.join(folderPath, result.filename))
            }
        }
    })()
}

これでページの表示も早くなる(マークダウンを毎回パースする手間が減る)、かつ、マークダウンに変更があったら再度パーサーにかかるようにキャッシュを消すようにしています。
これで勝った!!!と思ってたんですが。

 ⨯ unhandledRejection: StaticGenBailoutError: Route /posts/android_video_editor_akari_droid/ with `dynamic = "error"` couldn't be rendered statically because it used `revalidateTag C:\Users\takusan23\Desktop\Dev\NextJS\ziyuutyou-next\content\posts\ziyuutyou_update_2024.md`. See more info here: https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic#dynamic-rendering
    at trackDynamicDataAccessed (webpack-internal:///(rsc)/./node_modules/next/dist/server/app-render/dynamic-rendering.js:106:15)
    at revalidate (webpack-internal:///(rsc)/./node_modules/next/dist/server/web/spec-extension/revalidate.js:51:52)
    at revalidateTag (webpack-internal:///(rsc)/./node_modules/next/dist/server/web/spec-extension/revalidate.js:26:12)
    at NextJsCacheStore.deleteCache (webpack-internal:///(rsc)/./src/NextJsCacheStore.ts:25:66)
    at eval (webpack-internal:///(rsc)/./src/ContentFolderManager.ts:45:29)
    at eval (webpack-internal:///(rsc)/./src/ContentFolderManager.ts:196:21) {
  code: 'NEXT_STATIC_GEN_BAILOUT'
Next.jsを静的書き出しモードで使っている場合は、たとえ開発モードでもrevalidateTagが呼べない。
もちろん静的書き出しではバックエンド側の機能(ブラウザではなくNode.jsが必要な機能)は使えないので、revalidateTagが使えないというのは分かるんですが、ちょっと期待していたので残念。
静的書き出し時に使えない機能はこちらです:

Guides: Static Exports | Next.js

Next.js enables starting as a static site or Single-Page Application (SPA), then later optionally upgrading to use features that require a server.

Next.js のキャッシュ案2

よく見たらnext.config.js、これ開発中・静的書き出し中それぞれ別に設定を変更できるらしい。

Configuration: next.config.js | Next.js

Learn how to configure your application with next.config.js.

これで、開発中はoutput: 'export'を消すようにすればいいのでは・・・?
というわけで試してみた、動いてます!!!

const { PHASE_DEVELOPMENT_SERVER } = require('next/dist/shared/lib/constants')

module.exports = async (phase, { defaultConfig }) => {

    /** @type {import('next').NextConfig} */
    const nextConfig = {
        output: 'export',
        trailingSlash: true,
        webpack(config) {
            // SVG をコンポーネントにできる
            config.module.rules.push({
                test: /\.svg$/,
                use: ['@svgr/webpack'],
            })
            return config
        },
        experimental: {
            scrollRestoration: true,
        }
    }

    // このサイトは静的書き出しを使っていますが、静的書き出しモードでは使えない機能を開発中のみ使いたいため、
    // 開発時のみ静的書き出しモードを OFF にする。( Next.js の revalidateTag )
    // 開発時のみ OFF になるが、本番環境は静的書き出しを使うため、静的書き出しで使える機能のみを使う必要があります。
    // https://nextjs.org/docs/app/building-your-application/deploying/static-exports#supported-features
    if (phase === PHASE_DEVELOPMENT_SERVER) {
        nextConfig.output = undefined
    }

    return nextConfig
}

パース結果を使い回すようにしたため、超速くなりました。
開発中も静的書き出し時も超高速です(てか今までが毎回パースしてたからくっっっそ遅かった)
あと、これもシングルトンのときと同じくwatchFolderが何度も呼ばれてしまうため、やっぱりglobalThisしないとだめかも。。。。

Next.js のキャッシュ案3

watchFolderが何回も呼ばれちゃうので、globalThis等で制御する必要があり、気持ち悪いというかそれならシングルトンで良かった。
あとはrevalidateTagのためだけにnext.config.jsいじって開発時のみSSRとかやりたくないので。。。

のと、unstable_cacheはどうやら(開発時だけかも)ブラウザのスーパーリロードでキャッシュが消えることが判明したので、
もう無理にキャッシュを消すためにrevalidateTagなんかしないで、手元でスーパーリロードすればいいじゃん。ってなった。

このキャッシュやっかいなことに、これ開発サーバーを再起動してもキャッシュ(unstable_cache の結果)は残り続けるらしく、知らないとガチ沼にはまりそう。
というかキャッシュがスーパーリロードで消えるとかどこに書いてある?、、書いてないけどこれまぐれで動いてるんか?

export class NextJsCacheStore<T> implements CacheStore<T> {

    async getCache(key: string, notExists: (key: string) => Promise<T>): Promise<T> {
        // キャッシュがあればそれ、なければ notExists を呼び出す
        const cacheOrCreate = unstable_cache(
            async () => notExists(key),
            [key],
            { tags: [key] }
        )
        // 待って返す
        const result = await cacheOrCreate()
        return result
    }

    deleteCache(key: string): void {
        // 未実装
        // ブラウザのスーパーリロードをすれば消せます。
    }

}

まとめ

  • シングルトン案
    • globalThisとかいうオブジェクトに入れることで、開発時でもインスタンスを一つにできる
      • 正解なのかは不明
    • でも試した限りなんかconstructor()が2回呼ばれている(2ついる?)
  • unstable_cache 案
    • マークダウンが変化したらrevalidateTagを呼び出してキャッシュを消したいところだが、静的書き出しモードでは利用できない
  • unstable_cache + 開発時のみ サーバーサイドレンダリング 案
    • 開発時のみ SSR にしているのが引っかかる
    • ファイル監視用の関数がホットリードのたびに呼ばれるので、シングルトン同様制御が必要
  • unstable_cacheで変更したら自分でスーパーリロードする案
    • 普通のリロードだとキャッシュが返ってくる
    • でも開発時だけSSRと比べると一番マシな気がする、、、

どれがベスト?今のところ一番最後かなあ、、、
でもマークダウンのパースする部分にNext.js依存を持ち込むかと言われるとシングルトンが最有力になる。まあいいや。

あと調べると、マークダウンパース結果をメモリ(シングルトンで配列を持つ)ではなく、ストレージに書き込む案もありましたが、
Next.jsのキャッシュがまさにそれな気がする。自前で書くかNext.jsにお任せするか。

テーマ設定を localStorage に、あと useSyncExternalStore

テーマ設定をlocalStorageで持つようにしました。
2回目開いた時にテーマ設定が引き継がれます。

また、localStorage書き込みイベントを投げて、useSyncExternalStoreを使い、React側でも購読できるようにしました。

useSyncExternalStore – React

The library for web and native user interfaces

https://ja.react.dev/reference/react/useSyncExternalStore

useSyncExternalStore + dispatchEventを使ってlocalStorageの変更を通知できるようにすると、
地味に複数タブを開いたときもテーマ設定が反映されるのでちょっと感動。

import { useSyncExternalStore } from "react"
import { ThemeTool, Theme } from "../../src/ThemeTool"

const subscribe = (onStoreChange: () => void) => {
    window.addEventListener('storage', onStoreChange)
    return () => {
        window.removeEventListener('storage', onStoreChange)
    }
}
const getSnapshot = () => ThemeTool.readTheme()
const getServerSnapshot = () => 'light'

/**
 * テーマ用カスタムフック。
 * 現在のテーマ設定と、テーマ変更の関数を返します。
 */
export default function useTheme() {
    // useSyncExternalStore で localStorage と React のステートをつなげる
    const theme = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)

    // 設定変更用関数
    function setTheme(theme: Theme) {
        ThemeTool.saveTheme(theme)
        // subscribe で通知が行くように
        window.dispatchEvent(new Event('storage'))
    }

    return { theme, setTheme }
}

そういえば、端末がダークモード設定でも初回時はライトテーマになる問題、<head>にJavaScriptを差し込めば良さそうだけど、
Next.jsだと出来なさそう・・・?

スマホ用目次を Tailwind CSS で作る

とりあえず記事の一番上に。目次を開くコンポーネントを置きました。
なんとJavaScript無しで再現できます。GitHubのMarkdownとかでも使えるハズ。

<details>
    <summary>
        目次
    </summary>
    <p><a href="#1">1</a></p>
    <p><a href="#2">2</a></p>
    <p><a href="#3">3</a></p>
</details>

↓こんなの

Imgur

次に、Tailwind CSSで見た目を調整したい(展開ボタンとか付けたい)
というわけでこちら→

Hover, focus, and other states - Core concepts

Using utilities to style elements on hover, focus, and more.

https://tailwindcss.com/docs/hover-focus-and-other-states

どうやら、親要素の状態(例えばこの場合は展開した時に<details>にopen属性がつく)に応じて CSS を付ける方法があります。
groupを使う方法です。

{/* group つける */}
<details className="group select-none">
    <summary className="list-none">
        {/* 開いてるときと閉じてる時を作っておく、CSS で切り替える。group-open で group 付けた要素の状態参照ができる */}
        <p className="flex group-open:hidden">ここを押すと開く</p>
        <p className="hidden group-open:flex">もう一回ここを押すと閉じる</p>
    </summary>
    <p><a href="#1">1</a></p>
    <p><a href="#2">2</a></p>
    <p><a href="#3">3</a></p>
</details>

Imgur

展開アイコンも出したい?
これでどうだろう?

{/* group つける */}
<details className="group select-none">
    <summary className="list-none">
        {/* group-open で group 付けた要素の状態参照ができる */}
        <div className="flex flex-row">
            <p className="grow">もくじ</p>
            {/* アイコンは https://fonts.google.com/icons から */}
            <div className="rotate-180 group-open:rotate-0">
                <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24">
                    <path d="m296-345-56-56 240-240 240 240-56 56-184-184-184 184Z" />
                </svg>
            </div>
        </div>
    </summary>
    <p><a href="#1">1</a></p>
    <p><a href="#2">2</a></p>
    <p><a href="#3">3</a></p>
</details>

どうだろう!?!?。
React Server ComponentだとJavaScriptをギリギリまで使いたくないと思うから便利だと思う!!!

Imgur

Imgur

コードブロックにコピーボタンを置いた

こんな感じに、コードブロックをマウスオーバーするとコピーボタンが出てくるようになりました。
欲しかったけどunifiedのプラグインとか絶対難しそうで、useEffectで動的に差し込むか~どうするかな~思ってたところで。

Imgur

仕組みとしては、Markdownにあるコードを色付けするrehype-pretty-codeは、shikiで色付けしているわけですが、
これ任意のコードを差し込むAPIが用意されているんですね。Transformersってやつ。

const remarkParser = await unified()
    .use(rehypePrettyCode, {
        theme: "dark-plus",
        transformers: [
            // コピーボタンを差し込む
            transformShikiCodeBlockCopyButton()
        ]
    })
    .process(matterResult.content)

transformShikiCodeBlockCopyButton.tsがこちらです。
ボタン要素を作って、押した時にクリップボードにコピーされるようにします。
pre(node) { }が<pre>要素に対してDOM 操作が出来るやつです。今回は<pre>に<button>を差し込んでいるので。<code>要素をいじるcode(node) { }とかもあります。

ここでもTailwind CSSのgroupが大活躍です。親のコードブロックがマウスオーバーしたら、ボタンが表示されるよう、親にはgroup、ボタンにはhidden group-hover:flex?を付けています。JavaScriptなしでここまで出来るんだ。


/** コピーボタンにつける Tailwind CSS のユーティリティ名 */
const className = 'hidden group-hover:flex p-2 m-2 absolute top-0 right-0 cursor-pointer rounded-md bg-background-dark border-2 border-content-primary-dark text-content-primary-dark fill-content-primary-dark'

export default function transformShikiCodeBlockCopyButton(): ShikiTransformer {
    return {
        name: 'transformShikiCodeBlockCopyButton',

        // 生成後の <pre> 要素を編集する
        pre(node) {

            // コピーボタンを追加するためにまず親を position: relative する
            // src の中ですが、このファイルだけ特別にユーティリティ名走査対象にしているので Tailwind CSS が使えます。
            node.properties.class = 'relative group'

            // コピーボタンを差し込む
            node.children.push({
                type: 'element',
                tagName: 'button',
                properties: {
                    data: this.source,
                    onclick: /* javascript */ `navigator.clipboard.writeText(this.attributes.data.value)`,
                    class: className,
                },
                children: [
                    {
                        type: 'element',
                        tagName: 'svg',
                        properties: {
                            xmlns: 'http://www.w3.org/2000/svg',
                            height: '24',
                            viewBox: '0 -960 960 960',
                            width: '24'
                        },
                        children: [
                            {
                                type: 'element',
                                tagName: 'path',
                                properties: {
                                    // アイコンは https://fonts.google.com/icons より
                                    d: 'M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h167q11-35 43-57.5t70-22.5q40 0 71.5 22.5T594-840h166q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm0-80h560v-560h-80v120H280v-120h-80v560Zm280-560q17 0 28.5-11.5T520-800q0-17-11.5-28.5T480-840q-17 0-28.5 11.5T440-800q0 17 11.5 28.5T480-760Z'
                                },
                                children: []
                            }
                        ]
                    }
                ],
            })
        }
    }
}

あ、もし私みたいにTailwind CSSを使う場合、tailwind.config.jsで、transformShikiCodeBlockCopyButton.tsもユーティリティ名走査対象に追加する必要があります。
もしくは./appとかのjsxがあるフォルダ内に書いた場合はいらないです。私は./srcの中に書いたので走査対象じゃない。

module.exports = {
  content: [
    "./app/**/*.{js,ts,jsx,tsx,mdx}",
    "./pages/**/*.{js,ts,jsx,tsx,mdx}",
    "./components/**/*.{js,ts,jsx,tsx,mdx}",
    // コピーボタンを差し込むので
    "./src/transformShikiCodeBlockCopyButton.ts"
  ],
  // 以下省略
}

作ったアプリコンポーネントを作り直した

メニューを押して見ないと何があるか分からないので、選択中以外の項目も下に表示するようにしてみた。
マルチカラムです。

Imgur

OGP 画像

リンクを共有したときに表示される、あの画像。
OGP画像とかOpenGraph Imageとか言われてる?

Next.jsではhtml(JSX)を組み立てる感覚で画像を作ることが出来ます。すごい時代ですね。

Metadata Files: opengraph-image and twitter-image | Next.js

API Reference for the Open Graph Image and Twitter Image file conventions.

Route Handlers 機能

これはpage.tsxならhtmlをレスポンスとして返しますが、
スマホ向けにjsonとかRSS用にxmlとか、html以外をレスポンスで返したいときが多々あると思います。
このような場合は、このRoute Handlers機能を使うことで、html以外を生成してレスポンスとして返してあげることが出来ます。

page.tsxが静的書き出し出来るのと同じ用に、このRoute Handlers機能もレスポンスを静的書き出し時に生成することが出来ます。
ただ、生成するためには条件があり、GETリクエストののみ + リクエストのたびに同じデータを返すようなRoute Handlersでなければいけません。

静的サイトなので、同じデータを配信することしか出来ない。逆に言えばこれさえ守ればxmlとかjsonとかも静的書き出し時に生成できるので面白いことが出来そう(今回のOGP 画像がこれ)
何言ってるかよくわからない場合は公式見て:

Guides: Static Exports | Next.js

Next.js enables starting as a static site or Single-Page Application (SPA), then later optionally upgrading to use features that require a server.

app/posts/[blog]/example.json/route.ts

import ContentFolderManager from "../../../../src/ContentFolderManager"

/** 動的ルーティング */
type PageProps = {
    params: { blog: string }
}

/**
 * ルートハンドラー
 * 静的書き出し時に生成するため、request 引数は使ってはいけない
 */
export async function GET(_: Request, { params }: PageProps) {
    return Response.json({ dynamic_routing: params.blog })
}

// app/posts/[blog]/page.tsx の generateStaticParams と同じ
export async function generateStaticParams() {
    // 記事一覧
    const fileNameList = await ContentFolderManager.getBlogNameList()
    // この場合はキーが blog になるけどこれはファイル名によって変わる([page].tsxなら page がキーになる)
    return fileNameList.map((name) => ({ blog: name }))
}

レスポンスはこんな感じになります

{"dynamic_routing":"ziyuutyou_update_2024"}

Route Handlers で OGP 画像を生成する

app/posts/[blog]/opengraph-image.png/route.tsxを作ります。
そして雑ですがこんな感じにしてみると。。。

import { ImageResponse } from "next/og"

/** 動的ルーティング */
type PageProps = {
    params: { blog: string }
}

/**
 * OGP 画像を生成するルートハンドラー
 * OGP 画像を静的書き出し時に生成するため、request 引数は使ってはいけない。
 */
export async function GET(_: Request, { params }: PageProps) {
    return new ImageResponse(
        (
            <div
                style={{
                    display: 'flex',
                    flexDirection: 'column',
                    padding:10
                }}
            >
                <h1 style={{ fontSize: 60 }}>
                    ここにタイトルを入れる
                </h1>
                <p style={{ fontSize: 30 }}>
                    2024-05-02
                </p>
                <p style={{ fontSize: 30 }}>
                    @takusan_23
                </p>
            </div>
        ),
        {
            width: 1200,
            height: 630
        }
    )
}

/** 動的ルーティング */
export async function generateStaticParams() {
    // 各自よしなに実装してください。page.tsx の動的ルーティングと同じです。
    // return [{ blog: 'first' }]
}

こんな感じのOGP 画像が出来ます。
結構良さそう。すげ~~~

Imgur

使えるCSSとかはこの辺が多分そう。
flexで作っていけば良さそう。長い文字入れても乱れないかは確認しておいたほうが良さそう。

GitHub - vercel/satori: Enlightened library to convert HTML and CSS to SVG

Enlightened library to convert HTML and CSS to SVG - vercel/satori

https://github.com/vercel/satori

というわけで作ってみました。
Imgur

ちゃんと静的書き出し時に記事毎に生成されてますね!
これで共有したときイケてるサイトみたいに画像が出ます!

Imgur

metadata に入れる

正規ルート?ではなくRoute Handlersで作ったので自動では<head>にog:imageを追加してくれません。
自分でURLを指定して追加する必要があります。

/** head に値を入れる */
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
    const markdownData = await ContentFolderManager.getBlogItem(params.blog)
    const ogpTitle = `${markdownData.title} - ${EnvironmentTool.SITE_NAME}`
    const ogpUrl = `${EnvironmentTool.BASE_URL}${markdownData.link}`

    return {
        title: ogpTitle,
        alternates: {
            canonical: ogpUrl
        },
        openGraph: {
            title: ogpTitle,
            url: ogpUrl,
            // OGP 画像は opengraph-image.png/route.tsx 参照
            images: `${ogpUrl}opengraph-image.png` // ←これ
        }
    }
}

Imgur

メモ

画像はbase64にしたあと<img>に入れるとデータの取り出し?操作の回数が減るからおすすめとのこと。

GitHub - vercel/satori: Enlightened library to convert HTML and CSS to SVG

Enlightened library to convert HTML and CSS to SVG - vercel/satori

https://github.com/vercel/satori

せっかくなのでコード貼ります

ファイルを読み出すユーティリティクラスを作ってみたけどわざわざ作るまででもないと言われれればそう。

class FileReadTool {

    /** <img> の src へ base64 のデータを渡す際に先頭に入れておく文字列。png 版。 */
    static BASE64_PREFIX_PNG = 'data:image/png;base64,'

    /** {@link BASE64_PREFIX_PNG}の svg 版。 */
    static BASE64_PREFIX_SVG = 'data:image/svg+xml;,'

    /**
     * 引数に渡したファイルパスのデータを base64 形式で読み出して返す
     * 
     * @param filePathSegments ファイルパス。/app/icon.png なら可変長引数に 'app', 'icon.png' を渡す。
     * @returns base64 のデータ
     */
    static async readBase64(...filePathSegments: string[]) {
        return await fs.readFile(path.join(process.cwd(), ...filePathSegments), { encoding: 'base64' })
    }

    /**
     * テキストファイルを読み出す。
     * 
     * @param filePathSegments ファイルパス。/public/icon/menu.svg なら可変長引数に 'public', 'icon', 'menu.svg' を渡す。
     */
    static async readTextFile(...filePathSegments: string[]) {
        return await fs.readFile(path.join(process.cwd(), ...filePathSegments), { encoding: 'utf-8' })
    }

    /**
     * 引数に渡したファイルパスを読み出して Buffer で返す
     * 
     * @param filePathSegments ファイルパス。/styles/css/fonts/font.ttf なら可変長引数に 'styles', 'css', 'fonts', 'font.ttf' を渡す。
     */
    static async readByteArray(...filePathSegments: string[]) {
        return await fs.readFile(path.join(process.cwd(), ...filePathSegments))
    }
}
// /app/posts/[blog]/opengraph-image.png/route.tsx
// TODO PageProps の定義
// TODO generateStaticParams の実装

export async function GET(_: Request, { params }: PageProps) {
    // 記事を取得
    const markdownData = await ContentFolderManager.getBlogItem(params.blog)

    // Tailwind CSS の色を取得
    const colors = resolveConfig(tailwindConfig).theme?.colors
    const backgroundColor = colors['background']['light']
    const containerColor = colors['container']['primary']['light']
    const contentColor = colors['content']['primary']['light']

    // 表示するアイコン。base64 とかで直接渡すのがいいらしい(相対 URL 無理だった)
    const [iconBase64, homeIconBase64, blogIconBase64, tagIconBase64] = await Promise.all([
        // アバター画像。/app/icon.png
        FileReadTool.readBase64('app', 'icon.png'),
        // ナビゲーションドロワーのアイコン
        FileReadTool.readTextFile('public', 'icon', 'home.svg'),
        FileReadTool.readTextFile('public', 'icon', 'book.svg'),
        FileReadTool.readTextFile('public', 'icon', 'sell.svg')
    ])

    // フォントファイル
    // styles/css/fonts にある ttf を見に行く
    const fontFileBuffer = await FileReadTool.readByteArray('styles', 'css', 'fonts', 'Koruri-Regular-sub.ttf')

    return new ImageResponse(
        (
            // 背景
            <div
                style={{
                    height: '100%',
                    width: '100%',
                    position: 'relative',
                    display: 'flex',
                    backgroundColor: backgroundColor,
                    fontFamily: 'KoruriFont'
                }}
            >

                {/* flex 横並び。 */}
                {/* パーセントで仕切るんじゃなくて、flex-grow 使うべきな気がするけど、width 明示的に指定しないとテキストの折り返し出来ない気がして、、 */}
                <div
                    style={{
                        height: '100%',
                        width: '100%',
                        position: 'absolute',
                        display: 'flex',
                        flexDirection: 'row'
                    }}
                >

                    {/* アイコン並んでる部分 */}
                    <div
                        style={{
                            width: '10%',
                            display: 'flex',
                            flexDirection: 'column',
                            marginTop: 20,
                            alignItems: 'center'
                        }}
                    >
                        {/* アバター画像 */}
                        <img
                            style={{ borderRadius: 50 }}
                            width={70}
                            height={70}
                            src={`${FileReadTool.BASE64_PREFIX_PNG}${iconBase64}`}
                        />

                        {/* ナビゲーションドロワーのアイコン並んでる部分 */}
                        {
                            [homeIconBase64, blogIconBase64, tagIconBase64].map((svg) => (
                                <img
                                    style={{ marginTop: 40 }}
                                    width={50}
                                    height={50}
                                    src={`${FileReadTool.BASE64_PREFIX_SVG}${svg}`}
                                />
                            ))
                        }
                    </div>

                    {/* flex 縦並び */}
                    <div
                        style={{
                            width: '90%',
                            display: 'flex',
                            flexDirection: 'column',
                            paddingRight: 30
                        }}
                    >

                        {/* タイトルバーの部分 */}
                        <div
                            style={{
                                display: 'flex',
                                flexDirection: 'row',
                                alignItems: 'center'
                            }}
                        >
                            <h1
                                style={{
                                    flexGrow: 1,
                                    fontSize: 40,
                                    color: contentColor
                                }}
                            >
                                {EnvironmentTool.SITE_NAME}
                            </h1>
                        </div>

                        {/* 記事のタイトルと投稿日時の部分 */}
                        <div
                            style={{
                                flexGrow: 1,
                                display: 'flex',
                                flexDirection: 'column',
                                padding: 20,
                                backgroundColor: containerColor,
                                borderTopLeftRadius: 40,
                                borderTopRightRadius: 40
                            }}
                        >

                            <h1
                                style={{
                                    fontSize: 70,
                                    color: contentColor,
                                    flexGrow: 1,
                                    wordBreak: 'break-all'
                                }}
                            >
                                {markdownData.title}
                            </h1>

                            <p
                                style={{
                                    fontSize: 40,
                                    color: contentColor,
                                    alignSelf: 'flex-end'
                                }}
                            >
                                {markdownData.createdAt}
                            </p>
                        </div>
                    </div>

                </div>
            </div>
        ),
        {
            width: 1200,
            height: 630,
            fonts: [
                {
                    name: 'KoruriFont',
                    data: fontFileBuffer
                }
            ]
        }
    )
}

静的サイトで検索機能

この手の検索機能、全文検索とかいう名前がついているらしい。

静的サイト書き出しという性質上、よくあるブログ記事検索みたいなのはかなりめんどくさいんですよね。
静的書き出し時にサーバーに配置する(ユーザーに配信する)html とかを生成するので、検索ワードに応じた検索結果のhtmlを作ることが出来ないんですよね、、
(それこそサーバーサイドレンダリングするサーバーが必要)

というわけで、静的書き出し時でも検索機能を付けたい場合、思いつくのが、
検索用に全部の記事を一つにした.jsonを作って、検索をしたときにjsonファイルをリクエストして、クライアント側で検索ワードを元にフィルタリングしていく。

[
    {
        title: "記事のタイトル",
        content: "どうもこんにちは、今回は~"
    }
]
// 擬似コード
// 全部の記事を JSON で静的書き出し時に作っておいて、検索実行時に取得する
const allBlogJson = await fetch('search.json').then(res => res.json())

// 検索ワードが含まれているか
const keyword = keywordInput.value
const searchResult = allBlogJson.filter(blog => blog.content.match(keyword))

// 表示する
searchResult.forEach(containsBlog => {
    // todo
})

ただ、全部の記事を一つのJSONにして配信すると、多分とんでもない通信量になってしまう。。。
全部詰め込んだJSON(1MB超え)配信するのは、、、その辺の小さくした画像よりもずっと大きいJSONできついわ。

というわけで静的書き出しサイトだと検索機能付けるのは厳しそうに見えたのですが、pagefindを見つけた。

Pagefind

Pagefind is a fully static search library that aims to perform well on large sites, while using as little of your users’ bandwidth as possible, and without hosting any infrastructure.

https://pagefind.app/

転送量を抑えながら全文検索ができる模様。静的サイト向けの全文検索機能みたい!!
Next.js 向けの記事もあった

Add search to your Next.js static site with Pagefind

Pete Millspaugh's digital garden

https://petemillspaugh.com/nextjs-search-with-pagefind

pagefind 導入

pagefindをいれて、

npm i -D pagefind

静的書き出し後に、pagefindの処理を追加します。
outは静的書き出しフォルダです。distとかの場合もある?

"deploy": "npm run build && pagefind --site out"

pagefind 検索 UI

pagefindの標準検索UIもありますが、Next.jsのルーティングで動くか怪しいので、今回は1から作ります。
(next/linkの画面遷移でもDOMContentLoaded呼ばれる?)

というわけでまず検索するためのJavaScriptをインポートしたいのですが、
どうやら、npx pagefindした後に生成されるpagefind.jsをロードする必要があるそう。。
何が難しいか言うと、静的書き出し時までpagefind.jsが存在しないんですよね。開発時は無い。

というわけで先駆け者さんのをまるまるパクった。
windowオブジェクト(staticにあたるやつ)にpagefindを差し込む。ページ表示時に。
ついでに、開発時にも適当な値が帰ってきてほしいので、開発中のみpagefindInDevMockに向けるようにしました。

pagefind.search()で検索が出来ます。
各検索結果はasync data()関数を呼び出すことでタイトルとか本文とかが取り出せます。
今回はPromise.allで10件取り出してロードした状態でJSXに渡してますが、ロード前の状態でJSXに渡して、useEffect()で表示されたらロードみたいなことも出来ると思います。

タイトルはmeta['title']で取れるらしいです。htmlの中から<h1>を探してきてそれをタイトルとして使うみたいなので、複数<h1>がある場合は注意ですね。

動くかまでは見てないけどこんな感じ。開発中はハードコートした値が帰ってきます。
あとpagefind、型がないから自前で適当に作ってasでキャストしてるけど正攻法なのかな、これ。

/** await pagefind.search の返り値 */
type PagefindSearchResults = {
    results: PagefindSearchResult[]
}

/** 各検索結果エントリ。data() を await することで詳細を取得する。 */
type PagefindSearchResult = {
    id: string,
    data: () => Promise<PagefindSearchFragment>
}

/** PagefindSearchResult.data() の返り値 */
type PagefindSearchFragment = {
    url: string,
    content: string,
    meta: Record<string, string>
}

/** 開発時は適当な値を返す pagefind。UI の調整用。 */
function pagefindInDevMock() {
    // 適当にダミーを作って返す
    const results: PagefindSearchResult[] = (new Array(5)).fill(0).map((_, index) => ({
        id: 'example',
        data: () => new Promise((resolve) => resolve({
            url: `/posts/page/${index + 1}/`,
            content: 'process.env.NODE_ENV === development',
            meta: { 'title': `Page ${index + 1}` }
        }))
    }))
    const result: PagefindSearchResults = {
        results: results
    }
    return { search: () => new Promise<PagefindSearchResults>((resolve) => resolve(result)) }
}

export default function PagefindSearch() {
    // キーワード
    const [searchWord, setSearchWord] = useState('')
    // 検索結果
    const [searchResult, setSearchResult] = useState<PagefindSearchFragment[]>([])

    // pagefind を読み込む
    // window.pagefind が使えるように 
    // なんでこんな回りくどい方法を取っているかというと、pagefind は静的書き出し後に生成する JavaScript をロードする必要があり、開発時は使えない。
    useEffect(() => {
        (async () => {
            if (!window["pagefind"]) {
                try {
                    window["pagefind"] = await import(
                        // @ts-expect-error pagefind.js generated after build
                        /* webpackIgnore: true */ "/pagefind/pagefind.js"
                    )
                } catch (e) {
                    // 開発時は適当にハードコートされた値を返す。本番は await import するはずなので問題ないはず
                    if (process.env.NODE_ENV === 'development') {
                        window["pagefind"] = pagefindInDevMock()
                    }
                }
            }
        })();
    }, [])

    // 検索する
    async function search(keyword: string) {
        if (window["pagefind"]) {
            // 検索する
            const pagefindResult = await window["pagefind"].search(keyword) as PagefindSearchResults
            // 10件取り出してロードする
            const searchResultFragmentList = await Promise.all(pagefindResult.results.slice(0, 10).map((r) => r.data()))
            // 文字制限したい
            const formatTextResultFragmentList = searchResultFragmentList.map((fragment) => ({ ...fragment, content: fragment.content.substring(0, 100) }))
            // UI に反映
            setSearchResult(formatTextResultFragmentList)
        }
    }

    // 検索結果を JSX で組み立てる
    return (
        <div className="flex flex-col">

            <input
                value={searchWord}
                onChange={(ev) => setSearchWord(ev.target.value)} />
        
            <button onClick={() => search(searchWord)}>
                検索
            </button>

            {
                searchResult.map((result) => (
                    <Link href={result.url} key={result.url}>
                        {fragment.meta['title']}
                    </Link>
                ))
            }
        </div>
    )
}

pagefind インデックス対象の調整

おそらくそのままでは、本文以外の、投稿日時とかタグとかも検索結果に出てきてしまいます。
検索結果には本文だけでてきてほしいと思います。というわけでMarkdownをhtmlにして表示している箇所にdata-pagefind-bodyをつけました。

<div
    data-pagefind-body
    className="content_div"
    dangerouslySetInnerHTML={{ __html: markdownData.html }} />

一点、注意点があり、data-pagefind-bodyを付けると、ついていない画面は検索結果の対象にはなりません。
data-pagefind-bodyを一回でも使った場合、検索結果に出てきて欲しいすべてのページで同様に付ける必要があります。
今回は記事本文だけ検索結果に表示されればいいので、data-pagefind-bodyをブログ記事本文page.tsxに付けて終わりです。

組み込んだソースコード

実際に動かしてみた結果

英語だけかと思ったら日本語も結構出てきて感動した。すごい。なんだこれ???

Imgur

Imgur

Imgur

文字数カウントからコードブロックの分を消す

はい。

const REGEX_MARKDOWN_CODE_BLOCK = /```([\S])([\s\S\n]*?)```/g

// マークダウン読み出す
const rawMarkdownText = await fs.readFile(filePath, { encoding: 'utf-8' })
// 文字数カウント。
// 正規表現でコードブロックを取り出して、その分の文字数を消す
const markdownCodeBlockAllExtract = Array.from(rawMarkdownText.matchAll(this.REGEX_MARKDOWN_CODE_BLOCK), (m) => m[0])
const markdownCodeBlockLength = markdownCodeBlockAllExtract.reduce((accumulator, currentValue) => accumulator + currentValue.length, 0)
const textCount = rawMarkdownText.length - markdownCodeBlockLength

本番(意味深)に入れる

一応人がいなさそうな深夜とかに本番環境へ入れようと思います。。。
(そもそも見てる人おらんやろ)

Imgur

Markdownのパース回数が減ったので、かかる時間もかなり短くなった(てか本当に今まで長すぎた)
Windows Updateすら環境に配慮する時代やぞ

Imgur

本番(意味深)のAmazon CloudFrontから見ていますがちゃんと反映されました。
2024/05/05 の午前3時くらいのことです。おはよう!朝4時に何してるんだい?

Imgur

↑検索ボタンが出ていますねっ

終わりに

JavaScriptでクラスってあんまり使わないらしい?のであんまり気にならないのかもしれないけど、
thisを付けないといけないの、明らかに冗長だと思う。

class ContentFolderManager {

    static POSTS_FOLDER_PATH = path.join(process.cwd(), `content`, `posts`)
    static POSTS_BASE_URL = `/posts`

    static async getBlogItem(fileName: string) {
        const filePath = path.join(this.POSTS_FOLDER_PATH, `${fileName}.md`)
        return this.parseMarkdown(filePath, this.POSTS_BASE_URL)
    }

    private static async parseMarkdown(filePath: string, baseUrl: string) {
        // todo
    }
}

!!!!
JavaScriptのクラスで思い出したんですけど、JavaScriptのクラスのコンストラクタってreturnで値返せるらしいんですよね。。
(コンストラクタでreturnしたいことあるんかな?)
(てかnewしたクラスとは関係ない値が返せるってこと??)

終わりに2

JSXの中でIIFE(即時実行関数式)を使うのってアリなのかな。
Jetpack Composeと違ってJSX内ではifが使えない(正確にはJSのifは文なので、値を返せない。三項演算子を使う)。

早期 return したいときにIIFEを使ったけどどうなの?あり?
この程度ならlet component: ReactNodeでJSX外で条件分岐すれば良い気もしてきた。。。letが嫌ならこれ?

<div className="flex flex-col items-center w-full space-y-6">
    {(() => {

        // ロード中
        if (isLoading) {
            return <CircleLoading />
        }

        // まだ検索してない
        if (!searchResult) {
            return searchLogoElement
        }

        // 検索したけど 0 件だった
        if (searchResult.length === 0) {
            return (
                <>
                    {searchLogoElement}
                    <p className="text-content-primary-light dark:text-content-primary-dark">
                        検索結果が見つかりませんでした。
                    </p>
                </>
            )
        }

        // 検索結果を出す
        return <SearchResult resultList={searchResult} />

    })()}
</div>

終わりに3

Google AnalyticsのUAがGA4に取って代わったため、UAがサービス終了になるわけですが、
7月1日より前にUAで集めたデータをダウンロードしておく必要があります。UAの集計結果を見ることが出来なくなってしまいます。

以上です。お疲れ様でした。8888888888