開発日誌

開発した時に躓いたこととかの記録

React Hook Formとreact-dropzoneとZodでファイルアップロードフォームを作って、nodemailerでファイルをメール送信する

  • React Hook Formとreact-dropzoneを組み合わせた時にバリデーションを発火させるのに手間取ったのでメモ。知ってれば難しいことはない。
  • Next.jsのAPIでnodemailerを使ってメールを送るのアップデート版
  • textareaのinputをメール送信するだけの機能に、下記の要素を追加
    • 選択肢を追加
    • ファイルのアップロードを追加(pdfに制限する)
    • バリデーションを追加
  • 機能の実現のために、下記のライブラリを追加した
    • React Hook Form: form項目増えるので管理のため
    • react-dropzone: ファイルアップロードをドラッグ&ドロップで実現したいため
    • Zod: バリデーション機能のため
    • formidable: formのデータ受信のため
  • リポジトリ

UI側

ライブラリ選定理由

  • 元々Formik & Yupの組み合わせを使ってたが、最近はReact Hook Form & Zodの波がキてると聞いてノってみた
  • React Hook Form & Zodの方が軽くてパフォーマンスにも優れてるようなので、使い心地を試したい

やっていき

react-dropzoneのdrop時に、Zodのバリデーションを効かせる

  const onDrop = useCallback(
    (acceptedFiles: File[]): void => {
      setValue('file', acceptedFiles[0], { shouldValidate: true })
    },
    [setValue]
  )

react-dropzoneをpdfのみに制限する

  • inputの時とあまり変わらない
  const { getRootProps, getInputProps, isDragActive } = useDropzone({
    onDrop,
    accept: {
      'application/pdf': ['.pdf'],
    },
  })

formDataの生成

interface SchemaInterface {
  [key: string]: string | number | Blob
}

interface CustomFormData extends FormData {
  append<T extends string | Blob>(
    name: keyof Schema,
    value: T,
    fileName?: string
  ): void
}

const createFormDatata = (data: Schema) => {
  const formData = new FormData() as CustomFormData
  Object.keys(data).forEach((key: string) => {
    const subKey = key as keyof Schema
    const d = data as SchemaInterface
    const tmp = d[subKey]
    if (typeof tmp === 'number') {
      formData.append(subKey, String(tmp))
    } else {
      formData.append(subKey, tmp)
    }
  })
  return formData
}

POSTのheaderの変更

 headers: {
   'Content-Type': 'application/json',
},

api(Serverless Function)側

  • vercelにデプロイするが、vercel serverless functionはあまり大きなファイルを扱うことはできないみたいなので注意
  • バックエンドのAPIとしてのバリデーションもあった方が良いが、今回はその点はSKIP

やっていき

formidableはv3を利用する

Next.jsのbodyParserはOFFにする

  • Next.jsにbodyParserというbodyをいい感じにparseしてくれる機能がありJSONでリクエスト時は大変ありがたいのだが、formでリクエストするときは不都合になるのでオフにする
  • Routing: API Routes | Next.js
export const config = {
  api: {
    bodyParser: false,
  },
}

おわり🎉

  • ファイルアップロードしてメールが送るができるようになりました🎉
  • formまわりの型、とりあえず赤線は解消できたが、違和感があるのでそのうち再チャレンジしたい
  • ファイルアップロードのAPIって結構大変。これももう少し楽な方法ありそうなので探してみたい。

Newtの複数参照フィールドで絞り込みをする

  • 個人ブログのタグで絞り込みの書き方がイケてないのを思い出した
  • APIによるタグの絞り込みが方法がわからなかったので下記のように全記事を取得してきてjsでフィルタをしていた。記事が少ない&ページングもしてない今は良いが、記事が増えたら破綻するので解消する。
  • リポジトリ


export const getArticleByTag = async (slug: string) => {
  const { items } = await client.getContents<Article>({
    appUid: 'manas-diary',
    modelUid: 'article',
    query: {
      select: ['_id', 'title', 'slug', 'tags', 'body', 'icon', 'summary'],
    },
  })

  const tagItems = items.filter(
    (item) => item.tags.filter((tag) => tag.slug === slug).length
  )

  return tagItems
}

結論

  • 絞り込みは『コンテンツID(_id)』で実施する
  • slugはIDではない
