PluginProbe ʕ •ᴥ•ʔ
Secure Custom Fields / trunk
Secure Custom Fields vtrunk
6.9.1 6.9.0 6.8.9 6.8.7 6.8.8 6.8.6 6.8.4 6.8.5 trunk 6.4.0-beta1 6.4.0-beta2 6.4.1 6.4.1-beta3 6.4.1-beta4 6.4.1-beta5 6.4.1-beta6 6.4.1-beta7 6.4.2 6.5.0 6.5.1 6.5.2 6.5.3 6.5.4 6.5.5 6.5.6 6.5.7 6.6.0 6.7.0 6.7.1 6.8.0 6.8.1 6.8.2 6.8.3
secure-custom-fields / assets / src / js / pro / blocks-v3 / components / jsx-parser.js
secure-custom-fields / assets / src / js / pro / blocks-v3 / components Last commit date
block-edit.js 3 weeks ago block-form.js 2 months ago block-placeholder.js 6 months ago block-preview.js 6 months ago block-toolbar-fields.js 2 months ago error-boundary.js 2 months ago inline-editing-toolbar.js 6 months ago jsx-parser.js 2 months ago popover-wrapper.js 2 months ago
jsx-parser.js
453 lines
1 /**
2 * JSX Parser for ACF Blocks - Based on 6.7.0.2
3 * Converts HTML strings to React/JSX elements for rendering in the block editor
4 */
5
6 /* global acf, React, Text */
7
8 // eslint-disable-next-line import/no-unresolved -- WordPress provides jquery as an external script dependency.
9 import jQuery from 'jquery';
10 import { createElement, createRef } from '@wordpress/element';
11
12 const useInnerBlocksProps =
13 wp.blockEditor.__experimentalUseInnerBlocksProps ||
14 wp.blockEditor.useInnerBlocksProps;
15
16 /**
17 * Gets the JSX-compatible name for an HTML attribute
18 *
19 * @param {string} attrName Attribute name.
20 * @return {string} JSX-compatible attribute name.
21 */
22 function getJSXNameReplacement( attrName ) {
23 return acf.isget( acf, 'jsxNameReplacements', attrName ) || attrName;
24 }
25
26 /**
27 * ACF InnerBlocks wrapper component
28 *
29 * @param {Object} props Component props.
30 * @return {JSX.Element} InnerBlocks wrapper element.
31 */
32 function ACFInnerBlocksComponent( props ) {
33 const { className = 'acf-innerblocks-container' } = props;
34 const innerBlocksProps = useInnerBlocksProps( { className }, props );
35 return createElement( 'div', {
36 ...innerBlocksProps,
37 children: innerBlocksProps.children,
38 } );
39 }
40
41 /**
42 * Script component for handling <script> tags
43 */
44 class ScriptComponent extends React.Component {
45 render() {
46 return createElement( 'div', {
47 ref: ( element ) => ( this.el = element ),
48 } );
49 }
50
51 setHTML( scriptContent ) {
52 jQuery( this.el ).html( `<script>${ scriptContent }</script>` );
53 }
54
55 componentDidUpdate() {
56 this.setHTML( this.props.children );
57 }
58
59 componentDidMount() {
60 this.setHTML( this.props.children );
61 }
62 }
63
64 /**
65 * Helper function to parse style attribute string into object
66 *
67 * @param {string} styleString Style attribute value.
68 * @return {Object} Parsed style object.
69 */
70 function parseStyleAttribute( styleString ) {
71 const styleObj = {};
72 if ( ! styleString ) {
73 return styleObj;
74 }
75
76 styleString.split( ';' ).forEach( ( rule ) => {
77 const colonIndex = rule.indexOf( ':' );
78 if ( colonIndex > 0 ) {
79 let property = rule.substr( 0, colonIndex ).trim();
80 const value = rule.substr( colonIndex + 1 ).trim();
81
82 // Convert to camelCase
83 if ( property.charAt( 0 ) !== '-' ) {
84 property = acf.strCamelCase( property );
85 }
86 styleObj[ property ] = value;
87 }
88 } );
89
90 return styleObj;
91 }
92
93 /**
94 * Parses and transforms a DOM attribute to React props format
95 * Based on 6.7.0.2's implementation
96 *
97 * @param {Attr} attribute DOM attribute.
98 * @return {Object} React prop name and value.
99 */
100 function parseAttribute( attribute ) {
101 let attrName = attribute.name;
102 let attrValue = attribute.value;
103
104 // Allow custom filtering
105 const customParsed = acf.applyFilters(
106 'acf_blocks_parse_node_attr',
107 false,
108 attribute
109 );
110 if ( customParsed ) {
111 return customParsed;
112 }
113
114 switch ( attrName ) {
115 case 'class':
116 attrName = 'className';
117 break;
118 case 'style':
119 const styleObj = {};
120 attrValue.split( ';' ).forEach( ( rule ) => {
121 const colonIndex = rule.indexOf( ':' );
122 if ( colonIndex > 0 ) {
123 let property = rule.substr( 0, colonIndex ).trim();
124 const value = rule.substr( colonIndex + 1 ).trim();
125 if ( property.charAt( 0 ) !== '-' ) {
126 property = acf.strCamelCase( property );
127 }
128 styleObj[ property ] = value;
129 }
130 } );
131 attrValue = styleObj;
132 break;
133 default:
134 // Skip data- attributes processing, keep them as-is
135 if ( attrName.indexOf( 'data-' ) === 0 ) {
136 break;
137 }
138
139 attrName = getJSXNameReplacement( attrName );
140 const firstChar = attrValue.charAt( 0 );
141 if ( firstChar === '[' || firstChar === '{' ) {
142 attrValue = JSON.parse( attrValue );
143 }
144 if ( attrValue === 'true' || attrValue === 'false' ) {
145 attrValue = attrValue === 'true';
146 }
147 }
148
149 return { name: attrName, value: attrValue };
150 }
151
152 /**
153 * Main parseNodeToJSX function - Based on 6.7.0.2's function `O`
154 * Recursively converts DOM nodes to React/JSX elements
155 *
156 * @param {Node} node DOM node.
157 * @param {number} depth Recursion depth.
158 * @return {JSX.Element|null} Parsed JSX element.
159 */
160 function parseNodeToJSX( node, depth = 0, callbacks = {} ) {
161 const {
162 onNewInlineEditingElementSelected,
163 onNewContentEditableElementSelected,
164 blockFieldInfo,
165 } = callbacks;
166
167 const selectInlineEditingElement = ( uid, element = null ) => {
168 if ( onNewInlineEditingElementSelected ) {
169 onNewInlineEditingElementSelected( uid );
170 return;
171 }
172
173 if ( acf.blockEdit?.setCurrentInlineEditingElementUid ) {
174 acf.blockEdit.setCurrentInlineEditingElementUid( uid );
175 }
176
177 if ( acf.blockEdit?.setCurrentInlineEditingElement ) {
178 acf.blockEdit.setCurrentInlineEditingElement( element );
179 }
180 };
181
182 const selectContentEditableElement = ( fieldSlug, element = null ) => {
183 if ( onNewContentEditableElementSelected ) {
184 onNewContentEditableElementSelected( fieldSlug );
185 return;
186 }
187
188 if ( acf.blockEdit?.setCurrentContentEditableElement ) {
189 acf.blockEdit.setCurrentContentEditableElement( element );
190 }
191 };
192
193 // Determine component type
194 const nodeName = node.nodeName.toLowerCase();
195 let componentType;
196 switch ( nodeName ) {
197 case 'innerblocks':
198 componentType = 'ACFInnerBlocks';
199 break;
200 case 'script':
201 componentType = ScriptComponent;
202 break;
203 case '#comment':
204 return null;
205 default:
206 componentType = getJSXNameReplacement( nodeName );
207 }
208
209 if ( ! componentType ) {
210 return null;
211 }
212
213 const props = {};
214
215 // Add ref to first-level elements (except ACFInnerBlocks)
216 if ( depth === 1 && componentType !== 'ACFInnerBlocks' ) {
217 props.ref = createRef();
218 }
219
220 // Parse attributes
221 acf.arrayArgs( node.attributes )
222 .map( parseAttribute )
223 .forEach( ( { name, value } ) => {
224 props[ name ] = value;
225 } );
226
227 // Handle data-acf-inline-fields attributes
228 if ( node.hasAttribute( 'data-acf-inline-fields' ) ) {
229 props.style = {
230 ...parseStyleAttribute( node.getAttribute( 'style' ) || '' ),
231 pointerEvents: 'all',
232 };
233 props.role = 'button';
234 props.tabIndex = 0;
235
236 props.onFocus = ( event ) => {
237 event.stopPropagation();
238 const uid = node.attributes.getNamedItem(
239 'data-acf-inline-fields-uid'
240 ).value;
241 selectInlineEditingElement( uid, event.currentTarget );
242 };
243
244 props.onMouseDown = ( event ) => event.stopPropagation();
245
246 props.onClick = ( event ) => {
247 event.stopPropagation();
248 const link = event.target.closest( 'a' );
249 if ( link && link.tagName === 'A' ) {
250 event.preventDefault();
251 acf.debug( `Navigation prevented for ${ link.href }` );
252 }
253 if (
254 ! event.target.hasAttribute( 'data-acf-inline-contenteditable' )
255 ) {
256 const uid = node.attributes.getNamedItem(
257 'data-acf-inline-fields-uid'
258 ).value;
259 selectInlineEditingElement( uid, event.currentTarget );
260 }
261 };
262
263 props.onKeyDown = ( event ) => {
264 if ( event.key === 'Tab' && event.shiftKey ) {
265 event.preventDefault();
266 const toolbar = document.querySelector(
267 '.acf-inline-editing-toolbar'
268 );
269 const button = toolbar?.querySelector( 'button' );
270 if ( button ) {
271 button.focus();
272 const uid = node.attributes.getNamedItem(
273 'data-acf-inline-fields-uid'
274 ).value;
275 selectInlineEditingElement( uid, event.currentTarget );
276 }
277 }
278 if ( event.key === 'Enter' ) {
279 event.stopPropagation();
280 const link = event.target.closest( 'a' );
281 if ( link && link.tagName === 'A' ) {
282 event.preventDefault();
283 acf.debug( `Navigation prevented for ${ link.href }` );
284 }
285 const uid = node.attributes.getNamedItem(
286 'data-acf-inline-fields-uid'
287 ).value;
288 selectInlineEditingElement( uid, event.currentTarget );
289 }
290 };
291 }
292
293 // Handle data-acf-inline-contenteditable attributes
294 if ( node.hasAttribute( 'data-acf-inline-contenteditable' ) ) {
295 const fieldSlug = node.attributes.getNamedItem(
296 'data-acf-inline-contenteditable-field-slug'
297 ).value;
298
299 const fieldInfo =
300 blockFieldInfo || acf.blockEdit?.getBlockFieldInfo?.();
301 const editableFields = fieldInfo
302 ? fieldInfo.filter(
303 ( field ) =>
304 field.name === fieldSlug &&
305 ( field.type === 'text' || field.type === 'textarea' )
306 )
307 : [];
308
309 if ( editableFields.length > 0 ) {
310 props.contentEditable = true;
311 props.suppressContentEditableWarning = true;
312 props.role = 'input';
313 props.tabIndex = 0;
314
315 props.onFocus = ( event ) => {
316 const link = event.target.closest( 'a' );
317 if ( link && link.tagName === 'A' ) {
318 event.preventDefault();
319 acf.debug( `Navigation prevented for ${ link.href }` );
320 }
321 event.stopPropagation();
322 selectContentEditableElement( fieldSlug, event.currentTarget );
323 if ( node.hasAttribute( 'data-acf-inline-fields' ) ) {
324 const uid = node.attributes.getNamedItem(
325 'data-acf-inline-fields-uid'
326 ).value;
327 selectInlineEditingElement( uid, event.currentTarget );
328 } else {
329 selectInlineEditingElement( null );
330 }
331 };
332
333 props.onPaste = ( event ) => {
334 event.preventDefault();
335 const text = event.clipboardData.getData( 'text/plain' );
336 event.currentTarget.textContent =
337 event.currentTarget.textContent + text;
338 };
339 } else {
340 // Remove invalid contentEditable attributes
341 delete props[ 'data-acf-inline-contenteditable-field-slug' ];
342 delete props[ 'data-acf-inline-contenteditable' ];
343 }
344 }
345
346 // Add click handler to clear selection if clicking outside inline fields
347 if (
348 ! node.hasAttribute( 'data-acf-inline-fields' ) &&
349 ! node.hasAttribute( 'data-acf-inline-contenteditable' )
350 ) {
351 props.onClick = ( event ) => {
352 if ( event.target === event.currentTarget ) {
353 selectInlineEditingElement( null );
354 selectContentEditableElement( null );
355 }
356 };
357 }
358
359 // Handle ACFInnerBlocks component
360 if ( componentType === 'ACFInnerBlocks' ) {
361 return createElement( ACFInnerBlocksComponent, { ...props } );
362 }
363
364 // Build element with children
365 const elementArgs = [ componentType, props ];
366
367 acf.arrayArgs( node.childNodes ).forEach( ( childNode ) => {
368 if ( childNode instanceof Text ) {
369 const textContent = childNode.textContent;
370 if ( textContent ) {
371 elementArgs.push( textContent );
372 }
373 } else {
374 elementArgs.push(
375 parseNodeToJSX( childNode, depth + 1, callbacks )
376 );
377 }
378 } );
379
380 const element = createElement.apply( this, elementArgs );
381
382 return element;
383 }
384
385 // Preserve the legacy parser for v1/v2 blocks, which pass the block version
386 // as the second argument.
387 const legacyParseJSX = acf.parseJSX;
388
389 /**
390 * Main parseJSX function exposed on the acf global object
391 * Matches 6.7.0.2's implementation exactly
392 *
393 * @param {string} htmlString HTML string.
394 * @param {Function} onNewInlineEditingElementSelected Inline element selection callback or jQuery parser.
395 * @param {Function} onContentEditableChange ContentEditable change callback.
396 * @param {Function} onNewContentEditableElementSelected ContentEditable selection callback.
397 * @param {Array} blockFieldInfo Field metadata.
398 * @param {Function} $ jQuery-compatible parser.
399 * @return {JSX.Element|Array} Parsed JSX children.
400 */
401 export function parseJSX(
402 htmlString,
403 onNewInlineEditingElementSelected = jQuery,
404 onContentEditableChange = null,
405 onNewContentEditableElementSelected = null,
406 blockFieldInfo = null,
407 $ = null
408 ) {
409 if (
410 typeof onNewInlineEditingElementSelected === 'number' &&
411 typeof legacyParseJSX === 'function'
412 ) {
413 return legacyParseJSX( htmlString, onNewInlineEditingElementSelected );
414 }
415
416 const isJQueryParser =
417 typeof onNewInlineEditingElementSelected === 'function' &&
418 ( arguments.length === 2 ||
419 ( onNewInlineEditingElementSelected.fn &&
420 onNewInlineEditingElementSelected.fn.jquery ) );
421 const parser = isJQueryParser
422 ? onNewInlineEditingElementSelected
423 : $ || jQuery;
424 const callbacks = isJQueryParser
425 ? {}
426 : {
427 onNewInlineEditingElementSelected,
428 onContentEditableChange,
429 onNewContentEditableElementSelected,
430 blockFieldInfo,
431 };
432
433 // Wrap in div to ensure valid HTML structure
434 htmlString = '<div>' + htmlString + '</div>';
435
436 // Handle self-closing InnerBlocks tags
437 htmlString = htmlString.replace(
438 /<InnerBlocks([^>]+)?\/>/,
439 '<InnerBlocks$1></InnerBlocks>'
440 );
441
442 // Parse with jQuery, convert to React, and extract children from wrapper div
443 const parsedElement = parseNodeToJSX(
444 parser( htmlString )[ 0 ],
445 0,
446 callbacks
447 );
448 return parsedElement.props.children;
449 }
450
451 acf.parseJSXV3 = parseJSX;
452 acf.parseJSX = parseJSX;
453