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";