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:
28
src/gitea.js
28
src/gitea.js
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user