import { LightningElement, api } from "lwc"; import { CodeBlockTheme, CodeBlockLanguage } from "typings/custom"; import cx from "classnames"; import { track as gtmTrack } from "dxUtils/analytics"; import { highlightCode } from "dxUtils/shiki"; import { normalizeBoolean } from "dxUtils/normalizers"; /* Custom language support is handled by the Shiki wrapper module which maps proprietary languages to supported ones: - AMPScript -> HTML (markup-like syntax) - SSJS -> JavaScript - GTL -> Handlebars - LWC -> JavaScript/JSX - SQL Documentation -> SQL */ // Used for remove enclosing
 tag's (if occurs)
const preTagRegexp: RegExp = /^(.*)<\/pre>$/is;

export const LIGHT = "light";
export const DARK = "dark";
export const EVENT_NAME = "dx-codeblock-theme";
const THEMES = [LIGHT, DARK];

export default class CodeBlock extends LightningElement {
    @api defaultTheme: CodeBlockTheme = LIGHT;
    @api header: string = "";
    @api sourceLink: string = "";
    @api language: string = "";
    // if it is true, it renders code as is coming instead of wrapping it into html/xml comments tags.
    @api isEncoded = false;

    private _codeBlockRendered: boolean = false;
    private _showLoadingIndicator: boolean = false;
    private _isCbHeightCalculated: boolean = false;
    private _plainCodeForCopy: string = "";
    private markupLangs = ["visualforce", "html", "xml"];
    private componentLoaded: boolean = false;
    private showLanguageDropdown: boolean = false;
    private copyBtnText: string = "Click to copy";
    private theme: CodeBlockTheme = LIGHT;
    private selectedLanguageLabel: string = "";
    private selectedLanguageId: string = "";
    private selectedLanguage: CodeBlockLanguage = { label: "Language", id: "" };
    private allLanguages: CodeBlockLanguage[] = [
        { label: "Language", id: "" },
        { label: "Apex", id: "apex" },
        { label: "Java", id: "java" },
        { label: "Javascript", id: "javascript" },
        { label: "Javascript", id: "js" },
        { label: "CSS", id: "css" },
        { label: "Visualforce", id: "visualforce" },
        { label: "Html", id: "html" },
        { label: "xml", id: "xml" },
        { label: "TypeScript", id: "typescript" },
        { label: "TypeScript", id: "ts" },
        { label: "PHP", id: "php" },
        { label: "JSON", id: "json" },
        { label: "Swift", id: "swift" },
        { label: "Kotlin", id: "kotlin" },
        { label: "Python", id: "python" },
        { label: "Bash", id: "bash" },
        { label: "Shell", id: "shell" },
        { label: "Shell", id: "sh" },
        { label: "SQL", id: "sql" },
        { label: "YAML", id: "yaml" },
        { label: "YAML", id: "yml" },
        { label: "Markdown", id: "markdown" },
        { label: "Markdown", id: "md" },
        { label: "JSX", id: "jsx" },
        { label: "GraphQL", id: "graphql" },
        { label: "DataWeave", id: "dataweave" },
        { label: "AMPScript", id: "ampscript" },
        { label: "SSJS", id: "ssjs" },
        { label: "GTL", id: "gtl" },
        { label: "LWC", id: "lwc" },
        { label: "SQL Documentation", id: "sql_docs_template" },
        { label: "Agent Script", id: "agentscript" }
    ];

    _codeBlock: string = "";
    @api
    get codeBlock(): string {
        return this._codeBlock;
    }
    set codeBlock(value: string) {
        this._codeBlockRendered = false;
        this._isCbHeightCalculated = false;
        let match;
        this._codeBlock = (
            (match = preTagRegexp.exec(value.trim())) === null
                ? value.trim()
                : match[1]
        ).trim();
    }

    @api
    get showLoadingIndicator() {
        return this._showLoadingIndicator;
    }

    set showLoadingIndicator(value) {
        this._showLoadingIndicator = normalizeBoolean(value);
    }

    get updateThemeBtnText(): string {
        return `Switch to ${this.theme === DARK ? "Light" : "Dark"} mode`;
    }

    get showCodeSourceBtn(): boolean {
        return !!this.sourceLink;
    }

    get codeblocksClassname(): string {
        return cx(`dx-theme-${this.theme}`);
    }

