diff --git a/.artifacts/massive-improvement/REPORT.md b/.artifacts/massive-improvement/REPORT.md new file mode 100644 index 0000000..4cde5e0 --- /dev/null +++ b/.artifacts/massive-improvement/REPORT.md @@ -0,0 +1,96 @@ +# 大規模コード改善レポート + +## Summary + +github-pera1-workersの大規模改善を実施。888行の単一ファイルをモジュール分割し、UIをモダン化、開発ツールチェーンを刷新、Chrome拡張を新規作成。 + +## 構造変更(Before / After) + +```mermaid +flowchart LR + subgraph Before["Before: 単一ファイル"] + A1[src/index.ts
888行] --> B1[全ロジック混在
ルーティング+URL解析+
GitHub API+フィルタ+ツリー+UI+MCP] + end + subgraph After["After: モジュール分割"] + A2[src/index.ts
~50行 ルーティング] --> R2[src/resolver.ts
URL/クエリ解決] + A2 --> B2[src/github.ts
GitHub API+ZIP処理] + A2 --> C2[src/mcp.ts
MCPサーバー] + A2 --> D2[src/ui.ts
UI/HTML] + B2 --> E2[src/filters.ts
フィルタ] + B2 --> F2[src/tree.ts
ツリー表示] + G2[src/types.ts
型定義] -.-> R2 + H2[src/constants.ts
定数] -.-> B2 + end +``` + +## 変更ファイル一覧 + +| ファイル | 変更種別 | 説明 | +|---------|---------|------| +| `src/index.ts` | 書き換え | 888行 → ~50行のルーティング薄層 | +| `src/types.ts` | 新規 | FileEntry, GitHubRepositoryParams, ParsedGitHubUrl, ResolvedRequest | +| `src/constants.ts` | 新規 | 定数定義(サイズ制限、拡張子リスト、キャッシュ設定) | +| `src/filters.ts` | 新規 | バイナリ判定、スキップ判定、インクルード判定 | +| `src/tree.ts` | 新規 | ディレクトリツリー表示生成 | +| `src/github.ts` | 新規 | GitHub ZIP取得・展開・フォーマット | +| `src/resolver.ts` | 新規 | URL解析+クエリパラメータ結合ロジック | +| `src/mcp.ts` | 新規 | MCPサーバー定義 | +| `src/ui.ts` | 新規 | モダンランディングページ(ダークモード対応) | +| `vite.config.ts` | 新規 | Cloudflare Vite Plugin設定 | +| `wrangler.toml` | 更新 | compatibility_date更新、nodejs_compat追加 | +| `package.json` | 更新 | v2.0.0、Vite/tsgo追加、scripts更新 | +| `extension/manifest.json` | 新規 | Chrome拡張 Manifest V3 | +| `extension/content.js` | 新規 | GitHub上に「Go to Pera1」ボタン挿入 | + +## 改善内容 + +### 1. モジュール分割 +- 888行の巨大index.tsを8つの専門モジュールに分割 +- 各モジュールの責務が明確(Single Responsibility) +- テスト可能な純関数として切り出し + +### 2. UI/UX大改善 +- CSS変数によるテーマ管理 +- `prefers-color-scheme`でダークモード自動対応 +- レスポンシブデザイン(モバイル対応) +- フェードイン/スライドアップアニメーション +- URLコピーボタン +- ローディング状態表示 +- 特徴紹介セクション(MCP、フィルタ、ツリーモード) + +### 3. 開発ツールチェーン +- **Vite + @cloudflare/vite-plugin**: `vite dev`で開発、`vite build`でビルド +- **tsgo (TypeScript 7.0 native preview)**: Go製の超高速型チェッカー +- **wrangler**: 引き続き直接デプロイにも対応 + +### 4. Chrome拡張「Go to Pera1」 +- GitHubリポジトリページでリポ名横にボタン表示 +- クリックでPera1のURLに直接ジャンプ +- MutationObserverでGitHubのSPA遷移にも対応 + +### 5. パフォーマンス・品質 +- Cache-Controlヘッダー追加(成功: 10分、エラー: 1分) +- compatibility_dateを最新に更新 +- nodejs_compatフラグ追加(jszip依存のNode.js API対応) + +## スクリーンショット + +### ランディングページ(ライトモード) +![Landing Page](./images/after-landing.png) + +### ランディングページ(ダークモード) +![Landing Page Dark](./images/after-landing-dark.png) + +### リポジトリ取得結果(Tree Mode) +![Result](./images/after-result.png) + +## テスト結果 + +- [x] TypeScript型チェック(tsc --noEmit): パス +- [x] tsgo型チェック: パス +- [x] wrangler dry-run build: パス +- [x] Vite dev server起動: 正常 +- [x] ルートページ(/): 200 OK +- [x] リポジトリ取得(?mode=tree): 200 OK +- [x] MCPエンドポイント(/mcp): 406 (想定通り - POST必要) +- [x] エラーページ: 正常表示 diff --git a/.artifacts/massive-improvement/images/after-landing-dark.png b/.artifacts/massive-improvement/images/after-landing-dark.png new file mode 100644 index 0000000..2db1e1f Binary files /dev/null and b/.artifacts/massive-improvement/images/after-landing-dark.png differ diff --git a/.artifacts/massive-improvement/images/after-landing.png b/.artifacts/massive-improvement/images/after-landing.png new file mode 100644 index 0000000..0b31172 Binary files /dev/null and b/.artifacts/massive-improvement/images/after-landing.png differ diff --git a/.artifacts/massive-improvement/images/after-result.png b/.artifacts/massive-improvement/images/after-result.png new file mode 100644 index 0000000..b084fab Binary files /dev/null and b/.artifacts/massive-improvement/images/after-result.png differ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b16caf9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,86 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Install dependencies + run: npm install + + - name: Type check + run: npx tsc --noEmit + + - name: Run tests + run: npx vitest run + + preview: + if: github.event_name == 'pull_request' + needs: test + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Install dependencies + run: npm install + + - name: Deploy to preview + id: deploy + run: | + OUTPUT=$(npx wrangler deploy --env preview --minify 2>&1) + echo "$OUTPUT" + PREVIEW_URL=$(echo "$OUTPUT" | grep -oP 'https://[^\s]+\.workers\.dev' | head -1) + echo "preview_url=$PREVIEW_URL" >> "$GITHUB_OUTPUT" + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + + - name: Comment preview URL on PR + uses: actions/github-script@v7 + with: + script: | + const previewUrl = '${{ steps.deploy.outputs.preview_url }}' || 'https://pera1-preview..workers.dev'; + const body = `🚀 **Preview deployed!**\n\n${previewUrl}\n\nThis preview will be updated on every push to this PR.`; + + // Find existing bot comment + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + const botComment = comments.find(c => + c.user.type === 'Bot' && c.body.includes('Preview deployed') + ); + + if (botComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..e8b1713 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,24 @@ +name: Deploy + +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Install dependencies + run: npm install + + - name: Deploy to Cloudflare Workers (production) + run: npx wrangler deploy --minify + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/.gitignore b/.gitignore index e319e06..9bfc1ef 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ dist/ # deps node_modules/ +package-lock.json .wrangler # env @@ -31,3 +32,4 @@ lerna-debug.log* # misc .DS_Store +.tmp-build/ diff --git a/extension/content.js b/extension/content.js new file mode 100644 index 0000000..ea45442 --- /dev/null +++ b/extension/content.js @@ -0,0 +1,99 @@ +(function () { + "use strict"; + + const PERA1_HOST = "pera1.pages.dev"; + const BUTTON_ID = "pera1-goto-btn"; + + function getRepoPath() { + const path = location.pathname; + const segments = path.split("/").filter(Boolean); + // Must have at least owner/repo + if (segments.length < 2) return null; + // Exclude settings, actions, issues, pulls, etc. at the repo level + // We want: /owner/repo, /owner/repo/tree/..., /owner/repo/blob/... + return segments.slice(0, 2).join("/"); + } + + function createButton(repoPath) { + const btn = document.createElement("a"); + btn.id = BUTTON_ID; + btn.href = `https://${PERA1_HOST}/github.com/${repoPath}`; + btn.target = "_blank"; + btn.rel = "noopener noreferrer"; + btn.textContent = "Go to Pera1"; + btn.style.cssText = [ + "display: inline-flex", + "align-items: center", + "gap: 4px", + "margin-left: 8px", + "padding: 3px 10px", + "font-size: 12px", + "font-weight: 600", + "color: #fff", + "background: #0969da", + "border-radius: 6px", + "text-decoration: none", + "vertical-align: middle", + "line-height: 1.5", + "cursor: pointer", + "transition: background 0.15s", + ].join(";"); + + btn.addEventListener("mouseenter", () => { + btn.style.background = "#0550ae"; + }); + btn.addEventListener("mouseleave", () => { + btn.style.background = "#0969da"; + }); + + return btn; + } + + function injectButton() { + // Avoid duplicate + if (document.getElementById(BUTTON_ID)) return; + + const repoPath = getRepoPath(); + if (!repoPath) return; + + // Try multiple selectors for GitHub's repo heading area + const selectors = [ + "#repository-container-header strong[itemprop='name'] a", + "#repository-container-header .AppHeader-context-item-label", + "[data-pjax='#repo-content-pjax-container'] strong a", + ".AppHeader-context-full li:last-child a", + ]; + + let anchor = null; + for (const sel of selectors) { + anchor = document.querySelector(sel); + if (anchor) break; + } + + if (!anchor) return; + + const btn = createButton(repoPath); + // Insert after the repo name element + const parent = anchor.closest("li") || anchor.parentElement; + if (parent) { + parent.style.display = + parent.style.display === "flex" ? "flex" : parent.style.display; + parent.appendChild(btn); + } + } + + // Initial injection + injectButton(); + + // Re-inject on GitHub's SPA navigation (turbo/pjax) + let lastUrl = location.href; + const observer = new MutationObserver(() => { + if (location.href !== lastUrl) { + lastUrl = location.href; + // Small delay for DOM to update + setTimeout(injectButton, 300); + } + }); + + observer.observe(document.body, { childList: true, subtree: true }); +})(); diff --git a/extension/generate-icons.sh b/extension/generate-icons.sh new file mode 100755 index 0000000..b53574c --- /dev/null +++ b/extension/generate-icons.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# Generate simple placeholder icons for the extension +# Requires ImageMagick (convert command) +# If not available, create the icons manually + +if command -v magick &> /dev/null; then + magick -size 48x48 xc:'#0969da' -fill white -font Helvetica -pointsize 24 -gravity center -annotate 0 'P1' extension/icon48.png + magick -size 128x128 xc:'#0969da' -fill white -font Helvetica -pointsize 64 -gravity center -annotate 0 'P1' extension/icon128.png + echo "Icons generated!" +elif command -v convert &> /dev/null; then + convert -size 48x48 xc:'#0969da' -fill white -font Helvetica -pointsize 24 -gravity center -annotate 0 'P1' extension/icon48.png + convert -size 128x128 xc:'#0969da' -fill white -font Helvetica -pointsize 64 -gravity center -annotate 0 'P1' extension/icon128.png + echo "Icons generated!" +else + echo "ImageMagick not found. Please create icon48.png and icon128.png manually." +fi diff --git a/extension/icon128.png b/extension/icon128.png new file mode 100644 index 0000000..a50b3b1 Binary files /dev/null and b/extension/icon128.png differ diff --git a/extension/icon48.png b/extension/icon48.png new file mode 100644 index 0000000..f005711 Binary files /dev/null and b/extension/icon48.png differ diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 0000000..7130214 --- /dev/null +++ b/extension/manifest.json @@ -0,0 +1,17 @@ +{ + "manifest_version": 3, + "name": "Go to Pera1", + "version": "1.0.0", + "description": "Add a 'Go to Pera1' button on GitHub repository pages to quickly fetch code as plain text for LLMs.", + "content_scripts": [ + { + "matches": ["https://github.com/*/*"], + "js": ["content.js"], + "run_at": "document_idle" + } + ], + "icons": { + "48": "icon48.png", + "128": "icon128.png" + } +} diff --git a/package.json b/package.json index 9fc710a..b93a95e 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,19 @@ { "name": "pera1", - "version": "1.2.0", + "version": "2.0.0", + "type": "module", "license": "MIT", "scripts": { - "dev": "wrangler dev", + "dev": "vite dev", + "dev:wrangler": "wrangler dev", + "build": "vite build", + "deploy": "vite build && wrangler deploy", "deploy:workers": "wrangler deploy --minify", - "tail": "wrangler tail" + "typecheck": "tsc --noEmit", + "typecheck:native": "node node_modules/@typescript/native-preview/bin/tsgo.js --noEmit", + "tail": "wrangler tail", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@hono/mcp": "^0.1.1", @@ -15,7 +23,12 @@ "zod": "^3.24.1" }, "devDependencies": { + "@cloudflare/vite-plugin": "^1.30.1", "@cloudflare/workers-types": "^4.20250405.0", + "@typescript/native-preview": "7.0.0-dev.20260326.1", + "typescript": "^6.0.2", + "vite": "^8.0.3", + "vitest": "^4.1.2", "wrangler": "^4.7.2" } } diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..2e15733 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,41 @@ +/** Maximum file size to display (files larger than this are truncated) */ +export const MAX_DISPLAY_FILE_SIZE = 30 * 1024; // 30KB + +/** Files larger than this are skipped entirely */ +export const MAX_FILE_SIZE = 500 * 1024; // 500KB + +/** Ratio of non-printable characters that triggers binary detection */ +export const BINARY_THRESHOLD = 0.05; + +/** Number of characters sampled for binary detection */ +export const BINARY_SAMPLE_SIZE = 1000; + +/** Default branches to try when the specified branch fails */ +export const DEFAULT_BRANCHES = ["main", "master"] as const; + +/** Example repository URL shown on the landing page */ +export const EXAMPLE_REPO = "https://github.com/kazuph/github-pera1-workers"; + +/** App version */ +export const APP_VERSION = "2.0.0"; + +/** Image file extensions to skip */ +export const IMAGE_EXTENSIONS = new Set([ + ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".webp", ".svg", +]); + +/** Binary file extensions to skip */ +export const BINARY_EXTENSIONS = new Set([ + ".zip", ".tar", ".gz", ".rar", ".7z", + ".exe", ".dll", ".so", ".dylib", + ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", + ".mp3", ".mp4", ".avi", ".mov", ".wav", + ".bin", ".dat", ".db", ".sqlite", + ".woff", ".woff2", ".ttf", ".eot", +]); + +/** Cache duration for successful responses (10 minutes) */ +export const CACHE_MAX_AGE = 600; + +/** Cache duration for error responses (1 minute) */ +export const CACHE_ERROR_MAX_AGE = 60; diff --git a/src/filters.ts b/src/filters.ts new file mode 100644 index 0000000..b3cef23 --- /dev/null +++ b/src/filters.ts @@ -0,0 +1,69 @@ +import { + BINARY_EXTENSIONS, + BINARY_SAMPLE_SIZE, + BINARY_THRESHOLD, + IMAGE_EXTENSIONS, + MAX_FILE_SIZE, +} from "./constants"; + +/** Detect binary content by sampling for non-printable characters */ +export function isBinaryContent(content: string): boolean { + const sampleSize = Math.min(content.length, BINARY_SAMPLE_SIZE); + let nonPrintable = 0; + for (let i = 0; i < sampleSize; i++) { + const charCode = content.charCodeAt(i); + if (charCode === 0 || (charCode < 32 && ![9, 10, 13].includes(charCode))) { + nonPrintable++; + } + } + return nonPrintable / sampleSize > BINARY_THRESHOLD; +} + +/** Determine whether a file should be skipped from output */ +export function shouldSkipFile( + filename: string, + size: number, + content: string | undefined, + hasTsConfig: boolean, +): boolean { + const ext = "." + (filename.toLowerCase().split(".").pop() || ""); + + // Lock files + if (/-lock\.|\.lock$/.test(filename)) return true; + + // Binary/image extensions + if (IMAGE_EXTENSIONS.has(ext) || BINARY_EXTENSIONS.has(ext)) return true; + + // TS projects: skip compiled JS/MJS + if (hasTsConfig && (filename.endsWith(".js") || filename.endsWith(".mjs"))) return true; + + // Size limit + if (size > MAX_FILE_SIZE) return true; + + // Binary content + if (content && isBinaryContent(content)) return true; + + return false; +} + +/** Determine whether a file matches directory and extension filters */ +export function shouldIncludeFile( + filename: string, + targetDirs: string[], + targetExts: string[], +): boolean { + if (targetDirs.length > 0) { + const matchesDir = targetDirs.some((dir) => { + const normalizedDir = dir.endsWith("/") ? dir : `${dir}/`; + return filename.startsWith(normalizedDir); + }); + if (!matchesDir) return false; + } + + if (targetExts.length > 0) { + const ext = filename.split(".").pop()?.toLowerCase() || ""; + if (!targetExts.includes(ext)) return false; + } + + return true; +} diff --git a/src/github.ts b/src/github.ts new file mode 100644 index 0000000..dc9f366 --- /dev/null +++ b/src/github.ts @@ -0,0 +1,208 @@ +import JSZip from "jszip"; +import { DEFAULT_BRANCHES, MAX_DISPLAY_FILE_SIZE } from "./constants"; +import { shouldIncludeFile, shouldSkipFile } from "./filters"; +import { createTreeDisplay } from "./tree"; +import type { + FileEntry, + GitHubRepositoryParams, + ResolvedRequest, +} from "./types"; + +/** Fetch a GitHub repository ZIP archive */ +export async function fetchZip( + owner: string, + repo: string, + branch: string, +): Promise { + const zipUrl = `https://codeload.github.com/${owner}/${repo}/zip/${branch}`; + console.log(`Fetching zip from: ${zipUrl}`); + return await fetch(zipUrl, { + headers: { "User-Agent": "Pera1-Bot/2.0" }, + }); +} + +/** Fetch ZIP with automatic branch fallback */ +export async function fetchZipWithFallback( + owner: string, + repo: string, + branch: string, +): Promise<{ response: Response; branch: string }> { + const zipResp = await fetchZip(owner, repo, branch); + + if (zipResp.ok) { + return { response: zipResp, branch }; + } + + for (const defaultBranch of DEFAULT_BRANCHES) { + if (branch === defaultBranch) continue; + console.log( + `Branch "${branch}" failed (${zipResp.status}). Trying "${defaultBranch}"...`, + ); + + const tempResp = await fetchZip(owner, repo, defaultBranch); + if (tempResp.ok) { + console.log(`Switched to branch "${defaultBranch}"`); + return { response: tempResp, branch: defaultBranch }; + } + } + + throw new Error( + `Failed to fetch repository. Tried branches: ${[branch, ...DEFAULT_BRANCHES].filter((v, i, a) => a.indexOf(v) === i).join(", ")}. Status: ${zipResp.status} ${zipResp.statusText}`, + ); +} + +/** Extract files from ZIP and build formatted output */ +async function extractAndFormat( + owner: string, + repo: string, + branch: string, + targetDirs: string[], + targetExts: string[], + targetFile: string | undefined, + isTreeMode: boolean, +): Promise { + const { response: zipResp, branch: resolvedBranch } = + await fetchZipWithFallback(owner, repo, branch); + const finalBranch = resolvedBranch; + + const arrayBuffer = await zipResp.arrayBuffer(); + const jszip = await JSZip.loadAsync(arrayBuffer); + const rootPrefix = `${repo}-${finalBranch}/`; + + const hasTsConfig = Object.keys(jszip.files).some( + (name) => name.startsWith(rootPrefix) && name.endsWith("tsconfig.json"), + ); + + const fileTree = new Map(); + let originalTotalSize = 0; + let displayTotalSize = 0; + + for (const fileObj of Object.values(jszip.files)) { + if (fileObj.dir || !fileObj.name.startsWith(rootPrefix)) continue; + + const fileRelative = fileObj.name.slice(rootPrefix.length); + + if (targetFile) { + if (fileRelative !== targetFile) continue; + } else { + if (!shouldIncludeFile(fileRelative, targetDirs, targetExts)) continue; + } + + const isReadmeFile = /readme\.md$/i.test(fileRelative); + + if (isTreeMode && !isReadmeFile) { + fileTree.set(fileRelative, { size: 0, content: "" }); + } else { + const content = await fileObj.async("string"); + const size = new TextEncoder().encode(content).length; + + if (shouldSkipFile(fileRelative, size, content, hasTsConfig)) continue; + + let isTruncated = false; + let processedContent = content; + let displaySize = size; + + if (size > MAX_DISPLAY_FILE_SIZE) { + processedContent = content.substring(0, MAX_DISPLAY_FILE_SIZE); + const remainingSize = (size - MAX_DISPLAY_FILE_SIZE) / 1024; + processedContent += `\n\n[Truncated at 30KB. ${remainingSize.toFixed(1)}KB remaining.]`; + isTruncated = true; + displaySize = MAX_DISPLAY_FILE_SIZE; + } + + originalTotalSize += size; + displayTotalSize += displaySize; + fileTree.set(fileRelative, { + size, + content: processedContent, + isTruncated, + }); + } + } + + if (targetFile) { + const fileEntry = fileTree.get(targetFile); + if (!fileEntry) throw new Error(`File not found: ${targetFile}`); + return fileEntry.content; + } + + if (isTreeMode) { + let resultText = "# Directory Structure\n\n"; + resultText += createTreeDisplay(fileTree, false); + + const readmeFiles = Array.from(fileTree.entries()).filter( + ([path, { content }]) => /readme\.md$/i.test(path) && content, + ); + + if (readmeFiles.length > 0) { + resultText += "\n# README Files\n\n"; + for (const [path, { content }] of readmeFiles) { + resultText += `## ${path}\n\n${content}\n\n`; + } + } + return resultText; + } + + let resultText = "# File Tree\n\n"; + resultText += createTreeDisplay(fileTree, true); + resultText += `\n# Files (Total: ${(originalTotalSize / 1024).toFixed(2)} KB → ${(displayTotalSize / 1024).toFixed(2)} KB)\n\n`; + + for (const [path, { content }] of fileTree) { + resultText += `\`\`\`${path}\n${content}\n\`\`\`\n\n`; + } + + return resultText; +} + +/** Handle a resolved HTTP request (used by index.ts route handler) */ +export async function handleGitHubRequest( + resolved: ResolvedRequest, +): Promise { + return extractAndFormat( + resolved.owner, + resolved.repo, + resolved.branch, + resolved.targetDirs, + resolved.targetExts, + resolved.targetFile, + resolved.isTreeMode, + ); +} + +/** Process a GitHub repository from MCP-style params (used by mcp.ts) */ +export async function processGitHubRepository( + params: GitHubRepositoryParams, +): Promise { + const { url, dir, ext, branch, file, mode } = params; + + const queryDirs = dir + ?.split(",") + .map((d) => d.trim()) + .filter((d) => d); + const queryExts = ext + ?.split(",") + .map((e) => e.trim().toLowerCase()) + .filter((e) => e); + + const finalTargetDirs = + queryDirs && queryDirs.length > 0 + ? queryDirs.map((d) => (d.endsWith("/") ? d : d + "/")) + : []; + + // Parse owner/repo from URL + const parsed = new URL(url.startsWith("http") ? url : `https://${url}`); + const segments = parsed.pathname.split("/").filter(Boolean); + if (segments.length < 2) { + throw new Error("Invalid GitHub repository URL format"); + } + + return extractAndFormat( + segments[0], + segments[1], + branch || "main", + finalTargetDirs, + queryExts || [], + file, + mode === "tree", + ); +} diff --git a/src/index.ts b/src/index.ts index 68700c2..e7ab22d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,887 +1,50 @@ import { Hono } from "hono"; -import type { Context } from "hono"; -import JSZip from "jszip"; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { StreamableHTTPTransport } from '@hono/mcp'; -import { z } from 'zod'; +import { StreamableHTTPTransport } from "@hono/mcp"; +import { CACHE_ERROR_MAX_AGE, CACHE_MAX_AGE } from "./constants"; +import { handleGitHubRequest } from "./github"; +import { mcpServer } from "./mcp"; +import { resolveRequest } from "./resolver"; +import { createErrorPage, createLandingPage } from "./ui"; const app = new Hono(); -// MCP サーバーの設定 -const mcpServer = new McpServer({ - name: 'github-pera1-mcp-server', - version: '1.2.0', +// MCP endpoint +app.all("/mcp", async (c) => { + const transport = new StreamableHTTPTransport(); + await mcpServer.connect(transport); + return transport.handleRequest(c); }); -// GitHubコード取得ツールの登録 -mcpServer.registerTool("fetch_github_code", { - title: "GitHub Code Fetcher", - description: "Fetch code from GitHub repositories with flexible filtering options", - inputSchema: { - url: z.string().describe("GitHub repository URL (e.g., https://github.com/owner/repo)"), - dir: z.string().optional().describe("Filter by directory path (optional)"), - ext: z.string().optional().describe("Filter by file extensions (optional)"), - branch: z.string().optional().describe("Git branch name (optional)"), - file: z.string().optional().describe("Specific file to fetch (optional)"), - mode: z.enum(["tree", "full"]).optional().describe("Display mode (optional)") - } -}, - async (args) => { - try { - // パラメータバリデーション - if (!args || typeof args !== 'object') { - throw new Error('Arguments are required'); - } - - if (!args.url) { - throw new Error('URL parameter is required. Please provide a GitHub repository URL (e.g., https://github.com/owner/repo)'); - } - - if (typeof args.url !== 'string' || args.url.trim() === '') { - throw new Error('URL must be a non-empty string'); - } - - // 既存のGitHub処理ロジックを再利用 - const result = await processGitHubRepository(args); - - return { - content: [{ - type: 'text', - text: result - }] - }; - } catch (error) { - throw new Error(`Failed to fetch GitHub code: ${error instanceof Error ? error.message : 'Unknown error'}`); - } - } -); - -// 既存のGitHub処理ロジックを関数として抽出 -async function processGitHubRepository(params: any): Promise { - const { url, dir, ext, branch: paramBranch, file: queryFile, mode } = params; - - let parsed: URL; - try { - parsed = new URL(url.startsWith('http') ? url : `https://${url}`); - } catch (error) { - throw new Error(`Invalid URL: ${error instanceof Error ? error.message : 'Unknown error'}`); - } - - const segments = parsed.pathname.split("/").filter(Boolean); - if (segments.length < 2) { - throw new Error("Invalid GitHub repository URL format"); - } - - const owner = segments[0]; - const repo = segments[1]; - - // パラメータの処理 - const queryDirs = dir?.split(",").map(d => d.trim()).filter(d => d); - const queryExts = ext?.split(",").map(e => e.trim().toLowerCase()).filter(e => e); - const isTreeMode = mode === "tree"; - - // URLパスからの情報抽出は簡略化(パラメータ優先) - let branch = paramBranch || "main"; - let finalTargetDirs: string[] = []; - if (queryDirs && queryDirs.length > 0) { - finalTargetDirs = queryDirs.map(d => d.endsWith('/') ? d : d + '/'); - } - const targetExts = queryExts || []; - const targetFile = queryFile; - - // ZIP取得 - let zipResp = await fetchZip(owner, repo, branch); - if (!zipResp.ok) { - // デフォルトブランチへのフォールバック - const defaultBranches = ["main", "master"]; - let foundBranch = false; - for (const defaultBranch of defaultBranches) { - if (branch === defaultBranch) continue; - - const tempResp = await fetchZip(owner, repo, defaultBranch); - if (tempResp.ok) { - branch = defaultBranch; - zipResp = tempResp; - foundBranch = true; - break; - } - } - - if (!foundBranch) { - throw new Error(`Failed to fetch repository: ${zipResp.status} ${zipResp.statusText}`); - } - } - - const arrayBuffer = await zipResp.arrayBuffer(); - const jszip = await JSZip.loadAsync(arrayBuffer); - const rootPrefix = `${repo}-${branch}/`; - - // TypeScript プロジェクト判定 - const hasTsConfig = Object.keys(jszip.files).some( - (name) => name.startsWith(rootPrefix) && name.endsWith("tsconfig.json") - ); - - const fileTree = new Map(); - let originalTotalSize = 0; - let displayTotalSize = 0; - - for (const fileObj of Object.values(jszip.files)) { - if (fileObj.dir) continue; - if (!fileObj.name.startsWith(rootPrefix)) continue; - - const fileRelative = fileObj.name.slice(rootPrefix.length); - - // ファイルフィルタリング - if (targetFile) { - if (fileRelative !== targetFile) continue; - } else { - if (!shouldIncludeFile(fileRelative, finalTargetDirs, targetExts)) continue; - } - - const isReadmeFile = /readme\.md$/i.test(fileRelative); - - if (isTreeMode && !isReadmeFile) { - fileTree.set(fileRelative, { size: 0, content: "" }); - } else { - const content = await fileObj.async("string"); - const size = new TextEncoder().encode(content).length; - - if (shouldSkipFile(fileRelative, size, content, hasTsConfig)) { - continue; - } - - let isTruncated = false; - let processedContent = content; - let displaySize = size; - - if (size > MAX_DISPLAY_FILE_SIZE) { - processedContent = content.substring(0, MAX_DISPLAY_FILE_SIZE); - const remainingSize = (size - MAX_DISPLAY_FILE_SIZE) / 1024; - processedContent += `\n\nThis file is too large, truncated at 30KB. There is ${remainingSize.toFixed(2)}KB remaining.`; - isTruncated = true; - displaySize = MAX_DISPLAY_FILE_SIZE; - } - - originalTotalSize += size; - displayTotalSize += displaySize; - - fileTree.set(fileRelative, { - size, - content: processedContent, - isTruncated - }); - } - } +// Main route: landing page or repository fetch +app.get("/*", async (c) => { + const url = new URL(c.req.url); + const path = url.pathname.slice(1); + const protocol = c.req.url.startsWith("https") ? "https" : "http"; + const host = c.req.header("host") || "localhost"; - // 単一ファイル指定の場合 - if (targetFile) { - const fileEntry = fileTree.get(targetFile); - if (!fileEntry) { - throw new Error(`File not found: ${targetFile}`); - } - return fileEntry.content; + // Root path → landing page + if (!path) { + return c.html(createLandingPage(protocol, host)); } - // レスポンス生成 - if (isTreeMode) { - let resultText = "# Directory Structure\n\n"; - resultText += createTreeDisplay(fileTree, false); - - const readmeFiles = Array.from(fileTree.entries()) - .filter(([path, { content }]) => /readme\.md$/i.test(path) && content); - - if (readmeFiles.length > 0) { - resultText += "\n# README Files\n\n"; - for (const [path, { content }] of readmeFiles) { - resultText += `## ${path}\n\n${content}\n\n`; - } - } - - return resultText; - } else { - let resultText = "# 📁 File Tree\n\n"; - resultText += createTreeDisplay(fileTree, true); - - resultText += `\n# 📝 Files (Total: ${(originalTotalSize / 1024).toFixed(2)} KB→${(displayTotalSize / 1024).toFixed(2)} KB)\n\n`; - for (const [path, { content }] of fileTree) { - resultText += `\`\`\`${path}\n${content}\n\`\`\`\n\n`; - } - - return resultText; + try { + const resolved = resolveRequest(path, url.searchParams); + const result = await handleGitHubRequest(resolved); + + return c.text(result, 200, { + "Cache-Control": `public, max-age=${CACHE_MAX_AGE}`, + }); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : "Unknown error"; + console.error(`Error: ${msg}`); + + const status = msg.includes("Invalid") ? 400 : msg.includes("not found") ? 404 : 500; + return c.html( + createErrorPage(protocol, host, path, msg), + status as 400 | 404 | 500, + { "Cache-Control": `public, max-age=${CACHE_ERROR_MAX_AGE}` }, + ); } -} - -// GitHub リポジトリの例 -const EXAMPLE_REPO = "https://github.com/kazuph/github-pera1-workers"; - -// エラー応答生成関数 -function createErrorResponse( - c: Context, - targetUrl: string, - errorMessage: string, - status: 400 | 403 | 404 | 500, -) { - const host = c.req.header("host") || ""; - const protocol = c.req.url.startsWith("https") ? "https" : "http"; - const fullUrl = targetUrl - ? `${protocol}://${host}/${targetUrl}` - : `${protocol}://${host}/${EXAMPLE_REPO}`; - - return c.html( - ` - - - - - - - github pera1 - - - -

