# MH4GF / Hirotaka Miyagi / 宮城広隆 > Building [Liam ERD](https://liambx.com), Software Engineer at ROUTE06, inc. > Tokyo, Japan ## What I can do Web Frontend (React, Next.js) / Backend (Ruby on Rails, Go) / GraphQL / Cloud (AWS, Terraform) ## Work Experience ### [ROUTE06, Inc.](https://route06.co.jp/) - Current - 2022 - Software Engineer, Tech Lead - React, GraphQL, Rails, AWS ### [Bit Journey, Inc.](https://bitjourney.com/) - 2022 - 2021 - Software Engineer(Freelance) - React, GraphQL, Rails ### [DXER, Inc.](https://dxer.co.jp/) - 2022 - Software Engineer(Freelance) - React, Go, GraphQL ### [ResortWorx, Inc.](https://resortworx.jp/) - 2021 - 2020 - Software Engineer(Freelance) - Vue, Rails, AWS ### [Timee, Inc.](https://timee.co.jp/) - 2021 - 2018 - Backend Engineer - Rails, AWS ## Find me on - [X](https://x.com/mh4gf) @MH4GF - [GitHub](https://github.com/MH4GF) @MH4GF - [Zenn](https://zenn.dev/mh4gf) @mh4gf - [sizu.me](https://sizu.me/mh4gf) @mh4gf ## About ### 好む振る舞い この資料は、宮城広隆が「自身が働く上で良しとする振る舞い」を言語化しまとめています。 他の方に強制するものではなく、宮城と働くイメージをしやすくする用途を想定しています。 ### Soft Skills ### プロであれ - エンジニアである前に社会人。約束を守る・時間を守る・気持ちよく仕事をする - 残業をしないしさせない。健康に、サステナブルに成果を出せる方法を考える - プロダクトを良くすることにベストを尽くす - フルサイクルでありたい。「自分の仕事じゃないから」とスルーせず、プログラミング以外も可能な限りなんでもやる - [Miletos エンジニアマニフェスト](https://www.notion.so/Miletos-1ff3198c35ff4907a38dabfecb1e3d41) ### リモートワーク - 情報が然るべき方法で「公開・整理・配信」されている状態を目指す ref: [社内向け「透明ガイド」を公開します| sonopy@Ubie Discovery](https://note.com/sonopy/n/na5cd53e7c204) - フロー情報として作業は GitHub Issue に常に書き連ねる ref: [Working Out Loud](https://blog.studysapuri.jp/entry/2018/11/14/working-out-loud) - ストック情報として知見や共有事項はドキュメント or Custom Linter に昇華する - コンテキストを追えるよう可能な限り参照を張る。「このタスクがなぜ必要になったのかわからない」という状況を作らない - 基本非同期コミュニケーションとし、タイムゾーンを気にせず進められるようにする - チーム API を定義し、自身のチーム・他チームが協調しやすい状況をつくる: [30 分で分かった気になるチームトポロジー | Ryuzee.com](https://slide.meguro.ryuzee.com/slides/109) - 地理的距離が離れているからこその雑談の時間を大事にする ### ミーティング - [Google 流、会議をより効率的にする秘訣とルール](https://note.com/shuheikoyama/n/n69c2c3e123fc) - 可能な限り短時間にする - 情報共有ではなく意思決定の場とする - 24 時間前までに資料を用意し共有、認識合わせが済んだらすぐ本題に入る - タイムマネジメント・議事録・ネクストアクションの徹底 - 顔を出す。所作や表情によって相手に与える情報量を増やす ### コミュニケーション - 結論から話す - 文章は要点だけに絞り平易な言葉で - 前提認識のすり合わせと、事実・主観・主張を明確にする - [配慮はするが、遠慮はしない](https://www.nikkansports.com/baseball/news/201801130000090.html) ### Product Development ### Issue driven - 理想状態をまず定義する。制約やリスクと比較し最善を尽くす - お客様への価値貢献を最優先する ### チーム開発 - HRT(謙虚・尊敬・信頼)を持って働く - 検査・適応・透明性を大事にする ref: [The Scrum Guide](https://scrumguides.org/docs/scrumguide/v1/Scrum-Guide-JA.pdf) - 人ではなく事に向かう 邪推をしない - Fail Fast 早期に失敗できる状態を作る - 集中状態にあるタスクがない場合、PR などのレビューを最優先タスクとする ref: [コードレビューのスピード](http://shuuji3.xyz/eng-practices/review/reviewer/speed.html) - 「引き継ぎ」をなくすように働く 怪我や病気で自身が突然離脱しても業務が回る状態に保つ - 適切なドキュメンテーション、作業や残タスクの可視化 - 声を上げた人のフォロワーになる、リアクションをする ref: [チームで仕事をするなら、リアクションし続けよ|森 一貴(Mori Kazuki)](https://note.com/dutoit6/n/ned66041f43ff) - この資料に記載していることをは自身のポリシーであり、違う意見があることは当たり前である ### Ops - You build it, You run it. 自身が作ったものに対して責任を持ち自身で運用する ref: [A Conversation with Werner Vogels](https://queue.acm.org/detail.cfm?id=1142065) - ランタイムやライブラリのアップデートが苦しくならないように Renovate や Dependabot に投資する - トイルは山積みになり続けることが多いが、レバレッジが効く順序を検討し技術で解決していく ### Engineering ### 開発 - 全ての開発は理想のインターフェースから始める - ロジックはテストを書く、書けないなら書けるように責務を分割する - バグの恒久対応はテストで表現する - コーディングスタイル(インデントなど)のレビューは一切せず、linter や formatter に従う ref: [Why Prettier? · Prettier](https://prettier.io/docs/en/why-prettier) - 人間のレビューを減らすために linter を積極的に整備する - ビッグバンリリースはせずフィーチャートグルを利用する - OSS やコミュニティに積極的に還元する ### 意思決定・技術選定 - ADR(Architecture Decision Record)を積極的に書く - 理論的根拠に対する強固な共通の理解を作るため - できないこと(制約・リスク)を具体的に書き、許容できるかを判断できるようにするため - [チーム開発における技術選定の進め方 - ROUTE06 Tech Blog](https://tech.route06.co.jp/entry/2023/06/07/120217) - 今しなくて良い意思決定は可能な限り遅延させ、情報を集め最適な選択肢を選べるようにする ref: [リーン開発での『決定を遅らせる』がやっと理解できた話 - 無気力生活 (ノ ´ω `)ノ ~゜](https://gdgd-shinoyu.hatenablog.com/entry/2018/12/16/081728) - 社内フレームワークではなく主流な手段で解決し、キャッチアップと採用を容易にする - 主流な手段は世の中の情報量も多く、ChatGPT のような LLM を利用した問題解決も容易になる ### Git, Commit, Pull Request - [トランクベース開発](https://cloud.google.com/architecture/devops/devops-tech-trunk-based-development?hl=ja)や[GitHub Flow](https://gist.github.com/Gab-km/3705015)を採用し、1 日に複数回以上デプロイを行う - PR は誰がいつ見ても理解できるような粒度でコンテキストを記載する - 想定される質問に対して全て置き回答してからレビューを依頼する - commit は cherry-pick で価値を生む単位 - フィーチャーブランチに対する Force Push はコミットの整理のために活用するが、レビュー開始後は行わない - コミットメッセージの言語は社内の公用語やプロダクトの利用ユーザーの言語に合わせる - 日本人向けのプロダクトにも関わらず英語で書くことに価値は低いと考える ### SaaS - ビジネスのコアではない領域については、コードを書く前に SaaS での解決を検討する 車輪の再発明はしない - 価値が説明できるのであれば有料でも導入できるよう交渉する ### 取扱説明書 ref: https://github.com/konifar/about-me/blob/main/readme/取扱説明書.md ### 働く時間と場所 - 本業: 平日10:00 ~ 19:00 くらい - 副業: 平日 8:00 ~ 10:00, 休日どちらかの日中 - 予定は Google カレンダーに入れています - 「ブロック」とついた予定は通院などで本当にブロックしていて、返答ができないと思います - 「調整可」とついた予定は MTG を入れても大丈夫です - リビングで働いているため、後ろで家族が通ったり声が入ることもあるかも - 声がノイズに感じた場合はミュートするので遠慮なく教えてください ### コミュニケーション方法 - オープンなコミュニケーションが好き - テキストコミュニケーションを好みます(ルーツとしては古き良きインターネットの文章が好き) - 口頭で話した内容もテキストでログを残しがち - 人ではなく事に向かいたいと考えています 誰かを攻撃したり、揚げ足を取ることはありません - 自身の直感は結構間違っているやんけという感覚があります そのため思考をテキストに書き出したり、チームメンバーに壁打ちさせてもらいながら修正していくことを好みます ### 仕事への根源的なモチベーション - 「課題解決」と「学習とその転用」です 自身ができることを増やしていくことに達成感を感じます - そのためエンジニアという仕事は自身にとって天職だと感じていますが、長い人生色々な仕事を経験してみたいとも思っています ### パフォーマンスややる気が落ちる時 - 並列でやることが増えすぎて、すべてが中途半端になってしまった時 ### リフレッシュ方法 - 散歩やジョギング - 趣味の盆栽のようなプログラムをいじって心を落ち着かせる - ペットのうさぎを愛でる ### 一緒に働く方への要望 - 自分へのフィードバックはなんでも歓迎します!些細なことでも教えてもらえると嬉しいです。 - 間違ったことを言うことがあるので、そこに気づいた時は指摘してもらえるとありがたいです。 - なんでもボールを持ちすぎるところがあるので、少しチームに頼りすぎくらいのスタンスでいます。協力してもらえると嬉しいです。 ### Hirotaka Miyagi | 宮城 広隆 ### 自己 PR - フロントエンド・バックエンド・インフラまで一気通貫で開発を進められることを強みとしています。 - キャリアの大半がスタートアップでの活動であり、会社の成長のためにフルサイクルになんでも取り組んできました。今後も継続していくつもりです。 - チームで働き、一人だけでは得られない成果を生むことを大事にしています。丁寧なドキュメンテーションや仕組み化でトラック係数を上げつつ、チームビルディングやコミュニケーションを進めていきたいです。 - どちらかというと仕事が好きな性格で、仕事へのモチベーションは新しい知識の学習と転用です。業界の最新動向・枯れて安定した技術それぞれ取り入れつつ、自身や会社としての技術投資を進めていきたいです。技術的なアウトプットや OSS 活動も継続して取り組んでいきたいです。 [好む振る舞い](/behavior), [キャリアの指向性](/thinking-in-career)も合わせてご覧ください。 ### 過去の使用技術 - Ruby, Rails, Rspec, FactoryBot, RuboCop, graphql-ruby - TypeScript, React, Next.js, Jest, Testing Library, MSW, ESLint, Prettier, Tailwind CSS, Webpack, Vite, Vitest, Storybook, ProseMirror, reg-suit, urql, GraphQL Code Generator, Vue.js, Nuxt.js - Go, Gorm, gplgen - Terraform, AWS Fargate, ALB, RDS, S3, CloudFront, CloudWatch, Route53, VPC, Amplify, Cognito - GitHub Actions, CircleCI, Sentry, Datadog, Redash, Slack, Figma, draw.io, VSCode, Notion, Google Workspace, Slack, Zoom, Google Meet ### RESUME ### 株式会社タイミー(2022/4~2022/10) 業務委託として開発プラットフォームチームに所属し、Rails/Next.js での GraphQL の導入・初期設計・各種オンボーディングを担当しました。 [Rails+Next.js で GraphQL を導入する時に考えたこと - Timee Product Team Blog](https://tech.timee.co.jp/entry/2022/09/29/110000) ### 株式会社ビットジャーニー(2021/9〜現在) 業務委託として [https://kibe.la/](https://kibe.la/) の開発チームに参画し、フロントエンドをメインにフルサイクルに開発を進行しました。 #### ProseMirror を利用したリッチテキストエディタの開発 - ProseMirror の木構造 ⇄DOM⇄markdown text の相互変換により、リッチテキストエディタで記入したテキストを即座に markdown に変換させる - ユーザーフレンドリーなテーブル記法の入力 UI の実装 - [https://blog.kibe.la/entry/rich-text-editor-beta-table](https://blog.kibe.la/entry/rich-text-editor-beta-table) - 列・行・セルの選択や追加、削除などを直感的に行えるように - DOM を効率的にレンダリングするためのキャッシュアルゴリズムの実装 #### フルサイクルな機能開発 - DB 設計・Rails の MVC 設計・GraphQL インターフェース・React コンポーネント・Redash/GA4 での分析までを一貫して担当 - GitHub Actions の導入と開発プロセス中のトイルの自動化 #### リリース 7 年が経過する Rails + React アプリケーションのモダン化 - Sass の@import 非推奨による sassc-rails から cssbundling-rails への移行 - webpack.config.js の最適化 - Sass → CSS in JS への移行のための技術選定 - jQuery からの安全な脱却ロードマップの策定・進行(進行中)、ES5 を上げていくためのリスク評価、古い polyfill の削除等 --- ### 株式会社リゾートワークス(2020/12〜現在) ワーケーションを通じて働く人の“創造性“を刺激する福利厚生サブスクリプションサービス  [ResortWorx](https://resortworx.jp/)  で、インフラ/バックエンドエンジニアを担当しました。 #### インフラ構築の 0→1 に伴う技術選定と構築 プロダクトローンチ初期に求められる最低限の構成で、ランニングコストの抑制とエンジニアがキャッチアップできるようなドキュメンテーションを心がけました。 - Nuxt.js + Rails API の利用者向け Web アプリケーションと、Rails SSR による管理画面のホスティング - Route53 → ALB → ECS / RDS の一般的な Rails のホスティング - GitHub Actions による CD 環境、 SSM を利用した ssh(rails c 等)実行環境 - CloudWatch によるロギングとアラート、AWS Chatbot による Slack 通知 - S3 + CloudFront による SPA のホスティング、GitHub Actions による CD, workflow dispatch を利用したブランチ指定のデプロイ - のちに Nuxt の SSR も必要になったため Vercel へ移行 - 上記の AWS リソースを terraform で IaC 化、draw.io + VSCode によるアーキテクチャ図の記載、その他オペレーションをドキュメント化 #### チケットベースでのチーム開発 - Rails, Nuxt での機能追加 - CloudWatch Logs Insights で可視化・検索ができるよう Rails のログの JSON 化 #### 企画 LP サイトの構築 - Nuxt.js(composition API), Tailwind CSS, Vercel による 0→1 を一人で担当 - デザイナーと Figma 上で会話しながら背中合わせで進行 - Lighthouse の点数をほぼ 100 点に --- ### 株式会社タイミー(2018/07〜2021/09) サービスローンチの 1 ヶ月前にジョイン、初期は 1 人サーバーサイドとして Rails をメインに、プロダクト/会社の成長とともに幅広い業務を担当しました。 #### PjM/PdM/バックエンドエンジニア (2020/08〜2021/04) ##### 当時の課題 会社の成長に伴いガバナンスを強めていく必要性があり、プロダクト「タイミー」における社内オペレーション改善や、コーポレートエンジニアリングの領域で開発を進行しました。 ##### メンバー構成 プロジェクトの開発メンバーとしては 2 名で、関係部署にヒアリングしながら遊軍のように活動しました。 - 自身: プロジェクトマネジメント/ 要件定義/ Rails や Go での実装 - チームメンバー: 元 iOS エンジニア(Rails はビギナー)/ 要件定義/ Rails や Go での実装 - 関わる部署: 経理財務、 CS ##### やったこと ヒアリングや SaaS の選定、スケジューリングや開発着手、リリースまで一気通貫で担当しました。(NDA レベルの業務が多く少しぼかしています) - CRM ツール HubSpot の API 連携 - 経理・財務業務 SaaS との API 連携 - 内部統制に伴う各種機能開発の進行(職務権限規定に沿った権限管理・与信・反社チェックなど) - 社内業務のオペレーション改善 - Mac にインストールし deamon で常駐起動するソフトウェアを Go で開発 - 主に非機能要件であるリリースパイプラインの設計を担当 死活監視、バイナリのセルフアップデート機構、リトライ制御など - チームメンバーに Rails を指導 コードレビューやキャッチアップのための順序立てたタスク振りなど --- #### 新規事業 PjM/バックエンドエンジニア/フロントエンドエンジニア (2021/04〜2021/08) ##### メンバー構成 - 開発チーム: iOS 担当 1 名、 デザイナー 1 名、 Web フロントエンド&バックエンド API2 名 - 関わった部署: 経営, Sales, CS, 経理 ##### 役割 - プロジェクトマネジメント・技術選定・設計・実装(Rails, Vue.js)を担当 - チームのエンジニアは それぞれ iOS と Rails に強いメンバーとのプロジェクトだったため、それぞれの強みを活かしてもらいつつ取りこぼしがないよう拾う立ち回りに努めた - Biz サイドとの折衝、サポートや経理とのオペレーション構築など - エンジニアリングとしてはコアドメインのモデリングや Stripe を利用した決済周りの設計実装を主に担当した ##### 技術選定・初期設計 - Vue.js/vue-router を使った SPA の実装 - 一般的な Rails の環境構築(annotate/rails-erd/bullet/rspec/rubocop) - 監視/ロギング/CI/CD の導入(Datadog, Sentry, lograge を利用したログの JSON 化、 CircleCI) - 商品情報登録・在庫管理・注文・決済履歴などのドメインの境界を意識したモデリング - Rails プロジェクトでの行動指針決め - 原則テストコードを書く、書けないなら書けるよう責務を分割する - トランザクションやロックなど SQL を適切に書く - シンプル・ミニマルに ##### 決済機能の要件定義・技術選定・Stripe API の実装/運用 [新規事業の決済機能として Stripe を導入する上で考えたこと全て - Timee Product Team Blog](https://tech.timee.co.jp/entry/2020/12/10/131108) ##### スキーマ駆動開発の導入・運用 [Rails + RSpec + OpenAPI3 + Committee でスキーマ駆動開発を運用する Tips - Timee Product Team Blog](https://tech.timee.co.jp/entry/2020/07/05/150312) --- #### SRE (2021/01〜2021/04) ##### 当時の課題 プロダクトの成長は進みテレビ CM を打つことになり、現状のスケールしない EC2 によるインフラでは耐えられないだろう、という課題がありました。ただひたすらこなしていた開発業務についてもメンバーが増えスクラムを回せるようになり、組織化が進んで自身の属人性は剥がれてきていました。運よくシニアレベルの SRE の方が採用できたこともあり、その方と主に SRE チームを立ち上げ AWS のアーキテクチャから作り変えるプロジェクトを始め、キャッチアップしつつインフラの移行を進めていきました。 ##### やったこと(公開情報) [Production で Rails6 マルチ DB 対応を小さく始める](https://speakerdeck.com/mh4gf/productionderails6marutidbdui-ying-woxiao-sakushi-meru) [GitHub - MH4GF/ecr-lifecycle: Delete more than specified number of images, and protect if dependent on ECS task](https://github.com/MH4GF/ecr-lifecycle) [Redash を Fargate, Datadog, Terraform で構築/運用する - Timee Product Team Blog](https://tech.timee.co.jp/entry/2020/04/20/175821) --- #### バックエンドエンジニア (2018/07〜2020/01) サービスローンチの 1 ヶ月前にジョイン、初期は 1 人サーバーサイドとして保守運用を担当 Rails をメインに、プロダクト/会社の成長とともに幅広い業務を担当しました。 ##### メンバー構成 - リリース前: iOS…5 人、 サーバーサイド 4 人 - リリース後〜半年程度: iOS…2 人、 サーバーサイド 1 人、 技術顧問数人 - 徐々にメンバーは増えていきました ##### 役割 スタートアップのシード期なので、通常の機能開発や運用はもちろん、経営・CS・経理・営業チームからの要望対応も全てバックログに載せただひたすらにこなしていました。 ##### やったこと - 0→1 の開発・0→1 後のサービスの保守運用・負債解消 - RSpec, Rubocop, OpenAPI3 の導入 - Ruby/Rails のバージョンアップ業 - API のバージョニング、Serializer のスキーマ分割 - サービス固有の強いドメインを持つ機能の設計、実装 ### 業務外活動 #### オープンソース貢献 日常的に行なっています。いくつかリンクを紹介します。 - ProseMirror コミュニティでの活動 - Pull Requests - add serializer option to set custom regexp for escaping [https://github.com/ProseMirror/prosemirror-markdown/pull/68](https://github.com/ProseMirror/prosemirror-markdown/pull/68) - declare Builders type for builders() \*\*\*\*[https://github.com/ProseMirror/prosemirror-test-builder/pull/9](https://github.com/ProseMirror/prosemirror-test-builder/pull/9) - Fix types for cellSelection \*\*\*\*[https://github.com/ProseMirror/prosemirror-tables/pull/160](https://github.com/ProseMirror/prosemirror-tables/pull/160) - フォーラムでのバグ報告や知見共有 - ガイドを和訳し公開: [https://zenn.dev/mh4gf/articles/d25ef1ff30b5a6](https://zenn.dev/mh4gf/articles/d25ef1ff30b5a6) - feature(plugin): add alternative implementation for mermaid.js reporter plugin \*\*\*\*[https://github.com/sverweij/dependency-cruiser/pull/599](https://github.com/sverweij/dependency-cruiser/pull/599) - dependency-cruiser という JS/TS プロジェクトのディレクトリ構造のビジュアライズツールで、mermaid.js 構文での出力を可能にする実装を追加 - feat: add generatorLibrary options and allow faker to select \*\*\*\*[https://github.com/ardeois/graphql-codegen-typescript-mock-data/pull/93](https://github.com/ardeois/graphql-codegen-typescript-mock-data/pull/93) - [ExtractType]: add `has` prefix to default values for config \*\*\*\*[https://github.com/DmitryTsepelev/rubocop-graphql/pull/89](https://github.com/DmitryTsepelev/rubocop-graphql/pull/89) #### 登壇 社内外の勉強会・カンファレンスで発表を行っています。 - [sassc-rails を利用している我々は、Sass の@import の非推奨化をどのように乗り越えていくか](https://speakerdeck.com/mh4gf/sassc-railswoli-yong-siteiruwo-ha-sassno-at-importnofei-tui-jiang-hua-wodonoyounicheng-riyue-eteikuka) - [Production で Rails6 マルチ DB 対応を小さく始める](https://speakerdeck.com/mh4gf/productionderails6marutidbdui-ying-woxiao-sakushi-meru) ### 学歴 - 2019/03 武蔵大学社会学部 メディア社会学科 中退 ### スキル - Ruby / Rails ... 6 年 - TypeScript ... 2 年 - React ... 2 年 - Terraform / AWS ... 2 年 - Go ... 2 年 ### 免許・資格 - 普通自動車免許 ## Articles ### このサイトで llms-full.txt を提供し始めた このサイト([mh4gf.dev](https://mh4gf.dev/))で **llms-full.txt** を新たに提供し始めました。 https://mh4gf.dev/llms-full.txt から直接アクセスできます。 mh4gf.dev は私の個人サイトで、自己紹介や職務経歴書、ブログ記事などをまとめています。 llms-full.txt ではそれらを **一枚のマークダウンテキスト** として出力しています。 本記事では、llms-full.txt とは何か・なぜ llms-full.txt を提供し始めたのか、そして Next.js を使った実装方法について紹介します。 ### llms.txt / llms-full.txt とは何か https://llmstxt.org/ **llms.txt** とは、サイト上の重要なドキュメントや概要をまとめておき、生成 AI(LLM)がそれを読み込むことでサイト全体の情報を把握しやすくするためのファイル形式です。 提案者は [Answer.AI](https://www.answer.ai/) の共同創業者である Jeremy Howard 氏であり、Anthropic や Cursor といった LLM 系ツール、また Zapier などもサポートしています。[llmstxt.site](https://llmstxt.site/) には、サポートしているサイトや事例が一覧で掲載されています。 似たような目的のファイルとして sitemap.xml があります。こちらは検索エンジン向けに「すべてのページの一覧」を提供するファイルですが、llms.txt は **LLM が理解しやすいフォーマット**(マークダウン形式)で書かれ、サイトマップより **自由度が高い** のが特徴です。 たとえば、外部サイトへのリンクを含めたり、ページの目次や概要のような人間の文章を追加しても問題ありません。 提案には llms.txt と llms-full.txt という二つの目的のファイルが含まれており、これらの違いは以下です。 - **llms.txt** … 目次や概要のように、サイト全体を俯瞰できるエッセンスのみをコンパクトにまとめたもの。小さなトークン数でサイトの全体像を LLM に把握させ、ユーザーの質問に最適なドキュメントページの URL をクイックに返却するなどの用途が想定されています。 - **llms-full.txt** … 全ドキュメントや全記事を **ほぼ全文** 入れ込んだ大容量ファイル。すべての情報をコンテキストに入れ、ユーザーの質問に対して参照先の URL を返すのではなく回答を直接返す用途が想定されています。 このサイトの場合、全文を入れ込んでもそこまで大量なトークン数にならず、また「すべてをまとめて一気に LLM に渡したい」というニーズが大きかったので、より大容量の **llms-full.txt** を作っています。 --- ### このサイトで提供し始めた理由は? ### 大規模コンテキストウィンドウ時代の活用 OpenAI の GPT-4o は **128,000 トークン**、o1 は **200,000 トークン** までコンテキストに含められると [ドキュメントで説明](https://platform.openai.com/docs/models#gpt-4o) されています。 私の用意した llms-full.txt は、[OpenAI Tokenizer](https://platform.openai.com/tokenizer) で計測したところ **57,385 トークン / 121,192 文字** でした。(2025/01/04 時点) 丸ごと読み込んでも GPT-4o なら余裕があり、コピー&ペーストし LLM に全文を食わせても余裕を持って会話できます。 ### 業務プロダクトでの活用 もともとは仕事で関わっているプロダクト開発でも **llms-full.txt** が役立つのではないか、という発想がありました。 私は Product Required Document やインセプションデッキなどの情報を LLM に投げ込み、コンテキストを理解してもらった上で開発方針やユーザーインタビュー設計など多くの場面で相談しています (もちろん LLM への情報漏洩に気をつけています)。しかし、 - 毎度必要な情報を手作業でコピペするのは面倒 - ChatGPT の GPTs や Dify などで RAG(Retrieval Augmented Generation)の仕組みを構築しても、情報の更新が大変 …という課題がありました。 そこで **「/llms-full.txt を常に自動生成しておき、必要に応じてまるっとコピペして会話する」** という運用は、かなりシンプルで手軽だと感じました。ユーザーマニュアルや周辺情報を LLM が解釈しやすい状態に保守しておき、常にマークダウン 1 枚にまとめられるようにしておく形です。 /llms-full.txt というパスでパブリックに公開せずとも、CI/CD でチーム向けに生成し手動で注入するコンテキストとして活用することは、本格的な検索システムを導入せずとも疑似的に RAG 的な運用ができ、プロダクト開発の効率化につながるのではないかと考えています。 ### パーソナルなサイトを LLM に読ませる価値 個人サイトであっても、以下のような用途があると考えています。 - レジュメのフィードバックやキャリア相談 - 自分風の記事を生成する補助 ... 自分が過去に書いたブログをすべて読ませて、文体やテーマを踏襲した記事下書きを作成してもらう llms.txt が広く流通すれば、LLM が Web サイトにアクセスする際に優先的に利用されるといった状況も考えられますが、現時点では誰かに活用してもらうというより、自身の情報を整理し自分自身のために活用できそうです。 --- ### 実装方法 続いては実装方法を紹介します。このサイトは Next.js で構築し、ブログ記事をマークダウンテキストで記載、[contentlayer](https://contentlayer.dev/) で読み込んで取得しページを生成しています。 今回は以下の要件があったため、Next.js の API Route を利用して実装しました。 - `/llms-full.txt` といった拡張子つきのパスで提供したい - Content-Type を `text/plain` でレスポンスを返却したい ### サンプルコード 以下は実際の簡易版サンプルです。 ```ts title="/app/llms-full.txt/route.ts" import { NextResponse } from "next/server"; import { allArticles } from "@/.contentlayer/generated"; import { externalArticles } from "../_features/articles/data"; import { compareDesc } from "../_utils"; import { me } from "../_constants"; // 情報を集めてきてマークダウン形式で出力 const content = ` # ${me.name} > ${me.description} ### Work Experience ${me.workExperiences .map((work) => { return ` ### [${work.company.name}](${work.company.url}) - ${work.period} - ${work.company.position.join("\n- ")} `; }) .join("\n")} ### Articles ${allArticles .sort((a, b) => compareDesc(a.publishedAt, b.publishedAt)) .map((doc) => `### ${doc.title}\n${doc.body.raw}\n`) .join("\n\n")} ### External Articles ${externalArticles.map((doc) => `- [${doc.title}](${doc.href})`).join("\n")} `; export function GET() { return new NextResponse(content, { headers: { "Content-Type": "text/plain; charset=utf-8", }, }); } ``` このように、文字列テンプレートの中で **自分のデータ**(自己紹介・経歴・記事など)をマークダウン形式で出力し、`text/plain` で返すだけです。contentlayer 経由で取得しているため、コンテンツが更新されたとしても自動的に反映することができます。 実際のコードは[こちら](https://github.com/MH4GF/mysite/blob/a781b2b1f7c0664194b35a1f65f7051f188bc995/app/llms-full.txt/route.ts)です。 --- ### まとめ - **llms.txt / llms-full.txt** とは、**LLM に対してサイト情報を提供するための新しい標準**として提案されているファイルフォーマット - 大規模なコンテキストウィンドウを持つモデルが使えるようになったため、簡易的な RAG としての利用が便利 - Next.js で提供する場合は API Route で実装する ### 2024年の振り返り こんにちは、宮城です。2024 年も振り返りを残します。年次の振り返りはかれこれ 5 回目となりました。 https://mh4gf.dev/articles/2023-summary https://mh4gf.dev/articles/2022-summary https://note.com/mh4gf/n/nf131e1c3bc7b https://note.com/mh4gf/n/n0831d457d2ee 2024 年はプロダクト立ち上げに注力した年でした。前年と比べ少しポエミィな内容が多めです。 結論としては、**ROUTE06 に入社しもうすぐ 2 年、楽しくやっています!** ### 0→1 の暗中模索と見えてきた光明 ROUTE06 での仕事についてです。昨年はプラットフォームエンジニアリングとして組織内での技術の標準化を目指し活動していましたが、2024 年の初めに立ち上がった新規事業チームへ異動となり、ローコードプラットフォーム領域のプロダクト開発を進めています。テックリードとして開発全般をリードしつつ、2024 年後半からは加えてプロダクトオーナーも拝任し、プロダクト戦略やマーケティングを担当しています。 リサーチによる課題・ニーズの特定や、PoC 実装での実現可能性の検証の日々。複数回ピボットも行い、産みの苦しみを味わった 1 年でした。 チームメンバーにはかなり救われました。不確実で答えがない状況が続く中、互いに支え合い、めげずに熱量高く取り組み続けられるのは今のチームだからこそです。 暗中模索の日々から少しずつ光が見え始め、近日中にはプロダクト第 1 弾を公開できる予定です。OSS にするので、まだ README も何もないですが https://github.com/liam-hq/liam で開発を進めています。 ### Product-Led Growth とマーケティング 今年は初めてプロダクトオーナーロールも担当することになりました。現フェーズでの注力事項は提供価値の確立とその仮説検証・初期ユーザー獲得のためのマーケティングです。 ソフトウェアエンジニアとして生きてきた中で、Product-Led Growth におけるマーケティングは自身にとって大きく考え方がアップデートさせられる経験でした。 提供価値が明確なプロダクトは、マーケティングメッセージが一貫しやすく、様々なマーケティング施策に展開しやすく、ユーザーが他者に紹介しやすいです。「良いものを作る」と「使ってもらう」は両輪であり、相互に影響し合うことを改めて理解しました。 書き出してみると当たり前なことだなあとも思いつつ、今までの自分は無意識のうちに避けていたり、他の方に任せてしまっていた領域でした。2025 年は使ってもらうためのアクションやコミュニケーションに本気で集中する年にしていきたいです。 DHH の [小さなチーム、大きな仕事](https://amzn.asia/d/4rpE7nw) にあった以下の記述もよかったです。 > **マーケティングは部門ではない** > あなたの会社にマーケティング部門はあるだろうか? もしないなら、いいことだ。もしあるのなら、彼らだけがマーケティングの責任を負うべきだとは思わないことだ。経理は部門だが、マーケティングはそうではない。マーケティングは、会社のみんなが行うものである。三六五日、二四時間いつでも。  何かコミュニケーションの手段があるのなら、マーケティングはできる。 > > - 電話に出るときもマーケティングだ。 > - メールを送るときもマーケティングだ。 > - あなたの製品が使われるときもマーケティングだ。 > - ウェブサイトに書き込む言葉もマーケティングだ。 > - もしソフトウェアを作ったら、エラーのメッセージもマーケティングだ。 ### 事業の成長と個人の成長 この年次の振り返りは個人の成長の差分確認としての目的が大きいのですが、「プロダクトを伸ばすために今できる全てをやること」が結局は自身の成長に返ってくるため、最近はキャリアロードマップ的なものを考えなくてもよいかな、と思えてきました。「3 年後どうなっていたいか」と聞かれても、最近は「事業を数段大きくさせていたい」と答えています。 少し過去の話をすると、自分はファーストキャリアでシード期の [タイミー](https://timee.co.jp/) に 1 人目の正社員エンジニアとしてジョインし、シリーズ C あたりで離れました。以下の記事に書いています。(今見るとこそばゆいですね) https://note.com/mh4gf/n/n901dafda3ffa タイミーと出会えたのは本当に幸運で、とんでもない人たちと一つの夢に向かってがむしゃらに取り組んだ日々は自分の中で大きな財産であり、かけがえのないものです。少しでも事業成長に貢献できていれば良いなと思いつつ、今思うと圧倒的なスピードで伸びていく事業とプロダクトに引っ張られて目の前の課題をこなしていただけで、ただその場にいただけの人間だったと振り返っています。 今度は、自分の力で事業とプロダクトを引っ張っていけるかという挑戦だと考えています。かつてお世話になった人たちと同じ振る舞いができるか。今この打席に立てることに感謝しつつ、最高のチームメンバーと共に来年はより強く事業に向き合っていきたい。 ### 2024 年触った技術やライブラリ 毎年の差分を確認するためにその年触った技術を振り返っています。今年は技術的な取り組みは減りましたが、それも確認するために列挙してみます。特に思い入れのあるトピックについては取り上げます。 - フロントエンド: TypeScript, React, Next.js(App Router), Tailwind CSS, CSS Modules, Radix UI, Storybook, Vitest, Playwright, msw, graphql-codgen, urql, Apollo Client, Vite, Biome, pnpm, Valibot, Valtio, Yjs, React Flow, ruby/prism, WebAssembly - バックエンド: Ruby, Rails, graphql-ruby, Sequel(ORM), Airtable - インフラ: AWS ECS, Vercel, Terraform - そのほか: GraphQL, GitHub Actions - 生成 AI 関連: ChatGPT, Claude, Perplexity, v0, GitHub Copilot, Ideogram, Dify フロントエンド・バックエンド・インフラは 2022 年あたりから大きく変わらなくなりました。概観すると React + Rails + GraphQL の Web 系領域で枯れたライブラリを選んでいるといったところでしょうか。 唯一特徴的な点として、生成 AI 系ツールを毎日レートリミット近くまで使い倒しています。これほどまで日常に侵食してきてくるとは昨年には考えていなかったところでした。 ### TypeScript / React 一昨年、昨年と引き続き主戦場となりました。状況に応じて Vite と Next.js を使い分けて利用しています。Vite については、Findy さんにお呼びいただいてオフラインで発表もさせていただきました。 https://speakerdeck.com/mh4gf/impressions-after-6-months-of-using-vite-plus-react-router-e448b113-96f4-479a-adea-7d003e5d3fda 今年は色んな PoC をやっていたのですが、その中でもローコードプラットフォームのプロダクト開発に関連する内容として Valibot を中心とした動的 UI 構築について記事を書きました。 https://tech.route06.co.jp/entry/2024/09/26/122250 Yjs を利用したリアルタイム共同編集についても記事を書きました。この記事は久しぶりに 100 はてブを超え、手応えを感じました。 https://tech.route06.co.jp/entry/2024/07/03/154219 記事にはしていないですが、Ruby3.3 でデフォルトのパーサーとして採用された Prism の WASM 版を Node.js から呼び出し利用する、といったちょっと特殊なことをやったのも面白い体験でした。これは来年どこかのタイミングでチームメンバーが発表したりしてくれるはずです。 ### 生成 AI 関連ツール 2024 年は ChatGPT pro mode を中心とした生成 AI 関連ツールに一通り課金し、自身の出力できる成果の幅が圧倒的に広がった年でした。 プロダクトマネジメントやマーケティングなどの経験が薄い領域にもなんとか取り組めているのは生成 AI のおかげです。仕事中も常に複数のタブで並行して出力をさせたり、プライベートでも散歩をしながらイヤホンマイク越しに ChatGPT と英会話の練習をしてもらったりしています。 生成 AI や AI エージェントが「専門領域のジュニアレベルの人間」の仕事を奪うとよく言われますが、実際に奪うのは「学習速度が早く専門領域の横展開が可能な人間」だと考えています。生成 AI は大半の領域で 6~70 点の答えが出せるため、その出力のリスクを評価できる判断力が求められています。 開発経験のない起業家が Cursor を使ってごりごりコードを書く事例も増えていますし、学習速度の速い若手がシニアレベルの出力を出すことも不可能ではないはずです。 自分はこの状況にはそこまで悲観的ではなく、自分自身・組織・プロダクトとしてこの時代の流れにどう乗りこなせていけるかとポジティブに考えています。仕事が奪われてしまっても別の仕事が残るでしょうし、プログラミングは趣味として続けていくでしょう。来年末にはどういった振り返りをしているかも楽しみです。 ### 記事執筆 2024 年は 24 件の記事を書きました。2023 年は 19 件なので微増しています。 https://mh4gf.dev/articles 2024 年前半は月 4 回の記事公開を目標としていましたが、プロダクトに集中し始めてからは止めてしまいました。来年度も個人名義でのアウトプットは減るかもしれませんが、プロダクト関連の発信ができると良いなと考えています。 ### 2025 年は英語 プロダクトが英語圏向けということもあり、来年は英語に力を入れていきたいです。スピーキングとリスニングが壊滅的なためなんとかしたい。海外のテックカンファレンスに参加し現地のエンジニアと会話できる状態を目標に頑張ります。 2024 年関わった皆さん、改めてありがとうございました。2025 年もどうぞよろしくお願いします! ### デスク裏にApple Magic Keyboardをマウントした デスク裏に Apple Magic Keyboard をマウントした。 普段キーボードは [Corne V4 Chocolate](https://shop.yushakobo.jp/products/9442) を使っており、何を隠そう Magic Keyboard は Touch ID 専用機となっている。 デスク上の目立つところには置きたくなく、とはいえ手の届きやすいところに設置したいということで、デスク裏にマウントすることにした。 ### マウントの背景 Magic Keyboard を Touch ID 専用機としてデスク裏に固定するアイデアは、数年前に Reddit で話題になったこの投稿を見たのがきっかけ。 [Under desk iMac fingerprint scanner! : r/macsetups](https://www.reddit.com/r/macsetups/comments/pmwts8/under_desk_imac_fingerprint_scanner/) 投稿者の方は、 iMac と好みのキーボードを使いながら Touch ID の機能も活用するためにこの方法を採用したとのこと。 自分も Macbook をクラムシェルモードで運用したいため似たような構成となった。 ### マウントの方法 キーボード専用のデスク裏マウント製品などないため、DIY での解決を模索した。ホームセンターでの散策や ChatGPT との相談を経て、最終的に DIY 用のドアハンドルをデスク裏に固定するアイデアにたどり着いた。 [Amazon | Sugarello ドアハンドル 取っ手 ドアノブ 取手 プルハンドル 引き出し 引手 戸棚 キャビネット 引き戸 210mm (シルバー) | ドアノブ・ツマミ](https://amzn.to/4dHiYVN) 特別な器具なしに単純なネジ留めだけで固定できたのでよかった。とりあえずキーボードを上に載せるだけにしている。滑り止めを貼った方が良いかも。 余談だが、こういう時に 3D プリンターがあれば自分の理想の形状のマウントブラケットを用意できるんだろうな〜と 3D プリンターが欲しくなる。 ### 使い心地 作業時の視点がこんな感じ。 ![作業時の視点](/images/mount-magic-keyboard/2.jpg) 自身の身体に近い場所に Touch ID があるので押しやすく、同時に机の上もすっきりできた。今まではデスク上の適当なところに置いていたのが、専用の置き場所ができたことで位置が一定になり、ノールックで認証操作ができるようになったのも嬉しい。 Magic Keyboard は充電が必要だが、隣の Macbook のマウント付近に巻き取り式の Lightning ケーブルを常備しているので、適宜そこから引っ張り出して充電している。 今時 Lightning ケーブルが必要なのは面倒ではあるものの、同じく使用している Magic Trackpad も同様の運用が必要なので仕方ないかなと思っている。 ### DifyでのAPIアクセス実践 - Obsidianから呼び出しURL記事の要約をノートに保存する 最近は LLM を使って色々実験しており、その中でも Dify をよく使っています。今回は Dify を使った小ネタを紹介します。 [Dify](https://dify.ai/jp) は、カスタマイズ可能な AI ワークフロー・チャットボット・エージェントをノーコードで作成できるプラットフォームです。作成したワークフローはブラウザや Chrome 拡張機能、API 経由で実行できます。一方、[Obsidian](https://obsidian.md/) は拡張性の高い markdown ベースのノートアプリケーションとして知られています。 今回はこれらのツールを連携させ、以下の機能を実現しました。 1. Dify を利用して、指定した URL の記事を自動的に要約する 2. Obsidian から Dify の要約機能を API 経由で呼び出し、結果をノートとして保存する 私は Obsidian にさまざまなメモやログを蓄積しており、日々気になった Web 上の記事もメモと共に Obsidian に保存し、ナレッジベースとして活用しています。 1 年ほど前から ChatGPT でも Web 記事の内容を読んで返答できるようになったので、普段でも要約をまとめてもらってノートにコピペしていましたが、その作業を自動化して一発で Obsidian のノートに保存できないかと考えていました。それが Dify と Obsidian の組み合わせで実現できました。 Dify と Obsidian の組み合わせを紹介する記事となるとかなりニッチな領域なんですが、両者とも拡張性が高いため、さまざまな用途に活用できると思います。例えば Dify の API アクセスを活用すると、Dify だけでは機能が足りない時に Zapier から API アクセスする形で補うなども可能です。 以下のセクションでは、Dify でのワークフロー作成から Obsidian での実装まで、具体的な手順を解説していきます。 ### 動作イメージ 実際の動作イメージは以下の通りです: ![](/images/generate-url-summary-with-dify-and-obsidian/3.gif) 1. コマンドパレットから「Templater: Create new note from template」を選択 2. テンプレート「url-summary」を選択 3. 要約・保存したい記事の URL を入力 4. 数秒待つと、要約されたノートが自動的に保存される ノートとして保存した後は、LLM の要約結果を必要に応じて調整できます。私は [Evergreen Notes](https://notes.andymatuschak.org/Evergreen_notes) の思想を取り入れて情報整理をしており、ここで既存の他のノートへのリンクを貼るようにしています。 この仕組みはスマホ版の Obsidian でも問題なく動作するため、出先などでも記事を簡単に保存できるのが便利です。 ### Dify での要約ワークフロー作成 Dify を使って、URL から記事を要約するワークフローを作成します。以下がワークフローの概要です。 ![](/images/generate-url-summary-with-dify-and-obsidian/1.png) このワークフローは以下のノードで構成されています: 1. 開始ノード:URL を入力として受け取ります。 2. JinaReader ツール:入力された URL の内容を読み取ります。 3. JSON Parse ツール:JSON 文字列から任意のフィールドを取り出すツールです。ここでは JinaReader の出力から記事のタイトルを取り出します。 4. LLM ノード(GPT-4o):記事の内容を要約します。要約は日本語で、主な議論点と結論を含む 3 行以内の箇条書きで生成されます。 5. 終了ノード:要約内容(content)とタイトル(title)を出力します。 このワークフローを自分の Dify アカウントで再現したい方は、以下の DSL をコピーの上インポートしてください。
ワークフローの DSL ```yaml app: description: "" icon: 🤖 icon_background: "#FFEAD5" mode: workflow name: 要約 kind: app version: 0.1.1 workflow: conversation_variables: [] environment_variables: [] features: file_upload: image: enabled: false number_limits: 3 transfer_methods: - local_file - remote_url opening_statement: "" retriever_resource: enabled: false sensitive_word_avoidance: enabled: false speech_to_text: enabled: false suggested_questions: [] suggested_questions_after_answer: enabled: false text_to_speech: enabled: false language: "" voice: "" graph: edges: - data: isInIteration: false sourceType: start targetType: tool id: 1722151586902-source-1722300182734-target source: "1722151586902" sourceHandle: source target: "1722300182734" targetHandle: target type: custom zIndex: 0 - data: isInIteration: false sourceType: tool targetType: tool id: 1722300182734-source-1723634891190-target source: "1722300182734" sourceHandle: source target: "1723634891190" targetHandle: target type: custom zIndex: 0 - data: isInIteration: false sourceType: tool targetType: llm id: 1723634891190-source-1722299744292-target source: "1723634891190" sourceHandle: source target: "1722299744292" targetHandle: target type: custom zIndex: 0 - data: isInIteration: false sourceType: llm targetType: end id: 1722299744292-source-1722300260176-target source: "1722299744292" sourceHandle: source target: "1722300260176" targetHandle: target type: custom zIndex: 0 nodes: - data: desc: "" selected: false title: 開始 type: start variables: - label: URL max_length: 256 options: [] required: true type: text-input variable: url height: 90 id: "1722151586902" position: x: 30 y: 349 positionAbsolute: x: 30 y: 349 selected: true sourcePosition: right targetPosition: left type: custom width: 244 - data: context: enabled: true variable_selector: - "1722300182734" - text desc: "" model: completion_params: temperature: 0.7 mode: chat name: gpt-4o-mini provider: openai prompt_template: - id: b2b30596-9568-474b-8c30-75da1cfc104b role: system text: "以下の記事を要約してください。要約は、日本語で、主な議論点と結論を含め、箇条書きで、3行以内でお願いします。 {{#context#}}" selected: false title: Generate summary type: llm variables: [] vision: configs: detail: high enabled: true height: 98 id: "1722299744292" position: x: 942 y: 349 positionAbsolute: x: 942 y: 349 selected: false sourcePosition: right targetPosition: left type: custom width: 244 - data: desc: "" provider_id: jina provider_name: jina provider_type: builtin selected: false title: JinaReader tool_configurations: gather_all_images_at_the_end: 0 gather_all_links_at_the_end: 0 image_caption: 0 max_retries: 3 no_cache: 0 proxy_server: null summary: 0 target_selector: null wait_for_selector: null tool_label: JinaReader tool_name: jina_reader tool_parameters: url: type: mixed value: "{{#1722151586902.url#}}" type: tool height: 298 id: "1722300182734" position: x: 334 y: 349 positionAbsolute: x: 334 y: 349 selected: false sourcePosition: right targetPosition: left type: custom width: 244 - data: desc: "" outputs: - value_selector: - "1722299744292" - text variable: content - value_selector: - "1723634891190" - text variable: title selected: false title: 終了 type: end height: 116 id: "1722300260176" position: x: 1246 y: 349 positionAbsolute: x: 1246 y: 349 selected: false sourcePosition: right targetPosition: left type: custom width: 244 - data: desc: "" provider_id: json_process provider_name: json_process provider_type: builtin selected: false title: Extract title tool_configurations: ensure_ascii: 1 tool_label: JSON Parse tool_name: parse tool_parameters: content: type: mixed value: "{{#1722300182734.text#}}" json_filter: type: mixed value: data.title type: tool height: 90 id: "1723634891190" position: x: 638 y: 349 positionAbsolute: x: 638 y: 349 selected: false sourcePosition: right targetPosition: left type: custom width: 244 viewport: x: 68.00119294975991 y: 34.74871751854829 zoom: 0.6686980554677073 ```
ワークフローを作成したら、API キーを取得します。この API キーは後で Obsidian から要約機能を呼び出す際に使用します。 ![](/images/generate-url-summary-with-dify-and-obsidian/2.png) 次のセクションでは、この Dify ワークフローを Obsidian から呼び出す方法について説明します。 ### Obsidian から API 呼び出しとノート保存の実装 Obsidian から Dify の API を呼び出し、生成された要約をノートとして保存する機能を実装します。この実装には Obsidian の [Templater プラグイン](https://github.com/SilentVoid13/Templater) を使用します。Templater プラグインをインストールし、有効化してください。 ### 1. API 呼び出し用 JavaScript ファイルの作成 Dify の API を呼び出すための JavaScript 関数を記述したファイルを作成します。以下の内容で `getSummaryFromUrl.js` というファイルを作成し、Obsidian の vault の適切な場所(自分は `templater-scripts` フォルダにしています)に保存します。 ```js title="/templater-scripts/getSummaryFromUrl.js" /** * * @param {string} url - The URL of the article to summarize. * @param {function(string): Promise} onTitleUpdate - Callback function to handle title updates as they are streamed. * @param {function(string): void} onContentUpdate - Callback function to handle content updates as they are streamed. */ async function getSummaryFromUrl(url, onTitleUpdate, onContentUpdate) { const response = await fetch("https://api.dify.ai/v1/workflows/run", { method: "POST", headers: { Authorization: "Bearer [DifyのAPIキーを貼り付け]", "Content-Type": "application/json", }, body: JSON.stringify({ inputs: { url: url }, // 「開始」ノードで設定した入力フィールド response_mode: "streaming", // Server-Sent Eventsで返却される user: "obsidian", // APIアクセスしたユーザーを識別するための情報。適当な文字列で大丈夫 }), }); const reader = response.body.getReader(); const decoder = new TextDecoder("utf-8"); let buffer = ""; let bufferObj; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); for (const line of lines) { // レスポンスには先頭に `data: ` という文字列とJSON文字列が含まれている if (line.startsWith("data: ")) { try { // 不要な文字列を取り除いてパース bufferObj = JSON.parse(line.substring(6)); } catch { continue; } // タイトル抽出のノードが終了したらその時点でタイトルを保存 if ( bufferObj.event === "node_finished" && bufferObj.data.title === "Extract title" && bufferObj.data?.outputs?.text ) { await onTitleUpdate(bufferObj.data.outputs.text); } // LLMのテキストチャンクを都度保存 if (bufferObj.event === "text_chunk" && bufferObj.data?.text) { onContentUpdate(bufferObj.data.text); } } buffer = lines[lines.length - 1]; } } } module.exports = getSummaryFromUrl; ``` `[DifyのAPIキーを貼り付け]` の部分を、先ほど取得した Dify の API キーに置き換えてください。 `response_mode: "streaming"` を指定することで、Server-Sent Events(SSE)形式でレスポンスが返却されます。これにより、要約の生成過程をリアルタイムで取得し、ノートに反映することが可能になります。 今回は 「`text_chunk` イベントであれば要約の生成結果だと見なして保存」としていますが、ワークフローで利用する LLM が複数の場合は困るかもしれません。 `text_chunk` イベントのレスポンスにはどのノードで実行されたかという情報が含まれていないためです。 もし困った場合は無理にリアルタイムで更新せずに、 `node_finished` イベントで最終結果を取り出す形でも実現できます。 ### 2. テンプレートファイルの作成 次に、ノートの作成を行うテンプレートファイルを作成します。以下の内容で `url-summary.md` というファイルを作成し、テンプレートフォルダに保存します。(自分は `templater` フォルダにしています。) ```md title="/templater/url-summary.md" <%* // 実行時に URL を入力するプロンプトを出す const url = await tp.system.prompt("Please enter a URL"); // わかりやすさのために、自分は記事のノートのプレフィックスとして「📰」をつけています // この辺りのフォーマットはお好みで変更してください。 const handleUpdateTitle = async (newTitle) => { await tp.file.rename(`📰${newTitle}`); tR += `[${newTitle}](${url})\n`; } const handleUpdateContent = (newContent) => { tR += newContent; } await tp.user.getSummaryFromUrl(url, handleUpdateTitle, handleUpdateContent); %> ``` ### 3. Templater の設定 Templater の設定で、テンプレートフォルダと `getSummaryFromUrl.js` を保存したスクリプトフォルダのパスを指定してください。 これで設定は完了です。コマンドパレットを開き「Templater: Create new note from template」を実行すると、記事要約を含めてノートに保存されました! ### おわりに 今回は Dify の API アクセスの実践例として、Obsidian からワークフローを呼び出して結果をノートに保存する方法を紹介しました。 今回はある程度シンプルなワークフローでしたが、カスタマイズすることで単なる記事要約に限らず様々な用途に使えるかと思います。 余談ですが、最近はノーコードで実現可能なことは可能な限りそちらに寄せる方が構築スピードや試行錯誤のしやすさから良いと考え、Dify や Zapier を試している面もあります。しかしプログラミングはどうしても楽しいので、結局この記事の例でもコーディングしてしまいました。うまくバランスを取って両方利活用していきたいですね。 ### Next.js App RouterでPagefindを使うときのあれこれ このブログサイトでサイト内検索機能を追加しました。検索が必要なほど記事数はないので完全に自己満足。 右下の ⌘ ボタンか、Cmd + K キーを押すと検索フォームが表示されるので、テキストを入力し試してみてください。 ![テキストを入力し記事を検索する](/images/pagefind-with-app-router/1.gif) サイト内検索機能は[Pagefind](https://pagefind.app/)を利用して実装しました。静的サイトに特化した全文検索ライブラリで、UI フレームワークに依存せずに利用できます。全文検索にも関わらず転送量をかなり抑えることができるのも大きなメリットです。とても便利ですが、Next.js で利用する際にいくつか考えることがありました。今回はそれらを紹介します! Pagefind とは何か、どのように使うか、についてはインターネット上に多くの記事があるため割愛します。 ### 前提 このサイトは Next.js App Router で構築しています。 ブログ記事はマークダウンファイルでリポジトリ上に配置しており、[contentlayer](https://contentlayer.dev/)で読み込み JSON データに変換した後、[Unified.js](https://unifiedjs.com/), [rehype-react](https://github.com/rehypejs/rehype-react)を利用し React コンポーネントに変換します。 これらの処理は React Server Components で実行され、ビルド時に静的な HTML として出力されます。詳しくはリポジトリをご覧ください。 https://github.com/MH4GF/mysite ### Indexing ### Next.js でのインデックスの作成 Pagefind CLI でビルド時にインデックスの作成を行います。Next.js でのビルド結果は `.next` に配置されるため、そのディレクトリを指定します。具体的なコマンドは以下のようになります: ```json title="package.json" "build:pagefind": "pagefind --site .next --output-path public/search" ``` pagefind の生成ファイルは `public/search` に出力しました。Next.js では public ディレクトリに配置したファイルは静的アセットとしてホスティングしてくれるため、それを利用します。これはクライアントサイドの JavaScript から pagefind を呼び出すために必要です。 Pagefind は静的な HTML に対してインデックスを作成するため、 `next build` の後に CLI を実行する必要があります。以下のように `postbuild` で実行することで、 `pnpm build` を実行するだけで後処理として Pagefind を実行することができます。 ```json "build": "next build", "postbuild": "pnpm run build:pagefind", ``` ### インデックスに独自データを追加する 通常は Pagefind CLI の利用だけで良いのですが、このサイトでは NodeJS API を利用しスクリプトファイルを作って実行する形にしました。 背景として、このサイトは他サイトで公開している記事へのリンク集としている側面もあり、Zenn や所属企業のテックブログで書いた記事なども記事一覧にリンクとして掲載しています: http://mh4gf.dev/articles そのため、外部サイトの記事であってもタイトルを検索できたら良いなと考えました。それは NodeJS API で Pagefind を実行し `addCustomRecord()` を使うと実現できました。 以下のようにスクリプトファイルを用意します: ```ts title="scripts/pagefind.mts" import { createIndex } from "pagefind"; // このサイトで表示している外部記事のデータ import { externalArticles } from "../app/_features/articles/data/externalArticles"; async function main() { const { index } = await createIndex({}); if (index === undefined) { throw new Error("Failed to create index"); } // `--site .next` と同等 await index.addDirectory({ path: ".next", }); // addCustomRecord() を利用し、外部記事のデータをインデックスに追加 for (const article of externalArticles) { await index.addCustomRecord({ url: article.href, content: article.title, meta: { title: article.title, externalLink: "true", }, language: "ja", }); } // `--output-path public/search` と同等 await index.writeFiles({ outputPath: "public/search", }); } main(); ``` externalArticles は、外部記事の情報を持つ以下のような構造のオブジェクトです。それを`index.addCustomRecord()` に渡す形で実現できました。 ```ts export const externalArticles = [ { title: "チュートリアル: Yjs, valtio, React で実現する共同編集アプリケーション", publishedAt: "2024/07/03", href: "https://tech.route06.co.jp/entry/2024/07/03/154219", tags: ["route06-tech-blog"], }, ... ] ``` https://github.com/MH4GF/mysite/blob/467b401e369a74f987fa9c5161f54ec6387e6a06/app/_features/articles/data/externalArticles.ts 今回はこの externalArticles を import するために、スクリプトファイルも TypeScript ファイルとしました。もしこれが JSON の読み込みなどであれば mjs でも良いかと思います。 TypeScript ファイルを実行するために、今回は[tsx ](https://github.com/privatenumber/tsx)を使いました。インストールし依存に追加します: ``` pnpm add -D tsx ``` Pagefind CLI を置き換えます。 ```json title="package.json" "build:pagefind": "pagefind --site .next --output-path public/search" // [!code --] "build:pagefind": "tsx ./scripts/pagefind.mts" // [!code ++] ``` ### Search Pagefind では検索結果を表示する UI コンポーネントが同梱されていますが、このサイトでは React コンポーネントを自前で用意したかったため[search API](https://pagefind.app/docs/api/)を直接使うことにしました。 ### Pagefind の初期化 Pagefind は、生成ファイルに含まれる pagefind.js をブラウザから読み込むことで動作しますが、Next.js のビルド時にはそれらがないため、ビルドの失敗を解決する必要があります。 具体的には以下の記事で紹介されていた、window オブジェクトに Dynamic Import で差し込みつつ `webpackIgnore`コメントを使う方法で解決できました。 https://www.petemillspaugh.com/nextjs-search-with-pagefind ```ts void (async () => { if (typeof window !== "undefined" && typeof window.pagefind === "undefined") { try { window.pagefind = await import( // @ts-expect-error pagefind.js generated after build /* webpackIgnore: true */ "/search/pagefind.js" ); } catch { window.pagefind = { search: () => Promise.resolve({ results: [] }) }; } } })(); ``` https://github.com/MH4GF/mysite/blob/467b401e369a74f987fa9c5161f54ec6387e6a06/app/_features/command/items/SearchGroup.tsx#L34-L47 ### React コンポーネントから Pagefind Search API の読み込み Pagefind の Search API は以下のように利用します。 - `const search = await pagefind.search("static");` で検索を実行 - `const oneResult = await search.results[0].data();` で検索結果を取り出す 2 回非同期関数を実行することになります。React コンポーネントで非同期関数を扱う方法はいくつかありますが、React 19 からは Promise から値を取り出す [use](https://ja.react.dev/reference/react/use) API が提供され、Next.js では既に利用できるようになっています。 今回はこちらを利用してみました。個人のサイトなので新しい API をどしどし使う。 ```tsx // Pagefindの型定義 type Data = { url: string; meta: { title: string; }; }; type Result = { id: string; data: () => Promise; }; type Pagefind = { search: (query: string) => Promise<{ results: Result[] }>; }; declare global { interface Window { pagefind: Pagefind | undefined; } } // キャッシュを有効にしつつ、データを取り出す関数 const getData = cache(async (result: Result) => result.data()); // .next内のhtmlファイル名がPageFindの検索結果として使われてしまうため変換する関数 // /server/app/articles/pagefind-with-app-router.html を // /articles/pagefind-with-app-router にする const formatUrl = (url: string): string => { return url.replace(/\/server\/app\/articles\/(.*)\.html/, "/articles/$1"); }; const SearchResultItem: FC<{ result: Result }> = ({ result }) => { // `use()` でPromiseから値を取り出す const data = use(getData(result)); return {data.meta.title}; }; // PageFindの検索を実行する関数 こちらも `cache()` でラップ const search = cache(async (query: string): Promise => { if (!window.pagefind) { return []; } return (await window.pagefind.search(query)).results; }); const InnerSearchResultItems: FC<{ query: string }> = ({ query }) => { // `use()` でPromiseから値を取り出す const results = use(search(query)); return results.map((result) => ( )); }; export const SearchResultItems = () => { return ( ); }; ``` `use()` で Promise から値を取り出す場合、`cache()` 関数と `Suspense` コンポーネントを利用することになります。 `use()` を使う際、再レンダリングにより複数回のデータフェッチが行われることを回避するために、Promise を読み取る関数はキャッシュされている必要があります。そのために `cache()`を利用します。 Promise が解決していない場合、 `use()` は Promise を throw しコンポーネントをサスペンドさせます。 `Suspense` コンポーネントでラップし待機します。 詳しくは以下の記事が参考になります。 https://azukiazusa.dev/blog/promise-context-value-react-hook https://zenn.dev/uhyo/articles/react-use-rfc --- 上記のコード例では Suspense の fallback を省略していたり、スタイリング([shadcn/ui](https://ui.shadcn.com/)の Command コンポーネントを利用しています)を省略しています。詳しくはソースコードをご覧ください。 https://github.com/MH4GF/mysite/blob/ab0cee1ecc0bff89828fa68261446c752d58228d/app/_features/command/items/SearchGroup.tsx ### 終わり 今回は Next.js で Pagefind を組み込む事例を紹介しました。私自身は他の全文検索ライブラリを使ったことはないのですが、Pagefind は簡単に利用でき、困るところはほぼありませんでした。(余談ですが shadcn/ui の Command コンポーネントの調整の方が大変でした。) 今回は使用しませんでしたが、Pagefind は[検索結果のハイライト](https://pagefind.app/docs/highlighting/)にも対応しているためそちらも気が向いたら試せると良いなと思っています。 終わり! ### 2024年にWebエンジニアがWordpressテーマ開発をした備忘録 Wordpress テーマ開発をすることになり、先日無事リリースを迎えたので備忘録的にツール選定や構成の記録を残しておく。 普段は Web エンジニアをしていて、Wordpress も PHP もほぼ初めてなので最適ではない箇所もあるかも。 ### 方針 - 納品後の追加改修等は別のエンジニアが担当する可能性が高いので、可能な限り Wordpress テーマとして王道な選択肢で学習コストを下げたい。 - 静的解析・テスト・継続的デプロイなど、開発生産性を上げるものについては取り入れたい。 ### スターターテーマ https://underscores.me/ を利用した。2012 年の公開と少々古いが、今でも使われているようで情報量も多い。 [Template Hierarchy](https://developer.wordpress.org/themes/basics/template-hierarchy/)もあまり理解していない状態だったため、一般的な構成を知りたいと思いベースとなるテーマを探したところ、スターターテーマやブランクテーマといったジャンルがあることを知った。その中の一つとして選んだ。 選んだ感想として、基本的なテンプレート構成や、fuctions.php で書いた方が良い各種設定、[get_template_part()](https://developer.wordpress.org/reference/functions/get_template_part/)によるコンポーネント分割などが学べたのがよかった。Linter や Formatter も同梱されているのもありがたい。 ただ完全にまっさらなテーマというわけではなく、モーダルの開閉やカスタマイザーなどいくつかの JavaScript ファイルがあったが、これらは要件に合わなかったためほとんど 0 から作り直した。Sass のコンパイルも設定されていたが CSS で十分なため外した。 ### カスタム投稿タイプとカスタムタクソノミー 管理画面上で簡単に作成・更新できる[Custom Post Type UI](https://ja.wordpress.org/plugins/custom-post-type-ui/)プラグインを利用した。今回は「カスタム投稿として絞り込んでデータ取得し、専用のテンプレートで表示できれば良い」というシンプルな要件だったため、コードは書かずよく使われているプラグインで実現した。 ### カスタムフィールド [Smart Custom Fields](https://ja.wordpress.org/plugins/smart-custom-fields/)プラグインを利用した。おそらく最も有名なのは[Advanced Custom Fields](https://ja.wordpress.org/plugins/advanced-custom-fields/)プラグインだと思われるが、定義できるデータ型のうち繰り返しフィールド(key/value のオブジェクトの配列を表現できるフィールド)が月額課金が必要な有料プランのため困ってしまい、無料で実現できるプラグインとして選択した。 最小限かつ必要十分な機能を揃えている印象で、とてもよかった。エディタ画面での入力フィールドに説明を置けるのも良い。 ### JavaScript ライブラリ アニメーションやインタラクションは基本的には CSS アニメーションや Web API を使った自前実装で解決できたものの、複雑な領域については JavaScript ライブラリを利用した。 - カルーセル ... [Swiper](https://swiperjs.com/) - SVG アニメーション ... [@lottiefiles/lottie-player](https://www.npmjs.com/package/@lottiefiles/lottie-player) - 慣性スクロール ... [Lenis](https://lenis.darkroom.engineering/) - パララックスアニメーション ... [GSAP](https://gsap.com/) / [ScrollTrigger](https://gsap.com/docs/v3/Plugins/ScrollTrigger) カルーセルは CSS の scroll-snap で、パララックスは CSS の perspective でできるかも...?と思いつつ、今回は時間の都合上 JavaScript ライブラリに任せることにした。いずれ挑戦してみたい。 ### CSS [Tailwind CSS](https://tailwindcss.com/)を利用した。Tailwind CLI を使って `dist/style.css` に出力し、[wp_enqueue_style()](https://developer.wordpress.org/reference/functions/wp_enqueue_style/) で読み込む素朴な運用だ。 ここだけは方針にある王道な選択肢か?と言われたら疑問は残ってしまうが、Scoped CSS が使えない単純な PHP テンプレートという状況で BEM 等の CSS 設計を考えるより保守しやすく、かつ CSS in JS ライブラリと違い JavaScript への依存がないため、PHP でも運用可能と考えた。 この選択については、デザインシステムとしての統一性と拡張性が得られたり、生成 AI のサポートが受けやすいなどの想定していた価値を享受できた部分はあるが、Wordpress のコンポーネント分割の難しさによる相性の悪さも感じた。この件は長くなるため別の記事で紹介したい。 ### Linter, Formatter [PHP_CodeSniffer](https://github.com/PHPCSStandards/PHP_CodeSniffer), [Biome](https://biomejs.dev/), [Prettier](https://prettier.io/)を利用した。使い分けはこんな感じ。 - PHP ファイルの Lint, Format ... PHP_CodeSniffer - JavaScript ファイルの Lint ... Biome - それ以外の Format ... Prettier 最初は PHP ファイルの Format も、慣れている Prettier でできないかなーと [prettier/plugin-php](https://github.com/prettier/plugin-php)を利用していたが、HTML の一部に PHP を埋め込む書き方の場合 1 トークンごとに改行されるなどで結構見づらかったため、underscores に同梱されていた PHP_CodeSniffer に切り替えた。Linter としても PHP のよろしくない書き方をしている箇所の指摘もしてくれるため PHP の学習に役立った。 Biome は推奨ルールを設定するだけで簡単に使えるので良い。 ### テスト Playwright の [toHaveScreenshot()](https://playwright.dev/docs/test-snapshots) を使った Visual Regression Test(VRT)だけ用意した。PC/モバイル/タブレットサイズのブラウザでアクセスし全ページのスクリーンショットを保存しておき、コードの変更によって出てきた差分が意図的かを確認する。スクリーンショットは Git リポジトリにコミットして、特に VRT のための SaaS などは利用していない。一人開発のため CI も設定せずローカルでのみテスト実行した。 これは最低限の労力で十分な効果を発揮してくれた。例えばレスポンシブ対応のためのスタイル調整が PC 側に影響していないかなどに気付けるのはもちろん、とにかく常に全ページ問題なく開けることが確認できているのは安心感があった。 スクリーンショットの差分は GitHub 上で確認もでき、履歴も見れるので、デザイナーにも GitHub リポジトリにアクセスできるようにしてもらい、開発終盤のデザインレビューで出てきた細かい調整などは差分を見てもらう運用にしていた。 ### ソースコード管理とデプロイ 今回は FTP でデータを VPS にアップロードする形でのデプロイだった。テーマディレクトリを Git リポジトリとして管理し、[SamKirkland/FTP-Deploy-Action](https://github.com/SamKirkland/FTP-Deploy-Action)を使って GitHub Action 経由で FTP を実行するようにした。手動で FTP することがなくなったため安全になり、かつ git push したら数十秒でデプロイが完了するので非常に楽になった。 テーマディレクトリ以外も Git 管理したいものが出てくるかもと思い、リポジトリルートに `wp-content/themes/theme-name` というディレクトリを置いてその中で開発していたが、特に何もなかったので次回は `theme-name` をリポジトリルートにして良いかも。 手元での環境構築には[Local](https://localwp.com/)を利用した。Virtualbox を使って nginx, MySQL, PHP の環境を簡単に作ってくれて、特に困ることもなかった。自己署名による SSL 化もワンクリックでできて助かる。 Local で作った環境のテーマディレクトリは Mac だと `/Users/ユーザー名/Local Sites/サイト名/app/public/wp-content/themes` になり、Git リポジトリは別ディレクトリで作っていたため、シンボリックリンクを貼ることで Local の環境で使えるようにした。 ### Wordpress テーマ開発をした感想 React などの UI ライブラリと比べて、純粋に HTML、CSS、JS を書く経験はとても良かった。今は Internet Explorer が退場し、HTML や CSS だけでできることが増えており、必要に応じて JavaScript を使う程度で済むことが多い。また TypeScript がなくても JSDoc で十分に良い開発体験を得られた。Web の基礎技術の進歩を享受できたと感じる。 WordPress については、CMS として必要な機能がほぼすべて揃っており、ネット上に多くの情報があるため、「PHP、CSS、JavaScript でカスタマイズ可能なローコードツール」としての開発生産性は非常に高いと感じた。 PHP テンプレートについては、リクエストごとに WP_Query でデータを取得し、ループと共に HTML をレンダリングするというシンプルな方法はわかりやすかった。ただロジックとプレゼンテーションの分離が難しく、テンプレートパーツによるファイル分割はできるものの引数を簡単に渡せる程度で、コードの可読性やメンテナンス性は低いと感じた。React などのモダンフレームワークが解決しようとした課題や、SSR や React Server Component などの新しい流れについて改めて理解した。 ### おわり いい経験でした。 ### GitHub Scheduled ReminderによるCI失敗時の通知 GitHub Actions の CI が失敗した時にリアルタイムで通知させたいと思い調べていたところ、そういえば [Scheduled Reminder](https://github.com/settings/reminders) の real-time alerts に `Your PR has failed CI checks` という項目があることを思い出した。 Schedued Reminder についてはこれらの記事を参照ください: https://docs.github.com/ja/account-and-profile/setting-up-and-managing-your-personal-account-on-github/managing-your-membership-in-organizations/managing-your-scheduled-reminders https://tech.route06.co.jp/entry/2023/04/25/154828 他の通知設定と違いこの項目だけテキスト入力が必要で、ドキュメントを見てもどういった値を入れれば良いかわからず、今まで使わずに放置してしまっていた。 自身の関わるプロダクトでは `frontend-lint` という名前の CI Job があったため試しに入力してみると、確かに失敗時に通知が来てくれた。Require Status Checks の設定と同じなのかも。 Slackの通知画面 カンマ区切りで設定すれば良いということなので、現在はこのように設定している。 Scheduled Reminder の設定画面 'frontend-lint,frontend-test,backend-lint,backend-test' Slack 通知はリアルタイムで見ていることが多いので、その仕組みに乗れたので満足だ。 --- あまりにも `Your PR has failed CI checks` についての情報が少なくないかと調べていると、「設定例が非常に少なくわからない」という Discussions があった。 https://github.com/orgs/community/discussions/23986 Scheduled Reminder の設定値はカンマ区切りの文字列なので、ジョブ名にカンマが含まれているとうまく動作しないとのこと。これは matrix を使うと起きるため困ることは多そう。 また「なぜ全ての CI 失敗時に通知を受け取れないのか?」という意見もあった。これもそう思う、改善に期待したい。 ### ワークチェアを手放し、ステッパーとスツールに移行した ワークチェアを手放し、ステッパーとスツールだけで作業している。 ![Xiserのステッパー](/images/stepper-and-stool/3.webp) ステッパーは見た目通りの器具で、ステップに両足を乗せて右、左と踏み込む運動器具。意外と負荷がある。 買ったのはこれ。(公式サイトが見当たらないので mybest さんのリンク) https://my-best.com/products/309587 結構なお値段だが、安価なステッパーの場合連続使用時間が 1 時間などに決まっていて、超えると壊れてしまうとのこと。ネットの情報ではステッパーを買うなら Xiser 一択とのことなので買ってみた。 ステッパーは自動昇降デスクと組み合わせて使っている。作業中常に踏むわけではなくて、ビルドの待ち時間や思考中などのちょっとした空き時間に踏んでいる。なので作業中の基本姿勢は両足並行の状態でステッパーの上に立つ形。 ステッパーなしの単純なスタンディング作業だと長時間同じ姿勢になるためすぐ疲れてしまうのだが、適宜ステッパーを踏んで体勢を変えることで疲れにくくなり、立ち作業が継続しやすくなった。 スツールは Zara Home のアッシュ バー スツール。Zara Home のインテリアは安価でデザインがとても良くて好き。 [アッシュ バー スツール - ブラウン | ZARA Japan / 日本](https://www.zara.com/jp/ja/%E3%82%A2%E3%83%83%E3%82%B7%E3%83%A5-%E3%83%8F%E3%82%99%E3%83%BC-%E3%82%B9%E3%83%84%E3%83%BC%E3%83%AB-p42365073.html) スツールは補助的な役割で、立つのに疲れたら座る形。ワークチェアほど座り心地はよくないので長時間座ることはなく、ちょっと座ってすぐ立ち作業に戻る。 背が高めのスツールなので、ステッパーの上にスツールを置いて座っても自身の足が当たらず邪魔にならないのは良い誤算だった。自動昇降デスクなので高さも合わせられる。ちなみにデスクの高さはステッパーに乗るときは 115cm, スツールに座る時は 95cm となっている。(身長 173cm の男性) ![スツール on ステッパー](/images/stepper-and-stool/1.webp) 今回ワークチェアを手放すというストイックな選択をしたのだが、2 ヶ月ほどワークチェアを脇に寄せておき移行期間を設け、なくても問題ないことを確認してから手放した。想像以上になんとかなっている。 リビングにデスクスペースがあるのだけど、ワークチェアは部屋に合わないなと感じていたのもありスッキリして良い。 ### axe-core/playwrightとmarkuplintを導入しアクセシビリティの自動テストをできるようにした Web アクセシビリティに興味があったので、まず機械的なチェックツールから学んで知識を増やそうということでこのサイトに [@axe-core/playwright](https://www.npmjs.com/package/@axe-core/playwright) と [markuplint](https://github.com/markuplint/markuplint) を導入してみました。 ### @axe-core/playwright のセットアップ 既に Playwright が導入されている状況を想定し進めます。まず[@axe-core/playwright ](https://www.npmjs.com/package/@axe-core/playwright)をインストールします。 ```sh pnpm add -D @axe-core/playwright ``` このサイトの場合 VRT として Playwright を動かしているテストがあるので([過去資料](https://speakerdeck.com/mh4gf/playwright-de-fan-xiao-sakushi-meru-vrt-to-ci-nosutetupunoxuan-ze-zhi))、そのプロセスに同居する形で axe を実行することにしました。 ```ts title="e2e.test.ts" import AxeBuilder from "@axe-core/playwright"; // [!code ++] import type { Page, TestInfo } from "@playwright/test"; const setup = async (page: Page): Promise => { // 省略 }; const screenshot = async (page: Page, testInfo: TestInfo): Promise => { // 省略 }; const testA11y = async (page: Page) => { // [!code ++] const accessibilityScanResults = await new AxeBuilder({ page }).analyze(); // [!code ++] expect(accessibilityScanResults.violations).toEqual([]); // [!code ++] }; // [!code ++] test("VRT", async ({ page }, testInfo) => { await setup(page); await screenshot(page, testInfo); // [!code --] await Promise.all([testA11y(page), screenshot(page, testInfo)]); // [!code ++] }); ``` `accessibilityScanResults.violations` が空配列になっているかどうか、で検証します。このサイトの場合対応しきれない指摘はなかったのでこのままでよかったのですが、もし除外したい指摘がある場合は `toMatchSnapshot` にし配列の状態をそのまま保存する形になるようです。 このテストをそれぞれのページで実行し、出てきた指摘を解消していくのを進めました。 ### markuplint のセットアップ 以下のコマンドを実行し、対話式にセットアップします。 ```sh $ pnpm dlx markuplint --init Packages: +170 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Progress: resolved 170, reused 169, downloaded 1, added 170, done ┌──────────────────────────────────────┐ │ │ │ /✔\ Markuplint │ │ v4.1.0 │ │ │ │ Initialization │ │ │ └──────────────────────────────────────┘ ✔ Which do you use template engines? · React (JSX) ✔ May I install them automatically? (y/N) · true ✔ Do you customize rules? (y/N) · false ✔ Does it import the recommended config? (y/N) · true ✨Created: /Users/mh4gf/ghq/github.com/MH4GF/mysite/.markuplintrc Install automatically npm install -D --legacy-peer-deps markuplint @markuplint/jsx-parser @markuplint/react-spec npm ERR! code EUNSUPPORTEDPROTOCOL npm ERR! Unsupported URL Type "workspace:": workspace:^* npm ERR! A complete log of this run can be found in: /Users/mh4gf/.npm/_logs/2024-03-14T00_55_41_545Z-debug-0.log ``` .markuplintrc ファイルの作成はできましたが、依存のインストールでエラーになってしまいました。このプロジェクトでは pnpm を使っているのですが、npm でインストールしようとしてエラーになっているようです。 markuplint が実行したコマンドを参考に以下のパッケージをインストールします。このサイトは React で実装されているため、必要になるパッケージが含まれています。 ```sh pnpm add -D markuplint @markuplint/jsx-parser @markuplint/react-spec ``` 設定ファイルの入力中の補完が欲しいので、.markuplintrc ファイルを `.markuplintrc.ts` に変換して TypeScript で書けるようにします。 ```ts title=".markuplintrc.ts" import type { Config } from "@markuplint/ml-config"; const config: Config = { specs: { "\\.tsx?$": "@markuplint/react-spec", }, parser: { "\\.tsx?$": "@markuplint/jsx-parser", }, extends: ["markuplint:recommended"], }; export default config; ``` `Config` 型は `@markuplint/ml-config` パッケージにあるようです。このパッケージはインストールされていないため、依存に追加します。 ```sh pnpm add -D @markuplint/ml-config ``` package.json の scripts に以下を追加し、実行できるようにします。 ```json title="package.json" "lint:markuplint": "markuplint \"**/*.tsx\"", ``` これで lint が実行できるようになったので、それぞれ出てきた指摘を対応していきました。 ### 指摘を受けて対応したこと axe-playwright と markuplint それぞれで指摘が出たため、その指摘がなくなるよう解消していきました。 以下で対応したことを列挙してみます。理解が間違っている箇所があるかもしれませんが、ぜひ指摘いただけると嬉しいです。 - アクセシブルネームがなかった要素の対応 - アイコンリンクそれぞれに aria-label を設定した - 記事の見出しリンクに aria-label を設定した( 「このセクションへのリンク」にした) - ヘッダーの nav 要素の aria-label を「グローバルナビゲーション」、記事の目次の nav 要素の aria-label に「目次」を設定した - リンクカードの og 画像に alt がなかったので「`${hostname}のサムネイル画像`」を追加した - リンクカードの img タグの width と height が `100%` という指定だったが、それを整数値にした - 見出しが h2 から始まっているページがあったので h1 からに変えた - コントラスト比の改善 ... コードブロックの diff がある場合にコントラストが弱い指摘がでていたので、背景色の明度を調整した - インライン SVG に含まれていた xmlns は効果がないため削除した - 横スクロール可能なコードブロックに tabindex="0" を設定した - これは悩んだので後述します。 また指摘を受けて背景知識や対応方法を調べたり、関連記事を読む中で改善した方が良いと判断した対応や、VoiseOver で読み上げてもらった上で気になった箇所の対応も以下に掲載します。 - aria-label や代替テキストは英語にしていたが、このサイトは日本語のコンテンツなので全て日本語に変えた - 日付表記は `2024/03/16` にしていたが、VoiceOver で年月日の読み上げができないため `2024-03-16` に変えた - Web サイトの装飾目的で hr 要素を利用していたが、hr 要素はコンテンツの意味的な区切りを表し、スクリーンリーダーでも区切り線として読み上げられてしまうため div に切り替えた ### 横スクロール可能なコードブロックに tabindex="0"を設定した 指摘を受けて悩んだ事例を 1 つ紹介します。axe からの以下のような指摘を受けました。 `Ensure elements that have scrollable content are accessible by keyboard.`(id: [scrollable-region-focusable](https://dequeuniversity.com/rules/axe/4.8/scrollable-region-focusable?application=playwright)) スクロール可能なコンテンツはキーボードでのアクセスができるようにするべきというもので、これは以下のような記事内のコードブロックで指摘されていました。 ```ts const text = "コードブロックです"; ``` このコードブロックを表示している pre 要素には `overflow-x: auto` を指定しているため、コンテンツがはみ出した際に横スクロールが発生します。しかしこの要素はフォーカスを当てることができないため、キーボードでの横スクロールができないのは改善すべき、とのことでした。 [指摘内容の説明 URL](https://dequeuniversity.com/rules/axe/4.8/scrollable-region-focusable?application=playwright)にもある通り、この問題の解決には `tabindex="0"` を指定することで解決できます。pre 要素にフォーカスを当てることができるようになり、横スクロールをキーボードで行えるようになります。 しかし、pre 要素に tabindex を設定すると今度 Linter として設定していた Biome でエラーになってしまいました。 `The HTML element pre is non-interactive. Do not use tabIndex. `(rule: [lint/a11y/noNoninteractiveTabindex](https://biomejs.dev/linter/rules/no-noninteractive-tabindex)) 対話的ではない要素に tabindex を設定すべきでない、という指摘です。pre 要素は一般的に対話的ではないのでこの指摘は理にかなっているものの、今回の場合コードブロック内のコンテンツがはみ出した場合はスクロール操作が必要になるため、どうすべきか迷ってしまいました。 結論として、今回は Biome のルールを `biome-ignore` でこのコードブロックの行だけ無効にすることにしました。対話的な操作が必要なのは事実であり、ユーザーの操作を損ねないための例外パターンと判断しました。 ### 感想 思っていたより手軽に導入できたのと、ある程度は対応しきれないルールがあると想像していたのですが全て導入できてよかったです。 自動テストや静的解析として導入することでより実践的な形で知識を得られますし、どう解消するか調べる過程で関連する情報が手に入り知識が広がるのはやはり良いなと感じました。 もちろん自動テストだけではアクセシビリティを完全に担保することはできず、手動でのアクセシビリティテストも重要です。そのためにも体系的な知識をこれからつけていこうと思います。 ### 余談: axe のレポートを見やすくする axe-html-reporter について axe-playwright でのテストは、以下のように指摘が 0 で空配列かどうかで検証することになります。 ```ts title="e2e.test.ts" const accessibilityScanResults = await new AxeBuilder({ page }).analyze(); expect(accessibilityScanResults.violations).toEqual([]); ``` 指摘がある場合、ある程度大きなオブジェクトが配列内に含まれているため、Playwright のテスト結果では読みづらいです。見やすいレポーターはないか調べてみると、axe-html-reporter を使うのが良さそうだったため紹介します。 まずプロジェクトに `axe-html-reporter` をインストールします。 ```sh pnpm add -D axe-html-reporter ``` axe によるテストの途中で、axe-html-reporter のレポート作成を挟みます。 ```ts title="e2e.test.ts" import { createHtmlReport } from "axe-html-reporter"; // [!code ++] const testA11y = async (page: Page) => { const accessibilityScanResults = await new AxeBuilder({ page }).analyze(); createHtmlReport({ // [!code ++] results: accessibilityScanResults, // [!code ++] }); // [!code ++] expect(accessibilityScanResults.violations).toEqual([]); }; ``` このように設定することで、以下のように HTML で見やすいレポートが生成されます。 ![axe-html-reporterの出力HTML](/images/axe-core-playwright-and-markuplint/1.png) ### 参考資料 - [コンポーネントをアクセシブルに保つ技術](https://zenn.dev/ubie_dev/articles/38b2b93272ee60) - [Accessibility testing | Playwright](https://playwright.dev/docs/accessibility-testing) - [aria-label を使いすぎない](https://azukiazusa.dev/blog/do-not-use-aria-label-too-much/) - [スクリーンリーダーに配慮したテキスト表記](https://azukiazusa.dev/blog/screen-reader-friendly-text-notation/) ### TPS40 (GREY/SILVER DAISY) Build Log TPS40 というカスタムキーボードのキットを組み立てたので、そのビルドログを書く。 https://deadline.space/products/tps40-keyboard TPS40 は、DEADLINE STUDIO が手がける 40%レイアウトのキーボード。日本では[遊舎工房さんがベンダーとなって Group Buy を開催しており](https://shop.yushakobo.jp/products/7556)、自分は 2023 年 5 月の GB で注文し、2023 年 10 月に届いた。 接続端子は USB Type-C で、 DAISY(横ずれ配列) と PLANCK(格子配列) の二つのレイアウトが選択できる。無線接続も可能だが、遊舎工房さんの GB 版の場合は非対応とのこと。 最近でも遊舎工房さんのツイートを見ていると何度か入荷アナウンスをしているため、手に入る機会は今後もあるかもしれない。 40%キーボードというサイズが自分は好きで、Corne Cherry V3 から自作キーボードを始め、Keyball 39 → cocot46plus → Keyball 44 と乗り換えてきた。そんな中 40%かつカスタムキーボードかつデザインが可愛いキーボードの Group Buy が開催されると聞いて、44,000 円という値段に渋っていたものの清水の舞台を飛び降りる気持ちで買ってみた。結果としてはかなりの大満足となっている。 ### Build Log さてこの TPS40 だが、製品自体はとても良いもののサポートがあまり良くないという評価を見かけるが、自分もそうだね...となってしまった。 まずビルドガイドが用意されていない。同梱物にガイドにあたる図の紙が入っているが、IKEA の説明のような図があるだけでそれだけではさっぱりわからなかった。[DEADLINE STUDIO の Discord サーバー](https://discord.com/channels/958628217730777088/997468609062383716)に TPS40 専用のチャンネルがあるのだが、そこでのビルド方法についての質問には「有志の組み立て動画があるのでそちらを見てくれ」とのこと。以下の動画が参照されていた。 https://www.bilibili.com/video/BV1oP411Y7Nr/ 他に情報は見つけられなかったため、自分もこの動画を食い入るように見ることでなんとかビルドできた。幸い不具合もなかったため自分はチャットを深追いしたりはしていないのだが、困ったことがあればチャンネルで相談するのも良いかも。 ### 組み立て中の写真 ![](/images/tps40/6.jpg) ボトムケース。これがかなり重い。 ![](/images/tps40/7.jpg) 六角形の溝にクッションシールを貼っていく ![](/images/tps40/8.jpg) おそらく「Titanium stonewash shrapnel」と呼ばれているもの。同梱されている IKEA の説明のような図ではこのパーツについての説明がされていて、PLANCK と DAISY でねじ止めする場所が違うので注意。 ![](/images/tps40/11.jpg) この図だけではなんのこっちゃとなるので、動画と見比べながら止めていく。 ![](/images/tps40/9.jpg) 溝に乗せたところ。TPS40 はカスタムキーボードでよくあるガスケットマウント方式とのことで、これは PCB を固定せず、クッションで沈み込ませることでタイピング時の底打ち感を抑えることができるとのこと。 ![](/images/tps40/10.jpg) PCB やトッププレートを乗せたところ。 ![](/images/tps40/5.jpg) 完成! ### VIA TPS40 のキーマップを変更するには [VIA](https://usevia.app) という Web アプリを使うとのこと。 自分は今まで [Remap](https://remap-keys.app/) か、 GitHub の [qmk_firmware リポジトリ](https://github.com/qmk/qmk_firmware)を手元に落としてきた上での直接ビルドしか経験したことがなかったので、今回初めて VIA を使った。 VIA とは何か?についてはこちらのサリチル酸さんの記事がとても参考になった。 https://salicylic-acid3.hatenablog.com/entry/via-manual TPS40 のキーマップは 2024/02/12 現在 VIA の Web アプリ上ですぐ使えるようにはなっておらず、自身でキーマップの JSON ファイルをアップロードして利用する必要がある。VIA の GitHub リポジトリに[プルリクエスト](https://github.com/the-via/keyboards/pull/1953)が上がっているが、まだマージされていないようだ。 そのプルリクエストに含まれている JSON ファイルをダウンロードし VIA の Web アプリでアップロードすることでキーマップの編集ができるようになる。詳しくは上記サリチル酸さんの記事に記載されているのでそちらを参照のこと。 注意点として、DAISY レイアウトの場合は `tps40a.json`、PLANCK レイアウトの場合は `tps40b.json` となる。 ### 完成 ![](/images/tps40/2.jpg) ![](/images/tps40/3.jpg) ![](/images/tps40/1.jpg) キーキャップは[PBTfans Doubleshot WOB](https://shop.yushakobo.jp/products/6349)を遊舎工房さんで見つけて購入した。完全に同色というわけではないけど、ある程度揃えられていて良い。 ### 所感 ダークグレーとシルバーのケースがとても好みで可愛い。デスク上での佇まいも良く、かなり所有欲を満たしてくれる。 打鍵音もアクリル積層ケースと比べて吸収されずっしりと鳴る感じがするのもわかる。ケースがかなりの重さのため打鍵時の安定感はありつつ、ガスケットマウントにより比較的柔らかめな打鍵感なので指も疲れにくいように感じる。 配列としては、元々使っていた Keyball 44 と大体が同じなものの一番右の修飾キー列がないため、そのあたりのキーマップを見直す必要があった程度ですぐ慣れることができた。 ということで TPS40 のビルドログだった。組み立て自体はちょっと難易度高めだったが、完成したものを見るとそれを忘れさせるくらいに満足している。かなりキーボード欲も満たされてしまったので、しばらくはこれで遊びたい。 ### Next.js App Router製ブログサイトでのツイートの埋め込み このブログサイトでツイートの埋め込みをサポートしました。 ツイートの埋め込みについてはウェブ上での情報量も多く枯れている内容かと思いきや、意外と考えることがあったため Next.js App Router での埋め込みについてまとめます。 この記事では X のポストのことをツイートという呼称に統一しています。(伝わりやすいので....) ### 埋め込みの前提知識 ツイートの埋め込みを実装する際の前提知識としては、以下の Catnose さんの記事が参考になります。 https://zenn.dev/catnose99/articles/329d7d61968efb ツイートのウェブページから[ツイートの埋め込み]を選択し、埋め込み用の HTML をコピーします。以下のような HTML が手に入ります。 ```html ``` この widgets.js が読み込まれると、twitter-tweet クラスを持つ blockquote タグが iframe に置き換わります。 以下のような表示です。 その他にも [Catnose さんの記事](https://zenn.dev/catnose99/articles/329d7d61968efb)では JavaScript フレームワークでのパフォーマンス向上などにも触れられています。 以降はこの記事を読んでいる前提で記載していきます。 ### このサイトについて 対象となるこのサイトの概要について説明します。 - Next.js App Router で作られているブログサイト - 記事はマークダウンファイルとしてリポジトリ内に保存されている - マークダウンファイルを [contentlayer](https://contentlayer.dev/) で読み込み、React Server Component 上で [Unified.js](https://unifiedjs.com/) を利用し ReactElement に変換する ソースコードはこちらです: https://github.com/MH4GF/mysite ### 基本的な考え方 ウェブ上でよく見かける React でのツイート埋め込みの記事において、 以下のように tweetId だけを props で受け取って blockquote タグを生成するコンポーネントを実装するパターンをよく見かけます。 ```tsx ``` しかしこの場合埋め込みが完了するまでツイート本文が抜け落ちてしまうため、プログレッシブエンハンスメントの観点であまり望ましくありません。 プログレッシブエンハンスメントとは、誰もが Web ページの基本的なコンテンツと機能にアクセスできるようにした上で、追加のブラウザ機能やネットワーク回線を持つユーザーはさらに強化された機能を利用できるようにする設計思想です。 ツイート埋め込みで考えると、blockquote 要素としてもツイート本文を閲覧できつつ、 widgets.js の読み込みが完了したならば iframe のリッチな表示に切り替わる方が望ましいです。 それを実現するためには、今回はツイートのウェブページから得られる HTML をそのまま利用する方が望ましい、と判断しました。 次節以降からは、本題の Next.js での実装について紹介していきます。 ### Next.js での widgets.js の読み込み まず、Next.js で widgets.js を読み込む方法について考えていきます。これは `next/script` を利用するのが良いでしょう。 ```tsx title="TwitterWidgets.tsx" import Script from "next/script"; export const TwitterWidgets = () => { return ( ``` ここの `