Files
Gnuboard7/app/Extension/Traits/GeneratesComponentManifest.php
T
HeuJung 151a7ae945 fix(extensions,core): 저장 대상이 아닌 편집기가 폼을 변경됨으로 만들지 않게
미리보기 편집기는 설정을 시험해 보는 자리인데, 본문 동기화가 hasChanges 를
렌더를 일으키는 setLocal 로 올리면서 시험 입력만으로 [저장] 이 켜졌다.
어느 편집기가 저장 대상인지는 레이아웃만 알므로 핸들러가 필드명을 알아보게
하지 않고 trackChanges 선언으로 받는다 — 하드코딩하면 다른 확장이 같은
미리보기 패턴을 쓸 때 그대로 재발한다.

같은 결함이 형제 경로에도 있었다. 평문 폴백은 hasChanges 를 본문 배치
(render:false + selfManaged:true)에 실어, 편집기 자산을 못 불러온 환경에서
본문만 고치면 저장 버튼이 잠긴 채로 남았다 — 폐쇄망에서는 그 폴백이 정상
경로다. 폴백은 렌더 시점에도 같은 함수를 부르므로 화면을 여는 것만으로
변경됨이 되지 않도록, 사용자 입력에서 온 호출만 플래그를 올린다.

components.json 생성기는 종결 개행을 PHP_EOL 로 써서 Windows 빌드마다
그 파일을 유령 변경으로 남겼다. 줄 내용이 같아 diff 가 비어 보이므로
무엇이 바뀐 것인지 알 수 없는 변경만 쌓인다.
2026-09-01 21:53:47 +09:00

172 lines
7.2 KiB
PHP