export const getArticleByTag = async (tagId: string) => {
  const { items } = await client.getContents<Article>({
    appUid: 'manas-diary',
    modelUid: 'article',
    query: {
      select: ['_id', 'title', 'slug', 'tags', 'body', 'icon', 'summary'],
      tags: {
        in: [tagId],
      },
    },
  })

  return items
}

結論が出るまで

気づくまで

  • 公式にできそうな雰囲気のことは書いてある

    • Newt CDN API | Newt API Reference
    • Filtersの項のInclusionを使えばよさそうだが、slugを投げても投げてもかえってこない。エラーが出る。
  • ふと気づく。『slugって、idじゃないかも』

  • タグの詳細を確認。名前とslugしかない。ここで撤退してはいけない。左上のページを開くをクリックする。

  • ページを開いた後。右側、ステータスの青いラベルのすこし下に『コンテンツID』が存在している。コードに直貼りで試したところ絞り込みが動いた。つまり、『絞り込みで必要になるIDはコンテンツIDのこと』である。

今のコードを理解しなおす

  • NewtとNext.jsを利用してブログを作成するを参考にペタペタ構築したのだが、この記事のgetArticleBySlugの理解が間違っていた。私はこれを『IDで取得する処理(内部的にarticle/[slug]のようなものになる)』と理解していた。
  • だがQueryに入っているということは、CDN APIのドキュメントのQueriesの欄に該当する
    • つまりここはEquality operatorで合致するslugにフィルタされているだけ
export const getArticleBySlug = async (slug: string) => {
  const article = await client.getFirstContent<Article>({
    appUid: 'manas-diary',
    modelUid: 'article',
    query: {
      slug,
      select: [
        '_id',
        'title',
        'slug',
        'tags',
        'body',
        'icon',
        'summary',
        'publishDate',
      ],
    },
  })
  return article
}
  • その点を理解しなおしたうえで、Queriesの下の注意をしっかり読む。

参照先に対してクエリをかけたい場合、_id に対してのみクエリをかけられる仕様となっております。その他のフィールドに対してクエリをかけることはできません。

  • 先のコードの通り_idというフィールドはコード上にはりつけていたがしっかり認識してなかった
  • というわけで再掲になるが、下記のように修正。
    • getArticleBysTagの引数はslugではなくtagIdに変更
    • queryにtagsでの絞り込みを追加。
      • このブログではtagsは複数参照フィールドなので、一致検索ではなく包含検索で行う。複数参照フィールドは一致検索ができない。
export const getArticleByTag = async (id: string) => {
  const { items } = await client.getContents<Article>({
    appUid: 'manas-diary',
    modelUid: 'article',
    query: {
      select: ['_id', 'title', 'slug', 'tags', 'body', 'icon', 'summary'],
      tags: {
        in: [id],
      },
    },
  })

  return items
}

おわり

  • おわり。コピペするときも自分が何を書いたのかちゃんと理解しようね案件だった
  • 特定の1つの記事をとるのに、Newtのように絞り込む手法とGET /article/idとする手法があると思うが、どちらが良いか甲乙つけがたいなと思う。
    • バックエンドで0件確認して404にしたとて、フロントでも404だから………という処理が入るので、『SQLでとれたデータをそのまま返す』の方がバックエンド・フロントエンド統一した流れで見るとシンプルで良いかもしれない。
  • ちなみにslugって何?についてはMDNでは通常は URL の最後にある Web アドレスの固有の識別部分と説明されている
    • なら、ID同じでも良くない??という気持ちもあるが、確かに『DB上で管理するためのID』と『スコープ内で固有の人間が読める識別子』は分けておいた方がシステム的に良い気がする。納得した。

はてなブログからmicroCMSにデータ移行する方法

  • 既存CMSからmicroCMSに移行できるのか気になった
  • 個人でCMSを持ってないので、はてなブログをmicroCMSにデータ移行をやってみた
  • データ移行だけ試してみるだけなのでこのブログは引き続きはてなブログで運営する
  • 題材のブログは読書記録ブログの方

※諸注意

  • ブログとCMSは違うが、CMSでブログ運用できるので題材にした
  • Pythonは趣味レベルなので、あまり綺麗ではない
  • microCMS, Next.jsについての説明はしない

はてなブログからデータをexport

