1/** 2 * Chatbot Message Formatter 3 * Converts Markdown text to HTML with support for: 4 * - Inline formatting (bold, italic, links) 5 * - Lists (unordered and ordered) 6 * - Tables 7 * - Headings 8 * 9 * Dependencies: jQuery, DOMPurify 10 */ 11 12// dashoefer.de ist die tatsächliche Kunden-Domain, an die der Bot Links 13// liefert (fest verdrahtet, siehe auch seminarfinder.js) - unabhaengig davon, 14// unter welcher Umgebung frontend-main selbst gerade laeuft (localhost, 15// *.dashofer.local, ...). Ein reiner window.location.origin-Vergleich wuerde 16// diese Links ausserhalb der echten Produktions-Domain immer als "extern" 17// behandeln und nie Parameter anhaengen. 18function isInternalChatbotLink(parsedUrl) { 19 if (parsedUrl.origin === window.location.origin) return true; 20 return parsedUrl.hostname === 'dashoefer.de' 21 || parsedUrl.hostname === 'www.dashoefer.de' 22 || parsedUrl.hostname.endsWith('.dashoefer.de'); 23} 24 25/** 26 * Ergaenzt den vollen collect_url_params-Satz (templates/macros.html.twig) an 27 * Links aus Bot-Antworten, analog zu normalen Website-Links. Nur fuer Links 28 * auf dashoefer.de bzw. derselben Origin, damit externe Quellen-Links aus dem 29 * Bot nicht mit internen Tracking-Parametern versehen werden. 30 * @param {string} url - Rohe URL aus der Bot-Antwort 31 * @returns {string} URL mit ergaenzten Parametern (oder unveraendert bei externen/relativen Sonderfaellen) 32 */ 33function appendChatbotLinkParams(url) { 34 if (!url || url === '#' || typeof window.getChatbotLinkParams !== 'function') return url; 35 36 let parsed; 37 try { 38 parsed = new URL(url, window.location.origin); 39 } catch (e) { 40 return url; 41 } 42 if (!isInternalChatbotLink(parsed)) return url; 43 44 const additionalParams = window.getChatbotLinkParams(); 45 if (!additionalParams) return url; 46 47 const extraParams = new URLSearchParams(additionalParams.replace(/^\?/, '')); 48 extraParams.forEach((value, key) => parsed.searchParams.set(key, value)); 49 50 // Origin mit ausgeben - bei absoluten dashoefer.de-Links (andere Origin 51 // als die aktuelle frontend-main-Umgebung) wuerde sonst nur der Pfad
52 // zurueckkommen und der Link faelschlich auf die aktuelle Domain zeigen. 53 return parsed.origin + parsed.pathname + '?' + parsed.searchParams.toString() + parsed.hash; 54} 55 56/** 57 * Format inline Markdown syntax (links, bold, italic) 58 * @param {string} text - Text to format 59 * @returns {string} Formatted HTML text 60 */ 61function formatInlineMarkdown(text) { 62 if (!text) return ''; 63 64 // Vom Bot in Anfuehrungszeichen genannte Seminar-/Produkttitel im 65 // Fliesstext (ohne Link) hervorheben - haeufigstes beobachtetes Muster 66 // fuer Titel ausserhalb von Markdown-Links (z.B. 'das Seminar "XYZ" 67 // ...', siehe manuelle API-Tests). Muss VOR der Link-Verarbeitung 68 // laufen, sonst wuerden auch Anfuehrungszeichen im href-Attribut des 69 // erzeugten <a>-Tags erfasst. Anfuehrungszeichen selbst bleiben stehen, 70 // nur der Inhalt wird als **bold**-Markdown markiert (siehe Bold-Schritt 71 // unten). 72 text = text.replace(/"([^"\n]{3,120})"/g, (match, inner) => `"**${inner}**"`); 73 text = text.replace(/â([^â\n]{3,120})"/g, (match, inner) => `â**${inner}**"`); 74 75 // Drittes beobachtetes Titel-Muster: Label-Zeilen wie "Seminartitel: ViDA 76 // â die Umsatzsteuerreform 2027" (analog zum bereits vorhandenen "Link: 77 // ..."-Muster). Nur ganze Zeilen, die EXAKT mit dem Label beginnen - 78 // response_to_html() ruft formatInlineMarkdown() pro Zeile/Listeneintrag 79 // auf, daher reichen Anfang/Ende-Anker ohne Multiline-Flag. 80 text = text.replace(/^(Seminartitel|Titel)(\s*:\s*)(.+)$/i, (match, label, sep, title) => `${label}${sep}**${title}**`); 81 82 // Format links: [text](url) 83 text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, linkText, linkUrl) => { 84 const sanitizedUrl = DOMPurify.sanitize(`<a href="${linkUrl}"></a>`); 85 const tempDiv = document.createElement('div'); 86 tempDiv.innerHTML = sanitizedUrl; 87 let safeUrl = tempDiv.querySelector('a')?.getAttribute('href') || '#'; 88 safeUrl = appendChatbotLinkParams(safeUrl); 89 const escapedText = $('<div></div>').text(linkText).html(); 90 91 // Der Linktext ist in der Praxis (fast) immer der Seminar-/Produkt- 92 // titel (siehe manuelle API-Tests) - bei internen Dashoefer-Links 93 // daher zusaetzlich fett hervorheben, analog zu den in 94 // Anfuehrungszeichen genannten Titeln oben. 95 let isInternal = false; 96 try { 97 isInternal = isInternalChatbotLink(new URL(safeUrl, window.location.origin)); 98 } catch (e) { /* relative/ungueltige URL - keine Hervorhebung */ } 99 const linkContent = isInternal ? `<strong>${escapedText}</strong>` : escapedText; 100 101 return `<a href="${safeUrl}" target="_blank" class="text-primary text-decoration-underline">${linkContent}</a>`; 102 }); 103 104 // Format bold: **text** 105 text = text.replace(/\*\*([^*]+)\*\*/g, (match, boldText) => { 106 const escapedText = $('<div></div>').text(boldText).html(); 107 return `<strong>${escapedText}</strong>`; 108 }); 109 110 // FIX: Italic-Regex erlaubt jetzt auch Italic am Zeilenanfang/-ende 111 // Original-Regex verlangte ein Zeichen VOR und NACH *text*, was Italic 112 // am Anfang eines Satzes oder einer Listenzeile komplett verhinderte. 113 text = text.replace(/(?<!\*)\*(?!\*)([^*\n]+?)\*(?!\*)/g, (match, italicText) => { 114 const escapedText = $('<div></div>').text(italicText).html(); 115 return `<em>${escapedText}</em>`; 116 }); 117 118 return text; 119} 120 121/** 122 * Convert markdown-style text response to HTML 123 * Supports headings, lists, tables, and inline markdown 124 * @param {string} text - Raw text response to convert 125 * @returns {string} Sanitized HTML string 126 */ 127function response_to_html(text) { 128 let html;
129 text = text.replace(/\\n/g, '\n'); 130 html = text.split('\n'); 131 132 let markup = $('<div></div>'); 133 let node_nr = 0; 134 135 if (text != '') { 136 for (let i = 0; i < html.length; i++) { 137 if (html[i].trim().length == 0) continue; 138 139 node_nr++; 140 let prev_line = (i > 0) ? markup.find('#response_line_' + (node_nr - 1))[0] : false; 141 let rawText = html[i].trim(); 142 143 // --- LISTEN (ul / ol) --- 144 if (rawText.indexOf('- ') == 0 || rawText.match(/^\d+\.\s/) != null) { 145 let listtype = rawText.startsWith('- ') ? 'ul' : 'ol'; 146 147 let li_text_raw = rawText; 148 if (listtype == 'ul') li_text_raw = li_text_raw.slice(2); 149 else if (listtype == 'ol') li_text_raw = li_text_raw.replace(/^\d+\.\s/, ''); 150 151 let li_content = formatInlineMarkdown(li_text_raw); 152 let li_template = '<li id="response_line_' + node_nr + '" data-listtype="' + listtype + '">' + li_content + '</li>'; 153 154 let prevIsList = prev_line && $(prev_line)[0].nodeName.toLowerCase() == 'li'; 155 let prevListType = prevIsList ? $(prev_line).attr('data-listtype') : null; 156 157 if (!prevIsList || prevListType != listtype) { 158 let startAttr = ''; 159 if (listtype === 'ol') { 160 const matchNumber = rawText.match(/^(\d+)\./); 161 if (matchNumber && matchNumber[1]) { 162 startAttr = ' start="' + matchNumber[1] + '"'; 163 } 164 } 165 markup.append($('<' + listtype + startAttr + ' class="mb-2">' + li_template + '</' + listtype + '>')); 166 } else { 167 let currentList = $(prev_line).parent(); 168 currentList.append($(li_template)); 169 } 170 } 171 // --- TABELLEN --- 172 else if (rawText.startsWith('|')) { 173 // FIX: Trennzeile robuster erkennen (z.B. | --- | :---: | --: |) 174 if (/^\|[\s\-:|]+\|[\s\-:|]*$/.test(rawText)) { 175 continue; 176 } 177 178 let row = $('<tr id="response_line_' + node_nr + '"></tr>'); 179 let isHeader = (!prev_line || ($(prev_line)[0].nodeName.toLowerCase() != 'tr')); 180 let html_edit = rawText.split('|'); 181 let startIdx = (html_edit[0].trim() === "") ? 1 : 0; 182 let endIdx = (html_edit[html_edit.length-1].trim() === "") ? html_edit.length - 1 : html_edit.length; 183 184 for (let j = startIdx; j < endIdx; j++) { 185 let cellText = html_edit[j] ? html_edit[j].trim() : ""; 186 let cellTag = isHeader ? 'th' : 'td'; 187 row.append($('<' + cellTag + ' id="response_line_' + node_nr + '_' + j + '">' + formatInlineMarkdown(cellText) + '</' + cellTag + '>')); 188 } 189 190 if (isHeader) {
191 let table_container = $('<div class="table-responsive mb-2"></div>'); 192 let new_table = $('<table class="table table-bordered mb-0"></table>'); 193 let thead = $('<thead></thead>').append(row); 194 new_table.append(thead); 195 new_table.append('<tbody></tbody>'); 196 table_container.append(new_table); 197 markup.append(table_container); 198 } else { 199 let currentTableBody = $(prev_line).closest('table').find('tbody'); 200 if (currentTableBody.length > 0) { 201 currentTableBody.append(row); 202 } else { 203 $(prev_line).after(row); 204 } 205 } 206 } 207 // --- ÃBERSCHRIFTEN & TEXT --- 208 else { 209 let tagName = 'p'; 210 let className = ''; 211 // FIX: slice() statt replace() â replace('#####', '') entfernte nur das 212 // ERSTE Vorkommen und konnte '#' mitten im Text treffen. slice() ist präzise. 213 if (rawText.startsWith('#####')) { tagName = 'h5'; rawText = rawText.slice(5).trim(); className = 'h5 text-primary vd_1rem fw-bold'; } 214 else if (rawText.startsWith('####')) { tagName = 'h4'; rawText = rawText.slice(4).trim(); className = 'h4 text-primary vd_125rem fw-bold'; } 215 else if (rawText.startsWith('###')) { tagName = 'h3'; rawText = rawText.slice(3).trim(); className = 'h3 text-primary vd_15rem fw-bold'; } 216 else if (rawText.startsWith('##')) { tagName = 'h2'; rawText = rawText.slice(2).trim(); className = 'h2 text-primary vd_175rem fw-bold'; } 217 else if (rawText.startsWith('#')) { tagName = 'h1'; rawText = rawText.slice(1).trim(); className = 'h1 text-primary vd_2rem fw-bold'; } 218 else if (rawText === '---' || rawText === '***') { 219 markup.append($('<hr class="my-2 border-top border-secondary opacity-25">')); 220 continue; 221 } 222 223 let parsedText = formatInlineMarkdown(rawText.trim()); 224 markup.append($('<' + tagName + ' id="response_line_' + node_nr + '" class="px-2 py-1 ' + className + '">' + parsedText + '</' + tagName + '>')); 225 } 226 } 227 } else { 228 markup = '<p></p>'; 229 } 230 231 const unsafeHtml = $(markup).html(); 232 const safeHtml = DOMPurify.sanitize(unsafeHtml); 233 return safeHtml; 234} 235 236/** 237 * Format bot response and insert into message element 238 * @param {string} text - Raw bot response text 239 * @param {HTMLElement} messageElement - Element to insert formatted HTML into 240 */ 241function formatBotMessage(text, messageElement) { 242 if (!messageElement) return; 243 244 const formattedHtml = response_to_html(text); 245 messageElement.innerHTML = formattedHtml; 246} 247 248// Export for use in other scripts 249if (typeof module !== 'undefined' && module.exports) { 250 module.exports = { 251 formatInlineMarkdown, 252 response_to_html, 253 formatBotMessage 254 }; 255}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.