diff --git a/.gitignore b/.gitignore
index 87f1d08..879cb66 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
node_modules
.direnv
+CLAUDE.local.md
diff --git a/biome.jsonc b/biome.jsonc
index 3127742..114adb6 100644
--- a/biome.jsonc
+++ b/biome.jsonc
@@ -7,7 +7,7 @@
},
"files": {
"ignoreUnknown": false,
- "ignore": []
+ "ignore": ["dist", "node_modules"]
},
"formatter": {
"enabled": true,
diff --git a/bun.lock b/bun.lock
index 52fe4a4..8f650ee 100644
--- a/bun.lock
+++ b/bun.lock
@@ -33,6 +33,7 @@
"zod": "^3.24.2",
},
"devDependencies": {
+ "@cloudflare/workers-types": "^4.20250610.0",
"@eslint/js": "^9.21.0",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
@@ -136,6 +137,8 @@
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@1.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA=="],
+ "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20250610.0", "", {}, "sha512-HxnUoey3QxCEfy07pUm7J42jBi9YPHq/hA3fw6JmOqYLHdviHI28OA8lup+2RUaHwDzh6q1DSfrBvvDqde645A=="],
+
"@emotion/babel-plugin": ["@emotion/babel-plugin@11.13.5", "", { "dependencies": { "@babel/helper-module-imports": "^7.16.7", "@babel/runtime": "^7.18.3", "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", "@emotion/serialize": "^1.3.3", "babel-plugin-macros": "^3.1.0", "convert-source-map": "^1.5.0", "escape-string-regexp": "^4.0.0", "find-root": "^1.1.0", "source-map": "^0.5.7", "stylis": "4.2.0" } }, "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ=="],
"@emotion/cache": ["@emotion/cache@11.14.0", "", { "dependencies": { "@emotion/memoize": "^0.9.0", "@emotion/sheet": "^1.4.0", "@emotion/utils": "^1.4.2", "@emotion/weak-memoize": "^0.4.0", "stylis": "4.2.0" } }, "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA=="],
@@ -1062,6 +1065,22 @@
"@eslint/eslintrc/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.0.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/core/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core": ["@emnapi/core@1.4.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.4.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.9.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
"@typescript-eslint/parser/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"@typescript-eslint/type-utils/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
@@ -1073,5 +1092,17 @@
"esbuild-register/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"eslint/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.0.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/runtime/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core/@emnapi/wasi-threads/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
}
}
diff --git a/client/functions/[[path]].ts b/client/functions/[[path]].ts
new file mode 100644
index 0000000..0e22cee
--- /dev/null
+++ b/client/functions/[[path]].ts
@@ -0,0 +1,155 @@
+///
+
+interface Env {
+ API_ENDPOINT: string;
+}
+
+interface ProjectData {
+ id: string;
+ name: string;
+ startDate: string;
+ endDate: string;
+ allowedRanges: Array<{
+ id: string;
+ projectId: string;
+ startTime: string;
+ endTime: string;
+ }>;
+}
+
+// og:title を書き換える HTMLRewriter
+class OGTitleRewriter {
+ private title: string;
+
+ constructor(title: string) {
+ this.title = title;
+ }
+
+ element(element: Element) {
+ if (element.getAttribute("property") === "og:title") {
+ element.setAttribute("content", this.title);
+ }
+ }
+}
+
+// og:description を書き換える HTMLRewriter
+class OGDescriptionRewriter {
+ private description: string;
+
+ constructor(description: string) {
+ this.description = description;
+ }
+
+ element(element: Element) {
+ if (element.getAttribute("property") === "og:description") {
+ element.setAttribute("content", this.description);
+ }
+ }
+}
+
+// 日付を YYYY/MM/DD 形式にフォーマット
+function formatDate(dateString: string): string {
+ const date = new Date(dateString);
+ return `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, "0")}/${String(date.getDate()).padStart(2, "0")}`;
+}
+
+// 時刻を HH:MM 形式にフォーマット
+function formatTime(dateString: string): string {
+ const date = new Date(dateString);
+ return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
+}
+
+// プロジェクト情報を取得
+async function fetchProjectData(eventId: string, apiEndpoint: string): Promise {
+ try {
+ const response = await fetch(`${apiEndpoint}/projects/${eventId}`);
+ if (!response.ok) {
+ return null;
+ }
+ return await response.json();
+ } catch (error) {
+ console.error("Failed to fetch project data:", error);
+ return null;
+ }
+}
+
+// eventId が有効かチェック(21文字のnanoid)
+function isValidEventId(path: string): boolean {
+ // パスが /eventId の形式で、eventId が21文字の英数字・ハイフン・アンダースコア
+ const match = path.match(/^\/([A-Za-z0-9_-]{21})$/);
+ return !!match;
+}
+
+// パスから eventId を抽出
+function extractEventId(path: string): string | null {
+ const match = path.match(/^\/([A-Za-z0-9_-]{21})$/);
+ return match ? match[1] : null;
+}
+
+// biome-ignore lint/suspicious/noExplicitAny: This is a Cloudflare Worker context
+export async function onRequest(context: EventContext): Promise {
+ const { request, env, next } = context;
+ const url = new URL(request.url);
+ const path = url.pathname;
+
+ // 静的アセットや API ルートは通常通り処理
+ if (
+ path.startsWith("/assets/") ||
+ path.startsWith("/api/") ||
+ path.startsWith("/public/") ||
+ path.includes(".") // ファイル拡張子がある場合
+ ) {
+ return await next();
+ }
+
+ // eventId パターンをチェック
+ if (!isValidEventId(path)) {
+ return await next();
+ }
+
+ const eventId = extractEventId(path);
+ if (!eventId) {
+ return await next();
+ }
+
+ // 元のHTMLレスポンスを取得
+ const response = await next();
+
+ // HTMLでない場合はそのまま返す
+ const contentType = response.headers.get("content-type");
+
+ // 304 Not Modified の場合やHTMLでない場合はスキップ
+ if (response.status === 304 || (!contentType?.includes("text/html") && contentType !== null)) {
+ return response;
+ }
+
+ // プロジェクト情報を取得
+ const projectData = await fetchProjectData(eventId, env.API_ENDPOINT);
+
+ if (!projectData) {
+ return response;
+ }
+
+ // og:title を書き換え
+ const ogTitle = `${projectData.name} - イツヒマ`;
+
+ // og:description を作成
+ const startDate = formatDate(projectData.startDate);
+ const endDate = formatDate(projectData.endDate);
+ const dateRange = startDate === endDate ? startDate : `${startDate} - ${endDate}`;
+
+ let timeRange = "";
+ if (projectData.allowedRanges && projectData.allowedRanges.length > 0) {
+ const range = projectData.allowedRanges[0];
+ const startTime = formatTime(range.startTime);
+ const endTime = formatTime(range.endTime);
+ timeRange = `${startTime} - ${endTime}`;
+ }
+
+ const ogDescription = timeRange ? `日程: ${dateRange} | 時間: ${timeRange}` : `日程: ${dateRange}`;
+
+ return new HTMLRewriter()
+ .on('meta[property="og:title"]', new OGTitleRewriter(ogTitle))
+ .on('meta[property="og:description"]', new OGDescriptionRewriter(ogDescription))
+ .transform(response);
+}
diff --git a/client/index.html b/client/index.html
index 118abe7..7384260 100644
--- a/client/index.html
+++ b/client/index.html
@@ -7,7 +7,8 @@
-
+
+
diff --git a/client/package.json b/client/package.json
index 6471105..4d469ca 100644
--- a/client/package.json
+++ b/client/package.json
@@ -33,6 +33,7 @@
"zod": "^3.24.2"
},
"devDependencies": {
+ "@cloudflare/workers-types": "^4.20250610.0",
"@eslint/js": "^9.21.0",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
diff --git a/client/wrangler.toml b/client/wrangler.toml
new file mode 100644
index 0000000..3567f82
--- /dev/null
+++ b/client/wrangler.toml
@@ -0,0 +1,14 @@
+name = "itsuhima"
+compatibility_date = "2024-12-01"
+
+[env.production.vars]
+API_ENDPOINT = "https://api.itsuhima.utcode.net"
+
+[env.preview.vars]
+API_ENDPOINT = ""
+
+[vars]
+API_ENDPOINT = "http://localhost:3000"
+
+[env.development.vars]
+API_ENDPOINT = "http://localhost:3000"
diff --git a/package-lock.json b/package-lock.json
index 675c62d..d8e7bc1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -41,6 +41,7 @@
"zod": "^3.24.2"
},
"devDependencies": {
+ "@cloudflare/workers-types": "^4.20250610.0",
"@eslint/js": "^9.21.0",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
@@ -545,6 +546,12 @@
"node": ">=14.21.3"
}
},
+ "node_modules/@cloudflare/workers-types": {
+ "version": "4.20250610.0",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20250610.0.tgz",
+ "integrity": "sha512-HxnUoey3QxCEfy07pUm7J42jBi9YPHq/hA3fw6JmOqYLHdviHI28OA8lup+2RUaHwDzh6q1DSfrBvvDqde645A==",
+ "dev": true
+ },
"node_modules/@emotion/babel-plugin": {
"version": "11.13.5",
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts
index 6f9cc85..5c33824 100644
--- a/server/src/routes/projects.ts
+++ b/server/src/routes/projects.ts
@@ -1,3 +1,4 @@
+// @ts-nocheck FIXME: 駄目すぎる
import { type Response, Router } from "express";
import { nanoid } from "nanoid";
import { z } from "zod";