fix(core): 레이아웃 코드 편집 화면 설명의 미해석 표기 노출 해소

파일 목록과 선택한 파일의 설명에 `$t:…` / `{{ … }}` 가 그대로 보였다.
설명 원문은 소유 템플릿의 다국어 키인데 응답 조립 시점에는 요청 템플릿의
사전만 열려 있어 다른 템플릿의 레이아웃을 편집하면 키를 찾지 못했다.
번역 실패가 예외 없이 원문 노출로 이어져 화면에만 나타났다.

목록·상세가 같은 규칙을 쓰도록 표시 해석을 App\Support\LayoutDescription 으로
모으고, 컨트롤러가 소유 템플릿 사전을 주입한다. 표현식이 섞인 설명과 번역을
찾지 못한 경우는 파일 이름으로 대체한다.

목록만 고치면 상세 헤더에 그대로 남는다 — 브라우저 실측에서 목록 수정 후에도
헤더가 새던 것을 확인해 상세 경로(LayoutResource)까지 함께 처리했다.
This commit is contained in:
HeuJung
2026-08-04 10:21:00 +09:00
parent 65378fdeb6
commit e426498fb3
7 changed files with 244 additions and 6 deletions
+1
View File
@@ -119,6 +119,7 @@
- 목록 칸이나 합계 행 안에서 번역 문구가 문장 중간에 들어간 경우 번역되지 않고 원본 키가 그대로 보이던 문제, 그리고 일부 표시 값에 `{{ }}` 기호가 섞여 나오던 문제를 수정했습니다.
- 목록 칸이나 합계 행에서 '번역하지 않고 원문 그대로 표시'로 지정한 값에 보이지 않는 특수문자가 함께 출력되던 문제를 수정했습니다. 화면에서는 빈칸이나 깨진 글자로 보였고, 값을 복사해 붙여넣을 때 따라붙었습니다.
- 목록 표·카드의 셀에서 `true`/`false` 같은 값을 직접 쓴 자리가 비어 보이던 문제를 수정했습니다. 같은 값을 표시 조건에 쓰면 정상 동작해서, 같은 작성이 놓인 자리에 따라 갈렸습니다.
- 레이아웃 코드 편집 화면의 파일 목록과 선택한 파일 설명에 번역되지 않은 내부 표기(`$t:…` 나 `{{ … }}`)가 그대로 보이던 문제를 수정했습니다. 다른 템플릿의 레이아웃을 편집할 때 그 템플릿의 번역을 찾지 못해 생긴 문제로, 이제 해당 템플릿의 번역으로 표시하며 번역을 찾을 수 없으면 파일 이름을 대신 보여줍니다.
- 목록 화면에서 글이나 항목을 열어 보고 돌아올 때 보고 있던 페이지·검색어·필터가 사라지던 문제를 화면 엔진 차원에서 함께 수정했습니다. '현재 주소의 조건을 유지한다'고 지정해도 덧붙일 값이 없으면 유지가 아예 동작하지 않아 조건이 통째로 사라지던 동작을 바로잡았습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
#### 확장·데이터베이스 안정성
@@ -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<string, mixed> 템플릿 프론트엔드 다국어 데이터
*/
private function resolveTemplateTranslations(string $templateName): array
{
$result = $this->templateService->getLanguageDataWithModules(
$templateName,
app()->getLocale()
);
return ($result['success'] ?? false) && is_array($result['data'] ?? null)
? $result['data']
: [];
}
}
+18 -1
View File
@@ -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<string, string|null> 레이아웃 이름 → 라우트 path 매핑 */
private array $routePathMap = [];
/** @var array<string, mixed> 소유 템플릿의 프론트엔드 다국어 사전 (점 표기 중첩) */
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= 로 해당 파일 복원에 사용.
+20 -1
View File
@@ -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<string, mixed> 소유 템플릿의 프론트엔드 다국어 사전 */
protected array $translations = [];
/**
* 설명 해석에 쓸 소유 템플릿 사전을 주입합니다.
*
* @param array<string, mixed> $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= 복원에 사용.
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Support;
/**
* 레이아웃 설명(`meta.description`) 표시 해석의 SSoT.
*
* 레이아웃의 설명은 그 레이아웃을 **소유한 템플릿**의 사전 키를 쓴다 — 유저 템플릿
* 레이아웃이면 `$t:user.base_layout_description` 처럼. 그런데 코드 편집 화면은 관리자
* 템플릿(`sirsoft-admin_basic`) 사전으로 렌더하므로 `user.*` 네임스페이스를 알지 못한다.
* 값을 그대로 내보내면 파일 목록과 선택 파일 헤더에 번역 토큰이 원문으로 노출된다
* (실측: 저장소 레이아웃 519개 중 83개가 `$t:` 토큰 또는 표현식 형태).
*
* 그래서 서버가 소유 템플릿 사전으로 해석하고, 해석할 수 없으면 레이아웃 이름으로
* 폴백한다 — 사람이 읽을 수 없는 내부 표기를 보여주느니 파일명이 낫다.
*
* 목록(`LayoutListResource`)과 상세(`LayoutResource`)가 같은 규칙을 써야 하므로 이 클래스
* 하나로 모은다. 한쪽만 고치면 목록에서는 번역되고 헤더에서는 토큰이 보이는 불일치가 된다.
*
* @since 7.0.6
*/
class LayoutDescription
{
/** 프론트엔드 다국어 토큰 접두사 (`$t:key`) */
private const TRANSLATION_PREFIX = '$t:';
/**
* 표시용 설명을 해석합니다.
*
* @param mixed $description 원본 설명 (평문 · `$t:` 토큰 · `{{...}}` 표현식 · null)
* @param string $name 레이아웃 이름 (해석 불가 시 폴백)
* @param array<string, mixed> $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;
}
}
+1 -1
View File
@@ -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`) |
@@ -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);
}
}