Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions site/src/components/icons/Symbols.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
233 changes: 46 additions & 187 deletions site/src/components/shortcodes/Code.astro
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -75,6 +78,7 @@ const {
code,
containerClass,
'data-language': dataLanguage,
file,
fileMatch,
filePath,
lang,
Expand All @@ -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
Expand Down Expand Up @@ -121,219 +129,66 @@ 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
const codeBlocks = parentContainer?.querySelectorAll('.code-tab-content')

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>

Expand All @@ -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>
)}
Expand All @@ -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>
Expand All @@ -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 ? (
Expand All @@ -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>
Expand Down
Loading
Loading