microCMSにデータをインポートする

悩みポイント

  • exportした本文がHTML形式の場合、リッチエディタにいれてしまうと、HTMLタグがそのまま出力されてしまう
  • microCMSデフォルトのデータであるpublishAtがimport日時になってしまう
    • publishAtはUIからは変更ができるが、インポートは不可
  • 近い悩みを抱える人を発見 WordPressからmicroCMSに記事を移行した時の覚え書き

解決方法の模索

  • 先人から『importデータを別枠で隔離した方がわかりやすくなる』とアドバイスをもらったので、カスタムスキーマにimportデータを隔離し、『importデータがあれば優先して表示、なければ新規データを読み込む』とした。
  • カスタムスキーマCSVでimportできないが、 先の記事 と同じくPOSTならデータを入れることができるので、複数回POSTを叩くことでデータの移行を実現する

CSVの追加作業

POSTの実行

  • POSTの実行の前にmicroCMSのAPI keyの権限をPOST許容に変更
    • 移行作業が終わったら不可に戻しておく
  • POSTのスクリプトはコチラ
    • 1回実行ごとに5秒sleep
      • 制限はないようだが、気持ち的に
    • 'fieldId' : 'importData',は必須
    • publishDateはmicroCMSはISO formatに変換。何もつけないとUTCになってしまうため、+09:00も追加。
    • 投稿の古い順からPOSTする
      • こうしておくとGETするときにpublishedAtでソートするだけで投稿順に取得できる

UI構築

【メモ】App RouterでmicroCMS構築

  • 公式の構築記事からの主な変更点は下記
  • Next.jsの公式を参照するときは、左上は青のApp Routerになっていることを確認
  • キャッシュが消えない時は.nextcacheを消してdev環境を再起動するといける

悩み

  • 新規記事の作成でもimportデータの欄が表示されてしまう
  • importデータと共存のために、本来ならrequiredにしたい本文をrequiredにできていない

おわり

  • リッチエディタ(HTML形式でexportされる)をリッチエディタに取り込めないのは移行を勧めるにあたってひっかかるポイントになってしまいそうなので手段を見つけたい
  • App Routerは今回はじめてじっくり触ったが、しっくりくる使い心地で良い。getStaticPropsをコンポーネント内でただのデータfetchのように書ける点がとても好き
  • App Routerの影響なのか、今回開発するときにdev環境のAPIのキャッシュがすごい強く、CMSのデータを更新してリロードしてもデータが変わらない事象に遭遇した。.nextcacheを消して再起動で解消したが、もっと良い方法がある気がする。
  • 【リマインド】microCMSのAPIキーのPOSTの権限をOFFを忘れずに!

VPSのSFTP接続設定をして、CalckeyをWindowsでPWAした時のアプリアイコンを変更したかった

【追記】

packagesの中を自分で置き換えるのは推奨されないようで、customディレクトリ内にファイルを置いてpnpm run gulpするのが推奨のやり方のようです。

が、Calckeyの最新のstableバージョンである13.1.4.1 ではassets配下のディレクトリの中身のコピーに対応しておらず、したがってhttps://yourinstance.tld/static-assets/icons/filename.extと1階層深堀したところにあるiconsファイルは置換ができません。

developでは当該箇所は修正されているので、新しいバージョンはstableになったらまた挑戦してみようかなと思います。

【追記ココマデ】

TLにて『AndroidでMisskeyのアイコンがfaviconにならないの……』という話を見てわたしも~~と思って参戦してみた。

iOSではfaviconがアイコンになりますが、Android/WindowsのPWAでは仕様が違うようです。

やること

  • 公式にやり方が書いてある
  • manifest.jsonに書いてあるファイルを置き換えに行けばOK。
  • manifest.jsonを確認すると/static-assets/icons/192.png/static-assets/icons/512.pngと置き換えればよさそう
  • 私のサーバーはSSH接続しかできないので、SFTP接続できるようにして、ファイルを配置できるようにしていく

SFTP接続をできるようにする

前提

手順

メモ

  • 最初FFFTPで試行錯誤してたのだが、FFFTPだとSSH鍵認証できないのでダメっぽい

