Render GitHub style alerts

A blockquote starting with `[!NOTE]` or one of its siblings showed up
as a plain quote, while GitHub and the web explorer render it as a
titled callout.

Co-authored-by: Rūdolfs Ošiņš <rudolfs@osins.org>
This commit is contained in:
Brandon Oxendine
2026-10-07 16:54:00 +02:00
committed by Rūdolfs Ošiņš
co-authored by Rūdolfs Ošiņš
parent 1bc5ffa9fe
commit 70046d0c74
4 changed files with 148 additions and 1 deletions
+4
View File
@@ -43,6 +43,8 @@
--color-feedback-error-border: var(--color-semantic-red-600); --color-feedback-error-border: var(--color-semantic-red-600);
--color-feedback-error-bg: var(--color-semantic-red-100); --color-feedback-error-bg: var(--color-semantic-red-100);
--color-feedback-error-bg-selected: var(--color-semantic-red-200); --color-feedback-error-bg-selected: var(--color-semantic-red-200);
--color-feedback-info-text: var(--color-semantic-blue-800);
--color-feedback-important-text: var(--color-accent-purple-800);
--color-diff-addition: var(--color-semantic-green-500); --color-diff-addition: var(--color-semantic-green-500);
--color-diff-deletion: var(--color-semantic-red-500); --color-diff-deletion: var(--color-semantic-red-500);
@@ -121,6 +123,8 @@
--color-feedback-error-border: var(--color-semantic-red-700); --color-feedback-error-border: var(--color-semantic-red-700);
--color-feedback-error-bg: var(--color-semantic-red-900); --color-feedback-error-bg: var(--color-semantic-red-900);
--color-feedback-error-bg-selected: var(--color-semantic-red-800); --color-feedback-error-bg-selected: var(--color-semantic-red-800);
--color-feedback-info-text: var(--color-semantic-blue-400);
--color-feedback-important-text: var(--color-accent-purple-400);
--color-diff-addition: var(--color-semantic-green-400); --color-diff-addition: var(--color-semantic-green-400);
--color-diff-deletion: var(--color-semantic-red-400); --color-diff-deletion: var(--color-semantic-red-400);
+58
View File
@@ -1,3 +1,13 @@
<script lang="ts" module>
const alertIcons = {
note: "guide",
tip: "lightbulb",
important: "comment",
warning: "warning",
caution: "stop",
} as const;
</script>
<script lang="ts"> <script lang="ts">
import { mount, tick, unmount } from "svelte"; import { mount, tick, unmount } from "svelte";
@@ -6,6 +16,7 @@
import { parseFrontmatter } from "@app/lib/frontmatter"; import { parseFrontmatter } from "@app/lib/frontmatter";
import { invoke } from "@app/lib/invoke"; import { invoke } from "@app/lib/invoke";
import { import {
alertVariants,
isTaskCheckbox, isTaskCheckbox,
maximumReferences, maximumReferences,
renderMarkdown, renderMarkdown,
@@ -332,6 +343,21 @@
} }
} }
for (const title of container.querySelectorAll("p.alert-title")) {
const variant = alertVariants.find(name =>
title.parentElement?.classList.contains(`alert-${name}`),
);
if (!variant) continue;
icons.push(
mount(Icon, {
target: title,
anchor: title.firstChild ?? undefined,
props: { name: alertIcons[variant] },
}),
);
}
enhanceCodeBlocks(container); enhanceCodeBlocks(container);
if (!scrolledToHash && window.location.hash) { if (!scrolledToHash && window.location.hash) {
@@ -657,6 +683,38 @@
.markdown :global(dl dd) { .markdown :global(dl dd) {
margin: 0 0 0 2rem; margin: 0 0 0 2rem;
} }
.markdown :global(.alert) {
border-left: 0.3rem solid var(--alert-color);
padding: 0 0 0 1rem;
margin: 1rem 0;
}
.markdown :global(.alert > .alert-title) {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--alert-color);
font: var(--txt-body-m-semibold);
margin-bottom: 0.375rem;
}
.markdown :global(.alert > :last-child) {
margin-bottom: 0;
}
.markdown :global(.alert-note) {
--alert-color: var(--color-feedback-info-text);
}
.markdown :global(.alert-tip) {
--alert-color: var(--color-feedback-success-text);
}
.markdown :global(.alert-important) {
--alert-color: var(--color-feedback-important-text);
}
.markdown :global(.alert-warning) {
--alert-color: var(--color-feedback-warning-text);
}
.markdown :global(.alert-caution) {
--alert-color: var(--color-feedback-error-text);
}
</style> </style>
{#if frontMatter && frontMatter.length > 0} {#if frontMatter && frontMatter.length > 0}
+57 -1
View File
@@ -98,6 +98,58 @@ const anchorMarkedExtension = {
renderer: (token: Tokens.Generic): string => `<a name="${token.text}"></a>`, renderer: (token: Tokens.Generic): string => `<a name="${token.text}"></a>`,
}; };
// GitHub flavoured alerts: a blockquote whose first line is a `[!NOTE]` marker
// renders as a titled callout rather than a quote.
export const alertVariants = [
"note",
"tip",
"important",
"warning",
"caution",
] as const;
export type AlertVariant = (typeof alertVariants)[number];
function isAlertVariant(name: string): name is AlertVariant {
return (alertVariants as readonly string[]).includes(name);
}
const alertMarkedExtension: TokenizerExtension & RendererExtension = {
name: "alert",
level: "block",
start: (src: string) => src.match(/^ {0,3}> *\[!/m)?.index,
tokenizer(src: string) {
const quote = /^(?: {0,3}>[^\n]*(?:\n|$))+/.exec(src);
if (!quote) {
return;
}
const body = quote[0].replace(/^ {0,3}> ?/gm, "");
const marker = /^\[!([a-zA-Z]+)\][^\S\n]*(?:\n|$)/.exec(body);
if (!marker) {
return;
}
const variant = marker[1].toLowerCase();
if (!isAlertVariant(variant)) {
return;
}
return {
type: "alert",
raw: quote[0],
variant,
tokens: this.lexer.blockTokens(body.slice(marker[0].length)),
};
},
renderer(token: Tokens.Generic): string {
const variant = token.variant as AlertVariant;
const title = variant.charAt(0).toUpperCase() + variant.slice(1);
return `<div class="alert alert-${variant}"><p class="alert-title">${title}</p>${this.parser.parse(token.tokens ?? [])}</div>`;
},
};
// Past this many references in one document the rest stay text, matching // Past this many references in one document the rest stay text, matching
// the number of chips `Markdown` mounts. // the number of chips `Markdown` mounts.
export const maximumReferences = 200; export const maximumReferences = 200;
@@ -180,7 +232,11 @@ export const markdownWithExtensions = new Marked(
markedFootnote({ refMarkers: true }), markedFootnote({ refMarkers: true }),
markedEmoji({ emojis }), markedEmoji({ emojis }),
((): MarkedExtension => ({ ((): MarkedExtension => ({
extensions: [anchorMarkedExtension, radicleReferenceMarkedExtension], extensions: [
anchorMarkedExtension,
alertMarkedExtension,
radicleReferenceMarkedExtension,
],
}))(), }))(),
); );
+29
View File
@@ -0,0 +1,29 @@
import type { RadiclePeer } from "@tests/support/peerManager.js";
import * as Fs from "node:fs/promises";
import * as Path from "node:path";
import { expect, test } from "@tests/support/fixtures.js";
import { createRepo } from "@tests/support/repo.js";
async function createRichRepo(peer: RadiclePeer) {
const { rid, repoFolder } = await createRepo(peer, { name: "rich" });
await Fs.writeFile(
Path.join(repoFolder, "README.md"),
["# Rich", "", "> [!NOTE]", "> Read the guide first.", ""].join("\n"),
);
await peer.git(["add", "."], { cwd: repoFolder });
await peer.git(["commit", "-m", "Write a rich readme"], { cwd: repoFolder });
await peer.git(["push", "rad", "main"], { cwd: repoFolder });
return { rid };
}
test("render alerts in a readme", async ({ page, peer }) => {
const { rid } = await createRichRepo(peer);
await page.goto(`/repos/${rid}/home`);
const readme = page.locator(".markdown");
await expect(
readme.locator(".alert-note").getByText("Read the guide first."),
).toBeVisible();
});