Files
Gnuboard7/docs/extension/editor-spec.md
T
HeuJung dd8f9a49c4 fix(core,template): 편집기 image 위젯 값 형태·데이터 연결 값 보호·상속 노드 편집 표면 수정
공개 는 헤더 「로고 이미지」에 이미지를 지정하면 화면이 엑박이 되는 제보였다.
image 위젯은 배경용으로 설계되어 {url,size,repeat,position} 객체를 내보내는데, 값 슬롯이
하나뿐인 apply 경로(propValue/cssVar/단일 styleProp)가 그 객체를 그대로 props 에 기록해
소비 컴포넌트가 [object Object] 를 URL 로 받았다. 예외도 콘솔 오류도 서버 로그도 남지
않는다 — 깨진 이미지 요청은 SPA catch-all 때문에 404 조차 아니라 200(HTML)이고, 편집기
미리보기는 정상이라 조작 중에는 이상이 보이지 않는다.

방어선을 넷으로 세웠다. 쓰기 축약(공용 헬퍼 scalarizeImageValue 단일 지점, 게이트는 위젯
이름이며 값 형태 sniffing 이 아니다) · 읽기 역조립(표현식 문자열도 되감아 업로드 1클릭에
소실되지 않게 한다) · 런타임 방어(업그레이드 전 화면을 위해 템플릿 Img 가 url 을 해석하고
손상값이면 src 를 아예 붙이지 않는다) · 저장 데이터 백필(업그레이드 스텝). 런타임과 백필은
완전히 같은 엄격 판정식(키 집합 ⊆ 4키 AND url 보유)을 쓴다 — 엔진의 느슨한 판정식을 백필에
이식하면 레이아웃 전수에서 정상 props 2,219건을 파괴한다(실측).

전수조사에서 파생한 인접 결함 넷을 함께 고쳤다.

- number 위젯이 코어 레지스트리에 미등록이라 「탭 표시 게시판 수」 같은 컨트롤이
 「지원하지 않는 컨트롤」로 폴백해 편집 자체가 불가했다. nodeKey apply 는 coreProps 가
 선언만 하고 엔진 switch 에 case 가 없어 무음 no-op 이었다.
- 상속(base)·주입(extension) 노드 중 바인딩을 가진 것이 data_bound 로 분류돼 편집이
 열려 있었는데, 저장 마스킹이 그 노드를 통째로 폐기하므로 편집분이 오류도 경고도 없이
 사라졌다(저장은 200 이고 history 는 clear 돼 undo 도 불가). 출처 잠금이 항상 우선하도록
 판정 순서를 통일하고, 단일 판정 헬퍼로 인라인 편집·복제·Delete·잘라내기·드래그 commit
 까지 전 표면을 같은 기준으로 막았다.
- prop 자리의 표현식 값을 위젯이 해석하지 못해 빈 컨트롤로 보이고, 조작하는 순간 환경설정
 과의 연결이 소리 없이 끊겼다. 판정·배지·잠금·해제·복구를 ControlRenderer 공용 게이트
 한 곳으로 올려 신규 위젯에도 자동 적용되게 했다. 「직접 지정으로 바꾸기」에는 「되돌리기」를
 동반해 편도가 되지 않게 한다.
- 편집기 모드에서 updateTemplateData 가 빈 레이아웃으로 같은 reactRoot 에 두 번째 커밋을
 걸어 편집기 트리를 통째로 제거했다. renderTemplate 의 편집기 분기가 비동기라 부팅 중
 setGlobalState 가 그 커밋 뒤에 도착할 때만 발현하는 경합이었다.
2026-09-08 17:20:01 +09:00

47 KiB

편집기 스펙 (editor-spec.json)

레이아웃 편집기가 템플릿/모듈/플러그인을 편집할 때 사용하는 편집 역량 선언 파일입니다.

TL;DR (5초 요약)

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 는 작성자 메모(엔진 무시), 알려진 필드 외 자유 필드는 보존만
6. 확장별 선언 실측·동반 의무는 그 확장의 docs/editor-spec.md 가 소유 (ext:docgen 이 유지)

개요

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 을 참조한다.

