nginx 의 정규식 location 은 프리픽스 location 보다 먼저 매칭되므로, `location ~* \.(js|css|json)$` 블록이 있는 서버에서는 확장자 붙은 동적 엔드포인트가 `try_files ... /index.php` 폴백이 실행될 기회 없이 404 가 된다. aaPanel/CyberPanel/Plesk 기본 템플릿에 들어있어 드물지 않으며, 관리자 화면조차 뜨지 않아 "서버 설정을 고치세요" 안내가 순환 참조가 된다. 관례를 깬 쪽이 G7 이므로 해소 책임도 G7 에 두고, 두 형태를 모두 서빙한 뒤 환경에 따라 택일한다. 확장자 형태는 영구 유지한다 — 제거하면 URL 을 하드코딩한 서드파티 확장이 깨진다. - 라우트: dualSuffix / dualSuffixSegment / dualAsset 매크로로 23개 엔드포인트와 프로브를 이중 등록. 확장자 형태를 먼저 등록한다 — 확장자 없는 쪽이 더 느슨한 패턴이라 순서가 뒤집히면 `.json` 요청까지 삼킨다 - URL 생성: 서버 App\Support\AssetUrl, 프론트 core/support/assetUrl.ts 로 집약. 기본 모드에서 생성 결과는 치환 이전과 문자열까지 동일하다 (쿼리 순서 포함 — 순서가 바뀌면 의미는 같아도 HTTP 캐시 키가 갈린다) - 자가 복구: 부트스트랩 자산 로드 실패 시 확장자 없는 형태로 1회 단방향 전환. 역방향 금지 + 기존 재시도 예산 공유로 무한 왕복을 막는다 - 감지·전환: 인스톨러 프로브(설치 시 확정) / 관리자 환경설정 일반 탭 / g7:asset-url-mode. 판정은 상태코드가 아니라 매직 토큰 + Content-Type 으로 한다. 상태코드만 보면 "404 대신 200 + 에러 HTML" 환경에서 영원히 오판한다 - 봇은 JavaScript 를 실행하지 않아 자가 복구가 닿지 않으므로, 모드 변경 시 SEO 프리렌더 캐시를 비워 재생성시킨다 가드: audit 룰 2종(dynamic-route-static-extension / asset-url-builder-required), Playwright 6건(정적 블록 가로채기 시뮬레이션), 루프 방지 불변식 L1~L9 전수 red 증명. 부수 정리: 양 Composer 에 중복돼 있던 확장 에셋 수집 123줄을 트레이트로 통합하고, 자산 확장자 화이트리스트 누락(.map)과 경로 정규화 우회 가능성을 함께 교정. sir.kr 커뮤니티의 hang 님께서 제보해주셨습니다.
19 KiB
템플릿 개발 가이드라인
그누보드7 템플릿 개발을 위한 디렉토리 구조, 개발 프로세스, 빌드 과정 가이드
TL;DR (5초 요약)
1. 디렉토리: templates/[vendor-template]/ (예: sirsoft-admin_basic)
2. 필수 파일: template.json, routes.json, components.json
3. 컴포넌트: src/components/{basic,composite,layout}/
4. 빌드: php artisan template:build [identifier]
5. 핸들러: src/handlers/에 커스텀 액션 핸들러 정의
목차
1. 디렉토리 구조
templates/_bundled/[vendor-template]/
├── template.json # 템플릿 메타데이터
├── routes.json # 기본 라우트 정의
├── components.json # 컴포넌트 매니페스트
├── /src/
│ ├── /components/
│ │ ├── /basic/ # 기본 컴포넌트
│ │ ├── /composite/ # 집합 컴포넌트
│ │ └── /layout/ # 레이아웃 컴포넌트
│ ├── /handlers/ # 커스텀 액션 핸들러
│ │ ├── index.ts # handlerMap export
│ │ └── *.ts # 개별 핸들러 파일
│ └── index.ts # 컴포넌트 export
├── /dist/ # 빌드 결과
│ └── components.js
└── /tests/ # Vitest 테스트
주요 파일 설명
| 파일 | 설명 |
|---|---|
template.json |
템플릿 이름, 버전, 작성자 등 메타데이터 |
routes.json |
URL 경로와 레이아웃 매핑 정의 |
components.json |
등록된 컴포넌트 목록 (basic, composite, layout) |
/src/components/ |
컴포넌트 소스 코드 |
/src/handlers/ |
커스텀 액션 핸들러 (init_actions, lifecycle, actions에서 사용) |
/dist/components.js |
빌드된 컴포넌트 번들 |
/tests/ |
컴포넌트 테스트 파일 (Vitest) |
2. 개발 프로세스
Step 1: 컴포넌트 구현
/src/components/ 디렉토리에 컴포넌트 개발:
- 기본/집합/레이아웃 컴포넌트 개발
- HTML 태그 직접 사용 금지
- 기존 컴포넌트 재사용 우선
// ✅ DO: 기본 컴포넌트 사용
import { Div, H2, Button } from '../basic';
export const Card: React.FC<CardProps> = ({ title, onClick }) => (
<Div className="p-4 bg-white dark:bg-gray-800">
<H2>{title}</H2>
<Button onClick={onClick}>확인</Button>
</Div>
);
// ❌ DON'T: HTML 태그 직접 사용
export const Card = ({ title }) => (
<div className="p-4"> {/* 금지! */}
<h2>{title}</h2> {/* 금지! */}
</div>
);
Step 2: 컴포넌트 등록
components.json에 컴포넌트 등록:
{
"basic": ["Button", "Input", "Div", "Span", "H1", "H2"],
"composite": ["Card", "Modal", "PageHeader", "DataGrid"],
"layout": ["Container", "Grid", "AdminLayout"]
}
Step 3: 기본 레이아웃 정의
/layouts/*.json에 기본 레이아웃 정의:
- JSON 스키마 준수
- 데이터 바인딩/다국어 활용
{
"version": "1.0.0",
"layout": {
"name": "AdminLayout",
"children": [
{
"name": "PageHeader",
"props": {
"title": "$t:dashboard.title"
}
}
]
}
}
Step 4: 빌드
# Artisan 명령어 사용 (권장)
php artisan template:build sirsoft-admin_basic
# 모든 템플릿 빌드
php artisan template:build --all
# 파일 감시 모드
php artisan template:build sirsoft-admin_basic --watch
/dist/components.js생성- 코어 엔진과 독립적으로 빌드
Step 5: 테스트
테스트 실행 규칙
템플릿은 자체 vitest.config.ts를 가지며, 루트의 setup 파일과 alias를 참조함
✅ 템플릿 디렉토리에서 실행해도 루트와 동일하게 동작
✅ server.fs.allow로 루트 디렉토리 접근 허용
템플릿 vitest.config.ts 표준
각 템플릿 디렉토리에 vitest.config.ts 파일이 필요합니다:
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
const rootDir = path.resolve(__dirname, '../..');
export default defineConfig({
plugins: [react()],
server: {
fs: {
allow: [rootDir], // 루트 디렉토리 접근 허용
},
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: [path.resolve(rootDir, 'resources/js/tests/setup.ts')],
include: ['src/**/*.{test,spec}.{ts,tsx}'],
},
resolve: {
alias: {
'@': path.resolve(rootDir, 'resources/js'),
},
},
});
테스트 실행 방법
루트에서 실행:
# 전체 테스트 (모든 템플릿 포함)
powershell -Command "npm run test:run"
# 특정 템플릿 테스트
powershell -Command "npm run test:run -- templates/sirsoft-admin_basic"
# 특정 테스트 필터
powershell -Command "npm run test:run -- DataGrid"
템플릿 디렉토리에서 실행:
cd templates/sirsoft-admin_basic
powershell -Command "npm run test:run" # 해당 템플릿 전체 테스트
powershell -Command "npm run test:run -- DataGrid" # 특정 테스트 필터
왜 이 설정이 필요한가?
| 문제 | 해결 |
|---|---|
| 루트 setup 파일 참조 필요 | server.fs.allow로 루트 디렉토리 접근 허용 |
| alias(@) 미적용 | resolve.alias에 루트 기준 경로 설정 |
| 테스트 범위 제한 | include로 해당 템플릿만 테스트 |
- 모든 컴포넌트 테스트 작성 필수
- Vitest 사용
3. 커스텀 핸들러 개발
커스텀 핸들러는 init_actions, lifecycle, actions에서 호출하는 템플릿 전용 함수입니다.
핸들러 파일 구조
/src/handlers/
├── index.ts # handlerMap export
├── setThemeHandler.ts # 테마 관련 핸들러
├── setLocaleHandler.ts # 언어 관련 핸들러
└── ...
핸들러 작성 규칙
// handlers/setThemeHandler.ts
/**
* 핸들러 함수 시그니처
* @param action - 액션 정의 (handler, target, params 등)
* @param context - 액션 컨텍스트 (data, event, navigate 등)
*/
export async function setThemeHandler(
action: any,
context?: any
): Promise<void> {
// action.target: 바인딩이 해결된 값
const theme = action?.target;
if (theme) {
localStorage.setItem('g7_color_scheme', theme);
applyTheme(theme);
}
}
handlerMap 등록
// handlers/index.ts
import { setThemeHandler, initThemeHandler } from './setThemeHandler';
import { setLocaleHandler, updateMyLanguageHandler } from './setLocaleHandler';
export const handlerMap = {
// 테마 핸들러
setTheme: setThemeHandler,
initTheme: initThemeHandler,
// 언어 핸들러
setLocale: setLocaleHandler,
updateMyLanguage: updateMyLanguageHandler,
} as const;
export type HandlerName = keyof typeof handlerMap;
레이아웃 JSON에서 사용
init_actions (레이아웃 로드 시):
{
"init_actions": [
{
"handler": "initTheme",
"target": "{{query.theme}}"
}
]
}
lifecycle (컴포넌트 마운트/언마운트 시):
{
"id": "my_component",
"lifecycle": {
"onMount": [
{ "type": "click", "handler": "loadData", "target": "{{route.id}}" }
],
"onUnmount": [
{ "type": "click", "handler": "cleanup" }
]
}
}
actions (이벤트 기반):
{
"actions": [
{
"type": "click",
"handler": "setTheme",
"target": "dark"
}
]
}
핸들러에서 접근 가능한 데이터
| 데이터 | 접근 방법 | 설명 |
|---|---|---|
| target 값 | action.target |
바인딩 해결된 target |
| params | action.params |
바인딩 해결된 params 객체 |
| 이벤트 | context.event |
DOM 이벤트 객체 |
| 데이터 컨텍스트 | context.data |
route, query, _global 등 |
| navigate 함수 | context.navigate |
페이지 이동 함수 |
주의사항
- ✅ 핸들러는 반드시
handlerMap에 등록해야 함 - ✅ 핸들러는 async 함수로 작성 가능
- ✅
action.target은 바인딩이 해결된 값으로 전달됨 - ❌ 등록되지 않은 핸들러는 경고 로그 출력 후 무시됨
- ❌ 핸들러 내에서 직접 DOM 조작은 최소화
4. 빌드 프로세스
Artisan 빌드 명령어 (권장)
G7은 크로스 플랫폼 호환을 위해 Artisan 빌드 명령어를 제공합니다:
# 코어 템플릿 엔진 빌드 (기본)
php artisan core:build
# 코어 전체 빌드 (npm run build)
php artisan core:build --full
# 코어 파일 감시 모드
php artisan core:build --watch
# 템플릿 빌드
php artisan template:build sirsoft-admin_basic
# 모든 템플릿 빌드
php artisan template:build --all
# 템플릿 파일 감시 모드
php artisan template:build sirsoft-admin_basic --watch
빌드 명령어 요약
| 명령어 | 설명 | 결과물 |
|---|---|---|
php artisan core:build |
코어 템플릿 엔진 빌드 | /public/build/core/template-engine.min.js |
php artisan core:build --full |
코어 전체 빌드 | /public/build/ |
php artisan template:build [id] |
템플릿 빌드 | /templates/[id]/dist/components.js |
php artisan module:build [id] |
모듈 빌드 | /modules/[id]/dist/ |
빌드 순서 (권장)
# 1. 코어 빌드
php artisan core:build
# 2. 템플릿 빌드
php artisan template:build sirsoft-admin_basic
# 3. 모듈 빌드 (필요시)
php artisan module:build sirsoft-ecommerce
직접 npm 사용 시 (Windows)
Windows 환경에서 npm을 직접 사용해야 하는 경우 PowerShell 래퍼를 사용합니다:
# 코어 빌드
powershell -Command "npm run build:core"
# 템플릿 빌드
powershell -Command "cd templates/sirsoft-admin_basic; npm run build"
Vite 설정 주의사항
외부 라이브러리(react-select, lodash 등)를 사용하는 경우, 브라우저 환경에서 process is not defined 오류가 발생할 수 있습니다. 이를 방지하려면 vite.config.ts에 다음 설정이 필수입니다:
// vite.config.ts
export default defineConfig({
define: {
'process.env.NODE_ENV': JSON.stringify('production'),
},
// ... 기타 설정
});
왜 필요한가?
- 많은 npm 패키지가 Node.js 환경의
process.env.NODE_ENV를 참조 - 브라우저에서는
process객체가 존재하지 않음 - IIFE 빌드 시 런타임 오류 발생 가능
- Vite의
define옵션으로 빌드 시점에 문자열로 치환
5. 템플릿 네이밍 규칙
형식
[vendor-template] (GitHub 스타일)
디렉토리 예시
/templates/sirsoft-admin_basic/- sirsoft의 기본 관리자 템플릿/templates/johndoe-shop/- johndoe의 쇼핑몰 템플릿
네임스페이스 매핑
| 항목 | 값 |
|---|---|
| 디렉토리 | /templates/sirsoft-admin_basic/ |
| 식별자 | sirsoft-admin_basic |
6. 코어 엔진과 템플릿의 역할 분리
핵심 원칙
✅ 코어 = 렌더링 로직만 제공
✅ 템플릿 = 컴포넌트만 제공
✅ 데이터 바인딩, 다국어, 액션 = 코어가 자동 처리
코어 엔진 (그누보드7 Core 제공)
위치: /resources/js/core/template-engine/
빌드 결과: /public/build/core/template-engine.js
구성 요소:
| 모듈 | 역할 |
|---|---|
ComponentRegistry |
컴포넌트 등록 및 조회 |
DataBindingEngine |
{{data}} 바인딩 처리 |
TranslationEngine |
$t:key 다국어 처리 |
ActionDispatcher |
actions 액션 핸들러 변환 |
DynamicRenderer |
레이아웃 JSON → React 컴포넌트 렌더링 |
TemplateRenderer |
템플릿 전체 렌더링 관리 |
금지 사항:
- ❌ 템플릿에서 렌더링 엔진 구현 금지
- ❌ 템플릿에서 코어 엔진 파일 수정 금지
템플릿 (창작자 제공)
위치: /templates/[vendor-template]/src/components/
빌드 결과: /templates/[vendor-template]/dist/components.js
제공 항목:
- 기본 컴포넌트 (Button, Input, Div 등)
- 집합 컴포넌트 (Card, Modal, PageHeader 등)
- 레이아웃 컴포넌트 (Container, Grid 등)
금지 사항:
- ❌ 렌더링 엔진 구현 금지
- ❌ 데이터 바인딩 로직 금지 (코어가 처리)
- ❌ 액션 디스패칭 로직 금지 (코어가 처리)
- ❌ HTML 태그 직접 사용 금지
동작 방식
1. 레이아웃 JSON 정의 (사용자):
{
"name": "Card",
"props": {
"title": "{{user.name}}",
"onClick": "navigate:/users/:id"
}
}
2. 코어 엔진 처리 (자동):
{{user.name}}→ DataBindingEngine이 실제 값으로 치환onClick→ ActionDispatcher가 함수로 변환- 처리된 props를 컴포넌트에 전달
3. 컴포넌트 렌더링 (템플릿):
export const Card: React.FC<CardProps> = ({ title, onClick }) => (
<Div onClick={onClick}>
<H2>{title}</H2>
</Div>
);
템플릿 부트스트랩 의무 (engine-v1.46.0+)
src/index.ts 의 initTemplate() 함수에서 다음을 등록해야 합니다:
- 커스텀 핸들러 등록 —
actionDispatcher.registerHandler(name, handler)(모든 템플릿 공통) - IDV 모달 launcher 등록 —
window.G7Core.identity.setLauncher(launcher)(engine-v1.46.0+ 신규)
본인인증 정책이 활성화되면 코어 IdentityGuardInterceptor 가 428 응답을 가로채 launcher 를 호출합니다. launcher 미등록 시 코어 defaultLauncher 가 토스트 + /identity/challenge 풀페이지 navigate 로 폴백합니다.
// src/index.ts
import { handlerMap } from './handlers';
import { registerMyTemplateIdentityLauncher } from './handlers/identityLauncher';
export function initTemplate(): void {
if (typeof window === 'undefined') return;
const registerHandlers = () => {
const actionDispatcher = (window as any).G7Core?.getActionDispatcher?.();
if (actionDispatcher) {
Object.entries(handlerMap).forEach(([name, handler]) => {
actionDispatcher.registerHandler(name, handler);
});
// IDV launcher 는 핸들러 등록 직후 — G7Core.identity 준비됨
registerMyTemplateIdentityLauncher();
}
// ...
};
// ...
}
상세 가이드: ../extension/template-idv-bootstrap.md.
레이아웃 편집기 스펙(editor-spec.json)의 스타일 컨트롤
레이아웃 편집기 속성 모달의 스타일 컨트롤은 editor-spec.json 의 controls 에서
정의한다. 코어 편집기는 위젯 UI 와 적용/역해석 메커니즘(recipeEngine)만 제공하고,
어떤 코드를 생성할지는 컨트롤의 apply 선언이 정한다(스타일 시스템 비종속, 원칙 4.8).
apply 종류는 classToken(클래스 토큰), styleProp(인라인 style), cssVar, propValue.
색/크기/배경 컨트롤은 템플릿 스타일 라이브러리 대응을 apply 로 선언한다
색상(글자색/배경색)·크기(가로/세로)·배경 컨트롤은 그 템플릿이 사용하는 스타일
라이브러리 표현으로 적용·역해석되도록 apply 를 선언해야 한다.
- 토큰형 라이브러리(Tailwind): 색 컨트롤을
classToken으로 선언한다.- 프리셋 색은
options에 고정 토큰(apply.tokens)으로 — 예text-gray-900. 프리셋 토큰은 라이트·다크 모두 동작한다(편집기 다크 탭이dark:prefix 부여). - 자유 색(컬러 피커/HEX)은 control-level
apply.tokenTemplate(예text-[{value}])로. - 각
options항목에swatch(표시용 HEX) 를 주면 색 위젯이 토큰 스와치를 렌더한다.
- 프리셋 색은
- 인라인/CSS 변수 라이브러리:
styleProp/cssVar로 선언(다크 표현 한계 명시).
자유값(임의 색/크기)의 다크 한계
Tailwind 는 빌드 시 safelist 에 없는 임의값 클래스(dark:text-[#hex])를 CSS 에 넣지
못한다. 따라서 자유값(직접 입력한 HEX/임의 px)은 라이트 전용이다. 편집기 다크 탭에서는
자유 입력칸이 비활성화되고 "패널 프리셋 색을 고르면 다크에도 적용됩니다" 안내가 표시된다
(프리셋 토큰만 다크 적용). 프리셋 토큰의 dark: 변형은 빌드에 포함되도록 safelist 에
보장한다 — sirsoft-admin_basic 은 scripts/extract-safelist.cjs 가 editor-spec 의
apply.tokens 에서 자동 도출, sirsoft-basic 은 src/styles/safelist.txt 에 명시.
다크 모드 표현 선언(darkMode)과 편집기 프리뷰 격리
편집기 프리뷰는 어드민 환경이 다크 테마(html.dark)여도 라이트/다크를 독립적으로
보여줘야 한다. 이를 위해 템플릿은 editor-spec 최상위에 darkMode 를 선언한다:
"darkMode": {
"strategy": "ancestor-class",
"ancestorSelector": ".dark",
"previewIsolation": {
"rewriteSelector": ".dark",
"replaceWith": ".g7le-preview-dark",
"flattenLayers": true
}
}
코어 CSS 서빙 API(/api/admin/templates/{id}/editor/component-styles.css)가 편집기 진입
시에만 이 선언대로 CSS 의 다크 조상 셀렉터(rewriteSelector)를 프리뷰 전용 마커
(replaceWith)로 치환해 서빙한다. 일반 사용자 페이지 CSS 는 원본 그대로다
(사용자 페이지 무영향). strategy: "none" 또는 미선언이면 편집기 다크 탭이 비노출된다.
flattenLayers 는 CSS cascade-layer(@layer)를 쓰는 라이브러리(Tailwind v4 등)
전용 옵트인이다. 편집기 CSS 가 어드민 호스트 CSS 와 같은 @layer 이름을 공유하면
cross-build 레이어 우선순위 충돌로 프리뷰 다크 규칙이 적용되지 않을 수 있는데,
true 로 두면 서빙 시 편집기 CSS 의 @layer 래퍼를 평탄화(unlayered)해 프리뷰
규칙이 호스트 레이어드 규칙을 이기게 한다. @layer 비사용 라이브러리(Bootstrap 등)는
미선언/false. rewriteSelector 도 라이브러리에 맞게 선언한다(예 Bootstrap
[data-bs-theme=dark]) — 코어는 선언값을 그대로 치환할 뿐 특정 라이브러리를 가정하지 않는다.