github pera1

- - ${errorMessage ? `
Error: ${errorMessage}
` : ''} - -
- ${ - !targetUrl - ? `

Example: ${fullUrl}

` - : `

URL: ${fullUrl}

` - } -
- -
-

Set Parameters

- -
- - -
Enter full GitHub URL including https://
-
- -
- - -
Filter files by directory paths (comma-separated)
-
- -
- - -
Filter files by extensions (comma-separated, without dots)
-
- -
- - -
- -
- - -
Defaults to main or master if not specified
-
- -
- - -
Retrieve only a specific file
-
- - -
- -
-

How to Use

-

This tool fetches code from GitHub repositories and combines files into a single view.

-

Examples:

-
    -
  • Basic: ${protocol}://${host}/github.com/username/repo
  • -
  • With branch: ${protocol}://${host}/github.com/username/repo/tree/branch-name
  • -
  • With params: ${protocol}://${host}/github.com/username/repo?dir=src&ext=ts,tsx
  • -
-
- - - - - `, - status, - ); -} - -// GitHubリポジトリのZIPファイルを取得 -async function fetchZip(owner: string, repo: string, branch: string) { - const zipUrl = `https://codeload.github.com/${owner}/${repo}/zip/${branch}`; - console.log(`📦 Fetching zip from: ${zipUrl}`); - return await fetch(zipUrl, { - headers: { - "User-Agent": "Pera1-Bot", - }, - }); -} - -// 定数 -const MAX_DISPLAY_FILE_SIZE = 30 * 1024; // 30KB - -// ディレクトリツリー文字列の生成 -function createTreeDisplay( - fileTree: Map, - showSize = false, -): string { - const dirs = new Set(); - - for (const [path] of fileTree) { - const parts = path.split("/"); - for (let i = 1; i <= parts.length; i++) { - dirs.add(parts.slice(0, i).join("/")); - } - } - - const sortedDirs = Array.from(dirs).sort(); - let result = ""; - - for (const dir of sortedDirs) { - const depth = dir.split("/").length - 1; - const indent = " ".repeat(depth); - const name = dir.split("/").pop() || ""; - const isFile = !Array.from(dirs).some((d) => d.startsWith(dir + "/")); - - if (showSize && isFile) { - const fileInfo = fileTree.get(dir); - if (fileInfo) { - const sizeKB = (fileInfo.size / 1024).toFixed(2); - if (fileInfo.isTruncated) { - result += `${indent}📄 ${name} (${sizeKB} KB→30KB truncated)\n`; - } else { - result += `${indent}📄 ${name} (${sizeKB} KB)\n`; - } - } else { - result += `${indent}📄 ${name} (0.00 KB)\n`; - } - } else { - result += `${indent}${isFile ? "📄" : "📂"} ${name}\n`; - } - } - - return result; -} - -// バイナリコンテンツ判定 -function isBinaryContent(content: string): boolean { - const sampleSize = Math.min(content.length, 1000); - let nonPrintable = 0; - for (let i = 0; i < sampleSize; i++) { - const charCode = content.charCodeAt(i); - if (charCode === 0 || (charCode < 32 && ![9, 10, 13].includes(charCode))) { - nonPrintable++; - } - } - return nonPrintable / sampleSize > 0.05; -} - -// 出力スキップ判定用(バイナリや大サイズファイル、ロックファイルなど) -function shouldSkipFile( - filename: string, - size: number, - content: string | undefined, - hasTsConfig: boolean, -): boolean { - const MAX_FILE_SIZE = 500 * 1024; // 500KB - const imageExtensions = new Set([ - ".png", - ".jpg", - ".jpeg", - ".gif", - ".bmp", - ".ico", - ".webp", - ".svg", - ]); - const binaryExtensions = new Set([ - ".zip", - ".tar", - ".gz", - ".rar", - ".7z", - ".exe", - ".dll", - ".so", - ".dylib", - ".pdf", - ".doc", - ".docx", - ".xls", - ".xlsx", - ".ppt", - ".pptx", - ".mp3", - ".mp4", - ".avi", - ".mov", - ".wav", - ".bin", - ".dat", - ".db", - ".sqlite", - ]); - - const ext = filename.toLowerCase().split(".").pop() || ""; - - // ロックファイル除外 - if (filename.match(/-lock\.|\.lock$/)) return true; - - // バイナリ拡張子除外 - if (imageExtensions.has(`.${ext}`) || binaryExtensions.has(`.${ext}`)) - return true; - - // TSプロジェクトの場合、.jsや.mjsは除外 - if (hasTsConfig && (filename.endsWith(".js") || filename.endsWith(".mjs"))) - return true; - - // サイズ制限 - if (size > MAX_FILE_SIZE) return true; - - // 中身がバイナリ - if (content && isBinaryContent(content)) return true; - - return false; -} - -// ディレクトリ・拡張子フィルタによる出力可否 -function shouldIncludeFile( - filename: string, - targetDirs: string[], - targetExts: string[], -): boolean { - // ディレクトリフィルタ - if (targetDirs.length > 0) { - const matchesDir = targetDirs.some((dir) => { - const normalizedDir = dir.endsWith("/") ? dir : `${dir}/`; - return filename.startsWith(normalizedDir); - }); - if (!matchesDir) return false; - } - - // 拡張子フィルタ - if (targetExts.length > 0) { - const ext = filename.split(".").pop()?.toLowerCase() || ""; - if (!targetExts.includes(ext)) return false; - } - - return true; -} - -// MCP エンドポイント -app.all('/mcp', async (c) => { - const transport = new StreamableHTTPTransport() - await mcpServer.connect(transport) - return transport.handleRequest(c) -}); - -app.get("/*", async (c) => { - try { - const url = new URL(c.req.url); - const path = url.pathname.slice(1); - const params = url.searchParams; - - // パラメータ抽出 (クエリパラメータ) - const queryDirs = params.get("dir")?.split(",").map(d => d.trim()).filter(d => d); - const queryExts = params.get("ext")?.split(",").map(e => e.trim().toLowerCase()).filter(e => e); - const isTreeMode = params.get("mode") === "tree"; - const paramBranch = params.get("branch")?.trim(); - const queryFile = params.get("file")?.trim(); // クエリパラメータのfile - - if (!path) { - return createErrorResponse(c, "", "No repository URL provided", 400); - } - - let urlStr = path.startsWith("http") ? path : `https://${path}`; - - let parsed: URL; - try { - parsed = new URL(urlStr); - } catch (error) { - const msg = `Invalid URL: ${error instanceof Error ? error.message : "Unknown error"}`; - return createErrorResponse(c, urlStr, msg, 400); - } - - const segments = parsed.pathname.split("/").filter(Boolean); - if (segments.length < 2) { - return createErrorResponse( - c, - urlStr, - "Invalid GitHub repository URL format", - 400, - ); - } - - const owner = segments[0]; - const repo = segments[1]; - - // URLパスからブランチ名、ディレクトリパス、ファイルパスを抽出 - let urlBranch: string | undefined; - let urlDir: string | undefined; - let urlFilePath: string | undefined; // blob URL用のファイルパス - - if (segments.length > 3 && segments[2] === "tree") { - // /tree/ の後の部分を解析 (例: /tree/branch/path/to/dir) - const branchAndDirParts = segments.slice(3); - urlBranch = branchAndDirParts[0]; // 最初の部分をブランチ名候補 - if (branchAndDirParts.length > 1) { - urlDir = branchAndDirParts.slice(1).join("/"); - } - } else if (segments.length > 3 && segments[2] === "blob") { - // /blob/ の後の部分を解析 (例: /blob/branch/path/to/file) - const branchAndFileParts = segments.slice(3); - urlBranch = branchAndFileParts[0]; - if (branchAndFileParts.length > 1) { - urlFilePath = branchAndFileParts.slice(1).join("/"); // 残りをファイルパス候補 - } - } else if (segments.length > 2 && segments[2] !== "tree" && segments[2] !== "blob") { - // /owner/repo/path/to/dir or /owner/repo/file.txt の場合 (ブランチはデフォルト) - // この時点ではディレクトリかファイルか不明瞭だが、後続の処理で targetFile が優先される - urlDir = segments.slice(2).join("/"); // 一旦ディレクトリとして扱う - } - - // ブランチ名の決定 (クエリパラメータ > URLパス > デフォルト "main") - let branch = paramBranch || urlBranch || "main"; - - // ディレクトリの決定 (クエリパラメータとURLパスを結合) - let finalTargetDirs: string[] = []; - if (queryDirs && queryDirs.length > 0) { - // クエリパラメータの dir が指定されている場合 - if (urlDir) { - // URLパスにもディレクトリがある場合、結合する - const basePath = urlDir.endsWith('/') ? urlDir : urlDir + '/'; - finalTargetDirs = queryDirs.map(d => { - const relativePath = d.startsWith('/') ? d.slice(1) : d; - // 結合時に末尾のスラッシュを統一(shouldIncludeFile の挙動に合わせる) - const combined = basePath + relativePath; - return combined.endsWith('/') ? combined : combined + '/'; - }); - } else { - // URLパスにディレクトリがない場合、クエリパラメータの dir をそのまま使用 - // 末尾スラッシュ統一 - finalTargetDirs = queryDirs.map(d => d.endsWith('/') ? d : d + '/'); - } - } else if (urlDir) { - // クエリパラメータがなく、URLパスにディレクトリがある場合 - // 末尾スラッシュ統一 - finalTargetDirs = [urlDir.endsWith('/') ? urlDir : urlDir + '/']; - } - // 拡張子はクエリパラメータからのみ取得 - const targetExts = queryExts || []; - // 単一ファイル指定の決定 (クエリパラメータ > URLパス) - // 単一ファイル指定の決定 (クエリパラメータ > URLパス > URLパス内のディレクトリ + クエリパラメータのファイル) - let targetFile: string | undefined; - if (queryFile) { - // クエリパラメータの file が最優先 - if (urlDir) { - // URLにディレクトリパスがあり、クエリにファイルパスがある場合、結合 - // パスの結合: スラッシュの重複を避ける - const basePath = urlDir.endsWith('/') ? urlDir : urlDir + '/'; - const relativePath = queryFile.startsWith('/') ? queryFile.slice(1) : queryFile; - targetFile = basePath + relativePath; - } else { - // URLにディレクトリパスがなく、クエリにファイルパスがある場合 - targetFile = queryFile; - } - } else if (urlFilePath) { - // クエリパラメータがなく、URLが /blob/ 形式の場合 - targetFile = urlFilePath; - } - - // ZIP取得 - // ZIP取得とブランチのフォールバック処理 - let zipResp = await fetchZip(owner, repo, branch); - if (!zipResp.ok) { - // ブランチが存在しない場合、デフォルトブランチ (main, master) を試す - const defaultBranches = ["main", "master"]; - let foundBranch = false; - for (const defaultBranch of defaultBranches) { - // 現在試行中のブランチと同じ場合はスキップ - if (branch === defaultBranch) continue; - - console.log(`🤔 Branch "${branch}" failed (${zipResp.status}). Trying default branch "${defaultBranch}"...`); - const tempResp = await fetchZip(owner, repo, defaultBranch); - if (tempResp.ok) { - branch = defaultBranch; // 成功したブランチ名に更新 - zipResp = tempResp; - foundBranch = true; - console.log(`✅ Successfully switched to branch "${branch}"`); - break; - } else { - console.log(`👎 Default branch "${defaultBranch}" also failed (${tempResp.status}).`); - } - } - - // デフォルトブランチでも失敗した場合 - if (!foundBranch) { - const triedBranches = [paramBranch, urlBranch, "main", "master"].filter(Boolean).join('", "'); - const errorMsg = `Failed to fetch zip for tried branches ("${triedBranches}"): ${zipResp.status} ${zipResp.statusText}`; - return createErrorResponse(c, urlStr, errorMsg, zipResp.status as 404 | 403 | 500); - } - } - - if (!zipResp.ok) { - const errorMsg = `Failed to fetch zip: ${zipResp.status} ${zipResp.statusText}`; - return createErrorResponse( - c, - urlStr, - errorMsg, - zipResp.status as 404 | 403 | 500, - ); - } - - const arrayBuffer = await zipResp.arrayBuffer(); - const jszip = await JSZip.loadAsync(arrayBuffer); - const rootPrefix = `${repo}-${branch}/`; - - // tsconfig.json があればTSプロジェクト判定 - const hasTsConfig = Object.keys(jszip.files).some( - (name) => name.startsWith(rootPrefix) && name.endsWith("tsconfig.json"), - ); - - const fileTree = new Map(); - let originalTotalSize = 0; // 元のサイズ合計 - let displayTotalSize = 0; // 表示用サイズ合計 - - for (const fileObj of Object.values(jszip.files)) { - if (fileObj.dir) continue; - if (!fileObj.name.startsWith(rootPrefix)) continue; - - const fileRelative = fileObj.name.slice(rootPrefix.length); - - // 単一ファイル指定がある場合の処理、またはディレクトリ/拡張子フィルタリング - if (targetFile) { - // 単一ファイル指定がある場合、そのファイル以外はスキップ - if (fileRelative !== targetFile) { - continue; - } - // targetFile と一致した場合、以降の dir/ext フィルタは適用しない - } else { - // targetFile が指定されていない場合のみ、dir/ext フィルタを適用 - if (!shouldIncludeFile(fileRelative, finalTargetDirs, targetExts)) { - continue; - } - } - - const isReadmeFile = /readme\.md$/i.test(fileRelative); - - if (isTreeMode && !isReadmeFile) { - // ツリーモードではREADME以外はコンテンツを読み込まず、ツリーの構築のみ行う - fileTree.set(fileRelative, { size: 0, content: "" }); - } else { - // 通常モードまたはREADMEファイルはファイルコンテンツを読み込む - const content = await fileObj.async("string"); - const size = new TextEncoder().encode(content).length; - - // スキップ条件チェック - if (shouldSkipFile(fileRelative, size, content, hasTsConfig)) { - continue; - } - - // ファイルサイズが30KB以上なら切り捨て - let isTruncated = false; - let processedContent = content; - let displaySize = size; - - if (size > MAX_DISPLAY_FILE_SIZE) { - // 30KBまでの内容に切り捨て - processedContent = content.substring(0, MAX_DISPLAY_FILE_SIZE); - // 残りのサイズを計算 - const remainingSize = (size - MAX_DISPLAY_FILE_SIZE) / 1024; - // 切り捨てメッセージを追加 - processedContent += `\n\nThis file is too large, truncated at 30KB. There is ${remainingSize.toFixed(2)}KB remaining.`; - isTruncated = true; - // 表示用のサイズを30KBに制限 - displaySize = MAX_DISPLAY_FILE_SIZE; - } - - // 元のサイズを合計に追加 - originalTotalSize += size; - // 表示用サイズを合計に追加 - displayTotalSize += displaySize; - - fileTree.set(fileRelative, { - size, - content: processedContent, - isTruncated - }); - } - } - - // 単一ファイル指定がある場合の処理 - if (targetFile) { - const fileEntry = fileTree.get(targetFile); // Map.get で直接取得 - if (!fileEntry) { - // ファイルが見つからない場合のエラーメッセージを改善 - const availableFiles = Array.from(fileTree.keys()).sort().join("\n - "); - const errorMsg = `File not found: ${targetFile}\n\nAvailable files matching filters (dir: ${finalTargetDirs.join(',') || 'none'}, ext: ${targetExts.join(',') || 'none'}):\n - ${availableFiles || '(No files found or matched filters)'}`; - return createErrorResponse(c, urlStr, errorMsg, 404); - } - // スキップされたファイル(バイナリ等)でないことを確認 - if (fileEntry.content === undefined) { - return createErrorResponse(c, urlStr, `File content skipped (binary, large, etc.): ${targetFile}`, 400); - } - return c.text(fileEntry.content, 200); - } - - // レスポンス生成 - if (isTreeMode) { - // ツリーのみ表示(READMEファイルの内容も含める) - let resultText = "# Directory Structure\n\n"; - resultText += createTreeDisplay(fileTree, false); - - // READMEファイルがあれば追加 - const readmeFiles = Array.from(fileTree.entries()) - .filter(([path, { content }]) => /readme\.md$/i.test(path) && content); - - if (readmeFiles.length > 0) { - resultText += "\n# README Files\n\n"; - for (const [path, { content }] of readmeFiles) { - resultText += `## ${path}\n\n${content}\n\n`; - } - } - - return c.text(resultText, 200); - } else { - // 通常モード:ツリー+ファイル内容 - let resultText = "# 📁 File Tree\n\n"; - resultText += createTreeDisplay(fileTree, true); - - resultText += `\n# 📝 Files (Total: ${(originalTotalSize / 1024).toFixed(2)} KB→${(displayTotalSize / 1024).toFixed(2)} KB)\n\n`; - for (const [path, { content }] of fileTree) { - resultText += `\`\`\`${path}\n${content}\n\`\`\`\n\n`; - } - - return c.text(resultText, 200); - } - } catch (e: unknown) { - const msg = `Unexpected error: ${e instanceof Error ? e.message : "Unknown error"}`; - console.error(`❌ ${msg}`); - return createErrorResponse(c, "", msg, 500); - } }); export default app; diff --git a/src/mcp.ts b/src/mcp.ts new file mode 100644 index 0000000..c99cd68 --- /dev/null +++ b/src/mcp.ts @@ -0,0 +1,41 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { APP_VERSION } from "./constants"; +import { processGitHubRepository } from "./github"; + +export const mcpServer = new McpServer({ + name: "github-pera1-mcp-server", + version: APP_VERSION, +}); + +mcpServer.registerTool( + "fetch_github_code", + { + title: "GitHub Code Fetcher", + description: "Fetch code from GitHub repositories with flexible filtering options", + inputSchema: { + url: z.string().describe("GitHub repository URL (e.g., https://github.com/owner/repo)"), + dir: z.string().optional().describe("Filter by directory path (comma-separated)"), + ext: z.string().optional().describe("Filter by file extensions (comma-separated)"), + branch: z.string().optional().describe("Git branch name"), + file: z.string().optional().describe("Specific file path to fetch"), + mode: z.enum(["tree", "full"]).optional().describe("Display mode: tree (structure only) or full"), + }, + }, + async (args) => { + if (!args?.url || typeof args.url !== "string" || args.url.trim() === "") { + throw new Error("URL parameter is required. Provide a GitHub repository URL."); + } + + const result = await processGitHubRepository({ + url: args.url, + dir: args.dir, + ext: args.ext, + branch: args.branch, + file: args.file, + mode: args.mode, + }); + + return { content: [{ type: "text" as const, text: result }] }; + }, +); diff --git a/src/resolver.ts b/src/resolver.ts new file mode 100644 index 0000000..da065b9 --- /dev/null +++ b/src/resolver.ts @@ -0,0 +1,153 @@ +import type { ResolvedRequest } from "./types"; + +/** + * Normalize various repository input formats into an HTTPS URL path. + * + * Supported formats: + * - git@github.com:owner/repo.git (SCP-style SSH) + * - ssh://git@github.com/owner/repo.git + * - git://github.com/owner/repo.git + * - https://github.com/owner/repo.git + * - github.com/owner/repo + */ +function normalizeRepositoryInput(raw: string): string { + // SCP-style: git@github.com:owner/repo.git + const scpMatch = raw.match( + /^(?:ssh:\/\/)?git@([^:/]+)[:/](.+?)(?:\.git)?$/, + ); + if (scpMatch) { + return `https://${scpMatch[1]}/${scpMatch[2]}`; + } + + // git:// protocol + const gitProtoMatch = raw.match(/^git:\/\/([^/]+)\/(.+?)(?:\.git)?$/); + if (gitProtoMatch) { + return `https://${gitProtoMatch[1]}/${gitProtoMatch[2]}`; + } + + // Strip trailing .git from any remaining format + const stripped = raw.replace(/\.git$/, ""); + return stripped.startsWith("http") ? stripped : `https://${stripped}`; +} + +/** + * Resolve a request's URL path and query parameters into structured parameters + * for GitHub repository processing. + */ +export function resolveRequest( + path: string, + searchParams: URLSearchParams, +): ResolvedRequest { + if (!path) { + throw new Error("No repository URL provided"); + } + + const urlStr = normalizeRepositoryInput(path); + + let parsed: URL; + try { + parsed = new URL(urlStr); + } catch (error) { + throw new Error( + `Invalid URL: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + + const segments = parsed.pathname.split("/").filter(Boolean); + if (segments.length < 2) { + throw new Error("Invalid GitHub repository URL format"); + } + + const owner = segments[0]; + const repo = segments[1]; + + // Extract branch, dir, file from URL path segments + let urlBranch: string | undefined; + let urlDir: string | undefined; + let urlFilePath: string | undefined; + + if (segments.length > 3 && segments[2] === "tree") { + const branchAndDirParts = segments.slice(3); + urlBranch = branchAndDirParts[0]; + if (branchAndDirParts.length > 1) { + urlDir = branchAndDirParts.slice(1).join("/"); + } + } else if (segments.length > 3 && segments[2] === "blob") { + const branchAndFileParts = segments.slice(3); + urlBranch = branchAndFileParts[0]; + if (branchAndFileParts.length > 1) { + urlFilePath = branchAndFileParts.slice(1).join("/"); + } + } else if ( + segments.length > 2 && + segments[2] !== "tree" && + segments[2] !== "blob" + ) { + urlDir = segments.slice(2).join("/"); + } + + // Query parameters + const queryDirs = searchParams + .get("dir") + ?.split(",") + .map((d) => d.trim()) + .filter((d) => d); + const queryExts = searchParams + .get("ext") + ?.split(",") + .map((e) => e.trim().toLowerCase()) + .filter((e) => e); + const paramBranch = searchParams.get("branch")?.trim(); + const queryFile = searchParams.get("file")?.trim(); + const isTreeMode = searchParams.get("mode") === "tree"; + + // Branch priority: query param > URL path > default "main" + const branch = paramBranch || urlBranch || "main"; + + // Directory resolution: combine urlDir + query dirs + let targetDirs: string[] = []; + if (queryDirs && queryDirs.length > 0) { + if (urlDir) { + const basePath = urlDir.endsWith("/") ? urlDir : urlDir + "/"; + targetDirs = queryDirs.map((d) => { + const relativePath = d.startsWith("/") ? d.slice(1) : d; + const combined = basePath + relativePath; + return combined.endsWith("/") ? combined : combined + "/"; + }); + } else { + targetDirs = queryDirs.map((d) => (d.endsWith("/") ? d : d + "/")); + } + } else if (urlDir) { + targetDirs = [urlDir.endsWith("/") ? urlDir : urlDir + "/"]; + } + + // Extensions from query only + const targetExts = queryExts || []; + + // File resolution: query file > URL blob path, combined with urlDir + let targetFile: string | undefined; + if (queryFile) { + if (urlDir) { + const basePath = urlDir.endsWith("/") ? urlDir : urlDir + "/"; + const relativePath = queryFile.startsWith("/") + ? queryFile.slice(1) + : queryFile; + targetFile = basePath + relativePath; + } else { + targetFile = queryFile; + } + } else if (urlFilePath) { + targetFile = urlFilePath; + } + + return { + owner, + repo, + branch, + targetDirs, + targetExts, + targetFile, + isTreeMode, + originalUrl: urlStr, + }; +} diff --git a/src/tree.ts b/src/tree.ts new file mode 100644 index 0000000..5c912a1 --- /dev/null +++ b/src/tree.ts @@ -0,0 +1,43 @@ +import type { FileEntry } from "./types"; + +/** Build an indented tree display string from a file map */ +export function createTreeDisplay( + fileTree: Map, + showSize = false, +): string { + const dirs = new Set(); + + for (const [path] of fileTree) { + const parts = path.split("/"); + for (let i = 1; i <= parts.length; i++) { + dirs.add(parts.slice(0, i).join("/")); + } + } + + const sortedDirs = Array.from(dirs).sort(); + const dirsArray = sortedDirs; // already an array, avoid re-conversion + let result = ""; + + for (const dir of sortedDirs) { + const depth = dir.split("/").length - 1; + const indent = " ".repeat(depth); + const name = dir.split("/").pop() || ""; + const isFile = !dirsArray.some((d) => d.startsWith(dir + "/")); + + if (showSize && isFile) { + const fileInfo = fileTree.get(dir); + if (fileInfo) { + const sizeKB = (fileInfo.size / 1024).toFixed(2); + result += fileInfo.isTruncated + ? `${indent}📄 ${name} (${sizeKB} KB→30KB truncated)\n` + : `${indent}📄 ${name} (${sizeKB} KB)\n`; + } else { + result += `${indent}📄 ${name} (0.00 KB)\n`; + } + } else { + result += `${indent}${isFile ? "📄" : "📂"} ${name}\n`; + } + } + + return result; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..28791af --- /dev/null +++ b/src/types.ts @@ -0,0 +1,37 @@ +/** File entry stored in the in-memory tree after extraction */ +export interface FileEntry { + size: number; + content: string; + isTruncated?: boolean; +} + +/** Parameters accepted by processGitHubRepository */ +export interface GitHubRepositoryParams { + url: string; + dir?: string; + ext?: string; + branch?: string; + file?: string; + mode?: "tree" | "full"; +} + +/** Parsed information extracted from a GitHub URL */ +export interface ParsedGitHubUrl { + owner: string; + repo: string; + branch?: string; + dir?: string; + filePath?: string; +} + +/** Result of resolving a request's URL path + query parameters */ +export interface ResolvedRequest { + owner: string; + repo: string; + branch: string; + targetDirs: string[]; + targetExts: string[]; + targetFile?: string; + isTreeMode: boolean; + originalUrl: string; +} diff --git a/src/ui.ts b/src/ui.ts new file mode 100644 index 0000000..49acdac --- /dev/null +++ b/src/ui.ts @@ -0,0 +1,560 @@ +import { APP_VERSION, EXAMPLE_REPO } from "./constants"; + +/** Escape HTML special characters to prevent XSS */ +function escapeHtml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** Escape a string for safe embedding inside a JavaScript string literal */ +function escapeJs(str: string): string { + return str + .replace(/\\/g, "\\\\") + .replace(/'/g, "\\'") + .replace(/"/g, '\\"') + .replace(//g, "\\x3e") + .replace(/\n/g, "\\n"); +} + +/** Generate the landing page HTML */ +export function createLandingPage( + protocol: string, + host: string, + errorMessage?: string, + targetUrl?: string, +): string { + const safeProtocol = escapeHtml(protocol); + const safeHost = escapeHtml(host); + const safeTargetUrl = targetUrl ? escapeHtml(targetUrl) : ""; + const safeErrorMessage = errorMessage ? escapeHtml(errorMessage) : ""; + const fullUrl = targetUrl + ? `${safeProtocol}://${safeHost}/${safeTargetUrl}` + : `${safeProtocol}://${safeHost}/${escapeHtml(EXAMPLE_REPO.replace("https://", ""))}`; + + return ` + + + + + + github pera1 — Code to Text for LLMs + + + +
+
+ +

