mirror of
https://github.com/coder/coder.git
synced 2025-07-13 21:36:50 +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 | `,
|
||||
},
|
||||
};
|
||||
|
||||
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,
|
||||
} 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<MarkdownProps> = (props) => {
|
||||
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,
|
||||
}}
|
||||
>
|
||||
@ -197,6 +229,149 @@ export const InlineMarkdown: FC<InlineMarkdownProps> = (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<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) => ({
|
||||
fontSize: 16,
|
||||
lineHeight: "24px",
|
||||
|
@ -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%;
|
||||
|
@ -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: {
|
||||
|
Reference in New Issue
Block a user