From da1653a8eaaa90bb7b88bae32684eb4d525cea9c Mon Sep 17 00:00:00 2001 From: 2620227 Date: Thu, 2 Jul 2026 14:18:27 +0900 Subject: [PATCH] =?UTF-8?q?feat(projects):=20=EC=84=A4=EB=AA=85=20?= =?UTF-8?q?=EB=A7=88=ED=81=AC=EB=8B=A4=EC=9A=B4=20=EB=A0=8C=EB=8D=94?= =?UTF-8?q?=EB=A7=81=20+=20=EC=A0=80=EC=9E=A5=EC=86=8C=20README=20?= =?UTF-8?q?=EA=B0=80=EC=A0=B8=EC=98=A4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 등록 모달의 '한 줄 설명' 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) --- package-lock.json | 86 +++++++++++++++++++++++++++++++++++++++ package.json | 1 + public/css/components.css | 4 +- public/css/modal.css | 32 +++++++++++++++ public/js/modals.js | 26 +++++++++++- src/gitea.js | 28 +++++++++++++ src/helpers.js | 36 +++++++++++++++- src/server.js | 13 +++++- views/_project_detail.ejs | 6 ++- views/dashboard.ejs | 11 +++-- 10 files changed, 232 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1d8a8f2..5bdc092 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "ejs": "^3.1.10", "express": "^4.21.2", "express-session": "^1.18.1", + "markdown-it": "^14.3.0", "openid-client": "^5.7.0", "pg": "^8.13.1" }, @@ -32,6 +33,12 @@ "node": ">= 0.6" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -242,6 +249,18 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -562,6 +581,25 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -574,6 +612,33 @@ "node": ">=10" } }, + "node_modules/markdown-it": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.0.tgz", + "integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -583,6 +648,12 @@ "node": ">= 0.4" } }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "license": "MIT" + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -898,6 +969,15 @@ "node": ">= 0.10" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", @@ -1135,6 +1215,12 @@ "node": ">= 0.6" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, "node_modules/uid-safe": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", diff --git a/package.json b/package.json index f690f31..1904611 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "ejs": "^3.1.10", "express": "^4.21.2", "express-session": "^1.18.1", + "markdown-it": "^14.3.0", "openid-client": "^5.7.0", "pg": "^8.13.1" } diff --git a/public/css/components.css b/public/css/components.css index 32e65aa..798a3fd 100644 --- a/public/css/components.css +++ b/public/css/components.css @@ -56,7 +56,7 @@ .pcard-titlewrap { display: flex; align-items: center; gap: 7px; min-width: 0; } .pcard-repoico { display: inline-flex; color: var(--fg2); flex: 0 0 auto; } .pcard-title { font-size: 14px; font-weight: 700; color: var(--link); line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.pcard-desc { margin-top: 8px; font-size: 12.5px; color: var(--fg2); line-height: 1.5; flex: 1; } +.pcard-desc { margin-top: 8px; font-size: 12.5px; color: var(--fg2); line-height: 1.5; flex: 1; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; white-space: pre-wrap; word-break: break-word; } .pcard-tags { margin-top: 11px; display: flex; flex-wrap: wrap; gap: 6px; } .pcard-foot { margin-top: 13px; display: flex; align-items: center; justify-content: space-between; gap: 8px; } .pcard-stats { display: flex; align-items: center; gap: 13px; font-size: 12px; color: var(--fg2); } @@ -130,7 +130,7 @@ .react-btn .ico-on, .modal-action .ico-on { display: none; } .react-btn.on .ico-on, .modal-action.on .ico-on { display: inline-flex; } .react-btn.on .ico-off, .modal-action.on .ico-off { display: none; } -.fcard-desc { margin-top: 8px; font-size: 13px; color: var(--fg2); line-height: 1.55; } +.fcard-desc { margin-top: 8px; font-size: 13px; color: var(--fg2); line-height: 1.55; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; white-space: pre-wrap; word-break: break-word; } .fcard-tags { margin-top: 12px; display: flex; flex-wrap: wrap; gap: 6px; } .fcard-foot { margin-top: 15px; display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; } .fcard-author { display: flex; align-items: center; gap: 8px; min-width: 0; } diff --git a/public/css/modal.css b/public/css/modal.css index df45ebb..17e065a 100644 --- a/public/css/modal.css +++ b/public/css/modal.css @@ -19,6 +19,30 @@ .modal-close:hover { background: var(--panel); border-color: var(--border-soft); } .modal-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 14px; } .modal-desc { margin: 14px 0 0; font-size: 14px; color: var(--fg); line-height: 1.65; white-space: pre-wrap; } +.modal-desc-empty { color: var(--fg3); } +/* 설명(README) 마크다운 본문 */ +.md-body { margin: 14px 0 0; font-size: 14px; color: var(--fg); line-height: 1.65; word-break: break-word; } +.md-body > :first-child { margin-top: 0; } +.md-body > :last-child { margin-bottom: 0; } +.md-body h1, .md-body h2, .md-body h3, .md-body h4 { margin: 18px 0 8px; font-weight: 800; line-height: 1.3; letter-spacing: -.01em; } +.md-body h1 { font-size: 19px; padding-bottom: 6px; border-bottom: 1px solid var(--divider); } +.md-body h2 { font-size: 16.5px; padding-bottom: 5px; border-bottom: 1px solid var(--divider); } +.md-body h3 { font-size: 15px; } +.md-body h4 { font-size: 14px; } +.md-body p { margin: 10px 0; } +.md-body ul, .md-body ol { margin: 10px 0; padding-left: 22px; } +.md-body li { margin: 3px 0; } +.md-body a { color: var(--link); text-decoration: none; } +.md-body a:hover { text-decoration: underline; } +.md-body code { font-family: 'SF Mono', ui-monospace, Menlo, monospace; font-size: 12.5px; background: var(--inset); border: 1px solid var(--border-soft); border-radius: 5px; padding: 1px 5px; } +.md-body pre { background: var(--inset); border: 1px solid var(--border-soft); border-radius: 9px; padding: 12px 14px; overflow-x: auto; margin: 12px 0; } +.md-body pre code { background: transparent; border: 0; padding: 0; font-size: 12.5px; } +.md-body blockquote { margin: 12px 0; padding: 2px 14px; border-left: 3px solid var(--border); color: var(--fg2); } +.md-body hr { border: 0; border-top: 1px solid var(--divider); margin: 16px 0; } +.md-body table { border-collapse: collapse; margin: 12px 0; display: block; overflow-x: auto; } +.md-body th, .md-body td { border: 1px solid var(--border); padding: 6px 12px; font-size: 13px; } +.md-body th { background: var(--panel); font-weight: 700; } +.md-body img { max-width: 100%; border-radius: 8px; } .modal-actions { margin-top: 18px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } .modal-action { display: inline-flex; align-items: center; gap: 7px; background: var(--btn-bg); border: 1px solid var(--btn-bd); border-radius: 8px; @@ -76,6 +100,14 @@ .share-label .hint { font-weight: 500; color: var(--fg3); } .share-input { width: 100%; font-family: inherit; font-size: 14px; color: var(--fg); background: var(--inset); border: 1px solid var(--border); border-radius: 8px; padding: 9px 12px; outline: none; } .share-input.mono { font-family: 'SF Mono', ui-monospace, Menlo, monospace; font-size: 13px; } +.share-textarea { resize: vertical; min-height: 96px; line-height: 1.6; } +.share-label-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.readme-btn { + display: inline-flex; align-items: center; gap: 5px; background: var(--btn-bg); border: 1px solid var(--btn-bd); border-radius: 7px; padding: 4px 9px; + cursor: pointer; font-family: inherit; font-size: 11.5px; font-weight: 600; color: var(--fg2); white-space: nowrap; transition: background .14s ease, color .14s ease; +} +.readme-btn:hover { background: var(--btn-hover); color: var(--fg); } +.readme-btn:disabled { opacity: .6; cursor: default; } .share-input:focus { border-color: var(--link); box-shadow: 0 0 0 3px var(--focus-ring); } .vis-toggle { display: inline-flex; background: var(--inset); border: 1px solid var(--border); border-radius: 8px; padding: 3px; gap: 3px; } .vis-opt { display: inline-flex; align-items: center; gap: 5px; background: transparent; border: 0; border-radius: 6px; padding: 6px 16px; cursor: pointer; font-family: inherit; font-size: 13px; font-weight: 600; color: var(--fg2); white-space: nowrap; transition: background .14s ease, color .14s ease; } diff --git a/public/js/modals.js b/public/js/modals.js index dc458fb..1d7d996 100644 --- a/public/js/modals.js +++ b/public/js/modals.js @@ -1,7 +1,30 @@ // 상세 모달 · 공유(등록) 모달 — 열기/닫기 및 공개범위 토글. -import { $, $$ } from "./util.js"; +import { $, $$, toast } from "./util.js"; import { applyReactions } from "./reactions.js"; +// 등록 모달의 "저장소 README 가져오기" — repo_url 의 README 를 받아 설명란을 채운다. +async function importReadme() { + const m = $("#shareModal"); if (!m) return; + const btn = $("#readmeImport", m); + const repoInput = $('input[name="repo_url"]', m); + const ta = $('textarea[name="description"]', m); + const repo = ((repoInput && repoInput.value) || "").trim(); + if (!repo) { toast("먼저 저장소 URL 을 입력하세요"); if (repoInput) repoInput.focus(); return; } + if (ta && ta.value.trim() && !window.confirm("현재 설명 내용을 README 로 덮어쓸까요?")) return; + const label = btn ? btn.innerHTML : ""; + if (btn) { btn.disabled = true; btn.textContent = "가져오는 중…"; } + try { + const res = await fetch("/api/readme?repo_url=" + encodeURIComponent(repo), { headers: { "X-Requested-With": "fetch" } }); + const data = res.ok ? await res.json() : null; + if (data && data.content && ta) { ta.value = data.content; toast("README 를 가져왔어요"); ta.focus(); } + else toast("README 를 찾지 못했어요 · URL 을 확인해 주세요"); + } catch (e) { + toast("가져오기에 실패했어요"); + } finally { + if (btn) { btn.disabled = false; btn.innerHTML = label; } + } +} + export function openDetail(id) { const tpl = $('#detailStore template[data-detail-id="' + id + '"]'); const overlay = $("#detailModal"); @@ -49,6 +72,7 @@ export function initModals() { if (e.target.closest("[data-close-detail]")) { e.preventDefault(); closeDetail(); return; } if (e.target.closest("[data-open-share]")) { e.preventDefault(); openShare(); return; } if (e.target.closest("[data-close-share]")) { e.preventDefault(); closeShare(); return; } + if (e.target.closest("#readmeImport")) { e.preventDefault(); importReadme(); return; } const detail = $("#detailModal"); if (detail && !detail.hidden && e.target === detail) { closeDetail(); return; } diff --git a/src/gitea.js b/src/gitea.js index 06a2560..3c23c20 100644 --- a/src/gitea.js +++ b/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); + } +} diff --git a/src/helpers.js b/src/helpers.js index 31782f3..ebbd260 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -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", diff --git a/src/server.js b/src/server.js index be83bd3..e2f1424 100644 --- a/src/server.js +++ b/src/server.js @@ -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 }); diff --git a/views/_project_detail.ejs b/views/_project_detail.ejs index 8572d45..8e1e752 100644 --- a/views/_project_detail.ejs +++ b/views/_project_detail.ejs @@ -38,7 +38,11 @@ <% if (pdTags.length) { %> <% } %> - + <% if (p.description && p.description.trim()) { %> + + <% } else { %> + + <% } %> -
<%= p.description || "설명 없음" %>
+
<%= mdPlain(p.description) || "설명 없음" %>
<% if (tags.length) { %>
<% tags.forEach(function (t) { %><%= t %><% }); %>
<% } %> @@ -153,7 +153,7 @@ -
<%= p.description || "설명 없음" %>
+
<%= mdPlain(p.description) || "설명 없음" %>
<% if (tags.length) { %>
<% tags.forEach(function (t) { %><%= t %><% }); %>
<% } %> @@ -211,8 +211,11 @@