Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
node_modules
.direnv
CLAUDE.local.md
2 changes: 1 addition & 1 deletion biome.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
},
"files": {
"ignoreUnknown": false,
"ignore": []
"ignore": ["dist", "node_modules"]
},
"formatter": {
"enabled": true,
Expand Down
31 changes: 31 additions & 0 deletions bun.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -136,6 +137,8 @@

"@biomejs/cli-win32-x64": ["@biomejs/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA=="],

"@cloudflare/workers-types": ["@cloudflare/[email protected]", "", {}, "sha512-HxnUoey3QxCEfy07pUm7J42jBi9YPHq/hA3fw6JmOqYLHdviHI28OA8lup+2RUaHwDzh6q1DSfrBvvDqde645A=="],

"@emotion/babel-plugin": ["@emotion/[email protected]", "", { "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/[email protected]", "", { "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=="],
Expand Down Expand Up @@ -1062,6 +1065,22 @@

"@eslint/eslintrc/debug/ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],

"@tailwindcss/oxide-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA=="],

"@tailwindcss/oxide-wasm32-wasi/@emnapi/core/tslib": ["[email protected]", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],

"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime/tslib": ["[email protected]", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],

"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads/tslib": ["[email protected]", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],

"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core": ["@emnapi/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw=="],

"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util/tslib": ["[email protected]", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],

"@typescript-eslint/parser/debug/ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],

"@typescript-eslint/type-utils/debug/ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
Expand All @@ -1073,5 +1092,17 @@
"esbuild-register/debug/ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],

"eslint/debug/ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],

"@tailwindcss/oxide-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads/tslib": ["[email protected]", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],

"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA=="],

"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core/tslib": ["[email protected]", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],

"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/runtime/tslib": ["[email protected]", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],

"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util/tslib": ["[email protected]", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],

"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core/@emnapi/wasi-threads/tslib": ["[email protected]", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
}
}
155 changes: 155 additions & 0 deletions client/functions/[[path]].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/// <reference types="@cloudflare/workers-types" />

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<ProjectData | null> {
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<Env, any, any>): Promise<Response> {
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);
}
3 changes: 2 additions & 1 deletion client/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta property="og:type" content="website" />
<meta property="og:title" content="イツヒマ - 「いつ暇?」で日程調整しよう" />
<meta property="og:title" content="イツヒマ" />
<meta property="og:description" content="「いつ暇?」で日程調整しよう" />
<meta property="og:image" content="https://itsuhima.utcode.net/og-image.webp" />
<meta property="og:site_name" content="イツヒマ" />
<meta property="og:locale" content="ja_JP" />
Expand Down
1 change: 1 addition & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 14 additions & 0 deletions client/wrangler.toml
Original file line number Diff line number Diff line change
@@ -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"
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions server/src/routes/projects.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// @ts-nocheck FIXME: 駄目すぎる
import { type Response, Router } from "express";
import { nanoid } from "nanoid";
import { z } from "zod";
Expand Down