diff --git a/docusaurus.config.ts b/docusaurus.config.ts index b99932f..ccae5e3 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -13,6 +13,7 @@ import remarkIncludes from "./server/remark-includes"; import remarkVariables from "./server/remark-variables"; import remarkUpdateTags from "./server/remark-update-tags"; import remarkTOC from "./server/remark-toc"; +import remarkCodeSnippet from "./server/remark-code-snippet"; import { fetchVideoMeta } from "./server/youtube-meta"; import { getRedirects } from "./server/redirects"; import { @@ -176,6 +177,12 @@ const config: Config = { loadConfig(getVersionFromVFile(vfile)).variables, }, ], + [ + remarkCodeSnippet, + { + langs: ["code"], + }, + ], [ remarkUpdateAssetPaths, { diff --git a/server/remark-code-snippet.ts b/server/remark-code-snippet.ts new file mode 100644 index 0000000..28a5637 --- /dev/null +++ b/server/remark-code-snippet.ts @@ -0,0 +1,307 @@ +/* + * This plugin will transform code snippets like this: + * + * ```code + * # Copy and Paste the below and run on the Teleport Auth server. + * $ cat > api-role.yaml < + (node: MdxastNode): node is MdastCode => + node.type === "code" && langs.includes(node.lang); + +const getTextChildren = (contentValue: string): MdxastNode => ({ + type: "text", + value: contentValue, +}); + +const getVariableNode = ( + value: string, + isGlobal: boolean, + description: string | boolean +): MdxJsxFlowElement => { + const descriptionValue = description ? description : ""; + + return { + type: "mdxJsxFlowElement", + name: "var", + attributes: [ + { type: "mdxJsxAttribute", name: "name", value }, + { + type: "mdxJsxAttribute", + name: "isGlobal", + value: isGlobal.toString(), + }, + { + type: "mdxJsxAttribute", + name: "description", + value: `${descriptionValue}`, + }, + ], + children: [], + }; +}; + +const getChildrenNode = (content: string): MdxastNode[] => { + const hasVariable = content?.includes(")/gm); + const firstPartLine = content.split("")[i + 1]; + if (nextPartLine?.includes(" { + const children = getChildrenNode(content); + + return { + type: "mdxJsxFlowElement", + name: "command", + attributes: [], + children: [ + { + type: "mdxJsxFlowElement", + name: "commandline", + attributes: [ + { + type: "mdxJsxAttribute", + name: "data-content", + value: `${prefix} `, + }, + ], + children: children, + }, + ], + }; +}; + +const getLineNode = (content: string, attributes = []): MdxJsxFlowElement => { + const children = getChildrenNode(content); + + return { + type: "mdxJsxFlowElement", + name: "commandline", + attributes, + children: children, + }; +}; + +const getCommentNode = ( + content: string, + attributes: MdxJsxAttribute[] = [] +): MdxJsxFlowElement => ({ + type: "mdxJsxFlowElement", + name: "commandcomment", + attributes, + children: [ + { + type: "text", + value: content, + }, + ], +}); + +const getCodeLine = ( + content: string, + attributes: MdxJsxAttribute[] = [] +): MdxJsxFlowElement => { + const children = getChildrenNode(content); + + return { + type: "mdxJsxFlowElement", + name: "codeline", + attributes, + children: children, + }; +}; + +export interface RemarkCodeSnippetOptions { + langs: string[]; + lint?: boolean; + resolve?: boolean; +} + +export default function remarkCodeSnippet({ + langs = ["code"], + lint = false, +}: RemarkCodeSnippetOptions): Transformer { + return (root, vfile) => { + visit( + root, + isCode(langs), + (node: MdastCode, index, parent: MdxAnyElement) => { + const content: string = node.value; + const codeLines = content.split("\n"); + const children = []; + + for (let i = 0; i < codeLines.length; i++) { + const hasLeadingDollar = codeLines[i][0] === "$"; + const hasHost = codeLines[i][0] === ">" && codeLines[i].includes("$"); + const hasGrate = codeLines[i][0] === "#"; + const trimmedValue = codeLines[i].slice(1).trim(); + + if (hasLeadingDollar) { + children.push(getCommandNode(trimmedValue)); + + const commandArrayElem = children[children.length - 1].children; + + if (codeLines[i].includes("<<")) { + let heredocMark = codeLines[i].match(/[^<<]*$/)[0].trim(); + + if (heredocMark.includes(">")) { + heredocMark = heredocMark.split(">")[0].trim(); + } + + if (heredocMark.includes("'")) { + heredocMark = heredocMark.match(/'(.*?)'/)[1]; + } + + if (heredocMark.indexOf("-") === 0) { + heredocMark = heredocMark.slice(1); + } + + while (codeLines[i] && codeLines[i] !== heredocMark) { + commandArrayElem.push(getLineNode(codeLines[i + 1])); + + i++; + } + + if (codeLines.every((line) => line !== heredocMark)) { + if (lint) { + vfile.fail( + "No closing line for heredoc format", + node, + RULE_ID + ); + } else { + console.error( + `ERROR: no closing line ${heredocMark} in the file ${vfile.path}` + ); + } + } + } + + let hasNextLine = codeLines[i]?.[codeLines[i]?.length - 1] === "\\"; + + while (hasNextLine) { + commandArrayElem.push(getLineNode(codeLines[i + 1])); + + i++; + hasNextLine = + Boolean(codeLines[i]) && + codeLines[i][codeLines[i].length - 1] === "\\"; + + if (lint && !codeLines[i]) { + vfile.fail( + "The last string in the multiline command has to be without symbol \\", + node, + RULE_ID + ); + } + } + } else if (hasHost) { + const parts = codeLines[i].split("$"); + const ghostText = `${parts[0].slice(1).trim()} $`; + const commandText = parts[1].trim(); + + children.push(getCommandNode(commandText, ghostText)); + } else if (hasGrate) { + if (codeLines[i][1] === "#") { + children.push(getCommentNode(codeLines[i].slice(1))); + } else { + children.push( + getCommentNode(trimmedValue, [ + { + type: "mdxJsxAttribute", + name: "data-type", + value: "descr", + }, + ]) + ); + } + } else { + children.push(getCodeLine(codeLines[i])); + // This is an empty code line. Make sure it renders correctly by + // pushing a
element after it. Otherwise, this becomes an + // empty "CodeLine" element that does not display unless we apply + // styling that could have unintended effects on other CodeLines. + if (codeLines[i] == "") { + children.push({ + type: "mdxJsxFlowElement", + name: "br", + attributes: [], + }); + } + } + } + + parent.children[index] = { + type: "mdxJsxFlowElement", + name: "snippet", + attributes: [], + children, + } as MdxJsxFlowElement; + } + ); + }; +} diff --git a/src/components/Command/Command.module.css b/src/components/Command/Command.module.css new file mode 100644 index 0000000..6ada577 --- /dev/null +++ b/src/components/Command/Command.module.css @@ -0,0 +1,102 @@ +@keyframes shift-button { + 0% { + opacity: 0; + transform: translateX(3px); + } + + 100% { + opacity: 1; + transform: translateX(0); + } +} + +.button { + position: absolute; + top: 0; + left: 0; + display: none; + align-items: center; + margin: 0; + padding: var(--m-0-5) 6px; + color: var(--color-light-gray); + background-color: var(--color-darkest); + opacity: 0; + transform: translateX(3px); + transition: color var(--t-interaction); + animation-name: shift-button; + animation-duration: 0.3s; + animation-fill-mode: forwards; + cursor: pointer; + appearance: none; + + &:hover, + &:focus, + &:active { + color: white; + outline: none; + } +} + +.line { + display: block; +} + +.command { + position: relative; + flex-direction: column; + box-sizing: border-box; + margin: 0 calc(0px - var(--m-2)); + padding: 0 var(--m-2); + line-height: var(--lh-md); + color: var(--color-white); + background-color: var(--color-code); + transition: background-color var(--t-interaction); + + @media (--sm-scr) { + font-size: var(-fs-text-sm); + } + + @media (--md-scr) { + font-size: var(-fs-text-md); + } + + & .line:first-of-type::before { + content: attr(data-content); + } + + & :global { + & .wrapper-input input { + color: var(--color-light-blue); + background-color: transparent; + } + + & .wrapper-input input::placeholder { + color: var(--color-light-gray); + } + + & .wrapper-input svg { + color: var(--color-light-blue); + } + } + + &:hover, + &:focus { + background-color: var(--color-darkest); + } + + &:hover .button, + &:focus .button { + display: flex; + } +} + +.comment { + margin: 0; + font-size: var(--fs-text-md); + line-height: var(--lh-md); + + &[data-type="descr"] { + white-space: break-spaces; + word-break: break-word; + } +} diff --git a/src/components/Command/Command.tsx b/src/components/Command/Command.tsx new file mode 100644 index 0000000..fff9dcf --- /dev/null +++ b/src/components/Command/Command.tsx @@ -0,0 +1,68 @@ +import { useRef, useState, useCallback, ReactNode } from "react"; +import Icon from "/src/components/Icon"; +import HeadlessButton from "/src/components/HeadlessButton"; +import { toCopyContent } from "/utils/general"; +import styles from "./Command.module.css"; + +const TIMEOUT = 1000; + +export interface CommandLineProps { + children: ReactNode; +} + +export function CommandLine(props: CommandLineProps) { + return ; +} + +export interface CommandCommentProps { + children: ReactNode; +} + +export function CommandComment(props: CommandCommentProps) { + return

; +} + +export interface CommandProps { + children: ReactNode; +} + +export default function Command({ children, ...props }: CommandProps) { + const [isCopied, setIsCopied] = useState(false); + const codeRef = useRef(); + + const handleCopy = useCallback(() => { + if (!navigator.clipboard) { + return; + } + + if (codeRef.current) { + const procesedInnerText = toCopyContent(codeRef.current, [ + "." + styles.line, + ]); + + navigator.clipboard.writeText(procesedInnerText); + setIsCopied(true); + + setTimeout(() => { + setIsCopied(false); + }, TIMEOUT); + } + }, []); + + return ( +

+ + {isCopied ? ( + + ) : ( + + )} + + {children} +
+ ); +} diff --git a/src/components/Command/index.ts b/src/components/Command/index.ts new file mode 100644 index 0000000..6e95b36 --- /dev/null +++ b/src/components/Command/index.ts @@ -0,0 +1 @@ +export { default, CommandLine, CommandComment } from "./Command"; diff --git a/src/components/Snippet/Snippet.module.css b/src/components/Snippet/Snippet.module.css new file mode 100644 index 0000000..e2d1c08 --- /dev/null +++ b/src/components/Snippet/Snippet.module.css @@ -0,0 +1,10 @@ +.wrapper { + & pre { + color: #A9A590; + } +} + +.scroll { + width: max-content; + min-width: 100%; +} diff --git a/src/components/Snippet/Snippet.tsx b/src/components/Snippet/Snippet.tsx new file mode 100644 index 0000000..8da449d --- /dev/null +++ b/src/components/Snippet/Snippet.tsx @@ -0,0 +1,14 @@ +import Pre from "/src/theme/MDXComponents/Pre"; +import styles from "./Snippet.module.css"; + +export interface SnippetProps { + children: React.ReactNode; +} + +export default function Snippet({ children }: SnippetProps) { + return ( +
+      
{children}
+
+ ); +} diff --git a/src/components/Snippet/index.ts b/src/components/Snippet/index.ts new file mode 100644 index 0000000..6f0cafa --- /dev/null +++ b/src/components/Snippet/index.ts @@ -0,0 +1 @@ +export { default } from "./Snippet"; diff --git a/src/theme/MDXComponents/Code.tsx b/src/theme/MDXComponents/Code.tsx index 0916cfb..bdb7bb9 100644 --- a/src/theme/MDXComponents/Code.tsx +++ b/src/theme/MDXComponents/Code.tsx @@ -1,9 +1,10 @@ import cn from "classnames"; import { DetailedHTMLProps, HTMLAttributes } from "react"; import styles from "./Code.module.css"; +import codeBlockStyles from "./CodeBlock.module.css"; export const CodeLine = (props: CodeLineProps) => { - return ; + return ; }; const isHLJSNode = (className?: string) => diff --git a/src/theme/MDXComponents/Pre.tsx b/src/theme/MDXComponents/Pre.tsx index 620641f..783f266 100644 --- a/src/theme/MDXComponents/Pre.tsx +++ b/src/theme/MDXComponents/Pre.tsx @@ -5,6 +5,8 @@ import HeadlessButton from "/src/components/HeadlessButton"; import { toCopyContent } from "/utils/general"; import styles from "./Pre.module.css"; import codeBlockStyles from "./CodeBlock.module.css"; +import commandStyles from "/src/components/Command/Command.module.css"; +import codeBlockStyles from "./CodeBlock.module.css"; const TIMEOUT = 1000; @@ -41,6 +43,15 @@ const Pre = ({ children, className }: CodeProps) => { ".hljs", ]; + // If copyText includes at least one CommandLine, the intention is for + // users to copy commands and not example outputs (CodeLines). If there + // are no CommandLines, it is fine to copy the CodeLines. + if (copyText.getElementsByClassName(commandStyles.line).length > 0) { + classesToCopy.push("." + commandStyles.line); + } else { + classesToCopy.push("." + codeBlockStyles.line); + } + document.body.appendChild(copyText); const processedInnerText = toCopyContent(copyText, classesToCopy); diff --git a/src/theme/MDXComponents/index.tsx b/src/theme/MDXComponents/index.tsx index 30f4249..1f42d95 100644 --- a/src/theme/MDXComponents/index.tsx +++ b/src/theme/MDXComponents/index.tsx @@ -13,6 +13,8 @@ import MDXUl from "@theme/MDXComponents/Ul"; import MDXLi from "@theme/MDXComponents/Li"; import MDXImg from "@theme/MDXComponents/Img"; import Mermaid from "@theme/Mermaid"; +import Command, { CommandLine, CommandComment } from "/src/components/Command"; +import Snippet from "/src/components/Snippet"; import type { MDXComponentsObject } from "@theme/MDXComponents"; @@ -26,6 +28,9 @@ const MDXComponents: MDXComponentsObject = { Admonition, code: Code, codeline: CodeLine, + command: Command, + commandcomment: CommandComment, + commandline: CommandLine, details: MDXDetails, // For MD mode support, see https://github.com/facebook/docusaurus/issues/9092#issuecomment-1602902274 h1: (props: ComponentProps<"h1">) => , h2: (props: ComponentProps<"h2">) => , @@ -37,6 +42,7 @@ const MDXComponents: MDXComponentsObject = { li: MDXLi, mermaid: Mermaid, pre: MDXPre, + snippet: Snippet, ul: MDXUl, }; diff --git a/yarn.lock b/yarn.lock index dd9f331..815129e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8227,6 +8227,11 @@ he@^1.2.0: resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== +highlight.js@~11.10.0: + version "11.10.0" + resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.10.0.tgz#6e3600dc4b33d6dc23d5bd94fbf72405f5892b92" + integrity sha512-SYVnVFswQER+zu1laSya563s+F8VDGt7o35d4utbamowvUNLLMovFqwCLSocpZTz3MgaSRA1IbqRWZv97dtErQ== + highlight.js@~11.9.0: version "11.9.0" resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.9.0.tgz#04ab9ee43b52a41a047432c8103e2158a1b8b5b0" @@ -9244,7 +9249,7 @@ lowercase-keys@^3.0.0: resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-3.0.0.tgz#c5e7d442e37ead247ae9db117a9d0a467c89d4f2" integrity sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ== -lowlight@^3.0.0, lowlight@^3.1.0: +lowlight@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/lowlight/-/lowlight-3.1.0.tgz#aa394c5f3a7689fce35fa49a7c850ba3ead4f590" integrity sha512-CEbNVoSikAxwDMDPjXlqlFYiZLkDJHwyGu/MfOsJnF3d7f3tds5J3z8s/l9TMXhzfsJCCJEAsD78842mwmg0PQ== @@ -9253,6 +9258,15 @@ lowlight@^3.0.0, lowlight@^3.1.0: devlop "^1.0.0" highlight.js "~11.9.0" +lowlight@^3.1.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/lowlight/-/lowlight-3.2.0.tgz#f06152e211b9caa09ff65e22e4bf6d085782f6cc" + integrity sha512-8Me8xHTCBYEXwcJIPcurnXTeERl3plwb4207v6KPye48kX/oaYDiwXy+OCm3M/pyAPUrkMhalKsbYPm24f/UDg== + dependencies: + "@types/hast" "^3.0.0" + devlop "^1.0.0" + highlight.js "~11.10.0" + lru-cache@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920"