diff --git a/benchmarks/GhostImporterBench.php b/benchmarks/GhostImporterBench.php new file mode 100644 index 0000000..4eef551 --- /dev/null +++ b/benchmarks/GhostImporterBench.php @@ -0,0 +1,102 @@ +sourceDir = sys_get_temp_dir() . '/yiipress-ghost-bench-source-' . uniqid(); + $this->targetDir = sys_get_temp_dir() . '/yiipress-ghost-bench-target-' . uniqid(); + mkdir($this->sourceDir, 0o755, true); + mkdir($this->targetDir, 0o755, true); + $this->sourceFile = $this->sourceDir . '/ghost.json'; + + $posts = []; + $postsTags = []; + for ($i = 1; $i <= 100; $i++) { + $posts[] = [ + 'id' => 'post-' . $i, + 'title' => 'Post ' . $i, + 'slug' => 'post-' . $i, + 'status' => 'published', + 'type' => 'post', + 'published_at' => '2024-03-15 10:30:00', + 'custom_excerpt' => 'Summary ' . $i . '.', + 'html' => '

Body ' . $i . '.

', + ]; + $postsTags[] = ['post_id' => 'post-' . $i, 'tag_id' => 'tag-php']; + } + + file_put_contents( + $this->sourceFile, + json_encode([ + 'db' => [[ + 'data' => [ + 'posts' => $posts, + 'tags' => [ + ['id' => 'tag-php', 'slug' => 'php', 'name' => 'PHP'], + ], + 'posts_tags' => $postsTags, + ], + ]], + ], JSON_THROW_ON_ERROR), + ); + + $this->importer = new GhostContentImporter(); + } + + public function tearDown(): void + { + $this->removeDir($this->sourceDir); + $this->removeDir($this->targetDir); + } + + #[Revs(10)] + #[Iterations(3)] + #[Warmup(1)] + public function benchImportPosts(): void + { + $this->removeDir($this->targetDir); + mkdir($this->targetDir, 0o755, true); + + $this->importer->import(['file' => $this->sourceFile], $this->targetDir, 'blog'); + } + + private function removeDir(string $path): void + { + if (!is_dir($path)) { + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($iterator as $item) { + if ($item->isDir()) { + rmdir($item->getPathname()); + } else { + unlink($item->getPathname()); + } + } + + rmdir($path); + } +} diff --git a/config/common/di/importer.php b/config/common/di/importer.php index 477b2f3..51b6fd5 100644 --- a/config/common/di/importer.php +++ b/config/common/di/importer.php @@ -3,6 +3,7 @@ declare(strict_types=1); use YiiPress\Console\ImportCommand; +use YiiPress\Import\Ghost\GhostContentImporter; use YiiPress\Import\Telegram\TelegramContentImporter; $workingDirectory = getcwd() ?: dirname(__DIR__, 3); @@ -12,6 +13,7 @@ '__construct()' => [ 'rootPath' => $workingDirectory, 'importers' => [ + 'ghost' => new GhostContentImporter(), 'telegram' => new TelegramContentImporter(), ], ], diff --git a/docs/commands.md b/docs/commands.md index ed8768d..6d3122e 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -144,7 +144,7 @@ Imports content from external sources into a YiiPress collection. **Arguments:** -- `source` — source type to import from (required). Currently supported: `telegram`. +- `source` — source type to import from (required). Currently supported: `ghost`, `telegram`. **Common options:** @@ -191,6 +191,29 @@ Supports both single-chat exports (`result.json` with `messages` array) and full ./yiipress import telegram --directory=./telegram-data --content-dir=content ``` +### Ghost import + +Imports posts and pages from a Ghost JSON export file. Export your site from Ghost Admin via Settings > Labs > Export your content. + +**Importer options:** + +- `--file` — path to the Ghost `.json` export file (required). Absolute or relative to project root. + +The importer reads the standard `db[0].data` export structure and converts: + +- Ghost posts (`type = post`) into markdown files in the target collection. +- Ghost pages (`type = page`) into standalone markdown files in the content root. +- `title`, `slug`, `published_at`, `status`, `custom_excerpt`, `feature_image`, tags, authors, and `html` into YiiPress front matter and body content. + +Published posts are imported normally. Non-published posts and pages are imported with `draft: true`. Unsupported post types are skipped. Duplicate output filenames get numeric suffixes so earlier files are not overwritten. + +**Examples:** + +```bash +./yiipress import ghost --file=/path/to/ghost-export.json +./yiipress import ghost --file=./ghost.json --collection=blog +``` + ### Adding custom importers Importers implement `YiiPress\Import\ContentImporterInterface` and are registered via [Yii3 DI](https://yiisoft.github.io/docs/guide/concept/di-container.html) in `config/common/di/importer.php`. Each importer declares its own options via the `options()` method. See [Importing content](importing-content.md) for details. diff --git a/docs/importing-content.md b/docs/importing-content.md index ae12c12..1477a6b 100644 --- a/docs/importing-content.md +++ b/docs/importing-content.md @@ -60,6 +60,18 @@ Imports messages from a Telegram Desktop channel export (JSON format). See [commands.md](commands.md#yii-import) for usage details. +### GhostContentImporter + +Imports posts and pages from a Ghost JSON export. + +**Options:** + +- `--file` — Path to the Ghost `.json` export file (required) + +The importer converts Ghost posts into the selected YiiPress collection and Ghost pages into standalone content root markdown files. It preserves common metadata (`title`, date, draft status, excerpt summary, feature image, tags, and authors), keeps `html` as the markdown body, skips unsupported post types, and avoids overwriting duplicate output filenames. + +See [commands.md](commands.md#ghost-import) for usage details. + ## Writing a custom importer Create a class implementing `ContentImporterInterface`. Each importer declares its own options — a file-based importer might need a `directory`, while an API-based importer might need `url` and `api-key`. diff --git a/roadmap.md b/roadmap.md index f36c641..bf4ce98 100644 --- a/roadmap.md +++ b/roadmap.md @@ -110,5 +110,5 @@ - [ ] Jekyll - [ ] Hugo - [ ] Medium exported Markdown -- [ ] Ghost +- [x] Ghost - [x] Telegram export diff --git a/src/Import/Ghost/GhostContentImporter.php b/src/Import/Ghost/GhostContentImporter.php new file mode 100644 index 0000000..fd16e96 --- /dev/null +++ b/src/Import/Ghost/GhostContentImporter.php @@ -0,0 +1,501 @@ +exportData($data); + if ($export === null) { + return new ImportResult( + totalMessages: 0, + importedCount: 0, + importedFiles: [], + skippedFiles: [], + warnings: ["Ghost export data not found in $sourceFile"], + ); + } + + $posts = $this->list($export['posts'] ?? []); + $tagsByPost = $this->tagsByPost($export); + $authorsByPost = $this->authorsByPost($export); + + FileHelper::ensureDirectory($targetDirectory, 0o755); + + $collectionDir = $targetDirectory . '/' . $collection; + $importedFiles = []; + $skippedFiles = []; + $usedPaths = []; + $hasCollectionEntries = false; + + foreach ($posts as $post) { + $entry = $this->readPost($post, $tagsByPost, $authorsByPost); + if ($entry === null) { + $skippedFiles[] = $this->stringValue($post['id'] ?? null, $this->stringValue($post['slug'] ?? null)); + continue; + } + + $directory = $targetDirectory; + if ($entry['type'] === 'post') { + FileHelper::ensureDirectory($collectionDir, 0o755); + $directory = $collectionDir; + $hasCollectionEntries = true; + } + + $path = $this->uniquePath($directory, $this->filename($entry), $usedPaths); + file_put_contents($path, $this->buildMarkdownFile($entry)); + $importedFiles[] = $path; + } + + if ($hasCollectionEntries) { + $this->ensureCollectionConfig($collectionDir, $collection); + } + + return new ImportResult( + totalMessages: count($posts), + importedCount: count($importedFiles), + importedFiles: $importedFiles, + skippedFiles: $skippedFiles, + warnings: [], + ); + } + + public function name(): string + { + return 'ghost'; + } + + /** + * @param array $data + * @return array|null + */ + private function exportData(array $data): ?array + { + if (isset($data['db']) && is_array($data['db'])) { + $first = $data['db'][0] ?? null; + if (is_array($first) && isset($first['data']) && is_array($first['data'])) { + return $first['data']; + } + } + + if (isset($data['data']) && is_array($data['data'])) { + return $data['data']; + } + + return isset($data['posts']) && is_array($data['posts']) ? $data : null; + } + + /** + * @param mixed $value + * @return list> + */ + private function list(mixed $value): array + { + if (!is_array($value)) { + return []; + } + + $items = []; + foreach ($value as $item) { + if (is_array($item)) { + $items[] = $item; + } + } + + return $items; + } + + /** + * @param array $export + * @return array> + */ + private function tagsByPost(array $export): array + { + $tags = []; + foreach ($this->list($export['tags'] ?? []) as $tag) { + $id = $this->stringValue($tag['id'] ?? null); + $slug = $this->filesystemSlug($this->stringValue($tag['slug'] ?? null, $this->stringValue($tag['name'] ?? null))); + if ($id !== '') { + $tags[$id] = $slug; + } + } + + $byPost = []; + foreach ($this->list($export['posts_tags'] ?? []) as $relation) { + $postId = $this->stringValue($relation['post_id'] ?? null); + $tagId = $this->stringValue($relation['tag_id'] ?? null); + if ($postId !== '' && isset($tags[$tagId])) { + $byPost[$postId][$tags[$tagId]] = true; + } + } + + return $this->flattenMap($byPost); + } + + /** + * @param array $export + * @return array> + */ + private function authorsByPost(array $export): array + { + $authors = []; + foreach ($this->list($export['users'] ?? []) as $author) { + $id = $this->stringValue($author['id'] ?? null); + $slug = $this->filesystemSlug($this->stringValue($author['slug'] ?? null, $this->stringValue($author['name'] ?? null))); + if ($id !== '') { + $authors[$id] = $slug; + } + } + + $byPost = []; + foreach ($this->list($export['posts_authors'] ?? []) as $relation) { + $postId = $this->stringValue($relation['post_id'] ?? null); + $authorId = $this->stringValue($relation['author_id'] ?? null); + if ($postId !== '' && isset($authors[$authorId])) { + $byPost[$postId][$authors[$authorId]] = true; + } + } + + return $this->flattenMap($byPost); + } + + /** + * @param array> $map + * @return array> + */ + private function flattenMap(array $map): array + { + $result = []; + foreach ($map as $postId => $values) { + $result[$postId] = array_keys($values); + } + + return $result; + } + + /** + * @param array $post + * @param array> $tagsByPost + * @param array> $authorsByPost + * @return array{ + * id: string, + * type: 'post'|'page', + * title: string, + * slug: string, + * date: string, + * permalink: string, + * draft: bool, + * summary: string, + * image: string, + * body: string, + * tags: list, + * authors: list + * }|null + */ + private function readPost(array $post, array $tagsByPost, array $authorsByPost): ?array + { + $type = $this->stringValue($post['type'] ?? null, 'post'); + if (!in_array($type, ['post', 'page'], true)) { + return null; + } + /** @var 'post'|'page' $type */ + + $status = $this->stringValue($post['status'] ?? null); + if (in_array($status, ['deleted'], true)) { + return null; + } + + $id = $this->stringValue($post['id'] ?? null); + $title = $this->stringValue($post['title'] ?? null); + $slug = $this->filesystemSlug($this->stringValue($post['slug'] ?? null)); + if ($slug === 'post') { + $slug = $this->slugFromTitle($title); + } + if ($title === '') { + $title = ucfirst(str_replace('-', ' ', $slug)); + } + + $body = $this->stringValue($post['html'] ?? null); + if ($body === '') { + $body = $this->stringValue($post['markdown'] ?? null, $this->stringValue($post['plaintext'] ?? null)); + } + + return [ + 'id' => $id, + 'type' => $type, + 'title' => $title, + 'slug' => $slug, + 'date' => $this->stringValue($post['published_at'] ?? null, $this->stringValue($post['created_at'] ?? null)), + 'permalink' => $type === 'page' ? '/' . $slug . '/' : '', + 'draft' => $status !== '' && $status !== 'published', + 'summary' => $this->stringValue($post['custom_excerpt'] ?? null, $this->stringValue($post['excerpt'] ?? null)), + 'image' => $this->imagePath($this->stringValue($post['feature_image'] ?? null)), + 'body' => trim($body) . "\n", + 'tags' => $id !== '' ? ($tagsByPost[$id] ?? []) : [], + 'authors' => $id !== '' ? ($authorsByPost[$id] ?? []) : [], + ]; + } + + private function imagePath(string $image): string + { + if (str_starts_with($image, '__GHOST_URL__')) { + return str_replace('__GHOST_URL__', '', $image); + } + + return $image; + } + + /** + * @param array{ + * id: string, + * type: 'post'|'page', + * title: string, + * slug: string, + * date: string, + * permalink: string, + * draft: bool, + * summary: string, + * image: string, + * body: string, + * tags: list, + * authors: list + * } $entry + */ + private function filename(array $entry): string + { + if ($entry['type'] === 'post') { + $date = $this->datePart($entry['date']); + + return ($date !== '' ? $date . '-' : '') . $entry['slug'] . '.md'; + } + + return $entry['slug'] . '.md'; + } + + /** + * @param array $usedPaths + */ + private function uniquePath(string $directory, string $filename, array &$usedPaths): string + { + $path = $directory . '/' . $filename; + if (!isset($usedPaths[$path]) && !file_exists($path)) { + $usedPaths[$path] = true; + return $path; + } + + $base = pathinfo($filename, PATHINFO_FILENAME); + $extension = pathinfo($filename, PATHINFO_EXTENSION); + $suffix = 2; + do { + $path = $directory . '/' . $base . '-' . $suffix . ($extension !== '' ? '.' . $extension : ''); + $suffix++; + } while (isset($usedPaths[$path]) || file_exists($path)); + + $usedPaths[$path] = true; + + return $path; + } + + /** + * @param array{ + * id: string, + * type: 'post'|'page', + * title: string, + * slug: string, + * date: string, + * permalink: string, + * draft: bool, + * summary: string, + * image: string, + * body: string, + * tags: list, + * authors: list + * } $entry + */ + private function buildMarkdownFile(array $entry): string + { + $frontMatter = "---\n"; + $frontMatter .= 'title: ' . $this->yamlEscape($entry['title']) . "\n"; + + if ($entry['date'] !== '') { + $frontMatter .= 'date: ' . $entry['date'] . "\n"; + } + + if ($entry['permalink'] !== '') { + $frontMatter .= 'permalink: ' . $this->yamlEscape($entry['permalink']) . "\n"; + } + + if ($entry['draft']) { + $frontMatter .= "draft: true\n"; + } + + if ($entry['summary'] !== '') { + $frontMatter .= 'summary: ' . $this->yamlEscape($entry['summary']) . "\n"; + } + + if ($entry['image'] !== '') { + $frontMatter .= 'image: ' . $this->yamlEscape($entry['image']) . "\n"; + } + + if ($entry['tags'] !== []) { + $frontMatter .= "tags:\n"; + foreach ($entry['tags'] as $tag) { + $frontMatter .= ' - ' . $this->yamlEscape($tag) . "\n"; + } + } + + if ($entry['authors'] !== []) { + $frontMatter .= "authors:\n"; + foreach ($entry['authors'] as $author) { + $frontMatter .= ' - ' . $this->yamlEscape($author) . "\n"; + } + } + + return $frontMatter . "---\n\n" . $entry['body']; + } + + private function stringValue(mixed $value, string $default = ''): string + { + if (!is_string($value)) { + return $default; + } + + return trim($value); + } + + private function datePart(string $date): string + { + return preg_match('/^(\d{4}-\d{2}-\d{2})/', $date, $matches) === 1 ? $matches[1] : ''; + } + + private function filesystemSlug(string $slug): string + { + $slug = str_replace(['/', '\\'], '-', trim($slug)); + $slug = (string) preg_replace('/[<>:"|?*\x00-\x1F]+/', '-', $slug); + $slug = trim($slug, ". \t\n\r\0\x0B-"); + + return $slug === '' ? 'post' : $slug; + } + + private function slugFromTitle(string $title): string + { + $slug = (string) preg_replace('/[^\p{L}\p{N}]+/u', '-', mb_strtolower($title)); + $slug = trim($slug, '-'); + + return $slug === '' ? 'post' : $slug; + } + + private function ensureCollectionConfig(string $collectionDir, string $collection): void + { + $configPath = $collectionDir . '/_collection.yaml'; + if (is_file($configPath)) { + return; + } + + $config = 'title: ' . ucfirst($collection) . "\n"; + $config .= "sort_by: date\n"; + $config .= "sort_order: desc\n"; + $config .= "entries_per_page: 10\n"; + $config .= "feed: true\n"; + + file_put_contents($configPath, $config); + } + + private function yamlEscape(string $value): string + { + $value = str_replace(["\r", "\n"], ' ', $value); + if (preg_match('/[:#\[\]{}|>&*!,\'"%@`]/', $value) === 1) { + return '"' . addcslashes($value, '"\\') . '"'; + } + + return $value; + } +} diff --git a/tests/Unit/Console/ImportCommandTest.php b/tests/Unit/Console/ImportCommandTest.php index a53cdb1..a33410e 100644 --- a/tests/Unit/Console/ImportCommandTest.php +++ b/tests/Unit/Console/ImportCommandTest.php @@ -103,11 +103,41 @@ public function testImportsToCustomCollection(): void assertStringContainsString('Imported: 1', $result['output']); } + public function testImportsGhostExport(): void + { + $exportFile = $this->sourceDir . '/ghost.json'; + file_put_contents( + $exportFile, + json_encode([ + 'db' => [[ + 'data' => [ + 'posts' => [[ + 'id' => 'post-1', + 'title' => 'Hello Ghost', + 'slug' => 'hello-ghost', + 'status' => 'published', + 'type' => 'post', + 'published_at' => '2024-03-15 10:30:00', + 'html' => 'Body.', + ]], + ], + ]], + ], JSON_THROW_ON_ERROR), + ); + + $result = $this->runImport('ghost', ['--file' => $exportFile]); + + assertSame(0, $result['exitCode'], $result['output']); + assertStringContainsString('Importing from ghost', $result['output']); + assertStringContainsString('Imported: 1', $result['output']); + } + public function testShowsAvailableImportersOnError(): void { $result = $this->runImport('wordpress', ['--directory' => $this->sourceDir]); assertSame(65, $result['exitCode']); + assertStringContainsString('ghost', $result['output']); assertStringContainsString('telegram', $result['output']); } diff --git a/tests/Unit/Import/GhostContentImporterTest.php b/tests/Unit/Import/GhostContentImporterTest.php new file mode 100644 index 0000000..88889d4 --- /dev/null +++ b/tests/Unit/Import/GhostContentImporterTest.php @@ -0,0 +1,213 @@ +targetDir = sys_get_temp_dir() . '/yiipress-ghost-target-' . uniqid(); + mkdir($sourceDir, 0o755, true); + mkdir($this->targetDir, 0o755, true); + $this->sourceFile = $sourceDir . '/ghost.json'; + } + + protected function tearDown(): void + { + $this->removeDir(dirname($this->sourceFile)); + $this->removeDir($this->targetDir); + } + + public function testImportsPostsAndPagesFromGhostExport(): void + { + file_put_contents($this->sourceFile, json_encode([ + 'db' => [[ + 'data' => [ + 'posts' => [ + [ + 'id' => 'post-1', + 'title' => 'Hello: Ghost', + 'slug' => 'hello-ghost', + 'status' => 'published', + 'type' => 'post', + 'published_at' => '2024-03-15 10:30:00', + 'custom_excerpt' => 'Short summary.', + 'feature_image' => '__GHOST_URL__/content/images/hero.jpg', + 'html' => '

Hello from Ghost.

', + ], + [ + 'id' => 'page-1', + 'title' => 'About', + 'slug' => 'about', + 'status' => 'published', + 'type' => 'page', + 'published_at' => '2024-03-16 11:00:00', + 'html' => '

About page.

', + ], + ], + 'tags' => [ + ['id' => 'tag-1', 'slug' => 'php', 'name' => 'PHP'], + ], + 'posts_tags' => [ + ['post_id' => 'post-1', 'tag_id' => 'tag-1'], + ], + 'users' => [ + ['id' => 'author-1', 'slug' => 'jane-doe', 'name' => 'Jane Doe'], + ], + 'posts_authors' => [ + ['post_id' => 'post-1', 'author_id' => 'author-1'], + ], + ], + ]], + ], JSON_THROW_ON_ERROR)); + + $result = (new GhostContentImporter())->import(['file' => $this->sourceFile], $this->targetDir, 'blog'); + + assertSame(2, $result->totalMessages()); + assertSame(2, $result->importedCount()); + assertSame([], $result->warnings()); + + $post = file_get_contents($this->targetDir . '/blog/2024-03-15-hello-ghost.md'); + $this->assertNotFalse($post); + assertStringContainsString('title: "Hello: Ghost"', $post); + assertStringContainsString('date: 2024-03-15 10:30:00', $post); + assertStringContainsString('summary: Short summary.', $post); + assertStringContainsString('image: /content/images/hero.jpg', $post); + assertStringContainsString("tags:\n - php\n", $post); + assertStringContainsString("authors:\n - jane-doe\n", $post); + assertStringContainsString('

Hello from Ghost.

', $post); + $this->assertFileExists($this->targetDir . '/blog/_collection.yaml'); + + $page = file_get_contents($this->targetDir . '/about.md'); + $this->assertNotFalse($page); + assertStringContainsString('title: About', $page); + assertStringContainsString('permalink: /about/', $page); + assertStringContainsString('

About page.

', $page); + } + + public function testMarksDraftsAndSkipsUnsupportedPostTypes(): void + { + file_put_contents($this->sourceFile, json_encode([ + 'data' => [ + 'posts' => [ + [ + 'id' => 'draft-1', + 'title' => 'Draft Post', + 'slug' => 'draft-post', + 'status' => 'draft', + 'type' => 'post', + 'published_at' => '2024-04-01 09:00:00', + 'html' => 'Draft body.', + ], + [ + 'id' => 'unknown-1', + 'title' => 'Unknown', + 'slug' => 'unknown', + 'status' => 'published', + 'type' => 'custom', + ], + ], + ], + ], JSON_THROW_ON_ERROR)); + + $result = (new GhostContentImporter())->import(['file' => $this->sourceFile], $this->targetDir, 'blog'); + + assertSame(2, $result->totalMessages()); + assertSame(1, $result->importedCount()); + assertSame(['unknown-1'], $result->skippedFiles()); + + $post = file_get_contents($this->targetDir . '/blog/2024-04-01-draft-post.md'); + $this->assertNotFalse($post); + assertStringContainsString("draft: true\n", $post); + } + + public function testDoesNotOverwriteDuplicateSlugs(): void + { + file_put_contents($this->sourceFile, json_encode([ + 'posts' => [ + [ + 'id' => 'post-1', + 'title' => 'Duplicate', + 'slug' => 'duplicate', + 'status' => 'published', + 'type' => 'post', + 'published_at' => '2024-05-01 09:00:00', + 'html' => 'First.', + ], + [ + 'id' => 'post-2', + 'title' => 'Duplicate', + 'slug' => 'duplicate', + 'status' => 'published', + 'type' => 'post', + 'published_at' => '2024-05-01 10:00:00', + 'html' => 'Second.', + ], + ], + ], JSON_THROW_ON_ERROR)); + + $result = (new GhostContentImporter())->import(['file' => $this->sourceFile], $this->targetDir, 'blog'); + + assertSame(2, $result->importedCount()); + $this->assertFileExists($this->targetDir . '/blog/2024-05-01-duplicate.md'); + $this->assertFileExists($this->targetDir . '/blog/2024-05-01-duplicate-2.md'); + } + + public function testWarnsWhenFileIsMissing(): void + { + $result = (new GhostContentImporter())->import(['file' => $this->sourceFile], $this->targetDir, 'blog'); + + assertSame(0, $result->importedCount()); + assertCount(1, $result->warnings()); + assertStringContainsString('file option is required', $result->warnings()[0]); + } + + public function testWarnsWhenJsonIsInvalid(): void + { + file_put_contents($this->sourceFile, '{'); + + $result = (new GhostContentImporter())->import(['file' => $this->sourceFile], $this->targetDir, 'blog'); + + assertSame(0, $result->importedCount()); + assertCount(1, $result->warnings()); + assertStringContainsString('Invalid Ghost JSON', $result->warnings()[0]); + } + + private function removeDir(string $path): void + { + if (!is_dir($path)) { + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($iterator as $item) { + /** @var SplFileInfo $item */ + if ($item->isDir()) { + rmdir($item->getPathname()); + } else { + unlink($item->getPathname()); + } + } + rmdir($path); + } +}