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[] = '- '
+ . ''
+ . '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 '- '
+ . ''
+ . '' . ($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);
+ }
+}