Files
Gnuboard7/resources/js/core/assets/AssetFailureNotice.ts
T
HeuJung 0833bd1f6a fix(core,engine): 확장 번들 503 판정을 선언 축에서 소실 축으로 교체
빈 번들 503 을 "에셋을 선언한 활성 확장이 있는데 결과가 비었다" 로 판정해,
스타일 규칙이 아직 없는 0바이트 CSS 만 선언된 기본 구성이 통째로 503 이 됐다.
file_get_contents 는 0바이트에서 false 가 아니라 '' 를 돌려주므로 읽기 실패
분기도 타지 않는다. 선언 축 게터는 전부 file_exists 게이트라 부재를 셀 수 없어,
존재 게이트가 없는 getDeclaredAssetAbsolutePaths 를 통로로 두고 503 의 근거를
소실 축(findMissingDeclaredAssets)으로 옮긴다. 존재하되 비었으면 빈 200 이다.

곁들여: 실패 배너 항목명을 내부 구분 키에서 사용자 어휘로(engine-v1.64.7),
봇 화면이 없는 템플릿 CSS 를 링크하지 않도록 실재 게이트, 매니페스트 자산
선언 드리프트 4건 정정.
2026-09-04 15:36:33 +09:00

575 lines
18 KiB
TypeScript

