feat(projects): 설명 마크다운 렌더링 + 저장소 README 가져오기

등록 모달의 '한 줄 설명' input 을 여러 줄 textarea 로 바꾸고, 설명을
markdown-it 로 렌더링한다(html:false 로 raw HTML 이스케이프·javascript:
링크 차단 — XSS 안전). 상세 화면은 마크다운으로, 카드 미리보기는 기호를
걷어낸 순수 텍스트(mdPlain) + 3줄 클램프로 표시.

'저장소 README 가져오기' 버튼과 GET /api/readme 라우트를 추가해 repo_url
의 Gitea README 를 설명란으로 불러올 수 있다(gitea.js fetchReadme).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-02 14:18:27 +09:00
parent 4a3e5636c8
commit da1653a8ea
10 changed files with 232 additions and 11 deletions

View File

@@ -48,3 +48,31 @@ export async function fetchMainLang(repoUrl) {
clearTimeout(timer);
}
}
// 레포의 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 = await res.text();
// 너무 큰 README 는 앞부분만(설명란이 감당할 범위). 20KB 컷.
return text.length > 20000 ? text.slice(0, 20000) : text;
}
return "";
} catch {
return "";
} finally {
clearTimeout(timer);
}
}