Skip to content

feat: support GFM alerts in markdown #17662

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
May 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions site/src/components/Markdown/Markdown.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
`,
},
};
177 changes: 176 additions & 1 deletion site/src/components/Markdown/Markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down Expand Up @@ -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,
}}
>
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions site/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
Expand All @@ -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%;
Expand Down Expand Up @@ -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%;
Expand All @@ -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%;
Expand Down
2 changes: 2 additions & 0 deletions site/tailwind.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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))",
Expand All @@ -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: {
Expand Down
Loading