Skip to content

Commit bbe3ce8

Browse files
committed
Add functions to avoid stringifying HTML in translations with tokens
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.
1 parent 1c7e242 commit bbe3ce8

File tree

2 files changed

+189
-76
lines changed

2 files changed

+189
-76
lines changed

public_html/wp-content/mu-plugins/blocks/assets/src/sessions/block-content.js

+47-51
Original file line numberDiff line numberDiff line change
@@ -8,50 +8,40 @@ import classnames from 'classnames';
88
* WordPress dependencies
99
*/
1010
const { Disabled } = wp.components;
11-
const { Component, Fragment, RawHTML } = wp.element;
11+
const { Component, RawHTML } = wp.element;
1212
const { decodeEntities } = wp.htmlEntities;
13-
const { __, sprintf } = wp.i18n;
13+
const { __ } = wp.i18n;
1414

1515
/**
1616
* Internal dependencies
1717
*/
18-
import { arrayToHumanReadableList } from "../shared/block-content";
18+
import { tokenSplit, arrayTokenReplace, intersperse, listify } from "../shared/block-content";
1919

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

24-
if ( speakerData.length ) {
25-
speakerData = speakerData.map( ( speaker ) => {
26-
let { link = '', title = {} } = speaker;
27-
title = title.rendered || __( 'Unnamed', 'wordcamporg' );
24+
speakerData = speakerData.map( ( speaker ) => {
25+
let { link = '', title = {} } = speaker;
26+
title = title.rendered || __( 'Unnamed', 'wordcamporg' );
2827

29-
return sprintf(
30-
'<a href="%s">%s</a>',
31-
link,
32-
decodeEntities( title.trim() )
33-
);
34-
} );
28+
if ( ! link ) {
29+
return decodeEntities( title.trim() );
30+
}
3531

36-
speakers = sprintf(
37-
/* translators: %s is a list of names. */
38-
__( 'Presented by %s', 'wordcamporg' ),
39-
arrayToHumanReadableList( speakerData )
40-
);
41-
}
32+
return ( <a href={ link }>{ decodeEntities( title.trim() ) }</a> );
33+
} );
34+
35+
speakers = arrayTokenReplace(
36+
/* translators: %s is a list of names. */
37+
tokenSplit( __( 'Presented by %s', 'wordcamporg' ) ),
38+
[ listify( speakerData ) ]
39+
);
4240

4341
return (
44-
<Fragment>
45-
{ speakers &&
46-
<div className="wordcamp-session-speakers">
47-
<Disabled>
48-
<RawHTML>
49-
{ speakers }
50-
</RawHTML>
51-
</Disabled>
52-
</div>
53-
}
54-
</Fragment>
42+
<div className="wordcamp-session-speakers">
43+
{ speakers }
44+
</div>
5545
);
5646
}
5747

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

90-
metaContent = sprintf(
80+
metaContent = arrayTokenReplace(
9181
/* translators: 1: A date; 2: A time; 3: A location; */
92-
__( '%1$s at %2$s in %3$s', 'wordcamporg' ),
93-
decodeEntities( session.session_date_time.date ),
94-
decodeEntities( session.session_date_time.time ),
95-
sprintf(
96-
'<span class="wordcamp-session-track wordcamp-session-track-%s">%s</span>',
97-
decodeEntities( firstTrack.slug.trim() ),
98-
decodeEntities( firstTrack.name.trim() )
99-
)
82+
tokenSplit( __( '%1$s at %2$s in %3$s', 'wordcamporg' ) ),
83+
[
84+
decodeEntities( session.session_date_time.date ),
85+
decodeEntities( session.session_date_time.time ),
86+
(
87+
<span className={ classnames( 'wordcamp-session-track', 'wordcamp-session-track-' + decodeEntities( firstTrack.slug.trim() ) ) }>
88+
{ decodeEntities( firstTrack.name.trim() ) }
89+
</span>
90+
)
91+
]
10092
);
10193
} else {
102-
metaContent = sprintf(
94+
metaContent = arrayTokenReplace(
10395
/* translators: 1: A date; 2: A time; */
104-
__( '%1$s at %2$s', 'wordcamporg' ),
105-
decodeEntities( session.session_date_time.date ),
106-
decodeEntities( session.session_date_time.time ),
96+
tokenSplit( __( '%1$s at %2$s', 'wordcamporg' ) ),
97+
[
98+
decodeEntities( session.session_date_time.date ),
99+
decodeEntities( session.session_date_time.time ),
100+
]
107101
);
108102
}
109103

110104
meta = (
111105
<div className="wordcamp-session-details-meta">
112-
<RawHTML>
113-
{ metaContent }
114-
</RawHTML>
106+
{ metaContent }
115107
</div>
116108
);
117109
}
118110

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

