import type {
ExpressiveCodeBlock,
ExpressiveCodePlugin,
ResolverContext,
} from '@expressive-code/core';
import { codeLineClass, PluginTexts } from '@expressive-code/core';
import { h } from '@expressive-code/core/hast';
import codeBlockButtonJsModule from './js-module.min';
const terminalLanguageGroups = [
'ansi',
'bash',
'bat',
'batch',
'cmd',
'console',
'nu',
'nushell',
'powershell',
'ps',
'ps1',
'psd1',
'psm1',
'sh',
'shell',
'shellscript',
'shellsession',
'zsh',
];
export function isTerminalLanguage(language: string) {
return terminalLanguageGroups.includes(language);
}
export const frameTypes = ['code', 'terminal', 'none', 'auto'] as const;
export type FrameType = (typeof frameTypes)[number];
export function getFramesBaseStyles(
name: string,
svg: string,
{ cssVar }: ResolverContext
) {
const escapedSvg = svg.replace(//g, '%3E');
const svgUrl = `url("data:image/svg+xml,${escapedSvg}")`;
const buttonStyles = `.${name} {
display: flex;
gap: 0.25rem;
flex-direction: row;
position: absolute;
inset-block-start: calc(${cssVar('borderWidth')} + var(--button-spacing));
inset-inline-end: calc(${cssVar('borderWidth')} + ${cssVar(
'uiPaddingInline'
)} / 2 + var(--button-spacing));
/* hide code block button when there is no JavaScript */
@media (scripting: none) {
display: none;
}
/* RTL support: Code is always LTR, so the inline code block button
must match this to avoid overlapping the start of lines */
direction: ltr;
unicode-bidi: isolate;
button {
position: relative;
align-self: flex-end;
margin: 0;
padding: 0;
border: none;
border-radius: 0.2rem;
z-index: 1;
cursor: pointer;
transition-property: opacity, background, border-color;
transition-duration: 0.2s;
transition-timing-function: cubic-bezier(0.25, 0.46, 0.45, 0.94);
/* Mobile-first styles: Make the button visible and tappable */
width: 2.5rem;
height: 2.5rem;
background: var(--code-background);
opacity: 0.75;
div {
position: absolute;
inset: 0;
border-radius: inherit;
background: ${cssVar('frames.inlineButtonBackground')};
opacity: ${cssVar('frames.inlineButtonBackgroundIdleOpacity')};
transition-property: inherit;
transition-duration: inherit;
transition-timing-function: inherit;
}
&::before {
content: '';
position: absolute;
pointer-events: none;
inset: 0;
border-radius: inherit;
border: ${cssVar('borderWidth')} solid ${cssVar(
'frames.inlineButtonBorder'
)};
opacity: ${cssVar('frames.inlineButtonBorderOpacity')};
}
&::after {
content: '';
position: absolute;
pointer-events: none;
inset: 0;
background-color: ${cssVar('frames.inlineButtonForeground')};
-webkit-mask-image: ${svgUrl};
-webkit-mask-repeat: no-repeat;
mask-image: ${svgUrl};
mask-repeat: no-repeat;
margin: 0.475rem;
line-height: 0;
}
/*
On hover or focus, make the button fully opaque
and set hover/focus background opacity
*/
&:hover, &:focus:focus-visible {
opacity: 1;
div {
opacity: ${cssVar(
'frames.inlineButtonBackgroundHoverOrFocusOpacity'
)};
}
}
/* On press, set active background opacity */
&:active {
opacity: 1;
div {
opacity: ${cssVar(
'frames.inlineButtonBackgroundActiveOpacity'
)};
}
}
}
.feedback {
--tooltip-arrow-size: 0.35rem;
--tooltip-bg: ${cssVar('frames.tooltipSuccessBackground')};
color: ${cssVar('frames.tooltipSuccessForeground')};
pointer-events: none;
user-select: none;
-webkit-user-select: none;
position: relative;
align-self: center;
background-color: var(--tooltip-bg);
z-index: 99;
padding: 0.125rem 0.75rem;
border-radius: 0.2rem;
margin-inline-end: var(--tooltip-arrow-size);
opacity: 0;
transition-property: opacity, transform;
transition-duration: 0.2s;
transition-timing-function: ease-in-out;
transform: translate3d(0, 0.25rem, 0);
&::after {
content: '';
position: absolute;
pointer-events: none;
top: calc(50% - var(--tooltip-arrow-size));
inset-inline-end: calc(-2 * (var(--tooltip-arrow-size) - 0.5px));
border: var(--tooltip-arrow-size) solid transparent;
border-inline-start-color: var(--tooltip-bg);
}
&.show {
opacity: 1;
transform: translate3d(0, 0, 0);
}
}
}
@media (hover: hover) {
/* If a mouse is available, hide the button by default and make it smaller */
.${name} button {
opacity: 0;
width: 2rem;
height: 2rem;
}
/* Reveal the non-hovered button in the following cases:
- when the frame is hovered
- when a sibling inside the frame is focused
- when the code block button shows a visible feedback message
*/
.frame:hover .${name} button:not(:hover),
.frame:focus-within :focus-visible ~ .${name} button:not(:hover),
.frame .${name} .feedback.show ~ button:not(:hover) {
opacity: 0.75;
}
}
/* Increase end padding of the first line for the code block button */
:nth-child(1 of .${codeLineClass}) .code {
padding-inline-end: calc(2rem + ${cssVar('codePaddingInline')});
}`;
return buttonStyles;
}
export interface PluginFramesProps {
/**
* The code block's title. For terminal frames, this is displayed as the terminal window title,
* and for code frames, it's displayed as the file name in an open file tab.
*
* If no title is given, the plugin will try to automatically extract a title from a
* [file name comment](https://expressive-code.com/key-features/frames/#file-name-comments)
* inside your code, unless disabled by the `extractFileNameFromCode` option.
*/
title: string;
/**
* Allows you to override the automatic frame type detection for a code block.
*
* The supported values are `code`, `terminal`, `none` and `auto`.
*
* @default `auto`
*/
frame: FrameType;
}
export interface TextMap {
buttonTooltip: string;
buttonExecuted: string;
}
export const defaultPluginCodeBlockButtonTexts = new PluginTexts({
buttonTooltip: 'Run in terminal',
buttonExecuted: 'Command executing...',
});
const svg = [
``,
].join('');
export function pluginCodeBlockButton(
name: string = 'runInTerminal',
iconSvg: string = svg,
pluginCodeBlockButtonTexts: PluginTexts = defaultPluginCodeBlockButtonTexts,
shouldShowButton: (
codeBlock: ExpressiveCodeBlock,
isTerminal: boolean
) => boolean = () => true,
addAttributes: (
codeBlock: ExpressiveCodeBlock,
isTerminal: boolean
) => Record = () => ({})
): ExpressiveCodePlugin {
return {
name,
baseStyles: (context: any) => getFramesBaseStyles(name, iconSvg, context),
jsModules: [
codeBlockButtonJsModule
.replace('[SELECTOR]', `.expressive-code .${name} button`)
.replace('[BUTTON_NAME]', name),
],
hooks: {
postprocessRenderedBlock: ({ codeBlock, renderData, locale }) => {
// get text strings for the current locale
const texts = pluginCodeBlockButtonTexts.get(locale);
// retrieve information about the current block
const { frame = 'auto' } = codeBlock.props;
const isTerminal =
frame === 'terminal' ||
(frame === 'auto' && isTerminalLanguage(codeBlock.language));
const extraElements: any[] = [];
if (shouldShowButton(codeBlock, isTerminal)) {
extraElements.push(
h('div', { className: name }, [
h(
'button',
{
title: texts.buttonTooltip,
'data-copied': texts.buttonExecuted,
...addAttributes(codeBlock, isTerminal),
},
[h('div')]
),
])
);
renderData.blockAst.children.push(...extraElements);
}
},
},
};
}