@automattic/newspack-blocks 4.33.1 → 4.34.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/editor-rtl.css +4 -3
  3. package/dist/editor.asset.php +1 -1
  4. package/dist/editor.css +4 -3
  5. package/dist/editor.js +14 -14
  6. package/dist/modal.asset.php +1 -1
  7. package/dist/modal.js +1 -1
  8. package/includes/class-modal-checkout.php +248 -6
  9. package/includes/class-newspack-blocks-api.php +8 -0
  10. package/includes/class-newspack-blocks.php +38 -0
  11. package/languages/newspack-blocks-de_DE.po +124 -119
  12. package/languages/newspack-blocks-es_ES.po +124 -119
  13. package/languages/newspack-blocks-fr_BE.po +124 -119
  14. package/languages/newspack-blocks-nb_NO.po +124 -119
  15. package/languages/newspack-blocks-pt_PT.po +124 -119
  16. package/languages/newspack-blocks.pot +129 -125
  17. package/newspack-blocks.php +2 -2
  18. package/package.json +2 -2
  19. package/src/blocks/author-list/class-wp-rest-newspack-author-list-controller.php +6 -1
  20. package/src/blocks/author-profile/class-wp-rest-newspack-authors-controller.php +196 -48
  21. package/src/blocks/author-profile/edit.js +23 -65
  22. package/src/blocks/author-profile/single-author.js +7 -12
  23. package/src/blocks/author-profile/social-links.js +25 -0
  24. package/src/blocks/author-profile/social-links.test.js +42 -0
  25. package/src/blocks/checkout-button/view.php +4 -2
  26. package/src/blocks/homepage-articles/templates/article.php +1 -18
  27. package/src/modal-checkout/checkout-button-trigger.js +162 -24
  28. package/src/modal-checkout/checkout-button-trigger.test.js +149 -9
  29. package/src/modal-checkout/modal.js +31 -1
  30. package/src/shared/js/author-fetch.js +217 -0
  31. package/src/shared/js/author-fetch.test.js +230 -0
  32. package/vendor/composer/installed.php +2 -2
@@ -7,7 +7,7 @@
7
7
  * Author URI: https://newspack.com/
8
8
  * Text Domain: newspack-blocks
9
9
  * Domain Path: /languages
10
- * Version: 4.33.1
10
+ * Version: 4.34.0-alpha.2
11
11
  *
12
12
  * @package Newspack_Blocks
13
13
  */
@@ -15,7 +15,7 @@
15
15
  define( 'NEWSPACK_BLOCKS__PLUGIN_FILE', __FILE__ );
16
16
  define( 'NEWSPACK_BLOCKS__BLOCKS_DIRECTORY', 'dist/' );
17
17
  define( 'NEWSPACK_BLOCKS__PLUGIN_DIR', plugin_dir_path( NEWSPACK_BLOCKS__PLUGIN_FILE ) );
18
- define( 'NEWSPACK_BLOCKS__VERSION', '4.33.1' );
18
+ define( 'NEWSPACK_BLOCKS__VERSION', '4.34.0-alpha.2' );
19
19
 
20
20
  require_once NEWSPACK_BLOCKS__PLUGIN_DIR . 'includes/class-newspack-blocks.php';
21
21
  require_once NEWSPACK_BLOCKS__PLUGIN_DIR . 'includes/class-newspack-blocks-api.php';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automattic/newspack-blocks",
3
- "version": "4.33.1",
3
+ "version": "4.34.0-alpha.2",
4
4
  "author": "Automattic",
5
5
  "description": "=== Newspack Blocks === Contributors: (this should be a list of wordpress.org userid's) Donate link: https://example.com/ Tags: comments, spam Requires at least: 4.5 Tested up to: 5.1.1 Stable tag: 0.1.0 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html",
6
6
  "repository": {
@@ -57,7 +57,7 @@
57
57
  },
58
58
  "devDependencies": {
59
59
  "@types/lodash": "^4.17.23",
60
- "newspack-components": "4.8.0",
60
+ "newspack-components": "4.8.1-alpha.1",
61
61
  "newspack-scripts": "5.11.0"
62
62
  },
63
63
  "overrides": {
@@ -173,6 +173,11 @@ class WP_REST_Newspack_Author_List_Controller extends WP_REST_Newspack_Authors_C
173
173
  ];
174
174
  $options = wp_parse_args( $options, $default_options );
175
175
  $fields = $options['fields'];
