PluginProbe
Parse.ly / 3.24.2
Parse.ly v3.24.2
3.24.3 3.24.2 3.24.1 3.24.0 3.23.7 3.23.6 3.23.5 3.23.4 3.23.3 3.16.0 3.16.1 3.16.2 3.16.3 3.16.4 3.17.0 3.18.0 3.18.1 3.19.0 3.19.1 3.19.2 3.19.3 3.2.0 3.2.1 3.20.0 3.20.1 All 107 releases
wp-parsely / src / content-helper / editor-sidebar / smart-linking / utils.ts

utils.ts in Parse.ly 3.24.2, at src/content-helper/editor-sidebar/smart-linking/utils.ts

809 lines 24.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * WordPress dependencies
3 */
4 // eslint-disable-next-line import/named
5 import { BlockInstance, getBlockContent } from '@wordpress/blocks';
6 import { dispatch, select } from '@wordpress/data';
7
8 /**
9 * Internal dependencies
10 */
11 import { dispatchCoreBlockEditor } from '../../../@types/gutenberg/types';
12 import { addITMParamsToURL, escapeRegExp, removeITMParamsFromURL } from '../../common/utils/functions';
13 import { InboundSmartLink, SmartLink } from './provider';
14 import { ALLOWED_BLOCKS } from './smart-linking';
15 import { SmartLinkingStore } from './store';
16 export { escapeRegExp } from '../../common/utils/functions';
17
18 /**
19 * Checks if a smart link is an inbound smart link.
20 *
21 * @since 3.16.0
22 *
23 * @param {SmartLink|InboundSmartLink} link The smart link to check.
24 *
25 * @return {link is InboundSmartLink} Whether the smart link is an inbound smart link.
26 */
27 export function isInboundSmartLink( link: SmartLink | InboundSmartLink ): link is InboundSmartLink {
28 return ( link as InboundSmartLink ).post_data !== undefined;
29 }
30
31 /**
32 * Finds all text nodes in an element that contain a given search text and are
33 * not within an anchor tag.
34 *
35 * This is useful for finding text nodes that should be linked.
36 *
37 * @since 3.14.1
38 *
39 * @param {HTMLElement} element The element to search within.
40 * @param {string} searchText The text to search for.
41 *
42 * @return {Node[]} The text nodes that match the search text and are not within an anchor tag.
43 */
44 export function findTextNodesNotInAnchor( element: HTMLElement, searchText: string ): Node[] {
45 const walker = document.createTreeWalker( element, NodeFilter.SHOW_TEXT, {
46 acceptNode: ( node ) => {
47 if ( ! node.textContent || ! node.textContent.includes( searchText ) ) {
48 return NodeFilter.FILTER_REJECT;
49 }
50 let parent = node.parentNode;
51 while ( parent && parent !== element ) {
52 if ( parent.nodeName === 'A' && ! parent.textContent?.includes( searchText ) ) {
53 return NodeFilter.FILTER_REJECT;
54 }
55 parent = parent.parentNode;
56 }
57 return NodeFilter.FILTER_ACCEPT;
58 },
59 } );
60
61 const textNodes = [];
62 let node;
63 while ( ( node = walker.nextNode() ) ) {
64 textNodes.push( node );
65 }
66 return textNodes;
67 }
68
69 /**
70 * Checks if a smart link is present in a text node.
71 *
72 * @since 3.16.0
73 *
74 * @param {Text} textNode The text node to check.
75 * @param {string} smartLinkUID The smart link UID to check for.
76 *
77 * @return {boolean} Whether the smart link is present in the text node.
78 */
79 function isLinkAtNode( textNode: Text, smartLinkUID: string ): boolean {
80 let parentNode: Node | null = textNode;
81 while ( parentNode && ! ( parentNode instanceof HTMLAnchorElement ) ) {
82 parentNode = parentNode.parentNode;
83 }
84 return parentNode instanceof HTMLAnchorElement && parentNode.dataset.smartlink === smartLinkUID;
85 }
86
87 /**
88 * Checks if a node is inside a similar node to a reference node.
89 *
90 * @since 3.16.0
91 *
92 * @param {Node} node The node to check.
93 * @param {HTMLElement} referenceNode The reference node to compare against.
94 *
95 * @return {boolean} Whether the node is inside a similar node to the reference node.
96 */
97 function isInsideSimilarNode( node: Node, referenceNode: HTMLElement ): boolean {
98 let currentNode = node.parentNode;
99 while ( currentNode ) {
100 // Check by nodeName or any specific attribute.
101 if ( currentNode.nodeName === referenceNode.nodeName ) {
102 return true;
103 }
104 currentNode = currentNode.parentNode;
105 }
106 return false;
107 }
108
109 /**
110 * Checks if a node is inside a heading (h1, h2, h3, etc.) or a caption element.
111 *
112 * @since 3.16.2
113 *
114 * @param {Node} node The DOM node to check.
115 *
116 * @return {boolean} True if the node is inside a heading or a caption, false otherwise.
117 */
118 function isInsideHeadingOrCaption( node: Node ): boolean {
119 let currentNode: Node | null = node;
120 while ( currentNode ) {
121 if (
122 currentNode.nodeName.match( /^H[1-6]$/i ) !== null ||
123 currentNode.nodeName.toLowerCase() === 'figcaption'
124 ) {
125 return true;
126 }
127 currentNode = currentNode.parentNode;
128 }
129 return false;
130 }
131
132 /**
133 * Finds all text nodes in an element that contain a given search text.
134 *
135 * @since 3.16.0
136 *
137 * @param {Node} element The element to search within.
138 * @param {string} searchText The text to search for.
139 *
140 * @return {Node[]} The text nodes that match the search text and are not within a similar node.
141 */
142 function findTextNodes( element: Node, searchText: string ): Node[] {
143 const textNodes: Node[] = [];
144 const walker = document.createTreeWalker( element, NodeFilter.SHOW_TEXT, null );
145
146 while ( walker.nextNode() ) {
147 const node = walker.currentNode;
148 if ( node.textContent && node.textContent.includes( searchText ) ) {
149 textNodes.push( node );
150 }
151 }
152
153 return textNodes;
154 }
155
156 /**
157 * Applies an HTML node to a block's content, replacing the text of the link
158 * with the HTML node.
159 *
160 * This is useful for applying a link to a block's content.
161 *
162 * @since 3.16.0
163 *
164 * @param {BlockInstance} block The block instance to apply the link to.
165 * @param {SmartLink} link The link suggestion to apply.
166 * @param {HTMLElement} htmlNode The HTML node to apply to the block.
167 *
168 * @return {string|undefined} The updated block content, or `undefined` if the block content could not be updated.
169 */
170 export function applyNodeToBlock(
171 block: BlockInstance,
172 link: SmartLink,
173 htmlNode: HTMLElement
174 ): string|undefined {
175 const blockContent: string = getBlockContent( block );
176
177 const doc = new DOMParser().parseFromString( blockContent, 'text/html' );
178 const contentElement = doc.body.firstChild as HTMLElement;
179
180 if ( ! contentElement ) {
181 return;
182 }
183
184 const textNodes = findTextNodes( contentElement, link.text );
185
186 let occurrenceCount = 0;
187 let hasAddedNode = false;
188
189 textNodes.forEach( ( node ) => {
190 if ( ! node.textContent || hasAddedNode ) {
191 return;
192 }
193
194 if ( isInsideSimilarNode( node, htmlNode ) ) {
195 // Check if the node content contains the link text, and if so
196 // increase the occurrence count.
197 if ( node.textContent?.includes( link.text ) ) {
198 occurrenceCount++;
199 }
200 return;
201 }
202
203 const regex = new RegExp( escapeRegExp( link.text ), 'g' );
204 let match;
205 while ( ( match = regex.exec( node.textContent ) ) !== null ) {
206 if ( occurrenceCount === link.match?.blockOffset ) {
207 const anchor: HTMLElement = htmlNode.cloneNode( true ) as HTMLElement;
208 anchor.textContent = match[ 0 ];
209
210 const range = document.createRange();
211 range.setStart( node, match.index );
212 range.setEnd( node, match.index + match[ 0 ].length );
213 range.deleteContents();
214 range.insertNode( anchor );
215
216 if ( node.textContent && match.index + match[ 0 ].length < node.textContent.length ) {
217 const remainingText = document.createTextNode(
218 node.textContent.slice( match.index + match[ 0 ].length )
219 );
220 node.parentNode?.insertBefore( remainingText, anchor.nextSibling );
221 }
222
223 hasAddedNode = true;
224
225 return;
226 }
227 occurrenceCount++;
228 }
229 } );
230
231 // Update the block content with the new content.
232 dispatchCoreBlockEditor.updateBlockAttributes( block.clientId, { content: contentElement.innerHTML } );
233 return contentElement.innerHTML;
234 }
235
236 /**
237 * Sorts smart links based on their block position and link position within the
238 * block.
239 *
240 * The applied links are sorted after the not applied links.
241 *
242 * @since 3.16.0
243 *
244 * @param {SmartLink[]} smartLinks The smart links to sort.
245 *
246 * @return {SmartLink[]} The sorted smart links.
247 */
248 export function sortSmartLinks( smartLinks: SmartLink[] ): SmartLink[] {
249 // Break-down in two buckets: applied and not applied.
250 const appliedLinks = smartLinks.filter( ( link ) => link.applied );
251 const notAppliedLinks = smartLinks.filter( ( link ) => ! link.applied );
252
253 const sortByBlockPosition = ( a: SmartLink, b: SmartLink ) => {
254 if ( ! a.match || ! b.match ) {
255 return 0;
256 }
257
258 if ( a.match.blockPosition === b.match.blockPosition ) {
259 return a.match.blockLinkPosition - b.match.blockLinkPosition;
260 }
261 return a.match.blockPosition - b.match.blockPosition;
262 };
263
264 appliedLinks.sort( sortByBlockPosition );
265 notAppliedLinks.sort( sortByBlockPosition );
266
267 return [ ...notAppliedLinks, ...appliedLinks ];
268 }
269
270 /**
271 * Recursively collect all blocks into a flat array.
272 *
273 * @since 3.16.0
274 *
275 * @param {BlockInstance[]} blocks The blocks to flatten.
276 * @param {BlockInstance[]} flatList The flat list of blocks.
277 *
278 * @return {BlockInstance[]} The flat list of blocks.
279 */
280 function flattenBlocks( blocks: BlockInstance[], flatList: BlockInstance[] = [] ): BlockInstance[] {
281 blocks.forEach( ( block ) => {
282 if ( block.innerBlocks.length ) {
283 return flattenBlocks( block.innerBlocks, flatList );
284 }
285 flatList.push( block );
286 } );
287 return flatList;
288 }
289
290 /**
291 * Represents the counts of occurrences and applications of links within text
292 * content.
293 *
294 * - `encountered`: The number of times a specific link text is encountered in
295 * the content.
296 * - `linked`: The number of times a link has been successfully applied for a
297 * specific link text.
298 *
299 * @since 3.14.1
300 * @since 3.16.0 Moved from `content-helper/editor-sidebar/smart-linking/component.tsx`.
301 */
302 type LinkOccurrenceCounts = {
303 [key: string]: {
304 encountered: number;
305 linked: number;
306 };
307 };
308
309 /**
310 * Iterates through blocks of content to calculate the correct block and offset
311 * for each link suggestion.
312 *
313 * This function processes each block's content to identify and handle text
314 * nodes that match provided link suggestions. It avoids inserting links within
315 * existing anchor and respects the specified offset for each link to determine
316 * the correct block.
317 *
318 * Note: The function is recursive for blocks containing inner blocks, ensuring
319 * all nested content is processed.
320 *
321 * @since 3.16.0
322 *
323 * @param {Readonly<BlockInstance>[]} blocks The blocks of content where links should be applied.
324 * @param {SmartLink[]} links An array of link suggestions to apply to the content.
325 * @param {LinkOccurrenceCounts} occurrenceCounts An object to keep track of the number of times each link
326 * text has been encountered and applied across all blocks.
327 * @param {number} currentIndex The current index of the block being processed.
328 *
329 * @return {SmartLink[]} The array of link suggestions that have been successfully applied to the content.
330 */
331 export function calculateSmartLinkingMatches(
332 blocks: Readonly<BlockInstance>[],
333 links: SmartLink[],
334 occurrenceCounts: LinkOccurrenceCounts = {},
335 currentIndex: number = 0
336 ): SmartLink[] {
337 blocks.forEach( ( block, index ) => {
338 const currentBlockIndex = currentIndex + index;
339
340 if ( block.innerBlocks?.length ) {
341 calculateSmartLinkingMatches( block.innerBlocks, links, occurrenceCounts, currentBlockIndex );
342 return;
343 }
344
345 if ( ! block.originalContent ) {
346 return;
347 }
348
349 const blockContent: string = getBlockContent( block );
350 const doc = new DOMParser().parseFromString( blockContent, 'text/html' );
351 const contentElement = doc.body.firstChild;
352
353 if ( ! ( contentElement instanceof HTMLElement ) ) {
354 return;
355 }
356
357 const fullContentPlain = contentElement.textContent;
358
359 links.forEach( ( link ) => {
360 const textNodes = findTextNodesNotInAnchor( contentElement, link.text );
361 const occurrenceKey = `${ link.text }#${ link.offset }`;
362 occurrenceCounts[ occurrenceKey ] = occurrenceCounts[ occurrenceKey ] || { encountered: 0, linked: 0 };
363
364 let cumulativeTextLength = 0;
365 let blockOffsetCounter = 0;
366
367 textNodes.forEach( ( node ) => {
368 const regex = new RegExp( escapeRegExp( link.text ), 'g' );
369 let match;
370 const nodeText = node.textContent ?? '';
371 const startPosition = fullContentPlain?.indexOf( nodeText, cumulativeTextLength ) ?? 0;
372
373 while ( ( match = regex.exec( nodeText ) ) !== null ) {
374 const occurrenceCount = occurrenceCounts[ occurrenceKey ];
375 occurrenceCount.encountered++;
376 blockOffsetCounter++;
377
378 if ( occurrenceCount.encountered - 1 === link.offset && occurrenceCount.linked < 1 ) {
379 occurrenceCount.linked++;
380
381 // Skip if the link is inside a non-allowed block.
382 if ( ! ALLOWED_BLOCKS.includes( block.name ) ) {
383 // eslint-disable-next-line no-console
384 console.warn( `PCH Smart Linking: Skipping non-allowed block (${ block.name }):`, link.text );
385 return;
386 }
387
388 // Skip if the node is inside a heading or a caption.
389 if ( isInsideHeadingOrCaption( node ) ) {
390 // eslint-disable-next-line no-console
391 console.warn( `PCH Smart Linking: Skipping heading or caption:`, link.text );
392 return;
393 }
394
395 link.match = {
396 blockId: block.clientId,
397 blockOffset: blockOffsetCounter - 1,
398 blockPosition: currentBlockIndex,
399 blockLinkPosition: startPosition + match.index,
400 };
401 }
402 }
403
404 cumulativeTextLength += nodeText.length;
405 } );
406 } );
407 } );
408
409 return links;
410 }
411
412 /**
413 * Gets all smart links in the post content.
414 *
415 * This function parses the post content to find all the smart links in the post
416 * content. Each smart link is identified by the `data-smartlink` attribute in
417 * the anchor tag.
418 *
419 * After finding all the smart links, it calculates the correct block and offset
420 * for each link suggestion.
421 *
422 * @since 3.16.0
423 *
424 * @return {SmartLink[]} The smart links in the post content.
425 */
426 export function getAllSmartLinksInPost(): SmartLink[] {
427 const blocks = flattenBlocks( select( 'core/block-editor' ).getBlocks() );
428 const postContent = select( 'core/editor' ).getEditedPostContent();
429 const parser = new DOMParser();
430 const doc = parser.parseFromString( postContent, 'text/html' );
431 const allLinks = Array.from( doc.querySelectorAll( 'a[data-smartlink]' ) as NodeListOf<HTMLAnchorElement> );
432 const smartLinks: SmartLink[] = [];
433
434 allLinks.forEach( ( link ) => {
435 const uid = link.getAttribute( 'data-smartlink' ) ?? '';
436 const href = {
437 raw: removeITMParamsFromURL( link.href ),
438 itm: addSmartLinkITMParamsToURL( link.href, uid ),
439 };
440 const text = link.textContent ?? '';
441 const title = link.title;
442
443 // Find the block this link belongs to.
444 const block = blocks.find(
445 ( blockInstance ) => getBlockContent( blockInstance ).includes( uid )
446 );
447
448 if ( ! block ) {
449 return;
450 }
451
452 const blockIndex = blocks.indexOf( block );
453
454 const smartLink: SmartLink = {
455 uid,
456 smart_link_id: 0,
457 href,
458 text,
459 title,
460 status: 'applied',
461 applied: true,
462 offset: getLinkOffset( link, doc ),
463 match: {
464 blockId: block?.clientId ?? '',
465 blockPosition: blockIndex,
466 blockOffset: -1, // Will be calculated later.
467 blockLinkPosition: -1, // Will be calculated later.
468 },
469 };
470
471 smartLinks.push( smartLink );
472 } );
473
474 return calculateSmartLinkingMatches( blocks, smartLinks );
475 }
476
477 /**
478 * Gets the offset of a link in the post content.
479 *
480 * This function calculates the offset of a link in the post content by counting
481 * the number of times the link text is encountered before the link in the post
482 * content.
483 *
484 * @since 3.16.0
485 *
486 * @param {HTMLAnchorElement} link The link to calculate the offset for.
487 * @param {Document} document The document to search the link in.
488 *
489 * @return {number} The offset of the link in the post content.
490 */
491 function getLinkOffset( link: HTMLAnchorElement, document: Document ): number {
492 const smartLinkUID = link.dataset.smartlink;
493 const linkText = link.textContent?.trim();
494
495 if ( ! smartLinkUID ) {
496 return -1;
497 }
498 if ( ! linkText ) {
499 return -1;
500 }
501
502 let occurrence = 0;
503
504 const treeWalker = document.createTreeWalker( document.body, NodeFilter.SHOW_TEXT );
505 while ( treeWalker.nextNode() ) {
506 const textNode = treeWalker.currentNode as Text;
507 const nodeValue = textNode.nodeValue ?? '';
508 let pos = nodeValue.indexOf( linkText );
509
510 while ( pos !== -1 ) {
511 if ( isLinkAtNode( textNode, smartLinkUID ) ) {
512 return occurrence;
513 }
514
515 // Move to next occurrence of linkText in the current text node.
516 pos = nodeValue.indexOf( linkText, pos + linkText.length );
517 occurrence++;
518 }
519 }
520
521 return -1;
522 }
523
524 /**
525 * Represents the return type of the `validateAndFixSmartLinks` function.
526 *
527 * @since 3.16.0
528 */
529 type ValidateAndFixSmartLinksReturnType = {
530 missingSmartLinks: SmartLink[],
531 didAnyFixes: boolean
532 };
533
534 /**
535 * Validates and fixes smart links in a specific content.
536 *
537 * This function checks if the smart links in the store are still present in the
538 * post content. If a smart link is not found in the post content, it tries to
539 * find a link that matches the text, title and href of the smart link. And if
540 * the link is found, it restores the missing fields from the link
541 * (data-smartlink and title).
542 *
543 * @since 3.16.0
544 *
545 * @param {string} content The post content to validate and fix smart links.
546 * @param {string|false} blockId The block ID to filter the smart links by.
547 *
548 * @return {ValidateAndFixSmartLinksReturnType} The missing smart links and whether any fixes were made.
549 */
550 export async function validateAndFixSmartLinks(
551 content: string,
552 blockId: string|false = false
553 ): Promise<ValidateAndFixSmartLinksReturnType> {
554 // Get the post content and all the smart links from the store.
555 let smartLinks = select( SmartLinkingStore ).getSmartLinks();
556
557 // If a blockId is provided, filter out all the smart links that don't belong to the block.
558 if ( blockId ) {
559 smartLinks = smartLinks.filter( ( smartLink ) => smartLink.match?.blockId === blockId );
560 }
561
562 // Create a DOM from the post content.
563 const parser = new DOMParser();
564 const doc = parser.parseFromString( content, 'text/html' );
565
566 const missingSmartLinks: SmartLink[] = [];
567
568 // Check for each smart link UID in the post content.
569 smartLinks.forEach( ( smartLink ) => {
570 // Search for the link with the UID in the post content.
571 const link = doc.querySelector( `a[data-smartlink="${ smartLink.uid }"]` );
572
573 // If the link is not found, add it to the missing smart links array.
574 if ( ! link ) {
575 missingSmartLinks.push( smartLink );
576 }
577 } );
578
579 // Flag to check if any fixes were made.
580 let didAnyFixes = false;
581
582 // For each missing smart link, try to find a link that matches the text, title and href.
583 for ( let i = 0; i < missingSmartLinks.length; i++ ) {
584 const missingSmartLink = missingSmartLinks[ i ];
585 if ( ! missingSmartLink.match?.blockId ) {
586 continue;
587 }
588
589 // Get the block that contains the smart link.
590 const block = select( 'core/block-editor' ).getBlock( missingSmartLink.match?.blockId );
591
592 if ( ! block ) {
593 continue;
594 }
595
596 const blockContent: string = getBlockContent( block );
597 const blockDoc = new DOMParser().parseFromString( blockContent, 'text/html' );
598
599 const link = Array.from( blockDoc.querySelectorAll( 'a' ) ).find( ( a ) => {
600 return a.textContent === missingSmartLink.text &&
601 a.href.includes( missingSmartLink.href.raw ) &&
602 ! a.hasAttribute( 'data-smartlink' );
603 } );
604
605 if ( ! link ) {
606 continue;
607 }
608
609 // If the link is found, remove it from the missing smart links array.
610 missingSmartLinks.splice( i--, 1 );
611
612 // Restore the missing fields from the link (data-smartlink and title).
613 link.setAttribute( 'data-smartlink', missingSmartLink.uid );
614 link.setAttribute( 'href', missingSmartLink.href.itm );
615 link.title = missingSmartLink.title;
616
617 // Update the block content with the new content.
618 const paragraph = blockDoc.body.firstChild as HTMLElement;
619 dispatchCoreBlockEditor.updateBlockAttributes(
620 block.clientId, { content: paragraph.innerHTML }
621 );
622 didAnyFixes = true;
623 }
624
625 return { missingSmartLinks, didAnyFixes };
626 }
627
628 /**
629 * Validates and fixes smart links in the post content.
630 *
631 * @since 3.16.0
632 *
633 * @return {boolean} Whether any fixes were made.
634 */
635 export async function validateAndFixSmartLinksInPost(): Promise<boolean> {
636 const postContent = select( 'core/editor' ).getEditedPostContent();
637 const { missingSmartLinks, didAnyFixes } = await validateAndFixSmartLinks( postContent );
638
639 // Remove any missing smart links that are not in the store.
640 missingSmartLinks.forEach( ( missingLink ) => {
641 dispatch( SmartLinkingStore ).removeSmartLink( missingLink.uid );
642 } );
643
644 return didAnyFixes;
645 }
646
647 /**
648 * Validates and fixes smart links in a block content.
649 *
650 * @since 3.16.0
651 *
652 * @param {BlockInstance} block The block instance to validate and fix smart links.
653 *
654 * @return {boolean} Whether any fixes were made.
655 */
656 export async function validateAndFixSmartLinksInBlock( block: BlockInstance ): Promise<boolean> {
657 const blockContent: string = getBlockContent( block );
658 const { missingSmartLinks, didAnyFixes } = await validateAndFixSmartLinks( blockContent, block.clientId );
659
660 // Remove any missing smart links that are not in the store.
661 missingSmartLinks.forEach( ( missingLink ) => {
662 dispatch( SmartLinkingStore ).removeSmartLink( missingLink.uid );
663 } );
664
665 return didAnyFixes;
666 }
667
668 /**
669 * Selects a smart link in the block content.
670 *
671 * This function sets focus to the link element, selects the link text, and
672 * scrolls the viewport to the link element.
673 *
674 * @since 3.16.0
675 * @since 3.23.6 Select the whole link, including any nested markup.
676 *
677 * @param {HTMLElement|null} blockContent The block content to select the smart link in.
678 * @param {string} smartLinkValue The smart link value to select.
679 */
680 export const selectSmartLink = ( blockContent: HTMLElement | null, smartLinkValue: string ): void => {
681 if ( ! blockContent ) {
682 return;
683 }
684
685 const linkElement = blockContent.querySelector(
686 `a[data-smartlink="${ smartLinkValue }"]`,
687 ) as HTMLElement;
688
689 if ( linkElement ) {
690 // Set focus to the link element.
691 linkElement.focus();
692
693 // Select the link.
694 const ownerDocument = blockContent.ownerDocument;
695 const range = ownerDocument.createRange();
696 if ( linkElement.firstChild ) {
697 // Covers every child, so a link holding nested markup is selected in
698 // full rather than up to its first child.
699 range.selectNodeContents( linkElement );
700 const sel = ownerDocument.getSelection();
701 if ( sel ) {
702 sel.removeAllRanges();
703 sel.addRange( range );
704 }
705 }
706
707 // Scroll the viewport to the link element.
708 linkElement.scrollIntoView( { behavior: 'smooth', block: 'center' } );
709 }
710 };
711
712 /**
713 * Selects a smart link, reapplying the selection until it sticks.
714 *
715 * The Editor resets the selection while it finishes initializing, which
716 * discards a selection made as soon as the link renders.
717 *
718 * @since 3.23.6
719 *
720 * @param {Document} canvasDocument The document holding the smart link.
721 * @param {string} smartLinkValue The smart link value to select.
722 * @param {number} attempts The maximum number of attempts.
723 */
724 export const selectSmartLinkWhenReady = async (
725 canvasDocument: Document,
726 smartLinkValue: string,
727 attempts: number = 10
728 ): Promise<void> => {
729 const selector = `a[data-smartlink="${ smartLinkValue }"]`;
730
731 for ( let attempt = 0; attempt < attempts; attempt++ ) {
732 selectSmartLink( canvasDocument.body, smartLinkValue );
733
734 await new Promise( ( resolve ) => setTimeout( resolve, 300 ) );
735
736 const linkElement = canvasDocument.querySelector( selector );
737 if ( linkElement && canvasDocument.getSelection()?.toString() === linkElement.textContent ) {
738 return;
739 }
740 }
741 };
742
743 /**
744 * Trims a URL for display, ensuring it fits within the specified maximum length.
745 *
746 * @since 3.16.0
747 *
748 * @param {string} url The URL to trim.
749 * @param {number} maxLength The maximum length of the URL.
750 *
751 * @return {string} The trimmed URL.
752 */
753 export function trimURLForDisplay( url: string, maxLength: number ): string {
754 // Remove protocol (http, https) and www.
755 const strippedUrl = url.replace( /(^\w+:|^)\/\//, '' ).replace( /^www\./, '' );
756
757 // If no maxLength is specified or the URL length is already less than
758 // maxLength, return the stripped URL.
759 if ( ! maxLength || strippedUrl.length <= maxLength ) {
760 return strippedUrl;
761 }
762
763 // Get the domain name and path from the URL
764 const domain = strippedUrl.split( '/' )[ 0 ];
765 const path = strippedUrl.substring( domain.length );
766
767 // Update the maxLength to account for the domain name, plus a slash.
768 maxLength -= domain.length;
769
770 // Calculate part lengths for trimming.
771 const partLength = Math.floor( ( maxLength - 3 ) / 2 );
772 const start = path.substring( 0, partLength );
773 const end = path.substring( path.length - partLength );
774
775 return `${ domain }${ start }...${ end }`;
776 }
777
778 /**
779 * Gets all the URLs from an array of smart links.
780 *
781 * @since 3.16.0
782 *
783 * @param {SmartLink[]} smartLinks The smart links to get the URLs from.
784 *
785 * @return {string[]} The URLs from the smart links.
786 */
787 export function getAllSmartLinksURLs( smartLinks: SmartLink[] ): string[] {
788 return smartLinks.map( ( link ) => link.href.raw );
789 }
790
791 /**
792 * Adds ITM parameters to a smart link URL.
793 *
794 * @since 3.19.0
795 *
796 * @param {string} url The URL to add ITM parameters to.
797 * @param {string} smartLinkUid The smart link UID to add ITM parameters for.
798 *
799 * @return {string} The URL with ITM parameters added.
800 */
801 export function addSmartLinkITMParamsToURL( url: string, smartLinkUid: string ): string {
802 return addITMParamsToURL( url, {
803 campaign: 'wp-parsely',
804 medium: 'smart-link',
805 source: 'smart-linking',
806 term: smartLinkUid,
807 } );
808 }
809