diff --git a/includes/class-blocks.php b/includes/class-blocks.php
index d0486951c3..c0766055d1 100644
--- a/includes/class-blocks.php
+++ b/includes/class-blocks.php
@@ -23,6 +23,9 @@ public static function init() {
require_once NEWSPACK_ABSPATH . 'src/blocks/correction-box/class-correction-box-block.php';
require_once NEWSPACK_ABSPATH . 'src/blocks/correction-item/class-correction-item-block.php';
}
+ if ( wp_is_block_theme() ) {
+ require_once NEWSPACK_ABSPATH . 'src/blocks/byline/class-byline-block.php';
+ }
\add_action( 'enqueue_block_editor_assets', [ __CLASS__, 'enqueue_block_editor_assets' ] );
}
diff --git a/src/blocks/byline/block.json b/src/blocks/byline/block.json
new file mode 100644
index 0000000000..f7d76cd63b
--- /dev/null
+++ b/src/blocks/byline/block.json
@@ -0,0 +1,64 @@
+{
+ "name": "newspack/byline",
+ "category": "newspack",
+ "attributes": {
+ "customByline": {
+ "type": "string",
+ "default": ""
+ },
+ "showAvatar": {
+ "type": "boolean",
+ "default": false
+ },
+ "avatarSize": {
+ "type": "number",
+ "default": 24,
+ "enum": [ 16, 24, 32, 48 ]
+ },
+ "linkToAuthorArchive": {
+ "type": "boolean",
+ "default": true
+ }
+ },
+ "supports": {
+ "html": false,
+ "align": true,
+ "alignWide": false,
+ "spacing": {
+ "margin": true,
+ "padding": true,
+ "__experimentalDefaultControls": {
+ "margin": false,
+ "padding": false
+ }
+ },
+ "__experimentalBorder": {
+ "color": true,
+ "radius": true,
+ "style": true,
+ "width": true
+ },
+ "color": {
+ "text": true,
+ "background": true
+ },
+ "filter": {
+ "duotone": true
+ },
+ "typography": {
+ "fontSize": true,
+ "lineHeight": true,
+ "textAlign": true
+ }
+ },
+ "selectors": {
+ "filter": {
+ "duotone": ".newspack-byline-avatar img"
+ }
+ },
+ "usesContext": [
+ "postId",
+ "postType"
+ ],
+ "textdomain": "newspack-plugin"
+}
diff --git a/src/blocks/byline/class-byline-block.php b/src/blocks/byline/class-byline-block.php
new file mode 100644
index 0000000000..2ed11aa0f1
--- /dev/null
+++ b/src/blocks/byline/class-byline-block.php
@@ -0,0 +1,163 @@
+ [ __CLASS__, 'render_block' ],
+ 'uses_context' => [ 'postId', 'postType' ],
+ ]
+ );
+ }
+
+ /**
+ * This function is used to get the duotone class name from the preset value.
+ *
+ * @param mixed $attributes Block attributes.
+ * @return string Constructed class name for duotone filter.
+ */
+ public static function newspack_byline_get_duotone_class_name( $attributes ) {
+ $duotone_preset = isset( $attributes['style']['color']['duotone'] ) ? $attributes['style']['color']['duotone'] : null;
+ if ( str_starts_with( $duotone_preset, 'var:preset|duotone|' ) ) {
+ $slug = str_replace( 'var:preset|duotone|', '', $duotone_preset );
+ return ' wp-duotone-' . sanitize_title( $slug );
+ }
+ return '';
+ }
+
+ /**
+ * Parse byline content to convert custom tags ([Author][/Author]) to HTML.
+ *
+ * @param string $byline_content Value of byline as stored in attribute.
+ * @param bool $with_links Whether to include links to author archives.
+ * @param bool $with_avatars Whether to include author avatars.
+ * @param int $avatar_size Avatar size in pixels.
+ * @param string $duotone_class Duotone filter class for avatar images.
+ * @return string Parsed byline with author tags converted to HTML.
+ */
+ public static function parse_byline( $byline_content, $with_links = true, $with_avatars = false, $avatar_size = 48, $duotone_class = '' ) {
+ if ( empty( $byline_content ) ) {
+ return '';
+ }
+
+ // Regex to find all author tags and replace them.
+ return preg_replace_callback(
+ '/\[Author id=(\d*)\](.*?)\[\/Author\]/s',
+ function ( $matches ) use ( $with_links, $with_avatars, $avatar_size, $duotone_class ) {
+ $author_id = $matches[1];
+ $author_name = $matches[2];
+ $author_url = get_author_posts_url( $author_id );
+ $html = '';
+
+ // Add avatar before author if enabled.
+ if ( $with_avatars ) {
+ $avatar_url = get_avatar_url( $author_id, [ 'size' => $avatar_size * 2 ] );
+ $class = 'avatar avatar-' . esc_attr( $avatar_size ) . ' photo wp-block-newspack-avatar__image ';
+ $avatar_html = '
';
+
+ if ( ! empty( $duotone_class ) ) {
+ $html .= '' . $avatar_html . '';
+ } else {
+ $html .= '' . $avatar_html . '';
+ }
+ }
+
+ // Add link to author archive if enabled.
+ if ( $with_links ) {
+ $html .= '' . esc_html( $author_name ) . '';
+ } else {
+ $html .= esc_html( $author_name );
+ }
+
+ return '' . $html . '';
+ },
+ $byline_content
+ );
+ }
+
+ /**
+ * Block render callback.
+ *
+ * @param array $attributes The block attributes.
+ * @param string $content The block content.
+ * @param object $block The block.
+ *
+ * @return string The block HTML.
+ */
+ public static function render_block( array $attributes, string $content, $block ) {
+ $post_id = $block->context['postId'] ?? null;
+
+ if ( empty( $post_id ) ) {
+ return '';
+ }
+
+ $custom_byline = $attributes['customByline'] ?? '';
+ $show_avatar = $attributes['showAvatar'] ?? false;
+ $avatar_size = $attributes['avatarSize'] ?? 24;
+ $link_to_author = $attributes['linkToAuthorArchive'] ?? true;
+ $duotone_class = self::newspack_byline_get_duotone_class_name( $attributes );
+ $wrapper_attributes = get_block_wrapper_attributes( [ 'class' => 'newspack-byline' ] );
+
+ // If custom byline is empty, generate a default one using post author(s).
+ if ( empty( $custom_byline ) ) {
+ $authors = [];
+
+ if ( function_exists( 'get_coauthors' ) ) {
+ $authors = get_coauthors( $post_id );
+ } else {
+ $authors[] = get_userdata( get_post_field( 'post_author', $post_id ) );
+ }
+
+ if ( ! empty( $authors ) ) {
+ $custom_byline = 'Published by ';
+ foreach ( $authors as $index => $author ) {
+ if ( $index > 0 ) {
+ $custom_byline .= ( $index === count( $authors ) - 1 ) ? ' and ' : ', ';
+ }
+ $custom_byline .= "[Author id={$author->ID}]{$author->display_name}[/Author]";
+ }
+ }
+ }
+
+ $parsed_byline = self::parse_byline( $custom_byline, $link_to_author, $show_avatar, $avatar_size, $duotone_class );
+
+ ob_start();
+ ?>
+
>
+
+
+ {
+ if ( !metaByline ) { return ''; }
+
+ // Regex to convert [Author][/Author] tags to token markup
+ return metaByline.replace(
+ /\[Author id=(\d+)\](.*?)\[\/Author\]/g,
+ (match, id, name) => {
+ return `
+ ${name}
+
+ `;
+ }
+ );
+};
+
+/**
+ * Parse byline meta for preview display.
+ *
+ * @param {string} metaByline Value of byline as stored in meta key.
+ * @param {boolean} showAvatar Whether to show author avatars.
+ * @param {number} avatarSize Size of author avatars.
+ * @param {boolean} linkToAuthorArchive Whether to link to author archives.
+ * @param {Array} authors List of authors to use for avatar and link generation.
+ * @return {string} Parsed byline for preview display.
+ */
+const parseForPreview = (metaByline, showAvatar = false, avatarSize = 24, linkToAuthorArchive = true, authors, duotoneClassName = '') => {
+ if ( !metaByline ) { return ''; }
+
+ return metaByline.replace(
+ /\[Author id=(\d*)\](.*?)\[\/Author\]/g,
+ ( match, id, name ) => {
+ let avatar = '';
+ let authorLink = name;
+
+ const matchedAuthor = authors.find( author => author.id === Number( id ) );
+ const baseUrl = matchedAuthor?.avatar_urls?.['96'] || '';
+ const avatarUrl = addQueryArgs( removeQueryArgs(baseUrl, ['s'] ), {
+ s: avatarSize * 2,
+ } );
+
+ if ( showAvatar && avatarUrl && baseUrl ) {
+ avatar = `
+
+ `;
+ }
+
+ if ( linkToAuthorArchive ) {
+ authorLink = `${name}`;
+ }
+
+ return `${avatar}${authorLink}`;
+ }
+ );
+};
+
+/**
+ * Transform the bylineElement innerHTML into the format that we expect to save.
+ *
+ * @param {Element} element Byline element reference.
+ * @return {string} Updated byline text, transformed into the save format.
+ */
+const transformByline = element => {
+ const clonebylineElement = element.cloneNode( true );
+
+ // Remove avatar elements first (if any)
+ const avatarElements = clonebylineElement.querySelectorAll( '.avatar-display' );
+ avatarElements.forEach( el => el.remove() );
+
+ const tokenElements =
+ clonebylineElement.querySelectorAll( 'span[data-token]' );
+
+ tokenElements.forEach( tokenElement => {
+ const authorID = tokenElement.dataset.token;
+ const authorName = tokenElement.dataset.name ||
+ ( tokenElement.querySelector('.components-form-token-field__token-text')?.innerText || '' ).trim();
+
+ if ( authorID && authorName ) {
+ tokenElement.replaceWith(
+ document.createTextNode(
+ `[Author id=${authorID}]${authorName}[/Author]`
+ )
+ );
+ }
+ } );
+
+ return clonebylineElement.innerHTML;
+};
+
+const Edit = ( { attributes, context, setAttributes, isSelected } ) => {
+ const { postId, postType } = context;
+ const { customByline, showAvatar, avatarSize, linkToAuthorArchive } = attributes;
+ const [ authors, setAuthors ] = useState( [] );
+ const [ tokensInUse, setTokensInUse ] = useState( [] );
+
+ // Refs to manage states
+ const editableRef = useRef( null );
+ const contentRef = useRef( customByline || '' );
+ const isTypingRef = useRef( false );
+ const typingTimeoutRef = useRef( null );
+ const savingTimeoutRef = useRef( null );
+
+ const blockProps = useBlockProps( {
+ className: 'newspack-byline-block',
+ __unstableLayoutClassNames: []
+ } );
+
+ const duotoneClassName = blockProps.className
+ ? blockProps.className.split(' ')
+ .filter( ( classes ) => classes.includes( 'wp-duotone' ) )
+ .join(' ')
+ : '';
+
+ const postAuthors = usePostAuthors( { postId, postType } );
+
+ useEffect( () => {
+ if ( postAuthors?.length ) {
+ setAuthors( postAuthors );
+ }
+ }, [ postAuthors ] );
+
+ // Set default byline if none exists (initialize byline)
+ useEffect( () => {
+ if ( ( !customByline || '' === customByline ) && authors.length > 0 ) {
+ let defaultByline = 'Published by: ';
+
+ authors.forEach( ( author, index ) => {
+ if ( index > 0 ) {
+ defaultByline += ( index === authors.length - 1 ) ? ' and ' : ', ';
+ }
+ defaultByline += `[Author id=${author.id}]${author.display_name}[/Author]`;
+ } );
+
+ setAttributes( { customByline: defaultByline } );
+ }
+ }, [ authors, customByline, setAttributes ] );
+
+ // Update avatar when duotone changes
+ useEffect(() => {
+ if ( editableRef.current && showAvatar ) {
+ const selectionData = saveSelection();
+ updateAvatarDisplay();
+ if ( selectionData ) {
+ restoreSelection( selectionData );
+ }
+ }
+ }, [ duotoneClassName ] );
+
+ // Save cursor position
+ const saveSelection = () => {
+ const selection = editableRef.current?.ownerDocument.defaultView.getSelection();
+ if (!selection.rangeCount) { return null; }
+
+ return {
+ range: selection.getRangeAt(0).cloneRange(),
+ startContainer: selection.anchorNode,
+ startOffset: selection.anchorOffset,
+ endContainer: selection.focusNode,
+ endOffset: selection.focusOffset
+ };
+ };
+
+ // Restore cursor position
+ const restoreSelection = ( selectionData ) => {
+ if ( !selectionData || !selectionData.range ) { return; }
+
+ try {
+ const selection = editableRef.current?.ownerDocument.defaultView.getSelection();
+ selection.removeAllRanges();
+ selection.addRange( selectionData.range );
+ } catch ( e ) {}
+ };
+
+ // Update tokens in use
+ const updateTokensInUse = ( element ) => {
+ if ( !element ) { return; }
+
+ const tokenElements = element.querySelectorAll( 'span[data-token]' );
+ const inUse = [ ...tokenElements ].map(span => Number( span.dataset.token ) );
+ setTokensInUse( inUse );
+ };
+ // Update avatar displays in edit mode
+ const updateAvatarDisplay = () => {
+ if ( !editableRef.current ) {
+ return;
+ }
+
+ // First, remove any existing avatar displays
+ const existingAvatars = editableRef.current.querySelectorAll( '.avatar-display' );
+ existingAvatars.forEach( el => el.remove() );
+
+ if ( !showAvatar ) {
+ return;
+ }
+
+ const authorTokens = editableRef.current.querySelectorAll( '.author-token' );
+
+ authorTokens.forEach( token => {
+ const authorId = token.dataset.token;
+ const authorName = token.dataset.name;
+ const matchedAuthor = authors.find( author => author.id === Number( authorId ) );
+
+ if ( matchedAuthor ) {
+ const baseUrl = matchedAuthor?.avatar_urls?.['96'] || '';
+ const avatarUrl = addQueryArgs( removeQueryArgs( baseUrl, ['s'] ), {
+ s: avatarSize * 2,
+ } );
+
+ if ( avatarUrl ) {
+ const avatarEl = document.createElement( 'span' );
+ avatarEl.className = `newspack-byline-avatar avatar-display avatar-display-${authorId}`;
+
+ // Apply duotone classes from blockProps
+ if ( duotoneClassName ) {
+ avatarEl.className += ` ${duotoneClassName}`;
+ }
+
+ avatarEl.innerHTML = `
+
+ `;
+
+ // Insert avatar before the token
+ token.parentNode.insertBefore( avatarEl, token );
+ }
+ }
+ });
+ };
+
+ // Initialize contenteditable and set up event handlers
+ const setupContentEditable = useCallback( element => {
+ if ( !element || !authors.length ) {
+ return;
+ }
+ editableRef.current = element;
+
+ // If contenteditable is empty or has changed, initialize it
+ if ( element.innerHTML !== parseForEdit( contentRef.current ) ) {
+ element.innerHTML = parseForEdit( contentRef.current );
+ }
+
+ const handleInput = () => {
+ // Mark as typing to prevent focus loss
+ isTypingRef.current = true;
+
+ if ( typingTimeoutRef.current ) {
+ clearTimeout( typingTimeoutRef.current );
+ }
+
+ // Set timeout to detect when typing stops
+ typingTimeoutRef.current = setTimeout( () => {
+ isTypingRef.current = false;
+ }, 2000 );
+
+ // Save changes with debounce
+ if ( savingTimeoutRef.current ) {
+ clearTimeout( savingTimeoutRef.current );
+ }
+
+ savingTimeoutRef.current = setTimeout( () => {
+ const selectionData = saveSelection();
+
+ // Remove avatar displays - they shouldn't be saved
+ const avatarElements = element.querySelectorAll( '.avatar-display' );
+ avatarElements.forEach( el => el.remove() );
+
+ // Update content ref
+ const transformedContent = transformByline( element );
+ contentRef.current = transformedContent;
+
+ // Update tokens in use
+ updateTokensInUse( element );
+
+ // Add back avatars if needed
+ if ( showAvatar ) {
+ updateAvatarDisplay();
+ }
+
+ // Update attributes (delayed to maintain focus)
+ setTimeout( () => {
+ setAttributes( { customByline: transformedContent } );
+
+ // Make sure we keep focus and restore cursor
+ if ( editableRef.current && editableRef.current.ownerDocument.activeElement !== editableRef.current ) {
+ editableRef.current.focus();
+ }
+
+ // Restore selection
+ if ( selectionData ) {
+ restoreSelection( selectionData );
+ }
+ }, 10 );
+ }, 1000 );
+ };
+
+ // Click handler for token removal
+ const handleClick = ( e ) => {
+ if ( e.target.classList.contains( 'token-inline-block__remove' ) ) {
+ const tokenElement = e.target.closest( '.token-inline-block' );
+ if ( tokenElement ) {
+ // Remove associated avatar if present
+ const authorId = tokenElement.dataset.token;
+ const avatarEl = element.querySelector( `.avatar-display-${authorId}` );
+ if ( avatarEl ) {
+ avatarEl.remove();
+ }
+ tokenElement.remove();
+ handleInput();
+ }
+ }
+ };
+
+ // Add event listeners
+ element.addEventListener( 'input', handleInput );
+ element.addEventListener( 'click', handleClick );
+
+ // Set initial tokens
+ updateTokensInUse( element );
+
+ // Initialize avatars if needed
+ if ( showAvatar ) {
+ updateAvatarDisplay();
+ }
+
+ return () => {
+ // Cleanup event listeners
+ element.removeEventListener( 'input', handleInput );
+ element.removeEventListener( 'click', handleClick );
+
+ // Clear timeouts
+ if ( typingTimeoutRef.current ) {
+ clearTimeout( typingTimeoutRef.current );
+ }
+ if ( savingTimeoutRef.current ) {
+ clearTimeout( savingTimeoutRef.current );
+ }
+ };
+ }, [ setAttributes, showAvatar, avatarSize, authors, duotoneClassName ] );
+
+ // Function to insert token
+ const insertToken = ( token ) => {
+ const element = editableRef.current;
+ if ( !element ) {
+ return;
+ }
+
+ // Save selection
+ const selection = editableRef.current?.ownerDocument.defaultView.getSelection();
+ let range;
+
+ if ( selection.rangeCount > 0 ) {
+ range = selection.getRangeAt( 0 );
+ } else {
+ // Create a range at the end if none exists
+ range = document.createRange();
+ const lastChild = element.lastChild;
+ if ( lastChild ) {
+ range.setStartAfter( lastChild );
+ } else {
+ range.setStart( element, 0 );
+ }
+ range.collapse( true );
+ }
+
+ // Delete any selected content
+ range.deleteContents();
+
+ // Create token element
+ const tokenElement = document.createElement( 'span' );
+ tokenElement.id = `token-${token.id}`;
+ tokenElement.contentEditable = false;
+ tokenElement.draggable = true;
+ tokenElement.className = 'components-form-token-field__token token-inline-block author-token';
+ tokenElement.dataset.token = token.id;
+ tokenElement.dataset.name = token.display_name;
+ tokenElement.innerHTML = `
+ ${token.display_name}
+
+ `;
+
+ // Insert token
+ range.insertNode( tokenElement );
+
+ // Add avatar if enabled
+ if ( showAvatar && token ) {
+ const baseUrl = token?.avatar_urls?.['96'] || '';
+ const avatarUrl = addQueryArgs( removeQueryArgs( baseUrl, [ 's' ] ), {
+ s: avatarSize * 2,
+ } );
+
+ if ( avatarUrl ) {
+ const avatarEl = document.createElement( 'span' );
+ avatarEl.className = `newspack-byline-avatar avatar-display avatar-display-${token.id}`;
+
+ // Apply duotone classes from blockProps
+ if ( duotoneClassName ) {
+ avatarEl.className += ` ${duotoneClassName}`;
+ }
+
+ avatarEl.innerHTML = `
+
+ `;
+
+ // Insert avatar before the token
+ tokenElement.parentNode.insertBefore( avatarEl, tokenElement );
+ }
+ }
+
+ // Add space after token
+ const spaceNode = document.createTextNode(' ');
+ tokenElement.parentNode.insertBefore( spaceNode, tokenElement.nextSibling );
+
+ // Move cursor after the space
+ const newRange = document.createRange();
+ newRange.setStart( spaceNode, 1 );
+ newRange.setEnd( spaceNode, 1 );
+ selection.removeAllRanges();
+ selection.addRange( newRange );
+
+ element.focus();
+
+ // Update content
+ const transformedContent = transformByline( element );
+ contentRef.current = transformedContent;
+ updateTokensInUse( element );
+
+ // Update attributes
+ setAttributes( { customByline: transformedContent } );
+ };
+
+ // Update content ref when block attributes change
+ useEffect( () => {
+ contentRef.current = customByline || '';
+
+ // Update editable content if we have a reference and content changed
+ if (editableRef.current && parseForEdit( customByline ) !== editableRef.current.innerHTML) {
+ // Only update if not currently typing to avoid cursor jumps
+ if ( !isTypingRef.current ) {
+ editableRef.current.innerHTML = parseForEdit( customByline );
+ updateTokensInUse( editableRef.current );
+
+ // Update avatar display if needed
+ if ( showAvatar ) {
+ updateAvatarDisplay();
+ }
+ }
+ }
+ }, [ customByline ]);
+
+ // Effect to update avatar display when avatar settings change
+ useEffect( () => {
+ if ( editableRef.current ) {
+ const selectionData = saveSelection();
+
+ // Remove all existing avatars
+ const existingAvatars = editableRef.current.querySelectorAll( '.avatar-display' );
+ existingAvatars.forEach( el => el.remove() );
+
+ // Add new avatars if needed
+ if ( showAvatar ) {
+ updateAvatarDisplay();
+ }
+
+ // Restore selection
+ if ( selectionData ) {
+ restoreSelection( selectionData );
+ }
+ }
+ }, [ showAvatar, avatarSize ] );
+
+ // Focus handler
+ useEffect(() => {
+ if ( isSelected && editableRef.current ) {
+ // Focus the element and move cursor to end if not already focused
+ if ( editableRef.current.ownerDocument.activeElement !== editableRef.current ) {
+ editableRef.current.focus();
+
+ // Move cursor to end
+ const selection = editableRef.current?.ownerDocument.defaultView.getSelection();
+ const range = document.createRange();
+
+ if ( editableRef.current.lastChild ) {
+ if ( editableRef.current.lastChild.nodeType === Node.TEXT_NODE ) {
+ range.setStart( editableRef.current.lastChild, editableRef.current.lastChild.length );
+ } else {
+ range.setStartAfter( editableRef.current.lastChild );
+ }
+ } else {
+ range.setStart( editableRef.current, 0 );
+ }
+
+ range.collapse( true );
+ selection.removeAllRanges();
+ selection.addRange( range );
+ }
+ }
+ }, [ isSelected ] );
+
+ // Update available authors
+ const availableAuthors = authors.filter( author => !tokensInUse.includes( author.id ) );
+
+
+ return (
+ <>
+
+
+ setAttributes( { showAvatar: !showAvatar } ) }
+ />
+ { showAvatar && (
+ setAttributes( { avatarSize: Number( value ) } ) }
+ />
+ )}
+ setAttributes( { linkToAuthorArchive: !linkToAuthorArchive } ) }
+ />
+
+
+ { isSelected ? (
+ <>
+ { customByline ? (
+ <>
+ isTypingRef.current = true }
+ onBlur={ () => {
+ setTimeout( () => {
+ isTypingRef.current = false;
+ }, 100 );
+ } }
+ />
+ { availableAuthors.length > 0 && (
+
+
+ { __('Add author:', 'newspack-plugin') }
+
+
+ { availableAuthors.map( ( author ) => (
+
+ ) ) }
+
+
+ ) }
+ >
+ ) : (
+
+ { __( 'Loading byline…', 'newspack-plugin' ) }
+
+ )}
+ >
+ ) : (
+
+ ) }
+ >
+ );
+};
+
+export default Edit;
diff --git a/src/blocks/byline/hooks.js b/src/blocks/byline/hooks.js
new file mode 100644
index 0000000000..97be7ea07b
--- /dev/null
+++ b/src/blocks/byline/hooks.js
@@ -0,0 +1,80 @@
+/**
+ * WordPress dependencies
+ */
+import { useSelect } from '@wordpress/data';
+import { useEffect, useState } from '@wordpress/element';
+import { store as coreStore } from '@wordpress/core-data';
+import apiFetch from '@wordpress/api-fetch';
+
+/**
+ * Hook to get post authors (supports co-authors if available)
+ *
+ * @param {Object} options Options for fetching authors.
+ * @param {number} options.postId The post ID.
+ * @param {string} options.postType The post type.
+ * @return {Array} Array of authors.
+ */
+export function usePostAuthors({ postId, postType }) {
+ const [ authors, setAuthors ] = useState( [] );
+ const [ coAuthorsLoaded, setCoAuthorsLoaded ] = useState( false );
+
+ // First try to get co-authors if the plugin is active
+ useEffect( () => {
+ if ( !postId ) {
+ return;
+ }
+
+ const controller = new AbortController();
+ const signal = controller.signal;
+
+ apiFetch( {
+ path: `/coauthors/v1/coauthors?post_id=${postId}`,
+ signal,
+ } )
+ .then( ( coauthors ) => {
+ if ( Array.isArray( coauthors ) && coauthors.length > 0) {
+ setAuthors( coauthors );
+ }
+ setCoAuthorsLoaded( true );
+ })
+ .catch( () => {
+ // If co-authors API fails, fall back to the default author
+ setCoAuthorsLoaded( true );
+ } );
+
+ return () => {
+ controller.abort();
+ };
+ }, [ postId ] );
+
+ // If co-authors isn't available or failed to load, use default WordPress author
+ const { defaultAuthor } = useSelect(
+ ( select ) => {
+ // Only fetch the default author if co-authors didn't return anything
+ if ( !coAuthorsLoaded ) {
+ return { defaultAuthor: null };
+ }
+
+ const { getEditedEntityRecord, getUser } = select( coreStore );
+ const _authorId = getEditedEntityRecord( 'postType', postType, postId )?.author;
+
+ return {
+ defaultAuthor: _authorId ? getUser( _authorId ) : null,
+ };
+ },
+ [ postType, postId, coAuthorsLoaded ]
+ );
+
+ // If co-authors API failed and we have a default author, use that
+ useEffect( () => {
+ if ( coAuthorsLoaded && authors.length === 0 && defaultAuthor ) {
+ setAuthors( [ {
+ id: defaultAuthor.id,
+ name: defaultAuthor.name,
+ avatar_urls: defaultAuthor.avatar_urls,
+ display_name: defaultAuthor.name,
+ } ] );
+ }
+ }, [ coAuthorsLoaded, defaultAuthor, authors.length ] );
+ return authors;
+}
diff --git a/src/blocks/byline/index.js b/src/blocks/byline/index.js
new file mode 100644
index 0000000000..7743f95dbc
--- /dev/null
+++ b/src/blocks/byline/index.js
@@ -0,0 +1,43 @@
+/**
+ * WordPress dependencies
+ */
+import { Path, SVG } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import './style.scss';
+import metadata from './block.json';
+import Edit from './edit';
+
+export const title = __('Byline', 'newspack-plugin');
+
+export const icon = (
+
+);
+
+const { name } = metadata;
+
+export { metadata, name };
+
+export const settings = {
+ title,
+ icon: {
+ src: icon,
+ foreground: '#36f',
+ },
+ keywords: [
+ __('author', 'newspack-plugin'),
+ __('byline', 'newspack-plugin'),
+ __('coauthor', 'newspack-plugin')
+ ],
+ description: __(
+ 'Display post author byline.',
+ 'newspack-plugin'
+ ),
+ usesContext: ['postId', 'postType'],
+ edit: Edit
+};
diff --git a/src/blocks/byline/style.scss b/src/blocks/byline/style.scss
new file mode 100644
index 0000000000..75eb878c50
--- /dev/null
+++ b/src/blocks/byline/style.scss
@@ -0,0 +1,167 @@
+// General styles (apply to both editor and frontend)
+.wp-block-newspack-byline {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ margin-bottom: 1em;
+}
+
+.newspack-byline-author {
+ display: inline-flex;
+ align-items: center;
+ margin-right: 0.5em;
+ white-space: nowrap;
+
+ &:last-child {
+ margin-right: 0;
+ }
+}
+
+.newspack-byline-avatar {
+ display: inline-block;
+ margin: 0 0.5rem;
+ position: relative;
+ z-index: 0;
+
+ img {
+ border-radius: 50%;
+ vertical-align: middle;
+ }
+}
+
+// Text nodes between author tokens
+.wp-block-newspack-byline > span:not(.newspack-byline-author) {
+ display: inline-block;
+ white-space: nowrap;
+ margin-right: 0.25em;
+}
+
+// Editor-only styles
+.editor-styles-wrapper {
+ .newspack-byline-preview {
+ line-height: 1.5;
+ cursor: text;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ }
+
+ .newspack-byline-author-tokens {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ .newspack-byline-author-token {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 8px;
+ background: #f0f0f0;
+ border-radius: 4px;
+ }
+
+ .newspack-byline-textarea {
+ min-height: 36px;
+ padding: 4px 0;
+ border: 1px solid transparent;
+ outline: none;
+
+ &:focus {
+ border-color: #007cba;
+ }
+ }
+
+ .token-inline-block {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 8px;
+ margin: 0 2px;
+ background-color: #e7f0fa;
+ border-radius: 3px;
+ font-size: 13px;
+ line-height: 1.4;
+ border: 1px solid #c0d6e8;
+ position: relative;
+ z-index: 1;
+ white-space: nowrap;
+ }
+
+ .token-inline-block__remove {
+ margin-left: 4px;
+ padding: 0 !important;
+ min-width: 18px !important;
+ height: 18px !important;
+
+ svg {
+ width: 18px;
+ height: 18px;
+ }
+ }
+
+ .components-form-token-field__remove-token {
+ background-color: transparent !important;
+ }
+
+ .components-form-token-field__token-text {
+ background-color: transparent;
+ }
+
+ .newspack-author-selector {
+ display: flex;
+ align-items: center;
+ margin-top: 8px;
+ flex-wrap: wrap;
+ gap: 6px;
+ border-top: 1px solid #ddd;
+ padding-top: 8px;
+ }
+
+ .newspack-author-selector-label {
+ margin-right: 8px;
+ font-size: 13px;
+ }
+
+ .author-token-button {
+ margin: 0 !important;
+ display: inline-flex;
+ align-items: center;
+ padding: 4px 8px;
+ background-color: #f0f0f0;
+ border-radius: 3px;
+ font-size: 13px;
+
+ svg {
+ margin-left: 4px;
+ }
+
+ * {
+ pointer-events: none;
+ }
+ }
+
+ .avatar-display {
+ display: inline-block;
+ margin-right: 4px;
+ vertical-align: middle;
+ position: relative;
+ z-index: 0;
+
+ img {
+ border-radius: 50%;
+ vertical-align: middle;
+ }
+ }
+
+ .components-form-token-field__token.token-inline-block.author-token {
+ display: inline-flex;
+ align-items: center;
+ background-color: #e0f0ff;
+ border-radius: 4px;
+ padding: 2px 8px;
+ margin: 0 4px;
+ color: #06c;
+ font-weight: 500;
+ white-space: nowrap;
+ }
+}
diff --git a/src/blocks/index.js b/src/blocks/index.js
index fed4cc8776..8aa71a7b78 100644
--- a/src/blocks/index.js
+++ b/src/blocks/index.js
@@ -11,13 +11,14 @@ import { registerBlockType } from '@wordpress/blocks';
import * as readerRegistration from './reader-registration';
import * as correctionBox from './correction-box';
import * as correctionItem from './correction-item';
+import * as byline from './byline';
/**
* Block Scripts
*/
import './core-image';
-export const blocks = [ readerRegistration, correctionBox, correctionItem ];
+export const blocks = [ readerRegistration, correctionBox, correctionItem, byline ];
const readerActivationBlocks = [ 'newspack/reader-registration' ];
const correctionBlocks = [ 'newspack/correction-box', 'newspack/correction-item' ];
diff --git a/webpack.config.js b/webpack.config.js
index f7416c5ac6..c824fb4772 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -91,6 +91,13 @@ const entry = {
'correction-item',
'index.js'
),
+ 'byline-block': path.join(
+ __dirname,
+ 'src',
+ 'blocks',
+ 'byline',
+ 'index.js'
+ ),
'my-account': path.join(
__dirname,
'includes',