github pera1

+

Fetch GitHub repos as plain text for LLMs. Paste a URL, get code instantly.

+
+ + ${safeErrorMessage ? `
Error: ${safeErrorMessage}
` : ""} + +
+

🔍 Fetch Repository

+
+
+ + +
HTTPS, SSH (git@), or owner/repo format
+
+ +
+
+ + +
Comma-separated filter
+
+
+ + +
Without dots
+
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
Fetch only this specific file
+
+ + + +
+ + +
+
+
+ +
+
+
🤖
+

MCP Support

+

Model Context Protocol server at /mcp endpoint

+
+
+
🗂
+

Smart Filter

+

Filter by directory, extension, or single file

+
+
+
🌳
+

Tree Mode

+

Get repo structure without file contents

+
+
+ +
+

Quick Start

+
    +
  • Basic: ${safeProtocol}://${safeHost}/github.com/owner/repo
  • +
  • Branch: ${safeProtocol}://${safeHost}/github.com/owner/repo/tree/dev
  • +
  • Filter: ${safeProtocol}://${safeHost}/github.com/owner/repo?dir=src&ext=ts
  • +
  • File: ${safeProtocol}://${safeHost}/github.com/owner/repo?file=README.md
  • +
  • Tree: ${safeProtocol}://${safeHost}/github.com/owner/repo?mode=tree
  • +
  • MCP: ${safeProtocol}://${safeHost}/mcp
  • +
