확장 E2E config 의 로케일 미고정을 코어 포함 7개에서 해소했다. 미지정 시 첫 페이지 로드의 Accept-Language 가 en-US 로 나가고 엔진이 그 값을 localStorage 에 굳혀, 인증 후에도 세션 전체가 영어로 렌더돼 한국어 문구를 단언하는 spec 이 전멸한다. 같은 함정을 정적으로 막는 룰을 함께 등록했다. 관리자 공용 탭·스위치는 ARIA 와 키보드 처리가 없어 마우스 없이는 조작할 수 없었고 보조기기에 목록·선택 상태가 전달되지 않았다. WAI-ARIA 규약대로 보완하고, role 변경에 맞춰 낡은 셀렉터를 갱신했다(admin_basic E2E 회귀 1건 포함). nhnkcp 설정 화면은 저장 후 정규화 결과가 입력칸에 반영되지 않았다. 계측 결과 refetch 와 initLocal 은 정상 동작하며 _local 도 갱신되지만 사용자가 편집한 입력칸만 값을 유지한다. 저장 응답으로 폼을 명시 재바인딩하고 그 기전을 트러블슈팅에 남겼다. safelist 생성기가 빌드마다 main.css 에 빈 줄을 누적해 매 빌드가 소스를 변경된 것으로 만들던 문제도 함께 고쳤다.
182 lines
7.0 KiB
JavaScript
182 lines
7.0 KiB
JavaScript
/**
|
|
* JSON 레이아웃에서 반응형/임의값 클래스 추출
|
|
*
|
|
* 추출 대상:
|
|
* - 반응형: lg:, md:, sm:, xl:, 2xl:
|
|
* - 다크 모드: dark:
|
|
* - 상태: hover:, focus:, active:, disabled:
|
|
* - 임의값: w-[260px], text-[13px] 등
|
|
*
|
|
* 사용법:
|
|
* node scripts/extract-safelist.cjs
|
|
*
|
|
* 출력:
|
|
* src/styles/safelist.txt
|
|
*/
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
/**
|
|
* 디렉토리 내 모든 JSON 파일 찾기 (재귀)
|
|
*/
|
|
function findJsonFiles(dir) {
|
|
let results = [];
|
|
try {
|
|
const items = fs.readdirSync(dir);
|
|
for (const item of items) {
|
|
const fullPath = path.join(dir, item);
|
|
const stat = fs.statSync(fullPath);
|
|
if (stat.isDirectory()) {
|
|
results = results.concat(findJsonFiles(fullPath));
|
|
} else if (item.endsWith('.json')) {
|
|
results.push(fullPath);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// 디렉토리 접근 실패 시 무시
|
|
}
|
|
return results;
|
|
}
|
|
|
|
const layoutsDir = path.join(__dirname, '../layouts');
|
|
const files = findJsonFiles(layoutsDir);
|
|
const classes = new Set();
|
|
|
|
/**
|
|
* editor-spec.json 의 classToken 컨트롤이 생성할 수 있는 고정 토큰의 `dark:` 변형을 safelist 에
|
|
* 보장한다. 편집기는 다크 탭에서 이 토큰들에
|
|
* `dark:` prefix 를 붙여 적용하는데, 그 클래스가 빌드 CSS 에 없으면(레이아웃 JSON 에서 미사용 시
|
|
* Tailwind 가 생성 안 함) 화면에 반영되지 않는다. editor-spec 이 SSoT 이므로 거기서 토큰을 도출해
|
|
* `dark:` 변형을 inline safelist 에 추가한다(원칙 4.8 — 토큰 형식은 editor-spec 이 정함).
|
|
*/
|
|
// editor-spec.json 합본 — 분할 manifest + `$include` → editor-spec/{block}.json
|
|
// 을 단일 spec 으로 읽는다. PHP EditorSpecAssembler 의 합본 규칙과 동치이며, 본
|
|
// 스크립트는 템플릿과 함께 배포되므로 외부 의존 없이 자기완결적으로 인라인한다.
|
|
function assembleEditorSpec(specPath) {
|
|
if (!fs.existsSync(specPath)) return null;
|
|
let manifest;
|
|
try {
|
|
manifest = JSON.parse(fs.readFileSync(specPath, 'utf-8'));
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
const includes = manifest.$include;
|
|
delete manifest.$include;
|
|
if (!includes || typeof includes !== 'object') return manifest;
|
|
const baseDir = path.dirname(specPath);
|
|
for (const [key, rel] of Object.entries(includes)) {
|
|
if (typeof rel !== 'string' || rel === '') continue;
|
|
const blockPath = path.join(baseDir, rel.replace(/\//g, path.sep));
|
|
if (!fs.existsSync(blockPath)) continue;
|
|
try {
|
|
manifest[key] = JSON.parse(fs.readFileSync(blockPath, 'utf-8'));
|
|
} catch (e) {
|
|
/* 누락 — 무손실 디그레이드 */
|
|
}
|
|
}
|
|
return manifest;
|
|
}
|
|
|
|
function collectEditorDarkTokens() {
|
|
const specPath = path.join(__dirname, '../editor-spec.json');
|
|
const spec = assembleEditorSpec(specPath);
|
|
if (!spec) return;
|
|
const tokens = new Set();
|
|
const collectApply = (apply) => {
|
|
if (!apply || apply.type !== 'classToken') return;
|
|
for (const t of apply.tokens || []) {
|
|
if (typeof t === 'string' && t.length > 0) tokens.add(t);
|
|
}
|
|
// tokenTemplate(임의값 합성)은 자유값이라 safelist 대상이 아님 — 고정 tokens 만.
|
|
};
|
|
for (const control of Object.values(spec.controls || {})) {
|
|
collectApply(control.apply);
|
|
for (const opt of control.options || []) collectApply(opt.apply);
|
|
// slider `scale` 항목 중 완전한 클래스 토큰(예: opacity-50)을 수집한다. tokenTemplate:"{value}"
|
|
// 슬라이더(opacity 등)는 scale 값 자체가 적용 토큰이므로 apply.tokens 에 잡히지 않는다.
|
|
if (Array.isArray(control.scale)) {
|
|
for (const s of control.scale) {
|
|
if (typeof s === 'string' && /^[a-z]/.test(s) && /[a-z]-/.test(s)) tokens.add(s);
|
|
}
|
|
}
|
|
}
|
|
// 각 토큰의 라이트 + dark: 변형을 safelist 에 추가한다.
|
|
// - dark: 변형 — 편집기 다크 탭 적용.
|
|
// - 라이트 토큰 자체 — 레이아웃 JSON 미사용 신규 토큰(shadow/rounded/border/italic/underline/
|
|
// overflow 등)이 **저장 전 라이브 프리뷰**에서 즉시 반영되도록 보장(레이아웃 스캔은 저장된
|
|
// JSON 만 보므로, 사용자가 처음 토큰을 적용하는 순간엔 빌드 CSS 에 없을 수 있음).
|
|
for (const t of tokens) {
|
|
classes.add(t);
|
|
classes.add('dark:' + t);
|
|
}
|
|
}
|
|
collectEditorDarkTokens();
|
|
|
|
// 추출 대상 패턴
|
|
const patterns = [
|
|
/^(lg|md|sm|xl|2xl):/, // 반응형 breakpoint
|
|
/^dark:/, // 다크 모드
|
|
/^(hover|focus|active|disabled):/, // 상태
|
|
/\[.+\]/, // 임의값 (arbitrary values)
|
|
];
|
|
|
|
for (const file of files) {
|
|
const content = fs.readFileSync(file, 'utf-8');
|
|
const matches = content.matchAll(/"className":\s*"([^"]+)"/g);
|
|
for (const match of matches) {
|
|
const classList = match[1].split(/\s+/);
|
|
for (const cls of classList) {
|
|
// 유효한 클래스만 (오염 데이터 제외)
|
|
if (cls.includes("'") || cls.includes('}') || cls.includes('{')) continue;
|
|
if (cls.includes('(') || cls.includes(')') || cls.includes('?')) continue;
|
|
if (cls.length === 0) continue;
|
|
// Tailwind 클래스 형식 검증 (알파벳/숫자/하이픈/콜론/슬래시/대괄호만 허용)
|
|
if (!/^[a-zA-Z0-9\-:/\[\]\.%]+$/.test(cls)) continue;
|
|
|
|
// 패턴 매칭
|
|
if (patterns.some((p) => p.test(cls))) {
|
|
classes.add(cls);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// main.css에 @source inline() 자동 삽입
|
|
const mainCssPath = path.join(__dirname, '../src/styles/main.css');
|
|
let mainCss = fs.readFileSync(mainCssPath, 'utf-8');
|
|
|
|
// 기존 safelist 마커 제거
|
|
//
|
|
// 삽입 블록은 앞뒤에 개행을 하나씩 두는데, 마커 사이만 잘라내면 그 개행 두 개가 남는다.
|
|
// 그대로 두면 빌드 1회마다 main.css 에 빈 줄 2개가 쌓여(실측: 373줄 누적) 매 빌드가 소스
|
|
// 파일을 변경된 것으로 만든다. 잘라낼 때 마커 주변 공백까지 함께 걷어낸다.
|
|
const markerStart = '/* SAFELIST-START */';
|
|
const markerEnd = '/* SAFELIST-END */';
|
|
const startIdx = mainCss.indexOf(markerStart);
|
|
const endIdx = mainCss.indexOf(markerEnd);
|
|
if (startIdx !== -1 && endIdx !== -1) {
|
|
mainCss =
|
|
mainCss.slice(0, startIdx).replace(/\s+$/, '') +
|
|
mainCss.slice(endIdx + markerEnd.length).replace(/^\s+/, '\n\n');
|
|
}
|
|
|
|
// 새 safelist 삽입 (@source "../../layouts/**/*.json"; 바로 뒤에)
|
|
const classesStr = [...classes].sort().join(' ');
|
|
const safelistBlock = `
|
|
${markerStart}
|
|
@source inline("${classesStr}");
|
|
${markerEnd}`;
|
|
|
|
// @source "../../layouts/**/*.json"; 뒤에 삽입
|
|
const layoutSourcePattern = /@source\s+"\.\.\/\.\.\/layouts\/\*\*\/\*\.json";/;
|
|
if (layoutSourcePattern.test(mainCss)) {
|
|
mainCss = mainCss.replace(layoutSourcePattern, (match) => match + safelistBlock);
|
|
} else {
|
|
// 패턴 없으면 @import "tailwindcss"; 뒤에 삽입
|
|
mainCss = mainCss.replace(/@import\s+"tailwindcss";/, (match) => match + safelistBlock);
|
|
}
|
|
|
|
fs.writeFileSync(mainCssPath, mainCss);
|
|
|
|
console.log(`✓ Injected ${classes.size} classes into main.css`);
|