137129
category = (
138130
<div className="wordcamp-session-details-categories">
139-
{ categories.join( item_separator ) }
131+
{ intersperse( categories, separator ) }
140132
</div>
141133
);
142134
}
143135

144136
return (
145137
<div className="wordcamp-session-details">
146-
{ show_meta && meta }
147-
{ show_category && category }
138+
{ meta }
139+
{ category }
148140
</div>
149141
);
150142
}
151143

152144
class SessionsBlockContent extends Component {
145+
hasSpeaker( session ) {
146+
return get( session, '_embedded.speakers', [] ).length > 0;
147+
}
148+
153149
render() {
154150
const { attributes, sessionPosts } = this.props;
155151
const { className, show_speaker, show_images, image_align, image_size, content, excerpt_more, show_meta, show_category } = attributes;
@@ -178,7 +174,7 @@ class SessionsBlockContent extends Component {
178174
</Disabled>
179175
</h3>
180176

181-
{ show_speaker && get( post, '_embedded.speakers', [] ).length &&
177+
{ show_speaker && this.hasSpeaker( post ) &&
182178
<SessionSpeakers session={ post }/>
183179
}
184180

public_html/wp-content/mu-plugins/blocks/assets/src/shared/block-content/index.js

+142-25
Original file line numberDiff line numberDiff line change
@@ -4,53 +4,170 @@
44
import { get } from 'lodash';
55
import classnames from 'classnames';
66

7-
87
/**
98
* WordPress dependencies
109
*/
11-
const { __, _x, sprintf } = wp.i18n;
12-
const { decodeEntities } = wp.htmlEntities;
10+
const { __ } = wp.i18n;
11+
12+
/**
13+
* Split a string into an array with sprintf-style tokens as the delimiter.
14+
*
15+
* Including the entire match as a capture group causes the tokens to be included in the array
16+
* as separate items instead of being removed.
17+
*
18+
* This allows translated strings, which may contain tokens in different positions than they have
19+
* in English, to be manipulated, modified, and included as an array of child elements in a
20+
* React template.
21+
*
22+
* See also arrayTokenReplace
23+
*
24+
* Example:
25+
*
26+
* tokenSplit( 'I accuse %1$s in the %2$s with the %3$s!' )
27+
*
28+
* becomes
29+
*
30+
* [ 'I accuse ', '%1$s', ' in the ', '%2$s', ' with the ', '%3$s', '!' ]
31+
*
32+
* @param {String} string
33+
*
34+
* @returns {Array}
35+
*/
36+
export function tokenSplit( string ) {
37+
const regex = /(%[1-9]?\$?[sd]+)/;
38+
39+
return string.split( regex );
40+
}
41+
42+
/**
43+
* Replace array items that are sprintf-style tokens with argument values.
44+
*
45+
* This allows tokens to be replaced with complex objects such as React elements, instead of just strings.
46+
* This way, for example, a translation can include both plain strings and HTML and be inserted as an array
47+
* of child elements into a React template without having to use RawHTML.
48+
*
49+
* See also tokenSplit
50+
*
51+
* Example:
52+
*
53+
* arrayTokenReplace(
54+
* [ 'I accuse ', '%1$s', ' in the ', '%2$s', ' with the ', '%3$s', '!' ],
55+
* [ 'Professor Plum', 'Conservatory', 'Wrench' ]
56+
* )
57+
*
58+
* becomes
59+
*
60+
* [ 'I accuse ', 'Professor Plum', ' in the ', 'Conservatory', ' with the ', 'Wrench', '!' ]
61+
*
62+
* @param {Array} source
63+
* @param {Array} args
64+
*
65+
* @returns {Array}
66+
*/
67+
export function arrayTokenReplace( source, args ) {
68+
let specificArgIndex,
69+
nextArgIndex = 0;
70+
71+
return source.flatMap( ( value ) => {
72+
const regex = /^%([1-9])?\$?[sd]+$/;
73+
const match = value.match( regex );
74+
75+
if ( Array.isArray( match ) ) {
76+
if ( match.length > 1 && 'undefined' !== typeof match[1] ) {
77+
specificArgIndex = Number( match[1] ) - 1;
1378

79+
if ( 'undefined' !== typeof args[ specificArgIndex ] ) {
80+
value = args[ specificArgIndex ];
81+
}
82+
} else {
83+
value = args[ nextArgIndex ];
84+
85+
nextArgIndex ++;
86+
}
87+
}
88+
89+
return value;
90+
} );
91+
}
92+
93+
/**
94+
* Insert a separator item in between each item in an array.
95+
*
96+
* See https://stackoverflow.com/a/23619085/402766
97+
*
98+
* @param {Array} array
99+
* @param {String} separator
100+
*
101+
* @returns {Array}
102+
*/
103+
export function intersperse( array, separator ) {
104+
if ( ! array.length ) {
105+
return [];
106+
}
107+
108+
return array
109+
.slice( 1 )
110+
.reduce(
111+
( accumulator, curValue, curIndex ) => {
112+
const sep = ( typeof separator === 'function' ) ? sep( curIndex ) : separator;
113+
114+
return accumulator.concat( [ sep, curValue ] );
115+
},
116+
[ array[0] ]
117+
);
118+
}
119+
120+
/**
121+
* Add proper list grammar to an array of strings.
122+
*
123+
* Insert punctuation and conjunctions in between array items so that when it is joined into
124+
* a single string, it is a human-readable list.
125+
*
126+
* Example:
127+
*
128+
* listify( [ '<em>apples</em>', '<strong>oranges</strong>', '<del>bananas</del>' ] )
129+
*
130+
* becomes
131+
*
132+
* [ '<em>apples</em>', ', ', '<strong>oranges</strong>', ', ', ' and ', '<del>bananas</del>' ]
133+
*
134+
* so that when the array is joined, it becomes
135+
*
136+
* '<em>apples</em>, <strong>oranges</strong>, and <del>bananas</del>'
137+
*
138+
* @param {Array} array
139+
*
140+
* @returns {Array}
141+
*/
142+
export function listify( array ) {
143+
let list = [];
144+
145+
/* translators: used between list items, there is a space after the comma */
146+
const separator = __( ', ', 'wordcamporg' );
147+
/* translators: preceding the last item in a list, there are spaces on both sides */
148+
const conjunction = __( ' and ', 'wordcamporg' );
14149

15-
export function arrayToHumanReadableList( array ) {
16150
if ( ! Array.isArray( array ) ) {
17-
return '';
151+
return list;
18152
}
19153

20154
const count = array.length;
21-
let list = '';
22155

23156
switch ( count ) {
24157
case 0:
25158
break;
26159
case 1:
27-
[ list ] = array;
160+
list = array;
28161
break;
29162
case 2:
30-
const [ first, second ] = array;
31-
list = sprintf(
32-
/* translators: Each %s is a person's name. */
33-
_x( '%1$s and %2$s', 'list of two items', 'wordcamporg' ),
34-
first,
35-
second
36-
);
163+
list = intersperse( array, conjunction );
37164
break;
38165
default:
39-
/* translators: used between list items, there is a space after the comma */
40-
const item_separator = __( ', ', 'wordcamporg' );
41166
let [ last, ...initial ] = [ ...array ].reverse();
42167

43-
initial = initial.join( item_separator ) + item_separator;
44-
45-
list = sprintf(
46-
/* translators: 1: A list of items. 2: The last item in a list of items. */
47-
_x( '%1$s and %2$s', 'list of three or more items', 'wordcamporg' ),
48-
initial,
49-
last
50-
);
168+
list = intersperse( initial, separator ).concat( [ separator, conjunction, last ] );
51169
break;
52170
}
53171

54172
return list;
55173
}
56-

0 commit comments

Comments
 (0)