サーバー内のファイルを置き換えて反映!

  • ……できればよかったのですが、calckey 13.1.4.1では現状推奨される手段では置換ができません。developではそれなりに前に修正されているので、次のバージョンでは反映されると思います。
  • その時にきっと動くようになる手順を打消しつつおいておきます。

手順

  • /home/[calckey実行ユーザー名前]/calckey/custom/icons192.png512.pngを配置する
  • calckeyディレクトリでpnpm run gulpを叩く
  • キャッシュクリア or https://[鯖のドメイン]/static-assets/icons/192.pnghttps://[鯖のドメイン]/static-assets/icons/512.pngにアクセスして何度か更新して画像を反映!!
  • Windowsの場合、右の『・・・』からアプリを選択してインストール!
  • 🎉!!!

  • 下の画像の右上、インストールされているアプリの文言の左が元々Calckeyロゴにしかならなかった感じ。

おわり

  • SFTP接続できるようにするのってどうやるの??で3時間くらい使った気がする
    • インフラエンジニア偉大すぎる
  • 画面のことだからclientディレクトリだろうと思ったら違ったのでぴえんってなった
    • でも確かに『バックエンドに置くもの(クライアント側の持ち物ではない)』と言われたらそうかも。
  • サーバー再起動、VPSかりてから初だったのでちょっとドキドキした
    • サーバー再起動したらKAGOYAのサイトが落ちててちょっと焦った。すぐなおったようで良かった。
  • 実はWindowsでPWAできるのをこの時はじめて知った。スマホだけだと思ってた。

Sassを利用しているNext.jsをStorybook6から7に移行する

  • Storybookのv7がでてたのでアップデートしてみる。2023/5/18時点の7.0.12にアプデする。
  • リポジトリ

道のり

6 -> 7に移行し、起動できるようになるまで

  • 公式の Migration guide for Storybook 7.0 のupgradeコマンドをたたくも、何故かupgradeされない
  • 仕方ないのでpackage.jsonを自分で編集
  • Storybookをバージョン6から7への移行 の記事を参考2点実施
    • storybookコマンドを変更
    • @storybook/nextjsをinstall
    • main.jsを編集
    • storyを編集
  • ここでstorybook起動するも動かなかったので、再度upgradeコマンドを叩いく。質問が変わり、起動はするようになった。

起動後エラーの修正

  • 下記のエラーが出た。
ModuleBuildError: Module build failed (from ./node_modules/@storybook/nextjs/node_modules/sass-loader/dist/cjs.js):
SassError: expected "{".
  ╷
2 │       import API from "!../../../node_modules/style-loader/dist/runtime/injectStylesIntoStyleTag.js";
  │                                                                                                     ^
  ╵
  src\components\frame\frame.module.scss 2:101  root stylesheet
  • 2022/9に同じ悩みを抱えた人がいた
  • 完成形は下記。v6の頃よりかなりシンプルになっていい感じ。
const path = require('path')

module.exports = {
  stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
  addons: [
    '@storybook/addon-links',
    '@storybook/addon-essentials',
    '@storybook/addon-interactions',
    {
      name: '@storybook/addon-styling',
      options: {},
    },
  ],
  framework: {
    name: '@storybook/nextjs',
    options: {},
  },
  features: {
    buildStoriesJson: true,
  },
  docs: {
    autodocs: true,
  },
}

おわり

  • 今までstorybook起因のvulnerabilitiesが結構あってちょっと気になってたのだが、v7になって一気に解消されてvulnerabilitiesがゼロになった。嬉しい。

Next.jsのAPIでnodemailerを使ってメールを送る

  • メール通知を作ってくれ。Next.jsのAPI Routesで。と言われて『できるんだ……』ってなったのでやってみるなど。
  • Routing: API Routes | Next.js

ざっくり仕様

  • メールはGmailを使う
  • ライブラリはnodemailerというやつを使う
  • FWはもちろんNext.js

結果

import type { NextApiRequest, NextApiResponse } from 'next'
import { createTransport } from 'nodemailer'

const transporter = createTransport({
  service: process.env.MAIL_SERVICE,
  secure: true,
  auth: {
    user: process.env.MAIL_AUTH_USER,
    pass: process.env.MAIL_AUTH_PASS,
  },
})

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const method = req.method
  switch (method) {
    case 'POST': {
      const { title, body } = req.body
      try {
        await transporter.sendMail({
          from: process.env.MAIL_FORM_USER,
          to: process.env.MAIL_TO_DEFAULT,
          subject: title,
          text: body,
        })
        res.status(200).end()
      } catch (error) {
        res.status(500).end()
      }
      res.status(500).end()
      break
    }
    default: {
      res.status(404).end()
    }
  }
}
  • src/pages/index.tsx