+
+ +
+

github pera1 v${APP_VERSION} — GitHub

+
+
+ + + +`; +} + +/** Generate an error response HTML page */ +export function createErrorPage( + protocol: string, + host: string, + targetUrl: string, + errorMessage: string, +): string { + return createLandingPage(protocol, host, errorMessage, targetUrl); +} diff --git a/tests/helpers/build-zip.ts b/tests/helpers/build-zip.ts new file mode 100644 index 0000000..9ac3872 --- /dev/null +++ b/tests/helpers/build-zip.ts @@ -0,0 +1,47 @@ +import JSZip from "jszip"; + +/** + * Build a test ZIP that mimics GitHub's codeload format. + * GitHub ZIPs have a root folder: `{repo}-{branch}/` + */ +export async function buildTestZip( + repoName: string, + branch: string, + files: Record, +): Promise { + const zip = new JSZip(); + const prefix = `${repoName}-${branch}`; + + for (const [path, content] of Object.entries(files)) { + zip.file(`${prefix}/${path}`, content); + } + + return zip.generateAsync({ type: "arraybuffer" }); +} + +/** Standard test files for a typical repo */ +export const SAMPLE_FILES: Record = { + "README.md": "# Test Repo\n\nThis is a test repository.", + "src/index.ts": 'export const hello = "world";\n', + "src/utils.ts": "export function add(a: number, b: number) { return a + b; }\n", + "tsconfig.json": '{"compilerOptions":{"target":"ESNext","module":"ESNext","strict":true}}', + "package.json": '{"name":"test-repo","version":"1.0.0"}', +}; + +/** Create a mock Response wrapping a ZIP ArrayBuffer */ +export async function buildZipResponse( + repoName: string, + branch: string, + files: Record = SAMPLE_FILES, +): Promise { + const buf = await buildTestZip(repoName, branch, files); + return new Response(buf, { + status: 200, + headers: { "Content-Type": "application/zip" }, + }); +} + +/** Create a 404 Response */ +export function notFoundResponse(): Response { + return new Response("Not Found", { status: 404 }); +} diff --git a/tests/integration/http.spec.ts b/tests/integration/http.spec.ts new file mode 100644 index 0000000..3cea2ae --- /dev/null +++ b/tests/integration/http.spec.ts @@ -0,0 +1,267 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import app from "../../src/index"; +import { + buildTestZip, + SAMPLE_FILES, +} from "../helpers/build-zip"; + +// --------------------------------------------------------------------------- +// Network-boundary interception: replace globalThis.fetch to prevent real +// GitHub API calls. This is NOT an internal mock — it intercepts at the +// system boundary (outbound HTTP) and returns realistic ZIP responses. +// --------------------------------------------------------------------------- + +type FetchHandler = (url: string, init?: RequestInit) => Promise; + +const originalFetch = globalThis.fetch; + +function installFetchInterceptor(handler: FetchHandler) { + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const urlStr = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const result = await handler(urlStr, init); + if (result) return result; + return originalFetch(input, init as any); + }) as typeof fetch; +} + +function restoreFetch() { + globalThis.fetch = originalFetch; +} + +// Default: return a valid ZIP for any codeload.github.com request +async function defaultZipHandler(url: string): Promise { + if (!url.includes("codeload.github.com")) return null; + + const match = url.match(/codeload\.github\.com\/([^/]+)\/([^/]+)\/zip\/(.+)/); + if (!match) return null; + + const [, , repo, branch] = match; + const buf = await buildTestZip(repo, branch, SAMPLE_FILES); + return new Response(buf, { + status: 200, + headers: { "Content-Type": "application/zip" }, + }); +} + +// Helper to make requests to the app +function request(path: string, method = "GET"): Promise { + return app.fetch(new Request(`http://localhost${path}`, { method })) as Promise; +} + +// ==================================================================== +// Test Suite +// ==================================================================== + +describe("HTTP Integration Tests", () => { + beforeAll(() => { + installFetchInterceptor(defaultZipHandler); + }); + + afterAll(() => { + restoreFetch(); + }); + + // ------------------------------------------------------------------ + // Test 1: Basic — GET /github.com/owner/repo + // ------------------------------------------------------------------ + it("Test 1: Basic URL returns 200 with file tree and content", async () => { + const res = await request("/github.com/owner/repo"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/plain"); + + const body = await res.text(); + // Should contain file tree + expect(body).toContain("File Tree"); + // Should contain file contents from SAMPLE_FILES + expect(body).toContain("README.md"); + expect(body).toContain("src/index.ts"); + }); + + // ------------------------------------------------------------------ + // Test 2: Branch — GET /github.com/owner/repo/tree/dev + // ------------------------------------------------------------------ + it("Test 2: Branch URL fetches correct branch", async () => { + let fetchedBranch = ""; + installFetchInterceptor(async (url) => { + if (!url.includes("codeload.github.com")) return null; + const match = url.match(/\/zip\/(.+)/); + if (match) fetchedBranch = match[1]; + return defaultZipHandler(url); + }); + + const res = await request("/github.com/owner/repo/tree/dev"); + expect(res.status).toBe(200); + expect(fetchedBranch).toBe("dev"); + + // Restore default handler + installFetchInterceptor(defaultZipHandler); + }); + + // ------------------------------------------------------------------ + // Test 3: Filter (dir+ext) — GET /github.com/owner/repo?dir=src&ext=ts + // ------------------------------------------------------------------ + it("Test 3: Filter by dir and ext returns filtered results", async () => { + const res = await request("/github.com/owner/repo?dir=src&ext=ts"); + expect(res.status).toBe(200); + + const body = await res.text(); + // Should contain TS files from src/ + expect(body).toContain("src/index.ts"); + // Should NOT contain non-ts files or files outside src/ + expect(body).not.toContain("package.json"); + }); + + // ------------------------------------------------------------------ + // Test 4: File — GET /github.com/owner/repo?file=README.md + // ------------------------------------------------------------------ + it("Test 4: Single file request returns file content", async () => { + const res = await request("/github.com/owner/repo?file=README.md"); + expect(res.status).toBe(200); + + const body = await res.text(); + expect(body).toContain("# Test Repo"); + // Should NOT contain tree structure + expect(body).not.toContain("File Tree"); + }); + + // ------------------------------------------------------------------ + // Test 5: Tree mode — GET /github.com/owner/repo?mode=tree + // ------------------------------------------------------------------ + it("Test 5: Tree mode returns directory structure only", async () => { + const res = await request("/github.com/owner/repo?mode=tree"); + expect(res.status).toBe(200); + + const body = await res.text(); + expect(body).toContain("Directory Structure"); + // README content should still be included in tree mode + expect(body).toContain("README"); + }); + + // ------------------------------------------------------------------ + // Test 6: MCP — POST /mcp + // ------------------------------------------------------------------ + it("Test 6: MCP endpoint responds to POST", async () => { + const res = await request("/mcp", "POST"); + // MCP endpoint should respond (not 404). It may return 400/200 depending + // on whether a valid MCP payload was sent. + expect(res.status).not.toBe(404); + }); + + // ------------------------------------------------------------------ + // Test 7: SSH URL — GET /git@github.com:owner/repo.git + // ------------------------------------------------------------------ + it("Test 7: SSH URL is parsed correctly", async () => { + const res = await request("/git@github.com:owner/repo.git"); + expect(res.status).toBe(200); + + const body = await res.text(); + expect(body).toContain("File Tree"); + }); + + // ------------------------------------------------------------------ + // Test 8: Landing page — GET / + // ------------------------------------------------------------------ + it("Test 8: Root path returns landing page HTML", async () => { + const res = await request("/"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/html"); + + const body = await res.text(); + expect(body).toContain(""); + }); + + // ------------------------------------------------------------------ + // Test 9: Branch fallback + // ------------------------------------------------------------------ + it("Test 9: Falls back to main/master when requested branch fails", async () => { + const attemptedBranches: string[] = []; + installFetchInterceptor(async (url) => { + if (!url.includes("codeload.github.com")) return null; + const match = url.match(/\/zip\/(.+)/); + if (match) attemptedBranches.push(match[1]); + + // Fail the first branch (nonexistent), succeed on "main" + if (url.includes("/zip/nonexistent")) { + return new Response("Not Found", { status: 404 }); + } + return defaultZipHandler(url); + }); + + const res = await request("/github.com/owner/repo?branch=nonexistent"); + expect(res.status).toBe(200); + expect(attemptedBranches).toContain("nonexistent"); + expect(attemptedBranches).toContain("main"); + + installFetchInterceptor(defaultZipHandler); + }); + + // ------------------------------------------------------------------ + // Test 10: Invalid URL — GET /not-a-valid-url + // ------------------------------------------------------------------ + it("Test 10: Invalid URL returns 400 error", async () => { + const res = await request("/not-a-valid-url"); + expect(res.status).toBe(400); + + const body = await res.text(); + expect(body).toContain("Invalid"); + }); + + // ------------------------------------------------------------------ + // Test 11: Owner only — GET /github.com/owner + // ------------------------------------------------------------------ + it("Test 11: Owner-only URL returns 400", async () => { + const res = await request("/github.com/owner"); + expect(res.status).toBe(400); + + const body = await res.text(); + expect(body).toContain("Invalid GitHub repository URL"); + }); + + // ------------------------------------------------------------------ + // Test 12: File not found — GET /github.com/owner/repo?file=nonexistent.txt + // ------------------------------------------------------------------ + it("Test 12: Requesting nonexistent file returns 404", async () => { + const res = await request("/github.com/owner/repo?file=nonexistent.txt"); + expect(res.status).toBe(404); + }); + + // ------------------------------------------------------------------ + // Test 13: ZIP fetch failure (all branches) → 500 + // ------------------------------------------------------------------ + it("Test 13: ZIP fetch failure on all branches returns 500", async () => { + installFetchInterceptor(async (url) => { + if (!url.includes("codeload.github.com")) return null; + return new Response("Not Found", { status: 404 }); + }); + + const res = await request("/github.com/owner/repo"); + expect(res.status).toBe(500); + + installFetchInterceptor(defaultZipHandler); + }); + + // ------------------------------------------------------------------ + // Test 14: Empty repository (ZIP with 0 files) + // ------------------------------------------------------------------ + it("Test 14: Empty repository returns 200 with empty tree", async () => { + installFetchInterceptor(async (url) => { + if (!url.includes("codeload.github.com")) return null; + const match = url.match(/codeload\.github\.com\/[^/]+\/([^/]+)\/zip\/(.+)/); + if (!match) return null; + // Build ZIP with no files + const buf = await buildTestZip(match[1], match[2], {}); + return new Response(buf, { + status: 200, + headers: { "Content-Type": "application/zip" }, + }); + }); + + const res = await request("/github.com/owner/repo"); + expect(res.status).toBe(200); + + const body = await res.text(); + expect(body).toContain("File Tree"); + + installFetchInterceptor(defaultZipHandler); + }); +}); diff --git a/tests/unit/resolver.spec.ts b/tests/unit/resolver.spec.ts new file mode 100644 index 0000000..f44f75c --- /dev/null +++ b/tests/unit/resolver.spec.ts @@ -0,0 +1,181 @@ +import { describe, it, expect } from "vitest"; +import { resolveRequest } from "../../src/resolver"; + +function params(obj: Record = {}): URLSearchParams { + return new URLSearchParams(obj); +} + +describe("resolveRequest", () => { + // Test 15: Basic URL parsing + describe("Basic URL parsing", () => { + it("parses github.com/owner/repo", () => { + const result = resolveRequest("github.com/owner/repo", params()); + expect(result.owner).toBe("owner"); + expect(result.repo).toBe("repo"); + expect(result.branch).toBe("main"); + expect(result.targetDirs).toEqual([]); + expect(result.targetExts).toEqual([]); + expect(result.targetFile).toBeUndefined(); + expect(result.isTreeMode).toBe(false); + expect(result.originalUrl).toBe("https://github.com/owner/repo"); + }); + + it("handles https:// prefix", () => { + const result = resolveRequest("https://github.com/owner/repo", params()); + expect(result.owner).toBe("owner"); + expect(result.repo).toBe("repo"); + }); + }); + + // Test 16: tree/blob URL parsing + describe("tree/blob URL parsing", () => { + it("parses /tree/branch URL", () => { + const result = resolveRequest( + "github.com/owner/repo/tree/develop", + params(), + ); + expect(result.branch).toBe("develop"); + expect(result.targetDirs).toEqual([]); + }); + + it("parses /tree/branch/path URL", () => { + const result = resolveRequest( + "github.com/owner/repo/tree/main/src/lib", + params(), + ); + expect(result.branch).toBe("main"); + expect(result.targetDirs).toEqual(["src/lib/"]); + }); + + it("parses /blob/branch/file URL", () => { + const result = resolveRequest( + "github.com/owner/repo/blob/main/src/index.ts", + params(), + ); + expect(result.branch).toBe("main"); + expect(result.targetFile).toBe("src/index.ts"); + }); + }); + + // Test 17: query params (dir, ext, file, mode, branch) + describe("query parameters", () => { + it("handles dir param", () => { + const result = resolveRequest( + "github.com/owner/repo", + params({ dir: "src" }), + ); + expect(result.targetDirs).toEqual(["src/"]); + }); + + it("handles multiple dirs", () => { + const result = resolveRequest( + "github.com/owner/repo", + params({ dir: "src,lib" }), + ); + expect(result.targetDirs).toEqual(["src/", "lib/"]); + }); + + it("handles ext param", () => { + const result = resolveRequest( + "github.com/owner/repo", + params({ ext: "ts,tsx" }), + ); + expect(result.targetExts).toEqual(["ts", "tsx"]); + }); + + it("handles file param", () => { + const result = resolveRequest( + "github.com/owner/repo", + params({ file: "README.md" }), + ); + expect(result.targetFile).toBe("README.md"); + }); + + it("handles mode=tree", () => { + const result = resolveRequest( + "github.com/owner/repo", + params({ mode: "tree" }), + ); + expect(result.isTreeMode).toBe(true); + }); + + it("handles branch param (overrides URL path branch)", () => { + const result = resolveRequest( + "github.com/owner/repo/tree/develop", + params({ branch: "feature" }), + ); + expect(result.branch).toBe("feature"); + }); + }); + + // Test 18: SSH URL normalization + describe("SSH URL normalization", () => { + it("normalizes git@github.com:owner/repo.git", () => { + const result = resolveRequest( + "git@github.com:owner/repo.git", + params(), + ); + expect(result.owner).toBe("owner"); + expect(result.repo).toBe("repo"); + expect(result.originalUrl).toBe("https://github.com/owner/repo"); + }); + + it("normalizes ssh://git@github.com/owner/repo.git", () => { + const result = resolveRequest( + "ssh://git@github.com/owner/repo.git", + params(), + ); + expect(result.owner).toBe("owner"); + expect(result.repo).toBe("repo"); + }); + + it("normalizes git://github.com/owner/repo.git", () => { + const result = resolveRequest( + "git://github.com/owner/repo.git", + params(), + ); + expect(result.owner).toBe("owner"); + expect(result.repo).toBe("repo"); + }); + }); + + // Test 19: .git suffix removal + describe(".git suffix removal", () => { + it("strips .git from https URL", () => { + const result = resolveRequest( + "https://github.com/owner/repo.git", + params(), + ); + expect(result.owner).toBe("owner"); + expect(result.repo).toBe("repo"); + expect(result.originalUrl).toBe("https://github.com/owner/repo"); + }); + + it("strips .git from bare domain URL", () => { + const result = resolveRequest("github.com/owner/repo.git", params()); + expect(result.owner).toBe("owner"); + expect(result.repo).toBe("repo"); + }); + }); + + // Test 20: Invalid URL → error + describe("invalid URLs", () => { + it("throws on empty path", () => { + expect(() => resolveRequest("", params())).toThrow( + "No repository URL provided", + ); + }); + + it("throws on owner-only URL (no repo)", () => { + expect(() => resolveRequest("github.com/owner", params())).toThrow( + "Invalid GitHub repository URL format", + ); + }); + + it("throws on non-URL gibberish", () => { + expect(() => + resolveRequest("not a url at all", params()), + ).toThrow(); + }); + }); +}); diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..e54b23e --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "vite"; +import { cloudflare } from "@cloudflare/vite-plugin"; + +export default defineConfig({ + plugins: [cloudflare()], +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..1d82461 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.spec.ts"], + environment: "node", + }, + resolve: { + extensions: [".ts", ".tsx", ".js", ".jsx", ".json"], + }, +}); diff --git a/wrangler.toml b/wrangler.toml index 5c0a2db..d93b616 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,5 +1,9 @@ name = "pera1" main = "src/index.ts" -compatibility_date = "2023-12-05" +compatibility_date = "2025-04-01" +compatibility_flags = ["nodejs_compat"] assets = { directory = "./public/" } + +[env.preview] +name = "pera1-preview"