]> git.ipfire.org Git - thirdparty/bootstrap.git/commitdiff
Improve code snippets (#42148)
authorMark Otto <markd.otto@gmail.com>
Wed, 11 Mar 2026 20:18:37 +0000 (13:18 -0700)
committerGitHub <noreply@github.com>
Wed, 11 Mar 2026 20:18:37 +0000 (13:18 -0700)
* Improve code snippets

* format/lint

site/src/components/icons/Symbols.astro
site/src/components/shortcodes/Code.astro
site/src/components/shortcodes/CodeCopy.astro
site/src/components/shortcodes/Example.astro
site/src/components/shortcodes/JsDocs.astro
site/src/components/shortcodes/ScssDocs.astro
site/src/libs/clipboard.ts [new file with mode: 0644]
site/src/libs/highlight.ts [new file with mode: 0644]
site/src/scss/_component-examples.scss

index f6d6351c363e980ba7984f18442bb722957a0150..9e813089705655b964f6e470fa820c4ca08b696f 100644 (file)
@@ -66,6 +66,9 @@
       d="M5.854 4.854a.5.5 0 1 0-.708-.708l-3.5 3.5a.5.5 0 0 0 0 .708l3.5 3.5a.5.5 0 0 0 .708-.708L2.707 8l3.147-3.146zm4.292 0a.5.5 0 0 1 .708-.708l3.5 3.5a.5.5 0 0 1 0 .708l-3.5 3.5a.5.5 0 0 1-.708-.708L13.293 8l-3.147-3.146z"
     ></path>
   </symbol>
+  <symbol id="copy" viewBox="0 0 16 16">
+    <path fill-rule="evenodd" d="M4 2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2zm2-1a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1zM2 5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-1h1v1a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h1v1z"/>
+  </symbol>
   <symbol id="envelope" viewBox="0 0 16 16">
     <path d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2zm2-1a1 1 0 0 0-1 1v.217l7 4.2 7-4.2V4a1 1 0 0 0-1-1zm13 2.383-4.708 2.825L15 11.105zm-.034 6.876-5.64-3.471L8 9.583l-1.326-.795-5.64 3.47A1 1 0 0 0 2 13h12a1 1 0 0 0 .966-.741M1 11.105l4.708-2.897L1 5.383z"/>
   </symbol>
index a376bf8d8d26ab1e8bb429a7baa801b1db976a77..e8bb904867406a107a4f93db06af91d86781486b 100644 (file)
@@ -1,10 +1,9 @@
 ---
 import fs from 'node:fs'
 import path from 'node:path'
-import { codeToHtml } from 'shiki'
 import { transformerNotationDiff, transformerNotationHighlight } from '@shikijs/transformers'
-import bootstrapLight from 'bootstrap-vscode-theme/themes/bootstrap-light.json'
-import bootstrapDark from 'bootstrap-vscode-theme/themes/bootstrap-dark.json'
+import { getConfig } from '@libs/config'
+import { highlightCode } from '@libs/highlight'
 import { replaceConfigInText } from '@libs/remark'
 
 interface Tab {
@@ -52,6 +51,10 @@ interface Props {
    * This takes precedence over the `code` prop.
    */
   filePath?: string
+  /**
+   * A file path to display in the highlight toolbar instead of the language label.
+   */
+  file?: string
   /**
    * Defines if the `<Code>` component is nested inside an `<Example>` component or not.
    * @default false
@@ -75,6 +78,7 @@ const {
   code,
   containerClass,
   'data-language': dataLanguage,
+  file,
   fileMatch,
   filePath,
   lang,
@@ -83,6 +87,10 @@ const {
   tabs
 } = Astro.props
 
+const fileUrl = file
+  ? `${getConfig().repo}/blob/v${getConfig().current_version}/${file}`.replaceAll('\\', '/')
+  : null
+
 // Extract language from multiple possible sources (for markdown code blocks)
 // Priority: lang prop > data-language attribute > className pattern
 let detectedLang = lang || dataLanguage
@@ -121,137 +129,42 @@ if (filePath && fileMatch && codeToDisplay) {
   codeToDisplay = matches.map(m => m[0]).join('\n\n')
 }
 
-// Add line wrapper for shell languages to support shell prompts
-const shouldWrapLines = detectedLang && ['bash', 'sh', 'powershell'].includes(detectedLang)
-
-// Transformer to ensure class name is always 'astro-code' instead of 'shiki'
-const classTransformer = {
-  name: 'class-name-transformer',
-  pre(node: any) {
-    // Force replace all 'shiki' classes with 'astro-code'
-    const existingClasses = node.properties?.className || []
-    const newClasses = existingClasses.map((cls: any) => {
-      if (typeof cls === 'string') {
-        return cls.replace(/shiki/g, 'astro-code')
-      }
-      return cls
-    })
-    node.properties.className = newClasses
-  }
-}
-
-const lineWrapperTransformer = {
-  name: 'line-wrapper',
-  line(node: any) {
-    // Wrap non-comment lines in a span with .line class for shell prompt styling
-    const hasOnlyComments = node.children.every((child: any) =>
-      child.type === 'element' &&
-      child.properties?.class &&
-      Array.isArray(child.properties.class) &&
-      child.properties.class.some((cls: any) => typeof cls === 'string' && cls.includes('comment'))
-    )
-
-    if (!hasOnlyComments) {
-      node.properties = node.properties || {}
-      node.properties.class = node.properties.class
-        ? `${node.properties.class} line`
-        : 'line'
-    }
-  }
-}
-
-const transformers: any[] = [
-  transformerNotationDiff(), // Supports // [!code ++] and // [!code --] notation
-  transformerNotationHighlight(), // Supports line highlight notation
-  classTransformer
+const diffTransformers = [
+  transformerNotationDiff(),
+  transformerNotationHighlight()
 ]
-if (shouldWrapLines) {
-  transformers.push(lineWrapperTransformer)
-}
 
 // Process tabs if provided
 let highlightedTabs: Array<{ label: string; code: string }> | null = null
 if (tabs && tabs.length > 0) {
   highlightedTabs = await Promise.all(
-    tabs.map(async (tab) => {
-      // Replace config placeholders in the code
-      const processedCode = replaceConfigInText(tab.code)
-
-      const tabLang = tab.lang || detectedLang || 'bash'
-      const shouldWrapTabLines = ['bash', 'sh', 'powershell'].includes(tabLang)
-
-      const tabTransformers: any[] = [
-        transformerNotationDiff(),
-        transformerNotationHighlight(),
-        classTransformer
-      ]
-      if (shouldWrapTabLines) {
-        tabTransformers.push(lineWrapperTransformer)
-      }
-
-      let tabHighlighted = await codeToHtml(processedCode, {
-        lang: tabLang,
-        themes: {
-          light: bootstrapLight,
-          dark: bootstrapDark
-        },
-        transformers: tabTransformers
-      })
-
-      // Replace 'shiki' with 'astro-code' in the generated HTML
-      tabHighlighted = tabHighlighted.replace(/class=(["'])shiki(\s+)/g, 'class=$1astro-code$2')
-      tabHighlighted = tabHighlighted.replace(/class=(["'])shiki(["'])/g, 'class=$1astro-code$2')
-      tabHighlighted = tabHighlighted.replace(/shiki-themes/g, 'astro-code-themes')
-
-      return {
-        label: tab.label,
-        code: tabHighlighted
-      }
-    })
+    tabs.map(async (tab) => ({
+      label: tab.label,
+      code: await highlightCode(
+        replaceConfigInText(tab.code),
+        tab.lang || detectedLang || 'bash',
+        diffTransformers
+      )
+    }))
   )
 }
 
-let highlightedCode = codeToDisplay && detectedLang
-  ? await codeToHtml(codeToDisplay, {
-      lang: detectedLang,
-      themes: {
-        light: bootstrapLight,
-        dark: bootstrapDark
-      },
-      transformers
-    })
+const highlightedCode = codeToDisplay && detectedLang
+  ? await highlightCode(codeToDisplay, detectedLang, diffTransformers)
   : null
-
-// Replace 'shiki' with 'astro-code' in the generated HTML
-if (highlightedCode) {
-  // Replace class="shiki" or class='shiki' (preserving other classes like has-diff)
-  highlightedCode = highlightedCode.replace(/class=(["'])shiki(\s+)/g, 'class=$1astro-code$2')
-  highlightedCode = highlightedCode.replace(/class=(["'])shiki(["'])/g, 'class=$1astro-code$2')
-  // Replace shiki-themes if it exists
-  highlightedCode = highlightedCode.replace(/shiki-themes/g, 'astro-code-themes')
-}
 ---
 
 <script>
-  import ClipboardJS from 'clipboard'
+  import { initCopyButtons } from '@libs/clipboard'
 
-  const btnTitle = 'Copy to clipboard'
-  const btnEdit = 'Edit on StackBlitz'
-
-  function snippetButtonTooltip(selector: string, title: string) {
-    document.querySelectorAll(selector).forEach((btn) => {
-      bootstrap.Tooltip.getOrCreateInstance(btn, { title })
-    })
-  }
-
-  snippetButtonTooltip('.btn-clipboard', btnTitle)
-  snippetButtonTooltip('.btn-edit', btnEdit)
+  // StackBlitz tooltip
+  document.querySelectorAll('.btn-edit').forEach((btn) => {
+    bootstrap.Tooltip.getOrCreateInstance(btn, { title: 'Edit on StackBlitz' })
+  })
 
   // Handle tab switching
   document.querySelectorAll('.code-tabs').forEach((tabContainer) => {
     const buttons = tabContainer.querySelectorAll('.code-tab-btn')
-    // Find the parent container that holds both tabs and content
-    // Could be .bd-code-snippet or .bd-example-snippet
     const parentContainer = tabContainer.closest('.bd-code-snippet') ||
                            tabContainer.closest('.bd-example-snippet') ||
                            tabContainer.parentElement?.parentElement
@@ -259,81 +172,23 @@ if (highlightedCode) {
 
     buttons.forEach((button, index) => {
       button.addEventListener('click', () => {
-        // Remove active class from all buttons and hide all code blocks
         buttons.forEach((btn) => btn.classList.remove('active'))
         codeBlocks?.forEach((block) => block.classList.remove('active'))
 
-        // Add active class to clicked button and show corresponding code block
         button.classList.add('active')
         codeBlocks?.[index]?.classList.add('active')
       })
     })
   })
 
-  const clipboard = new ClipboardJS('.btn-clipboard', {
-    target: (trigger) => trigger.closest('.bd-code-snippet')?.querySelector('.astro-code')!,
-    text: (trigger) => {
-      // For tabbed code, find the active tab's code
-      const snippet = trigger.closest('.bd-code-snippet')
-      const activeTab = snippet?.querySelector('.code-tab-content.active .astro-code')
-      if (activeTab) {
-        return activeTab.textContent?.trim()!
-      }
-      // Trim text to workaround a Firefox issue where the structure of the DOM (uncontrolled) is relevant for the
-      // copied text.
-      // https://github.com/zenorocha/clipboard.js/issues/439#issuecomment-312344621
-      return snippet?.querySelector('.astro-code')!.textContent?.trim()!
+  initCopyButtons('.bd-code-snippet [data-bd-clipboard]', (trigger) => {
+    const snippet = trigger.closest('.bd-code-snippet')
+    const activeTab = snippet?.querySelector('.code-tab-content.active .astro-code')
+    if (activeTab) {
+      return activeTab.textContent?.trim() || ''
     }
-  })
-
-  clipboard.on('success', (event) => {
-    const iconFirstChild = event.trigger.querySelector('.bi')?.firstElementChild
-    const tooltipBtn = bootstrap.Tooltip.getInstance(event.trigger)
-    const namespace = 'http://www.w3.org/1999/xlink'
-    const originalXhref = iconFirstChild?.getAttributeNS(namespace, 'href')
-    const isCheckIconVisible = originalXhref === '#check2'
-
-    if (isCheckIconVisible) {
-      return
-    }
-
-    tooltipBtn?.setContent({ '.tooltip-inner': 'Copied!' })
-
-    event.trigger.addEventListener(
-      'hidden.bs.tooltip',
-      () => {
-        tooltipBtn?.setContent({ '.tooltip-inner': btnTitle })
-      },
-      { once: true }
-    )
-
-    event.clearSelection()
-
-    if (originalXhref) {
-      iconFirstChild?.setAttributeNS(namespace, 'href', originalXhref.replace('clipboard', 'check2'))
-    }
-
-    setTimeout(() => {
-      if (originalXhref) {
-        iconFirstChild?.setAttributeNS(namespace, 'href', originalXhref)
-      }
-    }, 2000)
-  })
-
-  clipboard.on('error', (event) => {
-    const modifierKey = /mac/i.test(navigator.userAgent) ? '\u2318' : 'Ctrl-'
-    const fallbackMsg = `Press ${modifierKey}C to copy`
-    const tooltipBtn = bootstrap.Tooltip.getInstance(event.trigger)
-
-    tooltipBtn?.setContent({ '.tooltip-inner': fallbackMsg })
 
-    event.trigger.addEventListener(
-      'hidden.bs.tooltip',
-      () => {
-        tooltipBtn?.setContent({ '.tooltip-inner': btnTitle })
-      },
-      { once: true }
-    )
+    return snippet?.querySelector('.astro-code')?.textContent?.trim() || ''
   })
 </script>
 
@@ -352,6 +207,8 @@ if (highlightedCode) {
               </button>
             ))}
           </div>
+        ) : file ? (
+          <a class="text-decoration-none font-monospace fs-xs fg-3" href={fileUrl} target="_blank" rel="noopener noreferrer">{file}</a>
         ) : (
           <div class="font-monospace text-uppercase fs-xs fg-3">{displayLang}</div>
         )}
@@ -363,9 +220,9 @@ if (highlightedCode) {
               </svg>
             </button>
           )}
-          <button type="button" class="btn-clipboard mt-0 me-0" title="Copy to clipboard">
-            <svg class="bi" aria-hidden="true">
-              <use href="#clipboard" />
+          <button type="button" class="btn btn-xs btn-icon bg-transparent" title="Copy" data-bd-clipboard>
+            <svg class="bi fs-md" aria-hidden="true">
+              <use href="#copy" />
             </svg>
           </button>
         </div>
@@ -386,7 +243,7 @@ if (highlightedCode) {
     )}
   </>
 ) : (
-  <div class="bd-code-snippet">
+  <div class:list={["bd-code-snippet", containerClass]}>
     {!noToolbar && (
       <div class="hstack highlight-toolbar align-items-center">
         {highlightedTabs ? (
@@ -400,13 +257,15 @@ if (highlightedCode) {
               </button>
             ))}
           </div>
+        ) : file ? (
+          <a class="text-decoration-none font-monospace fs-xs fg-3" href={fileUrl} target="_blank" rel="noopener noreferrer">{file}</a>
         ) : (
           <div class="font-monospace text-uppercase fs-xs fg-3">{displayLang}</div>
         )}
         <div class="d-flex ms-auto">
-          <button type="button" class="btn-clipboard mt-0 me-0" title="Copy to clipboard">
-            <svg class="bi" aria-hidden="true">
-              <use href="#clipboard" />
+          <button type="button" class="btn btn-xs btn-icon bg-transparent" title="Copy" data-bd-clipboard>
+            <svg class="bi fs-md" aria-hidden="true">
+              <use href="#copy" />
             </svg>
           </button>
         </div>
index b583e9a3b137cdda6282b3ca3d78864f3093656f..888bf5294f0fcd27c5c4519e10826947588096af 100644 (file)
@@ -1,7 +1,5 @@
 ---
-import { codeToHtml } from 'shiki'
-import bootstrapLight from 'bootstrap-vscode-theme/themes/bootstrap-light.json'
-import bootstrapDark from 'bootstrap-vscode-theme/themes/bootstrap-dark.json'
+import { highlightCode } from '@libs/highlight'
 
 interface Props {
   /**
@@ -17,136 +15,22 @@ interface Props {
 
 const { code, lang = 'bash' } = Astro.props
 
-// Add line wrapper for shell languages to support shell prompts
-const shouldWrapLines = ['bash', 'sh', 'powershell'].includes(lang)
-
-const classTransformer = {
-  name: 'class-name-transformer',
-  pre(node: any) {
-    const existingClasses = node.properties?.className || []
-    const newClasses = existingClasses.map((cls: any) => {
-      if (typeof cls === 'string') {
-        return cls.replace(/shiki/g, 'astro-code')
-      }
-      return cls
-    })
-    node.properties.className = newClasses
-  }
-}
-
-const lineWrapperTransformer = {
-  name: 'line-wrapper',
-  line(node: any) {
-    const hasOnlyComments = node.children.every((child: any) =>
-      child.type === 'element' &&
-      child.properties?.class &&
-      Array.isArray(child.properties.class) &&
-      child.properties.class.some((cls: any) => typeof cls === 'string' && cls.includes('comment'))
-    )
-
-    if (!hasOnlyComments) {
-      node.properties = node.properties || {}
-      node.properties.class = node.properties.class
-        ? `${node.properties.class} line`
-        : 'line'
-    }
-  }
-}
-
-const transformers: any[] = [classTransformer]
-if (shouldWrapLines) {
-  transformers.push(lineWrapperTransformer)
-}
-
-let highlightedCode = await codeToHtml(code, {
-  lang,
-  themes: {
-    light: bootstrapLight,
-    dark: bootstrapDark
-  },
-  transformers
-})
-
-// Replace 'shiki' with 'astro-code'
-highlightedCode = highlightedCode.replace(/class=(["'])shiki(\s+)/g, 'class=$1astro-code$2')
-highlightedCode = highlightedCode.replace(/class=(["'])shiki(["'])/g, 'class=$1astro-code$2')
-highlightedCode = highlightedCode.replace(/shiki-themes/g, 'astro-code-themes')
+const highlightedCode = await highlightCode(code, lang)
 ---
 
 <script>
-  import ClipboardJS from 'clipboard'
-
-  const btnTitle = 'Copy to clipboard'
-
-  document.querySelectorAll('.code-copy').forEach((container) => {
-    const btn = container.querySelector('.btn-clipboard')
-    if (btn) {
-      bootstrap.Tooltip.getOrCreateInstance(btn, { title: btnTitle })
-    }
-  })
-
-  const clipboard = new ClipboardJS('.code-copy .btn-clipboard', {
-    text: (trigger) => {
-      return trigger.closest('.code-copy')?.querySelector('.astro-code')?.textContent?.trim() || ''
-    }
-  })
-
-  clipboard.on('success', (event) => {
-    const iconFirstChild = event.trigger.querySelector('.bi')?.firstElementChild
-    const tooltipBtn = bootstrap.Tooltip.getInstance(event.trigger)
-    const namespace = 'http://www.w3.org/1999/xlink'
-    const originalXhref = iconFirstChild?.getAttributeNS(namespace, 'href')
-    const isCheckIconVisible = originalXhref === '#check2'
-
-    if (isCheckIconVisible) {
-      return
-    }
-
-    tooltipBtn?.setContent({ '.tooltip-inner': 'Copied!' })
-
-    event.trigger.addEventListener(
-      'hidden.bs.tooltip',
-      () => {
-        tooltipBtn?.setContent({ '.tooltip-inner': btnTitle })
-      },
-      { once: true }
-    )
-
-    event.clearSelection()
-
-    if (originalXhref) {
-      iconFirstChild?.setAttributeNS(namespace, 'href', originalXhref.replace('clipboard', 'check2'))
-    }
-
-    setTimeout(() => {
-      if (originalXhref) {
-        iconFirstChild?.setAttributeNS(namespace, 'href', originalXhref)
-      }
-    }, 2000)
-  })
-
-  clipboard.on('error', (event) => {
-    const modifierKey = /mac/i.test(navigator.userAgent) ? '\u2318' : 'Ctrl-'
-    const fallbackMsg = `Press ${modifierKey}C to copy`
-    const tooltipBtn = bootstrap.Tooltip.getInstance(event.trigger)
-
-    tooltipBtn?.setContent({ '.tooltip-inner': fallbackMsg })
+  import { initCopyButtons } from '@libs/clipboard'
 
-    event.trigger.addEventListener(
-      'hidden.bs.tooltip',
-      () => {
-        tooltipBtn?.setContent({ '.tooltip-inner': btnTitle })
-      },
-      { once: true }
-    )
+  initCopyButtons('.code-copy [data-bd-clipboard]', (trigger) => {
+    return trigger.closest('.code-copy')?.querySelector('.astro-code')?.textContent?.trim() || ''
   })
 </script>
 
 <div class="code-copy">
   <Fragment set:html={highlightedCode} />
-  <button type="button" class="btn-clipboard" title="Copy to clipboard">
-    <svg class="bi" aria-hidden="true">
-      <use href="#clipboard" />
+  <button type="button" class="btn btn-xs btn-icon bg-transparent" title="Copy" data-bd-clipboard>
+    <svg class="bi fs-md" aria-hidden="true">
+      <use href="#copy" />
     </svg>
   </button>
 </div>
@@ -172,7 +56,7 @@ highlightedCode = highlightedCode.replace(/shiki-themes/g, 'astro-code-themes')
     background-color: transparent !important;
   }
 
-  .code-copy .btn-clipboard {
+  .code-copy :global([data-bd-clipboard]) {
     position: static;
     display: flex;
     align-items: center;
index 7003e285f16c8c89ca0859853e86a275beee4c54..081737924e59afe3d822e4decc223705c68cf2b8 100644 (file)
@@ -22,6 +22,10 @@ interface Props {
    * The CSS class(es) to be added to the preview wrapping `div` element.
    */
   class?: string
+  /**
+   * A file path to display in the highlight toolbar instead of the language label.
+   */
+  file?: string
   /**
    * The preview wrapping `div` element ID.
    */
@@ -48,6 +52,7 @@ const {
   code,
   customMarkup,
   class: className,
+  file,
   id,
   lang = 'html',
   showMarkup = true,
@@ -80,6 +85,6 @@ const simplifiedMarkup = sourceMarkup
     </div>
   )}
   {showMarkup && (
-    <Code code={simplifiedMarkup} lang={lang} nestedInExample={true} addStackblitzJs={addStackblitzJs} />
+    <Code code={simplifiedMarkup} lang={lang} file={file} nestedInExample={true} addStackblitzJs={addStackblitzJs} />
   )}
 </div>
index dfdd67aa2682a75a40b530a9dee13aa62eb5410f..de32c788b716275b39b0de3f04c677cbc420777b 100644 (file)
@@ -1,6 +1,5 @@
 ---
 import fs from 'node:fs'
-import { getConfig } from '@libs/config'
 import Code from '@shortcodes/Code.astro'
 
 // Prints everything between `// js-docs-start "name"` and `// js-docs-end "name"`
@@ -52,20 +51,4 @@ try {
 }
 ---
 
-<Code containerClass="bd-example-snippet bd-code-snippet" code={content} lang="js">
-  <div slot="pre" class="d-flex align-items-center highlight-toolbar ps-3 pe-2 py-1 border-bottom">
-    <a
-      class="text-decoration-none color-body"
-      href={`${getConfig().repo}/blob/v${getConfig().current_version}/${file}`.replaceAll('\\', '/')}
-      target="_blank"
-      rel="noopener noreferrer"
-    >
-      {file}
-    </a>
-    <div class="d-flex ms-auto">
-      <button type="button" class="btn-clipboard mt-0 me-0" title="Copy to clipboard">
-        <svg class="bi" aria-hidden="true"><use href="#clipboard"></use></svg>
-      </button>
-    </div>
-  </div>
-</Code>
+<Code containerClass="bd-example-snippet" file={file} code={content} lang="js" />
index ac89991a9b8d04879488281af79e46faae0e1e15..59b3bd2f3de93feda61df8a4710882729870b0df 100644 (file)
@@ -1,6 +1,5 @@
 ---
 import fs from 'node:fs'
-import { getConfig } from '@libs/config'
 import Code from '@shortcodes/Code.astro'
 
 // Prints everything between `// scss-docs-start "name"` and `// scss-docs-end "name"`
@@ -54,20 +53,4 @@ try {
 }
 ---
 
-<Code containerClass="bd-example-snippet" code={content} lang="scss">
-  <div slot="pre" class="d-flex align-items-center highlight-toolbar ps-3 pe-2 py-1 border-bottom">
-    <a
-      class="text-decoration-none color-body"
-      href={`${getConfig().repo}/blob/v${getConfig().current_version}/${file}`.replaceAll('\\', '/')}
-      target="_blank"
-      rel="noopener noreferrer"
-    >
-      {file}
-    </a>
-    <div class="d-flex ms-auto">
-      <button type="button" class="btn-clipboard mt-0 me-0" title="Copy to clipboard">
-        <svg class="bi" aria-hidden="true"><use href="#clipboard"></use></svg>
-      </button>
-    </div>
-  </div>
-</Code>
+<Code containerClass="bd-example-snippet" file={file} code={content} lang="scss" />
diff --git a/site/src/libs/clipboard.ts b/site/src/libs/clipboard.ts
new file mode 100644 (file)
index 0000000..77850eb
--- /dev/null
@@ -0,0 +1,63 @@
+import ClipboardJS from 'clipboard'
+
+declare const bootstrap: any
+
+const btnTitle = 'Copy'
+
+export function initCopyButtons(selector: string, textFn: (trigger: Element) => string): ClipboardJS {
+  document.querySelectorAll(selector).forEach((btn) => {
+    bootstrap.Tooltip.getOrCreateInstance(btn, { title: btnTitle })
+  })
+
+  const clipboard = new ClipboardJS(selector, { text: textFn })
+
+  clipboard.on('success', (event) => {
+    const useEl = event.trigger.querySelector('.bi use')
+    const tooltipBtn = bootstrap.Tooltip.getInstance(event.trigger)
+    const originalHref = useEl?.getAttribute('href')
+
+    if (originalHref === '#check2') {
+      return
+    }
+
+    tooltipBtn?.setContent({ '.tooltip-inner': 'Copied!' })
+
+    event.trigger.addEventListener(
+      'hidden.bs.tooltip',
+      () => {
+        tooltipBtn?.setContent({ '.tooltip-inner': btnTitle })
+      },
+      { once: true }
+    )
+
+    event.clearSelection()
+
+    if (useEl) {
+      useEl.setAttribute('href', '#check2')
+    }
+
+    setTimeout(() => {
+      if (useEl && originalHref) {
+        useEl.setAttribute('href', originalHref)
+      }
+    }, 2000)
+  })
+
+  clipboard.on('error', (event) => {
+    const modifierKey = /mac/i.test(navigator.userAgent) ? '\u2318' : 'Ctrl-'
+    const fallbackMsg = `Press ${modifierKey}C to copy`
+    const tooltipBtn = bootstrap.Tooltip.getInstance(event.trigger)
+
+    tooltipBtn?.setContent({ '.tooltip-inner': fallbackMsg })
+
+    event.trigger.addEventListener(
+      'hidden.bs.tooltip',
+      () => {
+        tooltipBtn?.setContent({ '.tooltip-inner': btnTitle })
+      },
+      { once: true }
+    )
+  })
+
+  return clipboard
+}
diff --git a/site/src/libs/highlight.ts b/site/src/libs/highlight.ts
new file mode 100644 (file)
index 0000000..bb6ae00
--- /dev/null
@@ -0,0 +1,65 @@
+import { codeToHtml, type ShikiTransformer } from 'shiki'
+import bootstrapLight from 'bootstrap-vscode-theme/themes/bootstrap-light.json'
+import bootstrapDark from 'bootstrap-vscode-theme/themes/bootstrap-dark.json'
+
+const classTransformer: ShikiTransformer = {
+  name: 'class-name-transformer',
+  pre(node) {
+    const existingClasses = (node.properties?.className as string[]) || []
+    node.properties.className = existingClasses.map((cls) => {
+      if (typeof cls === 'string') {
+        return cls.replace(/shiki/g, 'astro-code')
+      }
+      return cls
+    })
+  }
+}
+
+const lineWrapperTransformer: ShikiTransformer = {
+  name: 'line-wrapper',
+  line(node) {
+    const hasOnlyComments = node.children.every(
+      (child) =>
+        child.type === 'element' &&
+        child.properties?.class &&
+        Array.isArray(child.properties.class) &&
+        child.properties.class.some((cls) => typeof cls === 'string' && cls.includes('comment'))
+    )
+
+    if (!hasOnlyComments) {
+      node.properties = node.properties || {}
+      node.properties.class = node.properties.class ? `${node.properties.class} line` : 'line'
+    }
+  }
+}
+
+function replaceShikiClasses(html: string): string {
+  return html
+    .replace(/class=(["'])shiki(\s+)/g, 'class=$1astro-code$2')
+    .replace(/class=(["'])shiki(["'])/g, 'class=$1astro-code$2')
+    .replace(/shiki-themes/g, 'astro-code-themes')
+}
+
+export async function highlightCode(
+  code: string,
+  lang: string,
+  extraTransformers: ShikiTransformer[] = []
+): Promise<string> {
+  const shouldWrapLines = ['bash', 'sh', 'powershell'].includes(lang)
+
+  const transformers: ShikiTransformer[] = [...extraTransformers, classTransformer]
+  if (shouldWrapLines) {
+    transformers.push(lineWrapperTransformer)
+  }
+
+  const highlighted = await codeToHtml(code, {
+    lang,
+    themes: {
+      light: bootstrapLight as any,
+      dark: bootstrapDark as any
+    },
+    transformers
+  })
+
+  return replaceShikiClasses(highlighted)
+}
index db6466cec2080c461188b286049335bf33e84cdc..1218783bfafdeccc5fc8749f98497672d5ffe7a1 100644 (file)
@@ -42,7 +42,7 @@
   }
 
   .highlight-toolbar {
-    padding-block: .375rem;
+    padding-block: .5rem;
     padding-inline-start: var(--bd-example-padding);
     padding-inline-end: calc(var(--bd-example-padding) - .5em);
     background-color: var(--bs-bg-1);