mirror of
https://github.com/coder/coder.git
synced 2025-07-21 01:28:49 +00:00
feat: support GFM alerts in markdown (#17662)
Closes https://github.com/coder/coder/issues/17660 Add support to [GFM Alerts](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts). <img width="635" alt="Screenshot 2025-05-02 at 14 26 36" src="https://github.com/user-attachments/assets/8b785e0f-87f4-4bbd-9107-67858ad5dece" /> PS: This was heavily copied from https://github.com/coder/coder-registry/blob/dev/cmd/main/site/src/components/MarkdownView/MarkdownView.tsx
This commit is contained in:
@ -74,3 +74,24 @@ export const WithTable: Story = {
|
|||||||
| cell 1 | cell 2 | 3 | 4 | `,
|
| 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.
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
@ -8,12 +8,20 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "components/Table/Table";
|
} from "components/Table/Table";
|
||||||
import isEqual from "lodash/isEqual";
|
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 ReactMarkdown, { type Options } from "react-markdown";
|
||||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||||
import { dracula } from "react-syntax-highlighter/dist/cjs/styles/prism";
|
import { dracula } from "react-syntax-highlighter/dist/cjs/styles/prism";
|
||||||
import gfm from "remark-gfm";
|
import gfm from "remark-gfm";
|
||||||
import colors from "theme/tailwindColors";
|
import colors from "theme/tailwindColors";
|
||||||
|
import { cn } from "utils/cn";
|
||||||
|
|
||||||
interface MarkdownProps {
|
interface MarkdownProps {
|
||||||
/**
|
/**
|
||||||
@ -114,6 +122,30 @@ export const Markdown: FC<MarkdownProps> = (props) => {
|
|||||||
return <TableCell>{children}</TableCell>;
|
return <TableCell>{children}</TableCell>;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 <blockquote {...renderProps}>{children}</blockquote>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MarkdownGfmAlert alertType={alertContent.type} {...renderProps}>
|
||||||
|
{alertContent.children}
|
||||||
|
</MarkdownGfmAlert>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
...components,
|
...components,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -197,6 +229,149 @@ export const InlineMarkdown: FC<InlineMarkdownProps> = (props) => {
|
|||||||
export const MemoizedMarkdown = memo(Markdown, isEqual);
|
export const MemoizedMarkdown = memo(Markdown, isEqual);
|
||||||
export const MemoizedInlineMarkdown = memo(InlineMarkdown, 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<string, unknown> & {
|
||||||
|
children?: ReactNode;
|
||||||
|
};
|
||||||
|
if (recastProps.target === "_blank") {
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...el,
|
||||||
|
props: {
|
||||||
|
...recastProps,
|
||||||
|
target: "_blank",
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
{recastProps.children}
|
||||||
|
<span className="sr-only"> (link opens in new tab)</span>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
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<HTMLElement> & {
|
||||||
|
alertType: string;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
const MarkdownGfmAlert: FC<MarkdownGfmAlertProps> = ({
|
||||||
|
alertType,
|
||||||
|
children,
|
||||||
|
...delegatedProps
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="pb-6">
|
||||||
|
<aside
|
||||||
|
{...delegatedProps}
|
||||||
|
className={cn(
|
||||||
|
"border-0 border-l-4 border-solid border-border p-4 text-white",
|
||||||
|
"[&_p]:m-0 [&_p]:mb-2",
|
||||||
|
|
||||||
|
alertType === "important" &&
|
||||||
|
"border-highlight-purple [&_p:first-child]:text-highlight-purple",
|
||||||
|
|
||||||
|
alertType === "warning" &&
|
||||||
|
"border-border-warning [&_p:first-child]:text-border-warning",
|
||||||
|
|
||||||
|
alertType === "note" &&
|
||||||
|
"border-highlight-sky [&_p:first-child]:text-highlight-sky",
|
||||||
|
|
||||||
|
alertType === "tip" &&
|
||||||
|
"border-highlight-green [&_p:first-child]:text-highlight-green",
|
||||||
|
|
||||||
|
alertType === "caution" &&
|
||||||
|
"border-highlight-red [&_p:first-child]:text-highlight-red",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className="font-bold">
|
||||||
|
{alertType[0]?.toUpperCase() + alertType.slice(1).toLowerCase()}
|
||||||
|
</p>
|
||||||
|
{children}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const markdownStyles: Interpolation<Theme> = (theme: Theme) => ({
|
const markdownStyles: Interpolation<Theme> = (theme: Theme) => ({
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
lineHeight: "24px",
|
lineHeight: "24px",
|
||||||
|
@ -29,6 +29,7 @@
|
|||||||
--surface-orange: 34 100% 92%;
|
--surface-orange: 34 100% 92%;
|
||||||
--surface-sky: 201 94% 86%;
|
--surface-sky: 201 94% 86%;
|
||||||
--surface-red: 0 93% 94%;
|
--surface-red: 0 93% 94%;
|
||||||
|
--surface-purple: 251 91% 95%;
|
||||||
--border-default: 240 6% 90%;
|
--border-default: 240 6% 90%;
|
||||||
--border-success: 142 76% 36%;
|
--border-success: 142 76% 36%;
|
||||||
--border-warning: 30.66, 97.16%, 72.35%;
|
--border-warning: 30.66, 97.16%, 72.35%;
|
||||||
@ -41,6 +42,7 @@
|
|||||||
--highlight-green: 143 64% 24%;
|
--highlight-green: 143 64% 24%;
|
||||||
--highlight-grey: 240 5% 65%;
|
--highlight-grey: 240 5% 65%;
|
||||||
--highlight-sky: 201 90% 27%;
|
--highlight-sky: 201 90% 27%;
|
||||||
|
--highlight-red: 0 74% 42%;
|
||||||
--border: 240 5.9% 90%;
|
--border: 240 5.9% 90%;
|
||||||
--input: 240 5.9% 90%;
|
--input: 240 5.9% 90%;
|
||||||
--ring: 240 10% 3.9%;
|
--ring: 240 10% 3.9%;
|
||||||
@ -69,6 +71,7 @@
|
|||||||
--surface-orange: 13 81% 15%;
|
--surface-orange: 13 81% 15%;
|
||||||
--surface-sky: 204 80% 16%;
|
--surface-sky: 204 80% 16%;
|
||||||
--surface-red: 0 75% 15%;
|
--surface-red: 0 75% 15%;
|
||||||
|
--surface-purple: 261 73% 23%;
|
||||||
--border-default: 240 4% 16%;
|
--border-default: 240 4% 16%;
|
||||||
--border-success: 142 76% 36%;
|
--border-success: 142 76% 36%;
|
||||||
--border-warning: 30.66, 97.16%, 72.35%;
|
--border-warning: 30.66, 97.16%, 72.35%;
|
||||||
@ -80,6 +83,7 @@
|
|||||||
--highlight-green: 141 79% 85%;
|
--highlight-green: 141 79% 85%;
|
||||||
--highlight-grey: 240 4% 46%;
|
--highlight-grey: 240 4% 46%;
|
||||||
--highlight-sky: 198 93% 60%;
|
--highlight-sky: 198 93% 60%;
|
||||||
|
--highlight-red: 0 91% 71%;
|
||||||
--border: 240 3.7% 15.9%;
|
--border: 240 3.7% 15.9%;
|
||||||
--input: 240 3.7% 15.9%;
|
--input: 240 3.7% 15.9%;
|
||||||
--ring: 240 4.9% 83.9%;
|
--ring: 240 4.9% 83.9%;
|
||||||
|
@ -53,6 +53,7 @@ module.exports = {
|
|||||||
orange: "hsl(var(--surface-orange))",
|
orange: "hsl(var(--surface-orange))",
|
||||||
sky: "hsl(var(--surface-sky))",
|
sky: "hsl(var(--surface-sky))",
|
||||||
red: "hsl(var(--surface-red))",
|
red: "hsl(var(--surface-red))",
|
||||||
|
purple: "hsl(var(--surface-purple))",
|
||||||
},
|
},
|
||||||
border: {
|
border: {
|
||||||
DEFAULT: "hsl(var(--border-default))",
|
DEFAULT: "hsl(var(--border-default))",
|
||||||
@ -69,6 +70,7 @@ module.exports = {
|
|||||||
green: "hsl(var(--highlight-green))",
|
green: "hsl(var(--highlight-green))",
|
||||||
grey: "hsl(var(--highlight-grey))",
|
grey: "hsl(var(--highlight-grey))",
|
||||||
sky: "hsl(var(--highlight-sky))",
|
sky: "hsl(var(--highlight-sky))",
|
||||||
|
red: "hsl(var(--highlight-red))",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
keyframes: {
|
keyframes: {
|
||||||
|
Reference in New Issue
Block a user