Make the image optional for WordPress posts too

Some blog posts just don't have one. WordPressPublisher no longer
rejects publishing a type=blog post without image_url — it uploads
and attaches it as the featured image when present, and simply skips
that step (no featured_media set) otherwise.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
m105
2026-07-21 20:07:47 +03:00
co-authored by Claude Sonnet 5
parent 42abc658f2
commit 4b3af4cc89
5 changed files with 70 additions and 45 deletions
+15 -12
View File
@@ -37,8 +37,9 @@ SocialAccount (login for a social network)
Post (one piece of content, belongs to a Site, optionally a SocialAccount)
└─ type: same vocabulary as SocialAccount.network, plus "blog"
└─ target: nullable, network-specific destination (e.g. a subreddit for type=reddit)
└─ image_url: nullable URL. Required to publish type=blog (WordPress) —
used as the featured image. Optional for every other type.
└─ image_url: nullable URL, optional for every type. If set on a
type=blog post, WordPressPublisher uploads it and uses it as the
featured image — but it's fine to publish a blog post without one.
└─ status: draft -> scheduled -> publishing -> published | failed
└─ published_url: set once it's actually live somewhere
└─ publish_error: set to the failure reason when status=failed (or when a
@@ -157,9 +158,9 @@ must exist if given. `type` required, one of `twitter`, `instagram`,
`facebook`, `reddit`, `tiktok`, `linkedin`, `youtube`, `blog`, `other`.
`body` required. `title` nullable. `target` nullable — a network-specific
destination, e.g. the subreddit name for `type=reddit`. `image_url` nullable
URL — not enforced at save time (you can draft a `type=blog` post without one),
but `WordPressPublisher` will reject publishing it until it's set. `status`
defaults to `draft` if omitted. `scheduled_at` nullable datetime.
URL, optional for every type — if set on a `type=blog` post it's uploaded to
WordPress as the featured image, but publishing works fine without one.
`status` defaults to `draft` if omitted. `scheduled_at` nullable datetime.
Note: `type` and the linked Social Account's `network` are **not**
cross-validated — you can link a `type=blog` post to a Twitter account, or
@@ -217,18 +218,20 @@ php artisan posts:publish {post_id}
What it does:
1. Loads the Post and its Site.
2. Validates: `post.type` must be `blog`, `post.title` must be set,
`post.image_url` must be set, `site.cms_type` must be `wordpress`, and
the site must have `wp_username`/`wp_app_password` set. Any failure here
exits 1 with a descriptive message and **does not touch** `Post.status`.
3. Downloads `post.image_url`, then POSTs it to
`site.cms_type` must be `wordpress`, and the site must have
`wp_username`/`wp_app_password` set. Any failure here exits 1 with a
descriptive message and **does not touch** `Post.status`.
3. If `post.image_url` is set: downloads it, then POSTs it to
`{site.url}/wp-json/wp/v2/media` (HTTP Basic Auth with the site's
WordPress Application Password) to upload it as media and get back a
media id.
media id. Skipped entirely if there's no image — that's fine, the post
just won't have a featured image.
4. POSTs to `{site.url}/wp-json/wp/v2/posts` with `status: publish` (goes
live immediately, not a WP draft) and `featured_media` set to that id.
live immediately, not a WP draft), and `featured_media` set to the
uploaded media id if there was one.
5. On success: sets `Post.status = published`, `Post.published_url` to the
new post's permalink, prints `{"success": true, "url": "..."}`, exits 0.
6. On failure at either HTTP step (bad credentials, unreachable site,
6. On failure at any HTTP step (bad credentials, unreachable site,
unreachable/rejected image, WordPress error): sets `Post.status = failed`,
prints `{"success": false, "error": "..."}`, exits 1.
+4 -4
View File
@@ -26,10 +26,10 @@ Built with Laravel 13, Livewire, and Tailwind CSS.
(e.g. Reddit's browser automation) don't block the page.
- Built-in publishers (`app/Support/Publishing/`), one per network, resolved
by `Post.type`:
- **WordPress**: posts directly to a site's WordPress REST API, uploading
`Post.image_url` as the featured image first — required to publish a
WordPress post, optional for every other post type. Also available
synchronously from the CLI: `php artisan posts:publish {post_id}`.
- **WordPress**: posts directly to a site's WordPress REST API. If
`Post.image_url` is set (optional, for every post type), it's uploaded
and used as the featured image. Also available synchronously from the
CLI: `php artisan posts:publish {post_id}`.
- **Reddit**: drives a standalone Playwright sidecar at
`sidecars/reddit-post/` that logs in and creates a post.
- Anything else: no publisher yet — publish however fits, then report
+11 -10
View File
@@ -20,10 +20,6 @@ class WordPressPublisher implements Publisher
return PublishResult::rejected("Post {$post->id} has no title, required to publish to WordPress.");
}
if (! $post->image_url) {
return PublishResult::rejected("Post {$post->id} has no image, required to publish to WordPress.");
}
$site = $post->site;
if ($site->cms_type !== 'wordpress') {
@@ -34,20 +30,25 @@ class WordPressPublisher implements Publisher
return PublishResult::rejected("Site '{$site->name}' is missing WordPress credentials.");
}
$payload = [
'title' => $post->title,
'content' => $post->body,
'status' => 'publish',
];
if ($post->image_url) {
$mediaId = $this->uploadFeaturedImage($post, $site);
if ($mediaId instanceof PublishResult) {
return $mediaId;
}
$payload['featured_media'] = $mediaId;
}
try {
$response = Http::withBasicAuth($site->wp_username, $site->wp_app_password)
->post(rtrim($site->url, '/').'/wp-json/wp/v2/posts', [
'title' => $post->title,
'content' => $post->body,
'status' => 'publish',
'featured_media' => $mediaId,
]);
->post(rtrim($site->url, '/').'/wp-json/wp/v2/posts', $payload);
} catch (ConnectionException $e) {
return PublishResult::failed("could not connect to site: {$e->getMessage()}");
}
@@ -27,8 +27,6 @@
<td class="p-3">
@if ($post->image_url)
<img src="{{ $post->image_url }}" alt="" class="w-10 h-10 object-cover rounded">
@elseif ($post->type === 'blog')
<span class="text-xs text-amber-600">missing</span>
@else
<span class="text-gray-400">—</span>
@endif
@@ -118,7 +116,7 @@
<div>
<label class="block text-sm font-medium">
Image URL {{ $type === 'blog' ? '(required to publish to WordPress)' : '(optional)' }}
Image URL (optional{{ $type === 'blog' ? ' — used as the featured image on WordPress if set' : '' }})
</label>
<input type="url" wire:model="imageUrl" placeholder="https://..." class="w-full border rounded p-2">
@error('imageUrl') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
+36 -13
View File
@@ -29,20 +29,9 @@ test('it fails when the post has no title', function () {
->assertExitCode(1);
});
test('it fails when the post has no image', function () {
$site = Site::factory()->wordpress()->create();
$post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi', 'image_url' => null]);
$this->artisan('posts:publish', ['post' => $post->id])
->expectsOutputToContain('no image')
->assertExitCode(1);
expect($post->fresh()->status)->toBe($post->status);
});
test('it fails when the site is not configured as WordPress', function () {
$site = Site::factory()->create(['cms_type' => 'custom']);
$post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi', 'image_url' => 'https://example.com/image.jpg']);
$post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi']);
$this->artisan('posts:publish', ['post' => $post->id])
->expectsOutputToContain('not configured as WordPress')
@@ -51,13 +40,47 @@ test('it fails when the site is not configured as WordPress', function () {
test('it fails when the WordPress site is missing credentials', function () {
$site = Site::factory()->create(['cms_type' => 'wordpress', 'wp_username' => null, 'wp_app_password' => null]);
$post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi', 'image_url' => 'https://example.com/image.jpg']);
$post = Post::factory()->create(['site_id' => $site->id, 'type' => 'blog', 'title' => 'Hi']);
$this->artisan('posts:publish', ['post' => $post->id])
->expectsOutputToContain('missing WordPress credentials')
->assertExitCode(1);
});
test('it publishes successfully without an image', function () {
Http::fake([
'*/wp-json/wp/v2/posts' => Http::response([
'id' => 42,
'link' => 'https://wp.example.com/2026/07/hello-world/',
], 201),
]);
$site = Site::factory()->wordpress()->create(['url' => 'https://wp.example.com']);
$post = Post::factory()->create([
'site_id' => $site->id,
'type' => 'blog',
'title' => 'Hello World',
'body' => 'Post content',
'image_url' => null,
'status' => 'draft',
]);
$this->artisan('posts:publish', ['post' => $post->id])
->expectsOutputToContain('"success":true')
->assertExitCode(0);
expect($post->fresh())
->status->toBe('published')
->published_url->toBe('https://wp.example.com/2026/07/hello-world/');
Http::assertNotSent(fn ($request) => $request->url() === 'https://wp.example.com/wp-json/wp/v2/media');
Http::assertSent(function ($request) {
return $request->url() === 'https://wp.example.com/wp-json/wp/v2/posts'
&& ! array_key_exists('featured_media', $request->data());
});
});
test('it publishes successfully, uploading the image as the featured media', function () {
Http::fake([
'https://images.example.com/*' => Http::response('fake-image-bytes', 200, ['Content-Type' => 'image/jpeg']),