// Gitea 언어 통계 조회 — 프로젝트의 repo_url 로 메인 언어를 자동 판별. // 공개 레포는 토큰 없이도 조회되고, 비공개 레포는 GITEA_TOKEN 이 있어야 한다. // 네트워크 실패/타임아웃/비-Gitea URL 이면 빈 문자열을 반환해 호출부가 폴백하도록 한다. import { config } from "./config.js"; // repo_url → { base, owner, repo } 파싱. URL 형태가 아니거나 owner/repo 가 없으면 null. // 예) https://gitea.bokdev.in/hong/my-app(.git) → { base, owner: hong, repo: my-app } export function parseRepo(repoUrl) { if (!repoUrl) return null; let u; try { u = new URL(repoUrl); } catch { return null; } const parts = u.pathname.replace(/\.git$/, "").split("/").filter(Boolean); if (parts.length < 2) return null; return { base: `${u.protocol}//${u.host}`, owner: parts[0], repo: parts[1] }; } // 레포의 메인 언어(바이트 최다) 1개를 반환. 실패/없음이면 "". export async function fetchMainLang(repoUrl) { const r = parseRepo(repoUrl); if (!r) return ""; const url = `${r.base}/api/v1/repos/${encodeURIComponent(r.owner)}/${encodeURIComponent(r.repo)}/languages`; const headers = { Accept: "application/json" }; if (config.giteaToken) headers.Authorization = `token ${config.giteaToken}`; // 등록 응답이 Gitea 응답을 기다리다 멈추지 않도록 3초 타임아웃. const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 3000); try { const res = await fetch(url, { headers, signal: ctrl.signal }); if (!res.ok) return ""; const data = await res.json(); // { "JavaScript": 12345, "CSS": 678, ... } let top = ""; let max = -1; for (const [lang, bytes] of Object.entries(data || {})) { if (typeof bytes === "number" && bytes > max) { max = bytes; top = lang; } } return top; } catch { return ""; } finally { clearTimeout(timer); } } // raw README 의 개행을 정리한다. Gitea 화면(렌더링)에는 없지만 raw 원문에 남아 있는 // 군더더기 개행이 설명란에 "없던 엔터"처럼 들어오는 문제를 막는다. // - CRLF/CR → LF 로 통일(Windows 개행의 \r 제거) // - 줄 끝 공백 제거 // - 3줄 이상 연속 빈 줄 → 빈 줄 1개(2개 개행)로 축소 (마크다운상 동일) // - 문서 앞뒤 공백/개행 제거 function normalizeReadme(text) { return String(text) .replace(/\r\n?/g, "\n") .replace(/[ \t]+$/gm, "") .replace(/\n{3,}/g, "\n\n") .trim(); } // 레포의 README 원문(마크다운)을 반환. 실패/없음이면 "". // Gitea raw 엔드포인트로 기본 브랜치의 README 파일을 후보 순서대로 시도한다. export async function fetchReadme(repoUrl) { const r = parseRepo(repoUrl); if (!r) return ""; const headers = {}; if (config.giteaToken) headers.Authorization = `token ${config.giteaToken}`; const base = `${r.base}/api/v1/repos/${encodeURIComponent(r.owner)}/${encodeURIComponent(r.repo)}/raw`; const candidates = ["README.md", "README.MD", "readme.md", "README", "README.markdown"]; const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 4000); try { for (const name of candidates) { const res = await fetch(`${base}/${name}`, { headers, signal: ctrl.signal }); if (!res.ok) continue; const text = normalizeReadme(await res.text()); // 너무 큰 README 는 앞부분만(설명란이 감당할 범위). 20KB 컷. return text.length > 20000 ? text.slice(0, 20000) : text; } return ""; } catch { return ""; } finally { clearTimeout(timer); } }