Files
ai-dev-portal/src/gitea.js
2620227 1f5179cd04 feat(projects): 등록 폼·카드 미리보기·README 가져오기 개선
- 등록 모달: 저장소 URL 필드를 제목 아래(설명 위)로 이동 — README 가져오기 흐름 자연화
- URL 미입력 상태로 README 가져오기 시 입력란에 빨간 인라인 안내 표시(토스트 대신)
- 피드 카드: 설명 미리보기 2줄 클램프 + flex:1 로 태그·푸터를 카드 하단에 정렬
- fetchReadme: CRLF→LF·줄끝공백·연속빈줄 정리로 불필요한 개행 유입 방지
- mdPlain: 개행을 살려(빈 줄만 제거) 카드 미리보기가 README 줄 구조로 보이게

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 13:15:20 +09:00

93 lines
3.6 KiB
JavaScript

// 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);
}
}