177 lines
6.6 KiB
JavaScript
177 lines
6.6 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 마커 제거
|
|
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) + mainCss.slice(endIdx + markerEnd.length);
|
|
}
|
|
|
|
// 새 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`);
|