| 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 |
|