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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
$mediaId = $this->uploadFeaturedImage($post, $site);
|
||||
$payload = [
|
||||
'title' => $post->title,
|
||||
'content' => $post->body,
|
||||
'status' => 'publish',
|
||||
];
|
||||
|
||||
if ($mediaId instanceof PublishResult) {
|
||||
return $mediaId;
|
||||
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
|
||||
|
||||
@@ -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']),
|
||||
|
||||
Reference in New Issue
Block a user