// L0-무결성 — CSS 커스텀 프로퍼티와 정적 경로의 참조 무결성 검사. // // 왜 필요한가: stylelint 도 html-validate 도 "var(--x) 를 썼는데 --x 가 어디에도 // 없다"를 잡지 못한다. 미정의 변수는 선언 전체를 무효로 만들지만 콘솔 에러가 // 없어서 조용히 살아남는다. 실측 사고 — cheongon-care/main.css 가 var(--dur-fast) // 를 11회 쓰는데 tokens.css 에는 --dur-quick 만 있었다. 그 결과 transition 선언 // 11개가 통째로 무효였고, 호버 전환이 전부 즉시 점프였다. // // 경로도 같은 이유로 검사한다. href/src 오타는 빌드가 통과하고 배포도 되지만 // 사용자에게만 404 로 나타난다. // // 사용: node tools/integrity.mjs import fs from "node:fs"; import path from "node:path"; import url from "node:url"; const here = path.dirname(url.fileURLToPath(import.meta.url)); const root = path.resolve(here, ".."); const workRoot = path.join(root, "public", "work"); let pass = 0; const failures = []; function ok(label) { pass++; void label; } function fail(label, detail) { failures.push(`FAIL ${label}${detail ? ` — ${detail}` : ""}`); } /** * CSS 주석을 제거한다. 주석 안의 var() 나 url() 은 참조가 아니다. * 주석이 먹은 줄바꿈은 그대로 남겨 보고하는 행 번호가 원본과 어긋나지 않게 한다. */ function stripComments(css) { return css.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, "")); } /** :root 등에서 정의된 커스텀 프로퍼티 이름을 모은다. */ function collectDefined(css) { const names = new Set(); // 선언 위치에서만 잡는다: `--name:` 형태이되 var( 안이 아닌 것. for (const m of css.matchAll(/(^|[;{]\s*)(--[A-Za-z0-9_-]+)\s*:/g)) { names.add(m[2]); } return names; } /** * 폴백이 없는 var(--name) 사용처만 모은다. * `var(--delay, 0ms)` 처럼 폴백을 둔 것은 요소별로 인라인 주입하는 선택적 변수이므로 * 정의가 없어도 선언이 무효가 되지 않는다 — 결함이 아니다. */ function collectUsed(css) { const uses = []; for (const m of css.matchAll(/var\(\s*(--[A-Za-z0-9_-]+)\s*([,)])/g)) { if (m[2] === ",") continue; uses.push({ name: m[1], index: m.index }); } return uses; } function lineOf(text, index) { return text.slice(0, index).split("\n").length; } const showcases = fs .readdirSync(workRoot, { withFileTypes: true }) .filter((d) => d.isDirectory()) .map((d) => d.name) .sort(); if (showcases.length === 0) fail("쇼케이스 탐색", "public/work 아래에 디렉터리가 없다"); /* ── 1. CSS 커스텀 프로퍼티 참조 무결성 ── */ for (const slug of showcases) { const stylesDir = path.join(workRoot, slug, "styles"); if (!fs.existsSync(stylesDir)) continue; const cssFiles = fs .readdirSync(stylesDir) .filter((f) => f.endsWith(".css")) .map((f) => path.join(stylesDir, f)); // 한 쇼케이스의 CSS 는 @import 로 묶여 한 문서가 되므로 정의를 합집합으로 본다. const merged = cssFiles.map((f) => stripComments(fs.readFileSync(f, "utf8"))); const defined = new Set(); for (const css of merged) for (const n of collectDefined(css)) defined.add(n); // 미디어쿼리·@supports 안에서만 정의되는 변수도 정의로 친다(위 정규식이 이미 포함). const undefinedUses = []; cssFiles.forEach((file, i) => { for (const use of collectUsed(merged[i])) { if (!defined.has(use.name)) { undefinedUses.push(`${path.relative(root, file)}:${lineOf(merged[i], use.index)} ${use.name}`); } } }); if (undefinedUses.length) { const shown = undefinedUses.slice(0, 6).join(", "); fail( `${slug} CSS 변수 정의`, `미정의 ${undefinedUses.length}건 — ${shown}${undefinedUses.length > 6 ? " …" : ""}`, ); } else { ok(`${slug} CSS 변수 정의`); } } /* ── 2. 정적 경로 무결성 (로컬 상대 경로만) ── */ const localRef = /(?:href|src)\s*=\s*"(\.\/[^"#?]+|\.\.\/[^"#?]+)"/g; const cssUrlRef = /url\(\s*["']?(\.{1,2}\/[^"')?#]+)/g; for (const slug of showcases) { const dir = path.join(workRoot, slug); const broken = []; const walk = (d) => { for (const entry of fs.readdirSync(d, { withFileTypes: true })) { const p = path.join(d, entry.name); if (entry.isDirectory()) { walk(p); continue; } const isHtml = entry.name.endsWith(".html"); const isCss = entry.name.endsWith(".css"); if (!isHtml && !isCss) continue; const text = fs.readFileSync(p, "utf8"); const body = isCss ? stripComments(text) : text; const pattern = isHtml ? localRef : cssUrlRef; pattern.lastIndex = 0; for (const m of body.matchAll(pattern)) { const target = path.resolve(path.dirname(p), m[1]); if (!fs.existsSync(target)) { broken.push(`${path.relative(root, p)}:${lineOf(body, m.index)} → ${m[1]}`); } } } }; walk(dir); if (broken.length) { fail(`${slug} 경로 참조`, `끊긴 링크 ${broken.length}건 — ${broken.slice(0, 5).join(", ")}`); } else { ok(`${slug} 경로 참조`); } } /* ── 3. 한글 프로젝트 필수 조판 규칙 ── */ for (const slug of showcases) { const stylesDir = path.join(workRoot, slug, "styles"); if (!fs.existsSync(stylesDir)) continue; const all = fs .readdirSync(stylesDir) .filter((f) => f.endsWith(".css")) .map((f) => fs.readFileSync(path.join(stylesDir, f), "utf8")) .join("\n"); if (/word-break:\s*keep-all/.test(all)) ok(`${slug} keep-all`); else fail(`${slug} keep-all`, "한글 조판 필수 규칙 word-break: keep-all 이 없다"); if (/Pretendard|Noto Sans KR|본고딕|MaruBuri|마루부리/.test(all)) ok(`${slug} 한글 폰트 지정`); else fail(`${slug} 한글 폰트 지정`, "한글 폰트를 명시하지 않아 시스템 기본으로 떨어진다"); } /* ── 4. 슬롭 지문 정적 grep ── */ const slopPatterns = [ [/#6366f1|#818cf8|#a855f7|#8b5cf6|#c4b5fd|#a78bfa|#22d3ee/i, "보라·인디고·네온 시안 계열 하드코딩"], [/text-transform:\s*uppercase/i, "전체 대문자 라벨"], [/font-family:[^;]*\bInter\b/i, "Inter 를 이유 없이"], [/100vh/i, "100vh (모바일 주소창 — 100dvh 여야 한다)"], ]; for (const slug of showcases) { const dir = path.join(workRoot, slug); const hits = []; const walk = (d) => { for (const entry of fs.readdirSync(d, { withFileTypes: true })) { const p = path.join(d, entry.name); if (entry.isDirectory()) { walk(p); continue; } if (!/\.(html|css|js)$/.test(entry.name)) continue; const text = stripComments(fs.readFileSync(p, "utf8")); for (const [re, label] of slopPatterns) { const m = text.match(re); if (m) hits.push(`${path.relative(root, p)} ${label}`); } } }; walk(dir); if (hits.length) fail(`${slug} 슬롭 지문`, hits.slice(0, 5).join(", ")); else ok(`${slug} 슬롭 지문`); } /* ── 5. JS 스크롤 리스너 금지 (하드 게이트 10) ── */ for (const slug of showcases) { const dir = path.join(workRoot, slug); const hits = []; const walk = (d) => { for (const entry of fs.readdirSync(d, { withFileTypes: true })) { const p = path.join(d, entry.name); if (entry.isDirectory()) { walk(p); continue; } if (!/\.(html|js)$/.test(entry.name)) continue; const text = fs.readFileSync(p, "utf8"); if (/addEventListener\(\s*["']scroll["']/.test(text)) { hits.push(path.relative(root, p)); } } }; walk(dir); if (hits.length) fail(`${slug} 스크롤 리스너`, hits.join(", ")); else ok(`${slug} 스크롤 리스너`); } console.log(`무결성 검사 — 쇼케이스 ${showcases.length}종`); for (const f of failures) console.log(" " + f); // verify.mjs 는 출력에서 /PASS\s./ 와 /FAIL\s./ 를 세어 요약을 만든다. // 요약 줄에 'FAIL 0' 이라고 적으면 그것까지 실패로 집계돼 리포트가 거짓말을 한다. // 그래서 통과일 때는 FAIL 이라는 낱말을 쓰지 않는다. if (failures.length) { console.log(`✗ 무결성 ${failures.length}건 실패 (검사 ${pass + failures.length}개)`); } else { console.log(`✓ PASS ${pass}개 검사 전부 통과`); } process.exit(failures.length ? 1 : 0);