/** * External dependencies */ import type { SuggestionGenerationActionInfo, SuggestionGenerationEditableElement, SuggestionGenerationFormInfo, SuggestionGenerationRequest, } from '@nab/types'; /** * Internal dependencies */ import { getCssPathInDocument } from '../../assets/src/admin/scripts/css-selector-finder/css-path'; import { getInnerContentEditableElements, isContentEditableElement, } from '../../assets/src/admin/scripts/css-selector-finder/editable-content'; export async function extractRelevantDomFromUrl( url: string ): Promise< SuggestionGenerationRequest > { const targetUrl = new URL( url, window.location.href ); if ( targetUrl.origin !== window.location.origin ) { throw new Error( 'URL must belong to the current origin.' ); } const html = await fetch( targetUrl.href, { credentials: 'same-origin', } ).then( ( response ) => { if ( ! response.ok ) { throw new Error( `Unable to fetch page: ${ response.status }` ); } return response.text(); } ); const doc = new DOMParser().parseFromString( html, 'text/html' ); cleanupDocument( doc ); const root = doc.querySelector( 'main' ) || doc.querySelector( '[role="main"]' ) || doc.body; return { url: targetUrl.href, title: cleanText( doc.title ), language: doc.documentElement.lang || '', pageSummary: extractPageSummary( root ), editableElements: extractEditableElements( doc, root ), linksAndButtons: extractActions( root ), forms: extractForms( root ), }; } function cleanupDocument( doc: Document ): void { doc.querySelectorAll( 'script, style, noscript, svg, iframe, template, link, meta' ).forEach( ( el ) => el.remove() ); } function extractPageSummary( root: Element ): string { const lines: string[] = []; visit( root ); return lines .map( ( line ) => ( line.startsWith( '#' ) ? `\n${ line }` : line ) ) .join( '\n' ) .replace( /\n\n\n*/g, '\n\n' ) .trim(); function visit( node: Element ): void { if ( ! isRelevantElement( node ) ) { return; } switch ( node.tagName.toLowerCase() ) { case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6': { const level = Number( node.tagName[ 1 ] ); const text = cleanText( node.textContent || '' ); if ( text ) { lines.push( `${ '#'.repeat( level ) } ${ text }` ); lines.push( '' ); } return; } case 'p': { const text = cleanText( node.textContent || '' ); if ( text ) { lines.push( text ); lines.push( '' ); } return; } case 'ul': case 'ol': { for ( const li of node.children ) { if ( li.tagName.toLowerCase() !== 'li' ) { continue; } const text = cleanText( li.textContent || '' ); if ( text ) { lines.push( `- ${ text }` ); } } lines.push( '' ); return; } case 'a': case 'button': { const text = cleanText( node.textContent || '' ); if ( text ) { lines.push( `[${ text }]` ); } return; } case 'img': { const alt = cleanText( ( node as HTMLImageElement ).alt || '' ); if ( alt ) { lines.push( `![${ alt }]` ); } return; } } for ( const child of node.children ) { visit( child ); } } } function extractEditableElements( doc: Document, root: HTMLElement ): ReadonlyArray< SuggestionGenerationEditableElement > { return getInnerContentEditableElements( root ) .filter( ( el ) => el.nodeName !== 'IMG' && !! el.innerHTML.trim() ) .filter( isRelevantElement ) .map( ( el ): SuggestionGenerationEditableElement => ( { selector: getCssPathInDocument( doc, el ) ?? '', html: el.innerHTML.trim(), } ) ); } function extractActions( root: Element ): SuggestionGenerationActionInfo[] { return Array.from( root.querySelectorAll( 'a, button, input[type="button"], input[type="submit"]' ) ) .filter( isRelevantElement ) .filter( isContentEditableElement ) .map( ( el ) => { const input = el as HTMLInputElement; const anchor = el as HTMLAnchorElement; const label = el instanceof HTMLInputElement ? input.value : el.textContent || ''; return { tag: el.tagName.toLowerCase(), selector: getCssPathInDocument( el.ownerDocument, el ), label: cleanText( label ), href: el instanceof HTMLAnchorElement ? anchor.href : undefined, type: el instanceof HTMLInputElement ? input.type : undefined, }; } ) .filter( ( item ) => !! item.label && item.label.length <= 100 ) .slice( 0, 40 ); } function extractForms( root: Element ): SuggestionGenerationFormInfo[] { return Array.from( root.querySelectorAll( 'form' ) ) .filter( isRelevantElement ) .map( ( form ) => ( { selector: getCssPathInDocument( form.ownerDocument, form ), fields: Array.from( form.querySelectorAll( 'input, select, textarea' ) ) .map( getFieldLabel ) .filter( Boolean ), submitButtons: extractActions( form ).filter( ( action ) => action.tag === 'button' || action.type === 'submit' ), } ) ); } function getFieldLabel( field: Element ): string { const input = field as HTMLInputElement; const id = input.id; const label = ( id && field.ownerDocument.querySelector( `label[for="${ cssEscape( id ) }"]` )?.textContent ) || input.getAttribute( 'aria-label' ) || input.getAttribute( 'placeholder' ) || input.name || input.type || ''; return cleanText( label ); } function isRelevantElement( el: Element ): el is HTMLElement { const htmlEl = el as HTMLElement; if ( htmlEl.hidden ) { return false; } if ( htmlEl.getAttribute( 'aria-hidden' ) === 'true' ) { return false; } return true; } function cleanText( text: string ): string { return text.replace( /\s+/g, ' ' ).trim(); } function cssEscape( value: string ): string { return window.CSS?.escape ? window.CSS.escape( value ) : value.replace( /[^a-zA-Z0-9_-]/g, '\\$&' ); }