Skip to content

Commit

Permalink
Add functions to avoid stringifying HTML in translations with tokens
Browse files Browse the repository at this point in the history
When replacing tokens in a formatted string (which is often a
translated string), it's problematic if the replacements include
HTML, because then it has to be a string instead of a React element,
which leads to using things like RawHTML. This adds some helper
functions that handle things as arrays instead of strings so that
they can be manipulated and added as child elements in React templates.
  • Loading branch information
coreymckrill committed Mar 2, 2019
1 parent 1c7e242 commit bbe3ce8
Show file tree
Hide file tree
Showing 2 changed files with 189 additions and 76 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -8,50 +8,40 @@ import classnames from 'classnames';
* WordPress dependencies
*/
const { Disabled } = wp.components;
const { Component, Fragment, RawHTML } = wp.element;
const { Component, RawHTML } = wp.element;
const { decodeEntities } = wp.htmlEntities;
const { __, sprintf } = wp.i18n;
const { __ } = wp.i18n;

/**
* Internal dependencies
*/
import { arrayToHumanReadableList } from "../shared/block-content";
import { tokenSplit, arrayTokenReplace, intersperse, listify } from "../shared/block-content";

function SessionSpeakers( { session } ) {
let speakers;
let speakerData = get( session, '_embedded.speakers', [] );

if ( speakerData.length ) {
speakerData = speakerData.map( ( speaker ) => {
let { link = '', title = {} } = speaker;
title = title.rendered || __( 'Unnamed', 'wordcamporg' );
speakerData = speakerData.map( ( speaker ) => {
let { link = '', title = {} } = speaker;
title = title.rendered || __( 'Unnamed', 'wordcamporg' );

return sprintf(
'<a href="%s">%s</a>',
link,
decodeEntities( title.trim() )
);
} );
if ( ! link ) {
return decodeEntities( title.trim() );
}

speakers = sprintf(
/* translators: %s is a list of names. */
__( 'Presented by %s', 'wordcamporg' ),
arrayToHumanReadableList( speakerData )
);
}
return ( <a href={ link }>{ decodeEntities( title.trim() ) }</a> );
} );

speakers = arrayTokenReplace(
/* translators: %s is a list of names. */
tokenSplit( __( 'Presented by %s', 'wordcamporg' ) ),
[ listify( speakerData ) ]
);

return (
<Fragment>
{ speakers &&
<div className="wordcamp-session-speakers">
<Disabled>
<RawHTML>
{ speakers }
</RawHTML>
</Disabled>
</div>
}
</Fragment>
<div className="wordcamp-session-speakers">
{ speakers }
</div>
);
}

Expand Down Expand Up @@ -87,38 +77,40 @@ function SessionDetails( { session, show_meta, show_category } ) {
return 'wcb_track' === term.taxonomy;
} );

metaContent = sprintf(
metaContent = arrayTokenReplace(
/* translators: 1: A date; 2: A time; 3: A location; */
__( '%1$s at %2$s in %3$s', 'wordcamporg' ),
decodeEntities( session.session_date_time.date ),
decodeEntities( session.session_date_time.time ),
sprintf(
'<span class="wordcamp-session-track wordcamp-session-track-%s">%s</span>',
decodeEntities( firstTrack.slug.trim() ),
decodeEntities( firstTrack.name.trim() )
)
tokenSplit( __( '%1$s at %2$s in %3$s', 'wordcamporg' ) ),
[
decodeEntities( session.session_date_time.date ),
decodeEntities( session.session_date_time.time ),
(
<span className={ classnames( 'wordcamp-session-track', 'wordcamp-session-track-' + decodeEntities( firstTrack.slug.trim() ) ) }>
{ decodeEntities( firstTrack.name.trim() ) }
</span>
)
]
);
} else {
metaContent = sprintf(
metaContent = arrayTokenReplace(
/* translators: 1: A date; 2: A time; */
__( '%1$s at %2$s', 'wordcamporg' ),
decodeEntities( session.session_date_time.date ),
decodeEntities( session.session_date_time.time ),
tokenSplit( __( '%1$s at %2$s', 'wordcamporg' ) ),
[
decodeEntities( session.session_date_time.date ),
decodeEntities( session.session_date_time.time ),
]
);
}

meta = (
<div className="wordcamp-session-details-meta">
<RawHTML>
{ metaContent }
</RawHTML>
{ metaContent }
</div>
);
}

