diff --git a/src/connector.js b/src/connector.js
index d02d847..5b4c506 100644
--- a/src/connector.js
+++ b/src/connector.js
@@ -6,6 +6,8 @@ import {
buildPager,
buildResultsPerPage,
buildSearchStatus,
+ buildSmartSnippet,
+ buildSmartSnippetQuestionsList,
buildUrlManager,
buildDidYouMean,
buildContext,
@@ -35,6 +37,8 @@ const defaults = {
"numberOfSuggestions": 5,
"minimumCharsForSuggestions": 3,
"enableHistoryPush": true,
+ "enableSmartSnippets": false,
+ "smartSnippetToggleLimit": 250,
"isContextSearch": false,
"isAdvancedSearch": false,
"originLevel3": originPath,
@@ -58,6 +62,8 @@ let resultListController;
let querySummaryController;
let didYouMeanController;
let pagerController;
+let smartSnippetController;
+let smartSnippetQuestionListController;
let statusController;
let urlManager;
let unsubscribeManager;
@@ -66,6 +72,8 @@ let unsubscribeResultListController;
let unsubscribeQuerySummaryController;
let unsubscribeDidYouMeanController;
let unsubscribePagerController;
+let unsubscribeSmartSnippetController;
+let unsubscribeSmartSnippetQuestionListController;
// UI states
let updateSearchBoxFromState = false;
@@ -74,6 +82,8 @@ let resultListState;
let querySummaryState;
let didYouMeanState;
let pagerState;
+let smartSnippetState;
+let smartSnippetQuestionListState;
let lastCharKeyUp;
let activeSuggestion = 0;
let pagerManuallyCleared = false;
@@ -92,6 +102,8 @@ let querySummaryElement = document.querySelector( '#query-summary' );
let pagerElement = document.querySelector( '#pager' );
let suggestionsElement = document.querySelector( '#suggestions' );
let didYouMeanElement = document.querySelector( '#did-you-mean' );
+let smartSnippetsElement = document.querySelector( '#smart-snippet' );
+let smartSnippetQuestionListContainerElement = document.querySelector( '#smart-snippet-question-list' );
// UI templates
let resultTemplateHTML = document.getElementById( 'sr-single' )?.innerHTML;
@@ -105,6 +117,9 @@ let pageTemplateHTML = document.getElementById( 'sr-pager-page' )?.innerHTML;
let nextPageTemplateHTML = document.getElementById( 'sr-pager-next' )?.innerHTML;
let pagerContainerTemplateHTML = document.getElementById( 'sr-pager-container' )?.innerHTML;
let qsA11yHintHTML = document.getElementById( 'sr-qs-hint' )?.innerHTML;
+let smartSnippetHTML = document.getElementById( 'sr-smart-snippet-container' )?.innerHTML;
+let smartSnippetQuestionListHTML = document.getElementById( 'sr-smart-snippet-question-list-container' )?.innerHTML;
+let smartSnippetQuestionListContainerHTML = document.getElementById( 'sr-smart-snippet-question-list-container' )?.innerHTML;
// Init parameters and UI
function initSearchUI() {
@@ -374,6 +389,76 @@ function initTpl() {
resultsSection.append( querySummaryElement );
}
+ // Smart snippet - Featured SS
+ if ( params.enableSmartSnippets && !smartSnippetHTML ) {
+ smartSnippetHTML =
+ `
+
+
%[question]
+
+
+
+ %[answer]
+
%[smart_snippet_answer_ai_disclaimer]
+
+
+ %[answer_truncated]
+
+
+
+
+
+
+
+
- %[source.raw.displaynavlabel]
+
+
`;
+
+ // Localize
+ if ( lang === "fr" ) {
+ smartSnippetHTML = smartSnippetHTML.replace( '%[smart_snippet_answer_ai_disclaimer]', "Information récupérée en utilisant l'intelligence artificielle." );
+ smartSnippetHTML = smartSnippetHTML.replace( '%[smart_snippet_toggle_more]', "Afficher plus" );
+ } else {
+ smartSnippetHTML = smartSnippetHTML.replace( '%[smart_snippet_answer_ai_disclaimer]', 'Information retrieved by artificial intelligence.' );
+ smartSnippetHTML = smartSnippetHTML.replace( '%[smart_snippet_toggle_more]', "Show more" );
+ }
+ }
+
+ // Smart snippet - Question list container
+ if ( params.enableSmartSnippets && !smartSnippetQuestionListContainerHTML ) {
+ smartSnippetQuestionListContainerHTML =
+ ``;
+
+ // Localize
+ if ( lang === "fr" ) {
+ smartSnippetQuestionListContainerHTML = smartSnippetQuestionListContainerHTML.replace( '%[smart_snippet_question_list_title]', 'Les gens demandent aussi' );
+ } else {
+ smartSnippetQuestionListContainerHTML = smartSnippetQuestionListContainerHTML.replace( '%[smart_snippet_question_list_title]', 'People also ask' );
+ }
+ }
+
+ // Smart snippets - Featured SS
+ if ( params.enableSmartSnippets && !smartSnippetsElement ) {
+ smartSnippetsElement = document.createElement( "div" );
+ smartSnippetsElement.id = "smart-snippets";
+
+ resultsSection.append( smartSnippetsElement );
+ }
+
// auto-create did you mean element
if ( !didYouMeanElement ) {
didYouMeanElement = document.createElement( "div" );
@@ -400,6 +485,43 @@ function initTpl() {
pagerElement = newPagerElement;
}
+
+ // Smart snippet - Question list item
+ if( params.enableSmartSnippets && !smartSnippetQuestionListHTML ) {
+ smartSnippetQuestionListHTML =
+ `
+
+ %[question]
+
+
+ %[answer]
+
%[smart_snippet_answer_ai_disclaimer]
+
+
+
+
- %[source.raw.displaynavlabel]
+
+
+
+ `;
+
+ // Localize
+ if ( lang === "fr" ) {
+ smartSnippetQuestionListHTML = smartSnippetQuestionListHTML.replace( '%[smart_snippet_answer_ai_disclaimer]', "Information récupérée en utilisant l'intelligence artificielle." );
+ } else {
+ smartSnippetQuestionListHTML = smartSnippetQuestionListHTML.replace( '%[smart_snippet_answer_ai_disclaimer]', 'Information retrieved by artificial intelligence.' );
+ }
+ }
+
+ // Smart snippets - Questions list container
+ if ( params.enableSmartSnippets && !smartSnippetQuestionListContainerElement ) {
+ smartSnippetQuestionListContainerElement = document.createElement( "div" );
+ smartSnippetQuestionListContainerElement.id = "smart-snippets-question-list";
+
+ // Add it after the results list element (after the results, before the paging)
+ resultListElement.after( smartSnippetQuestionListContainerElement );
+ }
+
// initialize the search box
searchBoxElement = document.querySelector( params.searchBoxQuery );
@@ -512,6 +634,97 @@ function stripHtml(html) {
return tmp.textContent || tmp.innerText || "";
}
+// Calculates the length of the text for a block of HTML
+function getTextLength( content ){
+ var elem;
+
+ // If a string is passed in, convert it to an element
+ if( !( content instanceof Element ) ){
+ elem = document.createElement( 'div' );
+ elem.innerHTML = String( content );
+ } else {
+ elem = content;
+ }
+
+ // Get the inside content
+ var fullText = elem.textContent || '';
+
+ // Strip out extra whitespace, like indenting
+ fullText = fullText.replace( /[\n\r]+|[\s]{2,}/g, ' ' ).trim();
+ return fullText.length;
+
+}
+
+// Truncate an HTML string to a given text length, preserving tag structure.
+function truncateHtml( html, maxLength ) {
+
+ // Put into a temp div element, so we can work with it
+ const container = document.createElement( "div" );
+ container.innerHTML = html;
+
+ // If content is less than maxLength, return it as-is
+ if ( maxLength < 0 || getTextLength( container ) <= maxLength ) {
+ return html;
+ }
+
+ let remaining = maxLength;
+
+ // Recursive function that goes through the HTML tree, rebuilding it to the
+ // point where we reach `maxLength`
+ function cloneWithLimit( node ) {
+ if ( remaining <= 0 ) return null;
+
+ // If this node is just text, we're at the deepest point of this part of the tree.
+ // If we're below the limit, return as-is. If we hit the limit, truncate here and add the ellipsis.
+ if ( node.nodeType === Node.TEXT_NODE ) {
+ const text = node.nodeValue || '';
+ if ( text.length <= remaining ) {
+ remaining -= text.length;
+ return document.createTextNode( text );
+ } else {
+ const truncatedText = text.slice( 0, remaining ) + '…';
+ remaining = 0;
+ return document.createTextNode( truncatedText );
+ }
+ }
+
+ // If it's a tag, we go inside and recursively iterate through the children until we hit the length limit
+ if ( node.nodeType === Node.ELEMENT_NODE ) {
+ // Create a copy of the current tag
+ const clone = node.cloneNode( false );
+
+ // Iterate through the children of the original node
+ for ( let child of node.childNodes ) {
+ if ( remaining <= 0 ) break; // If we hit the limit, stop here.
+ const childClone = cloneWithLimit( child );
+ if ( childClone ) clone.appendChild( childClone );
+ }
+
+ // Drop empty elements (except self-closing ones)
+ if ( !clone.hasChildNodes() && !['BR', 'IMG'].includes( clone.tagName ) ) {
+ return null;
+ }
+ return clone;
+ }
+
+ // Drop comments and other node types
+ return null;
+ }
+
+ // Build a truncated copy of the HTML structure
+ const truncatedHtml = document.createDocumentFragment();
+ for ( let child of container.childNodes ) {
+ if ( remaining <= 0 ) break;
+ const chunk = cloneWithLimit( child );
+ if ( chunk ) truncatedHtml.appendChild( chunk );
+ }
+
+ // Serialize back to HTML
+ const wrapper = document.createElement( 'div' );
+ wrapper.appendChild( truncatedHtml );
+ return wrapper.innerHTML;
+}
+
// Focus to H2 heading in results section
function focusToView() {
let focusElement = resultsSection.querySelector( "h2" );
@@ -658,6 +871,11 @@ function initEngine() {
didYouMeanController = buildDidYouMean( headlessEngine, { options: { automaticallyCorrectQuery: params.automaticallyCorrectQuery } } );
pagerController = buildPager( headlessEngine, { options: { numberOfPages: params.numberOfPages } } );
statusController = buildSearchStatus( headlessEngine );
+
+ if( params.enableSmartSnippets ){
+ smartSnippetController = buildSmartSnippet( headlessEngine );
+ smartSnippetQuestionListController = buildSmartSnippetQuestionsList( headlessEngine );
+ }
// Refine search based on URL parameters for filters, mostly used in Advanced Search to trigger only one search per page load
if ( urlParams.allq || urlParams.exctq || urlParams.anyq || urlParams.noneq || urlParams.fqupdate || urlParams.dmn || urlParams.fqocct || urlParams.elctn_cat || urlParams.filetype || urlParams.site || urlParams.year || urlParams.declaredtype || urlParams.startdate || urlParams.enddate || urlParams.dprtmnt ) {
@@ -892,6 +1110,10 @@ function initEngine() {
unsubscribeQuerySummaryController = querySummaryController.subscribe( () => updateQuerySummaryState( querySummaryController.state ) );
unsubscribeDidYouMeanController = didYouMeanController.subscribe( () => updateDidYouMeanState( didYouMeanController.state ) );
unsubscribePagerController = pagerController.subscribe( () => updatePagerState( pagerController.state ) );
+ if( params.enableSmartSnippets ) {
+ unsubscribeSmartSnippetController = smartSnippetController.subscribe( () => updateSmartSnippetState( smartSnippetController.state ) );
+ unsubscribeSmartSnippetQuestionListController = smartSnippetQuestionListController.subscribe( () => updateSmartSnippetQuestionListState( smartSnippetQuestionListController.state ) );
+ }
// Clear event tracking, for legacy browsers
const onUnload = () => {
@@ -902,6 +1124,8 @@ function initEngine() {
unsubscribeQuerySummaryController?.();
unsubscribeDidYouMeanController?.();
unsubscribePagerController?.();
+ unsubscribeSmartSnippetController?.();
+ unsubscribeSmartSnippetQuestionListController?.();
};
// Listen to URL change (hash)
@@ -990,6 +1214,14 @@ function initEngine() {
didYouMeanElement.textContent = "";
pagerElement.textContent = "";
pagerManuallyCleared = true;
+ if( params.enableSmartSnippets ) {
+ if( smartSnippetsElement && smartSnippetsElement.textContent ) {
+ smartSnippetsElement.textContent = "";
+ }
+ if( smartSnippetQuestionListContainerElement && smartSnippetQuestionListContainerElement.textContent ) {
+ smartSnippetQuestionListContainerElement.textContent = "";
+ }
+ }
// Show no results message in Query Summary if no query entered
querySummaryElement.innerHTML = noResultTemplateHTML;
@@ -1411,5 +1643,107 @@ function updatePagerUrlParam( currentPage ) {
window.history.replaceState( {}, '', `${winPath}?${newSearch}${winLoc.hash}` );
}
+// Function in insert values into smart snippet HTML templates
+function insertSmartSnippetValues ( smartSnippetState, standalone = false, truncateLimit = -1) {
+ const { question, answer, source } = smartSnippetState;
+
+ var snippetHTML = (standalone ? smartSnippetHTML : smartSnippetQuestionListHTML);
+ snippetHTML = snippetHTML
+ .replace( '%[question]', DOMPurify.sanitize( question ) )
+ .replace( '%[answer]', DOMPurify.sanitize( answer ) )
+ .replace( '%[answer_truncated]', truncateHtml( DOMPurify.sanitize( answer ), truncateLimit ) );
+
+ if(source) {
+ var displaynavlabel = source?.raw?.displaynavlabel ? source.raw.displaynavlabel.split( '>' ).join( ' ' ) : source.uri;
+ snippetHTML = snippetHTML.replace( '%[source.raw.displaynavlabel]', displaynavlabel )
+ .split( '%[source.title]' ).join ( source.title )
+ .split( '%[source.uri]' ).join ( source.uri );
+ }
+
+ return snippetHTML;
+}
+
+// Update the "featured" Smart Snippets section
+function updateSmartSnippetState ( newState ) {
+
+ smartSnippetState = newState;
+ smartSnippetsElement.innerHTML = ''; // Clear contents of SM
+
+ // We don't get the full smart snippet state past the first page, so don't render anything
+ if( pagerState.currentPage > 1 ) return;
+
+ if( smartSnippetState.answerFound ) {
+ smartSnippetsElement.innerHTML = insertSmartSnippetValues( smartSnippetState, true, params.smartSnippetToggleLimit );
+
+ // If the length of the answer is less that params.smartSnippetToggleLimit, remove toggle controls
+ if( getTextLength( smartSnippetState.answer ) <= params.smartSnippetToggleLimit ) {
+
+ document.querySelector( '.smart-snippet-toggle-height' ).remove();
+ document.querySelector( '.smart-snippet-answer-truncated' ).remove();
+
+ } else {
+
+ // Add height toggle stuff
+ const smartSnippetsContainerElement = document.getElementById( 'smart-snippet-container' );
+ smartSnippetsContainerElement.classList.add( 'smart-snippet-height-limiter' ); // Collapse by default
+ const smartSnippetToggleButton = document.getElementById( 'smart-snippet-toggle' );
+ const smartSnippetAnswer = document.getElementById( 'smart-snippet-answer' );
+ smartSnippetAnswer.querySelectorAll( "a, link, button, input" ).forEach( ( el ) => {
+ el.setAttribute( 'disabled', 'true' );
+ el.setAttribute( 'tabindex', '-1' );
+ } );
+
+ // Handle the
+ smartSnippetToggleButton.addEventListener( 'click', () => {
+
+ // Expand the container
+ if(smartSnippetsContainerElement.classList.contains( 'smart-snippet-height-limiter' )){
+ document.querySelector( '.smart-snippet-toggle-height' ).remove();
+ smartSnippetsContainerElement.classList.remove( 'smart-snippet-height-limiter' );
+ smartSnippetAnswer.setAttribute( "aria-hidden", "false" );
+ smartSnippetAnswer.querySelectorAll( "a, link, button, input" ).forEach( ( el ) => {
+ el.removeAttribute( 'disabled' );
+ el.removeAttribute( 'tabindex' );
+ } );
+
+ // Collapse the container
+ } else {
+ smartSnippetsContainerElement.classList.add( 'smart-snippet-height-limiter' );
+ smartSnippetAnswer.setAttribute( "aria-hidden", "true" );
+ smartSnippetToggleButton.setAttribute( "aria-expanded", "false" );
+ smartSnippetAnswer.querySelectorAll( "a, link, button, input" ).forEach( ( el ) => {
+ el.setAttribute( 'disabled', 'true' );
+ el.setAttribute( 'tabindex', '-1' );
+ } );
+ smartSnippetToggleButton.querySelector( '#smart-snippet-toggle-label' ).innerText = lang === "fr" ? "Afficher plus": "Show more";
+ smartSnippetToggleButton.querySelector( '#smart-snippet-toggle-icon' ).classList.add( 'glyphicon-chevron-down' );
+ smartSnippetToggleButton.querySelector( '#smart-snippet-toggle-icon' ).classList.remove( 'glyphicon-chevron-up' );
+ smartSnippetToggleButton.focus();
+ }
+ } );
+
+ }
+ }
+}
+
+// Update the Smart Snippets questions section
+function updateSmartSnippetQuestionListState ( newState ) {
+ smartSnippetQuestionListState = newState;
+ smartSnippetQuestionListContainerElement.innerHTML = ''; // Clear contents of SS question list container
+
+ // We don't get the full smart snippet state past the first page, so don't render anything
+ if( pagerState.currentPage > 1 ) return;
+
+ // If there are questions, populate smartSnippetQuestionListItemsHTML
+ if( smartSnippetQuestionListState?.questions && smartSnippetQuestionListState?.questions.length > 0 ) {
+ let smartSnippetQuestionListItemsHTML = '';
+ for ( const i in smartSnippetQuestionListState.questions ) {
+ smartSnippetQuestionListItemsHTML += insertSmartSnippetValues( smartSnippetQuestionListState.questions[i], false );
+ }
+ smartSnippetQuestionListContainerElement.innerHTML = smartSnippetQuestionListContainerHTML.split( '%[smart_snippet_question_list]' ).join( smartSnippetQuestionListItemsHTML );
+ }
+
+}
+
// Run Search UI
initSearchUI();