/**
* 구동 자산 로드 실패 안내 배너
*
* 화면을 그리는 데 필요한 자산(에디터 본체·아이콘 폰트·코드 편집기 등)을 끝내
* 불러오지 못했을 때, 사용자가 그 사실을 알고 조치할 수 있게 하는 배너다.
*
* 종전에는 이 계층이 아예 없었다 — 로드 실패는 `console.warn` 이나 조용한 `resolve()`
* 로 삼켜져, 사용자에게는 "버튼이 안 눌린다" · "입력창 자리가 비어 있다" 로만 나타났다.
* 자체 서버 로그에도 흔적이 없어 운영자가 원인을 특정할 수 없었다.
*
* 이 모듈은 호스트 컴포넌트에 의존하지 않고 DOM 에 직접 주입한다. Toast·Modal 은
* 베이스 레이아웃이 마운트한 호스트 컴포넌트가 있어야 화면에 뜨는데, 독립 레이아웃
* (`extends` 없음 — 예: `admin_login.json`)에는 그 호스트가 없다. 자산 실패는 바로
* 그런 화면에서도 알려야 한다.
*
* Tailwind 클래스가 아니라 인라인 스타일을 쓰는 이유: 이 번들(코어 엔진)은 템플릿의
* Tailwind 빌드 CSS 와 별개라, 여기서 처음 등장하는 클래스는 어느 템플릿의 CSS 에도
* 컴파일되어 있지 않다. 자산이 실패한 상황에서 스타일까지 없으면 안내 자체가 안 보인다.
*
* @module AssetFailureNotice
* @since engine-v1.62.0
*/
import { createLogger } from '../utils/Logger';
const logger = createLogger('AssetFailureNotice');
/** 배너 컨테이너 element id */
const CONTAINER_ID = 'g7-asset-failure-notice';
/** 시스템 배너(SystemBannerManager) 컨테이너 id — 겹치지 않게 아래로 내리기 위해 참조 */
const SYSTEM_BANNER_CONTAINER_ID = 'g7-system-banners';
/**
* 자산 로드 실패 1건
*
* @since engine-v1.62.0
*/
export interface AssetFailure {
/** 중복 누적을 막는 식별자 (같은 id 는 갱신) */
id: string;
/** 짧은 항목명 (여러 건 합산 표시에 쓰인다) */
label: string;
/** 사용자에게 보일 안내 문장. 생략하면 label 기반 기본 문구 */
message?: string;
/**
* [다시 시도] 동작. 생략하면 버튼을 렌더하지 않는다.
* resolve 되면 해당 실패를 해제하고, reject 되면 배너를 유지한 채 재시도 실패를 알린다.
*/
retry?: () => void | Promise<void>;
}
/** 등록된 실패 목록 (id → 실패) */
const failures = new Map<string, AssetFailure>();
/** 재시도 진행 중 여부 */
let retrying = false;
/** 마지막 재시도가 실패했는지 */
let retryFailed = false;
/** documentElement 의 다크 클래스 변화를 감시하는 옵저버 */
let themeObserver: MutationObserver | null = null;
/**
* 번역 문자열을 얻습니다.
*
* 자산 실패는 부팅 초기에도 발생할 수 있어 번역 엔진이 아직 없을 수 있다.
* 그때는 키 대신 읽을 수 있는 폴백 문구를 쓴다 — 안내 배너에 `core.assets.retry`
* 같은 키가 그대로 노출되면 안내로서 기능하지 못한다.
*
* @param key 번역 키
* @param fallback 번역 엔진 부재 시 사용할 문구
* @param params 치환 파라미터
* @return string 번역된 문자열
* @since engine-v1.62.0
*/
function t(key: string, fallback: string, params?: Record<string, string | number>): string {
try {
const translate = (window as any)?.G7Core?.t;
if (typeof translate === 'function') {
const result = translate(key, params);
// 번역 미적재 시 키 자체가 돌아온다 — 그 경우 폴백을 쓴다
if (typeof result === 'string' && result !== key && !result.startsWith('core.assets.')) {
return result;
}
}
} catch {
// 번역 실패는 안내 표시를 막을 이유가 못 된다
}
return substitute(fallback, params);
}
/**
* 폴백 문구의 자리표시자를 치환합니다.
*
* @param text 원본 문구
* @param params 치환 파라미터
* @return string 치환된 문구
* @since engine-v1.62.0
*/
function substitute(text: string, params?: Record<string, string | number>): string {
if (!params) {
return text;
}
return Object.entries(params).reduce(
(acc, [key, value]) => acc.split('{' + key + '}').join(String(value)),
text
);
}
/**
* 자산 로드 실패를 알립니다.
*
* 같은 `id` 로 다시 호출하면 누적하지 않고 갱신한다.
*
* @param failure 실패 정보
* @return void
* @since engine-v1.62.0
*/
export function notifyAssetFailure(failure: AssetFailure): void {
if (!failure || !failure.id) {
return;
}
failures.set(failure.id, failure);
retryFailed = false;
logger.warn(`Asset failure notified: ${failure.id} (${failure.label})`);
render();
}
/**
* 자산 실패를 해제합니다 (복구되었을 때).
*
* @param id 실패 식별자
* @return void
* @since engine-v1.62.0
*/
export function clearAssetFailure(id: string): void {
if (failures.delete(id)) {
logger.log(`Asset failure cleared: ${id}`);
render();
}
}
/**
* 모든 자산 실패를 해제합니다.
*
* @return void
* @since engine-v1.62.0
*/
export function clearAllAssetFailures(): void {
if (failures.size === 0) {
return;
}
failures.clear();
render();
}
/**
* 현재 등록된 자산 실패 목록을 돌려줍니다.
*
* 테스트·E2E 가 "실패가 표면화되었는가" 를 화면 문자열이 아니라 상태로 단언할 수 있게 한다.
*
* @return AssetFailure[] 실패 목록 (등록 순서)
* @since engine-v1.62.0
*/
export function getAssetFailures(): AssetFailure[] {
return Array.from(failures.values());
}
/**
* 자산 실패 안내에 쓰는 사용자 어휘를 번역합니다 (배너 밖 호출자용).
*
* 항목명(label)은 사용자 어휘여야 한다 — 내부 구분 키·식별자를 그대로 넘기면 배너에
* "module을(를) 불러오지 못했습니다" 처럼 내부 이름이 노출된다.
*
* @param key 번역 키
* @param fallback 번역 엔진 부재 시 사용할 문구
* @param params 치환 파라미터
* @return string 번역된 문자열
* @since engine-v1.64.7
*/
export function assetText(key: string, fallback: string, params?: Record<string, string | number>): string {
return t(key, fallback, params);
}
/**
* 등록된 실패 중 재시도 가능한 것을 모두 재시도합니다.
*
* @return Promise<void>
* @since engine-v1.62.0
*/
export async function retryAssetFailures(): Promise<void> {
if (retrying) {
return;
}
retrying = true;
retryFailed = false;
render();
const targets = Array.from(failures.values()).filter(item => typeof item.retry === 'function');
let anyFailed = false;
for (const target of targets) {
try {
await target.retry!();
failures.delete(target.id);
} catch (error) {
anyFailed = true;
logger.warn(`Asset retry failed: ${target.id}`, error);
}
}
retrying = false;
retryFailed = anyFailed;
render();
}
/**
* 다크 모드 여부를 판정합니다.
*
* @return bool 다크 모드이면 true
* @since engine-v1.62.0
*/
function isDarkMode(): boolean {
try {
return document.documentElement.classList.contains('dark');
} catch {
return false;
}
}
/**
* 배너를 DOM 에 렌더링합니다.
*
* @return void
* @since engine-v1.62.0
*/
function render(): void {
if (typeof document === 'undefined' || !document.body) {
return;
}
const existing = document.getElementById(CONTAINER_ID);
if (failures.size === 0) {
existing?.remove();
stopThemeObserver();
return;
}
const container = existing ?? createContainer();
applyContainerStyle(container);
container.replaceChildren(buildContent());
startThemeObserver();
}
/**
* 배너 컨테이너를 생성해 body 에 붙입니다.
*
* @return HTMLElement 컨테이너
* @since engine-v1.62.0
*/
function createContainer(): HTMLElement {
const container = document.createElement('div');
container.id = CONTAINER_ID;
container.setAttribute('role', 'alert');
container.setAttribute('aria-live', 'polite');
document.body.appendChild(container);
return container;
}
/**
* 컨테이너 스타일을 적용합니다.
*
* 시스템 배너(유지보수·프리뷰)가 떠 있으면 그 아래로 내려 겹치지 않게 한다.
* 콘텐츠는 밀어내지 않는다 — 자산 실패는 화면 레이아웃을 바꿔야 할 만큼의 사건이 아니고,
* 밀어내면 폴백 입력창의 위치가 흔들린다.
*
* @param container 컨테이너 element
* @return void
* @since engine-v1.62.0
*/
function applyContainerStyle(container: HTMLElement): void {
const systemBanners = document.getElementById(SYSTEM_BANNER_CONTAINER_ID);
const offset = systemBanners?.offsetHeight ?? 0;
container.style.cssText = [
'position:fixed',
`top:${offset}px`,
'left:0',
'right:0',
'z-index:9998',
'display:flex',
'justify-content:center',
'pointer-events:none',
].join(';');
}
/**
* 배너 본문을 구성합니다.
*
* `innerHTML` 을 쓰지 않고 element 를 조립한다 — 문구에 확장이 넘긴 label 이 섞이므로
* 문자열 조립은 주입 경로가 된다.
*
* @return HTMLElement 본문 element
* @since engine-v1.62.0
*/
function buildContent(): HTMLElement {
const dark = isDarkMode();
const items = Array.from(failures.values());
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
const box = document.createElement('div');
box.style.cssText = [
'pointer-events:auto',
'box-sizing:border-box',
'width:100%',
'max-width:960px',
'margin:8px',
'padding:12px 16px',
'border-radius:8px',
`border:1px solid ${dark ? '#78350f' : '#fcd34d'}`,
`background:${dark ? '#451a03' : '#fffbeb'}`,
`color:${dark ? '#fef3c7' : '#78350f'}`,
'box-shadow:0 4px 12px rgba(0,0,0,0.12)',
'font-size:14px',
'line-height:1.5',
'display:flex',
isMobile ? 'flex-direction:column' : 'flex-direction:row',
isMobile ? 'align-items:stretch' : 'align-items:center',
'gap:12px',
].join(';');
// 테마 색은 개별 프로퍼티로 한 번 더 못 박는다. 일괄 문자열(cssText)은 파서가
// 모르는 선언 하나로 전체가 버려지는 환경이 있어(테스트 환경의 jsdom 이 그렇다)
// 색이 통째로 빠질 수 있다 — 안내 배너가 배경 없이 뜨면 안내로서 기능하지 못한다.
box.style.background = dark ? '#451a03' : '#fffbeb';
box.style.color = dark ? '#fef3c7' : '#78350f';
box.style.borderColor = dark ? '#78350f' : '#fcd34d';
box.appendChild(buildMessageArea(items, isMobile));
box.appendChild(buildActions(items, isMobile, dark));
return box;
}
/**
* 문구 영역을 구성합니다.
*
* @param items 실패 목록
* @param isMobile 모바일 폭 여부
* @return HTMLElement 문구 영역
* @since engine-v1.62.0
*/
function buildMessageArea(items: AssetFailure[], isMobile: boolean): HTMLElement {
const wrap = document.createElement('div');
wrap.style.cssText = ['flex:1 1 auto', 'min-width:0', 'display:flex', 'gap:8px'].join(';');
const icon = document.createElement('span');
icon.setAttribute('aria-hidden', 'true');
icon.textContent = '⚠';
icon.style.cssText = 'flex:0 0 auto;font-size:16px;line-height:1.4';
wrap.appendChild(icon);
const texts = document.createElement('div');
texts.style.cssText = 'flex:1 1 auto;min-width:0';
if (items.length === 1) {
const only = items[0];
const line = document.createElement('div');
line.setAttribute('data-asset-failure-id', only.id);
line.textContent =
only.message ??
t('core.assets.load_failed', '{label}을(를) 불러오지 못했습니다.', { label: only.label });
texts.appendChild(line);
} else {
const head = document.createElement('div');
head.style.cssText = 'font-weight:600';
head.textContent = t('core.assets.load_failed_multiple', '{count}개 항목을 불러오지 못했습니다.', {
count: items.length,
});
texts.appendChild(head);
const list = document.createElement('div');
list.style.cssText = 'margin-top:2px;opacity:0.9;word-break:break-word';
list.textContent = items.map(item => item.label).join(', ');
texts.appendChild(list);
}
if (retryFailed) {
const failedLine = document.createElement('div');
failedLine.style.cssText = 'margin-top:4px;font-weight:600';
failedLine.textContent = t(
'core.assets.retry_failed',
'다시 시도했지만 실패했습니다. 잠시 후 다시 시도해 주세요.'
);
texts.appendChild(failedLine);
}
wrap.appendChild(texts);
if (isMobile) {
wrap.style.alignItems = 'flex-start';
}
return wrap;
}
/**
* 버튼 영역을 구성합니다.
*
* @param items 실패 목록
* @param isMobile 모바일 폭 여부
* @param dark 다크 모드 여부
* @return HTMLElement 버튼 영역
* @since engine-v1.62.0
*/
function buildActions(items: AssetFailure[], isMobile: boolean, dark: boolean): HTMLElement {
const actions = document.createElement('div');
actions.style.cssText = [
'flex:0 0 auto',
'display:flex',
'gap:8px',
isMobile ? 'width:100%' : '',
'align-items:center',
]
.filter(Boolean)
.join(';');
const hasRetry = items.some(item => typeof item.retry === 'function');
if (hasRetry) {
const retryButton = document.createElement('button');
retryButton.type = 'button';
retryButton.dataset.action = 'retry';
retryButton.disabled = retrying;
retryButton.textContent = retrying
? t('core.assets.retrying', '다시 시도 중...')
: t('core.assets.retry', '다시 시도');
retryButton.style.cssText = [
isMobile ? 'flex:1 1 auto' : '',
'padding:6px 14px',
'border-radius:6px',
'font-size:14px',
'font-weight:600',
'cursor:pointer',
`border:1px solid ${dark ? '#b45309' : '#d97706'}`,
`background:${dark ? '#b45309' : '#f59e0b'}`,
`color:${dark ? '#fffbeb' : '#451a03'}`,
retrying ? 'opacity:0.6' : '',
]
.filter(Boolean)
.join(';');
retryButton.addEventListener('click', () => {
void retryAssetFailures();
});
actions.appendChild(retryButton);
}
const closeButton = document.createElement('button');
closeButton.type = 'button';
closeButton.dataset.action = 'close';
closeButton.setAttribute('aria-label', t('core.assets.close', '닫기'));
closeButton.textContent = '✕';
closeButton.style.cssText = [
'padding:6px 10px',
'border-radius:6px',
'font-size:14px',
'cursor:pointer',
'border:1px solid transparent',
'background:transparent',
`color:${dark ? '#fef3c7' : '#78350f'}`,
].join(';');
closeButton.addEventListener('click', () => {
clearAllAssetFailures();
});
actions.appendChild(closeButton);
return actions;
}
/**
* 다크 모드 전환을 감시해 배너 색을 따라가게 합니다.
*
* @return void
* @since engine-v1.62.0
*/
function startThemeObserver(): void {
if (themeObserver || typeof MutationObserver === 'undefined') {
return;
}
themeObserver = new MutationObserver(() => {
if (failures.size > 0) {
render();
}
});
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'],
});
}
/**
* 다크 모드 감시를 중단합니다.
*
* @return void
* @since engine-v1.62.0
*/
function stopThemeObserver(): void {
themeObserver?.disconnect();
themeObserver = null;
}
/**
* 서버가 HTML 에 직접 심은 템플릿 externals 의 로드 실패를 배너로 흘려보냅니다.
*
* 그 태그들은 엔진 번들보다 먼저 평가되므로, 실패는 `template-externals-head` 가 심어 둔
* 부트스트랩의 대기열에 쌓인다. 엔진이 뜬 뒤 그 대기열을 비우고, 이후의 실패는 곧바로
* 배너로 가도록 sink 를 건다.
*
* 이 통로가 없으면 아이콘 폰트·글꼴 CSS 실패가 "아이콘이 0×0 으로 사라진 화면" 으로만
* 나타난다 — 아이콘만으로 조작하는 버튼이 있는 화면에서는 곧 조작 불능이고, 자체 서버
* 로그에도 흔적이 없어 운영자가 원인을 특정할 수 없다.
*
* @return void
* @since engine-v1.63.0
*/
export function drainExternalAssetFailures(): void {
const globalScope = window as any;
/**
* 부트스트랩 대기열 항목을 배너 실패 항목으로 옮깁니다.
*
* @param entry 대기열 항목
* @return 배너 실패 항목
*/
const toFailure = (entry: any): AssetFailure => ({
id: String(entry?.id ?? ''),
label: String(entry?.label ?? entry?.url ?? 'asset'),
retry: typeof entry?.retry === 'function' ? entry.retry : undefined,
});
const queue: any[] = Array.isArray(globalScope.__g7ExternalAssetFailures)
? globalScope.__g7ExternalAssetFailures
: [];
queue.forEach(entry => {
if (entry?.id) {
notifyAssetFailure(toFailure(entry));
}
});
globalScope.__g7ExternalAssetSink = (entry: any) => {
if (entry?.id) {
notifyAssetFailure(toFailure(entry));
}
};
logger.log(`템플릿 externals 실패 대기열 ${queue.length}건 반영`);
}