664 lines
25 KiB
PHP
664 lines
25 KiB
PHP
<?php
|
|
|
|
namespace App\Extension\Traits;
|
|
|
|
use App\Exceptions\LayoutIncludeException;
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
/**
|
|
* 레이아웃 JSON 파일 검증 및 partial 해석 기능을 제공하는 트레이트.
|
|
*
|
|
* 템플릿 설치 및 모듈 활성화 시 레이아웃 파일의 유효성을 검사하고
|
|
* partial 파일을 병합하는 공통 로직을 제공합니다.
|
|
*/
|
|
trait ValidatesLayoutFiles
|
|
{
|
|
/**
|
|
* Partial 순환 참조 추적용 스택
|
|
*/
|
|
private array $partialStack = [];
|
|
|
|
/**
|
|
* 레이아웃 파일들을 검증합니다.
|
|
*
|
|
* @param string $layoutsPath 레이아웃 디렉토리 경로
|
|
* @param string $identifier 소스 식별자 (템플릿 또는 모듈)
|
|
* @param string $sourceType 소스 타입 ('template' 또는 'module')
|
|
* @param bool $recursive 재귀적으로 하위 디렉토리를 스캔할지 여부
|
|
* @return array 검증된 레이아웃 데이터 배열
|
|
*
|
|
* @throws \Exception 레이아웃 검증 실패 시
|
|
*/
|
|
protected function validateLayoutFiles(string $layoutsPath, string $identifier, string $sourceType, bool $recursive = false): array
|
|
{
|
|
$validatedLayouts = [];
|
|
$errors = [];
|
|
|
|
if (! File::exists($layoutsPath)) {
|
|
return $validatedLayouts;
|
|
}
|
|
|
|
// 레이아웃 파일 스캔
|
|
if ($recursive) {
|
|
$layoutFiles = $this->scanLayoutFilesRecursively($layoutsPath);
|
|
} else {
|
|
// 루트 디렉토리의 JSON 파일만 스캔
|
|
$layoutFiles = File::glob("{$layoutsPath}/*.json");
|
|
|
|
// errors/ 디렉토리만 추가 스캔 (에러 페이지 레이아웃용)
|
|
$errorsPath = "{$layoutsPath}/errors";
|
|
if (File::exists($errorsPath)) {
|
|
$errorLayoutFiles = File::glob("{$errorsPath}/*.json");
|
|
$layoutFiles = array_merge($layoutFiles, $errorLayoutFiles);
|
|
}
|
|
}
|
|
|
|
if (empty($layoutFiles)) {
|
|
return $validatedLayouts;
|
|
}
|
|
|
|
foreach ($layoutFiles as $layoutFile) {
|
|
$relativePath = str_replace(base_path().DIRECTORY_SEPARATOR, '', $layoutFile);
|
|
// Windows/Linux 호환을 위해 슬래시로 통일
|
|
$relativePath = str_replace('\\', '/', $relativePath);
|
|
|
|
// JSON 파싱 검증
|
|
$jsonContent = File::get($layoutFile);
|
|
$layoutData = json_decode($jsonContent, true);
|
|
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
$errors[] = $this->formatLayoutValidationError($sourceType, 'json_error', [
|
|
'file' => $relativePath,
|
|
'error' => json_last_error_msg(),
|
|
]);
|
|
|
|
continue;
|
|
}
|
|
|
|
// is_partial이 true면 검증 스킵 (DB 저장 안 함, partial 전용 파일)
|
|
if (isset($layoutData['meta']['is_partial']) && $layoutData['meta']['is_partial'] === true) {
|
|
continue;
|
|
}
|
|
|
|
// 템플릿의 경우 layout_name 필수 검증
|
|
if ($sourceType === 'template' && ! isset($layoutData['layout_name'])) {
|
|
$errors[] = $this->formatLayoutValidationError($sourceType, 'missing_name', [
|
|
'file' => $relativePath,
|
|
]);
|
|
|
|
continue;
|
|
}
|
|
|
|
// 레이아웃 이름 결정
|
|
$layoutName = $layoutData['layout_name'] ?? $this->generateLayoutNameFromPath($layoutsPath, $layoutFile, $identifier);
|
|
|
|
// partial 병합 검증
|
|
$basePath = dirname($layoutFile);
|
|
$mainDataSources = $layoutData['data_sources'] ?? [];
|
|
|
|
try {
|
|
$layoutData = $this->resolveAllPartials($layoutData, $basePath, 0, $mainDataSources);
|
|
} catch (LayoutIncludeException $e) {
|
|
$errors[] = $this->formatLayoutValidationError($sourceType, 'partial_error', [
|
|
'file' => $relativePath,
|
|
'error' => $e->getMessage(),
|
|
'partial' => $e->getIncludePath(),
|
|
]);
|
|
|
|
continue;
|
|
}
|
|
|
|
// data_sources ID 중복 검증 (현재 레이아웃 내부)
|
|
$dataSourcesError = $this->validateDataSourceIds($layoutData['data_sources'] ?? [], $relativePath);
|
|
if ($dataSourcesError) {
|
|
$errors[] = $dataSourcesError;
|
|
|
|
continue;
|
|
}
|
|
|
|
// 검증 통과한 레이아웃 저장
|
|
$validatedLayouts[] = [
|
|
'file' => $layoutFile,
|
|
'data' => $layoutData,
|
|
'layout_name' => $layoutName,
|
|
];
|
|
}
|
|
|
|
// extends 상속 관계에서 data_sources ID 중복 검증
|
|
$extendsErrors = $this->validateExtendsDataSourceIds($validatedLayouts, $sourceType);
|
|
$errors = array_merge($errors, $extendsErrors);
|
|
|
|
// 오류가 하나라도 있으면 예외 발생
|
|
if (! empty($errors)) {
|
|
$errorMessage = $this->formatLayoutValidationError($sourceType, 'validation_failed', [
|
|
'identifier' => $identifier,
|
|
'count' => count($errors),
|
|
]);
|
|
$errorMessage .= "\n- ".implode("\n- ", $errors);
|
|
|
|
throw new \Exception($errorMessage);
|
|
}
|
|
|
|
return $validatedLayouts;
|
|
}
|
|
|
|
/**
|
|
* extends 상속 관계에서 data_sources ID 중복을 검증합니다.
|
|
*
|
|
* 부모 레이아웃과 자식 레이아웃 간의 data_sources ID 중복을 체크합니다.
|
|
* 설치 시점에는 DB에 레이아웃이 없으므로 파일 시스템 기반으로 검증합니다.
|
|
*
|
|
* @param array $validatedLayouts 검증된 레이아웃 배열
|
|
* @param string $sourceType 소스 타입 ('template' 또는 'module')
|
|
* @return array 에러 메시지 배열
|
|
*/
|
|
protected function validateExtendsDataSourceIds(array $validatedLayouts, string $sourceType): array
|
|
{
|
|
$errors = [];
|
|
|
|
// layout_name을 키로 하는 맵 생성
|
|
$layoutMap = [];
|
|
foreach ($validatedLayouts as $layout) {
|
|
$layoutMap[$layout['layout_name']] = $layout;
|
|
}
|
|
|
|
// 각 레이아웃의 extends 관계 검증
|
|
foreach ($validatedLayouts as $layout) {
|
|
$layoutData = $layout['data'];
|
|
$layoutName = $layout['layout_name'];
|
|
|
|
// extends가 없으면 스킵
|
|
if (! isset($layoutData['extends'])) {
|
|
continue;
|
|
}
|
|
|
|
$parentLayoutName = $layoutData['extends'];
|
|
|
|
// 부모 레이아웃이 같은 템플릿/모듈 내에 있는지 확인
|
|
if (! isset($layoutMap[$parentLayoutName])) {
|
|
// 부모가 다른 소스(DB에 이미 존재)에 있을 수 있으므로 여기서는 스킵
|
|
// 런타임에 LayoutService에서 검증됨
|
|
continue;
|
|
}
|
|
|
|
$parentLayout = $layoutMap[$parentLayoutName];
|
|
$parentDataSources = $parentLayout['data']['data_sources'] ?? [];
|
|
$childDataSources = $layoutData['data_sources'] ?? [];
|
|
|
|
// 부모-자식 간 ID 중복 체크
|
|
$parentIds = array_column($parentDataSources, 'id');
|
|
$duplicates = [];
|
|
|
|
foreach ($childDataSources as $childDataSource) {
|
|
if (isset($childDataSource['id']) && in_array($childDataSource['id'], $parentIds, true)) {
|
|
$duplicates[] = $childDataSource['id'];
|
|
}
|
|
}
|
|
|
|
if (! empty($duplicates)) {
|
|
$errors[] = __('exceptions.layout.duplicate_data_source_id_extends', [
|
|
'ids' => implode(', ', array_unique($duplicates)),
|
|
'child' => $layoutName,
|
|
'parent' => $parentLayoutName,
|
|
]);
|
|
}
|
|
}
|
|
|
|
return $errors;
|
|
}
|
|
|
|
/**
|
|
* data_sources 배열 내 ID 중복을 검증합니다.
|
|
*
|
|
* @param array $dataSources data_sources 배열
|
|
* @param string $filePath 검증 중인 파일 경로 (에러 메시지용)
|
|
* @return string|null 에러 메시지 또는 null (중복 없음)
|
|
*/
|
|
protected function validateDataSourceIds(array $dataSources, string $filePath): ?string
|
|
{
|
|
$ids = [];
|
|
$duplicates = [];
|
|
|
|
foreach ($dataSources as $dataSource) {
|
|
if (! isset($dataSource['id'])) {
|
|
continue;
|
|
}
|
|
|
|
$id = $dataSource['id'];
|
|
if (in_array($id, $ids, true)) {
|
|
$duplicates[] = $id;
|
|
} else {
|
|
$ids[] = $id;
|
|
}
|
|
}
|
|
|
|
if (! empty($duplicates)) {
|
|
return __('exceptions.layout.duplicate_data_source_id_in_file', [
|
|
'ids' => implode(', ', array_unique($duplicates)),
|
|
'file' => $filePath,
|
|
]);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 레이아웃 검증 에러 메시지를 포맷합니다.
|
|
*
|
|
* @param string $sourceType 소스 타입 ('template' 또는 'module')
|
|
* @param string $errorType 에러 타입
|
|
* @param array $params 에러 파라미터
|
|
* @return string 포맷된 에러 메시지
|
|
*/
|
|
protected function formatLayoutValidationError(string $sourceType, string $errorType, array $params): string
|
|
{
|
|
$translationKey = $sourceType === 'template'
|
|
? "templates.errors.layout_validation_{$errorType}"
|
|
: "modules.errors.layout_validation_{$errorType}";
|
|
|
|
return __($translationKey, $params);
|
|
}
|
|
|
|
/**
|
|
* 레이아웃 파일 경로에서 대상 템플릿 타입을 판별합니다.
|
|
*
|
|
* layouts/ 기준 상대 경로의 첫 번째 디렉토리 세그먼트로 판별합니다.
|
|
* - admin/ → 'admin'
|
|
* - user/ → 'user'
|
|
* - 직접 위치한 파일 → null (경고 후 스킵)
|
|
*
|
|
* @param string $layoutsPath 레이아웃 기본 경로
|
|
* @param string $layoutFile 레이아웃 파일 전체 경로
|
|
* @return string|null 템플릿 타입 ('admin', 'user') 또는 null (스킵 대상)
|
|
*/
|
|
protected function getLayoutTargetType(string $layoutsPath, string $layoutFile): ?string
|
|
{
|
|
// 슬래시로 통일하여 상대 경로 계산
|
|
$normalizedBase = rtrim(str_replace('\\', '/', $layoutsPath), '/').'/';
|
|
$normalizedFile = str_replace('\\', '/', $layoutFile);
|
|
|
|
$relativePath = str_replace($normalizedBase, '', $normalizedFile);
|
|
|
|
// 첫 번째 디렉토리 세그먼트 추출
|
|
$segments = explode('/', $relativePath);
|
|
|
|
if (count($segments) <= 1) {
|
|
// 루트에 직접 위치한 파일 (디렉토리 없음)
|
|
Log::warning("레이아웃 파일이 admin/ 또는 user/ 하위에 위치하지 않아 스킵됩니다: {$layoutFile}");
|
|
|
|
return null;
|
|
}
|
|
|
|
$firstSegment = $segments[0];
|
|
|
|
if ($firstSegment === 'admin') {
|
|
return 'admin';
|
|
}
|
|
|
|
if ($firstSegment === 'user') {
|
|
return 'user';
|
|
}
|
|
|
|
// admin/user 외 디렉토리 (예: api/)
|
|
Log::warning("레이아웃 파일이 admin/ 또는 user/ 하위에 위치하지 않아 스킵됩니다: {$layoutFile}");
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 레이아웃 디렉토리에서 모든 JSON 파일을 재귀적으로 스캔합니다.
|
|
*
|
|
* @param string $basePath 스캔할 기본 경로
|
|
* @return array<string> 레이아웃 파일 경로 배열
|
|
*/
|
|
protected function scanLayoutFilesRecursively(string $basePath): array
|
|
{
|
|
$files = [];
|
|
|
|
$iterator = new \RecursiveIteratorIterator(
|
|
new \RecursiveDirectoryIterator($basePath, \RecursiveDirectoryIterator::SKIP_DOTS),
|
|
\RecursiveIteratorIterator::LEAVES_ONLY
|
|
);
|
|
|
|
foreach ($iterator as $file) {
|
|
if ($file->isFile() && $file->getExtension() === 'json') {
|
|
$files[] = $file->getPathname();
|
|
}
|
|
}
|
|
|
|
return $files;
|
|
}
|
|
|
|
/**
|
|
* 파일 경로를 기반으로 레이아웃 이름을 생성합니다.
|
|
*
|
|
* @param string $basePath 레이아웃 기본 경로
|
|
* @param string $filePath 레이아웃 파일 경로
|
|
* @param string $identifier 소스 식별자
|
|
* @return string 생성된 레이아웃 이름
|
|
*/
|
|
protected function generateLayoutNameFromPath(string $basePath, string $filePath, string $identifier): string
|
|
{
|
|
// 상대 경로 추출 (basePath 기준)
|
|
$relativePath = str_replace($basePath.DIRECTORY_SEPARATOR, '', $filePath);
|
|
|
|
// .json 확장자 제거
|
|
$relativePath = preg_replace('/\.json$/i', '', $relativePath);
|
|
|
|
// 디렉토리 구분자를 언더스코어로 변환
|
|
$layoutPath = str_replace([DIRECTORY_SEPARATOR, '/'], '_', $relativePath);
|
|
|
|
// 레이아웃 이름 생성: {identifier}_{path}
|
|
return $identifier.'_'.$layoutPath;
|
|
}
|
|
|
|
/**
|
|
* 레이아웃 데이터 내의 모든 partial을 재귀적으로 해석합니다.
|
|
*
|
|
* @param array $data 레이아웃 데이터
|
|
* @param string $basePath 기준 경로 (현재 레이아웃 파일의 디렉토리)
|
|
* @param int $depth 현재 partial 깊이
|
|
* @param array $dataSources 메인 레이아웃의 data_sources (partial 파일에서 참조 가능)
|
|
* @return array partial이 모두 해석된 데이터
|
|
*
|
|
* @throws LayoutIncludeException
|
|
*/
|
|
protected function resolveAllPartials(array $data, string $basePath, int $depth = 0, array $dataSources = []): array
|
|
{
|
|
// 1. 깊이 제한 검증
|
|
$maxDepth = config('template.layout.max_inheritance_depth', 10);
|
|
if ($depth > $maxDepth) {
|
|
throw new LayoutIncludeException(
|
|
__('exceptions.layout.max_include_depth_exceeded', ['max' => $maxDepth]),
|
|
null,
|
|
$this->partialStack
|
|
);
|
|
}
|
|
|
|
// 2. partial 키가 있으면 처리 (배열 항목 치환)
|
|
if (isset($data['partial']) && is_string($data['partial'])) {
|
|
return $this->resolvePartial($data['partial'], $basePath, $depth, $dataSources);
|
|
}
|
|
|
|
// 3. 배열의 각 요소를 재귀적으로 처리
|
|
foreach ($data as $key => $value) {
|
|
if (is_array($value)) {
|
|
$data[$key] = $this->resolveAllPartials($value, $basePath, $depth, $dataSources);
|
|
}
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* 단일 partial을 해석하여 실제 데이터를 반환합니다.
|
|
*
|
|
* @param string $partialPath partial 경로
|
|
* @param string $basePath 기준 경로
|
|
* @param int $depth 현재 깊이
|
|
* @param array $dataSources 메인 레이아웃의 data_sources
|
|
* @return array 로드된 데이터
|
|
*
|
|
* @throws LayoutIncludeException
|
|
*/
|
|
protected function resolvePartial(string $partialPath, string $basePath, int $depth, array $dataSources = []): array
|
|
{
|
|
try {
|
|
// 1. 상대 경로를 절대 경로로 변환
|
|
$absolutePath = $this->resolvePartialPath($partialPath, $basePath);
|
|
} catch (LayoutIncludeException $e) {
|
|
// 경로 해석 실패 시 로그만 남기고 에러 표시 컴포넌트 반환
|
|
Log::warning('Partial 경로 해석 실패', [
|
|
'partial_path' => $partialPath,
|
|
'base_path' => $basePath,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return $this->createPartialErrorComponent($partialPath, $e->getMessage());
|
|
}
|
|
|
|
// 2. 순환 참조 감지 (자기 자신 재귀 참조는 허용 - 대댓글 등 데이터 기반 재귀 구조 지원)
|
|
// 자기 자신을 참조하는 경우: 이전 항목이 자신과 같으면 재귀 구조이므로 허용
|
|
// 다른 파일을 통한 순환 참조(A→B→A)만 에러로 처리
|
|
$stackCount = count($this->partialStack);
|
|
$isRecursiveSelfReference = $stackCount > 0 && $this->partialStack[$stackCount - 1] === $absolutePath;
|
|
|
|
if (in_array($absolutePath, $this->partialStack, true) && ! $isRecursiveSelfReference) {
|
|
$trace = implode(' → ', $this->partialStack)." → {$absolutePath}";
|
|
$errorMessage = __('exceptions.layout.circular_include', ['trace' => $trace]);
|
|
|
|
Log::warning('Partial 순환 참조 감지', [
|
|
'partial_path' => $partialPath,
|
|
'trace' => $trace,
|
|
]);
|
|
|
|
return $this->createPartialErrorComponent($partialPath, $errorMessage);
|
|
}
|
|
|
|
// 자기 자신 재귀 참조인 경우: 더 이상 재귀하지 않고 partial 참조 그대로 반환
|
|
// 런타임에 프론트엔드에서 데이터 기반으로 렌더링됨
|
|
if ($isRecursiveSelfReference) {
|
|
return [
|
|
'partial' => $partialPath,
|
|
'_recursive' => true,
|
|
];
|
|
}
|
|
|
|
// 3. 파일 존재 여부 확인
|
|
if (! file_exists($absolutePath)) {
|
|
$errorMessage = __('exceptions.layout.include_file_not_found', [
|
|
'path' => $partialPath,
|
|
'resolved' => $absolutePath,
|
|
]);
|
|
|
|
Log::warning('Partial 파일을 찾을 수 없음', [
|
|
'partial_path' => $partialPath,
|
|
'resolved_path' => $absolutePath,
|
|
]);
|
|
|
|
return $this->createPartialErrorComponent($partialPath, $errorMessage);
|
|
}
|
|
|
|
// 4. 스택에 추가
|
|
$this->partialStack[] = $absolutePath;
|
|
|
|
try {
|
|
// 5. JSON 파일 로드
|
|
$json = file_get_contents($absolutePath);
|
|
$data = json_decode($json, true);
|
|
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
$errorMessage = __('exceptions.layout.invalid_include_json', [
|
|
'path' => $partialPath,
|
|
'error' => json_last_error_msg(),
|
|
]);
|
|
|
|
Log::warning('Partial JSON 파싱 실패', [
|
|
'partial_path' => $partialPath,
|
|
'error' => json_last_error_msg(),
|
|
]);
|
|
|
|
return $this->createPartialErrorComponent($partialPath, $errorMessage);
|
|
}
|
|
|
|
// 6. 로드된 데이터 내부의 partial도 재귀적으로 처리
|
|
$newBasePath = dirname($absolutePath);
|
|
$data = $this->resolveAllPartials($data, $newBasePath, $depth + 1, $dataSources);
|
|
|
|
return $data;
|
|
} finally {
|
|
array_pop($this->partialStack);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* partial 경로를 절대 경로로 변환하고 보안 검증을 수행합니다.
|
|
*
|
|
* @param string $partialPath partial 경로
|
|
* @param string $basePath 기준 경로
|
|
* @return string 절대 경로
|
|
*
|
|
* @throws LayoutIncludeException
|
|
*/
|
|
protected function resolvePartialPath(string $partialPath, string $basePath): string
|
|
{
|
|
// 1. 먼저 현재 basePath 기준으로 상대 경로 시도
|
|
$absolutePath = $basePath.DIRECTORY_SEPARATOR.$partialPath;
|
|
|
|
// 2. partials/로 시작하고 basePath 기준 파일이 없으면 layouts 루트 기준으로 fallback
|
|
if (str_starts_with($partialPath, 'partials/') || str_starts_with($partialPath, 'partials\\')) {
|
|
if (! file_exists($absolutePath)) {
|
|
$layoutsDir = $this->getLayoutsBaseDirectory($basePath);
|
|
$absolutePath = $layoutsDir.DIRECTORY_SEPARATOR.$partialPath;
|
|
}
|
|
}
|
|
|
|
// realpath로 실제 경로 확인
|
|
$realPath = realpath($absolutePath);
|
|
|
|
// 2. realpath 실패 시 수동 정규화
|
|
if ($realPath === false) {
|
|
$realPath = $this->normalizeFilePath($absolutePath);
|
|
}
|
|
|
|
// 3. 보안 검증: layouts 디렉토리 내부인지 확인
|
|
$layoutsDir = $this->getLayoutsBaseDirectory($basePath);
|
|
|
|
// Windows/Linux 호환을 위해 경로 정규화
|
|
$realPathNormalized = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $realPath);
|
|
$layoutsDirNormalized = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $layoutsDir);
|
|
|
|
if (! str_starts_with($realPathNormalized, $layoutsDirNormalized)) {
|
|
throw new LayoutIncludeException(
|
|
__('exceptions.layout.include_outside_directory', [
|
|
'path' => $partialPath,
|
|
'allowed_dir' => $layoutsDir,
|
|
]),
|
|
$partialPath,
|
|
$this->partialStack
|
|
);
|
|
}
|
|
|
|
return $realPath;
|
|
}
|
|
|
|
/**
|
|
* 경로를 정규화합니다 (.과 .. 처리).
|
|
*
|
|
* @param string $path 경로
|
|
* @return string 정규화된 경로
|
|
*/
|
|
protected function normalizeFilePath(string $path): string
|
|
{
|
|
$path = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $path);
|
|
$parts = [];
|
|
$segments = explode(DIRECTORY_SEPARATOR, $path);
|
|
|
|
foreach ($segments as $segment) {
|
|
if ($segment === '..' && count($parts) > 0) {
|
|
array_pop($parts);
|
|
} elseif ($segment !== '.' && $segment !== '') {
|
|
$parts[] = $segment;
|
|
}
|
|
}
|
|
|
|
return implode(DIRECTORY_SEPARATOR, $parts);
|
|
}
|
|
|
|
/**
|
|
* 허용된 layouts 디렉토리를 추출합니다.
|
|
*
|
|
* @param string $basePath 기준 경로
|
|
* @return string layouts 디렉토리 경로
|
|
*/
|
|
protected function getLayoutsBaseDirectory(string $basePath): string
|
|
{
|
|
// layouts 디렉토리 또는 그 하위 디렉토리까지만 허용
|
|
if (str_contains($basePath, 'modules')) {
|
|
// modules/{name}/resources/layouts
|
|
if (preg_match('#(.*modules[\\\\/][^\\\\/]+[\\\\/]resources[\\\\/]layouts)#', $basePath, $matches)) {
|
|
return $matches[1];
|
|
}
|
|
} elseif (str_contains($basePath, 'plugins')) {
|
|
// plugins/{name}/resources/layouts
|
|
if (preg_match('#(.*plugins[\\\\/][^\\\\/]+[\\\\/]resources[\\\\/]layouts)#', $basePath, $matches)) {
|
|
return $matches[1];
|
|
}
|
|
} elseif (str_contains($basePath, 'templates')) {
|
|
// templates/{name}/layouts
|
|
if (preg_match('#(.*templates[\\\\/][^\\\\/]+[\\\\/]layouts)#', $basePath, $matches)) {
|
|
return $matches[1];
|
|
}
|
|
}
|
|
|
|
return $basePath;
|
|
}
|
|
|
|
/**
|
|
* Partial 파일 로드 실패 시 화면에 표시할 에러 컴포넌트를 생성합니다.
|
|
*
|
|
* @param string $partialPath Partial 경로
|
|
* @param string $errorMessage 에러 메시지
|
|
* @return array 에러 표시용 컴포넌트 배열
|
|
*/
|
|
protected function createPartialErrorComponent(string $partialPath, string $errorMessage): array
|
|
{
|
|
// 개발 환경에서만 에러 표시
|
|
if (! config('app.debug', false)) {
|
|
return []; // 프로덕션에서는 빈 배열 반환 (아무것도 렌더링 안 함)
|
|
}
|
|
|
|
return [
|
|
'type' => 'basic',
|
|
'name' => 'Div',
|
|
'props' => [
|
|
'className' => 'p-4 my-2 bg-red-50 dark:bg-red-900 border-red-300 dark:border-red-600 rounded-lg',
|
|
],
|
|
'children' => [
|
|
[
|
|
'type' => 'basic',
|
|
'name' => 'Div',
|
|
'props' => [
|
|
'className' => 'flex items-center gap-2 mb-2',
|
|
],
|
|
'children' => [
|
|
[
|
|
'type' => 'basic',
|
|
'name' => 'Span',
|
|
'props' => [
|
|
'className' => 'text-xl',
|
|
],
|
|
'text' => '⚠️',
|
|
],
|
|
[
|
|
'type' => 'basic',
|
|
'name' => 'Span',
|
|
'props' => [
|
|
'className' => 'font-bold text-red-900 dark:text-red-100',
|
|
],
|
|
'text' => 'Partial Error',
|
|
],
|
|
],
|
|
],
|
|
[
|
|
'type' => 'basic',
|
|
'name' => 'P',
|
|
'props' => [
|
|
'className' => 'text-sm text-red-700 dark:text-red-300 mb-1',
|
|
],
|
|
'text' => 'Path: '.$partialPath,
|
|
],
|
|
[
|
|
'type' => 'basic',
|
|
'name' => 'P',
|
|
'props' => [
|
|
'className' => 'text-sm text-red-600 dark:text-red-400',
|
|
],
|
|
'text' => 'Error: '.$errorMessage,
|
|
],
|
|
],
|
|
];
|
|
}
|
|
}
|