mirror of
https://seed.radicle.dev/rad:z4D5UCArafTzTQpDZNQRuqswh3ury.git
synced 2026-10-18 19:10:45 +08:00
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:
committed by
Rūdolfs Ošiņš
co-authored by
Rūdolfs Ošiņš
parent
1bc5ffa9fe
commit
70046d0c74
@@ -43,6 +43,8 @@
|
||||
--color-feedback-error-border: var(--color-semantic-red-600);
|
||||
--color-feedback-error-bg: var(--color-semantic-red-100);
|
||||
--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-deletion: var(--color-semantic-red-500);
|
||||
@@ -121,6 +123,8 @@
|
||||
--color-feedback-error-border: var(--color-semantic-red-700);
|
||||
--color-feedback-error-bg: var(--color-semantic-red-900);
|
||||
--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-deletion: var(--color-semantic-red-400);
|
||||
|
||||
@@ -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">
|
||||
import { mount, tick, unmount } from "svelte";
|
||||
|
||||
@@ -6,6 +16,7 @@
|
||||
import { parseFrontmatter } from "@app/lib/frontmatter";
|
||||
import { invoke } from "@app/lib/invoke";
|
||||
import {
|
||||
alertVariants,
|
||||
isTaskCheckbox,
|
||||
maximumReferences,
|
||||
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);
|
||||
|
||||
if (!scrolledToHash && window.location.hash) {
|
||||
@@ -657,6 +683,38 @@
|
||||
.markdown :global(dl dd) {
|
||||
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>
|
||||
|
||||
{#if frontMatter && frontMatter.length > 0}
|
||||
|
||||
+57
-1
@@ -98,6 +98,58 @@ const anchorMarkedExtension = {
|
||||
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
|
||||
// the number of chips `Markdown` mounts.
|
||||
export const maximumReferences = 200;
|
||||
@@ -180,7 +232,11 @@ export const markdownWithExtensions = new Marked(
|
||||
markedFootnote({ refMarkers: true }),
|
||||
markedEmoji({ emojis }),
|
||||
((): MarkedExtension => ({
|
||||
extensions: [anchorMarkedExtension, radicleReferenceMarkedExtension],
|
||||
extensions: [
|
||||
anchorMarkedExtension,
|
||||
alertMarkedExtension,
|
||||
radicleReferenceMarkedExtension,
|
||||
],
|
||||
}))(),
|
||||
);
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user