{
  "$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 · controls · componentCapabilities · nesting 템플릿 스펙
sampleData · sampleGlobal · states · *Recipes 그 데이터를 소유한 모듈·플러그인 스펙
여러 확장이 함께 쓰는 공용 ID(settings · roles · me 등) 템플릿 스펙

공용 ID 를 확장마다 각자 선언하면 같은 ID 의 샘플이 여러 곳에 생기고, 그것들이 갈라져도 오류가 나지 않는다 — 어느 것이 쓰이는지가 병합 순서에 좌우된다.

모듈·플러그인이 componentPalette 를 선언하면 템플릿 선언과 같은 자리를 두고 다투게 되므로 두지 않는다. 팔레트에 얹고 싶은 것이 있다면 그것은 활성 템플릿의 스펙으로 간다.

무엇을 빠뜨렸는지는 증상으로 가른다

편집기는 선언되지 않은 것을 없는 것으로 다룰 뿐 실패를 보고하지 않는다. 그래서 빠뜨린 자리를 알려 주는 것은 오류 메시지가 아니라 증상이다.

증상 빠뜨린 자리
컴포넌트가 팔레트 목록에 아예 없다 componentPalette.entries
팔레트에 등록했는데 목록에 안 보인다 componentPalette.groups 의 어느 묶음에도 없다
팔레트에서 끌어 놓을 수 없다 nesting.draggable
놓을 자리가 없다(컨테이너가 거부한다) nesting.containers
놓았는데 속성 패널이 비어 있다 componentCapabilities
속성 패널에 특정 항목만 없다 controls
캔버스의 한 영역만 빈 화면이다 sampleData.byDataSourceId(또는 byEndpointPattern)
값이 undefined 라 영역 전체가 사라진다 sampleGlobal
특정 상태(빈 목록·오류·모달 열림)를 볼 수 없다 states

캔버스는 실제 API 를 부르지 않고 sampleData 로 그린다. 그래서 레이아웃에 data_source 를 추가하고 샘플을 붙이지 않으면 편집기에서만 그 자리가 비고 실제 화면은 정상 동작한다 — 오류도 경고도 서버 로그도 남지 않는다.

캔버스는 또한 정적 시뮬레이션이라 클릭·응답으로 만들어지는 상태를 스스로 만들지 못한다. 모바일 드로어(햄버거 클릭), 쿠키 동의 배너(동의 전 방문자), 본인인증 창(428 응답)처럼 어떤 사건 뒤에만 나타나는 화면은 states 로 그 상태를 주입해 두지 않으면 편집 자체가 불가능하다.

반영 절차

편집기 스펙은 JSON 이므로 빌드가 필요 없다. 다만 서빙은 활성 디렉토리만 읽고 _bundled 폴백이 없으므로, _bundled 에서 고친 뒤 update 커맨드를 돌리지 않으면 편집기에는 직전 내용이 그대로 보인다. 파일은 고쳤는데 화면이 안 바뀌었다면 거의 이 경우다.

php artisan {module|plugin|template}:update {id} --force

확장별 실측은 그 확장이 소유한다

어느 확장이 무엇을 얼마나 선언했는지, 그 확장에서 프리뷰 샘플이 붙지 않는 data_source 가 무엇인지는 그 확장의 docs/editor-spec.md 가 답한다. php artisan ext:docgen 이 그 실측 부분을 유지하므로 스펙을 고친 뒤 재실행한다. 코어 문서(이 문서)에는 블록 문법과 공통 판단 기준만 둔다.

블록별 작성 규칙

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] 기입 — 속성 탭의 기본 적용 방식) / nodeKey(노드 최상위 구조키 node[nodeKey] 기입 — dataKey 처럼 런타임 엔진이 props 가 아니라 노드 자신에서 읽는 키. props·children·type 등 예약 구조키는 지정해도 무시되고, 디바이스(breakpoint) 축은 무시하며 항상 최상위에 쓴다 — 런타임 responsive 병합은 props/children/text/if/iteration 5키만 보므로 분기에 쓰면 영원히 읽히지 않는다). 색·크기 등 라이브러리 대응 컨트롤은 프리셋 토큰 + 자유값(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 로 결선한다(아래 "데이터 정의 빌트인" 참조).
  • number — 숫자를 기대하는 prop(예 maxVisibleBoards)을 편집한다. 위젯이 number 타입을 직접 내보내므로 "5" 같은 문자열이 저장되지 않는다. 빈 입력은 prop 삭제, 0 은 유효값이라 삭제되지 않으며, 숫자로 해석할 수 없는 입력은 방출하지 않아 저장본을 보존한다(조용한 변조 금지). min/max/step 은 선언만 하고 클램프하지 않는다 — 사용자가 넣은 값은 그대로 보존한다. 값이 {{바인딩}} 문자열일 때의 보호는 위젯이 아니라 아래 「데이터 연결 값 보호」 공용 게이트가 맡는다 — 숫자 입력칸이 그 값을 빈칸으로 표시해 다음 blur 에 소실시키는 것을 그 게이트가 차단한다.
  • i18n-text vs text — 표시 텍스트(사용자가 화면에서 읽는 라벨/메시지/플레이스홀더)는 i18n-text 를 쓴다. i18n-text 는 평문 입력 시 createCustomKey 로 다국어 키를 자동 생성해 $t:custom.* 로 치환하고(미리보기에 raw 키 미노출), 🌐 펼침으로 ko/en/ja 동시 편집을 제공하며, {{바인딩}} 값이면 읽기전용 디그레이드한다(공통 위젯 I18nTextField). 비-표시값(URL/id/수치/색/통화코드/separator 등)만 text 를 쓴다. 표시 텍스트에 text 를 쓰면 raw $t: 노출·다국어 누락 회귀가 발생한다(정적 검사가 배열 라벨 필드의 text 사용을 차단).

데이터 연결 값 보호 — 모든 위젯 공통: prop 자리에 {{...}} 표현식이나 설정 참조가 저장돼 있으면, 그 값을 편집하는 컨트롤은 원문 배지로 디그레이드된다. 위젯은 그 문자열을 해석하지 못해 빈 컨트롤로 보이고, 그 상태에서 조작하면 표현식이 그대로 덮여 환경설정과의 연결이 끊기기 때문이다. 이 판정은 개별 위젯이 아니라 컨트롤 렌더러 한 곳에서 이뤄지므로 새 위젯을 등록해도 자동 적용된다 — 위젯 안에 같은 판정을 다시 넣지 않는다(넣으면 해제 경로까지 막혀 「직접 지정으로 바꾸기」가 무반응이 된다).

보호 상태에서는 그 컨트롤의 파괴적 조작이 함께 잠긴다. 값을 바꾸려면 「직접 지정으로 바꾸기」를 눌러 명시적으로 열고, 「되돌리기」로 원래 연결값을 복구할 수 있다(해제 직후에는 취소, 값을 넣은 뒤에는 복구로 동작).

예외는 표현식 자체를 다루도록 설계된 위젯뿐이다 — text·i18n-text 는 표현식을 데이터 칩으로 분해해 보여 주는 것이 그 위젯의 기능이고, image·core-id 는 같은 보호를 위젯 안에서 더 세밀하게(갤러리·업로드·관리 모달 진입까지) 제공한다. 새 위젯을 이 예외에 넣으려면 원문 표시·해제·복구 셋을 모두 갖춰야 한다.

image 위젯의 값 형태 — apply 경로에 따라 축약된다:

image 위젯은 배경 이미지용으로 설계되어 {url, size, repeat, position} 객체를 내보낸다. 그런데 값 슬롯이 하나뿐인 apply 경로(propValue / cssVar / 단일 styleProp)는 그 객체를 담을 수 없다 — 엔진이 url 만 남겨 스칼라로 축약한다(컴포넌트 prop 은 맨 문자열, CSS 값 문맥은 url(...) 래핑). url 이 없거나 비어 있으면 그 자리를 삭제해 컴포넌트의 폴백이 살아난다.

축약을 전제로 위젯 표면도 달라진다 — 단일 값 슬롯 컨트롤에서는 표시모드(채움/맞춤/타일) 버튼이 렌더되지 않는다(size/repeat/position 을 저장할 자리가 없어 눌러도 저장되지 않는 죽은 컨트롤이다). 4속성을 모두 저장하려면 styleProp 의 props 배열에 backgroundImage·backgroundSize·backgroundRepeat·backgroundPosition 을 함께 선언한다.

image 를 classToken 이나 cssVar 에 연결하거나 apply 를 생략하면 값이 문자열로 축약될 경로가 없어 [object Object] 가 저장된다 — 정적 검사가 그 선언을 차단한다.

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 다(런타임 응답 추측 금지). 정적 검사가 스키마를 가드한다.

선택지(options) 직교 완화: 선택 컴포넌트(Select/RadioGroup/TagInput/SearchableDropdown 등)의 options 배열은 정적 목록 직접 편집(propControls selectOptions → apply.propKey: "options")과 데이터소스 바인딩(dataProps options array)을 둘 다 노출한다 — 같은 options prop 에 정적·동적 두 입력을 모두 제공한다(부록6 "한 prop 은 한쪽만" 규칙의 명시적 완화). 정적 검사의 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 바인딩전용)에 등록되어야 한다 — 정적 검사가 둘 다 아닌 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 패스스루" 참조.

