// 뷰 헬퍼 — 빌드/프런트 프레임워크 없이 서버에서 SVG 를 직접 만들어 EJS 로 출력한다. // (디자인 레퍼런스의 Octicon 패스 / identicon 알고리즘 / 언어색을 그대로 포팅) // res.locals 에 실어 모든 뷰에서 icon()/identicon()/langColor()/renderMarkdown() 로 사용. import MarkdownIt from "markdown-it"; // GitHub Octicon 16x16 패스 모음. const OCTICONS = { code: '', // (꺾쇠 + 슬래시) — 박스 없이 헤더 색 상속. Bootstrap code-slash 패스. codeslash: '', search: '', sun: '', moon: '', caret: '', signout: '', lock: '', globe: '', repo: '', starFill: '', star: '', heart: '', heartFill: '', comment: '', plus: '', x: '', reply: '', check: '', ext: '', trash: '', book: '', pencil: '', }; // 인라인 SVG 아이콘 문자열. fill 기본은 currentColor(부모 색 상속). export function icon(name, opts = {}) { const size = opts.size || 16; const fill = opts.fill || "currentColor"; const path = OCTICONS[name] || OCTICONS.repo; return ( `` ); } // 관리자 인증 뱃지 — 골드 스캘럽 씰 + 흰 체크(다색이라 icon() 대신 전용 SVG). // 관리자 판별은 Keycloak 그룹 기준(users.is_admin 에 로그인 시 저장). 프로필 사번 옆에 표시. function scallopPath(cx, cy, n, rValley, rOuter) { // 계곡점(rValley)들을 바깥으로 볼록한 원호로 이어 씰 톱니를 만든다. const half = Math.PI / n; const sag = rOuter - rValley * Math.cos(half); // 볼록 높이(sagitta) const c = 2 * rValley * Math.sin(half); // 현 길이 const r = (sag * sag + (c / 2) * (c / 2)) / (2 * sag); // 원호 반지름 let d = ""; for (let i = 0; i < n; i++) { const a0 = (i / n) * 2 * Math.PI - Math.PI / 2; const a1 = ((i + 1) / n) * 2 * Math.PI - Math.PI / 2; const x0 = (cx + rValley * Math.cos(a0)).toFixed(2), y0 = (cy + rValley * Math.sin(a0)).toFixed(2); const x1 = (cx + rValley * Math.cos(a1)).toFixed(2), y1 = (cy + rValley * Math.sin(a1)).toFixed(2); if (i === 0) d += `M ${x0} ${y0} `; d += `A ${r.toFixed(2)} ${r.toFixed(2)} 0 0 1 ${x1} ${y1} `; } return d + "Z"; } export function adminBadge(opts = {}) { const size = opts.size || 15; const title = opts.title || "관리자 인증"; const scallop = scallopPath(8, 8, 11, 5.7, 7.5); return ( `${title}` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` ); } // 프로젝트 설명(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) → 텍스트, 코드펜스/이미지/배지 제거) // 개행을 공백으로 뭉개지 않고 유지해, 2줄 클램프 미리보기가 README 앞부분의 // 줄 구조 그대로 보이도록 한다(제목 줄 + 첫 설명 줄 등). 빈 줄은 없애 줄 낭비를 막는다. 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(/[ \t]+/g, " ") // 줄 안의 연속 공백만 축소(개행은 유지) .replace(/ *\n */g, "\n") // 줄 앞뒤 공백 제거 .replace(/\n{2,}/g, "\n") // 빈 줄 제거 → 내용 줄만 남김 .trim(); } // 언어 → 색(카드의 언어 점). const LANG_COLORS = { Python: "#3572A5", TypeScript: "#3178c6", JavaScript: "#f1e05a", Go: "#00ADD8", Rust: "#dea584", Shell: "#89e051", Java: "#b07219", }; export function langColor(lang) { return LANG_COLORS[lang] || "#8b949e"; } // 태그에서 대표 언어 추론(프로젝트 등록 시 lang 저장용). export function langFromTags(tags) { const t = (tags || []).map((x) => String(x).toLowerCase()); if (t.includes("fastapi") || t.includes("python") || t.includes("django")) return "Python"; if (t.includes("react") || t.includes("ts") || t.includes("typescript") || t.includes("next")) return "TypeScript"; if (t.includes("go") || t.includes("golang")) return "Go"; if (t.includes("rust")) return "Rust"; if (t.includes("node") || t.includes("js")) return "JavaScript"; return ""; // 매칭되는 언어 태그가 없으면 언어 미표시 } // 문자열 → 결정적 해시(아바타/색 시드). function hashSeed(seed) { let h = 0; const s = String(seed); for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; return h; } // 사번 기반 identicon(5x5 대칭 격자)의 rects+color 계산 코어. function identiconCore(seed) { const h = hashSeed(seed); const color = `hsl(${h % 360},${48 + (h % 22)}%,52%)`; const grid = 5; const on = new Array(25).fill(false); for (let col = 0; col < 3; col++) { for (let row = 0; row < 5; row++) { const bit = (h >> (col * 5 + row)) & 1; on[row * 5 + col] = !!bit; on[row * 5 + (4 - col)] = !!bit; } } const cell = 100 / grid; // viewBox 0..100 기준 let rects = ""; for (let r = 0; r < grid; r++) { for (let c = 0; c < grid; c++) { if (on[r * grid + c]) { rects += ``; } } } return { color, rects }; } // 사번 기반 identicon 아바타(인라인 span). 디자인과 동일한 시각. export function identicon(seed, size = 28) { const { color, rects } = identiconCore(seed); const inner = Math.round(size * 0.74); const radius = Math.max(5, Math.round(size * 0.2)); const svg = `${rects}`; return ( `${svg}` ); } // /avatar/:sabun 엔드포인트가 업로드 사진이 없을 때 응답하는 독립 SVG 문서 문자열. export function identiconSvg(seed, size = 128) { const { color, rects } = identiconCore(seed); return `${rects}`; } // 프로필 사진 . 실제 이미지는 /avatar/:sabun 엔드포인트가 업로드본 또는 identicon SVG 로 응답한다. // (모든 아바타 렌더 지점을 이 헬퍼로 통일 → 뷰마다 업로드 여부를 조회할 필요가 없다.) function escapeAttr(s) { return String(s).replace(/[&"'<>]/g, (c) => ({ "&": "&", '"': """, "'": "'", "<": "<", ">": ">" }[c])); } export function avatar(sabun, size = 28) { const radius = Math.max(5, Math.round(size * 0.2)); const src = `/avatar/${encodeURIComponent(sabun)}`; return ( `` ); }