From 8407f1016aba16b27e1e996a5402be166ad1f889 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Sat, 13 Jun 2026 13:33:35 +0300 Subject: [PATCH] Add WordPress content importer --- benchmarks/WordPressImporterBench.php | 98 ++++ config/common/di/importer.php | 2 + docs/commands.md | 25 +- docs/importing-content.md | 12 + roadmap.md | 2 +- .../WordPress/WordPressContentImporter.php | 466 ++++++++++++++++++ tests/Unit/Console/ImportCommandTest.php | 30 +- .../Import/WordPressContentImporterTest.php | 245 +++++++++ 8 files changed, 877 insertions(+), 3 deletions(-) create mode 100644 benchmarks/WordPressImporterBench.php create mode 100644 src/Import/WordPress/WordPressContentImporter.php create mode 100644 tests/Unit/Import/WordPressContentImporterTest.php diff --git a/benchmarks/WordPressImporterBench.php b/benchmarks/WordPressImporterBench.php new file mode 100644 index 0000000..e9ee807 --- /dev/null +++ b/benchmarks/WordPressImporterBench.php @@ -0,0 +1,98 @@ +sourceDir = sys_get_temp_dir() . '/yiipress-wordpress-bench-source-' . uniqid(); + $this->targetDir = sys_get_temp_dir() . '/yiipress-wordpress-bench-target-' . uniqid(); + mkdir($this->sourceDir, 0o755, true); + mkdir($this->targetDir, 0o755, true); + $this->sourceFile = $this->sourceDir . '/wordpress.xml'; + + $items = []; + for ($i = 1; $i <= 100; $i++) { + $items[] = '' + . '<![CDATA[Post ' . $i . ']]>' + . 'https://example.com/2024/03/post-' . $i . '/' + . 'Body ' . $i . '.

]]>
' + . '' + . '' . $i . '' + . '2024-03-15 10:30:00' + . 'post-' . $i . '' + . 'publish' + . 'post' + . '' + . '' + . '
'; + } + + file_put_contents( + $this->sourceFile, + '' + . '' + . '' . implode('', $items) . '', + ); + + $this->importer = new WordPressContentImporter(); + } + + 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..a84d861 100644 --- a/config/common/di/importer.php +++ b/config/common/di/importer.php @@ -4,6 +4,7 @@ use YiiPress\Console\ImportCommand; use YiiPress\Import\Telegram\TelegramContentImporter; +use YiiPress\Import\WordPress\WordPressContentImporter; $workingDirectory = getcwd() ?: dirname(__DIR__, 3); @@ -13,6 +14,7 @@ 'rootPath' => $workingDirectory, 'importers' => [ 'telegram' => new TelegramContentImporter(), + 'wordpress' => new WordPressContentImporter(), ], ], ], diff --git a/docs/commands.md b/docs/commands.md index ed8768d..112d5bf 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: `telegram`, `wordpress`. **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 ``` +### WordPress import + +Imports posts and pages from a WordPress WXR XML export file. Export your site from WordPress via Tools > Export > All content. + +**Importer options:** + +- `--file` — path to the WordPress WXR `.xml` export file (required). Absolute or relative to project root. + +The importer reads `` records from the export and converts: + +- WordPress posts (`wp:post_type = post`) into markdown files in the target collection. +- WordPress pages (`wp:post_type = page`) into standalone markdown files in the content root. +- `title`, `wp:post_date`, `link`, `excerpt:encoded`, `content:encoded`, `wp:status`, categories, and tags into YiiPress front matter and body content. + +Published posts are imported normally. Non-published posts and pages are imported with `draft: true`. Attachments, revisions, menu items, trashed posts, and auto-drafts are skipped. Duplicate output filenames get numeric suffixes so earlier files are not overwritten. + +**Examples:** + +```bash +./yiipress import wordpress --file=/path/to/wordpress-export.xml +./yiipress import wordpress --file=./export.xml --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..a09d1a6 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. +### WordPressContentImporter + +Imports posts and pages from a WordPress WXR XML export. + +**Options:** + +- `--file` — Path to the WordPress WXR `.xml` export file (required) + +The importer converts WordPress posts into the selected YiiPress collection and WordPress pages into standalone content root markdown files. It preserves common metadata (`title`, date, permalink path, draft status, excerpt summary, tags, and categories), keeps `content:encoded` as the markdown body, skips unsupported WordPress item types, and avoids overwriting duplicate output filenames. + +See [commands.md](commands.md#wordpress-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..a2c0d2b 100644 --- a/roadmap.md +++ b/roadmap.md @@ -106,7 +106,7 @@ ## Priority 9: Data importers -- [ ] WordPress +- [x] WordPress - [ ] Jekyll - [ ] Hugo - [ ] Medium exported Markdown diff --git a/src/Import/WordPress/WordPressContentImporter.php b/src/Import/WordPress/WordPressContentImporter.php new file mode 100644 index 0000000..1610e99 --- /dev/null +++ b/src/Import/WordPress/WordPressContentImporter.php @@ -0,0 +1,466 @@ +loadXml($xml); + if ($document === null) { + return new ImportResult( + totalMessages: 0, + importedCount: 0, + importedFiles: [], + skippedFiles: [], + warnings: ["Invalid WordPress WXR XML in $sourceFile"], + ); + } + + FileHelper::ensureDirectory($targetDirectory, 0o755); + + $collectionDir = $targetDirectory . '/' . $collection; + $importedFiles = []; + $skippedFiles = []; + $warnings = []; + $usedPaths = []; + $hasCollectionEntries = false; + + $items = $document->getElementsByTagName('item'); + foreach ($items as $item) { + if (!$item instanceof DOMElement) { + continue; + } + + $entry = $this->readItem($item); + if ($entry === null) { + $skippedFiles[] = $this->itemIdentifier($item); + continue; + } + + $directory = $targetDirectory; + if ($entry['type'] === 'post') { + FileHelper::ensureDirectory($collectionDir, 0o755); + $directory = $collectionDir; + $hasCollectionEntries = true; + } + + $filename = $this->filename($entry); + $path = $this->uniquePath($directory, $filename, $usedPaths); + file_put_contents($path, $this->buildMarkdownFile($entry)); + $importedFiles[] = $path; + } + + if ($hasCollectionEntries) { + $this->ensureCollectionConfig($collectionDir, $collection); + } + + return new ImportResult( + totalMessages: $items->length, + importedCount: count($importedFiles), + importedFiles: $importedFiles, + skippedFiles: $skippedFiles, + warnings: $warnings, + ); + } + + public function name(): string + { + return 'wordpress'; + } + + private function loadXml(string $xml): ?DOMDocument + { + if ($xml === '') { + return null; + } + + $previous = libxml_use_internal_errors(true); + try { + $document = new DOMDocument(); + if (!$document->loadXML($xml, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING)) { + return null; + } + + return $document; + } finally { + libxml_clear_errors(); + libxml_use_internal_errors($previous); + } + } + + /** + * @return array{ + * type: 'post'|'page', + * title: string, + * slug: string, + * date: string, + * permalink: string, + * draft: bool, + * summary: string, + * body: string, + * tags: list, + * categories: list + * }|null + */ + private function readItem(DOMElement $item): ?array + { + $type = $this->childText($item, 'post_type', self::WP_NS); + if (!in_array($type, ['post', 'page'], true)) { + return null; + } + + $status = $this->childText($item, 'status', self::WP_NS); + if (in_array($status, ['trash', 'auto-draft'], true)) { + return null; + } + + $title = $this->childText($item, 'title'); + $slug = $this->filesystemSlug($this->childText($item, 'post_name', self::WP_NS)); + if ($slug === 'post') { + $slug = $this->slugFromTitle($title); + } + + if ($title === '') { + $title = ucfirst(str_replace('-', ' ', $slug)); + } + + $body = $this->childText($item, 'encoded', self::CONTENT_NS); + if ($body === '') { + $body = $this->childText($item, 'description'); + } + + return [ + 'type' => $type, + 'title' => $title, + 'slug' => $slug, + 'date' => $this->publishedDate($item), + 'permalink' => $this->permalink($item, $type, $slug), + 'draft' => $status !== '' && $status !== 'publish', + 'summary' => $this->summary($item), + 'body' => trim($body) . "\n", + 'tags' => $this->taxonomyValues($item, 'post_tag'), + 'categories' => $this->taxonomyValues($item, 'category'), + ]; + } + + private function itemIdentifier(DOMElement $item): string + { + $id = $this->childText($item, 'post_id', self::WP_NS); + + return $id !== '' ? $id : $this->childText($item, 'title'); + } + + private function childText(DOMElement $element, string $localName, ?string $namespace = null): string + { + foreach ($element->childNodes as $child) { + if (!$child instanceof DOMElement) { + continue; + } + + if ($child->localName !== $localName) { + continue; + } + + if ($namespace !== null && $child->namespaceURI !== $namespace) { + continue; + } + + return trim($child->textContent); + } + + return ''; + } + + private function publishedDate(DOMElement $item): string + { + $date = $this->childText($item, 'post_date', self::WP_NS); + if ($date !== '' && !str_starts_with($date, '0000-00-00')) { + return $date; + } + + $date = $this->childText($item, 'pubDate'); + if ($date === '') { + return ''; + } + + try { + return (new \DateTimeImmutable($date))->format('Y-m-d H:i:s'); + } catch (Throwable) { + return ''; + } + } + + private function permalink(DOMElement $item, string $type, string $slug): string + { + $path = parse_url($this->childText($item, 'link'), PHP_URL_PATH); + if (is_string($path) && $path !== '') { + return str_starts_with($path, '/') ? $path : '/' . $path; + } + + return $type === 'page' ? '/' . $slug . '/' : ''; + } + + private function summary(DOMElement $item): string + { + $summary = $this->childText($item, 'encoded', self::EXCERPT_NS); + if ($summary === '') { + return ''; + } + + return trim(strip_tags($summary)); + } + + /** + * @return list + */ + private function taxonomyValues(DOMElement $item, string $domain): array + { + $values = []; + foreach ($item->childNodes as $child) { + if (!$child instanceof DOMElement || $child->localName !== 'category') { + continue; + } + + if ($child->getAttribute('domain') !== $domain) { + continue; + } + + $value = $child->getAttribute('nicename'); + if ($value === '') { + $value = $this->slugFromTitle(trim($child->textContent)); + } + + if ($value !== '') { + $values[$value] = true; + } + } + + return array_keys($values); + } + + /** + * @param array{ + * type: 'post'|'page', + * title: string, + * slug: string, + * date: string, + * permalink: string, + * draft: bool, + * summary: string, + * body: string, + * tags: list, + * categories: 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{ + * type: 'post'|'page', + * title: string, + * slug: string, + * date: string, + * permalink: string, + * draft: bool, + * summary: string, + * body: string, + * tags: list, + * categories: 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['tags'] !== []) { + $frontMatter .= "tags:\n"; + foreach ($entry['tags'] as $tag) { + $frontMatter .= ' - ' . $this->yamlEscape($tag) . "\n"; + } + } + + if ($entry['categories'] !== []) { + $frontMatter .= "categories:\n"; + foreach ($entry['categories'] as $category) { + $frontMatter .= ' - ' . $this->yamlEscape($category) . "\n"; + } + } + + return $frontMatter . "---\n\n" . $entry['body']; + } + + 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..37de181 100644 --- a/tests/Unit/Console/ImportCommandTest.php +++ b/tests/Unit/Console/ImportCommandTest.php @@ -103,12 +103,40 @@ public function testImportsToCustomCollection(): void assertStringContainsString('Imported: 1', $result['output']); } + public function testImportsWordPressExport(): void + { + $exportFile = $this->sourceDir . '/wordpress.xml'; + file_put_contents( + $exportFile, + '' + . '' + . '' + . 'Hello WordPress' + . '' + . '1' + . '2024-03-15 10:30:00' + . 'hello-wordpress' + . 'publish' + . 'post' + . '', + ); + + $result = $this->runImport('wordpress', ['--file' => $exportFile]); + + assertSame(0, $result['exitCode'], $result['output']); + assertStringContainsString('Importing from wordpress', $result['output']); + assertStringContainsString('Imported: 1', $result['output']); + } + public function testShowsAvailableImportersOnError(): void { - $result = $this->runImport('wordpress', ['--directory' => $this->sourceDir]); + $result = $this->runImport('unknown', ['--directory' => $this->sourceDir]); assertSame(65, $result['exitCode']); assertStringContainsString('telegram', $result['output']); + assertStringContainsString('wordpress', $result['output']); } /** diff --git a/tests/Unit/Import/WordPressContentImporterTest.php b/tests/Unit/Import/WordPressContentImporterTest.php new file mode 100644 index 0000000..ed5186f --- /dev/null +++ b/tests/Unit/Import/WordPressContentImporterTest.php @@ -0,0 +1,245 @@ +targetDir = sys_get_temp_dir() . '/yiipress-wordpress-target-' . uniqid(); + mkdir($sourceDir, 0o755, true); + mkdir($this->targetDir, 0o755, true); + $this->sourceFile = $sourceDir . '/wordpress.xml'; + } + + protected function tearDown(): void + { + $this->removeDir(dirname($this->sourceFile)); + $this->removeDir($this->targetDir); + } + + public function testImportsPublishedPostAndPageFromWxr(): void + { + file_put_contents($this->sourceFile, $this->wxr([ + $this->item([ + 'id' => 10, + 'title' => 'Hello: WordPress', + 'link' => 'https://example.com/2024/03/hello-wordpress/', + 'pubDate' => 'Fri, 15 Mar 2024 10:30:00 +0000', + 'postDate' => '2024-03-15 10:30:00', + 'postName' => 'hello-wordpress', + 'status' => 'publish', + 'type' => 'post', + 'content' => '

