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) Post (one piece of content, belongs to a Site, optionally a SocialAccount)
└─ type: same vocabulary as SocialAccount.network, plus "blog" └─ type: same vocabulary as SocialAccount.network, plus "blog"
└─ target: nullable, network-specific destination (e.g. a subreddit for type=reddit) └─ target: nullable, network-specific destination (e.g. a subreddit for type=reddit)
└─ image_url: nullable URL. Required to publish type=blog (WordPress) — └─ image_url: nullable URL, optional for every type. If set on a
used as the featured image. Optional for every other type. 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 └─ status: draft -> scheduled -> publishing -> published | failed
└─ published_url: set once it's actually live somewhere └─ published_url: set once it's actually live somewhere
└─ publish_error: set to the failure reason when status=failed (or when a └─ 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`. `facebook`, `reddit`, `tiktok`, `linkedin`, `youtube`, `blog`, `other`.
`body` required. `title` nullable. `target` nullable — a network-specific `body` required. `title` nullable. `target` nullable — a network-specific
destination, e.g. the subreddit name for `type=reddit`. `image_url` nullable 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), URL, optional for every type — if set on a `type=blog` post it's uploaded to
but `WordPressPublisher` will reject publishing it until it's set. `status` WordPress as the featured image, but publishing works fine without one.
defaults to `draft` if omitted. `scheduled_at` nullable datetime. `status` defaults to `draft` if omitted. `scheduled_at` nullable datetime.
Note: `type` and the linked Social Account's `network` are **not** 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 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: What it does:
1. Loads the Post and its Site. 1. Loads the Post and its Site.
2. Validates: `post.type` must be `blog`, `post.title` must be set, 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 `site.cms_type` must be `wordpress`, and the site must have
the site must have `wp_username`/`wp_app_password` set. Any failure here `wp_username`/`wp_app_password` set. Any failure here exits 1 with a
exits 1 with a descriptive message and **does not touch** `Post.status`. descriptive message and **does not touch** `Post.status`.
3. Downloads `post.image_url`, then POSTs it to 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 `{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 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 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 5. On success: sets `Post.status = published`, `Post.published_url` to the
new post's permalink, prints `{"success": true, "url": "..."}`, exits 0. 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`, unreachable/rejected image, WordPress error): sets `Post.status = failed`,
prints `{"success": false, "error": "..."}`, exits 1. 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. (e.g. Reddit's browser automation) don't block the page.
- Built-in publishers (`app/Support/Publishing/`), one per network, resolved - Built-in publishers (`app/Support/Publishing/`), one per network, resolved
by `Post.type`: by `Post.type`:
- **WordPress**: posts directly to a site's WordPress REST API, uploading - **WordPress**: posts directly to a site's WordPress REST API. If
`Post.image_url` as the featured image first — required to publish a `Post.image_url` is set (optional, for every post type), it's uploaded
WordPress post, optional for every other post type. Also available and used as the featured image. Also available synchronously from the
synchronously from the CLI: `php artisan posts:publish {post_id}`. CLI: `php artisan posts:publish {post_id}`.
- **Reddit**: drives a standalone Playwright sidecar at - **Reddit**: drives a standalone Playwright sidecar at
`sidecars/reddit-post/` that logs in and creates a post. `sidecars/reddit-post/` that logs in and creates a post.
- Anything else: no publisher yet — publish however fits, then report - Anything else: no publisher yet — publish however fits, then report
+14 -13
View File
@@ -20,10 +20,6 @@ class WordPressPublisher implements Publisher
return PublishResult::rejected("Post {$post->id} has no title, required to publish to WordPress."); 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; $site = $post->site;
if ($site->cms_type !== 'wordpress') { if ($site->cms_type !== 'wordpress') {
@@ -34,20 +30,25 @@ class WordPressPublisher implements Publisher
return PublishResult::rejected("Site '{$site->name}' is missing WordPress credentials."); return PublishResult::rejected("Site '{$site->name}' is missing WordPress credentials.");
} }
$mediaId = $this->uploadFeaturedImage($post, $site); $payload = [
'title' => $post->title,
'content' => $post->body,
'status' => 'publish',
];
if ($mediaId instanceof PublishResult) { if ($post->image_url) {
return $mediaId; $mediaId = $this->uploadFeaturedImage($post, $site);
if ($mediaId instanceof PublishResult) {
return $mediaId;
}
$payload['featured_media'] = $mediaId;
} }
try { try {
$response = Http::withBasicAuth($site->wp_username, $site->wp_app_password) $response = Http::withBasicAuth($site->wp_username, $site->wp_app_password)
->post(rtrim($site->url, '/').'/wp-json/wp/v2/posts', [ ->post(rtrim($site->url, '/').'/wp-json/wp/v2/posts', $payload);
'title' => $post->title,
'content' => $post->body,
'status' => 'publish',
'featured_media' => $mediaId,
]);
} catch (ConnectionException $e) { } catch (ConnectionException $e) {
return PublishResult::failed("could not connect to site: {$e->getMessage()}"); return PublishResult::failed("could not connect to site: {$e->getMessage()}");
} }
@@ -27,8 +27,6 @@
<td class="p-3"> <td class="p-3">
@if ($post->image_url) @if ($post->image_url)
<img src="{{ $post->image_url }}" alt="" class="w-10 h-10 object-cover rounded"> <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 @else
<span class="text-gray-400">—</span> <span class="text-gray-400">—</span>
@endif @endif
@@ -118,7 +116,7 @@
<div> <div>
<label class="block text-sm font-medium"> <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> </label>
<input type="url" wire:model="imageUrl" placeholder="https://..." class="w-full border rounded p-2"> <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 @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); ->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 () { test('it fails when the site is not configured as WordPress', function () {
$site = Site::factory()->create(['cms_type' => 'custom']); $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]) $this->artisan('posts:publish', ['post' => $post->id])
->expectsOutputToContain('not configured as WordPress') ->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 () { 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]); $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]) $this->artisan('posts:publish', ['post' => $post->id])
->expectsOutputToContain('missing WordPress credentials') ->expectsOutputToContain('missing WordPress credentials')
->assertExitCode(1); ->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 () { test('it publishes successfully, uploading the image as the featured media', function () {
Http::fake([ Http::fake([
'https://images.example.com/*' => Http::response('fake-image-bytes', 200, ['Content-Type' => 'image/jpeg']), 'https://images.example.com/*' => Http::response('fake-image-bytes', 200, ['Content-Type' => 'image/jpeg']),