176
+
177
+ // Only the component's own author roles can be listed. A caller-chosen role outside them
178
+ // is dropped, and when nothing usable remains the user query is skipped below rather than
179
+ // run without a role filter, which would match every user on the site.
180
+ $options['author_roles'] = array_values( array_intersect( (array) $options['author_roles'], Newspack_Blocks\get_authors_roles_slugs() ) );
176
181
  $current_page = 1;
177
182
 
178
183
  // Array to store all authors.
@@ -219,7 +224,7 @@ class WP_REST_Newspack_Author_List_Controller extends WP_REST_Newspack_Authors_C
219
224
  }
220
225
  }
221
226
 
222
- if ( in_array( $options['author_type'], [ 'all', 'users' ], true ) ) {
227
+ if ( ! empty( $options['author_roles'] ) && in_array( $options['author_type'], [ 'all', 'users' ], true ) ) {
223
228
  // Reset current page for new query.
224
229
  $current_page = 1;
225
230
  $exclude_empty = $options['exclude_empty'] ? true : false;
@@ -65,6 +65,39 @@ class WP_REST_Newspack_Authors_Controller extends WP_REST_Controller {
65
65
  return array_values( array_diff( $fields, self::USER_MANAGEMENT_FIELDS ) );
66
66
  }
67
67
 
68
+ /**
69
+ * Most IDs one batched lookup resolves.
70
+ *
71
+ * A page never needs more than it has blocks, and an unbounded list would recreate the long
72
+ * PHP request the batched lookup exists to avoid. The editor splits its batches to match.
73
+ */
74
+ const MAX_BATCH_IDS = 100;
75
+
76
+ /**
77
+ * Whether a WP user holds one of the roles this component treats as an author.
78
+ *
79
+ * The listing branch has always been narrowed to these roles. The by-id branches have to
80
+ * agree with it, or an id becomes a way to read any user's profile fields.
81
+ *
82
+ * @param WP_User|false $user The user.
83
+ * @return bool
84
+ */
85
+ public static function is_author_role_user( $user ) {
86
+ return $user && ! empty( array_intersect( (array) $user->roles, \Newspack_Blocks\get_authors_roles_slugs() ) );
87
+ }
88
+
89
+ /**
90
+ * Turn a comma-separated list (or array) of IDs into unique positive integers.
91
+ *
92
+ * @param string|array $value Raw request param.
93
+ * @return int[] At most MAX_BATCH_IDS of them.
94
+ */
95
+ public static function sanitize_id_list( $value ) {
96
+ $ids = is_array( $value ) ? $value : explode( ',', (string) $value );
97
+ $ids = array_filter( array_map( 'absint', $ids ) );
98
+ return array_slice( array_values( array_unique( $ids ) ), 0, self::MAX_BATCH_IDS );
99
+ }
100
+
68
101
  /**
69
102
  * Registers the necessary REST API routes.
70
103
  *
@@ -104,6 +137,12 @@ class WP_REST_Newspack_Authors_Controller extends WP_REST_Controller {
104
137
  'post_id' => [
105
138
  'sanitize_callback' => 'absint',
106
139
  ],
140
+ 'author_ids' => [
141
+ 'sanitize_callback' => [ __CLASS__, 'sanitize_id_list' ],
142
+ ],
143
+ 'guest_author_ids' => [
144
+ 'sanitize_callback' => [ __CLASS__, 'sanitize_id_list' ],
145
+ ],
107
146
  ],
108
147
  'permission_callback' => function() {
109
148
  return current_user_can( 'edit_posts' );
@@ -130,6 +169,14 @@ class WP_REST_Newspack_Authors_Controller extends WP_REST_Controller {
130
169
  $fields = self::restrict_fields( $fields );
131
170
  $include = ! empty( $request->get_param( 'include' ) ) ? explode( ',', $request->get_param( 'include' ) ) : null; // Fetch authors by multiple IDs.
132
171
  $post_id = ! empty( $request->get_param( 'post_id' ) ) ? $request->get_param( 'post_id' ) : 0; // Fetch authors for a specific post (contextual mode).
172
+ $author_ids = ! empty( $request->get_param( 'author_ids' ) ) ? $request->get_param( 'author_ids' ) : []; // Fetch several WP users by ID in one request.
173
+ $guest_author_ids = ! empty( $request->get_param( 'guest_author_ids' ) ) ? $request->get_param( 'guest_author_ids' ) : []; // Fetch several guest authors by ID in one request.
174
+
175
+ // If a list of IDs is provided, resolve them all in one response. A list with no valid ID
176
+ // is still a list, and gets an empty response rather than the recent-authors listing.
177
+ if ( $request->has_param( 'author_ids' ) || $request->has_param( 'guest_author_ids' ) ) {
178
+ return $this->get_authors_by_ids( $author_ids, $guest_author_ids, $fields, $avatar_hide_default );
179
+ }
133
180
 
134
181
  // If post_id is provided, get authors for that specific post.
135
182
  if ( $post_id ) {
@@ -190,7 +237,7 @@ class WP_REST_Newspack_Authors_Controller extends WP_REST_Controller {
190
237
  if ( 0 === $guest_author_total ) {
191
238
  $user = get_user_by( 'id', $author_id ); // Get the WP user.
192
239
 
193
- if ( $user ) {
240
+ if ( self::is_author_role_user( $user ) ) {
194
241
  $users = [ $user ];
195
242
  }
196
243
  }
@@ -242,29 +289,9 @@ class WP_REST_Newspack_Authors_Controller extends WP_REST_Controller {
242
289
  array_reduce(
243
290
  ! empty( $guest_authors ) ? $guest_authors : [],
244
291
  function( $acc, $guest_author ) use ( $fields, $avatar_hide_default ) {
245
- if ( $guest_author ) {
246
- if ( class_exists( 'CoAuthors_Guest_Authors' ) ) {
247
- $guest_author_data = [
248
- 'id' => intval( $guest_author->ID ),
249
- 'registered' => $guest_author->post_date,
250
- 'is_guest' => true,
251
- 'slug' => $guest_author->post_name,
252
- ];
253
-
254
- $guest_author = ( new CoAuthors_Guest_Authors() )->get_guest_author_by( 'id', $guest_author->ID );
255
-
256
- if ( in_array( 'avatar', $fields, true ) && function_exists( 'coauthors_get_avatar' ) ) {
257
- $avatar = coauthors_get_avatar( $guest_author, 256, $avatar_hide_default ? 'blank' : '' );
258
-
259
- if ( \Newspack_Blocks\is_avatar_displayable( $avatar, $avatar_hide_default ) ) {
260
- $guest_author_data['avatar'] = $avatar;
261
- }
262
- }
263
-
264
- $guest_author_data = self::fill_guest_author_data( $guest_author_data, $guest_author, $fields );
265
-
266
- $acc[] = $guest_author_data;
267
- }
292
+ $guest_author_data = self::format_guest_author( $guest_author, $fields, $avatar_hide_default );
293
+ if ( $guest_author_data ) {
294
+ $acc[] = $guest_author_data;
268
295
  }
269
296
  return $acc;
270
297
  },
@@ -273,24 +300,9 @@ class WP_REST_Newspack_Authors_Controller extends WP_REST_Controller {
273
300
  array_reduce(
274
301
  $users,
275
302
  function( $acc, $user ) use ( $fields, $avatar_hide_default ) {
276
- if ( $user ) {
277
- $user_data = [
278
- 'id' => intval( $user->data->ID ),
279
- 'registered' => $user->data->user_registered,
280
- 'is_guest' => false,
281
- 'slug' => $user->data->user_login,
282
- ];
283
-
284
- if ( in_array( 'avatar', $fields, true ) ) {
285
- $avatar = get_avatar( $user->data->ID, 256, $avatar_hide_default ? 'blank' : '' );
286
-
287
- if ( \Newspack_Blocks\is_avatar_displayable( $avatar, $avatar_hide_default ) ) {
288
- $user_data['avatar'] = $avatar;
289
- }
290
- }
291
-
292
- $user_data = self::fill_user_data( $user_data, $user, $fields );
293
- $acc[] = $user_data;
303
+ $user_data = self::format_user( $user, $fields, $avatar_hide_default );
304
+ if ( $user_data ) {
305
+ $acc[] = $user_data;
294
306
  }
295
307
  return $acc;
296
308
  },
@@ -312,6 +324,135 @@ class WP_REST_Newspack_Authors_Controller extends WP_REST_Controller {
312
324
  return rest_ensure_response( $response );
313
325
  }
314
326
 
327
+ /**
328
+ * Return the given users and guest authors in one response.
329
+ *
330
+ * Backs the editor's batched lookups: a page with many Author Profile blocks resolves every
331
+ * author with a single request instead of one per block. Each ID is formatted the way the
332
+ * single-ID path formats it, so a record matches what a block received on its own, including
333
+ * the guest-to-user fallback: a guest ID means "not known to be a WP user", because blocks
334
+ * saved before the `isGuestAuthor` attribute existed default to it for every author.
335
+ *
336
+ * @param int[] $author_ids WP user IDs.
337
+ * @param int[] $guest_author_ids Guest author post IDs.
338
+ * @param array $fields Fields to include.
339
+ * @param bool $avatar_hide_default Whether to hide the default avatar.
340
+ * @return WP_REST_Response
341
+ */
342
+ public function get_authors_by_ids( $author_ids, $guest_author_ids, $fields, $avatar_hide_default ) {
343
+ $authors = [];
344
+
345
+ if ( $guest_author_ids ) {
346
+ $guest_authors = get_posts(
347
+ [
348
+ 'post_type' => 'guest-author',
349
+ 'post__in' => $guest_author_ids,
350
+ 'posts_per_page' => count( $guest_author_ids ),
351
+ 'orderby' => 'post__in',
352
+ ]
353
+ );
354
+ $found = [];
355
+ foreach ( $guest_authors as $guest_author ) {
356
+ $found[] = (int) $guest_author->ID;
357
+ $guest_author_data = self::format_guest_author( $guest_author, $fields, $avatar_hide_default );
358
+ if ( $guest_author_data ) {
359
+ $authors[] = $guest_author_data;
360
+ }
361
+ }
362
+ // Resolve the rest as WP users, the way the single-ID path and the front-end renderer do.
363
+ $author_ids = array_values( array_unique( array_merge( $author_ids, array_diff( $guest_author_ids, $found ) ) ) );
364
+ }
365
+
366
+ if ( $author_ids ) {
367
+ // Two queries for the whole list instead of two per user: get_user_by() reads the user
368
+ // row and its capabilities from the object cache once both are primed.
369
+ cache_users( $author_ids );
370
+ }
371
+
372
+ foreach ( $author_ids as $author_id ) {
373
+ $user = get_user_by( 'id', $author_id );
374
+ if ( ! self::is_author_role_user( $user ) ) {
375
+ continue;
376
+ }
377
+ $user_data = self::format_user( $user, $fields, $avatar_hide_default );
378
+ if ( $user_data ) {
379
+ $authors[] = $user_data;
380
+ }
381
+ }
382
+
383
+ $response = new WP_REST_Response( $authors );
384
+ $response->header( 'x-wp-total', count( $authors ) );
385
+
386
+ return rest_ensure_response( $response );
387
+ }
388
+
389
+ /**
390
+ * Format a guest author post as an endpoint record.
391
+ *
392
+ * @param WP_Post|false $guest_author Guest author post.
393
+ * @param array $fields Fields to include.
394
+ * @param bool $avatar_hide_default Whether to hide the default avatar.
395
+ * @return array|null Record, or null when there is nothing to format.
396
+ */
397
+ public static function format_guest_author( $guest_author, $fields, $avatar_hide_default ) {
398
+ if ( ! $guest_author || ! class_exists( 'CoAuthors_Guest_Authors' ) ) {
399
+ return null;
400
+ }
401
+
402
+ $guest_author_data = [
403
+ 'id' => intval( $guest_author->ID ),
404
+ 'registered' => $guest_author->post_date,
405
+ 'is_guest' => true,
406
+ 'slug' => $guest_author->post_name,
407
+ ];
408
+
409
+ $guest_author = ( new CoAuthors_Guest_Authors() )->get_guest_author_by( 'id', $guest_author->ID );
410
+ if ( ! $guest_author ) {
411
+ return null;
412
+ }
413
+
414
+ if ( in_array( 'avatar', $fields, true ) && function_exists( 'coauthors_get_avatar' ) ) {
415
+ $avatar = coauthors_get_avatar( $guest_author, 256, $avatar_hide_default ? 'blank' : '' );
416
+
417
+ if ( \Newspack_Blocks\is_avatar_displayable( $avatar, $avatar_hide_default ) ) {
418
+ $guest_author_data['avatar'] = $avatar;
419
+ }
420
+ }
421
+
422
+ return self::fill_guest_author_data( $guest_author_data, $guest_author, $fields );
423
+ }
424
+
425
+ /**
426
+ * Format a WP user as an endpoint record.
427
+ *
428
+ * @param WP_User|false $user The user.
429
+ * @param array $fields Fields to include.
430
+ * @param bool $avatar_hide_default Whether to hide the default avatar.
431
+ * @return array|null Record, or null when there is nothing to format.
432
+ */
433
+ public static function format_user( $user, $fields, $avatar_hide_default ) {
434
+ if ( ! $user ) {
435
+ return null;
436
+ }
437
+
438
+ $user_data = [
439
+ 'id' => intval( $user->data->ID ),
440
+ 'registered' => $user->data->user_registered,
441
+ 'is_guest' => false,
442
+ 'slug' => $user->data->user_login,
443
+ ];
444
+
445
+ if ( in_array( 'avatar', $fields, true ) ) {
446
+ $avatar = get_avatar( $user->data->ID, 256, $avatar_hide_default ? 'blank' : '' );
447
+
448
+ if ( \Newspack_Blocks\is_avatar_displayable( $avatar, $avatar_hide_default ) ) {
449
+ $user_data['avatar'] = $avatar;
450
+ }
451
+ }
452
+
453
+ return self::fill_user_data( $user_data, $user, $fields );
454
+ }
455
+
315
456
  /**
316
457
  * Fill guest author data.
317
458
  *
@@ -392,7 +533,7 @@ class WP_REST_Newspack_Authors_Controller extends WP_REST_Controller {
392
533
  $user_data['url'] = esc_url( get_author_posts_url( $user->data->ID ) );
393
534
  }
394
535
  if ( false === $fields || in_array( 'social', $fields, true ) ) {
395
- $user_data['social'] = self::get_social( $user->data->ID );
536
+ $user_data['social'] = self::get_social( $user->data->ID, false );
396
537
  }
397
538
 
398
539
  if ( class_exists( '\Newspack\Authors_Custom_Fields' ) ) {
@@ -458,10 +599,11 @@ class WP_REST_Newspack_Authors_Controller extends WP_REST_Controller {
458
599
  /**
459
600
  * Get social media URLs and SVGs, if available. Only standard WP users have this user meta.
460
601
  *
461
- * @param int $author_id Author ID.
602
+ * @param int $author_id Author ID.
603
+ * @param boolean $is_guest_author Whether the ID is a CAP guest author, whose website lives in post meta.
462
604
  * @return array Array of social links and SVGs.
463
605
  */
464
- public static function get_social( $author_id ) {
606
+ public static function get_social( $author_id, $is_guest_author = true ) {
465
607
  $social_profiles = [
466
608
  'facebook',
467
609
  'twitter',
@@ -479,9 +621,13 @@ class WP_REST_Newspack_Authors_Controller extends WP_REST_Controller {
479
621
 
480
622
  return array_reduce(
481
623
  $social_profiles,
482
- function( $acc, $profile ) use ( $author_id ) {
624
+ function( $acc, $profile ) use ( $author_id, $is_guest_author ) {
483
625
  $is_website = 'website' === $profile;
484
- $handle = $is_website ? get_post_meta( $author_id, 'cap-website', true ) : get_the_author_meta( $profile, $author_id );
626
+ if ( $is_website && ! $is_guest_author ) {
627
+ // A WP user has no post meta to read, and reading it costs a query per user.
628
+ return $acc;
629
+ }
630
+ $handle = $is_website ? get_post_meta( $author_id, 'cap-website', true ) : get_the_author_meta( $profile, $author_id );
485
631
 
486
632
  if ( $handle ) {
487
633
  $url = 'twitter' === $profile ? esc_url( 'https://x.com/' . $handle ) : esc_url( $handle );
@@ -510,7 +656,9 @@ class WP_REST_Newspack_Authors_Controller extends WP_REST_Controller {
510
656
  */
511
657
  protected function get_post_authors( $post_id, $fields, $avatar_hide_default ) {
512
658
  $post = get_post( $post_id );
513
- if ( ! $post ) {
659
+
660
+ // Who wrote an unpublished post is not the caller's to know unless they can read it.
661
+ if ( ! $post || ! current_user_can( 'read_post', $post->ID ) ) {
514
662
  return rest_ensure_response( [] );
515
663
  }
516
664
 
@@ -40,6 +40,11 @@ import { pencil, postAuthor, pullLeft, pullRight } from '@wordpress/icons';
40
40
  import { __, sprintf } from '@wordpress/i18n';
41
41
  import { addQueryArgs } from '@wordpress/url';
42
42
 
43
+ /**
44
+ * Internal dependencies
45
+ */
46
+ import { fetchAuthorById, fetchAuthorList } from '../../shared/js/author-fetch';
47
+
43
48
  /**
44
49
  * Register block bindings source for author data in the editor.
45
50
  * This enables core blocks to display author data via bindings.
@@ -274,7 +279,6 @@ const AuthorProfile = ( { attributes, setAttributes, context, clientId } ) => {
274
279
  isGuestAuthor,
275
280
  isContextual,
276
281
  layoutVersion,
277
- showSocial,
278
282
  showEmail,
279
283
  textSize,
280
284
  showAvatar,
@@ -398,22 +402,15 @@ const AuthorProfile = ( { attributes, setAttributes, context, clientId } ) => {
398
402
  setError( null );
399
403
  setIsLoading( true );
400
404
  try {
401
- const params = {
402
- author_id: authorId,
403
- is_guest_author: isGuestAuthor ? 1 : 0,
405
+ // Shared with every other Author Profile block on the page, so a page of profiles
406
+ // costs one request instead of one per block.
407
+ const _author = await fetchAuthorById( {
408
+ authorId,
409
+ isGuestAuthor,
404
410
  fields: 'id,name,bio,email,social,avatar,url',
405
- };
406
-
407
- if ( avatarHideDefault ) {
408
- params.avatar_hide_default = 1;
409
- }
410
-
411
- const response = await apiFetch( {
412
- path: addQueryArgs( '/newspack-blocks/v1/authors', params ),
411
+ avatarHideDefault,
413
412
  } );
414
413
 
415
- const _author = response.pop();
416
-
417
414
  if ( ! _author ) {
418
415
  throw sprintf(
419
416
  /* translators: %s: error text for when no authors are found. */
@@ -476,21 +473,16 @@ const AuthorProfile = ( { attributes, setAttributes, context, clientId } ) => {
476
473
  fields.push( 'email' );
477
474
  }
478
475
  const results = await Promise.all(
479
- bylineAuthorIds.map( id => {
480
- const params = {
481
- author_id: id,
482
- is_guest_author: 0,
476
+ bylineAuthorIds.map( id =>
477
+ fetchAuthorById( {
478
+ authorId: id,
479
+ isGuestAuthor: false,
483
480
  fields: fields.join( ',' ),
484
- };
485
- if ( avatarHideDefault ) {
486
- params.avatar_hide_default = 1;
487
- }
488
- return apiFetch( {
489
- path: addQueryArgs( '/newspack-blocks/v1/authors', params ),
490
- } );
491
- } )
481
+ avatarHideDefault,
482
+ } )
483
+ )
492
484
  );
493
- setContextualAuthors( results.flat().filter( Boolean ) );
485
+ setContextualAuthors( results.filter( Boolean ) );
494
486
  } catch ( e ) {
495
487
  setError( e.message || e || __( 'Error fetching byline authors.', 'newspack-blocks' ) );
496
488
  setContextualAuthors( [] );
@@ -535,17 +527,6 @@ const AuthorProfile = ( { attributes, setAttributes, context, clientId } ) => {
535
527
  };
536
528
  }, [ authorsToRender, previewAuthorIndex, layoutVersion, clientId ] );
537
529
 
538
- // Combine social links and email, which are shown together.
539
- const getSocialLinks = authorData => {
540
- const socialLinks = ( showSocial && authorData?.social ) || {};
541
- if ( showEmail && authorData?.email ) {
542
- socialLinks.email = authorData.email;
543
- } else {
544
- delete socialLinks.email;
545
- }
546
- return socialLinks;
547
- };
548
-
549
530
  // Determine if we're in nested layout mode (publisher-controlled composition).
550
531
  // In nested mode, hide field toggles since publishers control display by adding/removing blocks.
551
532
  const isNestedLayout = layoutVersion === 2;
@@ -767,16 +748,7 @@ const AuthorProfile = ( { attributes, setAttributes, context, clientId } ) => {
767
748
  if ( authorId && ! error ) {
768
749
  return [];
769
750
  }
770
- const response = await apiFetch( {
771
- parse: false,
772
- path: addQueryArgs( '/newspack-blocks/v1/authors', {
773
- search,
774
- offset,
775
- fields: 'id,name',
776
- } ),
777
- } );
778
- const total = parseInt( response.headers.get( 'x-wp-total' ) || 0, 10 );
779
- const authors = await response.json();
751
+ const { authors, total } = await fetchAuthorList( { search, offset, fields: 'id,name' } );
780
752
  if ( ! maxItemsToSuggest && ! search ) {
781
753
  setMaxItemsToSuggest( total );
782
754
  }
@@ -922,11 +894,7 @@ const AuthorProfile = ( { attributes, setAttributes, context, clientId } ) => {
922
894
  { inspectorControls }
923
895
  { blockControls }
924
896
  { authorsToRender.map( authorData => (
925
- <SingleAuthor
926
- key={ authorData.id }
927
- author={ { ...authorData, social: getSocialLinks( authorData ) } }
928
- attributes={ attributes }
929
- />
897
+ <SingleAuthor key={ authorData.id } author={ authorData } attributes={ attributes } />
930
898
  ) ) }
931
899
  </div>
932
900
  );
@@ -938,7 +906,7 @@ const AuthorProfile = ( { attributes, setAttributes, context, clientId } ) => {
938
906
  <div { ...blockProps }>
939
907
  { inspectorControls }
940
908
  { blockControls }
941
- <SingleAuthor author={ { ...author, social: getSocialLinks( author ) } } attributes={ attributes } />
909
+ <SingleAuthor author={ author } attributes={ attributes } />
942
910
  </div>
943
911
  );
944
912
  }
@@ -972,17 +940,7 @@ const AuthorProfile = ( { attributes, setAttributes, context, clientId } ) => {
972
940
  return [];
973
941
  }
974
942
 
975
- const response = await apiFetch( {
976
- parse: false,
977
- path: addQueryArgs( '/newspack-blocks/v1/authors', {
978
- search,
979
- offset,
980
- fields: 'id,name',
981
- } ),
982
- } );
983
-
984
- const total = parseInt( response.headers.get( 'x-wp-total' ) || 0, 10 );
985
- const authors = await response.json();
943
+ const { authors, total } = await fetchAuthorList( { search, offset, fields: 'id,name' } );
986
944
 
987
945
  // Set max items for "load more" functionality in suggestions list.
988
946
  if ( ! maxItemsToSuggest && ! search ) {
@@ -12,6 +12,11 @@ import { autop } from '@wordpress/autop';
12
12
  */
13
13
  import classnames from 'classnames';
14
14
 
15
+ /**
16
+ * Internal dependencies
17
+ */
18
+ import { getSocialLinks } from './social-links';
19
+
15
20
  // Show a link to the author's post archive page, if available.
16
21
  const MaybeLink = ( { author, children, showArchiveLink } ) =>
17
22
  showArchiveLink && author && author.url ? (
@@ -25,18 +30,8 @@ const MaybeLink = ( { author, children, showArchiveLink } ) =>
25
30
  export const SingleAuthor = ( { author, attributes } ) => {
26
31
  const { showBio, showSocial, showEmail, showArchiveLink, showAvatar, textSize, avatarAlignment, avatarBorderRadius, avatarSize } = attributes;
27
32
 
28
- // Combine social links and email, which are shown together.
29
- const socialLinks = ( showSocial && author && author.social ) || {};
30
- if ( showEmail && author && author.email ) {
31
- socialLinks.email = author.email;
32
- } else {
33
- delete socialLinks.email;
34
- }
35
- if ( attributes.shownewspack_phone_number && author && author.newspack_phone_number ) {
36
- socialLinks.newspack_phone_number = author.newspack_phone_number;
37
- } else {
38
- delete socialLinks.newspack_phone_number;
39
- }
33
+ // Combine social links and contact links, which are shown together.
34
+ const socialLinks = getSocialLinks( author, { showSocial, showEmail, showPhone: attributes.shownewspack_phone_number } );
40
35
 
41
36
  const employment = [ attributes.shownewspack_role && author.newspack_role, attributes.shownewspack_employer && author.newspack_employer ]
42
37
  .filter( Boolean )
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Combine an author's social links with the contact links a block chooses to show.
3
+ *
4
+ * Returns a new object. Author records are shared between every block on the page that shows
5
+ * the same author, so a block must not write its own choices into the record it was given.
6
+ *
7
+ * @param {Object} author Author record from the authors endpoint.
8
+ * @param {Object} options
9
+ * @param {boolean} options.showSocial Whether to include the social links.
10
+ * @param {boolean} options.showEmail Whether to include the email link.
11
+ * @param {boolean} options.showPhone Whether to include the phone number link.
12
+ * @return {Object} Links keyed by service.
13
+ */
14
+ export function getSocialLinks( author, { showSocial, showEmail, showPhone } ) {
15
+ const links = { ...( ( showSocial && author?.social ) || {} ) };
16
+ delete links.email;
17
+ delete links.newspack_phone_number;
18
+ if ( showEmail && author?.email ) {
19
+ links.email = author.email;
20
+ }
21
+ if ( showPhone && author?.newspack_phone_number ) {
22
+ links.newspack_phone_number = author.newspack_phone_number;
23
+ }
24
+ return links;
25
+ }
@@ -0,0 +1,42 @@
1
+ import { getSocialLinks } from './social-links';
2
+
3
+ const author = () => ( {
4
+ social: { twitter: { url: 'https://x.com/a' } },
5
+ email: { url: 'mailto:a@example.test' },
6
+ newspack_phone_number: { url: 'tel:1' },
7
+ } );
8
+
9
+ describe( 'getSocialLinks', () => {
10
+ it( 'adds the email to the social links when it is shown', () => {
11
+ expect( getSocialLinks( author(), { showSocial: true, showEmail: true } ) ).toEqual( {
12
+ twitter: { url: 'https://x.com/a' },
13
+ email: { url: 'mailto:a@example.test' },
14
+ } );
15
+ } );
16
+
17
+ it( 'returns only the email when social links are hidden', () => {
18
+ expect( getSocialLinks( author(), { showSocial: false, showEmail: true } ) ).toEqual( { email: { url: 'mailto:a@example.test' } } );
19
+ } );
20
+
21
+ it( 'adds the phone number when it is shown', () => {
22
+ expect( getSocialLinks( author(), { showSocial: false, showEmail: false, showPhone: true } ) ).toEqual( {
23
+ newspack_phone_number: { url: 'tel:1' },
24
+ } );
25
+ } );
26
+
27
+ it( 'leaves the author record untouched', () => {
28
+ const record = author();
29
+
30
+ getSocialLinks( record, { showSocial: true, showEmail: true } );
31
+ expect( record.social ).toEqual( { twitter: { url: 'https://x.com/a' } } );
32
+
33
+ // A record shared between blocks may already carry a link another block added.
34
+ record.social.email = { url: 'mailto:other@example.test' };
35
+ expect( getSocialLinks( record, { showSocial: true, showEmail: false } ) ).toEqual( { twitter: { url: 'https://x.com/a' } } );
36
+ expect( record.social.email ).toEqual( { url: 'mailto:other@example.test' } );
37
+ } );
38
+
39
+ it( 'returns an empty object for a missing author', () => {
40
+ expect( getSocialLinks( undefined, { showSocial: true, showEmail: true } ) ).toEqual( {} );
41
+ } );
42
+ } );
@@ -178,8 +178,10 @@ function render_callback( $attributes ) {
178
178
  $hidden_fields .= $after_success_behavior ? '<input type="hidden" name="after_success_behavior" value="' . esc_attr( $after_success_behavior ) . '" />' : '';
179
179
  $hidden_fields .= $after_success_button_label ? '<input type="hidden" name="after_success_button_label" value="' . esc_attr( $after_success_button_label ) . '" />' : '';
180
180
  $hidden_fields .= $after_success_url ? '<input type="hidden" name="after_success_url" value="' . esc_attr( $after_success_url ) . '" />' : '';
181
- // Vouched for here because this is the last point the destination is known to come
182
- // from the block's own settings rather than from the request.
181
+ // Vouched for here because this is the last point the destination is known to
182
+ // come from the block's own settings — or, for a URL-triggered render, from a
183
+ // value already restricted to allowed hosts (see
184
+ // Modal_Checkout::build_url_triggered_button_attrs()).
183
185
  $after_success_token = $after_success_url ? Modal_Checkout::get_after_success_token( $after_success_url ) : '';
184
186
  $hidden_fields .= $after_success_token ? '<input type="hidden" name="after_success_token" value="' . esc_attr( $after_success_token ) . '" />' : '';
185
187
  }
@@ -142,24 +142,7 @@ call_user_func(
142
142
  ?>
143
143
  <div class="newspack-post-subtitle newspack-post-subtitle--in-homepage-block">
144
144
  <?php
145
- $allowed_tags = array(
146
- 'b' => true,
147
- 'strong' => true,
148
- 'i' => true,
149
- 'em' => true,
150
- 'mark' => true,
151
- 'u' => true,
152
- 'small' => true,
153
- 'sub' => true,
154
- 'sup' => true,
155
- 'a' => array(
156
- 'href' => true,
157
- 'target' => true,
158
- 'rel' => true,
159
- ),
160
- );
161
-
162
- echo wptexturize( wp_kses( $subtitle, $allowed_tags ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
145
+ echo wptexturize( Newspack_Blocks::sanitize_post_subtitle( $subtitle ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
163
146
  ?>
164
147
  </div>
165
148
  <?php endif; ?>