diff --git a/bun.lockb b/bun.lockb
index 6d8c186e..ce232e33 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/package.json b/package.json
index 2af54ff0..49507543 100644
--- a/package.json
+++ b/package.json
@@ -44,12 +44,14 @@
},
"devDependencies": {
"@fohte/eslint-config": "0.0.4",
+ "@types/jsdom": "21.1.7",
"@types/react": "18.2.73",
"@types/react-dom": "18.2.23",
"bun-types": "1.0.36",
"concurrently": "8.2.2",
"eslint": "8.56.0",
"eslint-config-next": "14.1.4",
+ "jsdom": "24.1.0",
"prettier": "3.2.5",
"textlint": "14.0.4",
"textlint-rule-preset-ja-technical-writing": "9.0.0",
diff --git a/scripts/fetch-ogp.ts b/scripts/fetch-ogp.ts
new file mode 100644
index 00000000..d79eed18
--- /dev/null
+++ b/scripts/fetch-ogp.ts
@@ -0,0 +1,88 @@
+import { promises as fs } from 'fs'
+import { JSDOM } from 'jsdom'
+
+const files = await fs.readdir('./src/contents/posts')
+
+const urls = (
+ await Promise.all(
+ files.map(async (file) => {
+ // extract urls from CardLink components
+ const content = await fs.readFile(`./src/contents/posts/${file}`, 'utf-8')
+
+ const regex = / match[1])
+ }),
+ )
+).flat()
+
+console.log(urls)
+
+type Data = {
+ [url: string]: {
+ title?: string | null
+ description?: string | null
+ image?: string | null
+ }
+}
+
+const fetchOgp = async (url: string): Promise => {
+ const res = await fetch(url)
+ const html = await res.text()
+ const dom = new JSDOM(html)
+
+ const data = {
+ title: dom.window.document
+ .querySelector('meta[property="og:title"]')
+ ?.getAttribute('content'),
+ description: dom.window.document
+ .querySelector('meta[property="og:description"]')
+ ?.getAttribute('content'),
+ image: dom.window.document
+ .querySelector('meta[property="og:image"]')
+ ?.getAttribute('content'),
+ }
+
+ if (data.title == null) {
+ data.title = dom.window.document.querySelector('title')?.textContent
+ }
+
+ if (data.image == null) {
+ // if amazon
+ if (res.url.startsWith('https://www.amazon.co.jp/')) {
+ const asin = res.url.match(/dp\/(\w+)/)?.[1]
+ if (asin == null) {
+ throw new Error(`ASIN not found: ${res.url}`)
+ }
+
+ data.image = `https://images.amazon.com/images/P/${asin}.09_SL110_.jpg`
+ } else {
+ // favicon
+ data.image = dom.window.document
+ .querySelector('link[rel="icon"]')
+ ?.getAttribute('href')
+ }
+ }
+
+ return data
+}
+
+const json: Data = JSON.parse(await fs.readFile('./src/data/ogp.json', 'utf-8'))
+
+for (const url of urls) {
+ if (json[url] != null) {
+ continue
+ }
+
+ console.log(`fetching ${url}...`)
+ const data = await fetchOgp(url)
+
+ json[url] = data
+ fs.writeFile('./src/data/ogp.json', JSON.stringify(json, null, 2) + '\n')
+
+ console.log(`fetched ${url}`)
+
+ // sleep
+ await new Promise((resolve) => setTimeout(resolve, 1000))
+}
diff --git a/src/components/mdx/CardLink.tsx b/src/components/mdx/CardLink.tsx
new file mode 100644
index 00000000..c0c6b8e8
--- /dev/null
+++ b/src/components/mdx/CardLink.tsx
@@ -0,0 +1,81 @@
+'use client'
+
+import { Box, Image, Text } from '@chakra-ui/react'
+
+import { type LinkProps } from '@/components/Link'
+import ogpData from '@/data/ogp.json'
+
+export interface Props extends LinkProps {
+ href: string
+}
+
+type OgpData = {
+ [url: string]: {
+ title?: string | null
+ description?: string | null
+ image?: string | null
+ }
+}
+
+const collapseDescription = (description: string): string => {
+ const maxLength = 80
+
+ const newDescription = description.substring(0, maxLength)
+ if (description !== newDescription) {
+ return `${newDescription}…`
+ }
+ return newDescription
+}
+
+export const CardLink: React.FC = ({ href }) => {
+ const ogp = (ogpData as OgpData)[href]
+
+ if (ogp == null) {
+ throw new Error(`OGP not found: ${href}`)
+ }
+
+ const domain = new URL(href).hostname
+
+ return (
+
+ {ogp.image && (
+
+ )}
+
+
+ {ogp.title}
+
+
+ {domain}
+
+ {ogp.description && (
+
+ {collapseDescription(ogp.description)}
+
+ )}
+
+
+ )
+}
diff --git a/src/components/mdx/index.tsx b/src/components/mdx/index.tsx
index 519c2b1d..51bfc2b2 100644
--- a/src/components/mdx/index.tsx
+++ b/src/components/mdx/index.tsx
@@ -16,6 +16,7 @@ import { MDXComponents } from 'mdx/types'
import * as React from 'react'
import { Link } from '@/components/Link'
+import { CardLink } from '@/components/mdx/CardLink'
import { CodeBlock } from '@/components/mdx/CodeBlock'
import { DocsHeading } from '@/components/mdx/DocsHeading'
import { Image } from '@/components/mdx/Image'
@@ -69,6 +70,7 @@ export const mdxComponents: MDXComponents = {
},
hr: (props) => ,
a: Link,
+ CardLink: CardLink,
p: (props) => ,
// FIXME: fix any type
@@ -120,4 +122,8 @@ export const rssComponents: MDXComponents = {
Mastodon: {url}
),
+
+ CardLink: ({ href, children }: React.ComponentProps) => (
+ {children}
+ ),
}
diff --git a/src/contents/posts/202405-review.mdx b/src/contents/posts/202405-review.mdx
new file mode 100644
index 00000000..307e68ae
--- /dev/null
+++ b/src/contents/posts/202405-review.mdx
@@ -0,0 +1,121 @@
+---
+title: '2024 年 5 月 振り返り'
+date: '2024-06-07T00:45:54+09:00'
+---
+
+5 月は充実した一月だった。せっかくだし記録に残しておきたいので、もう 6 月に入って一週間が経とうとしているが、一ヶ月間を振り返ってみる。
+
+## ゴールデンウィーク
+
+今年は 10 連休にした。もはや年末年始休暇よりも長いし、暇を持て余していた。
+たまの長期休暇だしめちゃくちゃ生産的な活動をしようと試みていたが、実際は家から出ずにゲームしかしていなかった。
+ワーケーション的なことをやろうとしたが、思い立ったのは GW 一週間前あたりで、良さげな宿がなく断念した。次の長期休暇は前もって行動したい。
+とはいえゲーム三昧はそれはそれで楽しかったので特に後悔はしてない。なんならもっとゲームしたい。
+
+## 登壇
+
+ここ最近は登壇意欲が高いが、5 月はさらに増していろいろやっていた。
+
+- 司会 @ [【初心者歓迎】登壇者と攻略するRubyKaigi 2024【プロも歓迎】 - connpass](https://smartbank.connpass.com/event/313812/)
+- [The Journey of rubocop-daemon into RuboCop - Speaker Deck](https://speakerdeck.com/fohte/the-journey-of-rubocop-daemon-into-rubocop) @ [LT - RubyKaigi 2024](https://rubykaigi.org/2024/presentations/lt/)
+- [Datadog Logs を活用して SLO 監視基盤を構築する - Speaker Deck](https://speakerdeck.com/fohte/datadog-logs-wohuo-yong-site-slo-jian-shi-ji-pan-wogou-zhu-suru) @ [Japan Datadog User Group Meetup#4 - connpass](https://datadog-jp.connpass.com/event/317091/)
+- [RubyKaigi で LT 初登壇したきっかけと感想 - Speaker Deck](https://speakerdeck.com/fohte/rubykaigi-te-lt-chu-deng-tan-sitakitukaketogan-xiang) @ [RubyKaigi 2024事後勉強会 - connpass](https://smarthr.connpass.com/event/319010/)
+
+今月だけでなく今年は頻繁に登壇していることもあり、だいぶ登壇慣れしてきた。
+
+登壇のモチベーションやメリットは RubyKaigi 2024 事後勉強会でも話したが、コミュニティに認知してもらえることや自分の発信に対するフィードバックがもらえることが特に嬉しいしありがたい。一連の rubocop-daemon 関連の発表では多くの人に共感してもらえたと思っているし、直近業務で難産だった Datadog Logs 活用の話では同じことをやられていた方から共感してもらえたり、新たなアイデアをもらえたりした。
+
+また、RubyKaigi で参加だけでなく LT までできたことはとても光栄に感じている。あの大規模な会場で話せたことは自信に繋がった。貴重な経験になった。次回も発表したいし KaigiEffect でなんらかをやっていきたい。
+
+## ゲーム
+
+### モンスターハンターライズ: サンブレイク
+
+
+
+GW でセールをしていたので、友人を誘ってやり始めた。今もまだやっていて、MR 80 くらいまで進んだ。
+
+モンハンは PS4 でプレーしたワールドぶりなのでかなり久々感がある。
+ライズははじめ Switch でリリースされていたこともあり、さすがにグラフィックやフレームレート的に厳しいと感じてプレーしていなかったのだが、Steam 版がリリースされて気になっていた。
+いまは UWQHD 144 fps でプレーしていてとても満足している。ヌルヌルサクサクだしグラフィックも感嘆するほど。
+
+アクション性が特に満足度高い。翔虫がとにかく良くて、これ無しのモンハンにはもう戻れない。移動が縦にも横にも快適になるし、鉄蟲糸技 (翔虫を使った攻撃技) も回避しながら攻撃できたりして楽しい。
+
+武器は弓を使っている。弓は見た目もいいし使用感も良く気に入っている。過去作ではハンマーメインで弓がサブだったけど、ライズでは弓しか使っていない。
+今作は弓が強い (らしい) というのもあるが、入れ替え技の身躱し矢斬りが火力を出しつつ回避の楽しさもあって特に面白い。以前は回避ランサーも好きだった [^1] し、それと似たような楽しさがある。
+スキルで狂化奮闘が出てからは特に強くて楽しい。無限スタミナだし、ワンパン乙みたいなこともなくなってとにかく快適。無限に身躱し矢斬りで攻撃しながら回避しつつ溜め 4 剛射を打ち続けられて、火力は出るし回避はいつでもできるし操作感が著しく改善された。スタミナ無限じゃないモンハンにはもう戻れない。
+
+[^1]: MHF でベルキュロス相手に極長ランスで回避しながら突くのめちゃくちゃ楽しかったし、またやりたい。
+
+そんな感じで、失った少年の心を取り戻すかの勢いでかなりハマっている。対人でもないからストレスが溜まらないのも良いポイント。
+
+### Sixtar Gate: STARTRAIL
+
+
+
+上から降ってくるタイプの音ゲー。BEMANI 機種でいうと SDVX が一番近い。ツマミがなく、白鍵盤が 5 つある SDVX。
+
+beatmania IIDX をやらなくなって久しいが、音ゲーはたまにやりたくなるので、最近はよくこれをやっている。家でぱっとできてかつ IIDX をある程度やっていた人でも楽しめるくらいの難易度の音ゲーとしてちょうど良い。
+
+かわいらしいデザインとは裏腹に音ゲーマーでもしっかりと楽しめるちゃんとした音ゲーなので、ぜひやってほしい。
+サウンドディレクターと譜面デザイナーが Sound Souler 氏なので、分かる人には音ゲーとしてちゃんとしていることが分かると思う。曲も某イベントの上位曲が入っていたり、譜面も最高難易度はしっかり難しくて、ちゃんと音ゲーしてる。
+
+目指せ全 PB (無理)。まずは全 SS から。いまは難易度 17 の SS に苦戦しているところで、S で精一杯。
+
+### Antimatter Dimensions
+
+
+
+[人生で最も時間を破壊された放置ゲー|zubu](https://note.com/zubububu/n/n62602227c1cc) を読んで軽い気持ちで始めてみたらめちゃくちゃ時間を溶かしてしまっている放置ゲー。
+Cookie Clicker に 10 倍くらい「数を増やす」要素を付け足したゲーム。1e100 とかそういうレベルではなく 1e1e9 とかになるくらい桁が無尽蔵に増えていく。
+Cookie Clicker にハマった人にはおすすめしたいゲーム。放置ゲーとはいいつつ適度にクリックする必要があり、時間は溶けるので注意。
+
+いまは Reality を解禁して 1e40 RM くらい。Reality は後から大型アップデートとして追加されただけあって、頻繁に新しい要素が追加されていくのが難しくもあり面白い。とりあえずクリア (クリアという概念はあるんだろうか) まではやりたい。
+
+### Vampire Survivors
+
+
+
+無双ゲー。2 年前くらいに流行っていたので知っている人も多そう。
+
+その流行っていた 2 年前にもプレーしていたのだが、最近また手を出してみたら要素が 2 年前の 10 倍くらい増えていた。死神を倒すために作られたような進化武器が追加されていたりする。
+
+なぜ今になってまたハマったかというと、Antimatter Dimensions で実績を解除していくのが楽しくて Steam 実績を集めるのが趣味になってしまったから。Vampire Survivors は 1 ゲームで 1 実績解除くらいできるので楽しい。本来の楽しみ方とは違うけど、実績解除ゲーとして楽しんでいる。
+
+## 映画
+
+### デッドデッドデーモンズデデデデデストラクション
+
+
+
+前編後編見た。漫画を全巻読破して感慨深い気持ちになっていたときに、ちょうど映画が上映されていることを知ったので見に行った。
+
+映画は漫画版が忠実に映像化されているという印象でとても良かった。特に音の臨場感が良く、漫画では味わえない壮大さを感じられた。
+
+ポストアポカリプス系の物語が好きなので、デデデデも例に漏れず刺さった。浅野いにおさんの作品はデデデデが初めてで、漫画の 1, 2 巻あたりはハイテンションについていけずなんだこれとなっていたが、読み進めていくとどんどん深みが出て面白くなった。ネタバレになるので書くことが限られるのだが、絶望的な展開が晴れていくような物語が好きな人にはおすすめ。
+
+## 漫画
+
+### それでも町は廻っている
+
+
+
+同じ作者の『[天国大魔境](https://amzn.to/4e6XW3r)』がとにかく面白く、同じ作者であれば絶対に面白いだろうと信じて読んだらやはり期待通りとても面白かった。
+『天国大魔境』はポストアポカリプス系でくすっと笑えるコメディ要素が入った漫画なのだが、この作品はコメディ全振りでよかった。ギャグのテンポが早くて心地よいし、シュールな笑いが持続する感じがとてもよい。
+読破してしまったけど、無限に続いてほしい作品だった。
+
+### おやすみプンプン
+
+
+
+デデデデの作者である浅野いにおさんの漫画。これはポストアポカリプス系ではなく、デデデデに似た謎のテンションで始まり話が進むにつれて深みが出てくるタイプの作品だった。
+救いのない鬱展開だったが面白く読めた。個人的に好きな展開としては鬱展開から這い上がっていく物語が好きなのだが、この作品は這い上がらずにそのまま鬱展開のまま進んでいくのがなかなか味わえない良い意味での気味悪さがあった。
+
+結局最後まで訳が分からない部分もあったのだが、以下の考察記事を読んでかなり納得感があった。読破した人は読んでほしい。
+
+
+
+## 最後に
+
+ブログは書きたいけど適切な粒度が見つからない、見つかっても書くハードルが高いなと思っていたので、月次振り返りはちょうどいいかもしれない。来月は今月分も振り返っていきたい。
+その振り返るためのネタができるくらいには人生をやっていきたい所存。KaigiEffect もやっていきたい。
diff --git a/src/data/ogp.json b/src/data/ogp.json
new file mode 100644
index 00000000..cc159925
--- /dev/null
+++ b/src/data/ogp.json
@@ -0,0 +1,34 @@
+{
+ "https://store.steampowered.com/app/1446780/MONSTER_HUNTER_RISE/?l=japanese": {
+ "title": "Steam:MONSTER HUNTER RISE",
+ "description": "躍動する狩猟本能! 狩猟に新風を巻き起こす、縦横無尽に躍動するアクション。思いのままに翔けあがれる、新たなフィールド。未知の興奮や驚きをもたらす、全く新しいモンスターたち。 「モンスターハンターライズ」で、かつてない狩猟体験がハンターたちを待っている!",
+ "image": "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/1446780/capsule_616x353.jpg?t=1715075183"
+ },
+ "https://store.steampowered.com/app/1802720/_/?l=japanese": {
+ "title": "Steam:シクスターゲート・スタートレール",
+ "description": "『Sixtar Gate: STARTRAIL (シクスターゲート・スタートレール)』は、さまざまな惑星を巡る宇宙航海を題材としたリズムゲーム。科学探査艦の艦長となって、ナビゲーター「シイ」と共に広大なシクスター星系を探査しよう!",
+ "image": "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/1802720/capsule_616x353.jpg?t=1698308601"
+ },
+ "https://store.steampowered.com/app/1399720/Antimatter_Dimensions/": {
+ "title": "Antimatter Dimensions on Steam",
+ "description": "Earn antimatter through dimensions! Gain Infinite antimatter to collapse the universe!",
+ "image": "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/1399720/capsule_616x353.jpg?t=1671837279"
+ },
+ "https://store.steampowered.com/app/1794680/Vampire_Survivors/?l=japanese": {
+ "title": "Steam:Vampire Survivors",
+ "description": "何千もの恐ろしい夜の化け物を退治して、夜明けまで生き延びよう! 『Vampire Survivors』は、ローグライト要素を持つゴシックホラーカジュアルゲーム。プレイヤーの選択次第で、迫りくる数百のモンスターを素早く撃破できます。",
+ "image": "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/1794680/capsule_616x353.jpg?t=1715080146"
+ },
+ "https://amzn.to/3V4nwxq": {
+ "title": "Amazon.co.jp: それでも町は廻っている(1) (ヤングキングコミックス) eBook : 石黒正数: Kindleストア",
+ "image": "https://images.amazon.com/images/P/B00GD2GVI4.09_SL110_.jpg"
+ },
+ "https://amzn.to/3Vf9Z62": {
+ "title": "Amazon.co.jp: おやすみプンプン(1) (ヤングサンデーコミックス) eBook : 浅野いにお: Kindleストア",
+ "image": "https://images.amazon.com/images/P/B00BHTSYJC.09_SL110_.jpg"
+ },
+ "https://gumichoko.hatenablog.com/entry/2019/07/26/230937": {
+ "title": "【考察】おやすみプンプンにおける「黒点」とは何だったのか - 雲を掴むような",
+ "description": "「おやすみプンプン」は浅野いにおが2007年からヤングサンデーで連載(2008年のヤンサン休刊に伴いその後はビックコミックスピリッツに移った)された全13巻の作品で、どこにでも居るような男の子、プンプンの半生を描いた物語です。 この作品はよく「鬱漫画」として紹介される事が多いです。 確かにファムファタル・自意識・人間関係・生死・愛、といった重く苦しいテーマを取り扱っている為、読んでいて苦しくなってしまう場面というのは往々にしてありますし、途中で挟まれるペガサス合唱団の話なんかも意味不明で「なんのこっちゃ」といったところで… よくわかんないけど鬱漫画って感じ…と、作品を読み終えて評価している人は…"
+ }
+}