diff --git a/CHANGELOG.md b/CHANGELOG.md index 744745e9..43ebc106 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -119,6 +119,7 @@ - 목록 칸이나 합계 행 안에서 번역 문구가 문장 중간에 들어간 경우 번역되지 않고 원본 키가 그대로 보이던 문제, 그리고 일부 표시 값에 `{{ }}` 기호가 섞여 나오던 문제를 수정했습니다. - 목록 칸이나 합계 행에서 '번역하지 않고 원문 그대로 표시'로 지정한 값에 보이지 않는 특수문자가 함께 출력되던 문제를 수정했습니다. 화면에서는 빈칸이나 깨진 글자로 보였고, 값을 복사해 붙여넣을 때 따라붙었습니다. - 목록 표·카드의 셀에서 `true`/`false` 같은 값을 직접 쓴 자리가 비어 보이던 문제를 수정했습니다. 같은 값을 표시 조건에 쓰면 정상 동작해서, 같은 작성이 놓인 자리에 따라 갈렸습니다. +- 레이아웃 코드 편집 화면의 파일 목록과 선택한 파일 설명에 번역되지 않은 내부 표기(`$t:…` 나 `{{ … }}`)가 그대로 보이던 문제를 수정했습니다. 다른 템플릿의 레이아웃을 편집할 때 그 템플릿의 번역을 찾지 못해 생긴 문제로, 이제 해당 템플릿의 번역으로 표시하며 번역을 찾을 수 없으면 파일 이름을 대신 보여줍니다. - 목록 화면에서 글이나 항목을 열어 보고 돌아올 때 보고 있던 페이지·검색어·필터가 사라지던 문제를 화면 엔진 차원에서 함께 수정했습니다. '현재 주소의 조건을 유지한다'고 지정해도 덧붙일 값이 없으면 유지가 아예 동작하지 않아 조건이 통째로 사라지던 동작을 바로잡았습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.) #### 확장·데이터베이스 안정성 diff --git a/app/Http/Controllers/Api/Admin/LayoutController.php b/app/Http/Controllers/Api/Admin/LayoutController.php index 4a3eb845..cd732266 100644 --- a/app/Http/Controllers/Api/Admin/LayoutController.php +++ b/app/Http/Controllers/Api/Admin/LayoutController.php @@ -49,9 +49,16 @@ class LayoutController extends AdminBaseController // 동기화 / 위지윅에서 넘어온 ?route= 로 해당 파일 복원에 사용한다. $routePathMap = $this->templateService->getLayoutRoutePathMap($templateName); + // 레이아웃 설명(`meta.description`)은 그 레이아웃을 소유한 템플릿의 사전 키를 쓴다. + // 코드 편집 화면은 관리자 템플릿 사전으로 렌더하므로 유저 템플릿 키를 알지 못해, + // 해석하지 않고 내보내면 설명 칸에 `$t:user.…` 가 원문으로 노출된다. + $translations = $this->resolveTemplateTranslations($templateName); + $collection = LayoutListResource::collection($layouts); $collection->collection->transform( - fn (LayoutListResource $resource) => $resource->withRoutePathMap($routePathMap) + fn (LayoutListResource $resource) => $resource + ->withRoutePathMap($routePathMap) + ->withTranslations($translations) ); return $this->success('common.success', $collection); @@ -80,7 +87,8 @@ class LayoutController extends AdminBaseController return $this->success( 'common.success', - new LayoutResource($layout) + (new LayoutResource($layout)) + ->withTranslations($this->resolveTemplateTranslations($templateName)) ); } @@ -109,7 +117,8 @@ class LayoutController extends AdminBaseController return $this->success( 'common.success', - new LayoutResource($layout) + (new LayoutResource($layout)) + ->withTranslations($this->resolveTemplateTranslations($templateName)) ); } catch (ConcurrentModificationException $e) { DB::rollBack(); @@ -264,4 +273,25 @@ class LayoutController extends AdminBaseController ); } } + + /** + * 목록 설명 해석에 쓸 소유 템플릿 사전을 로드합니다. + * + * 활성 로케일 기준이며, 로드에 실패하면 빈 배열을 돌려준다 — 그 경우 + * {@see LayoutListResource} 가 레이아웃 이름으로 폴백하므로 목록은 계속 그려진다. + * + * @param string $templateName 템플릿 식별자 + * @return array 템플릿 프론트엔드 다국어 데이터 + */ + private function resolveTemplateTranslations(string $templateName): array + { + $result = $this->templateService->getLanguageDataWithModules( + $templateName, + app()->getLocale() + ); + + return ($result['success'] ?? false) && is_array($result['data'] ?? null) + ? $result['data'] + : []; + } } diff --git a/app/Http/Resources/LayoutListResource.php b/app/Http/Resources/LayoutListResource.php index 84546a88..bf8ddb71 100644 --- a/app/Http/Resources/LayoutListResource.php +++ b/app/Http/Resources/LayoutListResource.php @@ -2,6 +2,7 @@ namespace App\Http\Resources; +use App\Support\LayoutDescription; use Carbon\Carbon; use Illuminate\Http\Request; @@ -27,6 +28,9 @@ class LayoutListResource extends BaseApiResource /** @var array 레이아웃 이름 → 라우트 path 매핑 */ private array $routePathMap = []; + /** @var array 소유 템플릿의 프론트엔드 다국어 사전 (점 표기 중첩) */ + private array $translations = []; + /** * 라우트 path 매핑을 주입합니다. * @@ -40,6 +44,19 @@ class LayoutListResource extends BaseApiResource return $this; } + /** + * 설명 해석에 쓸 소유 템플릿 사전을 주입합니다. + * + * @param array $translations 템플릿 프론트엔드 다국어 데이터 + * @return $this + */ + public function withTranslations(array $translations): self + { + $this->translations = $translations; + + return $this; + } + /** * 목록 행을 배열로 변환합니다. * @@ -55,7 +72,7 @@ class LayoutListResource extends BaseApiResource 'id' => $this->getValue('id'), 'template_id' => $this->getValue('template_id'), 'name' => $name, - 'description' => $this->getValue('description') ?: $name, + 'description' => LayoutDescription::resolve($this->getValue('description'), $name, $this->translations), // 이 레이아웃을 사용하는 라우트의 path (routes.json 기준). 파일 선택 시 ?route= // 동기화 / 위지윅에서 넘어온 ?route= 로 해당 파일 복원에 사용. diff --git a/app/Http/Resources/LayoutResource.php b/app/Http/Resources/LayoutResource.php index c7c2e53c..c8be3f80 100644 --- a/app/Http/Resources/LayoutResource.php +++ b/app/Http/Resources/LayoutResource.php @@ -2,6 +2,7 @@ namespace App\Http\Resources; +use App\Support\LayoutDescription; use Illuminate\Http\Request; class LayoutResource extends BaseApiResource @@ -16,6 +17,22 @@ class LayoutResource extends BaseApiResource */ protected array $routePathMap = []; + /** @var array 소유 템플릿의 프론트엔드 다국어 사전 */ + protected array $translations = []; + + /** + * 설명 해석에 쓸 소유 템플릿 사전을 주입합니다. + * + * @param array $translations 템플릿 프론트엔드 다국어 데이터 + * @return $this + */ + public function withTranslations(array $translations): static + { + $this->translations = $translations; + + return $this; + } + /** * 레이아웃 이름 → 라우트 path 매핑을 주입합니다. * @@ -45,7 +62,9 @@ class LayoutResource extends BaseApiResource 'id' => $this->getValue('id'), 'template_id' => $this->getValue('template_id'), 'name' => $name, - 'description' => $content['meta']['description'] ?? $name, + // 설명은 소유 템플릿 사전 키를 쓰므로 서버가 해석한다 — 목록(LayoutListResource)과 + // 같은 규칙이어야 파일 목록과 선택 파일 헤더의 표기가 어긋나지 않는다. + 'description' => LayoutDescription::resolve($content['meta']['description'] ?? null, $name, $this->translations), 'endpoint' => $content['endpoint'] ?? null, // 이 레이아웃을 사용하는 라우트의 path (routes.json 기준). 코드 편집기가 // 파일 선택 시 ?route= 동기화 / 위지윅에서 넘어온 ?route= 복원에 사용. diff --git a/app/Support/LayoutDescription.php b/app/Support/LayoutDescription.php new file mode 100644 index 00000000..0e51b23c --- /dev/null +++ b/app/Support/LayoutDescription.php @@ -0,0 +1,56 @@ + $translations 소유 템플릿의 프론트엔드 다국어 데이터 + * @return string 표시용 설명 + */ + public static function resolve(mixed $description, string $name, array $translations = []): string + { + if (! is_string($description) || $description === '') { + return $name; + } + + // 런타임 컨텍스트(`route` 등)가 필요한 식은 이 시점에 해석할 수 없다. + // 예: `{{route.id ? '$t:board.edit' : '$t:board.new'}}` + if (str_contains($description, '{{')) { + return $name; + } + + if (! str_starts_with($description, self::TRANSLATION_PREFIX)) { + return $description; + } + + $key = substr($description, strlen(self::TRANSLATION_PREFIX)); + $resolved = data_get($translations, $key); + + return is_string($resolved) && $resolved !== '' ? $resolved : $name; + } +} diff --git a/docs/backend/api/templates.md b/docs/backend/api/templates.md index 92f875a2..90df19cc 100644 --- a/docs/backend/api/templates.md +++ b/docs/backend/api/templates.md @@ -2162,7 +2162,7 @@ Authorization: Bearer {YOUR_TOKEN} | id | integer | 레이아웃 ID | | template_id | integer | 소속 템플릿 ID | | name | string | 레이아웃 이름 (예: `_admin_base`, `admin_user_list`) | -| description | string | 레이아웃 설명. 본문의 `meta.description` 에서 파생하며 없으면 이름을 그대로 사용 | +| description | string | 레이아웃 설명. 본문의 `meta.description` 에서 파생한다. 그 값은 레이아웃을 **소유한 템플릿**의 사전 키(`$t:user.base_layout_description` 등)일 수 있으므로 서버가 해당 템플릿 사전(활성 로케일)으로 해석해 내보낸다. 사전에 키가 없거나 `{{...}}` 표현식이어서 해석할 수 없으면 레이아웃 **이름**으로 폴백한다 — 번역 토큰이나 표현식 원문이 화면에 노출되지 않는다. 목록·상세 응답이 같은 규칙을 쓴다 | | route_path | string\|null | 이 레이아웃을 사용하는 라우트 path (routes.json 기준). 매핑이 없으면 null | | size | integer | 본문 크기(바이트) | | size_formatted | string | 사람이 읽는 크기 표기 (예: `176.9 KB`) | diff --git a/tests/Feature/Api/Admin/LayoutIndexPayloadPruningTest.php b/tests/Feature/Api/Admin/LayoutIndexPayloadPruningTest.php index aea0d020..b94f1996 100644 --- a/tests/Feature/Api/Admin/LayoutIndexPayloadPruningTest.php +++ b/tests/Feature/Api/Admin/LayoutIndexPayloadPruningTest.php @@ -222,4 +222,119 @@ class LayoutIndexPayloadPruningTest extends TestCase '목록 응답이 본문 총량보다 크다 — 본문이 그대로 실리고 있다' ); } + + /** + * 사전에 없는 `$t:` 토큰이 화면에 원문 그대로 노출되지 않는지 확인 + * + * 레이아웃의 `meta.description` 은 그 레이아웃을 **소유한 템플릿**의 사전 키를 쓴다 + * (예: 유저 템플릿 레이아웃이 `$t:user.base_layout_description`). 그런데 코드 편집 + * 화면은 관리자 템플릿 사전으로 렌더하므로 그 키를 알지 못한다. 응답이 토큰을 그대로 + * 내보내면 파일 목록 설명 칸에 `$t:user.base_layout_description` 이 노출된다. + * (브라우저 실측: 519개 중 83개가 이 형태) + */ + #[Test] + public function it_does_not_expose_raw_translation_tokens_in_description(): void + { + TemplateLayout::factory()->create([ + 'template_id' => $this->template->id, + 'name' => 'errors/404', + 'content' => [ + 'version' => '1.0.0', + 'layout_name' => 'errors/404', + 'meta' => ['description' => '$t:error.404.description'], + 'components' => [], + ], + ]); + + $response = $this->withToken($this->token) + ->getJson("/api/admin/templates/{$this->template->identifier}/layouts"); + + $response->assertOk(); + + $row = collect($response->json('data'))->firstWhere('name', 'errors/404'); + $this->assertNotNull($row); + $this->assertStringNotContainsString( + '$t:', + (string) $row['description'], + '번역 토큰이 해석되지 않은 채 목록 설명으로 노출됐다' + ); + $this->assertSame('errors/404', $row['description'], '해석 불가 시 레이아웃 이름으로 폴백해야 한다'); + } + + /** + * 런타임 컨텍스트가 필요한 표현식 description 이 원문으로 노출되지 않는지 확인 + * + * `{{route.id ? '$t:a' : '$t:b'}}` 형태는 목록 시점에 `route` 가 없어 서버가 해석할 수 + * 없다. 원문을 그대로 내보내면 설명 칸에 표현식이 그대로 보인다. + */ + #[Test] + public function it_does_not_expose_raw_expression_in_description(): void + { + TemplateLayout::factory()->create([ + 'template_id' => $this->template->id, + 'name' => 'board/form', + 'content' => [ + 'version' => '1.0.0', + 'layout_name' => 'board/form', + 'meta' => ['description' => "{{route.id ? '\$t:board.edit' : '\$t:board.new'}}"], + 'components' => [], + ], + ]); + + $response = $this->withToken($this->token) + ->getJson("/api/admin/templates/{$this->template->identifier}/layouts"); + + $response->assertOk(); + + $row = collect($response->json('data'))->firstWhere('name', 'board/form'); + $this->assertNotNull($row); + $this->assertStringNotContainsString('{{', (string) $row['description']); + $this->assertSame('board/form', $row['description']); + } + + /** + * 평문 설명은 종전대로 그대로 실리는지 확인 (회귀 보호) + */ + #[Test] + public function it_keeps_plain_description_as_is(): void + { + $response = $this->withToken($this->token) + ->getJson("/api/admin/templates/{$this->template->identifier}/layouts"); + + $response->assertOk(); + + $row = collect($response->json('data'))->firstWhere('name', 'admin_settings'); + $this->assertSame('설명 admin_settings', $row['description']); + } + + /** + * 상세 응답도 목록과 같은 규칙으로 설명을 해석하는지 확인 + * + * 코드 편집 화면은 선택한 파일의 설명을 헤더에 따로 표시하고, 그 값은 상세 + * 엔드포인트(`current_layout`)에서 온다. 목록만 고치면 파일 목록에는 번역된 설명이, + * 헤더에는 `$t:` 토큰이 보이는 불일치가 생긴다. (브라우저 실측으로 발견) + */ + #[Test] + public function it_resolves_description_in_detail_response_too(): void + { + TemplateLayout::factory()->create([ + 'template_id' => $this->template->id, + 'name' => 'errors/500', + 'content' => [ + 'version' => '1.0.0', + 'layout_name' => 'errors/500', + 'meta' => ['description' => '$t:error.500.description'], + 'components' => [], + ], + ]); + + $response = $this->withToken($this->token) + ->getJson("/api/admin/templates/{$this->template->identifier}/layouts/errors/500"); + + $response->assertOk(); + + $description = (string) $response->json('data.description'); + $this->assertStringNotContainsString('$t:', $description, '상세 응답에 번역 토큰이 원문 노출됐다'); + $this->assertSame('errors/500', $description); + } }