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

@@ -1,6 +1,7 @@
// 뷰 헬퍼 — 빌드/프런트 프레임워크 없이 서버에서 SVG 를 직접 만들어 EJS 로 출력한다.
// (디자인 레퍼런스의 Octicon 패스 / identicon 알고리즘 / 언어색을 그대로 포팅)
// res.locals 에 실어 모든 뷰에서 icon()/identicon()/langColor() 로 사용.
// res.locals 에 실어 모든 뷰에서 icon()/identicon()/langColor()/renderMarkdown() 로 사용.
import MarkdownIt from "markdown-it";
// GitHub Octicon 16x16 패스 모음.
const OCTICONS = {
@@ -39,6 +40,39 @@ export function icon(name, opts = {}) {
);
}
// 프로젝트 설명(README 형식) 마크다운 렌더링.
// html:false → 원문 속 raw HTML 은 태그가 아니라 텍스트로 이스케이프되어 XSS 를 막는다.
// markdown-it 기본 validateLink 가 javascript:/vbscript:/data: 링크도 차단한다.
const md = new MarkdownIt({ html: false, linkify: true, breaks: true });
// 마크다운 링크는 새 탭으로 열고 noopener 를 붙인다.
const defaultLinkOpen =
md.renderer.rules.link_open ||
function (tokens, idx, options, env, self) { return self.renderToken(tokens, idx, options); };
md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
tokens[idx].attrSet("target", "_blank");
tokens[idx].attrSet("rel", "noopener nofollow");
return defaultLinkOpen(tokens, idx, options, env, self);
};
export function renderMarkdown(src) {
return md.render(String(src || ""));
}
// 카드 미리보기용 — 마크다운 기호를 걷어낸 순수 텍스트 한 덩어리.
// (제목 #, 목록 -, 강조 *_`, 링크 [텍스트](url) → 텍스트, 코드펜스 ``` 제거)
export function mdPlain(src) {
return String(src || "")
.replace(/```[\s\S]*?```/g, " ") // 코드펜스 블록 제거
.replace(/`([^`]*)`/g, "$1") // 인라인 코드
.replace(/!\[[^\]]*\]\([^)]*\)/g, " ") // 이미지
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") // 링크 → 텍스트
.replace(/^\s{0,3}#{1,6}\s+/gm, "") // 제목 마커
.replace(/^\s{0,3}>\s?/gm, "") // 인용 마커
.replace(/^\s*[-*+]\s+/gm, "") // 목록 마커
.replace(/[*_~]{1,3}/g, "") // 강조 기호
.replace(/\s+/g, " ")
.trim();
}
// 언어 → 색(카드의 언어 점).
const LANG_COLORS = {
Python: "#3572A5", TypeScript: "#3178c6", JavaScript: "#f1e05a",