Files
Gnuboard7/docs/frontend/template-development.md
T
HeuJung 3945b6f1b3 feat(core,extensions): 구동 에셋 자체 제공 · 자산 실패 폴백 · 운영자 추가 에셋(custom/)
공개 저장소 이슈 gnuboard/g7 (@bigmsg) 제보에서 출발한 작업이다.

브라우저가 화면을 그리려고 제3자 CDN 에 도달해야 하면, 그 도달 실패는 예외도 로그도
남기지 않고 화면 기능만 조용히 사라진다. 폐쇄망·방화벽·광고차단기에서 재현되는데
자체 서버 로그에는 흔적이 없어 운영자가 원인을 특정할 수 없다. 제보된 것은 편집기
하나였지만 같은 구조가 아이콘·글꼴·코드편집기·압축 라이브러리·설치 마법사·개발
대시보드에 똑같이 있었으므로, 번들 확장과 템플릿 전체를 자체 제공으로 옮겼다.
런타임에 외부로 나가는 것은 주소 검색 서비스 하나만 남았다.

자체 제공만으로는 부족하다 — 자기 서버에서 받는 파일도 실패할 수 있고, 종전에는 그
실패가 무음이었다. CSS 경로에 재시도 계층을 세우고(스크립트 경로와 동형), 서버가 HTML
에 직접 심는 externals 까지 실패를 붙잡아 안내 배너와 [다시 시도]로 표면화했다.
편집기·코드편집기는 확보 실패 시 평문 입력으로 내려앉되 저장 계약을 유지한다.

두 번째 축은 운영자가 CSS 를 덧붙일 자리가 없던 문제다(sir.kr 문의). 확장 디렉토리의
custom/ 을 운영자 소유로 정해, 확장 교체가 그 디렉토리만은 보존하게 했다. 출처에
의존하지 않는 서술자로 해석하므로 나중에 다른 출처가 붙어도 소비자는 바뀌지 않는다.
확장 자산과 같은 메커니즘으로 정적 게시되어 CSS 내부 상대 url 도 해석되고, 파일을
고치면 그 변경을 감지해 재게시까지 예약된다.

FTP 접근이 없는 운영자에게는 그 자리도 없는 것과 같으므로 레이아웃 편집기에서 직접
넣고 고칠 수 있게 했다. 모듈·플러그인·템플릿이 한 엔드포인트를 공유한다 — 타입별로
나누면 같은 검증이 세 벌로 갈리고 그중 약한 하나가 우회로가 된다. 여기서 올린
스크립트는 그 레이아웃 한 장이 아니라 사이트 전 화면에서 실행되므로 레이아웃 편집과
분리된 전용 권한으로 연다. 운영자 CSS 가 화면을 조작 불능으로 만들면 그것을 고칠
화면에도 같은 CSS 가 실려 스스로 갇히므로, 서버가 목록을 비우는 탈출구(?custom=off)를
함께 뒀다.

동봉 자산은 재생성 경로에 버전 대조 가드를 붙였다. 선언과 다른 버전을 버전 디렉토리에
써 넣는 조용한 거짓말은 배포된 뒤에는 드러나지 않는다.
2026-08-27 16:47:14 +09:00

20 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. 디렉토리 구조
  2. 개발 프로세스
  3. 커스텀 핸들러 개발
  4. 빌드 프로세스
  5. 템플릿 네이밍 규칙
  6. 코어 엔진과 템플릿의 역할 분리

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 옵션으로 빌드 시점에 문자열로 치환

4-1. 운영자가 CSS·JS 를 덧붙이는 자리 (custom/)

템플릿 소스(src/styles/)를 고치고 빌드하는 방법은 템플릿 저작자의 방법입니다. 운영자에게는 부적합합니다 — Node.js 가 필요하고, 그렇게 넣은 파일은 다음 템플릿 업데이트에 사라집니다. 빌드 산출물(dist/css/)을 직접 고치는 것도 다음 빌드에 사라집니다.

운영자는 활성 템플릿 디렉토리의 custom/ 에 파일을 놓습니다.

templates/{템플릿-식별자}/custom/custom.css
  • 빌드하지 않습니다. 파일을 놓으면 다음 요청부터 적용됩니다.
  • 템플릿을 업데이트해도 이 디렉토리는 보존됩니다.
  • 적용 순서는 템플릿·확장 스타일보다 뒤라서 재정의가 그대로 반영됩니다.
  • 여러 파일을 놓으면 파일명 오름차순으로 적용됩니다(10-, 20- 접두사로 순서를 정할 수 있습니다).
  • 폰트·이미지 같은 파일도 같은 디렉토리에 두고 CSS 에서 상대 경로로 참조합니다.
  • 순서를 바꾸거나 일부만 싣고 싶으면 custom/assets.json 에 선언합니다.

모듈·플러그인도 같은 규약을 씁니다. 상세: module-assets.md "사용자 추가 에셋".

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() 함수에서 다음을 등록해야 합니다:

  1. 커스텀 핸들러 등록 — actionDispatcher.registerHandler(name, handler) (모든 템플릿 공통)
  2. 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]) — 코어는 선언값을 그대로 치환할 뿐 특정 라이브러리를 가정하지 않는다.


관련 문서