Hello from WordPress.

', + 'excerpt' => '

Short summary.

', + 'categories' => [ + ['domain' => 'category', 'nicename' => 'docs', 'title' => 'Docs'], + ['domain' => 'post_tag', 'nicename' => 'yii', 'title' => 'Yii'], + ], + ]), + $this->item([ + 'id' => 11, + 'title' => 'About', + 'link' => 'https://example.com/about/', + 'postDate' => '2024-03-16 11:00:00', + 'postName' => 'about', + 'status' => 'publish', + 'type' => 'page', + 'content' => '

About page.

', + ]), + ])); + + $result = (new WordPressContentImporter())->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-wordpress.md'); + $this->assertNotFalse($post); + assertStringContainsString('title: "Hello: WordPress"', $post); + assertStringContainsString('date: 2024-03-15 10:30:00', $post); + assertStringContainsString('permalink: /2024/03/hello-wordpress/', $post); + assertStringContainsString('summary: Short summary.', $post); + assertStringContainsString("tags:\n - yii\n", $post); + assertStringContainsString("categories:\n - docs\n", $post); + assertStringContainsString('

Hello from WordPress.

', $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 testMarksNonPublishedPostsAsDraftsAndSkipsAttachments(): void + { + file_put_contents($this->sourceFile, $this->wxr([ + $this->item([ + 'id' => 20, + 'title' => 'Draft Post', + 'postDate' => '2024-04-01 09:00:00', + 'postName' => 'draft-post', + 'status' => 'draft', + 'type' => 'post', + 'content' => 'Draft body.', + ]), + $this->item([ + 'id' => 21, + 'title' => 'Attachment', + 'status' => 'inherit', + 'type' => 'attachment', + ]), + ])); + + $result = (new WordPressContentImporter())->import(['file' => $this->sourceFile], $this->targetDir, 'blog'); + + assertSame(2, $result->totalMessages()); + assertSame(1, $result->importedCount()); + assertSame(['21'], $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, $this->wxr([ + $this->item([ + 'id' => 30, + 'title' => 'Duplicate', + 'postDate' => '2024-05-01 09:00:00', + 'postName' => 'duplicate', + 'status' => 'publish', + 'type' => 'post', + 'content' => 'First.', + ]), + $this->item([ + 'id' => 31, + 'title' => 'Duplicate', + 'postDate' => '2024-05-01 10:00:00', + 'postName' => 'duplicate', + 'status' => 'publish', + 'type' => 'post', + 'content' => 'Second.', + ]), + ])); + + $result = (new WordPressContentImporter())->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 WordPressContentImporter())->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 testWarnsWhenXmlIsInvalid(): void + { + file_put_contents($this->sourceFile, ''); + + $result = (new WordPressContentImporter())->import(['file' => $this->sourceFile], $this->targetDir, 'blog'); + + assertSame(0, $result->importedCount()); + assertCount(1, $result->warnings()); + assertStringContainsString('Invalid WordPress WXR XML', $result->warnings()[0]); + } + + /** + * @param list $items + */ + private function wxr(array $items): string + { + return '' + . '' + . '' . implode('', $items) . ''; + } + + /** + * @param array{ + * id: int, + * title: string, + * link?: string, + * pubDate?: string, + * postDate?: string, + * postName?: string, + * status: string, + * type: string, + * content?: string, + * excerpt?: string, + * categories?: list + * } $data + */ + private function item(array $data): string + { + $categories = ''; + foreach ($data['categories'] ?? [] as $category) { + $categories .= '' + . ''; + } + + return '' + . '<![CDATA[' . $data['title'] . ']]>' + . '' . ($data['link'] ?? '') . '' + . '' . ($data['pubDate'] ?? '') . '' + . '' + . '' + . '' . $data['id'] . '' + . '' . ($data['postDate'] ?? '') . '' + . '' + . '' . $data['status'] . '' + . '' . $data['type'] . '' + . $categories + . ''; + } + + 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); + } +}