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>, composite: list>, layout: list>} */ 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', }; } }