// SPDX-FileCopyrightText: 2025-2026 Alpin Insight Solutions GmbH & Co. KG // SPDX-License-Identifier: AGPL-3.0-only /** * Code block component for Insight UI. * * Transforms code elements into styled code blocks with syntax highlighting (via Prism.js), * a toolbar showing language and optional filename, and a copy-to-clipboard button. * Automatically removes common leading indentation for cleaner display. * * @example * // HTML structure (before initialization) *
 *   const greeting = "Hello, World!";
 *   console.log(greeting);
 * 
* * // JavaScript * CodeBlock.initAll(); */ export class CodeBlock { /** @type {WeakMap} Weak references to prevent multiple initialization */ static instances = new WeakMap(); /** * Creates a new CodeBlock instance. * Replaces the original element with a styled code block component. * * @param {HTMLElement} originalElement - The pre/code element with data-insight-code-block attribute */ constructor(originalElement) { // If an instance for this element already exists, return it if (CodeBlock.instances.has(originalElement)) { return CodeBlock.instances.get(originalElement); } this.id = originalElement.id; this.lang = originalElement.getAttribute('data-insight-code-block'); this.filename = originalElement.getAttribute('data-filename') || ""; this.code = originalElement.textContent; this.copyButton = undefined; this.copyEvent = undefined; this.element = this.generateCodeBlock(this.id, this.lang, this.filename, this.code); this.originalElement = originalElement originalElement.replaceWith(this.element); this.element.__insightInstance = this; CodeBlock.instances.set(this.originalElement, this); debugLog("New code block created: ", this.element); } /** * Remove common leading indentation from code. * * Finds the minimum indentation across all non-empty lines and removes * that amount from every line. This ensures code displays flush-left * regardless of how it was indented in the HTML template. * * @param {string} code The code to clean the indentation from. * @returns {string} The cleaned code. */ cleanIndentation(code) { const lines = code.split('\n'); // Find minimum indentation across all non-empty lines let minIndent = Infinity; for (const line of lines) { // Skip empty or whitespace-only lines if (line.trim().length === 0) continue; const indentMatch = line.match(/^\s*/); const indentLength = indentMatch ? indentMatch[0].length : 0; minIndent = Math.min(minIndent, indentLength); } // If no non-empty lines found or no indentation, return as-is if (minIndent === Infinity || minIndent === 0) { return code; } // Remove the minimum indent from all lines const cleanedLines = lines.map(line => line.slice(minIndent)); return cleanedLines.join('\n'); } /** * Create the toolbar with language/filename info and copy button. * * @param {string} lang The language of the code. * @param {string} filename An optional filename. * @returns {HTMLElement} The toolbar element. */ #createToolbar(lang, filename) { const toolbar = document.createElement('div'); toolbar.classList.add('flex', 'justify-between', 'bg-insight-raised', 'rounded-t-insight-surface', 'p-2'); const infobox = document.createElement('div'); infobox.classList.add('flex'); const langSpan = document.createElement('span'); langSpan.classList.add('text-insight-body', 'leading-loose', 'bg-insight-base', 'rounded-insight-control', 'px-2'); langSpan.textContent = lang; infobox.appendChild(langSpan); if (filename) { const fileSpan = document.createElement('span'); fileSpan.classList.add('text-insight-body', 'leading-loose', 'bg-insight-base', 'rounded-insight-control', 'px-2', 'ms-2'); fileSpan.textContent = filename; infobox.appendChild(fileSpan); } toolbar.appendChild(infobox); return toolbar; } /** * Create the copy button with icon. * * @returns {{button: HTMLButtonElement, icon: SVGElement}} The button and its icon. */ #createCopyButton() { const button = document.createElement('button'); button.classList.add('btn', 'btn-secondary', 'btn-s'); const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('fill', 'currentColor'); svg.setAttribute('aria-hidden', 'true'); svg.classList.add('size-5'); const path1 = document.createElementNS('http://www.w3.org/2000/svg', 'path'); path1.setAttribute('fill-rule', 'evenodd'); path1.setAttribute('d', 'M7.502 6h7.128A3.375 3.375 0 0 1 18 9.375v9.375a3 3 0 0 0 3-3V6.108c0-1.505-1.125-2.811-2.664-2.94a48.972 48.972 0 0 0-.673-.05A3 3 0 0 0 15 1.5h-1.5a3 3 0 0 0-2.663 1.618c-.225.015-.45.032-.673.05C8.662 3.295 7.554 4.542 7.502 6ZM13.5 3A1.5 1.5 0 0 0 12 4.5h4.5A1.5 1.5 0 0 0 15 3h-1.5Z'); const path2 = document.createElementNS('http://www.w3.org/2000/svg', 'path'); path2.setAttribute('fill-rule', 'evenodd'); path2.setAttribute('d', 'M3 9.375C3 8.339 3.84 7.5 4.875 7.5h9.75c1.036 0 1.875.84 1.875 1.875v11.25c0 1.035-.84 1.875-1.875 1.875h-9.75A1.875 1.875 0 0 1 3 20.625V9.375ZM6 12a.75.75 0 0 1 .75-.75h.008a.75.75 0 0 1 .75.75v.008a.75.75 0 0 1-.75.75H6.75a.75.75 0 0 1-.75-.75V12Zm2.25 0a.75.75 0 0 1 .75-.75h3.75a.75.75 0 0 1 0 1.5H9a.75.75 0 0 1-.75-.75ZM6 15a.75.75 0 0 1 .75-.75h.008a.75.75 0 0 1 .75.75v.008a.75.75 0 0 1-.75.75H6.75a.75.75 0 0 1-.75-.75V15Zm2.25 0a.75.75 0 0 1 .75-.75h3.75a.75.75 0 0 1 0 1.5H9a.75.75 0 0 1-.75-.75ZM6 18a.75.75 0 0 1 .75-.75h.008a.75.75 0 0 1 .75.75v.008a.75.75 0 0 1-.75.75H6.75a.75.75 0 0 1-.75-.75V18Zm2.25 0a.75.75 0 0 1 .75-.75h3.75a.75.75 0 0 1 0 1.5H9a.75.75 0 0 1-.75-.75Z'); svg.appendChild(path1); svg.appendChild(path2); button.appendChild(svg); button.appendChild(document.createTextNode('Copy')); return { button, icon: svg }; } /** * Create the code area with syntax highlighting. * * @param {string} lang The language for syntax highlighting. * @param {string} code The code content. * @returns {{wrapper: HTMLElement, codeElement: HTMLElement}} The wrapper and code element. */ #createCodeArea(lang, code) { const wrapper = document.createElement('div'); wrapper.classList.add('w-full', 'overflow-x-auto'); // Code is always LTR, even in RTL layouts wrapper.dir = 'ltr'; const pre = document.createElement('pre'); pre.classList.add('line-numbers', `language-${lang}`, '!overflow-visible', '!m-0', 'rounded-insight-surface'); const codeElement = document.createElement('code'); codeElement.classList.add('block', 'w-fit', 'pe-6'); codeElement.textContent = this.cleanIndentation(code).trim(); pre.appendChild(codeElement); wrapper.appendChild(pre); return { wrapper, codeElement }; } /** * Setup the copy event handler for the copy button. * * @param {HTMLElement} codeElement The code element to copy from. * @param {SVGElement} icon The icon to restore after copying. */ #setupCopyHandler(codeElement, icon) { this.copyEvent = async () => { try { await navigator.clipboard.writeText(codeElement.textContent); this.copyButton.textContent = gettext('✔ copied'); } catch (err) { // Fallback for older browsers const textarea = document.createElement('textarea'); textarea.value = codeElement.textContent; document.body.appendChild(textarea); textarea.select(); const copied = document.execCommand('copy'); document.body.removeChild(textarea); if (copied) { this.copyButton.textContent = gettext('✔ copied'); } else { console.error('Failed to copy!', err); this.copyButton.textContent = gettext('✖ Copy failed!'); } } setTimeout(() => { this.copyButton.textContent = ''; this.copyButton.appendChild(icon); this.copyButton.appendChild(document.createTextNode('Copy')); }, 1200); }; this.copyButton.addEventListener('click', this.copyEvent); } /** * Create the HTML structure of the code block component. * * @param {string} id The id of the code block. * @param {string} lang The language of the code. * @param {string} filename An optional filename, shown in the toolbar. * @param {string} code The code. * @returns {HTMLElement} The wrapper element of the created HTML structure. */ generateCodeBlock(id, lang, filename, code) { const wrapper = document.createElement('div'); wrapper.classList.add('flex', 'flex-col', 'bg-insight-surface', 'rounded-insight-surface', 'border', 'border-insight-surface'); wrapper.id = id; const toolbar = this.#createToolbar(lang, filename); const { button, icon } = this.#createCopyButton(); this.copyButton = button; toolbar.appendChild(this.copyButton); const { wrapper: codeWrapper, codeElement } = this.#createCodeArea(lang, code); wrapper.appendChild(toolbar); wrapper.appendChild(codeWrapper); // Temporarily add to DOM for Prism to process document.body.appendChild(wrapper); Prism.highlightElement(codeElement); this.#setupCopyHandler(codeElement, icon); return wrapper; } /** * Destroys the code block instance and removes all event listeners. * Call this before removing the element from DOM. */ destroy() { debugLog("Destroy code block: ", this.element); this.copyButton.removeEventListener("click", this.copyEvent); CodeBlock.instances.delete(this.originalElement); delete this.element.__insightInstance; this.originalElement = null; this.element = null; } /** * Initializes all code block instances on the page. * * @static */ static initAll() { document.querySelectorAll("[data-insight-code-block]").forEach(el => new CodeBlock(el)); } }