release: v0.10.0
All checks were successful
ci / build (push) Successful in 42s
release / release (push) Successful in 1m5s

This commit is contained in:
Yun Chan 2026-08-29 21:19:26 +09:00
parent 2b7fa32b5b
commit 6930906583
61 changed files with 15165 additions and 82 deletions

View file

@ -26,6 +26,7 @@ const DEFAULT_CFG = {
viewAttribute: "data-view",
views: ["main"],
h1: "h1",
h1MaxLines: 1,
brand: ".brand",
},
],
@ -103,49 +104,105 @@ const cssFilesOf = (p) => {
return out;
};
// 페이지가 공유 CSS·동적 JS·지역 토큰을 함께 쓰면 HTML 한 장과 파일명 규칙만으로
// 소유권을 추측할 수 없다. staticContract 는 검사 자체를 끄지 않고, 어떤 CSS를
// 어떤 문서 코퍼스로 대조하며 어느 파일이 토큰 권위인지 명시한다.
const contractFilesOf = (p, key, fallback) => {
const specs = p.staticContract?.[key];
if (!Array.isArray(specs)) return fallback;
return specs.map((file) => path.resolve(process.cwd(), file));
};
const scriptFilesOf = (p) => {
const out = [];
let html = "";
try { html = fs.readFileSync(path.resolve(process.cwd(), p.path), "utf8"); } catch { return out; }
for (const tag of html.matchAll(/<script[^>]+src=["']([^"']+)["'][^>]*>/g)) {
const src = tag[1];
if (/^(?:https?:|data:)/.test(src)) continue;
const file = path.resolve(pageDir(p), src.split("?")[0]);
if (fs.existsSync(file) && !out.includes(file)) out.push(file);
}
return out;
};
const selectorDocumentsOf = (p) => contractFilesOf(
p,
"selectorDocuments",
[path.resolve(process.cwd(), p.path), ...scriptFilesOf(p)],
);
const missingFiles = (files) => files.filter((file) => !fs.existsSync(file));
if (CFG.checks.deadCss) {
const ignore = CFG.deadCssIgnore || [];
for (const p of CFG.pages) {
let html = "";
try { html = fs.readFileSync(path.resolve(process.cwd(), p.path), "utf8"); }
catch { skip(`${p.name} 죽은 선택자`, "HTML 읽기 실패"); continue; }
const files = cssFilesOf(p);
const files = contractFilesOf(p, "selectorFiles", cssFilesOf(p));
const documents = selectorDocumentsOf(p);
const missing = missingFiles([...files, ...documents]);
if (missing.length) {
fail(`${p.name} 죽은 선택자 계약`, `없는 파일 ${missing.map((file) => path.relative(process.cwd(), file)).join(", ")}`);
continue;
}
if (!files.length) { skip(`${p.name} 죽은 선택자`, "로컬 CSS 없음"); continue; }
const corpus = documents.map((file) => fs.readFileSync(file, "utf8")).join("\n");
const dead = new Set();
for (const f of files) {
const src = fs.readFileSync(f, "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
for (const m of src.matchAll(/\.([A-Za-z_][A-Za-z0-9_-]*)/g)) {
const cls = m[1];
if (ignore.some((pfx) => cls.startsWith(pfx))) continue;
if (!html.includes(cls)) dead.add(cls);
if (!corpus.includes(cls)) dead.add(cls);
}
}
dead.size
? fail(`${p.name} 죽은 선택자 ${dead.size}종`, [...dead].slice(0, 8).map((c) => "." + c).join(" ") + (dead.size > 8 ? " …" : "") + " — 제거는 HTML·CSS·JS 삼위일체")
: pass(`${p.name} 죽은 선택자`, `${files.length}개 CSS 전 클래스 사용 중(무시 ${ignore.length}접두사)`);
: pass(`${p.name} 죽은 선택자`, `${files.length}개 CSS × ${documents.length}개 HTML·JS 문서(무시 ${ignore.length}접두사)`);
}
} else skip("죽은 선택자", "checks.deadCss=false");
if (CFG.checks.cssHygiene) {
for (const p of CFG.pages) {
const files = cssFilesOf(p);
const files = contractFilesOf(p, "hygieneFiles", cssFilesOf(p));
if (!files.length) { skip(`${p.name} 토큰 위생`, "로컬 CSS 없음"); continue; }
// 토큰 파일 = 파일명에 tokens 가 있는 것. 이 안의 값이 프로젝트의 단일 원본이다
const tokenFiles = files.filter((f) => /tokens/i.test(path.basename(f)));
const others = files.filter((f) => !tokenFiles.includes(f));
const hexes = []; const radii = new Set();
for (const f of others) {
const inferredTokens = cssFilesOf(p).filter((f) => /tokens/i.test(path.basename(f)));
const tokenFiles = contractFilesOf(p, "tokenFiles", inferredTokens);
const missing = missingFiles([...files, ...tokenFiles]);
if (missing.length) {
fail(`${p.name} 토큰 위생 계약`, `없는 파일 ${missing.map((file) => path.relative(process.cwd(), file)).join(", ")}`);
continue;
}
const tokenNames = new Set();
for (const f of new Set([...tokenFiles, ...files])) {
const src = fs.readFileSync(f, "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
for (const m of src.matchAll(/#[0-9a-fA-F]{3,8}\b/g)) hexes.push(`${path.basename(f)}:${m[0]}`);
for (const m of src.matchAll(/(?<![\w.-])rgba?\(/g)) hexes.push(`${path.basename(f)}:${m[0]}`);
for (const m of src.matchAll(/(--[A-Za-z0-9_-]+)\s*:/g)) tokenNames.add(m[1]);
}
const colors = []; const colorKinds = new Set(); const radii = new Set();
for (const f of files) {
// 어느 파일에 있든 custom property 선언은 지역 토큰이다. 선언값을 다시
// 하드코딩으로 세면 :root 토큰을 만든 페이지가 오히려 실패한다.
const src = fs.readFileSync(f, "utf8")
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/--[A-Za-z0-9_-]+\s*:\s*([^;{}]+);/g, "");
for (const m of src.matchAll(/#[0-9a-fA-F]{3,8}\b|(?:rgb|rgba|hsl|hsla|oklch|lab|lch)\([^)]*\)/g)) {
colors.push(`${path.basename(f)}:${m[0]}`);
colorKinds.add(m[0].toLowerCase().replace(/\s+/g, " "));
}
for (const m of src.matchAll(/border-radius:\s*([^;]+);/g)) if (!m[1].includes("var(")) radii.add(m[1].trim());
}
const budgets = p.staticContract?.budgets || {};
const hasColorBudget = Number.isFinite(budgets.colorLiterals);
const hasRadiusBudget = Number.isFinite(budgets.radiusLiterals);
const colorLimit = hasColorBudget ? budgets.colorLiterals : (tokenFiles.length ? 0 : null);
const radiusLimit = hasRadiusBudget ? budgets.radiusLiterals : null;
const bad = [];
if (tokenFiles.length && hexes.length) bad.push(`토큰 밖 색 리터럴 ${hexes.length}건(토큰 파일이 있는데 상수색)`);
if (radii.size >= CFG.thresholds.radiusLiterals) bad.push(`radius 리터럴 ${radii.size}종([${[...radii].join(", ")}])`);
if (colorLimit !== null && colors.length > colorLimit) bad.push(`색 리터럴 ${colors.length}건 > 계약 ${colorLimit}건`);
if (radiusLimit !== null ? radii.size > radiusLimit : radii.size >= CFG.thresholds.radiusLiterals) {
bad.push(`radius 리터럴 ${radii.size}종 > 계약 ${radiusLimit ?? `<${CFG.thresholds.radiusLiterals}`}종([${[...radii].join(", ")}])`);
}
bad.length
? fail(`${p.name} 토큰 위생`, bad.join(" · ") + (hexes.length ? " — 예: " + hexes.slice(0, 4).join(" ") : ""))
: pass(`${p.name} 토큰 위생`, `색 ${hexes.length}·radius ${radii.size} 리터럴(토큰 ${tokenFiles.length}파일)`);
? fail(`${p.name} 토큰 위생`, bad.join(" · ") + (colors.length ? " — 예: " + colors.slice(0, 4).join(" ") : ""))
: pass(`${p.name} 토큰 위생`, `색 ${colors.length}${colorLimit === null ? "" : `/${colorLimit}`}건(${colorKinds.size}종)·radius ${radii.size}${radiusLimit === null ? "" : `/${radiusLimit}`}종·토큰 ${tokenFiles.length}파일/${tokenNames.size}개`);
}
} else skip("토큰 위생", "checks.cssHygiene=false");
@ -170,6 +227,12 @@ const UTILS = `
for (const t of tops) if (!ls.length || t - ls[ls.length - 1] > 5) ls.push(t);
return ls.length;
};
window.__textLines = (sel) => {
const el = typeof sel === "string" ? document.querySelector(sel) : sel;
if (!el) return -1;
const text = [...el.childNodes].find((node) => node.nodeType === 3 && node.textContent.trim());
return window.__lines(text || el);
};
`;
const browser = await puppeteer.default.launch({ executablePath: CHROME, headless: "new", args: ["--force-device-scale-factor=1"] });
@ -234,7 +297,7 @@ for (const p of CFG.pages) {
if (meta.themeColor.replace(/\s+/g, "").toLowerCase() !== hexOf(meta.bodyBg))
m.push(`theme-color ${meta.themeColor} ≠ 실제 배경 ${meta.bodyBg}(낡은 토큰 의심)`);
}
if (meta.h1Count > 1) m.push(`h1 ${meta.h1Count}개(유일해야)`);
if (meta.h1Count !== 1) m.push(`h1 ${meta.h1Count}개(정확히 하나여야)`);
if (meta.imgNoAlt) m.push(`img alt 없음 ${meta.imgNoAlt}개`);
if (meta.jsonldCount && !meta.jsonld) m.push("JSON-LD 파싱 실패");
// 데모·로컬 페이지는 canonical/robots 미명시 허용 — 있으면 오히려 검증
@ -263,10 +326,14 @@ for (const p of CFG.pages) {
}
overs.length ? fail(`${p.name}/${v} 오버플로`, overs.join(" ")) : pass(`${p.name}/${v} 오버플로`, CFG.widths.join("/"));
// h1 1줄
// h1 줄 수 — 앱 화면은 기본 1줄, 마케팅 헤드라인은 페이지별로 최대 2줄까지 명시할 수 있다.
await page.setViewport({ width: 1440, height: 900 });
const h1l = await page.evaluate(`__lines(${JSON.stringify(p.h1 || "h1")})`);
h1l === 1 ? pass(`${p.name}/${v} h1 1줄`) : (h1l > 1 ? fail(`${p.name}/${v} h1 ${h1l}줄`) : skip(`${p.name}/${v} h1`, "요소 없음"));
const h1MaxLines = Number.isInteger(p.h1MaxLines) && p.h1MaxLines > 0 ? p.h1MaxLines : 1;
const h1Rule = h1MaxLines === 1 ? "h1 1줄" : `h1 최대 ${h1MaxLines}줄`;
h1l >= 1 && h1l <= h1MaxLines
? pass(`${p.name}/${v} ${h1Rule}`)
: (h1l > h1MaxLines ? fail(`${p.name}/${v} h1 ${h1l}줄(최대 ${h1MaxLines}줄)`) : skip(`${p.name}/${v} h1`, "요소 없음"));
// 세로 쌓임(수축) — 짧은 라벨 3줄 이상
if (CFG.checks.stack) {
@ -366,6 +433,7 @@ for (const p of CFG.pages) {
// ── 스케일×폭 h1 행렬 (안드로이드 글꼴 확대 재현) ──
if (CFG.checks.scaleMatrix) {
const bad = [];
const h1MaxLines = Number.isInteger(p.h1MaxLines) && p.h1MaxLines > 0 ? p.h1MaxLines : 1;
for (const w of [375, 390]) {
for (const s of CFG.fontScales) {
await page.setViewport({ width: w, height: 800, isMobile: true, hasTouch: true });
@ -373,10 +441,10 @@ for (const p of CFG.pages) {
await page.evaluate(UTILS);
await page.addStyleTag({ content: `html { font-size: ${16 * s}px !important; }` });
const l = await page.evaluate(`__lines(${JSON.stringify(p.h1 || "h1")})`);
if (l > 1) bad.push(`${w}@${s}x:${l}줄`);
if (l > h1MaxLines) bad.push(`${w}@${s}x:${l}줄(최대 ${h1MaxLines}줄)`);
}
}
bad.length ? fail(`${p.name} 스케일×폭 h1`, bad.join(",")) : pass(`${p.name} 스케일×폭 h1`, `scales ${CFG.fontScales.join("/")}`);
bad.length ? fail(`${p.name} 스케일×폭 h1`, bad.join(",")) : pass(`${p.name} 스케일×폭 h1`, `최대 ${h1MaxLines}줄 · scales ${CFG.fontScales.join("/")}`);
// 브랜드 1줄
await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
await page.goto(href, { waitUntil: "networkidle0" });
@ -386,7 +454,7 @@ for (const p of CFG.pages) {
const bl = await page.evaluate((sel) => {
const els = [...document.querySelectorAll(sel)];
const el = els.find((e) => e.offsetParent !== null || getComputedStyle(e).position === "fixed") || els[0];
return window.__lines(el);
return window.__textLines(el);
}, p.brand);
bl === 1 ? pass(`${p.name} 브랜드 1줄`) : fail(`${p.name} 브랜드 ${bl}줄(수축 의심)`);
}