<?php
namespace App\Extension\Traits;
/**
* 확장(모듈/플러그인) 컴포넌트 매니페스트(components.json) 생성 트레이트
*
* `module:build` / `plugin:build` 의 빌드 성공 직후 호출되어, 그 확장의 컴포넌트
* 소스를 스캔해 `{ identifier, version, components: { basic[], composite[], layout[] } }`
* 매니페스트를 빌드 경로 루트에 작성한다.
*
* 편집 모드 부팅 시 코어 `ComponentRegistry` 가 활성 확장의 components.json 을
* 네임스페이스 병합해 자동 컨트롤 생성(props 메타)의 입력으로 쓴다. 본 매니페스트가
* 없으면 그 확장 컴포넌트는 메타데이터 없이 무손실 보존만 된다(원칙 4.6 디그레이드).
*
* 스캔 규칙:
* - 컴포넌트 소스는 확장의 `resources/js/components/{basic,composite,layout}/` 하위
* `*.tsx` / `*.ts` 파일로 본다. 하위 분류 디렉토리 이름이 type(basic/composite/layout).
* - 분류 디렉토리 밖의 컴포넌트는 `composite` 로 분류(보수적 기본값).
* - 컴포넌트 name = 파일명(확장자 제외). props 정밀 추출은 본 S6-1 범위 밖 —
* `props: {}` 빈 메타로 둔다(코어 ComponentRegistry 가 런타임 props 메타로 보강).
* - 컴포넌트 소스 디렉토리가 없으면 빈 매니페스트를 작성한다(핸들러 전용 확장 등).
*
* 본 트레이트는 도메인-특화 컴포넌트 구조를 가정하지 않는다 — 디렉토리 컨벤션만으로
* 분류하고, 컨벤션 밖 구조는 composite 로 보존한다.
*
* @since engine-v1.54.0
*/
trait GeneratesComponentManifest
{
/**
* 확장 빌드 경로의 컴포넌트 소스를 스캔해 components.json 을 작성합니다.
*
* @param string $buildPath 빌드 경로 (확장 루트 — _bundled 또는 활성)
* @param string $identifier 확장 식별자 (vendor-extension 형식)
* @return array{written: bool, count: int, path: string} 작성 결과
*/
protected function generateComponentManifest(string $buildPath, string $identifier): array
{
$version = $this->readManifestVersion($buildPath);
$components = $this->scanComponentSources($buildPath);
$manifest = [
'$schema' => 'https://json-schema.org/draft/2020-12/schema',
'identifier' => $identifier,
'version' => $version,
'components' => $components,
];
$outputPath = $buildPath.'/components.json';
$json = json_encode(
$manifest,
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
);
// 종결 개행은 "\n" 고정 — PHP_EOL 은 Windows 에서 "\r\n" 이라, 같은 소스를 빌드해도
// 빌드한 OS 에 따라 산출물의 마지막 바이트가 달라진다. components.json 은 Git 추적
// 대상이므로 그 차이가 매 빌드마다 변경으로 잡히는데, 줄 내용이 같아 diff 는 비어
// 보인다 — 무엇이 바뀐 것인지 알 수 없는 변경만 남는다.
$written = $json !== false && file_put_contents($outputPath, $json."\n") !== false;
$count = count($components['basic']) + count($components['composite']) + count($components['layout']);
return ['written' => $written, 'count' => $count, 'path' => $outputPath];
}
/**
* 확장 매니페스트(module.json/plugin.json)에서 version 을 읽습니다.
*
* @param string $buildPath 빌드 경로
* @return string version 문자열 (미확인 시 '0.0.0')
*/
private function readManifestVersion(string $buildPath): string
{
foreach (['module.json', 'plugin.json'] as $file) {
$path = $buildPath.'/'.$file;
if (file_exists($path)) {
$decoded = json_decode((string) file_get_contents($path), true);
if (is_array($decoded) && isset($decoded['version']) && is_string($decoded['version'])) {
return $decoded['version'];
}
}
}
return '0.0.0';
}
/**
* 컴포넌트 소스 디렉토리를 스캔해 type 별 컴포넌트 목록을 구성합니다.
*
* @param string $buildPath 빌드 경로
* @return array{basic: list<array<string, mixed>>, composite: list<array<string, mixed>>, layout: list<array<string, mixed>>}
*/
private function scanComponentSources(string $buildPath): array
{
$result = ['basic' => [], 'composite' => [], 'layout' => []];
$componentsRoot = $buildPath.'/resources/js/components';
if (! is_dir($componentsRoot)) {
return $result;
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($componentsRoot, \FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
/** @var \SplFileInfo $file */
if (! $file->isFile()) {
continue;
}
$ext = strtolower($file->getExtension());
if (! in_array($ext, ['ts', 'tsx'], true)) {
continue;
}
$name = $file->getBasename('.'.$file->getExtension());
// index/엔트리 파일·테스트 파일은 컴포넌트로 보지 않는다
if (in_array(strtolower($name), ['index', 'main', 'entry'], true)) {
continue;
}
if (str_contains(strtolower($file->getFilename()), '.test.')
|| str_contains($file->getPathname(), DIRECTORY_SEPARATOR.'__tests__'.DIRECTORY_SEPARATOR)) {
continue;
}
$type = $this->classifyComponentByPath($componentsRoot, $file->getPathname());
$relativePath = ltrim(
str_replace(
[base_path().DIRECTORY_SEPARATOR, '\\'],
['', '/'],
$file->getPathname()
),
'/'
);
$result[$type][] = [
'name' => $name,
'type' => $type,
'path' => $relativePath,
'props' => new \stdClass,
];
}
// 결정적 순서 — name 기준 정렬 (빌드 재현성)
foreach ($result as $type => $list) {
usort($result[$type], fn ($a, $b) => strcmp($a['name'], $b['name']));
}
return $result;
}
/**
* 파일 경로의 분류 디렉토리(basic/composite/layout)로 컴포넌트 type 을 판정합니다.
*
* @param string $componentsRoot components 디렉토리 절대 경로
* @param string $filePath 컴포넌트 파일 절대 경로
* @return 'basic'|'composite'|'layout' 컴포넌트 type (컨벤션 밖이면 composite)
*/
private function classifyComponentByPath(string $componentsRoot, string $filePath): string
{
$relative = str_replace('\\', '/', substr($filePath, strlen($componentsRoot)));
$segments = array_values(array_filter(explode('/', $relative)));
$firstDir = $segments[0] ?? '';
return match (strtolower($firstDir)) {
'basic' => 'basic',
'layout' => 'layout',
default => 'composite',
};
}
}