공개 저장소 이슈 gnuboard/g7 (@bigmsg) 제보에서 출발한 작업이다. 브라우저가 화면을 그리려고 제3자 CDN 에 도달해야 하면, 그 도달 실패는 예외도 로그도 남기지 않고 화면 기능만 조용히 사라진다. 폐쇄망·방화벽·광고차단기에서 재현되는데 자체 서버 로그에는 흔적이 없어 운영자가 원인을 특정할 수 없다. 제보된 것은 편집기 하나였지만 같은 구조가 아이콘·글꼴·코드편집기·압축 라이브러리·설치 마법사·개발 대시보드에 똑같이 있었으므로, 번들 확장과 템플릿 전체를 자체 제공으로 옮겼다. 런타임에 외부로 나가는 것은 주소 검색 서비스 하나만 남았다. 자체 제공만으로는 부족하다 — 자기 서버에서 받는 파일도 실패할 수 있고, 종전에는 그 실패가 무음이었다. CSS 경로에 재시도 계층을 세우고(스크립트 경로와 동형), 서버가 HTML 에 직접 심는 externals 까지 실패를 붙잡아 안내 배너와 [다시 시도]로 표면화했다. 편집기·코드편집기는 확보 실패 시 평문 입력으로 내려앉되 저장 계약을 유지한다. 두 번째 축은 운영자가 CSS 를 덧붙일 자리가 없던 문제다(sir.kr 문의). 확장 디렉토리의 custom/ 을 운영자 소유로 정해, 확장 교체가 그 디렉토리만은 보존하게 했다. 출처에 의존하지 않는 서술자로 해석하므로 나중에 다른 출처가 붙어도 소비자는 바뀌지 않는다. 확장 자산과 같은 메커니즘으로 정적 게시되어 CSS 내부 상대 url 도 해석되고, 파일을 고치면 그 변경을 감지해 재게시까지 예약된다. FTP 접근이 없는 운영자에게는 그 자리도 없는 것과 같으므로 레이아웃 편집기에서 직접 넣고 고칠 수 있게 했다. 모듈·플러그인·템플릿이 한 엔드포인트를 공유한다 — 타입별로 나누면 같은 검증이 세 벌로 갈리고 그중 약한 하나가 우회로가 된다. 여기서 올린 스크립트는 그 레이아웃 한 장이 아니라 사이트 전 화면에서 실행되므로 레이아웃 편집과 분리된 전용 권한으로 연다. 운영자 CSS 가 화면을 조작 불능으로 만들면 그것을 고칠 화면에도 같은 CSS 가 실려 스스로 갇히므로, 서버가 목록을 비우는 탈출구(?custom=off)를 함께 뒀다. 동봉 자산은 재생성 경로에 버전 대조 가드를 붙였다. 선언과 다른 버전을 버전 디렉토리에 써 넣는 조용한 거짓말은 배포된 뒤에는 드러나지 않는다.
1062 lines
40 KiB
TypeScript
1062 lines
40 KiB
TypeScript
/**
|
|
* 그누보드7 템플릿 엔진 진입점
|
|
*
|
|
* 모든 코어 엔진 모듈을 통합하고 외부에서 사용할 수 있는 공개 API를 제공합니다.
|
|
*
|
|
* @packageDocumentation
|
|
*/
|
|
|
|
import React, { startTransition } from 'react';
|
|
import ReactDOM from 'react-dom/client';
|
|
import { ComponentRegistry } from './template-engine/ComponentRegistry';
|
|
import { DataBindingEngine } from './template-engine/DataBindingEngine';
|
|
import { TranslationEngine, TranslationContext } from './template-engine/TranslationEngine';
|
|
import { ActionDispatcher, setActionDispatcherInstance } from './template-engine/ActionDispatcher';
|
|
import DynamicRenderer from './template-engine/DynamicRenderer';
|
|
import { LayoutLoader } from './template-engine/LayoutLoader';
|
|
import { TransitionProvider } from './template-engine/TransitionContext';
|
|
import { TranslationProvider } from './template-engine/TranslationContext';
|
|
import { transitionManager } from './template-engine/TransitionManager';
|
|
import { ResponsiveProvider } from './template-engine/ResponsiveContext';
|
|
import { responsiveManager } from './template-engine/ResponsiveManager';
|
|
import { SlotProvider } from './template-engine/SlotContext';
|
|
import { DataSourceManager } from './template-engine/DataSourceManager';
|
|
import { ModalDataSourceWrapper } from './template-engine/ModalDataSourceWrapper';
|
|
import { ParentContextProvider } from './template-engine/ParentContextProvider';
|
|
import { TemplateNotFoundError } from './template-engine/TemplateEngineError';
|
|
import { mergeLocalInitSlot } from './template-engine/localInitSlot';
|
|
import { ErrorDisplay } from './template-engine/ErrorDisplay';
|
|
import { TemplateApp, initTemplateApp } from './TemplateApp';
|
|
import { createLogger } from './utils/Logger';
|
|
import { webSocketManager } from './websocket/WebSocketManager';
|
|
import { initializeG7CoreGlobals, initDevToolsAPI } from './template-engine/G7CoreGlobals';
|
|
import { checkLayoutEditorMode } from './template-engine/layout-editor/hooks/useEditorMode';
|
|
import { loadScriptWithRetry } from './template-engine/networkResilience';
|
|
// LayoutEditorChrome 은 정적 import 하지 않는다 — 편집기는 별도 lazy 번들
|
|
// (layout-editor.min.js)로 분리되어 `/admin/layout-editor/*` 진입 시에만 로드된다.
|
|
// @since engine-v1.51.0
|
|
|
|
const logger = createLogger('TemplateEngine');
|
|
|
|
/**
|
|
* 템플릿 메타데이터 인터페이스
|
|
*/
|
|
interface TemplateMetadata {
|
|
identifier: string;
|
|
locales: string[];
|
|
name: Record<string, string>;
|
|
description: Record<string, string>;
|
|
version: string;
|
|
type: string;
|
|
}
|
|
|
|
/**
|
|
* 템플릿 엔진 상태 인터페이스
|
|
*/
|
|
interface TemplateEngineState {
|
|
templateId: string | null;
|
|
locale: string;
|
|
isInitialized: boolean;
|
|
reactRoot: ReactDOM.Root | null;
|
|
containerId: string | null;
|
|
currentLayoutJson: any | null;
|
|
currentDataContext: Record<string, any>;
|
|
translationContext: TranslationContext;
|
|
registry: ComponentRegistry | null;
|
|
bindingEngine: DataBindingEngine | null;
|
|
translationEngine: TranslationEngine | null;
|
|
actionDispatcher: ActionDispatcher | null;
|
|
templateMetadata: TemplateMetadata | null;
|
|
}
|
|
|
|
/**
|
|
* 템플릿 엔진 초기화 옵션
|
|
*/
|
|
interface InitOptions {
|
|
templateId: string;
|
|
templateType?: string;
|
|
locale?: string;
|
|
debug?: boolean;
|
|
/** 확장 기능 캐시 버전 (모듈/플러그인 활성화 시 갱신됨) */
|
|
cacheVersion?: number;
|
|
}
|
|
|
|
/**
|
|
* 렌더링 옵션
|
|
*/
|
|
interface RenderOptions {
|
|
containerId: string;
|
|
layoutJson: any;
|
|
dataContext?: Record<string, any>;
|
|
translationContext?: TranslationContext;
|
|
}
|
|
|
|
/**
|
|
* 데이터 업데이트 옵션
|
|
*/
|
|
interface UpdateOptions {
|
|
/**
|
|
* true인 경우 startTransition 없이 즉시 동기적으로 렌더링합니다.
|
|
* 드래그 앤 드롭 후 순서 변경 등 즉각적인 UI 반영이 필요한 경우 사용합니다.
|
|
* 기본값: false (startTransition 사용)
|
|
*/
|
|
sync?: boolean;
|
|
}
|
|
|
|
/**
|
|
* 템플릿 엔진 공개 API
|
|
*/
|
|
interface TemplateEngineAPI {
|
|
initTemplateEngine: (options: InitOptions) => Promise<void>;
|
|
renderTemplate: (options: RenderOptions) => Promise<void>;
|
|
updateTemplateData: (data: Record<string, any>) => void;
|
|
destroyTemplate: () => void;
|
|
getState: () => Readonly<TemplateEngineState>;
|
|
}
|
|
|
|
/**
|
|
* 전역 상태
|
|
*/
|
|
const state: TemplateEngineState = {
|
|
templateId: null,
|
|
locale: 'ko',
|
|
isInitialized: false,
|
|
reactRoot: null,
|
|
containerId: null,
|
|
currentLayoutJson: null,
|
|
currentDataContext: {},
|
|
translationContext: {
|
|
templateId: '',
|
|
locale: 'ko',
|
|
},
|
|
registry: null,
|
|
bindingEngine: null,
|
|
translationEngine: null,
|
|
actionDispatcher: null,
|
|
templateMetadata: null,
|
|
};
|
|
|
|
/**
|
|
* 디버그 모드 플래그 (ModalDataSourceWrapper 등에서 참조)
|
|
*/
|
|
let debugMode = false;
|
|
|
|
/**
|
|
* React가 준비되기 전에 도착한 데이터 업데이트를 저장하는 큐
|
|
* renderTemplate 완료 후 순차적으로 적용됨
|
|
*/
|
|
const pendingDataUpdates: Array<{ data: Record<string, any>; options?: UpdateOptions }> = [];
|
|
|
|
/**
|
|
* 템플릿 메타데이터 로드
|
|
*
|
|
* 템플릿 컴포넌트 번들에 포함된 메타데이터를 가져옵니다.
|
|
* API 호출 없이 전역 변수에서 직접 접근하여 성능을 개선합니다.
|
|
*
|
|
* @param templateId - 템플릿 ID
|
|
* @returns 템플릿 메타데이터
|
|
*/
|
|
async function loadTemplateMetadata(templateId: string): Promise<TemplateMetadata> {
|
|
try {
|
|
logger.log('템플릿 메타데이터 로드 중...', templateId);
|
|
|
|
// 템플릿 컴포넌트 번들의 전역 변수명 생성
|
|
// 예: 'sirsoft-admin_basic' → 'SirsoftAdminBasic'
|
|
const globalVarName = templateId
|
|
.split('-')
|
|
.map(part =>
|
|
part
|
|
.split('_')
|
|
.map(subPart => subPart.charAt(0).toUpperCase() + subPart.slice(1))
|
|
.join('')
|
|
)
|
|
.join('');
|
|
|
|
// 전역 변수에서 메타데이터 가져오기
|
|
const templateBundle = (window as any)[globalVarName];
|
|
|
|
if (templateBundle?.templateMetadata) {
|
|
logger.log('템플릿 메타데이터 로드 완료 (전역 변수)', templateBundle.templateMetadata);
|
|
return templateBundle.templateMetadata;
|
|
}
|
|
|
|
// 폴백: 템플릿 번들에 메타데이터가 없는 경우 경고
|
|
logger.warn(
|
|
`템플릿 번들에 메타데이터가 없습니다. 전역 변수: ${globalVarName}`
|
|
);
|
|
|
|
// 기본값 반환
|
|
return {
|
|
identifier: templateId,
|
|
locales: ['ko', 'en'],
|
|
name: { ko: templateId, en: templateId },
|
|
description: { ko: '', en: '' },
|
|
version: '1.0.0',
|
|
type: 'admin',
|
|
};
|
|
} catch (error) {
|
|
logger.error('템플릿 메타데이터 로드 실패', error);
|
|
// 기본값 반환
|
|
return {
|
|
identifier: templateId,
|
|
locales: ['ko', 'en'],
|
|
name: { ko: templateId, en: templateId },
|
|
description: { ko: '', en: '' },
|
|
version: '1.0.0',
|
|
type: 'admin',
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 전역 변수 생성
|
|
*
|
|
* 템플릿 엔진 기획서에 명시된 전역 변수를 생성합니다:
|
|
* - $locale: 현재 언어 코드 (예: 'ko', 'en')
|
|
* - $locales: 시스템 활성 언어 목록 (활성 언어팩 기반, 예: ['ko', 'en', 'ja'])
|
|
* - $templateLocales: 현재 템플릿이 자체 번역을 제공하는 언어 목록 (template.json `locales`)
|
|
* - $user: 현재 로그인한 사용자 정보 (추후 구현)
|
|
* - $auth: 인증 상태 (추후 구현)
|
|
*
|
|
* @returns 전역 변수 객체
|
|
*/
|
|
function createGlobalVariables(): Record<string, any> {
|
|
// 시스템 활성 언어팩(_global.appConfig.supportedLocales) 을 우선 사용한다.
|
|
// 언어팩 설치/제거 시 즉시 사용자 선택 UI 에 반영되어야 하기 때문이다.
|
|
// 미초기화/누락 시에만 템플릿 정적 메타데이터로 폴백한다.
|
|
const templateApp = (typeof window !== 'undefined' ? (window as any).__templateApp : undefined);
|
|
const systemLocales = templateApp?.globalState?.appConfig?.supportedLocales;
|
|
const templateLocales = state.templateMetadata?.locales;
|
|
|
|
return {
|
|
$locale: state.locale,
|
|
$locales: (Array.isArray(systemLocales) && systemLocales.length > 0)
|
|
? systemLocales
|
|
: (templateLocales || ['ko', 'en']),
|
|
$templateLocales: templateLocales || ['ko', 'en'],
|
|
$templateId: state.templateId,
|
|
// 추후 추가 예정:
|
|
// $user: getCurrentUser(),
|
|
// $auth: getAuthState(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 템플릿 엔진 초기화
|
|
*
|
|
* 모든 엔진 인스턴스를 생성하고 템플릿 컴포넌트를 로드합니다.
|
|
*
|
|
* @param options - 초기화 옵션
|
|
* @throws {Error} 이미 초기화된 경우 또는 초기화 실패 시
|
|
*/
|
|
async function initTemplateEngine(options: InitOptions): Promise<void> {
|
|
try {
|
|
logger.log('템플릿 엔진 초기화 시작', options);
|
|
|
|
// 이미 초기화된 경우 에러
|
|
if (state.isInitialized) {
|
|
throw new Error('템플릿 엔진이 이미 초기화되었습니다. destroyTemplate()을 먼저 호출하세요.');
|
|
}
|
|
|
|
// 옵션 검증
|
|
if (!options.templateId) {
|
|
throw new TemplateNotFoundError();
|
|
}
|
|
|
|
// 디버그 모드 설정
|
|
debugMode = options.debug ?? false;
|
|
|
|
// G7Config에 debug 모드 설정 (DevTools에서 참조)
|
|
if (typeof window !== 'undefined') {
|
|
if (!(window as any).G7Config) {
|
|
(window as any).G7Config = {};
|
|
}
|
|
(window as any).G7Config.debug = debugMode;
|
|
}
|
|
|
|
// DevTools API 초기화 (debug 옵션 설정 후)
|
|
initDevToolsAPI();
|
|
|
|
// 상태 업데이트
|
|
state.templateId = options.templateId;
|
|
state.locale = options.locale || 'ko';
|
|
|
|
// 엔진 인스턴스 생성
|
|
logger.log('엔진 인스턴스 생성 중...');
|
|
|
|
state.registry = ComponentRegistry.getInstance();
|
|
state.bindingEngine = new DataBindingEngine();
|
|
state.translationEngine = TranslationEngine.getInstance();
|
|
|
|
// TranslationEngine에 캐시 버전 설정
|
|
if (options.cacheVersion !== undefined && options.cacheVersion > 0) {
|
|
state.translationEngine.setCacheVersion(options.cacheVersion);
|
|
}
|
|
|
|
// TranslationContext 초기화
|
|
state.translationContext = {
|
|
templateId: options.templateId,
|
|
locale: state.locale,
|
|
};
|
|
|
|
// ActionDispatcher에 TranslationEngine과 TranslationContext 전달
|
|
state.actionDispatcher = new ActionDispatcher(
|
|
{},
|
|
state.translationEngine,
|
|
state.translationContext
|
|
);
|
|
|
|
// ActionDispatcher 싱글톤 인스턴스로 설정
|
|
// renderItemChildren 등에서 getActionDispatcher()를 통해 동일한 인스턴스를 사용할 수 있도록 함
|
|
setActionDispatcherInstance(state.actionDispatcher);
|
|
|
|
// 다국어 파일 병렬 로드
|
|
logger.log('다국어 파일 로드 중...', options.templateId, state.locale);
|
|
|
|
const fallbackLocale = 'en';
|
|
const translationPromises = [
|
|
state.translationEngine
|
|
.loadTranslations(options.templateId, state.locale)
|
|
.then(() => {
|
|
logger.log(`다국어 파일 로드 완료: ${state.locale}`);
|
|
})
|
|
.catch((error) => {
|
|
logger.warn(
|
|
`다국어 파일 로드 실패 (${state.locale}):`,
|
|
error instanceof Error ? error.message : error
|
|
);
|
|
}),
|
|
];
|
|
|
|
// 폴백 로케일 로드 (기본 로케일과 다른 경우에만)
|
|
if (state.locale !== fallbackLocale) {
|
|
translationPromises.push(
|
|
state.translationEngine
|
|
.loadTranslations(options.templateId, fallbackLocale)
|
|
.then(() => {
|
|
logger.log(`폴백 다국어 파일 로드 완료: ${fallbackLocale}`);
|
|
})
|
|
.catch((error) => {
|
|
logger.warn(
|
|
`폴백 다국어 파일 로드 실패 (${fallbackLocale}):`,
|
|
error instanceof Error ? error.message : error
|
|
);
|
|
})
|
|
);
|
|
}
|
|
|
|
// 모든 다국어 파일 로드 대기 (병렬 실행)
|
|
await Promise.allSettled(translationPromises);
|
|
|
|
// 템플릿 메타데이터 로드
|
|
logger.log('템플릿 메타데이터 로드 중...', options.templateId);
|
|
state.templateMetadata = await loadTemplateMetadata(options.templateId);
|
|
|
|
// ComponentRegistry 로딩은 TemplateApp에서 병렬로 처리됨
|
|
// (성능 최적화를 위해 routes.json과 함께 병렬 로드)
|
|
|
|
// 초기화 완료
|
|
state.isInitialized = true;
|
|
|
|
logger.log('템플릿 엔진 초기화 완료 (ComponentRegistry는 별도 로드)');
|
|
} catch (error) {
|
|
logger.error('템플릿 엔진 초기화 실패', error);
|
|
|
|
// 초기화 실패 시 상태 초기화
|
|
state.isInitialized = false;
|
|
state.registry = null;
|
|
state.bindingEngine = null;
|
|
state.translationEngine = null;
|
|
state.actionDispatcher = null;
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 편집기 lazy 번들(layout-editor.min.js) 로드 후 LayoutEditorChrome 컴포넌트 반환.
|
|
*
|
|
* @since engine-v1.51.0
|
|
*
|
|
* 편집기는 메인 코어 번들에서 분리되어 `/admin/layout-editor/*` 진입 시에만 로드된다.
|
|
* 이미 로드돼 있으면 즉시 반환(멱등), 아니면 `<script>` 를 주입하고 로드 완료를 대기한다.
|
|
* 동시 다중 호출은 in-flight promise 로 병합해 중복 주입을 막는다.
|
|
*
|
|
* 주입은 `loadScriptWithRetry` 에 위임한다 — 종전에는 이 경로만 재시도 계층이 없어,
|
|
* 일시적 네트워크 유실 한 번에 편집기가 통째로 열리지 않았다. 다른 모든 자산 경로
|
|
* (레이아웃 스크립트·확장 번들·CSS)가 이미 이 로더를 쓰므로 편집기만 예외일 이유가 없다.
|
|
*
|
|
* 실패 문구에는 번들 경로를 싣지 않는다. 사용자가 고칠 수 있는 정보가 아니고,
|
|
* 내부 배치 구조를 화면에 노출한다. 경로는 콘솔 로그로만 남긴다.
|
|
*
|
|
* @returns LayoutEditorChrome React 컴포넌트
|
|
* @throws {Error} 스크립트 로드 실패 또는 컴포넌트 미노출 시
|
|
*/
|
|
let layoutEditorLoadPromise: Promise<any> | null = null;
|
|
|
|
/** 편집기 번들 `<script>` element id */
|
|
const LAYOUT_EDITOR_SCRIPT_ID = 'g7-layout-editor-bundle';
|
|
|
|
function loadLayoutEditorBundle(): Promise<any> {
|
|
const g7 = (window as any).G7Core;
|
|
|
|
// 이미 로드됨 — 즉시 반환 (멱등)
|
|
if (g7?.__LayoutEditorChrome) {
|
|
return Promise.resolve(g7.__LayoutEditorChrome);
|
|
}
|
|
|
|
// 진행 중인 로드가 있으면 재사용 (중복 주입 방지)
|
|
if (layoutEditorLoadPromise) {
|
|
return layoutEditorLoadPromise;
|
|
}
|
|
|
|
layoutEditorLoadPromise = (async () => {
|
|
// 번들 URL — blade 가 주입한 버전 포함 URL 우선, 폴백은 표준 경로
|
|
const src =
|
|
(window as any).G7Config?.coreEditorAsset || '/build/core/layout-editor.min.js';
|
|
|
|
// 앞선 시도가 남긴 element 제거 — 남겨두면 IIFE 가 두 번 실행된다
|
|
document.getElementById(LAYOUT_EDITOR_SCRIPT_ID)?.remove();
|
|
|
|
// 편집기 엔트리가 컴포넌트 노출 직후 호출하는 준비 완료 콜백 (주입 전에 등록)
|
|
(window as any).G7Core = (window as any).G7Core || {};
|
|
(window as any).G7Core.__onChromeReady = () => {
|
|
/* 노출 시점 통지 — 실제 대기는 script load 이벤트가 담당한다 */
|
|
};
|
|
|
|
try {
|
|
await loadScriptWithRetry(
|
|
src,
|
|
{ id: LAYOUT_EDITOR_SCRIPT_ID },
|
|
{ label: 'layout-editor bundle' }
|
|
);
|
|
} catch (error) {
|
|
layoutEditorLoadPromise = null; // 재시도 가능하도록 초기화
|
|
logger.error(`편집기 번들 로드 실패 (${src})`, error);
|
|
|
|
throw new Error('편집기를 불러오지 못했습니다. 네트워크 상태를 확인한 뒤 다시 시도해 주세요.');
|
|
}
|
|
|
|
const chrome = (window as any).G7Core?.__LayoutEditorChrome;
|
|
|
|
if (!chrome) {
|
|
layoutEditorLoadPromise = null;
|
|
logger.error(`편집기 번들 로드됨 — 그러나 __LayoutEditorChrome 미노출 (${src})`);
|
|
|
|
throw new Error('편집기를 불러왔으나 초기화하지 못했습니다. 페이지를 새로고침해 주세요.');
|
|
}
|
|
|
|
return chrome;
|
|
})();
|
|
|
|
return layoutEditorLoadPromise;
|
|
}
|
|
|
|
/**
|
|
* 템플릿 렌더링
|
|
*
|
|
* 레이아웃 JSON을 기반으로 React 컴포넌트를 렌더링합니다.
|
|
*
|
|
* @param options - 렌더링 옵션
|
|
* @throws {Error} 초기화되지 않은 경우 또는 렌더링 실패 시
|
|
*/
|
|
async function renderTemplate(options: RenderOptions): Promise<void> {
|
|
try {
|
|
logger.log('템플릿 렌더링 시작', options);
|
|
|
|
// 초기화 확인
|
|
if (!state.isInitialized) {
|
|
throw new Error('템플릿 엔진이 초기화되지 않았습니다. initTemplateEngine()을 먼저 호출하세요.');
|
|
}
|
|
|
|
// 옵션 검증
|
|
if (!options.containerId) {
|
|
throw new Error('containerId는 필수입니다.');
|
|
}
|
|
|
|
if (!options.layoutJson) {
|
|
throw new Error('layoutJson은 필수입니다.');
|
|
}
|
|
|
|
// DOM 컨테이너 확인
|
|
const container = document.getElementById(options.containerId);
|
|
if (!container) {
|
|
throw new Error(`컨테이너를 찾을 수 없습니다: #${options.containerId}`);
|
|
}
|
|
|
|
// 전역 변수 생성
|
|
const globalVariables = createGlobalVariables();
|
|
|
|
// 상태 업데이트 (전역 변수를 dataContext에 병합)
|
|
state.containerId = options.containerId;
|
|
state.currentLayoutJson = options.layoutJson;
|
|
state.currentDataContext = {
|
|
...globalVariables, // 전역 변수 (낮은 우선순위)
|
|
...(options.dataContext || {}), // 사용자 제공 데이터 (높은 우선순위)
|
|
};
|
|
state.translationContext = options.translationContext || {
|
|
templateId: state.templateId || '',
|
|
locale: state.locale,
|
|
};
|
|
|
|
// React Root 생성 또는 재사용
|
|
if (!state.reactRoot) {
|
|
logger.log('React Root 생성');
|
|
state.reactRoot = ReactDOM.createRoot(container);
|
|
}
|
|
|
|
// 레이아웃 편집기 모드 분기
|
|
// URL 이 `/admin/layout-editor/:identifier` 패턴이면 LayoutEditorChrome 을
|
|
// 같은 state.reactRoot + 같은 코어 컨텍스트 래퍼 안에서 렌더한다.
|
|
// 별도 createRoot / 별도 DOM 컨테이너 / 별도 컨텍스트 트리 일체 금지.
|
|
if (typeof window !== 'undefined') {
|
|
const editorMode = checkLayoutEditorMode(window.location.pathname);
|
|
if (editorMode) {
|
|
logger.log('레이아웃 편집기 모드 진입', editorMode);
|
|
|
|
// 편집기 lazy 번들 로드 (layout-editor.min.js) — 진입 시에만 로드
|
|
let LayoutEditorChrome: any;
|
|
try {
|
|
LayoutEditorChrome = await loadLayoutEditorBundle();
|
|
} catch (loadError) {
|
|
logger.error('레이아웃 편집기 번들 로드 실패', loadError);
|
|
// 컨테이너에 직접 에러 화면 렌더 (CSS 의존성 없는 인라인 스타일)
|
|
ErrorDisplay.render(options.containerId, {
|
|
title: '레이아웃 편집기 로드 실패',
|
|
message:
|
|
loadError instanceof Error
|
|
? loadError.message
|
|
: '편집기 번들을 불러오지 못했습니다.',
|
|
icon: 'fas fa-triangle-exclamation',
|
|
showStack: false,
|
|
showReloadButton: true,
|
|
debug: debugMode,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const chrome = React.createElement(LayoutEditorChrome, {
|
|
templateIdentifier: editorMode.templateIdentifier,
|
|
initialLocale: state.locale,
|
|
});
|
|
state.reactRoot.render(
|
|
React.createElement(
|
|
TranslationProvider,
|
|
{
|
|
translationEngine: state.translationEngine!,
|
|
translationContext: state.translationContext,
|
|
children: React.createElement(
|
|
TransitionProvider,
|
|
{
|
|
children: React.createElement(
|
|
ResponsiveProvider,
|
|
{
|
|
children: React.createElement(SlotProvider, { children: chrome }),
|
|
}
|
|
),
|
|
}
|
|
),
|
|
} as any
|
|
)
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// 레이아웃 JSON의 components 배열 가져오기
|
|
const components = options.layoutJson.components || [];
|
|
|
|
if (components.length === 0) {
|
|
logger.warn('렌더링할 컴포넌트가 없습니다.');
|
|
return;
|
|
}
|
|
|
|
// 레이아웃 JSON의 modals 배열 가져오기
|
|
const modals = options.layoutJson.modals || [];
|
|
logger.log('modals 배열:', modals);
|
|
logger.log('modals 개수:', modals.length);
|
|
|
|
// 루트 컴포넌트 렌더링 (TransitionProvider, ResponsiveProvider로 래핑하여 전환/반응형 상태 전파)
|
|
logger.log('DynamicRenderer로 렌더링 시작');
|
|
|
|
state.reactRoot.render(
|
|
React.createElement(
|
|
TranslationProvider,
|
|
{
|
|
translationEngine: state.translationEngine!,
|
|
translationContext: state.translationContext,
|
|
},
|
|
React.createElement(
|
|
TransitionProvider,
|
|
null,
|
|
React.createElement(
|
|
ResponsiveProvider,
|
|
null,
|
|
React.createElement(
|
|
SlotProvider,
|
|
null,
|
|
[
|
|
// 일반 컴포넌트 렌더링
|
|
...components.map((componentDef: any, index: number) => {
|
|
// @since engine-v1.24.5 레이아웃 이름을 key에 포함하여
|
|
// SPA 네비게이션 시 동일 base layout 공유 페이지 간 React 강제 remount
|
|
// @since engine-v1.24.8 _fromBase 컴포넌트는 stable key → 보존(update)
|
|
const layoutKey = state.currentLayoutJson?.layout_name || '';
|
|
return React.createElement(DynamicRenderer, {
|
|
key: (layoutKey && !componentDef._fromBase) ? `${componentDef.id}_${layoutKey}` : componentDef.id,
|
|
componentDef,
|
|
dataContext: state.currentDataContext,
|
|
translationContext: state.translationContext,
|
|
registry: state.registry!,
|
|
bindingEngine: state.bindingEngine!,
|
|
translationEngine: state.translationEngine!,
|
|
actionDispatcher: state.actionDispatcher!,
|
|
isRootRenderer: index === 0,
|
|
layoutKey,
|
|
});
|
|
}),
|
|
// 모달 렌더링 (ParentContextProvider로 감싸서 $parent._local 변경 시 모달만 리렌더링)
|
|
// modalStack 상태에 따라 isOpen 및 onClose 자동 주입
|
|
// 멀티 모달(중첩 모달) 지원: 스택에 있는 모든 모달이 렌더링됨
|
|
// data_sources가 정의된 모달은 ModalDataSourceWrapper로 감싸서 열릴 때 API 호출
|
|
React.createElement(
|
|
ParentContextProvider,
|
|
{ key: '__modal_parent_context_provider' },
|
|
modals.map((modalDef: any) => {
|
|
const modalStack = state.currentDataContext._global?.modalStack || [];
|
|
const isInStack = modalStack.includes(modalDef.id);
|
|
// 하위 호환성: modalStack이 없으면 activeModal 사용
|
|
const isOpen = isInStack || state.currentDataContext._global?.activeModal === modalDef.id;
|
|
// z-index는 스택 내 위치에 따라 동적으로 결정
|
|
const stackIndex = modalStack.indexOf(modalDef.id);
|
|
const zIndex = stackIndex >= 0 ? 50 + stackIndex : 50;
|
|
|
|
// 부모 컨텍스트 가져오기 ($parent 바인딩 지원)
|
|
// 모달 스택에서 현재 모달의 위치를 기반으로 부모 컨텍스트를 찾음
|
|
const layoutContextStack: Array<{
|
|
state: Record<string, any>;
|
|
setState: (updates: any) => void;
|
|
dataContext?: Record<string, any>;
|
|
}> = (window as any).__g7LayoutContextStack || [];
|
|
// 스택의 마지막 항목이 모달을 연 시점의 부모 컨텍스트
|
|
const parentContextEntry = layoutContextStack[layoutContextStack.length - 1];
|
|
const parentDataContext = parentContextEntry?.dataContext;
|
|
|
|
// 모달 렌더러 생성
|
|
const modalRenderer = React.createElement(DynamicRenderer, {
|
|
key: `modal_${modalDef.id}_renderer`,
|
|
componentDef: {
|
|
...modalDef,
|
|
props: {
|
|
...modalDef.props,
|
|
isOpen,
|
|
// z-index를 스택 위치에 따라 설정
|
|
style: {
|
|
...modalDef.props?.style,
|
|
zIndex,
|
|
},
|
|
// onClose는 closeModal 액션으로 연결
|
|
onClose: state.actionDispatcher?.createHandler(
|
|
{ type: 'click', handler: 'closeModal' },
|
|
state.currentDataContext
|
|
),
|
|
},
|
|
},
|
|
dataContext: state.currentDataContext,
|
|
translationContext: state.translationContext,
|
|
registry: state.registry!,
|
|
bindingEngine: state.bindingEngine!,
|
|
translationEngine: state.translationEngine!,
|
|
actionDispatcher: state.actionDispatcher!,
|
|
// $parent 바인딩을 위해 부모 데이터 컨텍스트 전달
|
|
parentDataContext,
|
|
});
|
|
|
|
// data_sources가 있으면 ModalDataSourceWrapper로 감싸기
|
|
if (modalDef.data_sources && modalDef.data_sources.length > 0) {
|
|
return React.createElement(ModalDataSourceWrapper, {
|
|
key: `modal_${modalDef.id}`,
|
|
isOpen,
|
|
modalId: modalDef.id,
|
|
dataSources: modalDef.data_sources,
|
|
dataContext: state.currentDataContext,
|
|
globalStateUpdater: state.actionDispatcher?.getGlobalStateUpdater(),
|
|
bindingEngine: state.bindingEngine!,
|
|
debug: debugMode,
|
|
children: modalRenderer,
|
|
});
|
|
}
|
|
|
|
return modalRenderer;
|
|
})
|
|
), // ParentContextProvider 종료
|
|
]
|
|
) // SlotProvider 종료
|
|
) // ResponsiveProvider 종료
|
|
) // TransitionProvider 종료
|
|
) // TranslationProvider 종료
|
|
);
|
|
|
|
logger.log('템플릿 렌더링 완료');
|
|
|
|
// React 준비 전에 큐에 저장된 데이터 업데이트 적용
|
|
if (pendingDataUpdates.length > 0) {
|
|
logger.log(`대기 중인 데이터 업데이트 ${pendingDataUpdates.length}건 적용`);
|
|
const updates = [...pendingDataUpdates];
|
|
pendingDataUpdates.length = 0; // 큐 비우기
|
|
|
|
// 큐에 있는 모든 데이터를 순차적으로 적용
|
|
for (const { data, options: updateOptions } of updates) {
|
|
updateTemplateData(data, updateOptions);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.error('템플릿 렌더링 실패', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 템플릿 데이터 업데이트
|
|
*
|
|
* 현재 렌더링된 템플릿의 데이터 컨텍스트를 업데이트하고 재렌더링합니다.
|
|
*
|
|
* @param data - 업데이트할 데이터
|
|
* @param options - 업데이트 옵션 (sync: true면 startTransition 없이 즉시 렌더링)
|
|
* @throws {Error} 초기화되지 않은 경우 또는 렌더링되지 않은 경우
|
|
*/
|
|
function updateTemplateData(data: Record<string, any>, options?: UpdateOptions): void {
|
|
try {
|
|
logger.log('템플릿 데이터 업데이트 시작', Object.keys(data));
|
|
|
|
// 초기화 확인
|
|
if (!state.isInitialized) {
|
|
throw new Error('템플릿 엔진이 초기화되지 않았습니다.');
|
|
}
|
|
|
|
// 렌더링 확인 - React가 준비되지 않았으면 큐에 저장
|
|
if (!state.reactRoot || !state.currentLayoutJson) {
|
|
logger.log('React 미준비 - 데이터 업데이트 큐잉:', Object.keys(data));
|
|
pendingDataUpdates.push({ data, options });
|
|
return;
|
|
}
|
|
|
|
// 전역 변수 재생성
|
|
const globalVariables = createGlobalVariables();
|
|
|
|
// 데이터 병합 (전역 변수 유지)
|
|
// _global 객체는 명시적으로 깊은 병합 수행
|
|
const mergedGlobalState = {
|
|
...(state.currentDataContext._global || {}),
|
|
...(data._global || {}),
|
|
};
|
|
|
|
// _localInit도 얕은 스프레드로 교체하면 안 됨 (engine-v1.52.2)
|
|
// progressive 데이터소스가 둘 이상이면 각자 독립적으로 updateTemplateData를 호출하는데,
|
|
// 소비부(DynamicRenderer의 useEffect)는 React commit 이후에 실행된다.
|
|
// 두 호출이 같은 commit 사이에 들어오면 나중 payload가 슬롯을 통째로 교체하여
|
|
// 먼저 도착한 소스의 initLocal이 한 번도 관측되지 않고 사라진다.
|
|
// → 아직 관측되지 않은(unconsumed) 슬롯만 누적 병합한다. 상세: localInitSlot.ts
|
|
const mergedLocalInit = mergeLocalInitSlot(state.currentDataContext._localInit, data._localInit);
|
|
|
|
state.currentDataContext = {
|
|
...globalVariables, // 전역 변수 (낮은 우선순위)
|
|
...state.currentDataContext, // 기존 데이터
|
|
...data, // 새 데이터 (높은 우선순위)
|
|
_global: mergedGlobalState, // _global은 명시적으로 깊은 병합된 값 사용
|
|
_localInit: mergedLocalInit, // _localInit은 소비 전이면 누적 병합된 값 사용
|
|
};
|
|
|
|
// _local 및 _computed는 _global의 값을 canonical source로 동기화
|
|
// SPA 네비게이션 시 이전 페이지의 stale _local/_computed가 top-level에 잔존하는 것을 방지
|
|
// (renderTemplate → updateTemplateData 트리 구조 통일 후 React가 컴포넌트를 보존하면서 발생)
|
|
if (mergedGlobalState._local !== undefined) {
|
|
state.currentDataContext._local = mergedGlobalState._local;
|
|
}
|
|
if (mergedGlobalState._computed !== undefined) {
|
|
state.currentDataContext._computed = mergedGlobalState._computed;
|
|
}
|
|
|
|
const components = state.currentLayoutJson.components || [];
|
|
const modals = state.currentLayoutJson.modals || [];
|
|
logger.log('updateTemplateData - modals 배열:', modals);
|
|
logger.log('updateTemplateData - modals 개수:', modals.length);
|
|
logger.log('updateTemplateData - activeModal:', state.currentDataContext._global?.activeModal);
|
|
|
|
// 렌더링 함수 정의 (startTransition 유무에 따라 재사용)
|
|
const doRender = () => {
|
|
state.reactRoot!.render(
|
|
React.createElement(
|
|
TranslationProvider,
|
|
{
|
|
translationEngine: state.translationEngine!,
|
|
translationContext: state.translationContext,
|
|
},
|
|
React.createElement(
|
|
TransitionProvider,
|
|
null,
|
|
React.createElement(
|
|
ResponsiveProvider,
|
|
null,
|
|
React.createElement(
|
|
SlotProvider,
|
|
null,
|
|
[
|
|
// 일반 컴포넌트 렌더링
|
|
...components.map((componentDef: any, index: number) => {
|
|
// @since engine-v1.24.5 레이아웃 이름을 key에 포함하여
|
|
// SPA 네비게이션 시 동일 base layout 공유 페이지 간 React 강제 remount
|
|
// @since engine-v1.24.8 _fromBase 컴포넌트는 stable key → 보존(update)
|
|
const layoutKey = state.currentLayoutJson?.layout_name || '';
|
|
return React.createElement(DynamicRenderer, {
|
|
key: (layoutKey && !componentDef._fromBase) ? `${componentDef.id}_${layoutKey}` : componentDef.id,
|
|
componentDef,
|
|
dataContext: state.currentDataContext,
|
|
translationContext: state.translationContext,
|
|
registry: state.registry!,
|
|
bindingEngine: state.bindingEngine!,
|
|
translationEngine: state.translationEngine!,
|
|
actionDispatcher: state.actionDispatcher!,
|
|
isRootRenderer: index === 0,
|
|
layoutKey,
|
|
});
|
|
}),
|
|
// 모달 렌더링 (ParentContextProvider로 감싸서 $parent._local 변경 시 모달만 리렌더링)
|
|
// modalStack 상태에 따라 isOpen 및 onClose 자동 주입
|
|
// 멀티 모달(중첩 모달) 지원: 스택에 있는 모든 모달이 렌더링됨
|
|
// data_sources가 정의된 모달은 ModalDataSourceWrapper로 감싸서 열릴 때 API 호출
|
|
React.createElement(
|
|
ParentContextProvider,
|
|
{ key: '__modal_parent_context_provider_update' },
|
|
modals.map((modalDef: any) => {
|
|
const modalStack = state.currentDataContext._global?.modalStack || [];
|
|
const isInStack = modalStack.includes(modalDef.id);
|
|
// 하위 호환성: modalStack이 없으면 activeModal 사용
|
|
const isOpen = isInStack || state.currentDataContext._global?.activeModal === modalDef.id;
|
|
// z-index는 스택 내 위치에 따라 동적으로 결정
|
|
const stackIndex = modalStack.indexOf(modalDef.id);
|
|
const zIndex = stackIndex >= 0 ? 50 + stackIndex : 50;
|
|
|
|
// 부모 컨텍스트 가져오기 ($parent 바인딩 지원)
|
|
const layoutContextStack2: Array<{
|
|
state: Record<string, any>;
|
|
setState: (updates: any) => void;
|
|
dataContext?: Record<string, any>;
|
|
}> = (window as any).__g7LayoutContextStack || [];
|
|
const parentContextEntry2 = layoutContextStack2[layoutContextStack2.length - 1];
|
|
const parentDataContext2 = parentContextEntry2?.dataContext;
|
|
|
|
// 모달 렌더러 생성
|
|
const modalRenderer = React.createElement(DynamicRenderer, {
|
|
key: `modal_${modalDef.id}_renderer`,
|
|
componentDef: {
|
|
...modalDef,
|
|
props: {
|
|
...modalDef.props,
|
|
isOpen,
|
|
// z-index를 스택 위치에 따라 설정
|
|
style: {
|
|
...modalDef.props?.style,
|
|
zIndex,
|
|
},
|
|
// onClose는 closeModal 액션으로 연결
|
|
onClose: state.actionDispatcher?.createHandler(
|
|
{ type: 'click', handler: 'closeModal' },
|
|
state.currentDataContext
|
|
),
|
|
},
|
|
},
|
|
dataContext: state.currentDataContext,
|
|
translationContext: state.translationContext,
|
|
registry: state.registry!,
|
|
bindingEngine: state.bindingEngine!,
|
|
translationEngine: state.translationEngine!,
|
|
actionDispatcher: state.actionDispatcher!,
|
|
// $parent 바인딩을 위해 부모 데이터 컨텍스트 전달
|
|
parentDataContext: parentDataContext2,
|
|
});
|
|
|
|
// data_sources가 있으면 ModalDataSourceWrapper로 감싸기
|
|
if (modalDef.data_sources && modalDef.data_sources.length > 0) {
|
|
return React.createElement(ModalDataSourceWrapper, {
|
|
key: `modal_${modalDef.id}`,
|
|
isOpen,
|
|
modalId: modalDef.id,
|
|
dataSources: modalDef.data_sources,
|
|
dataContext: state.currentDataContext,
|
|
globalStateUpdater: state.actionDispatcher?.getGlobalStateUpdater(),
|
|
bindingEngine: state.bindingEngine!,
|
|
debug: debugMode,
|
|
children: modalRenderer,
|
|
});
|
|
}
|
|
|
|
return modalRenderer;
|
|
})
|
|
), // ParentContextProvider 종료
|
|
]
|
|
) // SlotProvider 종료
|
|
) // ResponsiveProvider 종료
|
|
) // TransitionProvider 종료
|
|
) // TranslationProvider 종료
|
|
);
|
|
};
|
|
|
|
// sync 옵션에 따라 렌더링 방식 선택
|
|
if (options?.sync) {
|
|
// 동기 모드: startTransition 없이 즉시 렌더링 (드래그 앤 드롭 등 즉각적인 UI 반영 필요 시)
|
|
logger.log('재렌더링 시작 (sync mode - 즉시 렌더링)');
|
|
doRender();
|
|
} else {
|
|
// 기본 모드: startTransition으로 래핑하여 깜빡임 방지
|
|
logger.log('재렌더링 시작 (with startTransition)');
|
|
startTransition(() => {
|
|
doRender();
|
|
});
|
|
}
|
|
|
|
logger.log('템플릿 데이터 업데이트 완료');
|
|
} catch (error) {
|
|
logger.error('템플릿 데이터 업데이트 실패', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 템플릿 정리
|
|
*
|
|
* React Root를 언마운트하고 모든 상태를 초기화합니다.
|
|
*/
|
|
function destroyTemplate(): void {
|
|
try {
|
|
logger.log('템플릿 정리 시작');
|
|
|
|
// React Root 언마운트
|
|
if (state.reactRoot) {
|
|
logger.log('React Root 언마운트');
|
|
state.reactRoot.unmount();
|
|
state.reactRoot = null;
|
|
}
|
|
|
|
// 상태 초기화
|
|
state.templateId = null;
|
|
state.locale = 'ko';
|
|
state.isInitialized = false;
|
|
state.containerId = null;
|
|
state.currentLayoutJson = null;
|
|
state.currentDataContext = {};
|
|
state.translationContext = {
|
|
templateId: '',
|
|
locale: 'ko',
|
|
};
|
|
state.registry = null;
|
|
state.bindingEngine = null;
|
|
state.translationEngine = null;
|
|
state.actionDispatcher = null;
|
|
state.templateMetadata = null;
|
|
|
|
logger.log('템플릿 정리 완료');
|
|
} catch (error) {
|
|
logger.error('템플릿 정리 실패', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 현재 상태 조회
|
|
*
|
|
* 읽기 전용 상태 객체를 반환합니다.
|
|
*
|
|
* @returns 현재 템플릿 엔진 상태
|
|
*/
|
|
function getState(): Readonly<TemplateEngineState> {
|
|
return Object.freeze({ ...state });
|
|
}
|
|
|
|
/**
|
|
* ActionDispatcher 인스턴스 조회
|
|
*
|
|
* @returns ActionDispatcher 인스턴스 또는 null
|
|
*/
|
|
function getActionDispatcher(): ActionDispatcher | null {
|
|
return state.actionDispatcher;
|
|
}
|
|
|
|
/**
|
|
* 템플릿 엔진 공개 API
|
|
*/
|
|
const TemplateEngine: TemplateEngineAPI = {
|
|
initTemplateEngine,
|
|
renderTemplate,
|
|
updateTemplateData,
|
|
destroyTemplate,
|
|
getState,
|
|
};
|
|
|
|
/**
|
|
* 전역 객체에 노출
|
|
*
|
|
* G7Core 전역 API는 G7CoreGlobals.ts 모듈로 분리되어 있습니다.
|
|
*/
|
|
if (typeof window !== 'undefined') {
|
|
// G7Core 네임스페이스가 없으면 생성 (TemplateApp.ts에서 이미 생성했을 수 있음)
|
|
if (!(window as any).G7Core) {
|
|
(window as any).G7Core = {};
|
|
}
|
|
|
|
// TemplateEngine 노출
|
|
(window as any).G7Core.TemplateEngine = TemplateEngine;
|
|
|
|
// G7Core 전역 API 초기화
|
|
initializeG7CoreGlobals({
|
|
getState: () => ({
|
|
translationEngine: state.translationEngine,
|
|
translationContext: state.translationContext,
|
|
bindingEngine: state.bindingEngine,
|
|
actionDispatcher: state.actionDispatcher,
|
|
templateMetadata: state.templateMetadata,
|
|
}),
|
|
transitionManager,
|
|
responsiveManager,
|
|
webSocketManager,
|
|
});
|
|
|
|
logger.log('전역 객체 window.G7Core.TemplateEngine에 노출됨');
|
|
}
|
|
|
|
/**
|
|
* ESM export
|
|
*/
|
|
export default TemplateEngine;
|
|
export {
|
|
TemplateEngine,
|
|
initTemplateEngine,
|
|
renderTemplate,
|
|
updateTemplateData,
|
|
destroyTemplate,
|
|
getState,
|
|
getActionDispatcher,
|
|
LayoutLoader,
|
|
DataSourceManager,
|
|
// @since engine-v1.51.0 편집기 lazy 번들 로더 (테스트/진단용 노출)
|
|
loadLayoutEditorBundle,
|
|
};
|
|
|
|
export type {
|
|
TemplateEngineAPI,
|
|
TemplateEngineState,
|
|
InitOptions,
|
|
RenderOptions,
|
|
UpdateOptions,
|
|
};
|
|
|
|
export type { LayoutData, LayoutComponent } from './template-engine/LayoutLoader';
|
|
export type { DataSource, DataSourceType, ConditionContext } from './template-engine/DataSourceManager';
|
|
|
|
// TemplateApp export (이미 상단에서 import됨)
|
|
export { TemplateApp, initTemplateApp };
|
|
export type { TemplateAppConfig } from './TemplateApp';
|
|
export type { Route } from './routing/Router';
|
|
|
|
// TemplateApp.ts 모듈에서 이미 전역 노출 처리함 (Object.defineProperty getter 사용)
|