1import './styles/color-palette.css'; 2import './styles/_webfont-jetbrains-mono.css'; 3import './styles/code.css'; 4import './styles/component-terminal.css'; 5 6window.addEventListener('DOMContentLoaded', () => { 7 new Code(); 8}); 9 10class Code { 11 #clipboardIcon; 12 #diffCache = new WeakMap(); 13 14 constructor() { 15 this.#clipboardIcon = ` 16 <svg xmlns="http://www.w3.org/2000/svg" width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 17 <path stroke="none" d="M0 0h24v24H0z" fill="none"/> 18 <path d="M7 7m0 2.667a2.667 2.667 0 0 1 2.667 -2.667h8.666a2.667 2.667 0 0 1 2.667 2.667v8.666a2.667 2.667 0 0 1 -2.667 2.667h-8.666a2.667 2.667 0 0 1 -2.667 -2.667z" /> 19 <path d="M4.012 16.737a2.005 2.005 0 0 1 -1.012 -1.737v-10c0 -1.1 .9 -2 2 -2h10c.75 0 1.158 .385 1.5 1" /> 20 </svg> 21 `; 22 23 // initialize only visible code blocks on page load 24 this.#initializeConfigurationBlocks(); 25 this.#initializeDiffBlocks(); 26 this.#setupCopyCodeButtonsEventDelegation(); 27 this.#initializeVisibleCodeBlocks(); 28 29 // set up intersection observer for lazy loading 30 this.#setupLazyLoading(); 31 } 32 33 #setupLazyLoading() { 34 // create an intersection observer to detect when elements enter the viewport 35 this.observer = new IntersectionObserver( 36 (entries) => { 37 entries.forEach((entry) => { 38 if (entry.isIntersecting) { 39 const element = entry.target; 40 41 // initialize copy button if needed 42 if (element.classList.contains('codeblock') && !element.dataset.initialized) { 43 this.#initializeCopyCodeButton(element); 44 element.dataset.initialized = 'true'; 45 } 46 47 // stop observing once initialized 48 this.observer.unobserve(element); 49 } 50 }); 51 }, 52 { 53 rootMargin: '100px', // start loading slightly before elements enter viewport 54 threshold: 0.1, 55 }, 56 ); 57 58 // observe all uninitiated code blocks 59 document.querySelectorAll('.codeblock:not([data-initialized])').forEach((element) => { 60 this.observer.observe(element); 61 }); 62 } 63 64 #initializeVisibleCodeBlocks() { 65 // initialize only the code blocks that are currently visible in the viewport 66 const viewportHeight = window.innerHeight; 67 68 document.querySelectorAll('.codeblock').forEach((codeBlock) => { 69 const rect = codeBlock.getBoundingClientRect(); 70 // check if the element is in or near the viewport 71 if (rect.top < viewportHeight + 200) { 72 this.#initializeCopyCodeButton(codeBlock); 73 codeBlock.dataset.initialized = 'true'; 74 } 75 }); 76 } 77 78 #initializeConfigurationBlocks() { 79 this.#updateSelectedLanguageInConfigurationBlocks(); 80 81 this.#getConfigurationBlockElements().forEach((configBlock) => { 82 if (null !== configBlock.querySelector('.configuration-tabs button[role="tab"]')) { 83 this.#initializeAccessibleTabs(configBlock); 84 } else { 85 this.#initializeInaccessibleTabs(configBlock); 86 } 87 }); 88 } 89 90 #initializeAccessibleTabs(configBlock) { 91 configBlock.querySelectorAll('.configuration-tabs button[role="tab"]').forEach((tabControl)
91=> { 92 tabControl.addEventListener('click', (event) => { 93 // save the selected format to display it by default from now on 94 localStorage.setItem('selectedFormat', tabControl.getAttribute('data-language')); 95 96 this.#updateSelectedLanguageInConfigurationBlocks(); 97 event.target.closest('.configuration-block').scrollIntoView(true); 98 }); 99 }); 100 } 101 102 #initializeInaccessibleTabs(configBlock) { 103 configBlock.querySelectorAll('.configuration-tabs li').forEach((tabControl) => { 104 tabControl.addEventListener('click', (event) => { 105 // save the selected format to display it by default from now on 106 localStorage.setItem('selectedFormat', tabControl.getAttribute('data-language')); 107 108 this.#updateSelectedLanguageInConfigurationBlocks(); 109 event.target.closest('.configuration-block').scrollIntoView(true); 110 }); 111 }); 112 } 113 114 #initializeDiffBlocks() { 115 document.querySelectorAll('.codeblock-diff').forEach((codeBlockElement) => { 116 if (this.#diffCache.has(codeBlockElement)) { 117 return; 118 } 119 120 const codeElement = codeBlockElement.querySelector('.codeblock-code code'); 121 const linesElement = codeBlockElement.querySelector('.codeblock-lines'); 122 if (null === codeElement || null === linesElement) { 123 return; 124 } 125 126 const diffLines = this.#parseDiffLines(codeElement); 127 const hasChangedLines = diffLines.some((line) => 'context' !== line.type); 128 // blocks with hunk/file headers ('@@ ', '--- a/...') are real patches, whose 129 // before/after contents can't be derived reliably from their lines 130 const hasPatchHeaders = diffLines.some((line) => /^(@@|\+\+\+ |--- )/.test(line.text)); 131 if (!hasChangedLines || hasPatchHeaders) { 132 return; 133 } 134 135 this.#diffCache.set(codeBlockElement, { 136 diffLines: diffLines, 137 markerWidth: this.#getDiffMarkerWidth(diffLines), 138 originalCodeHtml: codeElement.innerHTML, 139 originalLineNumbers: linesElement.textContent, 140 copyButtonElement: this.#createDiffCopyButton(codeBlockElement), 141 }); 142 143 codeBlockElement.before(this.#createDiffTabs(codeBlockElement)); 144 }); 145 } 146 147 #parseDiffLines(codeElement) { 148 const diffLines = []; 149 let currentLine = null; 150 151 codeElement.childNodes.forEach((node) => { 152 if (Node.TEXT_NODE === node.nodeType) { 153 node.textContent.split('\n').forEach((segment, index) => { 154 if (index > 0) { 155 diffLines.push(currentLine ?? { type: 'context', text: '' }); 156 currentLine = null; 157 } 158 if ('' !== segment) { 159 currentLine = currentLine ?? { type: 'context', text: '' }; 160 currentLine.text += segment; 161 } 162 }); 163 164 return; 165 } 166 167 currentLine = currentLine ?? { type: 'context', text: '' }; 168 if (node.classList?.contains('hljs-addition')) { 169 currentLine.type = 'addition'; 170 } else if (node.classList?.contains('hljs-deletion')) { 171 currentLine.type = 'deletion'; 172 } 173 currentLine.text += node.textContent; 174 }); 175 176 if (null !== currentLine) { 177 diffLines.push(currentLine); 178 } 179 180 return diffLines; 181 } 182 183 // docs use two diff conventions: a 1-char marker ('+added') or a 2-char 184 // marker ('+ added'); the convention of each block is detected by looking 185 // at its added/removed lines, because context lines are ambiguous 186 #getDiffMarkerWidth(diffLines) { 187 const changedLines = diffLines.filter((line) => 'context' !== line.type); 188 189 return changedLines.every((line) => /^[-+]( |$)/.test(line.text)) ? 2 : 1; 190 } 191 192 #removeDiffLineMarker(text, markerWidth) { 193 if (!/^[-+ ]/.test(text)) { 194 return text; 195 } 196 197 const cleanedText = text.substring(1); 198 199 return 2 === markerWidth && cleanedText.startsWith(' ') ? cleanedText.substring(1) : cleanedText; 200 } 201 202 #createDiffTabs(codeBlockElement) { 203 const tabsElement = document.createElement('div'); 204 tabsElement.classList.add('diff-tabs'); 205 tabsElement.setAttribute('role', 'tablist'); 206 tabsElement.setAttribute('aria-label', 'Code diff view'); 207 208 Object.entries({ diff: 'Diff', before: 'Before', after: 'After' }
208).forEach(([view, label]) => { 209 const tabElement = document.createElement('button'); 210 tabElement.type = 'button'; 211 tabElement.textContent = label; 212 tabElement.setAttribute('role', 'tab'); 213 tabElement.setAttribute('data-diff-view', view); 214 tabElement.setAttribute('aria-selected', 'diff' === view ? 'true' : 'false'); 215 if ('diff' === view) { 216 tabElement.setAttribute('data-active', 'true'); 217 } else { 218 tabElement.setAttribute('tabindex', '-1'); 219 } 220 tabElement.addEventListener('click', () => this.#selectDiffTab(codeBlockElement, tabElement)); 221 tabsElement.appendChild(tabElement); 222 }); 223 224 tabsElement.addEventListener('keydown', (event) => { 225 const tabElements = [...tabsElement.querySelectorAll('button[role="tab"]')]; 226 const currentIndex = tabElements.indexOf(document.activeElement); 227 if (-1 === currentIndex) { 228 return; 229 } 230 231 let newIndex = null; 232 if ('ArrowRight' === event.key) { 233 newIndex = (currentIndex + 1) % tabElements.length; 234 } else if ('ArrowLeft' === event.key) { 235 newIndex = (currentIndex - 1 + tabElements.length) % tabElements.length; 236 } else if ('Home' === event.key) { 237 newIndex = 0; 238 } else if ('End' === event.key) { 239 newIndex = tabElements.length - 1; 240 } 241 242 if (null === newIndex) { 243 return; 244 } 245 246 event.preventDefault(); 247 tabElements[newIndex].focus(); 248 this.#selectDiffTab(codeBlockElement, tabElements[newIndex]); 249 }); 250 251 return tabsElement; 252 } 253 254 #selectDiffTab(codeBlockElement, selectedTabElement) { 255 const tabElements = selectedTabElement.closest('.diff-tabs').querySelectorAll('button[role="tab"]'); 256 tabElements.forEach((tabElement) => { 257 if (tabElement === selectedTabElement) { 258 tabElement.setAttribute('data-active', 'true'); 259 tabElement.setAttribute('aria-selected', 'true'); 260 tabElement.removeAttribute('tabindex'); 261 } else { 262 tabElement.removeAttribute('data-active'); 263 tabElement.setAttribute('aria-selected', 'false'); 264 tabElement.setAttribute('tabindex', '-1'); 265 } 266 }); 267 268 this.#renderDiffView(codeBlockElement, selectedTabElement.getAttribute('data-diff-view')); 269 } 270 271 #renderDiffView(codeBlockElement, view) { 272 const cache = this.#diffCache.get(codeBlockElement); 273 const codeElement = codeBlockElement.querySelector('.codeblock-code code'); 274 const linesElement = codeBlockElement.querySelector('.codeblock-lines'); 275 276 if ('diff' === view) { 277 codeElement.innerHTML = cache.originalCodeHtml; 278 linesElement.textContent = cache.originalLineNumbers; 279 } else { 280 const hiddenType = 'before' === view ? 'addition' : 'deletion'; 281 const visibleLines = cache.diffLines.filter((line) => hiddenType !== line.type); 282 codeElement.textContent = visibleLines 283 .map((line) => this.#removeDiffLineMarker(line.text, cache.markerWidth)) 284 .join('\n'); 285 linesElement.textContent = visibleLines.map((_line, index) => index + 1).join('\n'); 286 } 287 288 cache.copyButtonElement.hidden = 'diff' === view; 289 } 290 291 #createDiffCopyButton(codeBlockElement) { 292 const copyButtonElement = document.createElement('button'); 293 copyButtonElement.innerHTML = this.#clipboardIcon; 294 copyButtonElement.classList.add('btn-codeblock-copy-code'); 295 copyButtonElement.hidden = true; 296 codeBlockElement.prepend(copyButtonElement); 297 298 return copyButtonElement; 299 } 300 301 #setupCopyCodeButtonsEventDelegation() { 302 // use event delegation for copy buttons 303 document.addEventListener('click', (event) => { 304 const copyButton = event.target.closest('.btn-codeblock-copy-code'); 305 if (null === copyButton) { 306 return; 307 } 308 309 const codeBlockElement = copyButton.closest('.codeblock'); 310 if (!codeBlockElement) return; 311 312 const codeElement = codeBlockElement.querySelector('.codeblock-code'); 313 if (!codeElement) return; 314 315 let textToCopy = codeElement.textContent; 316 317 const isTerminalCodeBlock = codeBlockElement.matches('.codeblock-terminal, .codeblock-bash'); 318 if (isTerminalCodeBlock) { 319 const clone = codeElement.cloneNode(true); 320 clone.querySelectorAll('span.hljs-prompt').forEach((el) => el.remove()); 321 textToCopy = clone.textContent; 322 } 323 324 this.#copyTextToClipboard(textToCopy.trim()); 325 326 // change the button icon to give feedback 327 const originalCopyButtonInnerHTML = copyButton.innerHTML; 328 copyButton.classList.add('btn-codeblock-copy-code-active'); 329 copyButton.innerHTML = `${this.#clipboardIcon} Copied!`; 330 331 setTimeout(() => { 332 copyButton.innerHTML = originalCopyButtonInnerHTML; 333 copyButton.classList.remove('btn-codeblock-copy-code-active'); 334 }, 1500); 335 336 event.preventDefault(); 337 }); 338 } 339 340 #initializeCopyCodeButton(codeBlockElement) { 341 if ( 342 // skip buttons that have already been initialized 343 codeBlockElement.querySelector('.btn-codeblock-copy-code') || 344 // skip code blocks that already include a copy button (e.g. the Terminal component) 345 codeBlockElement.querySelector('.ui-button-copy') || 346 // skip code blocks related to diff changes 347 codeBlockElement.classList.contains('codeblock-diff') 348 ) { 349 return; 350 } 351 352 // enable the copy button only for terminal related blocks that only contain a single line; 353 // otherwise, the code block contains output or multiple commands, so it's messy to copy 354 const isTerminalCodeBlock = codeBlockElement.matches('.codeblock-terminal, .codeblock-bash'); 355 if ( 356 isTerminalCodeBlock && 357 codeBlockElement.querySelector('.codeblock-code').textContent.split('\n').length > 1 358 ) { 359 return; 360 } 361 362 const copyButtonElement = document.createElement('button'); 363 copyButtonElement.innerHTML = this.#clipboardIcon; 364 if (isTerminalCodeBlock) { 365 copyButtonElement.innerHTML = `${this.#clipboardIcon} Copy`; 366 } 367 copyButtonElement.classList.add('btn-codeblock-copy-code');
368 codeBlockElement.prepend(copyButtonElement); 369 } 370 371 #getConfigurationBlockElements() { 372 return document.querySelectorAll('div.configuration-block'); 373 } 374 375 #updateSelectedLanguageInConfigurationBlocks() { 376 const selectedLanguage = localStorage.getItem('selectedFormat'); 377 378 this.#getConfigurationBlockElements().forEach((configBlock) => { 379 const selectedLanguageIsInConfigurationBlock = 380 null !== configBlock.querySelector(`[data-language="${selectedLanguage}"]`); 381 if (!selectedLanguageIsInConfigurationBlock) { 382 return; 383 } 384 385 if (null !== configBlock.querySelector('.configuration-tabs button[role="tab"]')) { 386 this.#handleAccessibleTabs(configBlock, selectedLanguage); 387 } else { 388 this.#handleInaccessibleTabs(configBlock, selectedLanguage); 389 } 390 }); 391 } 392 393 #handleAccessibleTabs(configBlock, selectedLanguage) { 394 // unselect all tabs of the config block... 395 configBlock.querySelectorAll('.configuration-tabs button[role="tab"]').forEach((tabControl)
395=> { 396 tabControl.removeAttribute('data-active'); 397 tabControl.removeAttribute('aria-selected'); 398 tabControl.setAttribute('tabindex', '-1'); 399 }); 400 configBlock.querySelectorAll('.configuration-codeblock').forEach((codeBlock) => { 401 codeBlock.style.display = 'none'; 402 }); 403 404 // and then select only the one that corresponds to the selected language 405 const selectedTabControl = configBlock.querySelector( 406 `.configuration-tabs button[role="tab"][data-language="${selectedLanguage}"]`, 407 ); 408 selectedTabControl.setAttribute('data-active', 'true'); 409 selectedTabControl.setAttribute('aria-selected', 'true'); 410 selectedTabControl.removeAttribute('tabindex'); 411 configBlock.querySelector(`.configuration-codeblock[data-language="${selectedLanguage}"]`).style.display = 412 'block'; 413 } 414 415 #handleInaccessibleTabs(configBlock, selectedLanguage) { 416 // unselect all tabs of the config block... 417 configBlock.querySelectorAll('.configuration-tabs li').forEach((tabControl)
417=> { 418 tabControl.removeAttribute('data-active'); 419 }); 420 configBlock.querySelectorAll('.configuration-codeblock').forEach((codeBlock) => { 421 codeBlock.style.display = 'none'; 422 }); 423 424 // and then select only the one that corresponds to the selected language 425 configBlock 426 .querySelector(`.configuration-tabs li[data-language="${selectedLanguage}"]`) 427 .setAttribute('data-active', 'true'); 428 configBlock.querySelector(`.configuration-codeblock[data-language="${selectedLanguage}"]`).style.display = 429 'block'; 430 } 431 432 #copyTextToClipboard(textContent) { 433 // try to use the modern Clipboard API first 434 if (navigator.clipboard?.writeText) { 435 navigator.clipboard.writeText(textContent).catch(() => { 436 // fall back to the older method if Clipboard API fails 437 this.#fallbackCopyTextToClipboard(textContent); 438 }); 439 return; 440 } 441 442 // fall back to the older method if Clipboard API is not available 443 this.#fallbackCopyTextToClipboard(textContent); 444 } 445 446 #fallbackCopyTextToClipboard(textContent) { 447 // create a single hidden element for copying text 448 const hiddenCopyElement = document.createElement('textarea'); 449 450 // set non-editable to avoid focus and move outside of view 451 hiddenCopyElement.setAttribute('readonly', ''); 452 hiddenCopyElement.style.position = 'absolute'; 453 hiddenCopyElement.style.left = '-9999px'; 454 hiddenCopyElement.value = textContent; 455 456 document.body.appendChild(hiddenCopyElement); 457 458 hiddenCopyElement.select(); 459 document.execCommand('copy'); 460 461 document.body.removeChild(hiddenCopyElement); 462 } 463}
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.