Skip to content

Commit 4f80e46

Browse files
committed
feat: OGP の出し分け
1 parent 7811f8a commit 4f80e46

File tree

8 files changed

+182
-2
lines changed

8 files changed

+182
-2
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
node_modules
22
.direnv
3+
CLAUDE.local.md

biome.jsonc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
},
88
"files": {
99
"ignoreUnknown": false,
10-
"ignore": []
10+
"ignore": ["dist", "node_modules"]
1111
},
1212
"formatter": {
1313
"enabled": true,

client/functions/[[path]].ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/// <reference types="@cloudflare/workers-types" />
2+
3+
interface Env {
4+
API_ENDPOINT: string;
5+
}
6+
7+
interface ProjectData {
8+
id: string;
9+
name: string;
10+
startDate: string;
11+
endDate: string;
12+
allowedRanges: Array<{
13+
id: string;
14+
projectId: string;
15+
startTime: string;
16+
endTime: string;
17+
}>;
18+
}
19+
20+
// og:title を書き換える HTMLRewriter
21+
class OGTitleRewriter {
22+
private title: string;
23+
24+
constructor(title: string) {
25+
this.title = title;
26+
}
27+
28+
element(element: Element) {
29+
if (element.getAttribute("property") === "og:title") {
30+
element.setAttribute("content", this.title);
31+
}
32+
}
33+
}
34+
35+
// og:description を書き換える HTMLRewriter
36+
class OGDescriptionRewriter {
37+
private description: string;
38+
39+
constructor(description: string) {
40+
this.description = description;
41+
}
42+
43+
element(element: Element) {
44+
if (element.getAttribute("property") === "og:description") {
45+
element.setAttribute("content", this.description);
46+
}
47+
}
48+
}
49+
50+
// 日付を YYYY/MM/DD 形式にフォーマット
51+
function formatDate(dateString: string): string {
52+
const date = new Date(dateString);
53+
return `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, "0")}/${String(date.getDate()).padStart(2, "0")}`;
54+
}
55+
56+
// 時刻を HH:MM 形式にフォーマット
57+
function formatTime(dateString: string): string {
58+
const date = new Date(dateString);
59+
return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
60+
}
61+
62+
// プロジェクト情報を取得
63+
async function fetchProjectData(eventId: string, apiEndpoint: string): Promise<ProjectData | null> {
64+
try {
65+
const response = await fetch(`${apiEndpoint}/projects/${eventId}`);
66+
if (!response.ok) {
67+
return null;
68+
}
69+
return await response.json();
70+
} catch (error) {
71+
console.error("Failed to fetch project data:", error);
72+
return null;
73+
}
74+
}
75+
76+
// eventId が有効かチェック(21文字のnanoid)
77+
function isValidEventId(path: string): boolean {
78+
// パスが /eventId の形式で、eventId が21文字の英数字・ハイフン・アンダースコア
79+
const match = path.match(/^\/([A-Za-z0-9_-]{21})$/);
80+
return !!match;
81+
}
82+
83+
// パスから eventId を抽出
84+
function extractEventId(path: string): string | null {
85+
const match = path.match(/^\/([A-Za-z0-9_-]{21})$/);
86+
return match ? match[1] : null;
87+
}
88+
89+
// biome-ignore lint/suspicious/noExplicitAny: This is a Cloudflare Worker context
90+
export async function onRequest(context: EventContext<Env, any, any>): Promise<Response> {
91+
const { request, env, next } = context;
92+
const url = new URL(request.url);
93+
const path = url.pathname;
94+
95+
// 静的アセットや API ルートは通常通り処理
96+
if (
97+
path.startsWith("/assets/") ||
98+
path.startsWith("/api/") ||
99+
path.startsWith("/public/") ||
100+
path.includes(".") // ファイル拡張子がある場合
101+
) {
102+
return await next();
103+
}
104+
105+
// eventId パターンをチェック
106+
if (!isValidEventId(path)) {
107+
return await next();
108+
}
109+
110+
const eventId = extractEventId(path);
111+
if (!eventId) {
112+
return await next();
113+
}
114+
115+
// 元のHTMLレスポンスを取得
116+
const response = await next();
117+
118+
// HTMLでない場合はそのまま返す
119+
const contentType = response.headers.get("content-type");
120+
121+
// 304 Not Modified の場合やHTMLでない場合はスキップ
122+
if (response.status === 304 || (!contentType?.includes("text/html") && contentType !== null)) {
123+
return response;
124+
}
125+
126+
// プロジェクト情報を取得
127+
const projectData = await fetchProjectData(eventId, env.API_ENDPOINT);
128+
129+
if (!projectData) {
130+
return response;
131+
}
132+
133+
// og:title を書き換え
134+
const ogTitle = `${projectData.name} - イツヒマ`;
135+
136+
// og:description を作成
137+
const startDate = formatDate(projectData.startDate);
138+
const endDate = formatDate(projectData.endDate);
139+
const dateRange = startDate === endDate ? startDate : `${startDate} - ${endDate}`;
140+
141+
let timeRange = "";
142+
if (projectData.allowedRanges && projectData.allowedRanges.length > 0) {
143+
const range = projectData.allowedRanges[0];
144+
const startTime = formatTime(range.startTime);
145+
const endTime = formatTime(range.endTime);
146+
timeRange = `${startTime} - ${endTime}`;
147+
}
148+
149+
const ogDescription = timeRange ? `日程: ${dateRange} | 時間: ${timeRange}` : `日程: ${dateRange}`;
150+
151+
return new HTMLRewriter()
152+
.on('meta[property="og:title"]', new OGTitleRewriter(ogTitle))
153+
.on('meta[property="og:description"]', new OGDescriptionRewriter(ogDescription))
154+
.transform(response);
155+
}

client/index.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
88
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
99
<meta property="og:type" content="website" />
10-
<meta property="og:title" content="イツヒマ - 「いつ暇?」で日程調整しよう" />
10+
<meta property="og:title" content="イツヒマ" />
11+
<meta property="og:description" content="「いつ暇?」で日程調整しよう" />
1112
<meta property="og:image" content="https://itsuhima.utcode.net/og-image.webp" />
1213
<meta property="og:site_name" content="イツヒマ" />
1314
<meta property="og:locale" content="ja_JP" />

client/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"zod": "^3.24.2"
3434
},
3535
"devDependencies": {
36+
"@cloudflare/workers-types": "^4.20250610.0",
3637
"@eslint/js": "^9.21.0",
3738
"@types/react": "^19.0.10",
3839
"@types/react-dom": "^19.0.4",

client/wrangler.toml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
name = "itsuhima"
2+
compatibility_date = "2024-12-01"
3+
4+
[env.production.vars]
5+
API_ENDPOINT = "https://api.itsuhima.utcode.net"
6+
7+
[env.preview.vars]
8+
API_ENDPOINT = ""
9+
10+
[vars]
11+
API_ENDPOINT = "http://localhost:3000"
12+
13+
[env.development.vars]
14+
API_ENDPOINT = "http://localhost:3000"

package-lock.json

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/src/routes/projects.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// @ts-nocheck FIXME: 駄目すぎる
12
import { type Response, Router } from "express";
23
import { nanoid } from "nanoid";
34
import { z } from "zod";

0 commit comments

Comments
 (0)