v7.0.0 release

This commit is contained in:
HeuJung committed 2026-07-01 10:30:32 +09:00
1 parent 0e7fa04b03
commit c4ea9a6cd1
3582 files changed
+626189 -160151

No files matched your search

+6 -4
View File
@@ -11,8 +11,8 @@
|----------|---------|----------|
| [백엔드](backend/) | 32개 | 정상 |
| [프론트엔드](frontend/) | 51개 | 정상 |
| [확장 시스템](extension/) | 30개 | 정상 |
| 공통 | 19개 | 정상 |
| [확장 시스템](extension/) | 31개 | 정상 |
| 공통 | 20개 | 정상 |
| [AI 도구](ai-tools/) | - | 정상 |
<!-- AUTO-GENERATED-END: docs-readme-stats -->
@@ -206,12 +206,13 @@
| [handlers.md](frontend/handlers.md) | sirsoft-basic 핸들러 |
| [layouts.md](frontend/layouts.md) | sirsoft-basic 레이아웃 |
### 확장 시스템 (30개)
### 확장 시스템 (31개)
| 문서 | 제목 |
|------|------|
| [cache-driver.md](extension/cache-driver.md) | 캐시 드라이버 시스템 (CacheInterface) |
| [changelog-rules.md](extension/changelog-rules.md) | Changelog 규칙 (Changelog Rules) |
| [editor-spec.md](extension/editor-spec.md) | 편집기 스펙 (editor-spec.json) |
| [extension-manager.md](extension/extension-manager.md) | ExtensionManager (확장 관리자) |
| [extension-update-system.md](extension/extension-update-system.md) | 확장 업데이트 시스템 (Extension Update System) |
| [hooks.md](extension/hooks.md) | 훅 시스템 (Hook System) |
@@ -241,7 +242,7 @@
| [upgrade-step-guide.md](extension/upgrade-step-guide.md) | 업그레이드 스텝 작성 가이드 (Upgrade Step Guide) |
| [vendor-bundle.md](extension/vendor-bundle.md) | Vendor 번들 시스템 (Vendor Bundle System) |
### 공통 (19개)
### 공통 (20개)
| 문서 | 제목 |
|------|------|
@@ -264,6 +265,7 @@
| [requirements.md](requirements.md) | 그누보드7 시스템 요구사항 (System Requirements) |
| [SECURITY.md](SECURITY.md) | 그누보드7 템플릿 엔진 보안 가이드 |
| [testing-guide.md](testing-guide.md) | 그누보드7 테스트 가이드 |
| [e2e-testing.md](testing/e2e-testing.md) | 그누보드7 Playwright E2E 테스트 가이드 |
### AI 도구
+6 -1
View File
@@ -187,9 +187,13 @@ G7은 레이아웃 JSON 보안을 위해 **10개의 Custom Validation Rule**을
**허용 패턴**:
```regex
^/api/(admin|auth|public)/
^/api/((admin|auth|public)/|(modules|plugins)/{vendor-id}/)
```
`/api/modules/{vendor-module}/`, `/api/plugins/{vendor-plugin}/` 은 코어 라우트 프로바이더가
등록하는 확장 API 네임스페이스로, 확장 식별자(vendor-name — 하이픈 포함) 세그먼트가 뒤따를
때만 허용된다.
**검증 대상**:
- `data_sources[].endpoint` — 데이터소스 엔드포인트
- `components[].actions[].endpoint` — 액션 핸들러 엔드포인트 (재귀적 children 포함)
@@ -197,6 +201,7 @@ G7은 레이아웃 JSON 보안을 위해 **10개의 Custom Validation Rule**을
**차단**:
- 외부 URL (`http://`, `https://`)
- 비공개 API (`/api/internal/*`)
- 확장 식별자 형식이 아닌 modules/plugins 세그먼트 (`/api/modules/assets/*` 등)
- 직접 경로 (`/admin/*`)
- **경로 트래버설**: `../`, `..\` 패턴 차단
+14 -10
View File
@@ -4,18 +4,22 @@
import pino from 'pino';
const level = process.env.LOG_LEVEL || 'info';
const isMcpStdio = process.env.MCP_STDIO === '1';
export const logger = pino({
level,
transport: {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'SYS:standard',
ignore: 'pid,hostname',
export const logger = isMcpStdio
? pino({ level }, pino.destination(2))
: pino({
level,
transport: {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'SYS:standard',
ignore: 'pid,hostname',
},
},
},
},
});
);
// 에이전트별 로거 생성
export function createAgentLogger(agentName: string) {
+1 -1
View File
@@ -39,7 +39,7 @@
| 파일 | 설명 |
| ---- | ---- |
| `template.json` | 메타데이터 SSoT (type, components, error_config 등) |
| `template.json` | 메타데이터 SSoT (type, components, error_config, externals 등) |
| `routes.json` | 프론트엔드 라우트 정의 |
| `components.json` | 컴포넌트 레지스트리 매핑 |
| `CHANGELOG.md` | Keep a Changelog 형식 |
+4 -2
View File
@@ -16,7 +16,7 @@
| `modules/_bundled/[vendor-module]/resources/layouts/**/*.json` | _bundled 모듈 레이아웃 | `modules/_bundled/[vendor-module]/resources/lang/*.json` |
| `templates/[vendor-template]/layouts/**/*.json` | 템플릿 레이아웃 | `templates/[vendor-template]/lang/*.json` |
| `templates/_bundled/[vendor-template]/layouts/**/*.json` | _bundled 템플릿 레이아웃 | `templates/_bundled/[vendor-template]/lang/*.json` |
| `resources/layouts/**/*.json` | 코어 레이아웃 | `lang/*.json` |
| `resources/layouts/**/*.json` | 코어 레이아웃 | `lang/{ko,en}.json` (+ `lang/partial/{ko,en}/*.json`) |
### $ARGUMENTS 처리
@@ -100,9 +100,11 @@ const regex = /\$t:(?:defer:)?([a-zA-Z][a-zA-Z0-9_.-]*(?:\.[a-zA-Z0-9_-]+)+)/g;
modules/[vendor-module]/resources/lang/ko.json
modules/[vendor-module]/resources/lang/en.json
# 코어 언어 파일
# 코어 프론트엔드 언어 파일 (모듈/플러그인과 동일한 구조: 엔트리 + partial 분할)
lang/ko.json
lang/en.json
lang/partial/ko/*.json # 선택적 — 엔트리에서 $partial 디렉티브로 참조
lang/partial/en/*.json
```
### 3.2 기존 키 추출
+4 -1
View File
@@ -50,7 +50,8 @@ $ARGUMENTS 경로의 파일을 읽습니다.
경로가 지정되지 않은 경우, 다음 파일들을 대상으로 합니다:
- `lang/ko/*.php`, `lang/en/*.php` - 코어 언어 파일
- `lang/ko/*.php`, `lang/en/*.php` - 코어 백엔드 언어 파일 (Laravel `__()`/`trans()`)
- `lang/{ko,en}.json`, `lang/partial/{ko,en}/*.json` - 코어 프론트엔드 언어 파일 (`$t:` 프리픽스)
- `**/lang/ko/*.php`, `**/lang/en/*.php` - 모듈 언어 파일
- `**/resources/lang/*.json` - 프론트엔드 언어 파일
- `templates/**/lang/*.json` - 템플릿 언어 파일
@@ -137,6 +138,7 @@ ko/en 언어 파일과 번들 locale 파일의 키를 직접 비교한다.
**파일 유형별 매칭 (코어/모듈/플러그인/템플릿 4 scope)**:
- 코어 backend: `lang/{ko,en}/*.php` ⇔ `lang-packs/_bundled/g7-core-{locale}/backend/{locale}/*.php`
- 코어 frontend: `lang/{ko,en}.json` (+ `lang/partial/{ko,en}/*.json`) ⇔ `lang-packs/_bundled/g7-core-{locale}/frontend/{locale}.json` (+ `frontend/partial/*.json`)
- 모듈 backend: `modules/_bundled/{mod}/{src,resources}/lang/{ko,en}/*.php` (union) ⇔ `g7-module-{mod}-{locale}/backend/{locale}/*.php`
- 모듈 frontend: `modules/_bundled/{mod}/resources/lang/partial/{ko,en}/*.json` ⇔ `g7-module-{mod}-{locale}/frontend/partial/*.json`
- 플러그인: `plugins/_bundled/{pl}/lang/{ko,en}/*.php` ⇔ `g7-plugin-{pl}-{locale}/backend/{locale}/*.php`
@@ -151,6 +153,7 @@ ko/en 언어 파일과 번들 locale 파일의 키를 직접 비교한다.
| 파일 유형 | 우선 적용 규정 |
| --------- | -------------- |
| `lang/**/*.php` | database-guide.md (다국어 섹션) |
| `lang/{ko,en}.json`, `lang/partial/**/*.json` | data-binding-i18n.md (코어 프론트엔드 다국어) |
| `**/resources/lang/*.json` | module-i18n.md |
| `templates/**/lang/*.json` | data-binding-i18n.md |
| `app/Http/Requests/**` | validation.md |
+12
View File
@@ -181,6 +181,18 @@ HookManager::broadcast("core.user.notifications.{$user->uuid}", 'notification.re
> **중요**: 웹소켓 OFF 시 `broadcasting.default`가 `'null'`로 강제되는 동작은 `.env`의 `BROADCAST_CONNECTION` 값을 무시하고 적용됩니다. 따라서 운영자가 환경설정에서 OFF한 경우, `.env`에 `REVERB_HOST=localhost` 등이 남아 있어도 broadcast 시도가 발생하지 않습니다. 알림 시스템(mail/database 채널)은 이 설정과 독립적으로 정상 동작합니다.
#### 웹소켓 토글은 전 계층 SSoT (송신 · 프론트연결 · 채널인증)
웹소켓 사용 OFF 는 **송신 계층만** 막는 것이 아니라 다음 세 계층을 모두 차단합니다 (`websocket_enabled` 가 단일 SSoT):
| 계층 | OFF 시 동작 | 구현 |
|------|------------|------|
| ① 백엔드 송신 | `broadcasting.default='null'` → `HookManager::broadcast()` 즉시 return | `applyWebsocketConfig()` |
| ② 프론트 연결 | `broadcasting.connections.reverb.key` 를 빈 문자열로 무력화 → Blade `@if(reverb.key)` false → 브라우저 WebSocket 미연결 | `applyWebsocketConfig()` OFF 분기 |
| ③ 채널 인증 | `POST /api/broadcasting/auth` 가 `broadcasting.default==='null'` 이면 403 | `routes/api.php` 가드 |
따라서 `.env` 에 `REVERB_APP_KEY` 가 살아 있고 Reverb 서버가 운영자 OS 레벨로 기동 중이어도, 토글 OFF 면 프론트가 연결을 시도하지 않고 직접 연결 시도도 채널 인증 단계에서 거부됩니다. `.env` 는 토글 ON 일 때만 자격증명·endpoint 소스로 사용됩니다.
### 채널 타입
| 타입 | 클래스 | 용도 | 인증 필요 |
+56 -1
View File
@@ -28,9 +28,19 @@ applies_to 는 permission 기반 admin 판정 (User::isAdmin() — type='admin'
| `enabled` | 활성 여부 | bool |
| `grace_minutes` | 통과 유예 시간 | 0 = 매번 요구, n = n분 내 verified 있으면 통과 |
| `fail_mode` | 실패 시 동작 | `block` (예외) / `log_only` (감사 로그만) |
| `priority` | 매칭 우선순위 | 높을수록 먼저 평가 |
| `priority` | 매칭 우선순위 | 높을수록 먼저 평가. 동률이면 id 오름차순(먼저 생성된 정책 우선)으로 결정적 정렬. 운영자 UI 에서 같은 scope+target 에 동률 활성 정책 저장은 차단 — §1.1 |
| `conditions` | 추가 매칭 조건 (JSON) | `http_method` / `changed_fields` / `user_role` / `signup_stage` |
### 1.1 priority 동률 처리
같은 `scope`+`target` 에 여러 정책이 걸릴 수 있고, enforce 는 `priority` 내림차순으로 순회해 가장 먼저 차단(428)하는 정책을 적용한다. `priority` 가 같으면 적용 순서가 비결정적이 되어 "운영자가 의도한 정책(예: 성인 인증)이 무시되고 임의 정책이 먼저 적용"되는 결함이 발생했다. 두 층위로 차단한다:
1. **결정적 정렬** — `IdentityPolicyRepository::resolveByScopeTarget()` / `getRouteScopeIndex()` 가 `orderByDesc('priority')->orderBy('id')` 로 정렬. 동률이어도 항상 id 오름차순(먼저 생성된 정책 우선)이라 enforce 순서가 캐시 재빌드/실행계획과 무관하게 고정된다.
2. **동률 저장 차단** — 운영자가 관리자 UI 에서 정책을 생성/수정할 때, 같은 `scope`+`target` 에 **동일 priority 활성 정책**이 이미 있으면 422 로 거부한다 (`App\Rules\UniquePolicyPriorityPerTarget`, Store/Update FormRequest). 비활성(`enabled=false`) 정책은 enforce 대상이 아니므로 동률이어도 무해 — 저장하려는 정책과 기존 정책이 **둘 다 활성**일 때만 충돌로 본다.
운영자 입력 UI: 정책 폼 모달의 "우선순위" 입력칸(0~65535, 기본 100) + 정책 목록의 "우선순위" 컬럼. 코어(`sirsoft-admin_basic`)·모듈(`sirsoft-board`/`sirsoft-ecommerce`) 환경설정 정책 탭 모두 동일.
## 2. 두 가지 enforce 경로 (모두 동적 — 정책 DB toggle 만으로 즉시 효과)
### 2.1 라우트 자동 매핑 (scope=route)
@@ -120,6 +130,51 @@ HookManager::addFilter(
**보안**: 훅이 `IdentityPolicy` 외 타입(`null` 등)을 반환하면 원본 정책이 그대로 유지됩니다 (silent retention). 의도적으로 정책을 변경하려면 반드시 `IdentityPolicy` 인스턴스를 반환할 것.
## 5.1 정책 provider_id 해석 — fallback 체인
`IdentityPolicyService::enforce()` 는 정책 위반 시 `IdentityVerificationRequiredException` 을 던지는데, 정책에 `provider_id` 가 명시되지 않은 경우 `resolveProviderId()` 가 환경설정의 기본 프로바이더로 자동 fallback 합니다. 운영자가 정책마다 provider 를 일일이 지정하지 않아도 환경설정 default 가 일관 적용되도록 보장합니다.
해석 순서:
1. 정책 `provider_id` 가 등록된 provider 면 그대로 사용
2. 정책 미명시 또는 미등록 provider → `IdentityVerificationManager::resolveForPurpose()` 호출 (purpose 기반 fallback 체인 — 환경설정 `purpose_providers.{purpose}` → `default_provider` → 등록된 첫 provider)
3. Manager 예외 시 정책의 원본 provider_id 그대로 반환 (정책 정보 보존)
`resolveRenderHint()` 와 동일한 우선순위 체인을 적용하여 정책에 provider 가 지정되지 않아도 환경설정 default 가 모달 launcher payload 의 `provider_id` 로 정확히 전달됩니다.
## 5.2 Service 라이프사이클 훅 (cancel / consume_token)
`IdentityVerificationService` 는 challenge 라이프사이클 각 단계마다 `before_*` / `after_*` 페어로 hook 을 발행합니다. 외부 plugin 이 자기 record 정리·후속 작업을 listener 로 분리할 수 있습니다.
| 단계 | before hook | after hook |
| --- | --- | --- |
| Challenge 발급 | `core.identity.before_request` | `core.identity.after_request` |
| Verify | `core.identity.before_verify` | `core.identity.after_verify` |
| Cancel | `core.identity.before_cancel` | `core.identity.after_cancel` |
| Consume Token | `core.identity.before_consume_token` | `core.identity.after_consume_token` |
`after_cancel` / `after_consume_token` 의 세 번째 인자는 작업 성공 여부 (`bool`). 대상 log 가 없는 경우 두 번째 인자(`$log`) 는 `null` 이고 성공 여부는 `false`. 모든 경우에 before/after 둘 다 발행됩니다.
```php
// 예: 이니시스 challenge cancel 시 자기 mapping row 정리
class CleanInicisMappingOnCancel implements HookListenerInterface
{
public static function getSubscribedHooks(): array
{
return [
'core.identity.after_cancel' => ['method' => 'handle', 'priority' => 20],
];
}
public function handle(string $challengeId, ?IdentityVerificationLog $log, bool $success): void
{
if ($success && $log?->provider_id === 'inicis') {
$this->mappingRepository->deleteByChallengeId($challengeId);
}
}
}
```
## 6. 시드 정책 (코어 기본 9종, 모두 `core.*` namespace)
`config/core.php` `identity_policies` 블록에 선언. `IdentityPolicySeeder` 가 동기화하며 `HasUserOverrides` 트레이트가 운영자 수정값을 보존합니다.
+42
View File
@@ -92,6 +92,48 @@ final class SmsIdentityProvider implements VerificationProviderInterface
**target 식별자 규약**: SMS 라면 E.164 정규화된 휴대폰번호, 이메일이라면 소문자, 외부 ID 라면 안정 식별자. 핵심은 challenge 시작과 verify 후 listener 가 같은 식별자를 SHA256 한다는 일관성입니다.
## 3.1 VerificationChallenge DTO 필드 규약
`requestChallenge()` 가 반환하는 `VerificationChallenge` DTO 는 프론트 launcher 가 모달 상태 초기화에 그대로 사용합니다. 모든 필드는 named arguments 로 호출하며, 향후 코어가 신규 필드를 추가해도 기본값으로 자동 처리되도록 named args 패턴을 유지합니다.
| 필드 | 타입 | 의미 |
| --- | --- | --- |
| `id` | string | challenge UUID |
| `providerId` | string | provider 식별자 |
| `purpose` | string | signup / password_reset / self_update / sensitive_action / 확장 정의 |
| `channel` | string | email / sms / ipin / 확장 채널 |
| `targetHash` | string | SHA256(식별자) |
| `expiresAt` | CarbonInterface | 만료 시각 |
| `renderHint` | string | text_code / link / external_redirect |
| `redirectUrl` | ?string | external_redirect 분기 시 외부 URL |
| `publicPayload` | array | 프론트 노출용 공개 페이로드 (민감정보 제외) |
| `metadata` | array | 서버 내부 참조용 |
| `maxAttempts` | int (기본 0) | 허용 최대 시도 횟수. 0 은 무제한(popup/SDK 형 provider 의 기본) |
`maxAttempts` 는 `toArray()` 출력의 `max_attempts` 키로 노출되어 `ChallengeResource` 응답에 그대로 전달됩니다. 프론트 launcher 는 이 값으로 모달의 "남은 시도 횟수" UI 를 초기화합니다.
**코어 mail provider 패턴**:
```php
return new VerificationChallenge(
id: $log->id,
providerId: self::ID,
// ... 기존 필드
maxAttempts: $maxAttempts, // config('settings.identity.max_attempts', 5)
);
```
**popup/SDK 형 provider 패턴** (KG이니시스 등):
```php
return new VerificationChallenge(
id: $challengeId,
providerId: self::PROVIDER_ID,
// ... 기존 필드
// maxAttempts 미전달 시 DTO 기본값 0 (무제한)
);
```
## 4. verification_token 생성 방법
외부 SDK 가 자체 토큰(거래번호 등)을 주더라도 **절대 그대로 client 에 전달하지 마세요**. 우리 인프라용 토큰을 별도로 생성합니다:
+82 -3
View File
@@ -170,11 +170,18 @@ notification_definitions 레코드:
알림 발송 리스너는 기본적으로 큐로 디스패치되며, 큐 워커는 별도 프로세스라 `Auth::user()`/`App::getLocale()`이 모두 리셋됩니다. 그러나 G7은 디스패치 시점의 컨텍스트를 자동 복원하므로 다음이 보장됩니다:
- 발송 시점의 사용자 로케일이 자동 복원되어 다국어 메시지(메일 본문, 푸시 텍스트 등)가 원래 요청 언어로 정확히 발송
- 알림 발송 로그(`NotificationLogListener`)의 행위자가 실제 트리거한 사용자로 정상 기록
리스너 코드는 변경 불필요 — 평소처럼 `Auth::user()`, `__('...')` 호출하면 됩니다.
#### 알림 언어는 수신자 기준
알림 본문/제목의 렌더 언어는 요청자(트리거한 관리자/사용자)의 app locale 이 아니라 **수신자 본인 언어(`users.language`)** 로 결정됩니다. 관리자가 한국어 UI 에서 영어 사용자에게 주문 알림을 보내도, 그 알림은 수신자 언어로 렌더됩니다.
- `User` 는 Laravel `HasLocalePreference` contract 를 구현하며, `preferredLocale()` 이 `users.language`(SSoT)를 반환합니다. 빈값/미지원 로케일이면 `config('app.locale')` 으로 폴백합니다 (요청자 locale 폴백 없음).
- 비회원(`GuestNotifiable`)도 동일 contract 를 구현하며, 발송 트리거 시점에 저장된 로케일(예: 주문 시 `orderer_locale`)을 `guest_recipient.locale` 로 받아 `preferredLocale()` 이 반환합니다. 미저장 시 `app locale` 폴백.
- 렌더 3지점(`GenericNotification::toMail`/`toArray`, `NotificationDispatcher::resolveRenderedContent`)은 `BaseNotification::resolveNotifiableLocale($notifiable)` 헬퍼로 수신자 선호 로케일을 우선 해석합니다. 알림 렌더에서 `$notifiable->locale` 을 직접 참조하지 않습니다 (해당 속성은 존재하지 않아 항상 폴백됨).
> 자세한 동작은 [extension/hooks.md "사용자 컨텍스트 자동 복원"](../extension/hooks.md) 참조
---
@@ -190,11 +197,13 @@ notification_definitions 레코드:
| type | 설명 | value | 예시 |
|------|------|-------|------|
| `trigger_user` | 이벤트 유발자 | - | 주문자, 가입자 |
| `trigger_user` | 이벤트 유발자 (회원 User 또는 비회원 게스트) | - | 주문자, 가입자 |
| `related_user` | 관련 사용자 | relation 키 | 문의 작성자 |
| `role` | 역할 기반 | role identifier | admin, manager |
| `specific_users` | 특정 사용자 | user UUID 배열 | ["uuid1", "uuid2"] |
> `trigger_user` 는 회원/비회원을 동일 규칙으로 처리합니다. context 에 `trigger_user_id` 가 있으면 회원(User), 없고 표준 키 `guest_recipient` 가 있으면 비회원([비회원 발송](#비회원게스트-알림-발송) 참조)으로 해석됩니다.
### JSON 구조
```json
@@ -285,10 +294,13 @@ return [
| order_shipped | sirsoft-ecommerce.order.after_ship | mail, database |
| order_completed | sirsoft-ecommerce.order.after_complete | mail, database |
| order_cancelled | sirsoft-ecommerce.order.after_cancel | mail, database |
| new_order_admin | sirsoft-ecommerce.order.after_create | mail, database |
| order_pending_deposit | sirsoft-ecommerce.order.after_create | mail, database |
| new_order_admin | sirsoft-ecommerce.order.after_admin_notify | mail, database |
| inquiry_received | sirsoft-ecommerce.product_inquiry.after_create | mail, database |
| inquiry_replied | sirsoft-ecommerce.product_inquiry.after_reply | mail, database |
> `new_order_admin` 의 `order.after_admin_notify` 훅은 결제수단별로 발화 시점이 다르다. `OrderProcessingService` 가 무통장/비-PG/0원 주문은 주문 생성 시점에, 카드(PG) 주문은 결제완료(`completePayment`) 시점에 1회 발화한다. 카드 주문은 생성 시점이 `pending_order`(결제 전)라 그 시점 발송 시 결제 미완료/이탈 주문에 오발송된다. `order_pending_deposit` 는 무통장 한정으로 `EcommerceNotificationDataListener` 가 dbank 외 결제수단을 빈 결과로 게이팅한다.
**발송 리스너**: `EcommerceNotificationListener` (방식 A — 직접 호출)
게시판과 동일하게 각 훅 메서드에서 수신자를 결정하고 `$user->notify(new GenericNotification(...))` 직접 호출합니다.
@@ -565,6 +577,73 @@ HookManager::addFilter(
GenericNotification의 `__call()`이 `toFcm()` 호출을 `{hookPrefix}.notification.to_fcm` Filter 훅으로 위임합니다.
---
## 비회원(게스트) 알림 발송
user_id 없는 비회원(주문자 이메일/이름만 보유)도 회원과 동일한 발송 경로로 알림(이메일)을 받습니다. 비회원을 위한 별도 발송 흐름을 만들지 않고, **1급 수신자 값 객체**로 승격하여 기존 파이프라인을 그대로 탑니다.
### 핵심 구성
| 구성 | 위치 | 역할 |
|------|------|------|
| `GuestNotifiable` | `app/Notifications/GuestNotifiable.php` | 비회원 1급 수신자. Laravel `Notifiable` 트레잇 + `HasLocalePreference` 구현 (User 와 동일 계약). email/name/locale 보유 |
| `GuestRecipientInterface` | `app/Contracts/Notifications/GuestRecipientInterface.php` | 게스트 판별 코어 계약 (`isGuest()`). 게이트가 구체 타입(User) 검사 대신 이 계약 사용 |
| 표준 context 키 `guest_recipient` | `{email, name, locale}` | resolver 의 `trigger_user` 규칙이 user_id 없을 때 이 키로 GuestNotifiable 생성 |
### 수신자 해석 (NotificationRecipientResolver)
`trigger_user` 규칙은 회원/비회원 분기 없이 동작합니다.
```php
// extract_data 필터에서 비회원 컨텍스트 제공
'context' => [
'trigger_user_id' => null, // 비회원이므로 null
'guest_recipient' => [ // 코어 표준 키
'email' => $ordererEmail,
'name' => $ordererName,
'locale' => $ordererLocale, // 없으면 null → app locale 폴백
],
]
```
- `trigger_user_id` 가 있으면 User, 없고 `guest_recipient` 가 있으면 GuestNotifiable 을 수신자로 반환합니다.
- 중복 제거 키는 네임스페이스 분리: 회원 `user:{id}` / 비회원 `guest:{이메일 해시}` — null-id 게스트가 하나로 뭉개지지 않습니다.
- `exclude_trigger_user` 는 회원(user_id) 기준이라 게스트 수신자를 깨뜨리지 않습니다.
### 채널별 비회원 발송 허용 (allow_guest)
채널이 비회원 발송을 허용하는지는 `config/notification.php` 채널 메타의 `allow_guest` 로 선언합니다.
```php
['id' => 'mail', ..., 'allow_guest' => true ], // 비회원 가능
['id' => 'database', ..., 'allow_guest' => false], // 비회원 불가 (사이트내 알림)
```
- **미선언 채널은 기본 차단(false)** — 비회원 개인정보(이메일 등)가 의도치 않게 새 프로바이더로 노출되는 것을 막는 opt-in 정책입니다. (확장 단위 채널 활성 `isChannelEnabledForExtension` 의 "미선언=활성" 과 반대 방향)
- 모듈/플러그인이 `core.notification.filter_available_channels` 훅으로 추가하는 신규 채널(SMS·알림톡·앱푸시 등)도 동일하게 `allow_guest` 를 명시해야 비회원 발송이 허용됩니다.
- `NotificationChannelService::isChannelGuestAllowed($channelId)` 가 단일 진입점이며, `core.notification.channel_guest_allowed` 필터 훅으로 동적 재정의 가능합니다.
### 게이트 적용 지점
`GenericNotification::via()` 의 게이트 체인에 게스트 게이트가 합류합니다 (회원은 무영향).
```text
via()
├→ [게스트 게이트] notifiable 이 게스트 + 채널 allow_guest=false → 제외(skipped)
├→ isChannelEnabledForExtension (확장 채널 토글)
└→ ChannelReadinessChecker (채널 설정 완료)
```
게스트 + database → 게이트 제외 → 발송 안 됨. database 가 차단되므로 `notifications` morph 테이블에 null-id 행이 생기지 않습니다.
### 발송 로깅
비회원 발송도 기존 `notification_logs` 에 정상 기록됩니다 (`NotificationDispatcher::buildContext`).
- 게스트는 `recipient_user_id = null` + `recipient_identifier = 이메일` 로 기록됩니다. 게스트의 합성 키(`guest:...`)는 정수 FK 컬럼에 넣지 않습니다.
- 관리자 발송 이력 조회(`GET /api/admin/notification-logs?search={이메일}`)에서 `recipient_identifier` LIKE 검색으로 비회원 발송을 찾을 수 있습니다 (전체 접근 권한 기준).
### 채널 메타데이터 다국어 규칙
채널의 이름(`name`), 설명(`description`), 출처 라벨(`source_label`)은 **`{field}_key` 패턴으로 lang key 만 선언**하고, `NotificationChannelService::getAvailableChannels()` 가 활성 locale 기준으로 string 으로 해석해 반환합니다 (registry payload `name_key` 계약, 7.0.0-beta.4+).
+95
View File
@@ -162,6 +162,23 @@ SEO 렌더링 시 `_global` 컨텍스트에 프론트엔드 설정 데이터를
| `_global.settings` | `SettingsService` | 코어 프론트엔드 설정 (사이트명, 로고 등) |
| `_global.modules` | `SettingsService` | 활성 모듈 목록 |
| `_global.pluginSettings` | `PluginSettingsService` | 플러그인 설정 데이터 |
| `_global.site_name` | `general.site_name` | 사이트 이름. `resolveLocalizedValue` 통과 (다국어 array 안전) |
| `_global.site_url` | `general.site_url` | 사이트 URL |
### site_name SSoT 체인 (다국어 일관성)
`general.site_name` 은 사이트 식별 기준값(SSoT)이며, 다음 경로에서 사용된다. 운영자가 다국어 JSON array 로 저장할 수 있으므로 **모든 경로가 현재 로케일 string 으로 정규화**해야 한다 (array 를 그대로 `(string)`/Blade `e()` 캐스팅하면 "Array to string conversion" TypeError 발생).
| 경로 | 출처 | 정규화 |
|------|------|--------|
| OG `og:site_name` | `seo.og_default_site_name ?? general.site_name` (`SeoMetaResolver`) | `resolveLocalizedValue` ✅ |
| JSON-LD `WebSite.name` / `{site_name}` 치환 | `_global.site_name` ← `general.site_name` (`SeoRenderer::buildGlobalContext`) | `resolveLocalizedValue` ✅ |
| SPA `<title>` | `config('app.name')` ← `general.site_name` (`SettingsServiceProvider::applyAppConfig`) | `localizeSettingValue` ✅ |
| canonical | URL 기반, site_name 미사용 (`seo.blade.php`) | N/A (무관) |
> **OG 직접입력은 의도된 override**: `seo.og_default_site_name` 에 값을 입력하면 그 값이 우선하고, 비우면 `general.site_name` 을 따른다. admin SEO 탭의 OG 입력칸 라벨/안내가 이 "선택 재정의" 위상을 명시한다. 자동 동기화/칸 제거는 하지 않는다 (drift 는 명시적 override 로 허용).
>
> `resolveLocalizedValue` 의 SSoT 는 `app/Seo/Concerns/LocalizesSeoValues` 트레이트. `SettingsServiceProvider` 는 `register()` 단계(DI 전)라 트레이트 대신 동일 의미의 `localizeSettingValue` 를 인라인한다.
### initGlobal 매핑
@@ -1477,6 +1494,36 @@ public function seoVariables(): array
- `setting`, `core_setting`, `query`, `route` 소스는 SeoRenderer가 **자동으로 해석**합니다.
- `data` 소스는 레이아웃 JSON의 `meta.seo.vars`에서 표현식으로 매핑해야 합니다.
#### source 타입과 값 공급 주체
변수의 `source` 타입이 "값을 누가 채우는지"를 결정합니다. 같은 변수명을 모듈과 레이아웃이 동시에 선언해도, 값의 주인은 `source`가 정합니다.
| source | 값의 주인 | 레이아웃 `vars`로 덮어쓰기 |
|--------|----------|---------------------------|
| `setting` | 모듈/플러그인 설정 | 불가 (출처가 강제) |
| `core_setting` | 코어 설정 | 불가 |
| `query` | URL 쿼리 | 불가 |
| `route` | 라우트 파라미터 | 불가 |
| `data` | 레이아웃 `vars` | 유일하게 레이아웃이 값 공급 |
`source: data` 변수만 레이아웃 `vars`가 값을 공급하고, 나머지 타입은 각 출처(모듈 설정/코어 설정/URL/라우트)가 값을 강제하므로 레이아웃 `vars`로 덮을 수 없습니다.
> 주의: `source: core_setting` 변수도 **정의 주체는 모듈/플러그인**입니다 (코어는 `seoVariables()` 같은 정의 인프라를 갖지 않음). 코어 설정은 그 변수의 "값이 저장된 창고"일 뿐, 변수를 정의하지 않습니다.
#### required 플래그
변수 정의에 `required: true`를 지정하면, 해석 결과가 빈 문자열일 때 경고 로그를 남깁니다.
```php
'product_name' => ['source' => 'data', 'required' => true],
```
- 값이 비면 `Log::warning('[SEO] Required variable not resolved', ...)` 기록 (SeoRenderer)
- 렌더링을 **중단하지 않음** — 빈 값으로 계속 진행 (부분 누락 허용)
- 기본값 주입/예외 발생 없음 — 누락을 감지하는 진단용 표시
SEO 렌더링은 변수 하나가 비어도 나머지 메타태그를 정상 출력해야 하므로, `required`는 강제력 없이 경고만 수행합니다.
### meta.seo.extensions — 확장 변수 로드 선언
레이아웃 JSON에서 SEO 변수를 제공하는 확장을 선언합니다.
@@ -1539,6 +1586,54 @@ _seo.{page_type}.description — 해석된 SEO 설명
- `_common` 변수와 page_type별 변수 간 중복도 검증 대상
- 서로 다른 확장 간 동일 page_type의 변수명 충돌 시 경고 발생
### vars의 두 출처와 소비처
vars는 "정의 주체"에 따라 두 갈래로 나뉘며, 소비처도 분리됩니다. 두 소비처는 **서로 다른 변수 풀**을 봅니다.
| 정의 주체 | 처리 경로 | 소비처 |
|-----------|----------|--------|
| 모듈/플러그인 `seoVariables()` | `resolveSeoContext` | 확장 SEO 설정 탭의 제목/설명 템플릿 `{key}` 치환 |
| 레이아웃 `meta.seo.vars` | `resolveSeoVars` → `htmlMapper->setSeoVars` | seo-config.json의 `format`/`fields` 렌더 모드 컴포넌트 |
- **확장 SEO 설정 탭** ← 모듈 `seoVariables()` 기준. 운영자가 입력한 `meta_{page_type}_title` 등의 `{key}` 빈칸을 채움 (`data` 소스 변수가 여기로)
- **seo-config.json** ← 레이아웃 `meta.seo.vars` 기준. `format` 모드 `{key}` 치환(ComponentHtmlMapper)과 `fields` 모드 link `base_url`(`$var:xxx`)에 사용
겹치는 경우는 같은 변수를 **양쪽에 모두 선언**했을 때뿐입니다. 모듈이 정의한 변수는 레이아웃 `vars`에도 적지 않는 한 seo-config.json에서 쓸 수 없고, 레이아웃 임의 변수는 확장 SEO 탭에서 쓸 수 없습니다.
> 죽은 변수 주의: 레이아웃 `meta.seo.vars`에 선언만 하고 어느 소비처(확장 제목 템플릿 / seo-config.json 컴포넌트)에서도 참조하지 않으면, 해석은 되지만 출력 어디에도 반영되지 않습니다. 헤더/푸터 등 공통 컴포넌트 변수(`siteName`, `shopBase` 등)는 보통 베이스 레이아웃(`_user_base`)에서 소비되므로, 개별 페이지 레이아웃에 중복 선언된 동일 변수는 해당 페이지에서 미사용 상태로 남을 수 있습니다.
### og / twitter / structured_data 캐스케이드 경합
세 항목 모두 우선순위 서열은 `코어 < 모듈/플러그인 declaration < 레이아웃 override < 훅`이지만, 합쳐지는 단위가 다릅니다.
| 항목 | 코어 fallback | 모듈+플러그인 동시 제공 | 레이아웃 부분 재정의 | 병합 단위 |
|------|--------------|------------------------|---------------------|----------|
| `og` / `twitter` | 일부 키 제공 (site_name, image_width, type / card, site) | 키 단위 병합 (공존) | 가능 (키 단위) | 키 (`fillEmptyKeys`) |
| `structured_data` | 없음 | 마지막 확장 하나가 통째로 (extensions 배열 순서) | 불가 (통째로 전체 작성) | 문서 전체 (`=== null`일 때만 모듈) |
- **og/twitter**: `resolveOgData`가 (코어 fallback + 레이아웃 og)를 먼저 합치고, 모듈 declaration은 `fillEmptyKeys`로 **비어있는 키만** 채웁니다. 따라서 코어가 이미 채운 키(site_name, image_width, type)는 모듈이 못 덮고, 코어가 비워둔 키(image, image_alt, extra)만 모듈이 채웁니다. 레이아웃에 명시한 키는 항상 우선합니다.
- **structured_data**: 코어 fallback이 없습니다. 모듈/플러그인이 둘 다 제공하면 `meta.seo.extensions` 배열에서 **마지막에 선언된 확장**이 통째로 이깁니다(병합 아님). 레이아웃이 `meta.seo.structured_data`를 선언하면 모듈/플러그인 declaration은 전부 무시되며, 부분 재정의가 불가하므로 완성된 전체 구조를 작성해야 합니다.
- 세 항목 모두 마지막에 `core.seo.filter_og_data` / `filter_twitter_data` / `filter_structured_data` 훅이 제약 없이 통째로 덮을 수 있습니다 (이미 채워진 키도 변경 가능).
`og.type` 함정: 모듈 `seoOgDefaults`가 `type => 'product'`를 반환해도, 레이아웃에 `og.type`이 없으면 `resolveOgData`가 코어 기본값 `'website'`를 먼저 채워 모듈 값이 무시됩니다. 도메인 og:type이 필요한 레이아웃은 `og.type`을 직접 명시해야 합니다.
### meta.seo 표현식에서 참조 가능한 컨텍스트
SeoRenderer가 `$context`에 주입하는 데이터를 `og`/`structured_data`/`vars` 표현식에서 참조할 수 있습니다.
| 참조 경로 | 내용 |
|-----------|------|
| data_source ID (`product`, `reviews` 등) | `meta.seo.data_sources`로 로드한 API 응답 |
| `route` | 라우트 파라미터 + `route.path`(현재 URL 경로) |
| `query` | URL 쿼리스트링 |
| `_global` | 코어 설정(`settings`)·모듈(`modules`)·플러그인 설정 + `initGlobal` 매핑 데이터 |
| `_local` | init_actions의 `setState(target: local)` 평가 결과 |
| `_computed` / `$computed` | `computed` 섹션 평가 결과 |
| `_seo.{page_type}` | 엔진이 해석한 SEO 제목/설명 (`extensions` + vars + 운영자 템플릿 결과물) |
- `_seo`는 직접 작성하지 않고 SeoRenderer가 자동 주입합니다. 런타임에는 레이아웃이 선언한 `page_type` 하나만 채워집니다 (`_seo.product` 등). `og.title`에서 `{{_seo.{page_type}.title ?? <fallback>}}` 패턴으로 참조하는 것이 표준입니다.
- `_seo.{page_type}`의 가능한 page_type 목록은 코어 고정값이 아니라, 각 모듈/플러그인의 `seoVariables()` 키 + 설정의 `meta_{page_type}_title` 키에서 도출됩니다 (확장마다 다름).
## 개발 체크리스트
```
+62
View File
@@ -100,6 +100,7 @@ class WhitelistedEndpoint implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
// 실제 룰은 /api/(modules|plugins)/{vendor-id}/ 확장 프리픽스도 허용 — app/Rules/WhitelistedEndpoint.php 참조
if (!preg_match('/^\/api\/(admin|auth|public)\//', $value)) {
$fail(__('validation.custom.whitelisted_endpoint'));
}
@@ -1021,6 +1022,67 @@ Rule::exists("board_{$slug}_posts", 'id'),
---
## Service-Repository 패턴 {#service-repository-패턴}
FormRequest 의 검증 로직(closure rule, `prepareForValidation`, `withValidator`,
`messages` 등)이 데이터 조회를 필요로 할 때, Service 와 동일하게 Repository
Interface 경유 패턴을 따른다.
### 금지 — Model facade 직접 호출
```php
// ❌ Closure rule 안에서 Model::where 직접 호출
protected function uniqueRule(): Closure
{
return function ($attribute, $value, $fail) {
$exists = User::where('email', $value)->exists(); // ❌
if ($exists) { $fail(__('...')); }
};
}
// ❌ prepareForValidation 안에서 Model facade
protected function prepareForValidation(): void
{
$template = Template::where('identifier', $name)->first(); // ❌
}
```
### 필수 — Repository Interface 경유
FormRequest 는 생성자 주입 대신 `app(Interface::class)` 로 해석한다 (Laravel
의 FormRequest 는 컨테이너 해석 시점이 다르며, 모든 검증 closure 가 인스턴스
스코프 안에서 동작하므로 helper 호출이 가장 단순).
```php
use App\Contracts\Repositories\UserRepositoryInterface;
protected function uniqueRule(): Closure
{
return function ($attribute, $value, $fail) {
$exists = app(UserRepositoryInterface::class)->existsByEmail($value);
if ($exists) { $fail(__('...')); }
};
}
protected function prepareForValidation(): void
{
$template = app(TemplateRepositoryInterface::class)
->findByIdentifier($this->route('templateName'));
}
```
조회에 필요한 메서드가 Repository Interface 에 없으면 **Interface 에 메서드를
추가** 한 뒤 호출한다 (검증 로직 자체를 Service 로 옮기지 않는다 — 검증은
FormRequest 책임).
### 자동 검증
audit 룰 `formrequest-direct-data-access` 가 FormRequest 내 DB facade / Model
정적 호출 / 영속 메서드(`save`/`saveQuietly`/`forceDelete`) 직접 호출을 자동
차단한다. `Rule::exists(Model::class, ...)` 같은 validation rule helper 는 면제.
---
## 관련 문서
- [예외 처리 (exceptions.md)](exceptions.md) - Custom Exception 다국어 처리
+77
View File
@@ -132,6 +132,83 @@ class EcommerceModule extends AbstractModule
---
## 확장에서 cache 바인딩
플러그인 / 모듈 ServiceProvider 는 **글로벌 `CacheInterface` / `StorageInterface` 바인딩을 덮어쓰지 않는다**. 글로벌 재바인딩은 다음 결함을 만든다:
- ServiceProvider 등록 순서가 늦은 확장이 코어 `app(CacheInterface::class)` 를 자신의 도메인으로 누수
- 코어 LayoutService 등의 `$this->cache->put('ext.cache_version', ...)` 가 플러그인 도메인으로 작성됨 → 변경이 화면에 반영 안 됨
- 코어/타 확장이 같은 글로벌 바인딩을 공유하면서 상호 간섭
### 금지 패턴
```php
// plugins/_bundled/foo/src/Providers/FooServiceProvider.php — 금지
public function register(): void
{
$this->app->singleton(CacheInterface::class, function () {
return new PluginCacheDriver('foo');
});
}
```
audit 룰 `extension-no-global-cache-rebind` 가 자동 차단한다. 면제 표지 불허.
### 표준 패턴 — contextual binding
`BasePluginServiceProvider` / `BaseModuleServiceProvider` 의 `$cacheServices` 배열에 캐시 의존 서비스 클래스만 등록하면, 해당 클래스 생성자의 `CacheInterface` 인자에 본 확장 도메인의 `PluginCacheDriver` / `ModuleCacheDriver` 가 자동 주입된다. 글로벌 코어 바인딩은 그대로 보존된다.
## 플러그인에서 사용하기
### 플러그인 1단계: BasePluginServiceProvider 상속 + cacheServices 등록
```php
class FooPluginServiceProvider extends BasePluginServiceProvider
{
protected string $pluginIdentifier = 'vendor-foo_plugin';
protected array $cacheServices = [
FooService::class,
FooListener::class,
];
}
```
### 플러그인 2단계: Service 생성자에서 CacheInterface 주입
모듈과 동일하다 (§"모듈에서 사용하기" §2단계 참조). 키 prefix 만 `g7:plugin.{identifier}:` 로 다르다.
### 플러그인 3단계 (선택): 캐시 스토어 오버라이드
```php
class FooPlugin extends AbstractPlugin
{
public function getCacheStore(): string
{
return 'redis';
}
}
```
### Listener / 동적 인스턴스 생성 시
Listener 가 직접 `new MyService(cache: app(CacheInterface::class), ...)` 로 생성하면 글로벌 바인딩 (코어 도메인) 이 주입되어 도메인이 누수된다. 컨테이너 해석에 위임해야 contextual binding 이 적용된다:
```php
// 금지 — 글로벌 코어 캐시가 주입됨
$service = new MyService(
cache: app(CacheInterface::class),
);
// 표준 — $cacheServices 의 contextual binding 적용
$service = app()->makeWith(MyService::class, [
'config' => $dynamicConfig,
// cache 인자는 생략 → contextual binding 으로 자동 주입
]);
```
---
## 코어 서비스에서 사용하기
코어 서비스는 `CoreServiceProvider`에서 `CacheInterface`를 바인딩합니다.
+243
View File
@@ -0,0 +1,243 @@
# 편집기 스펙 (editor-spec.json)
> 레이아웃 편집기가 템플릿/모듈/플러그인을 편집할 때 사용하는 편집 역량 선언 파일입니다.
## TL;DR (5초 요약)
```text
1. editor-spec.json = 편집기 팔레트/스타일 컨트롤/중첩 규칙/샘플 데이터/레시피의 선언 (수작업 작성)
2. 타입 SSoT = resources/js/core/template-engine/layout-editor/spec/specTypes.ts (EditorSpec)
3. 분할 형식: manifest(editor-spec.json) + `$include` 맵 → editor-spec/{block}.json. 서버가 합본해 단일 spec 으로 서빙
4. 서빙은 활성 디렉토리만 기준 (_bundled 폴백 없음). _bundled 작업분은 {type}:update 로 활성 반영 후 런타임 노출
5. 친화 라벨은 $t: 다국어 키, comment 는 작성자 메모(엔진 무시), 알려진 필드 외 자유 필드는 보존만
```
## 개요
`editor-spec.json` 은 레이아웃 편집기가 한 확장(템플릿/모듈/플러그인)을 편집할 때 필요한 메타데이터를 선언한다. 어떤 컴포넌트를 팔레트에 노출할지, 각 컴포넌트에 어떤 스타일 컨트롤·이벤트·표시조건 편집을 허용할지, 무엇을 어디에 중첩할 수 있는지, 캔버스 프리뷰에 어떤 샘플 데이터를 쓸지 등을 담는다.
이 파일은 자동 생성 산출물이 아니라 **수작업으로 작성**한다. 확장 루트(`module.json`/`plugin.json`/`template.json` 옆)에 둔다.
- 타입 SSoT: `resources/js/core/template-engine/layout-editor/spec/specTypes.ts` 의 `EditorSpec` 인터페이스 — 각 블록의 의도·병합 규칙·플레이스홀더 문법이 JSDoc 으로 명시되어 있다. 본 문서와 타입이 어긋나면 타입이 정본이다.
- 서빙 엔드포인트: `/api/{templates|modules|plugins}/{id}/editor-spec`
- 로더: `editorSpecLoader.ts` — 편집 대상 템플릿 + 활성 모듈/플러그인 스펙을 fetch 해 네임스페이스 병합한다.
## 파일 구조 — manifest + 분할 블록
editor-spec.json 이 커지면(코어 admin 템플릿은 단일 파일 18,000줄을 넘었다) 상위 블록 단위로 분할한다. manifest 에 메타와 소형 설정을 인라인으로 두고, 대형 블록은 `$include` 맵으로 `editor-spec/{block}.json` 을 참조한다.
```json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"templateId": "sirsoft-basic",
"version": "1.0.0",
"description": "...",
"styleSystem": "tailwind",
"darkMode": { "strategy": "ancestor-class", "...": "소형 — 인라인 유지" },
"$include": {
"componentPalette": "editor-spec/componentPalette.json",
"nesting": "editor-spec/nesting.json",
"controls": "editor-spec/controls.json",
"componentCapabilities": "editor-spec/componentCapabilities.json",
"actionRecipes": "editor-spec/actionRecipes.json",
"conditionRecipes": "editor-spec/conditionRecipes.json",
"sampleGlobal": "editor-spec/sampleGlobal.json",
"sampleData": "editor-spec/sampleData.json",
"states": "editor-spec/states.json"
}
}
```
### 합본 규칙
- `$include` 의 **키 = 합본 spec 의 top-level 키**, 값 = manifest 위치 기준 상대 경로. 블록 파일 내용이 그 키의 값이 된다(top-level merge).
- 서버는 `App\Extension\Helpers\EditorSpecAssembler` 로 manifest + 블록을 합본해 단일 spec 으로 서빙한다. 합본 결과에는 `$include` 키가 남지 않는다.
- `$include` 가 없는 단일 파일(미분할)은 그대로 spec 이 된다 — 분할은 선택이며, 작은 확장은 단일 파일로 두어도 된다.
- include 파일이 없거나 파싱 실패하면 그 키만 누락하고 나머지는 합본한다(무손실 디그레이드, 서버 로그 경고).
- 분할은 값을 변형하지 않는 기계적 이동이다. 분할 전 단일 파일과 합본 결과는 키-값이 동일해야 한다.
### 서빙은 활성 디렉토리 기준
런타임 서빙은 **활성 디렉토리(`templates/{id}/...`)만** 읽는다. `_bundled` 폴백은 없다. `_bundled` 에서 작업한 분할본은 `{type}:update {id} --force` 로 활성 디렉토리에 반영된 뒤에만 편집기에 나타난다. JSON 만 바뀐 경우 빌드는 불필요하고 update 만 실행한다.
## 블록별 작성 규칙
### componentPalette — 요소 추가 팔레트
`groups`(사이드바 카테고리 + 소속 컴포넌트 이름) + `entries`(컴포넌트별 친화 라벨 + `defaultNode` 골격). 네임스페이스 병합 시 `groups` 는 concat, `entries` 는 key 병합된다. `defaultNode` 는 컴포넌트 트리 노드와 동일 구조(`type`/`name`/`props`/`text`/`children`)이며 새 노드 삽입 시 골격으로 쓰인다.
### controls — 재사용 스타일 컨트롤
위젯 종류(`widget`)·친화 라벨(`label`)·적용 방식(`apply`)·대상(`target`)·선택지(`options`)를 한 곳에 선언하고 `componentCapabilities.styleControls`(스타일 탭) 또는 `propControls`(속성 탭)가 키로 참조한다. `apply` 타입은 `classToken`(클래스 토큰 교체) / `styleProp`(인라인 스타일 속성) / `cssVar` / `propValue`(임의 `node.props[propKey]` 기입 — 속성 탭의 기본 적용 방식). 색·크기 등 라이브러리 대응 컨트롤은 프리셋 토큰 + 자유값(tokenTemplate)을 함께 선언한다. 상/우/하/좌 여백처럼 측마다 다른 값이 공존하는 속성은 다값을 1급으로 다룬다(일괄/개별 2모드, 단일 토큰 교체 금지).
**`propControls` 전용 위젯 종류**:
- `icon-picker` — 아이콘 prop(예 `iconName`/`triggerIcon`)을 카탈로그 그리드에서 검색·선택한다. **카탈로그는 템플릿 소유**(라이브러리 종속) — Font Awesome 등 아이콘셋을 쓰는 템플릿이 `G7Core.layoutEditor.registerWidget('icon-picker', ...)` 로 자기 위젯을 등록한다(코어는 위젯명만 디스패치, 아이콘 라이브러리 토큰 0). 프리뷰 폴백은 `preview.html`(코어 비해석) → `preview.className` → raw value 순. 카탈로그 미공급 시 자유 텍스트 입력으로 디그레이드.
- `options-list` — `Select`/`RadioGroup` 등의 정적 `options` 배열(value/label 행)을 추가/삭제/이동 편집한다. 값이 `{{바인딩}}` 이면 "바인딩됨(코드 편집)" 디그레이드(덮어쓰기 차단). 데이터소스 바인딩(dataProps `options`)과 직교 공존(위 "선택지 직교 완화").
- `array-items` / `array-group` / `array-cell-tree` / `number-list` — 정적 배열 prop 편집(탭/차트 데이터/카드 컬럼 등). `nodeEditor` 슬롯 또는 propControl 로 결선한다(아래 "데이터 정의 빌트인" 참조).
- `i18n-text` vs `text` — **표시 텍스트**(사용자가 화면에서 읽는 라벨/메시지/플레이스홀더)는 `i18n-text` 를 쓴다. `i18n-text` 는 평문 입력 시 `createCustomKey` 로 다국어 키를 자동 생성해 `$t:custom.*` 로 치환하고(미리보기에 raw 키 미노출), 🌐 펼침으로 ko/en/ja 동시 편집을 제공하며, `{{바인딩}}` 값이면 읽기전용 디그레이드한다(공통 위젯 `I18nTextField`). **비-표시값**(URL/id/수치/색/통화코드/separator 등)만 `text` 를 쓴다. 표시 텍스트에 `text` 를 쓰면 raw `$t:` 노출·다국어 누락 회귀가 발생한다(audit `editor-array-label-field-i18n` 가 배열 라벨 필드의 `text` 사용을 차단).
### componentCapabilities — 컴포넌트별 편집 역량
컴포넌트별로 `propControls`(속성 탭 컨트롤 화이트리스트 — `controls.json` 의 `apply.type==="propValue"` 컨트롤 참조, 비-스타일 prop 편집) / `dataProps`(데이터 연결 선언 — 아래) / `styleControls`(스타일 탭 컨트롤 화이트리스트) / `advanced`(고급 속성) / `events`(액션 편집 이벤트 — 미선언 시 동작 탭 숨김) / `flexEditor`(`container`/`item`/`auto` — 정렬 박스 편집 역할) / `visibilityCondition`(표시조건 탭 허용 여부) / `nodeEditor`·`canvasOverlay`(구조 에디터 일반 슬롯 `{kind,params}` — 종류별 고정 키 금지, kind 핸들러 레지스트리 디스패치)을 선언한다.
**데이터 연결(`dataProps`)**: "데이터를 바라보는 prop"(컬렉션의 `data`/`items`/`options` 배열, 입력 `value`·텍스트 등 단일값)을 데이터소스/상태에 바인딩하는 선언이다. 각 항목은 `{propKey, shape:'scalar'|'array', label?, itemFields?, required?, sources?}`. [속성] 탭 최상단 "데이터 연결" 영역이 각 항목을 검색형 바인딩 행으로 렌더하고, 선택 시 `{{<source>.<path>}}` 가 `node.props[propKey]` 에 기입된다(런타임 동일). 데이터 prop 은 **항상 바인딩 모드**(정적↔바인딩 토글 없음). **구조/수치 prop**(열 수·간격·페이지크기·크기)·**enum 선택**(variant/position)·**boolean config**(required/disabled)는 dataProps 비대상 — `propControls` 정적 편집이다. `dataProps` 와 `propControls` 는 직교한다(한 prop 은 한쪽만). `shape: 'array'` 면 `itemFields`(항목 객체 주요 필드)를 권장한다(검색 피커 항목 미리보기). `shape: 'object'` 는 컴포넌트가 객체 1건을 통째로 바라보는 prop(예 ProductCard.product, Avatar.author)용이다. 후보 풀은 편집기 샘플 데이터(data_sources `sampleData` shape + `sampleGlobal`) 가 SSoT 다(런타임 응답 추측 금지). audit `editor-datapropspec-shape-valid` 가 스키마를 가드한다.
**선택지(options) 직교 완화**: 선택 컴포넌트(Select/RadioGroup/TagInput/SearchableDropdown 등)의 `options` 배열은 정적 목록 직접 편집(propControls `selectOptions` → `apply.propKey: "options"`)과 데이터소스 바인딩(dataProps `options` array)을 **둘 다** 노출한다 — 같은 `options` prop 에 정적·동적 두 입력을 모두 제공한다(부록6 "한 prop 은 한쪽만" 규칙의 명시적 완화). audit 룰의 propControls 비교는 controlKey 문자열 기준이라 `selectOptions`(controlKey) ≠ `options`(dataProps propKey)로 자동 통과한다.
**반복(iteration) 데이터 연결**: `node.iteration.source`(반복 렌더링의 배열 소스)는 `props` 가 아니라 **노드 최상위 구조 키**라 컴포넌트 capability(`dataProps`)와 무관하다. iteration 은 Div/Span/Button/Li 등 거의 모든 컴포넌트에 붙을 수 있으므로, 편집기는 `dataProps` 선언 여부와 별개로 **iteration 을 가진 모든 노드**에 [속성] 탭 최상단 "반복 데이터 연결" 공용 영역(`IterationBindingSection`)을 노출한다(항상 array shape 바인딩). `item_var`/`index_var` 는 안쪽 자식 바인딩과 연동되어 읽기전용 힌트로만 표시한다(변경은 코드 편집).
**안전 바인딩 정규화**: G7 표준 바인딩은 `{{products?.data?.data ?? []}}` 처럼 옵셔널 체이닝(`?.`)과 널 병합 폴백(`?? []`/`?? ''`)을 쓴다. `parseBindingExpression`(역해석)은 이를 `?.`→`.` 흡수·폴백 분리(단순 리터럴만)·외곽 괄호 제거로 정규화해 소스/경로를 추출하고 연결됨으로 인식한다(`conditionRecipeEngine.normalizeConditionExpr` 선례와 동형 — 읽기 전용, 저장값 무변경). 후보 선택 시 `buildBindingExpression` 이 shape 별 안전 형태(`{{src?.path ?? []}}` array / `?? ''` scalar / `?? {}` object)로 재기입해 데이터 미도착 시 런타임 에러를 막는다. 진짜 복합식(삼항·파이프·다중 바인딩)은 "복합 바인딩(코드 편집)"으로 디그레이드한다.
**구조 에디터 슬롯(`nodeEditor`/`canvasOverlay`) — 종류별 고정 키 금지**: 목록(Ul/Ol/Nav/Form)·표(Table)·배열(탭/차트/카드 컬럼) 같은 구조 데이터의 전용 에디터는 `nodeEditor:{kind,params}`(속성 탭 본체) / `canvasOverlay:{kind,params}`(캔버스 인플레이스)로 선언한다. `kind` 는 레지스트리 키(예 `"children"`/`"table"`/`"array"`/`"array-group"`/`"array-cell-tree"`/`"tabnav"`)이고, `params` 는 그 kind 에디터가 해석하는 **불투명 객체**(children=아래 항목, table=행/셀/span 역할 매핑, array=항목 스키마)다. `tableEditor`/`childrenEditor` 같은 **종류별 고정 키를 capability 스키마에 두지 않는다** — 코어 모달/오버레이는 kind 만 보고 레지스트리에서 핸들러를 찾아 디스패치하므로(`if(kind==="table")` 분기 0), 새 구조 에디터는 코어 수정 없이 kind 등록만으로 추가된다.
**array kind params — `defaultItems`(선택)**: 컴포넌트가 그 prop 미지정 시 **내장 기본 목록**으로 렌더하는 경우(예 IconSelect 기본 아이콘 20종), 그 내장 목록을 `params.defaultItems` 에 그대로 선언한다. 에디터는 prop 이 미정의(undefined)일 때 그 목록을 시작 상태로 표시하고, 첫 변경 커밋 시 전체 목록+변경분을 함께 기록한다 — 항목 1개 추가가 내장 목록 전체를 교체하는 함정 차단. 명시적 빈 배열(`[]`)은 작성자 의도로 존중(시드 안 함). 컴포넌트 내장 목록과 스펙 선언의 드리프트는 동기화 가드 테스트로 차단할 것(컴포넌트가 기본 목록을 export → 테스트가 spec JSON 과 대조).
**children kind params** — `{childComponent, childLabel?, childTemplate?, itemFields?}`:
- `childComponent`: 추가할 자식 컴포넌트명(팔레트 `defaultNode` → 매니페스트 `props.default` 폴백으로 골격 생성).
- `childTemplate`(선택): "추가" 버튼이 만들 골격 노드 JSON 전체. 선언 시 `childComponent` 골격보다 우선한다 — 폼처럼 "라벨+입력칸 묶음" 행 단위 추가를 템플릿이 선언하는 용도. 골격 안에서 `"text": ""` 를 **선언한** 텍스트 노드에만 기본 안내 텍스트("새 항목")가 시드된다. `text` 미선언 자식(Input 등 void element)에는 시드하지 않는다(React error #137 크래시 차단).
- `childLabel`(선택): 추가 버튼 친화 명칭(`$t:` 토큰 가능). 미선언 시 `childComponent` 이름 그대로.
- `itemFields`(선택): 각 항목 행의 편집 필드 선언 배열. 미선언 시 `[{"kind":"text"}]`(항목의 의미 텍스트 자손 1필드 — 종전 동작). 필드 종류는 `{"kind":"text","label?"}` = 텍스트 자손 편집, `{"kind":"prop","prop":"<propKey>","label?"}` = 그 prop 을 가진 첫 자손의 `props[propKey]` 편집(예 Form 항목의 `placeholder`). **코어는 prop 이름을 모른다** — 어떤 prop 을 항목 편집에 노출할지는 전적으로 스펙 선언이다. 모든 필드는 동적 다국어 공통 위젯(`$t:custom.*` 키 생성/로케일 값 갱신)으로 편집되며, 항목에 해당 위치(텍스트 자손/prop 자손)가 없으면 그 필드는 그 행에서 숨겨진다. 편집 가능 필드가 전무한 구조 자식은 컴포넌트명 라벨 + 정렬/삭제만 제공한다.
**역할 식별 (이름 가정 금지)**: 코어는 컴포넌트 **이름**(`name==="Table"`)으로 표/목록/배열을 식별하지 않는다. 어떤 컴포넌트든 capability 의 `nodeEditor.kind`/`canvasOverlay.kind`/`dataProps`/`flexEditor` **역할 선언**으로 편집 동작을 결정한다. 따라서 `div`-그리드로 만든 표도 `nodeEditor:{kind:"table",params:{...역할 매핑}}` 만 선언하면 표 에디터가 붙고, 커스텀 목록 컴포넌트도 `nodeEditor:{kind:"children"}` 으로 목록 에디터가 붙는다. `params` 의 역할 매핑(rowContainer/row/cell 등)은 컴포넌트 이름이 아니라 그 컴포넌트가 어떤 자식 구조를 쓰는지를 적는다.
**데이터 정의 빌트인(옵션/배열/차트 수동 데이터)**: 정적으로 편집 가능한 "데이터 정의" 표면(Select.options, TabNavigation.tabs, BarChart.labels+datasets, DonutChart.data, CardGrid.cardColumns, DynamicFieldList.columns, SocialLoginButtons.providers 등)은 모두 `nodeEditor`(array/array-group/array-cell-tree) 또는 `propControls`(options-list) 로 편집 가능하게 선언한다. 전 draggable 컴포넌트는 데이터 표면을 가지면 편집 슬롯을 보유하거나(누락 0), 보유하지 않으면 비대상 allowlist(flat scalar / 표시 텍스트 / 레이아웃 / 런타임 scalar 바인딩전용)에 등록되어야 한다 — audit `editor-all-draggable-data-editable` 가 둘 다 아닌 draggable 을 차단한다.
**코어 제공 속성(요소 ID) + opt-out**: 코어는 모든 draggable 컴포넌트의 [속성] 탭 최상단에 "요소 ID" 컨트롤을 일괄 제공한다(값 = 표준 `node.props.id`, 강제 DOM 주입 없음 — 컴포넌트 passthrough 책임). 기존 `elemId`(→id) 같은 템플릿 propControl 은 코어로 이전(중복 선언 금지 — 코어 우선). 인라인 루트가 없는 컴포넌트(서드파티 모달/Portal)는 capability 에 `"coreProps": false` 로 opt-out 한다. `"coreProps": ["id"]` 처럼 부분집합 선언도 가능(미선언 = 코어 기본 전체). 코어 id 컨트롤은 `{{바인딩}}` 값이면 "바인딩됨(코드 편집)" 디그레이드, HTML 안전 문자만 허용(한글·공백 자동 제거). 컴포넌트 측 id passthrough 규약은 `docs/frontend/components-types.md` "요소 id 패스스루" 참조.
### nesting — 중첩 규칙
`draggable`(드래그/팔레트 배치 가능한 컴포넌트 이름) + `containers`(컨테이너별 `accepts` 자식 허용 목록). 병합 시 `draggable` 은 union, `containers` 는 key 병합. `accepts: []` 는 명시적 자식 거부, 엔트리 없는 컴포넌트는 어떤 자식도 받지 않는다(폴백 없음).
### sampleData — 캔버스 프리뷰 샘플 데이터
편집 모드 캔버스 렌더 시 데이터소스 응답을 결정한다. `byDataSourceId`(데이터소스 id 우선) → `byEndpointPattern`(엔드포인트 패턴 매칭) 순. 로더가 출처별 `bySource` 맵으로도 보존해 같은 id 를 여러 확장이 다른 shape 로 정의한 충돌을 해소한다. 샘플의 shape 은 실제 API Resource 가 바인딩하는 필드와 일치시킨다.
### sampleGlobal — `_global.*` baseline 시드
편집 모드 격리 store 의 `_global.*` baseline(헤더/푸터 등이 의존하는 `currentUser`/`settings`/`site` 등)을 선언한다. 코어 시드 → 모듈 → 플러그인 → 템플릿 순으로 deep merge 되며, 코어 keyspace leaf 충돌 시 코어가 이기고 dev 콘솔에 경고를 남긴다. 배열은 통째 교체된다.
### states — 페이지 상태 변종
한 라우트/base/modal 이 진입 맥락·사용자·검증 결과에 따라 가질 수 있는 변종 화면을 선언한다. `groups`(scope `route`/`base`/`modal` + items) 구조이며 병합 시 concat. 각 item 은 `sampleDataOverrides`(통째 교체) + `initialState`(local/global/query/route) + `formErrors`(필드 검증 실패 시뮬레이션) 결합이다. 그룹당 `default: true` 1개. items 가 2개 이상인 scope 에서만 편집기 캔버스 툴바에 상태 드롭다운(PageStateSwitcher)이 표시되며, 1개 이하/미선언/scope 미매칭이면 토글은 미표시되고 시뮬레이터는 no-op 으로 디그레이드한다.
`scope.kind: route` 매칭은 라우트 path 정확 일치 또는 `*` 세그먼트 glob 1단계(한 path 세그먼트). `base`/`modal` 은 정확 일치다. `scope.match` 는 `routes.json` 의 **실제 path 와 문자 단위로 일치**해야 토글이 표시된다 — admin 라우트는 실제 path 에 `*/admin/...` 프리픽스가 붙으므로(예 `*/admin/users`) scope.match 도 그 프리픽스를 포함해야 한다. 작성 시 추측하지 말고 `routes.json` 의 실제 path 를 확인한다.
`initialState` 의 네 갈래:
- `local`/`global`: `_local`/`_global` baseline 위 부분 머지(사용자/검증 결과에 따른 화면 변종).
- `query`: URL 쿼리 컨텍스트 오버라이드 — baseline(빈 객체) 위 부분 머지. 편집기는 평소 query 가 비어 있어 `{{query.tab}}`/`{{query.error}}` 같은 진입 맥락 분기 변종을 이 패치 없이는 미리볼 수 없다(예 `settings` 화면의 `query.tab=general|seo` 서브탭).
- `route`: path param 컨텍스트 오버라이드(`{{route.id}}` 유무 = 수정↔신규 작성 모드 변종). `null` 값을 주면 해당 토큰을 **제거**해 `sampleRouteParams` 자동 채움을 무력화한다(예 `users/:id/edit` 의 `route.id: null` → 신규 작성 모드). 통째 교체가 아니라 baseline 위 머지다.
`formErrors` 의 **키는 화면이 실제로 읽는 상태 경로**를 그대로 적는다 (`_local.errors.email`, `_global.loginErrors.email` 등). 값은 그 경로가 기대하는 형태(대개 표시 메시지 문자열 또는 `[message]` 배열 — 화면이 `?.[0]` 으로 읽으면 배열)다. 코어는 키를 dot-path 로 해석해 `_local.`/`_global.` 접두를 보고 해당 상태에 deep set 하며, 접두 없는 키는 `_local` 에 그대로 주입한다. FormContext 같은 별도 주입 메커니즘은 없다 — 레이아웃마다 검증 오류를 보관하는 경로가 제각각(`errors`/`loginErrors`/`addressErrors`/`formErrors` 등)이므로, 작성자가 정확한 경로를 지정해야 사용자 페이지와 동일하게 적색 오류가 표현된다. 메시지 값의 `$t:` 키는 편집 대상 사전으로 해석된 뒤 주입된다.
**점이 박힌 필드명은 대괄호 표기로 감싼다.** 점은 기본적으로 칸막이(경로 구분자)로 해석되지만, 키 자체에 점이 들어 있는 필드(예 `/shop/checkout` 의 주문자 입력칸은 `_local.errors?.['orderer.name']` 을 읽는다)는 `_local.errors['orderer.name']` 처럼 `['...']`(또는 `["..."]`)로 감싸 **리터럴 단일 키**로 지정한다. 대괄호 안의 점은 칸막이가 아니라 키의 일부로 보존된다 — 결과는 `{ errors: { "orderer.name": [...] } }`(중첩 `errors.orderer.name` 아님). 일반 점 구분자와 대괄호 표기는 한 키 안에서 혼용할 수 있다.
### actionRecipes — 친화 명칭 → 핸들러 JSON
`params`(친화 입력란) + `build`(핸들러 JSON 생성 틀)로 구성한다. `build` 값의 `{{paramKey}}`(중괄호 2개)가 입력값으로 치환된다. `onSuccess`/`onError` 가 `{{key}}` 이고 그 파라미터가 `action-list` 면 치환 결과는 중첩 액션 **배열**이 된다. 핸들러명/파라미터 키는 사용자에게 노출하지 않는다 — 사용자는 `label` 친화 명칭만 본다. `build.handler` 는 CLAUDE.md CRITICAL RULES 의 올바른 핸들러(`navigate`/`apiCall`/`setState`/...)를 쓴다. `comment` 키는 레시피가 아닌 작성자 메모다.
`build.handler` 는 리터럴 핸들러명 대신 `{{paramKey}}` **placeholder** 도 가능하다 — 호출할 핸들러를 응답값 등 데이터로 결정하는 provider-agnostic 액션(예: ecommerce 모듈 `requestPgPayment` "결제 진입" — 핸들러를 `{{response.data.pg_payment_handler}}` 데이터 칩으로 연결)에 쓴다. 결제 같은 **도메인 동작은 코어가 아니라 그 도메인 확장(모듈/플러그인/템플릿)이 자기 editor-spec `actionRecipes` 에 소유**한다 — 코어는 PG/도메인을 모른다. 확장 레시피는 로더가 `__source` 메타를 붙여 〔확장식별자〕 배지·'extension' 그룹으로 자동 분류한다. 라벨은 그 확장의 lang 네임스페이스를 명시한다(예: `$t:sirsoft-ecommerce.editor.action.request_pg_payment.label`).
이때 `matchAction`(역해석)은 placeholder-aware 로 동작하되, recipe 의 고유 `build.params` 키(예: `pgPaymentData`)가 실제 액션에 실재할 때만 후보로 인정한다(placeholder 핸들러가 무관 액션을 흡수하는 greedy 매칭 방지). 핸들러 필드를 데이터 칩으로 바꿔 쓸 수 있어야 하므로(`feedback_editor_handler_field_must_be_data_chip_bindable`), `buildAction` 은 ① placeholder 핸들러가 미입력으로 사라지면 build 토큰을 복원하고 ② **placeholder 핸들러 recipe 한정** `required` 입력 토큰도 미입력 시 보존한다 — 그래야 핸들러를 임의 데이터 칩으로 바꾸고 다른 required 값을 비워둬도 `params` fingerprint 가 유지돼 친화 카드가 [고급]으로 강등되지 않는다(저장·새로고침 후에도 친화 복원). 일반 recipe(리터럴 핸들러)는 미입력 required 를 그대로 떨궈 깔끔한 JSON 을 유지한다. 런타임에서 `ActionDispatcher` 가 `{{...}}` 핸들러를 컨텍스트로 먼저 해석한 뒤 라우팅한다(engine-v1.50.0).
#### actionChipCandidates — 동작 데이터 칩 컨텍스트 후보(도메인 응답 필드)
데이터 소스 onSuccess(`response.*`)·onError(`error.*`)·onReceive(`message.*`)·컴포넌트 동작 apiCall onSuccess 의 data-chip 입력칸이 고를 수 있는 표현식 칩 후보를 컨텍스트별로 선언한다. 코어는 도메인 중립 루트(`response.data`/`error.status` 등)만 알고, 확장이 내려주는 **도메인 응답 필드**(예: PG 결제 응답의 `data.pg_payment_handler`)는 그 확장이 본 블록으로 선언해 코어가 도메인을 모른 채 병합·노출한다.
```json
"actionChipCandidates": {
"response": [
{ "path": "data.pg_payment_handler", "labelKey": "sirsoft-ecommerce.editor.action_chip.response_pg_handler", "shape": "scalar" }
]
}
```
- 컨텍스트 키: `response` / `error` / `payload`. 각 값은 `{ path, labelKey, shape? }[]`. `path` 는 컨텍스트 루트 이하 점 경로(표현식은 `{{response.<path>}}`), `labelKey` 는 그 확장 lang 네임스페이스의 친화 라벨 키(`$t:` 없이 키만), `shape` 는 위젯 필터용(scalar/object/array, 미지정 scalar).
- 병합: 로더가 컨텍스트별 배열을 **concat**(코어 기본 칩 뒤에 확장 후보). 같은 path 는 코어 우선으로 1회만 노출(확장이 코어를 덮지 않음).
#### 중첩 액션 컨테이너 위젯 — `action-list` / `branch-list`
응답 후속·다단·분기 동작을 코드 없이 친화 편집하기 위한 두 위젯이 있다.
- `widget: 'action-list'` — 평면 액션 배열(`onSuccess`/`onError`/sequence·parallel `actions`)을 중첩 `ActionListBuilder` 로 재귀 편집(추가/순서/속성·데이터 칩). 코어 `apiCall`(onSuccess/onError)·`sequence`·`parallel` 이 사용. build 값은 `'{{onSuccess}}'` 처럼 sole-binding 으로 두면 치환 시 중첩 액션 **배열**이 그대로 들어간다(문자열 보간 아님). `switch.cases` 는 런타임이 객체맵(`Record<string,…>`)이라 action-list 로 표현 불가 → `advanced: true` 잠금 유지.
- `widget: 'branch-list'` — `conditions` 핸들러의 분기 배열(`[{if?, then}]`)을 분기별 실행조건(조건식 데이터 칩)·동작(중첩 `action-list`, 단일/배열 both) + 분기 추가/삭제/순서로 편집. 코어 `conditions` recipe 가 사용한다. 분기는 액션이 아니라 조건+동작 묶음이라 `action-list` 가 아닌 전용 위젯이 필요하다. recipe build 는 `conditions` 를 **액션 최상위 키**(`{ handler:'conditions', conditions:'{{branches}}' }`)로 둔다 — `handleConditions` 가 `action.conditions` 만 읽으므로 `params` 아래 두면 런타임이 인식하지 못한다. sole-binding 통째 캡처라 `then` 구조(단일/배열, placeholder 핸들러 포함)가 무손실 왕복된다.
`summarizeAction`(카드 한 줄 요약)은 `advanced` 또는 `action-list` 위젯 param 을 요약 꼬리에서 제외한다(중첩 배열이 `[N]` 토큰으로 새지 않도록). 이 위젯들로 `sequence → apiCall.onSuccess → conditions → then` 깊이의 동작도 특정 템플릿/모듈 의존 없이 코어 전역에서 친화 편집된다.
### conditionRecipes — 친화 조건 → `if` 표현식
`operators` 배열에 조건 후보를 둔다. 각 항목의 `expr` 은 **`{{}}` 없이** 식 본문만 적고, 파라미터 자리는 레시피 전용 플레이스홀더 `{paramKey}`(중괄호 1개)를 쓴다. `ConditionBuilder` 가 파라미터를 치환하고 여러 조건을 결합한 뒤 **최종 결과 전체를 단일 `{{ }}` 한 쌍**으로 감싼다(중첩 보간 `{{ {{x}} }}` 미발생). `actionRecipes` 의 `{{paramKey}}`(2개)와 혼동하지 않는다.
### stateLabels — 상태값 명칭 카탈로그 (데이터 연결 검색 피커용)
`_global.currentUser`·`query.q` 등 **템플릿 공통 상태값**의 로케일별 친화 명칭을 한 곳에서 선언한다. `[{ key, scope, label_key }]` 배열 — `key` 는 scope 루트 이하 점 경로(예 `currentUser.data.name`), `scope` 는 `data_source`/`_global`/`_local`/`route`/`query`/`_computed`, `label_key` 는 `$t:` 다국어 키. 데이터 연결(`dataProps`) 검색 피커가 후보를 표시할 때 raw 상태 키 대신 친화 명칭을 보여 주는 데 쓴다. 명칭이 `$t:` 키라 언어팩으로 로케일이 동적 추가되면 그 로케일 명칭도 자동 대응한다. 병합 시 concat(같은 key+scope 충돌은 뒤 단계=템플릿 우선). 카탈로그가 커버하지 못하는 상태값(레이아웃마다 의미가 다른 `_local.*` 등)은 키를 그대로 폴백 표시한다 — 마지막 세그먼트 가공·추측 명칭 생성은 하지 않는다. data_source 명칭은 `stateLabels` 가 아니라 data_source 정의의 `label_key` 로 단다.
### data_source `label_key` — 데이터 소스 친화 명칭 (검색 피커용, 레이아웃 JSON)
`stateLabels` 가 상태값(`_global`/`query` 등) 명칭을 editor-spec 카탈로그에서 다는 것과 달리, **data_source 의 친화 명칭은 그 소스를 선언한 레이아웃 JSON 의 `data_sources[]` 항목에 `label_key` 로 직접 단다**(`$t:` 다국어 키 — 예 `"label_key": "$t:editor.data_source.products"`). 데이터 연결(`dataProps`) 검색 피커가 data_source 후보를 표시할 때 현재 로케일의 친화 명칭으로 보여 주며, 미지정 시 소스 id 로 폴백한다. 명칭 키는 편집 대상 템플릿의 편집기 i18n(`lang/partial/{locale}/editor.json` 의 `data_source.<id>`)에 정의한다 — 언어팩으로 로케일이 동적 추가되면 자동 대응한다.
`label_key` 는 레이아웃 편집기의 **데이터 소스 편집기**(툴바 `⚙데이터` → `DataSourcesPanel`)가 관리한다. 번들 템플릿의 모든 레이아웃은 모든 data_source 에 `label_key` 를 보유해야 하며(누락 0), audit `data-source-label-key-coverage` 가 신규 레이아웃의 누락·비-`$t:` 값을 차단한다.
### darkMode — 다크 모드 표현 (소형, manifest 인라인 권장)
템플릿이 다크 모드를 어떻게 표현하는지 선언한다. `strategy`: `ancestor-class`(조상 클래스, Tailwind `.dark`) / `media-query` / `none`(다크 미지원 — 편집기 다크 탭 비노출). `previewIsolation`(`rewriteSelector` → `replaceWith`, `flattenLayers`)은 편집기 프리뷰를 admin 호스트의 `html.dark` 조상과 독립적으로 격리하기 위한 셀렉터 치환 규칙이다. 코어 CSS 서빙 API 가 이 선언대로 편집기용 CSS 의 다크 셀렉터를 프리뷰 전용 마커로 치환해 서빙한다(사용자 페이지 CSS 는 원본 그대로 — 무영향).
## 편집기 확장점 — `G7Core.layoutEditor`
템플릿은 커스텀 속성 위젯·노드 에디터·캔버스 인플레이스 오버레이를 코어 레지스트리에 등록해, 코어 수정 없이 자기 컴포넌트의 편집 UI 를 확장한다. 코어는 위젯명/kind 만 디스패치하고(메커니즘만 제공), 등록된 핸들러를 찾아 렌더한다.
| API | 용도 | capability 참조 |
| --- | --- | --- |
| `G7Core.layoutEditor.registerWidget(name, Comp)` | 속성/스타일 컨트롤 위젯 | `controls.json` 의 `widget: "<name>"` |
| `G7Core.layoutEditor.registerNodeEditor(kind, Comp)` | 노드 에디터(속성 탭 본체) | capability `nodeEditor.kind` |
| `G7Core.layoutEditor.registerCanvasOverlay(kind, Overlay)` | 캔버스 인플레이스 오버레이 | capability `canvasOverlay.kind` |
| `G7Core.layoutEditor.onReady(cb)` | 편집기 셸 로드 완료 콜백 | — |
**등록 시점 = 템플릿 module-load(최상위), `initTemplate`/`window.load` 게이트 밖.** 편집기 셸 번들은 lazy 로드라 템플릿 부트스트랩 시점에는 아직 실제 레지스트리가 없을 수 있다. 코어 메인 번들이 `G7Core.layoutEditor` **stub(예약 접수함)**을 항상 노출해 register* 호출을 큐에 적재하고, 편집기 셸 로드 시 실제 레지스트리로 교체하며 큐를 일괄 flush 한다. 따라서 템플릿은 module-load 시점에 즉시 등록해도 누락되지 않는다 — `initTemplate` 안에 두거나 `window.load` 를 기다리면 편집기 직접 진입(SPA 전환 없이 하드 로드) 시 위젯이 누락되는 간헐 회귀가 발생한다. `G7Core?.layoutEditor?.registerWidget` 옵셔널 체이닝으로 가드한다(identity launcher 패턴과 동형).
작성 예시(`templates/_bundled/sirsoft-admin_basic/src/layout-editor/registerEditorWidgets.ts`):
```ts
// 템플릿 index.ts 최상위(module-load)에서 호출 — initTemplate/window.load 밖
export function registerSirsoftAdminBasicEditorWidgets(): void {
if (typeof window === 'undefined') return;
const layoutEditor = (window as any).G7Core?.layoutEditor;
if (!layoutEditor?.registerWidget) return; // stub 부재 → 다음 호출/큐가 보존
layoutEditor.registerWidget('icon-picker', IconPickerWidget);
// 부록4-bis 레퍼런스: TabNavigation tabs 캔버스 인플레이스(+추가/✕삭제/◀▶이동).
// capability `canvasOverlay.kind:"tabnav"` 노드 선택 시 코어가 본 오버레이를 마운트.
layoutEditor.registerCanvasOverlay?.('tabnav', TabNavInplaceOverlay);
}
```
캔버스 인플레이스 오버레이는 속성 패널의 같은 구조 prop 에디터(예 `ArrayItemsEditor`)와 **동일한 노드 패치 경로(SSoT)**를 써야 양방향 동기가 보장된다(같은 `node.props.tabs` 패치). 편집 모드 전용 마커(`data-editor-item-path` 등)는 런타임에 주입하지 않는다(사용자 페이지 누출 0).
## 편집기 UI 규약 (일관성 잠금)
레이아웃 편집기 코어/템플릿 UI 는 아래 규약을 따른다. 신규 에디터/컨트롤 작성 시 위반하면 회귀(선택 불가/잘못된 탭/라이브러리 종속/클릭 가로채기 등)가 재발한다.
1. **kind-agnostic 레지스트리** — 구조 에디터는 `nodeEditor`/`canvasOverlay` 의 `{kind,params}` 로만 선언·디스패치한다. 종류별 고정 capability 키(`tableEditor` 등) 금지, 코어에 `if(kind===...)` 분기 금지.
2. **탭 책임 분리** — 구조/배열/노드 에디터와 데이터 연결은 **[속성] 탭**, [스타일] 탭은 CSS(클래스/인라인 스타일) 전용. 구조 편집을 스타일 탭에 두지 않는다.
3. **항목/셀 텍스트 다국어** — 목록 항목·표 셀의 텍스트 다국어는 직접 `text` 직독이 아니라 공용 `nodeTextPath`(`findTextNodePath`) 로 텍스트 자손까지 탐색해 `createCustomKey` 로 키화한다(복합 HTML/컴포넌트 셀 구조 보존). 표시 텍스트 입력은 공통 `I18nTextField` SSoT.
4. **모드 탭은 묶음 상단 단일 공용 탭** — 라이트/다크처럼 직교 축 모드 탭은 관련 컨트롤(테두리색+배경색 등)마다 복제하지 않고, 색상 섹션 상단에 단일 공용 탭(`ColorSchemeTabs`) 1개로 두어 묶음 전체에 적용한다. 모드 상태는 부모 단일 상태, 컨트롤엔 읽기전용 prop.
5. **색 적용 = 라이브러리 중립** — 프리셋 색은 `classToken`(스킴별, 다크 `dark:` 공존), 자유 HEX 는 인라인 스타일(라이트 전용 — 다크 빌드 불가). 편집기 코어는 Tailwind/Bootstrap 클래스 토큰을 직접 쓰지 않고 `g7le-*` BEM + 표준 CSS/inline style 만 쓴다. layout flow 판정은 computed style 기반.
6. **어포던스는 대상 바깥 전용 레일 + 코어 위 z-index** — 거터/핸들/이동 버튼을 대상(셀/노드/항목) 위에 겹치지 않고 대상 바깥 전용 레인에 두고, 코어 오버레이 위 전용 z-index 밴드에 둔다(클릭 가로채기 0 — `elementFromPoint` topmost=self 로 실측). 빈 셀 찌부러짐은 편집기 전용 CSS(td height) 1회 주입(content 무오염).
7. **탭/인플레이스 본체는 노드 파생 무상태** — 속성 모달은 패치마다 content 를 재마운트하므로, 탭 본체(ConditionBuilder/배열 에디터 등)는 자체 `useState` 로 값을 들고 있지 않고 매 렌더 노드 prop 에서 재구성한다(또는 노드 path 로 keying + 자유값 state 는 `useEffect` 재동기화). stale state 로 인한 오저장/409 회귀를 막는다.
## 작성자 메모와 자유 필드
- `comment` 키는 어느 블록에서나 작성자 메모이며 엔진이 무시한다(레시피/컨트롤로 해석되지 않음).
- 코어는 알려진 필드 외의 자유 필드를 그대로 보존만 한다. 알려지지 않은 키를 추가해도 손실되지 않지만, 엔진이 해석하지도 않는다.
## 다국어
친화 라벨(`label`)·설명(`description`)·`formErrors` 메시지는 `$t:...` 다국어 키로 작성한다. 새 키는 해당 확장의 프론트엔드 다국어 파일에 정의되어야 한다.
## 관련 파일
- 타입 SSoT: `resources/js/core/template-engine/layout-editor/spec/specTypes.ts`
- 로더(fetch + 병합): `resources/js/core/template-engine/layout-editor/spec/editorSpecLoader.ts`
- 서버 합본 헬퍼: `app/Extension/Helpers/EditorSpecAssembler.php`
- 서빙: `app/Http/Controllers/Api/Public/PublicTemplateController.php`, `app/Http/Controllers/Api/Admin/AdminTemplateAssetController.php`, `app/Services/ModuleService.php`, `app/Services/PluginService.php`
+29 -2
View File
@@ -506,18 +506,45 @@ class NotificationHookListener implements HookListenerInterface
*/
public function registerDynamicHooks(): void
{
// notification_definitions 테이블에서 훅 목록 조회
// 각 훅에 대해 HookManager::addAction() 등록
// notification_definitions 테이블에서 훅 목록 조회 후
// 각 훅을 HookListenerRegistrar::registerDynamicAction() 으로 등록
foreach ($definitions as $definition) {
foreach ($definition->hooks as $hook) {
HookListenerRegistrar::registerDynamicAction(
hookName: $hook,
listenerClass: self::class,
method: 'dispatchForDefinition',
boundArgs: [$definition], // 워커가 dispatchForDefinition($definition, ...$hookArgs) 로 복원 호출
priority: 30,
);
}
}
}
}
```
동적 훅도 **큐 디스패치가 기본**이다. 직접 `HookManager::addAction($hook, fn() => $this->send(...))` 로 등록하면
콜백이 훅 발화 시점(HTTP 요청 스레드)에서 동기 실행되어, 발송 대상이 많을 때 요청이 전체 처리 완료까지 막힌다
(정적 `getSubscribedHooks` 리스너는 `HookListenerRegistrar` 가 자동으로 큐 래핑하지만, 동적 등록은 Registrar 를
거치지 않으므로 이 누락이 발생하기 쉽다). 동적 등록은 반드시 `HookListenerRegistrar::registerDynamicAction()` 에
위임해 정적 등록과 동일한 큐/동기 정책을 적용한다. 직접 `dispatch(new DispatchHookListenerJob(...))` 를 리스너에
작성하지 않는다 — 큐 래핑 로직의 소유권은 Registrar 한 곳에 둔다.
| `registerDynamicAction` 인자 | 설명 |
|------|------|
| `hookName` | 구독할 훅 이름 |
| `listenerClass` / `method` | 큐 워커가 복원해 호출할 리스너 FQCN·public 메서드 |
| `boundArgs` | 훅 발화 인자 앞에 고정으로 붙일 인자 (예: DB 정의 모델). 직렬화 가능해야 함 |
| `priority` | 실행 우선순위 (기본 10) |
| `sync` | `true` 면 큐 래핑 없이 즉시 동기 실행 (큐 드라이버 무관). IDV 가드 등 요청 스레드 동기 실행이 필수일 때만 |
| 조건 | 설명 |
|------|------|
| 메서드명 | `registerDynamicHooks()` (덕 타이핑) |
| 호출 시점 | `CoreServiceProvider::boot()` 후반부 (DB 유효성 검증 후) |
| 별도 인터페이스 | 불필요 — `method_exists()` 체크 |
| 안전성 | 테이블 미존재 시 `Schema::hasTable()` 체크 필수 |
| 큐 정책 | `HookListenerRegistrar::registerDynamicAction()` 위임 (기본 큐, `sync: true` opt-out) — 직접 `addAction` 으로 동기 발송 금지 |
---
+84 -3
View File
@@ -34,6 +34,87 @@ lang-packs/
└── seed/*.json # 시드 다국어 데이터
```
## 코어 다국어 자원의 위치
코어 자체의 다국어 자원은 모듈/플러그인/템플릿과 동일한 `lang/` 트리 구조를
사용한다 (언어팩 디렉토리 구조와는 별개). 어떤 템플릿이 부팅되든 자동 노출된다.
```text
lang/
├── ko/ # 백엔드 .php (Laravel __() / trans())
│ └── activity_log.php, admin_layout.php, auth.php, ...
├── en/ # 백엔드 .php
│ └── activity_log.php, admin_layout.php, ...
├── partial/ # 프론트엔드 .json 분할 (선택)
│ ├── ko/
│ │ └── (영역별 .json — 필요 시)
│ └── en/
├── ko.json # 프론트엔드 엔트리 ($t: 프리픽스)
└── en.json # 프론트엔드 엔트리
```
| 축 | 코어 | 모듈 (sirsoft-board) | 동일 패턴 |
|---|---|---|---|
| 백엔드 .php | `lang/{ko,en}/*.php` | `modules/_bundled/sirsoft-board/resources/lang/{ko,en}/*.php` | ✓ |
| 프론트엔드 엔트리 | `lang/{ko,en}.json` | `modules/_bundled/sirsoft-board/resources/lang/{ko,en}.json` | ✓ |
| 프론트엔드 partial | `lang/partial/{ko,en}/*.json` | `modules/_bundled/sirsoft-board/resources/lang/partial/{ko,en}/*.json` | ✓ |
### 코어 키 공간 컨벤션
코어 프론트엔드 lang JSON 의 root 키는 `core.*` prefix 로 일원화한다. 영역별 세분화:
- `core.errors.*` — 템플릿 엔진 에러 메시지 (`TemplateNotFoundError` 등)
- `core.toast.*` — 코어가 발화하는 시스템 토스트
- `core.layout_editor.*` — 레이아웃 편집기 chrome
- `core.<영역>.*` — 향후 코어 UI 추가 시
호스팅 템플릿/모듈/플러그인의 lang JSON 은 root 에 `core` 키를 정의하지 않는다.
audit 룰 `core-frontend-i18n-location` 이 정적으로 차단한다.
### 런타임 병합 흐름
`TemplateService::getLanguageDataWithModules` 가 다음 순서로 병합:
1. 코어 자체 (`lang/{locale}.json` + partial) — 가장 베이스
2. 템플릿 (`templates/{id}/lang/{locale}.json`)
3. 모듈 (`modules/{id}/resources/lang/{locale}.json`) — 식별자 wrap
4. 플러그인 (`plugins/{id}/lang/{locale}.json`) — 식별자 wrap
5. 활성 언어팩 (`lang-packs/{id}/frontend/*.json` — `MergeFrontendLanguage` 필터)
코어가 베이스로 들어가 가장 낮은 우선순위 — 템플릿/모듈/플러그인/언어팩이 코어 키를
덮어쓸 수 있다 (예: `g7-core-ja/frontend/ko.json` 이 활성화되면 일본어로 번역).
### 병합 정책 — Deep Merge (재귀 병합)
`TemplateService::getLanguageDataWithModules` 는 `array_merge` 가 아닌 재귀 deep merge
(`deepMergeLanguageData`) 로 4개 레이어를 합친다. 동일 top-level 키(예: `layout_editor`,
`core`, `auth`) 의 하위 트리를 leaf 까지 내려가며 합치고, 어느 한쪽이 다른 레이어의
부분 트리만 정의해도 나머지 형제 키가 보존된다.
- assoc 트리끼리 충돌 → 재귀 병합 (양쪽 leaf 모두 보존, 동일 leaf 는 뒤가 우선)
- list / scalar / 한쪽만 array → 뒤 입력으로 덮어쓰기
- 우선순위(낮음 → 높음): 코어 < 템플릿 < 모듈 < 플러그인 < 활성 언어팩(filter 훅)
이 정책으로 템플릿이 `layout_editor.palette` 만 정의해도 코어의 `layout_editor.chrome /
device / zoom / preview / save` 가 살아남는다. 과거 shallow `array_merge` 는 동일
top-level 키 시 트리 전체가 교체되어, 템플릿이 한 sub-key 만 정의해도 코어의 다른
sub-key 가 통째 누락되는 결함이 발생했다.
확장 작성 시 권장 패턴:
- 코어가 정의한 도메인 네임스페이스(예: `layout_editor.*`, `core.*`, `auth.*`) 안에
자신의 sub-key 만 정의하면 deep merge 가 자동으로 양쪽 보존
- 코어 leaf 를 의도적으로 오버라이드하려면 동일 키 경로에 leaf 만 정의 (트리 교체
의도가 아니라 leaf override 의도임을 코드로 표현)
- 모듈/플러그인은 식별자 wrap (`module.{id}.*`, `plugin.{id}.*`) 사용을 우선 — 코어
도메인과 충돌할 일이 없고, deep merge 의 잠재적 부작용도 회피
### 호스팅 템플릿 무관성
코어가 자체 자원을 베이스로 제공하므로, 호스팅 템플릿이 바뀌어도 `$t:core.errors.*`
같은 키는 항상 해석된다. 코어가 발화하는 에러/토스트/편집기 등이 호스팅 템플릿
종속이 되지 않는다.
## Manifest 명세 (language-pack.json)
언어팩 매니페스트는 모듈/플러그인/템플릿 매니페스트와 동일한 필드 구조로 정렬되어 있어 외부 작성자가 다른 확장과 동일한 표준으로 언어팩을 만들 수 있습니다.
@@ -53,7 +134,7 @@ lang-packs/
"en": "G7 core Japanese language pack (bundled)",
"ja": "G7 コア 日本語 言語パック(バンドル)"
},
"version": "1.0.0-beta.1",
"version": "1.0.0",
"license": "MIT",
"scope": "core",
"target_identifier": null,
@@ -61,7 +142,7 @@ lang-packs/
"locale_name": "Japanese",
"locale_native_name": "日本語",
"text_direction": "ltr",
"g7_version": ">=7.0.0-beta.4",
"g7_version": ">=7.0.0",
"requires": {
"target_version": null,
"depends_on_core_locale": false
@@ -372,7 +453,7 @@ ko 에서 키 제거 시 번들 ja 의 대응 키도 수동 제거 권장 (자
번들 언어팩 자산을 수정할 때마다 모듈/플러그인/템플릿 확장과 동일한 수준의 버전 + CHANGELOG 관리를 적용한다.
- **버전 bump 시점**: 패키지가 외부에 한 번이라도 출시(릴리즈/배포) 된 이후의 수정에 한해 버전을 올린다. 아직 출시되지 않은 번들 패키지의 사전 보강(키 추가/번역 정정 등) 은 `1.0.0` 그대로 유지한다.
- **버전 bump 시점**: 패키지가 외부에 한 번이라도 출시(릴리즈/배포) 된 이후의 수정에 한해 버전을 올린다. 아직 출시되지 않은 번들 패키지의 사전 보강(키 추가/번역 정정 등) 은 `1.0.0` 그대로 유지한다. 이미 출시된 버전의 콘텐츠를 수정하면 patch bump 가 필수다 (출시 확정 버전의 콘텐츠를 버전 유지한 채 덮어쓰지 않는다).
- **버전 bump 단위**: 출시 후 키 추가/누락 보강은 패치 (1.0.0 → 1.0.1), 의미적으로 큰 변경이나 제거는 마이너 (1.0.0 → 1.1.0).
- **CHANGELOG.md**: 패키지 루트에 `CHANGELOG.md` 작성. Keep a Changelog 표준 (`## [버전] - YYYY-MM-DD` + `### Added/Changed/Fixed/Removed`).
- **톤**: 사용자 관점, 1~2줄 불릿. 내부 파일 경로/내부 함수명/이슈 번호 미기재.
+144 -13
View File
@@ -30,6 +30,41 @@ Layout Extension 시스템은 모듈/플러그인이 코어 또는 다른 확장
- 모듈 비활성화 시 자동으로 확장 UI 숨김
- 템플릿에서 모듈 확장 오버라이드 가능
- 우선순위 기반 렌더링 순서 제어
- 관리자가 레이아웃 시각 편집기로 확장 조각을 직접 편집 (아래 "시각 편집" 참조)
### 시각 편집 (레이아웃 편집기)
레이아웃 시각 편집기(`/admin/layout-editor/{templateId}`)의 라우트 트리에 `[확장 주입]`
그룹(출처별 하위그룹)이 표시되며, 확장 항목을 선택하면 확장 편집 모드로 진입해 확장 조각
(extension_point=`content.components`, overlay=각 injection 의 `components`)을 일반 레이아웃과
동등하게 시각 편집한다. 저장은 해당 확장 행(layout-extensions API)으로 적재되어 버전 관리·수정
감지·`--layout-strategy=keep` 보존이 동작한다.
`inject_props` 주입은 편집할 컴포넌트 트리가 없으므로, 그 호스트 노드를 라우트/공통 레이아웃
편집 중 선택해 **속성 모달의 "확장이 주입한 속성" 섹션**(출처 배지)에서 편집한다 — 편집 결과는
호스트 레이아웃이 아니라 그 확장 행으로 교차 저장된다. 라우트/공통 레이아웃 편집 중 확장이 주입한
영역은 잠금 표시되며 "확장 편집" 어포던스로 확장 편집 모드에 진입할 수 있다.
#### 주입 kind × 편집기 대응
확장이 호스트에 주입하는 모든 kind 와 편집기의 대응 방식은 아래 표가 기준이다. 새 주입 방법을
추가할 때는 이 표에 편집기 대응을 함께 정의해야 한다.
| 주입 kind | 주입 경로 | 편집기 대응 |
| --- | --- | --- |
| extension_point `components` (mode: replace/append/prepend) | 호스트 EP 노드 자리 (components·modals 트리 모두 순회) | 확장 편집 모드 시각 편집. 게이트(`if`) 뒤면 editor-spec `states` 로 상태 시뮬레이션, 미정의 시 디그레이드 안내 |
| overlay `injections[].components` (position: prepend/append/prepend_child/append_child/replace) | 호스트 `target_id` 노드 기준 | 동일 — 확장 편집 모드 시각 편집 |
| overlay `injections[].position: inject_props` | 호스트 `target_id` 노드 props 병합 | 라우트/공통 편집 중 속성 모달 "확장이 주입한 속성" 섹션에서 편집 (확장 행으로 교차 저장) |
| 호스트 **모달 내부** EP 주입 (예: 회원가입 약관 모달의 `html_content`) | 호스트 `modals[]` 안 EP 노드 | 확장 편집 모드가 그 모달 노드를 표시용 `isOpen=true` 로 캔버스에 함께 렌더해 시각 편집 (모달 편집 모드와 동형) |
| `content.modals` (확장이 모달을 통째로 기여) | 호스트 `modals[]` 끝에 병합 | 시각 편집 비대상 — 트리에는 노출되고, 진입 시 "코드 편집 이용" 디그레이드 안내 |
| `content.data_sources` | 호스트 `data_sources` 병합 | 데이터 소스 모달에 출처 배지로 표시 (시각 편집 비대상) |
| `content.scripts` | 호스트 `scripts` 병합 (중복 제거) | 캔버스에 로드만 (CKEditor CDN 등) — 편집은 코드 편집 |
| `content.init_actions` (overlay) | 호스트 `init_actions` 끝에 병합 | 시각 편집 비대상 — 진입 시 "코드 편집 이용" 디그레이드 안내 |
트리 노출 판별(백엔드 `getExtensionHostLayouts`)은 "주입이 성립하는가"(overlay: 실재 `target_id`
injection 또는 modals/data_sources/scripts/init_actions 보유, EP: 병합할 payload 보유 + 확장점 실재)만 본다.
시각 편집 가능 여부의 정밀 판정은 진입 시 프론트(editability 판정 + 렌더 검증 + 폴백 안내)가
수행한다 — 시각 조각이 없는 확장도 숨기지 않고 디그레이드 안내로 코드 편집을 유도한다.
---
@@ -121,8 +156,27 @@ templates/
| `injections[].position` | ✅ | string | 주입 위치 (아래 참조) |
| `injections[].components` | ✅ | array | 주입할 컴포넌트 배열 |
| `data_sources` | ❌ | array | 추가 데이터 소스 (선택) |
| `init_actions` | ❌ | array | 호스트 레이아웃 `init_actions` 뒤에 추가할 초기화 액션 배열 (선택). 모듈이 호스트가 모르는 `_global` 초기화(예: 모듈별 표시 통화 복원)를 기여할 때 사용. 호스트는 추가 단계의 존재/모듈을 모른다 |
| `priority` | ❌ | number | 우선순위 (기본값: 100, 낮을수록 먼저) |
> `init_actions` 병합은 **overlay(`target_layout`)** 확장에서 지원한다. 호스트 `init_actions` 배열 끝에 확장의 `init_actions` 가 순서대로 추가되므로, 호스트 초기화가 모두 끝난 뒤 확장 초기화가 실행된다. 모듈 비활성 시 그 확장 행 자체가 비활성화되어 추가 단계도 사라진다(호스트 레이아웃 수정 0). 모듈이 호출하는 핸들러는 모듈 소유 네임스페이스(`{module-id}.handlerName`)를 사용해 모듈 미설치 시 미등록 핸들러로 무동작하게 하는 것을 권장한다.
### 상속(extends) 체인 전파 — base 레이아웃 타겟 overlay
`target_layout` 이 다른 화면이 `extends` 로 상속하는 **공통/베이스 레이아웃**(예: `_user_base`, `_admin_base`)인 overlay 는, 그 base 를 상속하는 모든 자식 레이아웃(`home`, `shop/index` 등)을 서빙할 때도 적용된다.
서빙 파이프라인은 `extends` 를 해소해 자식 문서를 만들 때 상속한 base 이름들을 내부 메타로 보존하고, overlay 매칭 시 `[자식 레이아웃명] + 상속 체인` 전체를 대상으로 한다. 따라서 `_user_base` 를 `target_layout` 으로 하는 overlay(예: 헤더 슬롯 주입, `init_actions` 통화 복원)는 base 를 직접 서빙하지 않아도 자식 화면에 정상 주입된다. 다단계 상속(`A extends B extends _user_base`)도 체인 끝까지 전파된다.
```text
overlay(target_layout=_user_base) 주입
↓ (home extends _user_base 서빙 시)
home 화면에 _user_base 타겟 overlay 가 적용됨 (슬롯·init_actions 포함)
```
- Extension Point 는 병합된 컴포넌트 트리를 직접 스캔하므로 상속과 무관하게 항상 동작한다(이 전파는 overlay 매칭에만 해당).
- 같은 overlay 가 중복 적용되지 않도록 매칭 결과는 확장 ID 기준으로 합집합 후 `priority` 로 재정렬된다.
- 편집기에서 자식 라우트 레이아웃을 편집할 때, base 타겟 overlay 가 주입한 영역은 확장 출처 메타가 부여되어 확장 잠금 영역으로 표시된다(해당 overlay 자체의 편집 호스트는 base 레이아웃).
### position 옵션
| 값 | 설명 | 다이어그램 |
@@ -415,8 +469,18 @@ templates/_bundled/sirsoft-admin_basic/
### 활성화 시
1. `resources/extensions/*.json` 파일 스캔
2. `template_layout_extensions` 테이블에 등록 (또는 기존 레코드 복원)
3. 레이아웃 캐시 무효화
2. **대상 템플릿 적용 가능 여부 판별** (아래 "템플릿 대상 판정" 참조)
3. `template_layout_extensions` 테이블에 등록 (또는 기존 레코드 복원)
4. 레이아웃 캐시 무효화
### 템플릿 대상 판정
모듈/플러그인 확장은 admin·user 등 모든 활성 템플릿에 동기화되지만, 확장이 **대상으로 하는 레이아웃이 실제 존재하는 템플릿에만 등록**된다.
- **Overlay**: `target_layout` 이 그 템플릿에 레이아웃으로 존재해야 한다.
- **Extension Point**: `extension_point`(확장점명)이 그 템플릿의 어느 레이아웃 `content` 에 `type: extension_point` 노드로 정의되어 있어야 한다.
판정은 `RefreshesLayoutExtensions` trait 이 `LayoutExtensionService::isExtensionApplicableToTemplate()` 로 수행한다. 대상이 없는 템플릿(예: admin 레이아웃 대상 확장 ↔ user 템플릿)에는 등록하지 않으며, 과거 무차별 등록으로 잘못 생성된 행은 다음 동기화 시 `removeInapplicableExtension()` 으로 정리(soft delete)된다.
### 비활성화 시
@@ -613,15 +677,73 @@ Overlay 확장에서 `computed`, `state`, `modals` 섹션을 정의하면 호스
---
## 레이아웃 확장 편집 및 버전 관리
관리자는 모듈/플러그인이 주입한 레이아웃 확장을 일반 레이아웃과 동등한 수준으로 편집·버전관리·복구·미리보기할 수 있다.
### 관리자 편집
레이아웃 편집 화면(`admin_template_layout_edit`)의 좌측 트리에서 확장 항목을 선택하면 해당 확장의 `content` JSON 을 편집할 수 있다. 편집 권한은 일반 레이아웃과 동일한 `core.templates.layouts.edit` 를 사용한다.
확장 편집 API 는 `{extensionId}` 정수 PK 로 식별한다 — 동일 `target_name` 에 여러 source 의 확장이 존재할 수 있기 때문이다.
#### 좌측 트리는 오버라이드 해석 결과만 노출한다
편집 화면 좌측 트리(`GET .../layout-extensions`)는 화면 렌더링 경로와 동일한 오버라이드 해석을 적용한다. 동일 `(extension_type, target_name)` 범위에서 템플릿 오버라이드(`source_type = Template`)가 특정 모듈/플러그인을 오버라이드하면, 그 모듈/플러그인의 원본 확장은 화면에 적용되지 않으므로 트리에서도 제외된다.
이는 관리자가 "저장은 되지만 화면에 반영되지 않는" 가려진 확장 행을 편집하는 것을 막기 위함이다. 트리에는 항상 해당 템플릿에서 실제로 적용되는 확장(템플릿 오버라이드 + 오버라이드되지 않은 모듈/플러그인 확장)만 표시된다.
해석 로직은 `LayoutExtensionRepository::resolveOverrides()` 단일 출처를 사용하며, 렌더링 경로(`getResolvedExtensionPoints` / `getResolvedOverlays`)와 편집 트리(`getResolvedByTemplateId`)가 동일한 가시성 기준을 공유한다.
| 메서드 | 라우트 |
| ------ | ------ |
| `GET` | `/api/admin/templates/{templateName}/layout-extensions` (출처별 그룹핑) |
| `GET` | `.../layout-extensions/{extensionId}` |
| `PUT` | `.../layout-extensions/{extensionId}` |
| `GET` | `.../layout-extensions/{extensionId}/versions` |
| `GET` | `.../layout-extensions/{extensionId}/versions/{version}` |
| `POST` | `.../layout-extensions/{extensionId}/versions/{versionId}/restore` |
| `POST` | `.../layout-extensions/{extensionId}/preview` |
### 버전 이력
확장을 편집·저장하면 `template_layout_extension_versions` 테이블에 버전 스냅샷이 쌓인다. 일반 레이아웃의 `template_layout_versions` 와 동일한 구조이며, 이전 버전으로 복구할 수 있다. 버전 diff 계산 로직은 `App\Repositories\Concerns\CalculatesJsonContentDiff` trait 으로 일반 레이아웃과 공유한다.
### 사용자 수정 감지 (`original_content_hash`)
`template_layout_extensions` 의 `original_content_hash` / `original_content_size` 컬럼은 모듈/플러그인이 등록한 원본 확장 콘텐츠의 정규화 해시를 보존한다. 관리자가 확장을 편집해도 이 원본 해시는 변경되지 않는다 — 현재 `content` 해시와 원본 해시를 비교하여 "사용자가 수정한 확장"을 판별한다.
### 모듈/플러그인 업데이트 시 충돌 전략
`module:update` / `plugin:update` 의 `--layout-strategy` 옵션이 확장에도 적용된다 (일반 레이아웃과 동일한 수준):
- `overwrite` (기본): 파일 내용으로 확장 `content` 를 무조건 덮어쓴다. 관리자가 편집한 확장이 있으면 업데이트 전 경고를 출력한다.
- `keep`: 관리자가 편집한 확장(`original_content_hash` 불일치)은 `content`/`priority` 갱신을 건너뛰고 `is_active` 만 갱신한다. 사용자 수정분이 보존된다.
### 미리보기
확장 편집 중 미리보기는 편집 중인 `content` 를 대표 레이아웃에 임시 적용하여 렌더링한다.
- `overlay` 타입: `target_layout` 자체가 대표 레이아웃이므로 별도 선택이 불필요하다.
- `extension_point` 타입: 하나의 확장점이 여러 레이아웃에서 사용될 수 있으므로, 미리볼 대표 레이아웃을 선택하는 모달이 노출된다.
미리보기 URL 은 일반 레이아웃과 동일한 `/api/layouts/preview/{token}.json` 을 재사용하며, `preview_type` 컬럼으로 일반/확장 미리보기를 분기한다.
---
## 관련 파일
### 백엔드
| 파일 | 설명 |
|------|------|
| `app/Services/LayoutExtensionService.php` | 확장 적용 핵심 서비스 |
| `app/Services/LayoutExtensionService.php` | 확장 적용·편집·버전관리 핵심 서비스 |
| `app/Models/LayoutExtension.php` | 확장 모델 (DB 테이블) |
| `app/Models/TemplateLayoutExtensionVersion.php` | 확장 버전 이력 모델 |
| `app/Repositories/LayoutExtensionRepository.php` | 확장 Repository |
| `app/Repositories/LayoutExtensionVersionRepository.php` | 확장 버전 Repository |
| `app/Repositories/Concerns/CalculatesJsonContentDiff.php` | JSON diff 계산 trait (일반 레이아웃과 공유) |
| `app/Http/Controllers/Api/Admin/LayoutExtensionController.php` | 확장 편집/버전관리 admin 컨트롤러 |
| `app/Enums/LayoutExtensionType.php` | 확장 타입 Enum (ExtensionPoint, Overlay) |
| `app/Enums/LayoutSourceType.php` | 출처 타입 Enum (Module, Plugin, Template) |
@@ -635,16 +757,25 @@ Overlay 확장에서 `computed`, `state`, `modals` 섹션을 정의하면 호스
```
template_layout_extensions
├── template_id # 템플릿 ID
├── extension_type # ExtensionPoint | Overlay
├── target_name # 확장 포인트명 또는 대상 레이아웃명
├── source_type # Module | Plugin | Template
├── source_identifier # 모듈/플러그인/템플릿 식별자
├── override_target # 오버라이드 대상 (템플릿 오버라이드 시)
├── content # 확장 JSON 내용
├── priority # 우선순위
├── is_active # 활성 상태
└── deleted_at # Soft Delete
├── template_id # 템플릿 ID
├── extension_type # ExtensionPoint | Overlay
├── target_name # 확장 포인트명 또는 대상 레이아웃명
├── source_type # Module | Plugin | Template
├── source_identifier # 모듈/플러그인/템플릿 식별자
├── override_target # 오버라이드 대상 (템플릿 오버라이드 시)
├── content # 확장 JSON 내용
├── original_content_hash # 원본 확장 콘텐츠 SHA-256 해시 (사용자 수정 감지용)
├── original_content_size # 원본 확장 콘텐츠 바이트 크기
├── priority # 우선순위
├── is_active # 활성 상태
└── deleted_at # Soft Delete
template_layout_extension_versions
├── extension_id # 레이아웃 확장 ID (FK, cascadeOnDelete)
├── version # 버전 번호 (자동 증가)
├── content # 확장 정의 JSON 스냅샷
├── changes_summary # 변경 요약 JSON (added/removed/modified/char_diff)
└── created_by # 저장자 ID
```
---
+5 -2
View File
@@ -481,7 +481,10 @@ modules/_bundled/sirsoft-ecommerce/
├── module.json # 메타데이터 (이름, 버전, 설명 등 SSoT)
├── module.php # ModuleInterface 구현
├── LICENSE # 라이선스 전문 (MIT)
├── composer.json # 오토로딩 + 외부 패키지 의존성 설정
├── composer.json # 오토로딩 + 외부 패키지 의존성 설정 (Git 추적)
├── composer.lock # Composer 락 파일 (Git 추적 — vendor-bundle.json 의 composer_lock_sha256 가 이 파일을 검증)
├── vendor-bundle.json # 번들 메타파일: SHA256, 패키지 목록 (Git 추적)
├── vendor-bundle.zip # 압축된 vendor 디렉토리 (Git 추적)
├── package.json # npm 패키지 정의 (에셋 모듈만)
├── vite.config.ts # Vite 빌드 설정 (에셋 모듈만)
├── tsconfig.json # TypeScript 설정 (에셋 모듈만)
@@ -743,7 +746,7 @@ protected function registerBundledModuleInstance(): void
| 환경설정 구조 변경 | ✅ (SettingsMigrator) | 설정 키 이름/구조 변경 |
| 기존 데이터 변환 | ✅ | 데이터 형식 변환, 기본값 |
| 권한/역할/메뉴 추가·수정 | ❌ (자동 동기화) | Module.php에서 정의 |
| 정적 권한/메뉴 제거 | ✅ (cleanup 명시 호출) | #135: 동적 메뉴/권한 보존 |
| 정적 권한/메뉴 제거 | ✅ (cleanup 명시 호출) | 동적 메뉴/권한 보존 |
| 레이아웃 JSON 변경 | ❌ (자동 갱신) | refresh-layout에서 처리 |
| PHP 코드만 변경 | ❌ | 버전만 올리면 됨 |
+11
View File
@@ -39,6 +39,11 @@
| 백엔드 | `/lang/{locale}/*.php` | PHP 배열 | Laravel `__()` 함수 |
| 프론트엔드 | `/resources/lang/{locale}.json` | JSON | 레이아웃 JSON `$t:` 문법 |
> **코어 자체 다국어 자원도 동일 구조** — 코어는 `lang/{ko,en}/*.php` (백엔드) +
> `lang/{ko,en}.json` (+ `lang/partial/{ko,en}/*.json`) (프론트엔드) 를 사용한다.
> 모듈/플러그인/템플릿/코어 모두 같은 디렉토리 구조와 $partial 디렉티브 메커니즘을
> 공유한다. 상세: [docs/extension/language-packs.md "코어 다국어 자원의 위치"](language-packs.md#코어-다국어-자원의-위치).
### moduleIdentifier 규칙
```
@@ -198,6 +203,12 @@ modules/sirsoft-sample/
}
```
### 코어/템플릿 도메인과의 충돌
모듈 lang 데이터는 모듈 identifier wrap (`sirsoft-sample.*`) 으로 코어/템플릿 도메인과 자연 격리된다. 다만 코어/템플릿 lang 과 동일 top-level 키 (`layout_editor`, `core`, `auth` 등) 를 모듈이 직접 정의하는 것은 권장하지 않는다 — `TemplateService::getLanguageDataWithModules` 는 deep merge (재귀 병합) 정책이라 양쪽 leaf 가 보존되긴 하지만, 동일 키 경로 leaf 충돌 시 모듈이 우선순위가 높아 코어/템플릿 leaf 를 덮어쓴다. 의도된 오버라이드가 아닌 한 모듈 identifier wrap 안에만 정의.
상세 병합 정책: [docs/extension/language-packs.md](language-packs.md#병합-정책--deep-merge-재귀-병합) / [docs/frontend/data-binding-i18n.md](../frontend/data-binding-i18n.md#병합-정책--deep-merge-재귀-병합)
---
## $partial Fragment 시스템
+52 -1
View File
@@ -294,7 +294,10 @@ plugins/_bundled/sirsoft-payment/
├── plugin.json # 메타데이터 (이름, 버전, 설명 등 SSoT)
├── plugin.php # PluginInterface 구현 (루트)
├── LICENSE # 라이선스 전문 (MIT)
├── composer.json # PSR-4 오토로딩 + 외부 패키지 의존성
├── composer.json # PSR-4 오토로딩 + 외부 패키지 의존성 (Git 추적)
├── composer.lock # Composer 락 파일 (Git 추적 — vendor-bundle.json 의 composer_lock_sha256 가 이 파일을 검증)
├── vendor-bundle.json # 번들 메타파일: SHA256, 패키지 목록 (Git 추적)
├── vendor-bundle.zip # 압축된 vendor 디렉토리 (Git 추적)
├── vendor/ # Composer 의존성 (자동 생성, gitignore 대상)
├── upgrades/ # 버전 업그레이드 스텝 (AbstractUpgradeStep 상속 — g7_version >= 7.0.0-beta.5 플러그인 의무)
│ ├── Upgrade_1_1_0.php # 1.1.0 버전 업그레이드 스텝 (extends AbstractUpgradeStep)
@@ -348,6 +351,54 @@ plugins/_bundled/sirsoft-payment/
---
## 서비스 프로바이더 작성
모든 플러그인 ServiceProvider 는 `App\Extension\BasePluginServiceProvider` 를 상속한다. Laravel 기본 `ServiceProvider` 를 직접 상속하면 Repository / Storage / Cache 자동 바인딩과 다국어 로드를 직접 구현해야 하며, 글로벌 컨테이너 바인딩 누수 결함의 위험이 발생한다.
### 표준 구조
```php
namespace Plugins\Vendor\Foo\Providers;
use App\Extension\BasePluginServiceProvider;
use Plugins\Vendor\Foo\Repositories\FooRepository;
use Plugins\Vendor\Foo\Repositories\FooRepositoryInterface;
use Plugins\Vendor\Foo\Services\FooService;
class FooPluginServiceProvider extends BasePluginServiceProvider
{
/** 플러그인 식별자 (manifest 와 일치) */
protected string $pluginIdentifier = 'vendor-foo';
/** Repository 인터페이스 ↔ 구현체 매핑 */
protected array $repositories = [
FooRepositoryInterface::class => FooRepository::class,
];
/** CacheInterface 가 필요한 서비스 (contextual binding) */
protected array $cacheServices = [
FooService::class,
];
/** StorageInterface 가 필요한 서비스 (contextual binding) */
protected array $storageServices = [
// FooFileService::class,
];
}
```
`register()` / `boot()` 오버라이드가 필요한 경우 `parent::register()` / `parent::boot()` 를 먼저 호출한 뒤 추가 로직을 작성한다.
### 금지 사항
- **글로벌 `CacheInterface` / `StorageInterface` 바인딩 재정의 금지** — `$this->app->singleton(CacheInterface::class, ...)` 또는 `app()->bind(StorageInterface::class, ...)` 는 코어/타 확장 도메인을 누수시킨다. audit 룰 `extension-no-global-cache-rebind` 가 자동 차단한다.
- **인라인 contextual binding 복제 금지** — `$this->app->when([X::class])->needs(StorageInterface::class)->give(...)` 패턴을 직접 작성하지 말고 `$storageServices` 배열에 등록한다.
- **Listener 의 동적 인스턴스 생성에서 `app(CacheInterface::class)` 직접 호출 금지** — `app()->makeWith(MyService::class, [...])` 로 컨테이너 해석에 위임해야 contextual binding 이 적용된다.
상세: [cache-driver.md `## 확장에서 cache 바인딩`](cache-driver.md)
---
## 플러그인 설정 시스템
플러그인이 관리자 설정 UI를 제공하려면 `AbstractPlugin`을 상속하고 설정 관련 메서드를 구현합니다.
+42 -1
View File
@@ -223,7 +223,15 @@ public function restoreVersion(int $layoutId, int $version): TemplateLayout
"assets": {
"css": ["dist/bundle.css"],
"js": ["dist/bundle.js"]
}
},
"externals": [
{
"id": "fontawesome",
"type": "style",
"url": "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css",
"preconnect": "https://cdnjs.cloudflare.com"
}
]
}
```
@@ -239,6 +247,39 @@ public function restoreVersion(int $layoutId, int $version): TemplateLayout
| `type` | "admin" 또는 "user" |
| `locales` | 지원 언어 배열 (`config('app.supported_locales')`와 매칭) |
### 외부 리소스 (externals)
`externals`는 admin/user 템플릿에 공통 적용되는 선택 필드입니다. 최초 HTML 문서에 정적으로 필요한 외부 스타일, 웹폰트, 스크립트, 리소스 힌트를 선언합니다. `external_styles`는 사용하지 않습니다.
| 속성 | 타입 | 필수 | 적용 type | 허용값/형식 | 렌더링/동작 |
|---|---:|---:|---|---|---|
| `id` | string | 권장 | all | 영문/숫자/`-`/`_` | HTML `id` 속성 |
| `type` | string | 필수 | all | `style`, `webfont`, `script`, `preconnect`, `dns-prefetch`, `preload`, `modulepreload` | 출력 태그와 위치 결정 |
| `url` | string | 필수 | all | `https://...` | link 계열은 `href`, script는 `src` |
| `preconnect` | string | 선택 | `style`, `webfont`, `script`, `preload`, `modulepreload` | `https://cdn.example.com` | 리소스보다 먼저 `<link rel="preconnect">` 출력, 중복 제거 |
| `crossorigin` | boolean/string | 선택 | `style`, `webfont`, `script`, `preconnect`, `preload`, `modulepreload` | `true`, `anonymous`, `use-credentials` | `true`는 `anonymous`로 정규화 |
| `integrity` | string | 선택 | `style`, `webfont`, `script`, `preload`, `modulepreload` | SRI hash | HTML `integrity` |
| `referrerpolicy` | string | 선택 | `style`, `webfont`, `script`, `preload`, `modulepreload` | 표준 referrer policy | HTML `referrerpolicy` |
| `media` | string | 선택 | `style`, `webfont` | CSS media query | stylesheet link의 `media` |
| `position` | string | 선택 | `script` | `head`, `before-core`, `before-template`, `body-end` | script 삽입 위치, 기본 `before-template` |
| `async` | boolean | 선택 | `script` | `true`, `false` | `async`; `defer`와 동시 사용 불가 |
| `defer` | boolean | 선택 | `script` | `true`, `false` | `defer` |
| `as` | string | `preload` 필수 | `preload` | `style`, `script`, `font`, `image`, `fetch` 등 | preload의 `as` |
| `mimeType` | string | 선택 | `preload`, `modulepreload` | MIME type | HTML `type` |
| `fetchpriority` | string | 선택 | `preload`, `modulepreload` | `high`, `low`, `auto` | HTML `fetchpriority` |
| `type` | 출력 위치 | HTML 결과 |
|---|---|---|
| `style` | head | `<link rel="stylesheet" href="...">` |
| `webfont` | head | `<link rel="stylesheet" href="...">` |
| `script` | `position` 기준 | `<script src="..."></script>` |
| `preconnect` | head 상단 | `<link rel="preconnect" href="...">` |
| `dns-prefetch` | head 상단 | `<link rel="dns-prefetch" href="...">` |
| `preload` | head | `<link rel="preload" href="..." as="...">` |
| `modulepreload` | head | `<link rel="modulepreload" href="...">` |
`externals`는 페이지 최초 진입에 항상 필요한 리소스만 선언합니다. 라우트별 조건부 스크립트나 액션 실행 중 동적 로딩은 layout `scripts`와 `loadScript` 책임입니다.
---
## 에러 페이지 설정 (error_config)
+43 -1
View File
@@ -47,6 +47,7 @@
```
Phase 1: 필수 메타데이터
□ template.json 생성 (error_config 포함)
□ template.json externals 점검 (icon 컴포넌트 사용 시 Font Awesome 항목 필수)
□ routes.json 생성 (layout_name 필수)
Phase 2: 레이아웃 파일
@@ -126,7 +127,15 @@ Phase 6: 빌드 및 설치
"403": "403",
"500": "500"
}
}
},
"externals": [
{
"id": "fontawesome",
"type": "style",
"url": "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css",
"preconnect": "https://cdnjs.cloudflare.com"
}
]
}
```
@@ -136,6 +145,15 @@ Phase 6: 빌드 및 설치
- `locales`: 지원 언어 배열
- `error_config.layouts`: 404, 403, 500 매핑
**`externals` (선택, 단 icon 사용 시 사실상 필수)**:
- 최초 HTML 문서에 정적으로 필요한 외부 스타일·웹폰트·스크립트를 선언한다.
- `components.basic` 에 `icon` 을 등록하면 Icon 컴포넌트가 생성하는
`<i class="fas fa-...">` 를 렌더링할 Font Awesome CSS 가 반드시 필요하므로
위 `fontawesome` 항목을 함께 둔다. 누락 시 활성 템플릿에서 아이콘이 표시되지
않는다. icon 을 쓰지 않으면 `"externals": []` 로 두거나 필드를 생략한다.
- 속성/타입 상세: [template-basics.md "외부 리소스 (externals)"](template-basics.md)
### 1.1.1 hidden 필드 (선택)
`template.json` 에 `"hidden": true` 를 설정하면 관리자 UI 의 템플릿 목록에서 기본 제외됩니다. 학습용 샘플 템플릿, 내부 운영 전용 템플릿을 일반 사용자에게 감출 때 사용합니다.
@@ -471,6 +489,30 @@ export type HandlerName = keyof typeof handlerMap;
@tailwind utilities;
```
### 4.4 레이아웃 편집기 확장점 등록 (선택)
템플릿이 자기 컴포넌트의 커스텀 속성 위젯(예 아이콘 피커)·노드 에디터·캔버스 인플레이스 오버레이를 레이아웃 편집기에 추가하려면 `G7Core.layoutEditor` 확장점에 등록한다. 등록은 `src/index.ts` **최상위(module-load)에서 호출**한다 — `initTemplate`/`window.load` 게이트 안에 두면 편집기 직접 진입 시 위젯이 누락되는 회귀가 발생한다(코어 stub 예약 접수함이 큐로 보존하므로 module-load 시점 등록이 안전).
```typescript
import { registerMyTemplateEditorWidgets } from './layout-editor/registerEditorWidgets';
// index.ts 최상위 — initTemplate/window.load 밖
registerMyTemplateEditorWidgets();
```
```typescript
// layout-editor/registerEditorWidgets.ts
export function registerMyTemplateEditorWidgets(): void {
if (typeof window === 'undefined') return;
const le = (window as any).G7Core?.layoutEditor;
if (!le?.registerWidget) return; // stub 부재 → 큐가 보존
le.registerWidget('icon-picker', IconPickerWidget);
le.registerCanvasOverlay?.('tabnav', TabNavInplaceOverlay);
}
```
확장점 API·등록 시점·작성 규약 상세: [editor-spec.md "편집기 확장점"](editor-spec.md). identity launcher 등록(`G7Core.identity.setLauncher`)과 동일한 옵셔널 체이닝 가드 패턴이다.
---
## Phase 5: 다국어 파일
+11 -1
View File
@@ -528,7 +528,17 @@ CoreUpdateCommand::handle └─ disableMaintenanceMode
- `--skip-cache-clear`
- `--skip-bundled-updates`
수동 복구 시나리오에서 사용자가 부분 단계만 제어하고 싶다면 위 옵션을 선택적으로 조합한다. 옵션을 모두 부여하면 부모 spawn 시나리오와 등가 — 본 명령은 upgrade step 만 실행한다.
수동 복구 시나리오에서 사용자가 부분 단계만 제어하고 싶다면 위 옵션을 선택적으로 조합한다.
##### `--steps-only` — 업그레이드 스텝만 실행
`--steps-only` 는 위 5개 `--skip-*` 옵션이 제어하는 단계뿐 아니라 **권한 정상화(`ensureWritableDirectories`) · 오토로드 재생성(`updateComposerAutoload`)** 까지 — 즉 upgrade step 을 제외한 모든 보조 단계를 생략한다.
```bash
php artisan core:execute-upgrade-steps --from=<v> --to=<v> --force --steps-only
```
`--skip-*` 5개만으로는 spawn 자식 진입 블록의 `ensureWritableDirectories` 가 무조건 실행된다. 이 블록은 `modules` / `plugins` / `templates` 등 활성 디렉토리를 재귀 `chown`/`chmod` 하므로, `vendor/` · `node_modules/` 가 포함된 대규모 트리에서는 매우 느리다. 보조 단계가 이미 정상인 환경에서 특정 스텝의 데이터 보정만 단발 재실행할 때 `--steps-only` 로 이 비용을 건너뛴다.
### 사용 시점
+3 -1
View File
@@ -28,6 +28,8 @@ G7 코어와 모듈/플러그인은 PHP 의존성 관리에 Composer를 사용
└── vendor-bundle.json (메타파일: SHA256, 패키지 목록 등)
```
이 네 파일(`composer.json` / `composer.lock` / `vendor-bundle.json` / `vendor-bundle.zip`)은 코어 루트 및 각 `_bundled` 확장 루트에 함께 존재하며 모두 Git 으로 추적한다. 추출 결과인 `vendor/` 디렉토리만 `.gitignore` 대상이다. `composer.lock` 이 누락되면 `VendorIntegrityChecker` 가 `composer_lock_sha256` 을 검증할 수 없으므로, `composer update` 직후 생성된 `composer.lock` 과 재빌드된 번들 두 파일을 같은 커밋에 함께 포함한다.
`vendor-bundle.json` 스키마 (v1.0):
```json
@@ -271,5 +273,5 @@ CI 통합 예시:
## 11. 참고
- 본 문서는 vendor 번들 시스템의 사용자 가이드입니다.
- Bundle 자체는 .gitignore 에 등록되어 Git 추적되지 않습니다 (Release asset 형태로 배포 권장).
- 코어 및 번들 확장의 `composer.json` / `composer.lock` / `vendor-bundle.json` / `vendor-bundle.zip` 은 Git 으로 추적합니다. 추출 결과인 `vendor/` 디렉토리만 `.gitignore` 대상입니다.
- 확장 설치 모드는 `modules.vendor_mode` / `plugins.vendor_mode` 컬럼에 기록되며, 업데이트 시 자동 상속됩니다.
+25 -1
View File
@@ -50,11 +50,35 @@ API를 호출합니다. **주의: `api`가 아닌 `apiCall`을 사용해야 합
|------|------|--------|------|
| `auth_required` | boolean | false | true이면 Bearer 토큰을 Authorization 헤더에 포함 (401 에러 발생 시 로그인 페이지 리다이렉트) |
| `auth_mode` | string | "required" | 인증 모드: `"required"` (토큰 없으면 에러), `"optional"` (토큰 있으면 포함, 없어도 진행) |
| `identity_target` | object | — | 본인인증(IDV) 대상. 이 apiCall이 HTTP 428(본인인증 필요)을 받으면 인증 코드/링크를 보낼 `{ email?, phone? }` 을 흐름이 직접 선언 (engine-v1.51.0+) |
```text
중요: auth_required, auth_mode는 params 안이 아닌 액션 정의 최상위에 선언해야 합니다.
중요: auth_required, auth_mode, identity_target은 params 안이 아닌 액션 정의 최상위에 선언해야 합니다.
```
#### identity_target 사용 예시
본인인증 정책이 켜진 상태에서 비로그인(게스트) 사용자가 호출하는 API는, 인증 코드를 보낼 대상(이메일·전화)을 화면 입력값에서 선언해야 합니다. 서버는 428을 던지는 시점에 사용자가 방금 화면에 입력한 값을 알 수 없으므로, 대상 수집은 레이아웃의 책임입니다.
```json
{
"handler": "apiCall",
"target": "/api/modules/sirsoft-ecommerce/user/orders",
"auth_mode": "optional",
"identity_target": {
"email": "{{_local.orderer?.email || ''}}",
"phone": "{{_local.orderer?.phone || _local.shipping?.recipient_phone || ''}}"
},
"params": { "method": "POST", "body": { } }
}
```
- `email` / `phone` 둘 중 하나만 있어도 충분합니다 (서버가 둘 다 허용).
- 우선순위는 표현식 자체가 결정합니다 (예: 주문자 정보 우선 → 수취인 정보 폴백).
- 표현식은 `|| ''` fallback으로 빈 문자열을 보장합니다 (undefined 방지).
- 로그인 사용자는 빈 값이어도 서버가 세션에서 대상을 도출하므로 무방합니다.
- `G7Core.api`(axios) 직접 호출 경로에서는 호출 config에 `identity_target` 을 실어 동일하게 동작합니다.
#### auth_mode 사용 예시
비회원도 접근 가능하지만, 로그인 시 추가 기능을 제공하는 API:
+13 -2
View File
@@ -173,10 +173,21 @@ Pro 버전 아이콘은 라이선스가 필요하므로 사용할 수 없습니
### 사용 가능한 아이콘 소스
```html
<!-- CDN 버전 -->
https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css
<!-- template.json externals 선언 -->
{
"externals": [
{
"id": "fontawesome",
"type": "style",
"url": "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css",
"preconnect": "https://cdnjs.cloudflare.com"
}
]
}
```
Font Awesome은 컴포넌트에서 직접 import하지 않고, admin/user 템플릿의 `template.json` `externals`에 선언해 Blade가 최초 HTML에서 로드합니다.
### 아이콘 사용 방법
#### Icon 컴포넌트 사용 (권장)
+54
View File
@@ -148,6 +148,60 @@ Input 컴포넌트는 한글 등 IME(Input Method Editor) 조합 입력을 올
4. **HTML 태그 직접 사용 금지**: `<div>`, `<button>` 등 HTML 태그 직접 사용 불가
5. **집합 컴포넌트 재사용 우선**: 새로운 집합 컴포넌트 개발 시 기존 집합 컴포넌트를 재사용할 수 있는지 우선 검토
6. **Props 기본값 참조 안정성 필수**: 배열/객체 기본값은 모듈 레벨 상수 사용 (무한 렌더 루프 방지)
7. **`editorAttrs` 패스스루 필수** (레이아웃 편집기 nesting 대상): editor-spec `nesting.draggable` 에 등재된 composite/layout 컴포넌트는 `editorAttrs?: EditorAttrs` 를 받아 **시각적 루트 요소에 `{...editorAttrs}` 로 spread** 해야 한다 (§ "editorAttrs 패스스루" 참조)
### editorAttrs 패스스루 (레이아웃 편집기 nesting 컴포넌트)
편집 모드에서 코어 `DynamicRenderer` 는 각 컴포넌트에 `data-editor-*` 표식(드롭 슬롯/선택/드래그 DOM 쿼리용)과 선택/hover 핸들러를 **단일 `editorAttrs` 객체**로 주입한다. composite/layout 컴포넌트는 도메인 prop 만 명시 구조분해하고 미명시 props 를 DOM 으로 흘리지 않으므로, `editorAttrs` 를 받아 루트에 spread 하지 않으면 그 노드가 편집기에서 누락된다(편집기는 `[data-editor-path]` DOM 쿼리로 동작 — 컨테이너 노드가 누락되면 자식을 컨테이너 밖으로 옮기는 드롭 슬롯이 생성되지 않음).
```tsx
import type { EditorAttrs } from '../../types';
export interface FooProps {
/* ...도메인 prop 전부 명시 구조분해... */
editorAttrs?: EditorAttrs; // 편집기 주입 속성 (편집 모드 전용)
}
export const Foo = ({ /* 도메인 prop */, editorAttrs }: FooProps) => (
<Div className={...} {...editorAttrs}>{children}</Div> // 시각적 루트에 spread
);
```
| 구분 | editorAttrs 수신 | 이유 |
|------|------------------|------|
| basic (`{...props}` 패스스루) | 불필요 | 코어가 주입한 개별 `data-editor-*` 키가 `{...props}` 로 DOM 도달 |
| composite / layout | **필수** | 도메인 prop 만 구조분해 → 개별 키 유실 → `editorAttrs` 명시 수신 + 루트 spread 필요 |
| 서드파티 모달 / Portal | 면제 | interface 주석에 `editorAttrs 패스스루 — 모달 예외` 명기 |
- 사용자 페이지(비편집)에서는 `editorAttrs` 미주입 → `{...editorAttrs}` 가 no-op → DOM 구조/속성 불변 (프리뷰 ↔ 사용자 페이지 패리티 유지).
- `editorAttrs` 만 spread 하므로 도메인 prop 누출/HTML 동명 prop 타입 충돌이 없다. (`React.HTMLAttributes` 상속 + `{...rest}` 방식은 도메인 prop 누출 위험으로 채택 안 함.)
- 자동 검출: audit 룰 `editor-attrs-passthrough` (nesting.draggable 의 composite/layout 컴포넌트가 `editorAttrs` 미수신 시 error). 의도적 면제는 파일 헤더 `// editor-attrs:allow <사유>`.
### 요소 id 패스스루 (코어 일괄 ID)
레이아웃 편집기 코어는 모든 draggable 컴포넌트의 [속성] 탭 최상단에 "요소 ID" 컨트롤을 일괄 제공한다(값 = 표준 `node.props.id`, 코어는 강제 DOM 주입 안 함). 따라서 draggable 컴포넌트는 `editorAttrs` 와 별개로 **`id` prop 을 받아 시각적 루트에 전달**해야 그 id 가 실제 DOM 에 닿는다.
```tsx
export interface FooProps {
id?: string; // DOM id (코어 일괄 ID)
// ... 도메인 prop ...
editorAttrs?: EditorAttrs;
}
export const Foo = ({ id, /* 도메인 prop */, editorAttrs }: FooProps) => (
<Div id={id} className={...} {...editorAttrs}>{children}</Div> // id 를 루트에 명시 전달
);
```
| 구분 | id 수신/전달 | 비고 |
|------|------------|------|
| basic (`{...props}`/`{...validProps}` 패스스루) | 불필요 | id 가 props 스프레드로 DOM 도달 |
| composite / layout (도메인 prop 만 구조분해) | **필수** | `id?: string` 수신 + 루트에 `id={id}` 명시 전달 |
| 서드파티 모달 / Portal (인라인 루트 없음) | 면제 + **opt-out** | 인라인 DOM 이 없어 id 부착 불가 → 그 컴포넌트 capability 에 `"coreProps": false` 선언(코어 id 컨트롤 미노출). 예: ImageGallery(Lightbox) |
- 여러 렌더 분기(variant 별 root)가 있으면 **모든 root 분기**에 `id={id}` 를 전달한다(누락 분기는 그 variant 에서 id 미반영).
- id 값이 이미 `{{바인딩}}` 이거나 컴포넌트/핸들러가 동적 id 를 쓰면, 코어 id 컨트롤은 "바인딩됨(코드 편집)" 디그레이드로 표시해 덮어쓰기를 차단한다(작성자 정적 값만 편집).
- 코어 id 컨트롤은 HTML 안전 문자(영문자/숫자/`-`/`_`/`:`/`.`)만 허용하고 한글·공백 등은 자동 제거한다(`sanitizeElementId`).
### 올바른 패턴
+6
View File
@@ -140,6 +140,12 @@ import { Icon, IconName } from '../basic/Icon';
→ [상세 문서](components-advanced.md#컴포넌트-개발-체크리스트)
### 레이아웃 편집기 capability 선언 의무
새 draggable 컴포넌트(레이아웃 편집기 팔레트/캔버스에 노출)는 위 4개 파일에 더해 호스트 템플릿 `editor-spec/componentCapabilities.json` 에 capability 를 선언해야 편집기에서 "편집 불가(no-editable)"가 되지 않는다. 표시 텍스트/아이콘/select/목록·배열·표 데이터 prop 별로 `propControls`(속성 탭) / `dataProps`(데이터 연결) / `nodeEditor`·`canvasOverlay`(구조 에디터) / `styleControls`(스타일 탭) / `events`(동작 탭) 를 선언한다. 데이터 표면을 가진 컴포넌트가 편집 슬롯도 비대상 allowlist 도 없으면 audit `editor-all-draggable-data-editable` 가 차단한다. composite/layout 컴포넌트는 `editorAttrs` spread + `id` 패스스루도 필요하다.
상세: [editor-spec.md "componentCapabilities"](../extension/editor-spec.md) · [components-types.md "편집기 attribute 패스스루"](components-types.md)
---
## 관련 문서
+32 -5
View File
@@ -378,9 +378,27 @@ $t:defer:는 renderItemChildren 내부에서만 처리됨
### 병합 우선순위
1. 템플릿 언어 데이터 (기본)
2. 모듈 언어 데이터 (모듈 identifier를 키로 추가)
3. 플러그인 언어 데이터 (플러그인 identifier를 키로 추가)
1. 코어 (`lang/{locale}.json` + partial) — 베이스 레이어
2. 템플릿 언어 데이터
3. 모듈 언어 데이터 (모듈 identifier를 키로 추가)
4. 플러그인 언어 데이터 (플러그인 identifier를 키로 추가)
5. 활성 언어팩 (`template.language.merge` filter 훅 — 가장 높은 우선순위)
### 병합 정책 — Deep Merge (재귀 병합)
5개 레이어는 `array_merge` 가 아닌 재귀 deep merge (`deepMergeLanguageData`) 로 합쳐진다. 동일 top-level 키 (`layout_editor`, `core`, `auth` 등) 의 하위 트리를 leaf 까지 내려가며 병합하고, 어느 한쪽이 다른 레이어의 부분 트리만 정의해도 나머지 형제 키가 보존된다.
- assoc 트리 ↔ assoc 트리 충돌 → 재귀 병합 (양쪽 leaf 모두 보존, 동일 leaf 는 뒤가 우선)
- list / scalar / 한쪽만 array → 뒤 입력으로 덮어쓰기
- 우선순위(낮음 → 높음): 코어 < 템플릿 < 모듈 < 플러그인 < 활성 언어팩
확장 작성자 영향:
- 코어 도메인 네임스페이스 안에 자신의 sub-key 만 정의해도 안전. 예: 템플릿이 `layout_editor.palette` 만 정의해도 코어의 `layout_editor.chrome / device / zoom / preview / save` 가 살아남는다
- 코어 leaf 를 의도적으로 오버라이드하려면 동일 키 경로에 leaf 만 정의 (트리 교체 의도가 아니라 leaf override 의도임을 코드로 표현)
- 모듈/플러그인은 식별자 wrap (`module.{id}.*` / `plugin.{id}.*`) 사용을 우선 — 코어 도메인과 충돌할 일이 없고 deep merge 부작용도 회피
배경: 과거 shallow `array_merge` 는 동일 top-level 키 시 트리 전체가 교체되어, 템플릿이 한 sub-key 만 정의해도 코어의 다른 sub-key 가 통째 누락되는 결함이 발생. 상세 정책 / 회귀 사례는 [docs/extension/language-packs.md](../extension/language-packs.md#병합-정책--deep-merge-재귀-병합) 참조.
### 백엔드 구현 (`TemplateService`)
@@ -396,8 +414,17 @@ public function getLanguageDataWithModules(string $identifier, string $locale):
// 3. 활성 플러그인 언어 데이터 로드 및 병합
$pluginLangData = $this->loadActivePluginsLanguageData($locale);
// 4. 병합
$mergedData = array_merge($templateLangData, $moduleLangData, $pluginLangData);
// 4. 코어 자체의 프론트엔드 다국어 자원 로드 (베이스 레이어)
$coreLangData = $this->loadCoreFrontendLanguageData($locale);
// 5. Deep merge — assoc 트리 재귀 병합, list/scalar leaf 는 뒤가 우선
// 우선순위(낮음 → 높음): 코어 < 템플릿 < 모듈 < 플러그인
$mergedData = $this->deepMergeLanguageData(
$coreLangData, $templateLangData, $moduleLangData, $pluginLangData
);
// 6. 활성 언어팩 filter 훅 (가장 높은 우선순위 — 코어/모듈/플러그인 모두 덮어쓸 수 있음)
$mergedData = HookManager::applyFilters('template.language.merge', $mergedData, $identifier, $locale);
return ['success' => true, 'data' => $mergedData, 'error' => null];
}
+1
View File
@@ -84,6 +84,7 @@
| 필드 | 타입 | 필수 | 기본값 | 설명 |
|------|------|------|--------|------|
| `id` | string | ✅ | - | 데이터 소스 고유 ID (컴포넌트에서 `{{id.data}}` 형태로 참조) |
| `label_key` | string | ❌ | - | 친화 명칭 `$t:` 다국어 키 (예 `$t:editor.data_source.products`). 레이아웃 편집기 데이터 연결 검색 피커가 현재 로케일 명칭으로 표시. 미지정 시 `id` 폴백. 번들 템플릿은 전 data_source 보유 의무(audit `data-source-label-key-coverage`) |
| `type` | string | ✅ | - | 데이터 소스 타입 (`api`, `static`, `route_params`, `query_params`, `websocket`) |
| `endpoint` | string | ✅* | - | API 엔드포인트 (type이 `api`일 때 필수) |
| `method` | string | ❌ | `GET` | HTTP 메서드 (`GET`, `POST`, `PUT`, `PATCH`, `DELETE`) |
+6 -1
View File
@@ -10,6 +10,7 @@
3. launcher 반환 타입 = Promise<VerificationResult> (engine-v1.46.0+)
4. handle() 가 verified 시 return_request.url 에 ?verification_token=... query 자동 부착 후 fetch
5. defaultLauncher — launcher 미등록 시 토스트 + /identity/challenge?return=... navigate 폴백
6. 인증 대상(email/phone)은 흐름이 apiCall `identity_target` 으로 선언 → handle() 이 payload.target 에 병합 → launcher 가 사용 (engine-v1.51.0+)
```
## 정적 API 전체
@@ -23,7 +24,11 @@ class IdentityGuardInterceptor {
// 응답 감지
static isIdentityRequired(status: number, body: unknown): body is IdentityResponse428
static async handle(response: IdentityResponse428): Promise<Response | null>
static async handle(
response: IdentityResponse428,
originalRequest?: Pick<RequestInit, 'body' | 'headers' | 'credentials'>,
target?: IdentityVerificationTarget, // engine-v1.51.0+ — 흐름이 선언한 인증 대상
): Promise<Response | null>
// Deferred resolver — 모달 ↔ launcher Promise 통신
static createDeferred(): Promise<VerificationResult>
+30 -8
View File
@@ -53,19 +53,23 @@
## Extension Point 슬롯 명명 규칙
코어 모달 파셜(`_identity_challenge_modal.json`)에 다음 슬롯이 박혀있어 외부 IDV provider 플러그인이 자기 UI 와 SDK 를 주입할 수 있습니다:
코어 모달 파셜(`_identity_challenge_modal.json`)에 외부 IDV provider 플러그인이 자기 UI 와 SDK 를 주입할 수 있는 **단일 슬롯**이 박혀 있습니다:
| 슬롯 이름 | 용도 | 기본 콘텐츠 |
| 슬롯 이름 | 용도 | if 가드 |
| --- | --- | --- |
| `identity_provider_ui:text_code` | OTP 코드 입력 UI 슬롯 | 코어 default — 6자리 코드 입력 + 카운트다운 |
| `identity_provider_ui:link` | 링크 안내 UI 슬롯 | 코어 default — 메일 안내 + 스팸함 점검 안내 |
| `identity_provider_ui:provider` | provider 별 SDK 주입 슬롯 | 비어있음 (플러그인 미주입 시 렌더 없음) |
| `identity_provider_ui:provider` | 외부 IDV provider UI 주입 단일 슬롯 | `provider_id` 가 truthy 이면서 코어 mail 이 아닐 때만 마운트 |
플러그인은 다음 우편번호 / CKEditor5 와 동일한 G7 표준 패턴(`scripts` + `extension_point` + `callExternalEmbed`)으로 자기 콘텐츠를 주입합니다 — 코어 신규 인프라 도입 불필요.
**코어 mail / provider 미지정 케이스의 OTP 입력 / 링크 안내 UI 는 모달 파셜의 plain Div** (Extension Point 아님) 로 정의되어 있습니다. 즉 외부 plugin 이 `mode: replace` 를 써도 코어 default UI 가 사라지지 않습니다.
### 외부 plugin 가 슬롯에 콘텐츠 주입
플러그인은 다음 우편번호 / CKEditor5 와 동일한 G7 표준 패턴(`scripts` + `extension_point` + `callExternalEmbed`)으로 자기 콘텐츠를 주입합니다. **반드시 `mode: append`** 를 사용하여 다른 IDV plugin 과 공존 가능하게 합니다.
```json
{
"extension_point": "identity_provider_ui:provider",
"mode": "append",
"priority": 100,
"scripts": [
{ "src": "https://kcp-cdn.example.com/sdk.js", "id": "kcp_sdk" }
],
@@ -94,6 +98,24 @@
}
```
### 코어 mail 케이스 가드 패턴 (모달 / 풀페이지 공통)
모달의 코어 OTP UI · 재전송 버튼 · 확인 버튼은 다음 가드 패턴으로 노출 분기:
```
if: "(!_global.identityChallenge?.provider_id || _global.identityChallenge?.provider_id === 'g7:core.mail')"
```
코어 default provider (`g7:core.mail`) 가 환경설정의 default_provider 로 자동 채워질 수 있으므로 (정책 NULL provider_id fallback), `!provider_id` 만으로 가드하면 mail 인증 시 버튼/UI 가 사라지는 회귀가 발생합니다. 반드시 `|| === 'g7:core.mail'` 을 함께 검사할 것.
### 잘못된 패턴 (DO NOT)
| ❌ 금지 | ✅ 올바른 사용 |
| --- | --- |
| `extension_point: "identity_provider_ui:text_code"` | `extension_point: "identity_provider_ui:provider"` — text_code/link 슬롯은 plain Div 로 이동했으므로 외부 plugin 은 provider 슬롯만 사용 |
| `mode: "replace"` | `mode: "append"` — replace 는 다른 plugin 의 UI 까지 잠식하여 공존 불가 |
| `if: "{{!_global.identityChallenge?.provider_id}}"` (재전송/확인 버튼) | `if: "{{!provider_id || provider_id === 'g7:core.mail'}}"` — default fallback 으로 mail 이 채워지는 케이스 대비 |
## `_global.identityChallenge` 네임스페이스 스키마 (CONTRACT)
본인인증 흐름의 모든 상태는 `_global.identityChallenge.*` 한 네임스페이스로 일원화합니다. launcher 가 모달 open 직전에 이 객체를 set 하고, 모달의 모든 액션이 이 경로를 읽고/씁니다. 외부 IDV 프로바이더 플러그인이 자기 launcher 를 작성하는 경우에도 **이 스키마를 그대로 준수**해야 모달 / 풀페이지 / 재전송 / 카운트다운이 일관되게 동작합니다.
@@ -107,14 +129,14 @@
| `challenge_id` | string | challenge 응답 | verify / cancel API 의 path param |
| `expires_at` | ISO8601 string | challenge 응답 | 카운트다운 만료 기준 |
| `public_payload` | object | challenge 응답 | 코드 길이 / 링크 힌트 등 provider 가 공개한 메타 |
| **`target`** | **`{ email?, phone? } \| null`** | **launcher 가 폼/세션에서 추출** | **모달 재전송 시 동일 target 으로 challenge 재요청 — 누락 시 백엔드 422 `missing_target`** |
| **`target`** | **`{ email?, phone? } \| null`** | **흐름이 apiCall `identity_target` 으로 선언 → launcher 가 payload.target 에서 사용 (없으면 로그인 세션 폴백)** | **모달 재전송 시 동일 target 으로 challenge 재요청 — 누락 시 백엔드 422 `missing_target`** |
| `code` | string | 모달 입력 | text_code 모드 OTP 입력값 |
| `error` | string \| null | 모달 onError | 사용자에게 보일 에러 메시지 |
| `attempts` / `maxAttempts` | number | 모달 onError / launcher 초기값 | 시도 횟수 표시 + verify 버튼 비활성화 조건 |
| `remainingSeconds` | number | launcher 카운트다운 | 분/초 표시 + verify 버튼 비활성화 조건 |
| `resendCooldown` | number | 모달 재전송 onSuccess / launcher 카운트다운 | 재전송 버튼 비활성화 조건 (30초) |
> 🛑 `target` 필드 누락은 흔한 회귀입니다. launcher 가 첫 challenge 시작에 사용한 target 을 반드시 같은 객체에 저장하세요 — 모달 재전송 액션이 다른 곳에서 폼 값을 읽을 수 없습니다(모달 컨텍스트는 페이지 _local 과 분리).
> 🛑 `target` 필드 누락은 흔한 회귀입니다. 비로그인(게스트) 흐름은 428 을 유발하는 apiCall 에 `identity_target` 을 선언해야 합니다(engine-v1.51.0+) — 서버 428 payload 에는 target 이 없고(서버는 화면 입력값을 모름) 흐름이 선언해야 launcher 가 받습니다. launcher 는 첫 challenge 시작에 사용한 target 을 같은 객체(`identityChallenge.target`)에 저장하세요 — 모달 재전송 액션이 다른 곳에서 폼 값을 읽을 수 없습니다(모달 컨텍스트는 페이지 _local 과 분리). 로그인 사용자는 선언이 없어도 서버 세션이 도출합니다.
### launcher 가 채워야 하는 필드 vs 모달이 갱신하는 필드
@@ -606,6 +606,8 @@ iteration 내부에서 상태를 관리하는 컴포넌트(Input, Select, TagInp
- id가 없으면 동적으로 행 추가/삭제 시 이전 인스턴스의 내부 상태가 새 인스턴스에 표시될 수 있음
- 특히 내부 상태를 관리하는 컴포넌트(TagInput, Select, Input 등)에서 중요
> **iteration id 표현식 보간** (engine-v1.52.4+): 노드의 최상위 `id` 필드에 쓴 표현식(`id: "item_{{$idx}}"`, `id: "row_{{item.id}}"` 등)은 일반 렌더 경로(DynamicRenderer)에서 iteration 컨텍스트로 보간되어 행마다 고유 DOM id 가 된다. `index_var`(예 `"index_var": "$idx"`) 또는 `item_var` 의 필드를 활용한다. 정적 id 를 iteration 안에 그대로 두면 모든 행이 같은 HTML id 를 갖게 되어 W3C id 유일성을 위반하므로(렌더 누락까지 유발), 반복 항목 노드와 그 자식의 id 는 반드시 동적화한다. 내부 식별(slot 등록·React key)에는 원본 id 가 쓰이므로 보간은 DOM 출력에만 적용된다.
---
## 정렬 가능 렌더링 (sortable)
+1 -1
View File
@@ -77,7 +77,7 @@
| `version` | string | ✅ | 스키마 버전 (예: "1.0.0") |
| `layout_name` | string | ✅ | 레이아웃 식별자 (아래 네이밍 규칙 참조) |
| `meta` | object | ❌ | 메타 정보 (title, description) |
| `data_sources` | array | ✅ | API 데이터 소스 정의 |
| `data_sources` | array | ✅ | API 데이터 소스 정의 (각 항목에 `label_key` `$t:` 키 — 편집기 데이터 연결 피커 친화 명칭) |
| `init_actions` | array | ❌ | 초기화 액션 (레이아웃 로드 시 실행) |
| `modals` | array | ❌ | 모달 컴포넌트 정의 |
| `scripts` | array | ❌ | 외부 스크립트 동적 로드 (engine-v1.8.0+) |
+60
View File
@@ -563,6 +563,66 @@ export function initTemplate(): void {
---
## 레이아웃 편집기 스펙(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` 를 선언한다:
```json
"darkMode": {
"strategy": "ancestor-class",
"ancestorSelector": ".dark",
"previewIsolation": {
"rewriteSelector": ".dark",
"replaceWith": ".g7le-preview-dark",
"flattenLayers": true
}
}
```
코어 CSS 서빙 API(`/api/admin/templates/{id}/editor/components.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]`) — 코어는 선언값을 그대로 치환할 뿐 특정 라이브러리를 가정하지 않는다.
---
## 관련 문서
- [컴포넌트 개발 규칙](./components.md)
+31 -7
View File
@@ -141,10 +141,14 @@ class OrderActivityLogListenerTest extends TestCase
---
## 확장 마이그레이션 자동 로드 (requiredExtensions)
## 확장 격리 (requiredExtensions)
코어 테스트에서 확장(모듈/플러그인)의 DB 테이블이 필요한 경우, `$requiredExtensions` 프로퍼티를 선언합니다.
`RefreshDatabase`의 `migrate:fresh` 실행 시 해당 확장의 마이그레이션도 함께 실행됩니다.
코어 테스트는 기본적으로 확장(모듈/플러그인)이 격리된 상태로 실행됩니다.
테스트가 특정 확장을 필요로 하면 `$requiredExtensions` 프로퍼티로 명시합니다.
이 선언은 두 가지를 동시에 수행합니다:
1. **마이그레이션 로드** — `RefreshDatabase`의 `migrate:fresh` 실행 시 해당 확장의 마이그레이션도 함께 실행
2. **확장 로딩 allowlist** — testing 환경에서 명시한 확장의 ServiceProvider / route 만 등록 허용
```php
class ResourceAbilitiesTest extends TestCase
@@ -152,7 +156,7 @@ class ResourceAbilitiesTest extends TestCase
use RefreshDatabase;
/**
* sirsoft-marketing 플러그인이 User 리소스 훅에 개입하므로 마이그레이션 필요
* sirsoft-marketing 플러그인이 User 리소스 훅에 개입하므로 명시
*/
protected array $requiredExtensions = [
'plugins/sirsoft-marketing',
@@ -160,6 +164,21 @@ class ResourceAbilitiesTest extends TestCase
}
```
### 격리 동작 (allowlist)
`$requiredExtensions` 미선언 코어 테스트는 어떤 모듈/플러그인의 ServiceProvider 도
등록되지 않습니다. 이로써 한 확장의 전역 미들웨어(예: GDPR `CookieConsentMiddleware`)가
무관한 코어 테스트에 개입하는 것을 차단합니다.
- 확장의 ServiceProvider 바인딩 / route / 전역 미들웨어가 필요한 테스트는
그 확장을 `$requiredExtensions` 에 명시해야 합니다.
- 확장 자체 테스트(`ModuleTestCase` / `PluginTestCase` 상속)는 테스트 파일 경로로
자기 확장을 자동 탐지(`selfExtension()`)하므로 별도 선언이 불필요합니다.
다른 확장에 의존하는 경우(예: 이커머스 테스트가 결제 플러그인 필요)만 명시합니다.
- 격리는 testing 환경에서만 동작합니다 — 운영/개발 환경의 확장 로딩은 영향받지 않습니다.
- `ModuleManager::loadModules` / `PluginManager::loadPlugins`(hook listener / config /
채널 등록 경로)에는 격리가 적용되지 않습니다.
### 경로 형식
프로젝트 루트 기준 상대 경로로, `database/migrations` 디렉토리가 자동 추가됩니다:
@@ -172,10 +191,15 @@ class ResourceAbilitiesTest extends TestCase
### 동작 원리
`TestCase::setUpTraits()`에서 `RefreshDatabase::refreshDatabase()` 호출 전에 확장 마이그레이션 경로를 `migrator`에 등록합니다:
마이그레이션 등록과 allowlist 주입은 서로 다른 훅 시점에 일어납니다:
1. `setUpTraits()` → `loadExtensionMigrations()` (확장 경로 등록)
2. `parent::setUpTraits()` → `RefreshDatabase::refreshDatabase()` → `migrate:fresh` (코어 + 확장 함께 실행)
- **allowlist 주입** — `TestCase::setUp()` 최상단에서 `ExtensionTestAllowlist::set()` 호출.
`parent::setUp()`의 `createApplication()`(= 모든 ServiceProvider register/boot)보다
먼저 실행되어야 provider 가드가 올바른 시점에 적용됩니다.
- **마이그레이션 등록** — `TestCase::setUpTraits()`에서 `RefreshDatabase::refreshDatabase()`
호출 전에 확장 마이그레이션 경로를 `migrator`에 등록:
1. `setUpTraits()` → `loadExtensionMigrations()` (확장 경로 등록)
2. `parent::setUpTraits()` → `RefreshDatabase::refreshDatabase()` → `migrate:fresh` (코어 + 확장 함께 실행)
### 주의사항
+276
View File
@@ -0,0 +1,276 @@
# 그누보드7 Playwright E2E 테스트 가이드
## TL;DR (5초 요약)
```text
- 도구: Playwright 1.49+ (TypeScript, Vitest 와 동일 스택)
- 인증: PlaywrightIssueToken artisan 커맨드가 Sanctum 토큰 발급 (CLI + G7_PLAYWRIGHT_BYPASS=1 + APP_DEBUG 3중 가드)
- Base URL: PLAYWRIGHT_BASE_URL 환경변수 우선, .env APP_URL 차순위 (하드코딩 회피)
- 코어/확장 분리: 코어 = tests/Playwright/, 확장 = {확장 디렉토리}/tests/Playwright/
- 데이터 생성: 데이터를 소유한 영역에 시드 커맨드 배치 (코어 ↔ 모듈 의존 역전 회피)
- 외부 의존: mock-first 전략 (page.route() 로 결제창/외부 API 가로채기)
```
## §1. 도구 선택 정당화
| 기준 | Playwright | Laravel Dusk | Selenium | Claude MCP |
|---|---|---|---|---|
| Windows 11 + Apache + MySQL 호환 | ✅ 1급 | ChromeDriver 이슈 | 무거움 | ✅ |
| TypeScript (resources/js 동일 스택) | ✅ | PHP 전용 | 부진 | TS 아님 |
| Sanctum 토큰 fixture | `request.newContext()` | 가능 | 가능 | 가능 |
| 결정론적 | auto-wait + fixture isolation | 보통 | 보통 | ❌ (LLM 변동) |
| 개발자 학습 곡선 | ≈ 0 (Vitest 동일 언어) | PHP 별도 | 가파름 | 자연어 |
| CI/커밋 게이트 | ✅ | ✅ | ✅ | ❌ |
**선택**: Playwright (TypeScript). Vitest 와 동일 언어 → 학습 비용 0. Claude MCP 는 **디버깅 도구**로 보존.
## §2. 도구 설치 + 기본 사용법
```powershell
# 설치 (devDependency)
npm install -D @playwright/test
npx playwright install chromium
```
**`playwright.config.ts`** (코어 루트 — 모듈/플러그인/템플릿은 각자 위치):
```typescript
import { defineConfig, devices } from '@playwright/test';
function resolveBaseUrl(): string {
if (process.env.PLAYWRIGHT_BASE_URL) return process.env.PLAYWRIGHT_BASE_URL;
// .env 의 APP_URL (단 localhost 류 제외)
// 그 외 — Error
}
export default defineConfig({
testDir: './tests/Playwright/specs',
fullyParallel: true,
use: { baseURL: resolveBaseUrl(), trace: 'retain-on-failure', ignoreHTTPSErrors: true },
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});
```
**최소 spec 구조**:
```typescript
import { test, expect } from '@playwright/test';
test('@smoke 홈페이지 마운트', async ({ page }) => {
await page.goto('/');
await expect(page.getByTestId('nav-home')).toBeVisible({ timeout: 15_000 });
});
```
## §3. 코어/확장 분리 원칙 (CRITICAL)
```text
모듈/플러그인/템플릿 E2E spec 을 코어 디렉토리(tests/Playwright/) 에 작성 금지
✅ 모듈 E2E: modules/_bundled/{id}/tests/Playwright/specs/
✅ 플러그인 E2E: plugins/_bundled/{id}/tests/Playwright/specs/
✅ 템플릿 E2E: templates/_bundled/{id}/tests/Playwright/specs/
✅ 코어 E2E: tests/Playwright/specs/ (코어 엔진/관리자 API 검증만)
```
기존 PHPUnit testsuite (`Unit`/`Feature`/`Module`/`Plugin`) 및 Vitest 의 코어/확장 분리 원칙과 동일.
확장이 자체 config 를 가지므로 **확장 디렉토리에서 직접 실행**:
```powershell
cd modules/_bundled/sirsoft-ecommerce
$env:PLAYWRIGHT_BASE_URL='https://g7.dev'
npx playwright test
```
## §4. 데이터 생성 위치 — 책임 분리 매트릭스
| 데이터 종류 | 책임 영역 | 위치 | 호출 |
|---|---|---|---|
| 코어 권한/역할/유저/Sanctum 토큰 | 코어 | `app/Console/Commands/PlaywrightIssueToken.php` | `php artisan playwright:issue-token --permissions=core.xxx` |
| 모듈 권한 (`sirsoft-ecommerce.*`) | 모듈 | 코어 커맨드의 `--permissions=` 임의 식별자 | 동일 (Permission::firstOrCreate 자동 생성) |
| 모듈 도메인 데이터 (상품/주문) | 모듈 | `modules/_bundled/{id}/src/Console/Commands/PlaywrightSeed{id}.php` | `php artisan playwright:seed-{id}` |
| 플러그인 도메인 데이터 (결제 키) | 플러그인 | `plugins/_bundled/{id}/src/Console/Commands/PlaywrightSeed{id}.php` | 동일 |
| 외부 의존 (토스 결제창 응답) | spec 안 mock | `page.route(...)` | 호출 없음 |
**핵심 원칙**: 코어는 모듈 도메인을 모른다. 모듈 도메인 시드를 코어에 두면 의존 역전.
## §5. fixture 패턴
### 5.1 코어 fixture (`tests/Playwright/fixtures/auth.ts`)
```typescript
export function issueToken(...permissions: string[]): string {
return execSync(`php artisan playwright:issue-token ${permissions.map(p => `--permissions=${p}`).join(' ')}`, {
cwd: process.env.G7_ROOT || process.cwd(),
env: { ...process.env, G7_PLAYWRIGHT_BYPASS: '1' }, // ② 옵트인 자동 부착
}).toString().trim();
}
export async function authenticatePage(page: Page, token: string): Promise<void> {
await page.addInitScript((t) => localStorage.setItem('auth_token', t), token);
}
export const test = base.extend<AuthFixtures>({
editToken: async ({}, use) => use(issueToken('core.templates.layouts.edit')),
readOnlyToken: async ({}, use) => use(issueToken('core.templates.read')),
});
```
### 5.2 확장 fixture — 권한 + 시드 분리
```typescript
// modules/_bundled/sirsoft-ecommerce/tests/Playwright/fixtures/ecommerce-auth.ts
import { issueToken, authenticatePage } from '../../../../../../tests/Playwright/fixtures/auth';
export const test = base.extend<EcommerceAuthFixtures>({
settingsToken: async ({}, use) =>
use(issueToken('sirsoft-ecommerce.settings.read', 'sirsoft-ecommerce.settings.update')),
});
```
```typescript
// 권한 + 시드 조합 — mergeTests 사용
import { mergeTests } from '@playwright/test';
import { test as authTest } from '../../fixtures/ecommerce-auth';
import { test as seedTest } from '../../fixtures/ecommerce-seed';
const test = mergeTests(authTest, seedTest);
test('이커머스 상품 목록', async ({ page, settingsToken, seededEcommerce }) => {
await authenticatePage(page, settingsToken);
await page.goto('/admin/ecommerce/products');
await expect(page.getByTestId('product-list-row')).toHaveCount(seededEcommerce.productIds.length);
});
```
## §6. 도메인 매트릭스 (4 카테고리)
### 6.1 인증 가드 매트릭스 (access_outcome × user_permission)
`access-check` 응답을 page.route() 로 mock → 토큰 실제 권한과 무관하게 모든 분기 cover.
```typescript
await page.route('**/api/admin/templates/layouts/access-check', route =>
route.fulfill({ status: 401, body: JSON.stringify({ message: 'Unauthenticated.' }) })
);
await page.goto('/?mode=edit&template=sirsoft-basic');
await expect(page.getByTestId('wysiwyg-access-denied-unauthenticated')).toBeVisible();
```
### 6.2 UI 인터랙션 매트릭스 (anchor × modifier_key)
PreviewCanvas 의 `data-testid="preview-canvas-container"` 안에 동적 anchor 주입 + 클릭 시뮬레이션:
```typescript
await page.evaluate(({ href, modifier }) => {
const host = document.querySelector('[data-testid="preview-canvas-container"]');
const a = document.createElement('a');
a.setAttribute('href', href);
host.appendChild(a);
a.dispatchEvent(new MouseEvent('click', {
bubbles: true, cancelable: true,
ctrlKey: modifier === 'ctrl',
shiftKey: modifier === 'shift',
button: modifier === 'middle_button' ? 1 : 0,
}));
}, { href, modifier });
```
검증 신호: `evt.defaultPrevented === true` (intercept) / `false` (allow).
### 6.3 핸들러 동작 매트릭스 (suppressed_handler)
ActionDispatcher 의 `setPreviewMode(true)` + `setPreviewSuppressedHandlerCallback` 으로 분기 진입 검증:
```typescript
await page.evaluate(({ handler }) => {
const dispatcher = (window as any).__templateApp.getActionDispatcher();
let captured: any = null;
dispatcher.setPreviewMode(true);
dispatcher.setPreviewSuppressedHandlerCallback((name) => { captured = name; });
return dispatcher.dispatchAction({ handler }, {}).then(() => captured);
}, { handler: 'navigate' });
```
### 6.4 외부 의존 시나리오 — mock-first
토스페이먼츠 SDK / API mock 패턴은 `tests/Playwright/README.md` "외부 의존 시나리오" 참조.
## §7. 시나리오 매니페스트와 1:1 매핑
기존 PHPUnit/Vitest 와 동일하게 `tests/scenarios/<feature>.yaml` 의 `cross_product` axis 가 spec 의 `test.describe.parallel(axisName)` 으로 변환되어야 한다.
| YAML axis | spec 파일 | 케이스 |
|---|---|---|
| `cross_product[0]` (access_outcome × user_permission) | `auth-guard.spec.ts` | 12 |
| `cross_product[1]` (anchor_kind × modifier_key) | `anchor-intercept.spec.ts` | 45 |
| `cross_product[2]` (suppressed_handler) | `handler-suppression.spec.ts` | 6 |
| `cross_product[3]` (url_template_param × anchor_kind) | `url-template-param.spec.ts` | 27 |
각 test 케이스의 docblock 에 마킹:
```typescript
test('unauthenticated_401 × no_token', async ({ page }) => {
// @scenario access_outcome=unauthenticated_401, user_permission=no_token
// @effects access_denied_screen_renders, editor_does_not_mount_on_denial
// ...
});
```
`audit-branch.cjs` 의 `test-scenario-coverage` 룰이 매니페스트 axes ↔ docblock 매칭을 자동 검증.
## §8. 회귀 테스트 4단계 (CLAUDE.md 의무 절차 준수)
버그 수정 시 다음 4단계를 스킵 불가:
1. **실패하는 회귀 spec 작성** — 버그 재현 케이스 spec 작성
2. **baseline fail 확인** — 수정 전 실제 fail 확인 (테스트가 의도된 분기를 cover 함을 입증)
3. **코드 수정**
4. **green 전환** — 회귀 spec PASS 확인
`testing-guide.md` 의 PHPUnit/Vitest 4단계와 동일 원칙.
## §9. 무관 에러 처리 분기
E2E spec 작성 중 발견한 무관 에러는 같은 세션에서 처리:
- **테스트 stale** (logic 정상, 테스트가 오래됨) → 테스트 수정
- **로직 회귀** (테스트 정상, 로직이 의도와 어긋남) → 코드 수정
- **데이터 구조 불일치** (양쪽 모두 오래됨) → PO 보고 + 보류
상세: `testing-guide.md` "무관 에러 처리 분기".
## §10. 트러블슈팅
| 증상 | 원인 | 해결 |
|---|---|---|
| `Tests timed out — networkidle` | Reverb WebSocket 지속 연결 | `waitForLoadState('domcontentloaded')` + `waitForFunction` 사용 |
| 401 무한 redirect (`/login?redirect=...`) | 토큰이 testing DB(`g7_testing`) 에만 있고 production 서버(`g7`)는 못 찾음 | `G7_PLAYWRIGHT_BYPASS=1` 로 호출 (production DB 에 토큰 발급) |
| `preview-canvas-container 없음` | 위지윅 편집기 마운트 실패 (access-check 거부) | `page.route()` 로 access-check 200 mock |
| `defaultPrevented` 가 항상 false | onClickCapture 가 도달 안 함 | anchor 가 `preview-canvas-container` 자손인지 확인 |
| `location.href` setter override 실패 | Chromium 에서 `window.location` 은 non-configurable | best-effort 캡처. SSoT 신호는 `defaultPrevented` |
| 외부 origin navigation 으로 trace 복잡 | spec 이 외부 도메인을 로드 | `page.route('https://example.com/**', route => route.fulfill(...))` |
| Sanctum 토큰 누적 DB 오염 | 매 spec 마다 새 user/role 생성 | `globalTeardown.ts` 에서 `php artisan playwright:cleanup-tokens` (별도 커맨드 신설) |
| Windows 라인엔딩 | git autocrlf 충돌 | `.gitattributes` 에 `*.spec.ts text eol=lf` |
## §11. 디버깅 — Claude MCP 역할
Playwright = **결정론적 회귀 게이트** / Claude `chrome-devtools-mcp` = **라이브 진단**.
워크플로우:
1. Playwright spec 이 fail
2. `npx playwright show-trace test-results/<...>/trace.zip` 으로 timeline 확인
3. 단계별 DOM 스냅샷 + 네트워크 분석으로도 원인 불명 시 → Claude MCP `chrome-devtools-mcp` 로 동일 URL 라이브 진단
4. 진단 결과로 spec 보강 또는 코드 수정 → 다시 Playwright 로 결정론 검증
Claude MCP 는 단독 게이트가 아닌 **진단 보조 도구**. 모든 회귀는 Playwright spec 으로 승격되어야 CI/커밋 게이트에서 효력 발생.
## 참조
- 빠른 시작: `tests/Playwright/README.md`
- 모듈 sample skeleton: `modules/_bundled/sirsoft-ecommerce/tests/Playwright/README.md`
- 시나리오 매니페스트: `tests/scenarios/wysiwyg-editor-access-guard.yaml`
- PHPUnit/Vitest 가이드: `docs/testing-guide.md`, `docs/frontend/layout-testing.md`
- 가드 구현: `app/Console/Commands/PlaywrightIssueToken.php`, `app/Providers/SettingsServiceProvider.php::applyDebugConfig`
- 회귀 테스트: `tests/Unit/Providers/SettingsServiceProviderDebugConfigTest.php`