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 = ` `; } } 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); } }${cleanCodeBlock}