diff --git a/site/src/components/Markdown/Markdown.stories.tsx b/site/src/components/Markdown/Markdown.stories.tsx index d4adce530e..37a0670c73 100644 --- a/site/src/components/Markdown/Markdown.stories.tsx +++ b/site/src/components/Markdown/Markdown.stories.tsx @@ -74,3 +74,24 @@ export const WithTable: Story = { | cell 1 | cell 2 | 3 | 4 | `, }, }; + +export const GFMAlerts: Story = { + args: { + children: ` +> [!NOTE] +> Useful information that users should know, even when skimming content. + +> [!TIP] +> Helpful advice for doing things better or more easily. + +> [!IMPORTANT] +> Key information users need to know to achieve their goal. + +> [!WARNING] +> Urgent info that needs immediate user attention to avoid problems. + +> [!CAUTION] +> Advises about risks or negative outcomes of certain actions. + `, + }, +}; diff --git a/site/src/components/Markdown/Markdown.tsx b/site/src/components/Markdown/Markdown.tsx index a9bac7c6ad..b68919dce5 100644 --- a/site/src/components/Markdown/Markdown.tsx +++ b/site/src/components/Markdown/Markdown.tsx @@ -8,12 +8,20 @@ import { TableRow, } from "components/Table/Table"; import isEqual from "lodash/isEqual"; -import { type FC, memo } from "react"; +import { + type FC, + type HTMLProps, + type ReactElement, + type ReactNode, + isValidElement, + memo, +} from "react"; import ReactMarkdown, { type Options } from "react-markdown"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { dracula } from "react-syntax-highlighter/dist/cjs/styles/prism"; import gfm from "remark-gfm"; import colors from "theme/tailwindColors"; +import { cn } from "utils/cn"; interface MarkdownProps { /** @@ -114,6 +122,30 @@ export const Markdown: FC = (props) => { return {children}; }, + /** + * 2025-02-10 - The RemarkGFM plugin that we use currently doesn't have + * support for special alert messages like this: + * ``` + * > [!IMPORTANT] + * > This module will only work with Git versions >=2.34, and... + * ``` + * Have to intercept all blockquotes and see if their content is + * formatted like an alert. + */ + blockquote: (parseProps) => { + const { node: _node, children, ...renderProps } = parseProps; + const alertContent = parseChildrenAsAlertContent(children); + if (alertContent === null) { + return
{children}
; + } + + return ( + + {alertContent.children} + + ); + }, + ...components, }} > @@ -197,6 +229,149 @@ export const InlineMarkdown: FC = (props) => { export const MemoizedMarkdown = memo(Markdown, isEqual); export const MemoizedInlineMarkdown = memo(InlineMarkdown, isEqual); +const githubFlavoredMarkdownAlertTypes = [ + "tip", + "note", + "important", + "warning", + "caution", +]; + +type AlertContent = Readonly<{ + type: string; + children: readonly ReactNode[]; +}>; + +function parseChildrenAsAlertContent( + jsxChildren: ReactNode, +): AlertContent | null { + // Have no idea why the plugin parses the data by mixing node types + // like this. Have to do a good bit of nested filtering. + if (!Array.isArray(jsxChildren)) { + return null; + } + + const mainParentNode = jsxChildren.find((node): node is ReactElement => + isValidElement(node), + ); + let parentChildren = mainParentNode?.props.children; + if (typeof parentChildren === "string") { + // Children will only be an array if the parsed text contains other + // content that can be turned into HTML. If there aren't any, you + // just get one big string + parentChildren = parentChildren.split("\n"); + } + if (!Array.isArray(parentChildren)) { + return null; + } + + const outputContent = parentChildren + .filter((el) => { + if (isValidElement(el)) { + return true; + } + return typeof el === "string" && el !== "\n"; + }) + .map((el) => { + if (!isValidElement(el)) { + return el; + } + if (el.type !== "a") { + return el; + } + + const recastProps = el.props as Record & { + children?: ReactNode; + }; + if (recastProps.target === "_blank") { + return el; + } + + return { + ...el, + props: { + ...recastProps, + target: "_blank", + children: ( + <> + {recastProps.children} + (link opens in new tab) + + ), + }, + }; + }); + const [firstEl, ...remainingChildren] = outputContent; + if (typeof firstEl !== "string") { + return null; + } + + const alertType = firstEl + .trim() + .toLowerCase() + .replace("!", "") + .replace("[", "") + .replace("]", ""); + if (!githubFlavoredMarkdownAlertTypes.includes(alertType)) { + return null; + } + + const hasLeadingLinebreak = + isValidElement(remainingChildren[0]) && remainingChildren[0].type === "br"; + if (hasLeadingLinebreak) { + remainingChildren.shift(); + } + + return { + type: alertType, + children: remainingChildren, + }; +} + +type MarkdownGfmAlertProps = Readonly< + HTMLProps & { + alertType: string; + } +>; + +const MarkdownGfmAlert: FC = ({ + alertType, + children, + ...delegatedProps +}) => { + return ( +
+ +
+ ); +}; + const markdownStyles: Interpolation = (theme: Theme) => ({ fontSize: 16, lineHeight: "24px", diff --git a/site/src/index.css b/site/src/index.css index e2b71d7be6..f3bf0918dd 100644 --- a/site/src/index.css +++ b/site/src/index.css @@ -29,6 +29,7 @@ --surface-orange: 34 100% 92%; --surface-sky: 201 94% 86%; --surface-red: 0 93% 94%; + --surface-purple: 251 91% 95%; --border-default: 240 6% 90%; --border-success: 142 76% 36%; --border-warning: 30.66, 97.16%, 72.35%; @@ -41,6 +42,7 @@ --highlight-green: 143 64% 24%; --highlight-grey: 240 5% 65%; --highlight-sky: 201 90% 27%; + --highlight-red: 0 74% 42%; --border: 240 5.9% 90%; --input: 240 5.9% 90%; --ring: 240 10% 3.9%; @@ -69,6 +71,7 @@ --surface-orange: 13 81% 15%; --surface-sky: 204 80% 16%; --surface-red: 0 75% 15%; + --surface-purple: 261 73% 23%; --border-default: 240 4% 16%; --border-success: 142 76% 36%; --border-warning: 30.66, 97.16%, 72.35%; @@ -80,6 +83,7 @@ --highlight-green: 141 79% 85%; --highlight-grey: 240 4% 46%; --highlight-sky: 198 93% 60%; + --highlight-red: 0 91% 71%; --border: 240 3.7% 15.9%; --input: 240 3.7% 15.9%; --ring: 240 4.9% 83.9%; diff --git a/site/tailwind.config.js b/site/tailwind.config.js index d2935698e5..e4b40aa177 100644 --- a/site/tailwind.config.js +++ b/site/tailwind.config.js @@ -53,6 +53,7 @@ module.exports = { orange: "hsl(var(--surface-orange))", sky: "hsl(var(--surface-sky))", red: "hsl(var(--surface-red))", + purple: "hsl(var(--surface-purple))", }, border: { DEFAULT: "hsl(var(--border-default))", @@ -69,6 +70,7 @@ module.exports = { green: "hsl(var(--highlight-green))", grey: "hsl(var(--highlight-grey))", sky: "hsl(var(--highlight-sky))", + red: "hsl(var(--highlight-red))", }, }, keyframes: {