import { FAQ_HEADINGS } from '../analysis/analyzer'; export const FAQ_BLOCK = 'recomaze/faq'; export interface FaqPair { question: string; answer: string; } export interface BlockLike { name: string; attributes: Record; } const asked = (question: string): string => question.trim().toLowerCase().replace(/\s+/g, ' '); /** * A FAQ written for a page that already has one joins it: its questions go * into the page's first FAQ block, the ones it already asks left out, and * the new block goes away with the heading made for it. */ export const mergeNewFaqs = ( blocks: T[], made: Set ): T[] => { const at = blocks.findIndex( block => block.name === FAQ_BLOCK && !made.has(block) ); if (at === -1) { return blocks; } const questions = [ ...((blocks[at].attributes.questions as FaqPair[] | undefined) ?? []), ]; const seen = new Set(questions.map(pair => asked(pair.question))); blocks.forEach(block => { if (!made.has(block) || block.name !== FAQ_BLOCK) { return; } ((block.attributes.questions as FaqPair[] | undefined) ?? []).forEach( pair => { if (!seen.has(asked(pair.question))) { seen.add(asked(pair.question)); questions.push(pair); } } ); }); return blocks .map((block, index) => index === at ? { ...block, attributes: { ...block.attributes, questions } } : block ) .filter(block => !made.has(block)); }; const escapeHtml = (value: string): string => value.replace(/&/g, '&').replace(//g, '>'); /** Question and answer pairs as the agent's rewrites write them. */ const pairsHtml = (faq: FaqPair[]): string => faq .map( pair => `

${escapeHtml(pair.question)}

${escapeHtml(pair.answer)}

` ) .join(''); /** The FAQ as the agent's rewrites write it, which the editor turns into the FAQ block. */ export const faqHtml = (faq: FaqPair[]): string => `

FAQ

${pairsHtml(faq)}
`; /** * Add a written FAQ to HTML content (the classic editor, a product * description). A page that has a FAQ section already gets the new * questions inside it, the ones it asks left out, instead of a second FAQ * under the first; without one the FAQ goes at the end. The content is only * rewritten when there is a section to join. * * @param {string} content - The content as the editor holds it. * @param {FaqPair[]} faq - The questions just written. * @returns {string} The content with the FAQ in it. */ export const addFaqToHtml = (content: string, faq: FaqPair[]): string => { const doc: Document = new DOMParser().parseFromString( `${content}`, 'text/html' ); const section: Element | null = doc.body.querySelector('div.section-faq'); // A page written by hand has its questions under a heading that names // them ("Frequently asked questions"): they join that part of the page, // which runs to the next heading of its level. const heading = section ? null : (Array.from(doc.body.querySelectorAll('h2')).find(element => FAQ_HEADINGS.test(element.textContent ?? '') ) ?? null); if (!section && !heading) { return `${content}\n${faqHtml(faq)}`; } const part: Element[] = []; for ( let next = heading?.nextElementSibling ?? null; next && next.tagName !== 'H2'; next = next.nextElementSibling ) { part.push(next); } const asks = section ? Array.from(section.querySelectorAll('h3, h4')) : part.flatMap(element => element.matches('h3, h4') ? [element] : Array.from(element.querySelectorAll('h3, h4')) ); const seen = new Set(asks.map(ask => asked(ask.textContent ?? ''))); const fresh = faq.filter(pair => { const key = asked(pair.question); if (seen.has(key)) return false; seen.add(key); return true; }); if (fresh.length === 0) { return content; } if (section) { section.insertAdjacentHTML('beforeend', pairsHtml(fresh)); } else { (part[part.length - 1] ?? heading)?.insertAdjacentHTML( 'afterend', pairsHtml(fresh) ); } return doc.body.innerHTML; };