---
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 {
* 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
code,
containerClass,
'data-language': dataLanguage,
+ file,
fileMatch,
filePath,
lang,
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
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
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>
</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>
)}
</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>
)}
</>
) : (
- <div class="bd-code-snippet">
+ <div class:list={["bd-code-snippet", containerClass]}>
{!noToolbar && (
<div class="hstack highlight-toolbar align-items-center">
{highlightedTabs ? (
</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>
---
-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 {
/**
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>
background-color: transparent !important;
}
- .code-copy .btn-clipboard {
+ .code-copy :global([data-bd-clipboard]) {
position: static;
display: flex;
align-items: center;
--- /dev/null
+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)
+}