Some checks are pending
ci / build (push) Waiting to run
Measured convergence before: split hero 13/19, top-bar nav 19/19, one-line notice footer 14/19, body Pretendard 17/21, 10 similarity pairs above 0.78. Each showcase got a pre-assigned hero family, nav form, footer form, type pair, background, accent hue, radius, motion grammar and material from outputs/rework-2026-10-06/plan.md; direction cards and the chosen reason are recorded in every design.md. After: 0 similarity warnings, 19 distinct display faces, 7 hero families, 5 nav forms, 5 footer forms, no scroll reveals, 5 showcases with SVG filter materials. Example contact values replace bracket placeholders under the existing fictional-brand notices. Thumbnails, OG images, card copy and three e2e assertions updated; dead CSS in dure-enrollment and hannam-jari removed; similarity.mjs learns the new font families.
238 lines
8.7 KiB
JavaScript
238 lines
8.7 KiB
JavaScript
// 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 이 없다");
|
|
|
|
// 특정 서체를 강제하지 않는다 — 한글 글리프가 있는 서체를 하나라도 명시하면 된다.
|
|
// 예전 목록(Pretendard·Noto Sans KR·마루부리)만 허용하던 것이 서체 수렴을 부추겼다.
|
|
if (/Pretendard|Noto Sans KR|Noto Serif KR|본고딕|MaruBuri|마루부리|SUIT|Wanted Sans|IBM Plex Sans KR|Gothic A1|Nanum|Gowun|Hahmlet|Song Myung|Do Hyeon|Black Han Sans|Orbit|Gasoek|Sunflower|RIDIBatang|KoPub/.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);
|