import Head from 'next/head'
import styles from '@/styles/Home.module.scss'
import { useState } from 'react'

export default function Home() {
  const [text, setText] = useState('')
  const [result, setResult] = useState<string>()

  const onClick = () => {
    setResult('メールを送ってるよ……。')
    fetch('/api/mail', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        title: 'テストメール',
        body: text,
      }),
    })
      .then((res) => {
        setResult('メールを送ったよ')
      })
      .catch((err) => {
        setResult('メールを送るのに失敗したよ……。')
      })
  }

  const onChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
    setText(e.target.value)
  }

  return (
    <>
      <Head>
        <title>Mail Test App</title>
        <meta name='description' content='Generated by create next app' />
        <meta name='viewport' content='width=device-width, initial-scale=1' />
        <link rel='icon' href='/favicon.ico' />
      </Head>
      <main>
        <div className={`${styles.send}`}>
          {result === undefined ? (
            <>
              <textarea onChange={(e) => onChange(e)} />
              <button onClick={() => onClick()}>めーるおくるよー</button>
            </>
          ) : (
            <p>{result}</p>
          )}
        </div>
      </main>
    </>
  )
}

ひっかかったこと

Googleのログインにひっかかる

メモ

  • ts環境だとnodemailerがエラーになるのは @types/nodemailerをinstallしておけばOK
  • 同一オリジンポリシーがあるので、同じリポジトリのサイトから呼ぶだけならプラスで何かやる必要はなさそう

参考にしたサイト

おわり

  • 意外と簡単にできる!そして意外とメール送る処理って実装したことない。
  • メール送りまくったのでGoogleに不審に思われてないかちょっと不安
  • デプロイして動作確認もやったが、BASIC認証つけたとはいえインターネットに残しておくのは不安なので削除した。みんなもきをつけよう。
  • おわり。

個人サイトをポコポコ作るには

  • 最近個人サイトをポコポコ生やしてるので書く。
    • 更新間に合ってないが作った主な個人サイトは https://manasas.dev に適宜まとめている)。

前提

  • ギャラリー・ブログ・jsのみで作れる簡易なツールのサイトを作る想定
  • アプリケーション的なサイトも近い技術で作れるとは思う

使うもの

Github

  • 基本はmainブランチ1本構成
  • 大きめのもの作る時はブランチ切ることもある

ホスティングサービス

  • 基本Vercelを使ってるが、FirebaseやCloudflareも利用している

Firebase

  • ポートフォリオで利用
    • 制作時、Firestoreに激ハマりしていた名残
  • Github Actionsを組んで利用
  • ホスティングのみに利用するにはtoo muchな感じがあるので新規ではあまり採用していない

Vercel

Cloudflare

  • mana's toolsで利用
    • Calckeyの鯖立てした時に『Coudflareはhostingもできる』を知ったのがキッカケ
  • これもGithubリポジトリつなぐだけ。普通にホスティングする分にはVercelと大きな違いはないかも。

ホスティングサービス、どれを選ぶ?

やり方

  1. 何らかのフロントエンドフレームワークを使ってサイトを作る
    • Next.js, Astro, Svleteらへんが今の推し
  2. Githubに上げる
  3. ホスティングサービスでポチポチでリポジトリつないでホスティング
  4. サブドメインを作成。ホスティングサービスでポチポチで独自ドメインに変更。ドメイン変更が反映されるまでちょっとまつ。
  5. 完成🎉

おわり

  • ねっ、簡単でしょ?
  • 野良のHTML + CSS直打ち民を増やしたい気持ちがある。
  • サーバー借りるのは勇気がいるし、このへんのホスティングサービスならタダから小さくはじめられるよ!と、思ったのだが、Githubとフロントエンドフレームワークはそれなりに大きな壁になってしまうだろうか……
    • コンポーネント使わなければAstro/Svelteは個人サイト作るのに手軽だと思うのでちょいちょい言語化していきたい