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

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",

View File

@@ -6,8 +6,8 @@ import path from "path";
import { config } from "./config.js";
import { toolsForUser } from "./sites.js";
import { initOidc, loginRedirect, handleCallback, requireAuth, oidcReady, logoutUrl } from "./auth.js";
import { icon, identicon, langColor, langFromTags } from "./helpers.js";
import { fetchMainLang } from "./gitea.js";
import { icon, identicon, langColor, langFromTags, renderMarkdown, mdPlain } from "./helpers.js";
import { fetchMainLang, fetchReadme } from "./gitea.js";
import * as db from "./db.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -35,6 +35,8 @@ app.use((req, res, next) => {
res.locals.icon = icon;
res.locals.identicon = identicon;
res.locals.langColor = langColor;
res.locals.renderMarkdown = renderMarkdown;
res.locals.mdPlain = mdPlain;
next();
});
@@ -161,6 +163,13 @@ app.post("/projects/:id/visibility", requireAuth, async (req, res) => {
res.redirect(back + sep + "toast=" + encodeURIComponent(msg));
});
// --- README 가져오기 (등록 모달) — repo_url 의 Gitea README 를 마크다운 원문으로 반환 ---
app.get("/api/readme", requireAuth, async (req, res) => {
const content = await fetchReadme((req.query.repo_url || "").toString().trim());
if (!content) return res.status(404).json({ ok: false });
res.json({ ok: true, content });
});
// --- 프로젝트 삭제 (본인) ---
app.post("/projects/:id/delete", requireAuth, async (req, res) => {
const n = await db.deleteProject({ id: req.params.id, sabun: req.session.user.sabun });