Files
Gnuboard7/templates/_bundled/sirsoft-admin_basic/scripts/extract-safelist.cjs
T
2026-07-01 10:30:32 +09:00

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`);