diff --git a/site/src/components/Latency/Latency.tsx b/site/src/components/Latency/Latency.tsx index 136d79bb5b8f0..95f69e1c2c0c6 100644 --- a/site/src/components/Latency/Latency.tsx +++ b/site/src/components/Latency/Latency.tsx @@ -34,11 +34,10 @@ export const Latency: FC = ({ const notAvailableText = "Latency not available"; return ( - <> +
{notAvailableText} - - +
); } diff --git a/site/src/components/Markdown/Markdown.stories.tsx b/site/src/components/Markdown/Markdown.stories.tsx index b2351c1d43153..15a529afd3002 100644 --- a/site/src/components/Markdown/Markdown.stories.tsx +++ b/site/src/components/Markdown/Markdown.stories.tsx @@ -95,3 +95,24 @@ export const GFMAlerts: Story = { `, }, }; + +export const GFMAlertsWithLinks: Story = { + args: { + children: ` +> [!NOTE] +> This template is centrally managed by CI/CD in the [coder/templates](https://github.com/coder/templates) repository. + +> [!TIP] +> Check out the [documentation](https://docs.coder.com) for more information. + +> [!IMPORTANT] +> Make sure to read the [security guidelines](https://coder.com/security) before proceeding. + +> [!WARNING] +> This action may affect your [workspace settings](https://coder.com/settings). + +> [!CAUTION] +> Deleting this will remove all data. See [backup guide](https://coder.com/backup) first. + `, + }, +}; diff --git a/site/src/components/Markdown/Markdown.test.tsx b/site/src/components/Markdown/Markdown.test.tsx new file mode 100644 index 0000000000000..7f9852b6fb454 --- /dev/null +++ b/site/src/components/Markdown/Markdown.test.tsx @@ -0,0 +1,104 @@ +import { AppProviders } from "App"; +import { createTestQueryClient } from "testHelpers/renderHelpers"; +import { render, screen } from "@testing-library/react"; +import { Markdown } from "./Markdown"; + +const renderWithProviders = (children: React.ReactNode) => { + return render( + + {children} + , + ); +}; + +describe("Markdown", () => { + it("renders GFM alerts without links correctly", () => { + const markdown = `> [!NOTE] +> Useful information that users should know, even when skimming content.`; + + renderWithProviders({markdown}); + + // Should render as an alert, not a regular blockquote + const alert = screen.getByRole("complementary"); + expect(alert).toBeInTheDocument(); + expect(alert).toHaveTextContent( + "Useful information that users should know, even when skimming content.", + ); + }); + + it("renders GFM alerts with links correctly", () => { + const markdown = `> [!NOTE] +> This template is centrally managed by CI/CD in the [coder/templates](https://github.com/coder/templates) repository.`; + + renderWithProviders({markdown}); + + // Should render as an alert, not a regular blockquote + const alert = screen.getByRole("complementary"); + expect(alert).toBeInTheDocument(); + // The alert should contain the content (the alert type might be included) + expect(alert).toHaveTextContent( + /This template is centrally managed by CI\/CD in the.*repository/, + ); + + // Should contain the link + const link = screen.getByRole("link", { name: /coder\/templates/ }); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute("href", "https://github.com/coder/templates"); + }); + + it("renders multiple GFM alerts with links correctly", () => { + const markdown = `> [!TIP] +> Check out the [documentation](https://docs.coder.com) for more information. + +> [!WARNING] +> This action may affect your [workspace settings](https://coder.com/settings).`; + + renderWithProviders({markdown}); + + // Should render both alerts + const alerts = screen.getAllByRole("complementary"); + expect(alerts).toHaveLength(2); + + // Check first alert (TIP) + expect(alerts[0]).toHaveTextContent( + /Check out the.*documentation.*for more information/, + ); + const docLink = screen.getByRole("link", { name: /documentation/ }); + expect(docLink).toHaveAttribute("href", "https://docs.coder.com"); + + // Check second alert (WARNING) + expect(alerts[1]).toHaveTextContent( + /This action may affect your.*workspace settings/, + ); + const settingsLink = screen.getByRole("link", { + name: /workspace settings/, + }); + expect(settingsLink).toHaveAttribute("href", "https://coder.com/settings"); + }); + + it("falls back to regular blockquote for invalid alert types", () => { + const markdown = `> [!INVALID] +> This should render as a regular blockquote.`; + + renderWithProviders({markdown}); + + // Should render as a regular blockquote, not an alert + // Use a more specific selector since blockquote doesn't have an accessible role + const blockquote = screen.getByText( + /\[!INVALID\].*This should render as a regular blockquote/, + ); + expect(blockquote).toBeInTheDocument(); + }); + + it("renders regular blockquotes without alert syntax", () => { + const markdown = "> This is a regular blockquote without alert syntax."; + + renderWithProviders({markdown}); + + // Should render as a regular blockquote + const blockquote = screen.getByText( + "This is a regular blockquote without alert syntax.", + ); + expect(blockquote).toBeInTheDocument(); + }); +}); diff --git a/site/src/components/Markdown/Markdown.tsx b/site/src/components/Markdown/Markdown.tsx index ba7bcbf29a903..0453d835a0750 100644 --- a/site/src/components/Markdown/Markdown.tsx +++ b/site/src/components/Markdown/Markdown.tsx @@ -301,21 +301,43 @@ function parseChildrenAsAlertContent( }, }; }); - const [firstEl, ...remainingChildren] = outputContent; - if (typeof firstEl !== "string") { - return null; + + // Find the alert type by looking for the first string that contains the alert pattern + let alertType: string | null = null; + + for (let i = 0; i < outputContent.length; i++) { + const el = outputContent[i]; + if (typeof el === "string") { + const trimmed = el.trim(); + // Check if this string contains an alert pattern like [!NOTE], [!TIP], etc. + const alertMatch = trimmed.match(/^\[!([A-Z]+)\]/); + if (alertMatch) { + alertType = alertMatch[1].toLowerCase(); + + // Remove the alert type from this string and keep the rest + const remainingText = trimmed.replace(/^\[!([A-Z]+)\]\s*/, "").trim(); + if (remainingText) { + // Replace the current element with the remaining text + outputContent[i] = remainingText; + } else { + // If nothing remains, mark for removal + outputContent[i] = null; + } + break; + } + } } - const alertType = firstEl - .trim() - .toLowerCase() - .replace("!", "") - .replace("[", "") - .replace("]", ""); - if (!githubFlavoredMarkdownAlertTypes.includes(alertType)) { + if (!alertType || !githubFlavoredMarkdownAlertTypes.includes(alertType)) { return null; } + // Remove null elements and get the remaining content + const remainingChildren = outputContent.filter((el) => { + // Keep all elements except null ones + return el !== null; + }); + const hasLeadingLinebreak = isValidElement(remainingChildren[0]) && remainingChildren[0].type === "br"; if (hasLeadingLinebreak) {