From d5ccdea126e3249cd220a04795f11a75bca8a6d9 Mon Sep 17 00:00:00 2001 From: Thomas Miceli Date: Fri, 12 Jun 2026 00:04:39 +0700 Subject: [PATCH] GitHub alerts in Markdown Signed-off-by: Thomas Miceli --- internal/render/markdown.go | 1 + internal/render/markdown_alert.go | 167 ++++++++++++++++++++++++++++++ public/css/style.css | 4 + 3 files changed, 172 insertions(+) create mode 100644 internal/render/markdown_alert.go diff --git a/internal/render/markdown.go b/internal/render/markdown.go index 14b120c..a6cd73d 100644 --- a/internal/render/markdown.go +++ b/internal/render/markdown.go @@ -54,6 +54,7 @@ func newMarkdown(extraExtensions ...goldmark.Extender) goldmark.Markdown { ), emoji.Emoji, &mermaid.Extender{}, + &alertExtension{}, } extensions = append(extensions, extraExtensions...) diff --git a/internal/render/markdown_alert.go b/internal/render/markdown_alert.go new file mode 100644 index 0000000..acdadab --- /dev/null +++ b/internal/render/markdown_alert.go @@ -0,0 +1,167 @@ +package render + +import ( + "bytes" + "regexp" + "strings" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +// GitHub-style markdown alerts (admonitions), e.g. +// +// > [!NOTE] +// > Useful information that users should know. +// +// Supported types: note, tip, important, warning, caution. +// See https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts + +var alertRegex = regexp.MustCompile(`(?i)^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]$`) + +// titles displayed for each alert type +var alertTitles = map[string]string{ + "note": "Note", + "tip": "Tip", + "important": "Important", + "warning": "Warning", + "caution": "Caution", +} + +// octicon SVGs matching GitHub's alerts, inheriting the title color via `fill: currentColor` +var alertIcons = map[string]string{ + "note": ``, + "tip": ``, + "important": ``, + "warning": ``, + "caution": ``, +} + +// -- Alert extension -- // + +type alertExtension struct{} + +func (e *alertExtension) Extend(m goldmark.Markdown) { + m.Parser().AddOptions(parser.WithASTTransformers( + util.Prioritized(&alertTransformer{}, 100), + )) + m.Renderer().AddOptions(renderer.WithNodeRenderers( + util.Prioritized(newAlertRenderer(), 100), + )) +} + +// -- Alert node -- // + +type alertNode struct { + ast.BaseBlock + kind string +} + +var alertNodeKind = ast.NewNodeKind("Alert") + +func (n *alertNode) Kind() ast.NodeKind { return alertNodeKind } + +func (n *alertNode) Dump(source []byte, level int) { + ast.DumpHelper(n, source, level, map[string]string{"kind": n.kind}, nil) +} + +// -- Alert transformer -- // + +// alertTransformer turns blockquotes that start with an alert marker +// (e.g. `[!NOTE]`) into alert nodes. +type alertTransformer struct{} + +func (t *alertTransformer) Transform(node *ast.Document, reader text.Reader, _ parser.Context) { + source := reader.Source() + + // collect blockquotes first, since we mutate the tree while iterating + var blockquotes []*ast.Blockquote + _ = ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + if bq, ok := n.(*ast.Blockquote); ok { + blockquotes = append(blockquotes, bq) + } + } + return ast.WalkContinue, nil + }) + + for _, bq := range blockquotes { + kind := detectAndStripAlert(bq, source) + if kind == "" { + continue + } + + alert := &alertNode{kind: kind} + for child := bq.FirstChild(); child != nil; { + next := child.NextSibling() + bq.RemoveChild(bq, child) + alert.AppendChild(alert, child) + child = next + } + + if parent := bq.Parent(); parent != nil { + parent.ReplaceChild(parent, bq, alert) + } + } +} + +// detectAndStripAlert returns the alert kind if the blockquote's first line is +// an alert marker, removing that marker from the tree. It returns "" otherwise. +func detectAndStripAlert(bq *ast.Blockquote, source []byte) string { + para, ok := bq.FirstChild().(*ast.Paragraph) + if !ok || para.Lines().Len() == 0 { + return "" + } + + firstLine := para.Lines().At(0) + match := alertRegex.FindSubmatch(bytes.TrimSpace(firstLine.Value(source))) + if match == nil { + return "" + } + + // drop the inline nodes belonging to the marker line + for child := para.FirstChild(); child != nil; { + next := child.NextSibling() + txt, ok := child.(*ast.Text) + if !ok || txt.Segment.Start >= firstLine.Stop { + break + } + para.RemoveChild(para, child) + child = next + } + + // if the marker was the only content of its paragraph, drop the paragraph + if para.FirstChild() == nil { + bq.RemoveChild(bq, para) + } + + return strings.ToLower(string(match[1])) +} + +// -- Alert renderer -- // + +type alertRenderer struct{} + +func newAlertRenderer() renderer.NodeRenderer { return &alertRenderer{} } + +func (r *alertRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { + reg.Register(alertNodeKind, r.render) +} + +func (r *alertRenderer) render(w util.BufWriter, _ []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + n := node.(*alertNode) + if entering { + _, _ = w.WriteString(`
` + "\n") + _, _ = w.WriteString(`

`) + _, _ = w.WriteString(alertIcons[n.kind]) + _, _ = w.WriteString(alertTitles[n.kind]) + _, _ = w.WriteString("

\n") + } else { + _, _ = w.WriteString("
\n") + } + return ast.WalkContinue, nil +} diff --git a/public/css/style.css b/public/css/style.css index 2b33658..a00b89a 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -171,6 +171,10 @@ dl.dl-config dd { @apply bg-transparent dark:bg-transparent; } +.markdown-body .markdown-alert-title svg.octicon { + margin-right: 0.5rem; +} + .chroma.preview.markdown pre code { @apply p-4; }