    get toolbarIconColor(): string {
        return this.theme === DARK ? "white" : "blue-vibrant-20";
    }

    get showLoaderUI(): boolean {
        return (
            this.showLoadingIndicator &&
            this._isCbHeightCalculated &&
            !this._codeBlockRendered
        );
    }

    private calculateContainerHeight(): number | null {
        const computedStyle = window.getComputedStyle(this.template.host);
        const customHeight = computedStyle.getPropertyValue(
            "--dx-code-block-height"
        );

        // If custom height is set, use it
        if (
            customHeight &&
            customHeight !== "unset" &&
            !customHeight.includes("auto")
        ) {
            return parseFloat(customHeight);
        }

        if (!this.codeBlock) {
            return null;
        }

        // If no custom height is set, calculate from number of lines + padding
        const lines = this.codeBlock.split("\n").length;

        const codeVerticalPadding =
            (parseFloat(computedStyle.getPropertyValue("--dx-g-text-sm")) ||
                14) * 2;
        const codeLineHeight =
            parseFloat(computedStyle.getPropertyValue("--dx-g-spacing-mlg")) ||
            20;

        return lines * codeLineHeight + codeVerticalPadding;
    }

    private setupLoadingIndicator(): void {
        if (!this.showLoadingIndicator) {
            return;
        }
        const containerHeight = this.calculateContainerHeight();
        if (containerHeight) {
            const contentContainer = this.template.querySelector(
                ".code-block-content"
            ) as HTMLElement;
            if (contentContainer) {
                contentContainer.style.height = `${containerHeight}px`;
            }
            this._isCbHeightCalculated = true;
        }
    }

    private initializeTheme() {
        window.addEventListener(EVENT_NAME, this.toggleTheme);
        const darkModeSetting = localStorage.getItem(
            EVENT_NAME
        ) as CodeBlockTheme;
        if (THEMES.includes(darkModeSetting)) {
            this.theme = darkModeSetting;
        } else {
            this.theme = this.defaultTheme;
        }
    }

    connectedCallback() {
        this.initializeTheme();
    }

