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)
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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']),
|
||||||
|
|||||||
Reference in New Issue
Block a user