Skip to content

Commit 4307e49

Browse files
authored
Merge pull request #67 from ut-code/develop
release 2025/12/26
2 parents 14c81f4 + fe2ef23 commit 4307e49

File tree

8 files changed

+513
-1081
lines changed

8 files changed

+513
-1081
lines changed

client/functions/[[path]].ts

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ interface ProjectData {
1717
}>;
1818
}
1919

20-
// og:title を書き換える HTMLRewriter
2120
class OGTitleRewriter {
2221
private title: string;
2322

@@ -32,7 +31,6 @@ class OGTitleRewriter {
3231
}
3332
}
3433

35-
// og:description を書き換える HTMLRewriter
3634
class OGDescriptionRewriter {
3735
private description: string;
3836

@@ -47,7 +45,20 @@ class OGDescriptionRewriter {
4745
}
4846
}
4947

50-
// 日付を YYYY/MM/DD 形式にフォーマット(日本時間)
48+
class OGImageRewriter {
49+
private imageUrl: string;
50+
51+
constructor(imageUrl: string) {
52+
this.imageUrl = imageUrl;
53+
}
54+
55+
element(element: Element) {
56+
if (element.getAttribute("property") === "og:image") {
57+
element.setAttribute("content", this.imageUrl);
58+
}
59+
}
60+
}
61+
5162
function formatDate(dateString: string): string {
5263
const date = new Date(dateString);
5364
return new Intl.DateTimeFormat("ja-JP", {
@@ -74,14 +85,12 @@ async function fetchProjectData(eventId: string, apiEndpoint: string): Promise<P
7485
}
7586
}
7687

77-
// eventId が有効かチェック(21文字のnanoid)
78-
function isValidEventId(path: string): boolean {
88+
function isValidEventPath(path: string): boolean {
7989
// 新パス /e/eventId または 旧パス /eventId の形式で、eventId が21文字の英数字・ハイフン・アンダースコア
8090
const match = path.match(/^\/(?:e\/)?([A-Za-z0-9_-]{21})$/);
8191
return !!match;
8292
}
8393

84-
// パスから eventId を抽出
8594
function extractEventId(path: string): string | null {
8695
// 新パス /e/eventId または 旧パス /eventId から eventId を抽出
8796
const match = path.match(/^\/(?:e\/)?([A-Za-z0-9_-]{21})$/);
@@ -104,8 +113,7 @@ export async function onRequest(context: EventContext<Env, any, any>): Promise<R
104113
return await next();
105114
}
106115

107-
// eventId パターンをチェック
108-
if (!isValidEventId(path)) {
116+
if (!isValidEventPath(path)) {
109117
return await next();
110118
}
111119

@@ -117,33 +125,36 @@ export async function onRequest(context: EventContext<Env, any, any>): Promise<R
117125
// 元のHTMLレスポンスを取得
118126
const response = await next();
119127

120-
// HTMLでない場合はそのまま返す
121128
const contentType = response.headers.get("content-type");
122129

123-
// 304 Not Modified の場合やHTMLでない場合はスキップ
130+
// 304 Not Modified や HTML でない場合はスキップ
124131
if (response.status === 304 || (!contentType?.includes("text/html") && contentType !== null)) {
125132
return response;
126133
}
127134

128-
// プロジェクト情報を取得
129135
const projectData = await fetchProjectData(eventId, env.API_ENDPOINT);
130136

137+
const defaultOgImageUrl = `${url.origin}/og-image.jpg`;
138+
131139
if (!projectData) {
132-
return response;
140+
// プロジェクトが見つからない場合はデフォルト画像を設定
141+
return new HTMLRewriter()
142+
.on('meta[property="og:image"]', new OGImageRewriter(defaultOgImageUrl))
143+
.transform(response);
133144
}
134145

135-
// og:title を書き換え
136146
const ogTitle = `${projectData.name} - イツヒマ`;
137147

138-
// og:description を作成
139148
const startDate = formatDate(projectData.startDate);
140149
const endDate = formatDate(projectData.endDate);
141-
const dateRange = startDate === endDate ? startDate : `${startDate} - ${endDate}`;
142-
150+
const dateRange = startDate === endDate ? startDate : `${startDate}${endDate}`;
143151
const ogDescription = `日程範囲: ${dateRange}`;
144152

153+
const ogImageUrl = `${url.origin}/og/${eventId}`;
154+
145155
return new HTMLRewriter()
146156
.on('meta[property="og:title"]', new OGTitleRewriter(ogTitle))
147157
.on('meta[property="og:description"]', new OGDescriptionRewriter(ogDescription))
158+
.on('meta[property="og:image"]', new OGImageRewriter(ogImageUrl))
148159
.transform(response);
149160
}

client/functions/og/[eventId].tsx

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
/// <reference types="@cloudflare/workers-types" />
2+
import { ImageResponse } from "@cloudflare/pages-plugin-vercel-og/api";
3+
// biome-ignore lint/correctness/noUnusedImports: React is needed for JSX transform
4+
import React from "react";
5+
6+
interface Env {
7+
API_ENDPOINT: string;
8+
}
9+
10+
interface ProjectData {
11+
id: string;
12+
name: string;
13+
startDate: string;
14+
endDate: string;
15+
}
16+
17+
async function fetchProjectData(eventId: string, apiEndpoint: string): Promise<ProjectData | null> {
18+
try {
19+
const response = await fetch(`${apiEndpoint}/projects/${eventId}`);
20+
if (!response.ok) {
21+
return null;
22+
}
23+
return await response.json();
24+
} catch (error) {
25+
console.error("Failed to fetch project data:", error);
26+
return null;
27+
}
28+
}
29+
30+
function formatDate(dateString: string): string {
31+
const date = new Date(dateString);
32+
return new Intl.DateTimeFormat("ja-JP", {
33+
year: "numeric",
34+
month: "2-digit",
35+
day: "2-digit",
36+
timeZone: "Asia/Tokyo",
37+
})
38+
.format(date)
39+
.replace(/-/g, "/");
40+
}
41+
42+
function truncateByWidth(text: string, maxWidth = 60): string {
43+
let width = 0;
44+
let result = "";
45+
46+
for (const char of text) {
47+
// ASCII なら 1、それ以外は 2
48+
const charWidth = char.charCodeAt(0) < 128 ? 1 : 2;
49+
50+
if (width + charWidth > maxWidth) {
51+
break;
52+
}
53+
54+
width += charWidth;
55+
result += char;
56+
}
57+
58+
if (result.length < text.length) {
59+
result += "…";
60+
}
61+
62+
return result;
63+
}
64+
65+
export const onRequestGet: PagesFunction<Env, "eventId"> = async (context) => {
66+
const { env, params } = context;
67+
const eventId = params.eventId;
68+
69+
if (typeof eventId !== "string") {
70+
console.error("eventId is not a string");
71+
return new Response("Invalid event ID", { status: 400 });
72+
}
73+
74+
if (!eventId || !/^[A-Za-z0-9_-]{21}$/.test(eventId)) {
75+
return new Response("Invalid event ID", { status: 400 });
76+
}
77+
78+
const projectData = await fetchProjectData(eventId, env.API_ENDPOINT);
79+
80+
if (!projectData) {
81+
// プロジェクトがない場合はデフォルト画像にリダイレクト
82+
const url = new URL(context.request.url);
83+
return Response.redirect(`${url.origin}/og-image.jpg`, 302);
84+
}
85+
86+
const fontData = await fetch(
87+
"https://unpkg.com/@fontsource/[email protected]/files/m-plus-1p-japanese-700-normal.woff",
88+
).then((res) => res.arrayBuffer());
89+
90+
const projectName = truncateByWidth(projectData.name, 72);
91+
92+
const startDate = formatDate(projectData.startDate);
93+
const endDate = formatDate(projectData.endDate);
94+
const dateRange = startDate === endDate ? startDate : `${startDate}${endDate}`;
95+
96+
const response = new ImageResponse(
97+
<div
98+
style={{
99+
display: "flex",
100+
backgroundColor: "#0f82b1",
101+
padding: "48px 56px",
102+
width: "100%",
103+
height: "100%",
104+
fontFamily: "M PLUS 1p",
105+
fontWeight: 700,
106+
}}
107+
>
108+
<div
109+
style={{
110+
display: "flex",
111+
backgroundColor: "#fff",
112+
color: "#555",
113+
flexDirection: "column",
114+
borderRadius: "32px",
115+
flex: 1,
116+
alignItems: "center",
117+
padding: "40px",
118+
justifyContent: "space-between",
119+
}}
120+
>
121+
<div
122+
style={{
123+
display: "flex",
124+
flexDirection: "column",
125+
gap: "24px",
126+
justifyContent: "center",
127+
alignItems: "center",
128+
}}
129+
>
130+
<span
131+
style={{
132+
fontSize: "64px",
133+
textAlign: "center",
134+
wordBreak: "break-word",
135+
}}
136+
>
137+
{projectName}
138+
</span>
139+
<span
140+
style={{
141+
fontSize: "40px",
142+
textAlign: "center",
143+
}}
144+
>
145+
{dateRange}
146+
</span>
147+
</div>
148+
<div
149+
style={{
150+
color: "#0f82b1",
151+
display: "flex",
152+
alignItems: "center",
153+
gap: "8px",
154+
fontSize: "40px",
155+
}}
156+
>
157+
<svg
158+
width="48"
159+
height="48"
160+
viewBox="0 0 50 50"
161+
fill="none"
162+
xmlns="http://www.w3.org/2000/svg"
163+
role="img"
164+
aria-hidden="true"
165+
>
166+
<rect x="34" width="16" height="33" rx="2" fill="#0F82B1" />
167+
<rect y="17" width="33" height="16" rx="2" fill="#0F82B1" />
168+
<rect x="17" y="34" width="16" height="16" rx="2" fill="#0F82B1" />
169+
</svg>
170+
<div>イツヒマ</div>
171+
</div>
172+
</div>
173+
</div>,
174+
{
175+
width: 1200,
176+
height: 630,
177+
fonts: [
178+
{
179+
name: "M PLUS 1p",
180+
data: fontData,
181+
weight: 700,
182+
style: "normal",
183+
},
184+
],
185+
},
186+
);
187+
188+
return response;
189+
};

client/package.json

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,12 @@
1212
"preview": "vite preview"
1313
},
1414
"dependencies": {
15-
"@emotion/react": "^11.14.0",
16-
"@emotion/styled": "^11.14.0",
15+
"@cloudflare/pages-plugin-vercel-og": "^0.1.2",
1716
"@fullcalendar/core": "^6.1.15",
1817
"@fullcalendar/interaction": "^6.1.15",
1918
"@fullcalendar/react": "^6.1.15",
2019
"@fullcalendar/timegrid": "^6.1.15",
2120
"@hookform/resolvers": "^4.1.3",
22-
"@mui/material": "^6.4.7",
2321
"@tailwindcss/vite": "^4.0.13",
2422
"dayjs": "^1.11.13",
2523
"react": "^19.0.0",
@@ -37,7 +35,6 @@
3735
"@types/react-dom": "^19.0.4",
3836
"@vitejs/plugin-react": "^4.3.4",
3937
"daisyui": "^5.0.3",
40-
"globals": "^15.15.0",
4138
"hono": "^4.9.6",
4239
"typescript": "~5.7.2",
4340
"vite": "^6.3.5"

client/src/pages/Home.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,9 @@ export default function HomePage() {
3131
let errorMessage = "イベントの取得に失敗しました。";
3232
try {
3333
const data = await res.json();
34-
if (data && typeof data.message === "string" && data.message.trim()) {
35-
errorMessage = data.message.trim();
34+
const err = data as unknown as { message: string }; // Middleware のレスポンスは Hono RPC の型に乗らない
35+
if (typeof err.message === "string" && err.message.trim()) {
36+
errorMessage = err.message.trim();
3637
}
3738
} catch (_) {
3839
// レスポンスがJSONでない場合は無視

client/src/pages/Project.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ export default function ProjectPage() {
9393
projectName: string;
9494
} | null>(null);
9595

96+
// TODO: グローバルにしないと、delete の際は遷移を伴うので表示されない
9697
const [toast, setToast] = useState<{
9798
message: string;
9899
variant: "success" | "error";
@@ -215,9 +216,8 @@ export default function ProjectPage() {
215216
projectName: name,
216217
});
217218
} else {
218-
const { message } = await res.json();
219219
setToast({
220-
message,
220+
message: "イベントの作成に失敗しました。",
221221
variant: "error",
222222
});
223223
setTimeout(() => setToast(null), 3000);
@@ -604,6 +604,7 @@ export default function ProjectPage() {
604604
if (!res.ok) {
605605
throw new Error("削除に失敗しました。");
606606
}
607+
// TODO: トーストをグローバルにする
607608
navigate("/home");
608609
setToast({
609610
message: "イベントを削除しました。",

0 commit comments

Comments
 (0)