fix(showcase): 미정의 CSS 변수·한글 조판·접근성 결함 수리 및 무결성 게이트 두 겹 신설
stylelint 도 html-validate 도 "var(--x) 를 썼는데 --x 가 어디에도 없다"를 잡지 못한다. 미정의 변수는 선언 전체를 무효로 만드는데 콘솔 에러가 없어 조용히 남는다. 검사기를 심자마자 v0.9.1 의 실제 결함이 나왔다. 게이트 신설 - tools/integrity.mjs — L0 무결성. CSS 커스텀 프로퍼티 참조, 상대경로, 한글 조판 필수 규칙(keep-all·한글 폰트 지정), 슬롭 지문, 스크롤 리스너를 검사. verify.mjs 체인에 편입해 실패 시 배포를 막는다. 폴백이 있는 var(--x, 기본값) 은 선택적 주입이므로 미정의로 세지 않는다. - tools/showcase-audit.mjs — 320/390/768/1440 실렌더 감사. 가로 스크롤·오버플로· 컨트롤 줄바꿈·대비 AA·왼쪽 인셋·h1 개수·히어로 줄 수·깨진 이미지·콘솔 에러. 반투명 배경을 합성해 대비를 재고, WebGL 페이지를 위해 swiftshader 로 렌더한다. - html-validate 대상을 두 페이지에서 public/work 전체로 넓혔다. 수리한 결함 - 미정의 변수 64건: --dur-fast·--radius-xs 가 정의된 적이 없어 cask-archive, cheongon-care, materia-interior 의 transition 선언이 통째로 무효였다. 새 토큰을 만들지 않고 기존 --dur-quick·--radius 로 매핑했다 — 토큰을 늘리면 radius 어휘가 4종이 되어 하드 게이트 3에 걸린다. - 한글 조판: lumina-optics·pulsegate·synapse-bci 가 word-break: keep-all 없이 IBM Plex Sans 만 지정해 한글이 시스템 기본으로 떨어지고 있었다. 같은 계열의 IBM Plex Sans KR 을 스택에 넣고 keep-all 을 추가했다. - 슬롭 지문 19건: text-transform: uppercase 로 만든 영문 대문자 eyebrow 를 한글 문장으로 바꾸고, 대문자용으로 잡아둔 넓은 자간과 모노 서체도 되돌렸다. - 하드 게이트 9: materia-interior/hannam-duplex.html 의 100vh → 100dvh. 이 파일은 어디서도 참조되지 않으면서 인라인 hex 까지 갖고 있어 토큰 기반으로 다시 썼다. - 접근성 11건: label 하나가 output 과 input 둘에 걸리던 것 5건(font-foundry, lumina-optics), dl > div > div > dt 4건(muwol-coffee), div 에 붙은 aria-label 2건(overtone-sound, pulsegate). 측정기 자체의 오류 둘도 고쳤다 — 눈에 보이지 않는 .sr-only 를 대비 실패로 잡던 것과, overflow-x: auto 상자 안의 넓은 표를 오버플로로 잡던 것. 재는 도구가 틀리면 통과도 실패도 의미가 없다.
This commit is contained in:
parent
225c1d9f42
commit
123d9fc5a8
28 changed files with 658 additions and 133 deletions
236
apps/site/tools/integrity.mjs
Normal file
236
apps/site/tools/integrity.mjs
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
// 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);
|
||||
Loading…
Add table
Add a link
Reference in a new issue