if ( show_category && session.session_category.length ) {
/* translators: used between list items, there is a space after the comma */
const item_separator = esc_html__( ', ', 'wordcamporg' );
const separator = __( ', ', 'wordcamporg' );
const categories = terms
.filter( ( term ) => {
return 'wcb_session_category' === term.taxonomy;
Expand All @@ -136,20 +128,24 @@ function SessionDetails( { session, show_meta, show_category } ) {

category = (
<div className="wordcamp-session-details-categories">
{ categories.join( item_separator ) }
{ intersperse( categories, separator ) }
</div>
);
}

return (
<div className="wordcamp-session-details">
{ show_meta && meta }
{ show_category && category }
{ meta }
{ category }
</div>
);
}

class SessionsBlockContent extends Component {
hasSpeaker( session ) {
return get( session, '_embedded.speakers', [] ).length > 0;
}

render() {
const { attributes, sessionPosts } = this.props;
const { className, show_speaker, show_images, image_align, image_size, content, excerpt_more, show_meta, show_category } = attributes;
Expand Down Expand Up @@ -178,7 +174,7 @@ class SessionsBlockContent extends Component {
</Disabled>
</h3>

{ show_speaker && get( post, '_embedded.speakers', [] ).length &&
{ show_speaker && this.hasSpeaker( post ) &&
<SessionSpeakers session={ post }/>
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,53 +4,170 @@
import { get } from 'lodash';
import classnames from 'classnames';


/**
* WordPress dependencies
*/
const { __, _x, sprintf } = wp.i18n;
const { decodeEntities } = wp.htmlEntities;
const { __ } = wp.i18n;

/**
* Split a string into an array with sprintf-style tokens as the delimiter.
*
* Including the entire match as a capture group causes the tokens to be included in the array
* as separate items instead of being removed.
*
* This allows translated strings, which may contain tokens in different positions than they have
* in English, to be manipulated, modified, and included as an array of child elements in a
* React template.
*
* See also arrayTokenReplace
*
* Example:
*
* tokenSplit( 'I accuse %1$s in the %2$s with the %3$s!' )
*
* becomes
*
* [ 'I accuse ', '%1$s', ' in the ', '%2$s', ' with the ', '%3$s', '!' ]
*
* @param {String} string
*
* @returns {Array}
*/
export function tokenSplit( string ) {
const regex = /(%[1-9]?\$?[sd]+)/;

return string.split( regex );
}

/**
* Replace array items that are sprintf-style tokens with argument values.
*
* This allows tokens to be replaced with complex objects such as React elements, instead of just strings.
* This way, for example, a translation can include both plain strings and HTML and be inserted as an array
* of child elements into a React template without having to use RawHTML.
*
* See also tokenSplit
*
* Example:
*
* arrayTokenReplace(
* [ 'I accuse ', '%1$s', ' in the ', '%2$s', ' with the ', '%3$s', '!' ],
* [ 'Professor Plum', 'Conservatory', 'Wrench' ]
* )
*
* becomes
*
* [ 'I accuse ', 'Professor Plum', ' in the ', 'Conservatory', ' with the ', 'Wrench', '!' ]
*
* @param {Array} source
* @param {Array} args
*
* @returns {Array}
*/
export function arrayTokenReplace( source, args ) {
let specificArgIndex,
nextArgIndex = 0;

return source.flatMap( ( value ) => {
const regex = /^%([1-9])?\$?[sd]+$/;
const match = value.match( regex );

if ( Array.isArray( match ) ) {
if ( match.length > 1 && 'undefined' !== typeof match[1] ) {
specificArgIndex = Number( match[1] ) - 1;

if ( 'undefined' !== typeof args[ specificArgIndex ] ) {
value = args[ specificArgIndex ];
}
} else {
value = args[ nextArgIndex ];

nextArgIndex ++;
}
}

return value;
} );
}

/**
* Insert a separator item in between each item in an array.
*
* See https://stackoverflow.com/a/23619085/402766
*
* @param {Array} array
* @param {String} separator
*
* @returns {Array}
*/
export function intersperse( array, separator ) {
if ( ! array.length ) {
return [];
}

return array
.slice( 1 )
.reduce(
( accumulator, curValue, curIndex ) => {
const sep = ( typeof separator === 'function' ) ? sep( curIndex ) : separator;

return accumulator.concat( [ sep, curValue ] );
},
[ array[0] ]
);
}

/**
* Add proper list grammar to an array of strings.
*
* Insert punctuation and conjunctions in between array items so that when it is joined into
* a single string, it is a human-readable list.
*
* Example:
*
* listify( [ '<em>apples</em>', '<strong>oranges</strong>', '<del>bananas</del>' ] )
*
* becomes
*
* [ '<em>apples</em>', ', ', '<strong>oranges</strong>', ', ', ' and ', '<del>bananas</del>' ]
*
* so that when the array is joined, it becomes
*
* '<em>apples</em>, <strong>oranges</strong>, and <del>bananas</del>'
*
* @param {Array} array
*
* @returns {Array}
*/
export function listify( array ) {
let list = [];

/* translators: used between list items, there is a space after the comma */
const separator = __( ', ', 'wordcamporg' );
/* translators: preceding the last item in a list, there are spaces on both sides */
const conjunction = __( ' and ', 'wordcamporg' );

export function arrayToHumanReadableList( array ) {
if ( ! Array.isArray( array ) ) {
return '';
return list;
}

const count = array.length;
let list = '';

switch ( count ) {
case 0:
break;
case 1:
[ list ] = array;
list = array;
break;
case 2:
const [ first, second ] = array;
list = sprintf(
/* translators: Each %s is a person's name. */
_x( '%1$s and %2$s', 'list of two items', 'wordcamporg' ),
first,
second
);
list = intersperse( array, conjunction );
break;
default:
/* translators: used between list items, there is a space after the comma */
const item_separator = __( ', ', 'wordcamporg' );
let [ last, ...initial ] = [ ...array ].reverse();

initial = initial.join( item_separator ) + item_separator;

list = sprintf(
/* translators: 1: A list of items. 2: The last item in a list of items. */
_x( '%1$s and %2$s', 'list of three or more items', 'wordcamporg' ),
initial,
last
);
list = intersperse( initial, separator ).concat( [ separator, conjunction, last ] );
break;
}

return list;
}

0 comments on commit bbe3ce8

Please sign in to comment.