Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions benchmarks/GhostImporterBench.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php

declare(strict_types=1);

namespace YiiPress\Benchmarks;

use PhpBench\Attributes\AfterMethods;
use PhpBench\Attributes\BeforeMethods;
use PhpBench\Attributes\Iterations;
use PhpBench\Attributes\Revs;
use PhpBench\Attributes\Warmup;
use YiiPress\Import\Ghost\GhostContentImporter;

#[BeforeMethods('setUp')]
#[AfterMethods('tearDown')]
final class GhostImporterBench
{
private string $sourceDir;
private string $sourceFile;
private string $targetDir;
private GhostContentImporter $importer;

public function setUp(): void
{
$this->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' => '<p>Body ' . $i . '.</p>',
];
$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);
}
}
2 changes: 2 additions & 0 deletions config/common/di/importer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -12,6 +13,7 @@
'__construct()' => [
'rootPath' => $workingDirectory,
'importers' => [
'ghost' => new GhostContentImporter(),
'telegram' => new TelegramContentImporter(),
],
],
Expand Down
25 changes: 24 additions & 1 deletion docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**

Expand Down Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions docs/importing-content.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 1 addition & 1 deletion roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,5 +110,5 @@
- [ ] Jekyll
- [ ] Hugo
- [ ] Medium exported Markdown
- [ ] Ghost
- [x] Ghost
- [x] Telegram export
Loading