Meguro.esリニューアルの裏側 Cover

Meguro.esリニューアルの裏側

目次

    はじめに

    この記事では、Meguro.es 再始動後どのようにリニューアルを行ったのか、リニューアル告知記事で触れられなかった内容を中心にお話していきます。

    主にWeb開発の技術と生成AI寄りの話になると思います。

    リニューアルの背景

    前主催の fuya さんから主催を引き継いだ際、ロゴやコンセプトなど、Meguro.es のブランドに関わる部分すべての刷新を快諾いただき、リニューアルに踏み出しました。

    引き継ぎの時点ではデザイナーが不在で手をこまねいていたところ、同僚かつ目黒区民の lanberb がいたので、スカウトしてデザイナー兼運営として招き入れました。社内では、よく彼とツーマンセルでアレコレ開発に関与しています。

    そこに前運営から2名(fuya さん、mogamin さん)が加わり、計4名でMeguro.esは再始動しました。

    ロゴリニューアル

    今回特筆すべきものとして、生成AIでたたき台となるロゴを作ったことが挙げられます。

    ロゴリニューアルでは、はじめに lanberb が生成AIの出力を加工してパターン出しを行い、運営メンバーで良さげなものを選びました。

    ロゴとしては中段の右から2番目のもので決まりました。

    僕的には今までのロゴの要素をを受け継ぎつつ、新たなコミュニティの誕生を表現したかったのでかなりしっくり来ています。

    同様に、ロゴの色出しも進めました。

    ここはかなり悩みましたが、目黒感を全面に押し出すべく、目黒区のシンボルから色を拝借したものを選びました。

    リスの色に用いた目黒区の紋章の紫は、落ち着きと鮮やかさを兼ね備えおり、目黒川の桜をイメージしたピンク色とも馴染んでいます。

    こういった流れで、ひとまずロゴのリブランディングが完了しました。

    これは Meguro.es 運営のふわっとした方針でもあるんですが、膨大なリソースをコミュニティに割かないでも回せるようにすることを目指していたので、生成AIの活用がちょうどハマった場面でした。

    Webサイトリニューアル

    ロゴの次に lanberb にWebサイトのUIのデザインを制作してもらいました。

    僕はピクセルパーフェクトが苦手なので、UI面の実装は lanberb にも入ってもらいました。お陰で、年内滑り込みで公開まで漕ぎ着けました。

    クオリティとしては、実装したかった機能はすべて実装でき、あとは軽微なデザイン修正があるような状態です。

    採用した技術は、告知記事にあるとおり Vite・Preact です。SSGのフレームワークに Vike を使い、Cloudflare Pagesにデプロイしています。

    従来は Nuxt で SSG したものを Netlify から配信していたので、技術は変われどやっていることは同じです。

    また、Contentful と Webサイト をつなぐバックエンドに Hono を使い、Cloudflare Workersにデプロイしています。

    なぜ新たにバックエンドを用意したのかについては後述します。

    ここでは、告知記事で触れられなかった「Vikeを採用した背景」「プロジェクトの構成」「バックエンド」の3つをお話します。

    Vikeを採用した背景

    手短に話すと以下の通りです。

    • UIをJSXで書ける
    • Next.js・Gatsby.js以外でSSGに挑戦したい
    • ビルドの設定が複雑ではない
    • 開発サーバーが Vite(esbuild) なので速い

    UIを JSX で書ける SSG 向けフレームワークとして、 Gatsby.js や Next.js がよく用いられると思います。

    Gatsby.js は GraphQL がくっついた SSG ツールで、Next.js はWebサイトというよりもWebアプリケーションを作るためのツールで、オプションで SSG ができるというものです。

    Meguro.es もこのどちらかを使えば、僕が慣れているのもあって早く開発できたんですが、いわゆる「強くてニューゲーム」になってしまいかねなかったので採用は見送りました。

    そこで最初に白羽の矢を立てたのは 11ty です。

    かねてより SSG フレームワークで良さそうだなと思っていたものの、触れることなくここまで来たので、この機会に色々と触ってみました。

    11ty はテンプレートエンジンであれば、ほぼ任意の言語で UI をかけます。

    が、11tyとしては Mozilla が作った DSL である、Nunjucks を使ってねという感じでした(11tyに出会うまで僕は知らなかった)。

    別に、テンプレートエンジンにそこまでこだわりはなかったのですが

    • テンプレートエンジンや DSL に関しては文法を覚える手間が惜しいので、慣れているものを使いたい
      • 味の違う改造された HTML・JS はもうお腹いっぱいな気持ちがあります
    • 独自のファイル形式( .njk )で、サポートしているエディタが限られる
      • 僕は neovim を愛用しているんですが、使っている LSP 向けプラグインが Nunjucks をサポートしていません
    • Nunjucks のやってることはテンプレート + コードのコンポーネント化にすぎない
      • JSX で必要十分

    以上で、Nunjucks の使用は見送りました。

    Nunjuck がだめだと言うわけではなく今回のニーズに合わなかっただけなので、また機会があればこれで 11ty を使ってみようと思います。

    Nunjucks のかわりに JSX を使うことになったのですが、11ty の dev tool のファイル監視・webpack による JSX(TSX)のトランスパイル・TailwindCSSのバンドルがうまく噛み合わず、プロダクションのビルドはできるものの、HMR の有効化で苦戦しました。

    実装は残ってないんですが https://github.com/jahilldev/11tyby を参考にしています。ちらっと見ていただくとわかるとおり、開発サーバー起動や、ビルドは若干複雑です。

    特に、最近 11ty が 2.0 にバージョンがあがったこともあり、なかなか最新のAPIと併せて JSX を組み込めずにいました。

    あと、久々に webpack 使ったんですが、相変わらず Dev Server は動作が重たいなーと思いました。

    他には issue に esbuild を使う方法 が上がっていたので真似してみたんですが、11tyのレイアウトを使う方法が見つからず、こちらはこちらで苦戦を強いられました。

    HMR は動いたので、どうにかレイアウトを11ty側のビルドツールに食わせられれば開発はできるかもです。ただ、期日的に厳しいので断念しました。

    Vikeとは

    ここらへんで一旦ビルドの設定はシンプルに書きたくなったので、esbuild を使った流れから Vite 周辺を探り始めました。ようやく Vike にたどり着きます。

    Vike自体、前々(vite-plugin-ssrの頃)から知っていたのですが、11tyと同様触れたことのない技術でした。

    Vike は Vite で SSR するためのライブラリですが、SSG もサポートしています。

    PreactとVikeでSSGする場合、以下のように Vite の config file を書くだけで終わりなので、11tyでつらかった部分はこれで解消しました。

    import { defineConfig } from "vite";
    import preact from "@preact/preset-vite";
    import vike from "vike/plugin";
    
    export default defineConfig({
      plugins: [preact(), vike({ prerender: true })],
    });

    では、Vike の機能についていくつか触れながら、どういった機能や構成で要件を実現したのか紹介します。

    Vike の設定として、/renderer というディレクトリに +onRenderHtml.ts ファイルを置いて SSR の処理を書きます。

    React や Preact だと、ここで renderToString を呼び出します。

    const onRenderHtml: OnRenderHtmlAsync = async (
      pageContext,
    ): ReturnType<OnRenderHtmlAsync> => {
      const { Page, pageProps } = pageContext;
    
      if (!Page)
        throw new Error("My render() hook expects pageContext.Page to be defined");
      const pageHtml = renderToString(
        <Providers pageContext={pageContext}>
          <Page {...pageProps} />
        </Providers>,
      );
    
    	// +data.ts(後述)がreturnしたものをここでHTMLに注入する。
      const title = pageContext.data?.title || DEFAULT_TITLE;
      const desc = pageContext.data?.description || DEFAULT_DESCRIPTION;
      const ogImageUrl =
        pageContext.data?.ogImageUrl || `${WEBSITE_URL}/image_og.png`;
      const robotsContent = pageContext.data?.isPrivate ? "none" : "index,follow";
    
      const documentHtml = escapeInject`<!DOCTYPE html>
        <html lang="ja">
          <head>
            <meta charset="UTF-8" />
            <link rel="icon" href="/favicon.svg" />
            <title>${title}</title>
          </head>
          <body>
            <div id="root">${dangerouslySkipEscape(pageHtml)}</div>
          </body>
        </html>`;
    
      return {
        documentHtml,
        pageContext: {},
      };
    };

    また、 +onRenderClient.ts ファイルを置いて、Hydrate の処理を書きます。

    const onRenderClient: OnRenderClientAsync = async (
      pageContext,
    ): ReturnType<OnRenderClientAsync> => {
      const { Page, pageProps } = pageContext;
      if (!Page)
        throw new Error(
          "Client-side render() hook expects pageContext.Page to be defined",
        );
      const root = document.getElementById("root");
      if (!root) throw new Error("DOM element #root not found");
      hydrate(
        <Providers pageContext={pageContext}>
          <Page {...pageProps} />
        </Providers>,
        root,
      );
      document.title = pageContext.data?.title || DEFAULT_TITLE;
      document
        .querySelector('meta[name="description"]')
        ?.setAttribute(
          "content",
          pageContext.data?.description || DEFAULT_DESCRIPTION,
        );
    };

    レンダリング後に必要な、ページで共通のレイアウトやロジックはここで適用します。

    SSR・Hydrate は Node.js で書いていますが、他の言語やライブラリに変えることもできます。

    データを JSX に注入してHTMLの生成まで、細かく柔軟にできるのが良いですね。

    Vike の決まりとして /pages 内の +Page ファイルを持つディレクトリがルーティングに対応します。Next.js の Pages Router と似たものです。

    この /pages 内のディレクトリでは、サーバーサイドとクライアントサイドの処理を別ファイルで扱います。

    例えば / ページの場合、 /index/+data.ts がサーバーサイドで /index/+Page.tsx がクライアントサイドのファイルです。ちなみに SSG のパス生成も別ファイルで、 /index/+onBeforePrerenderStart.ts となります。

    Next.js の Pages Router と照らし合わせると、以下と対応します。

    • NextPage : +Page.tsx
    • getServerSideProps, getStaticProps : +data.ts
    • getStaticPaths : +onBeforePrerenderStart.ts

    VikeはNext.jsより後にできているのもあって、ある程度Next.jsのやり方に倣っていると言えます。

    Next.jsに親しみのある人からすると、新たに覚えることは少ないかつ、SSR周りの処理に手を加えやすいので、痒いところに手が届く感じがあります。

    プロジェクトの構成

    Vike の規則に従いつつ、ディレクトリ構成やデータの取得フローなどは僕が思うベストプラクティスを実践しました。

    ディレクトリ構成は以下になります。

    作るものはWebアプリというよりもブログに近いので、機能別に分けたりはしていないです。

    .
    ├── README.md
    ├── dist
    ├── public
    ├── src
        ├── components       # UIコンポーネント
        ├── hooks            # Custom Hooks
        ├── libs             # Architectureを表現するディレクトリ + utility
        │   ├── entities     # Entity層
        │   ├── presenters   # Presenter(Output)層
        │   ├── repositories # Repository層
        │   └── usecases     # Usecase層
        ├── pages
        ├── renderer
        ├── styles
        └── types

    データ取得は Clean Architecture を意識して設計しました。以下取得の流れです。

    取得系以外ないので、Controllerに相当する層はありません。

    Repository層でContentfulのJS SDKのAPIを呼び出し、そのレスポンスをPresenterで整形、Usecaseを介してUIのHTMLに注入するような流れです。

    実装の詳細は libs ディレクトリ内をご覧ください。

    小技の紹介

    TypeScript関連でちょっとした小技を使用し、開発を円滑化しました。

    Brand型で「HTMLの混ざった文章からHTMLを取り除いた文字列型」を表現しました。

    /** @see https://basarat.gitbook.io/typescript/main-1/nominaltyping#using-interfaces */
    export type Brand<T, U extends `${string}Brand`> = T & { [_ in U]: never };
    export type StringWithoutHtml = Brand<string, "stringWithoutHtmlBrand">;
    const htmlRegExp = /<\/?[^>]+(>|$)/g;
    function isStringWithoutHtml(str: string): str is StringWithoutHtml {
      return !str.match(htmlRegExp);
    }
    export function parseStringWithoutHtml(str: string) {
      const parsed = str.replace(htmlRegExp, "");
      return isStringWithoutHtml(parsed) ? parsed : ("" as StringWithoutHtml);
    }

    OG Description など、純粋なテキストがほしい箇所に対してこの型を当て、型レベルで parseStringWithoutHtml 関数の使用を強制しています。

    
    // Entity
    export interface Fuga {
      fields: {
    		// ~~~
        og: {
          title: string;
          description: StringWithoutHtml;
        };
      };
    }
    
    // Presenter
    export class FugaOutput implements Fuga {
      readonly fields: Fuga["fields"];
      constructor({ fields }: Entries["items"][number]) {
        this.fields = {
    			// ~~~
          og: {
            title: String(fields.title || ""),
            description: parseStringWithoutHtml(
              String(fields.summary || "").replace(/\n/g, ""),
            ),
          },
        };
      }
    }

    これで「特定の関数で文字列を変えてね!」と口頭で説明する手間を省くことができました。

    また、Result型でSSR時のエラーハンドリングを容易にしています。

    export type Result<R, E extends Error> = Ok<R> | Err<E>;
    
    export class Ok<R> {
      readonly #value: R;
      constructor(readonly res: R) {
        this.#value = res;
      }
      public isOk(): this is Ok<R> {
        return true;
      }
      public isErr(): this is Err<never> {
        return false;
      }
      public unwrap(): R {
        return this.#value;
      }
    }
    
    export class Err<E extends Error> {
      readonly #value: E;
      constructor(readonly err: E) {
        this.#value = err;
      }
      public isOk(): this is Ok<never> {
        return false;
      }
      public isErr(): this is Err<E> {
        return true;
      }
      public unwrap(): E {
        return this.#value;
      }
    }

    +data.ts で Result が Ok か Err かを判別し、Err であれば vike/abort でエラー内容をクライアントに返しています。

    import { render } from "vike/abort";
    import { getFoo } from "~/libs/usecases/getFoo";
    
    export async function data() {
      const fooRes = await getFoo();
      if (fooRes.isOk()) {
        return {
          foo: fooRes.unwrap(),
        };
      }
      throw render(503, fooRes.unwrap().message); // Err だと unswap すると Error オブジェクトになる
    }
    
    // uescase
    export const getFoo = async () => {
      const res = await foo.find();
      return res;
    };
    // repository
    export const foo = {
    	find: async (): Promise<Result<Foo, Error>> => {
        try {
          const foo = await client.getEntries({
            content_type: "foo",
          });
          return new Ok(new FooOutput(foo.items[0]));
        } catch (e) {
          return new Err(e as Error);
        }
      },
    } as const

    Repository 層などの UI から遠いところで適当な Error を投げることなく、UIに近いところまでエラー内容を持ってきてから操作できました。

    他にも色々と Vike + TypeScript で試行錯誤したので、ぜひ https://github.com/meguroes/next.meguro.es からご覧ください。

    バックエンド

    CMSがあるのに、なぜバックエンドが必要?となるかと思います。

    理由としては、Contentfulのアクセストークンをクライアントサイドに露出させないためです。

    また、Vikeは API Routes 的な機能を持ち合わせていないため、別でバックエンドのサーバーを立てる必要がありました。

    ContentfulのAPIをブラウザで叩こうとすると、アクセストークンを Authorization ヘッダーにつけることになり、普通にChromeなどのネットワークタブから見えてしまいます。

    別に Contentful に限ったことではないですが、有効期限切れがほぼないようなトークンは漏れ出してほしくないものです。

    そこで Proxy の JS サーバーでヘッダーにトークンを埋め込み、APIを呼び出す形にしました。

    バックエンド実装には Hono を使いました。Hono を用いた動機は、Proxy のサーバーを Cloudflare Workers で動かすためです。

    また、直近で Hono のアドカレに参加したのもあり、さらなる知見とユースケースの実践の機会を求めていました。

    今回は Contentful のデータを Workers で取得し、クライアントに返す API を実装しました。

    Contentful の JS SDK は Workersで(axiosのアダプターがないと)動かないので、簡単のためにContentful Delivery API を直だ叩きしています。

    なるべく JS SDK と使用感を揃えるべく、以下のMiddlewareを実装し Context に API の fetcher をセットして使いました。

    app.use("/api/*", async (c, next) => {
      const { CONTENTFUL_SPACE_ID, CONTENTFUL_ACCESS_TOKEN } = env<Env>(c);
      const contentful: Contentful = {
        getEntries: async (queryObject) => {
          const searchParams = new URLSearchParams();
          Object.entries(queryObject).forEach(([key, value]) => {
            if (!Array.isArray(value)) {
              searchParams.set(key, value);
            } else {
              value.forEach((v) => searchParams.append(key, v));
            }
          });
          const query = searchParams.toString();
          const res = await fetch(
            `https://cdn.contentful.com/spaces/${CONTENTFUL_SPACE_ID}/entries?${query}`,
            {
              headers: {
                Authorization: `Bearer ${CONTENTFUL_ACCESS_TOKEN}`,
              },
            },
          );
          const data = await res.json();
          return data;
        },
      };
      c.set("contentful", contentful);
      await next();
    });
    import { Hono } from "hono";
    
    const app = new Hono();
    
    app.get("/", async (c) => {
      const client = c.get("contentful");
      const { limit, skip } = c.req.query();
      const post = await client.getEntries({
        content_type: "post",
        order: ["-fields.createdAt"],
        ...(limit && { limit }),
        ...(skip && { skip }),
      });
      return c.json(post);
    });
    
    export default app;

    初期リリースで盛り込まれていた無限ロードのUIにこのAPIが使用されています(今はデザインの都合上消滅しましたが、多分復活します)。

    さいごに

    かなりタイトな日程の中で、デザインやイベント向けグッズの発注、connpassのイベントページ作成など、並行してリニューアルを進めてくれた運営メンバーの3名に感謝しています。改めてありがとうございます。

    特にロゴデザインからのWebサイトデザインまで、息をつく間もなく作業してくれた lanberb には、感謝してもしきれないです。

    今度ラーメン二郎おごります。鍋二郎でもいいです。ぜひあなたのお家に持ち込んでやらせてください。

    あと、CSSはもうTailwindCSSで勘弁してください。

    締めに Meguro.es のミートアップの告知です。

    2024/01/25 に Abema Towers で約4年ぶりの Meguro.es を開催します。1/18まで抽選枠募集中なので、登録はお早めに!

    https://meguroes.connpass.com/event/305991/

    また今回、特別ゲストとして syumai さん・sadnessOjisanさん・arayaさんの3名に、ECMAScript に関連した話題で発表していただきます。発表の内容は connpass に掲載しています。

    公募の登壇枠も4つ用意しております。初心者から上級者まで、技術のレベル感は問わないのでぜひご応募ください!

    また、登壇の有無に関わらず、皆さんがミートアップを安心して楽しんでいただけるように、新たなガイドラインのご確認をお願いしています。併せてよろしくお願いします。

    では、イベント当日にお会いしましょう!ご精読いただきありがとうございました。

    この記事を共有する