diff --git a/.github/changelog/fix-pre-publish-error-handling b/.github/changelog/fix-pre-publish-error-handling
new file mode 100644
index 0000000..62bf5e2
--- /dev/null
+++ b/.github/changelog/fix-pre-publish-error-handling
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+The Bluesky pre-publish preview now shows a clear message when it can't be generated — including a distinct one for permission errors — instead of failing without explanation.
diff --git a/build/pre-publish-panel/plugin.asset.php b/build/pre-publish-panel/plugin.asset.php
index 3f3e441..c904376 100644
--- a/build/pre-publish-panel/plugin.asset.php
+++ b/build/pre-publish-panel/plugin.asset.php
@@ -1 +1 @@
- array('react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-core-data', 'wp-data', 'wp-editor', 'wp-element', 'wp-i18n', 'wp-plugins'), 'version' => '8fa0d7dd1bccedfa1d37');
+ array('react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-core-data', 'wp-data', 'wp-editor', 'wp-element', 'wp-i18n', 'wp-plugins'), 'version' => '424b2fbff0951a70d6d3');
diff --git a/build/pre-publish-panel/plugin.js b/build/pre-publish-panel/plugin.js
index 87967cb..0e21e6a 100644
--- a/build/pre-publish-panel/plugin.js
+++ b/build/pre-publish-panel/plugin.js
@@ -1,4 +1,4 @@
-(()=>{"use strict";var e={n:t=>{var r=t&&t.__esModule?()=>t.default:()=>t;return e.d(r,{a:r}),r},d:(t,r)=>{for(var s in r)e.o(r,s)&&!e.o(t,s)&&Object.defineProperty(t,s,{enumerable:!0,get:r[s]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t)};const t=window.wp.editor,r=window.wp.plugins,s=window.wp.data,i=window.wp.coreData,o=window.wp.element,n=window.wp.i18n,a=window.wp.apiFetch;var d=e.n(a);const p=window.wp.components,l="undefined"!=typeof window&&window.atmosphereEditor||{},u=l.metaKey||"atmosphere_disabled",c=l.previewPath||"/atmosphere/1.0/admin/pre-publish-preview";function h(e){switch(e){case"short-form":return(0,n.__)("Short note","atmosphere");case"truncate-link":return(0,n.__)("Text with link","atmosphere");case"teaser-thread":return(0,n.__)("Teaser thread","atmosphere");case"redacted":return(0,n.__)("Not shared","atmosphere");default:return(0,n.__)("Link card","atmosphere")}}function m(e){return Array.isArray(e)&&e.some(e=>e&&e.over_limit)}const w=window.ReactJSXRuntime;function _(){const{postId:e,postType:r,title:a,content:l,excerpt:_,status:f,password:b}=(0,s.useSelect)(e=>{const r=e(t.store);return{postId:r.getCurrentPostId(),postType:r.getCurrentPostType(),title:r.getEditedPostAttribute("title"),content:r.getEditedPostContent(),excerpt:r.getEditedPostAttribute("excerpt"),status:r.getEditedPostAttribute("status"),password:r.getEditedPostAttribute("password")}},[]),[y]=(0,i.useEntityProp)("postType",r,"meta"),P=!(!y||!y[u]),[x,g]=(0,o.useState)(null),[v,j]=(0,o.useState)(!0),[T,k]=(0,o.useState)(!1),E=(0,o.useRef)(null);if((0,o.useEffect)(()=>{if(e)return j(!0),k(!1),clearTimeout(E.current),E.current=setTimeout(()=>{d()({path:c,method:"POST",data:{id:e,title:a,content:l,excerpt:_,status:f,password:b,disabled:P}}).then(e=>{g(e),j(!1)}).catch(()=>{k(!0),j(!1)})},400),()=>clearTimeout(E.current)},[e,a,l,_,f,b,P]),v)return(0,w.jsx)(p.Spinner,{});if(T||!x)return(0,w.jsx)("p",{children:(0,n.__)("Could not load the Bluesky preview.","atmosphere")});if(!x.will_publish)return(0,w.jsx)(p.Notice,{status:"info",isDismissible:!1,children:x.reason||(0,n.__)("This post won’t be shared to Bluesky.","atmosphere")});const S=x.records||[];return(0,w.jsxs)("div",{className:"atmosphere-pre-publish",children:[(0,w.jsx)("p",{children:(0,n.sprintf)(/* translators: %s: the publishing strategy, e.g. "Short note" or "Link card". */ /* translators: %s: the publishing strategy, e.g. "Short note" or "Link card". */
-(0,n.__)("This post will be shared to Bluesky as: %s.","atmosphere"),h(x.strategy))}),1===S.length&&(0,w.jsx)("p",{className:S[0].over_limit?"atmosphere-over-limit":void 0,children:(0,n.sprintf)(/* translators: 1: character count, 2: the 300-character limit. */ /* translators: 1: character count, 2: the 300-character limit. */
-(0,n.__)("%1$d / %2$d characters","atmosphere"),S[0].characters,x.limit)}),S.length>1&&(0,w.jsx)("ul",{children:S.map((e,t)=>(0,w.jsx)("li",{className:e.over_limit?"atmosphere-over-limit":void 0,children:(0,n.sprintf)(/* translators: 1: post number in the thread, 2: character count, 3: the limit. */ /* translators: 1: post number in the thread, 2: character count, 3: the limit. */
-(0,n.__)("Post %1$d: %2$d / %3$d characters","atmosphere"),t+1,e.characters,x.limit)},t))}),m(S)&&(0,w.jsx)(p.Notice,{status:"warning",isDismissible:!1,children:(0,n.__)("Your text is over Bluesky’s limit and will be shortened when published.","atmosphere")})]})}(0,r.registerPlugin)("atmosphere-pre-publish-panel",{render:()=>(0,w.jsx)(t.PluginPrePublishPanel,{title:(0,n.__)("Bluesky","atmosphere"),initialOpen:!0,children:(0,w.jsx)(_,{})})})})();
\ No newline at end of file
+(()=>{"use strict";var e={n:t=>{var r=t&&t.__esModule?()=>t.default:()=>t;return e.d(r,{a:r}),r},d:(t,r)=>{for(var s in r)e.o(r,s)&&!e.o(t,s)&&Object.defineProperty(t,s,{enumerable:!0,get:r[s]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t)};const t=window.wp.editor,r=window.wp.plugins,s=window.wp.data,i=window.wp.coreData,o=window.wp.element,n=window.wp.i18n,a=window.wp.apiFetch;var d=e.n(a);const p=window.wp.components,l="undefined"!=typeof window&&window.atmosphereEditor||{},u=l.metaKey||"atmosphere_disabled",c=l.previewPath||"/atmosphere/1.0/admin/pre-publish-preview";function h(e){switch(e){case"short-form":return(0,n.__)("Short note","atmosphere");case"truncate-link":return(0,n.__)("Text with link","atmosphere");case"teaser-thread":return(0,n.__)("Teaser thread","atmosphere");case"redacted":return(0,n.__)("Not shared","atmosphere");default:return(0,n.__)("Link card","atmosphere")}}function m(e){return Array.isArray(e)&&e.some(e=>e&&e.over_limit)}const w=window.ReactJSXRuntime;function _(){const{postId:e,postType:r,title:a,content:l,excerpt:_,status:f,password:b}=(0,s.useSelect)(e=>{const r=e(t.store);return{postId:r.getCurrentPostId(),postType:r.getCurrentPostType(),title:r.getEditedPostAttribute("title"),content:r.getEditedPostContent(),excerpt:r.getEditedPostAttribute("excerpt"),status:r.getEditedPostAttribute("status"),password:r.getEditedPostAttribute("password")}},[]),[v]=(0,i.useEntityProp)("postType",r,"meta"),y=!(!v||!v[u]),[P,x]=(0,o.useState)(null),[g,j]=(0,o.useState)(!0),[T,k]=(0,o.useState)(null),E=(0,o.useRef)(null);if((0,o.useEffect)(()=>{if(e)return j(!0),k(null),clearTimeout(E.current),E.current=setTimeout(()=>{d()({path:c,method:"POST",data:{id:e,title:a,content:l,excerpt:_,status:f,password:b,disabled:y}}).then(e=>{x(e),j(!1)}).catch(e=>{console.error("ATmosphere pre-publish preview failed:",e),k(e),j(!1)})},400),()=>clearTimeout(E.current)},[e,a,l,_,f,b,y]),g)return(0,w.jsx)(p.Spinner,{});if(T){const e=T?.code,t=T?.data?.status,r="rest_cookie_invalid_nonce"!==e&&("rest_forbidden"===e||401===t||403===t);return(0,w.jsx)("p",{children:r?(0,n.__)("You don’t have permission to preview this post.","atmosphere"):(0,n.__)("Could not load the Bluesky preview. Please try again.","atmosphere")})}if(!P)return(0,w.jsx)("p",{children:(0,n.__)("Could not load the Bluesky preview.","atmosphere")});if(!P.will_publish)return(0,w.jsx)(p.Notice,{status:"info",isDismissible:!1,children:P.reason||(0,n.__)("This post won’t be shared to Bluesky.","atmosphere")});const S=P.records||[];return(0,w.jsxs)("div",{className:"atmosphere-pre-publish",children:[(0,w.jsx)("p",{children:(0,n.sprintf)(/* translators: %s: the publishing strategy, e.g. "Short note" or "Link card". */ /* translators: %s: the publishing strategy, e.g. "Short note" or "Link card". */
+(0,n.__)("This post will be shared to Bluesky as: %s.","atmosphere"),h(P.strategy))}),1===S.length&&(0,w.jsx)("p",{className:S[0].over_limit?"atmosphere-over-limit":void 0,children:(0,n.sprintf)(/* translators: 1: character count, 2: the 300-character limit. */ /* translators: 1: character count, 2: the 300-character limit. */
+(0,n.__)("%1$d / %2$d characters","atmosphere"),S[0].characters,P.limit)}),S.length>1&&(0,w.jsx)("ul",{children:S.map((e,t)=>(0,w.jsx)("li",{className:e.over_limit?"atmosphere-over-limit":void 0,children:(0,n.sprintf)(/* translators: 1: post number in the thread, 2: character count, 3: the limit. */ /* translators: 1: post number in the thread, 2: character count, 3: the limit. */
+(0,n.__)("Post %1$d: %2$d / %3$d characters","atmosphere"),t+1,e.characters,P.limit)},t))}),m(S)&&(0,w.jsx)(p.Notice,{status:"warning",isDismissible:!1,children:(0,n.__)("Your text is over Bluesky’s limit and will be shortened when published.","atmosphere")})]})}(0,r.registerPlugin)("atmosphere-pre-publish-panel",{render:()=>(0,w.jsx)(t.PluginPrePublishPanel,{title:(0,n.__)("Bluesky","atmosphere"),initialOpen:!0,children:(0,w.jsx)(_,{})})})})();
\ No newline at end of file
diff --git a/includes/rest/admin/class-pre-publish-controller.php b/includes/rest/admin/class-pre-publish-controller.php
index 7a0fe4c..19409b5 100644
--- a/includes/rest/admin/class-pre-publish-controller.php
+++ b/includes/rest/admin/class-pre-publish-controller.php
@@ -20,6 +20,7 @@
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
+use function Atmosphere\debug_log;
use function Atmosphere\is_connected;
use function Atmosphere\is_supported_post_type;
@@ -228,6 +229,27 @@ public function get_preview( WP_REST_Request $request ) {
try {
$projection = ( new Post( $draft ) )->project();
+ } catch ( \Throwable $e ) {
+ /*
+ * `project()` runs the_content over raw, unsaved editor markup,
+ * so a malformed block or a misbehaving content/shortcode filter
+ * can throw. Return a structured error (and log the post ID for
+ * support) instead of letting the keystroke-driven endpoint fatal
+ * into an opaque 500.
+ */
+ debug_log(
+ \sprintf(
+ 'pre-publish projection failed for post %d: %s',
+ $draft->ID,
+ $e->getMessage()
+ )
+ );
+
+ return new \WP_Error(
+ 'atmosphere_projection_failed',
+ \__( 'The Bluesky preview could not be generated.', 'atmosphere' ),
+ array( 'status' => 500 )
+ );
} finally {
\remove_filter( 'pre_http_request', $block_http, 0 );
}
diff --git a/includes/rest/class-client-metadata-controller.php b/includes/rest/class-client-metadata-controller.php
index c38a3fc..fc682e1 100644
--- a/includes/rest/class-client-metadata-controller.php
+++ b/includes/rest/class-client-metadata-controller.php
@@ -16,6 +16,7 @@
use Atmosphere\OAuth\Client;
use WP_REST_Response;
use WP_REST_Server;
+use function Atmosphere\debug_log;
use function Atmosphere\sanitize_text;
/**
@@ -141,6 +142,15 @@ public function get_metadata(): WP_REST_Response {
\esc_html__( 'atmosphere_client_metadata must return an array with a non-empty string client_id and a redirect_uris list of admin URLs; falling back to the unfiltered metadata.', 'atmosphere' ),
'1.0.0'
);
+
+ /*
+ * `_doing_it_wrong()` is silent in production. Also route the
+ * failure through debug_log() so operators can opt into the
+ * signal via the `atmosphere_debug_log` filter without enabling
+ * WP_DEBUG site-wide — an OAuth client_id served from a
+ * misbehaving filter is worth surfacing.
+ */
+ debug_log( 'atmosphere_client_metadata filter returned an invalid value; using the unfiltered metadata.' );
}
$response = new WP_REST_Response( $metadata, 200 );
diff --git a/src/pre-publish-panel/plugin.js b/src/pre-publish-panel/plugin.js
index 6f3f2c1..b9fb008 100644
--- a/src/pre-publish-panel/plugin.js
+++ b/src/pre-publish-panel/plugin.js
@@ -45,7 +45,7 @@ function PrePublishPanel() {
const [ preview, setPreview ] = useState( null );
const [ loading, setLoading ] = useState( true );
- const [ error, setError ] = useState( false );
+ const [ error, setError ] = useState( null );
const debounce = useRef( null );
useEffect( () => {
@@ -54,7 +54,7 @@ function PrePublishPanel() {
}
setLoading( true );
- setError( false );
+ setError( null );
// Debounce so each keystroke doesn't fire a projector request.
clearTimeout( debounce.current );
@@ -76,8 +76,16 @@ function PrePublishPanel() {
setPreview( result );
setLoading( false );
} )
- .catch( () => {
- setError( true );
+ .catch( ( err ) => {
+ // Keep the error so the message can distinguish a
+ // permission failure from a transient one, and log it so
+ // a support report has something to go on.
+ // eslint-disable-next-line no-console -- Aid debugging.
+ console.error(
+ 'ATmosphere pre-publish preview failed:',
+ err
+ );
+ setError( err );
setLoading( false );
} );
}, 400 );
@@ -89,7 +97,33 @@ function PrePublishPanel() {
return
+ { isAuth + ? __( + 'You don’t have permission to preview this post.', + 'atmosphere' + ) + : __( + 'Could not load the Bluesky preview. Please try again.', + 'atmosphere' + ) } +
+ ); + } + + if ( ! preview ) { return ({ __( 'Could not load the Bluesky preview.', 'atmosphere' ) }
); diff --git a/tests/phpunit/tests/rest/admin/class-test-pre-publish-controller.php b/tests/phpunit/tests/rest/admin/class-test-pre-publish-controller.php index e7d8ca5..0d0e6af 100644 --- a/tests/phpunit/tests/rest/admin/class-test-pre-publish-controller.php +++ b/tests/phpunit/tests/rest/admin/class-test-pre-publish-controller.php @@ -116,6 +116,36 @@ public function test_preview_connected_short_form() { $this->assertFalse( $data['records'][0]['over_limit'] ); } + /** + * A projection that throws (e.g. malformed block markup tripping a + * content filter) is caught and reported as a structured error, not an + * opaque fatal. + * + * @covers ::get_preview + */ + public function test_preview_returns_error_when_projection_throws() { + $post = self::factory()->post->create_and_get( array( 'post_title' => '' ) ); + + $thrower = static function () { + throw new \RuntimeException( 'boom' ); + }; + \add_filter( 'the_content', $thrower, 9 ); + + try { + $result = $this->controller->get_preview( + $this->make_request( $post->ID, array( 'content' => 'A quick note.' ) ) + ); + } finally { + // Remove in finally so a failed assertion below can't leak the + // throwing filter into later tests. + \remove_filter( 'the_content', $thrower, 9 ); + } + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'atmosphere_projection_failed', $result->get_error_code() ); + $this->assertSame( 500, $result->get_error_data()['status'] ); + } + /** * A draft (not yet published) reports the real character count, not a * redacted zero. The transformer redacts non-published posts, so the