PageSourceSearch

https://www.dashoefer.de/main/assets/js/chatbot-formatter-a1f8bcb1e5bb30a3b51171e1f09882c9.js

js dashoefer.de collected 2026-10-02 06:55:02 UTC 11,972 bytes, 255 lines download raw bytes

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.