Source: engine/ur/Transformation.js

(function() {
    /** Text transformation/format handling for UR harmonization */
    Help4.engine.ur.Transformation = class {
        // demo content:
        // '<HTML> <HEAD> <META http-equiv="Content-Type" content="text/html; charset=utf-16le"> <TITLE>CDUSERNAME</TITLE> </HEAD> <BODY> <H3>Definition</H3> <P>USER name for which change documents should be displayed.</P> </BODY> </HTML>'
        static format(html) {
            // ATTENTION: if "Help4.removeXSSCode" is ever changed - also review and adopt here!
            // it was not used because it does not remove script content

            const docuLinks = [];
            if (!html) return {content: '', docuLinks};   // don't add invalid HTML error comment in this case

            // sanity check
            if (html.match(/<head>/gi)?.length !== 1 ||
                html.match(/<\/head>/gi)?.length !== 1 ||
                html.match(/<body>/gi)?.length !== 1 ||
                html.match(/<\/body>/gi)?.length !== 1)
            {
                return {content: '<!-- Error: Received invalid HTML - Wrong number of head or body elements -->'};
            }

            // separate head and body, so head content (e.g. div) doesn't end up in body when parsing
            const m = html.match(/<head>(?<head>.*)<\/head>.*?<body>(?<body>.*?)<\/body>/is);
            const d = document.implementation.createHTMLDocument();
            d.head.innerHTML = m?.groups?.head || '';
            d.body.innerHTML = m?.groups?.body || '';

            // link, meta, and title will be removed at the end by only returning body
            // removing all icons (svg) until we can render them properly
            d.querySelectorAll('script, iframe, embed')
            .forEach(e => e.remove());

            // remove inline event handlers
            d.querySelectorAll('*')
            .forEach(e => {
                for (const {name} of e.attributes) {
                    if (name.startsWith('on')) e.setAttribute(name, '');
                }
            });

            d.querySelectorAll('style')
            .forEach(s => {
                // convert to inline
                // allowing list-style-type (e.g. none) because of an OL with inconsistent numbering
                // the numbering was done in text, not from the LI entries
                // extend here if other styles are allowed
                for (const {selectorText, style} of s?.sheet?.cssRules) {
                    const t = style['list-style-type'];
                    if (!t) continue;

                    d.body.querySelectorAll(selectorText)
                    .forEach(e => {
                        Help4.extendObject(e.style, {'list-style-type': t});
                    });
                }

                // keeping this here for future reference:
                // if there is no access to style.sheet while detached from document, we need to examine textContent
                // potentially multiple matches with capture groups per match
                // const all = style.textContent?.matchAll(/([^\{\}]+?)\s?{(?:[^;]+;)?\s?(list-style-type.*?;)[^\}]?}/g) || [];
                // for (const [value, selector] of all) {
                //     style.parentElement.querySelectorAll(selector)
                //     .forEach(e => {
                //         const existing = e.getAttribute('style') || '';  // keep existing inline styles
                //         e.setAttribute('style', value + existing);
                //     });
                // }

                s.remove();
            });

            //svg
            // WebGUI example:
            // <svg data-sap-ls-svg-inline="true"
            //      data-sap-ls-svg-inlinehtmlexchange="true"
            //      id="M0:46:::19:2-icon"
            //      focusable="false"
            //      aria-hidden="true"
            //      preserveAspectRatio="none"
            //      viewBox="0 0 100 100"
            //      className="lsSvgAppIcon urSvgAppIconVAlign urSvgAppIconColorBase urSvgAppIconMetric lsAbapList--image">
            //          <use xlink:href="#_SAPGUI-icons_1_s_b_nocr"></use>
            // </svg>
            // original horizon theme fill - #1d2d3e !important

            const {
                engine: {ur: {UrHarmonizationEngine: {ICON_LIB_ID}}},
                Element
            } = Help4;
            const _toLibIconId = id => `${ICON_LIB_ID}-${id}`;

            d.querySelectorAll('svg')
            .forEach((svg) => {
                // Help4.Element.setAttribute doesn't support focusable, etc.
                svg.setAttribute('focusable', 'false');
                svg.setAttribute('aria-hidden', 'true');
                svg.setAttribute('preserveAspectRatio', 'none');
                svg.setAttribute('viewBox', "0 0 100 100");
                Element.addClass(svg, 'sap-ui-icons');

                const use = document.createElement('use');
                use.setAttribute('href','#' + _toLibIconId(svg.id));
                svg.removeAttribute('id');  // HTML element ids should be unique
                svg.append(use);
            });

            // links
            // - remove internal anchor links #, href doesn't start with # when accessed from the element
            // - a.href is uri# not just # somehow
            d.querySelectorAll('a[href*="#"]')
            .forEach(a => a.replaceWith(...a.childNodes));

            // - handle DOCU_LINK, remove other links
            d.querySelectorAll('a')
            .forEach(a => {
                // XRAY-5003 remove links
                // remove script, potential XSS
                // XRAY-4837 special handling for DOCU_LINK
                const key = a.href.match(/^sapevent:docu_link\(([\w.]+)\)$/i)?.[1];
                if (key) {
                    /** convert to A[data-docu-link] to be handled by _handleLinkClick in {@link Help4.control2.bubble.content.Html.onEvent}*/
                    docuLinks.push(key);
                    a.dataset.docuLink = key;
                    a.href = `javascript:ctx.cfg_show('#',false)`;  // href is required for link underline
                } else if (/^(?:sapevent|javascript):/i.test(a.href)) {
                    a.replaceWith(...a.childNodes);
                } else {
                    a.href = `javascript:ctx.cfg_show('${a.href}',true,null)`;
                }
            });

            // XRAY-4855: adopt formats
            // - deprecated: TT, CENTER

            // - italic
            d.querySelectorAll('i, em, samp')
            .forEach(el => {
                const span = document.createElement('span');
                span.className = 'span_italic';
                span.innerText = el.innerText;
                el.replaceWith(span);
            });

            // - monospace
            d.querySelectorAll('code, tt')
            .forEach(el => {
                const span = document.createElement('span');
                span.className = 'codeph';
                span.innerText = el.innerText;
                el.replaceWith(span);
            });

            // - table
            d.querySelectorAll('table')
            .forEach(el => el.classList.add('BorderTable'));

            // suggest bubble size based on paragraph length
            let size = 's';
            if (!d.querySelector('[data-bubble-size]')) {
                let maxLength = 0;
                for (const p of d.querySelectorAll('p')) {
                    if (p.innerText.length > maxLength) maxLength = p.innerText.length;
                }
                if (maxLength > 150) size = 'm';
                if (maxLength > 200) size = 'l';
            }


            return {content: d.body?.innerHTML || '', docuLinks, size};
        }

        /**
         * Reduce HTML heading elements to h(maximum level) and below; smaller numbers mean bigger headings;
         * h2 > h3 > h4 > h5 > h6;
         * this is a second level processing step, assuming input html is valid;
         * maxLevel is the largest allowed heading level in the output html
         * @param {string} html
         * @param {number} maxLevel
         * @return {string}
         */
        static reduceHeadingLevels(html, maxLevel) {
            if (!html) return html;

            const d = document.implementation.createHTMLDocument();
            d.body.innerHTML = html;

            // find first heading element
            let firstHeading;
            for (let k = 1; k < 7; k++) {
                firstHeading = d.querySelector('h' + k);
                if (firstHeading) break;
            }
            if (!firstHeading) return html;

            // determine how many levels to reduce if at all; do not increase levels
            const firstLevel = Number(firstHeading.nodeName.at(-1));
            const step = maxLevel - firstLevel;
            if (step <= 0) return html;

            // apply change for each heading
            for (let i = 6, level; i >= firstLevel; i--) {
                level = Math.min(i + step, 6);
                d.querySelectorAll(`h${i}`)
                .forEach(h => {
                    h.outerHTML = `<h${level}>${h.innerHTML}</h${level}>`;
                });
            }

            return d.body.innerHTML;
        }
    }
})();