@expressive-code/plugin-frames
Version:
Frames plugin for Expressive Code. Wraps code blocks in a styled editor or terminal frame with support for titles, multiple tabs and more.
595 lines (555 loc) • 23.6 kB
JavaScript
// src/index.ts
import { PluginTexts } from "@expressive-code/core";
import { h } from "@expressive-code/core/hast";
// src/styles.ts
import { PluginStyleSettings, codeLineClass, createInlineSvgUrl, multiplyAlpha, onBackground, setLuminance } from "@expressive-code/core";
var framesStyleSettings = new PluginStyleSettings({
defaultValues: {
frames: {
shadowColor: ({ theme, resolveSetting }) => theme.colors["widget.shadow"] || multiplyAlpha(resolveSetting("borderColor"), 0.75),
frameBoxShadowCssValue: ({ resolveSetting }) => `0.1rem 0.1rem 0.2rem ${resolveSetting("frames.shadowColor")}`,
editorActiveTabBackground: ({ theme }) => theme.colors["tab.activeBackground"],
editorActiveTabForeground: ({ theme }) => theme.colors["tab.activeForeground"],
editorActiveTabBorderColor: "transparent",
editorActiveTabIndicatorHeight: ({ resolveSetting }) => resolveSetting("borderWidth"),
editorActiveTabIndicatorTopColor: ({ theme }) => theme.colors["tab.activeBorderTop"],
editorActiveTabIndicatorBottomColor: ({ theme }) => theme.colors["tab.activeBorder"],
editorTabsMarginInlineStart: "0",
editorTabsMarginBlockStart: "0",
editorTabBorderRadius: ({ resolveSetting }) => resolveSetting("borderRadius"),
editorTabBarBackground: ({ theme }) => theme.colors["editorGroupHeader.tabsBackground"],
editorTabBarBorderColor: ({ resolveSetting }) => resolveSetting("borderColor"),
editorTabBarBorderBottomColor: ({ theme }) => theme.colors["editorGroupHeader.tabsBorder"] || "transparent",
editorBackground: ({ resolveSetting }) => resolveSetting("codeBackground"),
terminalTitlebarDotsForeground: ({ resolveSetting }) => resolveSetting("frames.terminalTitlebarForeground"),
terminalTitlebarDotsOpacity: "0.15",
terminalTitlebarBackground: ({ theme }) => theme.colors["titleBar.activeBackground"] || theme.colors["editorGroupHeader.tabsBackground"],
terminalTitlebarForeground: ({ theme }) => theme.colors["titleBar.activeForeground"],
terminalTitlebarBorderBottomColor: ({ theme, resolveSetting }) => theme.colors["titleBar.border"] || onBackground(resolveSetting("borderColor"), theme.type === "dark" ? "#000000bf" : "#ffffffbf"),
terminalBackground: ({ theme }) => theme.colors["terminal.background"],
inlineButtonBackground: ({ resolveSetting }) => resolveSetting("frames.inlineButtonForeground"),
inlineButtonBackgroundIdleOpacity: "0",
inlineButtonBackgroundHoverOrFocusOpacity: "0.2",
inlineButtonBackgroundActiveOpacity: "0.3",
inlineButtonForeground: ({ resolveSetting }) => resolveSetting("codeForeground"),
inlineButtonBorder: ({ resolveSetting }) => resolveSetting("frames.inlineButtonForeground"),
inlineButtonBorderOpacity: "0.4",
tooltipSuccessBackground: ({ theme }) => setLuminance(theme.colors["terminal.ansiGreen"] || "#0dbc79", 0.18),
tooltipSuccessForeground: "white",
copyIcon: createInlineSvgUrl([
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.75'>`,
`<path d='M3 19a2 2 0 0 1-1-2V2a2 2 0 0 1 1-1h13a2 2 0 0 1 2 1'/>`,
`<rect x='6' y='5' width='16' height='18' rx='1.5' ry='1.5'/>`,
`</svg>`
]),
terminalIcon: createInlineSvgUrl([
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 60 16' preserveAspectRatio='xMidYMid meet'>`,
`<circle cx='8' cy='8' r='8'/>`,
`<circle cx='30' cy='8' r='8'/>`,
`<circle cx='52' cy='8' r='8'/>`,
`</svg>`
])
}
},
preventUnitlessValues: ["frames.editorActiveTabIndicatorHeight", "frames.editorTabBorderRadius"]
});
function getFramesBaseStyles({ cssVar }, options) {
const tabBarBackground = [
`linear-gradient(to top, ${cssVar("frames.editorTabBarBorderBottomColor")} ${cssVar("borderWidth")}, transparent ${cssVar("borderWidth")})`,
`linear-gradient(${cssVar("frames.editorTabBarBackground")}, ${cssVar("frames.editorTabBarBackground")})`
].join(",");
const frameStyles = `.frame {
all: unset;
position: relative;
display: block;
--header-border-radius: calc(${cssVar("borderRadius")} + ${cssVar("borderWidth")});
--tab-border-radius: calc(${cssVar("frames.editorTabBorderRadius")} + ${cssVar("borderWidth")});
--button-spacing: 0.4rem;
--code-background: ${cssVar("frames.editorBackground")};
border-radius: var(--header-border-radius);
box-shadow: ${cssVar("frames.frameBoxShadowCssValue")};
.header {
display: none;
z-index: 1;
position: relative;
border-radius: var(--header-border-radius) var(--header-border-radius) 0 0;
}
/* Styles to apply if we have a title bar or tab bar */
&.has-title,
&.is-terminal {
& pre, & code {
border-top: none;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
}
/* Prevent empty window titles from collapsing in height */
.title:empty:before {
content: '\\a0';
}
/* Editor tab bar */
&.has-title:not(.is-terminal) {
--button-spacing: calc(1.9rem + 2 * (${cssVar("uiPaddingBlock")} + ${cssVar("frames.editorActiveTabIndicatorHeight")}));
/* Active editor tab */
& .title {
position: relative;
color: ${cssVar("frames.editorActiveTabForeground")};
background: ${cssVar("frames.editorActiveTabBackground")};
background-clip: padding-box;
margin-block-start: ${cssVar("frames.editorTabsMarginBlockStart")};
padding: calc(${cssVar("uiPaddingBlock")} + ${cssVar("frames.editorActiveTabIndicatorHeight")}) ${cssVar("uiPaddingInline")};
border: ${cssVar("borderWidth")} solid ${cssVar("frames.editorActiveTabBorderColor")};
border-radius: var(--tab-border-radius) var(--tab-border-radius) 0 0;
border-bottom: none;
overflow: hidden;
&::after {
content: '';
position: absolute;
pointer-events: none;
inset: 0;
border-top: ${cssVar("frames.editorActiveTabIndicatorHeight")} solid ${cssVar("frames.editorActiveTabIndicatorTopColor")};
border-bottom: ${cssVar("frames.editorActiveTabIndicatorHeight")} solid ${cssVar("frames.editorActiveTabIndicatorBottomColor")};
}
}
/* Tab bar background */
& .header {
display: flex;
background: ${tabBarBackground};
background-repeat: no-repeat;
padding-inline-start: ${cssVar("frames.editorTabsMarginInlineStart")};
&::before {
content: '';
position: absolute;
pointer-events: none;
inset: 0;
border: ${cssVar("borderWidth")} solid ${cssVar("frames.editorTabBarBorderColor")};
border-radius: inherit;
border-bottom: none;
}
}
}
/* Terminal window */
&.is-terminal {
--button-spacing: calc(1.9rem + ${cssVar("borderWidth")} + 2 * ${cssVar("uiPaddingBlock")});
--code-background: ${cssVar("frames.terminalBackground")};
/* Terminal title bar */
& .header {
display: flex;
align-items: center;
justify-content: center;
padding-block: ${cssVar("uiPaddingBlock")};
padding-block-end: calc(${cssVar("uiPaddingBlock")} + ${cssVar("borderWidth")});
position: relative;
font-weight: 500;
letter-spacing: 0.025ch;
color: ${cssVar("frames.terminalTitlebarForeground")};
background: ${cssVar("frames.terminalTitlebarBackground")};
border: ${cssVar("borderWidth")} solid ${cssVar("borderColor")};
border-bottom: none;
/* Display three dots at the left side of the header */
&::before {
content: '';
position: absolute;
pointer-events: none;
left: ${cssVar("uiPaddingInline")};
width: 2.1rem;
height: ${2.1 / 60 * 16}rem;
line-height: 0;
background-color: ${cssVar("frames.terminalTitlebarDotsForeground")};
opacity: ${cssVar("frames.terminalTitlebarDotsOpacity")};
-webkit-mask-image: ${cssVar("frames.terminalIcon")};
-webkit-mask-repeat: no-repeat;
mask-image: ${cssVar("frames.terminalIcon")};
mask-repeat: no-repeat;
}
/* Display a border below the header */
&::after {
content: '';
position: absolute;
pointer-events: none;
inset: 0;
border-bottom: ${cssVar("borderWidth")} solid ${cssVar("frames.terminalTitlebarBorderBottomColor")};
}
}
}
/* Code */
& pre {
background: var(--code-background);
}
}`;
const copyButtonStyles = `.copy {
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);
/* hide copy button when there is no JavaScript */
@media (scripting: none) {
display: none;
}
/* RTL support: Code is always LTR, so the inline copy 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: ${cssVar("frames.copyIcon")};
-webkit-mask-repeat: no-repeat;
mask-image: ${cssVar("frames.copyIcon")};
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 */
.copy 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 copy button shows a visible feedback message
*/
.frame:hover .copy button:not(:hover),
.frame:focus-within :focus-visible ~ .copy button:not(:hover),
.frame .copy .feedback.show ~ button:not(:hover) {
opacity: 0.75;
}
}
/* Increase end padding of the first line for the copy button */
:nth-child(1 of .${codeLineClass}) .code {
padding-inline-end: calc(2rem + ${cssVar("codePaddingInline")});
}`;
const styles = [
// Always add base frame styles
frameStyles,
// Add copy button styles if enabled
options.showCopyToClipboardButton ? copyButtonStyles : ""
];
return styles.join("\n");
}
// src/utils.ts
var frameTypes = ["code", "terminal", "none", "auto"];
function frameTypeFromString(input) {
if (input === "")
input = "none";
if (input === "editor")
input = "code";
if (input === "shell")
input = "terminal";
const frameType = input;
return frameTypes.includes(frameType) ? frameType : void 0;
}
var LanguageGroups = {
code: ["astro", "cjs", "htm", "html", "js", "jsx", "mjs", "svelte", "ts", "tsx", "typescript", "vb", "vue", "vue-html"],
terminal: ["ansi", "bash", "bat", "batch", "cmd", "console", "nu", "nushell", "powershell", "ps", "ps1", "psd1", "psm1", "sh", "shell", "shellscript", "shellsession", "zsh"],
data: ["csv", "env", "ini", "json", "toml", "xml", "yaml", "yml"],
styles: ["css", "less", "sass", "scss", "styl", "stylus", "xsl"],
textContent: ["markdown", "md", "mdx"]
};
var LanguagesWithFencedFrontmatter = ["astro", "markdown", "md", "mdx", "toml", "yaml", "yml"];
function isTerminalLanguage(language) {
return LanguageGroups.terminal.includes(language);
}
var getFileNameCommentRegExpString = () => [
// Start of line
`^`,
// Optional whitespace
`\\s*`,
// Mandatory comment start: `//`, `#` (but not `#!`), `<!--` or `/*`
`(?://|#(?!!)|<!--|/\\*)`,
// Optional whitespace
`\\s*`,
// Optional prefix before the file name:
// - This is intended to match strings like `File name:` or `Example :`,
// but not Windows drive letters like `C:`,
// or URL protocols like `https:`
// - We therefore expect the prefix to begin with any sequence of characters
// not starting with a letter + colon (to rule out Windows drive letters)
// - The prefix must then be followed by:
// - a Japanese colon (`\\uff1a`), or
// - a regular colon (`:`) not followed by `//` (to rule out URL protocols)
`(?:((?![a-z]:).*?)(?:\\uff1a|:(?!//)))?`,
// Optional whitespace
`\\s*`,
// Capture the file name
`(`,
// Optional Windows drive letter
`(?:[a-z]:)?`,
// Optional sequence of characters allowed in file paths
`[\\w./~%[\\]+\\\\-]*`,
// Optional dot and supported file extension
`(?:\\.(?:${Object.values(LanguageGroups).flat().sort().join("|")}))?`,
// End of file name capture
`)`,
// Optional whitespace
`\\s*`,
// Optional HTML or JS/CSS comment end (`-->` or `*/`)
`(?:-->|\\*/)?`,
// Optional whitespace
`\\s*`,
// End of line
`$`
].join("");
var fileNameCommentRegExp;
function getFileNameFromComment(line, lang) {
if (fileNameCommentRegExp === void 0) {
fileNameCommentRegExp = new RegExp(getFileNameCommentRegExpString(), "i");
}
const matches = fileNameCommentRegExp.exec(line);
const textBeforeFileName = matches?.[1] ?? "";
const possibleFileName = matches?.[2];
if (!possibleFileName)
return;
if (!possibleFileName.match(/[^.:/\\~]/))
return;
if (possibleFileName.match(/^\.{2,}(?!\/|\\)/))
return;
const languageGroup = Object.values(LanguageGroups).find((group) => group.includes(lang));
const fileNameWithoutPath = possibleFileName.replace(/^.*[/\\]/, "");
const fileExt = fileNameWithoutPath.match(/\.([^.]+)$/)?.[1];
const hasTypicalFileNameBeginning = possibleFileName.match(/^(\/|\\|\.[/\\]|~|[a-z]:).+/i);
const hasFileNameStartingWithDot = fileNameWithoutPath.startsWith(".");
const looksLikeSeparatedPath = (
// Contains path separators
possibleFileName.match(/[/\\]/) && // Also contains other characters (except path separators, numbers and dots)
possibleFileName.match(/[^/\\0-9.]/) && // Does not contain spaces
!possibleFileName.match(/\s/) && // Is all lowercase
possibleFileName === possibleFileName.toLowerCase()
);
const hasTypicalFileNamePattern = hasTypicalFileNameBeginning || hasFileNameStartingWithDot || looksLikeSeparatedPath;
if (hasTypicalFileNamePattern && (!textBeforeFileName.length || languageGroup === LanguageGroups.terminal)) {
return possibleFileName;
}
if (!fileExt || languageGroup && !languageGroup.includes(fileExt))
return;
return possibleFileName;
}
function extractFileNameFromCodeBlock(codeBlock) {
let extractedFileName = void 0;
let lineIdx = codeBlock.getLines(0, 4).findIndex((line) => {
extractedFileName = getFileNameFromComment(line.text, codeBlock.language);
return !!extractedFileName;
});
if (!extractedFileName)
return;
codeBlock.deleteLine(lineIdx);
if (LanguagesWithFencedFrontmatter.includes(codeBlock.language)) {
const openingFence = lineIdx > 0 ? codeBlock.getLine(lineIdx - 1)?.text.trim() : void 0;
const closingFence = codeBlock.getLine(lineIdx)?.text.trim();
const isFrontmatterEmptyNow = openingFence === closingFence && ["---", "+++"].includes(openingFence ?? "");
if (isFrontmatterEmptyNow) {
lineIdx--;
codeBlock.deleteLine(lineIdx);
codeBlock.deleteLine(lineIdx);
}
}
if (codeBlock.getLine(lineIdx)?.text.trim().length === 0) {
codeBlock.deleteLine(lineIdx);
}
return extractedFileName;
}
// src/copy-js-module.min.ts
var copy_js_module_min_default = 'try{(()=>{function i(o){let e=document.createElement("pre");Object.assign(e.style,{opacity:"0",pointerEvents:"none",position:"absolute",overflow:"hidden",left:"0",top:"0",width:"20px",height:"20px",webkitUserSelect:"auto",userSelect:"all"}),e.ariaHidden="true",e.textContent=o,document.body.appendChild(e);let a=document.createRange();a.selectNode(e);let n=getSelection();if(!n)return!1;n.removeAllRanges(),n.addRange(a);let r=!1;try{r=document.execCommand("copy")}finally{n.removeAllRanges(),document.body.removeChild(e)}return r}async function l(o){let e=o.currentTarget,a=e.dataset,n=!1,r=a.code.replace(/\\u007f/g,`\n`);try{await navigator.clipboard.writeText(r),n=!0}catch{n=i(r)}if(!n||e.parentNode?.querySelector(".feedback"))return;let t=document.createElement("div");t.classList.add("feedback"),t.append(a.copied),e.before(t),t.offsetWidth,requestAnimationFrame(()=>t?.classList.add("show"));let c=()=>!t||t.classList.remove("show"),d=()=>{!t||parseFloat(getComputedStyle(t).opacity)>0||(t.remove(),t=void 0)};setTimeout(c,1500),setTimeout(d,2500),e.addEventListener("blur",c),t.addEventListener("transitioncancel",d),t.addEventListener("transitionend",d)}function s(o){o.querySelectorAll?.("[SELECTOR]").forEach(e=>e.addEventListener("click",l))}s(document);var u=new MutationObserver(o=>o.forEach(e=>e.addedNodes.forEach(a=>{s(a)})));u.observe(document.body,{childList:!0,subtree:!0});document.addEventListener("astro:page-load",()=>{s(document)});})();}catch(e){console.error("[EC] copy-js-module failed:",e)}';
// src/index.ts
var pluginFramesTexts = new PluginTexts({
terminalWindowFallbackTitle: "Terminal window",
copyButtonTooltip: "Copy to clipboard",
copyButtonCopied: "Copied!"
});
pluginFramesTexts.addLocale("de", {
terminalWindowFallbackTitle: "Terminal-Fenster",
copyButtonTooltip: "In die Zwischenablage kopieren",
copyButtonCopied: "Kopiert!"
});
function pluginFrames(options = {}) {
options = {
extractFileNameFromCode: true,
showCopyToClipboardButton: true,
removeCommentsWhenCopyingTerminalFrames: true,
...options
};
return {
name: "Frames",
styleSettings: framesStyleSettings,
baseStyles: (context) => getFramesBaseStyles(context, options),
jsModules: options.showCopyToClipboardButton ? [copy_js_module_min_default.replace(/\[SELECTOR\]/g, ".expressive-code .copy button")] : void 0,
hooks: {
preprocessMetadata: ({ codeBlock }) => {
const { metaOptions, props } = codeBlock;
props.title = metaOptions.getString("title") ?? props.title;
const frame = metaOptions.getString("frame");
if (frame !== void 0) {
const frameType = frameTypeFromString(frame);
if (frameType === void 0)
throw new Error(
`Invalid frame type \`${frame}\` found in code block meta string.
Valid frame types are: ${frameTypes.join(", ")}.`.replace(/\s+/g, " ")
);
props.frame = frameType;
}
},
preprocessCode: ({ codeBlock }) => {
const { props, language } = codeBlock;
if (props.title === void 0 && props.frame !== "none" && options.extractFileNameFromCode) {
props.title = extractFileNameFromCodeBlock(codeBlock);
}
if ((props.frame ?? "auto") === "auto" && isTerminalLanguage(language)) {
const titleIsFileName = props.title && getFileNameFromComment(`// ${props.title}`, language);
if (titleIsFileName || codeBlock.getLines(0, 4).some((line) => line.text.match(/^\s*#!/))) {
props.frame = "code";
}
}
},
postprocessRenderedBlock: ({ codeBlock, renderData, locale }) => {
const texts = pluginFramesTexts.get(locale);
const { title: titleText, frame = "auto" } = codeBlock.props;
const isTerminal = frame === "terminal" || frame === "auto" && isTerminalLanguage(codeBlock.language);
const visibleTitle = frame !== "none" && titleText || isTerminal ? [h("span", { className: "title" }, titleText || "")] : [];
const screenReaderTitle = !titleText && isTerminal ? [h("span", { className: "sr-only" }, texts.terminalWindowFallbackTitle)] : [];
const extraElements = [];
if (options.showCopyToClipboardButton) {
let codeToCopy = codeBlock.code;
if (options.removeCommentsWhenCopyingTerminalFrames && isTerminal) {
codeToCopy = codeToCopy.replace(/(?<=^|\n)\s*#.*($|\n+)/g, "").trim();
}
codeToCopy = codeToCopy.replace(/\n/g, "\x7F");
extraElements.push(
h("div", { className: "copy" }, [
h(
"button",
{
title: texts.copyButtonTooltip,
"data-copied": texts.copyButtonCopied,
"data-code": codeToCopy
},
[h("div")]
)
])
);
}
renderData.blockAst = h(
"figure",
{
className: [
"frame",
// If the code block is a terminal, add the `is-terminal` class
...isTerminal ? ["is-terminal"] : [],
// If the code block has a title, add the `has-title` class
...frame !== "none" && titleText ? ["has-title"] : []
]
},
[
h("figcaption", { className: "header" }, [...visibleTitle, ...screenReaderTitle]),
// Render the original code block
renderData.blockAst,
// Add any extra elements (e.g. copy button)
...extraElements
]
);
}
}
};
}
export {
LanguageGroups,
LanguagesWithFencedFrontmatter,
pluginFrames,
pluginFramesTexts
};
//# sourceMappingURL=index.js.map