전용 UI 를 가진 코어 속성은 coreProps 로 다시 선언하지 않는다: isolatedState·isolatedScopeId 는 편집기가 별도 전용 컨트롤로 제공하며 그것이 값의 단일 출처다. 이 두 키를 coreProps 배열에 적어도 [속성] 탭의 일반 컨트롤 목록에는 나타나지 않는다 — 같은 값을 두 경로가 쓰면 값 형태가 갈리기 때문이다(전용 UI 는 켜짐 상태를 객체로 쓰는데 일반 토글 위젯은 true 를 낸다). 선언 자체는 오류가 아니고 조용히 무시되므로, 격리 설정을 노출하려면 그 전용 컨트롤을 쓴다.

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 는 코어 핸들러 규칙의 올바른 핸들러(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)는 그 확장이 본 블록으로 선언해 코어가 도메인을 모른 채 병합·노출한다.

"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), 정적 검사가 신규 레이아웃의 누락·비-$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):

// 템플릿 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 회귀를 막는다.

manifest description 작성 규칙

description 은 이 스펙이 무엇을 담고 있는가만 적는다. 독자는 이 확장을 쓰는 사람이다.

적지 않는다 이유
내부 작업 단계 (Phase 3, 1차 작업, 추후 추가) 확장만 내려받은 제3자에게는 해석할 근거가 없다. 그리고 다음 단계가 실제로 들어온 뒤에도 문구는 그대로 남아 거짓이 된다
심사·검토 판정 (— 정당, 타당함, 확인 완료) 작업자가 리뷰어에게 하는 해명이다. 읽는 쪽은 누가 무엇을 심사했는지 모른다
작업 방법 (전수 스캔 기반, 역추론해 작성) 스펙이 무엇인지가 아니라 어떻게 만들었는지다. 그 정보가 필요하면 $comment 에 둔다
항목 ID 나열 스펙이 커지면 곧 낡는다. 개수와 목록은 문서 생성기가 실측해 싣는다

비어 있을 것과 없을 것을 구분해 적는 것은 권장한다 — "sampleGlobal 은 이 모듈이 _global 키를 두지 않아 선언하지 않는다" 처럼, 왜 없는지는 읽는 쪽이 부재를 누락으로 오해하지 않게 한다.

확장 문서(docs/editor-spec.md)의 한 줄 요약은 이 필드를 옮겨 싣지 않고 실측에서 생성한다. 사람이 쓴 메모는 코드와 대조되지 않아 낡아도 드러나지 않는데, 문서에 옮겨지면 바로 아래 실측 표와 서로를 반박하게 된다.

작성자 메모와 자유 필드

  • 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
  • 확장별 실측 문서 생성: php artisan ext:docgen — 각 확장 docs/editor-spec.md
  • 확장 문서 규정: extension-documentation.md
  • 서빙: app/Http/Controllers/Api/Public/PublicTemplateController.php, app/Http/Controllers/Api/Admin/AdminTemplateAssetController.php, app/Services/ModuleService.php, app/Services/PluginService.php