    async formatCodeBlock() {
        // Show loading indicator immediately
        this.setupLoadingIndicator();

        const divEl = this.template.querySelector("div.code-block-content");
        const templateEl = document.createElement("template");

        // Decode HTML entities if the code is already encoded
        let cleanCodeBlock = this.codeBlock;
        if (this.isEncoded) {
            const tempElement = document.createElement("div");
            // eslint-disable-next-line @lwc/lwc/no-inner-html
            tempElement.innerHTML = cleanCodeBlock;
            cleanCodeBlock =
                tempElement.textContent ||
                tempElement.innerHTML ||
                cleanCodeBlock;
        }

        // Replace any  markup with a temporary nonsense sentinel (but one that is very
        // unlikely to affect Shiki's tokenization) so that Shiki will not strip them but does
        // still tokenize correctly. We want to italicize the "variables" ourselves after Shiki
        // does its own thing (W-11975205).
        cleanCodeBlock = cleanCodeBlock.replace(
            /(.+?)<\/var>/g,
            "vvvvv$1vvvvv"
        );

        // For markup languages, preserve existing HTML comments but don't wrap the whole thing in comments
        if (
            !this.isEncoded &&
            this.markupLangs.includes(this.selectedLanguage.id || "")
        ) {
            // Temporarily replace HTML comment characters to preserve them during highlighting
            cleanCodeBlock = cleanCodeBlock.replace(
                //gs,
                "@@COMMENT_START@@$1@@COMMENT_END@@"
            );
        }

        // Determine the language to use for highlighting
        const languageForHighlighting = this.markupLangs.includes(
            this.selectedLanguage.id || ""
        )
            ? "html"
            : this.selectedLanguage.id || "text";

        try {
            // Use Shiki to highlight the code with current theme
            const highlightedHtml = await highlightCode(
                cleanCodeBlock,
                languageForHighlighting,
                this.theme as "light" | "dark"
            );

            const tempDiv = document.createElement("div");
            // eslint-disable-next-line
            tempDiv.innerHTML = highlightedHtml;
            const preElement = tempDiv.querySelector("pre");

            if (preElement) {
                preElement.classList.add("codeblock", languageForHighlighting);

                // Italicize anything marked as a "variable" by the docs team
                // eslint-disable-next-line
                preElement.innerHTML = preElement.innerHTML.replace(
                    /vvvvv(.+?)vvvvv/g,
                    "$1"
                );
                // eslint-disable-next-line
                templateEl.innerHTML = preElement.outerHTML;
            } else {
                // eslint-disable-next-line
                templateEl.innerHTML = `
${cleanCodeBlock}
`; } } catch (error) { console.error( "Shiki highlighting failed, falling back to plain text:", error ); // eslint-disable-next-line templateEl.innerHTML = `
${cleanCodeBlock}
`; } if (divEl) { // eslint-disable-next-line divEl.innerHTML = ""; divEl.append(templateEl.content); if (this.markupLangs.includes(this.selectedLanguage.id || "")) { const res = this.template.querySelector(`code`); if (res) { // Restore any temporarily replaced HTML comment characters // eslint-disable-next-line res.innerHTML = res.innerHTML.replace( /@@COMMENT_START@@(.*?)@@COMMENT_END@@/gs, `<!--$1-->` ); } } // Get the plain code for copy functionality const codeElement = this.template.querySelector("code"); this._plainCodeForCopy = codeElement?.textContent?.trim() ?? ""; // Add line numbers to each line this.addLineNumbers(); } this._codeBlockRendered = true; } onLanguageChange(newLang: any) { this.selectedLanguage = this.allLanguages.find( (lang: CodeBlockLanguage) => lang.id === newLang.detail ) || this.allLanguages[0]; this.selectedLanguageLabel = this.selectedLanguage.label; this.selectedLanguageId = this.selectedLanguage.id; this.formatCodeBlock(); gtmTrack(newLang, "custEv_linkClick", { event: "custEv_linkClick", click_text: newLang.detail, element_title: "dx-dropdown", click_url: this.selectedLanguageId, element_type: "link", content_category: "code block" }); } /** * Note: There is a known issue in storybook@6.1.13 where some apis are not available if you access Storybook through IP address. * https://github.com/storybookjs/storybook/issues/13529 */ async copySource(event: Event) { gtmTrack(event.target!, "custEv_iconClick", { click_text: this.copyBtnText, element_type: "icon", element_title: this.copyBtnText, content_category: "code block" }); try { if (this._plainCodeForCopy) { await navigator.clipboard.writeText(this._plainCodeForCopy); this.copyBtnText = "Copied!"; setTimeout(() => { this.copyBtnText = "Click to copy"; }, 2000); } } catch (error) { console.error(error); } } updateThemeClicked(event: Event) { gtmTrack(event.target!, "custEv_iconClick", { click_text: this.updateThemeBtnText, element_type: "icon", element_title: this.updateThemeBtnText, content_category: "code block" }); window.dispatchEvent(new Event(EVENT_NAME)); } private toggleTheme = () => { this.theme = this.theme === DARK ? LIGHT : DARK; this.storeTheme(); // Re-render the code block with the new theme this.formatCodeBlock().catch((error) => { console.error( "Failed to re-format code block after theme change:", error ); }); }; private storeTheme = () => { localStorage.setItem(EVENT_NAME, this.theme); }; private addLineNumbers(): void { const codeElement = this.template.querySelector("code"); if (!codeElement) { return; } // Get the HTML content and split by newlines // eslint-disable-next-line const htmlContent = codeElement.innerHTML; const lines = htmlContent.split(/\n/); // Filter out empty lines at the end while (lines.length > 0 && !lines[lines.length - 1].trim()) { lines.pop(); } // Rebuild HTML with line numbers const numberedLines = lines.map((line, index) => { const lineNumber = index + 1; return `${lineNumber}${line}`; }); // eslint-disable-next-line codeElement.innerHTML = numberedLines.join("\n"); } renderedCallback() { if (this._codeBlockRendered) { return; } this.selectedLanguage = this.allLanguages.find( (l: CodeBlockLanguage) => l.id === this.language ) || this.allLanguages[0]; this.selectedLanguageLabel = this.selectedLanguage.label; this.selectedLanguageId = this.selectedLanguage.id; this.formatCodeBlock(); } disconnectedCallback(): void { window.removeEventListener(EVENT_NAME, this.toggleTheme); } }