250 lines
10 KiB
JavaScript
250 lines
10 KiB
JavaScript
// 쇼케이스 실렌더 감사 — 전 폭 × 전 페이지에서 하드 게이트를 기계로 판정한다.
|
||
//
|
||
// 검사 항목
|
||
// 1. 가로 스크롤 (하드 게이트 5)
|
||
// 2. 뷰포트를 넘는 요소 (오버플로)
|
||
// 3. 버튼·내비 라벨 2줄 접힘 (하드 게이트 6)
|
||
// 4. 텍스트 대비 AA (하드 게이트 7) — 조상 배경을 걷어 올려 실제 배경으로 잰다
|
||
// 5. 왼쪽 인셋 최소값 (가장자리에 붙는 제목)
|
||
// 6. h1 개수 · 히어로 헤드라인 줄 수
|
||
// 7. 콘솔 에러 · 깨진 이미지
|
||
//
|
||
// 사용: node tools/showcase-audit.mjs [slug ...]
|
||
// 인자가 없으면 public/work 아래 전부를 돈다.
|
||
|
||
import puppeteer from "puppeteer-core";
|
||
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");
|
||
|
||
const CHROME_CANDIDATES = [
|
||
"C:/Program Files/Google/Chrome/Application/chrome.exe",
|
||
"C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
|
||
process.env.CHROME_PATH,
|
||
].filter(Boolean);
|
||
|
||
const chrome = CHROME_CANDIDATES.find((p) => fs.existsSync(p));
|
||
if (!chrome) {
|
||
console.error("크롬을 찾지 못했다. CHROME_PATH 환경변수로 지정해라.");
|
||
process.exit(2);
|
||
}
|
||
|
||
const WIDTHS = [320, 390, 768, 1440];
|
||
|
||
const args = process.argv.slice(2);
|
||
const slugs = args.length
|
||
? args
|
||
: fs
|
||
.readdirSync(workRoot, { withFileTypes: true })
|
||
.filter((d) => d.isDirectory())
|
||
.map((d) => d.name)
|
||
.sort();
|
||
|
||
/** 한 쇼케이스가 가진 모든 index.html (하위 상세 페이지 포함) */
|
||
function pagesOf(slug) {
|
||
const out = [];
|
||
const walk = (dir) => {
|
||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||
const p = path.join(dir, e.name);
|
||
if (e.isDirectory()) walk(p);
|
||
else if (e.name === "index.html") out.push(p);
|
||
}
|
||
};
|
||
walk(path.join(workRoot, slug));
|
||
return out;
|
||
}
|
||
|
||
/* 페이지 안에서 도는 판정기. 여기서 나온 수치가 판정의 근거다. */
|
||
const PROBE = `() => {
|
||
const lum = (c) => {
|
||
const m = (c || '').match(/[\\d.]+/g);
|
||
if (!m) return 1;
|
||
const [r, g, b] = m.slice(0, 3).map(Number).map((v) => {
|
||
const s = v / 255;
|
||
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
||
});
|
||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||
};
|
||
// 반투명 배경을 만나면 뒤 배경과 합성한다. 불투명으로 취급하면
|
||
// 대비가 실제보다 나쁘게(혹은 좋게) 나온다 — 측정기가 틀리면 판정이 무의미하다
|
||
const bgOf = (el) => {
|
||
let n = el, acc = null;
|
||
while (n && n.nodeType === 1) {
|
||
const c = getComputedStyle(n).backgroundColor;
|
||
const m = (c || '').match(/[\\d.]+/g);
|
||
if (m) {
|
||
const a = m.length > 3 ? Number(m[3]) : 1;
|
||
if (a > 0.001) {
|
||
const rgb = m.slice(0, 3).map(Number);
|
||
acc = acc === null ? { rgb, a } : { rgb: acc.rgb.map((v, i) => v * acc.a + rgb[i] * (1 - acc.a)), a: 1 };
|
||
if (acc.a >= 0.999) return 'rgb(' + acc.rgb.map(Math.round).join(',') + ')';
|
||
}
|
||
}
|
||
n = n.parentElement;
|
||
}
|
||
return acc ? 'rgb(' + acc.rgb.map(Math.round).join(',') + ')' : 'rgb(255,255,255)';
|
||
};
|
||
// 스크린리더 전용 텍스트는 눈에 보이지 않으므로 대비 판정 대상이 아니다.
|
||
// 1px 상자 + clip 은 sr-only 의 표준 구현이고, 실제 본문이 이 모양일 수는 없다.
|
||
const srOnly = (el) => {
|
||
const cs = getComputedStyle(el);
|
||
const r = el.getBoundingClientRect();
|
||
const clipped = (cs.clipPath && cs.clipPath !== 'none') || (cs.clip && cs.clip !== 'auto');
|
||
return clipped && r.width <= 2 && r.height <= 2;
|
||
};
|
||
|
||
const visible = (el) => {
|
||
const cs = getComputedStyle(el);
|
||
if (cs.display === 'none' || cs.visibility === 'hidden' || Number(cs.opacity) === 0) return false;
|
||
if (srOnly(el)) return false;
|
||
const r = el.getBoundingClientRect();
|
||
return r.width > 0 && r.height > 0;
|
||
};
|
||
|
||
// 가로로 스크롤되는 상자 안의 넓은 표는 결함이 아니라 처방이다.
|
||
// 그 상자가 실제로 스크롤될 때만 면제한다 — 그냥 넘치는 것과 구별해야 한다
|
||
const inScroller = (el) => {
|
||
let n = el.parentElement;
|
||
while (n && n !== document.body) {
|
||
const ox = getComputedStyle(n).overflowX;
|
||
if ((ox === 'auto' || ox === 'scroll') && n.scrollWidth > n.clientWidth + 1) return true;
|
||
n = n.parentElement;
|
||
}
|
||
return false;
|
||
};
|
||
|
||
const contrast = [];
|
||
document.querySelectorAll('p,span,a,li,dt,dd,h1,h2,h3,h4,td,th,label,output,figcaption,kbd,caption,button,strong,em,summary').forEach((el) => {
|
||
const own = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim());
|
||
if (!own || !visible(el)) return;
|
||
const cs = getComputedStyle(el);
|
||
const size = parseFloat(cs.fontSize);
|
||
const bold = Number(cs.fontWeight) >= 700;
|
||
const large = size >= 24 || (size >= 18.66 && bold);
|
||
const a = lum(cs.color), b = lum(bgOf(el));
|
||
const ratio = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
||
const need = large ? 3 : 4.5;
|
||
if (ratio < need - 0.005) {
|
||
contrast.push({ tag: el.tagName, cls: String(el.className || '').slice(0, 30), size: +size.toFixed(1), ratio: +ratio.toFixed(2), need, text: el.textContent.trim().slice(0, 30) });
|
||
}
|
||
});
|
||
|
||
const overflow = [];
|
||
document.querySelectorAll('body *').forEach((el) => {
|
||
if (!visible(el) || inScroller(el)) return;
|
||
const r = el.getBoundingClientRect();
|
||
if (r.right > window.innerWidth + 1.5) overflow.push({ tag: el.tagName, cls: String(el.className || '').slice(0, 30), right: Math.round(r.right) });
|
||
});
|
||
|
||
const wrapped = [];
|
||
document.querySelectorAll('nav a, .btn, button, [role=button]').forEach((el) => {
|
||
if (!visible(el) || !el.textContent.trim()) return;
|
||
const cs = getComputedStyle(el);
|
||
const lh = parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 1.25;
|
||
const textRange = document.createRange();
|
||
textRange.selectNodeContents(el);
|
||
const textHeight = textRange.getBoundingClientRect().height;
|
||
if (textHeight > lh * 1.6) wrapped.push(el.textContent.trim().slice(0, 26));
|
||
});
|
||
|
||
const insets = [...document.querySelectorAll('h1,h2,h3,p,li')]
|
||
.filter((e) => e.textContent.trim() && visible(e))
|
||
.map((e) => Math.round(e.getBoundingClientRect().left));
|
||
|
||
const h1 = document.querySelectorAll('h1');
|
||
const firstH1 = h1[0];
|
||
let h1Lines = 0;
|
||
if (firstH1) {
|
||
const cs = getComputedStyle(firstH1);
|
||
const lh = parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 1.2;
|
||
h1Lines = Math.round(firstH1.getBoundingClientRect().height / lh);
|
||
}
|
||
|
||
const brokenImages = [...document.images].filter((i) => i.complete && i.naturalWidth === 0).map((i) => i.currentSrc || i.src);
|
||
|
||
return {
|
||
docWidth: document.documentElement.scrollWidth,
|
||
winWidth: window.innerWidth,
|
||
horizScroll: document.documentElement.scrollWidth > window.innerWidth + 1,
|
||
contrast, overflow, wrapped,
|
||
minInset: insets.length ? Math.min(...insets) : null,
|
||
h1Count: h1.length,
|
||
h1Lines,
|
||
brokenImages,
|
||
};
|
||
}`;
|
||
|
||
const browser = await puppeteer.launch({
|
||
executablePath: chrome,
|
||
headless: "new",
|
||
// WebGL 을 쓰는 페이지가 있으므로 --disable-gpu 를 쓰지 않는다.
|
||
// swiftshader 로 소프트웨어 렌더를 강제해 헤드리스에서도 캔버스가 뜬다
|
||
args: [
|
||
"--no-sandbox",
|
||
"--allow-file-access-from-files",
|
||
"--use-angle=swiftshader",
|
||
"--enable-unsafe-swiftshader",
|
||
"--force-device-scale-factor=1",
|
||
],
|
||
});
|
||
|
||
let totalFail = 0;
|
||
const lines = [];
|
||
|
||
for (const slug of slugs) {
|
||
for (const file of pagesOf(slug)) {
|
||
const rel = path.relative(workRoot, file).replace(/\\/g, "/");
|
||
for (const width of WIDTHS) {
|
||
console.log(`RUN ${rel} @${width}`);
|
||
const page = await browser.newPage();
|
||
const consoleErrors = [];
|
||
page.on("pageerror", (e) => consoleErrors.push(String(e).slice(0, 120)));
|
||
page.on("console", (m) => {
|
||
if (m.type() === "error") consoleErrors.push(m.text().slice(0, 120));
|
||
});
|
||
await page.setViewport({ width, height: 900 });
|
||
await page.goto(url.pathToFileURL(file).href, { waitUntil: "networkidle0" });
|
||
await page.evaluate(() => document.fonts.ready);
|
||
await new Promise((r) => setTimeout(r, 260));
|
||
|
||
// 문자열을 넘기면 puppeteer 는 그것을 '식'으로 평가한다.
|
||
// 화살표 함수만 넘기면 함수 객체가 그대로 직렬화돼 빈 객체가 돌아온다 — 즉시호출로 감싼다
|
||
const r = await page.evaluate(`(${PROBE})()`);
|
||
await page.close();
|
||
|
||
const problems = [];
|
||
if (r.horizScroll) problems.push(`가로스크롤 ${r.docWidth}>${r.winWidth}`);
|
||
if (r.overflow.length) problems.push(`오버플로 ${r.overflow.length} (${r.overflow.slice(0, 2).map((o) => o.tag + "." + o.cls).join(", ")})`);
|
||
if (r.wrapped.length) problems.push(`컨트롤 줄바꿈 ${r.wrapped.length} (${r.wrapped.slice(0, 2).join(", ")})`);
|
||
if (r.contrast.length) problems.push(`대비미달 ${r.contrast.length} (최악 ${Math.min(...r.contrast.map((c) => c.ratio))}:1 — ${r.contrast[0].cls || r.contrast[0].tag})`);
|
||
if (r.minInset !== null && r.minInset < 12) problems.push(`왼쪽 인셋 ${r.minInset}px`);
|
||
if (r.h1Count !== 1) problems.push(`h1 ${r.h1Count}개`);
|
||
if (r.h1Lines > 2) problems.push(`히어로 헤드라인 ${r.h1Lines}줄`);
|
||
if (r.brokenImages.length) problems.push(`깨진 이미지 ${r.brokenImages.length}`);
|
||
if (consoleErrors.length) problems.push(`콘솔에러 ${consoleErrors.length} (${consoleErrors[0]})`);
|
||
|
||
if (problems.length) {
|
||
totalFail++;
|
||
lines.push(`FAIL ${rel} @${width} — ${problems.join(" · ")}`);
|
||
if (r.contrast.length) {
|
||
for (const c of r.contrast.slice(0, 4)) {
|
||
lines.push(` ↳ ${c.ratio}:1 (${c.need} 필요) ${c.size}px ${c.tag}.${c.cls} "${c.text}"`);
|
||
}
|
||
}
|
||
} else {
|
||
lines.push(`PASS ${rel} @${width}`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
await browser.close();
|
||
|
||
for (const l of lines) console.log(l);
|
||
const passCount = lines.filter((l) => l.startsWith("PASS")).length;
|
||
console.log(`\n${totalFail ? "✗" : "✓"} 실렌더 감사 — PASS ${passCount} / FAIL ${totalFail}`);
|
||
process.exit(totalFail ? 1 : 0);
|